fix(ai-sessions): release the staged prompt when a send is refused

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-31 21:13:12 +02:00
co-authored by Claude Opus 5
parent d441f3c5a3
commit ad8fdb24bf
4 changed files with 11 additions and 9 deletions
@@ -2005,6 +2005,10 @@ export class AIChatManager {
options.files
)
}
// Handed off, so this manager no longer holds it. Only sendRequestImpl clears
// the field and a refusal never reaches it; left set, it reads to the
// auto-resume guard and the sidebar's draft cue as text the user is writing.
this.instructions = ''
}
/** Send the queued message, if there is one, as its own turn. The queue only
@@ -265,9 +265,10 @@ describe('AIChatManager cross-tab run guard', () => {
expect(mocks.runChatLoop).not.toHaveBeenCalled()
expect(manager.displayMessages).toHaveLength(2)
expect(manager.messages).toHaveLength(2)
// The prompt is handed back rather than lost, so the retry is still the
// user's to run once the other tab finishes.
expect(manager.instructions).toBe('first')
// Handed back rather than lost — and off the manager, which reports a
// non-empty `instructions` to the sidebar as text the user is writing.
expect(manager.queuedMessage).toBe('first')
expect(manager.instructions).toBe('')
})
// A turn flushes its queued message by re-entering sendRequest, and the lock
@@ -187,8 +187,8 @@ export async function withSessionRunLock<T>(
}
}
/** What exclusion amounts to with no lock to take: refuse while another tab's run
* is visibly on screen, and otherwise go.
/** What exclusion amounts to with no lock to take: nothing beyond the refusal
* `withSessionRunLock` already made on a visibly running turn.
*
* Not mutual exclusion, and cannot be made into it — a throttled hidden driver
* goes silent before its turn ends and is reaped as dead, and no probe tells a
@@ -278,8 +278,6 @@ describe('sessionState IndexedDB persistence', () => {
await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['keep']))
})
// A mirrored delete that announced itself would be mirrored straight back, and
// the two tabs would trade the same message and transaction without end.
it('removes a mirrored delete without announcing it', async () => {
const user = freshUser()
await login(user)
@@ -289,8 +287,7 @@ describe('sessionState IndexedDB persistence', () => {
await deleteSessionRecord('mirrored', false)
expect(deleteBroadcasts).toEqual([])
// Still genuinely removed — silence is not a no-op, it is what collects the
// row a write racing the other tab's delete left behind.
// Silence is not a no-op: the row still goes.
await rehydrate(user)
await vi.waitFor(() => expect(sessionState.sessions).toEqual([]))
})