diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx index 9635cade0b..2b6f34f501 100644 --- a/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx +++ b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx @@ -127,6 +127,60 @@ describe("TaskChatSystemNotice (PAP-443)", () => { ).not.toBeNull(); }); + it("shows workspace-ready comments as a compact row with expandable workspace metadata", () => { + renderNotice({ + text: [ + "## Workspace Ready", + "", + "- Strategy: `git_worktree`", + "- Branch: `fix/workspace-ready-notice`", + "- CWD: `/worktrees/workspace-ready-notice`", + ].join("\n"), + presentation: { + kind: "system_notice", + tone: "info", + title: "Workspace ready · fix/workspace-ready-notice", + detailsDefaultOpen: false, + density: "compact", + }, + metadata: { + version: 1, + sections: [ + { + title: "Workspace", + rows: [ + { type: "key_value", label: "Strategy", value: "git_worktree" }, + { + type: "key_value", + label: "Branch", + value: "fix/workspace-ready-notice", + }, + { type: "key_value", label: "CWD", value: "/worktrees/workspace-ready-notice" }, + ], + }, + ], + }, + }); + + expect(toggleButton().getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector('[data-testid="task-chat-system-notice"]')?.className).toContain( + "items-start", + ); + expect(toggleButton().textContent).toContain( + "Workspace ready · fix/workspace-ready-notice", + ); + expect(container.textContent).not.toContain("git_worktree"); + + flushSync(() => toggleButton().click()); + + const details = container.querySelector('[data-testid="task-chat-system-notice-details"]'); + expect(details?.textContent).toContain("Workspace"); + expect(details?.textContent?.match(/git_worktree/g)).toHaveLength(1); + expect(details?.textContent?.match(/fix\/workspace-ready-notice/g)).toHaveLength(1); + expect(details?.textContent).toContain("/worktrees/workspace-ready-notice"); + expect(details?.querySelector(".paperclip-markdown")).toBeNull(); + }); + it("shows Try again while folded and invokes it without expanding the notice", async () => { const onTryAgain = vi.fn(); renderNotice( @@ -154,7 +208,7 @@ describe("TaskChatSystemNotice (PAP-443)", () => { expect( container .querySelector('[data-testid="task-chat-system-notice"]') - ?.classList.contains("items-center"), + ?.classList.contains("items-start"), ).toBe(true); }); diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.tsx index 26a5e06e2b..566c9b0507 100644 --- a/ui/src/components/task-chat/TaskChatSystemNotice.tsx +++ b/ui/src/components/task-chat/TaskChatSystemNotice.tsx @@ -41,8 +41,9 @@ const TONE_ICON_CLASS: Record = { * it reads as a quiet left-aligned one-liner in TaskChatMarker's register — tone * icon + humanized plain-English title + relative time + chevron — instead of * a large gray paragraph of raw text. Expanding reveals the full - * markdown-rendered body plus any structured metadata sections; nothing is - * suppressed, only folded. + * markdown-rendered body plus any structured metadata sections. Workspace-ready + * notices omit their Markdown fallback when the equivalent structured workspace + * metadata is present, so each value appears once in the expanded panel. */ export function TaskChatSystemNotice({ item, @@ -63,6 +64,11 @@ export function TaskChatSystemNotice({ const sections = mapCommentMetadataToSystemNoticeSections(item.metadata, { runAgentId: item.runAgentId, }); + const isStructuredWorkspaceReadyNotice = + item.presentation?.kind === "system_notice" && + item.presentation.title?.trim().toLowerCase().startsWith("workspace ready") === true && + sections.some((section) => section.title?.trim().toLowerCase() === "workspace"); + const showBody = !isStructuredWorkspaceReadyNotice; const ToneIcon = TONE_ICON[tone]; const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined; const showTryAgain = @@ -78,7 +84,7 @@ export function TaskChatSystemNotice({ return (
-
- - {item.text} - -
+ {showBody ? ( +
+ + {item.text} + +
+ ) : null} {sections.length > 0 ? ( -
+
) : null} diff --git a/ui/src/components/task-chat/task-chat-adapter.test.ts b/ui/src/components/task-chat/task-chat-adapter.test.ts index cc85cfc630..f31ef7e958 100644 --- a/ui/src/components/task-chat/task-chat-adapter.test.ts +++ b/ui/src/components/task-chat/task-chat-adapter.test.ts @@ -196,4 +196,75 @@ describe("commentsToTaskChatItems", () => { timestamp: formatTaskChatTimestamp(createdAt), }); }); + + it("routes an agent-authored workspace-ready notice through the system renderer", () => { + const presentation = { + kind: "system_notice", + tone: "info", + title: "Workspace ready · fix/workspace-ready-notice", + detailsDefaultOpen: false, + density: "compact", + } as const; + const metadata = { + version: 1, + sections: [ + { + title: "Workspace", + rows: [ + { type: "key_value", label: "Strategy", value: "git_worktree" }, + { + type: "key_value", + label: "Branch", + value: "fix/workspace-ready-notice", + }, + { type: "key_value", label: "CWD", value: "/worktrees/workspace-ready-notice" }, + ], + }, + ], + } as const; + const items = commentsToTaskChatItems([ + { + id: "workspace-ready", + body: "## Workspace Ready\n\n- Strategy: `git_worktree`", + authorType: "agent", + authorAgentId: "agent-1", + createdByRunId: "run-1", + presentation, + metadata, + createdAt: "2026-09-02T12:59:03.318Z", + } as unknown as IssueChatComment, + ]); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + id: "workspace-ready", + kind: "message", + author: "system", + presentation, + metadata, + runAgentId: null, + createdAtIso: "2026-09-02T12:59:03.318Z", + }); + expect(items[0]).toHaveProperty("authorName", undefined); + expect(items[0]).toHaveProperty("agentIcon", undefined); + }); + + it("keeps an agent comment with message presentation as an agent bubble", () => { + const [item] = commentsToTaskChatItems([ + { + id: "agent-message", + body: "Implementation is complete.", + authorType: "agent", + authorAgentId: "agent-1", + presentation: { kind: "message" }, + metadata: { version: 1, sections: [] }, + createdAt: "2026-09-02T13:00:00.000Z", + } as unknown as IssueChatComment, + ]); + + expect(item).toMatchObject({ kind: "message", author: "agent" }); + if (item.kind !== "message") throw new Error("expected message item"); + expect(item.presentation).toBeUndefined(); + expect(item.metadata).toBeUndefined(); + }); }); diff --git a/ui/src/components/task-chat/task-chat-adapter.ts b/ui/src/components/task-chat/task-chat-adapter.ts index 07d1a99531..2402d6ba62 100644 --- a/ui/src/components/task-chat/task-chat-adapter.ts +++ b/ui/src/components/task-chat/task-chat-adapter.ts @@ -29,10 +29,18 @@ function effectiveAgentId(comment: IssueChatComment): string | null { } function authorKind(comment: IssueChatComment): TaskChatAuthorKind { - // System authorship wins over any derivable run→agent linkage (PAP-443): - // recovery notices carry a derivedAuthorAgentId but must not render as - // agent bubbles. - if (comment.authorType === "system") return "system"; + // The server-authored presentation contract wins over attribution. Some + // control-plane notices keep the run agent as their author for audit and + // authorization, but they must still use the system-notice renderer. + // System authorship also wins over any derivable run→agent linkage + // (PAP-443): recovery notices carry a derivedAuthorAgentId but must not + // render as agent bubbles. + if ( + comment.presentation?.kind === "system_notice" || + comment.authorType === "system" + ) { + return "system"; + } if (effectiveAgentId(comment)) return "agent"; if (comment.authorType === "user") return "human"; return "agent"; diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 7110394563..b9c3bcb9d7 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -146,8 +146,10 @@ export interface TaskChatMessageItem { attachedTurn?: TaskChatTurnItem; /** * Structured system-notice fields (PAP-443), carried only for - * author === "system": the comment's server-authored presentation hints and - * metadata sections drive the collapsed one-line row + expandable detail. + * author === "system": either system attribution or an explicit + * system_notice presentation routes the comment here. The comment's + * server-authored presentation hints and metadata sections drive the + * collapsed one-line row + expandable detail. */ presentation?: IssueCommentPresentation | null; metadata?: IssueCommentMetadata | null; diff --git a/ui/src/index.css b/ui/src/index.css index 30c4328a2b..8cb1bdca25 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1834,8 +1834,10 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { .paperclip-markdown { --tw-prose-pre-bg: var(--muted); --tw-prose-pre-code: var(--foreground); + --tw-prose-code: var(--foreground); --tw-prose-invert-pre-bg: var(--muted); --tw-prose-invert-pre-code: var(--foreground); + --tw-prose-invert-code: var(--foreground); } .paperclip-markdown pre { diff --git a/ui/storybook/stories/task-chat-system-notice.stories.tsx b/ui/storybook/stories/task-chat-system-notice.stories.tsx new file mode 100644 index 0000000000..553e8e24de --- /dev/null +++ b/ui/storybook/stories/task-chat-system-notice.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TaskChatSystemNotice } from "@/components/task-chat/TaskChatSystemNotice"; +import type { TaskChatMessageItem } from "@/components/task-chat/task-chat-model"; + +const workspaceReadyItem = { + id: "workspace-ready", + kind: "message", + author: "system", + text: [ + "## Workspace Ready", + "", + "- Strategy: `git_worktree`", + "- Branch: `fix/workspace-ready-notice`", + "- CWD: `/worktrees/workspace-ready-notice`", + ].join("\n"), + createdAtIso: new Date(Date.now() - 2 * 60_000).toISOString(), + presentation: { + kind: "system_notice", + tone: "info", + title: "Workspace ready · fix/workspace-ready-notice", + detailsDefaultOpen: false, + density: "compact", + }, + metadata: { + version: 1, + sections: [ + { + title: "Workspace", + rows: [ + { type: "key_value", label: "Strategy", value: "git_worktree" }, + { + type: "key_value", + label: "Branch", + value: "fix/workspace-ready-notice", + }, + { + type: "key_value", + label: "CWD", + value: "/worktrees/workspace-ready-notice", + }, + ], + }, + ], + }, +} satisfies TaskChatMessageItem; + +function StoryFrame({ item }: { item: TaskChatMessageItem }) { + return ( +
+ +
+ ); +} + +const meta = { + title: "Product/Task chat/System notice", + component: TaskChatSystemNotice, + args: { + item: workspaceReadyItem, + }, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "Compact control-plane notice used for structured task-thread events. Expand the row to inspect workspace metadata without presenting it as an agent reply.", + }, + }, + }, + render: ({ item }) => , +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const WorkspaceReadyCollapsed: Story = {}; + +export const WorkspaceReadyExpanded: Story = { + args: { + item: { + ...workspaceReadyItem, + presentation: { + ...workspaceReadyItem.presentation, + detailsDefaultOpen: true, + }, + }, + }, +};