diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 01d782ec8c..68f209e897 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -1962,8 +1962,13 @@ export class AIChatManager { // A synthetic send carries none of the user's text; its caller owns the // instructions it set and unwinds them itself. if (options.synthetic || options.queued) return + // The same fallback sendRequestImpl applies: the programmatic senders (an + // editor's AI Fix, Ask AI) leave the prompt on `this.instructions` and pass + // no `instructions` option, so reading the option alone would hand back an + // empty prompt and lose what they were about to send. + const instructions = options.instructions ?? this.instructions const restored = this.aiChatInput?.restoreInstructions( - options.instructions ?? '', + instructions, options.pastes ?? [], options.images ?? [], options.files ?? [] @@ -1972,9 +1977,9 @@ export class AIChatManager { // queue so it is still the user's to send rather than silently gone. The // queue holds plain text, so the pastes are expanded into it — parked as // bare tokens they would point at blobs nothing holds any more. - if (restored !== true && options.instructions) { + if (restored !== true && instructions) { this.restoreToInput( - expanded(chatDraft(options.instructions, options.pastes ?? [])), + expanded(chatDraft(instructions, options.pastes ?? [])), options.images, options.files ) diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index 15b640e33b..9dadc6a481 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -618,7 +618,14 @@ export default class HistoryManager { if (!db) return 'unavailable' try { const chat = await db.get('chats', id) - if (!chat) return 'missing' + 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) { diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts index 8e8fcc447c..bdd3362962 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts @@ -810,6 +810,25 @@ describe('HistoryManager.reloadChat', () => { 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: () => { diff --git a/frontend/src/lib/components/copilot/chat/artifacts/artifactsState.svelte.ts b/frontend/src/lib/components/copilot/chat/artifacts/artifactsState.svelte.ts index 5a45e681f5..05bf9b7fd3 100644 --- a/frontend/src/lib/components/copilot/chat/artifacts/artifactsState.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/artifacts/artifactsState.svelte.ts @@ -154,6 +154,11 @@ export class SessionArtifactsStore { // previous one's row placed into its list, nor one of its own rows dropped. if (this.#sessionId !== loaded) return if (read.state === 'unavailable') return + // Sampled after the awaits, once a load racing us may already have landed. + // A write below both derives from a list that load has not filled yet and + // bumps the token it checks on arrival, so it would leave this session + // holding only what this notification carried until the next write. + const loadInFlight = this.loading if (read.state === 'loaded') { // Weighed against what is held, not placed over it: an ordinary artifact // whose persist the store refused lives only in memory here, and taking @@ -161,10 +166,13 @@ export class SessionArtifactsStore { // a different one. const held = this.artifacts.find((a) => a.id === artifactId) this.#place(furtherAlong(read.artifact, held) ?? read.artifact) + if (loadInFlight) await this.#load() return } const next = this.artifacts.filter((a) => a.id !== artifactId) - if (next.length !== this.artifacts.length) this.#applyWrite(next) + if (next.length === this.artifacts.length) return + this.#applyWrite(next) + if (loadInFlight) await this.#load() } async get(id: string): Promise { diff --git a/frontend/src/lib/components/copilot/chat/artifacts/artifactsState.test.ts b/frontend/src/lib/components/copilot/chat/artifacts/artifactsState.test.ts index f79937337b..f9d1ccb16e 100644 --- a/frontend/src/lib/components/copilot/chat/artifacts/artifactsState.test.ts +++ b/frontend/src/lib/components/copilot/chat/artifacts/artifactsState.test.ts @@ -588,6 +588,29 @@ describe('cross-tab artifact sync', () => { expect(broadcasts).toEqual([]) }) + // A tab opening the session reads the whole list; a notification landing inside that read + // writes from the list it has so far — empty — and invalidates the read on its way out. + it('keeps the rest of the session when a notification lands mid-load', async () => { + const kept = await store.create('s1', { name: 'Plan', content: 'p' }) + const notified = await store.create('s1', { name: 'Notes', content: 'n' }) + + const opening = new stateMod.SessionArtifactsStore() + let release: () => void = () => {} + const held = new Promise((r) => (release = r)) + const listForSession = dbMod.listArtifactsForSession + vi.spyOn(dbMod, 'listArtifactsForSession').mockImplementationOnce(async (id: string) => { + await held + return listForSession(id) + }) + + const loading = opening.setSession('s1') + await opening.applyRemoteArtifact(notified.id) + release() + await loading + + expect(opening.artifacts.map((a) => a.id).sort()).toEqual([kept.id, notified.id].sort()) + }) + it('drops an artifact the other tab removed', async () => { const created = await store.create('s1', { name: 'Notes', content: 'x' }) await watcher.applyRemoteArtifact(created.id)