diff --git a/console/web/src/components/chat/coder/ApplyPatchView.tsx b/console/web/src/components/chat/coder/ApplyPatchView.tsx new file mode 100644 index 000000000..9e89caf6b --- /dev/null +++ b/console/web/src/components/chat/coder/ApplyPatchView.tsx @@ -0,0 +1,307 @@ +/** + * `coder::apply-patch` — V4A patch text rendered as per-file diffs. + * + * The request is one opaque string: `*** Begin Patch` … `*** End Patch` + * wrapping per-file hunks (`*** Add File:` / `*** Delete File:` / + * `*** Update File:` with an optional `*** Move to:`). Add hunks render + * like CreateFileView (empty old side); Update hunks reconstruct both + * sides from the change lines (old = context+`-`, new = context+`+` — + * the wire carries no full bodies); Delete hunks are a header row only, + * like DeleteFileView. Results pair with hunks BY INDEX (one result per + * hunk, in patch order) and carry the canonical absolute path, a kind + * badge, and an optional bounded post-apply echo. + */ +import { Chip, FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import { ChecksList } from './ChecksView' +import { CoderNewFilePreview, CoderTextDiff } from './CoderDiff' +import { + type ApplyPatchResult, + applyPatchRequestSchema, + applyPatchResponseSchema, + type PatchEcho, + type PatchResultKind, + safeParseRequest, + safeParseResponse, +} from './parsers' + +interface ApplyPatchViewProps { + input: unknown + output?: unknown + running?: boolean + preview?: boolean +} + +/* ---------------- pure V4A parsing (unit-tested) ---------------- */ + +export type PatchHunk = + | { kind: 'add'; path: string; content: string } + | { kind: 'delete'; path: string } + | { + kind: 'update' + path: string + moveTo: string | null + oldText: string + newText: string + } + +const ADD_PREFIX = '*** Add File: ' +const DELETE_PREFIX = '*** Delete File: ' +const UPDATE_PREFIX = '*** Update File: ' +const MOVE_PREFIX = '*** Move to: ' + +/** Any `*** ` line ends the current hunk body — file headers, the + Begin/End envelope, and forward-compat markers like `*** End of File`. */ +function isSectionLine(line: string): boolean { + return line.startsWith('*** ') +} + +/** + * Parse V4A patch text into per-file hunks. Tolerant by construction: + * a missing Begin/End envelope, unknown `*** ` markers and stray body + * lines are skipped rather than failing the whole patch — garbage input + * yields `[]` and the view falls back to the raw text. + */ +export function parsePatch(patch: string): PatchHunk[] { + const lines = patch.split('\n') + const hunks: PatchHunk[] = [] + let i = 0 + + while (i < lines.length) { + const line = lines[i] + + if (line.startsWith(ADD_PREFIX)) { + const path = line.slice(ADD_PREFIX.length) + i += 1 + const body: string[] = [] + while (i < lines.length && !isSectionLine(lines[i])) { + // Every Add body line is `+`-prefixed; skip malformed strays. + if (lines[i].startsWith('+')) body.push(lines[i].slice(1)) + i += 1 + } + hunks.push({ + kind: 'add', + path, + content: body.length > 0 ? `${body.join('\n')}\n` : '', + }) + continue + } + + if (line.startsWith(DELETE_PREFIX)) { + hunks.push({ kind: 'delete', path: line.slice(DELETE_PREFIX.length) }) + i += 1 + continue + } + + if (line.startsWith(UPDATE_PREFIX)) { + const path = line.slice(UPDATE_PREFIX.length) + i += 1 + let moveTo: string | null = null + if (i < lines.length && lines[i].startsWith(MOVE_PREFIX)) { + moveTo = lines[i].slice(MOVE_PREFIX.length) + i += 1 + } + const oldLines: string[] = [] + const newLines: string[] = [] + while (i < lines.length && !isSectionLine(lines[i])) { + const body = lines[i] + if (body.startsWith('@@')) { + // `@@ ` locators carry a real line of the file — keep + // it as shared context so hunk sections stay anchored; bare + // `@@` separators carry nothing. + const ctx = body.slice(2).trimStart() + if (ctx !== '') { + oldLines.push(ctx) + newLines.push(ctx) + } + } else if (body.startsWith('+')) { + newLines.push(body.slice(1)) + } else if (body.startsWith('-')) { + oldLines.push(body.slice(1)) + } else if (body.startsWith(' ')) { + oldLines.push(body.slice(1)) + newLines.push(body.slice(1)) + } else if (body === '') { + // Some emitters write context blank lines without the leading + // space — treat as shared context. + oldLines.push('') + newLines.push('') + } + i += 1 + } + hunks.push({ + kind: 'update', + path, + moveTo, + oldText: oldLines.join('\n'), + newText: newLines.join('\n'), + }) + continue + } + + // Begin/End envelope, unknown markers, stray text — skip. + i += 1 + } + + return hunks +} + +/** Result kind when the wire hasn't answered yet, derived from the hunk. */ +export function derivedKind(hunk: PatchHunk): PatchResultKind { + switch (hunk.kind) { + case 'add': + return 'added' + case 'delete': + return 'deleted' + case 'update': + return hunk.moveTo != null ? 'moved' : 'modified' + } +} + +/* ---------------- rendering ---------------- */ + +const KIND_TONE: Record = { + added: 'accent', + modified: 'default', + deleted: 'warn', + moved: 'default', +} + +function KindBadge({ kind }: { kind: PatchResultKind }) { + return {kind} +} + +/** Optional bounded post-apply snapshot — numbered mono lines. */ +function PatchEchoBlock({ echo }: { echo: PatchEcho }) { + if (echo.lines.length === 0) return null + // Post-apply line numbers are unique within one echo — stable keys. + const rows = echo.lines.map((text, i) => ({ + lineNo: echo.from_line + i, + text, + })) + return ( +
+ {rows.map((row) => ( +
+ + {row.lineNo} + + + {row.text} + +
+ ))} +
+ ) +} + +function HunkSection({ + hunk, + result, +}: { + hunk: PatchHunk + result?: ApplyPatchResult +}) { + const kind = result?.kind ?? derivedKind(hunk) + const moveTo = hunk.kind === 'update' ? hunk.moveTo : null + + return ( +
+
+ {/* Canonical absolute path once the wire responds; patch-relative + until. Moved files keep the from → to reading. */} + + {moveTo != null ? ( + <> + {hunk.path} {' '} + {result?.path ?? moveTo} + + ) : ( + (result?.path ?? hunk.path) + )} + + + {result?.new_line_count != null ? ( + {result.new_line_count} + ) : null} +
+ {hunk.kind === 'add' ? ( + + ) : hunk.kind === 'update' ? ( + + ) : null} + {result?.echo ? : null} +
+ ) +} + +/** Fallback row when the patch text didn't parse but the response did. */ +function ResultOnlyRow({ result }: { result: ApplyPatchResult }) { + return ( +
+ {result.path} + + {result.new_line_count != null ? ( + {result.new_line_count} + ) : null} +
+ ) +} + +export function ApplyPatchView({ + input, + output, + running, + preview, +}: ApplyPatchViewProps) { + const req = safeParseRequest(applyPatchRequestSchema, input) + if (!req) return null + const resp = + output != null && !preview + ? safeParseResponse(applyPatchResponseSchema, output) + : null + + const hunks = parsePatch(req.patch) + + return ( +
+
+ {hunks.length || (resp?.results.length ?? 0)} + {running ? ( + + · applying… + + ) : null} +
+ + {hunks.length > 0 ? ( + hunks.map((hunk, i) => ( + + )) + ) : resp ? ( + resp.results.map((result) => ( + + )) + ) : ( + // Unparseable patch, no response — never swallow the text. +
+          {req.patch}
+        
+ )} + + +
+ ) +} + +export function ApplyPatchPreview({ input }: { input: unknown }) { + return +} diff --git a/console/web/src/components/chat/coder/ChecksView.tsx b/console/web/src/components/chat/coder/ChecksView.tsx new file mode 100644 index 000000000..693f049e7 --- /dev/null +++ b/console/web/src/components/chat/coder/ChecksView.tsx @@ -0,0 +1,83 @@ +/** + * Post-write `checks` rows — shared by `coder::create-file`, + * `coder::update-file` and `coder::apply-patch` responses. One compact + * row per check: monospace command, a status badge, and the output + * behind a native `
` disclosure. `error` set (e.g. a timeout) + * means the exit code is not trustworthy — it wins over `exit_code` + * and renders amber, never as a plain failure exit. + */ +import { FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import type { CheckResult } from './parsers' + +export interface CheckStatus { + label: string + tone: 'accent' | 'warn' | 'alert' | 'default' +} + +/** Badge state for one check: error → amber, exit 0 → green, non-zero → + red, no exit code at all → neutral dash. */ +export function checkStatus(check: CheckResult): CheckStatus { + if (check.error != null && check.error !== '') { + return { label: check.error, tone: 'warn' } + } + if (check.exit_code == null) { + return { label: '—', tone: 'default' } + } + if (check.exit_code === 0) { + return { label: 'exit 0', tone: 'accent' } + } + return { label: `exit ${check.exit_code}`, tone: 'alert' } +} + +function CheckRow({ check }: { check: CheckResult }) { + const status = checkStatus(check) + const hasOutput = check.output !== '' + + return ( +
+ + + {check.command} + + {status.label} + {check.truncated ? ( + output truncated + ) : null} + + {hasOutput ? ( +
+          {check.output}
+        
+ ) : ( +
+ · no output +
+ )} +
+ ) +} + +/** Renders nothing when `checks` is absent or empty. */ +export function ChecksList({ + checks, +}: { + checks?: readonly CheckResult[] | null +}) { + if (!checks || checks.length === 0) return null + return ( +
+
+ checks +
+ {checks.map((check, i) => ( + + ))} +
+ ) +} diff --git a/console/web/src/components/chat/coder/CoderDiff.tsx b/console/web/src/components/chat/coder/CoderDiff.tsx index 675c84300..7870258d1 100644 --- a/console/web/src/components/chat/coder/CoderDiff.tsx +++ b/console/web/src/components/chat/coder/CoderDiff.tsx @@ -33,6 +33,31 @@ export function CoderNewFilePreview({ ) } +/** Two-sided text diff for apply-patch Update hunks: old side is the + hunk's context+`-` lines, new side its context+`+` lines. `newPath` + diverges from `oldPath` only on `*** Move to:` hunks. */ +export function CoderTextDiff({ + oldPath, + newPath, + oldContent, + newContent, +}: { + oldPath: string + newPath: string + oldContent: string + newContent: string +}) { + const options = usePierreDiffOptions() + const oldFile: FileContents = { name: oldPath, contents: oldContent } + const newFile: FileContents = { name: newPath, contents: newContent } + + return ( +
+ +
+ ) +} + /** Overwrite: previous content is not on the wire — show new body only. */ export function CoderOverwritePreview({ path, diff --git a/console/web/src/components/chat/coder/ContextView.tsx b/console/web/src/components/chat/coder/ContextView.tsx new file mode 100644 index 000000000..bfbcb03b4 --- /dev/null +++ b/console/web/src/components/chat/coder/ContextView.tsx @@ -0,0 +1,116 @@ +/** + * `coder::context` — compact workspace card: primary root, platform, + * git branch + dirty count (or "not a repository"), and instruction + * file names with their content behind a disclosure. Discovery data + * like InfoView, but about the WORKSPACE rather than the worker config. + */ +import { Chip, FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import { + type ContextGit, + contextRequestSchema, + contextResponseSchema, + type InstructionFile, + safeParseRequest, + safeParseResponse, +} from './parsers' + +interface ContextViewProps { + input: unknown + output?: unknown + running?: boolean + preview?: boolean +} + +/** "clean" / "3 dirty" / "50+ dirty" — status lines ARE the dirty + entries; truncation means the count is a floor, not exact. */ +export function dirtyLabel(status: string[], truncated: boolean): string { + if (status.length === 0 && !truncated) return 'clean' + return `${status.length}${truncated ? '+' : ''} dirty` +} + +function GitRow({ git }: { git: ContextGit | null | undefined }) { + return ( +
+ {git ? ( + <> + {git.branch} + 0 ? 'warn' : 'accent'}> + {dirtyLabel(git.status, git.status_truncated)} + + + ) : ( + + · not a repository + + )} +
+ ) +} + +function InstructionFileRow({ file }: { file: InstructionFile }) { + return ( +
+ + + {file.path} + + {file.truncated ? truncated : null} + +
+        {file.content}
+      
+
+ ) +} + +export function ContextView({ + input, + output, + running, + preview, +}: ContextViewProps) { + // Request is `{}` — trivially satisfiable, only bail on non-object junk. + const req = safeParseRequest(contextRequestSchema, input) + if (!req) return null + const resp = + output != null && !preview + ? safeParseResponse(contextResponseSchema, output) + : null + + return ( +
+
+ + coder + context + + {resp ? ( + + {resp.platform.os}/{resp.platform.arch} + + ) : null} + {running ? ( + + · querying… + + ) : null} +
+ + {resp ? ( + <> +
+ {resp.primary_root} +
+ + {resp.instruction_files.map((file) => ( + + ))} + + ) : running ? null : ( +
+ · no workspace reported +
+ )} +
+ ) +} diff --git a/console/web/src/components/chat/coder/CreateFileView.tsx b/console/web/src/components/chat/coder/CreateFileView.tsx index f9868915f..1b29cffcd 100644 --- a/console/web/src/components/chat/coder/CreateFileView.tsx +++ b/console/web/src/components/chat/coder/CreateFileView.tsx @@ -15,6 +15,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/Tooltip' +import { ChecksList } from './ChecksView' import { CoderNewFilePreview, CoderOverwritePreview } from './CoderDiff' import { createFileRequestSchema, @@ -130,6 +131,8 @@ export function CreateFileView({ /> ) })} + + ) } diff --git a/console/web/src/components/chat/coder/UpdateFileView.tsx b/console/web/src/components/chat/coder/UpdateFileView.tsx index 3b9c1df0b..cb8781e0a 100644 --- a/console/web/src/components/chat/coder/UpdateFileView.tsx +++ b/console/web/src/components/chat/coder/UpdateFileView.tsx @@ -29,6 +29,7 @@ import { TooltipTrigger, } from '@/components/ui/Tooltip' import { cn } from '@/lib/utils' +import { ChecksList } from './ChecksView' import { formatUpdateOp, type OpEcho, @@ -623,6 +624,8 @@ export function UpdateFileView({ ) : ( )} + + {showEchoes ? : null} ) } diff --git a/console/web/src/components/chat/coder/WorktreeView.tsx b/console/web/src/components/chat/coder/WorktreeView.tsx new file mode 100644 index 000000000..facde9e1d --- /dev/null +++ b/console/web/src/components/chat/coder/WorktreeView.tsx @@ -0,0 +1,145 @@ +/** + * `coder::worktree-add` / `coder::worktree-remove` — small lifecycle + * cards for named git worktrees. Add reports where the worktree landed + * (path + branch); remove reports the DISPOSITION: removed vs kept + * because dirty (uncommitted work is never silently destroyed), and + * whether the branch went with it. + */ +import { Chip, FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import { + safeParseRequest, + safeParseResponse, + type WorktreeRemoveResponse, + worktreeAddRequestSchema, + worktreeAddResponseSchema, + worktreeRemoveRequestSchema, + worktreeRemoveResponseSchema, +} from './parsers' + +interface WorktreeViewProps { + input: unknown + output?: unknown + running?: boolean + preview?: boolean +} + +export interface RemoveDisposition { + label: string + tone: 'accent' | 'warn' | 'alert' | 'default' +} + +/** Human-readable outcome of a worktree remove. kept-dirty is the + load-bearing state: the worktree survived BECAUSE it held + uncommitted work — never let it read like a completed removal. */ +export function removeDisposition( + resp: WorktreeRemoveResponse, +): RemoveDisposition { + if (!resp.removed) { + return resp.dirty + ? { label: 'kept — dirty', tone: 'warn' } + : { label: 'not removed', tone: 'default' } + } + return resp.branch_deleted + ? { label: 'removed · branch deleted', tone: 'accent' } + : { label: 'removed · branch kept', tone: 'accent' } +} + +function WorktreeCard({ + verb, + name, + running, + runningLabel, + children, +}: { + verb: string + name: string + running?: boolean + runningLabel: string + children?: React.ReactNode +}) { + return ( +
+
+ + worktree + {verb} + + {name} + {running ? ( + + · {runningLabel} + + ) : null} +
+ {children} +
+ ) +} + +export function WorktreeAddView({ + input, + output, + running, + preview, +}: WorktreeViewProps) { + const req = safeParseRequest(worktreeAddRequestSchema, input) + if (!req) return null + const resp = + output != null && !preview + ? safeParseResponse(worktreeAddResponseSchema, output) + : null + + return ( + + {resp ? ( +
+ {resp.path} + {resp.branch} +
+ ) : null} +
+ ) +} + +export function WorktreeRemoveView({ + input, + output, + running, + preview, +}: WorktreeViewProps) { + const req = safeParseRequest(worktreeRemoveRequestSchema, input) + if (!req) return null + const resp = + output != null && !preview + ? safeParseResponse(worktreeRemoveResponseSchema, output) + : null + + const disposition = resp ? removeDisposition(resp) : null + + return ( + + {resp && disposition ? ( +
+ {disposition.label} + {resp.path} + {resp.branch} + {resp.dirty && !resp.removed ? ( + + · uncommitted work left in place + + ) : null} +
+ ) : null} +
+ ) +} diff --git a/console/web/src/components/chat/coder/__tests__/ApplyPatchView.test.ts b/console/web/src/components/chat/coder/__tests__/ApplyPatchView.test.ts new file mode 100644 index 000000000..ff28ed7e3 --- /dev/null +++ b/console/web/src/components/chat/coder/__tests__/ApplyPatchView.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { derivedKind, parsePatch } from '../ApplyPatchView' + +const FULL_PATCH = [ + '*** Begin Patch', + '*** Update File: src/index.ts', + '*** Move to: src/main.ts', + '@@ iii.registerFunction(', + "- 'demo::add',", + "+ 'demo::sum',", + ' async (payload) => {', + '*** Add File: src/lib/log.ts', + '+export function log(msg: string): void {', + "+ process.stdout.write(msg + '\\n')", + '+}', + '*** Delete File: src/adapters.ts', + '*** End Patch', + '', +].join('\n') + +describe('parsePatch', () => { + it('splits a full patch into per-file hunks in patch order', () => { + const hunks = parsePatch(FULL_PATCH) + expect(hunks).toHaveLength(3) + expect(hunks[0]?.kind).toBe('update') + expect(hunks[1]?.kind).toBe('add') + expect(hunks[2]).toEqual({ kind: 'delete', path: 'src/adapters.ts' }) + }) + + it('strips the + prefix from Add File bodies and keeps a trailing newline', () => { + const hunks = parsePatch( + [ + '*** Begin Patch', + '*** Add File: notes.md', + '+# notes', + '+', + '+done', + '*** End Patch', + ].join('\n'), + ) + expect(hunks[0]).toEqual({ + kind: 'add', + path: 'notes.md', + content: '# notes\n\ndone\n', + }) + }) + + it('builds update sides from context, - and + lines', () => { + const hunk = parsePatch(FULL_PATCH)[0] + if (hunk?.kind !== 'update') throw new Error('expected update hunk') + expect(hunk.path).toBe('src/index.ts') + expect(hunk.moveTo).toBe('src/main.ts') + // @@ locator + context lines appear on BOTH sides; -/+ on one each. + expect(hunk.oldText).toBe( + "iii.registerFunction(\n 'demo::add',\n async (payload) => {", + ) + expect(hunk.newText).toBe( + "iii.registerFunction(\n 'demo::sum',\n async (payload) => {", + ) + }) + + it('skips bare @@ separators but keeps @@ locator text as shared context', () => { + const hunk = parsePatch( + [ + '*** Update File: a.ts', + '@@', + '-old()', + '+new()', + '@@ function tail() {', + ' unchanged', + ].join('\n'), + )[0] + if (hunk?.kind !== 'update') throw new Error('expected update hunk') + expect(hunk.oldText).toBe('old()\nfunction tail() {\nunchanged') + expect(hunk.newText).toBe('new()\nfunction tail() {\nunchanged') + }) + + it('keeps no moveTo when the Update hunk has no Move to line', () => { + const hunk = parsePatch(['*** Update File: a.ts', '-x', '+y'].join('\n'))[0] + if (hunk?.kind !== 'update') throw new Error('expected update hunk') + expect(hunk.moveTo).toBeNull() + }) + + it('tolerates a missing Begin/End envelope', () => { + const hunks = parsePatch('*** Delete File: gone.rs') + expect(hunks).toEqual([{ kind: 'delete', path: 'gone.rs' }]) + }) + + it('skips unknown *** markers without ending the parse', () => { + const hunks = parsePatch( + [ + '*** Update File: a.ts', + '+added', + '*** End of File', + '*** Delete File: b.ts', + ].join('\n'), + ) + expect(hunks).toHaveLength(2) + expect(hunks[1]?.kind).toBe('delete') + }) + + it('returns [] for text that is not a patch', () => { + expect(parsePatch('just some prose\nwith lines\n')).toEqual([]) + expect(parsePatch('')).toEqual([]) + }) +}) + +describe('derivedKind', () => { + it('maps hunk kinds to result kinds, moved only with a Move to', () => { + expect(derivedKind({ kind: 'add', path: 'a', content: '' })).toBe('added') + expect(derivedKind({ kind: 'delete', path: 'a' })).toBe('deleted') + expect( + derivedKind({ + kind: 'update', + path: 'a', + moveTo: null, + oldText: '', + newText: '', + }), + ).toBe('modified') + expect( + derivedKind({ + kind: 'update', + path: 'a', + moveTo: 'b', + oldText: '', + newText: '', + }), + ).toBe('moved') + }) +}) diff --git a/console/web/src/components/chat/coder/__tests__/checksContextWorktreeViews.test.ts b/console/web/src/components/chat/coder/__tests__/checksContextWorktreeViews.test.ts new file mode 100644 index 000000000..ff1370a75 --- /dev/null +++ b/console/web/src/components/chat/coder/__tests__/checksContextWorktreeViews.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { checkStatus } from '../ChecksView' +import { dirtyLabel } from '../ContextView' +import { removeDisposition } from '../WorktreeView' + +describe('checkStatus', () => { + it('is green for exit 0', () => { + expect( + checkStatus({ + command: 'cargo check', + exit_code: 0, + output: '', + truncated: false, + }), + ).toEqual({ label: 'exit 0', tone: 'accent' }) + }) + + it('is red for a non-zero exit', () => { + expect( + checkStatus({ + command: 'cargo clippy', + exit_code: 101, + output: 'error: unused variable\n', + truncated: false, + }), + ).toEqual({ label: 'exit 101', tone: 'alert' }) + }) + + it('is amber when error is set — even alongside an exit code', () => { + expect( + checkStatus({ + command: 'cargo test', + exit_code: 0, + output: '', + truncated: true, + error: 'timed out after 120s', + }), + ).toEqual({ label: 'timed out after 120s', tone: 'warn' }) + }) + + it('is a neutral dash when neither exit code nor error arrived', () => { + expect( + checkStatus({ command: 'true', output: '', truncated: false }), + ).toEqual({ label: '—', tone: 'default' }) + }) +}) + +describe('dirtyLabel', () => { + it('reads clean with no status lines', () => { + expect(dirtyLabel([], false)).toBe('clean') + }) + + it('counts status lines as dirty entries', () => { + expect(dirtyLabel([' M a.rs', '?? b/'], false)).toBe('2 dirty') + }) + + it('marks a truncated status as a floor, not an exact count', () => { + expect(dirtyLabel([' M a.rs'], true)).toBe('1+ dirty') + // Truncated with zero lines still cannot claim clean. + expect(dirtyLabel([], true)).toBe('0+ dirty') + }) +}) + +describe('removeDisposition', () => { + const base = { + path: '/work/.worktrees/fix', + branch: 'worktree-fix', + } + + it('reports removed with the branch deleted', () => { + expect( + removeDisposition({ + ...base, + removed: true, + dirty: false, + branch_deleted: true, + }), + ).toEqual({ label: 'removed · branch deleted', tone: 'accent' }) + }) + + it('reports removed with the branch kept', () => { + expect( + removeDisposition({ + ...base, + removed: true, + dirty: false, + branch_deleted: false, + }), + ).toEqual({ label: 'removed · branch kept', tone: 'accent' }) + }) + + it('never reads a dirty refusal as a removal', () => { + expect( + removeDisposition({ + ...base, + removed: false, + dirty: true, + branch_deleted: false, + }), + ).toEqual({ label: 'kept — dirty', tone: 'warn' }) + }) + + it('falls back to a neutral not-removed for clean refusals', () => { + expect( + removeDisposition({ + ...base, + removed: false, + dirty: false, + branch_deleted: false, + }), + ).toEqual({ label: 'not removed', tone: 'default' }) + }) +}) diff --git a/console/web/src/components/chat/coder/__tests__/parsers.test.ts b/console/web/src/components/chat/coder/__tests__/parsers.test.ts index 4a6e9669c..09707f3ec 100644 --- a/console/web/src/components/chat/coder/__tests__/parsers.test.ts +++ b/console/web/src/components/chat/coder/__tests__/parsers.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from 'vitest' import { + applyPatchRequestSchema, + applyPatchResponseSchema, CODER_FUNCTION_IDS, CODER_MUTATE_FUNCTION_IDS, + checkResultSchema, contentMatchSchema, + contextRequestSchema, + contextResponseSchema, createFileRequestSchema, createFileResponseSchema, deleteFileRequestSchema, @@ -31,6 +36,10 @@ import { updateFileResponseSchema, updateOpReplaceSchema, wireErrorSchema, + worktreeAddRequestSchema, + worktreeAddResponseSchema, + worktreeRemoveRequestSchema, + worktreeRemoveResponseSchema, } from '../parsers' function wrap(details: T) { @@ -46,7 +55,7 @@ describe('isCoderFunction', () => { for (const id of CODER_FUNCTION_IDS) { expect(isCoderFunction(id)).toBe(true) } - expect(CODER_FUNCTION_IDS).toHaveLength(9) + expect(CODER_FUNCTION_IDS).toHaveLength(13) }) it('rejects same-prefix non-members and other families', () => { @@ -61,11 +70,14 @@ describe('isCoderMutateFunction', () => { expect(isCoderMutateFunction(id)).toBe(true) } expect(isCoderMutateFunction('coder::move')).toBe(true) + expect(isCoderMutateFunction('coder::apply-patch')).toBe(true) }) it('rejects read-only coder ids and other families', () => { expect(isCoderMutateFunction('coder::read-file')).toBe(false) expect(isCoderMutateFunction('coder::search')).toBe(false) + expect(isCoderMutateFunction('coder::context')).toBe(false) + expect(isCoderMutateFunction('coder::worktree-add')).toBe(false) expect(isCoderMutateFunction('sandbox::exec')).toBe(false) }) }) @@ -316,6 +328,26 @@ describe('updateFileResponseSchema', () => { expect(r?.results[0]?.error?.code).toBe('C210') }) + it('parses top-level checks alongside the results', () => { + const r = safeParseResponse(updateFileResponseSchema, { + results: [ + { + path: '/work/src/lib.rs', + success: true, + applied: 1, + new_line_count: 10, + echoes: [], + echoes_truncated: false, + }, + ], + checks: [ + { command: 'cargo check', exit_code: 0, output: '', truncated: false }, + ], + }) + expect(r?.checks).toHaveLength(1) + expect(r?.checks?.[0]?.exit_code).toBe(0) + }) + it('rejects results missing the always-present echoes fields', () => { expect( safeParseResponse(updateFileResponseSchema, { @@ -762,6 +794,161 @@ describe('listFolderResponseSchema', () => { }) }) +describe('checkResultSchema', () => { + it('parses an errored check with the exit code omitted', () => { + const r = checkResultSchema.safeParse({ + command: 'cargo test', + output: '', + truncated: true, + error: 'timed out after 120s', + }) + expect(r.success).toBe(true) + if (r.success) { + expect(r.data.exit_code).toBeUndefined() + expect(r.data.error).toBe('timed out after 120s') + } + }) + + it('rejects a check missing the command', () => { + expect( + checkResultSchema.safeParse({ output: '', truncated: false }).success, + ).toBe(false) + }) +}) + +describe('applyPatchRequestSchema / applyPatchResponseSchema', () => { + it('accepts the opaque patch string request', () => { + const r = safeParseRequest(applyPatchRequestSchema, { + patch: '*** Begin Patch\n*** Delete File: old.rs\n*** End Patch\n', + }) + expect(r?.patch).toContain('*** Delete File: old.rs') + }) + + it('rejects a request without patch text', () => { + expect(safeParseRequest(applyPatchRequestSchema, {})).toBeNull() + }) + + it('parses a wrapped mixed-kind response with echo and checks', () => { + const r = safeParseResponse( + applyPatchResponseSchema, + wrap({ + results: [ + { + path: '/work/src/main.rs', + kind: 'moved', + new_line_count: 30, + echo: { from_line: 9, lines: [" 'demo::sum',"] }, + }, + { path: '/work/src/lib/log.rs', kind: 'added', new_line_count: 3 }, + { path: '/work/src/adapters.rs', kind: 'deleted' }, + ], + checks: [ + { + command: 'cargo check', + exit_code: 101, + output: 'error[E0425]: cannot find value `vm`\n', + truncated: false, + }, + ], + }), + ) + expect(r?.results).toHaveLength(3) + expect(r?.results[0]?.kind).toBe('moved') + expect(r?.results[0]?.echo?.from_line).toBe(9) + // Deleted files carry no line count or echo. + expect(r?.results[2]?.new_line_count).toBeUndefined() + expect(r?.results[2]?.echo).toBeUndefined() + expect(r?.checks?.[0]?.exit_code).toBe(101) + }) + + it('rejects a result with an unknown kind', () => { + expect( + safeParseResponse(applyPatchResponseSchema, { + results: [{ path: '/work/a.rs', kind: 'renamed' }], + }), + ).toBeNull() + }) +}) + +describe('contextRequestSchema / contextResponseSchema', () => { + it('accepts an empty discovery request (null coerced to {})', () => { + expect(safeParseRequest(contextRequestSchema, undefined)).toEqual({}) + }) + + it('parses a wrapped full workspace payload', () => { + const r = safeParseResponse( + contextResponseSchema, + wrap({ + primary_root: '/work', + base_paths: ['/work', '/tmp/coder-cache'], + platform: { os: 'linux', arch: 'aarch64' }, + git: { + branch: 'feat/worktrees', + status: [' M src/main.rs', '?? src/lib/'], + status_truncated: false, + recent_commits: ['936ba3be fix: surface stream-fatal errors'], + }, + instruction_files: [ + { path: 'CLAUDE.md', content: '# Project notes\n', truncated: false }, + ], + }), + ) + expect(r?.primary_root).toBe('/work') + expect(r?.git?.branch).toBe('feat/worktrees') + expect(r?.git?.status).toHaveLength(2) + expect(r?.instruction_files[0]?.path).toBe('CLAUDE.md') + }) + + it('tolerates an absent git block (not a repository)', () => { + const r = safeParseResponse(contextResponseSchema, { + primary_root: '/work', + base_paths: ['/work'], + platform: { os: 'darwin', arch: 'arm64' }, + instruction_files: [], + }) + expect(r?.git).toBeUndefined() + }) +}) + +describe('worktree schemas', () => { + it('parses add: name in, path + branch out', () => { + const req = safeParseRequest(worktreeAddRequestSchema, { + name: 'fix-timeouts', + }) + expect(req?.name).toBe('fix-timeouts') + const r = safeParseResponse( + worktreeAddResponseSchema, + wrap({ + path: '/work/.worktrees/fix-timeouts', + branch: 'worktree-fix-timeouts', + }), + ) + expect(r?.path).toBe('/work/.worktrees/fix-timeouts') + expect(r?.branch).toBe('worktree-fix-timeouts') + }) + + it('rejects an add request without a name', () => { + expect(safeParseRequest(worktreeAddRequestSchema, {})).toBeNull() + }) + + it('parses remove: kept-dirty refusal with the branch intact', () => { + const req = safeParseRequest(worktreeRemoveRequestSchema, { + name: 'fix-timeouts', + }) + expect(req?.name).toBe('fix-timeouts') + const r = safeParseResponse(worktreeRemoveResponseSchema, { + removed: false, + dirty: true, + path: '/work/.worktrees/fix-timeouts', + branch: 'worktree-fix-timeouts', + branch_deleted: false, + }) + expect(r?.removed).toBe(false) + expect(r?.dirty).toBe(true) + expect(r?.branch_deleted).toBe(false) + }) +}) + describe('formatUpdateOp', () => { it('labels line ops with 1-based ranges', () => { expect(formatUpdateOp({ op: 'remove', from_line: 3, to_line: 9 })).toBe( diff --git a/console/web/src/components/chat/coder/index.tsx b/console/web/src/components/chat/coder/index.tsx index 1f1557383..3a727e1ec 100644 --- a/console/web/src/components/chat/coder/index.tsx +++ b/console/web/src/components/chat/coder/index.tsx @@ -1,6 +1,8 @@ import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' import type { FunctionCallMessage } from '@/types/chat' +import { ApplyPatchPreview, ApplyPatchView } from './ApplyPatchView' +import { ContextView } from './ContextView' import { CreateFilePreview, CreateFileView } from './CreateFileView' import { DeleteFilePreview, DeleteFileView } from './DeleteFileView' import { InfoView } from './InfoView' @@ -15,6 +17,7 @@ import { ReadFileView } from './ReadFileView' import { SearchView } from './SearchView' import { TreeView } from './TreeView' import { UpdateFilePreview, UpdateFileView } from './UpdateFileView' +import { WorktreeAddView, WorktreeRemoveView } from './WorktreeView' export function CoderFunctionIdLabel({ functionId }: { functionId: string }) { if (!functionId.startsWith('coder::')) { @@ -63,6 +66,16 @@ function tryRender(message: FunctionCallMessage): React.ReactNode | null { return case 'coder::info': return + case 'coder::apply-patch': + return + case 'coder::context': + return + case 'coder::worktree-add': + return + case 'coder::worktree-remove': + return ( + + ) default: return null } @@ -85,6 +98,8 @@ function tryRenderPreview( return case 'coder::move': return + case 'coder::apply-patch': + return default: return null } diff --git a/console/web/src/components/chat/coder/parsers.ts b/console/web/src/components/chat/coder/parsers.ts index 716e583ed..7f94c85c6 100644 --- a/console/web/src/components/chat/coder/parsers.ts +++ b/console/web/src/components/chat/coder/parsers.ts @@ -11,6 +11,9 @@ * workers/coder/src/functions/tree.rs -> TreeInput/Output * workers/coder/src/functions/list_folder.rs -> ListFolderInput/Output * workers/coder/src/functions/info.rs -> InfoInput/Output + * workers/coder/src/functions/apply_patch.rs -> ApplyPatchInput/Output + * workers/coder/src/functions/context.rs -> ContextInput/Output + * workers/coder/src/functions/worktree.rs -> WorktreeAdd/RemoveInput/Output * * Schemas are non-strict so additive wire fields don't break the UI. * Every response-side Rust `Option` carries `skip_serializing_if = @@ -39,6 +42,10 @@ export const CODER_FUNCTION_IDS = [ 'coder::tree', 'coder::list-folder', 'coder::info', + 'coder::apply-patch', + 'coder::context', + 'coder::worktree-add', + 'coder::worktree-remove', ] as const export type CoderFunctionId = (typeof CODER_FUNCTION_IDS)[number] @@ -56,6 +63,7 @@ export const CODER_MUTATE_FUNCTION_IDS = [ 'coder::update-file', 'coder::delete-file', 'coder::move', + 'coder::apply-patch', ] as const export type CoderMutateFunctionId = (typeof CODER_MUTATE_FUNCTION_IDS)[number] @@ -85,6 +93,22 @@ export type WireError = z.infer export const entryKindSchema = z.enum(['file', 'dir', 'symlink', 'other']) export type EntryKind = z.infer +/** + * Post-write check run — shared by create-file, update-file and + * apply-patch responses. `error` set (e.g. a timeout) means the command + * never produced a trustworthy exit code; treat it as its own state, + * not as a failure exit. + */ +export const checkResultSchema = z.object({ + command: z.string(), + /** Omitted when the check errored before exiting. */ + exit_code: z.number().nullish(), + output: z.string(), + truncated: z.boolean(), + error: z.string().nullish(), +}) +export type CheckResult = z.infer + /* ---------------- info ---------------- */ /** `info.rs::InfoInput` — pure discovery call, zero arguments. */ @@ -154,6 +178,8 @@ export type CreateFileResult = z.infer export const createFileResponseSchema = z.object({ results: z.array(createFileResultSchema), + /** Post-write check runs; absent on older wires. */ + checks: z.array(checkResultSchema).nullish(), }) export type CreateFileResponse = z.infer @@ -247,6 +273,8 @@ export type UpdateFileResult = z.infer export const updateFileResponseSchema = z.object({ results: z.array(updateFileResultSchema), + /** Post-write check runs; absent on older wires. */ + checks: z.array(checkResultSchema).nullish(), }) export type UpdateFileResponse = z.infer @@ -527,6 +555,115 @@ export const listFolderResponseSchema = z.object({ }) export type ListFolderResponse = z.infer +/* ---------------- apply-patch ---------------- */ + +/** V4A patch text: `*** Begin Patch` … `*** End Patch` with per-file + Add/Delete/Update hunks. Parsed for rendering in ApplyPatchView. */ +export const applyPatchRequestSchema = z.object({ + patch: z.string(), +}) +export type ApplyPatchRequest = z.infer + +export const patchResultKindSchema = z.enum([ + 'added', + 'modified', + 'deleted', + 'moved', +]) +export type PatchResultKind = z.infer + +/** Bounded post-apply snapshot, like update-file's OpEcho (no op_index — + apply-patch is one hunk per file). */ +export const patchEchoSchema = z.object({ + /** 1-based first echoed line, AFTER the patch applied. */ + from_line: z.number(), + lines: z.array(z.string()), +}) +export type PatchEcho = z.infer + +export const applyPatchResultSchema = z.object({ + /** Canonical absolute; the destination path for moved files. */ + path: z.string(), + kind: patchResultKindSchema, + /** Absent for deleted files. */ + new_line_count: z.number().nullish(), + echo: patchEchoSchema.nullish(), +}) +export type ApplyPatchResult = z.infer + +export const applyPatchResponseSchema = z.object({ + /** One entry per file hunk, in patch order. */ + results: z.array(applyPatchResultSchema), + /** Post-write check runs; absent on older wires. */ + checks: z.array(checkResultSchema).nullish(), +}) +export type ApplyPatchResponse = z.infer + +/* ---------------- context ---------------- */ + +/** `context.rs::ContextInput` — pure discovery call, zero arguments. */ +export const contextRequestSchema = z.object({}) +export type ContextRequest = z.infer + +export const contextGitSchema = z.object({ + branch: z.string(), + /** Porcelain status lines — length is the dirty-entry count. */ + status: z.array(z.string()), + status_truncated: z.boolean(), + recent_commits: z.array(z.string()), +}) +export type ContextGit = z.infer + +export const instructionFileSchema = z.object({ + path: z.string(), + content: z.string(), + truncated: z.boolean(), +}) +export type InstructionFile = z.infer + +export const contextResponseSchema = z.object({ + primary_root: z.string(), + base_paths: z.array(z.string()), + platform: z.object({ + os: z.string(), + arch: z.string(), + }), + /** Absent when the primary root is not a git repository. */ + git: contextGitSchema.nullish(), + instruction_files: z.array(instructionFileSchema), +}) +export type ContextResponse = z.infer + +/* ---------------- worktree-add / worktree-remove ---------------- */ + +export const worktreeAddRequestSchema = z.object({ + name: z.string(), +}) +export type WorktreeAddRequest = z.infer + +export const worktreeAddResponseSchema = z.object({ + path: z.string(), + branch: z.string(), +}) +export type WorktreeAddResponse = z.infer + +export const worktreeRemoveRequestSchema = z.object({ + name: z.string(), +}) +export type WorktreeRemoveRequest = z.infer + +export const worktreeRemoveResponseSchema = z.object({ + /** False + dirty = refused: uncommitted work kept in place. */ + removed: z.boolean(), + dirty: z.boolean(), + path: z.string(), + branch: z.string(), + branch_deleted: z.boolean(), +}) +export type WorktreeRemoveResponse = z.infer< + typeof worktreeRemoveResponseSchema +> + /* ---------------- formatting helpers ---------------- */ /** Human-readable one-liner for a single update op (approval + done views). */ diff --git a/console/web/src/stories/fixtures/coder-fixtures.ts b/console/web/src/stories/fixtures/coder-fixtures.ts index 1604788de..67fcd6863 100644 --- a/console/web/src/stories/fixtures/coder-fixtures.ts +++ b/console/web/src/stories/fixtures/coder-fixtures.ts @@ -1124,6 +1124,219 @@ export const coderInfo = base( }), ) +/* ---------------- apply-patch ---------------- */ + +/** V4A patch: rename + edit, new file, deletion — one hunk per file. */ +const APPLY_PATCH_V4A = [ + '*** Begin Patch', + '*** Update File: workers/demo/src/index.ts', + '*** Move to: workers/demo/src/main.ts', + '@@ iii.registerFunction(', + "- 'demo::add',", + "+ 'demo::sum',", + ' async (payload: { a: number; b: number }) => {', + '*** Add File: workers/demo/src/lib/log.ts', + '+export function log(msg: string): void {', + "+ process.stdout.write(msg + '\\n')", + '+}', + '*** Delete File: workers/demo/src/adapters.ts', + '*** End Patch', + '', +].join('\n') + +/** Mixed-kind patch with per-file results, one echo, and passing checks. */ +export const coderApplyPatch = base( + 'coder-apply-patch', + 'coder::apply-patch', + { patch: APPLY_PATCH_V4A }, + wrapHarness({ + results: [ + { + path: '/work/workers/demo/src/main.ts', + kind: 'moved', + new_line_count: 26, + echo: { from_line: 6, lines: [" 'demo::sum',"] }, + }, + { + path: '/work/workers/demo/src/lib/log.ts', + kind: 'added', + new_line_count: 3, + }, + { path: '/work/workers/demo/src/adapters.ts', kind: 'deleted' }, + ], + checks: [ + { + command: 'pnpm exec tsc --noEmit', + exit_code: 0, + output: '', + truncated: false, + }, + ], + }), +) + +export const coderApplyPatchPending = base( + 'coder-apply-patch-pending', + 'coder::apply-patch', + { patch: APPLY_PATCH_V4A }, + undefined, + { pendingApproval: true }, +) + +/* ---------------- checks ---------------- */ + +/** Post-write checks in all three badge states: green exit 0, red + * non-zero with output, amber error (timeout) with truncated output. */ +export const coderUpdateWithChecks = base( + 'coder-update-checks', + 'coder::update-file', + { + files: [ + { + path: 'workers/demo/src/index.ts', + ops: [ + { + op: 'insert', + at_line: 1, + content: "import type { Logger } from 'iii-sdk'\n", + }, + ], + }, + ], + }, + { + results: [ + { + path: '/work/workers/demo/src/index.ts', + success: true, + applied: 1, + new_line_count: 27, + echoes: [ + { + op_index: 0, + from_line: 1, + lines: [ + "import type { Logger } from 'iii-sdk'", + "import { registerWorker } from 'iii-sdk'", + '', + ], + }, + ], + echoes_truncated: false, + }, + ], + checks: [ + { + command: 'pnpm exec biome check src/', + exit_code: 0, + output: 'Checked 14 files in 62ms. No fixes applied.\n', + truncated: false, + }, + { + command: 'pnpm exec tsc --noEmit', + exit_code: 2, + output: + "src/index.ts(1,15): error TS6133: 'Logger' is declared but its value is never read.\n", + truncated: false, + }, + { + command: 'pnpm test', + output: ' RUN v3.1.4 /work/workers/demo\n', + truncated: true, + error: 'timed out after 120s', + }, + ], + }, +) + +/* ---------------- context ---------------- */ + +/** Workspace card: git repo with dirty entries + one instruction file. */ +export const coderContext = base( + 'coder-context', + 'coder::context', + {}, + wrapHarness({ + primary_root: '/work', + base_paths: ['/work', '/tmp/coder-cache'], + platform: { os: 'linux', arch: 'aarch64' }, + git: { + branch: 'feat/worktrees', + status: [' M workers/demo/src/index.ts', '?? workers/demo/src/lib/'], + status_truncated: false, + recent_commits: [ + '936ba3be fix(provider): surface stream-fatal error events', + '5ddd788d chore(harness): bump to v1.1.5', + ], + }, + instruction_files: [ + { + path: 'CLAUDE.md', + content: + '# Project notes\n\n- Use pnpm, never npm.\n- Conventional commits.\n', + truncated: false, + }, + { + path: 'workers/demo/CLAUDE.md', + content: '# demo worker\n\nRun with III_ENGINE_URL set.\n', + truncated: true, + }, + ], + }), +) + +/** Non-repo workspace — git absent on the wire. */ +export const coderContextNoGit = base( + 'coder-context-no-git', + 'coder::context', + {}, + { + primary_root: '/tmp/scratch', + base_paths: ['/tmp/scratch'], + platform: { os: 'darwin', arch: 'arm64' }, + instruction_files: [], + }, +) + +/* ---------------- worktree-add / worktree-remove ---------------- */ + +export const coderWorktreeAdd = base( + 'coder-worktree-add', + 'coder::worktree-add', + { name: 'fix-timeouts' }, + wrapHarness({ + path: '/work/.worktrees/fix-timeouts', + branch: 'worktree-fix-timeouts', + }), +) + +export const coderWorktreeRemoveClean = base( + 'coder-worktree-remove', + 'coder::worktree-remove', + { name: 'fix-timeouts' }, + { + removed: true, + dirty: false, + path: '/work/.worktrees/fix-timeouts', + branch: 'worktree-fix-timeouts', + branch_deleted: true, + }, +) + +/** Refused: uncommitted work keeps the worktree (and its branch) alive. */ +export const coderWorktreeRemoveDirty = base( + 'coder-worktree-remove-dirty', + 'coder::worktree-remove', + { name: 'spike-quic' }, + wrapHarness({ + removed: false, + dirty: true, + path: '/work/.worktrees/spike-quic', + branch: 'worktree-spike-quic', + branch_deleted: false, + }), +) + /* ---------------- top-level error ---------------- */ export const coderGateError = base( @@ -1179,5 +1392,13 @@ export const coderFixtures = [ coderTreeSnapshot, coderListFolderPage, coderInfo, + coderApplyPatch, + coderApplyPatchPending, + coderUpdateWithChecks, + coderContext, + coderContextNoGit, + coderWorktreeAdd, + coderWorktreeRemoveClean, + coderWorktreeRemoveDirty, coderGateError, ] as const