diff --git a/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts b/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts index 9f6e955e67e..09a2b13c11b 100644 --- a/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts +++ b/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { GoalArmOutcome } from '../../shared/goal-arm.js'; -import { interpretGoalArmOutcome } from '../../renderer/goal-arm-outcome.js'; +import { interpretGoalArmOutcome } from '../../renderer/features/goals/testing.js'; import { getShellCopy } from '../../renderer/locales/shell-copy.js'; test('successful Goal arming closes while every reconciliation result locks the form', () => { diff --git a/apps/desktop/src/main/__tests__/goal-controller.test.ts b/apps/desktop/src/main/__tests__/goal-controller.test.ts new file mode 100644 index 00000000000..d063a15fef6 --- /dev/null +++ b/apps/desktop/src/main/__tests__/goal-controller.test.ts @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { act, createElement } from 'react'; +import type { GoalState, GoalStatus } from '@maka/core/goal'; +import { LocaleProvider } from '@maka/ui'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + createFakeGoalServices, + GoalServicesProvider, + useGoalController, + type GoalController, + type GoalServices, + type UseGoalControllerInput, +} from '../../renderer/features/goals/testing.js'; + +function goal( + sessionId: string, + status: GoalStatus = 'active', +): GoalState { + return { + id: `goal-${sessionId}`, + revision: 1, + sessionId, + condition: `Finish ${sessionId}`, + status, + setAt: 100, + iterations: 2, + maxIterations: 9, + consecutiveNoProgress: 0, + blockCap: 3, + tokenBudget: 500, + tokensAtStart: 10, + tokensNow: 60, + tokensBaselinePending: false, + ...(status === 'paused' ? { pausedAt: 200 } : {}), + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((next, fail) => { + resolve = next; + reject = fail; + }); + return { promise, resolve, reject }; +} + +let latestController: GoalController | undefined; + +function ControllerProbe(props: UseGoalControllerInput) { + latestController = useGoalController(props); + return null; +} + +function renderController( + root: ReturnType['root'], + services: GoalServices, + input: UseGoalControllerInput, +) { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement( + GoalServicesProvider, + { services }, + createElement(ControllerProbe, input), + ), + }), + ); +} + +function controller(): GoalController { + assert.ok(latestController); + return latestController; +} + +function input( + activeSessionId: string | undefined, + errors: string[] = [], +): UseGoalControllerInput { + return { + activeSessionId, + reportError: (sessionId, title, description) => { + errors.push(`${sessionId}: ${title}: ${description ?? ''}`); + }, + }; +} + +afterEach(() => { + latestController = undefined; + cleanupFakeDom(); +}); + +describe('useGoalController', () => { + it('clears on Session switch and ignores the previous Session late read', async () => { + const { root } = installReactRenderer(); + const first = deferred(); + let disposers = 0; + const defaults = createFakeGoalServices(); + const services = createFakeGoalServices({ + goal: { + ...defaults.goal, + get: (sessionId) => + sessionId === 'a' ? first.promise : Promise.resolve(goal('b')), + subscribeChanges: () => () => { + disposers += 1; + }, + }, + }); + + await act(async () => renderController(root, services, input('a'))); + assert.equal(controller().selectors.indicator, undefined); + await act(async () => renderController(root, services, input('b'))); + assert.equal(controller().selectors.indicator?.condition, 'Finish b'); + assert.equal(disposers, 1); + + await act(async () => first.resolve(goal('a'))); + assert.equal(controller().selectors.indicator?.condition, 'Finish b'); + + await act(async () => root.unmount()); + assert.equal(disposers, 2); + }); + + it('refreshes for matching and broadcast Goal changes only', async () => { + const { root } = installReactRenderer(); + let gets = 0; + let emit: ((sessionId: string | undefined) => void) | undefined; + const defaults = createFakeGoalServices(); + const services = createFakeGoalServices({ + goal: { + ...defaults.goal, + get: async () => { + gets += 1; + return gets === 2 ? goal('a', 'achieved') : goal('a'); + }, + subscribeChanges: (handler) => { + emit = handler; + return () => undefined; + }, + }, + }); + + await act(async () => renderController(root, services, input('a'))); + assert.equal(gets, 1); + assert.equal(controller().selectors.active, true); + + await act(async () => emit?.('other')); + assert.equal(gets, 1); + + await act(async () => emit?.('a')); + assert.equal(gets, 2); + assert.equal(controller().selectors.active, false); + + await act(async () => emit?.(undefined)); + assert.equal(gets, 3); + assert.equal(controller().selectors.active, true); + }); + + it('snapshots the dialog Session across navigation and routes arm through the port', async () => { + const { root } = installReactRenderer(); + const arms: unknown[][] = []; + const defaults = createFakeGoalServices(); + const services = createFakeGoalServices({ + goal: { + ...defaults.goal, + arm: async (...args) => { + arms.push(args); + return { kind: 'armed', goal: goal(args[0]) }; + }, + }, + }); + + await act(async () => renderController(root, services, input('a'))); + await act(async () => controller().commands.openDialog()); + await act(async () => renderController(root, services, input('b'))); + + assert.equal(controller().host.dialogSessionId, 'a'); + await act(async () => + controller().host.arm(controller().host.dialogSessionId!, { + condition: 'ship it', + maxIterations: null, + tokenBudget: 1_000, + }), + ); + assert.deepEqual(arms, [ + ['a', { condition: 'ship it', maxIterations: null, tokenBudget: 1_000 }], + ]); + }); + + it('projects the kill switch and deduplicates pending pause controls', async () => { + const { root } = installReactRenderer(); + const pause = deferred(); + let pauseCalls = 0; + const errors: string[] = []; + const defaults = createFakeGoalServices(); + const services = createFakeGoalServices({ + goal: { + ...defaults.goal, + get: async () => goal('a'), + pause: () => { + pauseCalls += 1; + return pauseCalls === 1 ? pause.promise : Promise.resolve(); + }, + }, + }); + + await act(async () => renderController(root, services, input('a', errors))); + const indicator = controller().selectors.indicator; + assert.equal(indicator?.condition, 'Finish a'); + assert.equal(indicator?.tokensSpent, 60); + assert.equal(indicator?.tokenBudget, 500); + + await act(async () => { + indicator?.onPause?.(); + indicator?.onPause?.(); + }); + assert.equal(pauseCalls, 1); + + await act(async () => pause.reject(new Error('offline'))); + assert.equal(errors.length, 1); + assert.equal( + errors[0], + 'a: Could not pause the goal: The goal may still be continuing. Try again now.', + ); + + await act(async () => controller().selectors.indicator?.onPause?.()); + assert.equal(pauseCalls, 2); + }); + + it('routes resume and clear controls for paused Goals', async () => { + const { root } = installReactRenderer(); + const calls: string[] = []; + const defaults = createFakeGoalServices(); + const services = createFakeGoalServices({ + goal: { + ...defaults.goal, + get: async () => goal('a', 'paused'), + clear: async (sessionId) => { + calls.push(`clear:${sessionId}`); + }, + resume: async (sessionId) => { + calls.push(`resume:${sessionId}`); + }, + }, + }); + + await act(async () => renderController(root, services, input('a'))); + const indicator = controller().selectors.indicator; + assert.equal(indicator?.status, 'paused'); + assert.equal(indicator?.pausedAt, 200); + await act(async () => { + indicator?.onResume?.(); + indicator?.onClear(); + }); + assert.deepEqual(calls, ['resume:a', 'clear:a']); + }); +}); diff --git a/apps/desktop/src/main/__tests__/goal-dialog.test.ts b/apps/desktop/src/main/__tests__/goal-dialog.test.ts index 353ba9d3f60..30c18dc3780 100644 --- a/apps/desktop/src/main/__tests__/goal-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/goal-dialog.test.ts @@ -24,7 +24,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; import type { GoalArmOutcome } from '../../shared/goal-arm.js'; -import { GoalDialog } from '../../renderer/goal-dialog.js'; +import { GoalDialog } from '../../renderer/features/goals/testing.js'; const originalGlobals = { document: globalThis.document, @@ -171,9 +171,6 @@ function installGoalDialog( cancelAnimationFrame: (handle: number) => clearTimeout(handle), IS_REACT_ACT_ENVIRONMENT: true, }); - (window as unknown as { maka: unknown }).maka = { - goal: { arm }, - }; const container = document.querySelector('#root'); assert.ok(container); const root = createRoot(container); @@ -192,6 +189,7 @@ function installGoalDialog( children: createElement(AstryxLocaleProvider, { children: createElement(GoalDialog, { sessionId, + onArm: arm, onClose: () => { closed += 1; }, diff --git a/apps/desktop/src/main/__tests__/goal-model.test.ts b/apps/desktop/src/main/__tests__/goal-model.test.ts new file mode 100644 index 00000000000..986f7d74665 --- /dev/null +++ b/apps/desktop/src/main/__tests__/goal-model.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { GoalState, GoalStatus } from '@maka/core/goal'; +import { + isLiveGoal, + readGoalBudget, +} from '../../renderer/features/goals/testing.js'; + +function goal(status: GoalStatus, pausedAt?: number): GoalState { + return { + id: 'goal-1', + revision: 1, + sessionId: 'session-1', + condition: 'Finish the refactor', + status, + setAt: 1, + iterations: 2, + maxIterations: 10, + consecutiveNoProgress: 0, + blockCap: 3, + tokensAtStart: 0, + tokensNow: 50, + tokensBaselinePending: false, + ...(pausedAt === undefined ? {} : { pausedAt }), + }; +} + +describe('Goals model', () => { + it('parses only whole, bounded, safe budget values', () => { + assert.deepEqual(readGoalBudget(' ', 1, 10), { kind: 'empty' }); + assert.deepEqual(readGoalBudget('7', 1, 10), { kind: 'value', value: 7 }); + for (const invalid of ['0', '11', '-1', '1.5', '1e2', '9007199254740992']) { + assert.deepEqual(readGoalBudget(invalid, 1, 10), { kind: 'invalid' }); + } + }); + + it('projects only running and well-formed paused Goals', () => { + assert.equal(isLiveGoal(goal('active')), true); + assert.equal(isLiveGoal(goal('waiting')), true); + assert.equal(isLiveGoal(goal('paused', 12)), true); + assert.equal(isLiveGoal(goal('paused')), false); + assert.equal(isLiveGoal(goal('paused', Number.NaN)), false); + assert.equal(isLiveGoal(goal('achieved')), false); + assert.equal(isLiveGoal(goal('cleared')), false); + }); +}); diff --git a/apps/desktop/src/main/__tests__/goal-services-adapter.test.ts b/apps/desktop/src/main/__tests__/goal-services-adapter.test.ts new file mode 100644 index 00000000000..e82445224ba --- /dev/null +++ b/apps/desktop/src/main/__tests__/goal-services-adapter.test.ts @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { SessionChangedEvent } from '@maka/core/session'; +import type { MakaBridge } from '../../preload/bridge-contract.js'; +import { createDesktopGoalServices } from '../../renderer/platform/desktop/create-goal-services.js'; + +describe('createDesktopGoalServices', () => { + it('maps Goal operations and narrows Session changes to Goal events', async () => { + const calls: Array<{ name: string; args: unknown[] }> = []; + let sessionHandler: ((event: SessionChangedEvent) => void) | undefined; + let disposed = 0; + const bridge = { + goal: new Proxy({}, { + get: (_target, property) => (...args: unknown[]) => { + calls.push({ name: `goal.${String(property)}`, args }); + return Promise.resolve(undefined); + }, + }), + sessions: { + subscribeChanges(handler: (event: SessionChangedEvent) => void) { + sessionHandler = handler; + return () => { + disposed += 1; + }; + }, + }, + } as unknown as MakaBridge; + const services = createDesktopGoalServices(bridge); + + await services.goal.get('s'); + await services.goal.arm('s', { + condition: 'done', + maxIterations: 4, + tokenBudget: null, + }); + await services.goal.clear('s'); + await services.goal.pause('s'); + await services.goal.resume('s'); + + const changes: Array = []; + const unsubscribe = services.goal.subscribeChanges((sessionId) => { + changes.push(sessionId); + }); + sessionHandler?.({ + type: 'sessions_changed', + reason: 'updated', + sessionId: 'ignored', + ts: 1, + }); + sessionHandler?.({ + type: 'sessions_changed', + reason: 'goal-change', + sessionId: 's', + ts: 2, + }); + sessionHandler?.({ + type: 'sessions_changed', + reason: 'goal-change', + ts: 3, + }); + unsubscribe(); + + assert.deepEqual(calls, [ + { name: 'goal.get', args: ['s'] }, + { + name: 'goal.arm', + args: ['s', { condition: 'done', maxIterations: 4, tokenBudget: null }], + }, + { name: 'goal.clear', args: ['s'] }, + { name: 'goal.pause', args: ['s'] }, + { name: 'goal.resume', args: ['s'] }, + ]); + assert.deepEqual(changes, ['s', undefined]); + assert.equal(disposed, 1); + }); +}); diff --git a/apps/desktop/src/main/__tests__/goals-boundary.test.ts b/apps/desktop/src/main/__tests__/goals-boundary.test.ts new file mode 100644 index 00000000000..7078ea94cd8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/goals-boundary.test.ts @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const desktopRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url))); +const featureRoot = join(desktopRoot, 'src', 'renderer', 'features', 'goals'); + +function sourceFiles(root: string): string[] { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return /\.(?:ts|tsx|md)$/.test(entry.name) ? [path] : []; + }); +} + +describe('Goals feature boundary', () => { + it('contains no Desktop global bridge or shell/process imports', () => { + const violations: string[] = []; + for (const path of sourceFiles(featureRoot)) { + const source = readFileSync(path, 'utf8'); + const name = relative(desktopRoot, path); + if (source.includes('window.maka')) violations.push(`${name}: Desktop global`); + for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) { + const imported = match[1] ?? ''; + if ( + imported.includes('app-shell') || + imported.includes('/preload/') || + imported.includes('/main/') + ) { + violations.push(`${name}: ${imported}`); + } + } + } + assert.deepEqual(violations, []); + }); + + it('is consumed outside the feature only through public entries', () => { + const allowed = /\/features\/goals\/(?:index|testing)(?:\.js)?$/; + const violations: string[] = []; + for (const root of [join(desktopRoot, 'src'), join(desktopRoot, 'stories')]) { + for (const path of sourceFiles(root)) { + if (path.startsWith(featureRoot)) continue; + const source = readFileSync(path, 'utf8'); + for (const match of source.matchAll(/from\s+['"]([^'"]*features\/goals[^'"]*)['"]/g)) { + const imported = match[1] ?? ''; + const normalized = imported.replace(/\\/g, '/'); + const explicitEntry = normalized.endsWith('/features/goals') + ? `${normalized}/index` + : normalized; + if (!allowed.test(explicitEntry)) { + violations.push(`${relative(desktopRoot, path)}: ${imported}`); + } + } + } + } + assert.deepEqual(violations, []); + }); + + it('keeps fake services out of the production entry', () => { + const productionEntry = readFileSync(join(featureRoot, 'index.ts'), 'utf8'); + assert.equal(productionEntry.includes('createFakeGoalServices'), false); + assert.equal(productionEntry.includes("from './testing"), false); + }); + + it('keeps Goal state, controls, and dialog ownership out of AppShell', () => { + const appShell = readFileSync( + join(desktopRoot, 'src', 'renderer', 'app-shell.tsx'), + 'utf8', + ); + for (const forbidden of [ + 'window.maka.goal', + 'useSessionGoal', + 'pendingGoalControlSessionIdsRef', + 'goalDialogSessionId', + 'setGoalDialogSessionId', + ]) { + assert.equal(appShell.includes(forbidden), false, forbidden); + } + assert.equal(appShell.includes('const goals = useGoalController({'), true); + assert.equal(appShell.includes(''), true); + }); +}); diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index 183799bf3b9..f95082b7db8 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -41,6 +41,10 @@ shell logic split by one concern (e.g. `app-shell-session-events.ts`, `app-shell-overlays.tsx`). Keep a slice to one concern; if it grows, split along the same seam. +Autonomous Goal state, controls, dialog lifecycle, and Desktop I/O live under +`features/goals`; AppShell supplies the active Session boundary and consumes +the feature's host, commands, and selectors. + `settings/` holds the settings pages and the `SettingsModal` shell — one page per `SettingsSection` (defined in `@maka/core`); the models/providers page is `ProvidersPanel`. Plus the `provider-*` files and the shared `settings-rows` / `settings-skeleton` / `settings-surface` helpers. ## Styles & tokens diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index bcdfc664406..065e4c1ad5b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -93,6 +93,7 @@ import { WorkbarTitlebarActions, useWorkbarController, } from './features/workbar'; +import { GoalHost, useGoalController } from './features/goals'; import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent'; import { useNewTaskChoice } from './use-new-task-choice'; import { NEW_TASK_PENDING_KEY } from './pending-items'; @@ -127,7 +128,6 @@ import { getDesktopConversationCopy } from './locales/conversation-copy'; import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; import { useShellSearch } from './use-shell-search'; -import { useSessionGoal } from './use-session-goal'; import { deriveStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; import { deriveProjectGroups, deriveWorktreeSessionIds } from './session-project-grouping'; @@ -237,7 +237,6 @@ function rebaseWorkspaceFileReferences( import { useSettingsModal } from './use-settings-modal'; import { RemoteProjectDirectoryDialog } from './remote-project-directory-dialog'; -import { GoalDialog } from './goal-dialog.js'; import { useSystemUiLocale } from './use-system-ui-locale'; import { isSessionWorkspaceUnavailableError, @@ -724,31 +723,10 @@ function AppShellContent({ resumeInterruptedSession, } = useShellResume({ activeId, toastApi, shellCopy, uiLocale }); const rendererMountedRef = useRef(true); - // 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)); - }, - [], - ); - /** - * The Session the Goal dialog is arming, or `undefined` when it is closed. - * Keyed by Session rather than a boolean so switching Sessions while the - * dialog is open can never arm the wrong one. - */ - const [goalDialogSessionId, setGoalDialogSessionId] = useState(); + const goals = useGoalController({ + activeSessionId: activeId, + reportError: showSessionError, + }); // 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 @@ -3119,10 +3097,10 @@ function AppShellContent({ }} onSetGoal={ activeId && activeBoundarySurface.localInteractionAvailable - ? () => setGoalDialogSessionId(activeId) + ? goals.commands.openDialog : undefined } - goalActive={activeGoal !== null} + goalActive={goals.selectors.active} goalDisabledReason={ activeStreamingLive || (activeId && turnActive) ? shellCopy.goalTurnActive @@ -3159,80 +3137,7 @@ function AppShellContent({ userLabel={userLabel} memoryActive={memoryActive} onOpenMemorySettings={() => openSettingsSection('memory')} - goalIndicator={ - activeGoal - ? (() => { - 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) => { - showSessionError( - activeGoal.sessionId, - shellCopy.goalClearFailedTitle, - localizedShellErrorMessage( - error, - shellCopy.goalClearFailedFallback, - uiLocale, - ), - ); - }); - }, - }; - if (activeGoal.status === 'paused') { - return { - ...common, - status: 'paused' as const, - pausedAt: activeGoal.pausedAt, - onResume: () => { - runGoalControl( - activeGoal.sessionId, - () => window.maka.goal.resume(activeGoal.sessionId), - (error) => { - showSessionError( - activeGoal.sessionId, - shellCopy.goalResumeFailedTitle, - localizedShellErrorMessage( - error, - shellCopy.goalResumeFailedFallback, - uiLocale, - ), - ); - }, - ); - }, - }; - } - return { - ...common, - status: activeGoal.status, - onPause: () => { - runGoalControl( - activeGoal.sessionId, - () => window.maka.goal.pause(activeGoal.sessionId), - (error) => { - showSessionError( - activeGoal.sessionId, - shellCopy.goalPauseFailedTitle, - localizedShellErrorMessage( - error, - shellCopy.goalPauseFailedFallback, - uiLocale, - ), - ); - }, - ); - }, - }; - })() - : undefined - } + goalIndicator={goals.selectors.indicator} messageLoadError={activeId ? messageLoadErrorBySession[activeId] : undefined} messageLoadRetryPending={activeId ? messageRetryPendingBySession[activeId] === true : false} onRetryMessages={activeId ? () => void retryMessages(activeId) : undefined} @@ -3361,10 +3266,7 @@ function AppShellContent({ contextKey={activeId} /> )} - setGoalDialogSessionId(undefined)} - /> + + +# Goals feature + +Goals is a vertical renderer feature. It owns the active Goal read model, +Goal-change subscription, arm/pause/resume/clear controls, dialog target, and +the Goal indicator projection consumed by the chat surface. + +## Dependency direction + +- Consumers import production APIs from `features/goals`. +- Tests and stories may additionally import `features/goals/testing`. +- Goals may use shared renderer copy, core types, and Maka UI. +- Goals must not import `AppShell`, the preload implementation, or the main process. +- Desktop I/O enters through `GoalServices`; feature code never reads the + Desktop global bridge directly. +- `AppShell` supplies the active Session id and a session-scoped error reporter. + It consumes only ``, commands, and selectors. + +## Lifecycle invariants + +- A Goal is session-scoped. Switching Sessions clears the previous read model + synchronously and fences late reads before fetching the new Goal. +- Only `active`, `waiting`, and well-formed `paused` Goals are projected. A + settled Goal removes the indicator and composer active state. +- Goal-change broadcasts refresh the active Session; events for another + Session do not. +- Pause and resume are mutually exclusive per Session while a control request + is pending, preserving the existing re-entry guard. +- Opening the dialog snapshots the active Session id. Navigating while it is + open cannot retarget the arm request to a different Session. +- A reconnecting arm result closes only when the Host confirms a new Goal. + Reconciled or unavailable results lock the form and show the authoritative + outcome, preventing an accidental duplicate arm request. +- Form input and errors reset on each open. Budgets are validated against core + bounds and are never silently clamped. + +## Public surface + +- `host` is passed intact to ``. +- `commands.openDialog` is the shell entry for the composer action. +- `selectors.active` disables duplicate Goal creation and + `selectors.indicator` feeds the active chat surface. + +The Desktop adapter is created once in the renderer composition root. Tests +use `createFakeGoalServices` from `testing.ts`; production code must not import +that entry. diff --git a/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts b/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts new file mode 100644 index 00000000000..09ad21e35a4 --- /dev/null +++ b/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts @@ -0,0 +1,241 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from 'react'; +import { useUiLocale, type ChatView } from '@maka/ui'; +import { + getShellCopy, + localizedShellErrorMessage, +} from '../../../locales/shell-copy.js'; +import { isLiveGoal, type LiveGoalState } from '../model/live-goal.js'; +import type { GoalArmInput, GoalArmOutcome } from '../ports.js'; +import { useGoalServices } from '../services-context.js'; +import type { GoalHostModel } from '../ui/goal-host.js'; + +type GoalIndicator = NonNullable< + ComponentProps['goalIndicator'] +>; + +export interface GoalControllerCommands { + /** Opens for the current Session and snapshots that id until close. */ + openDialog(): void; +} + +export interface GoalControllerSelectors { + active: boolean; + indicator?: GoalIndicator; +} + +export interface UseGoalControllerInput { + activeSessionId: string | undefined; + reportError(sessionId: string, title: string, description?: string): void; +} + +export interface GoalController { + host: GoalHostModel; + commands: GoalControllerCommands; + selectors: GoalControllerSelectors; +} + +/** Owns all renderer Goal state, controls, subscriptions, and dialog lifecycle. */ +export function useGoalController( + input: UseGoalControllerInput, +): GoalController { + const locale = useUiLocale(); + const copy = getShellCopy(locale).app; + const { goal: service } = useGoalServices(); + const [activeGoal, setActiveGoal] = useState(null); + const [dialogSessionId, setDialogSessionId] = useState(); + const pendingControlSessionIdsRef = useRef(new Set()); + + useEffect(() => { + const sessionId = input.activeSessionId; + if (!sessionId) { + setActiveGoal(null); + return; + } + + setActiveGoal(null); + let disposed = false; + let refreshSequence = 0; + const refresh = (): void => { + const sequence = ++refreshSequence; + void service + .get(sessionId) + .then((goal) => { + if (disposed || sequence !== refreshSequence) return; + setActiveGoal(goal && isLiveGoal(goal) ? goal : null); + }) + .catch(() => { + if (!disposed && sequence === refreshSequence) setActiveGoal(null); + }); + }; + + refresh(); + const unsubscribe = service.subscribeChanges((changedSessionId) => { + if (!changedSessionId || changedSessionId === sessionId) refresh(); + }); + return () => { + disposed = true; + refreshSequence += 1; + unsubscribe(); + }; + }, [input.activeSessionId, service]); + + const reportControlFailure = useCallback( + ( + sessionId: string, + error: unknown, + title: string, + fallback: string, + ): void => { + input.reportError( + sessionId, + title, + localizedShellErrorMessage(error, fallback, locale), + ); + }, + [input.reportError, locale], + ); + + const runExclusiveControl = useCallback( + ( + sessionId: string, + operation: () => Promise, + reportFailure: (error: unknown) => void, + ): void => { + const pending = pendingControlSessionIdsRef.current; + if (pending.has(sessionId)) return; + pending.add(sessionId); + void operation() + .catch(reportFailure) + .finally(() => pending.delete(sessionId)); + }, + [], + ); + + const indicator = useMemo(() => { + if (!activeGoal) return undefined; + 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 service.clear(activeGoal.sessionId).catch((error) => { + reportControlFailure( + activeGoal.sessionId, + error, + copy.goalClearFailedTitle, + copy.goalClearFailedFallback, + ); + }); + }, + }; + + if (activeGoal.status === 'paused') { + return { + ...common, + status: 'paused', + pausedAt: activeGoal.pausedAt, + onResume: () => { + runExclusiveControl( + activeGoal.sessionId, + () => service.resume(activeGoal.sessionId), + (error) => { + reportControlFailure( + activeGoal.sessionId, + error, + copy.goalResumeFailedTitle, + copy.goalResumeFailedFallback, + ); + }, + ); + }, + }; + } + + return { + ...common, + status: activeGoal.status, + onPause: () => { + runExclusiveControl( + activeGoal.sessionId, + () => service.pause(activeGoal.sessionId), + (error) => { + reportControlFailure( + activeGoal.sessionId, + error, + copy.goalPauseFailedTitle, + copy.goalPauseFailedFallback, + ); + }, + ); + }, + }; + }, [ + activeGoal, + copy.goalClearFailedFallback, + copy.goalClearFailedTitle, + copy.goalPauseFailedFallback, + copy.goalPauseFailedTitle, + copy.goalResumeFailedFallback, + copy.goalResumeFailedTitle, + reportControlFailure, + runExclusiveControl, + service, + ]); + + const openDialog = useCallback(() => { + if (input.activeSessionId) setDialogSessionId(input.activeSessionId); + }, [input.activeSessionId]); + const closeDialog = useCallback(() => setDialogSessionId(undefined), []); + const arm = useCallback( + (sessionId: string, request: GoalArmInput): Promise => + service.arm(sessionId, request), + [service], + ); + + return useMemo( + () => ({ + host: { + ...(dialogSessionId ? { dialogSessionId } : {}), + arm, + closeDialog, + }, + commands: { openDialog }, + selectors: { + active: activeGoal !== null, + ...(indicator ? { indicator } : {}), + }, + }), + [activeGoal, arm, closeDialog, dialogSessionId, indicator, openDialog], + ); +} diff --git a/apps/desktop/src/renderer/features/goals/index.ts b/apps/desktop/src/renderer/features/goals/index.ts new file mode 100644 index 00000000000..ac3d7768d82 --- /dev/null +++ b/apps/desktop/src/renderer/features/goals/index.ts @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { GoalHost } from './ui/goal-host'; +export { GoalServicesProvider } from './services-context'; +export { + useGoalController, + type GoalController, + type GoalControllerCommands, + type GoalControllerSelectors, +} from './controller/use-goal-controller'; +export type { GoalArmInput, GoalServices } from './ports'; diff --git a/apps/desktop/src/renderer/goal-arm-outcome.ts b/apps/desktop/src/renderer/features/goals/model/goal-arm-outcome.ts similarity index 96% rename from apps/desktop/src/renderer/goal-arm-outcome.ts rename to apps/desktop/src/renderer/features/goals/model/goal-arm-outcome.ts index a3d065b7811..9094eeda695 100644 --- a/apps/desktop/src/renderer/goal-arm-outcome.ts +++ b/apps/desktop/src/renderer/features/goals/model/goal-arm-outcome.ts @@ -18,7 +18,7 @@ */ import type { GoalState } from '@maka/runtime/goal-state'; -import type { GoalArmOutcome } from '../shared/goal-arm.js'; +import type { GoalArmOutcome } from '../../../../shared/goal-arm.js'; export type GoalArmReconciliationNotice = | { readonly kind: 'matching_goal'; readonly goal: GoalState } diff --git a/apps/desktop/src/renderer/features/goals/model/goal-budget.ts b/apps/desktop/src/renderer/features/goals/model/goal-budget.ts new file mode 100644 index 00000000000..a235165c4bd --- /dev/null +++ b/apps/desktop/src/renderer/features/goals/model/goal-budget.ts @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type GoalBudgetReading = + | { readonly kind: 'empty' } + | { readonly kind: 'value'; readonly value: number } + | { readonly kind: 'invalid' }; + +/** Parses a form budget without silently clamping or retaining stale input. */ +export function readGoalBudget( + text: string, + min: number, + max?: number, +): GoalBudgetReading { + const trimmed = text.trim(); + if (!trimmed) return { kind: 'empty' }; + if (!/^\d+$/.test(trimmed)) return { kind: 'invalid' }; + const value = Number(trimmed); + if (!Number.isSafeInteger(value) || value < min) return { kind: 'invalid' }; + if (max !== undefined && value > max) return { kind: 'invalid' }; + return { kind: 'value', value }; +} diff --git a/apps/desktop/src/renderer/features/goals/model/live-goal.ts b/apps/desktop/src/renderer/features/goals/model/live-goal.ts new file mode 100644 index 00000000000..2acac13506b --- /dev/null +++ b/apps/desktop/src/renderer/features/goals/model/live-goal.ts @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { GoalState, GoalStatus } from '@maka/core/goal'; + +type LiveGoalStatus = Extract; + +export type LiveGoalState = + | (GoalState & { readonly status: LiveGoalStatus }) + | (GoalState & { readonly status: 'paused'; readonly pausedAt: number }); + +const LIVE_GOAL_STATUSES: ReadonlySet = new Set([ + 'active', + 'waiting', + 'paused', +]); + +export function isLiveGoal(goal: GoalState): goal is LiveGoalState { + return ( + LIVE_GOAL_STATUSES.has(goal.status) && + (goal.status !== 'paused' || + (typeof goal.pausedAt === 'number' && Number.isFinite(goal.pausedAt))) + ); +} diff --git a/apps/desktop/src/renderer/features/goals/ports.ts b/apps/desktop/src/renderer/features/goals/ports.ts new file mode 100644 index 00000000000..486ea6d3f6d --- /dev/null +++ b/apps/desktop/src/renderer/features/goals/ports.ts @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { GoalState } from '@maka/core/goal'; +import type { GoalArmOutcome } from '../../../shared/goal-arm.js'; + +export type { GoalArmOutcome } from '../../../shared/goal-arm.js'; + +export type GoalUnsubscribe = () => void; + +export interface GoalArmInput { + readonly condition: string; + readonly maxIterations?: number | null; + readonly tokenBudget?: number | null; +} + +/** The minimum environment capability needed by the Goals feature. */ +export interface GoalService { + get(sessionId: string): Promise; + arm(sessionId: string, goal: GoalArmInput): Promise; + clear(sessionId: string): Promise; + pause(sessionId: string): Promise; + resume(sessionId: string): Promise; + /** Emits a Session id, or `undefined` for a broadcast Goal transition. */ + subscribeChanges(handler: (sessionId: string | undefined) => void): GoalUnsubscribe; +} + +export interface GoalServices { + goal: GoalService; +} diff --git a/apps/desktop/src/renderer/features/goals/services-context.tsx b/apps/desktop/src/renderer/features/goals/services-context.tsx new file mode 100644 index 00000000000..65efb5120a7 --- /dev/null +++ b/apps/desktop/src/renderer/features/goals/services-context.tsx @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext, useContext, type ReactNode } from 'react'; +import type { GoalServices } from './ports.js'; + +const GoalServicesContext = createContext(null); + +export function GoalServicesProvider(props: { + services: GoalServices; + children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useGoalServices(): GoalServices { + const services = useContext(GoalServicesContext); + if (!services) throw new Error('GoalServicesProvider is missing'); + return services; +} diff --git a/apps/desktop/src/renderer/features/goals/testing.ts b/apps/desktop/src/renderer/features/goals/testing.ts new file mode 100644 index 00000000000..83a81b98761 --- /dev/null +++ b/apps/desktop/src/renderer/features/goals/testing.ts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { GoalServices } from './ports.js'; + +export { GoalServicesProvider } from './services-context.js'; +export { GoalDialog } from './ui/goal-dialog.js'; +export { readGoalBudget } from './model/goal-budget.js'; +export { isLiveGoal, type LiveGoalState } from './model/live-goal.js'; +export { + interpretGoalArmOutcome, + type GoalArmOutcomeAction, + type GoalArmReconciliationNotice, +} from './model/goal-arm-outcome.js'; +export { + useGoalController, + type GoalController, + type UseGoalControllerInput, +} from './controller/use-goal-controller.js'; +export type { GoalArmInput, GoalArmOutcome, GoalServices } from './ports.js'; + +const noopSubscription = (): (() => void) => () => undefined; + +export function createFakeGoalServices( + overrides: Partial = {}, +): GoalServices { + return { + goal: { + get: async () => null, + arm: async () => { + throw new Error('Fake goal.arm is not configured'); + }, + clear: async () => undefined, + pause: async () => undefined, + resume: async () => undefined, + subscribeChanges: noopSubscription, + }, + ...overrides, + }; +} diff --git a/apps/desktop/src/renderer/goal-dialog.tsx b/apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx similarity index 75% rename from apps/desktop/src/renderer/goal-dialog.tsx rename to apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx index 7418547b5eb..36b5a56904e 100644 --- a/apps/desktop/src/renderer/goal-dialog.tsx +++ b/apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx @@ -18,7 +18,7 @@ */ /** - * The form behind the + menu's "Set a goal…" entry. + * The form behind the composer menu's "Set a goal…" entry. * * A Goal is the one control here that keeps spending tokens after the user * stops typing, so it is a dialog rather than a menu row: the condition needs @@ -27,11 +27,7 @@ * * Bounds are the Host's. This sends what the user chose and reports what the * Host answers; it does not clamp a rejected number into an accepted one, - * because the Goal that then runs would not be the one they asked for. For the - * same reason the budget fields hold the user's own text rather than a parsed - * number: a field that quietly keeps the last value it could parse can arm a - * budget the form is no longer showing, and a field that quietly keeps nothing - * can arm no budget at all where the user asked for a tight one. + * because the Goal that then runs would not be the one they asked for. */ import { useLayoutEffect, useRef, useState } from 'react'; import { Button } from '@astryxdesign/core/Button'; @@ -41,46 +37,31 @@ import { HStack, VStack } from '@astryxdesign/core/Stack'; import { Text } from '@astryxdesign/core/Text'; import { TextArea } from '@astryxdesign/core/TextArea'; import { TextInput } from '@astryxdesign/core/TextInput'; -import { useUiLocale } from '@maka/ui'; import { GOAL_CONDITION_TEXT_LIMIT, GOAL_MAX_ITERATIONS_LIMIT, GOAL_TOKEN_BUDGET_MINIMUM, } from '@maka/core/goal'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; +import { useUiLocale } from '@maka/ui'; +import { + getShellCopy, + localizedShellErrorMessage, +} from '../../../locales/shell-copy.js'; import { interpretGoalArmOutcome, type GoalArmReconciliationNotice, -} from './goal-arm-outcome.js'; - -type BudgetReading = - | { readonly kind: 'empty' } - | { readonly kind: 'value'; readonly value: number } - | { readonly kind: 'invalid' }; - -/** - * Read one budget field, refusing anything the Host would not take. - * - * `min`/`max` repeat the operation's own bounds so the refusal can be shown - * beside the field instead of arriving as a rejected frame; the numbers - * themselves come from the shared Goal limits, so there is still one authority - * for what they are. - */ -function readGoalBudget(text: string, min: number, max?: number): BudgetReading { - const trimmed = text.trim(); - if (!trimmed) return { kind: 'empty' }; - if (!/^\d+$/.test(trimmed)) return { kind: 'invalid' }; - const value = Number(trimmed); - if (!Number.isSafeInteger(value) || value < min) return { kind: 'invalid' }; - if (max !== undefined && value > max) return { kind: 'invalid' }; - return { kind: 'value', value }; -} +} from '../model/goal-arm-outcome.js'; +import { readGoalBudget } from '../model/goal-budget.js'; +import type { GoalArmInput, GoalArmOutcome } from '../ports.js'; -export function GoalDialog(props: { +export interface GoalDialogProps { /** The Session to arm. `undefined` closes the dialog. */ sessionId?: string; + onArm(sessionId: string, request: GoalArmInput): Promise; onClose(): void; -}) { +} + +export function GoalDialog(props: GoalDialogProps) { const locale = useUiLocale(); const copy = getShellCopy(locale).goalDialog; const [condition, setCondition] = useState(''); @@ -106,8 +87,15 @@ export function GoalDialog(props: { }, [props.sessionId]); const sessionId = props.sessionId; - const maxIterations = readGoalBudget(maxIterationsText, 1, GOAL_MAX_ITERATIONS_LIMIT); - const tokenBudget = readGoalBudget(tokenBudgetText, GOAL_TOKEN_BUDGET_MINIMUM); + const maxIterations = readGoalBudget( + maxIterationsText, + 1, + GOAL_MAX_ITERATIONS_LIMIT, + ); + const tokenBudget = readGoalBudget( + tokenBudgetText, + GOAL_TOKEN_BUDGET_MINIMUM, + ); const locked = reconciliation !== undefined; const canSubmit = condition.trim().length > 0 && @@ -142,9 +130,10 @@ export function GoalDialog(props: { setArming(true); setError(undefined); try { - const outcome = await window.maka.goal.arm(sessionId, { + const outcome = await props.onArm(sessionId, { condition: condition.trim(), - maxIterations: maxIterations.kind === 'value' ? maxIterations.value : null, + maxIterations: + maxIterations.kind === 'value' ? maxIterations.value : null, tokenBudget: tokenBudget.kind === 'value' ? tokenBudget.value : null, }); if (armGenerationRef.current !== armGeneration) return; @@ -173,15 +162,24 @@ export function GoalDialog(props: { className="goalDialog" > { - if (!open && !arming) props.onClose(); - }} />} + header={ + { + if (!open && !arming) props.onClose(); + }} + /> + } content={ - {copy.description} + + {copy.description} + {reconciliationMessage ? ( - {reconciliationMessage} + + {reconciliationMessage} + ) : null}