feat(ai-sessions): show a running session across tabs and reload finished turns (#10916)

* fix(ai-chat): make a disabled composer look disabled

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(ai-sessions): show a running session across tabs and reload finished turns

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

* fix(ai-sessions): keep queued drafts through catch-up and hold locks by identity

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

* fix(ai-sessions): carry pastes through refusals, spare resends and auto-resume

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

* fix(ai-sessions): retry held auto-resume, keep the footer, spare bfcache freezes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

* fix(ai-sessions): give each driving tab its own lock slot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

* fix(ai-sessions): release refused synthetic sends and use a text key separator

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

* fix(ai-sessions): merge late-refusal restores and keep attachment-only edits

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

* fix(ai-sessions): patch the stored chat pointer instead of rewriting the record

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

* docs(ai-sessions): align the run-signal comments with the code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

* fix(ai-sessions): retry transient catch-up skips and gate the remaining send paths

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

* docs(ai-sessions): name the chat-id seeding path persistTouched defers to

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDUsDEbycDCBTAH2x8jUAt

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-01 23:21:19 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 4808f21b6b
commit 816dc9dcd2
14 changed files with 865 additions and 43 deletions
@@ -44,8 +44,12 @@
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const hasCopilot = $derived($copilotInfo.enabled)
// Another tab is running a turn on this session: transcript stays readable,
// composer locks, and the chat re-reads the shared record when the turn ends.
const runHeldElsewhere = $derived(aiChatManager.runHeldElsewhere)
const disabled = $derived(
forceDisabled ||
runHeldElsewhere ||
!hasCopilot ||
(aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
@@ -58,19 +62,23 @@
const disabledMessage = $derived(
forceDisabled
? forceDisabledMessage
: freeTierExhausted
? ''
: !hasCopilot
? $aiUserDisabled
? 'Windmill AI is disabled in your account settings'
: isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
: runHeldElsewhere
? // The typing indicator and the composer placeholder already carry
// this state; a footer note would say it a third time.
''
: freeTierExhausted
? ''
: !hasCopilot
? $aiUserDisabled
? 'Windmill AI is disabled in your account settings'
: isAdmin
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
: aiChatManager.mode === AIMode.SCRIPT &&
aiChatManager.scriptEditorOptions?.lang &&
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
: ''
)
const suggestions = [
@@ -315,7 +315,10 @@
}
})
const showTypingIndicator = $derived(aiChatManager.loading)
// Also shown for a run held by another tab, labeled with where it is: the
// dots say a turn is in flight even before the reader reaches the footer
// note. Remote runs pause nothing and offer no Stop — this tab can't cancel.
const showTypingIndicator = $derived(aiChatManager.loading || aiChatManager.runHeldElsewhere)
// The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items +
// code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there
@@ -571,8 +574,14 @@
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
!aiChatManager.autoAcceptEditsActive
)
// A disabled state with no message (a remote hold, a spent free grant) keeps
// the footer toolbar in place — swapping it for an empty strip would make
// the model/mode row flash out and back on every remote turn. A state with
// a real message (archived, AI off) still shows it, hold or not, matching
// the precedence disabledMessage itself encodes.
const footerMessageShown = $derived(disabled && disabledMessage !== '')
const showFooterLeftControls = $derived(
!disabled &&
!footerMessageShown &&
(showContextPicker ||
showAutonomyModeSelector ||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
@@ -673,10 +682,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
? 'Wait for the turn in the other tab to switch conversation'
: aiChatManager.loading || aiChatManager.sendInFlight
? 'Stop the current answer to switch conversation'
: undefined}
onclick={() => {
loadPastChat(chat.id)
close()
@@ -706,7 +719,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Popover>
<Button
title="New chat"
title={aiChatManager.runHeldElsewhere
? 'Wait for the turn in the other tab to start a new chat'
: 'New chat'}
disabled={aiChatManager.runHeldElsewhere}
on:click={() => {
saveAndClear()
}}
@@ -770,17 +786,19 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
)}
>
<ChatTypingIndicator
loading={aiChatManager.loading}
loading={showTypingIndicator}
paused={waitingForUserAction}
label={aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
label={aiChatManager.runHeldElsewhere
? 'Running in another tab'
: aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
/>
</div>
{/if}
@@ -1104,12 +1122,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Tooltip>
{/if}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff && !disabled}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
{/if}
{#if disabled}
{#if footerMessageShown}
<div class="text-primary text-xs my-2 px-2">
<Markdown md={disabledMessage} />
</div>
@@ -129,6 +129,12 @@
// Generate mode-specific placeholder
const modePlaceholder = $derived.by(() => {
// The composer unlocks by itself when the other tab's turn ends, so the
// placeholder names what it is waiting on (the typing indicator says
// where the run is).
if (aiChatManager.runHeldElsewhere) {
return 'Waiting for the turn in the other tab to finish'
}
if (pendingQuestionToolCallId !== undefined) {
return 'Answer the question above'
}
@@ -501,7 +501,24 @@ export class AIChatManager {
openRunInPreview?: (a: { jobId: string; workspace: string; label: string }) => void
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
closeArtifact?: (artifactId: string) => void
loading = $state<boolean>(false)
#loading = $state<boolean>(false)
get loading(): boolean {
return this.#loading
}
// An accessor so every run bracket — the send turn, manual compaction, a
// rollback — reports its transitions through one place, synchronously: the
// rising edge posts the cross-tab "running here" signal the moment the
// bracket opens (after the send's preflight awaits; the post-preflight
// guard covers that gap), and `loading` falls only after the turn's last
// saveChat, making the falling edge the "safe to re-read the record" signal.
set loading(v: boolean) {
if (v === this.#loading) return
this.#loading = v
this.onRunningChanged?.(v)
}
/** Sessions wiring (see sessionRuntime); undefined for the global
* side-panel chat, whose transcript no other tab renders. */
onRunningChanged: ((running: boolean) => void) | undefined = undefined
currentReply = $state<string>('')
currentReasoning = $state<string>('')
currentReasoningActive = $state<boolean>(false)
@@ -677,6 +694,14 @@ export class AIChatManager {
// sessions modules — and re-read on every system-message rebuild; the send
// path rebuilds after beforeSend, so a fork committed there is picked up.
sessionContextResolver: (() => SessionPromptContext | undefined) | undefined = undefined
// Whether another tab is running a turn on this session right now (sessions
// wiring, same seam as above). The composer locks on it, and sendRequest
// refuses on it — the refusal covers the send already in flight when the
// other tab's run signal arrives, which no disabled input can stop.
runHeldElsewhereResolver: (() => boolean) | undefined = undefined
get runHeldElsewhere(): boolean {
return this.runHeldElsewhereResolver?.() ?? false
}
// The page the side panel shows, stamped on each user message. Same seam as above:
// a page tab is an iframe in its own realm, so the tab model is the only place the
// chat can learn it. Undefined for a live editor — ACTIVE EDITOR covers those.
@@ -1056,6 +1081,16 @@ export class AIChatManager {
* doesn't spawn a new job leaves nothing to re-trigger on. A turn that DOES
* spawn another job resumes again when that one finishes, which is the point.
*/
#autoResumeRetry: ReturnType<typeof setTimeout> | undefined
#scheduleAutoResumeRetry() {
clearTimeout(this.#autoResumeRetry)
this.#autoResumeRetry = setTimeout(() => {
this.#autoResumeRetry = undefined
void this.#maybeAutoResumeFromJobs()
}, 5_000)
}
async #maybeAutoResumeFromJobs() {
if (this.#autoResuming) return
// Global/sessions chat only (the only mode with a jobs tray + preamble).
@@ -1066,6 +1101,17 @@ export class AIChatManager {
// Nothing to continue (empty chat), or the user is mid-compose — don't
// clobber their draft or auto-send it. Their eventual send carries the notes.
if (this.messages.length === 0 || this.instructions.trim()) return
// Another tab is driving: the synthetic send would only be refused, and
// the instructions staged below would then block every later auto-resume
// in this tab. The notes stay pending; re-checked shortly, because the
// hold can clear silently (staleness after a driver crash) with nothing
// else to fire this. When the driver instead ends its turn normally, its
// own resume carries the notes and this tab's catch-up clears the local
// copy — the re-check then finds nothing and stands down.
if (this.runHeldElsewhere) {
this.#scheduleAutoResumeRetry()
return
}
this.#autoResuming = true
try {
const count = this.pendingJobNotes.length
@@ -1104,6 +1150,8 @@ export class AIChatManager {
// Invalidate any in-flight poll so its post-await continuation can't write
// into the conversation we're switching to.
this.#jobPollGeneration++
clearTimeout(this.#autoResumeRetry)
this.#autoResumeRetry = undefined
this.backgroundJobs = []
this.pendingJobNotes = []
}
@@ -2832,6 +2880,35 @@ export class AIChatManager {
sendUserToast('This action needs the AI chat. Start an AI session to continue.', true)
return
}
// Refused before anything mutates, so there is nothing to unwind: the
// draft (already taken by the composer) goes back where the user can see
// it, and the turn never starts. Only the message's own send restores it
// — a refused queued flush is re-queued by its caller (`accepted ===
// false`), and a copy here would double it. Paste tokens are expanded
// into the text, as the queue does, because the restore lanes carry no
// pastes.
if (this.runHeldElsewhere) {
if (options.synthetic) {
// Client-authored prompt (a job auto-resume), not user input: nothing
// to hand back and no toast. Releasing the staged text un-blocks the
// next auto-resume attempt, scheduled for when the hold clears.
this.instructions = ''
this.#scheduleAutoResumeRetry()
} else {
if (!options.queued) {
// Programmatic prompts (askAi, fix) stage their text in
// `this.instructions` and pass no option — fall back to it so
// they are handed back too.
this.restoreToInput(
expanded(chatDraft(options.instructions ?? this.instructions, options.pastes ?? [])),
options.images,
options.files
)
}
sendUserToast('This session is running in another tab. Your message was kept.', true)
}
return false
}
this.#sendsInFlight++
try {
return await this.sendRequestImpl(options)
@@ -3051,6 +3128,28 @@ export class AIChatManager {
)
}
const images = modelIsBlind ? [] : requestedImages
// Re-checks the wrapper's remote-run guard: a run announced by another tab
// during the upkeep awaits above would otherwise interleave two turns into
// one chat id. Resends are exempt — restartGeneration already truncated
// the transcript, so they run as the documented advisory race instead.
if (this.runHeldElsewhere && !options.resendReservationKey) {
this.#releaseOutgoingReservation(reservationKey)
if (options.synthetic) {
// Same as the wrapper guard: an internal prompt is released, not
// restored as a draft the user never wrote.
this.instructions = ''
this.#scheduleAutoResumeRetry()
} else {
// restoreToInput, not restoreInstructions: a draft typed during the
// awaits above occupies the composer, and this restore must merge
// into it (or queue), never be refused by it.
if (!options.queued) {
this.restoreToInput(expanded(chatDraft(this.instructions, pastes)), images, files)
}
sendUserToast('This session is running in another tab. Your message was kept.', true)
}
return false
}
const optimisticIndex = this.displayMessages.length
this.loading = true
// Create the abort controller before the (possibly slow) beforeSend pre-flight,
@@ -3892,6 +3991,29 @@ export class AIChatManager {
throw new Error('No user message found at the specified index')
}
// Refused before anything mutates: past this point the transcript is
// sliced and resend bytes are reserved, and the sendRequest guard could
// only refuse AFTER that damage — restoring nothing, since this path
// carries its text in `this.instructions`, not the options. The retry and
// edit controls check only local `loading`, so a remote run reaches here.
// An edit (newContent defined, even '': attachment-only edits exist) is
// restored with its pastes expanded into the text; a bare retry mutates
// nothing yet, so there is nothing to restore. Un-submitted context-chip
// edits are the one loss — the chips re-seed from the untouched message
// on the next edit.
if (this.runHeldElsewhere) {
if (newContent !== undefined) {
this.restoreToInput(
expanded(chatDraft(newContent, pastes ?? [])),
images ?? [],
files ?? []
)
}
// "Text", not "message": chip edits are the part that does not survive.
sendUserToast('This session is running in another tab. Your text was kept.', true)
return
}
// Resolve the API restart point BEFORE reserving bytes or truncating: a
// stale index must fail while nothing has been mutated, or the transcript
// would be left truncated with the reservation leaked. A negative index
@@ -4015,7 +4137,7 @@ export class AIChatManager {
this.onChatRotated?.(this.historyManager.getCurrentChatId())
}
loadPastChat = async (id: string) => {
loadPastChat = async (id: string, { preserveQueue = false } = {}) => {
// A turn commits into whatever transcript it finds when it ends, so swapping
// one in underneath it misfiles the turn — or duplicates it, when the loaded
// chat already carries the turn's own checkpoint. Gated on `sendInFlight`
@@ -4025,7 +4147,10 @@ export class AIChatManager {
if (chat) {
// Drop any message queued in the current conversation so it doesn't
// auto-send into the loaded one or linger as a card across the switch.
this.#clearQueue()
// `preserveQueue` is for reloads that are NOT a switch — a cross-tab
// catch-up re-reading the conversation on screen — where the queued
// draft is unsent user input the reload must not destroy.
if (!preserveQueue) this.#clearQueue()
// Stop the poller for the conversation being left before swapping in the
// loaded chat's jobs below.
this.clearBackgroundJobs()
@@ -7,6 +7,7 @@ import type { ChatCompletionMessageParam } from 'openai/resources/chat/completio
import type { DisplayMessage } from './shared'
import type { AttachedImage } from './imageUtils'
import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte'
import { makePasteToken } from './pasteTokens'
import { chatState } from './sharedChatState.svelte'
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import { runChatLoop } from './chatLoop'
@@ -3997,3 +3998,135 @@ describe('AIChatManager reasoning duration', () => {
expect(assistantDurations(manager)).toEqual([3_000, 7_000])
})
})
describe('AIChatManager cross-tab run seams', () => {
// The whole cross-tab feature hangs off these two seams: `loading`'s edges
// are the "running here" / "safe to re-read" signals, and the resolver is
// the advisory lock. Reverting `loading` to a plain $state field would
// silently disconnect every tab.
it('reports loading transitions, and only transitions, through onRunningChanged', () => {
const manager = new AIChatManager()
const seen: boolean[] = []
manager.onRunningChanged = (running) => seen.push(running)
manager.loading = true
manager.loading = true
manager.loading = false
manager.loading = false
expect(seen).toEqual([true, false])
})
it('refuses a send while another tab holds the run, keeping the draft', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.runHeldElsewhereResolver = () => true
const accepted = await manager.sendRequest({ instructions: 'race loser' })
expect(accepted).toBe(false)
expect(mocks.runChatLoop).not.toHaveBeenCalled()
expect(manager.loading).toBe(false)
// restoreToInput falls back to the queued draft when no composer is
// mounted, so the refused text must surface there rather than vanish.
expect(manager.queuedMessage).toBe('race loser')
})
// A synthetic (auto-resume) prompt is client-authored: a refusal must
// release it rather than hand it back as a draft the user never wrote —
// staged instructions would otherwise block every later auto-resume.
it('releases a refused synthetic send instead of restoring it as a draft', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.runHeldElsewhereResolver = () => true
manager.instructions = 'A background job just finished.'
const accepted = await manager.sendRequest({ synthetic: true })
expect(accepted).toBe(false)
expect(manager.instructions).toBe('')
expect(manager.queuedMessage).toBe('')
})
// The restore lanes carry no pastes, so a refusal must expand the tokens
// into the text — dangling markers with the content gone otherwise.
it('expands paste tokens into the text a refusal hands back', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.runHeldElsewhereResolver = () => true
const paste = { id: 1, lines: 1, content: 'the pasted block' }
await manager.sendRequest({
instructions: `see ${makePasteToken(paste)}`,
pastes: [paste]
})
expect(manager.queuedMessage).toBe('see the pasted block')
})
// The wrapper's check runs before the attachment upkeep awaits; a run
// announced by another tab during that upkeep must still be refused before
// the turn takes visible effect.
it('refuses a run announced by another tab during the preflight awaits', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
let held = false
manager.runHeldElsewhereResolver = () => held
let releaseUpkeep: (() => void) | undefined
vi.spyOn(manager.attachedFiles, 'refreshFolders').mockImplementation(
() => new Promise<void>((resolve) => (releaseUpkeep = resolve))
)
const sending = manager.sendRequest({ instructions: 'racing turn' })
await vi.waitFor(() => expect(manager.sendInFlight).toBe(true))
held = true
releaseUpkeep?.()
expect(await sending).toBe(false)
expect(mocks.runChatLoop).not.toHaveBeenCalled()
expect(manager.loading).toBe(false)
})
it('refuses a retry/edit while another tab holds the run, before mutating the transcript', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
manager.displayMessages = [
{ role: 'user', content: 'original prompt', index: 0 },
{ role: 'assistant', content: 'original reply' }
] as DisplayMessage[]
manager.messages = [
{ role: 'user', content: 'original prompt' }
] as ChatCompletionMessageParam[]
manager.runHeldElsewhereResolver = () => true
await manager.restartGeneration(0, 'edited prompt')
expect(mocks.runChatLoop).not.toHaveBeenCalled()
expect(manager.displayMessages).toHaveLength(2)
expect(manager.messages).toHaveLength(1)
// The edited text survives the refusal via restoreToInput's queued-draft
// fallback.
expect(manager.queuedMessage).toBe('edited prompt')
})
// A cross-tab catch-up re-reads the conversation on screen; the queued
// draft is unsent user input (possibly the refusal's kept message) that
// this non-switch reload must not destroy — while a real conversation
// switch still drops it.
it('keeps the queued draft when a catch-up reload preserves it', async () => {
const manager = new AIChatManager()
manager.isSessionChat = true
vi.spyOn(manager.historyManager, 'loadPastChat').mockResolvedValue({
id: 'c1',
actualMessages: [],
displayMessages: [],
title: '',
lastModified: 1
} as never)
manager.queueMessage('kept across catch-up')
await manager.loadPastChat('c1', { preserveQueue: true })
expect(manager.queuedMessage).toBe('kept across catch-up')
await manager.loadPastChat('c1')
expect(manager.queuedMessage).toBe('')
})
})
@@ -770,8 +770,17 @@
<!-- The composer box: border + rounded live HERE (on the wrapper), not on the
textarea, so context chips can sit INSIDE the box, above the text. The
textarea's own @tailwindcss/forms border/ring is neutralized below. -->
<!-- The disabled treatment lives on the wrapper for the same reason the box
does: `disabled` on the textarea alone leaves the field looking exactly
like a usable one, so the only cue that typing is refused is placeholder
text the eye reads as an invitation. -->
<div
class="w-full scroll-pb-2 bg-surface-input rounded-md border border-border-light focus-within:border-border-selected transition-colors"
class={twMerge(
'w-full scroll-pb-2 rounded-md border border-border-light transition-colors',
disabled
? 'bg-surface-disabled cursor-not-allowed'
: 'bg-surface-input focus-within:border-border-selected'
)}
>
<!-- Context chips live inside the input box, above the textarea. The snippet
self-guards (renders nothing when empty) so no blank row appears. -->
@@ -825,6 +834,7 @@
// @tailwindcss/forms border, focus ring, and background so only the
// wrapper reads as the field.
'!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0',
'disabled:cursor-not-allowed disabled:placeholder:text-disabled',
CHAT_INPUT_PADDING,
className
)}
@@ -599,6 +599,35 @@ export default class HistoryManager {
}).catch((err) => console.error('Could not delete chat', err))
}
/** Re-read one chat from the store into the in-memory mirror, for a record
* another tab wrote after this manager last read it. `init()` is the wrong
* tool: it re-reads the user's entire history to pick up a single chat.
*
* 'missing' is a fact about the conversation (the store holds nothing under
* this id); 'unavailable' is a fact about this browser. Callers act on the
* first and must not act on the second treating a closed database as an
* empty chat would throw away a transcript that is merely unreadable. */
async reloadChat(id: string): Promise<'loaded' | 'missing' | 'unavailable'> {
const db = await this.dbh.whenReady()
if (!db) return 'unavailable'
try {
const chat = await db.get('chats', id)
if (!chat) {
// Drop the mirror too. `loadPastChat` reads from it and never from the
// store, so a copy left behind here is a deleted chat that comes back
// on the next rotation onto this id.
const { [id]: _gone, ...rest } = this.savedChats
this.savedChats = rest
return 'missing'
}
this.savedChats = { ...this.savedChats, [id]: chat }
return 'loaded'
} catch (err) {
console.error('Could not reload chat', err)
return 'unavailable'
}
}
async loadPastChat(id: string) {
const chat = this.savedChats[id]
if (!chat) return
@@ -757,3 +757,62 @@ describe('HistoryManager modified-items mask persistence', () => {
expect(hm.getModifiedItems(id)).toBeUndefined()
})
})
describe('HistoryManager.reloadChat', () => {
it('picks up another tabs write, and tells an empty chat from an unreadable store', async () => {
const hm = new HistoryManager()
await hm.init()
const chatId = hm.getCurrentChatId()
await hm.saveChat(
[{ role: 'user', content: 'before the other tab ran' }] as DisplayMessage[],
[] as ChatCompletionMessageParam[]
)
// The other tab's turn, written straight to the store this one shares.
const db = await openDB('copilot-chat-history::admin@test')
const row = (await db.get('chats' as never, chatId)) as any
row.displayMessages = [{ role: 'user', content: 'written by the driving tab' }]
await db.put('chats' as never, row)
db.close()
expect(await hm.reloadChat(chatId)).toBe('loaded')
const chat = await hm.loadPastChat(chatId)
expect((chat?.displayMessages[0] as any).content).toBe('written by the driving tab')
// A chat the store does not hold — distinct from 'unavailable' below:
// 'missing' evicts the in-memory mirror, so conflating the two would let
// a store that merely failed to open erase transcripts this tab holds.
expect(await hm.reloadChat('no-such-chat')).toBe('missing')
})
it('evicts the mirrored copy of a chat the driver deleted', async () => {
const hm = new HistoryManager()
await hm.init()
const chatId = hm.getCurrentChatId()
await hm.saveChat(
[{ role: 'user', content: 'deleted by the driving tab' }] as DisplayMessage[],
[] as ChatCompletionMessageParam[]
)
const db = await openDB('copilot-chat-history::admin@test')
await db.delete('chats' as never, chatId)
db.close()
expect(await hm.reloadChat(chatId)).toBe('missing')
// loadPastChat serves the mirror, so a copy left behind would resurrect the
// deleted transcript the next time this id came round again.
expect(await hm.loadPastChat(chatId)).toBeUndefined()
})
it('reports a store it cannot open as unavailable, never as missing', async () => {
;(globalThis as any).indexedDB = {
open: () => {
throw new Error('blocked')
}
}
const hm = new HistoryManager()
await hm.init()
expect(await hm.reloadChat(hm.getCurrentChatId())).toBe('unavailable')
})
})
@@ -90,6 +90,15 @@ export class SessionArtifactsStore {
await this.#load()
}
/** Re-read the loaded session's artifacts from the store, for records another
* tab wrote after this one loaded. Forces the read setSession skips: that
* skip protects local edits whose best-effort persist failed, while a tab
* catching up on another tab's finished turn wants the store's truth. */
async resyncFromStore(): Promise<void> {
if (this.#sessionId === undefined) return
await this.#load()
}
async #load(): Promise<void> {
const token = ++this.#seq
const id = this.#sessionId
@@ -29,6 +29,12 @@ import { userWorkspaces, workspaceStore } from '$lib/stores'
import { copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { emptySchema, type StateStore } from '$lib/utils'
import {
localRunEnded,
localRunStarted,
onRemoteTurnEnd,
runHeldElsewhere
} from './sessionSync.svelte'
import {
commitSessionWorkspace,
deleteSession as deleteSessionState,
@@ -338,6 +344,15 @@ function createRuntime(session: Session): SessionRuntime {
// Carried into the tool helpers so this session's preview/deploy tool calls
// dispatch to THIS session even when another session is the UI-active one.
manager.sessionId = session.id
// Cross-tab awareness: heartbeat while this tab runs a turn, composer lock
// (and send refusal) while another tab does. The chat id is read at turn
// end, not captured at start — the turn may have rotated it, and the other
// tabs re-read whichever record it ended on.
manager.runHeldElsewhereResolver = () => runHeldElsewhere(session.id)
manager.onRunningChanged = (running) => {
if (running) localRunStarted(session.id, manager.historyManager.getCurrentChatId())
else localRunEnded(session.id, manager.historyManager.getCurrentChatId())
}
// The chat targets the session's OWN (possibly forked) workspace without
// switching the global workspaceStore. Resolved live from the session record
// so it tracks the pending → committed (and staged-fork) transitions.
@@ -958,6 +973,65 @@ export function getRuntime(sessionId: string): SessionRuntime | undefined {
return runtimes.get(sessionId)
}
// ---------------------------------------------------------------------------
// Cross-tab catch-up
// ---------------------------------------------------------------------------
// Chained per session so two turn-ends close together (a turn plus its queued
// follow-up) re-read sequentially: the later read starts after the earlier
// one's loadPastChat, so the newest record is what ends up on screen.
const catchUps = new Map<string, Promise<void>>()
onRemoteTurnEnd((sessionId, chatId) => {
const next = (catchUps.get(sessionId) ?? Promise.resolve())
.then(() => applyRemoteTurnEnd(sessionId, chatId))
.catch((e) => console.error('Failed to catch up on a turn from another tab', e))
catchUps.set(sessionId, next)
void next.finally(() => {
if (catchUps.get(sessionId) === next) catchUps.delete(sessionId)
})
// Awaited by the caller: the composer unlock rides on this settling.
return next
})
async function applyRemoteTurnEnd(sessionId: string, chatId: string): Promise<void> {
const runtime = runtimes.get(sessionId)
if (!runtime) return
const m = runtime.manager
// Two transient states get a short retry rather than a skip, because the
// composer unlocks when this promise settles and a skip would unlock it on
// stale history: a send of this tab's own still in preflight (it may yet be
// refused, leaving no turn to converge on), and a store that failed to
// open. A turn actually running here owns the transcript instead — its own
// end converges — and the pruner caps the whole hold at STALE_MS anyway.
for (let attempt = 0; ; attempt++) {
if (m.loading) return
if (!m.sendInFlight) {
const res = await m.historyManager.reloadChat(chatId)
if (res === 'missing') return
if (res === 'loaded') break
}
if (attempt >= 7) return
await new Promise((r) => setTimeout(r, 500))
if (runtimes.get(sessionId) !== runtime) return
}
// Disposed (session deleted, teardown) while the read was in flight.
if (runtimes.get(sessionId) !== runtime) return
// Adopts the driver's chat unconditionally, current view included: watching
// a session means following where its activity is, and it is also how tabs
// converge after an unsynced /clear rotation. A watcher browsing an older
// conversation is pulled along — deliberate, and the price of not syncing
// rotation as its own message.
//
// preserveQueue: this reload is a catch-up, not a conversation switch — a
// draft queued here (a refused send's kept message, a failed turn's card)
// is unsent user input the re-read must not destroy.
await m.loadPastChat(chatId, { preserveQueue: true })
// loadPastChat's own artifact sync no-ops for an unchanged session id, so
// artifacts the driver wrote during the turn need this forced re-read.
await m.artifacts.resyncFromStore()
}
// Point a session's preview at a single seed tab. For re-pointing an existing
// draft session at a new destination ("Open in AI session" / new-session-from-
// page on a reused transient): its previous tabs — persisted with the draft
@@ -405,8 +405,9 @@ export function takeSessionAutoSend(sessionId: string): boolean {
// Persist a session on a genuine user edit, promoting an in-memory-only
// (transient) pending session to a durable IndexedDB record on first touch.
// Non-touch writers (runtime chatId seeding, unread watermark) call putSession
// directly, so an untouched draft stays in memory and vanishes on reload.
// Non-touch writers (runtime chatId seeding via patchStoredSessionChatId, the
// unread watermark via putSession) persist directly, so an untouched draft
// stays in memory and vanishes on reload.
function persistTouched(s: Session): void {
if (s.transient) delete s.transient
s.lastActivityAt = Date.now()
@@ -437,10 +438,10 @@ async function deleteSessionRow(db: IDBPDatabase<SessionSchema>, id: string): Pr
await db.delete('sessions', id)
}
// The one way to write a session's record, and the other half of the invariant above:
// every caller reaches its write across an await — putSession on the DB handle, the
// reconcile and hydrate passes on a getAll() snapshot that an interleaved delete
// invalidates — so the tombstone has to be consulted here, not only at the entry points.
// The way a session record is written (patchStoredSessionChatId is the one
// exception: it re-checks the tombstone inline to stay inside its own
// transaction). Every caller reaches its write across an await, so the
// tombstone has to be consulted here, not only at the entry points.
async function putSessionRow(db: IDBPDatabase<SessionSchema>, s: Session): Promise<void> {
if (deletedSessionIds.has(s.id)) return
await db.put('sessions', s)
@@ -1151,7 +1152,36 @@ export function setSessionChatId(sessionId: string, chatId: string) {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (s && s.chatId !== chatId) {
s.chatId = chatId
void putSession(s)
void patchStoredSessionChatId(s, chatId)
}
}
// Persists the pointer through the STORED row, not this tab's copy: another
// tab may have written newer fields (summary, tabs, archive state) since this
// tab last read the record, and a whole-object put would roll them back — a
// watcher adopting the driver's rotation reaches here with exactly that copy.
async function patchStoredSessionChatId(s: Session, chatId: string): Promise<void> {
if (!BROWSER || s.transient || deletedSessionIds.has(s.id)) return
const db = await sessionsDb.whenReady()
if (!db) return
try {
const tx = db.transaction('sessions', 'readwrite')
const stored = await tx.store.get(s.id)
// Inline tombstone re-check in place of putSessionRow's: routing through
// it would put outside this transaction and lose the read's atomicity.
if (stored && !deletedSessionIds.has(s.id)) {
stored.chatId = chatId
await tx.store.put(stored)
await tx.done
return
}
await tx.done
// No stored row: either the record is not yet persisted — its own
// materialization writes it later with the chatId already set in memory —
// or another tab deleted it, and an upsert here would resurrect it. No
// write either way.
} catch (e) {
console.error('Failed to persist session chat id', e)
}
}
@@ -50,6 +50,7 @@ import {
getSessionDraftPrompt,
setSessionDraftPrompt,
setSessionTabs,
setSessionChatId,
reconcileSessionsLifecycle,
__resetDeletedSessionIdsForTesting,
setSessionArchived,
@@ -117,6 +118,30 @@ describe('sessionState IndexedDB persistence', () => {
await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['s2', 's1']))
})
// A watcher adopting the driver's chat rotation holds a stale in-memory
// record; persisting the pointer must not roll back fields another tab
// wrote to the store since.
it('setSessionChatId patches the stored row instead of writing back a stale copy', async () => {
const user = freshUser()
await login(user)
const stale = session({ id: 's1', createdAt: 100, summary: 'old summary' })
await putSession(stale)
// A newer write from another tab, landing directly in the store.
await putSession(session({ id: 's1', createdAt: 100, summary: 'newer summary' }))
sessionState.sessions = [stale]
setSessionChatId('s1', 'chat-2')
await flush()
await rehydrate(user)
await vi.waitFor(() => {
const s = sessionState.sessions.find((x) => x.id === 's1')
expect(s?.chatId).toBe('chat-2')
expect(s?.summary).toBe('newer summary')
})
})
it('does not persist a transient (untouched) session — it is in-memory only', async () => {
const user = freshUser()
await login(user)
@@ -0,0 +1,206 @@
import { BROWSER } from 'esm-env'
import { SvelteMap } from 'svelte/reactivity'
import { onUserChange, scopedKey } from '$lib/userScopedStorage'
import { randomUUID } from '$lib/utils/uuid'
// Cross-tab awareness for AI sessions. Invariant: no message carries state —
// a heartbeat is presence, turn-end triggers an idempotent re-read of the
// shared IndexedDB record — so tabs converge on the store, never on delivery
// order. The lock is advisory (a broadcast-latency race stays last-writer-
// wins, as with no channel), and the channel is per-user like the stores.
const CHANNEL_BASE = 'windmill-sessions-sync'
/** Silence past STALE_MS unlocks watchers a dead driver would strand. The
* window sits above the 1/min floor browsers throttle a hidden tab's timers
* to and a hidden driver is the normal case here. Only an uncleanly killed
* tab waits it out; a closed one says goodbye via the pagehide farewell. */
const HEARTBEAT_MS = 3_000
const STALE_MS = 90_000
const PRUNE_MS = 2_000
// `from` identifies the driving tab: two drivers racing on one session (the
// documented advisory race) hold separate slots, so one's turn-end can never
// unlock a watcher the other still holds.
export type SyncMsg =
| { kind: 'run-heartbeat'; sessionId: string; from: string }
| { kind: 'turn-end'; sessionId: string; chatId: string; from: string }
/** This tab's identity on the channel (a tab never receives its own posts). */
const TAB_ID = randomUUID()
// One slot per (session, driving tab), keyed with a separator no UUID contains.
// The value is a fresh object per message: turn-end's deferred cleanup asks
// "is this slot still mine?" by identity — a timestamp can't, since a same-
// millisecond follow-up heartbeat would compare equal and be deleted.
const remoteRuns = new SvelteMap<string, { at: number }>()
function runKey(sessionId: string, from: string): string {
return sessionId + ':' + from
}
export function runHeldElsewhere(sessionId: string): boolean {
const prefix = sessionId + ':'
for (const key of remoteRuns.keys()) {
if (key.startsWith(prefix)) return true
}
return false
}
let remoteTurnEnd: ((sessionId: string, chatId: string) => void | Promise<void>) | undefined
/** Registered by sessionRuntime, which already imports this module a
* callback rather than an import keeps that edge one-way. The returned
* promise is when the catch-up has been applied; the composer stays locked
* until it settles. */
export function onRemoteTurnEnd(
fn: (sessionId: string, chatId: string) => void | Promise<void>
): void {
remoteTurnEnd = fn
}
let channel: BroadcastChannel | undefined
let channelName: string | undefined
function openChannel(): void {
const name = scopedKey(CHANNEL_BASE)
if (name === channelName) return
channel?.close()
channel = undefined
channelName = name
if (!name) return
try {
const ch = new BroadcastChannel(name)
ch.onmessage = (ev: MessageEvent<SyncMsg>) => receive(ev.data)
channel = ch
} catch (e) {
// No BroadcastChannel (or blocked): every tab simply stays independent,
// which is the pre-sync behavior rather than a broken one.
console.error('sessionSync: could not open channel', e)
}
}
if (BROWSER) {
// A user switch rescopes the channel name, so the previous identity's
// channel is closed before the next one opens.
onUserChange(() => openChannel())
}
function receive(msg: SyncMsg): void {
switch (msg.kind) {
case 'run-heartbeat':
remoteRuns.set(runKey(msg.sessionId, msg.from), { at: Date.now() })
ensurePruner()
break
case 'turn-end': {
// Unlocking on receipt would let a send here start from history missing
// the turn that just ended, so the slot holds until the catch-up
// settles — unless the driver's next turn replaced it meanwhile (object
// identity, see remoteRuns). The pruner caps a wedged reload at STALE_MS.
const key = runKey(msg.sessionId, msg.from)
const hold = { at: Date.now() }
remoteRuns.set(key, hold)
ensurePruner()
Promise.resolve()
.then(() => remoteTurnEnd?.(msg.sessionId, msg.chatId))
.catch((e) => console.error('sessionSync: turn-end handler failed', e))
.finally(() => {
if (remoteRuns.get(key) === hold) remoteRuns.delete(key)
})
break
}
}
}
function post(msg: SyncMsg): void {
if (!channel) return
try {
channel.postMessage(msg)
} catch (e) {
// A failed post must never take the turn down with it.
console.error('sessionSync: could not post message', e)
}
}
let pruneTimer: ReturnType<typeof setInterval> | undefined
function ensurePruner(): void {
if (pruneTimer) return
pruneTimer = setInterval(() => {
const cutoff = Date.now() - STALE_MS
for (const [id, entry] of remoteRuns) {
if (entry.at < cutoff) remoteRuns.delete(id)
}
if (remoteRuns.size === 0) {
clearInterval(pruneTimer)
pruneTimer = undefined
}
}, PRUNE_MS)
}
// ---------------------------------------------------------------------------
// Driving side
// ---------------------------------------------------------------------------
// The chat id rides along for the pagehide farewell below, which cannot ask
// the manager for it. Taken at run start; only a mid-turn rotation could make
// it stale, and a farewell pointing at the pre-rotation record still converges
// (the re-read is idempotent and the next turn-end names the right one).
const heartbeats = new Map<string, { timer: ReturnType<typeof setInterval>; chatId: string }>()
/** Posted when the run's loading bracket opens after the send's attachment
* upkeep awaits, so a competing send can start during them; the sender's own
* post-preflight re-check is what refuses one that did. */
export function localRunStarted(sessionId: string, chatId: string): void {
if (heartbeats.has(sessionId)) return
post({ kind: 'run-heartbeat', sessionId, from: TAB_ID })
heartbeats.set(sessionId, {
timer: setInterval(
() => post({ kind: 'run-heartbeat', sessionId, from: TAB_ID }),
HEARTBEAT_MS
),
chatId
})
}
/** `chatId` is read at turn end, not reused from the start: a rotation
* mid-turn means the transcript now lives under a different record, and the
* watchers' re-read must follow it there. */
export function localRunEnded(sessionId: string, chatId: string): void {
const entry = heartbeats.get(sessionId)
if (entry !== undefined) {
clearInterval(entry.timer)
heartbeats.delete(sessionId)
}
post({ kind: 'turn-end', sessionId, chatId, from: TAB_ID })
}
if (BROWSER) {
// The run dies with the page: a turn-end farewell (which also has watchers
// re-read the last checkpoint) beats making them wait out STALE_MS. Not on
// a bfcache freeze (persisted) — that turn resumes with the page, and
// nothing would re-arm a farewelled heartbeat.
window.addEventListener('pagehide', (ev) => {
if (ev.persisted) return
for (const [sessionId, entry] of [...heartbeats]) {
localRunEnded(sessionId, entry.chatId)
}
})
}
/** Test seam: deliver a message as if it arrived on the channel. */
export function __receiveForTest(msg: SyncMsg): void {
receive(msg)
}
/** Test seam: clear the module's state between tests. */
export function __resetForTest(): void {
remoteRuns.clear()
if (pruneTimer) {
clearInterval(pruneTimer)
pruneTimer = undefined
}
for (const entry of heartbeats.values()) clearInterval(entry.timer)
heartbeats.clear()
remoteTurnEnd = undefined
}
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
__receiveForTest,
__resetForTest,
onRemoteTurnEnd,
runHeldElsewhere
} from './sessionSync.svelte'
// Exercises the receive-side state machine directly (the module opens no
// BroadcastChannel outside the browser). The channel itself is glue that only
// a real browser can prove; what these pin are the invariants a refactor could
// silently break: the identity-token cleanup, the staleness prune, and the
// turn-end hold.
beforeEach(() => {
__resetForTest()
vi.useFakeTimers()
})
afterEach(() => {
__resetForTest()
vi.useRealTimers()
})
describe('sessionSync receive-side state', () => {
it('locks on a heartbeat and unlocks by staleness when the driver dies silently', async () => {
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
expect(runHeldElsewhere('s1')).toBe(true)
// Refreshed heartbeats keep the lock past the original entry's window.
await vi.advanceTimersByTimeAsync(60_000)
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
await vi.advanceTimersByTimeAsync(60_000)
expect(runHeldElsewhere('s1')).toBe(true)
// Silence past STALE_MS (90s) prunes the entry.
await vi.advanceTimersByTimeAsync(40_000)
expect(runHeldElsewhere('s1')).toBe(false)
})
it('holds the lock through the turn-end catch-up and releases when it settles', async () => {
let releaseCatchUp: (() => void) | undefined
onRemoteTurnEnd(() => new Promise<void>((resolve) => (releaseCatchUp = resolve)))
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
__receiveForTest({ kind: 'turn-end', sessionId: 's1', chatId: 'c1', from: 'driver-a' })
await vi.advanceTimersByTimeAsync(0)
// Unlocking on receipt would let a send here start from history missing
// the turn that just ended; the lock must outlive the re-read.
expect(runHeldElsewhere('s1')).toBe(true)
releaseCatchUp?.()
await vi.advanceTimersByTimeAsync(0)
expect(runHeldElsewhere('s1')).toBe(false)
})
it("keeps the lock when the driver's next turn arrives during the catch-up", async () => {
let releaseCatchUp: (() => void) | undefined
onRemoteTurnEnd(() => new Promise<void>((resolve) => (releaseCatchUp = resolve)))
__receiveForTest({ kind: 'turn-end', sessionId: 's1', chatId: 'c1', from: 'driver-a' })
// Flush so the catch-up handler has started (releaseCatchUp is assigned)
// before the follow-up arrives — otherwise the release below no-ops and
// the lock would survive for the wrong reason (a catch-up that never
// settled), passing even with the identity comparison broken.
await vi.advanceTimersByTimeAsync(0)
// The queued-follow-up sequence: the next turn's first heartbeat lands
// while this tab's catch-up is still reading — in the same millisecond,
// which is why the cleanup must compare identity, not timestamps.
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-a' })
releaseCatchUp?.()
await vi.advanceTimersByTimeAsync(0)
expect(runHeldElsewhere('s1')).toBe(true)
})
// Two drivers on one session is the documented advisory race; a watcher
// must not compound it by unlocking when only one of them finishes.
it('stays locked when one of two drivers ends its turn', async () => {
onRemoteTurnEnd(() => Promise.resolve())
__receiveForTest({ kind: 'run-heartbeat', sessionId: 's1', from: 'driver-b' })
__receiveForTest({ kind: 'turn-end', sessionId: 's1', chatId: 'c1', from: 'driver-a' })
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(0)
// Driver A's slot released with its catch-up; driver B still holds its own.
expect(runHeldElsewhere('s1')).toBe(true)
})
})