#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>
232 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|