From 654ec2b81c7253eed7f1b7210ce22fca35dec2a5 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Tue, 8 Sep 2026 12:10:02 -0600 Subject: [PATCH] fix(remote): reconcile live transcripts after persistence --- src/main/ipc/index.ts | 6 +++++- src/main/services/remote.ts | 18 ++++++++++++++++++ test/shared.ts | 16 ++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 4ecc47c..5218210 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -361,7 +361,11 @@ export function registerIpc(): void { // ---- messages ---- ipcMain.handle(CHANNELS.messagesList, (_e, chatId: string) => repo.listMessages(chatId)) - ipcMain.handle(CHANNELS.messagesAdd, (_e, input: AddMessageInput) => repo.addMessage(input)) + ipcMain.handle(CHANNELS.messagesAdd, (_e, input: AddMessageInput) => { + const message = repo.addMessage(input) + remote.notifyTranscriptChanged(input.chatId) + return message + }) // ---- integrations ---- ipcMain.handle(CHANNELS.integrationsList, () => repo.listIntegrations()) diff --git a/src/main/services/remote.ts b/src/main/services/remote.ts index d73b9ac..31e267f 100644 --- a/src/main/services/remote.ts +++ b/src/main/services/remote.ts @@ -542,6 +542,9 @@ async function runTurn( // prompts aren't permanently rejected; only clear if we still own it. if (active.turns.get(sessionId) === controller) active.turns.delete(sessionId) if (active.liveTurns.get(sessionId) === acc) active.liveTurns.delete(sessionId) + // Deltas make the turn feel live; this snapshot makes it reliable. It also + // covers aborts and failures that complete without emitting a text event. + sendSnapshot(sessionId) sendFrameFor(active, { t: 'turn', sessionId, state: 'idle' }) // Drop the desktop's live bubble; the persisted reply (bumped above) is // reconciled from disk by the renderer's mirror, so this hands off cleanly. @@ -582,6 +585,21 @@ export function notifyQueueChanged(): void { if (share) sendQueue(share.currentSessionId) } +/** + * Reconcile a desktop-persisted message with the phone's transcript. + * + * Live deltas remain the fast path, but they are not an authoritative record: + * a provider can fail before emitting one, a local command never enters the LLM + * stream, and a guest can connect between two events. The renderer persists both + * sides of a desktop turn through `messages:add`, so publishing a snapshot from + * that boundary guarantees the phone eventually shows the same transcript. + */ +export function notifyTranscriptChanged(sessionId: string): void { + const active = share + if (!active || active.currentSessionId !== sessionId) return + sendSnapshot(sessionId) +} + // --- Relaying a *desktop-driven* turn to the phone ------------------------- /** Opaque handle threading one local turn's relay state through the IPC layer. */ diff --git a/test/shared.ts b/test/shared.ts index c9f5c2a..f045f5b 100644 --- a/test/shared.ts +++ b/test/shared.ts @@ -2725,6 +2725,22 @@ console.log('\nremote workspace ipc parity\n') preload.includes('removeListener(CHANNELS.remoteDelta') ) check('main emits remote:delta', service.includes('CHANNELS.remoteDelta')) + check( + 'persisted desktop messages refresh the remote transcript', + /CHANNELS\.messagesAdd[\s\S]{0,300}remote\.notifyTranscriptChanged\(input\.chatId\)/.test( + handlers + ) + ) + check( + 'remote transcript refresh sends an authoritative snapshot', + /function notifyTranscriptChanged[\s\S]{0,300}sendSnapshot\(sessionId\)/.test(service) + ) + check( + 'phone turns reconcile before becoming idle', + /active\.liveTurns\.get\(sessionId\)[\s\S]{0,300}sendSnapshot\(sessionId\)[\s\S]{0,200}state: 'idle'/.test( + service + ) + ) // ---- chats:updated parity ---- // The push that keeps the workstream strip honest. `worktree_path`, `branch`