From 56a4e9787121d289dafa5911300170df54fa4762 Mon Sep 17 00:00:00 2001 From: Alex Lucero Date: Sun, 31 May 2026 00:27:39 +0200 Subject: [PATCH 1/4] feat: surface linked repo changes --- apps/daemon/src/repo-changes.ts | 219 ++++++++++++++++++ apps/daemon/src/runs.ts | 8 + apps/daemon/src/server.ts | 46 +++- apps/daemon/tests/repo-changes.test.ts | 107 +++++++++ apps/daemon/tests/runs.test.ts | 35 +++ apps/web/src/components/AssistantMessage.tsx | 106 +++++++++ apps/web/src/providers/daemon.ts | 11 + apps/web/src/styles/viewer/tools.css | 119 ++++++++++ .../components/AssistantMessage.test.tsx | 50 ++++ apps/web/tests/providers/sse.test.ts | 50 ++++ packages/contracts/src/api/chat.ts | 33 +++ packages/contracts/src/sse/chat.ts | 2 + 12 files changed, 777 insertions(+), 9 deletions(-) create mode 100644 apps/daemon/src/repo-changes.ts create mode 100644 apps/daemon/tests/repo-changes.test.ts diff --git a/apps/daemon/src/repo-changes.ts b/apps/daemon/src/repo-changes.ts new file mode 100644 index 000000000..4f3cc5477 --- /dev/null +++ b/apps/daemon/src/repo-changes.ts @@ -0,0 +1,219 @@ +import { execFile as execFileCallback } from 'node:child_process'; + +import type { + LinkedRepoChangeDirectorySummary, + LinkedRepoChangeStatus, + LinkedRepoChangeSummary, +} from '@open-design/contracts'; + +const DEFAULT_GIT_TIMEOUT_MS = 2_000; +const DEFAULT_MAX_STATUS_LINES = 120; +const DEFAULT_MAX_DIFF_STAT_CHARS = 8_000; + +export interface LinkedRepoSnapshotDir { + path: string; + status: LinkedRepoChangeStatus; + branch: string | null; + headSha: string | null; + statusLines: string[]; + statusLineCount: number; + untrackedFileCount: number; + statusTruncated?: boolean; + diffStat: string | null; + diffStatTruncated?: boolean; + error: string | null; +} + +export interface LinkedRepoSnapshot { + generatedAt: number; + linkedDirs: LinkedRepoSnapshotDir[]; +} + +export interface RunGitResult { + stdout: string; + stderr: string; +} + +export type RunGit = (dir: string, args: string[]) => Promise; + +export interface CaptureLinkedRepoSnapshotOptions { + runGit?: RunGit; + maxStatusLines?: number; + maxDiffStatChars?: number; +} + +export async function captureLinkedRepoSnapshot( + linkedDirs: string[], + options: CaptureLinkedRepoSnapshotOptions = {}, +): Promise { + const runGit = options.runGit ?? defaultRunGit; + const maxStatusLines = options.maxStatusLines ?? DEFAULT_MAX_STATUS_LINES; + const maxDiffStatChars = options.maxDiffStatChars ?? DEFAULT_MAX_DIFF_STAT_CHARS; + const uniqueDirs = Array.from(new Set(linkedDirs.filter((dir) => typeof dir === 'string' && dir.trim()))); + const dirs = await Promise.all( + uniqueDirs.map((dir) => captureLinkedRepoDir(dir, runGit, maxStatusLines, maxDiffStatChars)), + ); + return { + generatedAt: Date.now(), + linkedDirs: dirs, + }; +} + +export async function captureLinkedRepoChangeSummary( + before: LinkedRepoSnapshot, + options: CaptureLinkedRepoSnapshotOptions = {}, +): Promise { + const after = await captureLinkedRepoSnapshot( + before.linkedDirs.map((dir) => dir.path), + options, + ); + return summarizeLinkedRepoChanges(before, after); +} + +export function summarizeLinkedRepoChanges( + before: LinkedRepoSnapshot, + after: LinkedRepoSnapshot, +): LinkedRepoChangeSummary { + const beforeByPath = new Map(before.linkedDirs.map((dir) => [dir.path, dir])); + const linkedDirs: LinkedRepoChangeDirectorySummary[] = after.linkedDirs.map((dir) => { + const baseline = beforeByPath.get(dir.path); + const baselineLines = new Set(baseline?.statusLines ?? []); + const newStatusLineCount = dir.statusLines.filter((line) => !baselineLines.has(line)).length; + const preexistingChangeCount = Math.min( + dir.statusLineCount, + Math.max(0, dir.statusLineCount - newStatusLineCount), + ); + return { + path: dir.path, + status: dir.status, + branch: dir.branch, + headSha: dir.headSha, + changedFileCount: dir.statusLineCount, + newStatusLineCount, + preexistingChangeCount, + untrackedFileCount: dir.untrackedFileCount, + statusLines: dir.statusLines, + ...(dir.statusTruncated ? { statusTruncated: true } : {}), + diffStat: dir.diffStat, + ...(dir.diffStatTruncated ? { diffStatTruncated: true } : {}), + error: dir.error, + }; + }); + const changedFileCount = linkedDirs.reduce((sum, dir) => sum + dir.changedFileCount, 0); + const newStatusLineCount = linkedDirs.reduce((sum, dir) => sum + dir.newStatusLineCount, 0); + const preexistingChangeCount = linkedDirs.reduce((sum, dir) => sum + dir.preexistingChangeCount, 0); + const untrackedFileCount = linkedDirs.reduce((sum, dir) => sum + dir.untrackedFileCount, 0); + return { + generatedAt: after.generatedAt, + linkedDirCount: linkedDirs.length, + changedFileCount, + newStatusLineCount, + preexistingChangeCount, + untrackedFileCount, + hasChanges: changedFileCount > 0, + linkedDirs, + }; +} + +async function captureLinkedRepoDir( + dir: string, + runGit: RunGit, + maxStatusLines: number, + maxDiffStatChars: number, +): Promise { + try { + await runGit(dir, ['rev-parse', '--show-toplevel']); + } catch (err) { + return emptySnapshotDir(dir, 'not_git', errorMessage(err)); + } + + try { + const [branch, headSha, status, diffStat] = await Promise.all([ + runGit(dir, ['branch', '--show-current']).catch(() => ({ stdout: '', stderr: '' })), + runGit(dir, ['rev-parse', '--short', 'HEAD']).catch(() => ({ stdout: '', stderr: '' })), + runGit(dir, ['status', '--short', '--untracked-files=all']), + runGit(dir, ['diff', '--stat', '--']).catch(() => ({ stdout: '', stderr: '' })), + ]); + const allStatusLines = splitLines(status.stdout); + const statusLines = allStatusLines.slice(0, maxStatusLines); + const rawDiffStat = diffStat.stdout.trim(); + const diffStatTruncated = rawDiffStat.length > maxDiffStatChars; + const statusValue: LinkedRepoChangeStatus = allStatusLines.length > 0 ? 'changed' : 'clean'; + return { + path: dir, + status: statusValue, + branch: branch.stdout.trim() || null, + headSha: headSha.stdout.trim() || null, + statusLines, + statusLineCount: allStatusLines.length, + untrackedFileCount: allStatusLines.filter((line) => line.startsWith('??')).length, + ...(allStatusLines.length > statusLines.length ? { statusTruncated: true } : {}), + diffStat: rawDiffStat + ? rawDiffStat.slice(0, maxDiffStatChars) + : null, + ...(diffStatTruncated ? { diffStatTruncated: true } : {}), + error: null, + }; + } catch (err) { + return emptySnapshotDir(dir, 'error', errorMessage(err)); + } +} + +function emptySnapshotDir( + dir: string, + status: LinkedRepoChangeStatus, + error: string | null, +): LinkedRepoSnapshotDir { + return { + path: dir, + status, + branch: null, + headSha: null, + statusLines: [], + statusLineCount: 0, + untrackedFileCount: 0, + diffStat: null, + error, + }; +} + +function splitLines(value: string): string[] { + return value + .split(/\r?\n/g) + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); +} + +function errorMessage(err: unknown): string { + if (!err) return 'Unknown git error.'; + if (err instanceof Error && err.message.trim()) return err.message.trim(); + return String(err); +} + +function defaultRunGit(dir: string, args: string[]): Promise { + const gitArgs = ['-c', 'core.quotepath=false', '-C', dir, ...args]; + return new Promise((resolve, reject) => { + execFileCallback( + 'git', + gitArgs, + { + encoding: 'utf8', + timeout: DEFAULT_GIT_TIMEOUT_MS, + maxBuffer: 512 * 1024, + windowsHide: true, + }, + (err, stdout, stderr) => { + const result = { + stdout: typeof stdout === 'string' ? stdout : String(stdout ?? ''), + stderr: typeof stderr === 'string' ? stderr : String(stderr ?? ''), + }; + if (err) { + const message = result.stderr.trim() || result.stdout.trim() || err.message; + reject(new Error(message)); + return; + } + resolve(result); + }, + ); + }); +} diff --git a/apps/daemon/src/runs.ts b/apps/daemon/src/runs.ts index 41f81cb73..170b74a9c 100644 --- a/apps/daemon/src/runs.ts +++ b/apps/daemon/src/runs.ts @@ -75,6 +75,7 @@ export function createChatRunService({ signal: null, error: null, errorCode: null, + repoChanges: null, cancelRequested: false, eventsLogPath: runsLogDir ? path.join(runsLogDir, id, 'events.jsonl') : null, eventsLogStream: null, @@ -155,8 +156,14 @@ export function createChatRunService({ eventsLogPath: run.eventsLogPath ?? null, mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null), toolBundle: summarizeRunToolBundle(run.toolBundle), + repoChanges: run.repoChanges ?? null, }); + const setRepoChanges = (run, repoChanges) => { + run.repoChanges = repoChanges ?? null; + run.updatedAt = Date.now(); + }; + const finish = (run, status, code: number | null = null, signal: string | null = null) => { if (TERMINAL_RUN_STATUSES.has(run.status)) return; run.status = status; @@ -338,6 +345,7 @@ export function createChatRunService({ fail, drop, statusBody, + setRepoChanges, isTerminal(status) { return TERMINAL_RUN_STATUSES.has(status); }, diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 6ea861454..cb32f7945 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -236,6 +236,10 @@ import { subscribe as subscribeFileEvents } from './project-watchers.js'; import { renderDesignSystemPreview } from './design-system-preview.js'; import { renderDesignSystemShowcase } from './design-system-showcase.js'; import { createChatRunService } from './runs.js'; +import { + captureLinkedRepoChangeSummary, + captureLinkedRepoSnapshot, +} from './repo-changes.js'; import { deriveRunErrorCode, runResultFromStatus } from './run-result.js'; import { countDesignSystemPreviewModules, @@ -10856,6 +10860,9 @@ export async function startServer({ const v = validateLinkedDirs(projectRecord.metadata.linkedDirs); return v.dirs ?? []; })(); + const linkedRepoBaseline = linkedDirs.length > 0 + ? await captureLinkedRepoSnapshot(linkedDirs) + : null; const cwdHint = cwd ? `\n\nYour working directory: ${cwd}\nWrite project files relative to it (e.g. \`index.html\`, \`assets/x.png\`). The user can browse those files in real time.${filesListBlock}` : ''; @@ -12482,15 +12489,36 @@ export async function startServer({ design.runs.finish(run, 'failed', 1, null); }); child.on('close', async (code, signal) => { + let linkedRepoChangesCaptured = false; + const finishClosedRun = async (status, finalCode, finalSignal) => { + if (!linkedRepoChangesCaptured && linkedRepoBaseline) { + linkedRepoChangesCaptured = true; + try { + const summary = await captureLinkedRepoChangeSummary(linkedRepoBaseline); + const hasRelevantRepoSignal = + summary.hasChanges || + summary.linkedDirs.some((dir) => dir.status === 'error'); + if (hasRelevantRepoSignal) { + design.runs.setRepoChanges(run, summary); + } + } catch (err) { + console.warn( + '[repo-changes] failed to capture linked repo summary:', + err && err.message ? err.message : err, + ); + } + } + design.runs.finish(run, status, finalCode, finalSignal); + }; try { clearInactivityWatchdog(); revokeToolToken('child_exit'); unregisterChatAgentEventSink(); if (acpSession?.hasFatalError()) { - return design.runs.finish(run, 'failed', code ?? 1, signal ?? null); + return finishClosedRun('failed', code ?? 1, signal ?? null); } if (agentStreamError) { - return design.runs.finish(run, 'failed', code ?? 1, signal ?? null); + return finishClosedRun('failed', code ?? 1, signal ?? null); } if ( code !== 0 && @@ -12502,7 +12530,7 @@ export async function startServer({ ); if (amrFailure) { sendAmrAccountFailure(amrFailure); - return design.runs.finish(run, 'failed', code ?? 1, signal ?? null); + return finishClosedRun('failed', code ?? 1, signal ?? null); } } const authFailure = classifyAgentAuthFailure( @@ -12515,7 +12543,7 @@ export async function startServer({ authFailure.message ?? cursorAuthGuidance(), { retryable: true }, )); - return design.runs.finish(run, 'failed', code ?? 1, signal ?? null); + return finishClosedRun('failed', code ?? 1, signal ?? null); } } // Empty-output guard: a clean `code === 0` exit with no visible @@ -12532,7 +12560,7 @@ export async function startServer({ 'Agent completed without producing any output. The model or provider may have returned an empty response — check the agent logs for upstream errors.', { retryable: true }, )); - return design.runs.finish(run, 'failed', code, signal); + return finishClosedRun('failed', code, signal); } if ( code === 0 && @@ -12545,7 +12573,7 @@ export async function startServer({ 'Plugin authoring ended before generating the required generated-plugin artifacts.', { retryable: true }, )); - return design.runs.finish(run, 'failed', code, signal); + return finishClosedRun('failed', code, signal); } // Plain-stream auth-failure guard: plain adapters (today // antigravity, deepseek's TUI variants) may exit cleanly with @@ -12573,7 +12601,7 @@ export async function startServer({ authFailure.message ?? `${def.name} authentication required. Please re-authenticate and retry.`, { retryable: true }, )); - return design.runs.finish(run, 'failed', 0, signal); + return finishClosedRun('failed', 0, signal); } } // Plain-stream empty-output guard: plain agents send raw stdout @@ -12635,7 +12663,7 @@ export async function startServer({ msg, { retryable: true }, )); - return design.runs.finish(run, 'failed', 0, signal); + return finishClosedRun('failed', 0, signal); } // ACP agents that don't shut down on stdin.end() (e.g. Devin for // Terminal) are forced to exit via SIGTERM from attachAcpSession after @@ -12770,7 +12798,7 @@ export async function startServer({ for (const chunk of plaintextStdoutBuffer) { send('stdout', { chunk }); } - design.runs.finish(run, status, code, signal); + await finishClosedRun(status, code, signal); } finally { // Best-effort cleanup of the per-run agy log file on every close // path — successful, failed, cancelled, or non-zero exit — so diff --git a/apps/daemon/tests/repo-changes.test.ts b/apps/daemon/tests/repo-changes.test.ts new file mode 100644 index 000000000..d7b7b1535 --- /dev/null +++ b/apps/daemon/tests/repo-changes.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { + captureLinkedRepoSnapshot, + summarizeLinkedRepoChanges, + type LinkedRepoSnapshot, + type RunGit, +} from '../src/repo-changes.js'; + +describe('linked repo change summaries', () => { + it('captures git status, diff stat, branch, and head for linked dirs', async () => { + const runGit: RunGit = async (_dir, args) => { + const command = args.join(' '); + if (command === 'rev-parse --show-toplevel') return { stdout: '/repo\n', stderr: '' }; + if (command === 'branch --show-current') return { stdout: 'main\n', stderr: '' }; + if (command === 'rev-parse --short HEAD') return { stdout: 'abc1234\n', stderr: '' }; + if (command === 'status --short --untracked-files=all') { + return { stdout: ' M src/app.ts\n?? src/new.ts\n', stderr: '' }; + } + if (command === 'diff --stat --') { + return { stdout: ' src/app.ts | 8 +++++---\n 1 file changed, 5 insertions(+), 3 deletions(-)\n', stderr: '' }; + } + throw new Error(`unexpected git command: ${command}`); + }; + + const snapshot = await captureLinkedRepoSnapshot(['/repo'], { runGit }); + + expect(snapshot.linkedDirs[0]).toMatchObject({ + path: '/repo', + status: 'changed', + branch: 'main', + headSha: 'abc1234', + statusLines: [' M src/app.ts', '?? src/new.ts'], + statusLineCount: 2, + untrackedFileCount: 1, + diffStat: 'src/app.ts | 8 +++++---\n 1 file changed, 5 insertions(+), 3 deletions(-)', + error: null, + }); + }); + + it('summarizes new and pre-existing status lines against the baseline', () => { + const before: LinkedRepoSnapshot = { + generatedAt: 1, + linkedDirs: [ + { + path: '/repo', + status: 'changed', + branch: 'main', + headSha: 'abc1234', + statusLines: [' M README.md'], + statusLineCount: 1, + untrackedFileCount: 0, + diffStat: 'README.md | 2 ++', + error: null, + }, + ], + }; + const after: LinkedRepoSnapshot = { + generatedAt: 2, + linkedDirs: [ + { + path: '/repo', + status: 'changed', + branch: 'main', + headSha: 'abc1234', + statusLines: [' M README.md', '?? src/new.ts'], + statusLineCount: 2, + untrackedFileCount: 1, + diffStat: 'README.md | 2 ++\n src/new.ts | 4 ++++', + error: null, + }, + ], + }; + + const summary = summarizeLinkedRepoChanges(before, after); + + expect(summary).toMatchObject({ + generatedAt: 2, + linkedDirCount: 1, + changedFileCount: 2, + newStatusLineCount: 1, + preexistingChangeCount: 1, + untrackedFileCount: 1, + hasChanges: true, + }); + expect(summary.linkedDirs[0]).toMatchObject({ + changedFileCount: 2, + newStatusLineCount: 1, + preexistingChangeCount: 1, + }); + }); + + it('reports a linked dir as not_git when git cannot read it as a repository', async () => { + const runGit: RunGit = async () => { + throw new Error('fatal: not a git repository'); + }; + + const snapshot = await captureLinkedRepoSnapshot(['/plain-folder'], { runGit }); + + expect(snapshot.linkedDirs[0]).toMatchObject({ + path: '/plain-folder', + status: 'not_git', + statusLineCount: 0, + error: 'fatal: not a git repository', + }); + }); +}); diff --git a/apps/daemon/tests/runs.test.ts b/apps/daemon/tests/runs.test.ts index 03e61fdc3..81832dbb3 100644 --- a/apps/daemon/tests/runs.test.ts +++ b/apps/daemon/tests/runs.test.ts @@ -119,6 +119,41 @@ describe('chat run service shutdown', () => { expect(JSON.stringify(status)).not.toContain('server.js'); }); + it('stores repo changes on the status body without emitting persistence-owned events', () => { + const runs = createRuns(); + const run = runs.create({ projectId: 'project-1', conversationId: 'conv-a' }) as any; + const summary = { + generatedAt: 1700000000, + linkedDirCount: 1, + changedFileCount: 1, + newStatusLineCount: 1, + preexistingChangeCount: 0, + untrackedFileCount: 0, + hasChanges: true, + linkedDirs: [ + { + path: '/repo/app', + status: 'changed', + branch: 'main', + headSha: 'abc1234', + changedFileCount: 1, + newStatusLineCount: 1, + preexistingChangeCount: 0, + untrackedFileCount: 0, + statusLines: [' M src/app.ts'], + diffStat: 'src/app.ts | 2 ++', + error: null, + }, + ], + }; + + runs.setRepoChanges(run, summary); + runs.finish(run, 'succeeded', 0, null); + + expect(runs.statusBody(run).repoChanges).toEqual(summary); + expect(run.events.map((event: { event: string }) => event.event)).toEqual(['end']); + }); + it('cancels active runs and terminates their child process during daemon shutdown', async () => { const runs = createRuns(); const child = new FakeChildProcess({ closeOn: 'SIGTERM' }); diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index 26bed5f74..614a55a00 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -28,6 +28,10 @@ import { type TrackingFeedbackRatingWithNone, type TrackingProjectKind, } from "@open-design/contracts/analytics"; +import type { + LinkedRepoChangeDirectorySummary, + LinkedRepoChangeSummary, +} from "@open-design/contracts"; import { splitOnQuestionForms, type QuestionForm, @@ -363,6 +367,7 @@ export function AssistantMessage({ ), ); const fileOps = useMemo(() => deriveFileOps(events), [events]); + const linkedRepoChanges = useMemo(() => latestLinkedRepoChanges(events), [events]); const produced = message.producedFiles ?? []; const displayedProduced = useMemo( () => @@ -521,6 +526,9 @@ export function AssistantMessage({ onRequestOpenFile={onRequestOpenFile} /> ) : null} + {linkedRepoChanges ? ( + + ) : null} {blocks.map((b, i) => { if (b.kind === "text") return ( @@ -1395,6 +1403,104 @@ function ProducedFiles({ ); } +function LinkedRepoChanges({ summary }: { summary: LinkedRepoChangeSummary }) { + const rows = summary.linkedDirs.filter( + (dir) => dir.changedFileCount > 0 || dir.status === "error", + ); + if (rows.length === 0) return null; + + const parts = [plural(summary.changedFileCount, "changed file")]; + if (summary.untrackedFileCount > 0) { + parts.push(plural(summary.untrackedFileCount, "untracked file")); + } + if (summary.preexistingChangeCount > 0) { + parts.push(`${summary.preexistingChangeCount} pre-existing`); + } + + return ( +
+
+ + + +
+
Linked repo changes
+
{parts.join(" · ")}
+
+
+
+ {rows.map((dir) => ( + + ))} +
+
+ ); +} + +function LinkedRepoChangeRow({ dir }: { dir: LinkedRepoChangeDirectorySummary }) { + const label = displayLinkedRepoPath(dir.path); + const meta = [ + dir.branch ? dir.branch : null, + dir.headSha ? dir.headSha : null, + ].filter(Boolean); + const visibleStatusLines = dir.statusLines.slice(0, 8); + return ( +
+
+ + {label} + + {meta.length > 0 ? ( + {meta.join(" · ")} + ) : null} + + {plural(dir.changedFileCount, "file")} + +
+ {dir.error ? ( +
{dir.error}
+ ) : dir.diffStat ? ( +
+          {dir.diffStat}
+          {dir.diffStatTruncated ? "\n…truncated" : ""}
+        
+ ) : visibleStatusLines.length > 0 ? ( +
+ {visibleStatusLines.map((line) => ( + {line} + ))} + {dir.statusTruncated ? …truncated : null} +
+ ) : null} +
+ ); +} + +function latestLinkedRepoChanges(events: AgentEvent[]): LinkedRepoChangeSummary | null { + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + if (event?.kind === "repo_changes") { + if ( + event.summary.hasChanges || + event.summary.linkedDirs.some((dir) => dir.status === "error") + ) { + return event.summary; + } + return null; + } + } + return null; +} + +function displayLinkedRepoPath(value: string): string { + const segments = value.replace(/\\/g, "/").split("/").filter(Boolean); + return segments.slice(-2).join("/") || value; +} + +function plural(count: number, singular: string): string { + return `${count} ${singular}${count === 1 ? "" : "s"}`; +} + // Pure renderer. State (busyKey, notices) and the action runner live in the // AssistantMessage parent so they survive the panel's unmount/remount cycle // during install (issue #2876). diff --git a/apps/web/src/providers/daemon.ts b/apps/web/src/providers/daemon.ts index 596de7f3d..b41fa779e 100644 --- a/apps/web/src/providers/daemon.ts +++ b/apps/web/src/providers/daemon.ts @@ -641,6 +641,7 @@ async function consumeDaemonRun({ // no `status` field still surfaces an error banner. let serverDeclaredSuccess = false; let lastEventId: string | null = initialLastEventId ?? null; + let sawRepoChanges = false; let canceled = false; const cancelRun = () => { if (canceled) return; @@ -729,6 +730,12 @@ async function consumeDaemonRun({ continue; } + if (event.event === 'repo_changes') { + sawRepoChanges = true; + handlers.onAgentEvent({ kind: 'repo_changes', summary: event.data }); + continue; + } + if (event.event === 'start') { const data = event.data as ChatSseStartPayload; onRunStatus?.('running'); @@ -769,6 +776,10 @@ async function consumeDaemonRun({ endStatus = status.status; exitCode = status.exitCode ?? null; exitSignal = status.signal ?? null; + if (status.repoChanges && !sawRepoChanges) { + sawRepoChanges = true; + handlers.onAgentEvent({ kind: 'repo_changes', summary: status.repoChanges }); + } // Fallback REST path: `status.status` is explicitly declared by the // daemon's run record (it passed `isChatRunStatus()` above), so an // explicit `'succeeded'` here is just as authoritative as the SSE diff --git a/apps/web/src/styles/viewer/tools.css b/apps/web/src/styles/viewer/tools.css index aa7208b61..438f1a6ec 100644 --- a/apps/web/src/styles/viewer/tools.css +++ b/apps/web/src/styles/viewer/tools.css @@ -226,6 +226,125 @@ padding: 3px 9px; } +.linked-repo-changes { + margin-top: 4px; + padding: 12px 14px; + border: 1px solid color-mix(in oklab, var(--accent) 28%, var(--border)); + border-radius: var(--radius); + background: color-mix(in oklab, var(--accent) 5%, var(--bg-panel)); +} +.linked-repo-changes__head { + display: flex; + align-items: flex-start; + gap: 9px; + margin-bottom: 10px; +} +.linked-repo-changes__icon { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 7px; + background: var(--bg-subtle); + color: var(--text-muted); + flex-shrink: 0; +} +.linked-repo-changes__copy { + min-width: 0; +} +.linked-repo-changes__title { + font-size: 13px; + font-weight: 650; + color: var(--text); +} +.linked-repo-changes__summary { + margin-top: 2px; + font-size: 11.5px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.linked-repo-changes__list { + display: flex; + flex-direction: column; + gap: 6px; +} +.linked-repo-change-row { + padding: 7px 8px; + border: 1px solid var(--border-soft); + border-radius: 7px; + background: var(--bg-panel); + min-width: 0; +} +.linked-repo-change-row__top { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.linked-repo-change-row__path { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 11.5px; + color: var(--text); + background: transparent; + padding: 0; +} +.linked-repo-change-row__meta, +.linked-repo-change-row__count { + flex-shrink: 0; + font-size: 10.5px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.linked-repo-change-row__count { + padding: 1px 6px; + border-radius: 8px; + background: var(--bg-subtle); + border: 1px solid var(--border); +} +.linked-repo-change-row__stat, +.linked-repo-change-row__error, +.linked-repo-change-row__status-lines { + margin-top: 7px; +} +.linked-repo-change-row__stat { + max-height: 140px; + overflow: auto; + padding: 7px 8px; + border-radius: 6px; + background: var(--bg-subtle); + border: 1px solid var(--border-soft); + color: var(--text-muted); + font-family: var(--mono); + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; +} +.linked-repo-change-row__status-lines { + display: flex; + flex-direction: column; + gap: 3px; +} +.linked-repo-change-row__status-lines code { + padding: 2px 6px; + border-radius: 4px; + background: var(--bg-subtle); + color: var(--text-muted); + font-family: var(--mono); + font-size: 11px; + overflow-wrap: anywhere; +} +.linked-repo-change-row__error { + color: #c0392b; + font-size: 11.5px; + line-height: 1.35; +} + .plugin-action-panel { margin-top: 4px; padding: 12px 14px; diff --git a/apps/web/tests/components/AssistantMessage.test.tsx b/apps/web/tests/components/AssistantMessage.test.tsx index d96ca1afa..018437529 100644 --- a/apps/web/tests/components/AssistantMessage.test.tsx +++ b/apps/web/tests/components/AssistantMessage.test.tsx @@ -302,3 +302,53 @@ describe('AssistantMessage recovered produced files', () => { expect(screen.getByText('iphone-device-reveal.mp4')).toBeTruthy(); }); }); + +describe('AssistantMessage linked repo changes', () => { + it('shows linked repo changes as run output', () => { + render( + , + ); + + expect(screen.getByTestId('linked-repo-changes')).toBeTruthy(); + expect(screen.getByText('Linked repo changes')).toBeTruthy(); + expect(screen.getByText('2 changed files · 1 untracked file')).toBeTruthy(); + expect(screen.getByText('repo/app')).toBeTruthy(); + expect(screen.getByText(/src\/app\.ts/)).toBeTruthy(); + }); +}); diff --git a/apps/web/tests/providers/sse.test.ts b/apps/web/tests/providers/sse.test.ts index ba739671d..64744ed22 100644 --- a/apps/web/tests/providers/sse.test.ts +++ b/apps/web/tests/providers/sse.test.ts @@ -1144,6 +1144,56 @@ describe('streamViaDaemon', () => { detail: 'tavily · shallow', }); }); + + it('forwards linked repo change summaries from daemon SSE', async () => { + const handlers = createDaemonHandlers(); + const summary = { + generatedAt: 1700000000, + linkedDirCount: 1, + changedFileCount: 2, + newStatusLineCount: 2, + preexistingChangeCount: 0, + untrackedFileCount: 1, + hasChanges: true, + linkedDirs: [ + { + path: '/repo/app', + status: 'changed', + branch: 'main', + headSha: 'abc1234', + changedFileCount: 2, + newStatusLineCount: 2, + preexistingChangeCount: 0, + untrackedFileCount: 1, + statusLines: [' M src/app.ts', '?? src/new.ts'], + diffStat: 'src/app.ts | 8 +++++---', + error: null, + }, + ], + }; + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(jsonResponse({ runId: 'run-1' })) + .mockResolvedValueOnce( + sseResponse( + `event: repo_changes\ndata: ${JSON.stringify(summary)}\n\n` + + 'event: end\ndata: {"code":0,"status":"succeeded"}\n\n', + ), + )); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'edit the linked repo' }], + systemPrompt: '', + signal: new AbortController().signal, + handlers, + }); + + expect(handlers.onAgentEvent).toHaveBeenCalledWith({ + kind: 'repo_changes', + summary, + }); + expect(handlers.onDone).toHaveBeenCalledWith(''); + }); }); describe('streamMessageOpenAI', () => { diff --git a/packages/contracts/src/api/chat.ts b/packages/contracts/src/api/chat.ts index babba0cb0..0ec938fff 100644 --- a/packages/contracts/src/api/chat.ts +++ b/packages/contracts/src/api/chat.ts @@ -232,6 +232,36 @@ export interface ChatRunCreateResponse { pluginId?: string | null; } +export type LinkedRepoChangeStatus = 'changed' | 'clean' | 'not_git' | 'error'; + +export interface LinkedRepoChangeDirectorySummary { + /** Absolute local linked directory path captured by the daemon. */ + path: string; + status: LinkedRepoChangeStatus; + branch?: string | null; + headSha?: string | null; + changedFileCount: number; + newStatusLineCount: number; + preexistingChangeCount: number; + untrackedFileCount: number; + statusLines: string[]; + statusTruncated?: boolean; + diffStat?: string | null; + diffStatTruncated?: boolean; + error?: string | null; +} + +export interface LinkedRepoChangeSummary { + generatedAt: number; + linkedDirCount: number; + changedFileCount: number; + newStatusLineCount: number; + preexistingChangeCount: number; + untrackedFileCount: number; + hasChanges: boolean; + linkedDirs: LinkedRepoChangeDirectorySummary[]; +} + export interface ChatRunStatusResponse { id: string; projectId: string | null; @@ -255,6 +285,8 @@ export interface ChatRunStatusResponse { mediaExecution?: MediaExecutionPolicy; /** Run-scoped tool bundle summary with secrets and command details redacted. */ toolBundle?: RunScopedToolBundleSummary; + /** Best-effort linked-repo change summary captured when the run ended. */ + repoChanges?: LinkedRepoChangeSummary | null; } export interface ChatRunListResponse { @@ -329,6 +361,7 @@ export type PersistedAgentEvent = draftPath?: string | null; } | { kind: 'usage'; inputTokens?: number; outputTokens?: number; costUsd?: number; durationMs?: number } + | { kind: 'repo_changes'; summary: LinkedRepoChangeSummary } | { kind: 'raw'; line: string }; export interface ChatMessage { diff --git a/packages/contracts/src/sse/chat.ts b/packages/contracts/src/sse/chat.ts index 3a27b70ad..9f9e98317 100644 --- a/packages/contracts/src/sse/chat.ts +++ b/packages/contracts/src/sse/chat.ts @@ -1,4 +1,5 @@ import type { LiveArtifactRefreshStatus } from '../api/live-artifacts.js'; +import type { LinkedRepoChangeSummary } from '../api/chat.js'; import type { SseErrorPayload } from '../errors.js'; import type { SseTransportEvent } from './common.js'; @@ -89,6 +90,7 @@ export type DaemonAgentPayload = export type ChatSseEvent = | SseTransportEvent<'start', ChatSseStartPayload> | SseTransportEvent<'agent', DaemonAgentPayload> + | SseTransportEvent<'repo_changes', LinkedRepoChangeSummary> | SseTransportEvent<'stdout', ChatSseChunkPayload> | SseTransportEvent<'stderr', ChatSseChunkPayload> | SseTransportEvent<'error', SseErrorPayload> From bd9f6cba20982c0d0b933676032e2fc20fb1a074 Mon Sep 17 00:00:00 2001 From: Alex Lucero Date: Sun, 31 May 2026 08:00:18 +0200 Subject: [PATCH 2/4] fix: persist linked repo change summaries --- apps/daemon/src/server.ts | 6 ++ apps/daemon/tests/chat-route.test.ts | 109 +++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index cb32f7945..1425f5c8b 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -2370,6 +2370,11 @@ function runSseEventToPersistedAgentEvent(event, data) { ...(message ? { detail: message } : {}), }; } + if (event === 'repo_changes') { + return data && typeof data === 'object' && Array.isArray(data.linkedDirs) + ? { kind: 'repo_changes', summary: data } + : null; + } if (event !== 'agent') return null; return daemonAgentPayloadToPersistedAgentEvent(data); } @@ -12500,6 +12505,7 @@ export async function startServer({ summary.linkedDirs.some((dir) => dir.status === 'error'); if (hasRelevantRepoSignal) { design.runs.setRepoChanges(run, summary); + send('repo_changes', summary); } } catch (err) { console.warn( diff --git a/apps/daemon/tests/chat-route.test.ts b/apps/daemon/tests/chat-route.test.ts index 5d627d8f3..92e29289e 100644 --- a/apps/daemon/tests/chat-route.test.ts +++ b/apps/daemon/tests/chat-route.test.ts @@ -1,4 +1,5 @@ import type http from 'node:http'; +import { execFileSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { chmodSync, @@ -383,6 +384,114 @@ process.stdin.on('end', () => { }, ); }); + + it('persists linked repo changes on the assistant message before ending the run', async () => { + const projectId = `proj-linked-repo-${randomUUID()}`; + const linkedRepo = mkdtempSync(join(tmpdir(), 'od-linked-repo-')); + tempDirs.push(linkedRepo); + writeFileSync(join(linkedRepo, 'README.md'), '# Linked repo fixture\n'); + execFileSync('git', ['init'], { cwd: linkedRepo, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: linkedRepo, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.name', 'Open Design Test'], { cwd: linkedRepo, stdio: 'ignore' }); + execFileSync('git', ['add', 'README.md'], { cwd: linkedRepo, stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', 'initial fixture'], { cwd: linkedRepo, stdio: 'ignore' }); + + const createProjectResponse = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Linked repo persistence fixture', + skillId: null, + designSystemId: null, + metadata: { linkedDirs: [linkedRepo] }, + }), + }); + expect(createProjectResponse.status).toBe(200); + const createProjectBody = await createProjectResponse.json() as { + conversationId: string; + }; + const conversationId = createProjectBody.conversationId; + expect(conversationId).toBeTruthy(); + const assistantMessageId = `assistant-${randomUUID()}`; + + await withFakeAgent( + 'opencode', + ` +const fs = require('node:fs'); +const path = require('node:path'); +const linkedRepo = ${JSON.stringify(linkedRepo)}; +process.stdin.resume(); +process.stdin.on('end', () => { + fs.writeFileSync(path.join(linkedRepo, 'src-new.ts'), 'export const fixture = true;\\n'); + console.log(JSON.stringify({ type: 'step_start' })); + console.log(JSON.stringify({ type: 'text', part: { text: 'updated linked repo' } })); + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); + process.exit(0); +}); +`, + async () => { + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + projectId, + conversationId, + assistantMessageId, + message: 'Edit the linked repo.', + }), + }); + expect(createResponse.status).toBe(202); + const createBody = await createResponse.json() as { runId: string }; + const eventsResponse = await fetch(`${baseUrl}/api/runs/${createBody.runId}/events`); + const eventsBody = await readSseUntil(eventsResponse, 'event: end'); + const repoChangesIndex = eventsBody.indexOf('event: repo_changes'); + const endIndex = eventsBody.indexOf('event: end'); + expect(repoChangesIndex).toBeGreaterThan(-1); + expect(endIndex).toBeGreaterThan(repoChangesIndex); + + const statusBody = await waitForRunStatus(baseUrl, createBody.runId) as { + status: string; + repoChanges?: { hasChanges: boolean; changedFileCount: number }; + }; + expect(statusBody.status).toBe('succeeded'); + expect(statusBody.repoChanges).toMatchObject({ + hasChanges: true, + changedFileCount: 1, + }); + + const messagesResponse = await fetch( + `${baseUrl}/api/projects/${projectId}/conversations/${conversationId}/messages`, + ); + expect(messagesResponse.status).toBe(200); + const messagesBody = await messagesResponse.json() as { + messages: Array<{ id: string; events?: Array }>; + }; + const assistantMessage = messagesBody.messages.find((message) => message.id === assistantMessageId); + expect(assistantMessage).toBeTruthy(); + expect(assistantMessage?.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'repo_changes', + summary: expect.objectContaining({ + hasChanges: true, + changedFileCount: 1, + linkedDirs: expect.arrayContaining([ + expect.objectContaining({ + path: expect.any(String), + status: 'changed', + statusLines: expect.arrayContaining(['?? src-new.ts']), + }), + ]), + }), + }), + ]), + ); + }, + ); + }); + it('closes the # Instructions block with an explicit "do not echo" guard so models do not parrot the prompt back', async () => { // claude-opus-4-7 (and a few other instruction-tuned models) start // their reply by echoing the # Instructions block verbatim, which From e096b662ab4fe9a8e0e13ee6d17d8edb089bd6c1 Mon Sep 17 00:00:00 2001 From: Alex Lucero Date: Sun, 31 May 2026 08:22:31 +0200 Subject: [PATCH 3/4] fix: show linked repo status lines with diff stats --- apps/web/src/components/AssistantMessage.tsx | 31 +++++++++++-------- .../components/AssistantMessage.test.tsx | 3 +- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index 614a55a00..17d34308b 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -1459,19 +1459,24 @@ function LinkedRepoChangeRow({ dir }: { dir: LinkedRepoChangeDirectorySummary }) {dir.error ? (
{dir.error}
- ) : dir.diffStat ? ( -
-          {dir.diffStat}
-          {dir.diffStatTruncated ? "\n…truncated" : ""}
-        
- ) : visibleStatusLines.length > 0 ? ( -
- {visibleStatusLines.map((line) => ( - {line} - ))} - {dir.statusTruncated ? …truncated : null} -
- ) : null} + ) : ( + <> + {dir.diffStat ? ( +
+              {dir.diffStat}
+              {dir.diffStatTruncated ? "\n…truncated" : ""}
+            
+ ) : null} + {visibleStatusLines.length > 0 ? ( +
+ {visibleStatusLines.map((line) => ( + {line} + ))} + {dir.statusTruncated ? …truncated : null} +
+ ) : null} + + )} ); } diff --git a/apps/web/tests/components/AssistantMessage.test.tsx b/apps/web/tests/components/AssistantMessage.test.tsx index 018437529..8b023e096 100644 --- a/apps/web/tests/components/AssistantMessage.test.tsx +++ b/apps/web/tests/components/AssistantMessage.test.tsx @@ -349,6 +349,7 @@ describe('AssistantMessage linked repo changes', () => { expect(screen.getByText('Linked repo changes')).toBeTruthy(); expect(screen.getByText('2 changed files · 1 untracked file')).toBeTruthy(); expect(screen.getByText('repo/app')).toBeTruthy(); - expect(screen.getByText(/src\/app\.ts/)).toBeTruthy(); + expect(screen.getAllByText(/src\/app\.ts/).length).toBeGreaterThan(0); + expect(screen.getByText('?? src/new.ts')).toBeTruthy(); }); }); From b8593cf8fd1a7d02c809e6ee615e3d8983f2fc60 Mon Sep 17 00:00:00 2001 From: Alex Lucero Date: Sun, 31 May 2026 08:49:20 +0200 Subject: [PATCH 4/4] fix: compare linked repo status by path --- apps/daemon/src/repo-changes.ts | 18 ++++++++-- apps/daemon/tests/repo-changes.test.ts | 48 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/daemon/src/repo-changes.ts b/apps/daemon/src/repo-changes.ts index 4f3cc5477..d0d121d81 100644 --- a/apps/daemon/src/repo-changes.ts +++ b/apps/daemon/src/repo-changes.ts @@ -77,8 +77,11 @@ export function summarizeLinkedRepoChanges( const beforeByPath = new Map(before.linkedDirs.map((dir) => [dir.path, dir])); const linkedDirs: LinkedRepoChangeDirectorySummary[] = after.linkedDirs.map((dir) => { const baseline = beforeByPath.get(dir.path); - const baselineLines = new Set(baseline?.statusLines ?? []); - const newStatusLineCount = dir.statusLines.filter((line) => !baselineLines.has(line)).length; + const baselinePaths = new Set((baseline?.statusLines ?? []).flatMap(statusLinePaths)); + const newStatusLineCount = dir.statusLines.filter((line) => { + const paths = statusLinePaths(line); + return paths.length === 0 || paths.every((path) => !baselinePaths.has(path)); + }).length; const preexistingChangeCount = Math.min( dir.statusLineCount, Math.max(0, dir.statusLineCount - newStatusLineCount), @@ -184,6 +187,17 @@ function splitLines(value: string): string[] { .filter((line) => line.trim().length > 0); } +function statusLinePaths(line: string): string[] { + const value = line.length > 3 ? line.slice(3).trim() : line.trim(); + if (!value) return []; + const renameSeparator = ' -> '; + if (!value.includes(renameSeparator)) return [value]; + return value + .split(renameSeparator) + .map((part) => part.trim()) + .filter(Boolean); +} + function errorMessage(err: unknown): string { if (!err) return 'Unknown git error.'; if (err instanceof Error && err.message.trim()) return err.message.trim(); diff --git a/apps/daemon/tests/repo-changes.test.ts b/apps/daemon/tests/repo-changes.test.ts index d7b7b1535..2359dce73 100644 --- a/apps/daemon/tests/repo-changes.test.ts +++ b/apps/daemon/tests/repo-changes.test.ts @@ -90,6 +90,54 @@ describe('linked repo change summaries', () => { }); }); + it('treats status-only transitions on the same path as pre-existing changes', () => { + const before: LinkedRepoSnapshot = { + generatedAt: 1, + linkedDirs: [ + { + path: '/repo', + status: 'changed', + branch: 'main', + headSha: 'abc1234', + statusLines: [' M src/app.ts'], + statusLineCount: 1, + untrackedFileCount: 0, + diffStat: 'src/app.ts | 2 ++', + error: null, + }, + ], + }; + const after: LinkedRepoSnapshot = { + generatedAt: 2, + linkedDirs: [ + { + path: '/repo', + status: 'changed', + branch: 'main', + headSha: 'abc1234', + statusLines: ['M src/app.ts'], + statusLineCount: 1, + untrackedFileCount: 0, + diffStat: 'src/app.ts | 2 ++', + error: null, + }, + ], + }; + + const summary = summarizeLinkedRepoChanges(before, after); + + expect(summary).toMatchObject({ + changedFileCount: 1, + newStatusLineCount: 0, + preexistingChangeCount: 1, + }); + expect(summary.linkedDirs[0]).toMatchObject({ + changedFileCount: 1, + newStatusLineCount: 0, + preexistingChangeCount: 1, + }); + }); + it('reports a linked dir as not_git when git cannot read it as a repository', async () => { const runGit: RunGit = async () => { throw new Error('fatal: not a git repository');