diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index 10b7a0776bfa..a3d1563262d2 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -23,6 +23,7 @@ import { type StatusGroup, stopBackgroundProcess } from '@/store/composer-status' +import { refreshSessionGoal } from '@/store/goals' import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status' import { $threadScrolledUp } from '@/store/thread-scroll' import { openSessionInNewWindow } from '@/store/windows' @@ -42,12 +43,25 @@ const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\. // Real codicons per group (no sparkles): a checklist for todos, the agent glyph // for subagents, a background process glyph for background tasks. const GROUP_ICON: Record = { + goal: 'target', todo: 'checklist', subagent: 'agent', background: 'server-process' } const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => { + if (group.type === 'goal') { + const status = group.items[0]?.goalStatus + + return status === 'paused' + ? s.goalPaused + : status === 'waiting' + ? s.goalWaiting + : status === 'done' + ? s.goalDone + : s.goalActive + } + if (group.type === 'todo') { return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length) } @@ -87,6 +101,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro useEffect(() => { if (sessionId) { void refreshBackgroundProcesses(sessionId) + void refreshSessionGoal(sessionId) } }, [sessionId]) @@ -154,7 +169,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro ) : undefined } - defaultCollapsed={group.type !== 'todo'} + defaultCollapsed={group.type !== 'todo' && group.type !== 'goal'} icon={} label={groupLabel(group, t.statusStack)} > diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx index 6857be46ccfe..2c43743c40c5 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -25,6 +25,24 @@ const TODO_GLYPHS: Record, { icon // Left slot: braille spinner while running, otherwise a small status dot // (green = done, red = failed) so the slot is always filled and rows align. function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode { + if (item.type === 'goal') { + if (item.goalStatus === 'paused') { + return + } + + if (item.goalStatus === 'done') { + return + } + + return ( + + ) + } + if (item.todoStatus === 'pending') { return ( )} + {item.type === 'goal' && item.currentTool && ( + + {item.currentTool} + + )} {failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && ( {s.exit(item.exitCode)} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 101fe7e59be5..4e0a439219ca 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -22,6 +22,7 @@ import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedCh import { setSessionCompacting } from '@/store/compaction' import { refreshBackgroundProcesses } from '@/store/composer-status' import { $gateway } from '@/store/gateway' +import { applyGoalStatusText } from '@/store/goals' import { dispatchNativeNotification } from '@/store/native-notifications' import { notify } from '@/store/notifications' import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding' @@ -909,6 +910,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // The gateway's notification poller announces background process // completions / watch matches here — re-sync the status stack. void refreshBackgroundProcesses(sessionId) + } else if (sessionId && payload?.kind === 'goal') { + applyGoalStatusText(sessionId, coerceGatewayText(payload?.text)) } } else if (event.type === 'review.summary') { // Self-improvement background review saved something to memory/skills diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c1d59a013b85..fb5628eb4b33 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2013,6 +2013,10 @@ export const en: Translations = { statusStack: { agents: 'Agents', background: count => `${count} Background`, + goalActive: 'Goal active', + goalDone: 'Goal done', + goalPaused: 'Goal paused', + goalWaiting: 'Goal waiting', subagents: count => `${count} Subagent${count === 1 ? '' : 's'}`, todos: (done, total) => `Tasks ${done}/${total}`, running: 'Running', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 90506fe92378..20ebe9bd27b9 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1669,6 +1669,10 @@ export interface Translations { statusStack: { agents: string background: (count: number) => string + goalActive: string + goalDone: string + goalPaused: string + goalWaiting: string subagents: (count: number) => string todos: (done: number, total: number) => string running: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index a7a9ec409e79..687315be7a65 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2202,6 +2202,10 @@ export const zh: Translations = { statusStack: { agents: '代理', background: count => `${count} 个后台任务`, + goalActive: '目标进行中', + goalDone: '目标已完成', + goalPaused: '目标已暂停', + goalWaiting: '目标等待中', subagents: count => `${count} 个子代理`, todos: (done, total) => `任务 ${done}/${total}`, running: '运行中', diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts index 4ba910f47e34..5d3b890c2883 100644 --- a/apps/desktop/src/store/composer-status.ts +++ b/apps/desktop/src/store/composer-status.ts @@ -5,6 +5,7 @@ import { stableArray } from '@/lib/stable-array' import type { TodoItem, TodoStatus } from '@/lib/todos' import { $gateway } from './gateway' +import { $goalsBySession, type GoalStatus } from './goals' import { dispatchNativeNotification } from './native-notifications' import { notifyError } from './notifications' import { $sessionStates } from './session-states' @@ -13,13 +14,15 @@ import { $todosBySession } from './todos' /** Composer status stack feed — merged todos, subagents, background per session. */ export type StatusItemState = 'done' | 'failed' | 'running' -export type StatusItemType = 'background' | 'subagent' | 'todo' +export type StatusItemType = 'background' | 'goal' | 'subagent' | 'todo' export interface ComposerStatusItem { /** background: non-zero exit shown inline when failed. */ exitCode?: number /** subagent: active tool label shown on the right. */ currentTool?: string + /** goal: active | paused | waiting | done. */ + goalStatus?: GoalStatus id: string /** background process: captured stdout/stderr tail for the inline viewer. */ output?: string @@ -143,10 +146,19 @@ const todoToItem = (t: TodoItem): ComposerStatusItem => ({ type: 'todo' }) +const goalToItem = (goal: { detail?: string; status: GoalStatus; title: string }): ComposerStatusItem => ({ + currentTool: goal.detail, + goalStatus: goal.status, + id: 'goal:standing', + state: goal.status === 'active' || goal.status === 'waiting' ? 'running' : 'done', + title: goal.title, + type: 'goal' +}) + // The single thing the stack reads: a typed, merged item list per session. export const $statusItemsBySession = computed( - [$subagentsBySession, $backgroundStatusBySession, $todosBySession], - (subs, background, todos) => { + [$goalsBySession, $subagentsBySession, $backgroundStatusBySession, $todosBySession], + (goals, subs, background, todos) => { const out: Record = {} const push = (sid: string, items: ComposerStatusItem[]) => { @@ -159,6 +171,10 @@ export const $statusItemsBySession = computed( push(sid, list.map(todoToItem)) } + for (const [sid, goal] of Object.entries(goals)) { + push(sid, [goalToItem(goal)]) + } + for (const [sid, list] of Object.entries(subs)) { push(sid, list.filter(s => s.status === 'running' || s.status === 'queued').map(subToItem)) } @@ -172,7 +188,7 @@ export const $statusItemsBySession = computed( ) // Fixed render order for the groups in the stack (top → bottom, above queue). -const TYPE_ORDER: readonly StatusItemType[] = ['todo', 'subagent', 'background'] +const TYPE_ORDER: readonly StatusItemType[] = ['goal', 'todo', 'subagent', 'background'] export interface StatusGroup { items: ComposerStatusItem[] diff --git a/apps/desktop/src/store/goals.test.ts b/apps/desktop/src/store/goals.test.ts new file mode 100644 index 000000000000..61c00dd487a1 --- /dev/null +++ b/apps/desktop/src/store/goals.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { $goalsBySession, applyGoalStatusText, clearSessionGoal } from './goals' + +describe('goal store', () => { + afterEach(() => { + vi.useRealTimers() + $goalsBySession.set({}) + }) + + it('stores active goals from /goal output', () => { + applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship the feature') + + expect($goalsBySession.get().s1).toMatchObject({ + status: 'active', + title: 'ship the feature' + }) + }) + + it('keeps the current title for continuation and pause messages', () => { + applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship the feature') + applyGoalStatusText('s1', '↻ Continuing toward goal (1/20): next step is tests') + + expect($goalsBySession.get().s1).toMatchObject({ + detail: 'Continuing toward goal (1/20): next step is tests', + status: 'active', + title: 'ship the feature' + }) + + applyGoalStatusText('s1', '⏸ Goal paused — 20/20 turns used. Use /goal resume to keep going.') + + expect($goalsBySession.get().s1).toMatchObject({ + status: 'paused', + title: 'ship the feature' + }) + }) + + it('lingers done goals before clearing them', () => { + vi.useFakeTimers() + + applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship the feature') + applyGoalStatusText('s1', '✓ Goal achieved: tests pass') + + expect($goalsBySession.get().s1).toMatchObject({ status: 'done' }) + + vi.advanceTimersByTime(7_999) + expect($goalsBySession.get().s1).toBeTruthy() + + vi.advanceTimersByTime(1) + expect($goalsBySession.get().s1).toBeUndefined() + }) + + it('clears on no-goal output', () => { + applyGoalStatusText('s1', '⊙ Goal set (20-turn budget): ship another feature') + applyGoalStatusText('s1', 'No active goal. Set one with /goal .') + + expect($goalsBySession.get().s1).toBeUndefined() + }) + + it('cancels pending done clears when replacing a goal', () => { + vi.useFakeTimers() + + applyGoalStatusText('s1', '⊙ Goal set: first') + applyGoalStatusText('s1', '✓ Goal achieved: first done') + applyGoalStatusText('s1', '⊙ Goal set: second') + + vi.advanceTimersByTime(8_000) + + expect($goalsBySession.get().s1).toMatchObject({ status: 'active', title: 'second' }) + + clearSessionGoal('s1') + }) +}) diff --git a/apps/desktop/src/store/goals.ts b/apps/desktop/src/store/goals.ts new file mode 100644 index 000000000000..3b19b7eb63b6 --- /dev/null +++ b/apps/desktop/src/store/goals.ts @@ -0,0 +1,177 @@ +import { atom } from 'nanostores' + +import { $gateway } from './gateway' + +export type GoalStatus = 'active' | 'done' | 'paused' | 'waiting' + +export interface SessionGoal { + detail?: string + status: GoalStatus + title: string + updatedAt: number +} + +export const $goalsBySession = atom>({}) + +const DONE_LINGER_MS = 8_000 +const clearTimers = new Map>() + +function cancelScheduledClear(sid: string) { + const timer = clearTimers.get(sid) + + if (timer !== undefined) { + clearTimeout(timer) + clearTimers.delete(sid) + } +} + +export function setSessionGoal(sid: string, goal: SessionGoal) { + if (!sid) { + return + } + + cancelScheduledClear(sid) + $goalsBySession.set({ ...$goalsBySession.get(), [sid]: goal }) + + if (goal.status === 'done') { + clearTimers.set( + sid, + setTimeout(() => { + clearTimers.delete(sid) + clearSessionGoal(sid) + }, DONE_LINGER_MS) + ) + } +} + +export function clearSessionGoal(sid: string) { + cancelScheduledClear(sid) + + const map = $goalsBySession.get() + + if (!(sid in map)) { + return + } + + const { [sid]: _drop, ...rest } = map + $goalsBySession.set(rest) +} + +const clean = (value: string): string => value.replace(/\r/g, '').trim() + +const firstLine = (value: string): string => clean(value).split('\n')[0]?.trim() ?? '' + +function goalTitleFromLine(line: string, pattern: RegExp): string { + return (line.match(pattern)?.[1] ?? '').trim() +} + +function nextGoalFromText(text: string, previous?: SessionGoal): SessionGoal | null | undefined { + const body = clean(text) + const line = firstLine(body) + + if (!line) { + return undefined + } + + if ( + /^No active goal\b/i.test(line) || + /^No goal (?:set|to resume)\b/i.test(line) || + /^✓ Goal cleared\b/i.test(line) + ) { + return null + } + + const now = Date.now() + const fromSet = goalTitleFromLine(line, /^⊙ Goal set(?:\s*\([^)]*\))?:\s*(.+)$/) + const fromActive = goalTitleFromLine(line, /^⊙ Goal\s*\([^)]*active[^)]*\):\s*(.+)$/) + const fromResume = goalTitleFromLine(line, /^▶ Goal resumed:\s*(.+)$/) + + if (fromSet || fromActive || fromResume) { + return { status: 'active', title: fromSet || fromActive || fromResume, updatedAt: now } + } + + const fromWaiting = goalTitleFromLine(line, /^⏳ Goal\s*\([^)]*(?:parked|active)[^)]*\):\s*(.+)$/) + + if (fromWaiting) { + return { status: 'waiting', title: fromWaiting, updatedAt: now } + } + + const fromPaused = goalTitleFromLine(line, /^⏸ Goal(?:\s*\([^)]*\)| paused)?:\s*(.+)$/) + + if (fromPaused) { + return { status: 'paused', title: fromPaused, updatedAt: now } + } + + const fromDone = goalTitleFromLine(line, /^✓ Goal done\s*\([^)]*\):\s*(.+)$/) + + if (fromDone) { + return { status: 'done', title: fromDone, updatedAt: now } + } + + if (/^↻ Continuing toward goal\b/i.test(line)) { + return { + detail: line.replace(/^↻\s*/, ''), + status: 'active', + title: previous?.title || 'Standing goal', + updatedAt: now + } + } + + if (/^⏳ Goal parked\b/i.test(line)) { + return { + detail: line.replace(/^⏳\s*/, ''), + status: 'waiting', + title: previous?.title || 'Standing goal', + updatedAt: now + } + } + + if (/^⏸ Goal paused\b/i.test(line)) { + return { + detail: line.replace(/^⏸\s*/, ''), + status: 'paused', + title: previous?.title || 'Standing goal', + updatedAt: now + } + } + + if (/^✓ Goal achieved\b/i.test(line)) { + return { + detail: line.replace(/^✓\s*/, ''), + status: 'done', + title: previous?.title || 'Standing goal', + updatedAt: now + } + } + + return undefined +} + +export function applyGoalStatusText(sid: string, text: string) { + if (!sid) { + return + } + + const next = nextGoalFromText(text, $goalsBySession.get()[sid]) + + if (next === null) { + clearSessionGoal(sid) + } else if (next) { + setSessionGoal(sid, next) + } +} + +export async function refreshSessionGoal(sid: string): Promise { + const gateway = $gateway.get() + + if (!sid || !gateway) { + return + } + + try { + const result = await gateway.request<{ output?: string }>('slash.exec', { command: 'goal status', session_id: sid }) + applyGoalStatusText(sid, result?.output ?? '') + } catch { + // Best-effort: older gateways or detached sessions simply won't hydrate it. + } +}