From d2c507ebc2711c8d50ccc57c0ee1d05e2ceb0328 Mon Sep 17 00:00:00 2001 From: Devesh Meena Date: Thu, 16 Apr 2026 13:10:47 +0530 Subject: [PATCH 01/13] Add three-pane diff viewer: Raw Diff, AI Order, and Summary Replaces the default Changes view with a side-by-side collapsible three-column layout showing traditional diffs, a prototype LLM-reordered diff story, and natural language summaries. Sidebar expands dynamically based on visible panel count. Made-with: Cursor --- .../components/chat-ui/RightSidebar.test.ts | 54 ++++ .../components/chat-ui/RightSidebar.tsx | 292 +++++++++++++++--- 2 files changed, 311 insertions(+), 35 deletions(-) diff --git a/src/client/components/chat-ui/RightSidebar.test.ts b/src/client/components/chat-ui/RightSidebar.test.ts index ec1d7557e..a113e3537 100644 --- a/src/client/components/chat-ui/RightSidebar.test.ts +++ b/src/client/components/chat-ui/RightSidebar.test.ts @@ -106,12 +106,66 @@ describe("RightSidebar", () => { )) expect(markup).toContain("src/app.ts") + expect(markup).toContain("Raw Diff") + expect(markup).toContain("AI Order") + expect(markup).toContain("Summary") + expect(markup).toContain("Hide all") + expect(markup).toContain("Updates app.ts to reshape existing behavior (+1/-1).") + expect(markup).toContain("flex items-start gap-3") expect(markup).toContain("Open branch switcher") expect(markup).toContain("Pull") expect(markup).toContain("3") expect(markup).not.toContain("Publish Branch") }) + test("builds deterministic fake AI ordering with one-line summaries", async () => { + const rightSidebarModule = await import("./RightSidebar") as Record + const buildPrototypeAiOrderedDiff = rightSidebarModule.buildPrototypeAiOrderedDiff + + expect(typeof buildPrototypeAiOrderedDiff).toBe("function") + if (typeof buildPrototypeAiOrderedDiff !== "function") { + return + } + + const input = [ + { + path: "src/app.ts", + changeType: "modified", + isUntracked: false, + additions: 12, + deletions: 4, + patchDigest: "digest-app", + }, + { + path: "README.md", + changeType: "added", + isUntracked: true, + additions: 20, + deletions: 0, + patchDigest: "digest-readme", + }, + { + path: "src/utils/math.ts", + changeType: "deleted", + isUntracked: false, + additions: 0, + deletions: 8, + patchDigest: "digest-math", + }, + ] + + const ordered = buildPrototypeAiOrderedDiff(input) as Array<{ + file: { path: string } + summary: string + orderLabel: string + }> + + expect(ordered).toHaveLength(3) + expect(ordered.map((entry) => entry.file.path)).not.toEqual(input.map((entry) => entry.path)) + expect(ordered.every((entry) => entry.summary.length > 0)).toBe(true) + expect(ordered[0]?.orderLabel).toBe("Step 1") + }) + test("renders the branch switcher affordance", () => { const onClose = mock(() => {}) const markup = renderToStaticMarkup(createElement( diff --git a/src/client/components/chat-ui/RightSidebar.tsx b/src/client/components/chat-ui/RightSidebar.tsx index 181f0cbd2..6be867cb9 100644 --- a/src/client/components/chat-ui/RightSidebar.tsx +++ b/src/client/components/chat-ui/RightSidebar.tsx @@ -34,8 +34,22 @@ import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, Dia type DiffRenderMode = "unified" | "split" type DiffFile = ChatDiffSnapshot["files"][number] type SidebarViewMode = "changes" | "history" +type DiffPanelKey = "raw" | "ai" | "summary" const EMPTY_CHECKED_PATHS: Record = {} +interface PrototypeAiOrderedDiffEntry { + file: DiffFile + summary: string + orderLabel: string + sortKey: number +} + +const DEFAULT_DIFF_PANEL_VISIBILITY: Record = { + raw: true, + ai: true, + summary: true, +} + function getDiffPreviewAttachment(projectId: string | null, file: DiffFile): ChatAttachment | null { if (!projectId || !file.mimeType || typeof file.size !== "number" || file.changeType === "deleted") { return null @@ -211,6 +225,56 @@ function formatFetchTooltip(isoTimestamp?: string) { return `Last fetched ${formatRelativeTime(isoTimestamp)}` } +function hashDiffPath(path: string) { + let hash = 0 + for (const char of path) { + hash = (hash * 33 + char.charCodeAt(0)) % 1_000_003 + } + return hash +} + +function buildPrototypeDiffSummary(file: DiffFile) { + const fileName = file.path.split("/").pop() ?? file.path + const lineDelta = file.additions > 0 || file.deletions > 0 + ? ` (+${file.additions}/-${file.deletions})` + : "" + + switch (file.changeType) { + case "added": + return `Introduces ${fileName} as a new piece of the flow${lineDelta}.` + case "deleted": + return `Removes ${fileName} to simplify the change set${lineDelta}.` + case "renamed": + return `Repositions ${fileName} so the story reads in a different place${lineDelta}.` + case "modified": + default: + return `Updates ${fileName} to reshape existing behavior${lineDelta}.` + } +} + +export function buildPrototypeAiOrderedDiff(files: DiffFile[]): PrototypeAiOrderedDiffEntry[] { + return files + .map((file) => { + const sortKey = (hashDiffPath(file.path) + file.additions * 17 + file.deletions * 29 + file.changeType.length * 31) % 10_000 + return { + file, + summary: buildPrototypeDiffSummary(file), + orderLabel: "", + sortKey, + } + }) + .sort((left, right) => { + if (left.sortKey !== right.sortKey) { + return right.sortKey - left.sortKey + } + return left.file.path.localeCompare(right.file.path) + }) + .map((entry, index) => ({ + ...entry, + orderLabel: `Step ${index + 1}`, + })) +} + function CommitHistoryRow({ entry, isPendingPush = false }: { entry: ChatBranchHistoryEntry; isPendingPush?: boolean }) { const relativeTime = formatRelativeTime(entry.authoredAt) const isClickable = Boolean(entry.githubUrl) @@ -1413,6 +1477,7 @@ function RightSidebarImpl({ const [commitModeInFlight, setCommitModeInFlight] = useState(null) const [isSyncing, setIsSyncing] = useState(false) const [isGitHubPublishModalOpen, setIsGitHubPublishModalOpen] = useState(false) + const [diffPanelVisibility, setDiffPanelVisibility] = useState>(DEFAULT_DIFF_PANEL_VISIBILITY) const [patchesByPath, setPatchesByPath] = useState>({}) const [patchErrorsByPath, setPatchErrorsByPath] = useState>({}) const [loadingPatchPaths, setLoadingPatchPaths] = useState>({}) @@ -1510,6 +1575,7 @@ function RightSidebarImpl({ && !isBusy const primaryCommitMode: DiffCommitMode = hasRemoteOrigin ? "commit_and_push" : "commit_only" const resolvedBranchName = diffs.branchName ?? "current branch" + const prototypeAiOrderedDiff = useMemo(() => buildPrototypeAiOrderedDiff(diffs.files), [diffs.files]) async function handleCommit(mode: DiffCommitMode) { if (!canCommit) return @@ -1604,8 +1670,55 @@ function RightSidebarImpl({ } }, [diffs.files, loadingPatchPaths, onLoadPatch, patchesByPath]) + const visiblePanelCount = Object.values(diffPanelVisibility).filter(Boolean).length + const showInlineDiffControls = viewMode === "changes" && (diffPanelVisibility.raw || diffPanelVisibility.ai) + + function renderDiffFile(file: DiffFile) { + const isCollapsed = collapsedPaths[file.path] ?? true + const isChecked = checkedPaths[file.path] ?? true + + return ( + { + if (!projectId) return + toggleCollapsedPath(projectId, file.path) + }} + onToggleChecked={() => { + if (!projectId) return + setCheckedPath(projectId, file.path, !isChecked) + }} + fileActions={fileActions} + patch={patchesByPath[file.path]} + patchError={patchErrorsByPath[file.path]} + isPatchLoading={Boolean(loadingPatchPaths[file.path])} + onLoadPatch={handleLoadPatch} + /> + ) + } + + function toggleDiffPanel(panel: DiffPanelKey) { + setDiffPanelVisibility((current) => ({ ...current, [panel]: !current[panel] })) + } + + function setAllDiffPanels(nextVisible: boolean) { + setDiffPanelVisibility({ + raw: nextVisible, + ai: nextVisible, + summary: nextVisible, + }) + } + return ( -
+
1 ? `${visiblePanelCount * 320}px` : "370px" }}>
@@ -1747,7 +1860,7 @@ function RightSidebarImpl({ />
- {viewMode === "changes" ? ( + {showInlineDiffControls ? (
-
+
{diffs.status === "no_repo" ? (
@@ -1800,39 +1913,148 @@ function RightSidebarImpl({

No file changes.

) : ( -
- {diffs.files.map((file) => { - const isCollapsed = collapsedPaths[file.path] ?? true - const isChecked = checkedPaths[file.path] ?? true - - return ( - { - if (!projectId) return - toggleCollapsedPath(projectId, file.path) - }} - onToggleChecked={() => { - if (!projectId) return - setCheckedPath(projectId, file.path, !isChecked) - }} - fileActions={fileActions} - patch={patchesByPath[file.path]} - patchError={patchErrorsByPath[file.path]} - isPatchLoading={Boolean(loadingPatchPaths[file.path])} - onLoadPatch={handleLoadPatch} - /> - ) - })} +
+
+
+
+ {[ + { value: "raw", label: "Raw Diff" }, + { value: "ai", label: "AI Order" }, + { value: "summary", label: "Summary" }, + ].map((panel) => { + const panelKey = panel.value as DiffPanelKey + const visible = diffPanelVisibility[panelKey] + return ( + + ) + })} +
+ +
+
+ {visiblePanelCount === 0 ? ( +
+ All diff views are collapsed. +
+ ) : ( +
+
1 ? `${visiblePanelCount * 300}px` : undefined }} + > + {diffPanelVisibility.raw ? ( +
+
+
+
Raw Diff
+
Traditional file-by-file view
+
+ +
+
+ {diffs.files.map((file) => renderDiffFile(file))} +
+
+ ) : null} + + {diffPanelVisibility.ai ? ( +
+
+
+
AI Order
+
Prototype reordered story of the diff
+
+ +
+
+ {prototypeAiOrderedDiff.map((entry) => ( +
+
+
+
+
+ {entry.orderLabel} +
+
{entry.summary}
+
+
+ LLM +
+
+
+ {renderDiffFile(entry.file)} +
+ ))} +
+
+ ) : null} + + {diffPanelVisibility.summary ? ( +
+
+
+
Summary
+
One-line natural language descriptions
+
+ +
+
+ {prototypeAiOrderedDiff.map((entry) => ( +
+
+
+ {entry.orderLabel} +
+
+
{entry.summary}
+
{entry.file.path}
+
+
+
+ ))} +
+
+ ) : null} +
+
+ )} {viewMode === "changes" ? (
From 4225ac2117c10bcc76975f8d9161219c93a95936 Mon Sep 17 00:00:00 2001 From: Aasish Raj Date: Thu, 16 Apr 2026 13:42:31 +0530 Subject: [PATCH 02/13] POC --- ui-test/README.md | 27 + ui-test/package.json | 13 + ui-test/public/app.js | 351 +++++++++++++ ui-test/public/index.html | 90 ++++ ui-test/public/styles.css | 503 ++++++++++++++++++ ui-test/server.js | 755 +++++++++++++++++++++++++++ ui-test/shared/command.js | 152 ++++++ ui-test/shared/diffHunks.js | 208 ++++++++ ui-test/shared/diffStats.js | 36 ++ ui-test/shared/parseAgentResponse.js | 148 ++++++ ui-test/test/command.test.js | 33 ++ ui-test/test/diffHunks.test.js | 77 +++ ui-test/test/diffStats.test.js | 24 + ui-test/test/parser.test.js | 75 +++ 14 files changed, 2492 insertions(+) create mode 100644 ui-test/README.md create mode 100644 ui-test/package.json create mode 100644 ui-test/public/app.js create mode 100644 ui-test/public/index.html create mode 100644 ui-test/public/styles.css create mode 100644 ui-test/server.js create mode 100644 ui-test/shared/command.js create mode 100644 ui-test/shared/diffHunks.js create mode 100644 ui-test/shared/diffStats.js create mode 100644 ui-test/shared/parseAgentResponse.js create mode 100644 ui-test/test/command.test.js create mode 100644 ui-test/test/diffHunks.test.js create mode 100644 ui-test/test/diffStats.test.js create mode 100644 ui-test/test/parser.test.js diff --git a/ui-test/README.md b/ui-test/README.md new file mode 100644 index 000000000..85ab2a787 --- /dev/null +++ b/ui-test/README.md @@ -0,0 +1,27 @@ +# Codex Git Diff Analyzer UI + +Local UI for analyzing git diffs through `codex app-server`. + +## Run + +```sh +cd ui-test +npm start +``` + +Open the printed localhost URL. + +The bridge uses the `codex` binary on `PATH` by default. To point at a specific binary: + +```sh +CODEX_BIN=/absolute/path/to/codex npm start +``` + +## Test + +```sh +cd ui-test +npm test +``` + +The app is dependency-free: Node serves the UI, starts `codex app-server`, and streams updates to the browser with Server-Sent Events. The bridge splits the diff into local change-block IDs before prompting Codex, so Codex streams only IDs, descriptions, and the summary instead of repeating the full diff back. diff --git a/ui-test/package.json b/ui-test/package.json new file mode 100644 index 000000000..f57575bae --- /dev/null +++ b/ui-test/package.json @@ -0,0 +1,13 @@ +{ + "name": "codex-git-diff-analyzer-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "test": "node --test test/*.test.js" + }, + "engines": { + "node": ">=22" + } +} diff --git a/ui-test/public/app.js b/ui-test/public/app.js new file mode 100644 index 000000000..a43d7d2aa --- /dev/null +++ b/ui-test/public/app.js @@ -0,0 +1,351 @@ +import { commandToString, DIFF_PRESETS } from "/shared/command.js"; +import { parseAgentResponse } from "/shared/parseAgentResponse.js"; + +const form = document.querySelector("#analysis-form"); +const projectPath = document.querySelector("#project-path"); +const preset = document.querySelector("#preset"); +const customCommand = document.querySelector("#custom-command"); +const customCommandField = document.querySelector(".custom-command"); +const reuseThread = document.querySelector("#reuse-thread"); +const analyzeButton = document.querySelector("#analyze-button"); +const cancelButton = document.querySelector("#cancel-button"); +const statusDot = document.querySelector("#status-dot"); +const statusText = document.querySelector("#status-text"); +const commandText = document.querySelector("#command-text"); +const stats = document.querySelector("#stats"); +const errorPanel = document.querySelector("#error-panel"); +const planPanel = document.querySelector("#plan-panel"); +const planList = document.querySelector("#plan-list"); +const summaryPanel = document.querySelector("#summary-panel"); +const summaryText = document.querySelector("#summary-text"); +const hunkCount = document.querySelector("#hunk-count"); +const hunks = document.querySelector("#hunks"); +const streamingPill = document.querySelector("#streaming-pill"); + +let latestState = null; +let lastStartedAt = null; +let responseBuffers = new Map(); +let currentParsed = parseAgentResponse(""); +let sourceHunks = []; +const contextVisibility = new Map(); + +loadConfig(); +connectEvents(); + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + clearError(); + + await postJson("/api/analyze", { + projectPath: projectPath.value, + preset: preset.value, + customCommand: customCommand.value, + reuseThread: reuseThread.checked, + }); +}); + +cancelButton.addEventListener("click", async () => { + clearError(); + await postJson("/api/cancel", {}); +}); + +preset.addEventListener("change", () => { + customCommandField.hidden = preset.value !== "custom"; + if (preset.value !== "custom" && DIFF_PRESETS[preset.value]) { + commandText.textContent = commandToString(DIFF_PRESETS[preset.value].command); + } +}); + +async function loadConfig() { + const response = await fetch("/api/config"); + const config = await response.json(); + projectPath.value = config.defaultProjectPath; +} + +function connectEvents() { + const source = new EventSource("/api/events"); + + source.addEventListener("state", (event) => { + latestState = JSON.parse(event.data); + renderState(latestState); + }); + + source.addEventListener("agent-delta", (event) => { + const payload = JSON.parse(event.data); + responseBuffers.set(payload.itemId, `${responseBuffers.get(payload.itemId) || ""}${payload.delta || ""}`); + currentParsed = parseAgentResponse([...responseBuffers.values()].join("\n\n")); + if (latestState) { + renderState(latestState); + } + }); + + source.addEventListener("agent-message-completed", (event) => { + const payload = JSON.parse(event.data); + responseBuffers.set(payload.itemId, payload.text || ""); + currentParsed = payload.parsed || parseAgentResponse([...responseBuffers.values()].join("\n\n")); + if (latestState) { + renderState(latestState); + } + }); + + source.addEventListener("log", (event) => { + const log = JSON.parse(event.data); + if (log.level === "error") { + showError(log.message); + } + }); + + source.onerror = () => { + statusText.textContent = "Bridge connection lost. Reconnecting..."; + statusDot.dataset.status = "failed"; + }; +} + +async function postJson(url, body) { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + showError(payload.error || "Request failed."); + } + return payload; +} + +function renderState(state) { + if (state.startedAt && state.startedAt !== lastStartedAt) { + lastStartedAt = state.startedAt; + responseBuffers = new Map(); + currentParsed = state.parsed || parseAgentResponse(""); + contextVisibility.clear(); + } + + if (Array.isArray(state.sourceHunks)) { + sourceHunks = state.sourceHunks; + } + + if (state.parsed?.hunks?.length || state.parsed?.summary) { + currentParsed = state.parsed; + } + + const parsed = currentParsed; + const running = ["starting", "running", "cancelling"].includes(state.status); + + statusDot.dataset.status = state.status; + statusText.textContent = state.statusText || state.status; + commandText.textContent = state.diffCommand ? state.diffCommand : ""; + analyzeButton.disabled = running; + cancelButton.disabled = state.status !== "running"; + streamingPill.hidden = state.status !== "running"; + + if (state.projectPath && document.activeElement !== projectPath) { + projectPath.value = state.projectPath; + } + + renderStats(state.diffStats); + renderError(state.error); + renderPlan(state.plan || []); + renderSummary(parsed.summary); + renderHunks(parsed.hunks || [], sourceHunks); +} + +function renderStats(diffStats) { + if (!diffStats) { + stats.textContent = ""; + return; + } + + stats.innerHTML = ""; + const items = [ + ["Files", diffStats.files], + ["Hunks", diffStats.hunks], + ["Added", `+${diffStats.additions}`], + ["Deleted", `-${diffStats.deletions}`], + ]; + + for (const [label, value] of items) { + const item = document.createElement("span"); + item.textContent = `${label}: ${value}`; + stats.append(item); + } +} + +function renderError(error) { + if (!error) { + clearError(); + return; + } + showError(error); +} + +function showError(message) { + errorPanel.hidden = false; + errorPanel.textContent = message; +} + +function clearError() { + errorPanel.hidden = true; + errorPanel.textContent = ""; +} + +function renderPlan(plan) { + if (!plan.length) { + planPanel.hidden = true; + planList.innerHTML = ""; + return; + } + + planPanel.hidden = false; + planList.innerHTML = ""; + for (const entry of plan) { + const item = document.createElement("li"); + item.dataset.status = entry.status; + item.textContent = entry.step; + planList.append(item); + } +} + +function renderSummary(summary) { + const hasSummary = Boolean(summary && summary.trim()); + summaryPanel.hidden = !hasSummary; + summaryText.textContent = hasSummary ? summary : ""; +} + +function renderHunks(items, originals) { + hunkCount.textContent = items.length === 1 ? "1 change block" : `${items.length} change blocks`; + + if (!items.length) { + hunks.className = "hunks empty-state"; + hunks.innerHTML = originals.length + ? "

Waiting for change notes from Codex.

" + : "

Run an analysis to see reordered change blocks and annotations.

"; + return; + } + + hunks.className = "hunks"; + hunks.innerHTML = ""; + const originalById = new Map(originals.map((hunk) => [hunk.id, hunk])); + for (const [index, hunk] of items.entries()) { + hunks.append(renderHunk(hunk, originalById.get(hunk.id), index)); + } +} + +function renderHunk(hunk, original, index) { + const article = document.createElement("article"); + article.className = "hunk"; + const blockId = hunk.id || original?.id || `change-${index + 1}`; + const visibility = contextVisibility.get(blockId) || {}; + const contextBefore = Array.isArray(original?.contextBefore) ? original.contextBefore : []; + const contextAfter = Array.isArray(original?.contextAfter) ? original.contextAfter : []; + + const header = document.createElement("header"); + header.className = "hunk-header"; + const title = document.createElement("h3"); + title.textContent = original?.title || hunk.id || `Change block ${index + 1}`; + + const actions = document.createElement("div"); + actions.className = "hunk-actions"; + actions.append( + renderContextButton(blockId, "before", contextBefore, Boolean(visibility.before)), + renderContextButton(blockId, "after", contextAfter, Boolean(visibility.after)), + ); + header.append(title, actions); + + const diff = document.createElement("div"); + diff.className = "diff"; + diff.append(...renderDiffLines(hunk.diff || original?.diff || "", { + contextBefore: visibility.before ? contextBefore : [], + contextAfter: visibility.after ? contextAfter : [], + })); + + const description = document.createElement("p"); + description.className = "description"; + description.textContent = hunk.description || "No description was provided."; + + article.append(header, diff, description); + return article; +} + +function renderContextButton(blockId, side, lines, visible) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "context-button"; + button.disabled = !lines.length; + button.setAttribute("aria-pressed", visible ? "true" : "false"); + button.textContent = visible ? `Hide ${side} lines` : `Show 10 lines ${side}`; + if (!lines.length) { + button.title = `No ${side} context lines available`; + } + + button.addEventListener("click", () => { + const current = contextVisibility.get(blockId) || {}; + contextVisibility.set(blockId, { + ...current, + [side]: !visible, + }); + if (latestState) { + renderState(latestState); + } + }); + + return button; +} + +function renderDiffLines(diffText, options = {}) { + const contextBefore = Array.isArray(options.contextBefore) ? options.contextBefore : []; + const contextAfter = Array.isArray(options.contextAfter) ? options.contextAfter : []; + const lines = String(diffText).split(/\r?\n/); + const hunkHeaderIndex = lines.findIndex((line) => line.startsWith("@@")); + const rows = []; + + for (const [index, line] of lines.entries()) { + rows.push(renderDiffLine(line)); + + if (index === hunkHeaderIndex) { + rows.push(...contextBefore.map((contextLine) => renderDiffLine(contextLine, true))); + } + } + + if (hunkHeaderIndex === -1) { + rows.unshift(...contextBefore.map((contextLine) => renderDiffLine(contextLine, true))); + } + + rows.push(...contextAfter.map((contextLine) => renderDiffLine(contextLine, true))); + return rows; +} + +function renderDiffLine(line, expanded = false) { + const row = document.createElement("div"); + row.className = `diff-line ${classifyDiffLine(line)}${expanded ? " line-expanded-context" : ""}`; + + const marker = document.createElement("span"); + marker.className = "diff-marker"; + marker.textContent = line.slice(0, 1) || " "; + + const content = document.createElement("code"); + content.textContent = line; + + row.append(marker, content); + return row; +} + +function classifyDiffLine(line) { + if (line.startsWith("diff --git ") || line.startsWith("---") || line.startsWith("+++")) { + return "line-meta"; + } + if (line.startsWith("@@")) { + return "line-hunk"; + } + if (line.startsWith("+")) { + return "line-add"; + } + if (line.startsWith("-")) { + return "line-delete"; + } + return "line-context"; +} diff --git a/ui-test/public/index.html b/ui-test/public/index.html new file mode 100644 index 000000000..390311519 --- /dev/null +++ b/ui-test/public/index.html @@ -0,0 +1,90 @@ + + + + + + Git Diff Analyzer + + + +
+
+
+

Codex App Server

+

Git Diff Analyzer

+

Turn a raw git diff into a data-flow ordered review with hunk-level notes.

+
+ +
+ + + + + + + + +
+ + +
+
+
+ +
+
+ + Ready + +
+
+
+ + + + + + + +
+
+
+

Annotated Changes

+

No change blocks yet

+
+ +
+ +
+

Run an analysis to see reordered change blocks and annotations.

+
+
+
+ + + + diff --git a/ui-test/public/styles.css b/ui-test/public/styles.css new file mode 100644 index 000000000..4ab5fefe6 --- /dev/null +++ b/ui-test/public/styles.css @@ -0,0 +1,503 @@ +:root { + color-scheme: light; + --page: #f5f6f8; + --panel: #ffffff; + --text: #202124; + --muted: #61666d; + --line: #d9dde3; + --line-strong: #b8c0ca; + --accent: #0f766e; + --accent-strong: #0b5f59; + --add-bg: #e3f7ea; + --add-text: #075c2f; + --delete-bg: #fde8e8; + --delete-text: #9b1c1c; + --hunk-bg: #eef2f7; + --summary-bg: #fff7d6; + --summary-line: #d5a90f; + --error-bg: #fff0f0; + --error-line: #d64545; + --shadow: 0 18px 48px rgba(31, 35, 40, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + color: var(--text); + background: var(--page); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, +input, +select { + font: inherit; +} + +.shell { + width: min(1440px, calc(100% - 32px)); + margin: 0 auto; + padding: 32px 0 48px; +} + +.workspace, +.summary-panel, +.plan-panel, +.error-panel, +.result-layout, +.status-band { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.workspace { + display: grid; + grid-template-columns: minmax(260px, 0.8fr) minmax(320px, 1.2fr); + gap: 28px; + align-items: start; + padding: 28px; +} + +.intro h1, +.result-header h2, +.plan-panel h2, +.hunk h3 { + margin: 0; +} + +.intro h1 { + max-width: 12ch; + font-size: 42px; + line-height: 1; + letter-spacing: 0; +} + +.lead { + max-width: 42rem; + margin: 18px 0 0; + color: var(--muted); + font-size: 18px; + line-height: 1.5; +} + +.eyebrow { + margin: 0 0 10px; + color: var(--accent-strong); + font-size: 12px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +.controls { + display: grid; + grid-template-columns: 1fr 220px; + gap: 16px; +} + +.field, +.check { + display: grid; + gap: 8px; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.field-wide { + grid-column: 1 / -1; +} + +.field input, +.field select { + width: 100%; + min-height: 42px; + border: 1px solid var(--line-strong); + border-radius: 6px; + padding: 10px 12px; + color: var(--text); + background: #fff; +} + +.field input:focus, +.field select:focus { + outline: 3px solid rgba(15, 118, 110, 0.2); + border-color: var(--accent); +} + +.check { + grid-column: 1 / -1; + display: flex; + align-items: center; +} + +.check input { + width: 18px; + height: 18px; + accent-color: var(--accent); +} + +.actions { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +button { + min-height: 42px; + border: 1px solid var(--accent); + border-radius: 6px; + padding: 10px 16px; + color: #fff; + background: var(--accent); + font-weight: 800; + cursor: pointer; +} + +button:hover:not(:disabled) { + background: var(--accent-strong); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.button-secondary { + color: var(--text); + background: #fff; + border-color: var(--line-strong); +} + +.button-secondary:hover:not(:disabled) { + background: #f0f2f4; +} + +.status-band { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: center; + margin-top: 16px; + padding: 14px 18px; +} + +.status-line { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.status-line strong, +#command-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#command-text { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 13px; +} + +.status-dot { + width: 12px; + height: 12px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--line-strong); +} + +.status-dot[data-status="starting"], +.status-dot[data-status="running"], +.status-dot[data-status="cancelling"] { + background: #d49b14; +} + +.status-dot[data-status="completed"] { + background: #168447; +} + +.status-dot[data-status="failed"], +.status-dot[data-status="interrupted"] { + background: var(--error-line); +} + +.stats { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.stats span { + border: 1px solid var(--line); + border-radius: 6px; + padding: 5px 8px; + color: var(--muted); + background: #fafbfc; + font-size: 12px; + font-weight: 800; +} + +.error-panel, +.plan-panel, +.summary-panel, +.result-layout { + margin-top: 16px; +} + +.error-panel { + border-left: 5px solid var(--error-line); + padding: 16px 18px; + color: #7a1515; + background: var(--error-bg); + font-weight: 700; +} + +.plan-panel { + padding: 18px; +} + +.plan-panel h2 { + font-size: 18px; +} + +.plan-panel ol { + display: grid; + gap: 8px; + margin: 14px 0 0; + padding-left: 24px; +} + +.plan-panel li { + color: var(--muted); +} + +.plan-panel li[data-status="completed"] { + color: #16703e; +} + +.plan-panel li[data-status="inProgress"] { + color: #9a6a00; + font-weight: 800; +} + +.summary-panel { + border-left: 5px solid var(--summary-line); + padding: 20px 22px; + background: var(--summary-bg); +} + +#summary-text { + max-width: 920px; + line-height: 1.6; +} + +.result-layout { + padding: 22px; +} + +.result-header { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: center; + margin-bottom: 18px; +} + +.result-header h2 { + font-size: 24px; +} + +.streaming-pill { + border: 1px solid #d49b14; + border-radius: 999px; + padding: 7px 12px; + color: #704b00; + background: #fff8df; + font-size: 12px; + font-weight: 900; +} + +.hunks { + display: grid; + gap: 18px; +} + +.empty-state { + min-height: 220px; + place-items: center; + border: 1px dashed var(--line-strong); + border-radius: 8px; + color: var(--muted); + background: #fafbfc; + text-align: center; +} + +.hunk { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; +} + +.hunk-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--line); + padding: 12px 14px; + background: #fafbfc; +} + +.hunk h3 { + font-size: 15px; +} + +.hunk-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.context-button { + min-height: 32px; + border: 1px solid var(--line-strong); + border-radius: 8px; + padding: 6px 10px; + color: var(--accent-strong); + background: #fff; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.context-button[aria-pressed="true"] { + border-color: var(--accent); + background: #e6f4f1; +} + +.context-button:disabled { + color: var(--muted); + background: #f2f4f7; + cursor: not-allowed; + opacity: 0.7; +} + +.diff { + overflow-x: auto; + background: #fbfcfe; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 13px; +} + +.diff-line { + display: grid; + grid-template-columns: 32px minmax(max-content, 1fr); + min-height: 24px; + border-bottom: 1px solid rgba(217, 221, 227, 0.65); +} + +.diff-line code { + display: block; + padding: 4px 12px 4px 0; + white-space: pre; +} + +.diff-marker { + padding: 4px 8px; + color: var(--muted); + user-select: none; + text-align: center; +} + +.line-expanded-context { + background: #fffdf2; +} + +.line-meta { + color: var(--muted); + background: #f2f4f7; + font-weight: 800; +} + +.line-hunk { + color: #4f5d72; + background: var(--hunk-bg); +} + +.line-add { + color: var(--add-text); + background: var(--add-bg); +} + +.line-delete { + color: var(--delete-text); + background: var(--delete-bg); +} + +.description { + margin: 0; + border-top: 1px solid var(--line); + padding: 14px 16px; + color: #33403c; + background: #f4faf7; + font-style: italic; + line-height: 1.5; +} + +[hidden] { + display: none !important; +} + +@media (max-width: 880px) { + .shell { + width: min(100% - 20px, 760px); + padding-top: 16px; + } + + .workspace { + grid-template-columns: 1fr; + padding: 20px; + } + + .intro h1 { + max-width: none; + font-size: 34px; + } + + .controls { + grid-template-columns: 1fr; + } + + .status-band, + .result-header { + align-items: flex-start; + flex-direction: column; + } + + .hunk-header { + align-items: flex-start; + flex-direction: column; + } + + .hunk-actions { + justify-content: flex-start; + } + + .stats { + justify-content: flex-start; + } +} diff --git a/ui-test/server.js b/ui-test/server.js new file mode 100644 index 000000000..b978a6ab7 --- /dev/null +++ b/ui-test/server.js @@ -0,0 +1,755 @@ +import { spawn } from "node:child_process"; +import { createReadStream, existsSync } from "node:fs"; +import { promises as fs } from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import process from "node:process"; +import readline from "node:readline"; +import { fileURLToPath } from "node:url"; +import { commandToString, DIFF_PRESETS, resolveDiffCommand, withUnifiedContext } from "./shared/command.js"; +import { parseUnifiedDiffHunks } from "./shared/diffHunks.js"; +import { computeDiffStats } from "./shared/diffStats.js"; +import { parseAgentResponse } from "./shared/parseAgentResponse.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const publicDir = path.join(__dirname, "public"); +const sharedDir = path.join(__dirname, "shared"); +const workspaceRoot = path.resolve(__dirname, ".."); +const defaultProjectPath = existsSync(path.join(workspaceRoot, "better-diff")) + ? path.join(workspaceRoot, "better-diff") + : workspaceRoot; +const port = Number(process.env.PORT || 4173); +const host = process.env.HOST || "127.0.0.1"; +const modelContextLines = 2; +const expandableContextLines = 10; +const viewerDiffContextLines = modelContextLines + expandableContextLines; + +const sseClients = new Set(); +let appServer; + +const state = { + status: "idle", + statusText: "Ready", + projectPath: defaultProjectPath, + diffCommand: commandToString(DIFF_PRESETS.lastCommit.command), + threadId: null, + turnId: null, + parsed: parseAgentResponse(""), + sourceHunks: [], + diffStats: null, + plan: [], + logs: [], + error: null, + startedAt: null, + completedAt: null, + serverReady: false, +}; + +const agentItemIds = new Set(); +const itemBuffers = new Map(); + +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url || "/", `http://${request.headers.host}`); + + if (request.method === "GET" && url.pathname === "/api/events") { + return handleEvents(response); + } + + if (request.method === "GET" && url.pathname === "/api/config") { + return sendJson(response, 200, { + defaultProjectPath, + diffPresets: DIFF_PRESETS, + }); + } + + if (request.method === "POST" && url.pathname === "/api/analyze") { + const body = await readJsonBody(request); + startAnalysis(body).catch((error) => { + failAnalysis(error); + }); + return sendJson(response, 202, { ok: true }); + } + + if (request.method === "POST" && url.pathname === "/api/cancel") { + await cancelAnalysis(); + return sendJson(response, 200, { ok: true }); + } + + return serveStatic(url.pathname, response); + } catch (error) { + return sendJson(response, error.statusCode || 500, { + error: error.message || "Unexpected server error", + }); + } +}); + +server.on("error", (error) => { + console.error(`Failed to start Git Diff Analyzer UI: ${error.message}`); + process.exitCode = 1; +}); + +server.listen(port, host, () => { + const url = `http://${host}:${port}`; + console.log(`Git Diff Analyzer UI running at ${url}`); + console.log(`Default project: ${defaultProjectPath}`); +}); + +async function startAnalysis(options = {}) { + if (state.status === "starting" || state.status === "running" || state.status === "cancelling") { + const error = new Error("An analysis is already running."); + error.statusCode = 409; + throw error; + } + + const projectPath = path.resolve(String(options.projectPath || defaultProjectPath)); + const command = resolveDiffCommand({ + preset: options.preset || "lastCommit", + customCommand: options.customCommand || "", + }); + const previousThreadId = options.reuseThread && state.projectPath === projectPath ? state.threadId : null; + + resetAnalysisState({ + status: "starting", + statusText: "Starting Codex app server", + projectPath, + diffCommand: commandToString(command), + threadId: previousThreadId, + startedAt: new Date().toISOString(), + }); + + await appServer.ensureInitialized(); + patchState({ + serverReady: true, + statusText: previousThreadId ? "Resuming thread" : "Starting thread", + }); + + let threadId = previousThreadId; + if (!threadId) { + const threadResult = await appServer.requestWithRetry("thread/start", { + cwd: projectPath, + approvalPolicy: "never", + }); + threadId = threadResult.thread.id; + patchState({ threadId }); + } else { + await appServer.requestWithRetry("thread/resume", { threadId }); + } + + patchState({ statusText: `Fetching ${state.diffCommand}` }); + const diffResult = await appServer.requestWithRetry("command/exec", { + command, + cwd: projectPath, + outputBytesCap: 8 * 1024 * 1024, + timeoutMs: 30000, + }); + + if (diffResult.exitCode !== 0) { + patchState({ + status: "failed", + statusText: "Git diff failed", + error: diffResult.stderr || `git diff exited with code ${diffResult.exitCode}`, + completedAt: new Date().toISOString(), + }); + return; + } + + const rawDiff = diffResult.stdout || ""; + const diffStats = computeDiffStats(rawDiff); + const viewerDiff = rawDiff.trim() + ? await fetchViewerDiff(command, projectPath, rawDiff) + : rawDiff; + const sourceBlocks = parseUnifiedDiffHunks(viewerDiff, { + modelContextLines, + viewContextLines: expandableContextLines, + }); + patchState({ diffStats, sourceHunks: sourceBlocks }); + + if (!rawDiff.trim()) { + patchState({ + status: "completed", + statusText: "No changes found", + parsed: { + hunks: [], + summary: "No changes were found for the selected diff range.", + partial: "", + isComplete: true, + raw: "", + }, + completedAt: new Date().toISOString(), + }); + return; + } + + patchState({ + status: "running", + statusText: "Sending diff to Codex", + }); + + const turnResult = await appServer.requestWithRetry("turn/start", { + threadId, + cwd: projectPath, + approvalPolicy: "never", + input: [ + { + type: "text", + text: buildAnalysisPrompt(sourceBlocks), + }, + ], + }); + + patchState({ + turnId: turnResult.turn.id, + status: "running", + statusText: "Codex is analyzing the diff", + }); +} + +async function fetchViewerDiff(command, projectPath, fallbackDiff) { + const contextCommand = withUnifiedContext(command, viewerDiffContextLines); + patchState({ statusText: "Fetching viewer context" }); + + const contextResult = await appServer.requestWithRetry("command/exec", { + command: contextCommand, + cwd: projectPath, + outputBytesCap: 16 * 1024 * 1024, + timeoutMs: 30000, + }); + + if (contextResult.exitCode === 0) { + return contextResult.stdout || ""; + } + + pushLog( + contextResult.stderr || `viewer context diff exited with code ${contextResult.exitCode}`, + "info", + ); + return fallbackDiff; +} + +async function cancelAnalysis() { + if (!state.threadId || !state.turnId || state.status !== "running") { + return; + } + + patchState({ + status: "cancelling", + statusText: "Cancelling analysis", + }); + + await appServer.requestWithRetry("turn/interrupt", { + threadId: state.threadId, + turnId: state.turnId, + }); +} + +function handleAppServerNotification(message) { + const { method, params = {} } = message; + + if (method === "thread/started" && params.thread?.id) { + patchState({ threadId: params.thread.id }); + return; + } + + if (method === "turn/started" && params.turn?.id === state.turnId) { + patchState({ + status: "running", + statusText: "Codex turn started", + }); + return; + } + + if (method === "turn/plan/updated" && params.turnId === state.turnId) { + patchState({ plan: params.plan || [] }); + return; + } + + if (method === "item/started") { + const item = params.item || {}; + if (item.type === "agentMessage") { + agentItemIds.add(item.id); + itemBuffers.set(item.id, ""); + patchState({ statusText: "Streaming analysis" }); + } + return; + } + + if (method === "item/agentMessage/delta") { + const itemId = params.itemId; + if (!agentItemIds.has(itemId)) { + return; + } + + const delta = params.delta || ""; + itemBuffers.set(itemId, `${itemBuffers.get(itemId) || ""}${delta}`); + broadcast("agent-delta", { itemId, delta }); + return; + } + + if (method === "item/completed") { + const item = params.item || {}; + if (item.type === "agentMessage" && agentItemIds.has(item.id)) { + itemBuffers.set(item.id, item.text || ""); + const parsed = parseAgentResponse(item.text || ""); + patchState({ parsed }); + broadcast("agent-message-completed", { + itemId: item.id, + text: item.text || "", + parsed, + }); + } + return; + } + + if (method === "turn/completed" && params.turn?.id === state.turnId) { + const turn = params.turn; + if (turn.status === "completed") { + patchState({ + status: "completed", + statusText: "Analysis complete", + error: null, + completedAt: new Date().toISOString(), + }); + return; + } + + if (turn.status === "interrupted") { + patchState({ + status: "interrupted", + statusText: "Analysis cancelled", + completedAt: new Date().toISOString(), + }); + return; + } + + patchState({ + status: "failed", + statusText: "Analysis failed", + error: friendlyCodexError(turn.error), + completedAt: new Date().toISOString(), + }); + return; + } + + if (method === "error") { + patchState({ + error: friendlyCodexError(params.error), + }); + } +} + +function resetAnalysisState(patch = {}) { + agentItemIds.clear(); + itemBuffers.clear(); + Object.assign(state, { + status: "idle", + statusText: "Ready", + parsed: parseAgentResponse(""), + sourceHunks: [], + diffStats: null, + plan: [], + error: null, + turnId: null, + startedAt: null, + completedAt: null, + }, patch); + broadcast("state", getSnapshot()); +} + +function patchState(patch = {}) { + Object.assign(state, patch); + broadcast("state", getSnapshot()); +} + +function failAnalysis(error) { + patchState({ + status: "failed", + statusText: "Analysis failed", + error: error.message || "Unexpected analysis error", + completedAt: new Date().toISOString(), + }); +} + +function getSnapshot() { + return { + status: state.status, + statusText: state.statusText, + projectPath: state.projectPath, + diffCommand: state.diffCommand, + threadId: state.threadId, + turnId: state.turnId, + parsed: state.parsed, + sourceHunks: state.sourceHunks, + diffStats: state.diffStats, + plan: state.plan, + logs: state.logs, + error: state.error, + startedAt: state.startedAt, + completedAt: state.completedAt, + serverReady: state.serverReady, + }; +} + +function buildAnalysisPrompt(sourceBlocks) { + const blockText = sourceBlocks + .map((hunk) => `--- SOURCE CHANGE BLOCK ${hunk.id} file=${hunk.file} --- +${hunk.diff} +--- END SOURCE CHANGE BLOCK ${hunk.id} ---`) + .join("\n\n"); + + return `Here are numbered git unified diff change blocks. A change block is a contiguous run of added/deleted lines with nearby context, split smaller than Git's default hunk when multiple edits are close together: + + +${blockText} + + +Please do the following: + +1. Arrange the change blocks in order of data flow (e.g., data models first, then business logic, then API layer, then UI/view layer). If the data flow order is ambiguous, use dependency order (things that are depended on come first). + +2. For each change block, write a one-sentence natural language description of what changed and why it matters. + +3. At the end, write a concise total summary (3-5 sentences) of all the changes together. + +Do not repeat any diff content in your response. Refer to change blocks only by their source block ID. + +Format your response exactly like this for each change block: + +--- CHANGE NOTE --- +ID: + +Description: + +--- END CHANGE NOTE --- + +Then after all change blocks: + +--- SUMMARY --- + +--- END SUMMARY ---`; +} + +function friendlyCodexError(error) { + if (!error) { + return "Codex reported an unknown error."; + } + + const info = error.codexErrorInfo || ""; + const message = error.message || String(error); + + if (info === "ContextWindowExceeded") { + return `${message} Narrow the diff range or analyze fewer files.`; + } + + if (info === "UsageLimitExceeded") { + return `${message} Usage limits or quota were reached.`; + } + + if (info === "Unauthorized") { + return `${message} Re-authenticate Codex and try again.`; + } + + if (info === "SandboxError") { + return `${message} Check project permissions and sandbox settings.`; + } + + return message; +} + +function pushLog(message, level = "info") { + const entry = { + level, + message, + at: new Date().toISOString(), + }; + state.logs = [...state.logs.slice(-79), entry]; + broadcast("log", entry); + broadcast("state", getSnapshot()); +} + +function handleEvents(response) { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + response.write("retry: 1000\n\n"); + sseClients.add(response); + sendEvent(response, "state", getSnapshot()); + + const heartbeat = setInterval(() => { + sendEvent(response, "ping", { at: Date.now() }); + }, 15000); + + response.on("close", () => { + clearInterval(heartbeat); + sseClients.delete(response); + }); +} + +function broadcast(event, payload) { + for (const client of sseClients) { + sendEvent(client, event, payload); + } +} + +function sendEvent(response, event, payload) { + response.write(`event: ${event}\n`); + response.write(`data: ${JSON.stringify(payload)}\n\n`); +} + +async function readJsonBody(request) { + let body = ""; + for await (const chunk of request) { + body += chunk; + if (body.length > 64 * 1024) { + const error = new Error("Request body is too large."); + error.statusCode = 413; + throw error; + } + } + + return body ? JSON.parse(body) : {}; +} + +async function serveStatic(urlPath, response) { + const pathname = decodeURIComponent(urlPath === "/" ? "/index.html" : urlPath); + const root = pathname.startsWith("/shared/") ? sharedDir : publicDir; + const relativePath = pathname.startsWith("/shared/") + ? pathname.replace(/^\/shared\//, "") + : pathname.replace(/^\//, ""); + const filePath = path.resolve(root, relativePath); + + if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) { + return sendJson(response, 403, { error: "Forbidden" }); + } + + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + return sendJson(response, 404, { error: "Not found" }); + } + + response.writeHead(200, { + "Content-Type": contentTypeFor(filePath), + "Cache-Control": "no-cache", + }); + createReadStream(filePath).pipe(response); + } catch { + sendJson(response, 404, { error: "Not found" }); + } +} + +function sendJson(response, statusCode, payload) { + response.writeHead(statusCode, { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + }); + response.end(JSON.stringify(payload)); +} + +function contentTypeFor(filePath) { + const extension = path.extname(filePath); + switch (extension) { + case ".html": + return "text/html; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".json": + return "application/json; charset=utf-8"; + default: + return "application/octet-stream"; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class AppServerClient { + constructor() { + this.child = null; + this.pending = new Map(); + this.nextId = 1; + this.initializing = null; + this.initialized = false; + this.notificationHandlers = new Set(); + } + + onNotification(handler) { + this.notificationHandlers.add(handler); + return () => this.notificationHandlers.delete(handler); + } + + async ensureInitialized() { + if (this.initialized) { + return; + } + + if (this.initializing) { + return this.initializing; + } + + this.initializing = this.initialize(); + try { + await this.initializing; + } finally { + this.initializing = null; + } + } + + async initialize() { + this.startProcess(); + await this.request("initialize", { + clientInfo: { + name: "my_diff_ui", + title: "Git Diff Analyzer", + version: "1.0.0", + }, + }, { id: 0 }); + this.notify("initialized", {}); + this.initialized = true; + pushLog("Codex app server initialized"); + } + + startProcess() { + if (this.child) { + return; + } + + const command = process.env.CODEX_BIN || "codex"; + this.child = spawn(command, ["app-server"], { + cwd: workspaceRoot, + env: { + ...process.env, + RUST_LOG: process.env.RUST_LOG || "info", + LOG_FORMAT: process.env.LOG_FORMAT || "json", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + this.child.stdout.setEncoding("utf8"); + this.child.stderr.setEncoding("utf8"); + + const stdout = readline.createInterface({ input: this.child.stdout }); + stdout.on("line", (line) => this.handleLine(line)); + + const stderr = readline.createInterface({ input: this.child.stderr }); + stderr.on("line", (line) => pushLog(line, "debug")); + + this.child.on("error", (error) => { + pushLog(`Failed to start codex app-server: ${error.message}`, "error"); + this.rejectAll(error); + this.resetProcess(); + }); + + this.child.on("exit", (code, signal) => { + pushLog(`Codex app-server exited with code ${code ?? "null"} signal ${signal ?? "null"}`, "error"); + this.rejectAll(new Error("Codex app-server exited unexpectedly.")); + this.resetProcess(); + }); + + pushLog(`Spawned ${command} app-server`); + } + + resetProcess() { + this.child = null; + this.initialized = false; + this.initializing = null; + patchState({ serverReady: false }); + } + + async requestWithRetry(method, params = {}) { + const maxAttempts = 5; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await this.request(method, params); + } catch (error) { + if (error.code !== -32001 || attempt === maxAttempts - 1) { + throw error; + } + + const backoff = 250 * 2 ** attempt; + const jitter = Math.floor(Math.random() * 150); + await delay(backoff + jitter); + } + } + + throw new Error("Retry attempts exhausted."); + } + + request(method, params = {}, options = {}) { + if (!this.child) { + this.startProcess(); + } + + const id = options.id ?? this.nextId++; + const message = { method, id, params }; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.writeMessage(message); + }); + } + + notify(method, params = {}) { + this.writeMessage({ method, params }); + } + + writeMessage(message) { + if (!this.child?.stdin?.writable) { + throw new Error("Codex app-server stdin is not writable."); + } + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } + + handleLine(line) { + if (!line.trim()) { + return; + } + + let message; + try { + message = JSON.parse(line); + } catch { + pushLog(`Non-JSON stdout from app-server: ${line}`, "debug"); + return; + } + + if (Object.prototype.hasOwnProperty.call(message, "id")) { + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + + this.pending.delete(message.id); + if (message.error) { + const error = new Error(message.error.message || "Codex app-server request failed."); + Object.assign(error, message.error); + pending.reject(error); + } else { + pending.resolve(message.result); + } + return; + } + + for (const handler of this.notificationHandlers) { + handler(message); + } + } + + rejectAll(error) { + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + } +} + +appServer = new AppServerClient(); +appServer.onNotification((message) => { + handleAppServerNotification(message); +}); diff --git a/ui-test/shared/command.js b/ui-test/shared/command.js new file mode 100644 index 000000000..64607d291 --- /dev/null +++ b/ui-test/shared/command.js @@ -0,0 +1,152 @@ +export const DIFF_PRESETS = { + working: { + label: "Unstaged changes", + command: ["git", "diff"], + }, + staged: { + label: "Staged changes", + command: ["git", "diff", "--staged"], + }, + lastCommit: { + label: "Last commit", + command: ["git", "diff", "HEAD~1"], + }, + mainBranch: { + label: "Branch vs main", + command: ["git", "diff", "main...HEAD"], + }, +}; + +export function commandToString(command) { + return command.map(quoteToken).join(" "); +} + +export function resolveDiffCommand({ preset = "lastCommit", customCommand = "" } = {}) { + if (preset === "custom") { + return parseGitDiffCommand(customCommand); + } + + const selected = DIFF_PRESETS[preset] || DIFF_PRESETS.lastCommit; + return [...selected.command]; +} + +export function parseGitDiffCommand(input) { + const tokens = splitCommandLine(input); + if (tokens.length < 2) { + throw new Error("Enter a git diff command."); + } + + if (tokens[0] !== "git" || tokens[1] !== "diff") { + throw new Error("Only git diff commands are allowed."); + } + + return tokens; +} + +export function splitCommandLine(input = "") { + const source = String(input).trim(); + const tokens = []; + let token = ""; + let quote = null; + let escaping = false; + + for (const char of source) { + if (escaping) { + token += char; + escaping = false; + continue; + } + + if (char === "\\") { + escaping = true; + continue; + } + + if (quote) { + if (char === quote) { + quote = null; + } else { + token += char; + } + continue; + } + + if (char === "\"" || char === "'") { + quote = char; + continue; + } + + if (/\s/.test(char)) { + if (token) { + tokens.push(token); + token = ""; + } + continue; + } + + token += char; + } + + if (escaping) { + token += "\\"; + } + + if (quote) { + throw new Error("Command contains an unterminated quote."); + } + + if (token) { + tokens.push(token); + } + + return tokens; +} + +export function withUnifiedContext(command, contextLines) { + const tokens = Array.isArray(command) ? command : []; + if (tokens[0] !== "git" || tokens[1] !== "diff") { + return [...tokens]; + } + + const result = ["git", "diff", `--unified=${Math.max(0, Math.floor(Number(contextLines) || 0))}`]; + let inPathspec = false; + + for (let index = 2; index < tokens.length; index += 1) { + const token = tokens[index]; + + if (token === "--") { + inPathspec = true; + result.push(token); + continue; + } + + if (!inPathspec && consumesUnifiedContextValue(token)) { + index += 1; + continue; + } + + if (!inPathspec && isInlineUnifiedContextOption(token)) { + continue; + } + + result.push(token); + } + + return result; +} + +function quoteToken(token) { + if (/^[A-Za-z0-9_./:@~+=,-]+$/.test(token)) { + return token; + } + + return JSON.stringify(token); +} + +function consumesUnifiedContextValue(token) { + return token === "-U" || token === "--unified"; +} + +function isInlineUnifiedContextOption(token) { + return /^-U\d+$/.test(token) || /^--unified=/.test(token); +} diff --git a/ui-test/shared/diffHunks.js b/ui-test/shared/diffHunks.js new file mode 100644 index 000000000..eedbac54e --- /dev/null +++ b/ui-test/shared/diffHunks.js @@ -0,0 +1,208 @@ +const DEFAULT_MODEL_CONTEXT_LINES = 2; +const DEFAULT_VIEW_CONTEXT_LINES = 10; + +export function parseUnifiedDiffHunks(diff = "", options = {}) { + const lines = String(diff).split(/\r?\n/); + const sections = splitFileSections(lines); + const blocks = []; + const modelContextLines = normalizeLineCount(options.modelContextLines, DEFAULT_MODEL_CONTEXT_LINES); + const viewContextLines = normalizeLineCount(options.viewContextLines, DEFAULT_VIEW_CONTEXT_LINES); + + for (const section of sections) { + const hunkStarts = []; + for (let index = 0; index < section.lines.length; index += 1) { + if (section.lines[index].startsWith("@@")) { + hunkStarts.push(index); + } + } + + if (!hunkStarts.length) { + blocks.push(buildBlock(section.lines, section, blocks.length, 0, 1)); + continue; + } + + const header = section.lines.slice(0, hunkStarts[0]); + for (let index = 0; index < hunkStarts.length; index += 1) { + const start = hunkStarts[index]; + const end = hunkStarts[index + 1] ?? section.lines.length; + const hunkBlocks = splitHunkIntoChangeBlocks(section.lines.slice(start, end), { + modelContextLines, + viewContextLines, + }); + const fileHunkLabel = hunkStarts.length > 1 ? ` hunk ${index + 1}` : ""; + + for (let blockIndex = 0; blockIndex < hunkBlocks.length; blockIndex += 1) { + const hunkBlock = hunkBlocks[blockIndex]; + const diffLines = [...header, ...hunkBlock.diffLines]; + blocks.push(buildBlock( + diffLines, + section, + blocks.length, + blockIndex, + hunkBlocks.length, + fileHunkLabel, + hunkBlock.contextBefore, + hunkBlock.contextAfter, + )); + } + } + } + + return blocks; +} + +function splitFileSections(lines) { + const sections = []; + let current = null; + + for (const line of lines) { + if (line.startsWith("diff --git ")) { + if (current?.lines.length) { + sections.push(current); + } + current = { + lines: [line], + ...parseDiffGitLine(line), + }; + continue; + } + + if (!current) { + current = { + lines: [], + oldFile: "", + newFile: "", + file: "", + }; + } + + current.lines.push(line); + } + + if (current?.lines.length) { + sections.push(current); + } + + return sections; +} + +function splitHunkIntoChangeBlocks(hunkLines, { modelContextLines, viewContextLines }) { + const hunkHeader = hunkLines[0]; + const body = hunkLines.slice(1); + const runs = findChangeRuns(body); + + if (!runs.length) { + return [{ + diffLines: hunkLines, + contextBefore: [], + contextAfter: [], + }]; + } + + return runs.map((run, index) => { + const previousEnd = runs[index - 1]?.end ?? 0; + const nextStart = runs[index + 1]?.start ?? body.length; + const contextBefore = Math.max(previousEnd, run.start - modelContextLines); + const contextAfter = Math.min(nextStart, run.end + modelContextLines); + const expandedBefore = Math.max(previousEnd, contextBefore - viewContextLines); + const expandedAfter = Math.min(nextStart, contextAfter + viewContextLines); + + return { + diffLines: [hunkHeader, ...body.slice(contextBefore, contextAfter)], + contextBefore: body.slice(expandedBefore, contextBefore), + contextAfter: body.slice(contextAfter, expandedAfter), + }; + }); +} + +function findChangeRuns(lines) { + const runs = []; + let current = null; + + for (let index = 0; index < lines.length; index += 1) { + if (isChangedBodyLine(lines[index])) { + if (!current) { + current = { start: index, end: index + 1 }; + } else { + current.end = index + 1; + } + continue; + } + + if (current) { + runs.push(current); + current = null; + } + } + + if (current) { + runs.push(current); + } + + return runs; +} + +function isChangedBodyLine(line) { + return (line.startsWith("+") || line.startsWith("-")) + && !line.startsWith("+++") + && !line.startsWith("---"); +} + +function buildBlock( + lines, + section, + globalIndex, + fileBlockIndex, + fileBlockCount, + fileHunkLabel = "", + contextBefore = [], + contextAfter = [], +) { + const id = `H${String(globalIndex + 1).padStart(3, "0")}`; + const file = section.file || section.newFile || section.oldFile || "unknown"; + const suffix = fileBlockCount > 1 ? `${fileHunkLabel} block ${fileBlockIndex + 1}` : fileHunkLabel; + + return { + id, + file, + oldFile: section.oldFile, + newFile: section.newFile, + title: `${id} ${file}${suffix}`, + diff: trimTrailingBlankLines(lines).join("\n"), + contextBefore: trimTrailingBlankLines(contextBefore), + contextAfter: trimTrailingBlankLines(contextAfter), + }; +} + +function parseDiffGitLine(line) { + const match = line.match(/^diff --git\s+a\/(.+?)\s+b\/(.+)$/); + if (!match) { + return { + oldFile: "", + newFile: "", + file: "", + }; + } + + return { + oldFile: match[1], + newFile: match[2], + file: match[2], + }; +} + +function trimTrailingBlankLines(lines) { + const result = [...lines]; + while (result.length && result[result.length - 1] === "") { + result.pop(); + } + return result; +} + +function normalizeLineCount(value, fallback) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return fallback; + } + return Math.floor(parsed); +} diff --git a/ui-test/shared/diffStats.js b/ui-test/shared/diffStats.js new file mode 100644 index 000000000..5c4758c95 --- /dev/null +++ b/ui-test/shared/diffStats.js @@ -0,0 +1,36 @@ +export function computeDiffStats(diff = "") { + const lines = String(diff).split(/\r?\n/); + const files = new Set(); + let hunks = 0; + let additions = 0; + let deletions = 0; + + for (const line of lines) { + if (line.startsWith("diff --git ")) { + files.add(line); + continue; + } + + if (line.startsWith("@@")) { + hunks += 1; + continue; + } + + if (line.startsWith("+") && !line.startsWith("+++")) { + additions += 1; + continue; + } + + if (line.startsWith("-") && !line.startsWith("---")) { + deletions += 1; + } + } + + return { + files: files.size, + hunks, + additions, + deletions, + lines: lines.filter(Boolean).length, + }; +} diff --git a/ui-test/shared/parseAgentResponse.js b/ui-test/shared/parseAgentResponse.js new file mode 100644 index 000000000..779483506 --- /dev/null +++ b/ui-test/shared/parseAgentResponse.js @@ -0,0 +1,148 @@ +export const HUNK_START = "--- HUNK ---"; +export const HUNK_END = "--- END HUNK ---"; +export const HUNK_NOTE_START = "--- HUNK NOTE ---"; +export const HUNK_NOTE_END = "--- END HUNK NOTE ---"; +export const CHANGE_NOTE_START = "--- CHANGE NOTE ---"; +export const CHANGE_NOTE_END = "--- END CHANGE NOTE ---"; +export const SUMMARY_START = "--- SUMMARY ---"; +export const SUMMARY_END = "--- END SUMMARY ---"; + +export function parseAgentResponse(text = "") { + const source = String(text); + const noteHunks = parseNoteBlocks(source, CHANGE_NOTE_START, CHANGE_NOTE_END); + const legacyNoteHunks = noteHunks.hunks.length || noteHunks.partial + ? noteHunks + : parseNoteBlocks(source, HUNK_NOTE_START, HUNK_NOTE_END); + const compactNotes = noteHunks.hunks.length || noteHunks.partial ? noteHunks : legacyNoteHunks; + if (compactNotes.hunks.length || compactNotes.partial) { + const summary = extractBetween(source, SUMMARY_START, SUMMARY_END); + const partial = compactNotes.partial || extractPartialSummary(source); + + return { + hunks: compactNotes.hunks, + summary, + partial, + isComplete: summary.length > 0 && partial.length === 0, + raw: source, + }; + } + + const hunks = []; + let cursor = 0; + let partial = ""; + + while (cursor < source.length) { + const start = source.indexOf(HUNK_START, cursor); + if (start === -1) { + break; + } + + const blockStart = start + HUNK_START.length; + const end = source.indexOf(HUNK_END, blockStart); + if (end === -1) { + partial = source.slice(start).trim(); + break; + } + + const block = source.slice(blockStart, end); + hunks.push(parseHunkBlock(block, hunks.length)); + cursor = end + HUNK_END.length; + } + + const summary = extractBetween(source, SUMMARY_START, SUMMARY_END); + partial = partial || extractPartialSummary(source); + + return { + hunks, + summary, + partial, + isComplete: summary.length > 0 && partial.length === 0, + raw: source, + }; +} + +function parseNoteBlocks(source, startMarker, endMarker) { + const hunks = []; + let cursor = 0; + let partial = ""; + + while (cursor < source.length) { + const start = source.indexOf(startMarker, cursor); + if (start === -1) { + break; + } + + const blockStart = start + startMarker.length; + const end = source.indexOf(endMarker, blockStart); + if (end === -1) { + partial = source.slice(start).trim(); + break; + } + + const block = source.slice(blockStart, end); + hunks.push(parseHunkNoteBlock(block, hunks.length)); + cursor = end + endMarker.length; + } + + return { hunks, partial }; +} + +function parseHunkNoteBlock(block, index) { + const trimmed = trimBlankLines(block); + const id = extractLineValue(trimmed, "ID") || `H${String(index + 1).padStart(3, "0")}`; + const description = extractLineValue(trimmed, "Description"); + + return { + id, + diff: "", + description, + }; +} + +function parseHunkBlock(block, index) { + const trimmed = trimBlankLines(block); + const descriptionMatch = trimmed.match(/\n\s*Description:\s*([\s\S]*)$/); + const description = descriptionMatch ? descriptionMatch[1].trim() : ""; + const diff = descriptionMatch + ? trimBlankLines(trimmed.slice(0, descriptionMatch.index)) + : trimmed; + + return { + id: `hunk-${index + 1}`, + diff, + description, + }; +} + +function extractBetween(source, startMarker, endMarker) { + const start = source.indexOf(startMarker); + if (start === -1) { + return ""; + } + + const contentStart = start + startMarker.length; + const end = source.indexOf(endMarker, contentStart); + if (end === -1) { + return ""; + } + + return source.slice(contentStart, end).trim(); +} + +function extractLineValue(source, label) { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = source.match(new RegExp(`(?:^|\\n)\\s*${escaped}:\\s*([^\\n]+)`)); + return match ? match[1].trim() : ""; +} + +function extractPartialSummary(source) { + const summaryStart = source.indexOf(SUMMARY_START); + if (summaryStart !== -1 && source.indexOf(SUMMARY_END, summaryStart) === -1) { + return source.slice(summaryStart).trim(); + } + return ""; +} + +function trimBlankLines(value) { + return String(value).replace(/^\s*\n/, "").replace(/\n\s*$/, ""); +} diff --git a/ui-test/test/command.test.js b/ui-test/test/command.test.js new file mode 100644 index 000000000..356451683 --- /dev/null +++ b/ui-test/test/command.test.js @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseGitDiffCommand, resolveDiffCommand, splitCommandLine, withUnifiedContext } from "../shared/command.js"; + +test("parses quoted git diff command", () => { + assert.deepEqual(splitCommandLine('git diff "main...HEAD" -- "src/app file.js"'), [ + "git", + "diff", + "main...HEAD", + "--", + "src/app file.js", + ]); +}); + +test("allows only git diff commands", () => { + assert.deepEqual(parseGitDiffCommand("git diff --staged"), ["git", "diff", "--staged"]); + assert.throws(() => parseGitDiffCommand("git status"), /Only git diff/); +}); + +test("resolves preset commands", () => { + assert.deepEqual(resolveDiffCommand({ preset: "working" }), ["git", "diff"]); +}); + +test("adds unified context without changing pathspecs", () => { + assert.deepEqual( + withUnifiedContext(["git", "diff", "-U1", "HEAD~1", "--", "-U1"], 12), + ["git", "diff", "--unified=12", "HEAD~1", "--", "-U1"], + ); + assert.deepEqual( + withUnifiedContext(["git", "diff", "--unified", "3", "--staged"], 12), + ["git", "diff", "--unified=12", "--staged"], + ); +}); diff --git a/ui-test/test/diffHunks.test.js b/ui-test/test/diffHunks.test.js new file mode 100644 index 000000000..e0db442a2 --- /dev/null +++ b/ui-test/test/diffHunks.test.js @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseUnifiedDiffHunks } from "../shared/diffHunks.js"; + +test("splits a unified diff into stable source hunk ids", () => { + const hunks = parseUnifiedDiffHunks(`diff --git a/src/model.js b/src/model.js +--- a/src/model.js ++++ b/src/model.js +@@ -1 +1 @@ +-old ++new +@@ -8 +8 @@ +-disabled ++enabled +diff --git a/src/service.js b/src/service.js +--- a/src/service.js ++++ b/src/service.js +@@ -2 +2 @@ +-return false ++return true`); + + assert.equal(hunks.length, 3); + assert.deepEqual(hunks.map((hunk) => hunk.id), ["H001", "H002", "H003"]); + assert.equal(hunks[0].file, "src/model.js"); + assert.equal(hunks[1].title, "H002 src/model.js hunk 2"); + assert.equal(hunks[2].diff.includes("src/service.js"), true); +}); + +test("splits multiple change runs inside one git hunk into separate blocks", () => { + const blocks = parseUnifiedDiffHunks(`diff --git a/producer.py b/producer.py +--- a/producer.py ++++ b/producer.py +@@ -1,12 +1,15 @@ + def build(): + payload = load() +- ++ + protocol = "SSL" +- if localhost: ++ if localhost or loopback: ++ protocol = "PLAINTEXT" + + options = { +- "retries": 1, +- "timeout": 10, ++ "retries": config.RETRIES, ++ "timeout": config.TIMEOUT, + }`); + + assert.equal(blocks.length, 3); + assert.deepEqual(blocks.map((block) => block.id), ["H001", "H002", "H003"]); + assert.equal(blocks[0].title, "H001 producer.py block 1"); + assert.equal(blocks[1].diff.includes("if localhost or loopback"), true); + assert.equal(blocks[1].diff.includes('"retries": config.RETRIES'), false); + assert.equal(blocks[2].diff.includes('"retries": config.RETRIES'), true); +}); + +test("keeps expandable context outside the compact hunk body", () => { + const before = Array.from({ length: 12 }, (_, index) => ` line ${index + 1}`); + const after = Array.from({ length: 12 }, (_, index) => ` line ${index + 13}`); + const blocks = parseUnifiedDiffHunks(`diff --git a/example.js b/example.js +--- a/example.js ++++ b/example.js +@@ -1,25 +1,25 @@ +${before.join("\n")} +- old value ++ new value +${after.join("\n")}`); + + assert.equal(blocks.length, 1); + const compactLines = blocks[0].diff.split("\n"); + assert.deepEqual(blocks[0].contextBefore, before.slice(0, 10)); + assert.deepEqual(blocks[0].contextAfter, after.slice(2, 12)); + assert.equal(compactLines.includes(" line 1"), false); + assert.equal(compactLines.includes(" line 11"), true); + assert.equal(compactLines.includes(" line 15"), false); +}); diff --git a/ui-test/test/diffStats.test.js b/ui-test/test/diffStats.test.js new file mode 100644 index 000000000..6f870b511 --- /dev/null +++ b/ui-test/test/diffStats.test.js @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { computeDiffStats } from "../shared/diffStats.js"; + +test("computes unified diff stats", () => { + const stats = computeDiffStats(`diff --git a/a.js b/a.js +--- a/a.js ++++ b/a.js +@@ -1,2 +1,2 @@ +-const a = 1 ++const a = 2 + const b = 3 +diff --git a/b.js b/b.js +@@ -1 +1,2 @@ ++new line`); + + assert.deepEqual(stats, { + files: 2, + hunks: 2, + additions: 2, + deletions: 1, + lines: 10, + }); +}); diff --git a/ui-test/test/parser.test.js b/ui-test/test/parser.test.js new file mode 100644 index 000000000..d76460cb6 --- /dev/null +++ b/ui-test/test/parser.test.js @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseAgentResponse } from "../shared/parseAgentResponse.js"; + +test("parses completed hunk blocks and summary", () => { + const parsed = parseAgentResponse(`--- HUNK --- +diff --git a/model.js b/model.js +@@ -1 +1 @@ +-old ++new + +Description: Updates the model value so dependent services read the new shape. + +--- END HUNK --- + +--- SUMMARY --- +The change updates the model shape and keeps downstream callers aligned. +--- END SUMMARY ---`); + + assert.equal(parsed.hunks.length, 1); + assert.equal(parsed.hunks[0].diff.includes("@@ -1 +1 @@"), true); + assert.equal(parsed.hunks[0].description, "Updates the model value so dependent services read the new shape."); + assert.equal(parsed.summary, "The change updates the model shape and keeps downstream callers aligned."); + assert.equal(parsed.partial, ""); + assert.equal(parsed.isComplete, true); +}); + +test("exposes incomplete hunk as partial streaming text", () => { + const parsed = parseAgentResponse(`--- HUNK --- +diff --git a/service.js b/service.js +@@ -4 +4 @@ +-return false ++return true`); + + assert.equal(parsed.hunks.length, 0); + assert.equal(parsed.partial.startsWith("--- HUNK ---"), true); + assert.equal(parsed.isComplete, false); +}); + +test("parses multiple hunks in order", () => { + const parsed = parseAgentResponse(`--- HUNK --- +@@ -1 +1 @@ +-a ++b + +Description: First. +--- END HUNK --- +--- HUNK --- +@@ -2 +2 @@ +-c ++d + +Description: Second. +--- END HUNK ---`); + + assert.deepEqual(parsed.hunks.map((hunk) => hunk.description), ["First.", "Second."]); +}); + +test("parses compact change note blocks without diff content", () => { + const parsed = parseAgentResponse(`--- CHANGE NOTE --- +ID: H002 + +Description: Updates the service after the model contract changed. +--- END CHANGE NOTE --- + +--- SUMMARY --- +The service now follows the new model contract. +--- END SUMMARY ---`); + + assert.equal(parsed.hunks.length, 1); + assert.equal(parsed.hunks[0].id, "H002"); + assert.equal(parsed.hunks[0].diff, ""); + assert.equal(parsed.hunks[0].description, "Updates the service after the model contract changed."); + assert.equal(parsed.summary, "The service now follows the new model contract."); +}); From f017473ef553045220c07bbe286292ed806b8192 Mon Sep 17 00:00:00 2001 From: Devesh Meena Date: Thu, 16 Apr 2026 14:05:56 +0530 Subject: [PATCH 03/13] Fix diff panel layout: merge controls into header bar, sticky panel headers, auto-fit grid - Moved Raw/AI/Summary toggle buttons into the parent Changes/History header to avoid z-index overlap issues with file cards - Made panel headers sticky so they stay visible while scrolling - Switched panels to auto-fit CSS grid that respects available sidebar width - Removed DiffFileCard sticky header to prevent overlap with control bar Made-with: Cursor --- .../components/chat-ui/RightSidebar.tsx | 152 ++++++++---------- 1 file changed, 70 insertions(+), 82 deletions(-) diff --git a/src/client/components/chat-ui/RightSidebar.tsx b/src/client/components/chat-ui/RightSidebar.tsx index 6be867cb9..d2efd31b6 100644 --- a/src/client/components/chat-ui/RightSidebar.tsx +++ b/src/client/components/chat-ui/RightSidebar.tsx @@ -1259,7 +1259,7 @@ function DiffFileCard({ return ( -
+
{!isCollapsed ?