Add desktop goal status display

This commit is contained in:
wjj1872744570-source 2026-07-13 11:10:27 +08:00 committed by Teknium
parent 24c3c27ba8
commit bb98595694
9 changed files with 324 additions and 5 deletions

View file

@ -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<StatusGroup['type'], string> = {
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
</Tip>
) : undefined
}
defaultCollapsed={group.type !== 'todo'}
defaultCollapsed={group.type !== 'todo' && group.type !== 'goal'}
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
label={groupLabel(group, t.statusStack)}
>

View file

@ -25,6 +25,24 @@ const TODO_GLYPHS: Record<Exclude<TodoStatus, 'in_progress' | 'pending'>, { 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 <Codicon className="text-muted-foreground/60" name="debug-pause" size="0.8rem" />
}
if (item.goalStatus === 'done') {
return <Codicon className="text-emerald-500/80" name="pass-filled" size="0.8rem" />
}
return (
<GlyphSpinner
ariaLabel={s.running}
className="text-[0.85rem] leading-none text-emerald-500/80"
spinner="braille"
/>
)
}
if (item.todoStatus === 'pending') {
return (
<span
@ -137,6 +155,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
{toolLabel(item.currentTool)}
</span>
)}
{item.type === 'goal' && item.currentTool && (
<span className="shrink-0 truncate text-[0.62rem] leading-4 text-muted-foreground/70">
{item.currentTool}
</span>
)}
{failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && (
<span className="shrink-0 rounded bg-destructive/15 px-1 text-[0.58rem] font-semibold text-destructive tabular-nums">
{s.exit(item.exitCode)}

View file

@ -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

View file

@ -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',

View file

@ -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

View file

@ -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: '运行中',

View file

@ -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<string, ComposerStatusItem[]> = {}
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[]

View file

@ -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 <text>.')
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')
})
})

View file

@ -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<Record<string, SessionGoal>>({})
const DONE_LINGER_MS = 8_000
const clearTimers = new Map<string, ReturnType<typeof setTimeout>>()
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<void> {
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.
}
}