diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 576714c49561..770632ddc9d9 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1122,6 +1122,51 @@ describe('usePromptActions slash.exec dispatch payloads', () => { $queuedPromptsBySession.set({}) }) + it('binds slash output and the busy queue to the TARGET session, not the foreground selection', async () => { + // A tile (⌘T tab, split pane) routes its slash commands through this hook + // with an explicit runtime id while the foreground selection names a + // different conversation. Binding the output writer to the foreground + // selection re-keyed the tile's cache entry onto the primary's stored + // session and parked its queued payload on the primary's queue. + const tileRuntimeId = 'tile-runtime' + const tileStoredId = 'tile-stored' + + $queuedPromptsBySession.set({}) + publishSessionState(tileRuntimeId, { + ...createClientSessionState(tileStoredId), + busy: true + }) + + const boundStoredIds: (null | string | undefined)[] = [] + + const requestGateway = vi.fn( + async (method: string) => (method === 'slash.exec' ? { type: 'send', message: 'run it in the tab' } : {}) as never + ) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + onUpdateState={(_sessionId, storedSessionId) => boundStoredIds.push(storedSessionId)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId="primary-stored" + /> + ) + + await handle!.submitText('/audit-only run it in the tab', { sessionId: tileRuntimeId }) + + // Every transcript write lands on the tile's own stored session. + expect(boundStoredIds).not.toHaveLength(0) + expect(new Set(boundStoredIds)).toEqual(new Set([tileStoredId])) + // …and the kickoff queues against the tile, never the foreground chat. + expect(getQueuedPrompts(tileStoredId).map(entry => entry.text)).toEqual(['run it in the tab']) + expect(getQueuedPrompts('primary-stored')).toEqual([]) + + dropSessionState(tileRuntimeId) + $queuedPromptsBySession.set({}) + }) + it('slash status header carries the command token, not the full invocation', async () => { // `/goal ` used to echo the entire invocation in the mono // header AND the goal text again in the backend notice right under it. 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 b755fbc884d5..bee6192894cd 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 @@ -519,7 +519,9 @@ export function usePromptActions({ if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) { triggerHaptic('selection') - await executeSlashCommand(visibleText) + // Forward the explicit target (background queue drain, tile) — dropping + // it ran the command against whatever chat happened to be in front. + await executeSlashCommand(visibleText, options?.sessionId ? { sessionId: options.sessionId } : undefined) return true } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 72f5b571d90e..07d80f02291d 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -24,7 +24,6 @@ import { $petGenInput, openPetGenerate } from '@/store/pet-generate' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $connection, - $selectedStoredSessionId, $sessions, $yoloActive, resolveComposerSessionKey, @@ -167,9 +166,15 @@ export function useSlashCommand(deps: SlashCommandDeps) { return null } - // A long-running command can finish after a session switch. Keep its - // output bound to the stored session selected at invocation time. - const storedSessionId = selectedStoredSessionIdRef.current + // Bind output to the TARGET session's own stored id, snapshotted now so + // a command that outlives a session switch still lands on the right + // chat. NOT the foreground selection: a tile (⌘T tab, split) runs its + // slash commands through this hook with an explicit runtime id while + // the selection names a different conversation, and passing that down + // to updateSessionState re-keyed the tile's cache entry onto the + // primary's stored session. Fall back to the selection only for a + // session with no published state yet (a draft this call just created). + const storedSessionId = $sessionStates.get()[sessionId]?.storedSessionId ?? selectedStoredSessionIdRef.current // Header carries the command token only. The full invocation would // duplicate long args — `/goal ` echoed the whole goal in the @@ -257,10 +262,7 @@ export function useSlashCommand(deps: SlashCommandDeps) { // view's — see isTargetSessionBusy. `busyRef` mirrors whatever chat // is on screen, while this command runs against the session // resolveTargetSessionId picked, routinely a different one. - const sessionStates = $sessionStates.get() - const targetState = sessionStates[sessionId] - - if (isTargetSessionBusy(sessionStates, sessionId, busyRef.current)) { + if (isTargetSessionBusy($sessionStates.get(), sessionId, busyRef.current)) { // The backend already executed the command — for `/goal ` // the goal is set and `message` is its kickoff prompt. Dropping // it here loses the kickoff silently (the goal exists but the @@ -268,15 +270,11 @@ export function useSlashCommand(deps: SlashCommandDeps) { // queue instead: it fires when the running turn settles, and the // queue panel above the composer shows it in the meantime. // - // Key off the storedSessionId resolved at invocation time (same - // value the output writer is bound to) rather than re-reading the - // globals here — a session switch between dispatch and this branch - // would otherwise park the kickoff on whichever chat is now in - // front. Fall back through the live selection for a session whose - // cache entry hasn't landed yet. - const storedId = storedSessionId || targetState?.storedSessionId || $selectedStoredSessionId.get() - - const queueKey = resolveComposerSessionKey(storedId, $sessions.get()) || storedId || sessionId + // Park it on the same stored session the output writer is bound to + // rather than re-reading the globals here — a session switch between + // dispatch and this branch would otherwise queue the kickoff on + // whichever chat is now in front. + const queueKey = resolveComposerSessionKey(storedSessionId, $sessions.get()) || storedSessionId || sessionId if (enqueueQueuedPrompt(queueKey, { attachments: [], text: message })) { renderSlashOutput('session busy — message queued to send when the current turn finishes')