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()