Files
boocode/apps/web/src/components/panes/CoderMessageList.tsx
indifferentketchup 59f07e8cb8 feat: write/edit robustness — fuzzy patch applier + worktree checkpoints (v2.7.1)
#3 Fuzzy patch applier: new pure fuzzy-match.ts (locateMatch, exact→trim→
unicode-canon→Levenshtein≥0.66, refuse-on-ambiguous) wired into pending_changes
applyOne/rewindOne so local-model whitespace/unicode drift in old_string no
longer loses the edit.

#4 Worktree checkpoint + conversation-trim: checkpoints table + checkpoints.ts
(shadow-commit of tracked+untracked into refs/boocode/checkpoints, hooked into
the 3 external-agent dispatcher paths) + POST restore route (reset --hard +
clean -fd -> transcript trim -> backend-session reset) + "Restore to here" UI.

Built by 3 parallel agents; DB-integration testing caught a created_at
self-deletion bug. Coder suite 234 passing; server+coder build + web tsc clean.
Builds on v2.7.0-mit. openspec write-edit-robustness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:01:57 +00:00

232 lines
6.8 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
import { MessageBubble, type MessageActions } from '@/components/MessageBubble';
import { ToolCallGroup } from '@/components/ToolCallGroup';
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
import { AskUserInputCard } from '@/components/AskUserInputCard';
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
import type { Message } from '@/api/types';
export interface CoderMessageWire {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
status?: 'streaming' | 'complete' | 'failed';
reasoning_text?: string;
tool_calls?: CoderToolCallWire[];
}
export interface CoderToolMessageWire {
id: string;
role: 'tool';
tool_results: {
tool_call_id: string;
output: unknown;
truncated?: boolean;
error?: string;
};
}
export type CoderTimelineWire = CoderMessageWire | CoderToolMessageWire;
function isToolMessage(m: CoderTimelineWire): m is CoderToolMessageWire {
return m.role === 'tool';
}
type RenderItem =
| { kind: 'message'; message: CoderMessageWire }
| { kind: 'tool_run'; run: ToolRun; key: string }
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
const GROUP_THRESHOLD = 3;
const SCROLL_THRESHOLD_PX = 150;
function flattenCoderMessages(messages: CoderTimelineWire[]): RenderItem[] {
const items: RenderItem[] = [];
const runsByCallId = new Map<string, ToolRun>();
for (const m of messages) {
if (isToolMessage(m)) {
const run = runsByCallId.get(m.tool_results.tool_call_id);
if (run) {
run.result = {
tool_call_id: m.tool_results.tool_call_id,
output: m.tool_results.output,
truncated: m.tool_results.truncated ?? false,
...(m.tool_results.error ? { error: m.tool_results.error } : {}),
};
}
continue;
}
if (m.role === 'user' || m.role === 'system') {
items.push({ kind: 'message', message: m });
continue;
}
const hasToolCalls = (m.tool_calls?.length ?? 0) > 0;
const hasText = m.content.trim().length > 0;
const hasReasoning = (m.reasoning_text?.trim().length ?? 0) > 0;
// External agents persist tool calls + final answer on one row. Render tools
// before the answer text so the timeline matches BooChat (tools, then reply).
const externalCombined = hasToolCalls && (hasText || hasReasoning);
if (externalCombined) {
if (hasReasoning) {
items.push({
kind: 'message',
message: { ...m, content: '', reasoning_text: m.reasoning_text },
});
}
for (const tc of m.tool_calls!) {
const run = wireToolCallToRun(tc);
runsByCallId.set(tc.id, run);
items.push({ kind: 'tool_run', run, key: tc.id });
}
if (hasText || m.status === 'streaming') {
items.push({
kind: 'message',
message: { ...m, reasoning_text: undefined },
});
}
continue;
}
// Native inference: separate assistant rows per step — mirror MessageList.
if (hasText || hasReasoning || m.status === 'streaming') {
items.push({ kind: 'message', message: m });
}
if (hasToolCalls) {
for (const tc of m.tool_calls!) {
const run = wireToolCallToRun(tc);
runsByCallId.set(tc.id, run);
items.push({ kind: 'tool_run', run, key: tc.id });
}
}
}
return items;
}
function groupToolRuns(items: RenderItem[]): RenderItem[] {
const out: RenderItem[] = [];
let i = 0;
while (i < items.length) {
const item = items[i]!;
if (item.kind !== 'tool_run') {
out.push(item);
i += 1;
continue;
}
const name = item.run.call.name;
if (name === 'ask_user_input') {
out.push(item);
i += 1;
continue;
}
let j = i + 1;
while (
j < items.length &&
items[j]!.kind === 'tool_run' &&
(items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name
) {
j += 1;
}
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
if (run.length >= GROUP_THRESHOLD) {
out.push({ kind: 'tool_group', runs: run.map((r) => r.run), key: `group-${run[0]!.key}` });
} else {
for (const r of run) out.push(r);
}
i = j;
}
return out;
}
interface Props {
messages: CoderTimelineWire[];
chatId?: string;
footer?: ReactNode;
actions?: MessageActions;
// write-edit-robustness #4: assistant message ids that have a worktree
// checkpoint. The "Restore to here" control renders only on these.
checkpointMessageIds?: Set<string>;
// write-edit-robustness #4: suppress restore during an active turn (mirrors
// composer gating in CoderPane).
restoreDisabled?: boolean;
}
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork'];
export function CoderMessageList({
messages,
chatId,
footer,
actions,
checkpointMessageIds,
restoreDisabled,
}: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
const renderItems = useMemo(
() => groupToolRuns(flattenCoderMessages(messages)),
[messages],
);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isNearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
}, []);
useEffect(() => {
if (isNearBottomRef.current) {
endRef.current?.scrollIntoView({ block: 'end' });
}
}, [messages]);
if (messages.length === 0) {
return null;
}
return (
<div className="flex-1 overflow-y-auto" ref={scrollRef} onScroll={handleScroll}>
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{renderItems.map((item) => {
if (item.kind === 'message') {
return (
<MessageBubble
key={item.message.id}
message={item.message as unknown as Message}
actions={actions}
hideActions={CODER_HIDDEN_ACTIONS}
hasCheckpoint={checkpointMessageIds?.has(item.message.id) ?? false}
restoreDisabled={restoreDisabled}
/>
);
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input' && chatId) {
return (
<AskUserInputCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={chatId}
apiPrefix="/api/coder"
/>
);
}
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;
})}
{footer}
<div ref={endRef} />
</div>
</div>
);
}