From df348e14b4d4fba108765fce582ba3456bc1c465 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 14 Jul 2026 15:08:06 +0530 Subject: [PATCH 1/5] Add collapsible review card footer --- src/ui/workspace-app.css | 7 +++++++ src/ui/workspace-app.tsx | 21 ++++++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index ed81685..b14f83d 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -222,6 +222,9 @@ body { } .review-more { + display: flex; + align-items: center; + justify-content: space-between; width: 100%; min-height: 44px; padding: 0 16px; @@ -240,6 +243,10 @@ body { color: var(--color-text-primary, #f5f5f6); } +.review-more .chevron { + flex: none; +} + .review-diff { display: grid; max-height: 520px; diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 7872386..a16aa3d 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -352,8 +352,7 @@ function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void { unmountPayload(); const files = card.files ?? []; - const visibleFiles = reviewFilesExpanded ? files : files.slice(0, 3); - const hiddenCount = Math.max(0, files.length - visibleFiles.length); + const hiddenCount = Math.max(0, files.length - 3); const expandable = isExpandableCard(card); const main = element("main", { className: "shell" }); const section = element("section", { className: "tool-card review" }); @@ -398,16 +397,24 @@ function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void { body.append(payload); if (hiddenCount > 0) { - const showMore = element("button", { + const toggleFiles = element("button", { className: "review-more", type: "button", - text: `Show ${hiddenCount} more ${hiddenCount === 1 ? "file" : "files"}`, + ariaExpanded: String(reviewFilesExpanded), }); - showMore.addEventListener("click", () => { - reviewFilesExpanded = true; + toggleFiles.append( + element("span", { + text: reviewFilesExpanded + ? "Collapse files" + : `Show ${hiddenCount} more ${hiddenCount === 1 ? "file" : "files"}`, + }), + renderChevron(reviewFilesExpanded, true), + ); + toggleFiles.addEventListener("click", () => { + reviewFilesExpanded = !reviewFilesExpanded; render(); }); - body.append(showMore); + body.append(toggleFiles); } section.append(body); From f640ed3fead5b1190928e5b3c3374fc634f4de9b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 14 Jul 2026 15:09:24 +0530 Subject: [PATCH 2/5] Add fullscreen review display mode --- src/ui/review-payload.tsx | 1 + src/ui/workspace-app.css | 98 ++++++++++++++++++++++++++ src/ui/workspace-app.tsx | 142 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 237 insertions(+), 4 deletions(-) diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index 068d2c1..12a774c 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -11,6 +11,7 @@ interface PayloadRendererOptions { hostContext?: HostContext; errorMessage?: string | null; visibleFileCount?: number; + presentation?: "inline" | "fullscreen"; } interface MountedPayload { diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index b14f83d..928193e 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -198,12 +198,63 @@ body { grid-template-columns: 54px minmax(0, 1fr) auto 24px; } +.review-header-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding-right: 16px; +} + .review-title-group { display: grid; min-width: 0; gap: 3px; } +.review-header-actions, +.review-fullscreen-actions, +.review-fullscreen-title { + display: flex; + align-items: center; +} + +.review-header-actions, +.review-fullscreen-actions { + justify-content: flex-end; + gap: 12px; +} + +.review-button { + min-height: 34px; + padding: 0 13px; + border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 92%, transparent); + border-radius: 9px; + background: color-mix(in srgb, var(--color-background-tertiary, #333338) 88%, transparent); + color: var(--color-text-primary, #f5f5f6); + cursor: pointer; + font: inherit; + font-size: var(--font-text-sm-size, 13px); + font-weight: 500; +} + +.review-button:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-background-tertiary, #414148) 96%, transparent); +} + +.review-button:disabled { + cursor: wait; + opacity: 0.7; +} + +.review-mode-error { + padding: 8px 14px; + border-top: 1px solid color-mix(in srgb, var(--color-border-danger, #7f3030) 56%, transparent); + background: color-mix(in srgb, var(--color-background-danger, #421e1e) 24%, transparent); + color: var(--color-danger-text, #ee7676); + font-size: var(--font-text-sm-size, 12px); +} + .review-summary { display: grid; border-top: 1px solid var(--tool-card-divider); @@ -247,6 +298,48 @@ body { flex: none; } +.review-fullscreen-shell { + height: 100vh; + height: 100dvh; +} + +.review-fullscreen { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + width: 100%; + height: 100%; + overflow: hidden; + background: var(--color-background-primary, #18191d); + color: var(--color-text-primary, #f5f5f6); +} + +.review-fullscreen-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + min-height: 68px; + padding: 12px 18px; + border-bottom: 1px solid var(--color-border-primary, #3a3a40); + background: color-mix(in srgb, var(--color-background-secondary, #28282d) 94%, transparent); +} + +.review-fullscreen-title { + min-width: 0; + gap: 12px; +} + +.review-fullscreen-body { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.review-fullscreen .review-diff { + height: 100%; + max-height: none; +} + .review-diff { display: grid; max-height: 520px; @@ -377,4 +470,9 @@ body { .review-header { grid-template-columns: 42px minmax(0, 1fr) auto 18px; } + + .review-header-row { + gap: 8px; + padding-right: 12px; + } } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index a16aa3d..62a3edf 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -33,6 +33,7 @@ interface MountedPayload { hostContext?: HostContext; errorMessage?: string | null; visibleFileCount?: number; + presentation?: "inline" | "fullscreen"; }): void; unmount(): void; } @@ -44,6 +45,8 @@ let hostContext: HostContext | undefined; let card: ToolResultCard | null = null; let expanded = false; let reviewFilesExpanded = false; +let reviewDisplayModePending = false; +let reviewDisplayModeError: string | null = null; let errorMessage: string | null = null; let currentPayload: MountedPayload | null = null; let currentPayloadContainer: HTMLElement | null = null; @@ -78,6 +81,8 @@ async function boot(): Promise { card = null; expanded = false; reviewFilesExpanded = false; + reviewDisplayModePending = false; + reviewDisplayModeError = null; errorMessage = "No result card is available for this tool result."; render(); return; @@ -87,16 +92,27 @@ async function boot(): Promise { card = nextCard; expanded = isReviewTool(tool) && isExpandableCard(nextCard); reviewFilesExpanded = false; + reviewDisplayModePending = false; + reviewDisplayModeError = null; errorMessage = null; render(); }; app.onhostcontextchanged = (ctx) => { + const previousDisplayMode = hostContext?.displayMode; hostContext = { ...hostContext, ...ctx, }; applyHostContext(); + if ( + previousDisplayMode !== hostContext.displayMode && + card && + isReviewTool(card.tool) + ) { + render(); + return; + } renderPayloadIfNeeded(); }; @@ -216,7 +232,10 @@ function renderEmpty(message: string, tone: "muted" | "error" = "muted"): void { } async function renderPayloadIfNeeded(): Promise { - if (!card || !currentPayloadContainer || !expanded) return; + const fullscreenReview = card && + isReviewTool(card.tool) && + hostContext?.displayMode === "fullscreen"; + if (!card || !currentPayloadContainer || (!expanded && !fullscreenReview)) return; const target = currentPayloadContainer; @@ -262,12 +281,23 @@ async function renderPayloadIfNeeded(): Promise { } if (isReviewTool(card.tool) || isPatchTool(card.tool)) { - const visibleFileCount = isReviewTool(card.tool) && !reviewFilesExpanded + const presentation = isReviewTool(card.tool) && hostContext?.displayMode === "fullscreen" + ? "fullscreen" + : "inline"; + const visibleFileCount = isReviewTool(card.tool) && + presentation === "inline" && + !reviewFilesExpanded ? Math.max(3, (card.files ?? []).slice(0, 3).length) : undefined; if (currentPayload) { - currentPayload.update({ card, hostContext, errorMessage, visibleFileCount }); + currentPayload.update({ + card, + hostContext, + errorMessage, + visibleFileCount, + presentation, + }); return; } @@ -281,6 +311,7 @@ async function renderPayloadIfNeeded(): Promise { hostContext, errorMessage, visibleFileCount, + presentation, }); return; } @@ -351,11 +382,17 @@ function renderHeaderSummary(card: ToolResultCard): HTMLElement { function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void { unmountPayload(); + if (hostContext?.displayMode === "fullscreen") { + renderFullscreenReview(card, display); + return; + } + const files = card.files ?? []; const hiddenCount = Math.max(0, files.length - 3); const expandable = isExpandableCard(card); const main = element("main", { className: "shell" }); const section = element("section", { className: "tool-card review" }); + const headerRow = element("div", { className: "review-header-row" }); const header = element("button", { className: "tool-header review-header", type: "button", @@ -389,7 +426,28 @@ function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void { renderChevron(expanded, expandable), ); - section.append(header); + headerRow.append(header); + if (files.length > 0 && canRequestDisplayMode("fullscreen")) { + const reviewButton = element("button", { + className: "review-button", + type: "button", + text: reviewDisplayModePending ? "Opening…" : "Review", + disabled: reviewDisplayModePending, + }); + reviewButton.setAttribute("aria-busy", String(reviewDisplayModePending)); + reviewButton.addEventListener("click", () => { + void requestReviewDisplayMode("fullscreen"); + }); + headerRow.append(reviewButton); + } + + section.append(headerRow); + if (reviewDisplayModeError) { + section.append(element("div", { + className: "review-mode-error", + text: reviewDisplayModeError, + })); + } if (expanded) { const body = element("div", { className: "review-summary" }); const payload = element("div", { className: "review-payload" }); @@ -425,6 +483,82 @@ function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void { renderPayloadIfNeeded(); } +function renderFullscreenReview(card: ToolResultCard, display: ToolDisplay): void { + const main = element("main", { className: "shell review-fullscreen-shell" }); + const section = element("section", { className: "review-fullscreen" }); + const header = element("header", { className: "review-fullscreen-header" }); + const titleGroup = element("div", { className: "review-fullscreen-title" }); + const icon = element("span", { className: "tool-icon", ariaHidden: "true" }); + icon.append(renderIcon(display.icon)); + + const heading = element("div", { className: "review-title-group" }); + heading.append(element("span", { className: "tool-title", text: "Review changes" })); + if (display.label) { + heading.append(element("span", { + className: "tool-label", + text: display.label, + title: display.label, + })); + } + titleGroup.append(icon, heading); + + const actions = element("div", { className: "review-fullscreen-actions" }); + const closeButton = element("button", { + className: "review-button", + type: "button", + text: reviewDisplayModePending ? "Closing…" : "Close review", + disabled: reviewDisplayModePending, + }); + closeButton.setAttribute("aria-busy", String(reviewDisplayModePending)); + closeButton.addEventListener("click", () => { + void requestReviewDisplayMode("inline"); + }); + actions.append(renderHeaderSummary(card), closeButton); + header.append(titleGroup, actions); + + const body = element("div", { className: "review-fullscreen-body" }); + currentPayloadContainer = body; + section.append(header); + if (reviewDisplayModeError) { + section.append(element("div", { + className: "review-mode-error", + text: reviewDisplayModeError, + })); + } + section.append(body); + main.append(section); + appRoot.replaceChildren(main); + renderPayloadIfNeeded(); +} + +function canRequestDisplayMode(mode: "inline" | "fullscreen"): boolean { + return Boolean(hostContext?.availableDisplayModes?.includes(mode)); +} + +async function requestReviewDisplayMode(mode: "inline" | "fullscreen"): Promise { + if (!app || reviewDisplayModePending) return; + + reviewDisplayModePending = true; + reviewDisplayModeError = null; + render(); + + try { + const result = await app.requestDisplayMode({ mode }); + hostContext = { + ...hostContext, + displayMode: result.mode, + }; + if (result.mode === "fullscreen") expanded = true; + } catch (requestError) { + reviewDisplayModeError = requestError instanceof Error + ? requestError.message + : "Unable to change the review display mode."; + } finally { + reviewDisplayModePending = false; + render(); + } +} + function renderChevron(isExpanded: boolean, visible: boolean): HTMLElement { const chevron = element("span", { className: visible ? `chevron ${isExpanded ? "expanded" : ""}` : "chevron", From 5136b30c2b18c6dee453fd4cf7eb0456215974de Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 14 Jul 2026 15:11:31 +0530 Subject: [PATCH 3/5] Add review file model and tests --- package.json | 2 +- src/ui/review-model.test.ts | 79 +++++++++++++++++++++++++++++++ src/ui/review-model.ts | 93 +++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/ui/review-model.test.ts create mode 100644 src/ui/review-model.ts diff --git a/package.json b/package.json index 0b8c5eb..2de6a0c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/ui/review-model.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/ui/review-model.test.ts b/src/ui/review-model.test.ts new file mode 100644 index 0000000..e0352c5 --- /dev/null +++ b/src/ui/review-model.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { + buildReviewFileEntries, + initialReviewPath, + reviewFileStatus, +} from "./review-model.js"; + +const patch = [ + "diff --git a/src/old.ts b/src/new.ts", + "similarity index 80%", + "rename from src/old.ts", + "rename to src/new.ts", + "index 1111111..2222222 100644", + "--- a/src/old.ts", + "+++ b/src/new.ts", + "@@ -1 +1 @@", + "-old value", + "+new value", + "diff --git a/README.md b/README.md", + "index 3333333..4444444 100644", + "--- a/README.md", + "+++ b/README.md", + "@@ -1 +1,2 @@", + " title", + "+details", + "", +].join("\n"); + +const entries = buildReviewFileEntries({ + tool: "show_changes", + files: [ + { + path: "src/new.ts", + previousPath: "src/old.ts", + type: "rename-changed", + additions: 1, + removals: 1, + }, + ], + payload: { patch }, +}); + +assert.equal(entries.length, 2); +assert.deepEqual( + entries.map(({ path, previousPath, additions, removals, status }) => ({ + path, + previousPath, + additions, + removals, + status, + })), + [ + { + path: "src/new.ts", + previousPath: "src/old.ts", + additions: 1, + removals: 1, + status: "renamed", + }, + { + path: "README.md", + previousPath: undefined, + additions: 1, + removals: 0, + status: "modified", + }, + ], +); +assert.ok(entries.every((entry) => entry.fileDiff)); + +assert.equal(reviewFileStatus("new"), "added"); +assert.equal(reviewFileStatus("deleted"), "deleted"); +assert.equal(reviewFileStatus("rename-pure"), "renamed"); +assert.equal(reviewFileStatus("change"), "modified"); + +assert.equal(initialReviewPath(entries), "src/new.ts"); +assert.equal(initialReviewPath(entries, "README.md"), "README.md"); +assert.equal(initialReviewPath(entries, "missing.ts"), "src/new.ts"); +assert.equal(initialReviewPath([]), undefined); diff --git a/src/ui/review-model.ts b/src/ui/review-model.ts new file mode 100644 index 0000000..d8e71b1 --- /dev/null +++ b/src/ui/review-model.ts @@ -0,0 +1,93 @@ +import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"; +import type { ToolResultCard } from "./card-types.js"; + +export type ReviewFileStatus = + | "added" + | "deleted" + | "modified" + | "renamed"; + +export interface ReviewFileEntry { + path: string; + previousPath?: string; + type?: string; + additions: number; + removals: number; + status: ReviewFileStatus; + fileDiff?: FileDiffMetadata; +} + +export function buildReviewFileEntries(card: ToolResultCard): ReviewFileEntry[] { + const parsedFiles = parseReviewPatch(card.payload?.patch); + const parsedByPath = new Map(parsedFiles.map((fileDiff) => [fileDiff.name, fileDiff])); + const entries: ReviewFileEntry[] = []; + + for (const file of card.files ?? []) { + if (!file.path) continue; + + const fileDiff = parsedByPath.get(file.path); + const stats = fileDiff ? reviewFileStats(fileDiff) : undefined; + entries.push({ + path: file.path, + previousPath: file.previousPath ?? fileDiff?.prevName, + type: file.type, + additions: file.additions ?? stats?.additions ?? 0, + removals: file.removals ?? stats?.removals ?? 0, + status: reviewFileStatus(file.type), + fileDiff, + }); + parsedByPath.delete(file.path); + } + + for (const fileDiff of parsedFiles) { + if (!parsedByPath.has(fileDiff.name)) continue; + + const stats = reviewFileStats(fileDiff); + entries.push({ + path: fileDiff.name, + previousPath: fileDiff.prevName, + additions: stats.additions, + removals: stats.removals, + status: fileDiff.prevName && fileDiff.prevName !== fileDiff.name + ? "renamed" + : "modified", + fileDiff, + }); + } + + return entries; +} + +export function parseReviewPatch(patch: string | undefined): FileDiffMetadata[] { + if (!patch) return []; + return parsePatchFiles(patch, "review", true).flatMap((parsedPatch) => parsedPatch.files); +} + +export function reviewFileStats( + fileDiff: FileDiffMetadata, +): { additions: number; removals: number } { + return fileDiff.hunks.reduce( + (stats, hunk) => ({ + additions: stats.additions + hunk.additionLines, + removals: stats.removals + hunk.deletionLines, + }), + { additions: 0, removals: 0 }, + ); +} + +export function reviewFileStatus(type: string | undefined): ReviewFileStatus { + if (type === "new") return "added"; + if (type === "deleted") return "deleted"; + if (type === "rename-pure" || type === "rename-changed") return "renamed"; + return "modified"; +} + +export function initialReviewPath( + entries: ReviewFileEntry[], + selectedPath?: string, +): string | undefined { + if (selectedPath && entries.some((entry) => entry.path === selectedPath)) { + return selectedPath; + } + return entries[0]?.path; +} From fd44a8244e0a9c9deb66771367b9be44dc3a9df3 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 14 Jul 2026 15:17:52 +0530 Subject: [PATCH 4/5] Render fullscreen reviews with Pierre file tree --- package-lock.json | 73 ++++++++++++++++- package.json | 1 + src/ui/review-payload.tsx | 161 +++++++++++++++++++++++++++++++------- src/ui/workspace-app.css | 142 ++++++++++++++++++++++++++++++++- src/ui/workspace-app.tsx | 6 +- 5 files changed, 351 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d7decc..a43a47f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@openai/codex-sdk": "^0.142.5", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", + "@pierre/trees": "1.0.0-beta.5", "better-sqlite3": "^12.10.0", "diff": "^8.0.3", "drizzle-orm": "^0.45.2", @@ -2814,7 +2815,7 @@ "react-dom": "^18.3.1 || ^19.0.0" } }, - "node_modules/@pierre/theme": { + "node_modules/@pierre/diffs/node_modules/@pierre/theme": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-1.0.3.tgz", "integrity": "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==", @@ -2823,6 +2824,61 @@ "vscode": "^1.0.0" } }, + "node_modules/@pierre/theme": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-1.1.0.tgz", + "integrity": "sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==", + "license": "MIT", + "peer": true, + "engines": { + "vscode": "^1.0.0" + } + }, + "node_modules/@pierre/theming": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@pierre/theming/-/theming-0.0.2.tgz", + "integrity": "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==", + "license": "apache-2.0", + "peerDependencies": { + "@pierre/theme": "^1.1.0", + "@shikijs/themes": "^3.0.0 || ^4.0.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0", + "shiki": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pierre/theme": { + "optional": true + }, + "@shikijs/themes": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "shiki": { + "optional": true + } + } + }, + "node_modules/@pierre/trees": { + "version": "1.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@pierre/trees/-/trees-1.0.0-beta.5.tgz", + "integrity": "sha512-IzxkB9qv6GLbeEXObhlAD205LfYHiLeRwJdnaIdX0f5keTZF4X9EfiuEQ3QiyxOxouVVmUX3rX7m6a8zNMo/wA==", + "license": "apache-2.0", + "dependencies": { + "@pierre/theming": "0.0.2", + "preact": "11.0.0-beta.0", + "preact-render-to-string": "6.6.5" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -5327,6 +5383,21 @@ "rc": "cli.js" } }, + "node_modules/preact": { + "version": "11.0.0-beta.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0-beta.0.tgz", + "integrity": "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==", + "license": "MIT" + }, + "node_modules/preact-render-to-string": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.5.tgz", + "integrity": "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10 || >= 11.0.0-0" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", diff --git a/package.json b/package.json index 2de6a0c..ea01c1c 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@openai/codex-sdk": "^0.142.5", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", + "@pierre/trees": "1.0.0-beta.5", "better-sqlite3": "^12.10.0", "diff": "^8.0.3", "drizzle-orm": "^0.45.2", diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index 12a774c..38dbf7c 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -1,8 +1,15 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { createRoot } from "react-dom/client"; -import { parsePatchFiles, type FileDiffMetadata, type FileDiffOptions } from "@pierre/diffs"; +import type { FileDiffOptions } from "@pierre/diffs"; import { FileDiff } from "@pierre/diffs/react"; +import type { GitStatusEntry } from "@pierre/trees"; +import { FileTree, useFileTree } from "@pierre/trees/react"; import type { HostContext, ToolResultCard } from "./card-types.js"; +import { + buildReviewFileEntries, + initialReviewPath, + type ReviewFileEntry, +} from "./review-model.js"; type ThemeType = "light" | "dark"; @@ -41,27 +48,30 @@ function ReviewPayload({ hostContext, errorMessage = null, visibleFileCount, + presentation = "inline", }: PayloadRendererOptions) { - const patch = card.payload?.patch; const themeType: ThemeType = hostContext?.theme === "light" ? "light" : "dark"; - const files = useMemo(() => parseFiles(patch), [patch]); - const visibleFiles = typeof visibleFileCount === "number" - ? files.slice(0, visibleFileCount) - : files; + const entries = useMemo(() => buildReviewFileEntries(card), [card]); const [openFiles, setOpenFiles] = useState(() => new Set()); if (errorMessage) return ; - if (!patch) return ; - if (files.length === 0) return ; + if (!card.payload?.patch) return ; + if (entries.length === 0) return ; const options = diffOptions(themeType); + if (presentation === "fullscreen") { + return ; + } + + const visibleEntries = typeof visibleFileCount === "number" + ? entries.slice(0, visibleFileCount) + : entries; return (
- {visibleFiles.map((fileDiff, index) => { - const key = fileDiff.cacheKey ?? `${fileDiff.prevName ?? ""}->${fileDiff.name}-${index}`; - const stats = diffStats(fileDiff); + {visibleEntries.map((entry) => { + const key = entry.path; const isOpen = openFiles.has(key); return ( @@ -80,15 +90,13 @@ function ReviewPayload({ setOpenFiles(next); }} > - {fileDiff.name} + {entry.path} - +{stats.additions} - -{stats.removals} + +{entry.additions} + -{entry.removals} - {isOpen ? ( - - ) : null} + {isOpen ? : null}
); })} @@ -97,19 +105,116 @@ function ReviewPayload({ ); } -function parseFiles(patch: string | undefined): FileDiffMetadata[] { - if (!patch) return []; - return parsePatchFiles(patch, "review", true).flatMap((parsedPatch) => parsedPatch.files); +function FullscreenReview({ + entries, + options, +}: { + entries: ReviewFileEntry[]; + options: FileDiffOptions; +}) { + const [selectedPath, setSelectedPath] = useState(() => initialReviewPath(entries)); + + useEffect(() => { + setSelectedPath((currentPath) => initialReviewPath(entries, currentPath)); + }, [entries]); + + const selectedEntry = entries.find((entry) => entry.path === selectedPath) ?? entries[0]; + + return ( +
+
+
+
+ {selectedEntry?.path} + {selectedEntry?.previousPath && selectedEntry.previousPath !== selectedEntry.path ? ( + from {selectedEntry.previousPath} + ) : null} +
+ {selectedEntry ? ( + + +{selectedEntry.additions} + -{selectedEntry.removals} + + ) : null} +
+
+ {selectedEntry ? ( + + ) : ( + + )} +
+
+ + +
+ ); } -function diffStats(fileDiff: FileDiffMetadata): { additions: number; removals: number } { - return fileDiff.hunks.reduce( - (stats, hunk) => ({ - additions: stats.additions + hunk.additionLines, - removals: stats.removals + hunk.deletionLines, - }), - { additions: 0, removals: 0 }, +function ReviewFileTree({ + entries, + selectedPath, + onSelect, +}: { + entries: ReviewFileEntry[]; + selectedPath?: string; + onSelect(path: string): void; +}) { + const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); + const gitStatus = useMemo( + () => entries.map((entry) => ({ path: entry.path, status: entry.status })), + [entries], ); + const { model } = useFileTree({ + paths, + gitStatus, + flattenEmptyDirectories: true, + initialExpansion: "open", + search: paths.length > 8, + onSelectionChange(selectedPaths) { + const path = selectedPaths.find((candidate) => paths.includes(candidate)); + if (path) onSelect(path); + }, + }); + + useEffect(() => { + if (!selectedPath) return; + for (const path of model.getSelectedPaths()) { + if (path !== selectedPath) model.getItem(path)?.deselect(); + } + model.getItem(selectedPath)?.select(); + model.scrollToPath(selectedPath, { focus: false }); + }, [model, selectedPath]); + + return ; +} + +function ReviewFileBody({ + entry, + options, +}: { + entry: ReviewFileEntry; + options: FileDiffOptions; +}) { + if (!entry.fileDiff) { + return ( + + ); + } + + return ; } function diffOptions(themeType: ThemeType): FileDiffOptions { diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 928193e..eb98b17 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -305,7 +305,7 @@ body { .review-fullscreen { display: grid; - grid-template-rows: auto auto minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); width: 100%; height: 100%; overflow: hidden; @@ -313,6 +313,10 @@ body { color: var(--color-text-primary, #f5f5f6); } +.review-fullscreen.has-mode-error { + grid-template-rows: auto auto minmax(0, 1fr); +} + .review-fullscreen-header { display: flex; align-items: center; @@ -335,9 +339,143 @@ body { overflow: hidden; } -.review-fullscreen .review-diff { +.review-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(240px, 24vw, 340px); + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.review-selected-file, +.review-file-tree-panel { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.review-selected-file { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border-right: 1px solid var(--color-border-primary, #3a3a40); +} + +.review-selected-file-header, +.review-file-tree-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 44px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border-primary, #3a3a40); + background: color-mix(in srgb, var(--color-background-secondary, #28282d) 72%, transparent); +} + +.review-selected-file-title { + display: grid; + min-width: 0; + gap: 2px; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); +} + +.review-selected-file-title strong, +.review-selected-file-title span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-selected-file-title strong { + font-size: var(--font-text-sm-size, 13px); + font-weight: 500; +} + +.review-selected-file-title span { + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-xs-size, 11px); +} + +.review-selected-file-body, +.review-file-tree-body { + min-width: 0; + min-height: 0; + overflow: auto; +} + +.review-selected-file-body { + background: var(--tool-payload-bg, var(--color-background-primary, #101114)); +} + +.review-selected-file-body > .status { + min-height: 100%; +} + +.review-selected-file-body .pierre-diff { height: 100%; max-height: none; + border-radius: 0; +} + +.review-file-tree-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + background: color-mix(in srgb, var(--color-background-secondary, #28282d) 58%, transparent); +} + +.review-file-tree-header { + color: var(--color-text-secondary, #d6d6dc); + font-size: var(--font-text-sm-size, 12px); +} + +.review-file-tree-header strong { + color: var(--color-text-primary, #f5f5f6); + font-weight: 500; +} + +.review-file-tree-header span { + min-width: 22px; + padding: 2px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-background-tertiary, #333338) 82%, transparent); + color: var(--color-text-tertiary, #a3a3aa); + text-align: center; +} + +.review-file-tree-body { + padding: 6px 4px 8px; +} + +.review-file-tree { + display: block; + width: 100%; + height: 100%; + --trees-border-color-override: transparent; + --trees-fg-override: var(--color-text-secondary, #d6d6dc); + --trees-selected-bg-override: color-mix(in srgb, var(--color-background-tertiary, #3a3a42) 88%, transparent); +} + +@media (max-width: 760px) { + .review-workspace { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) minmax(180px, 34vh); + } + + .review-selected-file { + border-right: 0; + border-bottom: 1px solid var(--color-border-primary, #3a3a40); + } + + .review-fullscreen-header { + align-items: flex-start; + padding: 10px 12px; + } + + .review-fullscreen-actions { + gap: 8px; + } } .review-diff { diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 62a3edf..583383a 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -485,7 +485,11 @@ function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void { function renderFullscreenReview(card: ToolResultCard, display: ToolDisplay): void { const main = element("main", { className: "shell review-fullscreen-shell" }); - const section = element("section", { className: "review-fullscreen" }); + const section = element("section", { + className: reviewDisplayModeError + ? "review-fullscreen has-mode-error" + : "review-fullscreen", + }); const header = element("header", { className: "review-fullscreen-header" }); const titleGroup = element("div", { className: "review-fullscreen-title" }); const icon = element("span", { className: "tool-icon", ariaHidden: "true" }); From ebe763c72b1fe9fd4991f9677c8817f8cbeb1933 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 14 Jul 2026 15:19:51 +0530 Subject: [PATCH 5/5] Split review UI into focused components --- src/ui/review-file-body.tsx | 20 +++++ src/ui/review-file-tree.tsx | 43 +++++++++++ src/ui/review-fullscreen.tsx | 67 +++++++++++++++++ src/ui/review-payload.tsx | 136 ++-------------------------------- src/ui/review-status-line.tsx | 9 +++ 5 files changed, 144 insertions(+), 131 deletions(-) create mode 100644 src/ui/review-file-body.tsx create mode 100644 src/ui/review-file-tree.tsx create mode 100644 src/ui/review-fullscreen.tsx create mode 100644 src/ui/review-status-line.tsx diff --git a/src/ui/review-file-body.tsx b/src/ui/review-file-body.tsx new file mode 100644 index 0000000..0427c9e --- /dev/null +++ b/src/ui/review-file-body.tsx @@ -0,0 +1,20 @@ +import type { FileDiffOptions } from "@pierre/diffs"; +import { FileDiff } from "@pierre/diffs/react"; +import type { ReviewFileEntry } from "./review-model.js"; +import { StatusLine } from "./review-status-line.js"; + +export function ReviewFileBody({ + entry, + options, +}: { + entry: ReviewFileEntry; + options: FileDiffOptions; +}) { + if (!entry.fileDiff) { + return ( + + ); + } + + return ; +} diff --git a/src/ui/review-file-tree.tsx b/src/ui/review-file-tree.tsx new file mode 100644 index 0000000..a4c2098 --- /dev/null +++ b/src/ui/review-file-tree.tsx @@ -0,0 +1,43 @@ +import { useEffect, useMemo } from "react"; +import type { GitStatusEntry } from "@pierre/trees"; +import { FileTree, useFileTree } from "@pierre/trees/react"; +import type { ReviewFileEntry } from "./review-model.js"; + +export function ReviewFileTree({ + entries, + selectedPath, + onSelect, +}: { + entries: ReviewFileEntry[]; + selectedPath?: string; + onSelect(path: string): void; +}) { + const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); + const pathSet = useMemo(() => new Set(paths), [paths]); + const gitStatus = useMemo( + () => entries.map((entry) => ({ path: entry.path, status: entry.status })), + [entries], + ); + const { model } = useFileTree({ + paths, + gitStatus, + flattenEmptyDirectories: true, + initialExpansion: "open", + search: paths.length > 8, + onSelectionChange(selectedPaths) { + const path = selectedPaths.find((candidate) => pathSet.has(candidate)); + if (path) onSelect(path); + }, + }); + + useEffect(() => { + if (!selectedPath) return; + for (const path of model.getSelectedPaths()) { + if (path !== selectedPath) model.getItem(path)?.deselect(); + } + model.getItem(selectedPath)?.select(); + model.scrollToPath(selectedPath, { focus: false }); + }, [model, selectedPath]); + + return ; +} diff --git a/src/ui/review-fullscreen.tsx b/src/ui/review-fullscreen.tsx new file mode 100644 index 0000000..a2e2f8c --- /dev/null +++ b/src/ui/review-fullscreen.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import type { FileDiffOptions } from "@pierre/diffs"; +import { ReviewFileBody } from "./review-file-body.js"; +import { ReviewFileTree } from "./review-file-tree.js"; +import { + initialReviewPath, + type ReviewFileEntry, +} from "./review-model.js"; +import { StatusLine } from "./review-status-line.js"; + +export function FullscreenReview({ + entries, + options, +}: { + entries: ReviewFileEntry[]; + options: FileDiffOptions; +}) { + const [selectedPath, setSelectedPath] = useState(() => initialReviewPath(entries)); + + useEffect(() => { + setSelectedPath((currentPath) => initialReviewPath(entries, currentPath)); + }, [entries]); + + const selectedEntry = entries.find((entry) => entry.path === selectedPath) ?? entries[0]; + + return ( +
+
+
+
+ {selectedEntry?.path} + {selectedEntry?.previousPath && selectedEntry.previousPath !== selectedEntry.path ? ( + from {selectedEntry.previousPath} + ) : null} +
+ {selectedEntry ? ( + + +{selectedEntry.additions} + -{selectedEntry.removals} + + ) : null} +
+
+ {selectedEntry ? ( + + ) : ( + + )} +
+
+ + +
+ ); +} diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index 38dbf7c..b8099c6 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -1,15 +1,11 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { createRoot } from "react-dom/client"; import type { FileDiffOptions } from "@pierre/diffs"; -import { FileDiff } from "@pierre/diffs/react"; -import type { GitStatusEntry } from "@pierre/trees"; -import { FileTree, useFileTree } from "@pierre/trees/react"; import type { HostContext, ToolResultCard } from "./card-types.js"; -import { - buildReviewFileEntries, - initialReviewPath, - type ReviewFileEntry, -} from "./review-model.js"; +import { ReviewFileBody } from "./review-file-body.js"; +import { FullscreenReview } from "./review-fullscreen.js"; +import { buildReviewFileEntries } from "./review-model.js"; +import { StatusLine } from "./review-status-line.js"; type ThemeType = "light" | "dark"; @@ -105,118 +101,6 @@ function ReviewPayload({ ); } -function FullscreenReview({ - entries, - options, -}: { - entries: ReviewFileEntry[]; - options: FileDiffOptions; -}) { - const [selectedPath, setSelectedPath] = useState(() => initialReviewPath(entries)); - - useEffect(() => { - setSelectedPath((currentPath) => initialReviewPath(entries, currentPath)); - }, [entries]); - - const selectedEntry = entries.find((entry) => entry.path === selectedPath) ?? entries[0]; - - return ( -
-
-
-
- {selectedEntry?.path} - {selectedEntry?.previousPath && selectedEntry.previousPath !== selectedEntry.path ? ( - from {selectedEntry.previousPath} - ) : null} -
- {selectedEntry ? ( - - +{selectedEntry.additions} - -{selectedEntry.removals} - - ) : null} -
-
- {selectedEntry ? ( - - ) : ( - - )} -
-
- - -
- ); -} - -function ReviewFileTree({ - entries, - selectedPath, - onSelect, -}: { - entries: ReviewFileEntry[]; - selectedPath?: string; - onSelect(path: string): void; -}) { - const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); - const gitStatus = useMemo( - () => entries.map((entry) => ({ path: entry.path, status: entry.status })), - [entries], - ); - const { model } = useFileTree({ - paths, - gitStatus, - flattenEmptyDirectories: true, - initialExpansion: "open", - search: paths.length > 8, - onSelectionChange(selectedPaths) { - const path = selectedPaths.find((candidate) => paths.includes(candidate)); - if (path) onSelect(path); - }, - }); - - useEffect(() => { - if (!selectedPath) return; - for (const path of model.getSelectedPaths()) { - if (path !== selectedPath) model.getItem(path)?.deselect(); - } - model.getItem(selectedPath)?.select(); - model.scrollToPath(selectedPath, { focus: false }); - }, [model, selectedPath]); - - return ; -} - -function ReviewFileBody({ - entry, - options, -}: { - entry: ReviewFileEntry; - options: FileDiffOptions; -}) { - if (!entry.fileDiff) { - return ( - - ); - } - - return ; -} - function diffOptions(themeType: ThemeType): FileDiffOptions { return { theme: { @@ -235,13 +119,3 @@ function diffOptions(themeType: ThemeType): FileDiffOptions { disableFileHeader: true, }; } - -function StatusLine({ - message, - tone = "muted", -}: { - message: string; - tone?: "muted" | "error"; -}) { - return
{message}
; -} diff --git a/src/ui/review-status-line.tsx b/src/ui/review-status-line.tsx new file mode 100644 index 0000000..c67b0b0 --- /dev/null +++ b/src/ui/review-status-line.tsx @@ -0,0 +1,9 @@ +export function StatusLine({ + message, + tone = "muted", +}: { + message: string; + tone?: "muted" | "error"; +}) { + return
{message}
; +}