mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Add desktop goal status display
This commit is contained in:
parent
24c3c27ba8
commit
bb98595694
9 changed files with 324 additions and 5 deletions
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '运行中',
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
73
apps/desktop/src/store/goals.test.ts
Normal file
73
apps/desktop/src/store/goals.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
177
apps/desktop/src/store/goals.ts
Normal file
177
apps/desktop/src/store/goals.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue