From 2a6368f0410c573850b542c7dbe1a2304cfa7f1a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 05:08:37 -0500 Subject: [PATCH] fix(desktop): send a skill's kickoff into the tab that invoked it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/work` typed into a fresh Cmd+T tab loaded the skill in that tab and printed "⚡ loading skill: work" there, then fired the skill's kickoff prompt as a user message into whatever conversation was on screen. The dispatcher resolves its target once, through resolveTargetSessionId, and every other consumer of that answer already honors it: the output writer binds to the target's stored id, and the busy gate reads the target's own state. The send did not — `submitPromptText(message)` passed no target at all, so submit fell back to `activeSessionIdRef`, which names the foreground chat. #71805 fixed the two sibling leaks in this same function; this is the third and the one that actually moved the user's prompt. Forward the resolved pair instead. Every target the dispatcher serves — a tile, a background queue drain, a session this very call created — was hitting the same fallback, so the fix covers the class rather than the tab case that surfaced it. --- .../hooks/use-prompt-actions/index.test.tsx | 50 +++++++++++++++++++ .../session/hooks/use-prompt-actions/slash.ts | 11 +++- 2 files changed, 60 insertions(+), 1 deletion(-) 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 770632ddc9d9..28fd021fcf10 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 @@ -1167,6 +1167,56 @@ describe('usePromptActions slash.exec dispatch payloads', () => { $queuedPromptsBySession.set({}) }) + it("sends a skill's kickoff into the TAB that invoked it, not the foreground chat", async () => { + // `/work` in a fresh ⌘T tab: slash.exec returns a skill dispatch whose + // `message` is the kickoff prompt. The dispatcher resolved the tab as its + // target, printed "⚡ loading skill" there — then submitted the kickoff + // with no target at all, so submit re-resolved from activeSessionIdRef and + // fired it as a user message into whatever conversation was on screen. + const tabRuntimeId = 'tab-runtime' + const tabStoredId = 'tab-stored' + + $queuedPromptsBySession.set({}) + publishSessionState(tabRuntimeId, createClientSessionState(tabStoredId)) + + const submitted: (Record | undefined)[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'prompt.submit') { + submitted.push(params) + } + + return ( + method === 'slash.exec' + ? { type: 'skill', name: 'work', message: 'Load the work skill, then: fix the tab bug' } + : {} + ) as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId="foreground-stored" + /> + ) + + await handle!.submitText('/work fix the tab bug', { sessionId: tabRuntimeId }) + + expect(submitted).toEqual([ + expect.objectContaining({ + session_id: tabRuntimeId, + text: 'Load the work skill, then: fix the tab bug' + }) + ]) + + dropSessionState(tabRuntimeId) + $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/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 07d80f02291d..f8f044ec028f 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 @@ -285,7 +285,16 @@ export function useSlashCommand(deps: SlashCommandDeps) { return } - await submitPromptText(message) + // Submit into the session this command was resolved against — the + // same pair the output writer and the busy gate above already use. + // Bare `submitPromptText(message)` let submit re-resolve from + // `activeSessionIdRef`, which names the FOREGROUND chat: a `/work` + // typed into a fresh ⌘T tab loaded the skill in that tab, printed + // "⚡ loading skill" there, then fired its kickoff as a user message + // into whatever conversation was on screen. Every other target the + // dispatcher serves (tile, background queue drain, a session created + // by this very call) had the same leak. + await submitPromptText(message, { sessionId, storedSessionId }) } try {