From a014abe7b85065d829b4549de6aa554e3d9b9c9f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 15 Aug 2026 08:23:06 +0800 Subject: [PATCH 1/3] feat(desktop): goal chip shows paused state, elapsed, tokens, pause/resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goal chip rendered a paused goal exactly like a running one — same pulsing accent dot, same counter — and the only control was clear, even though the host protocol fully supports goal.control pause|resume and resumeFromControl restarts a paused goal without spending a model turn. - Wire goal:pause / goal:resume through preload and the session-domain IPC bridge, via a generalized client controlGoalWithRetry (the clearGoal optimistic-revision retry, now shared by all three actions). - Status conflicts stop being retried away: the host folds invalid transitions into operation_conflict, and every accepted transition bumps the revision, so a conflict at an unchanged revision rethrows the host's reason instead of burning attempts and throwing a misleading retry-exhaustion error (this also fixes clearGoal). - The chip is status-aware: a paused goal shows a warning dot without pulse and its own aria label; elapsed wall-clock (frozen at pausedAt) and tokensSpent / tokenBudget render when available; a pause button appears for running goals and a resume button for paused goals next to the existing stop (clear) kill switch, with matching overflow-menu entries and failure toasts. No runtime or protocol changes — surface wiring only. Ref #3024 Generated-by: Maka --- .../runtime-host-client-operations.test.ts | 49 +++++++++ apps/desktop/src/main/runtime-host-client.ts | 20 +++- .../runtime-host-session-domains-ipc-main.ts | 7 ++ apps/desktop/src/preload/bridge-contract.d.ts | 4 + apps/desktop/src/preload/preload.ts | 6 ++ apps/desktop/src/renderer/app-shell.tsx | 86 +++++++++++---- .../src/renderer/locales/shell-copy.ts | 12 +++ apps/desktop/src/renderer/use-session-goal.ts | 5 +- apps/desktop/stories/app-shell.stories.tsx | 1 + .../session-context-layer-goal.test.tsx | 62 +++++++++++ packages/ui/src/chat-view.tsx | 18 ++-- packages/ui/src/conversation-copy.ts | 42 ++++++++ packages/ui/src/icons.tsx | 1 + packages/ui/src/session-context-layer.tsx | 101 ++++++++++++++++-- 14 files changed, 369 insertions(+), 45 deletions(-) create mode 100644 packages/ui/src/__tests__/session-context-layer-goal.test.tsx diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index c3d605d424..eb28f8a222 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -707,6 +707,55 @@ test('retries Goal clear only while the same Goal generation remains active', as ); }); +test('controlGoalWithRetry applies pause/resume with the queried revision', async () => { + const active = goalProjection(1); + const paused = { ...goalProjection(2), status: 'paused' as const, pausedAt: 5 }; + const { client, requests } = clientWithResponses([ + { sessionId: 'session-1', goal: active }, // queryGoal + { sessionId: 'session-1', goal: paused }, // goal.control pause result + { sessionId: 'session-1', goal: paused }, // queryGoal for resume + { sessionId: 'session-1', goal: { ...goalProjection(3) } }, // goal.control resume result + ]); + + await client.controlGoalWithRetry('session-1', 'pause'); + await client.controlGoalWithRetry('session-1', 'resume'); + + assert.deepEqual( + requests.filter(({ operation }) => operation === 'goal.control').map(({ input }) => input), + [ + { sessionId: 'session-1', goalId: 'goal-1', expectedRevision: 1, action: 'pause' }, + { sessionId: 'session-1', goalId: 'goal-1', expectedRevision: 2, action: 'resume' }, + ], + ); +}); + +test('controlGoalWithRetry rethrows a status refusal instead of retrying it away', async () => { + // The host folds invalid transitions into operation_conflict. Every accepted + // transition bumps the revision, so a conflict at an unchanged revision is a + // status refusal — the reason must surface, not a retry-exhaustion error. + const paused = { ...goalProjection(2), status: 'paused' as const, pausedAt: 5 }; + const refusal = new RuntimeHostOperationError( + 'goal.control', + 'operation_conflict', + 'Goal cannot pause from status paused', + ); + const { client, requests } = clientWithResponses([ + { sessionId: 'session-1', goal: paused }, // queryGoal + refusal, // goal.control conflict + { sessionId: 'session-1', goal: paused }, // re-query: SAME revision + ]); + + await assert.rejects( + () => client.controlGoalWithRetry('session-1', 'pause'), + /Goal cannot pause from status paused/, + ); + // No futile retries: exactly one control attempt. + assert.equal( + requests.filter(({ operation }) => operation === 'goal.control').length, + 1, + ); +}); + test('rejects an invalid sidecar continuation without misclassifying it as revision churn', async () => { const revision = catalogRevision('7'); const { client, requests } = clientWithResponses([ diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 5743c99b22..03b00cbf77 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1148,14 +1148,15 @@ export class DesktopRuntimeHostClient { }); } - async clearGoal(sessionId: string): Promise { + async controlGoalWithRetry(sessionId: string, action: GoalControlAction): Promise { const initial = await this.queryGoal(sessionId); if (initial.goal === null) return; const goalId = initial.goal.goalId; let goal = initial.goal; + let conflict: RuntimeHostOperationError | null = null; for (let attempt = 0; attempt < MAX_OPTIMISTIC_ATTEMPTS; attempt += 1) { try { - await this.controlGoal(goal, "clear"); + await this.controlGoal(goal, action); return; } catch (error) { if ( @@ -1164,12 +1165,25 @@ export class DesktopRuntimeHostClient { ) { throw error; } + conflict = error; + if (attempt === MAX_OPTIMISTIC_ATTEMPTS - 1) break; // a re-query would have no retry to serve } const current = await this.queryGoal(sessionId); if (current.goal === null || current.goal.goalId !== goalId) return; + if (current.goal.revision === goal.revision) { + // The host folds invalid transitions into operation_conflict too + // ("Goal cannot pause from status paused"). Every accepted transition + // bumps the revision, so a conflict at an unchanged revision is a + // status refusal, not a race — retrying is futile; surface the reason. + throw conflict; + } goal = current.goal; } - throw revisionConflict("Goal clear", sessionId); + throw revisionConflict(`Goal ${action}`, sessionId); + } + + async clearGoal(sessionId: string): Promise { + await this.controlGoalWithRetry(sessionId, "clear"); } async getPlanState(sessionId: string): Promise { diff --git a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts index 8f3339121b..ce6a9c1644 100644 --- a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts @@ -24,6 +24,7 @@ type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient & Pick< DesktopRuntimeHostClient, | 'clearGoal' + | 'controlGoalWithRetry' | 'controlPlan' | 'getRuntimeResource' | 'getPlanState' @@ -87,6 +88,12 @@ export function registerRuntimeHostSessionDomainsIpc( ipcMain.handle('goal:clear', async (_event, sessionId: unknown) => { await deps.client.clearGoal(requiredId(sessionId, 'Session')); }); + ipcMain.handle('goal:pause', async (_event, sessionId: unknown) => { + await deps.client.controlGoalWithRetry(requiredId(sessionId, 'Session'), 'pause'); + }); + ipcMain.handle('goal:resume', async (_event, sessionId: unknown) => { + await deps.client.controlGoalWithRetry(requiredId(sessionId, 'Session'), 'resume'); + }); handleReconnectableRead(ipcMain, 'plan-mode:getState', (_event, sessionId: unknown) => deps.client.getPlanState(requiredId(sessionId, 'Session')), diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index e5376d22a0..8a606b6aa3 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -691,6 +691,10 @@ export interface MakaBridge { get(sessionId: string): Promise; /** Clear the active goal, stopping autonomous continuation. */ clear(sessionId: string): Promise; + /** Pause the active goal without spending a model turn. */ + pause(sessionId: string): Promise; + /** Resume a paused goal without spending a model turn. */ + resume(sessionId: string): Promise; }; connections: { getSnapshot(sessionId?: string, host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 85db0e2925..9085dcebfd 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1863,6 +1863,12 @@ const makaBridge = { clear(sessionId: string): Promise { return invokeSessionRuntimeHost('goal:clear', sessionId); }, + pause(sessionId: string): Promise { + return invokeSessionRuntimeHost('goal:pause', sessionId); + }, + resume(sessionId: string): Promise { + return invokeSessionRuntimeHost('goal:resume', sessionId); + }, }, connections: { getSnapshot(sessionId?: string, host?: DesktopRuntimeHostRef) { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index b8cbab33a9..36b734cf93 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -3290,27 +3290,71 @@ function AppShellContent({ memoryActive={memoryActive} onOpenMemorySettings={() => openSettingsSection('memory')} goalIndicator={ - activeGoal - ? { - condition: activeGoal.condition, - status: activeGoal.status, - iterations: activeGoal.iterations, - maxIterations: activeGoal.maxIterations, - onClear: () => { - void window.maka.goal.clear(activeGoal.sessionId).catch((error) => { - toastApi.error( - shellCopy.goalClearFailedTitle, - localizedShellErrorMessage( - error, - shellCopy.goalClearFailedFallback, - uiLocale, - ), - ); - }); - }, - } - : undefined - } + activeGoal + ? { + condition: activeGoal.condition, + status: activeGoal.status, + iterations: activeGoal.iterations, + maxIterations: activeGoal.maxIterations, + setAt: activeGoal.setAt, + ...(activeGoal.pausedAt !== undefined + ? { pausedAt: activeGoal.pausedAt } + : {}), + tokensSpent: activeGoal.tokensNow, + ...(activeGoal.tokenBudget !== undefined + ? { tokenBudget: activeGoal.tokenBudget } + : {}), + ...(activeGoal.status === 'active' || activeGoal.status === 'waiting' + ? { + onPause: () => { + void window.maka.goal + .pause(activeGoal.sessionId) + .catch((error) => { + toastApi.error( + shellCopy.goalPauseFailedTitle, + localizedShellErrorMessage( + error, + shellCopy.goalPauseFailedFallback, + uiLocale, + ), + ); + }); + }, + } + : {}), + ...(activeGoal.status === 'paused' + ? { + onResume: () => { + void window.maka.goal + .resume(activeGoal.sessionId) + .catch((error) => { + toastApi.error( + shellCopy.goalResumeFailedTitle, + localizedShellErrorMessage( + error, + shellCopy.goalResumeFailedFallback, + uiLocale, + ), + ); + }); + }, + } + : {}), + onClear: () => { + void window.maka.goal.clear(activeGoal.sessionId).catch((error) => { + toastApi.error( + shellCopy.goalClearFailedTitle, + localizedShellErrorMessage( + error, + shellCopy.goalClearFailedFallback, + uiLocale, + ), + ); + }); + }, + } + : undefined + } messageLoadError={activeId ? messageLoadErrorBySession[activeId] : undefined} messageLoadRetryPending={activeId ? messageRetryPendingBySession[activeId] === true : false} onRetryMessages={activeId ? () => void retryMessages(activeId) : undefined} diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 6fb27102ba..50ec04853b 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -392,6 +392,10 @@ type ShellCopy = { resumeFailedFallback: string; goalClearFailedTitle: string; goalClearFailedFallback: string; + goalPauseFailedTitle: string; + goalPauseFailedFallback: string; + goalResumeFailedTitle: string; + goalResumeFailedFallback: string; appearanceLoadErrorTitle: string; appearanceLoadErrorFallback: string; memoryRefreshErrorTitle: string; @@ -1084,6 +1088,10 @@ const SHELL_COPY_BY_LOCALE = { resumeFailedFallback: '无法启动安全恢复,请检查任务状态后重试。', goalClearFailedTitle: '停止目标失败', goalClearFailedFallback: '目标仍可能继续运行,请立即重试。', + goalPauseFailedTitle: '暂停目标失败', + goalPauseFailedFallback: '目标可能仍在自动续行,请立即重试。', + goalResumeFailedTitle: '恢复目标失败', + goalResumeFailedFallback: '目标仍处于暂停状态,请重试。', appearanceLoadErrorTitle: '载入外观设置失败', appearanceLoadErrorFallback: '外观设置暂时无法载入,请稍后重试。', memoryRefreshErrorTitle: '刷新本地记忆状态失败', @@ -1602,6 +1610,10 @@ const SHELL_COPY_BY_LOCALE = { resumeFailedFallback: 'Safe recovery could not start. Check the task state and try again.', goalClearFailedTitle: 'Could not stop the goal', goalClearFailedFallback: 'The goal may still be running. Try again now.', + goalPauseFailedTitle: 'Could not pause the goal', + goalPauseFailedFallback: 'The goal may still be continuing. Try again now.', + goalResumeFailedTitle: 'Could not resume the goal', + goalResumeFailedFallback: 'The goal is still paused. Try again.', appearanceLoadErrorTitle: 'Could not load appearance settings', appearanceLoadErrorFallback: 'Appearance settings are temporarily unavailable. Try again later.', memoryRefreshErrorTitle: 'Could not refresh local memory status', diff --git a/apps/desktop/src/renderer/use-session-goal.ts b/apps/desktop/src/renderer/use-session-goal.ts index 287216a0b1..5836a6df20 100644 --- a/apps/desktop/src/renderer/use-session-goal.ts +++ b/apps/desktop/src/renderer/use-session-goal.ts @@ -3,8 +3,9 @@ * * Reads the session's goal via the preload bridge and re-fetches whenever the * main process emits a `goal-change` session event (goal set / continue / - * terminal / clear). Only surfaces goals that are still running (active or - * waiting, or paused); a settled goal returns null so the session context disappears. + * pause / resume / terminal / clear). Only surfaces goals that are still + * running (active or waiting, or paused); a settled goal returns null so the + * session context disappears. * * Kept as a tiny standalone hook (no app-shell coupling) so an autonomous, * token-burning loop always has a visible indicator and a one-click stop, diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 356a1f30d7..3b807658f1 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -901,6 +901,7 @@ export const SessionContextLayer: Story = { status: 'active', iterations: 4, maxIterations: 12, + setAt: Date.now() - 12 * 60_000, onClear: noop, }, onBranchBannerClick: noop, diff --git a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx new file mode 100644 index 0000000000..447f784acb --- /dev/null +++ b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx @@ -0,0 +1,62 @@ +/** + * The goal chip is the desktop kill switch for an autonomous loop: a running + * goal pulses with live progress, while a paused goal burns nothing and must + * read as paused (distinct label, no running affordance) with a resume path. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { LocaleProvider } from '../locale-context.js'; +import { SessionContextLayer, type SessionContextGoal } from '../session-context-layer.js'; + +function renderGoalChip(goal: SessionContextGoal): string { + return renderToStaticMarkup( + + + , + ); +} + +test('a running goal reads as running and offers pause, with elapsed and tokens', () => { + const markup = renderGoalChip({ + condition: 'Ship the feature', + status: 'active', + iterations: 3, + maxIterations: 50, + setAt: Date.now() - 12 * 60_000, + tokensSpent: 12_000, + tokenBudget: 100_000, + onPause: () => undefined, + onClear: () => undefined, + }); + assert.ok(markup.includes('Goal 3 of 50')); + assert.ok(markup.includes('12m')); + assert.ok(markup.includes('12k / 100k')); + assert.ok(markup.includes('Autonomous goal running')); + assert.ok(!markup.includes('Autonomous goal paused')); + assert.ok(markup.includes('Pause autonomous goal after 3/50 iterations')); + assert.ok(!markup.includes('Resume autonomous goal')); + // The clear kill switch stays. + assert.ok(markup.includes('Clear autonomous goal after 3/50 iterations')); +}); + +test('a paused goal reads as paused and offers resume, not pause', () => { + const markup = renderGoalChip({ + condition: 'Ship the feature', + status: 'paused', + iterations: 3, + maxIterations: 50, + setAt: 1_000, + pausedAt: 1_000 + 12 * 60_000, + onResume: () => undefined, + onClear: () => undefined, + }); + assert.ok(markup.includes('Autonomous goal paused')); + assert.ok(!markup.includes('Autonomous goal running')); + assert.ok(markup.includes('Resume autonomous goal after 3/50 iterations')); + assert.ok(!markup.includes('Pause autonomous goal')); + // Elapsed shows (frozen), tokens stay hidden without a budget. + assert.ok(markup.includes('12m')); + assert.ok(!markup.includes('12k')); +}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 994f12c375..fe34b080d7 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -33,7 +33,7 @@ import { useTurnVirtualizer } from './use-turn-virtualizer.js'; import { placeChatConversationItems } from './chat-conversation-items.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; -import { SessionContextLayer } from './session-context-layer.js'; +import { SessionContextLayer, type SessionContextGoal } from './session-context-layer.js'; export interface LiveContentActivationSnapshot { turnId: string; @@ -104,17 +104,13 @@ export function ChatView(props: { }>; /** * Active autonomous-goal indicator for the session, or undefined when no - * goal is running. Surfaces the loop (turn counter) with a one-click clear - * affordance so a token-burning goal is never invisible or unstoppable — - * this IS the desktop kill switch. `onClear` stops autonomous continuation. + * goal is running. Surfaces the loop (turn counter, elapsed, tokens) with + * pause/resume/clear affordances so a token-burning goal is never invisible + * or uncontrollable — this IS the desktop kill switch. `onClear` stops + * autonomous continuation; `onPause`/`onResume` control it without a model + * turn. */ - goalIndicator?: { - condition: string; - status: string; - iterations: number; - maxIterations: number; - onClear: () => void; - }; + goalIndicator?: SessionContextGoal; /** Error from loading the active session's persisted message log. */ messageLoadError?: string; messageLoadRetryPending?: boolean; diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 3a1fd2225e..56e8aae50b 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -18,6 +18,37 @@ type ResearchItem = Readonly<{ title: string; body: string }>; type ResearchOption = Readonly<{ label: string; body: string }>; type ResearchStarter = Readonly<{ label: string; prompt: string }>; +/** Compact token count for chip labels: 45,200 → "45k". */ +function formatCompactTokenCount(count: number): string { + if (count < 1_000) return `${count}`; + const thousands = count / 1_000; + return `${thousands >= 100 ? Math.round(thousands) : Math.round(thousands * 10) / 10}k`; +} + +/** Wall-clock units for the goal chip's elapsed label, per locale (zh uses spaced words, en letters). */ +interface GoalElapsedUnits { + second: string; + minute: string; + hour: string; + day: string; +} + +/** One shared elapsed ladder so the zh/en goalElapsed entries cannot drift. */ +function formatGoalElapsedUnits(elapsedMs: number, units: GoalElapsedUnits): string { + const seconds = Math.max(0, Math.floor(elapsedMs / 1000)); + if (seconds < 60) return `${seconds}${units.second}`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}${units.minute}`; + const hours = Math.floor(minutes / 60); + const restMinutes = minutes % 60; + if (hours < 24) { + return restMinutes === 0 + ? `${hours}${units.hour}` + : `${hours}${units.hour} ${restMinutes}${units.minute}`; + } + return `${Math.floor(hours / 24)}${units.day} ${hours % 24}${units.hour}`; +} + export interface ConversationCopy { empty: { ariaLabel: string; @@ -250,6 +281,15 @@ export interface ConversationCopy { clearGoalAriaLabel: (iteration: number, max: number) => string; goalProgress: (iteration: number, max: number) => string; goalRunningAriaLabel: string; + goalPausedAriaLabel: string; + pauseGoalAriaLabel: (iteration: number, max: number) => string; + resumeGoalAriaLabel: (iteration: number, max: number) => string; + pauseGoal: (condition: string, iteration: number, max: number, status: string) => string; + resumeGoal: (condition: string, iteration: number, max: number) => string; + /** Wall-clock elapsed label for the goal chip, e.g. "12m". */ + goalElapsed: (elapsedMs: number) => string; + /** Token usage label for the goal chip when a budget exists, e.g. "12k / 100k". */ + goalTokens: (spent: number, budget: number) => string; loadFailed: string; loading: string; retryLoad: string; @@ -423,6 +463,7 @@ const CONVERSATION_COPY = { }, }, clearGoal: (condition, iteration, max, status) => `自主执行目标进行中:「${condition}」(第 ${iteration}/${max} 轮,${status})。系统每轮后自动续行;点击可清除目标、停止续行。`, clearGoalAriaLabel: (iteration, max) => `清除自主执行目标(已进行 ${iteration}/${max} 轮)`, goalProgress: (iteration, max) => `目标 ${iteration} / ${max}`, goalRunningAriaLabel: '自主目标正在运行', + goalPausedAriaLabel: '自主目标已暂停', pauseGoalAriaLabel: (iteration, max) => `暂停自主执行目标(已进行 ${iteration}/${max} 轮)`, resumeGoalAriaLabel: (iteration, max) => `恢复自主执行目标(已进行 ${iteration}/${max} 轮)`, pauseGoal: (condition, iteration, max, status) => `暂停自主执行目标:「${condition}」(第 ${iteration}/${max} 轮,${status})。暂停后立即停止自动续行,不再消耗令牌;可随时恢复。`, resumeGoal: (condition, iteration, max) => `恢复自主执行目标:「${condition}」(第 ${iteration}/${max} 轮)。恢复后立即继续自动续行。`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: ' 秒', minute: ' 分钟', hour: ' 小时', day: ' 天' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: '任务载入失败', loading: '载入中…', retryLoad: '重试载入', quoteSelection: '引用', askInSidePanel: '在侧栏追问', noMessages: '暂无消息', branchBeforeInterrupt: '从中断前分支', sessionContextAriaLabel: '任务上下文', sessionLineageAriaLabel: '任务来源', sessionContextMore: (count) => `更多任务上下文(${count})`, titlebarIdentityAriaLabel: '当前任务', openProjectFolder: (name) => `在文件管理器中打开「${name}」`, openProjectFolderAction: '打开项目文件夹', @@ -561,6 +602,7 @@ const CONVERSATION_COPY = { }, }, clearGoal: (condition, iteration, max, status) => `Autonomous goal in progress: “${condition}” (iteration ${iteration}/${max}, ${status}). Maka continues after each iteration; click to clear the goal and stop continuing.`, clearGoalAriaLabel: (iteration, max) => `Clear autonomous goal after ${iteration}/${max} iterations`, goalProgress: (iteration, max) => `Goal ${iteration} of ${max}`, goalRunningAriaLabel: 'Autonomous goal running', + goalPausedAriaLabel: 'Autonomous goal paused', pauseGoalAriaLabel: (iteration, max) => `Pause autonomous goal after ${iteration}/${max} iterations`, resumeGoalAriaLabel: (iteration, max) => `Resume autonomous goal after ${iteration}/${max} iterations`, pauseGoal: (condition, iteration, max, status) => `Pause autonomous goal: “${condition}” (iteration ${iteration}/${max}, ${status}). Pausing stops autonomous continuation immediately — no more tokens burn; resume any time.`, resumeGoal: (condition, iteration, max) => `Resume autonomous goal: “${condition}” (iteration ${iteration}/${max}). Resuming continues autonomous iteration immediately.`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: 's', minute: 'm', hour: 'h', day: 'd' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: 'Task failed to load', loading: 'Loading…', retryLoad: 'Retry', quoteSelection: 'Quote', askInSidePanel: 'Ask in side panel', noMessages: 'No messages yet', branchBeforeInterrupt: 'Branched before interruption', sessionContextAriaLabel: 'Task context', sessionLineageAriaLabel: 'Task origin', sessionContextMore: (count) => `More task context (${count})`, titlebarIdentityAriaLabel: 'Current task', openProjectFolder: (name) => `Open “${name}” in the file manager`, openProjectFolderAction: 'Open project folder', diff --git a/packages/ui/src/icons.tsx b/packages/ui/src/icons.tsx index cdeded0bdb..9876027ae9 100644 --- a/packages/ui/src/icons.tsx +++ b/packages/ui/src/icons.tsx @@ -107,6 +107,7 @@ export { PanelRightClose, PanelRightOpen, Paperclip, + Pause, Pencil, Pin, PinOff, diff --git a/packages/ui/src/session-context-layer.tsx b/packages/ui/src/session-context-layer.tsx index 27472229eb..c08fccfd39 100644 --- a/packages/ui/src/session-context-layer.tsx +++ b/packages/ui/src/session-context-layer.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import { useEffect, useReducer, type ReactElement } from 'react'; import { BreadcrumbItem, Breadcrumbs, @@ -14,6 +14,7 @@ import { type DropdownMenuOption, } from '@astryxdesign/core'; import { getConversationCopy } from './conversation-copy.js'; +import { ICON_SIZE, Pause, Play } from './icons.js'; import { useUiLocale } from './locale-context.js'; export interface SessionContextBranch { @@ -34,6 +35,17 @@ export interface SessionContextGoal { status: string; iterations: number; maxIterations: number; + /** Epoch ms when the goal was armed; the chip derives wall-clock elapsed. */ + setAt: number; + /** Epoch ms when the goal was paused; freezes the chip clock while paused. */ + pausedAt?: number; + tokensSpent?: number; + /** When present (a budget exists), the chip shows spent / budget. */ + tokenBudget?: number; + /** Present when the goal can be paused (active/waiting). */ + onPause?(): void; + /** Present when the goal is paused and can be resumed. */ + onResume?(): void; onClear(): void; } @@ -56,17 +68,94 @@ export function SessionContextLayer(props: { }) { const copy = getConversationCopy(useUiLocale()).chat; const contextItems: ContextItem[] = []; + // A live elapsed label must keep moving: tick the chip every 30s so the + // wall clock does not freeze between session events. Paused goals freeze + // by design (pausedAt), so they skip the ticker. + const [, tick] = useReducer((value: number) => value + 1, 0); + const liveClock = props.goal !== undefined && props.goal.status !== 'paused'; + useEffect(() => { + if (!liveClock) return; + const interval = window.setInterval(tick, 30_000); + return () => window.clearInterval(interval); + }, [liveClock]); if (props.goal) { const goal = props.goal; + // A paused goal burns nothing: it must look distinct (no pulse, warning + // tone) from a running loop at a glance. + const paused = goal.status === 'paused'; + const elapsedMs = + paused && goal.pausedAt !== undefined + ? Math.max(0, goal.pausedAt - goal.setAt) + : Math.max(0, Date.now() - goal.setAt); + const goalText = [ + copy.goalProgress(goal.iterations, goal.maxIterations), + copy.goalElapsed(elapsedMs), + goal.tokenBudget !== undefined && goal.tokensSpent !== undefined + ? copy.goalTokens(goal.tokensSpent, goal.tokenBudget) + : null, + ] + .filter((part) => part !== null) + .join(' \u00b7 '); + const overflowItems: DropdownMenuOption[] = []; + if (goal.onPause) { + overflowItems.push({ + label: copy.pauseGoalAriaLabel(goal.iterations, goal.maxIterations), + icon: , + onClick: goal.onPause, + }); + } + if (goal.onResume) { + overflowItems.push({ + label: copy.resumeGoalAriaLabel(goal.iterations, goal.maxIterations), + icon: , + onClick: goal.onResume, + }); + } + overflowItems.push({ + label: copy.clearGoalAriaLabel(goal.iterations, goal.maxIterations), + icon: , + onClick: goal.onClear, + }); contextItems.push({ key: 'goal', element: (
- + - {copy.goalProgress(goal.iterations, goal.maxIterations)} + {goalText} + {goal.onPause ? ( + } + variant="ghost" + size="sm" + onClick={goal.onPause} + tooltip={copy.pauseGoal( + goal.condition, + goal.iterations, + goal.maxIterations, + goal.status, + )} + /> + ) : null} + {goal.onResume ? ( + } + variant="ghost" + size="sm" + onClick={goal.onResume} + tooltip={copy.resumeGoal(goal.condition, goal.iterations, goal.maxIterations)} + /> + ) : null}
), - overflowItems: [{ - label: copy.clearGoalAriaLabel(goal.iterations, goal.maxIterations), - icon: , - onClick: goal.onClear, - }], + overflowItems, }); } From 731c8fedd95fdfd5b968ec26809528faeadef7d2 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 19:15:49 +0800 Subject: [PATCH 2/3] fix(desktop): distinguish waiting goal state Generated-by: Codex --- apps/desktop/src/renderer/use-session-goal.ts | 15 ++- apps/desktop/stories/app-shell.stories.tsx | 91 +++++++++++++++---- .../session-context-layer-goal.test.tsx | 20 ++++ packages/ui/src/conversation-copy.ts | 5 +- packages/ui/src/session-context-layer.tsx | 21 +++-- 5 files changed, 120 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/renderer/use-session-goal.ts b/apps/desktop/src/renderer/use-session-goal.ts index 5836a6df20..f2b0a39ecb 100644 --- a/apps/desktop/src/renderer/use-session-goal.ts +++ b/apps/desktop/src/renderer/use-session-goal.ts @@ -14,10 +14,17 @@ import { useEffect, useState } from 'react'; import type { GoalState, GoalStatus } from '@maka/runtime/goal-state'; -const RUNNING_GOAL_STATUSES: ReadonlySet = new Set(['active', 'waiting', 'paused']); +type LiveGoalStatus = Extract; +type LiveGoalState = GoalState & { readonly status: LiveGoalStatus }; -export function useSessionGoal(sessionId: string | undefined): GoalState | null { - const [goal, setGoal] = useState(null); +const LIVE_GOAL_STATUSES: ReadonlySet = new Set(['active', 'waiting', 'paused']); + +function isLiveGoal(goal: GoalState): goal is LiveGoalState { + return LIVE_GOAL_STATUSES.has(goal.status); +} + +export function useSessionGoal(sessionId: string | undefined): LiveGoalState | null { + const [goal, setGoal] = useState(null); useEffect(() => { if (!sessionId) { @@ -30,7 +37,7 @@ export function useSessionGoal(sessionId: string | undefined): GoalState | null .get(sessionId) .then((g) => { if (cancelled) return; - setGoal(g && RUNNING_GOAL_STATUSES.has(g.status) ? g : null); + setGoal(g && isLiveGoal(g) ? g : null); }) .catch(() => { if (!cancelled) setGoal(null); diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 3b807658f1..a347b01ed5 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -868,18 +868,8 @@ const LINEAGE_SESSIONS: SessionSummary[] = [ }), ]; -// Real path: open a derived revision that is running an autonomous goal with -// local memory and Deep Research enabled. Session metadata stays in one context -// layer above the transcript instead of splitting across header pills and -// standalone branch/revision rows. The long session name is the point: it is -// what forces that layer to collapse rather than wrap. -// -// The banner reads 分自 without 从中断前: deriveBranchBanner only adds that hint -// when the caller supplies it, and the renderer deliberately does not until -// parent-message preloading lands (app-shell.tsx). A story that showed it would -// be showing a screen the app cannot currently produce. -export const SessionContextLayer: Story = { - render: () => ( +function GoalContextStory(props: { goal: NonNullable }) { + return ( + ); +} + +// Real path: open a derived revision that is running an autonomous goal with +// local memory and Deep Research enabled. Session metadata stays in one context +// layer above the transcript instead of splitting across header pills and +// standalone branch/revision rows. The long session name is the point: it is +// what forces that layer to collapse rather than wrap. +// +// The banner reads 分自 without 从中断前: deriveBranchBanner only adds that hint +// when the caller supplies it, and the renderer deliberately does not until +// parent-message preloading lands (app-shell.tsx). A story that showed it would +// be showing a screen the app cannot currently produce. +export const SessionContextLayer: Story = { + render: () => ( + + ), +}; + +export const SessionContextLayerWaiting: Story = { + render: () => ( + ), }; +export const SessionContextLayerPaused: Story = { + render: () => { + const pausedAt = Date.now() - 4 * 60_000; + return ( + + ); + }, +}; + // The titlebar states the session's identity in every session view, so the // stories above already show its ordinary state. These two cover what they // cannot: a session with no directory to name, and a name long enough to reach diff --git a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx index 447f784acb..01e0e68f64 100644 --- a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx +++ b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx @@ -60,3 +60,23 @@ test('a paused goal reads as paused and offers resume, not pause', () => { assert.ok(markup.includes('12m')); assert.ok(!markup.includes('12k')); }); + +test('a waiting goal reads as waiting without looking active or paused', () => { + const markup = renderGoalChip({ + condition: 'Wait for CI', + status: 'waiting', + iterations: 4, + maxIterations: 50, + setAt: Date.now() - 30_000, + tokensSpent: 12_000, + tokenBudget: 100_000, + onPause: () => undefined, + onClear: () => undefined, + }); + assert.ok(markup.includes('Autonomous goal waiting for conditions to change')); + assert.ok(!markup.includes('Autonomous goal running')); + assert.ok(!markup.includes('Autonomous goal paused')); + assert.ok(markup.includes('Pause autonomous goal after 4/50 iterations')); + assert.ok(!markup.includes('Resume autonomous goal')); + assert.ok(markup.includes('12k / 100k')); +}); diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 56e8aae50b..6b4f0214c0 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -281,6 +281,7 @@ export interface ConversationCopy { clearGoalAriaLabel: (iteration: number, max: number) => string; goalProgress: (iteration: number, max: number) => string; goalRunningAriaLabel: string; + goalWaitingAriaLabel: string; goalPausedAriaLabel: string; pauseGoalAriaLabel: (iteration: number, max: number) => string; resumeGoalAriaLabel: (iteration: number, max: number) => string; @@ -462,7 +463,7 @@ const CONVERSATION_COPY = { verification: '验证', }, }, - clearGoal: (condition, iteration, max, status) => `自主执行目标进行中:「${condition}」(第 ${iteration}/${max} 轮,${status})。系统每轮后自动续行;点击可清除目标、停止续行。`, clearGoalAriaLabel: (iteration, max) => `清除自主执行目标(已进行 ${iteration}/${max} 轮)`, goalProgress: (iteration, max) => `目标 ${iteration} / ${max}`, goalRunningAriaLabel: '自主目标正在运行', + clearGoal: (condition, iteration, max, status) => `自主执行目标进行中:「${condition}」(第 ${iteration}/${max} 轮,${status})。系统每轮后自动续行;点击可清除目标、停止续行。`, clearGoalAriaLabel: (iteration, max) => `清除自主执行目标(已进行 ${iteration}/${max} 轮)`, goalProgress: (iteration, max) => `目标 ${iteration} / ${max}`, goalRunningAriaLabel: '自主目标正在运行', goalWaitingAriaLabel: '自主目标正在等待条件变化', goalPausedAriaLabel: '自主目标已暂停', pauseGoalAriaLabel: (iteration, max) => `暂停自主执行目标(已进行 ${iteration}/${max} 轮)`, resumeGoalAriaLabel: (iteration, max) => `恢复自主执行目标(已进行 ${iteration}/${max} 轮)`, pauseGoal: (condition, iteration, max, status) => `暂停自主执行目标:「${condition}」(第 ${iteration}/${max} 轮,${status})。暂停后立即停止自动续行,不再消耗令牌;可随时恢复。`, resumeGoal: (condition, iteration, max) => `恢复自主执行目标:「${condition}」(第 ${iteration}/${max} 轮)。恢复后立即继续自动续行。`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: ' 秒', minute: ' 分钟', hour: ' 小时', day: ' 天' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: '任务载入失败', loading: '载入中…', retryLoad: '重试载入', quoteSelection: '引用', askInSidePanel: '在侧栏追问', noMessages: '暂无消息', branchBeforeInterrupt: '从中断前分支', sessionContextAriaLabel: '任务上下文', sessionLineageAriaLabel: '任务来源', sessionContextMore: (count) => `更多任务上下文(${count})`, @@ -601,7 +602,7 @@ const CONVERSATION_COPY = { verification: 'Verification', }, }, - clearGoal: (condition, iteration, max, status) => `Autonomous goal in progress: “${condition}” (iteration ${iteration}/${max}, ${status}). Maka continues after each iteration; click to clear the goal and stop continuing.`, clearGoalAriaLabel: (iteration, max) => `Clear autonomous goal after ${iteration}/${max} iterations`, goalProgress: (iteration, max) => `Goal ${iteration} of ${max}`, goalRunningAriaLabel: 'Autonomous goal running', + clearGoal: (condition, iteration, max, status) => `Autonomous goal in progress: “${condition}” (iteration ${iteration}/${max}, ${status}). Maka continues after each iteration; click to clear the goal and stop continuing.`, clearGoalAriaLabel: (iteration, max) => `Clear autonomous goal after ${iteration}/${max} iterations`, goalProgress: (iteration, max) => `Goal ${iteration} of ${max}`, goalRunningAriaLabel: 'Autonomous goal running', goalWaitingAriaLabel: 'Autonomous goal waiting for conditions to change', goalPausedAriaLabel: 'Autonomous goal paused', pauseGoalAriaLabel: (iteration, max) => `Pause autonomous goal after ${iteration}/${max} iterations`, resumeGoalAriaLabel: (iteration, max) => `Resume autonomous goal after ${iteration}/${max} iterations`, pauseGoal: (condition, iteration, max, status) => `Pause autonomous goal: “${condition}” (iteration ${iteration}/${max}, ${status}). Pausing stops autonomous continuation immediately — no more tokens burn; resume any time.`, resumeGoal: (condition, iteration, max) => `Resume autonomous goal: “${condition}” (iteration ${iteration}/${max}). Resuming continues autonomous iteration immediately.`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: 's', minute: 'm', hour: 'h', day: 'd' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: 'Task failed to load', loading: 'Loading…', retryLoad: 'Retry', quoteSelection: 'Quote', askInSidePanel: 'Ask in side panel', noMessages: 'No messages yet', branchBeforeInterrupt: 'Branched before interruption', sessionContextAriaLabel: 'Task context', sessionLineageAriaLabel: 'Task origin', sessionContextMore: (count) => `More task context (${count})`, diff --git a/packages/ui/src/session-context-layer.tsx b/packages/ui/src/session-context-layer.tsx index c08fccfd39..9eb8c55247 100644 --- a/packages/ui/src/session-context-layer.tsx +++ b/packages/ui/src/session-context-layer.tsx @@ -16,6 +16,7 @@ import { import { getConversationCopy } from './conversation-copy.js'; import { ICON_SIZE, Pause, Play } from './icons.js'; import { useUiLocale } from './locale-context.js'; +import { dotForStatus } from './status-vocabulary.js'; export interface SessionContextBranch { parentSessionId: string; @@ -32,7 +33,7 @@ export interface SessionContextRevision { export interface SessionContextGoal { condition: string; - status: string; + status: 'active' | 'waiting' | 'paused'; iterations: number; maxIterations: number; /** Epoch ms when the goal was armed; the chip derives wall-clock elapsed. */ @@ -81,9 +82,11 @@ export function SessionContextLayer(props: { if (props.goal) { const goal = props.goal; - // A paused goal burns nothing: it must look distinct (no pulse, warning - // tone) from a running loop at a glance. + // A paused goal burns nothing, while waiting remains live but is not + // currently executing. Both must be visually still; only paused needs an + // attention tone. const paused = goal.status === 'paused'; + const waiting = goal.status === 'waiting'; const elapsedMs = paused && goal.pausedAt !== undefined ? Math.max(0, goal.pausedAt - goal.setAt) @@ -122,9 +125,15 @@ export function SessionContextLayer(props: { element: (
{goalText} From af751796328c27aeca0613c704e25223a8123e41 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 20:37:06 +0800 Subject: [PATCH 3/3] fix(desktop): serialize goal controls and projection reads Generated-by: Codex --- apps/desktop/src/renderer/app-shell.tsx | 125 ++++++++++-------- apps/desktop/src/renderer/use-session-goal.ts | 19 ++- packages/ui/src/session-context-layer.tsx | 33 +++-- 3 files changed, 108 insertions(+), 69 deletions(-) diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 36b734cf93..456d4075a8 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -757,6 +757,22 @@ function AppShellContent({ // Active autonomous goal for the current session drives the header // kill-switch pill (visible indicator + one-click clear). const activeGoal = useSessionGoal(activeId); + const pendingGoalControlSessionIdsRef = useRef(new Set()); + const runGoalControl = useCallback( + ( + sessionId: string, + operation: () => Promise, + reportFailure: (error: unknown) => void, + ): void => { + const pending = pendingGoalControlSessionIdsRef.current; + if (pending.has(sessionId)) return; + pending.add(sessionId); + void operation() + .catch(reportFailure) + .finally(() => pending.delete(sessionId)); + }, + [], + ); // Set of session ids whose backend / connection is no longer usable — // drives the sidebar "已过期" pill (PR108g, paired with the PR108e chat // header banner). Derivation is pure (see `stale-sessions.ts`) so the @@ -3291,56 +3307,17 @@ function AppShellContent({ onOpenMemorySettings={() => openSettingsSection('memory')} goalIndicator={ activeGoal - ? { - condition: activeGoal.condition, - status: activeGoal.status, - iterations: activeGoal.iterations, - maxIterations: activeGoal.maxIterations, - setAt: activeGoal.setAt, - ...(activeGoal.pausedAt !== undefined - ? { pausedAt: activeGoal.pausedAt } - : {}), - tokensSpent: activeGoal.tokensNow, - ...(activeGoal.tokenBudget !== undefined - ? { tokenBudget: activeGoal.tokenBudget } - : {}), - ...(activeGoal.status === 'active' || activeGoal.status === 'waiting' - ? { - onPause: () => { - void window.maka.goal - .pause(activeGoal.sessionId) - .catch((error) => { - toastApi.error( - shellCopy.goalPauseFailedTitle, - localizedShellErrorMessage( - error, - shellCopy.goalPauseFailedFallback, - uiLocale, - ), - ); - }); - }, - } - : {}), - ...(activeGoal.status === 'paused' - ? { - onResume: () => { - void window.maka.goal - .resume(activeGoal.sessionId) - .catch((error) => { - toastApi.error( - shellCopy.goalResumeFailedTitle, - localizedShellErrorMessage( - error, - shellCopy.goalResumeFailedFallback, - uiLocale, - ), - ); - }); - }, - } - : {}), - onClear: () => { + ? (() => { + const common = { + condition: activeGoal.condition, + iterations: activeGoal.iterations, + maxIterations: activeGoal.maxIterations, + setAt: activeGoal.setAt, + tokensSpent: activeGoal.tokensNow, + ...(activeGoal.tokenBudget !== undefined + ? { tokenBudget: activeGoal.tokenBudget } + : {}), + onClear: () => { void window.maka.goal.clear(activeGoal.sessionId).catch((error) => { toastApi.error( shellCopy.goalClearFailedTitle, @@ -3351,8 +3328,52 @@ function AppShellContent({ ), ); }); - }, - } + }, + }; + if (activeGoal.status === 'paused') { + return { + ...common, + status: 'paused' as const, + pausedAt: activeGoal.pausedAt, + onResume: () => { + runGoalControl( + activeGoal.sessionId, + () => window.maka.goal.resume(activeGoal.sessionId), + (error) => { + toastApi.error( + shellCopy.goalResumeFailedTitle, + localizedShellErrorMessage( + error, + shellCopy.goalResumeFailedFallback, + uiLocale, + ), + ); + }, + ); + }, + }; + } + return { + ...common, + status: activeGoal.status, + onPause: () => { + runGoalControl( + activeGoal.sessionId, + () => window.maka.goal.pause(activeGoal.sessionId), + (error) => { + toastApi.error( + shellCopy.goalPauseFailedTitle, + localizedShellErrorMessage( + error, + shellCopy.goalPauseFailedFallback, + uiLocale, + ), + ); + }, + ); + }, + }; + })() : undefined } messageLoadError={activeId ? messageLoadErrorBySession[activeId] : undefined} diff --git a/apps/desktop/src/renderer/use-session-goal.ts b/apps/desktop/src/renderer/use-session-goal.ts index f2b0a39ecb..d042c5b1b7 100644 --- a/apps/desktop/src/renderer/use-session-goal.ts +++ b/apps/desktop/src/renderer/use-session-goal.ts @@ -14,13 +14,19 @@ import { useEffect, useState } from 'react'; import type { GoalState, GoalStatus } from '@maka/runtime/goal-state'; -type LiveGoalStatus = Extract; -type LiveGoalState = GoalState & { readonly status: LiveGoalStatus }; +type LiveGoalStatus = Extract; +type LiveGoalState = + | (GoalState & { readonly status: LiveGoalStatus }) + | (GoalState & { readonly status: 'paused'; readonly pausedAt: number }); const LIVE_GOAL_STATUSES: ReadonlySet = new Set(['active', 'waiting', 'paused']); function isLiveGoal(goal: GoalState): goal is LiveGoalState { - return LIVE_GOAL_STATUSES.has(goal.status); + return ( + LIVE_GOAL_STATUSES.has(goal.status) && + (goal.status !== 'paused' || + (typeof goal.pausedAt === 'number' && Number.isFinite(goal.pausedAt))) + ); } export function useSessionGoal(sessionId: string | undefined): LiveGoalState | null { @@ -32,15 +38,17 @@ export function useSessionGoal(sessionId: string | undefined): LiveGoalState | n return; } let cancelled = false; + let refreshSequence = 0; const refresh = (): void => { + const sequence = ++refreshSequence; void window.maka.goal .get(sessionId) .then((g) => { - if (cancelled) return; + if (cancelled || sequence !== refreshSequence) return; setGoal(g && isLiveGoal(g) ? g : null); }) .catch(() => { - if (!cancelled) setGoal(null); + if (!cancelled && sequence === refreshSequence) setGoal(null); }); }; refresh(); @@ -53,6 +61,7 @@ export function useSessionGoal(sessionId: string | undefined): LiveGoalState | n }); return () => { cancelled = true; + refreshSequence += 1; unsubscribe(); }; }, [sessionId]); diff --git a/packages/ui/src/session-context-layer.tsx b/packages/ui/src/session-context-layer.tsx index 9eb8c55247..657fa46d41 100644 --- a/packages/ui/src/session-context-layer.tsx +++ b/packages/ui/src/session-context-layer.tsx @@ -31,25 +31,35 @@ export interface SessionContextRevision { nextSessionId?: string; } -export interface SessionContextGoal { +interface SessionContextGoalBase { condition: string; - status: 'active' | 'waiting' | 'paused'; iterations: number; maxIterations: number; /** Epoch ms when the goal was armed; the chip derives wall-clock elapsed. */ setAt: number; - /** Epoch ms when the goal was paused; freezes the chip clock while paused. */ - pausedAt?: number; tokensSpent?: number; /** When present (a budget exists), the chip shows spent / budget. */ tokenBudget?: number; - /** Present when the goal can be paused (active/waiting). */ - onPause?(): void; - /** Present when the goal is paused and can be resumed. */ - onResume?(): void; onClear(): void; } +export type SessionContextGoal = + | (SessionContextGoalBase & { + status: 'active' | 'waiting'; + pausedAt?: never; + /** Present when the goal can be paused (active/waiting). */ + onPause?(): void; + onResume?: never; + }) + | (SessionContextGoalBase & { + status: 'paused'; + /** Epoch ms when the goal was paused; freezes the chip clock while paused. */ + pausedAt: number; + onPause?: never; + /** Present when the goal is paused and can be resumed. */ + onResume?(): void; + }); + interface ContextItem { key: string; element: ReactElement; @@ -87,10 +97,9 @@ export function SessionContextLayer(props: { // attention tone. const paused = goal.status === 'paused'; const waiting = goal.status === 'waiting'; - const elapsedMs = - paused && goal.pausedAt !== undefined - ? Math.max(0, goal.pausedAt - goal.setAt) - : Math.max(0, Date.now() - goal.setAt); + const elapsedMs = paused + ? Math.max(0, goal.pausedAt - goal.setAt) + : Math.max(0, Date.now() - goal.setAt); const goalText = [ copy.goalProgress(goal.iterations, goal.maxIterations), copy.goalElapsed(elapsedMs),