feat: surface linked repo changes

This commit is contained in:
Alex Lucero 2026-05-31 00:27:39 +02:00
parent 53fb175855
commit 56a4e97871
12 changed files with 777 additions and 9 deletions

View file

@ -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<RunGitResult>;
export interface CaptureLinkedRepoSnapshotOptions {
runGit?: RunGit;
maxStatusLines?: number;
maxDiffStatChars?: number;
}
export async function captureLinkedRepoSnapshot(
linkedDirs: string[],
options: CaptureLinkedRepoSnapshotOptions = {},
): Promise<LinkedRepoSnapshot> {
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<LinkedRepoChangeSummary> {
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<LinkedRepoSnapshotDir> {
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<RunGitResult> {
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);
},
);
});
}

View file

@ -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);
},

View file

@ -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

View file

@ -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',
});
});
});

View file

@ -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' });

View file

@ -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 ? (
<LinkedRepoChanges summary={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 (
<div className="linked-repo-changes" data-testid="linked-repo-changes">
<div className="linked-repo-changes__head">
<span className="linked-repo-changes__icon" aria-hidden>
<Icon name="github" size={14} />
</span>
<div className="linked-repo-changes__copy">
<div className="linked-repo-changes__title">Linked repo changes</div>
<div className="linked-repo-changes__summary">{parts.join(" · ")}</div>
</div>
</div>
<div className="linked-repo-changes__list">
{rows.map((dir) => (
<LinkedRepoChangeRow key={dir.path} dir={dir} />
))}
</div>
</div>
);
}
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 (
<div className="linked-repo-change-row">
<div className="linked-repo-change-row__top">
<code className="linked-repo-change-row__path" title={dir.path}>
{label}
</code>
{meta.length > 0 ? (
<span className="linked-repo-change-row__meta">{meta.join(" · ")}</span>
) : null}
<span className="linked-repo-change-row__count">
{plural(dir.changedFileCount, "file")}
</span>
</div>
{dir.error ? (
<div className="linked-repo-change-row__error">{dir.error}</div>
) : dir.diffStat ? (
<pre className="linked-repo-change-row__stat">
{dir.diffStat}
{dir.diffStatTruncated ? "\n…truncated" : ""}
</pre>
) : visibleStatusLines.length > 0 ? (
<div className="linked-repo-change-row__status-lines">
{visibleStatusLines.map((line) => (
<code key={line}>{line}</code>
))}
{dir.statusTruncated ? <code>truncated</code> : null}
</div>
) : null}
</div>
);
}
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).

View file

@ -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

View file

@ -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;

View file

@ -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(
<AssistantMessage
message={baseMessage({
content: '',
events: [
{
kind: 'repo_changes',
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,
},
],
},
} as ChatMessage['events'][number],
],
producedFiles: [],
})}
streaming={false}
projectId="proj-1"
/>,
);
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();
});
});

View file

@ -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', () => {

View file

@ -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 {

View file

@ -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>