fix: keep unsent input, init and chat rotation safe from runtime eviction

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-18 16:56:46 +02:00
co-authored by Claude Opus 5
parent 62e9cac57b
commit 3e70d0c8ef
5 changed files with 38 additions and 2 deletions
@@ -378,6 +378,7 @@
)
$effect(() => {
chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
chatHost.setComposerHasDraft(composerKey, !draft.isEmpty || pendingFileBytes > 0)
})
$effect(() => () => chatHost.clearComposerStaged(composerKey))
@@ -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<string>()
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).
@@ -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
@@ -470,6 +470,7 @@ export class FlowChatViewHost implements ChatViewHost {
return taken
}
setComposerStaged = () => {}
setComposerHasDraft = () => {}
clearComposerStaged = () => {}
attachmentBytesExcluding = () => 0
@@ -228,6 +228,8 @@ export interface SessionRuntime {
}
const runtimes = new SvelteMap<string, SessionRuntime>()
// Sessions whose runtime is still restoring its chat (initRuntime pending).
const initializing = new Set<string>()
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<void> {
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()
)