From 51013e9ae07f9dc4dc41b2c9c6cc1c4070dc8ca5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:33:38 -0700 Subject: [PATCH] fix(desktop): /goal clear removes the Goal paused card immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/goal clear` (and pause/resume/status) can come back from the gateway as a TYPED `{ type: "exec" }` command dispatch instead of the plain `{ output }` slash.exec shape. The typed exec/plugin branch in use-prompt-actions/slash.ts rendered the output ("✓ Goal cleared.") and returned immediately — it never reached the goal-store sync that the plain-output path runs (`applyGoalStatusText`). The composer status stack therefore kept showing the stale "Goal paused" card, with the old goal text, until the chat was left and reopened (which re-hydrates via `refreshSessionGoal`). Fix: in the typed exec/plugin dispatch branch, when the command is `goal`, mirror the dispatch output into the goal store via `applyGoalStatusText(sessionId, output)` before rendering — exactly what the plain-output path already does. This covers the whole sibling class (clear/pause/resume/status/done) since the store's text parser already understands every /goal output shape; set (`send` dispatch notice) was already handled. Tests: - use-prompt-actions/index.test.tsx: typed exec `✓ Goal cleared.` removes the session's goal entry immediately (#80348), and typed exec `▶ Goal resumed:` flips a paused card back to active. - store/goals.test.ts: `✓ Goal cleared.` output clears a paused goal. Fixes #80348 --- .../hooks/use-prompt-actions/index.test.tsx | 64 +++++++++++++++++++ .../session/hooks/use-prompt-actions/slash.ts | 11 ++++ apps/desktop/src/store/goals.test.ts | 8 +++ 3 files changed, 83 insertions(+) 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 0f588ea51a..178627865f 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 @@ -9,6 +9,7 @@ import { textPart } from '@/lib/chat-messages' import { createClientSessionState } from '@/lib/chat-runtime' import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer' import { $queuedPromptsBySession, getQueuedPrompts } from '@/store/composer-queue' +import { $goalsBySession, setSessionGoal } from '@/store/goals' import { $hudMode } from '@/store/hud' import { $notifications, clearNotifications } from '@/store/notifications' import { @@ -1203,6 +1204,69 @@ describe('usePromptActions slash.exec dispatch payloads', () => { expect(renderedText).not.toContain('/goal: no output') }) + it('clears the goal card when /goal clear returns a typed exec dispatch (#80348)', async () => { + // The gateway can answer `/goal clear` with a TYPED `{ type: "exec" }` + // dispatch instead of the plain `{ output }` shape. The typed branch used + // to render and return without touching the goal store, so the stale + // "Goal paused" card kept showing until the chat was reopened. + setSessionGoal(RUNTIME_SESSION_ID, { + status: 'paused', + title: 'ship the release notes', + updatedAt: Date.now() + }) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'slash.exec') { + return { type: 'exec', output: '✓ Goal cleared.' } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> + ) + + await handle!.submitText('/goal clear') + + expect($goalsBySession.get()[RUNTIME_SESSION_ID]).toBeUndefined() + + $goalsBySession.set({}) + }) + + it('updates the goal card live when /goal resume returns a typed exec dispatch', async () => { + // Sibling of the clear path: a typed exec `▶ Goal resumed: …` must flip + // the paused card back to active without a chat reopen. + setSessionGoal(RUNTIME_SESSION_ID, { + status: 'paused', + title: 'ship the release notes', + updatedAt: Date.now() + }) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'slash.exec') { + return { type: 'exec', output: '▶ Goal resumed: ship the release notes' } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> + ) + + await handle!.submitText('/goal resume') + + expect($goalsBySession.get()[RUNTIME_SESSION_ID]).toMatchObject({ + status: 'active', + title: 'ship the release notes' + }) + + $goalsBySession.set({}) + }) + it('queues the /goal kickoff instead of dropping it when the session is busy (#63352)', async () => { // The backend sets the goal the moment slash.exec runs — dropping the // returned kickoff message because busyRef was true left a goal the agent 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 b4dff0f95d..3d80e420e3 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 @@ -265,6 +265,17 @@ export function useSlashCommand(deps: SlashCommandDeps) { dispatch: NonNullable> ): Promise => { if (dispatch.type === 'exec' || dispatch.type === 'plugin') { + // `/goal clear|pause|resume|status` can come back as a TYPED exec + // dispatch (command.dispatch routing) instead of the plain-output + // shape handled below. This branch used to render and return + // without touching the goal store, so "✓ Goal cleared." printed + // while the stale "Goal paused" card kept showing until the chat + // was reopened (#80348). Mirror the output into the store exactly + // like the plain-output path does. + if (name === 'goal' && dispatch.output) { + applyGoalStatusText(sessionId, dispatch.output) + } + renderSlashOutput(dispatch.output ?? '(no output)') return diff --git a/apps/desktop/src/store/goals.test.ts b/apps/desktop/src/store/goals.test.ts index 61c00dd487..5390bd7c32 100644 --- a/apps/desktop/src/store/goals.test.ts +++ b/apps/desktop/src/store/goals.test.ts @@ -57,6 +57,14 @@ describe('goal store', () => { expect($goalsBySession.get().s1).toBeUndefined() }) + it('clears immediately on /goal clear output', () => { + applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship another feature') + applyGoalStatusText('s1', '⏸ Goal paused — 20/20 turns used. Use /goal resume to keep going.') + applyGoalStatusText('s1', '✓ Goal cleared.') + + expect($goalsBySession.get().s1).toBeUndefined() + }) + it('cancels pending done clears when replacing a goal', () => { vi.useFakeTimers()