From e5d21e87cb3d0e00c447c7c3c1e5ba67ae62c5ed Mon Sep 17 00:00:00 2001
From: teknium1 <127238744+teknium1@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:23:18 -0700
Subject: [PATCH] fix(desktop): hydrate goal indicator on /goal set and
controls, full locale copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follow-ups on the salvaged goal-status display (#63527):
- Seed the goal store from the /goal dispatch notice ("⊙ Goal set …") and
from /goal status|pause|resume|clear exec output in slash.ts. The backend
only emits status.update kind:"goal" after the first turn's post-turn
judge, so without this the indicator stayed empty while the kickoff turn
ran (sweeper review finding on #63527).
- Add the missing ja / zh-hant statusStack goal copy — desktop ships four
locales, not two (sweeper review finding on #55651).
- Add a component-level vitest for the composer goal indicator rendering
from store states: none / active / paused / detail line / other-session.
Co-authored-by: HaisamAbbas <95044189+HaisamAbbas@users.noreply.github.com>
Assisted-by: Claude Fable 5 via Hermes Agent
---
.../status-stack/goal-indicator.test.tsx | 87 +++++++++++++++++++
.../session/hooks/use-prompt-actions/slash.ts | 19 ++++
apps/desktop/src/i18n/ja.ts | 4 +
apps/desktop/src/i18n/zh-hant.ts | 4 +
4 files changed, 114 insertions(+)
create mode 100644 apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx
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: '執行中',