From 840431b49a18780a48a2ba8f2534bcba04da61ed Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 29 Jul 2026 01:50:22 +0300 Subject: [PATCH] fix: discard reverted optimistic messages after send Removes reverted branch messages from the optimistic shadow after a successful send Prevents deleted messages from being merged back in on the next tail refresh Covers the cleanup behavior with an updated test --- packages/ui/src/sync/session-actions.test.ts | 7 ++++++- packages/ui/src/sync/session-actions.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index ccb6d5c6..e0dbf858 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -626,7 +626,7 @@ describe("optimisticSend target directory", () => { expect(currentStore.getState().session_status["session-new"]).toBe(undefined) }) - test("commits the new branch locally when sending after a revert", async () => { + test("commits the new branch locally and discards its optimistic shadow when sending after a revert", async () => { const retainedMessage = { id: "msg_1", role: "user", sessionID: "session-reverted" } as Message const revertedMessage = { id: "msg_2", role: "user", sessionID: "session-reverted" } as Message const targetStore = createStore({}, { @@ -636,18 +636,21 @@ describe("optimisticSend target directory", () => { }) const childStores = createChildStores([["/target/project", targetStore]]) let optimisticMessage: Message | null = null + const optimisticShadow = new Set([revertedMessage.id]) const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions") setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project") setOptimisticRefs( (input) => { optimisticMessage = input.message + optimisticShadow.add(input.message.id) targetStore.setState((state) => ({ message: { ...state.message, [input.sessionID]: [...(state.message[input.sessionID] ?? []), input.message] }, part: { ...state.part, [input.message.id]: input.parts }, })) }, () => {}, + (input) => optimisticShadow.delete(input.messageID), ) await optimisticSend({ @@ -665,6 +668,8 @@ describe("optimisticSend target directory", () => { (optimisticMessage as unknown as Message).id, ]) expect(targetStore.getState().part.msg_2).toBe(undefined) + expect(optimisticShadow.has(revertedMessage.id)).toBe(false) + expect(optimisticShadow.has((optimisticMessage as unknown as Message).id)).toBe(true) }) test("restores the reverted branch when sending fails", async () => { diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 94324037..8ea4c39d 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -937,6 +937,17 @@ export async function optimisticSend(input: { const part = { ...stateBeforeSend.part } for (const revertedMessage of revertedMessages) delete part[revertedMessage.id] store.setState({ session, message, part }) + + // A server-backed user message can still remain in the loader's optimistic + // shadow until a page fetch confirms it. Forget the reverted branch there + // too, or the next tail refresh will merge those deleted messages back in. + for (const revertedMessage of revertedMessages) { + _optimisticConfirm?.({ + sessionID: input.sessionId, + directory: targetDirectory, + messageID: revertedMessage.id, + }) + } } const messageID = ascendingId("msg")