diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index 5c026c94a138..be00f382c11b 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -18,8 +18,11 @@ import {
buildThreadTurnInterruptInput,
createLocalDispatchSnapshot,
deriveComposerSendState,
+ deriveRevertedMessageContent,
+ deriveRevertedMessagePrompt,
dismissBranchMismatchForSession,
ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
+ fetchRevertedMessageAttachmentBlob,
getStartedThreadModelChangeBlockReason,
hasEnvironmentReconnectWarningGraceElapsed,
hasServerAcknowledgedLocalDispatch,
@@ -34,6 +37,92 @@ import {
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";
+describe("reverted message prompt", () => {
+ it("restores editable text without sent-only context blocks", () => {
+ const prompt = [
+ "Please fix this",
+ "",
+ "",
+ "- Terminal 1:",
+ " npm test",
+ "",
+ "",
+ "",
+ "- ",
+ "",
+ "",
+ '{"id":"preview-1","title":"Button","x":1,"y":2}',
+ "",
+ "",
+ '',
+ "Check this line",
+ "```diff",
+ "+const value = 1;",
+ "```",
+ "",
+ ].join("\n");
+
+ expect(deriveRevertedMessagePrompt(prompt)).toBe("Please fix this");
+ });
+
+ it("only excludes screenshots tied to parsed preview annotations", () => {
+ const prompt = [
+ "Please fix this",
+ "",
+ "",
+ "Preview annotation:",
+ "Id: preview-1",
+ "Page: Button",
+ "The attached screenshot is the annotated preview crop.",
+ "",
+ ].join("\n");
+
+ const content = deriveRevertedMessageContent(prompt);
+
+ expect(content.prompt).toBe("Please fix this");
+ expect([...content.previewAnnotationImageNames]).toEqual(["preview-annotation-preview-1.png"]);
+ expect(content.previewAnnotationImageNames.has("preview-annotation-design.png")).toBe(false);
+ });
+});
+
+describe("reverted message attachment fetch", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it("aborts a stalled attachment request after the timeout", async () => {
+ vi.useFakeTimers();
+ vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => {
+ const signal = init?.signal;
+ if (!signal) throw new Error("Expected an abort signal.");
+ return new Promise((_resolve, reject) => {
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
+ });
+ });
+
+ const pendingFetch = fetchRevertedMessageAttachmentBlob(
+ "https://example.test/image.png",
+ 1_000,
+ );
+ const rejection = expect(pendingFetch).rejects.toMatchObject({ name: "AbortError" });
+
+ await vi.advanceTimersByTimeAsync(1_000);
+ await rejection;
+ });
+
+ it("clears the timeout after an attachment request completes", async () => {
+ vi.useFakeTimers();
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
+
+ await fetchRevertedMessageAttachmentBlob("https://example.test/image.png", 1_000);
+
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
+
const environmentId = EnvironmentId.make("environment-local");
const projectId = ProjectId.make("project-1");
const threadId = ThreadId.make("thread-1");
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index 04561b507c3e..17a28eb7388d 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -16,19 +16,70 @@ import * as Schema from "effect/Schema";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentThreadDetails } from "../state/threads";
import {
+ deriveDisplayedUserMessageState,
filterTerminalContextsWithText,
stripInlineTerminalContextPlaceholders,
type TerminalContextDraft,
} from "../lib/terminalContext";
+import { extractTrailingPreviewAnnotation } from "../lib/previewAnnotation";
+import { parseReviewCommentMessageSegments } from "../reviewCommentContext";
import type { DraftThreadEnvMode } from "../composerDraftStore";
export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;
export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
+export const REVERTED_MESSAGE_ATTACHMENT_TIMEOUT_MS = 10_000;
export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
+export function deriveRevertedMessageContent(prompt: string): {
+ prompt: string;
+ previewAnnotationImageNames: ReadonlySet;
+} {
+ const withoutReviewComments = parseReviewCommentMessageSegments(prompt)
+ .filter((segment) => segment.kind === "text")
+ .map((segment) => segment.text)
+ .join("");
+
+ let withoutPreviewAnnotations = withoutReviewComments;
+ const previewAnnotationImageNames = new Set();
+ while (true) {
+ const extracted = extractTrailingPreviewAnnotation(withoutPreviewAnnotations);
+ if (!extracted.annotation) break;
+ if (extracted.annotation.hasScreenshot) {
+ previewAnnotationImageNames.add(`preview-annotation-${extracted.annotation.id}.png`);
+ }
+ withoutPreviewAnnotations = extracted.promptText;
+ }
+
+ return {
+ prompt: deriveDisplayedUserMessageState(withoutPreviewAnnotations).visibleText,
+ previewAnnotationImageNames,
+ };
+}
+
+export function deriveRevertedMessagePrompt(prompt: string): string {
+ return deriveRevertedMessageContent(prompt).prompt;
+}
+
+export async function fetchRevertedMessageAttachmentBlob(
+ previewUrl: string,
+ timeoutMs = REVERTED_MESSAGE_ATTACHMENT_TIMEOUT_MS,
+): Promise {
+ const controller = new AbortController();
+ const timeoutId = globalThis.setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ // The timeline may have already cached this URL from an
request,
+ // which carries no Origin header and therefore no CORS response header.
+ const response = await fetch(previewUrl, { cache: "reload", signal: controller.signal });
+ if (!response.ok) throw new Error("Could not load reverted message attachment.");
+ return await response.blob();
+ } finally {
+ globalThis.clearTimeout(timeoutId);
+ }
+}
+
export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS);
return () => globalThis.clearTimeout(timeoutId);
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 32a8e309beb9..fb760edffeb0 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -19,6 +19,7 @@ import {
OrchestrationThreadActivity,
ProviderInteractionMode,
ProviderDriverKind,
+ PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
RuntimeMode,
TerminalOpenInput,
} from "@t3tools/contracts";
@@ -206,6 +207,7 @@ import {
import {
appendTerminalContextsToPrompt,
formatTerminalContextLabel,
+ stripInlineTerminalContextPlaceholders,
type TerminalContextDraft,
type TerminalContextSelection,
} from "../lib/terminalContext";
@@ -292,7 +294,9 @@ import {
collectUserMessageBlobPreviewUrls,
createLocalDispatchSnapshot,
deriveComposerSendState,
+ deriveRevertedMessageContent,
dismissBranchMismatchForSession,
+ fetchRevertedMessageAttachmentBlob,
hasEnvironmentReconnectWarningGraceElapsed,
scheduleEnvironmentReconnectWarning,
hasServerAcknowledgedLocalDispatch,
@@ -463,6 +467,66 @@ type EnvironmentUnavailableState = {
readonly connection: EnvironmentConnectionPresentation;
};
+interface PendingRevertedMessageRestore {
+ threadRef: ScopedThreadRef;
+ messageId: MessageId;
+ turnCount: number;
+ knownFailureActivityIds: ReadonlySet;
+ draft: RevertedMessageDraft;
+}
+
+interface RevertedMessageDraft {
+ prompt: string;
+ images: ComposerImageAttachment[];
+ unavailableImageNames: string[];
+}
+
+async function prepareRevertedMessageDraft(message: ChatMessage): Promise {
+ const content = deriveRevertedMessageContent(message.text);
+ const prepared = await Promise.all(
+ (message.attachments ?? [])
+ .filter((attachment) => !content.previewAnnotationImageNames.has(attachment.name))
+ .map(async (attachment) => {
+ if (!attachment.previewUrl) {
+ return { image: null, unavailableImageName: attachment.name };
+ }
+ try {
+ const blob = await fetchRevertedMessageAttachmentBlob(attachment.previewUrl);
+ const file = new File([blob], attachment.name, {
+ type: blob.type || attachment.mimeType,
+ });
+ return {
+ image: {
+ type: "image" as const,
+ id: attachment.id,
+ name: attachment.name,
+ mimeType: file.type || attachment.mimeType,
+ sizeBytes: file.size,
+ previewUrl: URL.createObjectURL(file),
+ file,
+ },
+ unavailableImageName: null,
+ };
+ } catch {
+ return { image: null, unavailableImageName: attachment.name };
+ }
+ }),
+ );
+ return {
+ prompt: content.prompt,
+ images: prepared.flatMap((entry) => (entry.image ? [entry.image] : [])),
+ unavailableImageNames: prepared.flatMap((entry) =>
+ entry.unavailableImageName ? [entry.unavailableImageName] : [],
+ ),
+ };
+}
+
+function releaseRevertedMessageDraft(draft: RevertedMessageDraft): void {
+ for (const image of draft.images) {
+ if (image.previewUrl.startsWith("blob:")) URL.revokeObjectURL(image.previewUrl);
+ }
+}
+
function eventPathContainsSelector(event: Event, selector: string): boolean {
const path = event.composedPath();
if (path.length === 0 && event.target) {
@@ -1194,6 +1258,8 @@ function ChatViewContent(props: ChatViewProps) {
[environmentId, threadId],
);
const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
+ const routeThreadKeyRef = useRef(routeThreadKey);
+ routeThreadKeyRef.current = routeThreadKey;
const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
reportFailure: false,
@@ -1337,7 +1403,14 @@ function ChatViewContent(props: ChatViewProps) {
Record
>({});
const [isConnecting, _setIsConnecting] = useState(false);
- const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
+ const [revertingCheckpointThreadKeys, setRevertingCheckpointThreadKeys] = useState<
+ ReadonlySet
+ >(() => new Set());
+ const [pendingRevertedMessageRestore, setPendingRevertedMessageRestore] =
+ useState(null);
+ const pendingRevertedMessageRestoreRef = useRef(pendingRevertedMessageRestore);
+ pendingRevertedMessageRestoreRef.current = pendingRevertedMessageRestore;
+ const chatViewMountedRef = useRef(true);
const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState(
null,
);
@@ -2279,7 +2352,13 @@ function ChatViewContent(props: ChatViewProps) {
activePendingUserInput: activePendingUserInput?.requestId ?? null,
threadError,
});
- const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
+ const hasRevertingCheckpoint = revertingCheckpointThreadKeys.size > 0;
+ const isRevertingCheckpoint = revertingCheckpointThreadKeys.has(routeThreadKey);
+ const isRestoringRevertedMessage =
+ pendingRevertedMessageRestore !== null &&
+ scopedThreadKey(pendingRevertedMessageRestore.threadRef) === routeThreadKey;
+ const isRevertComposerBusy = isRevertingCheckpoint || isRestoringRevertedMessage;
+ const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertComposerBusy;
const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,
activeThread?.session ?? null,
@@ -2319,13 +2398,26 @@ function ChatViewContent(props: ChatViewProps) {
setAttachmentPreviewHandoffByMessageId({});
}, []);
useEffect(() => {
+ chatViewMountedRef.current = true;
return () => {
+ chatViewMountedRef.current = false;
+ const pendingRevert = pendingRevertedMessageRestoreRef.current;
+ if (pendingRevert) releaseRevertedMessageDraft(pendingRevert.draft);
clearAttachmentPreviewHandoffs();
for (const message of optimisticUserMessagesRef.current) {
revokeUserMessagePreviewUrls(message);
}
};
}, [clearAttachmentPreviewHandoffs]);
+ useEffect(() => {
+ const pendingRevert = pendingRevertedMessageRestoreRef.current;
+ if (!pendingRevert || scopedThreadKey(pendingRevert.threadRef) === routeThreadKey) {
+ return;
+ }
+ releaseRevertedMessageDraft(pendingRevert.draft);
+ pendingRevertedMessageRestoreRef.current = null;
+ setPendingRevertedMessageRestore(null);
+ }, [routeThreadKey]);
const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
if (previewUrls.length === 0) return;
@@ -3984,9 +4076,194 @@ function ChatViewContent(props: ChatViewProps) {
// activeThreadRef resets transitively with the active thread.
}, [activeThread?.id]);
+ const revertObservedThreadId = activeThread?.id ?? null;
+ const revertObservedActivities = activeThread?.activities ?? null;
+ const revertObservedMessages = activeThread?.messages ?? null;
useEffect(() => {
- setIsRevertingCheckpoint(false);
- }, [activeThread?.id]);
+ const pending = pendingRevertedMessageRestore;
+ if (
+ !pending ||
+ threadDetailLoading ||
+ revertObservedThreadId !== pending.threadRef.threadId ||
+ !revertObservedActivities ||
+ !revertObservedMessages
+ ) {
+ return;
+ }
+
+ const failed = revertObservedActivities.some((activity) => {
+ if (
+ activity.kind !== "checkpoint.revert.failed" ||
+ pending.knownFailureActivityIds.has(activity.id) ||
+ typeof activity.payload !== "object" ||
+ activity.payload === null
+ ) {
+ return false;
+ }
+ return (activity.payload as { turnCount?: unknown }).turnCount === pending.turnCount;
+ });
+ if (failed) {
+ releaseRevertedMessageDraft(pending.draft);
+ pendingRevertedMessageRestoreRef.current = null;
+ setPendingRevertedMessageRestore(null);
+ return;
+ }
+
+ if (revertObservedMessages.some((message) => message.id === pending.messageId)) {
+ return;
+ }
+
+ pendingRevertedMessageRestoreRef.current = null;
+ setPendingRevertedMessageRestore(null);
+
+ if (
+ pending.draft.prompt.length === 0 &&
+ pending.draft.images.length === 0 &&
+ pending.draft.unavailableImageNames.length === 0
+ ) {
+ releaseRevertedMessageDraft(pending.draft);
+ toastManager.add({
+ type: "warning",
+ title: "Message context was not restored",
+ description:
+ "The reverted message only contained attached context, which cannot be safely reused after a rewind.",
+ data: { hideCopyButton: true },
+ });
+ return;
+ }
+
+ const writeRevertedMessageToComposer = () => {
+ const currentDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget);
+ const existingPrompt = stripInlineTerminalContextPlaceholders(
+ currentDraft?.prompt ?? "",
+ ).trimEnd();
+ const nextPrompt =
+ existingPrompt.length > 0 && pending.draft.prompt.length > 0
+ ? `${existingPrompt}\n\n${pending.draft.prompt}`
+ : existingPrompt || pending.draft.prompt;
+ setComposerDraftPrompt(composerDraftTarget, nextPrompt);
+ const existingImages = currentDraft?.images ?? [];
+ const existingIds = new Set(existingImages.map((image) => image.id));
+ const existingDedupKeys = new Set(
+ existingImages.map((image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`),
+ );
+ const imagesToAdd: ComposerImageAttachment[] = [];
+ const skippedImages: ComposerImageAttachment[] = [];
+ const overflowImageNames: string[] = [];
+ const capacity = Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - existingImages.length);
+ for (const image of pending.draft.images) {
+ const dedupKey = `${image.mimeType}\0${image.sizeBytes}\0${image.name}`;
+ if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) {
+ skippedImages.push(image);
+ continue;
+ }
+ existingIds.add(image.id);
+ existingDedupKeys.add(dedupKey);
+ if (imagesToAdd.length < capacity) {
+ imagesToAdd.push(image);
+ } else {
+ skippedImages.push(image);
+ overflowImageNames.push(image.name);
+ }
+ }
+ if (imagesToAdd.length > 0) {
+ addComposerDraftImages(composerDraftTarget, imagesToAdd);
+ }
+ for (const image of skippedImages) {
+ if (image.previewUrl.startsWith("blob:")) URL.revokeObjectURL(image.previewUrl);
+ }
+ composerRef.current?.resetCursorState({ cursor: nextPrompt.length, prompt: nextPrompt });
+ window.requestAnimationFrame(() => composerRef.current?.focusAtEnd());
+ const missingImageReasons: string[] = [];
+ if (pending.draft.unavailableImageNames.length > 0) {
+ missingImageReasons.push(
+ `${pending.draft.unavailableImageNames.join(", ")} could not be loaded.`,
+ );
+ }
+ if (overflowImageNames.length > 0) {
+ missingImageReasons.push(
+ `${overflowImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit.`,
+ );
+ }
+ if (missingImageReasons.length > 0) {
+ toastManager.add({
+ type: "warning",
+ title: "Some images could not be restored",
+ description: missingImageReasons.join(" "),
+ });
+ }
+ };
+
+ const currentDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget);
+ const hasDraftToStash = Boolean(
+ currentDraft &&
+ (stripInlineTerminalContextPlaceholders(currentDraft.prompt).trim().length > 0 ||
+ currentDraft.images.length > 0),
+ );
+ const hasDraftContextToClear = Boolean(
+ currentDraft &&
+ (currentDraft.terminalContexts.length > 0 ||
+ currentDraft.elementContexts.length > 0 ||
+ currentDraft.previewAnnotations.length > 0 ||
+ currentDraft.reviewComments.length > 0),
+ );
+ const clearDraftContexts = () => {
+ setComposerDraftTerminalContexts(composerDraftTarget, []);
+ setComposerDraftElementContexts(composerDraftTarget, []);
+ setComposerDraftPreviewAnnotations(composerDraftTarget, []);
+ setComposerDraftReviewComments(composerDraftTarget, []);
+ };
+ if (hasDraftToStash && composerRef.current?.stashCurrentPrompt() !== true) {
+ if (hasDraftContextToClear) clearDraftContexts();
+ writeRevertedMessageToComposer();
+ toastManager.add({
+ type: "warning",
+ title: "Reverted message appended",
+ description: hasDraftContextToClear
+ ? "Your current draft could not be stashed, so the reverted message was appended and attached context was cleared."
+ : "Your current draft could not be stashed, so the reverted message was added after it instead.",
+ data: { hideCopyButton: true },
+ });
+ return;
+ }
+
+ // Context attachments are session-bound and cannot be round-tripped by
+ // the stash. Clear them before replacing the composer so they cannot be
+ // sent accidentally with the reverted prompt.
+ clearComposerDraftContent(composerDraftTarget);
+ writeRevertedMessageToComposer();
+ if (hasDraftContextToClear) {
+ toastManager.add({
+ type: "warning",
+ title: hasDraftToStash ? "Draft stashed without attached context" : "Draft context cleared",
+ description: hasDraftToStash
+ ? "Your draft text and images were stashed. Its attached context cannot be reused after a rewind."
+ : "Attached context cannot be safely reused after a rewind.",
+ data: { hideCopyButton: true },
+ });
+ } else if (hasDraftToStash) {
+ toastManager.add({
+ type: "info",
+ title: "Current draft stashed",
+ description: "The reverted message is ready to edit in the composer.",
+ data: { hideCopyButton: true },
+ });
+ }
+ }, [
+ addComposerDraftImages,
+ clearComposerDraftContent,
+ composerDraftTarget,
+ pendingRevertedMessageRestore,
+ revertObservedActivities,
+ revertObservedMessages,
+ revertObservedThreadId,
+ setComposerDraftElementContexts,
+ setComposerDraftPreviewAnnotations,
+ setComposerDraftPrompt,
+ setComposerDraftReviewComments,
+ setComposerDraftTerminalContexts,
+ threadDetailLoading,
+ ]);
useEffect(() => {
if (!activeThread?.id || terminalUiState.terminalOpen) return;
@@ -4794,9 +5071,14 @@ function ChatViewContent(props: ChatViewProps) {
]);
const onRevertToTurnCount = useCallback(
- async (turnCount: number) => {
+ async (turnCount: number, message: ChatMessage) => {
const localApi = readLocalApi();
- if (!localApi || !activeThread || isRevertingCheckpoint) return;
+ if (!localApi || !activeThread || hasRevertingCheckpoint || pendingRevertedMessageRestore) {
+ return;
+ }
+ const threadId = activeThread.id;
+ const sourceThreadRef = scopeThreadRef(environmentId, threadId);
+ const sourceThreadKey = scopedThreadKey(sourceThreadRef);
if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
setThreadError(
@@ -4809,6 +5091,10 @@ function ChatViewContent(props: ChatViewProps) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
+ // Start copying image blobs before opening the confirmation dialog. The
+ // optimistic preview handoff can be promoted to its server URL while the
+ // dialog is open, which revokes the blob URL captured by this callback.
+ const draftPromise = prepareRevertedMessageDraft(message);
const confirmed = await localApi.dialogs.confirm(
[
`Revert this thread to checkpoint ${turnCount}?`,
@@ -4818,26 +5104,68 @@ function ChatViewContent(props: ChatViewProps) {
{ variant: "destructive" },
);
if (!confirmed) {
+ releaseRevertedMessageDraft(await draftPromise);
return;
}
- setIsRevertingCheckpoint(true);
- setThreadError(activeThread.id, null);
+ setRevertingCheckpointThreadKeys((current) => {
+ const next = new Set(current);
+ next.add(sourceThreadKey);
+ return next;
+ });
+ setThreadError(threadId, null);
+ const draft = await draftPromise;
const result = await revertThreadCheckpoint({
environmentId,
input: {
- threadId: activeThread.id,
+ threadId,
turnCount,
},
});
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- setThreadError(
- activeThread.id,
- error instanceof Error ? error.message : "Failed to revert thread state.",
- );
+ const finishRevert = () => {
+ setRevertingCheckpointThreadKeys((current) => {
+ const next = new Set(current);
+ next.delete(sourceThreadKey);
+ return next;
+ });
+ };
+ if (result._tag === "Failure") {
+ releaseRevertedMessageDraft(draft);
+ if (chatViewMountedRef.current) {
+ finishRevert();
+ if (!isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ setThreadError(
+ threadId,
+ error instanceof Error ? error.message : "Failed to revert thread state.",
+ );
+ }
+ }
+ return;
}
- setIsRevertingCheckpoint(false);
+ if (!chatViewMountedRef.current) {
+ releaseRevertedMessageDraft(draft);
+ return;
+ }
+ if (routeThreadKeyRef.current !== sourceThreadKey) {
+ releaseRevertedMessageDraft(draft);
+ finishRevert();
+ return;
+ }
+ const pendingRestore: PendingRevertedMessageRestore = {
+ threadRef: sourceThreadRef,
+ messageId: message.id,
+ turnCount,
+ knownFailureActivityIds: new Set(
+ activeThread.activities
+ .filter((activity) => activity.kind === "checkpoint.revert.failed")
+ .map((activity) => activity.id),
+ ),
+ draft,
+ };
+ pendingRevertedMessageRestoreRef.current = pendingRestore;
+ setPendingRevertedMessageRestore(pendingRestore);
+ finishRevert();
},
[
activeThread,
@@ -4845,8 +5173,9 @@ function ChatViewContent(props: ChatViewProps) {
activeEnvironmentUnavailableLabel,
environmentId,
isConnecting,
- isRevertingCheckpoint,
+ hasRevertingCheckpoint,
isSendBusy,
+ pendingRevertedMessageRestore,
phase,
revertThreadCheckpoint,
setThreadError,
@@ -4875,6 +5204,7 @@ function ChatViewContent(props: ChatViewProps) {
!activeThread ||
isSendBusy ||
isConnecting ||
+ isRevertComposerBusy ||
threadDetailLoading ||
sendInFlightRef.current
) {
@@ -5961,14 +6291,17 @@ function ChatViewContent(props: ChatViewProps) {
// the callback reference is fully stable and never busts context identity.
const revertTurnCountRef = useRef(revertTurnCountByUserMessageId);
revertTurnCountRef.current = revertTurnCountByUserMessageId;
+ const timelineMessagesRef = useRef(timelineMessages);
+ timelineMessagesRef.current = timelineMessages;
const onRevertToTurnCountRef = useRef(onRevertToTurnCount);
onRevertToTurnCountRef.current = onRevertToTurnCount;
const onRevertUserMessage = useCallback((messageId: MessageId) => {
const targetTurnCount = revertTurnCountRef.current.get(messageId);
- if (typeof targetTurnCount !== "number") {
+ const message = timelineMessagesRef.current.find((entry) => entry.id === messageId);
+ if (typeof targetTurnCount !== "number" || !message || message.role !== "user") {
return;
}
- void onRevertToTurnCountRef.current(targetTurnCount);
+ void onRevertToTurnCountRef.current(targetTurnCount, message);
}, []);
// Empty state: no active thread
@@ -6213,7 +6546,7 @@ function ChatViewContent(props: ChatViewProps) {
onOpenTurnDiff={onOpenTurnDiff}
revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
onRevertUserMessage={onRevertUserMessage}
- isRevertingCheckpoint={isRevertingCheckpoint}
+ isRevertingCheckpoint={hasRevertingCheckpoint}
onImageExpand={onExpandTimelineImage}
markdownCwd={gitCwd ?? undefined}
resolvedTheme={resolvedTheme}
@@ -6323,7 +6656,7 @@ function ChatViewContent(props: ChatViewProps) {
projectSelectionRequired={isLocalDraftThread && activeProject === null}
phase={phase}
isConnecting={isConnecting}
- isSendBusy={isSendBusy}
+ isSendBusy={isSendBusy || isRevertComposerBusy}
sendDisabledReason={threadDetailLoading ? "Messages loading" : null}
isPreparingWorktree={isPreparingWorktree}
environmentUnavailable={activeEnvironmentUnavailableState}
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index e918e7758688..b6d95ba00eca 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -460,6 +460,8 @@ export interface ChatComposerHandle {
expandedCursor: number;
terminalContextIds: string[];
};
+ /** Stash the current text and images, returning whether the initial write succeeded. */
+ stashCurrentPrompt: () => boolean;
/** Reset composer cursor/trigger/highlight after external prompt mutations (e.g. onSend). */
resetCursorState: (options?: {
cursor?: number;
@@ -980,6 +982,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
* thread) can still be stashed while an earlier encode is running.
*/
const stashInFlightRef = useRef>(new Set());
+ const stashInitialWriteSucceededRef = useRef(false);
/**
* Count of pasted images still being compressed, per thread. Reserved
* against the attachment limit so concurrent pastes can't overshoot it,
@@ -2065,6 +2068,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
);
const stashCurrentPrompt = useCallback(async () => {
+ stashInitialWriteSucceededRef.current = false;
// Terminal-context placeholders reference live sessions the stash can't
// round-trip, so they are stripped from the stashed prompt.
const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim();
@@ -2137,6 +2141,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
setComposerCursor(0);
setComposerTrigger(null);
pulseStashBadge();
+ stashInitialWriteSucceededRef.current = true;
if (evicted) {
toastManager.add({
@@ -2548,6 +2553,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
readSnapshot: () => {
return readComposerSnapshot();
},
+ stashCurrentPrompt: () => {
+ void stashCurrentPrompt();
+ return stashInitialWriteSucceededRef.current;
+ },
resetCursorState: (options?: {
cursor?: number;
prompt?: string;
@@ -2643,6 +2652,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
selectedPromptEffort,
selectedProvider,
selectedProviderModels,
+ stashCurrentPrompt,
],
);
diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts
index 8b2479c7a349..d5cc604d4e55 100644
--- a/packages/client-runtime/src/state/threadReducer.test.ts
+++ b/packages/client-runtime/src/state/threadReducer.test.ts
@@ -827,6 +827,15 @@ describe("applyThreadDetailEvent", () => {
},
{
id: MessageId.make("msg-3"),
+ role: "user",
+ text: "Second",
+ turnId: null,
+ streaming: false,
+ createdAt: "2026-04-01T02:30:00.000Z",
+ updatedAt: "2026-04-01T02:30:00.000Z",
+ },
+ {
+ id: MessageId.make("msg-4"),
role: "assistant",
text: "Response 2",
turnId: TurnId.make("turn-2"),
@@ -851,7 +860,7 @@ describe("applyThreadDetailEvent", () => {
checkpointRef: CheckpointRef.make("ref-2"),
status: "ready",
files: [],
- assistantMessageId: MessageId.make("msg-3"),
+ assistantMessageId: MessageId.make("msg-4"),
completedAt: "2026-04-01T03:00:00.000Z",
},
],
@@ -875,11 +884,147 @@ describe("applyThreadDetailEvent", () => {
// turn-2 checkpoint is filtered out (turnCount 2 > revert target 1)
expect(result.thread.checkpoints).toHaveLength(1);
expect(result.thread.checkpoints[0]?.turnId).toBe("turn-1");
- // msg-3 (turn-2) is filtered, msg-1 (no turn) and msg-2 (turn-1) remain
+ // The second turnless user prompt and its turn-2 response are filtered.
expect(result.thread.messages).toHaveLength(2);
+ expect(result.thread.messages.map((message) => message.id)).toEqual(["msg-1", "msg-2"]);
expect(result.thread.latestTurn?.turnId).toBe("turn-1");
}
});
+
+ it("uses retained turns from the loaded pagination window", () => {
+ const loadedTurns = [90, 91, 92].map((turnCount) => ({
+ turnCount,
+ turnId: TurnId.make(`turn-${turnCount}`),
+ }));
+ const messages: OrchestrationThread["messages"] = loadedTurns.flatMap(
+ ({ turnCount }, index) => [
+ {
+ id: MessageId.make(`user-${turnCount}`),
+ role: "user" as const,
+ text: `Prompt ${turnCount}`,
+ turnId: null,
+ streaming: false,
+ createdAt: `2026-04-01T00:00:0${index * 2}.000Z`,
+ updatedAt: `2026-04-01T00:00:0${index * 2}.000Z`,
+ },
+ {
+ id: MessageId.make(`assistant-${turnCount}`),
+ role: "assistant" as const,
+ text: `Response ${turnCount}`,
+ turnId: null,
+ streaming: false,
+ createdAt: `2026-04-01T00:00:0${index * 2 + 1}.000Z`,
+ updatedAt: `2026-04-01T00:00:0${index * 2 + 1}.000Z`,
+ },
+ ],
+ );
+ const threadWithWindow: OrchestrationThread = {
+ ...baseThread,
+ messages,
+ checkpoints: loadedTurns.map(({ turnCount, turnId }, index) => ({
+ turnId,
+ checkpointTurnCount: turnCount,
+ checkpointRef: CheckpointRef.make(`ref-${turnCount}`),
+ status: "ready",
+ files: [],
+ assistantMessageId: MessageId.make(`assistant-${turnCount}`),
+ completedAt: `2026-04-01T00:01:0${index}.000Z`,
+ })),
+ };
+
+ const result = applyThreadDetailEvent(threadWithWindow, {
+ ...baseEventFields,
+ sequence: 15,
+ occurredAt: "2026-04-01T04:00:00.000Z",
+ aggregateKind: "thread",
+ aggregateId: ThreadId.make("thread-1"),
+ type: "thread.reverted",
+ payload: {
+ threadId: ThreadId.make("thread-1"),
+ turnCount: 91,
+ },
+ });
+
+ expect(result.kind).toBe("updated");
+ if (result.kind === "updated") {
+ expect(result.thread.messages.map((message) => message.id)).toEqual([
+ "user-90",
+ "assistant-90",
+ "user-91",
+ "assistant-91",
+ ]);
+ }
+ });
+
+ it("retains every message before the latest retained checkpoint", () => {
+ const message = (
+ id: string,
+ role: "user" | "assistant",
+ createdAt: string,
+ ): OrchestrationThread["messages"][number] => ({
+ id: MessageId.make(id),
+ role,
+ text: id,
+ turnId: null,
+ streaming: false,
+ createdAt,
+ updatedAt: createdAt,
+ });
+ const threadWithSteering: OrchestrationThread = {
+ ...baseThread,
+ messages: [
+ message("user-1", "user", "2026-04-01T01:00:00.000Z"),
+ message("assistant-commentary-1", "assistant", "2026-04-01T01:01:00.000Z"),
+ message("user-steering-1", "user", "2026-04-01T01:02:00.000Z"),
+ message("assistant-final-1", "assistant", "2026-04-01T01:03:00.000Z"),
+ message("user-2", "user", "2026-04-01T02:00:00.000Z"),
+ message("assistant-final-2", "assistant", "2026-04-01T02:01:00.000Z"),
+ ],
+ checkpoints: [
+ {
+ turnId: TurnId.make("turn-1"),
+ checkpointTurnCount: 1,
+ checkpointRef: CheckpointRef.make("ref-1"),
+ status: "ready",
+ files: [],
+ assistantMessageId: null,
+ completedAt: "2026-04-01T01:04:00.000Z",
+ },
+ {
+ turnId: TurnId.make("turn-2"),
+ checkpointTurnCount: 2,
+ checkpointRef: CheckpointRef.make("ref-2"),
+ status: "ready",
+ files: [],
+ assistantMessageId: null,
+ completedAt: "2026-04-01T02:02:00.000Z",
+ },
+ ],
+ };
+
+ const result = applyThreadDetailEvent(threadWithSteering, {
+ ...baseEventFields,
+ sequence: 16,
+ occurredAt: "2026-04-01T03:00:00.000Z",
+ aggregateKind: "thread",
+ aggregateId: ThreadId.make("thread-1"),
+ type: "thread.reverted",
+ payload: {
+ threadId: ThreadId.make("thread-1"),
+ turnCount: 1,
+ },
+ });
+
+ expect(result.kind).toBe("updated");
+ if (result.kind === "updated") {
+ expect(result.thread.messages.map((entry) => entry.id)).toEqual([
+ "user-1",
+ "assistant-commentary-1",
+ "user-steering-1",
+ "assistant-final-1",
+ ]);
+ }
+ });
});
describe("no-op events", () => {
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index 970fd94b1a16..7b93ba72b3f1 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -522,7 +522,12 @@ export function applyThreadDetailEvent(
);
const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId));
- const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds);
+ const latestCheckpoint = checkpoints.at(-1) ?? null;
+ const messages = retainMessagesAfterRevert(
+ thread.messages,
+ retainedTurnIds,
+ latestCheckpoint,
+ );
const proposedPlans = pipe(
thread.proposedPlans,
Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)),
@@ -531,8 +536,6 @@ export function applyThreadDetailEvent(
thread.activities,
Arr.filter((activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId)),
);
- const latestCheckpoint = checkpoints.at(-1) ?? null;
-
return {
kind: "updated",
thread: {
@@ -654,16 +657,18 @@ function rebindCheckpointAssistantMessage(
function retainMessagesAfterRevert(
messages: ReadonlyArray,
retainedTurnIds: ReadonlySet,
+ latestCheckpoint: OrchestrationCheckpointSummary | null,
): OrchestrationMessage[] {
- // Keep messages that belong to a retained turn, plus system messages and
- // messages without a turn binding (pre-turn-0 user messages).
- return Arr.filter(messages, (message) => {
- if (message.role === "system") {
- return true;
- }
- if (message.turnId === null) {
- return true;
- }
- return retainedTurnIds.has(message.turnId);
+ const checkpointMessageId = latestCheckpoint?.assistantMessageId ?? null;
+ const checkpointMessageIndex =
+ checkpointMessageId === null
+ ? -1
+ : messages.findIndex((message) => message.id === checkpointMessageId);
+
+ return messages.filter((message, index) => {
+ if (message.role === "system") return true;
+ if (message.turnId !== null) return retainedTurnIds.has(message.turnId);
+ if (checkpointMessageIndex >= 0) return index <= checkpointMessageIndex;
+ return latestCheckpoint !== null && message.createdAt <= latestCheckpoint.completedAt;
});
}