refactor(ai-sessions): replace transcript mirroring with a run-status heartbeat

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-28 17:01:31 +02:00
co-authored by Claude Opus 5
parent cd3d24776e
commit e13c713969
11 changed files with 213 additions and 527 deletions
@@ -501,8 +501,8 @@
)
// Only when this tab could hold the posture itself: a mode its own selector
// does not offer would read as a mode the user could switch away from here.
const showsMirroredPlan = $derived(
aiChatManager.mirroredPlanMode && aiChatManager.planModeAvailable
const showsRemotePlan = $derived(
aiChatManager.remotePlanMode && aiChatManager.planModeAvailable
)
// Fall back to ask-permission when the persisted mode isn't applicable in the
// current AI mode (e.g. auto-accept edits while in a mode without edits).
@@ -510,7 +510,7 @@
// run this tab is showing, and this tab's own mode governs nothing until it
// drives a turn itself.
const effectiveAutonomyMode = $derived(
showsMirroredPlan
showsRemotePlan
? AIAutonomyMode.PLAN
: availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
? aiChatManager.autonomyMode
@@ -535,16 +535,16 @@
return pending?.action === 'question' ? pending.toolCallId : undefined
})
// The composer is locked while another tab's run is on screen: this tab pairs
// a mirrored transcript with the history it held before that run, and a turn
// sent from that pair would reach the model as a conversation the driver has
// already moved past. It unlocks on its own once the re-read that follows the
// turn lands. A run parked on a question is the exception — answering is the
// one thing a watching tab is there to do, and the answer travels to the
// driver instead of starting a turn here.
const composerLocked = $derived(
aiChatManager.mirroringRemoteRun && pendingQuestionToolCallId === undefined
)
// The composer is locked for as long as another tab's run is in flight: this
// tab still shows the transcript from before that run, and a turn sent from
// it would reach the model as a conversation the driver has already moved
// past. It unlocks on its own once the re-read that follows the turn lands.
//
// No exception for a run parked on a question. Only the driving tab holds the
// question and the resolver waiting on it; a card that looks parked here is a
// restored one whose resolver left with the old page, and answering it would
// deliver to nobody.
const composerLocked = $derived(aiChatManager.runHeldElsewhere)
// Get app context for display when in APP mode
const appContext = $derived.by((): SelectedContext | undefined => {
@@ -639,10 +639,14 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#each pastChats as chat (chat.id)}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
disabled={aiChatManager.loading || aiChatManager.sendInFlight}
title={aiChatManager.loading || aiChatManager.sendInFlight
? 'Stop the current answer to switch conversation'
: undefined}
disabled={aiChatManager.loading ||
aiChatManager.sendInFlight ||
aiChatManager.runHeldElsewhere}
title={aiChatManager.runHeldElsewhere
? 'This session is running in another tab'
: aiChatManager.loading || aiChatManager.sendInFlight
? 'Stop the current answer to switch conversation'
: undefined}
onclick={() => {
loadPastChat(chat.id)
close()
@@ -654,11 +658,15 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
>
{chat.title}
</div>
<!-- Gated separately: it sits inside the disabled row button,
which does not disable it, and the catch-up re-read is
keyed on a chat id this would delete out from under it. -->
<Button
iconOnly
size="xs2"
btnClasses="!p-1"
variant="default"
disabled={aiChatManager.runHeldElsewhere}
startIcon={{ icon: X }}
on:click={() => {
deletePastChat(chat.id)
@@ -672,10 +680,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Popover>
<Button
title={aiChatManager.mirroringRemoteRun
title={aiChatManager.runHeldElsewhere
? 'This session is running in another tab'
: 'New chat'}
disabled={aiChatManager.mirroringRemoteRun}
disabled={aiChatManager.runHeldElsewhere}
on:click={() => {
saveAndClear()
}}
@@ -976,7 +984,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
onchange={onFolderInputChange}
/>
{/if}
{#if showsMirroredPlan}
{#if showsRemotePlan}
<!-- The posture belongs to the tab running the turn, so it reads here
rather than offering a choice that would only move this tab's own
preference while the label stayed put. -->
@@ -133,10 +133,10 @@
return 'Answer the question above'
}
// Ahead of 'Ask followup': a mirrored run is almost always mid-conversation,
// so a check below that one would never be reached, and the locked composer
// would sit there inviting a followup it will not accept.
if (aiChatManager.mirroringRemoteRun) {
// Ahead of 'Ask followup': a run held elsewhere is almost always
// mid-conversation, so a check below that one would never be reached, and the
// locked composer would sit there inviting a followup it will not accept.
if (aiChatManager.runHeldElsewhere) {
return 'Running in another tab'
}
@@ -704,6 +704,15 @@
// Read before `take()` empties the draft the id derives from, and only take
// once the answer is delivered — an undelivered one would leave the user
// with neither their text nor a resumed turn.
// Ahead of every `draft.take()` below, including the edit branch: a send
// refused after the draft is consumed loses the user's text, and
// restartGeneration has truncated the transcript by then as well. The
// composer is disabled while another tab drives, so this catches the ways
// in that bypass it — Enter, and submitting an edit.
if (aiChatManager.runHeldElsewhere) {
sendUserToast('This session is running in another tab. Continue it there.')
return
}
const answeredQuestionId = questionAnsweredBySend
if (
answeredQuestionId &&
@@ -716,16 +725,6 @@
return
}
if (aiChatManager.loading) {
// The composer is locked while another tab drives, with one exception:
// a run parked on a question stays answerable from here. An answer
// carrying an attachment misses the branch above (it only routes plain
// choices) and would land on the queue — which in this tab nothing
// drains, because the turn that would flush it belongs elsewhere. Keep
// the draft where the user put it and say what will send.
if (aiChatManager.mirroringRemoteRun) {
sendUserToast('This session is running in another tab. Answer with text to send it there.')
return
}
// Queue the message instead of silently discarding it — it is auto-sent
// when this tab's own streaming turn completes successfully.
// Editing-while-loading keeps the old discard behavior. Paste
@@ -61,7 +61,7 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
import { createLongHash } from '$lib/editorLangUtils'
import type { AIProvider, UserDraftItemKind } from '$lib/gen'
import { maskKey } from '$lib/components/sessions/modifiedItemsMask'
import { isMirroring, runPosition } from '$lib/components/sessions/sessionRunOwner.svelte'
import { runHeldElsewhere, runPosition } from '$lib/components/sessions/sessionRunOwner.svelte'
import { getStringError } from './utils'
import { type PasteAttachment } from './pasteTokens'
import {
@@ -695,17 +695,16 @@ export class AIChatManager {
remoteToolConfirmation: ((toolId: string, confirmed: boolean) => boolean) | undefined = undefined
remoteQuestionAnswer: ((toolId: string, choices: string[]) => boolean) | undefined = undefined
/** True while this manager is showing a run another tab is driving, and on
* through the re-read that follows it. Its transcript then comes from mirror
* frames while `messages` still holds the pre-run history, so the pair is
* mismatched and must not be written to the shared record — the driving tab
* owns it until the re-read lands.
/** True while another tab is driving this session's run, and on through the
* re-read that follows it. What this manager holds is the conversation from
* before that turn, so it must not be written to the shared record — the
* driving tab owns it until the re-read lands.
*
* Derived rather than stored: the position is what the runtime moves, and a
* second copy of it here could only ever be a copy that disagrees. A chat
* with no session (the docked copilot) is never mirroring. */
get mirroringRemoteRun(): boolean {
return isMirroring(this.sessionId)
* with no session (the docked copilot) never holds a run elsewhere. */
get runHeldElsewhere(): boolean {
return runHeldElsewhere(this.sessionId)
}
/** Whether the run on screen is one another tab is running in plan mode.
@@ -715,7 +714,7 @@ export class AIChatManager {
* only in the memory of the tab running the turn. It is read from the run's
* position for the same reason it is not stored there — the posture belongs
* to that run, and must not outlive it into a turn this tab drives. */
get mirroredPlanMode(): boolean {
get remotePlanMode(): boolean {
const position = runPosition(this.sessionId)
return position.state === 'watching' && position.planMode
}
@@ -804,7 +803,7 @@ export class AIChatManager {
// than deferred: the re-read when the run ends reseeds the mask from the
// driving tab's record, so a write held back here would be overwritten by
// it anyway — and writing now would put a mismatched pair in the record.
if (this.mirroringRemoteRun) return this.#maskPersistQueue
if (this.runHeldElsewhere) return this.#maskPersistQueue
this.#maskPersistQueue = this.#maskPersistQueue.then(() =>
this.historyManager
.saveChat(
@@ -1116,7 +1115,7 @@ export class AIChatManager {
#jobPersistQueue: Promise<void> = Promise.resolve()
#persistBackgroundJobs(): Promise<void> {
// Runs outside any turn, so the run guard never sees it.
if (this.mirroringRemoteRun) return this.#jobPersistQueue
if (this.runHeldElsewhere) return this.#jobPersistQueue
this.#jobPersistQueue = this.#jobPersistQueue.then(() =>
this.historyManager
.saveChat(
@@ -1540,9 +1539,9 @@ export class AIChatManager {
* summary, leaving history untouched.
*/
compactManually = async (): Promise<void> => {
// `loading` is the mirrored driver's while another tab runs the session;
// `loading` reflects the driver's run while another tab has the session;
// compacting here would rewrite a conversation this tab does not own.
if (this.loading || this.mirroringRemoteRun) {
if (this.loading || this.runHeldElsewhere) {
return
}
// A summary round-trip only pays off once there's a prior exchange to fold
@@ -1803,7 +1802,7 @@ export class AIChatManager {
context?: ContextElement[],
files: AttachedTextFile[] = []
) {
if (this.mirroringRemoteRun) {
if (this.runHeldElsewhere) {
// Handed back, not dropped. These senders have no draft of their own to
// fall back on — the raw-app inline prompt would lose what the user
// typed outright. Not `restoreToInput`, whose no-composer fallback is
@@ -2926,12 +2925,12 @@ export class AIChatManager {
sendUserToast('This action needs the AI chat. Start an AI session to continue.', true)
return
}
// Still pairing a mirrored transcript with the pre-run history: sending now
// would put a conversation the driving tab has already moved past to the
// model, and persist it over what that tab saved. The composer is locked
// for exactly this window, so nothing the user typed reaches here — this
// catches the sends that start without one (tool handlers, auto-resume).
if (this.mirroringRemoteRun) {
// This tab holds the conversation from before the driver's turn: sending now
// would put one the driving tab has already moved past to the model, and
// persist it over what that tab saved. The composer is locked for exactly
// this window, so nothing the user typed reaches here — this catches the
// sends that start without one (tool handlers, auto-resume).
if (this.runHeldElsewhere) {
this.restoreRefusedSend(options)
return false
}
@@ -4080,7 +4079,7 @@ export class AIChatManager {
saveAndClear = async () => {
// The transcript on screen belongs to a run another tab owns; saving it
// here would write a history this manager does not have.
if (this.mirroringRemoteRun) return
if (this.runHeldElsewhere) return
this.cancel('saveAndClear')
// Drop any message queued in this conversation so it can't auto-send into
// the fresh chat or linger as a card across the switch.
@@ -294,10 +294,9 @@ describe('AIChatManager cross-tab run guard', () => {
})
// The save paths that run outside a turn have no guard over them, and a tab
// rendering someone else's run holds a transcript that does not match its own
// `messages` — writing that pair would clobber the record the driving tab is
// still appending to.
it('does not persist while rendering a run another tab owns', async () => {
// watching someone else's run holds the conversation from before it — writing
// that would clobber the record the driving tab is still appending to.
it('does not persist while another tab owns the run', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.sessionId = 'session-watching'
@@ -319,16 +318,16 @@ describe('AIChatManager cross-tab run guard', () => {
manager.sessionId = 'session-planning'
noteDriverAlive('session-planning', true)
expect(manager.mirroredPlanMode).toBe(true)
expect(manager.remotePlanMode).toBe(true)
noteRemoteTurnEnded('session-planning')
expect(manager.mirroredPlanMode).toBe(false)
expect(manager.remotePlanMode).toBe(false)
})
// The other tab's run has ended but this one is still reading what it left
// behind, so its transcript is mirrored while `messages` is the pre-turn
// history. A send in that window would put the stale history to the model and
// persist it over the completed turn.
// behind, so it holds the conversation from before that turn. A send in that
// window would put the stale history to the model and persist it over the
// completed turn.
it('refuses to send while still catching up on a finished remote turn', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
@@ -1,70 +0,0 @@
import { describe, expect, it } from 'vitest'
import { withoutHeavyPayloads } from './sessionMirrorPayload'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
// Frames go out several times a second for the whole turn, so anything left in
// them is re-cloned and re-broadcast on every tick. The caps allow megabytes per
// image and per pasted file, and a structural typecheck cannot catch a field
// name that no longer exists — the message union is cast on the way out.
describe('withoutHeavyPayloads', () => {
it('strips the bytes and keeps what the transcript renders', () => {
const messages = [
{
role: 'user',
content: 'look at this',
images: [
{ dataUrl: 'data:image/png;base64,AAAA', mediaType: 'image/png', name: 'shot.png' }
],
files: [{ name: 'notes.md', content: 'a very long pasted file', id: 'f1' }],
pastes: [{ id: 1, lines: 400, content: 'a very long collapsed paste' }]
},
{ role: 'tool', content: 'took a screenshot', imageUrl: 'data:image/png;base64,BBBB' }
] as unknown as DisplayMessage[]
const stripped = withoutHeavyPayloads(messages)
expect(JSON.stringify(stripped)).not.toContain('data:image')
expect(JSON.stringify(stripped)).not.toContain('a very long pasted file')
expect(JSON.stringify(stripped)).not.toContain('a very long collapsed paste')
// The chip renders from the line count, so that has to survive.
expect((stripped[0] as any).pastes).toEqual([{ id: 1, lines: 400, content: '' }])
// The file chip is labelled from the name, so that has to survive.
expect((stripped[0] as any).files).toEqual([{ name: 'notes.md', content: '', id: 'f1' }])
// Images are dropped whole: the bubble renders one <img> per entry with no
// per-image guard, so an emptied url would show a broken image instead.
expect((stripped[0] as any).images).toBeUndefined()
expect((stripped[0] as any).content).toBe('look at this')
})
// A tool's output is the one payload with no ceiling at all — a query result
// or job logs ride the running turn's tail, which is re-cloned and re-sent
// several times a second until the turn ends.
it('drops a tool result and its logs, keeping the card that frames them', () => {
const messages = [
{
role: 'tool',
tool_call_id: 'tc-1',
content: 'Ran the query',
toolName: 'run_script',
parameters: { path: 'f/demo/q' },
result: { rows: Array.from({ length: 5000 }, (_, i) => ({ i, blob: 'x'.repeat(200) })) },
logs: 'y'.repeat(100_000)
}
] as unknown as DisplayMessage[]
const stripped = withoutHeavyPayloads(messages)
expect(JSON.stringify(stripped).length).toBeLessThan(500)
expect((stripped[0] as any).result).toBeUndefined()
expect((stripped[0] as any).logs).toBeUndefined()
// The card is still rendered from these while the output is in flight.
expect((stripped[0] as any).toolName).toBe('run_script')
expect((stripped[0] as any).parameters).toEqual({ path: 'f/demo/q' })
expect((stripped[0] as any).content).toBe('Ran the query')
})
it('passes through a message with nothing heavy in it', () => {
const messages = [{ role: 'assistant', content: 'plain reply' }] as unknown as DisplayMessage[]
expect(withoutHeavyPayloads(messages)[0]).toBe(messages[0])
})
})
@@ -1,47 +0,0 @@
import { describe, expect, it } from 'vitest'
import { canSpliceFrame, mirrorFrameStart } from './sessionMirrorPayload'
describe('mirrorFrameStart', () => {
// A resync exists to rescue a receiver whose prefix does not fit. Answering it
// with a frame that still starts mid-transcript fails the same check again and
// asks for another one, so the two tabs trade messages and nothing renders.
it('sends the whole transcript when full, even mid-turn', () => {
expect(mirrorFrameStart({ total: 40, turnStart: 36, full: true })).toBe(0)
})
it('reaches no further back than the running turn', () => {
expect(mirrorFrameStart({ total: 40, turnStart: 36, full: false })).toBe(36)
})
it('caps the tail when the turn itself is long', () => {
expect(mirrorFrameStart({ total: 40, turnStart: 2, full: false })).toBe(30)
})
it('handles a turn that has produced nothing yet', () => {
expect(mirrorFrameStart({ total: 0, turnStart: 0, full: false })).toBe(0)
})
})
describe('canSpliceFrame', () => {
const base = { baseIndex: 30, total: 40, localLength: 35, onSameChat: true }
it('accepts a tail that lands on a matching prefix', () => {
expect(canSpliceFrame(base)).toBe(true)
})
it('always accepts a full frame', () => {
expect(canSpliceFrame({ ...base, baseIndex: 0, onSameChat: false })).toBe(true)
})
it('rejects a tail when the receiver joined mid-run and has a gap', () => {
expect(canSpliceFrame({ ...base, localLength: 12 })).toBe(false)
})
it('rejects a tail when the receiver holds more than the sender has', () => {
expect(canSpliceFrame({ ...base, localLength: 44 })).toBe(false)
})
it('rejects a tail from a different conversation', () => {
expect(canSpliceFrame({ ...base, onSameChat: false })).toBe(false)
})
})
@@ -1,119 +0,0 @@
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
/**
* The attachment payloads a mirror frame does not carry, and how to put them
* back. A frame goes out several times a second for the whole turn, and the
* message holding an image or a pasted file sits in its tail unchanged
* throughout so shipping the payloads means re-cloning and re-broadcasting
* the same megabytes on every tick (the caps allow roughly 16MB of base64 image
* plus 5MB of file content on a single message, before pastes).
*
* Every heavy field is listed here rather than at its own call site: they are
* spread across the message union, and one that goes unlisted silently
* reintroduces the whole regression.
*/
/** Emptied in place, keeping the sibling fields the chip renders from. */
const BLANKED_ITEM_FIELDS = [
['files', 'content'],
['pastes', 'content']
] as const
/** Dropped whole: the bubble renders one <img> per entry with no per-image
* guard, so an emptied url would show a broken image where the real one is
* about to appear. */
const DROPPED_LIST_FIELDS = ['images'] as const
/** Dropped whole, and unbounded in a way the others are not: a tool's result or
* logs can be a whole query result or job output, and a frame re-clones and
* re-broadcasts the running turn's tail several times a second. A watching tab
* shows the tool card without its output until the turn-end re-read supplies
* it, which is the same trade the images above make. */
const DROPPED_FIELDS = ['result', 'logs'] as const
/** Emptied outright; guarded at the render site, so it renders nothing. */
const BLANKED_FIELDS = ['imageUrl'] as const
function isHeavy(message: DisplayMessage): boolean {
const m = message as Record<string, any>
return (
DROPPED_LIST_FIELDS.some((f) => m[f]?.length) ||
DROPPED_FIELDS.some((f) => m[f] !== undefined) ||
BLANKED_ITEM_FIELDS.some(([f]) => m[f]?.length) ||
BLANKED_FIELDS.some((f) => m[f])
)
}
/** Strip the bytes out of a transcript bound for a mirror frame. */
export function withoutHeavyPayloads(messages: DisplayMessage[]): DisplayMessage[] {
return messages.map((message) => {
if (!isHeavy(message)) return message
const stripped: Record<string, any> = { ...message }
for (const f of DROPPED_LIST_FIELDS) delete stripped[f]
for (const f of DROPPED_FIELDS) delete stripped[f]
for (const [f, item] of BLANKED_ITEM_FIELDS) {
if (stripped[f]?.length) stripped[f] = stripped[f].map((v: any) => ({ ...v, [item]: '' }))
}
for (const f of BLANKED_FIELDS) if (stripped[f]) stripped[f] = ''
return stripped as DisplayMessage
})
}
/** How many of the newest messages a frame carries when it is not sending the
* whole transcript. In-place edits to already-rendered cards (a tool card
* settling) land within a few messages of the end, so a short tail carries them
* while keeping the frame bounded on a long conversation. */
const MIRROR_TAIL = 10
/**
* Where the tail a frame carries starts.
*
* `full` sends the whole transcript and overrides everything else it is what
* answers a resync, and a resync that came back partial would fail the receiver's
* prefix check again and ask for another one, forever.
*
* Otherwise the frame reaches no further back than `turnStart`, the index of the
* running turn's first message: everything from there on is either new this turn
* or a stripped copy the receiver already got from an earlier frame of the same
* turn, so overwriting it can never destroy a message held complete from the
* store.
*/
export function mirrorFrameStart({
total,
turnStart,
full
}: {
total: number
turnStart: number
full: boolean
}): number {
if (full) return 0
return Math.max(turnStart, Math.max(0, total - MIRROR_TAIL))
}
/**
* Whether a receiver can splice this frame onto what it already holds, or has to
* ask for the whole transcript instead.
*
* A frame is positional, so it is only meaningful against the same conversation
* and a prefix of the same shape. A receiver holding fewer messages than the
* frame starts at has a gap; one holding more than the sender has messages the
* sender no longer does (it compacted, or switched chats), and splicing would
* render a conversation that never existed.
*/
export function canSpliceFrame({
baseIndex,
total,
localLength,
onSameChat
}: {
baseIndex: number
total: number
localLength: number
onSameChat: boolean
}): boolean {
// A frame that starts at 0 replaces everything, so it needs no prefix to
// agree with and can be adopted even when it names a different conversation.
if (baseIndex === 0) return true
return onSameChat && localLength >= baseIndex && localLength <= total
}
@@ -8,9 +8,9 @@ import { SvelteMap } from 'svelte/reactivity'
// have already run against the workspace. So one tab drives and the rest watch.
//
// Every gate in the cross-tab paths asks a variant of one question: may I send,
// is this frame mine to adopt, is what I am showing still the other tab's. This
// module answers all of them from a single position per session, so they cannot
// disagree with each other.
// may I save, is the run on screen still someone else's. This module answers all
// of them from a single position per session, so they cannot disagree with each
// other.
/** Whether this context can hold a real lock on a run. Web Locks is
* secure-context only, so a self-hosted instance served over plain HTTP has
@@ -18,17 +18,17 @@ import { SvelteMap } from 'svelte/reactivity'
* {@link withSessionRunLock} and {@link runLockHeld}. */
const EXCLUSIVE_OWNERSHIP = BROWSER && !!globalThis.navigator?.locks?.query
/** A driver with no frame for this long is presumed gone, pending the lock
* query that confirms it. Deliberately many times the frame cadence: a busy
* tab can be starved of ticks for a while without having gone anywhere. */
/** A driver silent for this long is presumed gone, pending the lock query that
* confirms it. Deliberately many times the status cadence: a busy tab can be
* starved of ticks for a while without having gone anywhere. */
const DRIVER_SILENCE_MS = 10_000
/** Where this tab stands in one session's run.
*
* `catchingUp` is its own position rather than a corner of `watching`: the
* driver's turn is over, but this tab still pairs a mirrored transcript with
* the history it held before that turn, and sending from that pair would put a
* conversation the driver has already moved past to the model. */
* driver's turn is over, but this tab still holds the conversation from before
* it, and sending from there would put one the driver has already moved past to
* the model. */
export type RunPosition =
| { state: 'idle' }
| { state: 'driving' }
@@ -60,24 +60,23 @@ export function isDriving(sessionId: string): boolean {
return runPosition(sessionId).state === 'driving'
}
/** Another tab is running the turn and this one is rendering its frames. */
/** Another tab is running the turn and this one is showing that it is. */
export function isWatching(sessionId: string): boolean {
return runPosition(sessionId).state === 'watching'
}
/** True from the first frame adopted until the re-read that follows the
* driver's turn completes. The save and send paths gate on it: until it
* clears, this tab's transcript and its model history are not the same
* conversation. */
export function isMirroring(sessionId: string | undefined): boolean {
/** True from the driver's first status message until the re-read that follows
* its turn completes. The save and send paths gate on it: until it clears, what
* this tab holds is the conversation from before the driver's turn. */
export function runHeldElsewhere(sessionId: string | undefined): boolean {
const state = runPosition(sessionId).state
return state === 'watching' || state === 'catchingUp'
}
/** A frame arrived, which is both the transcript and the sign of life. */
/** The driver said how its run is going, which is also its sign of life. */
export function noteDriverAlive(sessionId: string, planMode: boolean): void {
// A tab mid-turn is the authority on its own session; a frame reaching it can
// only be an echo of the run it is itself driving.
// A tab mid-turn is the authority on its own session; a status message
// reaching it can only be an echo of the run it is itself driving.
if (isDriving(sessionId)) return
positions.set(sessionId, { state: 'watching', lastHeardAt: Date.now(), planMode })
ensureReaper()
@@ -88,7 +87,12 @@ export function noteDriverAlive(sessionId: string, planMode: boolean): void {
* to perform it. The channel runs this in every tab that loads it, and one with
* no runtime would sit in a state nothing can leave. */
export function noteRemoteTurnEnded(sessionId: string): void {
if (runPosition(sessionId).state !== 'watching') return
const state = runPosition(sessionId).state
// `idle` counts. A tab that heard the turn end without having heard it start
// — it opened between the driver's last status message and its turn-end, or
// its handler registered in that window — holds a transcript from before the
// turn, and idle is precisely the position that permits driving on it.
if (state === 'driving' || state === 'catchingUp') return
if (!canCompleteCatchUp()) {
positions.delete(sessionId)
return
@@ -131,6 +135,12 @@ export async function withSessionRunLock<T>(
sessionId: string,
body: () => Promise<T>
): Promise<T | 'busy'> {
// Ahead of the lock, because holding the lock is not the same as being
// entitled to the conversation: the driver releases it at turn-end while this
// tab still owes itself the re-read, and a turn driven in that window sends
// the pre-run history the re-read exists to replace. `runHeldElsewhere` and not
// `isWatching` for exactly that reason.
if (runHeldElsewhere(sessionId)) return 'busy'
if (!EXCLUSIVE_OWNERSHIP) return await bestEffort(sessionId, body)
// Set before the body runs, so a turn that throws is told apart from a lock
// that could not be taken. Without it a failing turn would look like a failed
@@ -164,8 +174,8 @@ export async function withSessionRunLock<T>(
* run is visibly on screen, and otherwise go.
*
* This is deliberately not mutual exclusion, and cannot be made into it. A
* driver whose timers have been throttled in a hidden tab stops sending frames
* long before its turn ends, so it is reaped as dead and a send from here can
* driver whose timers have been throttled in a hidden tab goes silent long
* before its turn ends, so it is reaped as dead and a send from here can
* start a second turn against the same chat id. Nothing over the channel fixes
* that: a probe distinguishes a throttled tab from a closed one, but not a
* frozen tab from a closed one, and the browser freezes hidden tabs on much the
@@ -174,12 +184,8 @@ export async function withSessionRunLock<T>(
* Accepted rather than solved, because the only origins that land here are
* served over plain HTTP and are not localhost a shape used for local testing
* rather than for running Windmill. Every HTTPS deployment, and localhost, is a
* secure context and takes the real lock above. `isMirroring` and not
* `isWatching`: a tab that has moved on to `catchingUp` is still owed its
* re-read, and driving from here would send the pre-run history that re-read
* exists to replace. */
* secure context and takes the real lock above. */
async function bestEffort<T>(sessionId: string, body: () => Promise<T>): Promise<T | 'busy'> {
if (isMirroring(sessionId)) return 'busy'
return await drive(sessionId, body)
}
@@ -195,8 +201,8 @@ async function drive<T>(sessionId: string, body: () => Promise<T>): Promise<T> {
}
/** Whether any tab currently holds the run lock for this session. Used to
* settle a driver that stopped sending frames: a released lock proves the tab
* is gone, where silence alone only suggests it. */
* settle a driver that went silent: a released lock proves the tab is gone,
* where silence alone only suggests it. */
async function runLockHeld(sessionId: string): Promise<boolean> {
// Nothing to consult without the lock API, so a silent driver is reaped on
// silence alone — and on that path reaching `idle` does entitle this tab to
@@ -250,8 +256,8 @@ async function reapDeadDrivers(): Promise<void> {
.filter(([, p]) => p.state === 'watching' && now - p.lastHeardAt > DRIVER_SILENCE_MS)
.map(([id]) => id)
for (const id of stale) {
// Re-checked after the await: a frame may have landed while the query was
// in flight, and reaping then would tear down a run that is visibly alive.
// Re-checked after the await: a status message may have landed while the
// query was in flight, and reaping then would tear down a live run.
if (await runLockHeld(id)) continue
if (!isWatching(id)) continue
noteRemoteTurnEnded(id)
@@ -7,7 +7,7 @@ import {
withSessionRunLock
} from './sessionRunOwner.svelte'
const SESSIONS = ['session-watching', 'session-catching-up', 'session-idle']
const SESSIONS = ['session-watching', 'session-catching-up', 'session-idle', 'session-unheard']
// `positions` is module state and a watching entry keeps the reaper interval
// armed, so leave nothing behind for the next suite.
@@ -44,6 +44,18 @@ describe('withSessionRunLock with no lock to take', () => {
expect(await withSessionRunLock('session-idle', body)).toBe('ran')
expect(body).toHaveBeenCalledTimes(1)
})
// A tab that opened between the driver's last status message and its turn-end
// never saw the run start, so it is idle — holding the conversation from
// before the turn, and idle is the position that permits driving on it.
it('refuses a turn-end it never saw start, rather than driving on stale history', async () => {
onDriverLost(() => {})
noteRemoteTurnEnded('session-unheard')
const body = vi.fn(async () => 'ran')
expect(await withSessionRunLock('session-unheard', body)).toBe('busy')
expect(body).not.toHaveBeenCalled()
})
})
// A fresh module instance is the only honest way to test this: the runtime's
@@ -57,7 +69,7 @@ describe('a turn ending in a tab with no session runtime', () => {
owner.noteRemoteTurnEnded('session-no-runtime')
expect(owner.isCatchingUp('session-no-runtime')).toBe(false)
expect(owner.isMirroring('session-no-runtime')).toBe(false)
expect(owner.runHeldElsewhere('session-no-runtime')).toBe(false)
owner.clearRunPosition('session-no-runtime')
})
})
@@ -93,18 +93,15 @@ import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib'
import { sendUserToast } from '$lib/toast'
import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
import { canSpliceFrame, mirrorFrameStart, withoutHeavyPayloads } from './sessionMirrorPayload'
import {
broadcastMirror,
broadcastRunStatus,
broadcastTurnEnd,
MIRROR_THROTTLE_MS,
registerSyncHandlers,
requestCancel,
requestResync,
RUN_STATUS_INTERVAL_MS,
sendQuestionAnswer,
sendToolConfirmation,
type MirrorMsg,
type MirrorSnapshot
type RunStatusMsg
} from './sessionSync.svelte'
import {
clearRunPosition,
@@ -938,17 +935,15 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
manager.onChatRotated = (chatId) => setSessionChatId(session.id, chatId)
// One tab drives a session at a time. The lock brackets the turn so the other
// tabs can mirror it, and refuses a second driver rather than letting two
// turns interleave into one chat id.
// tabs can show that it is running, and refuses a second driver rather than
// letting two turns interleave into one chat id.
manager.runGuard = async (body) => {
const outcome = await withSessionRunLock(session.id, async () => {
// The first frame doubles as the "a run started here" signal: it is
// posted immediately and carries the chat id the watchers need.
startMirroring(session.id)
startRunStatus(session.id)
try {
return await body()
} finally {
stopMirroring(session.id)
stopRunStatus(session.id)
// The chat id is re-read here, not reused from above: the turn may
// have rotated it, and the listeners key their IndexedDB re-read on it.
broadcastTurnEnd(session.id, manager.historyManager.getCurrentChatId())
@@ -1007,154 +1002,67 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
}
// ---------------------------------------------------------------------------
// Cross-tab mirroring
// Cross-tab run status
// ---------------------------------------------------------------------------
// Driving tabs post a frame on this cadence for as long as their turn runs.
// A plain interval rather than an $effect on the manager's fields: it also
// serves as the liveness heartbeat (a run that stalls in a long tool call
// still ticks), and it picks up in-place edits to already-rendered tool cards
// that reactive tracking of the array root would miss.
const mirrorTimers = new Map<string, ReturnType<typeof setInterval>>()
// Driving tabs post their status on this cadence for as long as their turn
// runs. A plain interval rather than an $effect on the manager's fields: it
// doubles as the liveness heartbeat, so a run stalled in a long tool call still
// ticks and is not reaped as a closed tab.
const statusTimers = new Map<string, ReturnType<typeof setInterval>>()
// Where the running turn's own messages start, per session. A frame never
// reaches below it, which is what makes overwriting safe: everything at or after
// it either is new this turn or is a stripped copy the watcher got from an
// earlier frame of this same turn, so a frame can never replace a message the
// watcher holds complete from the store. Reset to 0 by a rewrite (compaction),
// after which no index is stable and whole transcripts travel instead.
const turnStarts = new Map<string, number>()
// The driver's transcript length at its last frame, so a shrink is detectable.
const lastSentTotals = new Map<string, number>()
function mirrorSnapshotOf(sessionId: string, full: boolean): MirrorSnapshot | undefined {
function postRunStatus(sessionId: string): void {
const runtime = runtimes.get(sessionId)
if (!runtime) return undefined
if (!runtime) return
const m = runtime.manager
const total = m.displayMessages.length
// A shrink means the transcript was rewritten, not appended to: compaction
// folds the head into a summary mid-turn. A watcher's prefix is then a
// conversation that no longer exists, and no index check can tell — the
// lengths still line up. Send the whole thing instead.
const rewritten = total < (lastSentTotals.get(sessionId) ?? 0)
lastSentTotals.set(sessionId, total)
if (rewritten) turnStarts.set(sessionId, 0)
// Slice before cloning: `$state.snapshot` walks whatever it is handed, so
// snapshotting the whole transcript to send ten messages would traverse the
// entire conversation on every tick.
const baseIndex = mirrorFrameStart({
total,
turnStart: turnStarts.get(sessionId) ?? 0,
full: full || rewritten
})
return {
broadcastRunStatus({
sessionId,
chatId: m.historyManager.getCurrentChatId(),
baseIndex,
// Stripped before the clone, not after: `$state.snapshot` deep-copies
// whatever it is handed, so stripping second still copies every image data
// URL and pasted file body first — megabytes per tick, several times a
// second, for bytes about to be thrown away.
tail: $state.snapshot(
withoutHeavyPayloads(m.displayMessages.slice(baseIndex))
) as DisplayMessage[],
total,
loading: m.loading,
currentReply: m.currentReply,
currentReasoning: m.currentReasoning,
currentReasoningActive: m.currentReasoningActive,
loadingLabel: m.loadingLabel,
compacting: m.compacting,
blockedOnUser: pendingUserAction(m.displayMessages) !== undefined,
loadingLabel: m.loadingLabel,
planModeActive: m.planModeActive
}
})
}
function postMirror(sessionId: string, { full = false } = {}): void {
const snap = mirrorSnapshotOf(sessionId, full)
if (snap) broadcastMirror(snap)
}
function startMirroring(sessionId: string): void {
stopMirroring(sessionId)
// Captured before the turn pushes its first message, so it is the index of
// the oldest message this turn owns.
turnStarts.set(sessionId, runtimes.get(sessionId)?.manager.displayMessages.length ?? 0)
postMirror(sessionId)
mirrorTimers.set(
function startRunStatus(sessionId: string): void {
stopRunStatus(sessionId)
// Posted immediately: this first message doubles as the "a run started here"
// signal, and carries the chat id the watching tabs key their re-read on.
postRunStatus(sessionId)
statusTimers.set(
sessionId,
setInterval(() => postMirror(sessionId), MIRROR_THROTTLE_MS)
setInterval(() => postRunStatus(sessionId), RUN_STATUS_INTERVAL_MS)
)
}
function stopMirroring(sessionId: string): void {
const timer = mirrorTimers.get(sessionId)
function stopRunStatus(sessionId: string): void {
const timer = statusTimers.get(sessionId)
if (!timer) return
clearInterval(timer)
mirrorTimers.delete(sessionId)
// One last frame: the closing tokens of a turn usually land between ticks,
// and this is what the passive tabs render until their re-read completes.
// Posted while the turn's bookkeeping still stands — it is a frame like any
// other, and one sent without `turnStart` reaches below the turn and replaces
// complete messages with payload-stripped copies, while one sent without
// `lastSentTotals` cannot notice a compaction landing on this very tick.
postMirror(sessionId)
lastSentTotals.delete(sessionId)
turnStarts.delete(sessionId)
statusTimers.delete(sessionId)
}
/** Adopt a frame from the tab driving this session. */
function applyMirror(msg: MirrorMsg): void {
// A tab mid-turn is the authority on its own session; a frame can only be
// an echo of a run this tab is itself driving.
/** Adopt the driving tab's status. The transcript is left alone: this tab keeps
* showing its own until `turn-end` triggers the re-read. */
function applyRunStatus(msg: RunStatusMsg): void {
// A tab mid-turn is the authority on its own session; a status message can
// only be an echo of a run this tab is itself driving.
if (isDriving(msg.sessionId)) return
const runtime = runtimes.get(msg.sessionId)
if (!runtime) return
const m = runtime.manager
const onSameChat = m.historyManager.getCurrentChatId() === msg.chatId
if (
!canSpliceFrame({
baseIndex: msg.baseIndex,
total: msg.total,
localLength: m.displayMessages.length,
onSameChat
})
) {
// Nothing here can host this tail: this tab joined mid-run, or the driver
// rotated to a chat it isn't on. Ask for the whole transcript instead of
// splicing onto a prefix that isn't the same conversation.
requestResync(msg.sessionId)
return
}
if (!onSameChat) {
// A full snapshot names the conversation it came from, so follow the
// driver onto it rather than rendering it under the wrong chat id.
m.historyManager.setCurrentChatId(msg.chatId)
setSessionChatId(msg.sessionId, msg.chatId)
}
// Safe to overwrite wholesale: a frame reaches no further back than the
// running turn's first message, so everything it replaces is either new or a
// stripped copy of its own from an earlier frame — never a message this tab
// holds complete from the store.
m.displayMessages =
msg.baseIndex === 0 ? msg.tail : [...m.displayMessages.slice(0, msg.baseIndex), ...msg.tail]
m.loading = msg.loading
m.currentReply = msg.currentReply
m.currentReasoning = msg.currentReasoning
m.currentReasoningActive = msg.currentReasoningActive
m.loadingLabel = msg.loadingLabel
m.compacting = msg.compacting
m.loadingLabel = msg.blockedOnUser ? 'Waiting for your answer in the other tab' : msg.loadingLabel
}
/** The driver answers a resync with its whole transcript. */
function answerResync(sessionId: string): void {
if (!isDriving(sessionId)) return
postMirror(sessionId, { full: true })
}
/** A run finished in another tab. The mirror carried the rendered transcript
* only, so re-read the record the driver just saved for everything else the
* API-format history, context usage, the edits mask, background jobs leaving
* this tab able to take the conversation over. */
/** A run finished in another tab. Nothing of it crossed the channel but its
* status, so re-read the record the driver just saved for everything else
* the rendered transcript, the API-format history, context usage, the edits
* mask, background jobs leaving this tab able to take the conversation
* over. */
async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Promise<void> {
if (isDriving(sessionId)) return
let caughtUp = false
@@ -1166,11 +1074,9 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro
}
const m = runtime.manager
// `loading` has to go first: loadPastChat refuses to run while the manager
// looks busy, and this one is the mirrored driver's, not a turn of our own.
// looks busy, and this one is the driver's, not a turn of our own. These
// three are exactly what applyRunStatus sets.
m.loading = false
m.currentReply = ''
m.currentReasoning = ''
m.currentReasoningActive = false
m.loadingLabel = undefined
m.compacting = false
const id = chatId || m.historyManager.getCurrentChatId()
@@ -1181,10 +1087,10 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro
const found = await m.historyManager.reloadChat(id)
if (found === 'unavailable') {
// The store is unreadable *right now*, which says nothing about the
// conversation — so this tab still pairs the mirrored transcript with
// the history it held before the run. Freeing it here would let a turn
// go out under that pair and save it over what the driver wrote. Stay
// gated and keep asking; the read is the only thing that can settle it.
// conversation — so this tab still holds the one from before the run.
// Freeing it here would let a turn go out under that and save it over
// what the driver wrote. Stay gated and keep asking; the read is the only
// thing that can settle it.
scheduleCatchUpRetry(sessionId, chatId, attempt)
return
}
@@ -1218,9 +1124,9 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro
*
* It retries for as long as the runtime lives, including against a store that
* will never open. Deliberate: the alternative is giving up and releasing the
* gate, and this tab would then send a mirrored transcript paired with pre-run
* history the driver's completed turn missing from what reaches the model,
* and its record overwritten. A read every few seconds is the cheaper half of
* gate, and this tab would then send the pre-run conversation the driver's
* completed turn missing from what reaches the model, and its record
* overwritten. A read every few seconds is the cheaper half of
* that trade, and a browser whose IndexedDB never opens has no session history,
* artifacts or records either, so a locked composer is not what is broken. */
const CATCH_UP_BACKOFF_MS = [300, 700, 1500, 3000, 5000]
@@ -1268,8 +1174,7 @@ function applyQuestionAnswer(sessionId: string, toolId: string, choices: string[
}
registerSyncHandlers({
onMirror: applyMirror,
onResyncRequest: answerResync,
onRunStatus: applyRunStatus,
onCancelRequest: applyCancelRequest,
onToolConfirmation: applyToolConfirmation,
onQuestionAnswer: applyQuestionAnswer,
@@ -1279,7 +1184,7 @@ registerSyncHandlers({
onSessionDelete: (id) => disposeRuntime(id),
// Artifacts persist to a store both tabs share, but each keeps its own
// reactive copy, so the tab that did not write one never hears of it. That is
// what leaves a mirrored plan awaiting approval with no document behind it.
// what leaves a plan awaiting approval with no document behind it.
onSessionArtifact: (sessionId, artifactId) =>
void runtimes.get(sessionId)?.manager.artifacts.applyRemoteArtifact(artifactId)
})
@@ -1306,10 +1211,10 @@ export function disposeRuntime(sessionId: string) {
runtime.manager.historyManager.close()
runtimes.delete(sessionId)
// The per-run bookkeeping outlives the manager it describes otherwise: a
// frame timer with no runtime to read keeps ticking, a pending catch-up would
// re-read for a manager that is gone, and a leftover position would answer
// for a session this tab no longer holds.
stopMirroring(sessionId)
// status timer with no runtime to read keeps ticking, a pending catch-up
// would re-read for a manager that is gone, and a leftover position would
// answer for a session this tab no longer holds.
stopRunStatus(sessionId)
cancelCatchUpRetry(sessionId)
clearRunPosition(sessionId)
}
@@ -1,23 +1,27 @@
import { BROWSER } from 'esm-env'
import { onUserChange, scopedKey } from '$lib/userScopedStorage'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import { noteDriverAlive, noteRemoteTurnEnded } from './sessionRunOwner.svelte'
// The channel between tabs open on the same AI session. Everything a session is
// made of — the record list, the chat transcript, the run itself — lives in the
// tab, so two tabs on one session are two independent copies of it. This module
// carries messages between those copies: record writes, and the driving tab's
// live transcript. Who is entitled to drive is sessionRunOwner's question; this
// module only tells it what arrived.
// carries messages between those copies: record writes, and how the driving
// tab's run is going. Who is entitled to drive is sessionRunOwner's question;
// this module only tells it what arrived.
//
// No message carries a transcript. Tabs converge by re-reading the shared
// IndexedDB record once a run ends, so what crosses the channel stays small and
// bounded whatever a turn produced.
//
// The channel is per-user (same email scoping as the IndexedDB stores), so a
// browser shared by two accounts never crosses them.
const CHANNEL_BASE = 'windmill-sessions-sync'
/** Mirror ticks are throttled to this while a turn streams. Also the heartbeat
* interval: an unchanged run still ticks, so silence means the driver is gone. */
export const MIRROR_THROTTLE_MS = 250
/** How often a driving tab posts its status. An unchanged run still ticks, so
* silence is what tells the other tabs the driver is gone; the reaper's
* threshold is several times this. */
export const RUN_STATUS_INTERVAL_MS = 1000
/** Carries only the id on purpose. Broadcast delivery order and IndexedDB
* commit order are independent, so shipping a copy of the record lets an older
@@ -36,34 +40,33 @@ type SessionArtifactMsg = { kind: 'session-artifact'; sessionId: string; artifac
* watcher does the same thing either way re-read the record and stop
* showing the run. */
type TurnEndMsg = { kind: 'turn-end'; sessionId: string; chatId: string }
type MirrorMsg = {
kind: 'mirror'
/**
* How the driving tab's run is going. Deliberately carries no transcript: a
* watching tab keeps showing its own, and drives the same loading indicator a
* local turn shows from these fields, then re-reads the record once `turn-end`
* says the run is over.
*
* Every field here is a scalar of fixed size. That is the property to preserve:
* the transcript this replaced carried tool results and job logs, which are
* bounded by nothing, and re-broadcasting them several times a second was a
* responsiveness bug that recurred once per field anyone forgot to strip.
*/
type RunStatusMsg = {
kind: 'run-status'
sessionId: string
chatId: string
/** Index the tail starts at; the receiver keeps its own prefix below it, and
* asks for a full snapshot when it has no prefix that reaches this far. */
baseIndex: number
tail: DisplayMessage[]
/** The driver's whole transcript length. A watcher holding more than this has
* a prefix the driver no longer has, so its splice would invent a transcript
* that never existed; it resyncs instead. */
total: number
loading: boolean
currentReply: string
currentReasoning: string
currentReasoningActive: boolean
loadingLabel: string | undefined
compacting: boolean
/** Parked on a question that only the driving tab can render. The watcher
* says where to answer it rather than implying work is in progress. */
blockedOnUser: boolean
loadingLabel: string | undefined
/** The driver's plan-mode posture. The only autonomy state worth carrying:
* every other one is a stored preference each tab keeps its own copy of,
* while plan mode is never persisted and so exists nowhere but the driving
* tab's memory. */
planModeActive: boolean
}
/** Sent by a tab whose local prefix can't host the tail it just received (it
* joined mid-run, or is on a different chat). The driver answers with a full
* snapshot. */
type ResyncRequestMsg = { kind: 'resync-request'; sessionId: string }
/** A Stop pressed in a tab that is only watching the run. */
type CancelRequestMsg = { kind: 'cancel-request'; sessionId: string }
/** A run blocked on the user is unblocked from whichever tab the user is in;
@@ -86,8 +89,7 @@ type SyncMsg =
| SessionDeleteMsg
| SessionArtifactMsg
| TurnEndMsg
| MirrorMsg
| ResyncRequestMsg
| RunStatusMsg
| CancelRequestMsg
| ToolConfirmationMsg
| QuestionAnswerMsg
@@ -97,8 +99,7 @@ type Handlers = {
onSessionDelete: (id: string) => void
onSessionArtifact: (sessionId: string, artifactId: string) => void
onTurnEnd: (sessionId: string, chatId: string) => void
onMirror: (msg: MirrorMsg) => void
onResyncRequest: (sessionId: string) => void
onRunStatus: (msg: RunStatusMsg) => void
onCancelRequest: (sessionId: string) => void
onToolConfirmation: (sessionId: string, toolId: string, confirmed: boolean) => void
onQuestionAnswer: (sessionId: string, toolId: string, choices: string[]) => void
@@ -172,12 +173,9 @@ function receive(msg: SyncMsg): void {
noteRemoteTurnEnded(msg.sessionId)
emit('onTurnEnd', msg.sessionId, msg.chatId)
break
case 'mirror':
case 'run-status':
noteDriverAlive(msg.sessionId, msg.planModeActive)
emit('onMirror', msg)
break
case 'resync-request':
emit('onResyncRequest', msg.sessionId)
emit('onRunStatus', msg)
break
case 'cancel-request':
emit('onCancelRequest', msg.sessionId)
@@ -218,17 +216,13 @@ export function broadcastSessionArtifact(sessionId: string, artifactId: string):
}
// ---------------------------------------------------------------------------
// Live mirroring
// Run status
// ---------------------------------------------------------------------------
export function broadcastTurnEnd(sessionId: string, chatId: string): void {
post({ kind: 'turn-end', sessionId, chatId })
}
export function requestResync(sessionId: string): void {
post({ kind: 'resync-request', sessionId })
}
export function requestCancel(sessionId: string): void {
post({ kind: 'cancel-request', sessionId })
}
@@ -241,11 +235,11 @@ export function sendQuestionAnswer(sessionId: string, toolId: string, choices: s
post({ kind: 'question-answer', sessionId, toolId, choices })
}
export type MirrorSnapshot = Omit<MirrorMsg, 'kind'>
export type RunStatus = Omit<RunStatusMsg, 'kind'>
/** Send the driver's current view of a run. */
export function broadcastMirror(snap: MirrorSnapshot): void {
post({ kind: 'mirror', ...snap })
/** Say how the run this tab is driving is going. */
export function broadcastRunStatus(status: RunStatus): void {
post({ kind: 'run-status', ...status })
}
export type { MirrorMsg }
export type { RunStatusMsg }