From 115721e330b579d24b09dba73d98477b6ab3c1f0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 12:20:06 +0530 Subject: [PATCH 01/24] fix(ui): normalize tool card change titles --- src/ui/card-types.ts | 8 ++- src/ui/icons.ts | 2 + src/ui/patch-display.test.ts | 39 ++++++++++++++- src/ui/patch-display.ts | 95 ++++++++++++++++++++++++++---------- src/ui/tool-display.test.ts | 28 ++++++++--- src/ui/tool-display.ts | 33 ++++++++----- 6 files changed, 158 insertions(+), 47 deletions(-) diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 596e2e84..dc25b401 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -17,6 +17,12 @@ export type ToolName = export type HostContext = NonNullable>; export type PatchOperation = "add" | "update" | "delete" | "move"; +export type ReviewFileType = + | "change" + | "rename-pure" + | "rename-changed" + | "new" + | "deleted"; export interface ToolResultCard { tool: ToolName; @@ -41,7 +47,7 @@ export interface ToolResultCard { path?: string; previousPath?: string; operation?: PatchOperation; - type?: string; + type?: ReviewFileType; additions?: number; removals?: number; }>; diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 22f86002..37fecf7b 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -8,6 +8,7 @@ import { Files, FolderOpen, FolderTree, + GitBranch, LoaderCircle, Search, SquareTerminal, @@ -24,6 +25,7 @@ export const toolIcons = { files: Files, folderOpen: FolderOpen, folderTree: FolderTree, + gitBranch: GitBranch, loading: LoaderCircle, readFile: FileText, search: Search, diff --git a/src/ui/patch-display.test.ts b/src/ui/patch-display.test.ts index ea1b268b..af290111 100644 --- a/src/ui/patch-display.test.ts +++ b/src/ui/patch-display.test.ts @@ -10,7 +10,7 @@ assert.deepEqual( getPatchDisplayParts({ files: [{ path: "created.ts", operation: "add" }] }), { title: "Added 1 file", - iconOperation: "add", + iconKind: "added", tone: "write", }, ); @@ -24,11 +24,46 @@ assert.deepEqual( }), { title: "Added 2 files", - iconOperation: "add", + iconKind: "added", tone: "write", }, ); +assert.deepEqual( + getPatchDisplayParts({ files: [{ path: "created.ts", type: "new" }] }), + { + title: "Added 1 file", + iconKind: "added", + tone: "write", + }, +); + +assert.deepEqual( + getPatchDisplayParts({ files: [{ path: "renamed.ts", type: "rename-changed" }] }), + { + title: "Renamed and edited 1 file", + iconKind: "renamed-edited", + tone: "edit", + }, +); + +assert.deepEqual( + getPatchDisplayParts({ files: [{ path: "removed.ts", type: "deleted" }] }), + { + title: "Deleted 1 file", + iconKind: "deleted", + tone: "delete", + }, +); + +assert.deepEqual( + getPatchDisplayParts({ files: [{ path: "unknown.ts" }] }), + { + title: "Changed 1 file", + tone: "edit", + }, +); + assert.deepEqual( getPatchDisplayParts({ files: [ diff --git a/src/ui/patch-display.ts b/src/ui/patch-display.ts index cd8f7791..c4bc59de 100644 --- a/src/ui/patch-display.ts +++ b/src/ui/patch-display.ts @@ -1,42 +1,81 @@ -import type { PatchOperation, ToolResultCard } from "./card-types.js"; +import type { ToolResultCard } from "./card-types.js"; + +export type FileChangeKind = + | "added" + | "edited" + | "deleted" + | "renamed" + | "renamed-edited" + | "unknown"; + +type ToolResultFile = NonNullable[number]; export interface PatchDisplayParts { title: string; - iconOperation?: PatchOperation; - tone: "edit" | "write"; + iconKind?: FileChangeKind; + tone: "edit" | "write" | "delete"; } -const patchOperationLabels: Record = { - add: "Added", - update: "Edited", - delete: "Deleted", - move: "Moved", +const fileChangeLabels: Record, string> = { + added: "Added", + edited: "Edited", + deleted: "Deleted", + renamed: "Renamed", + "renamed-edited": "Renamed and edited", }; -export function getPatchDisplayParts(card: Pick): PatchDisplayParts { +export function getPatchDisplayParts( + card: Pick, + options: { emptyTitle?: string } = {}, +): PatchDisplayParts { const files = card.files ?? []; - const operations = patchOperations(files); + const fileCount = countChangedFiles(files); - if (operations.length === 0) { - return { title: "Applied patch", tone: "edit" }; + if (fileCount === 0) { + return { title: options.emptyTitle ?? "Applied patch", tone: "edit" }; } - const singleOperation = operations.length === 1 ? operations[0] : undefined; - + const kinds = new Set(files.map(getFileChangeKind)); + const singleKind = kinds.size === 1 ? [...kinds][0] : undefined; const display: PatchDisplayParts = { - title: patchTitle(operations, countChangedFiles(files)), - tone: singleOperation === "add" ? "write" : "edit", + title: changeTitle(singleKind, fileCount), + tone: changeTone(singleKind), }; - if (singleOperation) display.iconOperation = singleOperation; + + if (singleKind && singleKind !== "unknown") display.iconKind = singleKind; return display; } -function patchOperations(files: NonNullable): PatchOperation[] { - const operations = new Set(); - for (const file of files) { - if (file.operation) operations.add(file.operation); +export function getFileChangeKind(file: ToolResultFile): FileChangeKind { + switch (file.operation) { + case "add": + return "added"; + case "update": + return "edited"; + case "delete": + return "deleted"; + case "move": + return "renamed"; } - return [...operations]; + + switch (file.type) { + case "new": + return "added"; + case "change": + return "edited"; + case "deleted": + return "deleted"; + case "rename-pure": + return "renamed"; + case "rename-changed": + return "renamed-edited"; + default: + return "unknown"; + } +} + +export function fileChangeKindLabel(kind: FileChangeKind): string { + return kind === "unknown" ? "Changed" : fileChangeLabels[kind]; } function countChangedFiles(files: NonNullable): number { @@ -55,14 +94,20 @@ function countChangedFiles(files: NonNullable): number return paths.size + unnamedFiles; } -function patchTitle(operations: PatchOperation[], fileCount: number): string { - if (operations.length === 1) { - return `${patchOperationLabels[operations[0]]} ${fileCount} ${fileNoun(fileCount)}`; +function changeTitle(kind: FileChangeKind | undefined, fileCount: number): string { + if (kind && kind !== "unknown") { + return `${fileChangeLabels[kind]} ${fileCount} ${fileNoun(fileCount)}`; } return `Changed ${fileCount} ${fileNoun(fileCount)}`; } +function changeTone(kind: FileChangeKind | undefined): PatchDisplayParts["tone"] { + if (kind === "added") return "write"; + if (kind === "deleted") return "delete"; + return "edit"; +} + function fileNoun(fileCount: number): "file" | "files" { return fileCount === 1 ? "file" : "files"; } diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index 86b67271..d1a30d52 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -4,9 +4,10 @@ import { toolIcons } from "./icons.js"; 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" }, { title: "Opened checkout", tone: "workspace" }], + [{ tool: "open_workspace", root: "/tmp/project", workspaceReused: true }, { title: "Reused checkout", tone: "workspace" }], [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree" }, { title: "Opened worktree", tone: "workspace" }], + [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree", workspaceReused: true }, { title: "Reused worktree", 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" }], @@ -36,8 +37,8 @@ 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" }, + { path: "src/b.ts", type: "change" }, ], })), { title: "Edited 2 files", tone: "review" }, @@ -47,13 +48,28 @@ 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" }, + { path: "src/b.ts", type: "change" }, ], })), { title: "Changed 2 files", tone: "review" }, ); +assert.deepEqual( + pickDisplay(getToolDisplay({ + tool: "show_changes", + files: [{ path: "src/old.ts", type: "deleted" }], + })), + { title: "Deleted 1 file", tone: "review" }, +); + +assert.equal( + getToolDisplay({ tool: "show_changes", payload: { patch: "diff --git a/a b/a" } }).title, + "Changes ready", +); + +assert.equal(getToolDisplay({ tool: "show_changes" }).title, "No changes"); + assert.equal( getToolDisplay({ tool: "exec_command", summary: { running: true, command: "npm test" } }).title, "Command running", diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index f9706690..56e7e398 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -26,12 +26,8 @@ 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: workspaceTitle(card), label: card.root ?? card.path, tone: "workspace", }; @@ -59,7 +55,7 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { case "apply_patch": { const display = getPatchDisplayParts(card); return { - icon: patchIcon(display.iconOperation), + icon: patchIcon(display.iconKind), title: display.title, label: singleFilePath(card), tone: display.tone, @@ -103,10 +99,16 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { tone: "shell", }; case "show_changes": { - const display = getPatchDisplayParts(card); + const display = getPatchDisplayParts(card, { emptyTitle: "Changes ready" }); + const fileCount = card.files?.length ?? 0; return { icon: toolIcons.diff, - title: (card.files?.length ?? 0) > 0 ? display.title : "No changes", + title: fileCount > 0 + ? display.title + : card.payload?.patch + ? display.title + : "No changes", + label: singleFilePath(card), tone: "review", }; } @@ -149,13 +151,18 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { return { kind: "empty" }; } -function patchIcon(operation: ReturnType["iconOperation"]): ToolIcon { - if (operation === "add") return toolIcons.writeFile; - if (operation === "delete") return toolIcons.deleteFile; - if (operation === "move") return toolIcons.files; +function patchIcon(kind: ReturnType["iconKind"]): ToolIcon { + if (kind === "added") return toolIcons.writeFile; + if (kind === "deleted") return toolIcons.deleteFile; + if (kind === "renamed" || kind === "renamed-edited") return toolIcons.files; return toolIcons.editFile; } +function workspaceTitle(card: ToolResultCard): string { + const mode = card.mode === "worktree" ? "worktree" : "checkout"; + return `${card.workspaceReused ? "Reused" : "Opened"} ${mode}`; +} + function singleFilePath(card: ToolResultCard): string | undefined { if (card.files?.length === 1) return card.files[0]?.path ?? card.path; return undefined; From 091cc4b4aee9939cbe8a3d8f4796e949bf5c8f6f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 12:20:38 +0530 Subject: [PATCH 02/24] fix(ui): scope pretty scrollbars to payloads --- src/ui/heavy-payload.tsx | 6 +++--- src/ui/review-payload.tsx | 8 +++++-- src/ui/workspace-app.css | 45 ++++++++++++++++++++++++++++++++++----- src/ui/workspace-app.tsx | 5 ++++- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/ui/heavy-payload.tsx b/src/ui/heavy-payload.tsx index 69779141..349514a0 100644 --- a/src/ui/heavy-payload.tsx +++ b/src/ui/heavy-payload.tsx @@ -77,7 +77,7 @@ function HeavyPayload({ ); } - return
{text}
; + return
{text}
; } function FilePayload({ @@ -134,7 +134,7 @@ function FilePayload({ }; }, [fileOptions, path, startLine, text]); - return
; + return
; } function DiffPayload({ @@ -163,7 +163,7 @@ function DiffPayload({ stickyHeader: true, disableFileHeader: true, }} - className="pierre-diff" + className="pierre-diff pretty-scrollbar" /> ); } diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index 068d2c1f..b492b135 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -56,7 +56,7 @@ function ReviewPayload({ const options = diffOptions(themeType); return ( -
+
{visibleFiles.map((fileDiff, index) => { const key = fileDiff.cacheKey ?? `${fileDiff.prevName ?? ""}->${fileDiff.name}-${index}`; @@ -86,7 +86,11 @@ function ReviewPayload({ {isOpen ? ( - + ) : null}
); diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 65713c34..f3a9abcb 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -9,6 +9,8 @@ --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(100 116 139 / 55%); + --scrollbar-thumb-hover: rgb(71 85 105 / 85%); } * { @@ -39,11 +41,44 @@ body { color: var(--color-text-primary, #f5f5f6); } -.tool-card, -.tool-card * { - scrollbar-width: thin; - scrollbar-color: gray transparent; - scrollbar-gutter: stable; +.pretty-scrollbar::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.pretty-scrollbar::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} + +.pretty-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.pretty-scrollbar::-webkit-scrollbar-thumb { + background: transparent; + background-clip: content-box; + border: 3px solid transparent; + border-radius: 9999px; +} + +.pretty-scrollbar:is(:hover, :focus, :focus-within)::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); +} + +.pretty-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover); +} + +.pretty-scrollbar::-webkit-scrollbar-corner { + background: transparent; +} + +@media (hover: none) { + .pretty-scrollbar::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); + } } .empty { diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index da32cd32..d1068c34 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -324,7 +324,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 { From 517653f2a102e4e5928f02b83ecf95aecb3099c2 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 12:22:31 +0530 Subject: [PATCH 03/24] style(ui): compact and clarify tool cards --- src/ui/tool-display.ts | 10 +++ src/ui/workspace-app.css | 160 ++++++++++++++++++++++++--------------- src/ui/workspace-app.tsx | 12 ++- 3 files changed, 121 insertions(+), 61 deletions(-) diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index 56e7e398..fc0bf961 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; + state?: "running" | "success" | "error"; } export type ToolHeaderSummary = @@ -90,6 +91,7 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { title: processTitle(card, "command"), label: processLabel(card), tone: "shell", + state: processState(card), }; case "write_stdin": return { @@ -97,6 +99,7 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { title: processTitle(card, "process"), label: processLabel(card), tone: "shell", + state: processState(card), }; case "show_changes": { const display = getPatchDisplayParts(card, { emptyTitle: "Changes ready" }); @@ -188,6 +191,13 @@ function processTitle(card: ToolResultCard, subject: "command" | "process"): str return subject === "command" ? "Ran command" : "Process finished"; } +function processState(card: ToolResultCard): ToolDisplay["state"] { + if (card.summary?.running === true) return "running"; + const exitCode = summaryNumber(card.summary, "exitCode"); + if (exitCode !== undefined && exitCode !== 0) return "error"; + return exitCode === 0 ? "success" : undefined; +} + function processLabel(card: ToolResultCard): string | undefined { const command = card.summary?.command; if (typeof command === "string") return command; diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index f3a9abcb..ffee8c32 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -3,12 +3,13 @@ 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); + --tool-card-border: color-mix(in srgb, var(--color-border-primary, #414141) 74%, transparent); + --tool-card-header-bg: color-mix(in srgb, var(--color-background-secondary, #272727) 88%, transparent); + --tool-card-body-bg: color-mix(in srgb, var(--color-background-primary, #181818) 94%, transparent); + --tool-card-hover-bg: color-mix(in srgb, var(--color-background-tertiary, #343434) 46%, transparent); + --tool-card-divider: color-mix(in srgb, var(--color-border-primary, #414141) 66%, transparent); + --tool-accent: var(--color-text-secondary, #b6b6bd); + --tool-accent-soft: color-mix(in srgb, var(--tool-accent) 12%, transparent); --scrollbar-thumb: rgb(100 116 139 / 55%); --scrollbar-thumb-hover: rgb(71 85 105 / 85%); } @@ -35,12 +36,45 @@ body { width: 100%; overflow: hidden; border: 1px solid var(--tool-card-border); - border-radius: 14px; + border-radius: 12px; background: var(--tool-card-header-bg); box-shadow: none; color: var(--color-text-primary, #f5f5f6); } +.tool-card.workspace, +.tool-card.directory { + --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 34%, #3b82f6 66%); +} + +.tool-card.read, +.tool-card.search { + --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 32%, #06b6d4 68%); +} + +.tool-card.write, +.tool-card.state-success { + --tool-accent: var(--color-success-text, #6fda83); +} + +.tool-card.edit, +.tool-card.review { + --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 28%, #d99742 72%); +} + +.tool-card.delete, +.tool-card.state-error { + --tool-accent: var(--color-danger-text, #ee7676); +} + +.tool-card.shell { + --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 42%, #64748b 58%); +} + +.tool-card.state-running { + --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 30%, #38bdf8 70%); +} + .pretty-scrollbar::-webkit-scrollbar { width: 10px; height: 10px; @@ -89,20 +123,27 @@ body { .tool-header { display: grid; - grid-template-columns: 54px minmax(0, 1fr) auto 24px; + grid-template-columns: 40px minmax(0, 1fr) auto 20px; align-items: center; - gap: 14px; + gap: 12px; width: 100%; - min-height: 82px; - padding: 12px 16px; + min-height: 64px; + padding: 10px 12px; border: 0; - border-radius: 13px; + border-radius: 11px; background: transparent; color: inherit; cursor: pointer; text-align: left; } +.tool-header:focus-visible, +.review-diff-file-header:focus-visible, +.review-more:focus-visible { + outline: 2px solid color-mix(in srgb, var(--tool-accent) 72%, transparent); + outline-offset: -2px; +} + .tool-header:hover:not(:disabled) { background: var(--tool-card-hover-bg); } @@ -113,18 +154,18 @@ body { .tool-icon { display: grid; - width: 54px; - height: 54px; + width: 40px; + height: 40px; 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(--tool-accent) 18%, transparent); + border-radius: 10px; + background: var(--tool-accent-soft); + color: var(--tool-accent); } .icon-svg { - width: 22px; - height: 22px; + width: 20px; + height: 20px; display: block; stroke-width: 1.8; } @@ -132,39 +173,39 @@ body { .tool-main { display: grid; min-width: 0; - gap: 4px; + gap: 2px; } .tool-title { color: var(--color-text-primary, #f5f5f6); - font-size: var(--font-text-md-size, 16px); - font-weight: 500; - line-height: 1.25; + font-size: var(--font-text-sm-size, 14px); + font-weight: 550; + line-height: 1.3; } .tool-label { overflow: hidden; - color: var(--color-text-secondary, #d6d6dc); + color: var(--color-text-tertiary, #a3a3aa); font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: var(--font-text-sm-size, 13px); - line-height: 1.35; + font-size: var(--font-text-sm-size, 12px); + line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; } .stats { display: inline-flex; - gap: 6px; + gap: 5px; align-items: center; font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: var(--font-text-sm-size, 13px); + font-size: var(--font-text-sm-size, 12px); font-variant-numeric: tabular-nums; white-space: nowrap; } .header-meta { - color: var(--color-text-secondary, #d6d6dc); - font-size: var(--font-text-sm-size, 13px); + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-sm-size, 12px); line-height: 1.35; text-align: right; white-space: nowrap; @@ -184,8 +225,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); @@ -200,8 +241,8 @@ body { } .chevron .icon-svg { - width: 16px; - height: 16px; + width: 15px; + height: 15px; } .chevron.expanded { @@ -237,7 +278,7 @@ body { } .review-header { - grid-template-columns: 54px minmax(0, 1fr) auto 24px; + grid-template-columns: 40px minmax(0, 1fr) auto 20px; } .review-title-group { @@ -265,15 +306,15 @@ body { .review-more { width: 100%; - min-height: 44px; - padding: 0 16px; + min-height: 40px; + padding: 0 12px; border: 0; border-top: 1px solid var(--tool-card-divider); background: transparent; - color: var(--color-text-secondary, #d6d6dc); + color: var(--color-text-tertiary, #a3a3aa); cursor: pointer; font: inherit; - font-size: var(--font-text-sm-size, 13px); + font-size: var(--font-text-sm-size, 12px); text-align: left; } @@ -285,7 +326,8 @@ body { .review-diff { display: grid; max-height: 520px; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; } .review-diff-files { @@ -307,10 +349,10 @@ body { .review-diff-file-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; + gap: 10px; width: 100%; - min-height: 50px; - padding: 0 16px; + min-height: 42px; + padding: 0 12px; border: 0; background: transparent; color: var(--color-text-primary, #f5f5f6); @@ -326,26 +368,26 @@ body { .review-diff-file-name, .review-diff-file-stats { overflow: hidden; - font-size: var(--font-text-sm-size, 14px); + font-size: var(--font-text-sm-size, 13px); text-overflow: ellipsis; white-space: nowrap; } .review-diff-file-name { - font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); } .review-diff-file-stats { justify-content: flex-end; overflow: visible; font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: var(--font-text-sm-size, 13px); + font-size: var(--font-text-sm-size, 12px); font-variant-numeric: tabular-nums; } .status { - padding: 12px 14px; - font-size: var(--font-text-sm-size, 13px); + padding: 10px 12px; + font-size: var(--font-text-sm-size, 12px); } .status.muted { @@ -376,8 +418,8 @@ body { max-height: 420px; margin: 0; overflow: auto; - padding: 12px 14px; - color: var(--color-text-secondary, #d6d6dc); + padding: 10px 12px; + color: var(--color-text-secondary, #c7c7ce); font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); font-size: var(--font-text-sm-size, 12px); line-height: 1.55; @@ -392,16 +434,16 @@ body { @media (max-width: 520px) { .tool-header { - grid-template-columns: 42px minmax(0, 1fr) auto 18px; - gap: 10px; - min-height: 68px; - padding: 10px 12px; + grid-template-columns: 36px minmax(0, 1fr) auto 18px; + gap: 9px; + min-height: 58px; + padding: 9px 10px; } .tool-icon { - width: 42px; - height: 42px; - border-radius: 10px; + width: 36px; + height: 36px; + border-radius: 9px; } .chevron { @@ -410,6 +452,6 @@ body { } .review-header { - grid-template-columns: 42px minmax(0, 1fr) auto 18px; + grid-template-columns: 36px minmax(0, 1fr) auto 18px; } } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index d1068c34..cf75d09d 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -161,7 +161,9 @@ function render(): void { const expandable = isExpandableCard(card); const main = element("main", { className: "shell" }); - const section = element("section", { className: `tool-card ${display.tone}` }); + const section = element("section", { + className: toolCardClassName(display), + }); const button = element("button", { className: "tool-header", type: "button", @@ -359,7 +361,7 @@ function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void { const hiddenCount = Math.max(0, files.length - visibleFiles.length); const expandable = isExpandableCard(card); const main = element("main", { className: "shell" }); - const section = element("section", { className: "tool-card review" }); + const section = element("section", { className: toolCardClassName(display) }); const header = element("button", { className: "tool-header review-header", type: "button", @@ -434,6 +436,12 @@ function renderChevron(isExpanded: boolean, visible: boolean): HTMLElement { return chevron; } +function toolCardClassName(display: ToolDisplay): string { + return ["tool-card", display.tone, display.state ? `state-${display.state}` : undefined] + .filter(Boolean) + .join(" "); +} + function setPayloadLoading(container: HTMLElement, loading: boolean): void { const header = container.previousElementSibling; const chevron = header?.querySelector(".chevron"); From 265f04faf92770e0bd430f4db2f03bea836855ad Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 12:25:40 +0530 Subject: [PATCH 04/24] refactor(ui): structure tool card details --- src/ui/review-payload.tsx | 53 ++++++++++ src/ui/workspace-app.css | 137 +++++++++++++++++++++++- src/ui/workspace-app.tsx | 217 ++++++++++++++++++++++++-------------- 3 files changed, 326 insertions(+), 81 deletions(-) diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index b492b135..46aebb54 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -3,6 +3,11 @@ 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 { + fileChangeKindLabel, + getFileChangeKind, + type FileChangeKind, +} from "./patch-display.js"; type ThemeType = "light" | "dark"; @@ -55,6 +60,18 @@ function ReviewPayload({ const options = diffOptions(themeType); + if (files.length === 1) { + return ( +
+ +
+ ); + } + return (
@@ -62,6 +79,7 @@ function ReviewPayload({ const key = fileDiff.cacheKey ?? `${fileDiff.prevName ?? ""}->${fileDiff.name}-${index}`; const stats = diffStats(fileDiff); const isOpen = openFiles.has(key); + const changeKind = fileChangeKind(card.files ?? [], fileDiff); return (
@@ -79,6 +97,13 @@ function ReviewPayload({ setOpenFiles(next); }} > + + {fileChangeSymbol(changeKind)} + {fileDiff.name} +{stats.additions} @@ -100,6 +125,34 @@ function ReviewPayload({ ); } +function fileChangeKind( + files: NonNullable, + fileDiff: FileDiffMetadata, +): FileChangeKind { + const cardFile = files.find((file) => ( + file.path === fileDiff.name || + file.previousPath === fileDiff.prevName || + file.path === fileDiff.prevName + )); + return cardFile ? getFileChangeKind(cardFile) : "unknown"; +} + +function fileChangeSymbol(kind: FileChangeKind): string { + switch (kind) { + case "added": + return "A"; + case "edited": + return "M"; + case "deleted": + return "D"; + case "renamed": + case "renamed-edited": + return "R"; + case "unknown": + return "•"; + } +} + function parseFiles(patch: string | undefined): FileDiffMetadata[] { if (!patch) return []; return parsePatchFiles(patch, "review", true).flatMap((parsedPatch) => parsedPatch.files); diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index ffee8c32..73577819 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -277,6 +277,101 @@ body { background: var(--tool-card-body-bg); } +.workspace-details { + display: grid; + max-height: 420px; + overflow: auto; +} + +.workspace-rows { + display: grid; + padding: 4px 0; +} + +.workspace-row { + display: grid; + grid-template-columns: minmax(92px, 0.28fr) minmax(0, 1fr); + gap: 12px; + min-height: 36px; + align-items: center; + padding: 7px 12px; +} + +.workspace-key { + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-sm-size, 12px); + font-weight: 500; +} + +.workspace-value { + min-width: 0; + overflow: hidden; + color: var(--color-text-secondary, #c7c7ce); + font-size: var(--font-text-sm-size, 12px); + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-value.mono { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); +} + +.workspace-diagnostics { + display: grid; + border-top: 1px solid var(--tool-card-divider); +} + +.workspace-diagnostics-title { + padding: 9px 12px 5px; + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-sm-size, 11px); + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.workspace-diagnostic { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 8px; + padding: 7px 12px 9px; +} + +.workspace-diagnostic-marker { + display: grid; + width: 18px; + height: 18px; + place-items: center; + border-radius: 9999px; + background: color-mix(in srgb, var(--color-warning-text, #e6b566) 14%, transparent); + color: var(--color-warning-text, #e6b566); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 11px; + font-weight: 700; +} + +.workspace-diagnostic-copy { + display: grid; + min-width: 0; + gap: 2px; +} + +.workspace-diagnostic-message { + color: var(--color-text-secondary, #c7c7ce); + font-size: var(--font-text-sm-size, 12px); +} + +.workspace-diagnostic-detail { + overflow: hidden; + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: var(--font-text-sm-size, 11px); + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + .review-header { grid-template-columns: 40px minmax(0, 1fr) auto 20px; } @@ -348,7 +443,7 @@ body { .review-diff-file-header { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: 22px minmax(0, 1fr) auto; gap: 10px; width: 100%; min-height: 42px; @@ -361,6 +456,40 @@ body { text-align: left; } +.review-file-kind { + display: grid; + width: 20px; + height: 20px; + place-items: center; + border-radius: 6px; + background: color-mix(in srgb, var(--color-text-tertiary, #a3a3aa) 10%, transparent); + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 10px; + font-weight: 700; +} + +.review-file-kind.added { + background: color-mix(in srgb, var(--color-success-text, #6fda83) 12%, transparent); + color: var(--color-success-text, #6fda83); +} + +.review-file-kind.edited, +.review-file-kind.renamed, +.review-file-kind.renamed-edited { + background: color-mix(in srgb, var(--color-warning-text, #e6b566) 12%, transparent); + color: var(--color-warning-text, #e6b566); +} + +.review-file-kind.deleted { + background: color-mix(in srgb, var(--color-danger-text, #ee7676) 12%, transparent); + color: var(--color-danger-text, #ee7676); +} + +.review-single-file { + overflow: hidden; +} + .review-diff-file-header:hover { background: var(--tool-card-hover-bg); } @@ -454,4 +583,10 @@ body { .review-header { grid-template-columns: 36px minmax(0, 1fr) auto 18px; } + + .workspace-row { + grid-template-columns: 1fr; + gap: 2px; + padding-block: 8px; + } } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index cf75d09d..34249172 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -228,7 +228,7 @@ async function renderPayloadIfNeeded(): Promise { } if (card.tool === "open_workspace") { - renderPrePayload(target, workspacePayloadText(card), "open_workspace"); + renderWorkspacePayload(target, card); return; } @@ -456,94 +456,151 @@ function setPayloadLoading(container: HTMLElement, loading: boolean): void { if (button) button.setAttribute("aria-busy", String(loading)); } -function workspacePayloadText(card: ToolResultCard): string { - const agentsFiles = card.agentsFiles ?? []; - const availableAgentsFiles = card.availableAgentsFiles ?? []; - const skills = card.skills ?? []; - const agentProviders = card.agentProviders ?? []; - const agents = card.agents ?? []; +function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): void { + unmountCurrentPayload(); + + const details = element("div", { + className: "workspace-details pretty-scrollbar", + }); + const rows = element("div", { className: "workspace-rows" }); + const worktree = card.worktree; + + if (worktree) { + const base = [ + worktree.baseRef, + worktree.baseSha?.slice(0, 8), + ].filter((value): value is string => Boolean(value)); + const flags = [ + worktree.detached ? "detached" : undefined, + worktree.managed ? "managed" : undefined, + worktree.dirtySource ? "dirty source" : undefined, + ].filter((value): value is string => Boolean(value)); + appendWorkspaceRow(rows, "Base", [...base, ...flags].join(" · ") || "Worktree"); + } + + if (card.sourceRoot && card.sourceRoot !== card.root) { + appendWorkspaceRow(rows, "Source checkout", card.sourceRoot, true); + } + + const instructionPaths = [ + ...(card.agentsFiles ?? []).map((file) => file.path ?? "AGENTS.md"), + ...(card.availableAgentsFiles ?? []).map((file) => file.path ?? "Nested instructions"), + ]; + if (instructionPaths.length > 0) { + appendWorkspaceRow(rows, "Instructions", compactList(instructionPaths), true); + } + + const skillNames = (card.skills ?? []).map((skill) => skill.name ?? skill.path ?? "Unnamed skill"); + if (skillNames.length > 0) { + appendWorkspaceRow(rows, "Skills", compactList(skillNames)); + } + + const providers = card.agentProviders ?? []; + if (providers.length > 0) { + const available: string[] = []; + for (const provider of providers) { + if (provider.available !== false) { + available.push(provider.name ?? "Unknown provider"); + } + } + const unavailableCount = providers.length - available.length; + const providerSummary = [ + compactList(available), + unavailableCount > 0 ? `${unavailableCount} unavailable` : undefined, + ].filter((value): value is string => Boolean(value)); + appendWorkspaceRow(rows, "Providers", providerSummary.join(" · ")); + } + + const agentNames = (card.agents ?? []).map((agent) => { + const name = agent.name ?? "Unnamed agent"; + return agent.provider ? `${name} · ${agent.provider}` : name; + }); + if (agentNames.length > 0) { + appendWorkspaceRow(rows, "Agents", compactList(agentNames)); + } + + if (rows.childElementCount > 0) details.append(rows); + 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"); -} + if (diagnostics.length > 0) { + const diagnosticList = element("div", { className: "workspace-diagnostics" }); + diagnosticList.append(element("div", { + className: "workspace-diagnostics-title", + text: `${diagnostics.length} skill ${diagnostics.length === 1 ? "diagnostic" : "diagnostics"}`, + })); -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(", ")})` : ""}`; -} + for (const diagnostic of diagnostics) { + const view = diagnosticView(diagnostic); + const item = element("div", { className: "workspace-diagnostic" }); + item.append(element("span", { + className: "workspace-diagnostic-marker", + text: "!", + ariaHidden: "true", + })); + const copy = element("span", { className: "workspace-diagnostic-copy" }); + copy.append(element("span", { className: "workspace-diagnostic-message", text: view.message })); + if (view.detail) { + copy.append(element("span", { className: "workspace-diagnostic-detail", text: view.detail })); + } + item.append(copy); + diagnosticList.append(item); + } + + details.append(diagnosticList); + } -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`; + if (details.childElementCount === 0) { + details.append(element("div", { className: "status muted", text: "No workspace details available." })); + } + + container.replaceChildren(details); } -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 appendWorkspaceRow( + container: HTMLElement, + label: string, + value: string, + mono = false, +): void { + const row = element("div", { className: "workspace-row" }); + row.append( + element("span", { className: "workspace-key", text: label }), + element("span", { + className: `workspace-value${mono ? " mono" : ""}`, + text: value, + title: value, + }), + ); + container.append(row); } -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 compactList(values: string[], visibleCount = 5): string { + const visible = values.slice(0, visibleCount); + const hiddenCount = values.length - visible.length; + return hiddenCount > 0 + ? `${visible.join(" · ")} · +${hiddenCount} more` + : visible.join(" · "); } -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 diagnosticView(diagnostic: unknown): { message: string; detail?: string } { + if (typeof diagnostic === "string") return { message: diagnostic }; + if (diagnostic instanceof Error) return { message: diagnostic.message }; + if (!diagnostic || typeof diagnostic !== "object") return { message: String(diagnostic) }; + + const record = diagnostic as Record; + const message = typeof record.message === "string" ? record.message : "Skill diagnostic"; + const collision = record.collision; + if (!collision || typeof collision !== "object") return { message }; + + const collisionRecord = collision as Record; + const winnerPath = collisionRecord.winnerPath; + const loserPath = collisionRecord.loserPath; + const detail = [ + typeof winnerPath === "string" ? `Using ${winnerPath}` : undefined, + typeof loserPath === "string" ? `Ignoring ${loserPath}` : undefined, + ].filter((value): value is string => Boolean(value)); + + return detail.length > 0 ? { message, detail: detail.join(" · ") } : { message }; } function toolNameFromMeta(result: CallToolResult): ToolName | undefined { From ca32b274913c249b2310ba2a75d99082d2034aa0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 12:29:54 +0530 Subject: [PATCH 05/24] fix(ui): preserve process state accents --- src/ui/tool-display.test.ts | 20 ++++++++++++++++++++ src/ui/workspace-app.css | 14 ++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index d1a30d52..3a7562a3 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -28,6 +28,14 @@ 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" }).icon, + toolIcons.folderOpen, +); +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", @@ -82,6 +90,18 @@ assert.equal( getToolDisplay({ tool: "write_stdin", summary: { running: false, exitCode: 0 } }).title, "Process finished", ); +assert.equal( + getToolDisplay({ tool: "exec_command", summary: { running: true } }).state, + "running", +); +assert.equal( + getToolDisplay({ tool: "exec_command", summary: { running: false, exitCode: 0 } }).state, + "success", +); +assert.equal( + getToolDisplay({ tool: "exec_command", summary: { running: false, exitCode: 1 } }).state, + "error", +); assert.deepEqual( pickDisplay(getToolDisplay({ tool: "glob", summary: { lines: 1, pattern: "**/*.ts" } })), diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 73577819..5657d66a 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -52,8 +52,7 @@ body { --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 32%, #06b6d4 68%); } -.tool-card.write, -.tool-card.state-success { +.tool-card.write { --tool-accent: var(--color-success-text, #6fda83); } @@ -62,8 +61,7 @@ body { --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 28%, #d99742 72%); } -.tool-card.delete, -.tool-card.state-error { +.tool-card.delete { --tool-accent: var(--color-danger-text, #ee7676); } @@ -71,6 +69,14 @@ body { --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 42%, #64748b 58%); } +.tool-card.state-success { + --tool-accent: var(--color-success-text, #6fda83); +} + +.tool-card.state-error { + --tool-accent: var(--color-danger-text, #ee7676); +} + .tool-card.state-running { --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 30%, #38bdf8 70%); } From 09c015dd9901ff4ec08d7ac521abbc910df31922 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 14:50:50 +0530 Subject: [PATCH 06/24] fix(ui): reveal payload scrollbars reliably --- src/ui/heavy-payload.tsx | 3 +++ src/ui/review-payload.tsx | 2 ++ src/ui/scrollbar.ts | 55 +++++++++++++++++++++++++++++++++++++++ src/ui/workspace-app.css | 13 ++++++--- 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src/ui/scrollbar.ts diff --git a/src/ui/heavy-payload.tsx b/src/ui/heavy-payload.tsx index 349514a0..a61e6dac 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 { pierrePrettyScrollbarCss } from "./scrollbar.js"; type ThemeType = "light" | "dark"; @@ -100,6 +101,7 @@ function FilePayload({ }, themeType, overflow: "scroll", + unsafeCSS: pierrePrettyScrollbarCss, }), [themeType], ); @@ -158,6 +160,7 @@ function DiffPayload({ hunkSeparators: "line-info", lineDiffType: "word-alt", overflow: "scroll", + unsafeCSS: pierrePrettyScrollbarCss, collapsedContextThreshold: 4, expansionLineCount: 20, stickyHeader: true, diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index 46aebb54..b38779b4 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -8,6 +8,7 @@ import { getFileChangeKind, type FileChangeKind, } from "./patch-display.js"; +import { pierrePrettyScrollbarCss } from "./scrollbar.js"; type ThemeType = "light" | "dark"; @@ -180,6 +181,7 @@ function diffOptions(themeType: ThemeType): FileDiffOptions { hunkSeparators: "line-info", lineDiffType: "word-alt", overflow: "scroll", + unsafeCSS: pierrePrettyScrollbarCss, collapsedContextThreshold: 4, expansionLineCount: 20, stickyHeader: false, diff --git a/src/ui/scrollbar.ts b/src/ui/scrollbar.ts new file mode 100644 index 00000000..d3cf21ac --- /dev/null +++ b/src/ui/scrollbar.ts @@ -0,0 +1,55 @@ +export const pierrePrettyScrollbarCss = ` +:host { + --diffs-scrollbar-gutter-override: 10px; +} + +[data-code] { + scrollbar-gutter: auto; +} + +[data-code]::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +[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: transparent; + background-clip: content-box; + border: 3px solid transparent; + border-radius: 9999px; +} + +:host(:hover) [data-code]::-webkit-scrollbar-thumb, +:host(:focus-within) [data-code]::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb, rgb(100 116 139 / 55%)); +} + +[data-code]::-webkit-scrollbar-thumb:hover, +[data-code]::-webkit-scrollbar-thumb:active { + background-color: var(--scrollbar-thumb-hover, rgb(71 85 105 / 85%)); +} + +[data-code]::-webkit-scrollbar-corner { + background: transparent; +} + +:host(:hover) { + --scrollbar-hover-repaint: ; +} + +@media (hover: none) { + [data-code]::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb, rgb(100 116 139 / 55%)); + } +} +`; diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 5657d66a..befb7a9c 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -97,17 +97,20 @@ body { } .pretty-scrollbar::-webkit-scrollbar-thumb { - background: transparent; + background-color: transparent; background-clip: content-box; border: 3px solid transparent; border-radius: 9999px; } -.pretty-scrollbar:is(:hover, :focus, :focus-within)::-webkit-scrollbar-thumb { +.pretty-scrollbar:hover::-webkit-scrollbar-thumb, +.pretty-scrollbar:focus::-webkit-scrollbar-thumb, +.pretty-scrollbar:focus-within::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb); } -.pretty-scrollbar::-webkit-scrollbar-thumb:hover { +.pretty-scrollbar::-webkit-scrollbar-thumb:hover, +.pretty-scrollbar::-webkit-scrollbar-thumb:active { background-color: var(--scrollbar-thumb-hover); } @@ -115,6 +118,10 @@ body { background: transparent; } +.pretty-scrollbar:hover { + --scrollbar-hover-repaint: ; +} + @media (hover: none) { .pretty-scrollbar::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb); From 897c57e61939bad0e6344b364f716eb7e198350e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 15:57:05 +0530 Subject: [PATCH 07/24] refactor(ui): clean up workspace details --- src/ui/icons.ts | 11 +++ src/ui/workspace-app.css | 174 +++++++++++++++++++++++++-------- src/ui/workspace-app.tsx | 202 +++++++++++++++++++++++++-------------- 3 files changed, 275 insertions(+), 112 deletions(-) diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 37fecf7b..a43edfa7 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -1,14 +1,19 @@ import { + Blocks, + Bot, ChevronDown, + Cpu, FileDiff, FileMinus, FilePenLine, FilePlus, FileText, Files, + FolderGit2, FolderOpen, FolderTree, GitBranch, + GitCommitHorizontal, LoaderCircle, Search, SquareTerminal, @@ -18,6 +23,8 @@ import { } from "lucide"; export const toolIcons = { + agents: Bot, + base: GitCommitHorizontal, chevronDown: ChevronDown, deleteFile: FileMinus, diff: FileDiff, @@ -26,9 +33,13 @@ export const toolIcons = { folderOpen: FolderOpen, folderTree: FolderTree, gitBranch: GitBranch, + instructions: FileText, loading: LoaderCircle, + providers: Cpu, readFile: FileText, search: Search, + skills: Blocks, + sourceCheckout: FolderGit2, terminal: Terminal, terminalSquare: SquareTerminal, writeFile: FilePlus, diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index befb7a9c..2e257201 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -303,13 +303,29 @@ body { .workspace-row { display: grid; - grid-template-columns: minmax(92px, 0.28fr) minmax(0, 1fr); - gap: 12px; - min-height: 36px; + grid-template-columns: 22px minmax(116px, 0.24fr) minmax(0, 1fr); + gap: 10px; + min-height: 40px; align-items: center; padding: 7px 12px; } +.workspace-row-icon { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border-radius: 6px; + background: color-mix(in srgb, var(--tool-accent) 9%, transparent); + color: color-mix(in srgb, var(--tool-accent) 72%, var(--color-text-tertiary, #a3a3aa)); +} + +.workspace-row-icon-svg { + width: 14px; + height: 14px; + stroke-width: 1.8; +} + .workspace-key { color: var(--color-text-tertiary, #a3a3aa); font-size: var(--font-text-sm-size, 12px); @@ -330,59 +346,102 @@ body { font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); } -.workspace-diagnostics { - display: grid; - border-top: 1px solid var(--tool-card-divider); +.workspace-chip-list { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 6px; } -.workspace-diagnostics-title { - padding: 9px 12px 5px; - color: var(--color-text-tertiary, #a3a3aa); +.workspace-chip { + display: inline-flex; + max-width: 100%; + min-height: 24px; + align-items: center; + overflow: hidden; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--tool-accent) 16%, var(--tool-card-divider)); + border-radius: 9999px; + background: color-mix(in srgb, var(--tool-accent) 7%, transparent); + color: var(--color-text-secondary, #c7c7ce); font-size: var(--font-text-sm-size, 11px); - font-weight: 600; - letter-spacing: 0.02em; - text-transform: uppercase; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; } -.workspace-diagnostic { - display: grid; - grid-template-columns: 20px minmax(0, 1fr); - gap: 8px; - padding: 7px 12px 9px; +.workspace-chip.muted { + border-style: dashed; + color: var(--color-text-tertiary, #a3a3aa); + opacity: 0.72; } -.workspace-diagnostic-marker { +.workspace-skills { display: grid; - width: 18px; - height: 18px; - place-items: center; - border-radius: 9999px; - background: color-mix(in srgb, var(--color-warning-text, #e6b566) 14%, transparent); - color: var(--color-warning-text, #e6b566); - font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: 11px; - font-weight: 700; } -.workspace-diagnostic-copy { +.workspace-skills-summary { display: grid; - min-width: 0; - gap: 2px; + grid-template-columns: 22px minmax(116px, 0.24fr) minmax(0, 1fr) 18px; + gap: 10px; + min-height: 40px; + align-items: center; + padding: 7px 12px; + cursor: pointer; + list-style: none; } -.workspace-diagnostic-message { +.workspace-skills-summary::-webkit-details-marker { + display: none; +} + +.workspace-skills-summary:hover { + background: var(--tool-card-hover-bg); +} + +.workspace-skills-summary:focus-visible { + outline: 2px solid color-mix(in srgb, var(--tool-accent) 72%, transparent); + outline-offset: -2px; +} + +.workspace-disclosure-action { color: var(--color-text-secondary, #c7c7ce); font-size: var(--font-text-sm-size, 12px); } -.workspace-diagnostic-detail { - overflow: hidden; +.workspace-disclosure-open { + display: none; +} + +.workspace-skills[open] .workspace-disclosure-closed { + display: none; +} + +.workspace-skills[open] .workspace-disclosure-open { + display: inline; +} + +.workspace-disclosure-chevron { + display: grid; + width: 18px; + height: 18px; + place-items: center; color: var(--color-text-tertiary, #a3a3aa); - font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: var(--font-text-sm-size, 11px); - line-height: 1.4; - text-overflow: ellipsis; - white-space: nowrap; + transition: transform 140ms ease; +} + +.workspace-disclosure-chevron .icon-svg { + width: 14px; + height: 14px; +} + +.workspace-skills[open] .workspace-disclosure-chevron { + transform: rotate(180deg); +} + +.workspace-skills-list { + padding: 1px 12px 12px 44px; } .review-header { @@ -598,8 +657,43 @@ body { } .workspace-row { - grid-template-columns: 1fr; - gap: 2px; + grid-template-columns: 22px minmax(0, 1fr); + gap: 2px 8px; + padding-block: 8px; + } + + .workspace-row-icon { + grid-row: 1 / span 2; + align-self: start; + } + + .workspace-row > .workspace-key, + .workspace-row > .workspace-value, + .workspace-row > .workspace-chip-list { + grid-column: 2; + } + + .workspace-skills-summary { + grid-template-columns: 22px minmax(0, 1fr) 18px; + gap: 2px 8px; padding-block: 8px; } + + .workspace-skills-summary .workspace-row-icon { + grid-row: 1 / span 2; + } + + .workspace-skills-summary .workspace-key, + .workspace-disclosure-action { + grid-column: 2; + } + + .workspace-disclosure-chevron { + grid-column: 3; + grid-row: 1 / span 2; + } + + .workspace-skills-list { + padding-left: 42px; + } } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 34249172..081f1e82 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -19,7 +19,7 @@ import { 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, @@ -475,11 +475,22 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v worktree.managed ? "managed" : undefined, worktree.dirtySource ? "dirty source" : undefined, ].filter((value): value is string => Boolean(value)); - appendWorkspaceRow(rows, "Base", [...base, ...flags].join(" · ") || "Worktree"); + appendWorkspaceTextRow( + rows, + "Base", + [...base, ...flags].join(" · ") || "Worktree", + toolIcons.base, + ); } if (card.sourceRoot && card.sourceRoot !== card.root) { - appendWorkspaceRow(rows, "Source checkout", card.sourceRoot, true); + appendWorkspaceTextRow( + rows, + "Source checkout", + card.sourceRoot, + toolIcons.sourceCheckout, + true, + ); } const instructionPaths = [ @@ -487,28 +498,32 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v ...(card.availableAgentsFiles ?? []).map((file) => file.path ?? "Nested instructions"), ]; if (instructionPaths.length > 0) { - appendWorkspaceRow(rows, "Instructions", compactList(instructionPaths), true); + appendWorkspaceTextRow( + rows, + "Instructions", + compactList(instructionPaths), + toolIcons.instructions, + true, + ); } - const skillNames = (card.skills ?? []).map((skill) => skill.name ?? skill.path ?? "Unnamed skill"); - if (skillNames.length > 0) { - appendWorkspaceRow(rows, "Skills", compactList(skillNames)); + const skills = card.skills ?? []; + if (skills.length > 0) { + appendWorkspaceSkills(rows, skills); } const providers = card.agentProviders ?? []; if (providers.length > 0) { - const available: string[] = []; + const providerChips: WorkspaceChip[] = []; for (const provider of providers) { - if (provider.available !== false) { - available.push(provider.name ?? "Unknown provider"); - } + const unavailable = provider.available === false; + providerChips.push({ + label: provider.name ?? "Unknown provider", + tone: unavailable ? "muted" : undefined, + title: unavailable ? provider.reason ?? "Provider unavailable" : undefined, + }); } - const unavailableCount = providers.length - available.length; - const providerSummary = [ - compactList(available), - unavailableCount > 0 ? `${unavailableCount} unavailable` : undefined, - ].filter((value): value is string => Boolean(value)); - appendWorkspaceRow(rows, "Providers", providerSummary.join(" · ")); + appendWorkspaceChipRow(rows, "Providers", providerChips, toolIcons.providers); } const agentNames = (card.agents ?? []).map((agent) => { @@ -516,39 +531,16 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v return agent.provider ? `${name} · ${agent.provider}` : name; }); if (agentNames.length > 0) { - appendWorkspaceRow(rows, "Agents", compactList(agentNames)); + appendWorkspaceTextRow( + rows, + "Agents", + compactList(agentNames), + toolIcons.agents, + ); } if (rows.childElementCount > 0) details.append(rows); - const diagnostics = card.skillDiagnostics ?? []; - if (diagnostics.length > 0) { - const diagnosticList = element("div", { className: "workspace-diagnostics" }); - diagnosticList.append(element("div", { - className: "workspace-diagnostics-title", - text: `${diagnostics.length} skill ${diagnostics.length === 1 ? "diagnostic" : "diagnostics"}`, - })); - - for (const diagnostic of diagnostics) { - const view = diagnosticView(diagnostic); - const item = element("div", { className: "workspace-diagnostic" }); - item.append(element("span", { - className: "workspace-diagnostic-marker", - text: "!", - ariaHidden: "true", - })); - const copy = element("span", { className: "workspace-diagnostic-copy" }); - copy.append(element("span", { className: "workspace-diagnostic-message", text: view.message })); - if (view.detail) { - copy.append(element("span", { className: "workspace-diagnostic-detail", text: view.detail })); - } - item.append(copy); - diagnosticList.append(item); - } - - details.append(diagnosticList); - } - if (details.childElementCount === 0) { details.append(element("div", { className: "status muted", text: "No workspace details available." })); } @@ -556,22 +548,109 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v container.replaceChildren(details); } -function appendWorkspaceRow( +interface WorkspaceChip { + label: string; + title?: string; + tone?: "muted"; +} + +function appendWorkspaceTextRow( container: HTMLElement, label: string, value: string, + icon: ToolIcon, mono = false, +): void { + const content = element("span", { + className: `workspace-value${mono ? " mono" : ""}`, + text: value, + title: value, + }); + appendWorkspaceRow(container, label, content, icon); +} + +function appendWorkspaceChipRow( + container: HTMLElement, + label: string, + chips: WorkspaceChip[], + icon: ToolIcon, +): void { + appendWorkspaceRow(container, label, renderWorkspaceChips(chips), icon); +} + +function appendWorkspaceRow( + container: HTMLElement, + label: string, + content: HTMLElement, + icon: ToolIcon, ): void { const row = element("div", { className: "workspace-row" }); row.append( + renderWorkspaceRowIcon(icon), element("span", { className: "workspace-key", text: label }), + content, + ); + container.append(row); +} + +function appendWorkspaceSkills( + container: HTMLElement, + skills: NonNullable, +): void { + const details = element("details", { className: "workspace-skills" }); + const summary = element("summary", { className: "workspace-skills-summary" }); + const action = element("span", { className: "workspace-disclosure-action" }); + action.append( element("span", { - className: `workspace-value${mono ? " mono" : ""}`, - text: value, - title: value, + className: "workspace-disclosure-closed", + text: `View all ${skills.length}`, + }), + element("span", { + className: "workspace-disclosure-open", + text: "Hide skills", }), ); - container.append(row); + const chevron = element("span", { + className: "workspace-disclosure-chevron", + ariaHidden: "true", + }); + chevron.append(renderIcon(toolIcons.chevronDown)); + summary.append( + renderWorkspaceRowIcon(toolIcons.skills), + element("span", { className: "workspace-key", text: "Skills" }), + action, + chevron, + ); + + const chips = skills.map((skill) => ({ + label: skill.name ?? skill.path ?? "Unnamed skill", + title: skill.path, + })); + const chipList = renderWorkspaceChips(chips); + chipList.classList.add("workspace-skills-list"); + details.append(summary, chipList); + container.append(details); +} + +function renderWorkspaceRowIcon(icon: ToolIcon): HTMLElement { + const wrapper = element("span", { + className: "workspace-row-icon", + ariaHidden: "true", + }); + wrapper.append(renderIcon(icon, "workspace-row-icon-svg")); + return wrapper; +} + +function renderWorkspaceChips(chips: WorkspaceChip[]): HTMLElement { + const list = element("span", { className: "workspace-chip-list" }); + for (const chip of chips) { + list.append(element("span", { + className: `workspace-chip${chip.tone ? ` ${chip.tone}` : ""}`, + text: chip.label, + title: chip.title, + })); + } + return list; } function compactList(values: string[], visibleCount = 5): string { @@ -582,27 +661,6 @@ function compactList(values: string[], visibleCount = 5): string { : visible.join(" · "); } -function diagnosticView(diagnostic: unknown): { message: string; detail?: string } { - if (typeof diagnostic === "string") return { message: diagnostic }; - if (diagnostic instanceof Error) return { message: diagnostic.message }; - if (!diagnostic || typeof diagnostic !== "object") return { message: String(diagnostic) }; - - const record = diagnostic as Record; - const message = typeof record.message === "string" ? record.message : "Skill diagnostic"; - const collision = record.collision; - if (!collision || typeof collision !== "object") return { message }; - - const collisionRecord = collision as Record; - const winnerPath = collisionRecord.winnerPath; - const loserPath = collisionRecord.loserPath; - const detail = [ - typeof winnerPath === "string" ? `Using ${winnerPath}` : undefined, - typeof loserPath === "string" ? `Ignoring ${loserPath}` : undefined, - ].filter((value): value is string => Boolean(value)); - - return detail.length > 0 ? { message, detail: detail.join(" · ") } : { message }; -} - function toolNameFromMeta(result: CallToolResult): ToolName | undefined { const meta = result._meta as Record | undefined; const tool = meta?.tool; From b0bf8be9d78a17d7c1f09028c0d2b977a31ac359 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 17:59:56 +0530 Subject: [PATCH 08/24] fix(ui): make workspace skills expandable --- src/ui/tool-display.test.ts | 10 ++-- src/ui/tool-display.ts | 4 +- src/ui/workspace-app.css | 96 +++++++------------------------------ src/ui/workspace-app.tsx | 65 ++++++++++++++----------- 4 files changed, 60 insertions(+), 115 deletions(-) diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index 3a7562a3..dc59a84f 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -4,10 +4,10 @@ import { toolIcons } from "./icons.js"; import { getToolDisplay, getToolHeaderSummary } from "./tool-display.js"; const displayCases: Array<[ToolResultCard, { title: string; tone: string }]> = [ - [{ tool: "open_workspace", root: "/tmp/project" }, { title: "Opened checkout", tone: "workspace" }], - [{ tool: "open_workspace", root: "/tmp/project", workspaceReused: true }, { title: "Reused checkout", tone: "workspace" }], - [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree" }, { title: "Opened worktree", tone: "workspace" }], - [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree", workspaceReused: true }, { title: "Reused worktree", tone: "workspace" }], + [{ 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 workspace", tone: "workspace" }], + [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree", workspaceReused: true }, { title: "Reused 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" }], @@ -131,7 +131,7 @@ 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( diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index fc0bf961..de979f1c 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -131,7 +131,6 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { if (card.tool === "open_workspace") { const parts = [ - typeof summary.mode === "string" ? summary.mode : undefined, countLabel(summaryNumber(summary, "agentsFiles"), "instruction"), countLabel(summaryNumber(summary, "skills"), "skill"), ].filter((part): part is string => Boolean(part)); @@ -162,8 +161,7 @@ function patchIcon(kind: ReturnType["iconKind"]): T } function workspaceTitle(card: ToolResultCard): string { - const mode = card.mode === "worktree" ? "worktree" : "checkout"; - return `${card.workspaceReused ? "Reused" : "Opened"} ${mode}`; + return `${card.workspaceReused ? "Reused" : "Opened"} workspace`; } function singleFilePath(card: ToolResultCard): string | undefined { diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 2e257201..8b7993d6 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -377,71 +377,31 @@ body { opacity: 0.72; } -.workspace-skills { - display: grid; -} - -.workspace-skills-summary { - display: grid; - grid-template-columns: 22px minmax(116px, 0.24fr) minmax(0, 1fr) 18px; - gap: 10px; - min-height: 40px; +.workspace-skills-list { align-items: center; - padding: 7px 12px; - cursor: pointer; - list-style: none; } -.workspace-skills-summary::-webkit-details-marker { - display: none; +.workspace-skills-toggle { + min-height: 24px; + padding: 3px 9px; + border: 1px solid color-mix(in srgb, var(--tool-accent) 28%, var(--tool-card-divider)); + border-radius: 9999px; + background: transparent; + color: var(--tool-accent); + cursor: pointer; + font: inherit; + font-size: var(--font-text-sm-size, 11px); + line-height: 1.25; + white-space: nowrap; } -.workspace-skills-summary:hover { - background: var(--tool-card-hover-bg); +.workspace-skills-toggle:hover { + background: color-mix(in srgb, var(--tool-accent) 10%, transparent); } -.workspace-skills-summary:focus-visible { +.workspace-skills-toggle:focus-visible { outline: 2px solid color-mix(in srgb, var(--tool-accent) 72%, transparent); - outline-offset: -2px; -} - -.workspace-disclosure-action { - color: var(--color-text-secondary, #c7c7ce); - font-size: var(--font-text-sm-size, 12px); -} - -.workspace-disclosure-open { - display: none; -} - -.workspace-skills[open] .workspace-disclosure-closed { - display: none; -} - -.workspace-skills[open] .workspace-disclosure-open { - display: inline; -} - -.workspace-disclosure-chevron { - display: grid; - width: 18px; - height: 18px; - place-items: center; - color: var(--color-text-tertiary, #a3a3aa); - transition: transform 140ms ease; -} - -.workspace-disclosure-chevron .icon-svg { - width: 14px; - height: 14px; -} - -.workspace-skills[open] .workspace-disclosure-chevron { - transform: rotate(180deg); -} - -.workspace-skills-list { - padding: 1px 12px 12px 44px; + outline-offset: 2px; } .review-header { @@ -673,27 +633,7 @@ body { grid-column: 2; } - .workspace-skills-summary { - grid-template-columns: 22px minmax(0, 1fr) 18px; - gap: 2px 8px; - padding-block: 8px; - } - - .workspace-skills-summary .workspace-row-icon { - grid-row: 1 / span 2; - } - - .workspace-skills-summary .workspace-key, - .workspace-disclosure-action { - grid-column: 2; - } - - .workspace-disclosure-chevron { - grid-column: 3; - grid-row: 1 / span 2; - } - .workspace-skills-list { - padding-left: 42px; + grid-column: 2; } } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 081f1e82..41f71d1b 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -597,39 +597,46 @@ function appendWorkspaceSkills( container: HTMLElement, skills: NonNullable, ): void { - const details = element("details", { className: "workspace-skills" }); - const summary = element("summary", { className: "workspace-skills-summary" }); - const action = element("span", { className: "workspace-disclosure-action" }); - action.append( - element("span", { - className: "workspace-disclosure-closed", + const visibleCount = 5; + const row = element("div", { className: "workspace-row workspace-skills-row" }); + const chipList = element("span", { className: "workspace-chip-list workspace-skills-list" }); + const hiddenChips: HTMLElement[] = []; + + for (const [index, skill] of skills.entries()) { + const chip = element("span", { + className: `workspace-chip${index >= visibleCount ? " workspace-skill-extra" : ""}`, + text: skill.name ?? skill.path ?? "Unnamed skill", + title: skill.path, + }); + if (index >= visibleCount) { + chip.hidden = true; + hiddenChips.push(chip); + } + chipList.append(chip); + } + + if (hiddenChips.length > 0) { + const toggle = element("button", { + className: "workspace-skills-toggle", + type: "button", text: `View all ${skills.length}`, - }), - element("span", { - className: "workspace-disclosure-open", - text: "Hide skills", - }), - ); - const chevron = element("span", { - className: "workspace-disclosure-chevron", - ariaHidden: "true", - }); - chevron.append(renderIcon(toolIcons.chevronDown)); - summary.append( + ariaExpanded: "false", + }); + toggle.addEventListener("click", () => { + const nextExpanded = toggle.getAttribute("aria-expanded") !== "true"; + toggle.setAttribute("aria-expanded", String(nextExpanded)); + toggle.textContent = nextExpanded ? "Show less" : `View all ${skills.length}`; + for (const chip of hiddenChips) chip.hidden = !nextExpanded; + }); + chipList.append(toggle); + } + + row.append( renderWorkspaceRowIcon(toolIcons.skills), element("span", { className: "workspace-key", text: "Skills" }), - action, - chevron, + chipList, ); - - const chips = skills.map((skill) => ({ - label: skill.name ?? skill.path ?? "Unnamed skill", - title: skill.path, - })); - const chipList = renderWorkspaceChips(chips); - chipList.classList.add("workspace-skills-list"); - details.append(summary, chipList); - container.append(details); + container.append(row); } function renderWorkspaceRowIcon(icon: ToolIcon): HTMLElement { From d770997bd95778f92aec6ab27b6ccfd45eab526c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 18:16:30 +0530 Subject: [PATCH 09/24] fix(ui): clamp workspace detail rows --- src/ui/workspace-app.css | 72 +++++++++++++++++++---- src/ui/workspace-app.tsx | 123 +++++++++++++++++++++++++-------------- 2 files changed, 141 insertions(+), 54 deletions(-) diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 8b7993d6..ed1960cd 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -349,13 +349,15 @@ body { .workspace-chip-list { display: flex; min-width: 0; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; gap: 6px; + overflow: hidden; } .workspace-chip { display: inline-flex; + flex: 0 0 auto; max-width: 100%; min-height: 24px; align-items: center; @@ -377,11 +379,33 @@ body { opacity: 0.72; } +.workspace-row-disclosure { + align-items: start; +} + +.workspace-disclosure { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; +} + .workspace-skills-list { - align-items: center; + max-height: 24px; } -.workspace-skills-toggle { +.workspace-disclosure.expanded { + grid-template-columns: minmax(0, 1fr); +} + +.workspace-skills-disclosure.expanded .workspace-skills-list { + max-height: none; + flex-wrap: wrap; + overflow: visible; +} + +.workspace-disclosure-toggle { min-height: 24px; padding: 3px 9px; border: 1px solid color-mix(in srgb, var(--tool-accent) 28%, var(--tool-card-divider)); @@ -395,15 +419,46 @@ body { white-space: nowrap; } -.workspace-skills-toggle:hover { +.workspace-disclosure-toggle:hover { background: color-mix(in srgb, var(--tool-accent) 10%, transparent); } -.workspace-skills-toggle:focus-visible { +.workspace-disclosure-toggle:focus-visible { outline: 2px solid color-mix(in srgb, var(--tool-accent) 72%, transparent); outline-offset: 2px; } +.workspace-disclosure.expanded .workspace-disclosure-toggle { + justify-self: start; +} + +.workspace-value-list { + display: grid; + min-width: 0; + max-height: calc(1.45em); + overflow: hidden; + color: var(--color-text-secondary, #c7c7ce); + font-size: var(--font-text-sm-size, 12px); + line-height: 1.45; +} + +.workspace-value-list.mono { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); +} + +.workspace-value-item { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-disclosure.expanded .workspace-value-list { + max-height: none; + gap: 5px; + overflow: visible; +} + .review-header { grid-template-columns: 40px minmax(0, 1fr) auto 20px; } @@ -629,11 +684,8 @@ body { .workspace-row > .workspace-key, .workspace-row > .workspace-value, - .workspace-row > .workspace-chip-list { - grid-column: 2; - } - - .workspace-skills-list { + .workspace-row > .workspace-chip-list, + .workspace-row > .workspace-disclosure { grid-column: 2; } } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 41f71d1b..21d565fd 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -498,10 +498,10 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v ...(card.availableAgentsFiles ?? []).map((file) => file.path ?? "Nested instructions"), ]; if (instructionPaths.length > 0) { - appendWorkspaceTextRow( + appendWorkspaceTextListRow( rows, "Instructions", - compactList(instructionPaths), + instructionPaths, toolIcons.instructions, true, ); @@ -531,10 +531,10 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v return agent.provider ? `${name} · ${agent.provider}` : name; }); if (agentNames.length > 0) { - appendWorkspaceTextRow( + appendWorkspaceTextListRow( rows, "Agents", - compactList(agentNames), + agentNames, toolIcons.agents, ); } @@ -569,6 +569,44 @@ function appendWorkspaceTextRow( appendWorkspaceRow(container, label, content, icon); } +function appendWorkspaceTextListRow( + container: HTMLElement, + label: string, + values: string[], + icon: ToolIcon, + mono = false, +): void { + if (values.length === 1) { + appendWorkspaceTextRow(container, label, values[0], icon, mono); + return; + } + + const row = element("div", { className: "workspace-row workspace-row-disclosure" }); + const disclosure = element("span", { className: "workspace-disclosure" }); + const list = element("span", { + className: `workspace-value-list${mono ? " mono" : ""}`, + }); + for (const value of values) { + list.append(element("span", { + className: "workspace-value-item", + text: value, + title: value, + })); + } + + const toggle = renderWorkspaceDisclosureToggle(values.length, (nextExpanded) => { + disclosure.classList.toggle("expanded", nextExpanded); + row.classList.toggle("expanded", nextExpanded); + }); + disclosure.append(list, toggle); + row.append( + renderWorkspaceRowIcon(icon), + element("span", { className: "workspace-key", text: label }), + disclosure, + ); + container.append(row); +} + function appendWorkspaceChipRow( container: HTMLElement, label: string, @@ -597,48 +635,53 @@ function appendWorkspaceSkills( container: HTMLElement, skills: NonNullable, ): void { - const visibleCount = 5; - const row = element("div", { className: "workspace-row workspace-skills-row" }); - const chipList = element("span", { className: "workspace-chip-list workspace-skills-list" }); - const hiddenChips: HTMLElement[] = []; - - for (const [index, skill] of skills.entries()) { - const chip = element("span", { - className: `workspace-chip${index >= visibleCount ? " workspace-skill-extra" : ""}`, - text: skill.name ?? skill.path ?? "Unnamed skill", - title: skill.path, - }); - if (index >= visibleCount) { - chip.hidden = true; - hiddenChips.push(chip); - } - chipList.append(chip); + const skillChips = skills.map((skill) => ({ + label: skill.name ?? skill.path ?? "Unnamed skill", + title: skill.path, + })); + if (skillChips.length === 1) { + appendWorkspaceChipRow(container, "Skills", skillChips, toolIcons.skills); + return; } - if (hiddenChips.length > 0) { - const toggle = element("button", { - className: "workspace-skills-toggle", - type: "button", - text: `View all ${skills.length}`, - ariaExpanded: "false", - }); - toggle.addEventListener("click", () => { - const nextExpanded = toggle.getAttribute("aria-expanded") !== "true"; - toggle.setAttribute("aria-expanded", String(nextExpanded)); - toggle.textContent = nextExpanded ? "Show less" : `View all ${skills.length}`; - for (const chip of hiddenChips) chip.hidden = !nextExpanded; - }); - chipList.append(toggle); - } + const row = element("div", { className: "workspace-row workspace-row-disclosure" }); + const disclosure = element("span", { className: "workspace-disclosure workspace-skills-disclosure" }); + const chipList = renderWorkspaceChips(skillChips); + chipList.classList.add("workspace-skills-list"); + + const toggle = renderWorkspaceDisclosureToggle(skills.length, (nextExpanded) => { + disclosure.classList.toggle("expanded", nextExpanded); + row.classList.toggle("expanded", nextExpanded); + }); + disclosure.append(chipList, toggle); row.append( renderWorkspaceRowIcon(toolIcons.skills), element("span", { className: "workspace-key", text: "Skills" }), - chipList, + disclosure, ); container.append(row); } +function renderWorkspaceDisclosureToggle( + total: number, + onToggle: (expanded: boolean) => void, +): HTMLButtonElement { + const toggle = element("button", { + className: "workspace-disclosure-toggle", + type: "button", + text: `View all ${total}`, + ariaExpanded: "false", + }); + toggle.addEventListener("click", () => { + const nextExpanded = toggle.getAttribute("aria-expanded") !== "true"; + toggle.setAttribute("aria-expanded", String(nextExpanded)); + toggle.textContent = nextExpanded ? "Show less" : `View all ${total}`; + onToggle(nextExpanded); + }); + return toggle; +} + function renderWorkspaceRowIcon(icon: ToolIcon): HTMLElement { const wrapper = element("span", { className: "workspace-row-icon", @@ -660,14 +703,6 @@ function renderWorkspaceChips(chips: WorkspaceChip[]): HTMLElement { return list; } -function compactList(values: string[], visibleCount = 5): string { - const visible = values.slice(0, visibleCount); - const hiddenCount = values.length - visible.length; - return hiddenCount > 0 - ? `${visible.join(" · ")} · +${hiddenCount} more` - : visible.join(" · "); -} - function toolNameFromMeta(result: CallToolResult): ToolName | undefined { const meta = result._meta as Record | undefined; const tool = meta?.tool; From 20a4a8603343f33ecdbf2bfacb9762f30239c659 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 18:23:10 +0530 Subject: [PATCH 10/24] fix(ui): preserve workspace disclosure state --- src/ui/workspace-app.tsx | 49 ++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 21d565fd..c7a00b00 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -48,6 +48,9 @@ let errorMessage: string | null = null; let currentPayload: MountedPayload | null = null; let currentPayloadContainer: HTMLElement | null = null; +type WorkspaceDisclosureKey = "instructions" | "skills" | "agents"; +const expandedWorkspaceDisclosures = new Set(); + const maybeAppRoot = document.querySelector("#app"); if (!maybeAppRoot) { @@ -78,6 +81,7 @@ async function boot(): Promise { card = null; expanded = false; reviewFilesExpanded = false; + expandedWorkspaceDisclosures.clear(); errorMessage = "No result card is available for this tool result."; render(); return; @@ -87,6 +91,7 @@ async function boot(): Promise { card = nextCard; expanded = isReviewTool(tool) && isExpandableCard(nextCard); reviewFilesExpanded = false; + expandedWorkspaceDisclosures.clear(); errorMessage = null; render(); }; @@ -97,7 +102,9 @@ async function boot(): Promise { ...ctx, }; applyHostContext(); - renderPayloadIfNeeded(); + // Workspace details inherit host variables directly. Rebuilding their DOM on + // iframe resize would reset an in-progress disclosure interaction. + if (card?.tool !== "open_workspace") renderPayloadIfNeeded(); }; app.onteardown = async () => { @@ -503,6 +510,7 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v "Instructions", instructionPaths, toolIcons.instructions, + "instructions", true, ); } @@ -536,6 +544,7 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v "Agents", agentNames, toolIcons.agents, + "agents", ); } @@ -574,6 +583,7 @@ function appendWorkspaceTextListRow( label: string, values: string[], icon: ToolIcon, + disclosureKey: WorkspaceDisclosureKey, mono = false, ): void { if (values.length === 1) { @@ -581,8 +591,13 @@ function appendWorkspaceTextListRow( return; } - const row = element("div", { className: "workspace-row workspace-row-disclosure" }); - const disclosure = element("span", { className: "workspace-disclosure" }); + const initiallyExpanded = expandedWorkspaceDisclosures.has(disclosureKey); + const row = element("div", { + className: `workspace-row workspace-row-disclosure${initiallyExpanded ? " expanded" : ""}`, + }); + const disclosure = element("span", { + className: `workspace-disclosure${initiallyExpanded ? " expanded" : ""}`, + }); const list = element("span", { className: `workspace-value-list${mono ? " mono" : ""}`, }); @@ -594,9 +609,14 @@ function appendWorkspaceTextListRow( })); } - const toggle = renderWorkspaceDisclosureToggle(values.length, (nextExpanded) => { + const toggle = renderWorkspaceDisclosureToggle(values.length, initiallyExpanded, (nextExpanded) => { disclosure.classList.toggle("expanded", nextExpanded); row.classList.toggle("expanded", nextExpanded); + if (nextExpanded) { + expandedWorkspaceDisclosures.add(disclosureKey); + } else { + expandedWorkspaceDisclosures.delete(disclosureKey); + } }); disclosure.append(list, toggle); row.append( @@ -644,14 +664,24 @@ function appendWorkspaceSkills( return; } - const row = element("div", { className: "workspace-row workspace-row-disclosure" }); - const disclosure = element("span", { className: "workspace-disclosure workspace-skills-disclosure" }); + const initiallyExpanded = expandedWorkspaceDisclosures.has("skills"); + const row = element("div", { + className: `workspace-row workspace-row-disclosure${initiallyExpanded ? " expanded" : ""}`, + }); + const disclosure = element("span", { + className: `workspace-disclosure workspace-skills-disclosure${initiallyExpanded ? " expanded" : ""}`, + }); const chipList = renderWorkspaceChips(skillChips); chipList.classList.add("workspace-skills-list"); - const toggle = renderWorkspaceDisclosureToggle(skills.length, (nextExpanded) => { + const toggle = renderWorkspaceDisclosureToggle(skills.length, initiallyExpanded, (nextExpanded) => { disclosure.classList.toggle("expanded", nextExpanded); row.classList.toggle("expanded", nextExpanded); + if (nextExpanded) { + expandedWorkspaceDisclosures.add("skills"); + } else { + expandedWorkspaceDisclosures.delete("skills"); + } }); disclosure.append(chipList, toggle); @@ -665,13 +695,14 @@ function appendWorkspaceSkills( function renderWorkspaceDisclosureToggle( total: number, + expanded: boolean, onToggle: (expanded: boolean) => void, ): HTMLButtonElement { const toggle = element("button", { className: "workspace-disclosure-toggle", type: "button", - text: `View all ${total}`, - ariaExpanded: "false", + text: expanded ? "Show less" : `View all ${total}`, + ariaExpanded: String(expanded), }); toggle.addEventListener("click", () => { const nextExpanded = toggle.getAttribute("aria-expanded") !== "true"; From 54f88c529bb062f564a52d9dfb081eeb7b1fe444 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 19:47:44 +0530 Subject: [PATCH 11/24] fix(ui): align diff file rows --- src/ui/workspace-app.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index ed1960cd..76b050de 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -531,6 +531,7 @@ body { .review-diff-file-header { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; + align-items: center; gap: 10px; width: 100%; min-height: 42px; @@ -547,6 +548,7 @@ body { display: grid; width: 20px; height: 20px; + align-self: center; place-items: center; border-radius: 6px; background: color-mix(in srgb, var(--color-text-tertiary, #a3a3aa) 10%, transparent); @@ -585,6 +587,7 @@ body { .review-diff-file-stats { overflow: hidden; font-size: var(--font-text-sm-size, 13px); + line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } From 97f8b8dda17ee863f3cbe339c457bb14da43aa51 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 20:36:12 +0530 Subject: [PATCH 12/24] style(ui): use precise webkit scrollbars --- src/ui/scrollbar.ts | 65 ++++++++++++++++------------------------ src/ui/workspace-app.css | 64 ++++++++++++++++----------------------- 2 files changed, 51 insertions(+), 78 deletions(-) diff --git a/src/ui/scrollbar.ts b/src/ui/scrollbar.ts index d3cf21ac..518da5a3 100644 --- a/src/ui/scrollbar.ts +++ b/src/ui/scrollbar.ts @@ -1,55 +1,42 @@ export const pierrePrettyScrollbarCss = ` :host { - --diffs-scrollbar-gutter-override: 10px; + --diffs-scrollbar-gutter-override: 12px; } [data-code] { scrollbar-gutter: auto; } -[data-code]::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -[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: transparent; - background-clip: content-box; - border: 3px solid transparent; - border-radius: 9999px; -} - -:host(:hover) [data-code]::-webkit-scrollbar-thumb, -:host(:focus-within) [data-code]::-webkit-scrollbar-thumb { - background-color: var(--scrollbar-thumb, rgb(100 116 139 / 55%)); -} - -[data-code]::-webkit-scrollbar-thumb:hover, -[data-code]::-webkit-scrollbar-thumb:active { - background-color: var(--scrollbar-thumb-hover, rgb(71 85 105 / 85%)); -} +@supports selector(::-webkit-scrollbar) { + [data-code]::-webkit-scrollbar { + width: 12px; + height: 12px; + } -[data-code]::-webkit-scrollbar-corner { - background: transparent; -} + [data-code]::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; + } -:host(:hover) { - --scrollbar-hover-repaint: ; -} + [data-code]::-webkit-scrollbar-track { + background: transparent; + } -@media (hover: none) { [data-code]::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb, rgb(100 116 139 / 55%)); + background-clip: content-box; + border: 4px solid transparent; + border-radius: 9999px; + } + + [data-code]::-webkit-scrollbar-thumb:hover, + [data-code]::-webkit-scrollbar-thumb:active { + background-color: var(--scrollbar-thumb-hover, rgb(71 85 105 / 85%)); + } + + [data-code]::-webkit-scrollbar-corner { + background: transparent; } } `; diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 76b050de..9056cb17 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -81,50 +81,36 @@ body { --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 30%, #38bdf8 70%); } -.pretty-scrollbar::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -.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: transparent; - background-clip: content-box; - border: 3px solid transparent; - border-radius: 9999px; -} - -.pretty-scrollbar:hover::-webkit-scrollbar-thumb, -.pretty-scrollbar:focus::-webkit-scrollbar-thumb, -.pretty-scrollbar:focus-within::-webkit-scrollbar-thumb { - background-color: var(--scrollbar-thumb); -} - -.pretty-scrollbar::-webkit-scrollbar-thumb:hover, -.pretty-scrollbar::-webkit-scrollbar-thumb:active { - background-color: var(--scrollbar-thumb-hover); -} +@supports selector(::-webkit-scrollbar) { + .pretty-scrollbar::-webkit-scrollbar { + width: 12px; + height: 12px; + } -.pretty-scrollbar::-webkit-scrollbar-corner { - background: transparent; -} + .pretty-scrollbar::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; + } -.pretty-scrollbar:hover { - --scrollbar-hover-repaint: ; -} + .pretty-scrollbar::-webkit-scrollbar-track { + background: transparent; + } -@media (hover: none) { .pretty-scrollbar::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb); + background-clip: content-box; + border: 4px solid transparent; + border-radius: 9999px; + } + + .pretty-scrollbar::-webkit-scrollbar-thumb:hover, + .pretty-scrollbar::-webkit-scrollbar-thumb:active { + background-color: var(--scrollbar-thumb-hover); + } + + .pretty-scrollbar::-webkit-scrollbar-corner { + background: transparent; } } From 210324a9d2235ecf9488948203c200db4e6dd64a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 20:41:53 +0530 Subject: [PATCH 13/24] style(ui): neutralize scrollbar thumb --- src/ui/scrollbar.ts | 4 ++-- src/ui/workspace-app.css | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ui/scrollbar.ts b/src/ui/scrollbar.ts index 518da5a3..d5dc7493 100644 --- a/src/ui/scrollbar.ts +++ b/src/ui/scrollbar.ts @@ -24,7 +24,7 @@ export const pierrePrettyScrollbarCss = ` } [data-code]::-webkit-scrollbar-thumb { - background-color: var(--scrollbar-thumb, rgb(100 116 139 / 55%)); + background-color: var(--scrollbar-thumb, rgb(128 128 128 / 55%)); background-clip: content-box; border: 4px solid transparent; border-radius: 9999px; @@ -32,7 +32,7 @@ export const pierrePrettyScrollbarCss = ` [data-code]::-webkit-scrollbar-thumb:hover, [data-code]::-webkit-scrollbar-thumb:active { - background-color: var(--scrollbar-thumb-hover, rgb(71 85 105 / 85%)); + background-color: var(--scrollbar-thumb-hover, rgb(112 112 112 / 85%)); } [data-code]::-webkit-scrollbar-corner { diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 9056cb17..363489eb 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -10,8 +10,8 @@ --tool-card-divider: color-mix(in srgb, var(--color-border-primary, #414141) 66%, transparent); --tool-accent: var(--color-text-secondary, #b6b6bd); --tool-accent-soft: color-mix(in srgb, var(--tool-accent) 12%, transparent); - --scrollbar-thumb: rgb(100 116 139 / 55%); - --scrollbar-thumb-hover: rgb(71 85 105 / 85%); + --scrollbar-thumb: color-mix(in srgb, var(--color-text-tertiary, #8a8a8a) 56%, transparent); + --scrollbar-thumb-hover: color-mix(in srgb, var(--color-text-secondary, #a8a8a8) 82%, transparent); } * { From 9cce6f4371561ba40580623d6877514216bbb2f4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 20:59:43 +0530 Subject: [PATCH 14/24] fix(ui): classify patch file operations --- src/apply-patch.test.ts | 5 +++- src/apply-patch.ts | 2 +- src/ui/patch-display.test.ts | 44 +++++++++++++++++++++++++++++++++++- src/ui/patch-display.ts | 37 ++++++++++++++++++++++++++++++ src/ui/review-payload.tsx | 24 ++++++++------------ 5 files changed, 95 insertions(+), 17 deletions(-) diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index c6f08695..3a846f4f 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -191,13 +191,16 @@ assert.throws( const overwriteRoot = await mkdtemp(join(tmpdir(), "devspace-apply-patch-overwrite-")); await writeFile(join(overwriteRoot, "duplicate.txt"), "old content\n"); -await applyPatch( +const overwriteResult = await applyPatch( overwriteRoot, `*** Begin Patch *** Add File: duplicate.txt +new content *** End Patch`, ); +assert.deepEqual(overwriteResult.files, [ + { path: "duplicate.txt", operation: "update" }, +]); assert.equal(await readFile(join(overwriteRoot, "duplicate.txt"), "utf8"), "new content\n"); await writeFile(join(overwriteRoot, "source.txt"), "from\n"); diff --git a/src/apply-patch.ts b/src/apply-patch.ts index 32ec4a46..05a73de6 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -365,7 +365,7 @@ export async function applyPatch(root: string, patch: string): Promise, + parsedFile: Pick, + index: number, +): FileChangeKind { + const parsedKind = getFileChangeKind(parsedFile); + + // The diff parser is authoritative for additions, deletions, and native Git + // rename metadata. This also keeps repeated operations on the same path from + // reusing the first matching card entry. + if (parsedKind !== "edited" && parsedKind !== "unknown") return parsedKind; + + // apply_patch emits one card file per generated diff in the same order. Its + // move patch currently lacks Git rename metadata, so preserve the explicit + // move operation when the destination lines up with the parsed diff. + const indexedFile = files[index]; + if ( + indexedFile?.operation === "move" && + (!parsedFile.path || indexedFile.path === parsedFile.path) + ) { + return "renamed"; + } + + const movedFile = files.find((file) => ( + file.operation === "move" && + file.path === parsedFile.path && + (!parsedFile.previousPath || file.previousPath === parsedFile.previousPath) + )); + if (movedFile) return "renamed"; + + // A parsed content change is more accurate than an "add" directive that + // overwrote an existing file. + if (parsedKind === "edited") return "edited"; + + return indexedFile ? getFileChangeKind(indexedFile) : "unknown"; +} + export function fileChangeKindLabel(kind: FileChangeKind): string { return kind === "unknown" ? "Changed" : fileChangeLabels[kind]; } diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index b38779b4..19120131 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -5,7 +5,7 @@ import { FileDiff } from "@pierre/diffs/react"; import type { HostContext, ToolResultCard } from "./card-types.js"; import { fileChangeKindLabel, - getFileChangeKind, + getRenderedFileChangeKind, type FileChangeKind, } from "./patch-display.js"; import { pierrePrettyScrollbarCss } from "./scrollbar.js"; @@ -80,7 +80,15 @@ function ReviewPayload({ const key = fileDiff.cacheKey ?? `${fileDiff.prevName ?? ""}->${fileDiff.name}-${index}`; const stats = diffStats(fileDiff); const isOpen = openFiles.has(key); - const changeKind = fileChangeKind(card.files ?? [], fileDiff); + const changeKind = getRenderedFileChangeKind( + card.files ?? [], + { + path: fileDiff.name, + previousPath: fileDiff.prevName, + type: fileDiff.type, + }, + index, + ); return (
@@ -126,18 +134,6 @@ function ReviewPayload({ ); } -function fileChangeKind( - files: NonNullable, - fileDiff: FileDiffMetadata, -): FileChangeKind { - const cardFile = files.find((file) => ( - file.path === fileDiff.name || - file.previousPath === fileDiff.prevName || - file.path === fileDiff.prevName - )); - return cardFile ? getFileChangeKind(cardFile) : "unknown"; -} - function fileChangeSymbol(kind: FileChangeKind): string { switch (kind) { case "added": From 3f4e54858236dcb61477de1ebdc4980d70d4d355 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 5 Aug 2026 21:37:22 +0530 Subject: [PATCH 15/24] feat(ui): expand workspace instructions --- src/ui/icons.ts | 3 + src/ui/workspace-app.css | 175 ++++++++++++++++++++++++++++ src/ui/workspace-app.tsx | 242 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 404 insertions(+), 16 deletions(-) diff --git a/src/ui/icons.ts b/src/ui/icons.ts index a43edfa7..a108af20 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -4,6 +4,7 @@ import { ChevronDown, Cpu, FileDiff, + FileCheck2, FileMinus, FilePenLine, FilePlus, @@ -34,6 +35,8 @@ export const toolIcons = { folderTree: FolderTree, gitBranch: GitBranch, instructions: FileText, + instructionAvailable: FileText, + instructionLoaded: FileCheck2, loading: LoaderCircle, providers: Cpu, readFile: FileText, diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 363489eb..6a3b5a5a 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -445,6 +445,181 @@ body { overflow: visible; } +.workspace-instruction-summary { + display: flex; + min-width: 0; + height: 24px; + align-items: center; + gap: 6px; + overflow: hidden; +} + +.workspace-instruction-summary-item { + display: inline-flex; + min-width: 0; + flex: 0 1 auto; + align-items: center; + gap: 5px; + overflow: hidden; + padding: 2px 7px 2px 5px; + border: 1px solid color-mix(in srgb, var(--tool-accent) 14%, var(--tool-card-divider)); + border-radius: 9999px; + background: color-mix(in srgb, var(--tool-accent) 6%, transparent); + color: var(--color-text-secondary, #c7c7ce); + font-size: var(--font-text-sm-size, 11px); + line-height: 1.25; +} + +.workspace-instruction-summary-item.available { + border-style: dashed; + color: var(--color-text-tertiary, #a3a3aa); +} + +.workspace-instruction-summary-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-instruction-status { + display: grid; + width: 18px; + height: 18px; + flex: 0 0 auto; + place-items: center; + border-radius: 5px; +} + +.workspace-instruction-status.loaded { + background: color-mix(in srgb, var(--color-success-text, #6fda83) 12%, transparent); + color: var(--color-success-text, #6fda83); +} + +.workspace-instruction-status.available { + background: color-mix(in srgb, var(--color-text-tertiary, #a3a3aa) 9%, transparent); + color: var(--color-text-tertiary, #a3a3aa); +} + +.workspace-instruction-status-svg { + width: 12px; + height: 12px; + stroke-width: 1.9; +} + +.workspace-instruction-list { + display: none; + min-width: 0; + overflow: hidden; + border: 1px solid var(--tool-card-divider); + border-radius: 9px; + background: color-mix(in srgb, var(--tool-card-body-bg) 88%, transparent); +} + +.workspace-instructions-disclosure.expanded .workspace-instruction-summary { + display: none; +} + +.workspace-instructions-disclosure.expanded .workspace-instruction-list { + display: grid; +} + +.workspace-instruction-item + .workspace-instruction-item { + border-top: 1px solid var(--tool-card-divider); +} + +.workspace-instruction-header { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: 22px minmax(0, 1fr) 18px; + align-items: center; + gap: 9px; + padding: 8px 10px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; +} + +.workspace-instruction-header.interactive { + cursor: pointer; +} + +.workspace-instruction-header.interactive:hover { + background: var(--tool-card-hover-bg); +} + +.workspace-instruction-header.interactive:focus-visible { + outline: 2px solid color-mix(in srgb, var(--tool-accent) 72%, transparent); + outline-offset: -2px; +} + +.workspace-instruction-text { + display: grid; + min-width: 0; + gap: 2px; +} + +.workspace-instruction-name { + min-width: 0; + overflow: hidden; + color: var(--color-text-primary, #f5f5f6); + font-size: var(--font-text-sm-size, 12px); + font-weight: 550; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-instruction-path { + min-width: 0; + overflow: hidden; + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: var(--font-text-sm-size, 10px); + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-instruction-chevron { + display: grid; + width: 18px; + height: 18px; + place-items: center; + color: var(--color-text-tertiary, #a3a3aa); + transition: transform 140ms ease; +} + +.workspace-instruction-item.expanded .workspace-instruction-chevron { + transform: rotate(180deg); +} + +.workspace-instruction-chevron-svg { + width: 14px; + height: 14px; +} + +.workspace-instruction-preview { + max-height: 300px; + margin: 0; + overflow: auto; + padding: 10px 12px; + border-top: 1px solid var(--tool-card-divider); + background: color-mix(in srgb, var(--color-background-primary, #101114) 92%, transparent); + color: var(--color-text-secondary, #c7c7ce); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: var(--font-text-sm-size, 11px); + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.workspace-instruction-preview[hidden] { + display: none; +} + .review-header { grid-template-columns: 40px minmax(0, 1fr) auto 20px; } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index c7a00b00..40585bb5 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -47,6 +47,7 @@ let reviewFilesExpanded = false; let errorMessage: string | null = null; let currentPayload: MountedPayload | null = null; let currentPayloadContainer: HTMLElement | null = null; +let openWorkspaceInstructionPath: string | null = null; type WorkspaceDisclosureKey = "instructions" | "skills" | "agents"; const expandedWorkspaceDisclosures = new Set(); @@ -82,6 +83,7 @@ async function boot(): Promise { expanded = false; reviewFilesExpanded = false; expandedWorkspaceDisclosures.clear(); + openWorkspaceInstructionPath = null; errorMessage = "No result card is available for this tool result."; render(); return; @@ -92,6 +94,7 @@ async function boot(): Promise { expanded = isReviewTool(tool) && isExpandableCard(nextCard); reviewFilesExpanded = false; expandedWorkspaceDisclosures.clear(); + openWorkspaceInstructionPath = null; errorMessage = null; render(); }; @@ -500,20 +503,11 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v ); } - const instructionPaths = [ - ...(card.agentsFiles ?? []).map((file) => file.path ?? "AGENTS.md"), - ...(card.availableAgentsFiles ?? []).map((file) => file.path ?? "Nested instructions"), - ]; - if (instructionPaths.length > 0) { - appendWorkspaceTextListRow( - rows, - "Instructions", - instructionPaths, - toolIcons.instructions, - "instructions", - true, - ); - } + appendWorkspaceInstructions( + rows, + card.agentsFiles ?? [], + card.availableAgentsFiles ?? [], + ); const skills = card.skills ?? []; if (skills.length > 0) { @@ -563,6 +557,219 @@ interface WorkspaceChip { tone?: "muted"; } +interface WorkspaceInstruction { + path: string; + content?: string; + status: "loaded" | "available"; +} + +function appendWorkspaceInstructions( + container: HTMLElement, + loadedFiles: NonNullable, + availableFiles: NonNullable, +): void { + const loaded: WorkspaceInstruction[] = []; + const loadedPaths = new Set(); + for (const file of loadedFiles) { + const path = file.path ?? "AGENTS.md"; + loaded.push({ + path, + content: file.content, + status: "loaded", + }); + loadedPaths.add(path); + } + + const available: WorkspaceInstruction[] = []; + for (const file of availableFiles) { + const path = file.path ?? "Nested instructions"; + if (!loadedPaths.has(path)) available.push({ path, status: "available" }); + } + const instructions: WorkspaceInstruction[] = [...loaded, ...available]; + + if (instructions.length === 0) return; + + const canPreview = loaded.some((file) => file.content !== undefined); + const canExpand = instructions.length > 1 || canPreview; + const initiallyExpanded = canExpand && expandedWorkspaceDisclosures.has("instructions"); + const row = element("div", { + className: `workspace-row workspace-row-disclosure workspace-instructions-row${initiallyExpanded ? " expanded" : ""}`, + }); + const disclosure = element("span", { + className: `workspace-disclosure workspace-instructions-disclosure${initiallyExpanded ? " expanded" : ""}`, + }); + const summary = renderWorkspaceInstructionSummary(instructions); + disclosure.append(summary); + + if (canExpand) { + const list = renderWorkspaceInstructionList(instructions); + disclosure.append(list); + const toggle = renderWorkspaceDisclosureToggle( + instructions.length, + initiallyExpanded, + (nextExpanded) => { + disclosure.classList.toggle("expanded", nextExpanded); + row.classList.toggle("expanded", nextExpanded); + if (nextExpanded) { + expandedWorkspaceDisclosures.add("instructions"); + } else { + expandedWorkspaceDisclosures.delete("instructions"); + openWorkspaceInstructionPath = null; + syncWorkspaceInstructionPreviews(list); + } + }, + instructions.length === 1 ? "View" : undefined, + ); + disclosure.append(toggle); + } + + row.append( + renderWorkspaceRowIcon(toolIcons.instructions), + element("span", { className: "workspace-key", text: "Instructions" }), + disclosure, + ); + container.append(row); +} + +function renderWorkspaceInstructionSummary( + instructions: WorkspaceInstruction[], +): HTMLElement { + const summary = element("span", { className: "workspace-instruction-summary" }); + const basenameCounts = new Map(); + for (const instruction of instructions) { + const basename = workspacePathBasename(instruction.path); + basenameCounts.set(basename, (basenameCounts.get(basename) ?? 0) + 1); + } + + for (const instruction of instructions) { + const basename = workspacePathBasename(instruction.path); + const item = element("span", { + className: `workspace-instruction-summary-item ${instruction.status}`, + title: `${instructionStatusLabel(instruction.status)}: ${instruction.path}`, + }); + item.append( + renderWorkspaceInstructionStatus(instruction.status), + element("span", { + className: "workspace-instruction-summary-name", + text: basenameCounts.get(basename) === 1 + ? basename + : workspaceCompactPath(instruction.path), + }), + ); + summary.append(item); + } + return summary; +} + +function renderWorkspaceInstructionList( + instructions: WorkspaceInstruction[], +): HTMLElement { + const list = element("span", { className: "workspace-instruction-list" }); + + for (const instruction of instructions) { + const item = element("span", { className: "workspace-instruction-item" }); + item.dataset.instructionPath = instruction.path; + const hasContent = instruction.status === "loaded" && instruction.content !== undefined; + const header = element(hasContent ? "button" : "span", { + className: `workspace-instruction-header${hasContent ? " interactive" : ""}`, + type: hasContent ? "button" : undefined, + ariaLabel: hasContent ? `View loaded instruction ${instruction.path}` : undefined, + ariaExpanded: hasContent ? "false" : undefined, + }); + const text = element("span", { className: "workspace-instruction-text" }); + const basename = workspacePathBasename(instruction.path); + text.append(element("span", { + className: "workspace-instruction-name", + text: basename, + })); + if (instruction.path !== basename) { + text.append(element("span", { + className: "workspace-instruction-path", + text: instruction.path, + title: instruction.path, + })); + } + + header.append( + renderWorkspaceInstructionStatus(instruction.status), + text, + ); + + if (hasContent) { + const chevron = element("span", { + className: "workspace-instruction-chevron", + ariaHidden: "true", + }); + chevron.append(renderIcon(toolIcons.chevronDown, "workspace-instruction-chevron-svg")); + header.append(chevron); + header.addEventListener("click", () => { + openWorkspaceInstructionPath = openWorkspaceInstructionPath === instruction.path + ? null + : instruction.path; + syncWorkspaceInstructionPreviews(list); + }); + + const preview = element("pre", { + className: "workspace-instruction-preview pretty-scrollbar", + text: instruction.content, + }); + preview.hidden = true; + item.append(header, preview); + } else { + item.append(header); + } + + list.append(item); + } + + syncWorkspaceInstructionPreviews(list); + return list; +} + +function syncWorkspaceInstructionPreviews(list: HTMLElement): void { + for (const item of list.querySelectorAll(".workspace-instruction-item")) { + const isOpen = item.dataset.instructionPath === openWorkspaceInstructionPath; + item.classList.toggle("expanded", isOpen); + const header = item.querySelector(".workspace-instruction-header.interactive"); + header?.setAttribute("aria-expanded", String(isOpen)); + const preview = item.querySelector(".workspace-instruction-preview"); + if (preview) preview.hidden = !isOpen; + } +} + +function renderWorkspaceInstructionStatus( + status: WorkspaceInstruction["status"], +): HTMLElement { + const label = instructionStatusLabel(status); + const wrapper = element("span", { + className: `workspace-instruction-status ${status}`, + title: label, + ariaLabel: label, + }); + wrapper.setAttribute("role", "img"); + wrapper.append(renderIcon( + status === "loaded" ? toolIcons.instructionLoaded : toolIcons.instructionAvailable, + "workspace-instruction-status-svg", + )); + return wrapper; +} + +function instructionStatusLabel(status: WorkspaceInstruction["status"]): string { + return status === "loaded" + ? "Loaded into the current workspace context" + : "Available for a nested directory"; +} + +function workspacePathBasename(path: string): string { + const parts = path.replaceAll("\\", "/").split("/").filter(Boolean); + return parts.at(-1) ?? path; +} + +function workspaceCompactPath(path: string): string { + const parts = path.replaceAll("\\", "/").split("/").filter(Boolean); + return parts.slice(-2).join("/") || path; +} + function appendWorkspaceTextRow( container: HTMLElement, label: string, @@ -697,17 +904,18 @@ function renderWorkspaceDisclosureToggle( total: number, expanded: boolean, onToggle: (expanded: boolean) => void, + collapsedLabel = `View all ${total}`, ): HTMLButtonElement { const toggle = element("button", { className: "workspace-disclosure-toggle", type: "button", - text: expanded ? "Show less" : `View all ${total}`, + text: expanded ? "Show less" : collapsedLabel, ariaExpanded: String(expanded), }); toggle.addEventListener("click", () => { const nextExpanded = toggle.getAttribute("aria-expanded") !== "true"; toggle.setAttribute("aria-expanded", String(nextExpanded)); - toggle.textContent = nextExpanded ? "Show less" : `View all ${total}`; + toggle.textContent = nextExpanded ? "Show less" : collapsedLabel; onToggle(nextExpanded); }); return toggle; @@ -758,6 +966,7 @@ function element( type?: string; title?: string; ariaHidden?: string; + ariaLabel?: string; ariaExpanded?: string; disabled?: boolean; } = {}, @@ -768,6 +977,7 @@ function element( if (options.type !== undefined && "type" in node) node.setAttribute("type", options.type); if (options.title !== undefined) node.title = options.title; if (options.ariaHidden !== undefined) node.setAttribute("aria-hidden", options.ariaHidden); + if (options.ariaLabel !== undefined) node.setAttribute("aria-label", options.ariaLabel); if (options.ariaExpanded !== undefined) node.setAttribute("aria-expanded", options.ariaExpanded); if (options.disabled !== undefined && "disabled" in node) { (node as HTMLButtonElement).disabled = options.disabled; From 363c69a839ad850b5f2c0fa9d0f7c3b4826b52ec Mon Sep 17 00:00:00 2001 From: Waishnav Date: Thu, 6 Aug 2026 01:50:05 +0530 Subject: [PATCH 16/24] feat(ui): show rename source paths --- src/ui/patch-display.test.ts | 44 +++++++++++++++++++++++++++++- src/ui/patch-display.ts | 53 ++++++++++++++++++++++++++++++++++++ src/ui/review-payload.tsx | 29 +++++++++++++++++++- src/ui/tool-display.test.ts | 12 ++++++++ src/ui/tool-display.ts | 9 ++++-- src/ui/workspace-app.css | 30 ++++++++++++++++++++ 6 files changed, 173 insertions(+), 4 deletions(-) diff --git a/src/ui/patch-display.test.ts b/src/ui/patch-display.test.ts index d84413dd..abfea2e0 100644 --- a/src/ui/patch-display.test.ts +++ b/src/ui/patch-display.test.ts @@ -1,5 +1,10 @@ import assert from "node:assert/strict"; -import { getPatchDisplayParts, getRenderedFileChangeKind } from "./patch-display.js"; +import { + getFileChangePathDisplay, + getPatchDisplayParts, + getRenderedFileChangeKind, + getRenderedFileChangePathDisplay, +} from "./patch-display.js"; assert.deepEqual(getPatchDisplayParts({}), { title: "Applied patch", @@ -29,6 +34,43 @@ assert.deepEqual( }, ); +assert.deepEqual( + getFileChangePathDisplay({ + path: "src/new-name.ts", + previousPath: "src/old-name.ts", + }), + { + current: "new-name.ts", + previous: "old-name.ts", + title: "src/old-name.ts → src/new-name.ts", + }, +); + +assert.deepEqual( + getFileChangePathDisplay({ + path: "packages/new/file.ts", + previousPath: "src/old/file.ts", + }), + { + current: "packages/new/file.ts", + previous: "src/old/file.ts", + title: "src/old/file.ts → packages/new/file.ts", + }, +); + +assert.deepEqual( + getRenderedFileChangePathDisplay( + [{ path: "src/new-name.ts", previousPath: "src/old-name.ts", operation: "move" }], + { path: "src/new-name.ts" }, + 0, + ), + { + current: "new-name.ts", + previous: "old-name.ts", + title: "src/old-name.ts → src/new-name.ts", + }, +); + assert.equal( getRenderedFileChangeKind( [ diff --git a/src/ui/patch-display.ts b/src/ui/patch-display.ts index d66b918c..265c335e 100644 --- a/src/ui/patch-display.ts +++ b/src/ui/patch-display.ts @@ -16,6 +16,12 @@ export interface PatchDisplayParts { tone: "edit" | "write" | "delete"; } +export interface FileChangePathDisplay { + current: string; + previous?: string; + title: string; +} + const fileChangeLabels: Record, string> = { added: "Added", edited: "Edited", @@ -111,6 +117,43 @@ export function getRenderedFileChangeKind( return indexedFile ? getFileChangeKind(indexedFile) : "unknown"; } +export function getFileChangePathDisplay( + file: Pick, +): FileChangePathDisplay | undefined { + const current = file.path ?? file.previousPath; + if (!current) return undefined; + + const previous = file.previousPath; + if (!previous || previous === current) { + return { current, title: current }; + } + + const sameDirectory = pathDirectory(previous) === pathDirectory(current); + return { + current: sameDirectory ? pathBasename(current) : current, + previous: sameDirectory ? pathBasename(previous) : previous, + title: `${previous} → ${current}`, + }; +} + +export function getRenderedFileChangePathDisplay( + files: NonNullable, + parsedFile: Pick, + index: number, +): FileChangePathDisplay | undefined { + const indexedFile = files[index]; + const matchedFile = files.find((file) => ( + file.path === parsedFile.path && + (!parsedFile.previousPath || !file.previousPath || file.previousPath === parsedFile.previousPath) + )); + const cardFile = matchedFile ?? indexedFile; + + return getFileChangePathDisplay({ + path: parsedFile.path ?? cardFile?.path, + previousPath: parsedFile.previousPath ?? cardFile?.previousPath, + }); +} + export function fileChangeKindLabel(kind: FileChangeKind): string { return kind === "unknown" ? "Changed" : fileChangeLabels[kind]; } @@ -148,3 +191,13 @@ function changeTone(kind: FileChangeKind | undefined): PatchDisplayParts["tone"] function fileNoun(fileCount: number): "file" | "files" { return fileCount === 1 ? "file" : "files"; } + +function pathDirectory(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex === -1 ? "" : path.slice(0, separatorIndex); +} + +function pathBasename(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex === -1 ? path : path.slice(separatorIndex + 1); +} diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index 19120131..f298cf14 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -5,6 +5,7 @@ import { FileDiff } from "@pierre/diffs/react"; import type { HostContext, ToolResultCard } from "./card-types.js"; import { fileChangeKindLabel, + getRenderedFileChangePathDisplay, getRenderedFileChangeKind, type FileChangeKind, } from "./patch-display.js"; @@ -89,6 +90,14 @@ function ReviewPayload({ }, index, ); + const pathDisplay = getRenderedFileChangePathDisplay( + card.files ?? [], + { + path: fileDiff.name, + previousPath: fileDiff.prevName, + }, + index, + ); return (
@@ -113,7 +122,25 @@ function ReviewPayload({ > {fileChangeSymbol(changeKind)} - {fileDiff.name} + {pathDisplay?.previous ? ( + + + + + + ) : ( + + {pathDisplay?.current ?? fileDiff.name} + + )} +{stats.additions} -{stats.removals} diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index dc59a84f..71d3504d 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -41,6 +41,18 @@ assert.equal( "needle in src", ); +assert.equal( + getToolDisplay({ + tool: "apply_patch", + files: [{ + path: "src/new-name.ts", + previousPath: "src/old-name.ts", + operation: "move", + }], + }).label, + "src/old-name.ts → src/new-name.ts", +); + assert.deepEqual( pickDisplay(getToolDisplay({ tool: "show_changes", diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index de979f1c..ee65b830 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -8,7 +8,10 @@ import { type ToolResultCard, } from "./card-types.js"; import { toolIcons, type ToolIcon } from "./icons.js"; -import { getPatchDisplayParts } from "./patch-display.js"; +import { + getFileChangePathDisplay, + getPatchDisplayParts, +} from "./patch-display.js"; export interface ToolDisplay { icon: ToolIcon; @@ -165,7 +168,9 @@ function workspaceTitle(card: ToolResultCard): string { } function singleFilePath(card: ToolResultCard): string | undefined { - if (card.files?.length === 1) return card.files[0]?.path ?? card.path; + if (card.files?.length === 1) { + return getFileChangePathDisplay(card.files[0])?.title ?? card.path; + } return undefined; } diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 6a3b5a5a..37c23cd1 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -757,6 +757,36 @@ body { font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); } +.review-diff-file-name.renamed { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + text-overflow: clip; +} + +.review-diff-file-path { + min-width: 0; + max-width: calc(50% - 10px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-diff-file-path.previous { + color: var(--color-text-tertiary, #a3a3aa); +} + +.review-diff-file-path.current { + color: var(--color-text-primary, #f5f5f6); +} + +.review-diff-file-arrow { + flex: 0 0 auto; + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); +} + .review-diff-file-stats { justify-content: flex-end; overflow: visible; From 06ba03bab8e66c4a96d321a7e51281024404ab57 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Thu, 6 Aug 2026 02:05:29 +0530 Subject: [PATCH 17/24] feat(ui): open single-file patch diffs --- src/ui/card-types.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/ui/card-types.ts | 8 ++++++++ src/ui/workspace-app.tsx | 3 ++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index eb16ad87..3905ecd9 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { isEditTool, isExpandableCard, + isInitiallyExpandedCard, isPatchTool, isShellTool, isToolName, @@ -31,6 +32,42 @@ test("a patch card expands only when it contains patch content", () => { assert.equal(isExpandableCard({ tool: "apply_patch" }), false); }); +test("a single-file patch opens immediately", () => { + assert.equal( + isInitiallyExpandedCard({ + tool: "apply_patch", + files: [{ path: "src/a.ts", operation: "update" }], + payload: { patch: "diff --git a/src/a.ts b/src/a.ts" }, + }), + true, + ); +}); + +test("a multi-file patch stays collapsed", () => { + assert.equal( + isInitiallyExpandedCard({ + tool: "apply_patch", + files: [ + { path: "src/a.ts", operation: "update" }, + { path: "src/b.ts", operation: "add" }, + ], + payload: { patch: "diff --git a/src/a.ts b/src/a.ts" }, + }), + false, + ); +}); + +test("show changes still opens immediately", () => { + assert.equal( + isInitiallyExpandedCard({ + tool: "show_changes", + files: [{ path: "src/a.ts", type: "change" }], + payload: { patch: "diff --git a/src/a.ts b/src/a.ts" }, + }), + true, + ); +}); + test("a workspace card expands when it contains provider metadata", () => { assert.equal( isExpandableCard({ diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index dc25b401..6d239a71 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -188,3 +188,11 @@ export function isExpandableCard(card: ToolResultCard): boolean { return Boolean(card.payload); } + +export function isInitiallyExpandedCard(card: ToolResultCard): boolean { + if (isReviewTool(card.tool)) return isExpandableCard(card); + if (isPatchTool(card.tool)) { + return card.files?.length === 1 && isExpandableCard(card); + } + return false; +} diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 40585bb5..b3ef36bc 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -8,6 +8,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { isEditTool, isExpandableCard, + isInitiallyExpandedCard, isPatchTool, isReadTool, isReviewTool, @@ -91,7 +92,7 @@ async function boot(): Promise { const nextCard = { ...structured, tool }; card = nextCard; - expanded = isReviewTool(tool) && isExpandableCard(nextCard); + expanded = isInitiallyExpandedCard(nextCard); reviewFilesExpanded = false; expandedWorkspaceDisclosures.clear(); openWorkspaceInstructionPath = null; From 3d43ba808e280a03d72ce0506fa7788f3fdea778 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Thu, 6 Aug 2026 02:45:58 +0530 Subject: [PATCH 18/24] refactor(ui): hide worktree implementation flags --- src/ui/icons.ts | 2 ++ src/ui/workspace-app.css | 27 +++++++++++++++++++++++++++ src/ui/workspace-app.tsx | 30 +++++++++++++++++++----------- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/ui/icons.ts b/src/ui/icons.ts index a108af20..1713e0f8 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -2,6 +2,7 @@ import { Blocks, Bot, ChevronDown, + CircleAlert, Cpu, FileDiff, FileCheck2, @@ -45,6 +46,7 @@ export const toolIcons = { sourceCheckout: FolderGit2, terminal: Terminal, terminalSquare: SquareTerminal, + warning: CircleAlert, writeFile: FilePlus, } as const satisfies Record; diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 37c23cd1..50862717 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -332,6 +332,33 @@ body { font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); } +.workspace-base-value { + display: flex; + min-width: 0; + align-items: center; + gap: 7px; +} + +.workspace-base-value .workspace-value { + flex: 0 1 auto; +} + +.workspace-base-warning { + display: grid; + width: 18px; + height: 18px; + flex: 0 0 auto; + place-items: center; + color: var(--color-warning-text, #e6b566); + cursor: help; +} + +.workspace-base-warning-svg { + width: 14px; + height: 14px; + stroke-width: 2; +} + .workspace-chip-list { display: flex; min-width: 0; diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index b3ef36bc..977e1105 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -481,17 +481,25 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v worktree.baseRef, worktree.baseSha?.slice(0, 8), ].filter((value): value is string => Boolean(value)); - const flags = [ - worktree.detached ? "detached" : undefined, - worktree.managed ? "managed" : undefined, - worktree.dirtySource ? "dirty source" : undefined, - ].filter((value): value is string => Boolean(value)); - appendWorkspaceTextRow( - rows, - "Base", - [...base, ...flags].join(" · ") || "Worktree", - toolIcons.base, - ); + const baseLabel = base.join(" · ") || "Worktree"; + const baseContent = element("span", { className: "workspace-base-value" }); + baseContent.append(element("span", { + className: "workspace-value", + text: baseLabel, + title: baseLabel, + })); + + if (worktree.dirtySource) { + const warning = element("span", { + className: "workspace-base-warning", + title: "The source checkout had uncommitted changes when this worktree was created. Those changes are not included here.", + ariaLabel: "Source checkout changes are not included in this worktree", + }); + warning.append(renderIcon(toolIcons.warning, "workspace-base-warning-svg")); + baseContent.append(warning); + } + + appendWorkspaceRow(rows, "Base", baseContent, toolIcons.base); } if (card.sourceRoot && card.sourceRoot !== card.root) { From 54dc7c8229836ab0a4340a8ce5158da6904a20aa Mon Sep 17 00:00:00 2001 From: Waishnav Date: Thu, 6 Aug 2026 14:10:07 +0530 Subject: [PATCH 19/24] fix(ui): preserve review file identity --- src/ui/patch-display.test.ts | 16 ++++++++ src/ui/patch-display.ts | 10 +++-- src/ui/review-payload.tsx | 8 ++-- src/ui/workspace-app.tsx | 77 ++++++++++++++++++++---------------- 4 files changed, 70 insertions(+), 41 deletions(-) diff --git a/src/ui/patch-display.test.ts b/src/ui/patch-display.test.ts index abfea2e0..612809ff 100644 --- a/src/ui/patch-display.test.ts +++ b/src/ui/patch-display.test.ts @@ -71,6 +71,22 @@ assert.deepEqual( }, ); +assert.deepEqual( + getRenderedFileChangePathDisplay( + [ + { path: "shared.ts", previousPath: "first.ts", operation: "move" }, + { path: "shared.ts", previousPath: "second.ts", operation: "move" }, + ], + { path: "shared.ts" }, + 1, + ), + { + current: "shared.ts", + previous: "second.ts", + title: "second.ts → shared.ts", + }, +); + assert.equal( getRenderedFileChangeKind( [ diff --git a/src/ui/patch-display.ts b/src/ui/patch-display.ts index 265c335e..ec1f7ad2 100644 --- a/src/ui/patch-display.ts +++ b/src/ui/patch-display.ts @@ -142,10 +142,12 @@ export function getRenderedFileChangePathDisplay( index: number, ): FileChangePathDisplay | undefined { const indexedFile = files[index]; - const matchedFile = files.find((file) => ( - file.path === parsedFile.path && - (!parsedFile.previousPath || !file.previousPath || file.previousPath === parsedFile.previousPath) - )); + const matchedFile = indexedFile?.path === parsedFile.path + ? indexedFile + : files.find((file) => ( + file.path === parsedFile.path && + (!parsedFile.previousPath || !file.previousPath || file.previousPath === parsedFile.previousPath) + )); const cardFile = matchedFile ?? indexedFile; return getFileChangePathDisplay({ diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index f298cf14..455e5472 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -117,6 +117,7 @@ function ReviewPayload({ > @@ -126,13 +127,12 @@ function ReviewPayload({ -