diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index d3cc2c8bd9..32c0658d3f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -378,6 +378,7 @@ ) $effect(() => { chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes) + chatHost.setComposerHasDraft(composerKey, !draft.isEmpty || pendingFileBytes > 0) }) $effect(() => () => chatHost.clearComposerStaged(composerKey)) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index acfcc305e0..8ad45466b1 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2822,6 +2822,27 @@ export class AIChatManager implements ChatViewHost { clearComposerStaged(key: string) { this.#composerStaged.delete(key) + this.#composersWithDraft.delete(key) + } + + // Composers whose draft is non-empty. The draft itself is component-local, so + // this is the only way to know an unmount would lose unsent input. + #composersWithDraft = new SvelteSet() + + setComposerHasDraft(key: string, hasDraft: boolean) { + if (hasDraft) this.#composersWithDraft.add(key) + else this.#composersWithDraft.delete(key) + } + + /** Unsent input an unmount of this chat would lose: a composer draft, or a + * queued message (whose text may be empty when it carries only attachments + * or context). */ + get hasUnsentInput(): boolean { + return ( + this.#composersWithDraft.size > 0 || + !this.#queuedDraft.isEmpty || + this.queuedContext !== undefined + ) } /** Release the outgoing-files reservation identified by `key` (a per-send token). diff --git a/frontend/src/lib/components/copilot/chat/chatViewHost.ts b/frontend/src/lib/components/copilot/chat/chatViewHost.ts index 4af23337e2..7b095d9c3d 100644 --- a/frontend/src/lib/components/copilot/chat/chatViewHost.ts +++ b/frontend/src/lib/components/copilot/chat/chatViewHost.ts @@ -85,6 +85,8 @@ export interface ChatViewHost { ) => void dequeueMessage: () => void setComposerStaged: (key: string, editingIndex: number | null, bytes: number) => void + /** Whether this composer holds anything unsent (text, pastes, attachments, reads in flight). */ + setComposerHasDraft: (key: string, hasDraft: boolean) => void clearComposerStaged: (key: string) => void attachmentBytesExcluding: (selfKey: string) => number diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index 008646e0e1..410ebd8287 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -470,6 +470,7 @@ export class FlowChatViewHost implements ChatViewHost { return taken } setComposerStaged = () => {} + setComposerHasDraft = () => {} clearComposerStaged = () => {} attachmentBytesExcluding = () => 0 diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 65f06b566d..2499911efd 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -228,6 +228,8 @@ export interface SessionRuntime { } const runtimes = new SvelteMap() +// Sessions whose runtime is still restoring its chat (initRuntime pending). +const initializing = new Set() function emptyFlow(): Flow { return { @@ -990,7 +992,10 @@ export function getOrCreateRuntime(session: Session): SessionRuntime { if (!runtime) { runtime = createRuntime(session) runtimes.set(session.id, runtime) - initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e)) + initializing.add(session.id) + initRuntime(runtime, session) + .catch((e) => console.error('Failed to init session runtime', e)) + .finally(() => initializing.delete(session.id)) } return runtime } @@ -1040,6 +1045,9 @@ onRemoteTurnEnd((sessionId, chatId) => { async function applyRemoteTurnEnd(sessionId: string, chatId: string): Promise { const runtime = runtimes.get(sessionId) if (!runtime) { + // The driving tab may have rotated to a new chat; follow it, or the next + // read and a later runtime would open the old one. + setSessionChatId(sessionId, chatId) peeks.delete(sessionId) return } @@ -1455,10 +1463,13 @@ const visitOrder: string[] = [] function isEvictable(runtime: SessionRuntime): boolean { const m = runtime.manager return ( + // initRuntime has no cancellation: evicted mid-way, it would go on to restore + // the chat's jobs into a manager nothing owns, which then polls them. + !initializing.has(runtime.sessionId) && !m.loading && !m.sendInFlight && m.instructions.trim() === '' && - m.queuedMessage.trim() === '' && + !m.hasUnsentInput && !m.backgroundJobs.some(isLiveJob) && !runtime.hasEditorCells() )