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>