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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user