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:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain, History } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
@@ -110,6 +110,10 @@ export interface MessageActions {
|
||||
onResend?: (chatId: string, content: string) => Promise<void>;
|
||||
onFork?: (chatId: string, messageId: string) => Promise<void>;
|
||||
onDelete?: (chatId: string, messageId: string) => Promise<void>;
|
||||
// write-edit-robustness #4 (BooCoder only): reset the worktree to this
|
||||
// message's pre-turn checkpoint and trim the transcript past it. BooChat
|
||||
// passes no such callback → the "Restore to here" control never renders.
|
||||
onRestoreCheckpoint?: (chatId: string, messageId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -119,6 +123,17 @@ interface Props {
|
||||
actions?: MessageActions;
|
||||
/** Hide actions that don't apply (fork, delete). */
|
||||
hideActions?: ('fork' | 'delete')[];
|
||||
/**
|
||||
* write-edit-robustness #4: this assistant message has a worktree checkpoint
|
||||
* → render "Restore to here" (only when `actions.onRestoreCheckpoint` is also
|
||||
* provided). CoderMessageList sets this from the checkpoint set.
|
||||
*/
|
||||
hasCheckpoint?: boolean;
|
||||
/**
|
||||
* write-edit-robustness #4: suppress the restore control during an active
|
||||
* turn (mirrors composer gating). Defaults to enabled.
|
||||
*/
|
||||
restoreDisabled?: boolean;
|
||||
}
|
||||
|
||||
function StatsLine({ message }: { message: Message }) {
|
||||
@@ -155,16 +170,22 @@ function ActionRow({
|
||||
message,
|
||||
actions,
|
||||
hiddenSet,
|
||||
hasCheckpoint = false,
|
||||
restoreDisabled = false,
|
||||
}: {
|
||||
message: Message;
|
||||
actions?: MessageActions;
|
||||
hiddenSet: Set<string>;
|
||||
hasCheckpoint?: boolean;
|
||||
restoreDisabled?: boolean;
|
||||
}) {
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [forking, setForking] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
@@ -240,12 +261,33 @@ function ActionRow({
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
if (restoring || !actions?.onRestoreCheckpoint) return;
|
||||
setRestoring(true);
|
||||
try {
|
||||
await actions.onRestoreCheckpoint(message.chat_id, message.id);
|
||||
setRestoreOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'restore failed');
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const isUser = message.role === 'user';
|
||||
const canRegen = isAssistant && message.status !== 'streaming';
|
||||
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||
const canFork = message.status === 'complete';
|
||||
const canDelete = message.status !== 'streaming';
|
||||
// write-edit-robustness #4: show "Restore to here" only for a completed
|
||||
// assistant message that has a checkpoint AND when the coder wired the
|
||||
// callback. Disabled (but visible) during an active turn.
|
||||
const canRestore =
|
||||
isAssistant &&
|
||||
hasCheckpoint &&
|
||||
message.status === 'complete' &&
|
||||
!!actions?.onRestoreCheckpoint;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -306,6 +348,18 @@ function ActionRow({
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{canRestore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRestoreOpen(true)}
|
||||
disabled={restoreDisabled || restoring}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Restore to here"
|
||||
title="Restore worktree to this point"
|
||||
>
|
||||
<History className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Dialog
|
||||
open={deleteOpen}
|
||||
@@ -338,6 +392,39 @@ function ActionRow({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={restoreOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!restoring) setRestoreOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restore to this point?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This resets the worktree to before this turn, removes every later
|
||||
message in this chat, and resets the agent's session. This cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRestoreOpen(false)}
|
||||
disabled={restoring}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void confirmRestore()}
|
||||
disabled={restoring}
|
||||
>
|
||||
{restoring ? 'Restoring…' : 'Restore'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -550,7 +637,15 @@ function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, sessionChats, capHitInfo, actions, hideActions }: Props) {
|
||||
export function MessageBubble({
|
||||
message,
|
||||
sessionChats,
|
||||
capHitInfo,
|
||||
actions,
|
||||
hideActions,
|
||||
hasCheckpoint,
|
||||
restoreDisabled,
|
||||
}: Props) {
|
||||
const hiddenSet = new Set(hideActions ?? []);
|
||||
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
|
||||
// branch because summary=true never coexists with kind='compact' (new
|
||||
@@ -652,7 +747,15 @@ export function MessageBubble({ message, sessionChats, capHitInfo, actions, hide
|
||||
</div>
|
||||
)}
|
||||
{!isStreaming && <StatsLine message={message} />}
|
||||
{!isStreaming && hasContent && <ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />}
|
||||
{!isStreaming && hasContent && (
|
||||
<ActionRow
|
||||
message={message}
|
||||
actions={actions}
|
||||
hiddenSet={hiddenSet}
|
||||
hasCheckpoint={hasCheckpoint}
|
||||
restoreDisabled={restoreDisabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user