From 94e62909b99c8ceea61a3dd95180ebbb1f141dd2 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Mon, 12 Oct 2026 11:42:42 -0700 Subject: [PATCH 1/6] fix(WS-PREVIEW): final check, an app's preview tab draws the application glyph its card draws The preview tab glyph (iconForArtifact) returned ChartColumn for every ui:// resource, so an Agent Drafter app drew lucide-chart-column on its tab and lucide-app-window on its transcript card. The tab now asks the card's own classifier (artifactCardKind) and draws ENTITY_ICONS.application for an app; Auto Visualiser figures and reports keep ChartColumn. The ui:// address lookup is shared with ArtifactKindMeta so the bar's kind word and the tab glyph read the same uri. --- .../artifacts/ArtifactViewer.tabGlyph.test.ts | 25 ++++++++++++++++++ .../components/artifacts/ArtifactViewer.tsx | 26 ++++++++++++------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/ui/desktop/src/components/artifacts/ArtifactViewer.tabGlyph.test.ts b/ui/desktop/src/components/artifacts/ArtifactViewer.tabGlyph.test.ts index 9ea47eacc..c1876328a 100644 --- a/ui/desktop/src/components/artifacts/ArtifactViewer.tabGlyph.test.ts +++ b/ui/desktop/src/components/artifacts/ArtifactViewer.tabGlyph.test.ts @@ -9,6 +9,7 @@ import { Globe, Image, } from '../icons/app-icons'; +import { ENTITY_ICONS } from '../icons/entity-icons'; import type { ArtifactSource } from './artifactTypes'; import { iconForArtifact } from './ArtifactViewer'; @@ -63,4 +64,28 @@ describe('the glyph a preview tab draws (V2-R3-3)', () => { ); expect(iconForArtifact(null)).toBe(File); }); + + it('draws an Agent Drafter app with the application glyph its transcript card draws', () => { + // One entity, one glyph (design.md §3.9): the app's card in the build chat + // draws the registry's application glyph, so its preview tab draws the same + // one rather than the figure glyph every other `ui://` resource takes. + const html = (sourceUri: string): ArtifactSource => ({ + kind: 'html', + title: 'a', + html: '

x

