diff --git a/apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx new file mode 100644 index 000000000000..09a7454b9c73 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' +import { $goalsBySession, type SessionGoal } from '@/store/goals' + +import { ComposerStatusStack } from './index' + +// The stack measures itself into a surface var — jsdom has no ResizeObserver. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', ResizeObserverStub) + +const SID = 'sess-goal-1' + +const goal = (status: SessionGoal['status'], title = 'ship the feature', detail?: string): SessionGoal => ({ + detail, + status, + title, + updatedAt: Date.now() +}) + +function renderStack(sessionId: null | string = SID) { + return render( + + + + + + ) +} + +describe('ComposerStatusStack goal indicator', () => { + beforeEach(() => { + $goalsBySession.set({}) + }) + + afterEach(() => { + cleanup() + $goalsBySession.set({}) + }) + + it('renders nothing when the session has no goal', () => { + const view = renderStack() + + expect(view.container.firstChild).toBeNull() + }) + + it('shows an active goal with its title', () => { + $goalsBySession.set({ [SID]: goal('active') }) + + renderStack() + + expect(screen.getByText('Goal active')).toBeTruthy() + expect(screen.getByText('ship the feature')).toBeTruthy() + }) + + it('labels a paused goal as paused', () => { + $goalsBySession.set({ [SID]: goal('paused') }) + + renderStack() + + expect(screen.getByText('Goal paused')).toBeTruthy() + expect(screen.getByText('ship the feature')).toBeTruthy() + }) + + it('shows the continuation detail line for an active goal', () => { + $goalsBySession.set({ [SID]: goal('active', 'ship it', 'Continuing toward goal (3/20)') }) + + renderStack() + + expect(screen.getByText('Continuing toward goal (3/20)')).toBeTruthy() + }) + + it('scopes the indicator to the goal-owning session', () => { + $goalsBySession.set({ 'other-session': goal('active') }) + + const view = renderStack() + + expect(view.container.firstChild).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index f8f044ec028f..c70e6977c0a7 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -18,6 +18,7 @@ import { setSessionYolo } from '@/lib/yolo-session' import { openCommandPalettePage } from '@/store/command-palette' import { setComposerDraft } from '@/store/composer' import { enqueueQueuedPrompt } from '@/store/composer-queue' +import { applyGoalStatusText } from '@/store/goals' import { dismissNotification, notify, notifyError } from '@/store/notifications' import { setPetScale } from '@/store/pet-gallery' import { $petGenInput, openPetGenerate } from '@/store/pet-generate' @@ -232,6 +233,15 @@ export function useSlashCommand(deps: SlashCommandDeps) { // `/goal ` looked like it did nothing. if ((dispatch.type === 'send' || dispatch.type === 'prefill') && dispatch.notice?.trim()) { renderSlashOutput(dispatch.notice.trim()) + + // `/goal ` returns its "⊙ Goal set …" notice here and kicks + // off the first turn immediately; the backend only emits a + // `status.update kind:"goal"` after that turn's post-turn judge + // runs. Seed the goal store from the notice so the indicator shows + // the active goal right away instead of after the first turn. + if (name === 'goal') { + applyGoalStatusText(sessionId, dispatch.notice.trim()) + } } const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? '' @@ -313,6 +323,15 @@ export function useSlashCommand(deps: SlashCommandDeps) { const output = result && typeof result === 'object' ? (result as SlashExecResponse) : null const body = output?.output || `/${name}: no output` + + // `/goal status|pause|resume|clear` come back as plain exec output + // ("⊙ Goal (active, 3/20 turns): …", "⏸ Goal paused: …", "✓ Goal + // cleared." …). Mirror it into the goal store so the composer + // indicator tracks pause/resume/clear immediately. + if (name === 'goal' && output?.output) { + applyGoalStatusText(sessionId, output.output) + } + renderSlashOutput(output?.warning ? `warning: ${output.warning}\n${body}` : body) return diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 738a4859e057..d37b9ad28186 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1880,6 +1880,10 @@ export const ja = defineLocale({ statusStack: { agents: 'エージェント', background: count => `バックグラウンド ${count} 件`, + goalActive: '目標進行中', + goalDone: '目標達成', + goalPaused: '目標一時停止中', + goalWaiting: '目標待機中', subagents: count => `サブエージェント ${count} 件`, todos: (done, total) => `タスク ${done}/${total}`, running: '実行中', diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 73933bf8885d..32a603b71d8f 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1825,6 +1825,10 @@ export const zhHant = defineLocale({ statusStack: { agents: '代理', background: count => `${count} 個背景任務`, + goalActive: '目標進行中', + goalDone: '目標已完成', + goalPaused: '目標已暫停', + goalWaiting: '目標等待中', subagents: count => `${count} 個子代理`, todos: (done, total) => `任務 ${done}/${total}`, running: '執行中',