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