', + sourceUri, + }); + expect(iconForArtifact(html('ui://agent-drafter/lab-notebook'))).toBe(ENTITY_ICONS.application); + expect(iconForArtifact(html('UI://Agent_Drafter/lab-notebook'))).toBe(ENTITY_ICONS.application); + expect( + iconForArtifact({ + kind: 'mcpResource', + title: 'a', + resource: { uri: 'ui://agent-drafter/lab-notebook', text: '' }, + }) + ).toBe(ENTITY_ICONS.application); + // Auto Visualiser figures and reports keep the figure glyph. + expect(iconForArtifact(html('ui://autovisualiser/chart'))).toBe(ChartColumn); + expect(iconForArtifact(html('ui://dashboard/report'))).toBe(ChartColumn); + }); }); diff --git a/ui/desktop/src/components/artifacts/ArtifactViewer.tsx b/ui/desktop/src/components/artifacts/ArtifactViewer.tsx index 0976e4ec2..b5a209073 100644 --- a/ui/desktop/src/components/artifacts/ArtifactViewer.tsx +++ b/ui/desktop/src/components/artifacts/ArtifactViewer.tsx @@ -85,6 +85,7 @@ import { Button, buttonVariants } from '../ui/button'; import { InfoTip } from '../ui/info-tip'; import { SegmentedControl } from '../ui/segmented-control'; import { artifactCardKind } from '../MCPUIResourceRenderer'; +import { ENTITY_ICONS } from '../icons/entity-icons'; import { ARTIFACT_CARD_COPY } from '../toolCallCopy'; import { PREVIEW_COPY } from './copy'; import { isCodePath } from './artifactCodePath'; @@ -531,11 +532,24 @@ export function iconForArtifact( if (!ext) return Folder; return FileText; } - // A `ui://` figure or report (Auto Visualiser, an app card): the figure glyph - // the tool row that made it draws (icons.md, "Figure"). + // A `ui://` resource. An Agent Drafter app is the application entity, so it + // draws the registry's glyph, the one its transcript card draws (one entity, + // one glyph; the card's own classifier decides). A figure or report (Auto + // Visualiser) draws the figure glyph the tool row that made it draws (icons.md, + // "Figure"). + if (artifactCardKind(artifact, artifactResourceUri(artifact)) === 'app') { + return ENTITY_ICONS.application; + } return ChartColumn; } +/** The `ui://` address a resource artifact was delivered under, when it has one. */ +function artifactResourceUri(artifact: ArtifactSource): string | undefined { + if (artifact.kind === 'mcpResource') return artifact.resource.uri; + if (artifact.kind === 'html') return artifact.sourceUri; + return undefined; +} + function formatBytes(value?: number) { if (value === undefined) return ''; if (value < 1024) return `${value} B`; @@ -2066,16 +2080,10 @@ function ArtifactErrorState({ * card's own (`artifactCardKind`), so the two never name a thing differently. */ function ArtifactKindMeta({ artifact }: { artifact: ArtifactSource }) { - const uri = - artifact.kind === 'mcpResource' - ? artifact.resource.uri - : artifact.kind === 'html' - ? artifact.sourceUri - : undefined; return ( - {ARTIFACT_CARD_COPY.kind[artifactCardKind(artifact, uri)]} + {ARTIFACT_CARD_COPY.kind[artifactCardKind(artifact, artifactResourceUri(artifact))]} ); From 414ac9cb9754a57c56a3e3b39e96c72a900c2e32 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Mon, 12 Oct 2026 11:48:09 -0700 Subject: [PATCH 2/6] fix(WS-TOOLS): final check, the /bug approval card shows a readable body and one warning glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Arguments preview was the backend's pretty JSON in a whitespace-pre box, so a string argument was one escaped line. platform__report_bug's body (the text that becomes a public issue) measured 68,874px wide in a 724px box, about 90 characters visible with literal \n, under a card saying "Read the body below". A parseable arguments object now renders as named fields, each string value the exact text with real line breaks, wrapped by an authored rule (.br-tool-preview-text: pre-wrap, overflow-wrap anywhere) in tool-call.css. Non-string values stay JSON; a clipped blob is never parsed and is shown as wrapped raw text beside the Truncated note. Collapse is decided on the wrapped line estimate, so a single long line still folds behind Show more. Never rendered as Markdown: the person is checking what will be published. The private card drew its AlertTriangle and then the prompt's own leading "⚠ " (bug_report::private_warning). The card now drops a leading warning glyph (with or without VS16) and keeps the rest exactly; a glyph inside the sentence stays, and a glyph-only prompt is kept rather than blanked. Done in the card, not the producer: the CLI and TUI print the prompt as bare text, where that glyph is the only warning sign, and persisted cards are fixed too. Tests: ToolCallConfirmation.test.tsx (5 failing before, 1 guard), and a real-Chromium geometry test in toolCardsGeometry.browser.test.tsx (scrollWidth 68,874 > 724 before; wraps at 760 and 580px after). --- .../components/ToolCallConfirmation.test.tsx | 104 ++++++++++++++++ .../src/components/ToolCallConfirmation.tsx | 22 +++- ui/desktop/src/components/ToolCallPreview.tsx | 111 ++++++++++++++++-- ui/desktop/src/components/tool-call.css | 26 ++++ .../toolCardsGeometry.browser.test.tsx | 59 ++++++++++ 5 files changed, 311 insertions(+), 11 deletions(-) diff --git a/ui/desktop/src/components/ToolCallConfirmation.test.tsx b/ui/desktop/src/components/ToolCallConfirmation.test.tsx index 3b68964b7..0113f1160 100644 --- a/ui/desktop/src/components/ToolCallConfirmation.test.tsx +++ b/ui/desktop/src/components/ToolCallConfirmation.test.tsx @@ -494,3 +494,107 @@ describe('ToolCallConfirmation shape (spec 3.6 cards)', () => { expect(screen.queryByRole('button')).toBeNull(); }); }); + +/** + * Final check, /bug (2026-10-12): the report approval card promised a body the + * person could not read. + * + * `platform__report_bug` sends its arguments as one `arguments` preview, and the + * body (the text that becomes a world-readable GitHub issue) arrived as a + * single escaped JSON line: 7,662 characters in a 724px box, about 90 of them + * visible, with literal `\n`. The card above it says "Read the body below". + */ +describe('ToolCallConfirmation for a /bug report (final check)', () => { + const body = + '**Describe the bug**\n\nIn this chat, the assistant created a new markdown file.\n\n' + + '**Steps to reproduce**\n\n1. Open a chat.\n2. Ask for a file.\n\n' + + 'The summary rail kept saying +0 -0 after the file was written. '.repeat(100); + const args = { + repository: 'github.com/BaranziniLab/biorouter', + title: 'Chat summary Code delta stays +0 -0 after file creation', + body, + labels: ['bug'], + chatPrivacyTier: 'private', + }; + const reportCard = (overrides: Parameters[0] = {}) => + renderCard({ + toolName: 'platform__report_bug', + risk: 'high', + arguments: args, + preview: { kind: 'arguments', json: JSON.stringify(args, null, 2), truncated: false }, + prompt: 'Publish this bug report to the public issue tracker? Read the body below.', + ...overrides, + }); + + it('shows the body as the exact text, with real line breaks, in the wrapping text style', () => { + reportCard(); + const shown = screen.getByTestId('tool-preview-value-body'); + // The exact text that will be published: real newlines, no JSON escapes. + expect(shown.textContent).toBe(body); + expect(shown.textContent).not.toContain('\\n'); + // Wrapping is authored CSS (tool-call.css), not a utility class. + expect(shown).toHaveClass('br-tool-preview-text'); + expect(screen.getByTestId('tool-preview-value-title')).toHaveTextContent(args.title); + // Every field is named, in the order the backend sent them. + expect( + screen.getAllByTestId('tool-preview-field').map((f) => f.querySelector('dt')!.textContent) + ).toEqual(['repository', 'title', 'body', 'labels', 'chatPrivacyTier']); + // A value that is not a string stays JSON, so `["bug"]` is not read as prose. + expect(screen.getByTestId('tool-preview-value-labels').textContent).toBe('[\n "bug"\n]'); + }); + + it('collapses a long body behind "Show more" so the decision stays in reach', () => { + reportCard(); + expect(screen.getByTestId('tool-preview-body').className).toContain('max-h-52'); + fireEvent.click(screen.getByRole('button', { name: 'Show more' })); + expect(screen.getByTestId('tool-preview-body').className).not.toContain('max-h-52'); + }); + + it('collapses a single long line, which a newline count alone would leave open', () => { + const oneLine = { body: 'x'.repeat(4000) }; + renderCard({ + toolName: 'platform__report_bug', + preview: { kind: 'arguments', json: JSON.stringify(oneLine, null, 2), truncated: false }, + }); + expect(screen.getByRole('button', { name: 'Show more' })).toBeInTheDocument(); + }); + + it('wraps a clipped argument blob it cannot parse, and still says it was clipped', () => { + const json = JSON.stringify(args, null, 2).slice(0, 300); + reportCard({ preview: { kind: 'arguments', json, truncated: true } }); + expect(screen.queryByTestId('tool-preview-field')).toBeNull(); + const raw = screen.getByTestId('tool-preview-raw'); + expect(raw.textContent).toBe(json); + expect(raw).toHaveClass('br-tool-preview-text'); + expect(screen.getByText('Truncated')).toBeInTheDocument(); + }); + + it('draws one warning glyph: a leading ⚠ in the prompt is not repeated beside the icon', () => { + for (const glyph of ['⚠ ', '\u26A0\uFE0F ', ' ⚠ ']) { + const { unmount } = reportCard({ + prompt: `${glyph}This chat is PRIVATE: it ran on a private model. Read the body below.`, + }); + const finding = screen.getByTestId('tool-security-finding'); + expect(finding.textContent).toBe( + 'This chat is PRIVATE: it ran on a private model. Read the body below.' + ); + // The icon is the one glyph. + expect(finding.querySelectorAll('svg')).toHaveLength(1); + // Still a finding: "Always allow" stays withheld. + expect(screen.queryByRole('button', { name: /Always allow/i })).toBeNull(); + unmount(); + } + }); + + it('keeps a warning glyph that is not the lead, and a prompt that is only a glyph', () => { + const { unmount } = reportCard({ + prompt: 'Publish this? ⚠ "Describe the bug" is YOUR last message.', + }); + expect(screen.getByTestId('tool-security-finding').textContent).toBe( + 'Publish this? ⚠ "Describe the bug" is YOUR last message.' + ); + unmount(); + reportCard({ prompt: '⚠' }); + expect(screen.getByTestId('tool-security-finding').textContent).toBe('⚠'); + }); +}); diff --git a/ui/desktop/src/components/ToolCallConfirmation.tsx b/ui/desktop/src/components/ToolCallConfirmation.tsx index c4b5db465..4f9074e4f 100644 --- a/ui/desktop/src/components/ToolCallConfirmation.tsx +++ b/ui/desktop/src/components/ToolCallConfirmation.tsx @@ -30,6 +30,26 @@ function refusalIsPermanent(error: unknown): boolean { ); } +/** A leading warning sign (with or without its emoji selector) and the space after it. */ +const LEADING_WARNING_GLYPH = /^\s*\u26A0\uFE0F?\s*/u; + +/** + * The finding as the card shows it, beside its own warning icon. + * + * A producer may lead its prompt with "⚠ ", because the terminal prints the + * prompt as bare text and that glyph is its only warning sign there: + * `bug_report::private_warning` does ("⚠ This chat is PRIVATE: …"). Drawn + * after the card's icon it made two warning signs side by side (final check, + * 2026-10-12), so the card drops a LEADING one and keeps the rest of the text + * exactly: a ⚠ inside the sentence marks a second warning and stays. Stripping + * here rather than at the producer also fixes cards persisted by an older + * daemon. A prompt that is only the glyph keeps it, rather than going blank. + */ +function findingText(prompt: string): string { + const stripped = prompt.replace(LEADING_WARNING_GLYPH, ''); + return stripped.length > 0 ? stripped : prompt; +} + const ALLOW_ONCE = 'allow_once'; const ALWAYS_ALLOW = 'always_allow'; const DENY = 'deny'; @@ -228,7 +248,7 @@ export default function ToolConfirmation({ className="flex items-start gap-2 text-body text-text-warning" >