Skip to content

Commit a003d43

Browse files
authored
feat(chat): render selected skills as user message badges (#290)
1 parent fb8bb6c commit a003d43

6 files changed

Lines changed: 72 additions & 11 deletions

File tree

src/renderer/components/thread/ChatPane/ChatPane.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,21 @@ describe("ChatPane", () => {
985985
).not.toBeInTheDocument();
986986
});
987987

988+
it("renders selected skills in user messages as skill badges", async () => {
989+
const thread = makeThread();
990+
seedUserMessageContent(thread.id, [
991+
{ kind: "skill", name: "simplify", invocation: "$simplify" },
992+
]);
993+
994+
const { container } = renderChatPane(thread);
995+
await waitFor(() => expect(hydrateThreadRuntimeItems).toHaveBeenCalledWith(thread.id));
996+
997+
const badge = container.querySelector('[data-skill-name="simplify"]');
998+
expect(badge).toHaveTextContent("simplify");
999+
expect(badge?.querySelector("svg")).toBeInTheDocument();
1000+
expect(screen.queryByText("$simplify")).not.toBeInTheDocument();
1001+
});
1002+
9881003
it("copies user message text from the inline action", async () => {
9891004
const thread = makeThread();
9901005
seedUserMessage(thread.id, "Copy this prompt");

src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { memo, type ReactNode, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
22
import { Link, Surface, Tooltip, toast } from "@heroui/react";
33
import { useLingui } from "@lingui/react/macro";
4-
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
4+
import { ChevronDown, ChevronUp, Copy, Sparkles } from "lucide-react";
55
import type { CanonicalContentBlock, MessageItemPayload } from "@/shared/contracts";
66
import { friendlyError } from "@/shared/messages";
77
import { AttachmentBar } from "@/renderer/components/composer/AttachmentBar";
@@ -66,8 +66,8 @@ export const UserMessage = memo(function UserMessage({ item, checkpointRevert }:
6666
const { slashCommand, body } = extractLeadingSlashCommand(rawText);
6767
const text = body;
6868
const commandPrefixLength = slashCommand ? rawText.length - body.length : 0;
69-
const hasInlineFileMentions = content.some(
70-
(block) => block.kind === "file" && block.source !== "attachment",
69+
const hasInlineContent = content.some(
70+
(block) => block.kind === "skill" || (block.kind === "file" && block.source !== "attachment"),
7171
);
7272
const attachments = enrichWithSelectorPayloads(
7373
buildUserPromptAttachments(content),
@@ -205,14 +205,11 @@ export const UserMessage = memo(function UserMessage({ item, checkpointRevert }:
205205
bodyClass = inlineBodyClass;
206206
bodyContent = (
207207
<>
208-
<span className="poracode-slash-chip poracode-slash-chip--user-message mr-1.5">
209-
<span className="poracode-slash-chip__slash">/</span>
210-
<span className="poracode-slash-chip__name">{slashCommand}</span>
211-
</span>
208+
<UserMessageSlashChip icon="/" label={slashCommand} />
212209
{renderUserMessageInlineContent(content, commandPrefixLength, actions)}
213210
</>
214211
);
215-
} else if (hasInlineFileMentions) {
212+
} else if (hasInlineContent) {
216213
bodyClass = inlineBodyClass;
217214
bodyContent = renderUserMessageInlineContent(content, 0, actions);
218215
} else if (text.length > 0) {
@@ -351,6 +348,7 @@ function buildUserPromptText(content: CanonicalContentBlock[]): string {
351348
return content
352349
.map((block) => {
353350
if (block.kind === "text") return block.text;
351+
if (block.kind === "skill") return block.invocation;
354352
if (block.kind === "file" && block.source !== "attachment") return block.path;
355353
return "";
356354
})
@@ -377,6 +375,23 @@ function renderUserMessageInlineContent(
377375
return;
378376
}
379377

378+
if (block.kind === "skill") {
379+
if (remainingSkip >= block.invocation.length) {
380+
remainingSkip -= block.invocation.length;
381+
return;
382+
}
383+
remainingSkip = 0;
384+
nodes.push(
385+
<UserMessageSlashChip
386+
key={`skill-${index}-${block.name}`}
387+
icon={<Sparkles aria-hidden="true" />}
388+
label={block.name}
389+
skillName={block.name}
390+
/>,
391+
);
392+
return;
393+
}
394+
380395
if (block.kind === "file") {
381396
if (block.source === "attachment") return;
382397
if (remainingSkip >= block.path.length) {
@@ -400,6 +415,26 @@ function renderUserMessageInlineContent(
400415
return nodes;
401416
}
402417

418+
function UserMessageSlashChip({
419+
icon,
420+
label,
421+
skillName,
422+
}: {
423+
icon: ReactNode;
424+
label: string;
425+
skillName?: string;
426+
}) {
427+
return (
428+
<span
429+
className="poracode-slash-chip poracode-slash-chip--user-message mr-1.5"
430+
{...(skillName ? { "data-skill-name": skillName } : {})}
431+
>
432+
<span className="poracode-slash-chip__slash">{icon}</span>
433+
<span className="poracode-slash-chip__name">{label}</span>
434+
</span>
435+
);
436+
}
437+
403438
const USER_MESSAGE_URL_RE = /https?:\/\/[^\s<>"']+/g;
404439

405440
function renderUserMessageText(text: string, keyPrefix: string): ReactNode[] {

src/renderer/styles.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2492,6 +2492,10 @@ html[data-app-unfocused] .poracode-provider-icon--finished {
24922492
cursor: default;
24932493
}
24942494

2495+
.poracode-slash-chip[data-skill-name] {
2496+
gap: 0.25em;
2497+
}
2498+
24952499
.poracode-slash-chip__slash {
24962500
display: inline-flex;
24972501
align-items: center;

src/shared/contracts/runtimeEvent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export type RuntimeContentStreamKind = z.infer<typeof runtimeContentStreamKindSc
5151

5252
export const canonicalContentBlockSchema = z.discriminatedUnion("kind", [
5353
z.object({ kind: z.literal("text"), text: z.string() }),
54+
z.object({ kind: z.literal("skill"), name: z.string(), invocation: z.string() }),
5455
z.object({
5556
kind: z.literal("image"),
5657
mimeType: z.string(),

src/shared/promptContent.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ describe("buildPromptContentBlocks", () => {
4242
]);
4343
});
4444

45-
it("formats skill segments with the provider invocation", () => {
45+
it("preserves skill segments for user-message rendering", () => {
4646
expect(
4747
buildPromptContentBlocks("Use the review-code skill.", [
4848
{
@@ -54,7 +54,13 @@ describe("buildPromptContentBlocks", () => {
5454
scope: "global",
5555
},
5656
]),
57-
).toEqual([{ kind: "text", text: "Use the review-code skill." }]);
57+
).toEqual([
58+
{
59+
kind: "skill",
60+
name: "review-code",
61+
invocation: "Use the review-code skill.",
62+
},
63+
]);
5864
});
5965
});
6066

src/shared/promptContent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function buildPromptContentBlocks(
114114
}
115115

116116
if (segment.kind === "skill") {
117-
content.push({ kind: "text", text: segment.invocation });
117+
content.push({ kind: "skill", name: segment.name, invocation: segment.invocation });
118118
continue;
119119
}
120120

0 commit comments

Comments
 (0)