fix(desktop): /goal clear removes the Goal paused card immediately

`/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
This commit is contained in:
Teknium
2026-08-18 16:33:38 -07:00
parent 23c1c9815f
commit 51013e9ae0
3 changed files with 83 additions and 0 deletions
@@ -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(
<Harness onReady={h => (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(
<Harness onReady={h => (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
@@ -265,6 +265,17 @@ export function useSlashCommand(deps: SlashCommandDeps) {
dispatch: NonNullable<ReturnType<typeof parseCommandDispatch>>
): Promise<void> => {
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
+8
View File
@@ -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()