fix(site/src/pages/AgentsPage/components): show skill chips in queued messages

The queued messages list rendered invoked skills as plain alias text.
Render them as skill chips in document order instead.
This commit is contained in:
Jaayden Halko
2026-10-15 19:08:51 +00:00
parent ac5964ebc1
commit 0a26200d12
3 changed files with 115 additions and 39 deletions
@@ -116,6 +116,44 @@ export const MultiLineTextTruncation: Story = {
},
};
// Invoked skills render as chips in document order.
export const WithSkillChips: Story = {
args: {
messages: [
buildMessage(1, [
{ type: "text", text: "Draft start" },
{
type: "skill-invocation",
skill_source: "personal",
skill_name: "zebra-notes",
},
{ type: "text", text: "draft end" },
]),
buildMessage(2, [
{
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
},
]),
// Only the first line shows, so the chip after the break is hidden.
buildMessage(3, [
{
type: "skill-invocation",
skill_source: "org",
skill_name: "release-notes",
},
{ type: "text", text: "First line\nSecond line" },
{
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
},
]),
],
},
};
// A message with both text and a file attachment shows the ImageIcon badge.
export const WithAttachments: Story = {
args: {
@@ -13,13 +13,13 @@ describe("getQueuedMessageInfo", () => {
buildMessage([{ type: "text", text: "hello" }]),
);
expect(result).toEqual({
displayText: "hello",
previewParts: [{ type: "text", text: "hello" }],
attachmentCount: 0,
hookNotices: [],
});
});
it("shows invoked skills by alias in document order", () => {
it("keeps invoked skills in document order", () => {
const result = getQueuedMessageInfo(
buildMessage([
{
@@ -30,7 +30,14 @@ describe("getQueuedMessageInfo", () => {
{ type: "text", text: "the flaky test" },
]),
);
expect(result.displayText).toBe("/project/triage the flaky test");
expect(result.previewParts).toEqual([
{
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
},
{ type: "text", text: "the flaky test" },
]);
});
it("collects hook notices without polluting the preview text", () => {
@@ -41,7 +48,7 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "hello",
previewParts: [{ type: "text", text: "hello" }],
attachmentCount: 0,
hookNotices: ["policy notice"],
});
@@ -52,24 +59,24 @@ describe("getQueuedMessageInfo", () => {
buildMessage([{ type: "text", text: "line1\nline2" }]),
);
expect(result).toEqual({
displayText: "line1\nline2",
previewParts: [{ type: "text", text: "line1\nline2" }],
attachmentCount: 0,
hookNotices: [],
});
});
it("returns attachment label for a single file", () => {
it("returns no preview for a single file", () => {
const result = getQueuedMessageInfo(
buildMessage([{ type: "file", file_id: "a", media_type: "image/png" }]),
);
expect(result).toEqual({
displayText: "[Queued message]",
previewParts: [],
attachmentCount: 1,
hookNotices: [],
});
});
it("returns attachment label for multiple files", () => {
it("returns no preview for multiple files", () => {
const result = getQueuedMessageInfo(
buildMessage([
{ type: "file", file_id: "a", media_type: "image/png" },
@@ -77,7 +84,7 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "[Queued message]",
previewParts: [],
attachmentCount: 2,
hookNotices: [],
});
@@ -91,33 +98,33 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "look",
previewParts: [{ type: "text", text: "look" }],
attachmentCount: 1,
hookNotices: [],
});
});
it("returns fallback for empty content", () => {
it("returns no preview for empty content", () => {
const result = getQueuedMessageInfo(buildMessage([]));
expect(result).toEqual({
displayText: "[Queued message]",
previewParts: [],
attachmentCount: 0,
hookNotices: [],
});
});
it("returns fallback for whitespace-only text", () => {
it("returns no preview for whitespace-only text", () => {
const result = getQueuedMessageInfo(
buildMessage([{ type: "text", text: " " }]),
);
expect(result).toEqual({
displayText: "[Queued message]",
previewParts: [],
attachmentCount: 0,
hookNotices: [],
});
});
it("returns attachment label for whitespace text + file", () => {
it("returns no preview for whitespace text + file", () => {
const result = getQueuedMessageInfo(
buildMessage([
{ type: "text", text: " " },
@@ -125,13 +132,13 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "[Queued message]",
previewParts: [],
attachmentCount: 1,
hookNotices: [],
});
});
it("joins multiple text parts with a space", () => {
it("keeps each text part", () => {
const result = getQueuedMessageInfo(
buildMessage([
{ type: "text", text: "a" },
@@ -139,7 +146,10 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "a b",
previewParts: [
{ type: "text", text: "a" },
{ type: "text", text: "b" },
],
attachmentCount: 0,
hookNotices: [],
});
@@ -154,7 +164,7 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "check this",
previewParts: [{ type: "text", text: "check this" }],
attachmentCount: 2,
hookNotices: [],
});
@@ -6,8 +6,12 @@ import {
PaperclipIcon,
Trash2Icon,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { ChatQueuedMessage } from "#/api/typesGenerated";
import { Fragment, useEffect, useState } from "react";
import type {
ChatQueuedMessage,
ChatSkillInvocationPart,
ChatTextPart,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import {
@@ -15,7 +19,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { getSkillAlias } from "../utils/skillAliases";
import { SkillChip } from "./ChatMessageInput/SkillChip";
type QueuedMessagesListProps = {
messages: readonly ChatQueuedMessage[];
@@ -24,8 +28,10 @@ type QueuedMessagesListProps = {
className?: string;
};
type QueuedMessagePreviewPart = ChatTextPart | ChatSkillInvocationPart;
type QueuedMessageInfo = {
displayText: string;
previewParts: QueuedMessagePreviewPart[];
attachmentCount: number;
hookNotices: string[];
};
@@ -34,26 +40,50 @@ export const getQueuedMessageInfo = (
message: ChatQueuedMessage,
): QueuedMessageInfo => {
let attachmentCount = 0;
const textParts: string[] = [];
const previewParts: QueuedMessagePreviewPart[] = [];
const hookNotices: string[] = [];
for (const part of message.content) {
if (part.type === "file" || part.type === "workspace-file-reference") {
attachmentCount++;
} else if (part.type === "text" && part.text?.trim()) {
textParts.push(part.text);
previewParts.push({ type: "text", text: part.text.trim() });
} else if (part.type === "skill-invocation") {
textParts.push(getSkillAlias(part.skill_source, part.skill_name));
previewParts.push(part);
} else if (part.type === "hook-notice" && part.text?.trim()) {
hookNotices.push(part.text);
}
}
const rawText = textParts.join(" ").trim();
return {
displayText: rawText || "[Queued message]",
attachmentCount,
hookNotices,
};
return { previewParts, attachmentCount, hookNotices };
};
// Shows the message's first line, with skills as chips in document order.
const QueuedMessagePreview: React.FC<{
parts: readonly QueuedMessagePreviewPart[];
}> = ({ parts }) => {
if (parts.length === 0) {
return "[Queued message]";
}
const lineBreakIndex = parts.findIndex(
(part) => part.type === "text" && part.text.includes("\n"),
);
const firstLineParts =
lineBreakIndex === -1 ? parts : parts.slice(0, lineBreakIndex + 1);
return (
<>
{firstLineParts.map((part, index) => (
<Fragment key={index}>
{index > 0 && " "}
{part.type === "skill-invocation" ? (
<SkillChip source={part.skill_source} name={part.skill_name} />
) : (
part.text.split("\n")[0]
)}
</Fragment>
))}
{lineBreakIndex !== -1 && "…"}
</>
);
};
export const QueuedMessagesList: React.FC<QueuedMessagesListProps> = ({
@@ -62,11 +92,10 @@ export const QueuedMessagesList: React.FC<QueuedMessagesListProps> = ({
onPromote,
className,
}) => {
const items = messages.map((message) => {
const { displayText, attachmentCount, hookNotices } =
getQueuedMessageInfo(message);
return { id: message.id, displayText, attachmentCount, hookNotices };
});
const items = messages.map((message) => ({
id: message.id,
...getQueuedMessageInfo(message),
}));
const [hoveredID, setHoveredID] = useState<number | null>(null);
// Tracks which item has an async action in flight and what kind.
@@ -177,8 +206,7 @@ export const QueuedMessagesList: React.FC<QueuedMessagesListProps> = ({
>
<div className="flex items-center gap-2 rounded-lg border border-solid border-border bg-surface-secondary px-3 py-2 font-sans text-sm leading-relaxed text-content-primary shadow-xs">
<span className="min-w-0 flex-1 truncate">
{item.displayText.split("\n")[0]}
{item.displayText.includes("\n") ? "…" : ""}
<QueuedMessagePreview parts={item.previewParts} />
</span>
{item.attachmentCount > 0 && (
<span