From bc01055e936ab697c8170c680aea585070e098bf Mon Sep 17 00:00:00 2001 From: BearHuddleston Date: Sun, 20 Sep 2026 04:30:05 -0500 Subject: [PATCH] fix(desktop): follow first-send draft assignment on rejection --- ...e-composer-submit.restore-scoping.test.tsx | 501 ++++++++++++++++++ .../hooks/use-composer-submit.test.tsx | 41 -- .../composer/hooks/use-composer-submit.ts | 27 +- .../session/hooks/use-prompt-actions/index.ts | 3 +- .../hooks/use-prompt-actions/submit.ts | 7 +- .../session/hooks/use-prompt-actions/utils.ts | 2 + .../use-session-actions/create-overrides.ts | 8 + .../hooks/use-session-actions/index.ts | 1 + 8 files changed, 541 insertions(+), 49 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-submit.restore-scoping.test.tsx diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.restore-scoping.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.restore-scoping.test.tsx new file mode 100644 index 0000000000..307fab3b66 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.restore-scoping.test.tsx @@ -0,0 +1,501 @@ +import { useStore } from '@nanostores/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { startTransition, Suspense, useLayoutEffect, useRef } from 'react' +import { afterEach, expect, it, vi } from 'vitest' + +import { createSessionRpcDispatcher } from '@/app/contrib/session-rpc-dispatcher' +import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from '@/app/routes' +import { usePromptActions } from '@/app/session/hooks/use-prompt-actions' +import { useSessionActions } from '@/app/session/hooks/use-session-actions' +import { useSessionStateCache } from '@/app/session/hooks/use-session-state-cache' +import { + announceNewSessionDraftKey, + clearSessionDraft, + mainComposerScope, + stashSessionDraft, + takeSessionDraft +} from '@/store/composer' +import { requestGatewayForAgent, requestGatewayForProfile } from '@/store/gateway' +import { $newChatRoute } from '@/store/profile' +import { + $activeSessionId, + $selectedStoredSessionId, + _resetSessionOwnerHintsForTests, + setActiveSessionId, + setAwaitingResponse, + setBusy, + setMessages, + setSelectedStoredSessionId, + setSessions, + setYoloActive +} from '@/store/session' +import { _resetSessionOwnerHoldsForTests } from '@/store/session-states' + +import { composerPlainText } from '../rich-editor' +import type { ChatBarProps } from '../types' + +import { useComposerDraft } from './use-composer-draft' +import { useComposerSubmit } from './use-composer-submit' + +// Substitute the assistant-ui adapter and transport, not the draft DOM, +// subscription, persistence, creation, submit actions, cache or dispatcher. +// One test injects onSubmit to exercise a direct rejected promise too. +const composer = vi.hoisted(() => { + let text = '' + const listeners = new Set<() => void>() + + const runtime = { + getState: () => ({ text }), + subscribe: (listener: () => void) => { + listeners.add(listener) + + return () => { + listeners.delete(listener) + } + } + } + + const api = { + setText: (value: string) => { + text = value + listeners.forEach(listener => listener()) + } + } + + return { aui: { composer: () => api }, runtime } +}) + +vi.mock('@assistant-ui/react', () => ({ + useAui: () => composer.aui, + useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => + selector({ composer: composer.runtime.getState() }), + useComposerRuntime: () => composer.runtime +})) +vi.mock('@/store/gateway', async original => ({ + ...(await original>()), + requestGatewayForAgent: vi.fn(), + requestGatewayForProfile: vi.fn(), + retainGatewayForAgent: vi.fn(async () => () => {}), + retainGatewayForSessionTurn: vi.fn(async () => () => {}) +})) +vi.mock('@/store/profile', async original => ({ + ...(await original>()), + ensureGatewayAgent: vi.fn(async () => undefined), + ensureGatewayProfile: vi.fn(async () => undefined) +})) + +let handles: { + cache: ReturnType + draft: ReturnType + submit: ReturnType +} + +let route: string | null = 'stored-B' +const busyRef = { current: false } +const getRouteToken = () => `${route ? sessionRoute(route) : NEW_CHAT_ROUTE}::` + +interface HarnessProps { + pendingScope?: string + suspend?: Promise + onSubmit?: ChatBarProps['onSubmit'] +} + +function PendingNavigation({ suspend }: Pick) { + if (suspend) { + throw suspend + } + + return null +} + +function Harness({ pendingScope, suspend, onSubmit }: HarnessProps) { + const active = useStore($activeSessionId) + const selected = useStore($selectedStoredSessionId) + const attachments = useStore(mainComposerScope.$attachments) + const queueEditRef = useRef(null) + const creatingSessionRef = useRef(false) + + const cache = useSessionStateCache({ + activeSessionId: active, + selectedStoredSessionId: selected, + busyRef, + setBusy, + setAwaitingResponse, + setMessages + }) + + const request = createSessionRpcDispatcher({ + ambientRequest: async () => { + throw new Error('unexpected ambient request') + }, + runtimeIdByStoredSessionIdRef: cache.runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef: cache.selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef: cache.sessionStateByRuntimeIdRef + }) + + const sessionActions = useSessionActions({ + activeSessionId: active, + selectedStoredSessionId: selected, + ...cache, + busyRef, + creatingSessionRef, + getRoutedStoredSessionId: () => route, + getRouteToken, + navigate: to => { + route = routeSessionId(String(to)) + }, + requestGateway: request + }) + + const actions = usePromptActions({ + activeSessionId: active, + ...cache, + busyRef, + branchCurrentSession: async () => false, + createBackendSessionForSend: sessionActions.createBackendSessionForSend, + getRoutedStoredSessionId: () => route, + getRouteToken, + handleSkinCommand: () => '', + openMemoryGraph: () => {}, + refreshSessions: async () => {}, + requestGateway: request, + resumeStoredSession: async () => { + throw new Error('unexpected resume') + }, + startFreshSessionDraft: () => {}, + sttEnabled: false + }) + + const activeQueueSessionKey = pendingScope ?? selected + + const draft = useComposerDraft({ + activeQueueSessionKey, + focusKey: active, + inputDisabled: false, + queueEditRef, + sessionId: active + }) + + const submit = useComposerSubmit({ + ...draft, + activeQueueSessionKey, + attachments, + busy: false, + compacting: false, + disabled: false, + inputDisabled: false, + drainNextQueued: async () => false, + exitQueuedEdit: () => false, + onCancel: () => {}, + onSteer: actions.redirectPrompt, + onSteerHidden: actions.injectHiddenPrompt, + onSubmit: onSubmit ?? actions.submitText, + queueCurrentDraft: () => false, + queueEdit: null, + queuedPrompts: [], + sessionId: active + }) + + // Keep the test's actions bound to the committed tree during a transition. + useLayoutEffect(() => { + handles = { cache, draft, submit } + }) + + return ( + <> +
+ + + ) +} + +function view(props: HarnessProps = {}) { + return ( + Pending navigation
}> + + + ) +} + +function navigate(session: 'A' | 'B' | null) { + route = session ? `stored-${session}` : null + setSelectedStoredSessionId(route) + setActiveSessionId(session ? `rt-${session}` : null) + busyRef.current = false + setBusy(false) +} + +function seed(props: HarnessProps = {}, initial: 'B' | null = 'B') { + setSessions( + (initial ? ['A', 'B'] : ['A']).map(id => ({ + id: `stored-${id}`, + profile: 'default', + source: 'desktop', + message_count: 1, + ended_at: null, + input_tokens: 0, + output_tokens: 0, + is_active: true, + last_active: 1, + started_at: 1, + model: null, + preview: null, + title: null, + tool_call_count: 0 + })) + ) + navigate(initial) + const rendered = render(view(props)) + act(() => { + handles.cache.ensureSessionState('rt-A', 'stored-A') + + if (initial) { + handles.cache.ensureSessionState('rt-B', 'stored-B') + } + }) + + return rendered +} + +const editorText = () => composerPlainText(handles.draft.editorRef.current!).trim() +const flushDraft = () => act(() => window.dispatchEvent(new Event('pagehide'))) + +afterEach(() => { + cleanup() + mainComposerScope.clear() + clearSessionDraft('stored-A') + clearSessionDraft('stored-B') + clearSessionDraft(null) + _resetSessionOwnerHintsForTests({ storage: true }) + _resetSessionOwnerHoldsForTests() + setYoloActive(false) + announceNewSessionDraftKey(null) + $newChatRoute.set(null) + window.localStorage.clear() + vi.resetAllMocks() + setActiveSessionId(null) + setSelectedStoredSessionId(null) + setSessions([]) + setBusy(false) + setAwaitingResponse(false) + setMessages([]) +}) + +it('keeps a late rejected B submit out of A’s draft and next ordinary Send (#66661)', async () => { + let rejectSubmit!: (error: Error) => void + vi.mocked(requestGatewayForProfile).mockImplementation(async (_profile, method) => { + if (method !== 'prompt.submit') { + throw new Error(`unexpected ${method}`) + } + + return await new Promise((_resolve, reject) => { + rejectSubmit = reject + }) + }) + stashSessionDraft('stored-A', 'A’s own next question', []) + stashSessionDraft('stored-B', 'B’s research report', []) + seed() + act(() => handles.submit.submitDraft()) + await waitFor(() => + expect(requestGatewayForProfile).toHaveBeenCalledWith( + 'default', + 'prompt.submit', + expect.objectContaining({ session_id: 'rt-B', text: 'B’s research report' }), + expect.anything(), + undefined + ) + ) + + act(() => navigate('A')) + expect(editorText()).toBe('A’s own next question') + // The real action converts a transport rejection into accepted === false. + await act(async () => { + rejectSubmit(new Error('fixture transport rejected B submit')) + }) + expect(editorText()).toBe('A’s own next question') + flushDraft() + expect(takeSessionDraft('stored-A').text.trim()).toBe('A’s own next question') + expect(takeSessionDraft('stored-B').text.trim()).toBe('B’s research report') + + vi.mocked(requestGatewayForProfile).mockResolvedValue({ status: 'streaming' }) + act(() => handles.submit.submitDraft()) + await waitFor(() => expect(requestGatewayForProfile).toHaveBeenCalledTimes(2)) + expect( + vi.mocked(requestGatewayForProfile).mock.calls.map(([, method, params]) => ({ + method, + session: params?.session_id, + text: params?.text + })) + ).toEqual([ + { method: 'prompt.submit', session: 'rt-B', text: 'B’s research report' }, + { method: 'prompt.submit', session: 'rt-A', text: 'A’s own next question' } + ]) + act(() => navigate('B')) + expect(editorText()).toBe('B’s research report') +}) + +it('restores text and attachments by the loaded draft owner, not an uncommitted navigation render', async () => { + let rejectSubmit!: (error: Error) => void + + const onSubmit = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectSubmit = reject + }) + ) + + const attachmentA = { id: 'url-a', kind: 'url' as const, label: 'A reference' } + const attachmentB = { id: 'url-b', kind: 'url' as const, label: 'B reference' } + stashSessionDraft('stored-A', 'draft A', [attachmentA]) + stashSessionDraft('stored-B', 'draft B', [attachmentB]) + const rendered = seed({ onSubmit }) + act(() => handles.submit.submitDraft()) + expect(onSubmit).toHaveBeenCalledWith(expect.stringContaining('draft B'), { + attachments: [attachmentB], + composerScope: 'stored-B' + }) + act(() => navigate('A')) + + // React starts rendering B again but cannot commit it. The render-time + // queue ref now says B; the actual editor and draft-swap owner remain A. + const suspended = new Promise(() => {}) + await act(async () => { + startTransition(() => rendered.rerender(view({ onSubmit, pendingScope: 'stored-B', suspend: suspended }))) + }) + expect(handles.draft.activeQueueSessionKeyRef.current).toBe('stored-B') + expect(editorText()).toBe('draft A') + await act(async () => { + rejectSubmit(new Error('fixture rejected onSubmit promise')) + }) + expect(editorText()).toBe('draft A') + expect(mainComposerScope.$attachments.get()).toEqual([attachmentA]) + flushDraft() + expect(takeSessionDraft('stored-A').text.trim()).toBe('draft A') + expect(takeSessionDraft('stored-A').attachments).toEqual([attachmentA]) + expect(takeSessionDraft('stored-B').text.trim()).toBe('draft B') + expect(takeSessionDraft('stored-B').attachments).toEqual([attachmentB]) + + // Abandon the pending render, then really switch. Ordinary draft restore + // recovers B; a rejection while B stays loaded must still repaint it. + rendered.rerender(view({ onSubmit })) + act(() => navigate('B')) + expect(editorText()).toBe('draft B') + expect(mainComposerScope.$attachments.get()).toEqual([attachmentB]) + await act(async () => { + startTransition(() => rendered.rerender(view({ onSubmit, pendingScope: 'stored-A', suspend: suspended }))) + }) + expect(handles.draft.activeQueueSessionKeyRef.current).toBe('stored-A') + onSubmit.mockResolvedValueOnce(false) + await act(async () => handles.submit.submitDraft()) + expect(onSubmit).toHaveBeenLastCalledWith(expect.stringContaining('draft B'), { + attachments: [attachmentB], + composerScope: 'stored-B' + }) + expect(editorText()).toBe('draft B') + expect(mainComposerScope.$attachments.get()).toEqual([attachmentB]) + expect(takeSessionDraft('stored-B').text.trim()).toBe('draft B') +}) + +it.each([ + { destination: 'B', beforeCreateReturns: false }, + { destination: 'A', beforeCreateReturns: false }, + { destination: null, beforeCreateReturns: false }, + { destination: 'A', beforeCreateReturns: true } +] as const)( + 'restores a rejected real first send to its created scope ($destination, before create returns: $beforeCreateReturns)', + async ({ destination, beforeCreateReturns }) => { + const attachment = { id: 'url-first', kind: 'url' as const, label: 'first reference' } + stashSessionDraft(null, 'my first question', [attachment]) + stashSessionDraft('stored-A', 'A’s own draft', []) + $newChatRoute.set({ connectionId: 'connection-first-send', profile: 'default' }) + setYoloActive(beforeCreateReturns) + + let rejectRequest!: (error: Error) => void + vi.mocked(requestGatewayForAgent).mockImplementation(async (_connection, _profile, method) => { + if (method === 'session.create') { + return { session_id: 'rt-B', stored_session_id: 'stored-B', info: {} } + } + + if (method === (beforeCreateReturns ? 'config.set' : 'prompt.submit')) { + return new Promise((_resolve, reject) => { + rejectRequest = reject + }) + } + + throw new Error(`unexpected ${method}`) + }) + + seed({}, null) + act(() => handles.submit.submitDraft()) + await waitFor(() => expect(rejectRequest).toBeTypeOf('function')) + expect(route).toBe('stored-B') + expect(handles.draft.draftScopeRef.current).toBe('stored-B') + expect(editorText()).toBe('') + expect(mainComposerScope.$attachments.get()).toEqual([]) + expect(vi.mocked(requestGatewayForAgent).mock.calls.map(([, , method]) => method)).toEqual([ + 'session.create', + beforeCreateReturns ? 'config.set' : 'prompt.submit' + ]) + + if (destination !== 'B') { + // A different stored chat OR a new pre-session draft is not the owner + // of this submit, even though this submit originally captured null. + stashSessionDraft(null, 'another fresh draft', []) + act(() => navigate(destination)) + } + + // Creation can await an armed-YOLO config write after it assigns the + // stored key. Its eventual drift abort must still restore to that key. + await act(async () => rejectRequest(new Error('fixture transport rejected first send'))) + expect(editorText()).toBe( + destination === 'B' ? 'my first question' : destination === 'A' ? 'A’s own draft' : 'another fresh draft' + ) + expect(mainComposerScope.$attachments.get()).toEqual(destination === 'B' ? [attachment] : []) + flushDraft() + expect(takeSessionDraft('stored-B').text.trim()).toBe('my first question') + expect(takeSessionDraft('stored-B').attachments).toEqual([attachment]) + expect(takeSessionDraft('stored-A').text.trim()).toBe('A’s own draft') + expect(takeSessionDraft(null).text.trim()).toBe(destination === 'B' ? '' : 'another fresh draft') + act(() => navigate('B')) + expect(editorText()).toBe('my first question') + expect(mainComposerScope.$attachments.get()).toEqual([attachment]) + } +) + +it('does not treat arbitrary navigation during a real create as assignment of the rejected draft', async () => { + stashSessionDraft(null, 'unsent first question', []) + stashSessionDraft('stored-A', 'A’s own draft', []) + $newChatRoute.set({ connectionId: 'connection-aborted-create', profile: 'default' }) + let completeCreate!: (value: unknown) => void + vi.mocked(requestGatewayForAgent).mockImplementation(async (_connection, _profile, method) => { + if (method === 'session.create') { + return new Promise(resolve => { + completeCreate = resolve + }) + } + + if (method === 'session.close') { + return {} + } + + throw new Error(`unexpected ${method}`) + }) + + seed({}, null) + act(() => handles.submit.submitDraft()) + await waitFor(() => expect(completeCreate).toBeTypeOf('function')) + act(() => navigate('A')) + await act(async () => completeCreate({ session_id: 'rt-B', stored_session_id: 'stored-B', info: {} })) + expect(vi.mocked(requestGatewayForAgent).mock.calls.map(([, , method]) => method)).toEqual([ + 'session.create', + 'session.close' + ]) + expect(route).toBe('stored-A') + expect(editorText()).toBe('A’s own draft') + flushDraft() + expect(takeSessionDraft('stored-A').text.trim()).toBe('A’s own draft') + expect(takeSessionDraft('stored-B').text.trim()).toBe('') + expect(takeSessionDraft(null).text.trim()).toBe('unsent first question') + act(() => navigate(null)) + expect(editorText()).toBe('unsent first question') +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx index cced81516a..89e0e559bc 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx @@ -132,7 +132,6 @@ function renderSubmitHook({ return { clearDraft, - draftScopeRef, hook, onCancel, onSteer, @@ -486,46 +485,6 @@ describe('useComposerSubmit busy-turn routing', () => { expect(onSubmit).toHaveBeenCalledWith('hello', expect.objectContaining({ composerScope: 'stored-session' })) ) }) - - it('keeps a rejected submit out of the composer after switching sessions', async () => { - let resolveSubmit!: (accepted: boolean) => void - - const pendingSubmit = new Promise(resolve => { - resolveSubmit = resolve - }) - - const { draftScopeRef, hook, loadIntoComposer, onSubmit, stashAt } = renderSubmitHook() - onSubmit.mockImplementationOnce(() => pendingSubmit) - - act(() => { - hook.result.current.dispatchSubmit('draft from session A') - }) - - draftScopeRef.current = 'stored-session-b' - - act(() => { - resolveSubmit(false) - }) - - await waitFor(() => - expect(stashAt).toHaveBeenCalledWith('stored-session', 'draft from session A', []) - ) - expect(loadIntoComposer).not.toHaveBeenCalled() - }) - - it('repaints a rejected submit while its composer remains loaded', async () => { - const { hook, loadIntoComposer, onSubmit, stashAt } = renderSubmitHook() - onSubmit.mockResolvedValueOnce(false) - - act(() => { - hook.result.current.dispatchSubmit('draft from this session') - }) - - await waitFor(() => - expect(stashAt).toHaveBeenCalledWith('stored-session', 'draft from this session', []) - ) - expect(loadIntoComposer).toHaveBeenCalledWith('draft from this session', []) - }) }) describe('useComposerSubmit with a clarify parked on the session', () => { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index ca376a3998..ef414a14ef 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -92,12 +92,24 @@ export function useComposerSubmit({ // must not publish an old session's text into the newly focused one. const dispatchSubmit = (text: string, attachments?: ComposerAttachment[], displayKind?: 'hidden') => { const submittedScope = draftScopeRef.current + let restoreScope = submittedScope const submittedAttachments = attachments ?? [] - const restore = () => { - stashAt(submittedScope, text, submittedAttachments) + // Only this operation's explicit session.create handoff may re-home a + // pre-session submit. A null → stored render can also be user navigation. + const assignment = + submittedScope === null + ? { + onComposerScopeAssigned: (scope: string) => { + restoreScope = scope + } + } + : {} - if (draftScopeRef.current === submittedScope) { + const restore = () => { + stashAt(restoreScope, text, submittedAttachments) + + if (draftScopeRef.current === restoreScope) { loadIntoComposer(text, submittedAttachments) } } @@ -108,8 +120,13 @@ export function useComposerSubmit({ void Promise.resolve( attachments - ? onSubmit(text, { attachments, composerScope: submittedScope, ...(displayKind ? { displayKind } : {}) }) - : onSubmit(text, { composerScope: submittedScope, ...(displayKind ? { displayKind } : {}) }) + ? onSubmit(text, { + attachments, + composerScope: submittedScope, + ...assignment, + ...(displayKind ? { displayKind } : {}) + }) + : onSubmit(text, { composerScope: submittedScope, ...assignment, ...(displayKind ? { displayKind } : {}) }) ) .then(accepted => void (accepted === false ? rejected() : clearSessionDraft(submittedScope))) .catch(rejected) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index c10857050f..986083e1b8 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -54,6 +54,7 @@ import type { ImageAttachResponse, SessionRedirectResponse } from '../../../types' +import type { CreateBackendSessionForSend } from '../use-session-actions/create-overrides' import { appendMidTurnUserMessage, @@ -218,7 +219,7 @@ interface PromptActionsOptions { activeSessionIdRef: MutableRefObject busyRef: MutableRefObject branchCurrentSession: () => Promise - createBackendSessionForSend: (preview?: string | null) => Promise + createBackendSessionForSend: CreateBackendSessionForSend getRoutedStoredSessionId: () => null | string getRuntimeIdForStoredSession: (storedSessionId: string) => null | string getRouteToken: () => string diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index a80ce8221d..e440ee4066 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -45,6 +45,7 @@ import { } from '../../../contrib/hooks/use-background-sync' import type { ClientSessionState } from '../../../types' import { sessionContextDrift } from '../session-context-drift' +import type { CreateBackendSessionForSend } from '../use-session-actions/create-overrides' import { resolveSessionProfile } from '../use-session-actions/utils' import { registerRecoveredRuntime, singleFlightSessionResume, takeRecoveredRuntime } from './single-flight-resume' @@ -67,7 +68,7 @@ interface SubmitPromptDeps { activeSessionIdRef: MutableRefObject busyRef: MutableRefObject copy: Translations['desktop'] - createBackendSessionForSend: (preview?: string | null) => Promise + createBackendSessionForSend: CreateBackendSessionForSend getRoutedStoredSessionId: () => null | string getRuntimeIdForStoredSession: (storedSessionId: string) => null | string getRouteToken: () => string @@ -731,7 +732,9 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { if (!sessionId) { try { - sessionId = await createBackendSessionForSend(bubbleText) + sessionId = await createBackendSessionForSend(bubbleText, undefined, { + onComposerScopeAssigned: options?.onComposerScopeAssigned + }) } catch (err) { dropOptimistic(null) releaseBusy() diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index 4c8e20a00f..b1b5f0ff13 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -703,6 +703,8 @@ export interface SubmitTextOptions { * (queue drain, steer, external submit requests): the check is a no-op * without it. */ composerScope?: string | null + /** This submit's fresh draft acquired a stored key. Never fired for navigation. */ + onComposerScopeAssigned?: (scope: string) => void /** What the transcript shows for this send, when it differs from the text * the agent receives. A `/skill` invocation expands into the whole skill * body — model-facing scaffolding the UI must never render — so the slash diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/create-overrides.ts b/apps/desktop/src/app/session/hooks/use-session-actions/create-overrides.ts index 9d49ea7c41..27c9698b91 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/create-overrides.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/create-overrides.ts @@ -10,10 +10,18 @@ * no caller needs one. */ export interface SessionCreateOverrides { + /** Renderer-only handoff, fired at the stored-id assignment before navigation. */ + onComposerScopeAssigned?: (scope: string) => void reasoningEffort?: string title?: string } +export type CreateBackendSessionForSend = ( + preview?: string | null, + seedMessages?: SessionSeedMessage[], + createOverrides?: SessionCreateOverrides +) => Promise + export interface SessionSeedMessage { content: string display_kind?: 'hidden' diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 75b10b74a6..f4696bb950 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -820,6 +820,7 @@ export function useSessionActions({ // Anything still parked under the pre-session draft bucket belongs // to this chat now (#114122); the composer moves it on scope swap. announceNewSessionDraftKey(stored) + createOverrides?.onComposerScopeAssigned?.(stored) navigate(sessionRoute(stored), { replace: true }) // Other windows (e.g. the main window when this is the pop-out) can't // see this session until they re-pull the shared list.