From 2b75e804f1310354bf6bbfccded3d2441315ede7 Mon Sep 17 00:00:00 2001 From: Shoaib Ansari Date: Sat, 2 May 2026 11:04:15 +0530 Subject: [PATCH] fix: retain first turn messages and restore last user message to composer on revert When clicking 'Revert to this point' on a single-turn conversation, the revert turn count was computed as checkpointTurnCount - 1 = 0, causing the checkpoint filter (checkpointTurnCount <= 0) to match nothing. This resulted in all messages being removed (blank screen) and the user's input text being lost. Fix checkpoint filtering to use Math.max(1, turnCount) so the first checkpoint is always retained, and restore the last retained user message text into the composer input after revert completes. --- apps/server/src/orchestration/projector.ts | 5 +- apps/web/src/components/ChatView.tsx | 14 ++++ apps/web/src/store.test.ts | 80 ++++++++++++++++++++++ apps/web/src/store.ts | 5 +- 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index deb8a6d44d..2a666420cb 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -575,15 +575,16 @@ export function projectEvent( return nextBase; } + const effectiveTurnCount = Math.max(1, payload.turnCount); const checkpoints = thread.checkpoints - .filter((entry) => entry.checkpointTurnCount <= payload.turnCount) + .filter((entry) => entry.checkpointTurnCount <= effectiveTurnCount) .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount) .slice(-MAX_THREAD_CHECKPOINTS); const retainedTurnIds = new Set(checkpoints.map((checkpoint) => checkpoint.turnId)); const messages = retainThreadMessagesAfterRevert( thread.messages, retainedTurnIds, - payload.turnCount, + effectiveTurnCount, ).slice(-MAX_THREAD_MESSAGES); const proposedPlans = retainThreadProposedPlansAfterRevert( thread.proposedPlans, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 40cd1b4210..51eb08b241 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -74,6 +74,7 @@ import { } from "../pendingUserInput"; import { selectProjectsAcrossEnvironments, + selectThreadByRef, selectThreadsAcrossEnvironments, useStore, } from "../store"; @@ -2379,6 +2380,17 @@ export default function ChatView(props: ChatViewProps) { turnCount, createdAt: new Date().toISOString(), }); + + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + const updatedThread = selectThreadByRef(useStore.getState(), threadRef); + if (updatedThread && promptRef.current.trim().length === 0) { + const lastUserMessage = [...updatedThread.messages] + .reverse() + .find((m) => m.role === "user"); + if (lastUserMessage) { + setComposerDraftPrompt(composerDraftTarget, lastUserMessage.text); + } + } } catch (err) { setThreadError( activeThread.id, @@ -2389,11 +2401,13 @@ export default function ChatView(props: ChatViewProps) { }, [ activeThread, + composerDraftTarget, environmentId, isConnecting, isRevertingCheckpoint, isSendBusy, phase, + setComposerDraftPrompt, setThreadError, ], ); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 99ddf4ca09..bc853c4ae4 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1015,4 +1015,84 @@ describe("incremental orchestration updates", () => { }); expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); }); + + it("retains first turn messages when reverting single-turn conversation (turnCount=0)", () => { + const state = makeState( + makeThread({ + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "hello", + turnId: TurnId.make("turn-1"), + createdAt: "2026-02-27T00:00:00.000Z", + completedAt: "2026-02-27T00:00:00.000Z", + streaming: false, + }, + { + id: MessageId.make("assistant-1"), + role: "assistant", + text: "hi there", + turnId: TurnId.make("turn-1"), + createdAt: "2026-02-27T00:00:01.000Z", + completedAt: "2026-02-27T00:00:01.000Z", + streaming: false, + }, + ], + proposedPlans: [ + { + id: "plan-1", + turnId: TurnId.make("turn-1"), + planMarkdown: "plan 1", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + }, + ], + activities: [ + { + id: EventId.make("activity-1"), + tone: "info", + kind: "step", + summary: "step one", + payload: {}, + turnId: TurnId.make("turn-1"), + createdAt: "2026-02-27T00:00:00.000Z", + }, + ], + turnDiffSummaries: [ + { + turnId: TurnId.make("turn-1"), + completedAt: "2026-02-27T00:00:01.000Z", + status: "ready", + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + files: [], + }, + ], + }), + ); + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.reverted", { + threadId: ThreadId.make("thread-1"), + turnCount: 0, + }), + localEnvironmentId, + ); + + expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ + "user-1", + "assistant-1", + ]); + expect(threadsOf(next)[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); + expect(threadsOf(next)[0]?.activities.map((activity) => activity.id)).toEqual([ + EventId.make("activity-1"), + ]); + expect(threadsOf(next)[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ + TurnId.make("turn-1"), + ]); + }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index e3012a8c8b..bcf61ad6f5 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1562,11 +1562,12 @@ function applyEnvironmentOrchestrationEvent( case "thread.reverted": return updateThreadState(state, event.payload.threadId, (thread) => { + const effectiveTurnCount = Math.max(1, event.payload.turnCount); const turnDiffSummaries = thread.turnDiffSummaries .filter( (entry) => entry.checkpointTurnCount !== undefined && - entry.checkpointTurnCount <= event.payload.turnCount, + entry.checkpointTurnCount <= effectiveTurnCount, ) .toSorted( (left, right) => @@ -1578,7 +1579,7 @@ function applyEnvironmentOrchestrationEvent( const messages = retainThreadMessagesAfterRevert( thread.messages, retainedTurnIds, - event.payload.turnCount, + effectiveTurnCount, ).slice(-MAX_THREAD_MESSAGES); const proposedPlans = retainThreadProposedPlansAfterRevert( thread.proposedPlans,