diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx index fbf8f97d..d68a6410 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx @@ -1,6 +1,8 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight, Bot } from 'lucide-react'; import type { ChatMessage } from '../types'; +import type { DiffLine } from './line-diff'; +import { diffStat, editDiff } from './line-diff'; import { CopyButton } from './CopyButton'; type ToolMessage = Extract; @@ -56,6 +58,20 @@ function truncate(str: string, max: number): string { return str.length > max ? str.slice(0, max) + '...' : str; } +/** + * Copy gives you the thing you'd want to paste — the command, or the text the file ends up containing — + * never the `key: value` dump, which is a rendering of the call and not usable as anything. + */ +function copyableInput(toolName: string, toolInput: Record): string { + const pick = (key: string): string | null => (typeof toolInput[key] === 'string' ? (toolInput[key] as string) : null); + if (toolName === 'Bash') return pick('command') ?? JSON.stringify(toolInput, null, 2); + if (toolName === 'Edit') return pick('new_string') ?? JSON.stringify(toolInput, null, 2); + if (toolName === 'Write') return pick('content') ?? JSON.stringify(toolInput, null, 2); + return Object.entries(toolInput) + .map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`) + .join('\n'); +} + export const ToolActivity = ({ message }: ToolActivityProps) => { const [open, setOpen] = useState(false); @@ -65,6 +81,10 @@ export const ToolActivity = ({ message }: ToolActivityProps) => { const isError = message.isError === true; const children = message.children ?? []; + // Computed even while collapsed, because the +/− counts are what make the collapsed row worth reading. + const diff = useMemo(() => editDiff(message.toolName, message.toolInput), [message.toolName, message.toolInput]); + const stat = diff ? diffStat(diff) : null; + return (
+ )} + + ); +}; + /** * What a subagent did, in order — its own tool calls nested one level further, its prose as plain text. * Deliberately not markdown-rendered: this is a trace, and it sits inside an already-nested panel. diff --git a/src/workspaces/officerdev/src/apps/Chat/components/line-diff.ts b/src/workspaces/officerdev/src/apps/Chat/components/line-diff.ts new file mode 100644 index 00000000..7036b5dd --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/line-diff.ts @@ -0,0 +1,89 @@ +// A line diff, written here rather than pulled in. jsdiff would do this, but it is a runtime dependency +// shipped to the browser to run a textbook LCS, and the LCS is shorter than the integration would be. +// +// Deliberately no line numbers. An Edit's `old_string` / `new_string` are fragments with no file +// position attached, so any number we printed beside them would be a number we invented — and a +// plausible-looking wrong line number is worse than none. + +export type DiffLine = { kind: 'add' | 'del' | 'ctx'; text: string }; + +/** + * The LCS table is O(n*m); at the cap that is ~640k int32s, which is fine, and past it the panel was + * never going to be readable anyway. `null` means "too big to diff" and the caller falls back to + * showing the raw strings. + */ +const MAX_LINES = 800; + +export function lineDiff(before: string, after: string): DiffLine[] | null { + const a = before.split('\n'); + const b = after.split('\n'); + if (a.length > MAX_LINES || b.length > MAX_LINES) return null; + + const n = a.length; + const m = b.length; + const width = m + 1; + const lcs = new Int32Array((n + 1) * width); + + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + lcs[i * width + j] = + a[i] === b[j] + ? lcs[(i + 1) * width + (j + 1)]! + 1 + : Math.max(lcs[(i + 1) * width + j]!, lcs[i * width + (j + 1)]!); + } + } + + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.push({ kind: 'ctx', text: a[i]! }); + i++; + j++; + } else if (lcs[(i + 1) * width + j]! >= lcs[i * width + (j + 1)]!) { + out.push({ kind: 'del', text: a[i]! }); + i++; + } else { + out.push({ kind: 'add', text: b[j]! }); + j++; + } + } + while (i < n) out.push({ kind: 'del', text: a[i++]! }); + while (j < m) out.push({ kind: 'add', text: b[j++]! }); + return out; +} + +/** Counts for the collapsed row, so the size of an edit is visible without expanding it. */ +export function diffStat(lines: DiffLine[]): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const line of lines) { + if (line.kind === 'add') added++; + else if (line.kind === 'del') removed++; + } + return { added, removed }; +} + +/** + * What an Edit/Write tool call is actually changing, or null when the call is neither. `Write` has no + * prior text on the wire, so it reads as an all-addition diff — which is what writing a new file is. + */ +export function editDiff(toolName: string, input: Record): DiffLine[] | null { + if (toolName === 'Edit') { + const before = input.old_string; + const after = input.new_string; + if (typeof before !== 'string' || typeof after !== 'string') return null; + return lineDiff(before, after); + } + if (toolName === 'Write') { + const content = input.content; + if (typeof content !== 'string') return null; + // Built directly rather than diffed against '': `''.split('\n')` is `['']`, not `[]`, so an empty + // "before" side would open every new file with a phantom deleted blank line. + const lines = content.split('\n'); + if (lines.length > MAX_LINES) return null; + return lines.map((text) => ({ kind: 'add' as const, text })); + } + return null; +}