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>
This commit is contained in:
@@ -147,11 +147,24 @@ interface Props {
|
||||
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 }: Props) {
|
||||
export function CoderMessageList({
|
||||
messages,
|
||||
chatId,
|
||||
footer,
|
||||
actions,
|
||||
checkpointMessageIds,
|
||||
restoreDisabled,
|
||||
}: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
@@ -189,6 +202,8 @@ export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
||||
message={item.message as unknown as Message}
|
||||
actions={actions}
|
||||
hideActions={CODER_HIDDEN_ACTIONS}
|
||||
hasCheckpoint={checkpointMessageIds?.has(item.message.id) ?? false}
|
||||
restoreDisabled={restoreDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,6 +381,29 @@ function usePendingChanges(sessionId: string) {
|
||||
return { changes, loading, refresh, approve, reject };
|
||||
}
|
||||
|
||||
// write-edit-robustness #4: which assistant messages in this chat have a
|
||||
// worktree checkpoint, so CoderMessageList can offer "Restore to here" only on
|
||||
// those. Refetched on message_complete (same trigger as pending changes) and
|
||||
// after a successful restore.
|
||||
function useCheckpoints(sessionId: string, chatId: string | undefined) {
|
||||
const [messageIds, setMessageIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!chatId) {
|
||||
setMessageIds(new Set());
|
||||
return Promise.resolve();
|
||||
}
|
||||
return api.coder
|
||||
.getCheckpoints(sessionId, chatId)
|
||||
.then((res) => setMessageIds(new Set(res.checkpoints.map((c) => c.message_id))))
|
||||
.catch(() => {/* boocoder may be down / endpoint not ready */});
|
||||
}, [sessionId, chatId]);
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
return { checkpointMessageIds: messageIds, refreshCheckpoints: refresh };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -640,6 +663,7 @@ export function CoderPane({
|
||||
},
|
||||
});
|
||||
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||
const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [queue, setQueue] = useState<string[]>([]);
|
||||
@@ -652,15 +676,18 @@ export function CoderPane({
|
||||
|
||||
// Refresh pending changes (and agent-session state for the §9b chip) when a
|
||||
// message_complete arrives — same trigger usePendingChanges already uses.
|
||||
// write-edit-robustness #4: also refetch checkpoints so a new turn's snapshot
|
||||
// surfaces its "Restore to here" control.
|
||||
useEffect(() => {
|
||||
const lastAssistant = [...messages].reverse().find(
|
||||
(m): m is CoderMessage => m.role === 'assistant',
|
||||
);
|
||||
if (lastAssistant?.status === 'complete') {
|
||||
refresh();
|
||||
void refreshCheckpoints();
|
||||
void refreshAgentSessions(sessionId);
|
||||
}
|
||||
}, [messages, refresh, sessionId]);
|
||||
}, [messages, refresh, refreshCheckpoints, sessionId]);
|
||||
|
||||
// The §9b chip only shows once the chat has ≥1 prior turn (a completed
|
||||
// assistant message). Hidden on a brand-new chat.
|
||||
@@ -867,6 +894,38 @@ export function CoderPane({
|
||||
}
|
||||
}, [activeTaskId]);
|
||||
|
||||
// write-edit-robustness #4: reset the worktree to a message's checkpoint and
|
||||
// trim the transcript past it. The confirm lives in MessageBubble's ActionRow
|
||||
// (plain Cancel/Restore). The restore route is keyed by checkpoint id, so we
|
||||
// resolve message→checkpoint via a fresh GET (cheap, and avoids a stale id if
|
||||
// the set changed). On success, refetch messages so the trimmed transcript
|
||||
// shows, plus checkpoints (later ones were deleted server-side) and pending
|
||||
// changes (the worktree was reset).
|
||||
const handleRestoreCheckpoint = useCallback(async (_chatId: string, messageId: string) => {
|
||||
if (!chatId || generating) return;
|
||||
let checkpointId: string | undefined;
|
||||
try {
|
||||
const res = await api.coder.getCheckpoints(sessionId, chatId);
|
||||
checkpointId = res.checkpoints.find((c) => c.message_id === messageId)?.id;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to load checkpoint');
|
||||
return;
|
||||
}
|
||||
if (!checkpointId) {
|
||||
toast.error('No checkpoint found for this message');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.coder.restoreCheckpoint(sessionId, checkpointId);
|
||||
await loadMessages();
|
||||
await refreshCheckpoints();
|
||||
refresh();
|
||||
toast.success('Restored to checkpoint');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'restore failed');
|
||||
}
|
||||
}, [chatId, generating, sessionId, loadMessages, refreshCheckpoints, refresh]);
|
||||
|
||||
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
||||
if (!chatId) return;
|
||||
// Only BooCoder skills route here; an agent's own commands (not skills) fall
|
||||
@@ -921,8 +980,11 @@ export function CoderPane({
|
||||
<CoderMessageList
|
||||
messages={messages as CoderTimelineWire[]}
|
||||
chatId={chatId}
|
||||
checkpointMessageIds={checkpointMessageIds}
|
||||
restoreDisabled={generating}
|
||||
actions={{
|
||||
onResend: async (_chatId, content) => { await sendOneMessage(content); },
|
||||
onRestoreCheckpoint: handleRestoreCheckpoint,
|
||||
}}
|
||||
footer={
|
||||
activeTaskId && !permissionPrompt && sending === false ? (
|
||||
|
||||
Reference in New Issue
Block a user