From a20ea746bcd5c72765ffa21e5bffc51f67ca715a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Thu, 6 Aug 2026 02:29:38 +0530 Subject: [PATCH 1/3] feat(ui): overhaul tool result cards --- package.json | 2 +- src/server.test.ts | 4 +- src/server.ts | 3 - src/ui/card-types.test.ts | 40 +++ src/ui/card-types.ts | 15 +- src/ui/heavy-payload.tsx | 9 +- src/ui/icons.ts | 12 + src/ui/review-payload.tsx | 128 +++++-- src/ui/scrollbar-styles.test.ts | 8 + src/ui/scrollbar-styles.ts | 43 +++ src/ui/tool-display.test.ts | 39 ++- src/ui/tool-display.ts | 25 +- src/ui/workspace-app.css | 486 +++++++++++++++++++++++---- src/ui/workspace-app.tsx | 347 ++++++++++++++----- src/ui/workspace-disclosures.test.ts | 28 ++ src/ui/workspace-disclosures.ts | 27 ++ 16 files changed, 1015 insertions(+), 201 deletions(-) create mode 100644 src/ui/scrollbar-styles.test.ts create mode 100644 src/ui/scrollbar-styles.ts create mode 100644 src/ui/workspace-disclosures.test.ts create mode 100644 src/ui/workspace-disclosures.ts diff --git a/package.json b/package.json index 59e23330..a0321569 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-disclosures.test.ts && tsx src/ui/scrollbar-styles.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/server.test.ts b/src/server.test.ts index 33f2a871..f0becf9b 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -63,7 +63,9 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(Array.isArray(card.skills)); assert.ok(Array.isArray(card.agentProviders)); assert.ok(Array.isArray(card.agents)); - assert.ok(Array.isArray(card.skillDiagnostics)); + assert.equal(card.skillDiagnostics, undefined); + assert.equal(card.instruction, undefined); + assert.equal((card.summary as Record | undefined)?.skillDiagnostics, undefined); }); test("concurrent checkout opens return one full context and one reuse instruction", async (t) => { diff --git a/src/server.ts b/src/server.ts index bfb8fb4f..01e1bf66 100644 --- a/src/server.ts +++ b/src/server.ts @@ -914,8 +914,6 @@ export function createMcpServer( skills: cardSkills, agentProviders: cardAgentProviders, agents: cardAgents, - skillDiagnostics: workspace.skillDiagnostics, - instruction: cardInstruction, summary: { mode: workspace.mode, agentsFiles: cardAgentsFiles.length, @@ -923,7 +921,6 @@ export function createMcpServer( skills: cardSkills.length, agentProviders: cardAgentProviders.length, agents: cardAgents.length, - skillDiagnostics: workspace.skillDiagnostics.length, }, }, }, diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index eb16ad87..e0ad2a38 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -5,6 +5,7 @@ import { isExpandableCard, isPatchTool, isShellTool, + shouldAutoExpandCard, isToolName, } from "./card-types.js"; @@ -31,6 +32,31 @@ test("a patch card expands only when it contains patch content", () => { assert.equal(isExpandableCard({ tool: "apply_patch" }), false); }); +test("single-file mutation cards open their result immediately", () => { + assert.equal( + shouldAutoExpandCard({ + tool: "apply_patch", + files: [{ path: "src/one.ts", operation: "update" }], + payload: { patch: "diff --git a/src/one.ts b/src/one.ts" }, + }), + true, + ); + assert.equal( + shouldAutoExpandCard({ + tool: "apply_patch", + files: [ + { path: "src/one.ts", operation: "update" }, + { path: "src/two.ts", operation: "update" }, + ], + payload: { patch: "diff --git a/src/one.ts b/src/one.ts" }, + }), + false, + ); + assert.equal(shouldAutoExpandCard({ tool: "write", payload: { patch: "diff" } }), true); + assert.equal(shouldAutoExpandCard({ tool: "edit", payload: { diff: "diff" } }), true); + assert.equal(shouldAutoExpandCard({ tool: "read", payload: { content: [] } }), false); +}); + test("a workspace card expands when it contains provider metadata", () => { assert.equal( isExpandableCard({ @@ -64,3 +90,17 @@ test("a workspace card expands when it contains available instruction files", () test("an empty workspace card stays collapsed", () => { assert.equal(isExpandableCard({ tool: "open_workspace" }), false); }); + +test("workspace source metadata alone does not create an empty expandable panel", () => { + assert.equal( + isExpandableCard({ tool: "open_workspace", worktree: { path: "/tmp/worktree" } }), + false, + ); +}); + +test("workspace diagnostics do not make a card expandable", () => { + assert.equal( + isExpandableCard({ tool: "open_workspace", summary: { skillDiagnostics: 3 } }), + false, + ); +}); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 596e2e84..67143368 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -72,8 +72,6 @@ export interface ToolResultCard { providerAvailable?: boolean; providerUnavailableReason?: string; }>; - skillDiagnostics?: unknown[]; - instruction?: string; } export interface ToolContent { @@ -165,15 +163,11 @@ export function isExpandableCard(card: ToolResultCard): boolean { Number(card.summary?.skills ?? 0) > 0 || Number(card.summary?.agentProviders ?? 0) > 0 || Number(card.summary?.agents ?? 0) > 0 || - Number(card.summary?.skillDiagnostics ?? 0) > 0 || Boolean(card.agentsFiles?.length) || Boolean(card.availableAgentsFiles?.length) || Boolean(card.skills?.length) || Boolean(card.agentProviders?.length) || - Boolean(card.agents?.length) || - Boolean(card.worktree) || - Boolean(card.instruction) || - Boolean(card.skillDiagnostics?.length) + Boolean(card.agents?.length) ); } @@ -182,3 +176,10 @@ export function isExpandableCard(card: ToolResultCard): boolean { return Boolean(card.payload); } + +export function shouldAutoExpandCard(card: ToolResultCard): boolean { + if (!isExpandableCard(card)) return false; + if (isReviewTool(card.tool)) return true; + if (isPatchTool(card.tool)) return card.files?.length === 1; + return isEditTool(card.tool) || isWriteTool(card.tool); +} diff --git a/src/ui/heavy-payload.tsx b/src/ui/heavy-payload.tsx index 69779141..5aaeadf8 100644 --- a/src/ui/heavy-payload.tsx +++ b/src/ui/heavy-payload.tsx @@ -12,6 +12,7 @@ import { type HostContext, type ToolResultCard, } from "./card-types.js"; +import { PIERRE_SCROLLBAR_STYLES } from "./scrollbar-styles.js"; type ThemeType = "light" | "dark"; @@ -77,7 +78,7 @@ function HeavyPayload({ ); } - return
{text}
; + return
{text}
; } function FilePayload({ @@ -100,6 +101,7 @@ function FilePayload({ }, themeType, overflow: "scroll", + unsafeCSS: PIERRE_SCROLLBAR_STYLES, }), [themeType], ); @@ -134,7 +136,7 @@ function FilePayload({ }; }, [fileOptions, path, startLine, text]); - return
; + return
; } function DiffPayload({ @@ -162,8 +164,9 @@ function DiffPayload({ expansionLineCount: 20, stickyHeader: true, disableFileHeader: true, + unsafeCSS: PIERRE_SCROLLBAR_STYLES, }} - className="pierre-diff" + className="pierre-diff pretty-scrollbar" /> ); } diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 22f86002..8d2a5c87 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -1,11 +1,17 @@ import { + BookOpen, + Bot, + ChevronRight, ChevronDown, + CircleAlert, + CircleCheck, FileDiff, FileMinus, FilePenLine, FilePlus, FileText, Files, + GitBranch, FolderOpen, FolderTree, LoaderCircle, @@ -17,13 +23,19 @@ import { } from "lucide"; export const toolIcons = { + alert: CircleAlert, + agents: Bot, + chevronRight: ChevronRight, chevronDown: ChevronDown, + check: CircleCheck, deleteFile: FileMinus, diff: FileDiff, editFile: FilePenLine, files: Files, folderOpen: FolderOpen, folderTree: FolderTree, + gitBranch: GitBranch, + instructions: BookOpen, loading: LoaderCircle, readFile: FileText, search: Search, diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index 068d2c1f..38069470 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { parsePatchFiles, type FileDiffMetadata, type FileDiffOptions } from "@pierre/diffs"; import { FileDiff } from "@pierre/diffs/react"; import type { HostContext, ToolResultCard } from "./card-types.js"; +import { PIERRE_SCROLLBAR_STYLES } from "./scrollbar-styles.js"; type ThemeType = "light" | "dark"; @@ -47,7 +48,11 @@ function ReviewPayload({ const visibleFiles = typeof visibleFileCount === "number" ? files.slice(0, visibleFileCount) : files; - const [openFiles, setOpenFiles] = useState(() => new Set()); + const isSingleFile = files.length === 1; + const [openFiles, setOpenFiles] = useState>(() => { + const onlyFile = files.length === 1 ? files[0] : undefined; + return onlyFile ? new Set([reviewFileKey(onlyFile)]) : new Set(); + }); if (errorMessage) return ; if (!patch) return ; @@ -56,37 +61,61 @@ function ReviewPayload({ const options = diffOptions(themeType); return ( -
+
- {visibleFiles.map((fileDiff, index) => { - const key = fileDiff.cacheKey ?? `${fileDiff.prevName ?? ""}->${fileDiff.name}-${index}`; + {visibleFiles.map((fileDiff) => { + const key = reviewFileKey(fileDiff); const stats = diffStats(fileDiff); const isOpen = openFiles.has(key); + const metadata = findReviewFile(card.files, fileDiff); + const operation = reviewOperation(metadata); + const previousPath = metadata?.previousPath ?? fileDiff.prevName; return ( -
- +
+ {!isSingleFile ? ( + + ) : null} {isOpen ? ( - + ) : null}
); @@ -101,6 +130,10 @@ function parseFiles(patch: string | undefined): FileDiffMetadata[] { return parsePatchFiles(patch, "review", true).flatMap((parsedPatch) => parsedPatch.files); } +function reviewFileKey(fileDiff: FileDiffMetadata): string { + return fileDiff.cacheKey ?? `${fileDiff.prevName ?? ""}->${fileDiff.name}`; +} + function diffStats(fileDiff: FileDiffMetadata): { additions: number; removals: number } { return fileDiff.hunks.reduce( (stats, hunk) => ({ @@ -111,6 +144,48 @@ function diffStats(fileDiff: FileDiffMetadata): { additions: number; removals: n ); } +type ReviewOperation = "add" | "update" | "delete" | "move"; + +function findReviewFile( + files: ToolResultCard["files"], + fileDiff: FileDiffMetadata, +): NonNullable[number] | undefined { + const name = fileDiff.name; + const previousName = fileDiff.prevName; + return files?.find((file) => { + if (file.path === name || file.previousPath === name) return true; + if (!previousName) return false; + return file.path === previousName || file.previousPath === previousName; + }); +} + +function reviewOperation(file: NonNullable[number] | undefined): ReviewOperation { + if (file?.operation === "add" || file?.type === "new") return "add"; + if (file?.operation === "delete" || file?.type === "deleted") return "delete"; + if ( + file?.operation === "move" || + file?.type === "rename-pure" || + file?.type === "rename-changed" + ) { + return "move"; + } + return "update"; +} + +function reviewOperationSymbol(operation: ReviewOperation): string { + if (operation === "add") return "+"; + if (operation === "delete") return "−"; + if (operation === "move") return "↗"; + return "~"; +} + +function reviewOperationLabel(operation: ReviewOperation): string { + if (operation === "add") return "Added file"; + if (operation === "delete") return "Deleted file"; + if (operation === "move") return "Moved file"; + return "Changed file"; +} + function diffOptions(themeType: ThemeType): FileDiffOptions { return { theme: { @@ -127,6 +202,7 @@ function diffOptions(themeType: ThemeType): FileDiffOptions { expansionLineCount: 20, stickyHeader: false, disableFileHeader: true, + unsafeCSS: PIERRE_SCROLLBAR_STYLES, }; } diff --git a/src/ui/scrollbar-styles.test.ts b/src/ui/scrollbar-styles.test.ts new file mode 100644 index 00000000..6d9f98cf --- /dev/null +++ b/src/ui/scrollbar-styles.test.ts @@ -0,0 +1,8 @@ +import assert from "node:assert/strict"; +import { PIERRE_SCROLLBAR_STYLES } from "./scrollbar-styles.js"; + +assert.match(PIERRE_SCROLLBAR_STYLES, /\[data-code\]::-webkit-scrollbar/); +assert.match(PIERRE_SCROLLBAR_STYLES, /scrollbar-gutter:\s*auto/); +assert.match(PIERRE_SCROLLBAR_STYLES, /background-color:\s*var\(--scrollbar-thumb/); +assert.doesNotMatch(PIERRE_SCROLLBAR_STYLES, /scrollbar-(?:width|color)/); +assert.doesNotMatch(PIERRE_SCROLLBAR_STYLES, /100 116 139|71 85 105/); diff --git a/src/ui/scrollbar-styles.ts b/src/ui/scrollbar-styles.ts new file mode 100644 index 00000000..6e15e26d --- /dev/null +++ b/src/ui/scrollbar-styles.ts @@ -0,0 +1,43 @@ +/** + * Pierre renders its actual code scrollers in a shadow root. Keep this CSS + * separate from the document utility so it can be injected through Pierre's + * supported unsafeCSS option. + */ +export const PIERRE_SCROLLBAR_STYLES = ` +[data-code] { + scrollbar-gutter: auto; + padding-bottom: var(--diffs-gap-block, var(--diffs-gap-fallback)); +} + +[data-code]::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +[data-code]::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} + +[data-code]::-webkit-scrollbar-track { + background: transparent; +} + +[data-code]::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb, rgb(156 156 156 / 58%)); + background-clip: content-box; + border: 2px solid transparent; + border-radius: 9999px; +} + +[data-code]:is(:hover, :focus, :focus-within)::-webkit-scrollbar-thumb, +:host(:is(:hover, :focus, :focus-within)) [data-code]::-webkit-scrollbar-thumb, +[data-code]::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover, rgb(204 204 204 / 82%)); +} + +[data-code]::-webkit-scrollbar-corner { + background: transparent; +} +`; diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index 86b67271..99370dac 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -6,7 +6,7 @@ import { getToolDisplay, getToolHeaderSummary } from "./tool-display.js"; const displayCases: Array<[ToolResultCard, { title: string; tone: string }]> = [ [{ tool: "open_workspace", root: "/tmp/project" }, { title: "Opened workspace", tone: "workspace" }], [{ tool: "open_workspace", root: "/tmp/project", workspaceReused: true }, { title: "Reused workspace", tone: "workspace" }], - [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree" }, { title: "Opened worktree", tone: "workspace" }], + [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree" }, { title: "Opened workspace", tone: "workspace" }], [{ tool: "read", path: "src/read.ts" }, { title: "Read file", tone: "read" }], [{ tool: "write", path: "src/write.ts" }, { title: "Wrote file", tone: "write" }], [{ tool: "edit", path: "src/edit.ts" }, { title: "Edited file", tone: "edit" }], @@ -27,6 +27,10 @@ for (const [card, expected] of displayCases) { } assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); +assert.equal( + getToolDisplay({ tool: "open_workspace", root: "/tmp/project", mode: "worktree" }).icon, + toolIcons.gitBranch, +); assert.equal( getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, "needle in src", @@ -36,19 +40,19 @@ assert.deepEqual( pickDisplay(getToolDisplay({ tool: "show_changes", files: [ - { path: "src/a.ts", operation: "update" }, - { path: "src/b.ts", operation: "update" }, + { path: "src/a.ts", type: "change", additions: 1, removals: 1 }, + { path: "src/b.ts", type: "change", additions: 2, removals: 0 }, ], })), - { title: "Edited 2 files", tone: "review" }, + { title: "Changed 2 files", tone: "review" }, ); assert.deepEqual( pickDisplay(getToolDisplay({ tool: "show_changes", files: [ - { path: "src/a.ts", operation: "add" }, - { path: "src/b.ts", operation: "update" }, + { path: "src/a.ts", type: "new", additions: 2, removals: 0 }, + { path: "src/b.ts", type: "change", additions: 1, removals: 1 }, ], })), { title: "Changed 2 files", tone: "review" }, @@ -95,7 +99,28 @@ assert.deepEqual( tool: "open_workspace", summary: { mode: "worktree", agentsFiles: 1, skills: 4 }, }), - { kind: "text", text: "worktree · 1 instruction · 4 skills" }, + { kind: "text", text: "1 instruction · 4 skills" }, +); +assert.deepEqual( + getToolHeaderSummary({ + tool: "open_workspace", + agentsFiles: [{ path: "AGENTS.md" }], + skills: [{ name: "frontend" }, { name: "testing" }], + }), + { kind: "text", text: "1 instruction · 2 skills" }, +); + +assert.equal( + getToolDisplay({ tool: "show_changes" }).title, + "No changes", +); +assert.equal( + getToolDisplay({ + tool: "show_changes", + summary: { files: 3 }, + files: [{ path: "src/only-one.ts", type: "change" }], + }).title, + "Changed 3 files", ); assert.deepEqual( diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index f9706690..302a5ef5 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -15,6 +15,7 @@ export interface ToolDisplay { title: string; label?: string; tone: string; + iconLabel?: string; } export type ToolHeaderSummary = @@ -26,14 +27,11 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { switch (card.tool) { case "open_workspace": return { - icon: toolIcons.folderOpen, - title: card.workspaceReused - ? "Reused workspace" - : card.mode === "worktree" - ? "Opened worktree" - : "Opened workspace", + icon: card.mode === "worktree" ? toolIcons.gitBranch : toolIcons.folderOpen, + title: card.workspaceReused ? "Reused workspace" : "Opened workspace", label: card.root ?? card.path, tone: "workspace", + iconLabel: card.mode === "worktree" ? "Worktree workspace" : "Checkout workspace", }; case "read": return { @@ -103,10 +101,10 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { tone: "shell", }; case "show_changes": { - const display = getPatchDisplayParts(card); + const fileCount = summaryNumber(card.summary, "files") ?? card.files?.length ?? 0; return { icon: toolIcons.diff, - title: (card.files?.length ?? 0) > 0 ? display.title : "No changes", + title: fileCount > 0 ? `Changed ${fileCount} ${fileNoun(fileCount)}` : "No changes", tone: "review", }; } @@ -125,10 +123,11 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { } if (card.tool === "open_workspace") { + const instructionCount = summaryNumber(summary, "agentsFiles") ?? card.agentsFiles?.length; + const skillCount = summaryNumber(summary, "skills") ?? card.skills?.length; const parts = [ - typeof summary.mode === "string" ? summary.mode : undefined, - countLabel(summaryNumber(summary, "agentsFiles"), "instruction"), - countLabel(summaryNumber(summary, "skills"), "skill"), + countLabel(instructionCount, "instruction"), + countLabel(skillCount, "skill"), ].filter((part): part is string => Boolean(part)); return parts.length > 0 ? { kind: "text", text: parts.join(" · ") } : { kind: "empty" }; } @@ -196,6 +195,10 @@ function countLabel(count: number | undefined, noun: string): string | undefined return `${count} ${noun}${count === 1 ? "" : "s"}`; } +function fileNoun(count: number): "file" | "files" { + return count === 1 ? "file" : "files"; +} + function durationLabel(durationMs: number | undefined): string | undefined { if (durationMs === undefined) return undefined; if (durationMs < 1_000) return `${Math.round(durationMs)}ms`; diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 65713c34..5f748be4 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -3,12 +3,14 @@ font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); background: transparent; color: var(--color-text-primary, #f5f5f6); - --tool-card-border: color-mix(in srgb, var(--color-border-primary, #414141) 92%, transparent); - --tool-card-header-bg: color-mix(in srgb, var(--color-background-secondary, #272727) 96%, transparent); - --tool-card-body-bg: color-mix(in srgb, var(--color-background-primary, #181818) 98%, transparent); - --tool-card-hover-bg: color-mix(in srgb, var(--color-background-tertiary, #343434) 62%, transparent); - --tool-card-icon-bg: color-mix(in srgb, var(--color-background-primary, #0d0d0d) 98%, transparent); - --tool-card-divider: color-mix(in srgb, var(--color-border-primary, #414141) 82%, transparent); + --scrollbar-thumb: rgb(156 156 156 / 58%); + --scrollbar-thumb-hover: rgb(204 204 204 / 82%); + --tool-card-border: color-mix(in srgb, var(--color-border-primary, #414141) 88%, transparent); + --tool-card-header-bg: color-mix(in srgb, var(--color-background-secondary, #202124) 96%, transparent); + --tool-card-body-bg: color-mix(in srgb, var(--color-background-primary, #151619) 98%, transparent); + --tool-card-hover-bg: color-mix(in srgb, var(--color-background-tertiary, #343434) 58%, transparent); + --tool-card-icon-bg: color-mix(in srgb, var(--color-background-primary, #0d0d0d) 86%, transparent); + --tool-card-divider: color-mix(in srgb, var(--color-border-primary, #414141) 74%, transparent); } * { @@ -22,6 +24,37 @@ body { overflow: hidden; } +.pretty-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.pretty-scrollbar::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} + +.pretty-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.pretty-scrollbar::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); + background-clip: content-box; + border: 2px solid transparent; + border-radius: 9999px; +} + +.pretty-scrollbar:is(:hover, :focus, :focus-within)::-webkit-scrollbar-thumb, +.pretty-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover); +} + +.pretty-scrollbar::-webkit-scrollbar-corner { + background: transparent; +} + .shell { width: 100%; padding: 0; @@ -35,15 +68,70 @@ body { border: 1px solid var(--tool-card-border); border-radius: 14px; background: var(--tool-card-header-bg); - box-shadow: none; + box-shadow: 0 10px 30px rgb(0 0 0 / 10%); color: var(--color-text-primary, #f5f5f6); } -.tool-card, -.tool-card * { - scrollbar-width: thin; - scrollbar-color: gray transparent; - scrollbar-gutter: stable; +.tool-card { + --card-accent: #a8b3c2; + --card-accent-strong: #e2e8f0; + --card-accent-soft: rgb(148 163 184 / 12%); +} + +.tool-card.workspace { + --card-accent: #38bdf8; + --card-accent-strong: #bae6fd; + --card-accent-soft: rgb(56 189 248 / 13%); +} + +.tool-card.worktree { + --card-accent: #a78bfa; + --card-accent-strong: #ddd6fe; + --card-accent-soft: rgb(167 139 250 / 14%); +} + +.tool-card.read, +.tool-card.search, +.tool-card.directory { + --card-accent: #60a5fa; + --card-accent-strong: #bfdbfe; + --card-accent-soft: rgb(96 165 250 / 13%); +} + +.tool-card.write { + --card-accent: #4ade80; + --card-accent-strong: #bbf7d0; + --card-accent-soft: rgb(74 222 128 / 13%); +} + +.tool-card.edit { + --card-accent: #fbbf24; + --card-accent-strong: #fef3c7; + --card-accent-soft: rgb(251 191 36 / 13%); +} + +.tool-card.shell { + --card-accent: #c084fc; + --card-accent-strong: #e9d5ff; + --card-accent-soft: rgb(192 132 252 / 13%); +} + +.tool-card.review { + --card-accent: #f472b6; + --card-accent-strong: #fbcfe8; + --card-accent-soft: rgb(244 114 182 / 13%); +} + +.tool-card.running { + --card-accent: #22d3ee; + --card-accent-strong: #a5f3fc; + --card-accent-soft: rgb(34 211 238 / 13%); +} + +.tool-card.failed { + --card-accent: #fb7185; + --card-accent-strong: #fecdd3; + --card-accent-soft: rgb(251 113 133 / 14%); } .empty { @@ -52,19 +140,25 @@ body { font-size: var(--font-text-sm-size, 13px); } +.empty.error, +.status.error { + color: var(--color-danger-text, #ee7676); +} + .tool-header { display: grid; - grid-template-columns: 54px minmax(0, 1fr) auto 24px; + grid-template-columns: 44px minmax(0, 1fr) auto 20px; align-items: center; - gap: 14px; + gap: 12px; width: 100%; - min-height: 82px; - padding: 12px 16px; + min-height: 72px; + padding: 10px 14px; border: 0; border-radius: 13px; background: transparent; color: inherit; cursor: pointer; + font: inherit; text-align: left; } @@ -72,25 +166,34 @@ body { background: var(--tool-card-hover-bg); } +.tool-header:focus-visible, +.review-diff-file-header:focus-visible, +.review-more:focus-visible, +.workspace-disclosure-summary:focus-visible, +.workspace-document-toggle:focus-visible { + outline: 2px solid var(--card-accent, #94a3b8); + outline-offset: -2px; +} + .tool-header:disabled { cursor: default; } .tool-icon { display: grid; - width: 54px; - height: 54px; + width: 44px; + height: 44px; place-items: center; - border: 1px solid color-mix(in srgb, var(--tool-card-border) 72%, transparent); - border-radius: 13px; - background: var(--tool-card-icon-bg); - color: var(--color-text-primary, #f5f5f6); + border: 1px solid color-mix(in srgb, var(--card-accent) 38%, var(--tool-card-border)); + border-radius: 12px; + background: var(--card-accent-soft); + color: var(--card-accent-strong); } .icon-svg { - width: 22px; - height: 22px; display: block; + width: 21px; + height: 21px; stroke-width: 1.8; } @@ -103,7 +206,7 @@ body { .tool-title { color: var(--color-text-primary, #f5f5f6); font-size: var(--font-text-md-size, 16px); - font-weight: 500; + font-weight: 560; line-height: 1.25; } @@ -119,7 +222,7 @@ body { .stats { display: inline-flex; - gap: 6px; + gap: 7px; align-items: center; font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); font-size: var(--font-text-sm-size, 13px); @@ -128,15 +231,18 @@ body { } .header-meta { + max-width: min(34vw, 320px); + overflow: hidden; color: var(--color-text-secondary, #d6d6dc); font-size: var(--font-text-sm-size, 13px); line-height: 1.35; text-align: right; + text-overflow: ellipsis; white-space: nowrap; } .header-meta.empty { - width: 0; + display: none; } .add { @@ -149,8 +255,8 @@ body { .chevron { display: grid; - width: 24px; - height: 24px; + width: 20px; + height: 20px; place-items: center; border-radius: 7px; color: var(--color-text-tertiary, #a3a3aa); @@ -191,18 +297,24 @@ body { } @media (prefers-reduced-motion: reduce) { + .chevron, + .workspace-disclosure-chevron { + transition: none; + } + .chevron.loading .icon-svg { animation: none; } } -.tool-body { +.tool-body, +.review-summary { border-top: 1px solid var(--tool-card-divider); background: var(--tool-card-body-bg); } .review-header { - grid-template-columns: 54px minmax(0, 1fr) auto 24px; + grid-template-columns: 44px minmax(0, 1fr) auto 20px; } .review-title-group { @@ -211,12 +323,6 @@ body { gap: 3px; } -.review-summary { - display: grid; - border-top: 1px solid var(--tool-card-divider); - background: var(--tool-card-body-bg); -} - .review-diff-file-stats { display: flex; align-items: center; @@ -230,8 +336,8 @@ body { .review-more { width: 100%; - min-height: 44px; - padding: 0 16px; + min-height: 42px; + padding: 0 14px; border: 0; border-top: 1px solid var(--tool-card-divider); background: transparent; @@ -272,10 +378,11 @@ body { .review-diff-file-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; + align-items: center; + gap: 12px; width: 100%; - min-height: 50px; - padding: 0 16px; + min-height: 52px; + padding: 8px 14px; border: 0; background: transparent; color: var(--color-text-primary, #f5f5f6); @@ -288,16 +395,53 @@ body { background: var(--tool-card-hover-bg); } -.review-diff-file-name, -.review-diff-file-stats { +.review-diff-file-main { + display: flex; + min-width: 0; + align-items: center; + gap: 9px; +} + +.review-diff-file-operation { + display: grid; + flex: 0 0 auto; + width: 20px; + height: 20px; + place-items: center; + border-radius: 6px; + background: var(--card-accent-soft, rgb(148 163 184 / 12%)); + color: var(--card-accent-strong, #e2e8f0); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 14px; + font-weight: 700; + line-height: 1; +} + +.review-diff-file-operation.add { + color: var(--color-success-text, #6fda83); +} + +.review-diff-file-operation.delete { + color: var(--color-danger-text, #ee7676); +} + +.review-diff-file-operation.move { + color: #c4b5fd; +} + +.review-diff-file-name { + min-width: 0; overflow: hidden; + font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); font-size: var(--font-text-sm-size, 14px); text-overflow: ellipsis; white-space: nowrap; } -.review-diff-file-name { - font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); +.review-diff-file-previous { + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 12px; } .review-diff-file-stats { @@ -306,6 +450,22 @@ body { font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); font-size: var(--font-text-sm-size, 13px); font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.review-diff-file-chevron { + color: var(--color-text-tertiary, #a3a3aa); + font-size: 18px; + line-height: 1; + transition: transform 140ms ease, color 140ms ease; +} + +.review-diff-file-header:hover .review-diff-file-chevron { + color: var(--color-text-primary, #f5f5f6); +} + +.review-diff-file-header[aria-expanded="true"] .review-diff-file-chevron { + transform: rotate(90deg); } .status { @@ -317,10 +477,6 @@ body { color: var(--color-text-secondary, #b7b7bf); } -.status.error { - color: var(--color-danger-text, #ee7676); -} - .pierre-diff, .pierre-file { --diffs-bg: var(--tool-payload-bg, var(--color-background-primary, #101114)); @@ -355,18 +511,220 @@ body { background: var(--color-background-primary, #101114); } +.workspace-disclosures { + --workspace-inline-padding: 14px; + display: grid; + padding: 0 0 6px; +} + +.workspace-info-value, +.workspace-document { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); +} + +.workspace-info-row { + display: grid; + grid-template-columns: 18px auto minmax(0, 1fr); + align-items: center; + gap: 8px; + min-width: 0; + min-height: 24px; +} + +.workspace-info-icon { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + color: var(--card-accent, #a8b3c2); + width: 18px; + height: 18px; +} + +.workspace-info-icon .icon-svg { + width: 15px; + height: 15px; +} + +.workspace-info-label { + color: var(--color-text-tertiary, #a3a3aa); + font-size: 12px; + white-space: nowrap; +} + +.workspace-info-value-group { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; + overflow: hidden; +} + +.workspace-info-value { + flex: 0 1 auto; + max-width: 100%; + min-width: 0; + overflow: hidden; + color: var(--color-text-secondary, #d6d6dc); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-disclosure { + border-top: 1px solid var(--tool-card-divider); +} + +.workspace-disclosure-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto 20px; + align-items: center; + gap: 10px; + width: 100%; + min-height: 42px; + padding: 4px var(--workspace-inline-padding); + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; +} + +.workspace-disclosure-summary:hover { + background: var(--tool-card-hover-bg); +} + +.workspace-disclosure-title { + color: var(--color-text-primary, #f5f5f6); + font-size: 13px; + font-weight: 550; +} + +.workspace-disclosure-count { + overflow: hidden; + color: var(--color-text-tertiary, #a3a3aa); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-disclosure-chevron { + display: grid; + width: 20px; + height: 20px; + place-items: center; + color: var(--color-text-tertiary, #a3a3aa); + transition: transform 140ms ease, color 140ms ease; +} + +.workspace-disclosure-chevron .icon-svg { + width: 16px; + height: 16px; +} + +.workspace-disclosure.open .workspace-disclosure-chevron { + transform: rotate(90deg); + color: var(--card-accent, #a8b3c2); +} + +.workspace-disclosure-body { + min-width: 0; + padding: 0 var(--workspace-inline-padding) 10px; +} + +.workspace-disclosure-body[hidden] { + display: none; +} + +.workspace-file-list, +.workspace-chip-list, +.workspace-agent-list { + max-height: 280px; + overflow: auto; +} + +.workspace-file-item + .workspace-file-item, +.workspace-agent-list .workspace-info-row + .workspace-info-row { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--tool-card-divider); +} + +.workspace-document-toggle { + flex: 0 0 auto; + align-self: center; + padding: 3px 0; + border: 0; + background: transparent; + color: var(--card-accent-strong, #e2e8f0); + cursor: pointer; + font: inherit; + font-size: 12px; + text-align: left; + white-space: nowrap; +} + +.workspace-document-toggle:hover { + color: var(--color-text-primary, #f5f5f6); +} + +.workspace-document-toggle[aria-expanded="true"] { + color: var(--card-accent, #a8b3c2); +} + +.workspace-document[hidden] { + display: none; +} + +.workspace-document { + margin: 4px 0 0 26px; + border-left: 2px solid color-mix(in srgb, var(--card-accent) 30%, var(--tool-card-divider)); + overflow: auto; + padding: 8px 10px; + border-radius: 7px; + background: var(--color-background-primary, #101114); + color: var(--color-text-secondary, #d6d6dc); + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.workspace-chip-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.workspace-chip { + max-width: 100%; + overflow: hidden; + padding: 5px 8px; + border: 1px solid color-mix(in srgb, var(--card-accent) 24%, var(--tool-card-divider)); + border-radius: 7px; + background: var(--card-accent-soft); + color: var(--color-text-secondary, #d6d6dc); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + @media (max-width: 520px) { .tool-header { - grid-template-columns: 42px minmax(0, 1fr) auto 18px; + grid-template-columns: 40px minmax(0, 1fr) auto 18px; gap: 10px; - min-height: 68px; - padding: 10px 12px; + min-height: 66px; + padding: 9px 11px; } .tool-icon { - width: 42px; - height: 42px; - border-radius: 10px; + width: 40px; + height: 40px; + border-radius: 11px; + } + + .header-meta { + max-width: 140px; } .chevron { @@ -375,6 +733,20 @@ body { } .review-header { - grid-template-columns: 42px minmax(0, 1fr) auto 18px; + grid-template-columns: 40px minmax(0, 1fr) auto 18px; + } + + .review-diff-file-header { + gap: 8px; + padding-right: 10px; + padding-left: 10px; + } + + .review-diff-file-previous { + display: none; + } + + .workspace-disclosures { + --workspace-inline-padding: 11px; } } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index da32cd32..207c942b 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -11,20 +11,27 @@ import { isPatchTool, isReadTool, isReviewTool, + shouldAutoExpandCard, isToolName, isToolResultCard, isWriteTool, payloadText, + summaryNumber, type HostContext, type ToolName, type ToolResultCard, } from "./card-types.js"; -import { renderIcon, toolIcons } from "./icons.js"; +import { renderIcon, toolIcons, type ToolIcon } from "./icons.js"; import { getToolDisplay, getToolHeaderSummary, type ToolDisplay, } from "./tool-display.js"; +import { + toggleWorkspaceDisclosure, + toggleWorkspaceDocument, + type WorkspaceDisclosureKey, +} from "./workspace-disclosures.js"; import "./workspace-app.css"; interface MountedPayload { @@ -47,6 +54,8 @@ let reviewFilesExpanded = false; let errorMessage: string | null = null; let currentPayload: MountedPayload | null = null; let currentPayloadContainer: HTMLElement | null = null; +let openWorkspaceDisclosures = new Set(); +let openWorkspaceDocuments = new Set(); const maybeAppRoot = document.querySelector("#app"); @@ -67,11 +76,7 @@ async function boot(): Promise { ); app.ontoolresult = (result) => { - const structuredContent = getStructuredContent>(result); - const metaCard = cardFromMeta(result); - const structured = metaCard - ? { ...structuredContent, ...metaCard } - : structuredContent; + const structured = cardForUi(result); const tool = toolNameFromMeta(result); if (!tool || !isToolResultCard(structured)) { @@ -84,8 +89,12 @@ async function boot(): Promise { } const nextCard = { ...structured, tool }; + if (!isSameWorkspaceCard(card, nextCard)) { + openWorkspaceDisclosures = new Set(); + openWorkspaceDocuments = new Set(); + } card = nextCard; - expanded = isReviewTool(tool) && isExpandableCard(nextCard); + expanded = shouldAutoExpandCard(nextCard); reviewFilesExpanded = false; errorMessage = null; render(); @@ -161,7 +170,15 @@ function render(): void { const expandable = isExpandableCard(card); const main = element("main", { className: "shell" }); - const section = element("section", { className: `tool-card ${display.tone}` }); + const stateClass = cardStateClass(card); + const section = element("section", { + className: [ + "tool-card", + display.tone, + card.mode === "worktree" ? "worktree" : undefined, + stateClass, + ].filter(Boolean).join(" "), + }); const button = element("button", { className: "tool-header", type: "button", @@ -177,6 +194,11 @@ function render(): void { } const icon = element("span", { className: "tool-icon", ariaHidden: "true" }); + if (display.iconLabel) { + icon.setAttribute("role", "img"); + icon.setAttribute("aria-label", display.iconLabel); + icon.removeAttribute("aria-hidden"); + } icon.append(renderIcon(display.icon)); const toolMain = element("span", { className: "tool-main" }); @@ -215,6 +237,16 @@ function renderEmpty(message: string, tone: "muted" | "error" = "muted"): void { appRoot.replaceChildren(main); } +function cardStateClass(card: ToolResultCard): "running" | "failed" | undefined { + if (card.tool !== "bash" && card.tool !== "exec_command" && card.tool !== "write_stdin") { + return undefined; + } + if (card.summary?.running === true) return "running"; + + const exitCode = summaryNumber(card.summary, "exitCode"); + return exitCode !== undefined && exitCode !== 0 ? "failed" : undefined; +} + async function renderPayloadIfNeeded(): Promise { if (!card || !currentPayloadContainer || !expanded) return; @@ -226,7 +258,7 @@ async function renderPayloadIfNeeded(): Promise { } if (card.tool === "open_workspace") { - renderPrePayload(target, workspacePayloadText(card), "open_workspace"); + renderWorkspacePayload(target, card); return; } @@ -324,7 +356,10 @@ function renderPrePayload( tool: string, ): void { unmountCurrentPayload(); - container.replaceChildren(element("pre", { className: `text-payload ${tool}`, text })); + container.replaceChildren(element("pre", { + className: `text-payload pretty-scrollbar ${tool}`, + text, + })); } function renderHeaderSummary(card: ToolResultCard): HTMLElement { @@ -445,94 +480,213 @@ function setPayloadLoading(container: HTMLElement, loading: boolean): void { if (button) button.setAttribute("aria-busy", String(loading)); } -function workspacePayloadText(card: ToolResultCard): string { +function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): void { + unmountCurrentPayload(); + + const details = element("div", { className: "workspace-disclosures" }); + const agentsFiles = card.agentsFiles ?? []; const availableAgentsFiles = card.availableAgentsFiles ?? []; + if (agentsFiles.length > 0 || availableAgentsFiles.length > 0) { + details.append(renderWorkspaceInstructions(agentsFiles, availableAgentsFiles)); + } + const skills = card.skills ?? []; - const agentProviders = card.agentProviders ?? []; + if (skills.length > 0) { + details.append(renderWorkspaceSkills(skills)); + } + const agents = card.agents ?? []; - const diagnostics = card.skillDiagnostics ?? []; - const lines = [ - card.workspaceId ? `Workspace: ${card.workspaceId}` : undefined, - card.root ? `Root: ${card.root}` : undefined, - card.mode ? `Mode: ${card.mode}` : undefined, - card.sourceRoot ? `Source root: ${card.sourceRoot}` : undefined, - card.worktree ? formatWorktree(card.worktree) : undefined, - skills.length > 0 - ? `Skills: ${skills.map((skill) => skill.name ?? skill.path ?? "unnamed").join(", ")}` - : "Skills: none", - agentProviders.length > 0 - ? `Agent providers: ${agentProviders.map(formatAgentProvider).join(", ")}` - : undefined, - agents.length > 0 - ? `Agents: ${agents.map(formatAgent).join(", ")}` - : undefined, - diagnostics.length > 0 - ? `Skill diagnostics: ${diagnostics.map(formatDiagnostic).join("; ")}` - : undefined, - availableAgentsFiles.length > 0 - ? `Nested instructions: ${availableAgentsFiles.map((file) => file.path ?? "unknown").join(", ")}` - : undefined, - agentsFiles.length > 0 - ? `\n${formatAgentsFilesForPayload(agentsFiles)}` - : "\nAGENTS.md: none loaded", - card.instruction ? `\nInstruction: ${card.instruction}` : undefined, - ].filter((line): line is string => typeof line === "string"); - - return lines.join("\n"); + const providers = card.agentProviders ?? []; + if (agents.length > 0 || providers.length > 0) { + details.append(renderWorkspaceAgents(agents, providers)); + } + + container.replaceChildren(details); } -function formatWorktree(worktree: NonNullable): string { - const details = [ - worktree.baseRef ? `base ${worktree.baseRef}` : undefined, - worktree.baseSha ? `at ${worktree.baseSha.slice(0, 12)}` : undefined, - worktree.managed === true ? "managed" : undefined, - worktree.detached === true ? "detached" : undefined, - worktree.dirtySource === true ? "dirty source" : undefined, - ].filter((detail): detail is string => Boolean(detail)); - return `Worktree: ${worktree.path ?? "unknown"}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; +function renderWorkspaceInfoRow( + icon: ToolIcon, + label: string, + value: string, + action?: HTMLElement, +): HTMLElement { + const row = element("div", { + className: "workspace-info-row", + }); + const iconNode = element("span", { className: "workspace-info-icon", ariaHidden: "true" }); + iconNode.append(renderIcon(icon)); + const valueGroup = element("span", { className: "workspace-info-value-group" }); + valueGroup.append(element("code", { className: "workspace-info-value", text: value, title: value })); + if (action) valueGroup.append(action); + row.append( + iconNode, + element("span", { className: "workspace-info-label", text: label }), + valueGroup, + ); + return row; } -function formatAgentProvider( - provider: NonNullable[number], -): string { - const name = provider.name ?? "unknown"; - if (provider.available !== false) return name; - return provider.reason ? `${name} unavailable: ${provider.reason}` : `${name} unavailable`; +function renderWorkspaceInstructions( + loaded: NonNullable, + available: NonNullable, +): HTMLElement { + const body = element("div", { className: "workspace-disclosure-body" }); + const fileList = element("div", { className: "workspace-file-list pretty-scrollbar" }); + + for (const [index, file] of loaded.entries()) { + const item = element("div", { className: "workspace-file-item" }); + const path = file.path ?? "AGENTS.md"; + const content = file.content?.trim(); + if (content) { + const documentKey = `${index}:${path}`; + const documentId = workspaceDocumentId(path, index); + const open = openWorkspaceDocuments.has(documentKey); + const toggle = element("button", { + className: "workspace-document-toggle", + type: "button", + text: "View", + ariaExpanded: String(open), + }); + toggle.setAttribute("aria-controls", documentId); + const pre = element("pre", { className: "workspace-document pretty-scrollbar", text: content }); + pre.id = documentId; + pre.hidden = !open; + pre.setAttribute("role", "region"); + toggle.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + openWorkspaceDocuments = toggleWorkspaceDocument(openWorkspaceDocuments, documentKey); + const nextOpen = openWorkspaceDocuments.has(documentKey); + toggle.setAttribute("aria-expanded", String(nextOpen)); + pre.hidden = !nextOpen; + }); + item.append( + renderWorkspaceInfoRow(toolIcons.instructions, "Loaded", path, toggle), + pre, + ); + } else { + item.append(renderWorkspaceInfoRow(toolIcons.instructions, "Loaded", path)); + } + fileList.append(item); + } + + for (const file of available) { + fileList.append(renderWorkspaceInfoRow( + toolIcons.instructions, + "Available", + file.path ?? "AGENTS.md", + )); + } + + body.append(fileList); + return renderWorkspaceDisclosure( + "Instructions", + `${loaded.length} loaded${available.length > 0 ? ` · ${available.length} available` : ""}`, + body, + "instructions", + ); } -function formatAgent(agent: NonNullable[number]): string { - const details = [ - agent.provider, - agent.model, - agent.thinking ? `thinking ${agent.thinking}` : undefined, - agent.providerAvailable === false - ? agent.providerUnavailableReason ?? "provider unavailable" - : undefined, - ].filter((detail): detail is string => Boolean(detail)); - return `${agent.name ?? "unnamed"}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; +function workspaceDocumentId(path: string, index: number): string { + const safePath = path.replace(/[^a-zA-Z0-9_-]+/g, "-"); + return `workspace-document-${index}-${safePath}`; } -function formatDiagnostic(diagnostic: unknown): string { - if (typeof diagnostic === "string") return diagnostic; - if (diagnostic instanceof Error) return diagnostic.message; - try { - return JSON.stringify(diagnostic) ?? String(diagnostic); - } catch { - return String(diagnostic); +function renderWorkspaceSkills( + skills: NonNullable, +): HTMLElement { + const body = element("div", { className: "workspace-disclosure-body" }); + const list = element("div", { className: "workspace-chip-list pretty-scrollbar" }); + for (const skill of skills) { + const name = skill.name ?? skill.path ?? "Unnamed skill"; + list.append(element("span", { className: "workspace-chip", text: name, title: skill.description ?? name })); + } + body.append(list); + return renderWorkspaceDisclosure("Skills", `${skills.length} available`, body, "skills"); +} + +function renderWorkspaceAgents( + agents: NonNullable, + providers: NonNullable, +): HTMLElement { + const body = element("div", { className: "workspace-disclosure-body workspace-agent-list" }); + for (const provider of providers) { + body.append(renderWorkspaceInfoRow( + provider.available === false ? toolIcons.alert : toolIcons.check, + "Provider", + provider.name ?? "Unknown provider", + )); + } + for (const agent of agents) { + body.append(renderWorkspaceInfoRow( + agent.providerAvailable === false ? toolIcons.alert : toolIcons.agents, + "Agent", + agent.name ?? "Unnamed agent", + )); } + return renderWorkspaceDisclosure( + "Agents", + `${agents.length} agents · ${providers.length} providers`, + body, + "agents", + ); +} + +function renderWorkspaceDisclosure( + title: string, + summaryText: string, + body: HTMLElement, + key: WorkspaceDisclosureKey, +): HTMLElement { + const open = openWorkspaceDisclosures.has(key); + const disclosure = element("section", { + className: `workspace-disclosure${open ? " open" : ""}`, + }); + const summary = element("button", { + className: "workspace-disclosure-summary", + type: "button", + ariaExpanded: String(open), + }); + const bodyId = `workspace-disclosure-${key}`; + body.id = bodyId; + body.hidden = !open; + body.setAttribute("role", "region"); + summary.setAttribute("aria-controls", bodyId); + const chevron = element("span", { className: "workspace-disclosure-chevron", ariaHidden: "true" }); + chevron.append(renderIcon(toolIcons.chevronRight)); + summary.append( + element("span", { className: "workspace-disclosure-title", text: title }), + element("span", { className: "workspace-disclosure-count", text: summaryText }), + chevron, + ); + summary.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + openWorkspaceDisclosures = toggleWorkspaceDisclosure(openWorkspaceDisclosures, key); + const nextOpen = openWorkspaceDisclosures.has(key); + disclosure.classList.toggle("open", nextOpen); + summary.setAttribute("aria-expanded", String(nextOpen)); + body.hidden = !nextOpen; + }); + disclosure.append(summary, body); + return disclosure; +} + +function workspaceCardIdentity( + value: { tool?: ToolName; workspaceId?: string; root?: string; path?: string } | null, +): string | undefined { + if (value?.tool !== "open_workspace") return undefined; + return value.workspaceId ?? value.root ?? value.path; } -function formatAgentsFilesForPayload( - agentsFiles: NonNullable, -): string { - return agentsFiles - .map((file) => { - const path = file.path ?? "AGENTS.md"; - const content = file.content?.trim(); - return content ? `${path}\n\n${content}` : `${path}\n\nNo content loaded.`; - }) - .join("\n\n"); +function isSameWorkspaceCard( + previous: { tool?: ToolName; workspaceId?: string; root?: string; path?: string } | null, + next: { tool?: ToolName; workspaceId?: string; root?: string; path?: string }, +): boolean { + const previousIdentity = workspaceCardIdentity(previous); + const nextIdentity = workspaceCardIdentity(next); + return previousIdentity !== undefined && previousIdentity === nextIdentity; } function toolNameFromMeta(result: CallToolResult): ToolName | undefined { @@ -544,13 +698,37 @@ function toolNameFromMeta(result: CallToolResult): ToolName | undefined { function cardFromMeta(result: CallToolResult): Partial | undefined { const meta = result._meta as Record | undefined; const metaCard = meta?.card; - return metaCard && typeof metaCard === "object" ? metaCard : undefined; + return metaCard && typeof metaCard === "object" + ? metaCard as Partial + : undefined; } function getStructuredContent(result: CallToolResult): T | undefined { return result.structuredContent as T | undefined; } +function cardForUi(result: CallToolResult): Partial | undefined { + // Model-facing workspace context is intentionally kept out of the card + // projection. In particular, diagnostics and instructions can be large or + // actionable for the model but are not user-facing card content. + const source = cardFromMeta(result) ?? getStructuredContent>(result); + if (!source) return undefined; + + const uiCard = { ...source } as Partial & Record; + delete uiCard.skillDiagnostics; + delete uiCard.diagnostics; + delete uiCard.instruction; + + if (uiCard.summary && typeof uiCard.summary === "object") { + const summary = { ...uiCard.summary } as Record; + delete summary.skillDiagnostics; + delete summary.diagnostics; + uiCard.summary = summary; + } + + return uiCard; +} + function element( tag: K, options: { @@ -575,4 +753,3 @@ function element( } return node; } - diff --git a/src/ui/workspace-disclosures.test.ts b/src/ui/workspace-disclosures.test.ts new file mode 100644 index 00000000..dbaf1b4c --- /dev/null +++ b/src/ui/workspace-disclosures.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + toggleWorkspaceDisclosure, + toggleWorkspaceDocument, +} from "./workspace-disclosures.js"; + +test("workspace disclosure state toggles one section without losing other open sections", () => { + let open = new Set<"instructions" | "skills" | "agents">(["skills"]); + + open = toggleWorkspaceDisclosure(open, "instructions"); + assert.deepEqual([...open].sort(), ["instructions", "skills"]); + + // Rendering a fresh DOM uses this state, so a host refresh cannot close an + // already-open section. + open = toggleWorkspaceDisclosure(open, "skills"); + assert.deepEqual([...open], ["instructions"]); +}); + +test("workspace document state toggles independently for each file", () => { + let open = new Set(["0:/project/AGENTS.md"]); + + open = toggleWorkspaceDocument(open, "1:/project/src/AGENTS.md"); + assert.deepEqual([...open].sort(), ["0:/project/AGENTS.md", "1:/project/src/AGENTS.md"]); + + open = toggleWorkspaceDocument(open, "0:/project/AGENTS.md"); + assert.deepEqual([...open], ["1:/project/src/AGENTS.md"]); +}); diff --git a/src/ui/workspace-disclosures.ts b/src/ui/workspace-disclosures.ts new file mode 100644 index 00000000..3f671aef --- /dev/null +++ b/src/ui/workspace-disclosures.ts @@ -0,0 +1,27 @@ +export type WorkspaceDisclosureKey = "instructions" | "skills" | "agents"; + +export function toggleWorkspaceDisclosure( + open: ReadonlySet, + key: WorkspaceDisclosureKey, +): Set { + const next = new Set(open); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; +} + +export function toggleWorkspaceDocument( + open: ReadonlySet, + key: string, +): Set { + const next = new Set(open); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; +} From 7bb05f89f2a507d2b04264d59fe49eeb26fb4242 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Thu, 6 Aug 2026 14:56:00 +0530 Subject: [PATCH 2/3] fix(ui): tighten workspace card state boundaries --- src/ui/workspace-app.css | 5 +++-- src/ui/workspace-app.tsx | 17 +---------------- src/ui/workspace-disclosures.test.ts | 25 +++++++++++++++++++++++++ src/ui/workspace-disclosures.ts | 18 ++++++++++++++++++ 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 5f748be4..fa907261 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -503,7 +503,7 @@ body { font-size: var(--font-text-sm-size, 12px); line-height: 1.55; white-space: pre-wrap; - word-break: break-word; + overflow-wrap: anywhere; } .text-payload.bash { @@ -513,6 +513,7 @@ body { .workspace-disclosures { --workspace-inline-padding: 14px; + display: grid; padding: 0 0 6px; } @@ -686,7 +687,7 @@ body { font-size: 11px; line-height: 1.5; white-space: pre-wrap; - word-break: break-word; + overflow-wrap: anywhere; } .workspace-chip-list { diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 207c942b..30382811 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -28,6 +28,7 @@ import { type ToolDisplay, } from "./tool-display.js"; import { + isSameWorkspaceCard, toggleWorkspaceDisclosure, toggleWorkspaceDocument, type WorkspaceDisclosureKey, @@ -673,22 +674,6 @@ function renderWorkspaceDisclosure( return disclosure; } -function workspaceCardIdentity( - value: { tool?: ToolName; workspaceId?: string; root?: string; path?: string } | null, -): string | undefined { - if (value?.tool !== "open_workspace") return undefined; - return value.workspaceId ?? value.root ?? value.path; -} - -function isSameWorkspaceCard( - previous: { tool?: ToolName; workspaceId?: string; root?: string; path?: string } | null, - next: { tool?: ToolName; workspaceId?: string; root?: string; path?: string }, -): boolean { - const previousIdentity = workspaceCardIdentity(previous); - const nextIdentity = workspaceCardIdentity(next); - return previousIdentity !== undefined && previousIdentity === nextIdentity; -} - function toolNameFromMeta(result: CallToolResult): ToolName | undefined { const meta = result._meta as Record | undefined; const tool = meta?.tool; diff --git a/src/ui/workspace-disclosures.test.ts b/src/ui/workspace-disclosures.test.ts index dbaf1b4c..2f690998 100644 --- a/src/ui/workspace-disclosures.test.ts +++ b/src/ui/workspace-disclosures.test.ts @@ -1,10 +1,35 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + isSameWorkspaceCard, toggleWorkspaceDisclosure, toggleWorkspaceDocument, } from "./workspace-disclosures.js"; +test("workspace card state is keyed only by the opaque workspace id", () => { + assert.equal( + isSameWorkspaceCard( + { tool: "open_workspace", workspaceId: "ws_same", root: "/tmp/project" }, + { tool: "open_workspace", workspaceId: "ws_same", root: "/tmp/project" }, + ), + true, + ); + assert.equal( + isSameWorkspaceCard( + { tool: "open_workspace", root: "/tmp/project" }, + { tool: "open_workspace", root: "/tmp/project" }, + ), + false, + ); + assert.equal( + isSameWorkspaceCard( + { tool: "open_workspace", workspaceId: "ws_one", path: "/tmp/project" }, + { tool: "open_workspace", workspaceId: "ws_two", path: "/tmp/project" }, + ), + false, + ); +}); + test("workspace disclosure state toggles one section without losing other open sections", () => { let open = new Set<"instructions" | "skills" | "agents">(["skills"]); diff --git a/src/ui/workspace-disclosures.ts b/src/ui/workspace-disclosures.ts index 3f671aef..839410a1 100644 --- a/src/ui/workspace-disclosures.ts +++ b/src/ui/workspace-disclosures.ts @@ -1,5 +1,23 @@ +import type { ToolName } from "./card-types.js"; + export type WorkspaceDisclosureKey = "instructions" | "skills" | "agents"; +export interface WorkspaceCardIdentityInput { + tool?: ToolName; + workspaceId?: string; + root?: string; + path?: string; +} + +export function isSameWorkspaceCard( + previous: WorkspaceCardIdentityInput | null, + next: WorkspaceCardIdentityInput, +): boolean { + const previousId = previous?.tool === "open_workspace" ? previous.workspaceId : undefined; + const nextId = next.tool === "open_workspace" ? next.workspaceId : undefined; + return previousId !== undefined && previousId === nextId; +} + export function toggleWorkspaceDisclosure( open: ReadonlySet, key: WorkspaceDisclosureKey, From 8b755da36fe35e07c320b3469ccb812afa134df5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Thu, 6 Aug 2026 15:02:20 +0530 Subject: [PATCH 3/3] test(ui): strengthen workspace disclosure coverage --- src/ui/workspace-disclosures.test.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/ui/workspace-disclosures.test.ts b/src/ui/workspace-disclosures.test.ts index 2f690998..81fe2305 100644 --- a/src/ui/workspace-disclosures.test.ts +++ b/src/ui/workspace-disclosures.test.ts @@ -14,6 +14,13 @@ test("workspace card state is keyed only by the opaque workspace id", () => { ), true, ); + assert.equal( + isSameWorkspaceCard( + { tool: "open_workspace", workspaceId: "ws_same", root: "/tmp/root-a", path: "/tmp/path-a" }, + { tool: "open_workspace", workspaceId: "ws_same", root: "/tmp/root-b", path: "/tmp/path-b" }, + ), + true, + ); assert.equal( isSameWorkspaceCard( { tool: "open_workspace", root: "/tmp/project" }, @@ -33,21 +40,33 @@ test("workspace card state is keyed only by the opaque workspace id", () => { test("workspace disclosure state toggles one section without losing other open sections", () => { let open = new Set<"instructions" | "skills" | "agents">(["skills"]); - open = toggleWorkspaceDisclosure(open, "instructions"); + const nextWithInstructions = toggleWorkspaceDisclosure(open, "instructions"); + assert.notStrictEqual(nextWithInstructions, open); + assert.deepEqual([...open], ["skills"]); + open = nextWithInstructions; assert.deepEqual([...open].sort(), ["instructions", "skills"]); // Rendering a fresh DOM uses this state, so a host refresh cannot close an // already-open section. - open = toggleWorkspaceDisclosure(open, "skills"); + const nextWithoutSkills = toggleWorkspaceDisclosure(open, "skills"); + assert.notStrictEqual(nextWithoutSkills, open); + assert.deepEqual([...open].sort(), ["instructions", "skills"]); + open = nextWithoutSkills; assert.deepEqual([...open], ["instructions"]); }); test("workspace document state toggles independently for each file", () => { let open = new Set(["0:/project/AGENTS.md"]); - open = toggleWorkspaceDocument(open, "1:/project/src/AGENTS.md"); + const nextWithNestedFile = toggleWorkspaceDocument(open, "1:/project/src/AGENTS.md"); + assert.notStrictEqual(nextWithNestedFile, open); + assert.deepEqual([...open], ["0:/project/AGENTS.md"]); + open = nextWithNestedFile; assert.deepEqual([...open].sort(), ["0:/project/AGENTS.md", "1:/project/src/AGENTS.md"]); - open = toggleWorkspaceDocument(open, "0:/project/AGENTS.md"); + const nextWithoutRootFile = toggleWorkspaceDocument(open, "0:/project/AGENTS.md"); + assert.notStrictEqual(nextWithoutRootFile, open); + assert.deepEqual([...open].sort(), ["0:/project/AGENTS.md", "1:/project/src/AGENTS.md"]); + open = nextWithoutRootFile; assert.deepEqual([...open], ["1:/project/src/AGENTS.md"]); });