From 063ba59ae33baee46a308b0d550a3a7aaa3d4dea Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:15:31 -0500 Subject: [PATCH] Fix workspace-ready notice rendering (#12716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps people manage AI agents and their work. > - The task thread shows user, agent, and control-plane messages. > - Workspace-ready comments keep agent attribution for audit and authorization. > - These comments also have an explicit system-notice presentation. > - The client used attribution before the presentation contract. > - As a result, workspace-ready comments appeared as agent bubbles. > - This pull request gives the system-notice presentation priority. > - The benefit is a compact workspace notice with expandable details. ## Linked Issues or Issue Description **What happened?** The task thread showed a workspace-ready control-plane comment as a normal agent message. The comment kept agent attribution for audit and authorization, so the client did not use its system-notice presentation. **Expected behavior** The task thread must show a comment with the `system_notice` presentation as a compact system notice. The user must be able to expand the notice to inspect its details. **Steps to reproduce** 1. Open a task with a workspace that becomes ready. 2. Wait for the control plane to add the workspace-ready comment. 3. Observe that the task thread shows the comment as an agent bubble instead of a system notice. **Paperclip version or commit** `master` at `8eaa5caa0`. **Deployment mode** Local dev (`pnpm dev`). **Agent adapter(s) involved** Not adapter-specific. This is a core UI bug. ## What Changed - Give the `system_notice` presentation priority when the task-chat adapter selects the message kind. - Pass the notice presentation data to the task-chat model. - Improve the compact and expanded workspace notice details. - Add component tests, adapter tests, and Storybook cases for workspace notices. ## Verification - `pnpm exec vitest run ui/src/components/task-chat/task-chat-adapter.test.ts ui/src/components/task-chat/TaskChatSystemNotice.test.tsx` passes 15 tests. - `pnpm check:token-gates` passes. - `pnpm -r typecheck` passes. - `pnpm build` passes. - `pnpm test:run` passes 5,670 tests and reports four pre-existing workspace-runtime failures. The same four failures reproduce when the two unrelated server test files run alone. They do not use the changed task-chat files. ## Risks - Risk is low. Normal agent messages still use the agent presentation. - A comment with an explicit system-notice presentation now uses the compact notice renderer. - Regression tests cover both paths. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI GPT-5 (`gpt-5`) through Codex. The agent used reasoning, repository tools, and code execution. The runtime did not expose the context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../task-chat/TaskChatSystemNotice.test.tsx | 56 +++++++++++- .../task-chat/TaskChatSystemNotice.tsx | 31 +++++-- .../task-chat/task-chat-adapter.test.ts | 71 +++++++++++++++ .../components/task-chat/task-chat-adapter.ts | 16 +++- .../components/task-chat/task-chat-model.ts | 6 +- ui/src/index.css | 2 + .../task-chat-system-notice.stories.tsx | 89 +++++++++++++++++++ 7 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 ui/storybook/stories/task-chat-system-notice.stories.tsx 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, + }, + }, + }, +};