diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index d6128bcb..c0ad9a48 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -276,6 +276,11 @@ describe('chatStore history mapping', () => {
]
expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([
+ {
+ id: 'goal-command',
+ type: 'user_text',
+ content: '/goal --tokens 2k ship the smoke test',
+ },
{
id: 'goal-output',
type: 'goal_event',
@@ -314,6 +319,11 @@ describe('chatStore history mapping', () => {
]
expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([
+ {
+ id: 'goal-command',
+ type: 'user_text',
+ content: '/goal ship the replacement target',
+ },
{
id: 'goal-output',
type: 'goal_event',
@@ -355,6 +365,18 @@ describe('chatStore history mapping', () => {
timestamp: '2026-04-06T00:00:02.000Z',
content: 'Goal marked complete.',
},
+ {
+ id: 'goal-status-command',
+ type: 'system',
+ timestamp: '2026-04-06T00:00:03.000Z',
+ content: '/goal\nstatus',
+ },
+ {
+ id: 'goal-stale-empty-output',
+ type: 'system',
+ timestamp: '2026-04-06T00:00:04.000Z',
+ content: 'No active goal.',
+ },
],
})
@@ -367,6 +389,11 @@ describe('chatStore history mapping', () => {
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
+ {
+ id: 'goal-command',
+ type: 'user_text',
+ content: '/goal ship the smoke test',
+ },
{
id: 'goal-output',
type: 'goal_event',
@@ -379,6 +406,17 @@ describe('chatStore history mapping', () => {
action: 'completed',
message: 'Goal marked complete.',
},
+ {
+ id: 'goal-status-command',
+ type: 'user_text',
+ content: '/goal status',
+ },
+ {
+ id: 'goal-stale-empty-output',
+ type: 'goal_event',
+ action: 'message',
+ message: 'No active goal.',
+ },
])
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({
action: 'completed',
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index 0d4560f8..596f8d19 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -1149,7 +1149,7 @@ function applyGoalEventToActiveGoal(
event.message &&
/no (active )?goal/i.test(event.message)
) {
- return null
+ return current
}
if (event.action === 'message') return current
const baseGoal = event.action === 'created' || event.action === 'replaced' ? null : current
@@ -1189,6 +1189,12 @@ function parseGoalCommandFromLocalCommand(content: unknown): { name: string; arg
}
}
+function formatVisibleLocalCommand(command: { name: string; args: string }): string {
+ const normalizedName = command.name.replace(/^\//, '')
+ const args = command.args.trim()
+ return `/${normalizedName}${args ? ` ${args}` : ''}`
+}
+
function extractLocalCommandOutputText(content: unknown): string | null {
const text = extractLocalCommandText(content)
if (!text) return null
@@ -1501,6 +1507,14 @@ export function mapHistoryMessagesToUiMessages(
const localCommand = parseGoalCommandFromLocalCommand(msg.content)
if (localCommand) {
pendingGoalCommand = localCommand
+ if (localCommand.name === 'goal') {
+ uiMessages.push({
+ id: msg.id || nextId(),
+ type: 'user_text',
+ content: formatVisibleLocalCommand(localCommand),
+ timestamp,
+ })
+ }
continue
}
diff --git a/src/commands/goal/goal.test.tsx b/src/commands/goal/goal.test.tsx
index db7ef697..c584ae27 100644
--- a/src/commands/goal/goal.test.tsx
+++ b/src/commands/goal/goal.test.tsx
@@ -1,9 +1,11 @@
import { describe, expect, test } from 'bun:test'
import { switchSession } from '../../bootstrap/state.js'
import type { SessionId } from '../../types/ids.js'
+import type { LocalJSXCommandContext } from '../../types/command.js'
+import { createCommandInputMessage } from '../../utils/messages.js'
import { call } from './goal.js'
-async function runGoal(args: string) {
+async function runGoal(args: string, context: Partial = {}) {
const calls: Array<{
result?: string
options?: {
@@ -17,7 +19,10 @@ async function runGoal(args: string) {
(result, options) => {
calls.push({ result, options })
},
- {} as never,
+ {
+ messages: [],
+ ...context,
+ } as LocalJSXCommandContext,
args,
)
@@ -107,4 +112,46 @@ describe('/goal command', () => {
})
expect(result.options?.shouldQuery).toBeUndefined()
})
+
+ test('hydrates completed goal state from persisted slash command history', async () => {
+ switchSession(`goal-command-${crypto.randomUUID()}` as SessionId)
+
+ const result = await runGoal('status', {
+ messages: [
+ createCommandInputMessage([
+ '/goal',
+ 'ship persisted goal',
+ ].join('\n')),
+ createCommandInputMessage([
+ '',
+ 'Goal created.',
+ 'Goal: active',
+ 'Objective: ship persisted goal',
+ 'Budget: 42 / 2,000 tokens',
+ 'Elapsed: 1m',
+ 'Continuations: 3',
+ '',
+ ].join('\n')),
+ createCommandInputMessage([
+ '',
+ 'Goal: complete',
+ 'Objective: ship persisted goal',
+ 'Budget: 1,234 / 2,000 tokens',
+ 'Elapsed: 23m',
+ 'Continuations: 2',
+ '',
+ ].join('\n')),
+ createCommandInputMessage([
+ '/goal',
+ 'status',
+ ].join('\n')),
+ createCommandInputMessage('No active goal.'),
+ ],
+ })
+
+ expect(result.result).toContain('Goal: complete')
+ expect(result.result).toContain('Objective: ship persisted goal')
+ expect(result.result).toContain('Budget: 1,234 / 2,000 tokens')
+ expect(result.result).toContain('Continuations: 2')
+ })
})
diff --git a/src/commands/goal/goal.tsx b/src/commands/goal/goal.tsx
index 29acce87..82a52cdc 100644
--- a/src/commands/goal/goal.tsx
+++ b/src/commands/goal/goal.tsx
@@ -7,6 +7,7 @@ import {
clearThreadGoal,
formatGoalStatus,
getThreadGoal,
+ hydrateThreadGoalFromMessages,
parseGoalCommand,
setThreadGoal,
updateThreadGoalStatus,
@@ -18,18 +19,23 @@ export async function call(
args: string,
): Promise {
const threadId = getSessionId()
+ const getCurrentGoal = () =>
+ getThreadGoal(threadId) ?? hydrateThreadGoalFromMessages(threadId, _context.messages)
+
try {
const parsed = parseGoalCommand(args)
if (parsed.type === 'status') {
- onDone(formatGoalStatus(getThreadGoal(threadId)), { display: 'system' })
+ onDone(formatGoalStatus(getCurrentGoal()), { display: 'system' })
return null
}
if (parsed.type === 'clear') {
+ getCurrentGoal()
const cleared = clearThreadGoal(threadId)
onDone(cleared ? 'Goal cleared.' : 'No active goal.', { display: 'system' })
return null
}
if (parsed.type === 'pause') {
+ getCurrentGoal()
const goal = updateThreadGoalStatus(threadId, 'paused')
onDone(goal ? formatGoalStatus(goal) : 'No active goal.', {
display: 'system',
@@ -37,6 +43,7 @@ export async function call(
return null
}
if (parsed.type === 'resume') {
+ getCurrentGoal()
const goal = updateThreadGoalStatus(threadId, 'active')
onDone(goal ? formatGoalStatus(goal) : 'No goal to resume.', {
display: 'system',
@@ -46,6 +53,7 @@ export async function call(
return null
}
if (parsed.type === 'complete') {
+ getCurrentGoal()
const goal = updateThreadGoalStatus(threadId, 'complete')
onDone(goal ? 'Goal marked complete.' : 'No active goal.', {
display: 'system',
@@ -53,7 +61,7 @@ export async function call(
return null
}
- const replaced = Boolean(getThreadGoal(threadId))
+ const replaced = Boolean(getCurrentGoal())
const goal = setThreadGoal(threadId, {
objective: parsed.objective,
tokenBudget: parsed.tokenBudget,
diff --git a/src/goals/goalState.ts b/src/goals/goalState.ts
index 7b7ddb76..e54dcd67 100644
--- a/src/goals/goalState.ts
+++ b/src/goals/goalState.ts
@@ -257,13 +257,10 @@ function goalFromLocalCommandOutput(
now: number,
): ThreadGoal | null {
const trimmed = output.trim()
- if (
- trimmed === 'Goal cleared.' ||
- trimmed === 'No active goal.' ||
- trimmed === 'No goal to resume.'
- ) {
+ if (trimmed === 'Goal cleared.') {
return null
}
+ if (trimmed === 'No active goal.' || trimmed === 'No goal to resume.') return current
if (trimmed === 'Goal marked complete.') {
return current ? { ...current, status: 'complete', updatedAt: now } : null
}