From 314bbfed91f188f6d6df7b87cb5f501f1d7e22e4 Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Sun, 12 Jul 2026 01:15:37 +0530 Subject: [PATCH 1/2] fix(tui): preserve initial user message on new session hydration Ported from upstream anomalyco/opencode#36176. --- packages/tui/src/component/prompt/index.tsx | 17 +++- packages/tui/src/context/sync.tsx | 95 ++++++++++++++++++- .../cli/cmd/tui/sync-live-hydration.test.tsx | 64 +++++++++++++ 3 files changed, 172 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 192964101d7b..d4253b28ba62 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1091,6 +1091,17 @@ export function Prompt(props: PromptProps) { }) } else { move.startSubmit() + sync.session.optimisticUserMessage({ + sessionID, + text: inputText, + parts: [...editorParts, ...nonTextParts], + agent: agent.name, + model: { + providerID: selectedModel.providerID, + modelID: selectedModel.modelID, + variant, + }, + }) sdk.client.session .prompt( { @@ -1111,6 +1122,7 @@ export function Prompt(props: PromptProps) { { throwOnError: true }, ) .catch((error) => { + sync.session.clearOptimisticUserMessage(sessionID) toast.show({ title: "Failed to send prompt", message: errorMessage(error), @@ -1131,15 +1143,14 @@ export function Prompt(props: PromptProps) { setStore("extmarkToPartIndex", new Map()) props.onSubmit?.() - // temporary hack to make sure the message is sent if (!props.sessionID) { if (editorParts.length > 0) editor.preserveSelectionFromNewSession() - setTimeout(() => { + queueMicrotask(() => { route.navigate({ type: "session", sessionID, }) - }, 50) + }) } input.clear() if (finishMoveProgress) move.finishSubmit() diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0511c5183e4..7744e4c55fc4 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -144,6 +144,32 @@ export const { const fullSyncedSessions = new Set() const syncingSessions = new Map>() const hydratingSessions = new Map; parts: Set }>() + const optimisticBySession = new Map() + + function clearOptimisticUserMessage(sessionID: string) { + const messageID = optimisticBySession.get(sessionID) + if (!messageID) return + optimisticBySession.delete(sessionID) + const messages = store.message[sessionID] + if (messages) { + const match = search(messages, messageID, (m) => m.id) + if (match.found) { + setStore( + "message", + sessionID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + } + } + setStore( + "part", + produce((draft) => { + delete draft[messageID] + }), + ) + } const touchMessage = (sessionID: string, messageID: string) => { hydratingSessions.get(sessionID)?.messages.add(messageID) } @@ -313,6 +339,12 @@ export const { } case "message.updated": { + if (event.properties.info.role === "user") { + const optimisticID = optimisticBySession.get(event.properties.info.sessionID) + if (optimisticID && optimisticID !== event.properties.info.id) { + clearOptimisticUserMessage(event.properties.info.sessionID) + } + } touchMessage(event.properties.info.sessionID, event.properties.info.id) const messages = store.message[event.properties.info.sessionID] if (!messages) { @@ -605,6 +637,8 @@ export const { if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] const currentMessages = draft.message[sessionID] ?? [] + const optimisticID = optimisticBySession.get(sessionID) + const optimisticIDs = optimisticID ? new Set([optimisticID]) : new Set() const infos = (messages.data ?? []).flatMap((message) => { if (!tracker.messages.has(message.info.id)) return [message.info] const current = currentMessages.find((item) => item.id === message.info.id) @@ -612,7 +646,9 @@ export const { }) infos.push( ...currentMessages.filter( - (message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id), + (message) => + (tracker.messages.has(message.id) || optimisticIDs.has(message.id)) && + !infos.some((item) => item.id === message.id), ), ) const removed = infos.slice(0, -100) @@ -658,6 +694,63 @@ export const { syncingSessions.set(sessionID, task) return task }, + optimisticUserMessage(input: { + sessionID: string + text: string + parts?: Array> + agent: string + model: { + providerID: string + modelID: string + variant?: string + } + }) { + clearOptimisticUserMessage(input.sessionID) + const messageID = `opt_${crypto.randomUUID()}` + optimisticBySession.set(input.sessionID, messageID) + const created = Date.now() + const message: Message = { + id: messageID, + sessionID: input.sessionID, + role: "user", + time: { created }, + agent: input.agent, + model: input.model, + } + const parts: Part[] = [ + ...(input.parts ?? []).map( + (part) => + ({ + ...part, + id: `opt_${crypto.randomUUID()}`, + sessionID: input.sessionID, + messageID, + }) as Part, + ), + { + id: `opt_${crypto.randomUUID()}`, + sessionID: input.sessionID, + messageID, + type: "text", + text: input.text, + }, + ] + const messages = store.message[input.sessionID] + if (!messages) { + setStore("message", input.sessionID, [message]) + } else { + setStore( + "message", + input.sessionID, + produce((draft) => { + draft.push(message) + }), + ) + } + setStore("part", messageID, parts) + return messageID + }, + clearOptimisticUserMessage, }, bootstrap, } diff --git a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx index 398c5c9ab524..bb8cea582447 100644 --- a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx @@ -260,3 +260,67 @@ test("a message removed during hydration does not regain stale parts", async () app.renderer.destroy() } }) + +test("optimistic user message survives new session hydration race", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const userText = "What is Rust?" + let resolveMessages!: (response: Response) => void + const messages = new Promise((resolve) => { + resolveMessages = resolve + }) + let requested = false + const { app, emit, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(session) + if (url.pathname === `/session/${sessionID}/message`) { + requested = true + return messages + } + if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) + return undefined + }, tmp.path) + + try { + sync.session.optimisticUserMessage({ + sessionID, + text: userText, + agent: "build", + model: { providerID: "test", modelID: "model" }, + }) + + const hydrate = sync.session.sync(sessionID) + await wait(() => requested) + emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } })) + emit( + global({ + id: "evt_part", + type: "message.part.updated", + properties: { + sessionID, + time: 2, + part: { id: partID, sessionID, messageID, type: "text", text: "Hi! How can I help you?" }, + }, + }), + ) + await wait(() => sync.data.part[messageID]?.[0]?.type === "text") + + resolveMessages( + json([ + { + info: assistant, + parts: [{ id: partID, sessionID, messageID, type: "text", text: "Hi! How can I help you?" }], + }, + ]), + ) + await hydrate + + const userMessages = (sync.data.message[sessionID] ?? []).filter((message) => message.role === "user") + expect(userMessages).toHaveLength(1) + const userParts = sync.data.part[userMessages[0]!.id] ?? [] + expect(userParts.some((part) => part.type === "text" && part.text === userText)).toBe(true) + expect(sync.data.part[messageID][0]).toMatchObject({ text: "Hi! How can I help you?" }) + } finally { + app.renderer.destroy() + } +}) From ca4b54698d7339a56ea8df670550115ee4e04c68 Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Sun, 12 Jul 2026 19:01:18 +0530 Subject: [PATCH 2/2] fix(tui): drop optimistic user message when real one is hydrated Addresses Opus review of PR #20: the hydration merge re-added the optimistic placeholder even when the server response already contained the real msg_ user message, rendering two user messages. Now detect the hydrated real user message, forget the optimistic id, and clean up its parts. Adds a regression test. --- packages/tui/src/context/sync.tsx | 17 ++++- .../cli/cmd/tui/sync-live-hydration.test.tsx | 63 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 7744e4c55fc4..0a2d1b8a1e34 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -638,7 +638,22 @@ export const { draft.todo[sessionID] = todo.data ?? [] const currentMessages = draft.message[sessionID] ?? [] const optimisticID = optimisticBySession.get(sessionID) - const optimisticIDs = optimisticID ? new Set([optimisticID]) : new Set() + // Once the server has persisted the real first user message, drop the + // optimistic placeholder — otherwise both the real and the optimistic + // user message render as duplicates. clearOptimisticUserMessage's message + // removal is handled by the `visible` replacement below; here we only need + // to forget the id and clean up its parts. + const optimisticSuperseded = + optimisticID !== undefined && + (messages.data ?? []).some( + (message) => message.info.role === "user" && message.info.id !== optimisticID, + ) + if (optimisticID !== undefined && optimisticSuperseded) { + optimisticBySession.delete(sessionID) + delete draft.part[optimisticID] + } + const optimisticIDs = + optimisticID && !optimisticSuperseded ? new Set([optimisticID]) : new Set() const infos = (messages.data ?? []).flatMap((message) => { if (!tracker.messages.has(message.info.id)) return [message.info] const current = currentMessages.find((item) => item.id === message.info.id) diff --git a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx index bb8cea582447..8a2e0803d053 100644 --- a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx @@ -324,3 +324,66 @@ test("optimistic user message survives new session hydration race", async () => app.renderer.destroy() } }) + +test("hydration returning the real user message drops the optimistic duplicate", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const userText = "What is Rust?" + const realUserID = "msg_user" + const realUserPartID = "prt_user" + const realUser = { + id: realUserID, + sessionID, + role: "user" as const, + agent: "build", + model: { providerID: "test", modelID: "model" }, + time: { created: 1 }, + } + let resolveMessages!: (response: Response) => void + const messages = new Promise((resolve) => { + resolveMessages = resolve + }) + let requested = false + const { app, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(session) + if (url.pathname === `/session/${sessionID}/message`) { + requested = true + return messages + } + if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) + return undefined + }, tmp.path) + + try { + sync.session.optimisticUserMessage({ + sessionID, + text: userText, + agent: "build", + model: { providerID: "test", modelID: "model" }, + }) + const optimisticUserID = sync.data.message[sessionID]!.find((message) => message.role === "user")!.id + expect(optimisticUserID.startsWith("opt_")).toBe(true) + + const hydrate = sync.session.sync(sessionID) + await wait(() => requested) + + // Server has now persisted the real user message; the hydration response + // carries both it and the assistant reply. + resolveMessages( + json([ + { info: realUser, parts: [{ id: realUserPartID, sessionID, messageID: realUserID, type: "text", text: userText }] }, + { info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "Hi!" }] }, + ]), + ) + await hydrate + + const userMessages = (sync.data.message[sessionID] ?? []).filter((message) => message.role === "user") + expect(userMessages).toHaveLength(1) + expect(userMessages[0]!.id).toBe(realUserID) + // The optimistic placeholder and its parts must be gone. + expect(sync.data.part[optimisticUserID]).toBeUndefined() + } finally { + app.renderer.destroy() + } +})