Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
18 changes: 18 additions & 0 deletions src/main/services/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. */
Expand Down
16 changes: 16 additions & 0 deletions test/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading