From c6d3d567ca2f3a336ec407fca10453c190ac415e Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 17:18:21 +0800 Subject: [PATCH 1/5] refactor(desktop): own task readiness below the Composer Move the task-readiness snapshot, its refresh revision and request fence from AppShell into a persistent TaskReadinessProvider in Conversation, mounted beside ComposerStagingProvider. The two Host reads reach the feature through an injected TaskReadinessServices port and a Desktop adapter. The transcript surface reads the notice through TaskReadinessNoticeConsumer instead of receiving it as props. AppShell now passes only the request projection, targets, refresh key and workspace-picker command. The legacy use-task-submission-readiness hook and the task-readiness-notice re-export are retired, and the public entry no longer exports the notice derivation. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 46 +-- .../__tests__/task-readiness-notice.test.ts | 4 +- .../__tests__/task-readiness-owner.test.ts | 357 ++++++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 24 +- .../src/renderer/chat-message-surface.tsx | 17 +- .../composition/desktop-feature-services.tsx | 6 +- .../renderer/features/conversation/README.md | 27 +- .../use-task-submission-readiness.ts | 25 +- .../renderer/features/conversation/index.ts | 9 +- .../conversation/readiness-services.ts | 39 ++ .../renderer/features/conversation/testing.ts | 1 + .../ui/task-readiness-provider.tsx | 93 +++++ .../create-task-readiness-services.ts} | 22 +- docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + scripts/check-app-shell-hooks.mjs | 1 - 16 files changed, 575 insertions(+), 100 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/task-readiness-owner.test.ts rename apps/desktop/src/renderer/{ => features/conversation/controller}/use-task-submission-readiness.ts (74%) create mode 100644 apps/desktop/src/renderer/features/conversation/readiness-services.ts create mode 100644 apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx rename apps/desktop/src/renderer/{task-readiness-notice.ts => platform/desktop/create-task-readiness-services.ts} (54%) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 1c79a7b7e3..197b079c6b 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -171,7 +171,6 @@ "src/renderer/settled-message-merge.ts", "src/renderer/side-chat-command.ts", "src/renderer/skill-invocation-feedback.ts", - "src/renderer/task-readiness-notice.ts", "src/renderer/theme.ts", "src/renderer/titlebar-dim-color.ts", "src/renderer/titlebar-modal-sync.ts", @@ -186,7 +185,6 @@ "src/renderer/use-shell-memory-pill.ts", "src/renderer/use-stable-actions.ts", "src/renderer/use-system-ui-locale.ts", - "src/renderer/use-task-submission-readiness.ts", "src/renderer/use-turn-action-registry.ts", "src/renderer/use-work-board.ts", "src/renderer/work-board-panel.tsx", @@ -252,6 +250,13 @@ "ownerSymbol": "PlanProvider", "count": 1 }, + { + "implementation": "src/renderer/features/conversation/controller/use-task-submission-readiness.ts", + "symbol": "useTaskSubmissionReadiness", + "owner": "src/renderer/features/conversation/ui/task-readiness-provider.tsx", + "ownerSymbol": "TaskReadinessProvider", + "count": 1 + }, { "implementation": "src/renderer/features/goals/controller/use-goal-controller.ts", "symbol": "useGoalController", @@ -613,7 +618,7 @@ "nonTriviaTokens": 561 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 50, + "importDeclarations": 49, "bridgePaths": { "window.maka.attachments.readBytes": 1, "window.maka.onboarding.setMilestone": 1, @@ -650,7 +655,6 @@ "useStableActions": 4, "useState": 10, "useSystemUiLocale": 1, - "useTaskSubmissionReadiness": 1, "useToast": 1, "useTurnActionRegistry": 1 }, @@ -716,7 +720,6 @@ "./use-shell-memory-pill": 1, "./use-stable-actions": 1, "./use-system-ui-locale": 1, - "./use-task-submission-readiness": 1, "./use-turn-action-registry": 1, "./workspace-readiness-recovery": 1, "@astryxdesign/core/AppShell": 1, @@ -728,8 +731,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 74, - "nonTriviaTokens": 9080 + "importSpecifiers": 73, + "nonTriviaTokens": 9062 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -3332,17 +3335,6 @@ "./locales/shell-copy.js": 1 } }, - "src/renderer/task-readiness-notice.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./features/conversation/index.js": 1 - } - }, "src/renderer/theme.ts": { "bridgePaths": { "window.maka.appWindow.setThemeSource": 1, @@ -3539,24 +3531,6 @@ "react": 1 } }, - "src/renderer/use-task-submission-readiness.ts": { - "bridgePaths": { - "window.maka.newTasks.getReadiness": 1, - "window.maka.taskReadiness.getSnapshot": 1 - }, - "environmentCapabilities": {}, - "hookCalls": { - "useEffect": 1, - "useRef": 1, - "useState": 2 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "react": 1 - } - }, "src/renderer/use-turn-action-registry.ts": { "bridgePaths": {}, "environmentCapabilities": { diff --git a/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts b/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts index 5937431acd..fdccfa6cb9 100644 --- a/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts +++ b/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts @@ -20,11 +20,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { TaskSubmissionReadinessSnapshot } from '@maka/core/task-submission-readiness'; +import { resolveTaskReadinessModelTarget } from '../../renderer/features/conversation/index.js'; import { deriveTaskReadinessNotice, isTaskSubmissionHardBlocked, - resolveTaskReadinessModelTarget, -} from '../../renderer/task-readiness-notice.js'; +} from '../../renderer/features/conversation/testing.js'; test('an unlocked stale session keeps its stored target until explicit recovery', () => { assert.deepEqual( diff --git a/apps/desktop/src/main/__tests__/task-readiness-owner.test.ts b/apps/desktop/src/main/__tests__/task-readiness-owner.test.ts new file mode 100644 index 0000000000..4d67de7e3f --- /dev/null +++ b/apps/desktop/src/main/__tests__/task-readiness-owner.test.ts @@ -0,0 +1,357 @@ +/* + * 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 assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { afterEach, describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { act, createElement, type ComponentProps, type ReactNode } from 'react'; +import type { TaskSubmissionReadinessSnapshot } from '@maka/core/task-submission-readiness'; +import { LocaleProvider } from '@maka/ui'; +import * as Conversation from '../../renderer/features/conversation/index.js'; +import { + TaskReadinessNoticeConsumer, + TaskReadinessProvider, + TaskReadinessServicesProvider, + type TaskReadinessServices, +} from '../../renderer/features/conversation/index.js'; +import { getTaskReadinessCopy } from '../../renderer/locales/task-readiness-copy.js'; +import { + createDesktopTaskReadinessServices, + type DesktopTaskReadinessBridge, +} from '../../renderer/platform/desktop/create-task-readiness-services.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(() => { + cleanupFakeDom(); +}); + +type Request = Parameters[1]; +type NewTaskTarget = Parameters[0]; +type NoticeView = ComponentProps['surface']>; + +const copy = getTaskReadinessCopy('en'); + +function snapshot( + id: 'runtime' | 'workspace' | 'model_target', + options: { readonly picker?: boolean } = {}, +): TaskSubmissionReadinessSnapshot { + const dimension = { + id, + state: 'unavailable' as const, + authority: id === 'runtime' + ? ('runtime_host' as const) + : id === 'workspace' + ? ('workspace_execution' as const) + : ('connection_readiness' as const), + checkedAt: 1, + ...(options.picker ? { repairTarget: { kind: 'workspace_picker' as const } } : {}), + }; + return { checkedAt: 1, state: 'unavailable', dimensions: [dimension], blockers: [dimension] }; +} + +const READY: TaskSubmissionReadinessSnapshot = { checkedAt: 1, state: 'ready', dimensions: [], blockers: [] }; + +function deferred() { + let resolvePromise!: (value: T) => void; + let rejectPromise!: (error: unknown) => void; + const promise = new Promise((resolveValue, rejectValue) => { + resolvePromise = resolveValue; + rejectPromise = rejectValue; + }); + return { promise, resolve: resolvePromise, reject: rejectPromise }; +} + +/** Records every read and answers it only when the test settles it. */ +function recordingServices() { + const reads: Array<{ + readonly kind: 'session' | 'new_task'; + readonly target: string | NewTaskTarget; + readonly request: Request; + readonly answer: ReturnType>; + }> = []; + const services: TaskReadinessServices = { + readSession(sessionId, request) { + const answer = deferred(); + reads.push({ kind: 'session', target: sessionId, request, answer }); + return answer.promise; + }, + readNewTask(target, request) { + const answer = deferred(); + reads.push({ kind: 'new_task', target, request, answer }); + return answer.promise; + }, + }; + return { services, reads }; +} + +function noticeRecorder() { + const rendered: NoticeView[] = []; + function Surface(props: NoticeView) { + rendered.push(props); + return createElement('p', null, props.title); + } + return { Surface, rendered, latest: () => rendered.at(-1) }; +} + +function owner( + services: TaskReadinessServices, + props: Omit[0], 'children'>, + children: ReactNode, +) { + return createElement(LocaleProvider, { + locale: 'en', + children: createElement(TaskReadinessServicesProvider, { + services, + children: createElement(TaskReadinessProvider, { ...props, children }), + }), + }); +} + +const sessionRequest: Request = { connectionSlug: 'openai', model: 'gpt-5', cwd: '/work/a' }; +const newTaskTarget: NewTaskTarget = { profileId: 'local', hostId: 'host-1', projectId: 'project-1' }; + +describe('TaskReadinessProvider', () => { + test('reads the Session target and publishes only the latest answer', async () => { + const { root, container } = installReactRenderer(); + const { services, reads } = recordingServices(); + const notice = noticeRecorder(); + const refreshKey = {}; + const view = createElement(TaskReadinessNoticeConsumer, { surface: notice.Surface }); + + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey, sessionId: 'a' }, view))); + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey, sessionId: 'b' }, view))); + assert.deepEqual(reads.map(({ kind, target, request }) => ({ kind, target, request })), [ + { kind: 'session', target: 'a', request: sessionRequest }, + { kind: 'session', target: 'b', request: sessionRequest }, + ]); + + await act(async () => reads[1]!.answer.resolve(READY)); + await act(async () => reads[0]!.answer.resolve(snapshot('runtime'))); + assert.equal(container.textContent, '', 'a late answer for the previous Session does not publish'); + assert.equal(notice.rendered.length, 0); + + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey, sessionId: 'b' }, view))); + assert.equal(reads.length, 2, 'an unrelated render does not read again'); + }); + + test('clears the shown notice as soon as the request changes', async () => { + const { root, container } = installReactRenderer(); + const { services, reads } = recordingServices(); + const notice = noticeRecorder(); + const refreshKey = {}; + const view = createElement(TaskReadinessNoticeConsumer, { surface: notice.Surface }); + + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey, sessionId: 'a' }, view))); + await act(async () => reads[0]!.answer.resolve(snapshot('runtime'))); + assert.equal(container.textContent, copy.runtime.title); + + const moved = { ...sessionRequest, cwd: '/work/b' }; + await act(async () => root.render(owner(services, { request: moved, refreshKey, sessionId: 'a' }, view))); + assert.equal(container.textContent, '', 'the previous workspace answer is not shown for the new one'); + assert.deepEqual(reads[1]?.request, moved); + + await act(async () => reads[1]!.answer.reject(new Error('Host unavailable'))); + assert.equal(container.textContent, '', 'a failed read shows nothing'); + }); + + test('reads the new-task target only without a Session, and nothing without either', async () => { + const { root, container } = installReactRenderer(); + const { services, reads } = recordingServices(); + const notice = noticeRecorder(); + const refreshKey = {}; + const view = createElement(TaskReadinessNoticeConsumer, { surface: notice.Surface }); + + await act(async () => root.render(owner(services, { request: {}, refreshKey, newTaskTarget }, view))); + assert.deepEqual(reads.map(({ kind, target }) => ({ kind, target })), [{ kind: 'new_task', target: newTaskTarget }]); + await act(async () => reads[0]!.answer.resolve(snapshot('runtime'))); + assert.equal(container.textContent, copy.runtime.title); + + // A shared Session has neither an owner Session nor a new-task target. + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey }, view))); + assert.equal(reads.length, 1); + assert.equal(container.textContent, ''); + + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey, sessionId: 'a', newTaskTarget }, view))); + assert.equal(reads[1]?.kind, 'session', 'an owner Session takes precedence over a new-task target'); + }); + + test('reads again for a new refresh key and for the retry action', async () => { + const { root, container } = installReactRenderer(); + const { services, reads } = recordingServices(); + const notice = noticeRecorder(); + const view = createElement(TaskReadinessNoticeConsumer, { surface: notice.Surface }); + const onboarding = {}; + + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey: onboarding, sessionId: 'a' }, view))); + await act(async () => root.render(owner(services, { request: { ...sessionRequest }, refreshKey: onboarding, sessionId: 'a' }, view))); + assert.equal(reads.length, 1, 'an equal request does not read again'); + + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey: {}, sessionId: 'a' }, view))); + assert.equal(reads.length, 2); + await act(async () => reads[1]!.answer.resolve(snapshot('runtime'))); + assert.equal(notice.latest()?.status, 'error'); + assert.equal(notice.latest()?.actionLabel, copy.runtime.actionLabel); + + await act(async () => notice.latest()?.onAction?.()); + assert.equal(reads.length, 3); + assert.equal(container.textContent, '', 'retry clears the notice until the new answer'); + }); + + test('routes a workspace blocker to the picker, and hides the action without one', async () => { + const { root } = installReactRenderer(); + const { services, reads } = recordingServices(); + const notice = noticeRecorder(); + const view = createElement(TaskReadinessNoticeConsumer, { surface: notice.Surface }); + const refreshKey = {}; + let picks = 0; + const openWorkspacePicker = () => { picks += 1; }; + + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey, sessionId: 'a', openWorkspacePicker }, view))); + await act(async () => reads[0]!.answer.resolve(snapshot('workspace', { picker: true }))); + assert.equal(notice.latest()?.actionLabel, copy.workspace.actionLabel.workspace_picker); + await act(async () => notice.latest()?.onAction?.()); + assert.equal(picks, 1); + assert.equal(reads.length, 1, 'the picker action does not read again'); + + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey, sessionId: 'a' }, view))); + assert.equal(notice.latest()?.onAction, undefined); + + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey: {}, sessionId: 'a', openWorkspacePicker }, view))); + await act(async () => reads[1]!.answer.resolve(snapshot('workspace'))); + assert.equal(notice.latest()?.actionLabel, copy.workspace.actionLabel.retry); + await act(async () => notice.latest()?.onAction?.()); + assert.equal(picks, 1); + assert.equal(reads.length, 3, 'a workspace blocker without a picker target retries'); + }); + + test('model blockers stay with their own recovery surfaces', async () => { + const { root, container } = installReactRenderer(); + const { services, reads } = recordingServices(); + const notice = noticeRecorder(); + const view = createElement(TaskReadinessNoticeConsumer, { surface: notice.Surface }); + + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey: {}, sessionId: 'a' }, view))); + await act(async () => reads[0]!.answer.resolve(snapshot('model_target'))); + assert.equal(container.textContent, ''); + assert.equal(notice.rendered.length, 0); + }); + + test('keeps the answer while the reader unmounts and remounts', async () => { + const { root, container } = installReactRenderer(); + const { services, reads } = recordingServices(); + const notice = noticeRecorder(); + const refreshKey = {}; + const render = (readerMounted: boolean) => owner( + services, + { request: sessionRequest, refreshKey, sessionId: 'a' }, + readerMounted ? createElement(TaskReadinessNoticeConsumer, { surface: notice.Surface }) : null, + ); + + await act(async () => root.render(render(true))); + await act(async () => reads[0]!.answer.resolve(snapshot('runtime'))); + await act(async () => root.render(render(false))); + assert.equal(container.textContent, ''); + await act(async () => root.render(render(true))); + assert.equal(container.textContent, copy.runtime.title); + assert.equal(reads.length, 1, 'hiding the transcript neither drops nor restarts the read'); + }); + + test('the reader requires its owner', () => { + const { root } = installReactRenderer(); + const notice = noticeRecorder(); + assert.throws( + () => act(() => root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(TaskReadinessNoticeConsumer, { surface: notice.Surface }), + }))), + /TaskReadinessProvider is required/, + ); + }); +}); + +describe('Desktop task readiness adapter', () => { + test('maps the two reads onto their bridge calls', async () => { + const calls: unknown[] = []; + const bridge: DesktopTaskReadinessBridge = { + taskReadiness: { + getSnapshot: async (input, sessionId) => { + calls.push(['taskReadiness.getSnapshot', input, sessionId]); + return READY; + }, + }, + newTasks: { + getReadiness: async (target, input) => { + calls.push(['newTasks.getReadiness', target, input]); + return READY; + }, + }, + }; + const services = createDesktopTaskReadinessServices(bridge); + assert.equal(await services.readSession('session-1', sessionRequest), READY); + assert.equal(await services.readNewTask(newTaskTarget, { cwd: '/work' }), READY); + assert.deepEqual(calls, [ + ['taskReadiness.getSnapshot', sessionRequest, 'session-1'], + ['newTasks.getReadiness', newTaskTarget, { cwd: '/work' }], + ]); + }); +}); + +describe('Task readiness ownership', () => { + const rendererRoot = resolve(fileURLToPath(new URL('../../../src/renderer/', import.meta.url))); + + function productionSources(root: string): string[] { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return entry.name === '__tests__' || entry.name === 'stories' ? [] : productionSources(path); + return /\.tsx?$/.test(entry.name) && entry.name !== 'testing.ts' ? [path] : []; + }); + } + + function sourcesMatching(pattern: RegExp): string[] { + return productionSources(rendererRoot) + .filter((path) => pattern.test(readFileSync(path, 'utf8'))) + .map((path) => relative(rendererRoot, path).replace(/\\/g, '/')) + .sort(); + } + + test('mounts one owner, one reader and one Desktop adapter', () => { + assert.deepEqual(sourcesMatching(/<(?:Conversation\.)?TaskReadinessProvider\b/), ['app-shell.tsx']); + assert.deepEqual(sourcesMatching(/<(?:Conversation\.)?TaskReadinessNoticeConsumer\b/), ['chat-message-surface.tsx']); + assert.deepEqual(sourcesMatching(/create-task-readiness-services/), ['composition/desktop-feature-services.tsx']); + assert.deepEqual( + sourcesMatching(/\btaskReadiness\s*\.\s*getSnapshot\b|\.\s*getReadiness\s*\(/), + ['platform/desktop/create-task-readiness-services.ts'], + ); + assert.deepEqual(sourcesMatching(/\buseTaskSubmissionReadiness\s*\(/), [ + 'features/conversation/controller/use-task-submission-readiness.ts', + 'features/conversation/ui/task-readiness-provider.tsx', + ]); + }); + + test('AppShell and the transcript surface hold no readiness snapshot or notice', () => { + const shell = readFileSync(join(rendererRoot, 'app-shell.tsx'), 'utf8'); + assert.doesNotMatch(shell, /useTaskSubmissionReadiness|deriveTaskReadinessNotice|taskReadinessNotice|taskReadiness\./); + assert.doesNotMatch(readFileSync(join(rendererRoot, 'chat-message-surface.tsx'), 'utf8'), /taskReadinessNotice|onTaskReadinessAction/); + for (const name of ['useTaskSubmissionReadiness', 'deriveTaskReadinessNotice', 'isTaskSubmissionHardBlocked', 'useTaskReadinessServices']) { + assert.equal(name in Conversation, false, `${name} is not a public Conversation capability`); + } + }); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 3612887cbd..92737590ae 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -61,7 +61,6 @@ import { } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; import { ChatMessageSurface } from './chat-message-surface'; -import { useTaskSubmissionReadiness } from './use-task-submission-readiness'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; import * as Conversation from './features/conversation'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; @@ -921,13 +920,6 @@ function AppShellContent({ ...Conversation.resolveTaskReadinessModelTarget(activeSession, activeSessionSendOutcome, newChatModel), ...(taskReadinessWorkspace ? { cwd: taskReadinessWorkspace } : {}), }; - const taskReadiness = useTaskSubmissionReadiness( - taskReadinessRequest, - onboarding.snapshot, - ownerActiveId, - activeId ? undefined : taskEntry.selectors.target, - ); - const taskReadinessNotice = Conversation.deriveTaskReadinessNotice(taskReadiness.snapshot, uiLocale); const taskSubmissionHardBlocked = !activeId && !taskEntry.selectors.target; // The titlebar names the directory the ACTIVE session runs in, so it reads @@ -1454,6 +1446,11 @@ function AppShellContent({ // on catalog moves. + openSessionWorkspaceRecovery(activeSession.id) + : taskEntry.selectors.canAddProject ? taskEntry.commands.addProject : undefined}> openSessionWorkspaceRecovery(activeSession.id) - : taskEntry.selectors.canAddProject - ? taskEntry.commands.addProject - : undefined - : taskReadiness.refresh - } showOnboardingHero={showOnboardingHero} onboardingState={onboardingState} onOpenSettings={openSettingsSection} @@ -2014,6 +2001,7 @@ function AppShellContent({ + ); } diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index f48390ddeb..b227a8cf16 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -30,9 +30,8 @@ import { import { OnboardingHero } from './onboarding-hero'; import type { SessionHealthNoticeView, SessionUiReads } from './features/conversation/index.js'; import type { WorkspaceReadinessRecovery } from './workspace-readiness-recovery'; -import type { TaskReadinessNotice } from './task-readiness-notice'; import { getShellCopy } from './locales/shell-copy'; -import { StagedQuoteChatView } from './features/conversation/index.js'; +import { StagedQuoteChatView, TaskReadinessNoticeConsumer } from './features/conversation/index.js'; import { useExternalStoreSelector } from './application/contracts/session-catalog/use-external-store-selector.js'; import { ChatRecoveryNotice, SessionHealthRecoveryNotice } from './chat-recovery-notice'; @@ -73,8 +72,6 @@ interface ChatMessageSurfaceProps extends Omit< sessionHealthNotice?: SessionHealthNoticeView; sessionHealthModelPickerAvailable: boolean; workspaceReadinessRecovery?: WorkspaceReadinessRecovery; - taskReadinessNotice?: TaskReadinessNotice; - onTaskReadinessAction?: () => void; showOnboardingHero: boolean; onboardingState: OnboardingState | undefined; onOpenSettings: (section?: SettingsSection) => void; @@ -104,8 +101,6 @@ export function ChatMessageSurface({ sessionHealthNotice, sessionHealthModelPickerAvailable, workspaceReadinessRecovery, - taskReadinessNotice, - onTaskReadinessAction, showOnboardingHero, onboardingState, onOpenSettings, @@ -211,15 +206,7 @@ export function ChatMessageSurface({ /> )} - {taskReadinessNotice && ( - - )} + {workspaceReadinessRecovery && ( + @@ -132,6 +135,7 @@ export function DesktopFeatureServicesProvider(props: { + diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index d8c13a1af0..a089572eca 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -201,6 +201,27 @@ directory pickers still require the original draft and Host to remain current. Staging uses `activeId ?? NEW_TASK_PENDING_KEY`; the editor's new-task persistence key remains distinct. Do not key this provider or the Composer's parent by Session. -Readiness, revision draft state, send-pending state, delivery recovery and the -remaining send orchestration are later M3 work. They can use captured submission -commands without restoring root subscriptions or acquiring the private controller. +Revision draft state, send-pending state, delivery recovery and the remaining +send orchestration are later M3 work. They can use captured submission commands +without restoring root subscriptions or acquiring the private controller. + +## Task readiness ownership (R2 M3) + +`TaskReadinessProvider` alone calls the readiness controller. It is mounted +beside `ComposerStagingProvider` and stays mounted across Session and section +switches, so hiding the transcript neither drops nor restarts the read. AppShell +supplies the request projection (model target and working directory), the owner +Session or new-task target, the onboarding snapshot as a refresh key, and the +workspace-picker command. It receives no snapshot, refresh command or notice. +`TaskReadinessNoticeConsumer` is the only reader: the transcript surface renders +it in the notice slot, a workspace blocker opens the picker, and every other +action reads again. + +The Desktop adapter injects the two Host reads as `TaskReadinessServices`; the +feature does not access `window.maka`. A changed request, target or refresh key +clears the previous snapshot before reading again, and a sequence fence admits +only the latest read. A shared Session has neither an owner Session nor a +new-task target and reads nothing. The snapshot context is module-local and +`controllerOwners` fixes the controller at the provider. Send-time admission +(`checkTaskSubmissionReadiness`) does not read this snapshot; it moves with +submission ownership. diff --git a/apps/desktop/src/renderer/use-task-submission-readiness.ts b/apps/desktop/src/renderer/features/conversation/controller/use-task-submission-readiness.ts similarity index 74% rename from apps/desktop/src/renderer/use-task-submission-readiness.ts rename to apps/desktop/src/renderer/features/conversation/controller/use-task-submission-readiness.ts index d95a5a7018..3adba73688 100644 --- a/apps/desktop/src/renderer/use-task-submission-readiness.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-task-submission-readiness.ts @@ -19,16 +19,20 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { TaskSubmissionReadinessSnapshot } from '@maka/core/task-submission-readiness'; -import type { - DesktopNewTaskTarget, - DesktopTaskSubmissionReadinessRequest, -} from '../preload/bridge-contract.js'; +import type { ConversationNewTaskTarget } from '../ports.js'; +import type { TaskReadinessRequest, TaskReadinessServices } from '../readiness-services.js'; +/** + * Called only by `TaskReadinessProvider`. A changed request, target or refresh + * key clears the previous snapshot before reading again; only the latest read + * publishes, so a slow answer for an earlier target cannot replace a newer one. + */ export function useTaskSubmissionReadiness( - request: DesktopTaskSubmissionReadinessRequest, + services: TaskReadinessServices, + request: TaskReadinessRequest, refreshKey: unknown, sessionId?: string, - newTaskTarget?: DesktopNewTaskTarget, + newTaskTarget?: ConversationNewTaskTarget, ) { const [snapshot, setSnapshot] = useState(); const [revision, setRevision] = useState(0); @@ -39,17 +43,16 @@ export function useTaskSubmissionReadiness( const sequence = ++requestSequence.current; try { const next = sessionId - ? await window.maka.taskReadiness.getSnapshot(request, sessionId) + ? await services.readSession(sessionId, request) : newTaskTarget - ? await window.maka.newTasks.getReadiness(newTaskTarget, request) + ? await services.readNewTask(newTaskTarget, request) : undefined; if (requestSequence.current === sequence) setSnapshot(next); - return next; } catch { if (requestSequence.current === sequence) setSnapshot(undefined); - return undefined; } }, [ + services, request.connectionSlug, request.model, request.cwd, @@ -65,5 +68,5 @@ export function useTaskSubmissionReadiness( void checkNow(); }, [checkNow, refreshKey, revision]); - return { snapshot, refresh, checkNow }; + return { snapshot, refresh }; } diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 28b16103be..97571608f4 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -17,12 +17,7 @@ * under the License. */ -export { - deriveTaskReadinessNotice, - isTaskSubmissionHardBlocked, - resolveTaskReadinessModelTarget, - type TaskReadinessNotice, -} from './model/task-readiness-notice.js'; +export { resolveTaskReadinessModelTarget } from './model/task-readiness-notice.js'; export type { AppShellSessionUiStateController, SessionPendingClaim, @@ -92,3 +87,5 @@ export { ComposerStagingServicesProvider, type ComposerStagingServices } from '. export { ComposerStagingProvider } from './ui/composer-staging-provider.js'; export { StagedComposer, type ComposerStagingProp } from './ui/staged-composer.js'; export { StagedQuoteChatView } from './ui/staged-quote-chat-view.js'; +export { TaskReadinessServicesProvider, type TaskReadinessServices } from './readiness-services.js'; +export { TaskReadinessProvider, TaskReadinessNoticeConsumer } from './ui/task-readiness-provider.js'; diff --git a/apps/desktop/src/renderer/features/conversation/readiness-services.ts b/apps/desktop/src/renderer/features/conversation/readiness-services.ts new file mode 100644 index 0000000000..970e201b29 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/readiness-services.ts @@ -0,0 +1,39 @@ +/* + * 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 { TaskSubmissionReadinessSnapshot } from '@maka/core/task-submission-readiness'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; +import type { ConversationNewTaskTarget } from './ports.js'; + +/** The model target and workspace a readiness probe checks. */ +export interface TaskReadinessRequest { + readonly connectionSlug?: string; + readonly model?: string; + readonly cwd?: string; +} + +/** The two Host reads behind the Composer's readiness notice; nothing else. */ +export interface TaskReadinessServices { + readSession(sessionId: string, request: TaskReadinessRequest): Promise; + readNewTask(target: ConversationNewTaskTarget, request: TaskReadinessRequest): Promise; +} + +const context = createServicesContext('TaskReadinessServicesProvider'); +export const TaskReadinessServicesProvider = context.Provider; +export const useTaskReadinessServices = context.useServices; diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index 6593c627ab..ae749f2ffa 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -122,3 +122,4 @@ export { TranscriptReadingPositionController, type TranscriptReadingPositionComm export { useComposerAttachments } from './controller/use-composer-attachments.js'; export { useComposerQuotes } from './controller/use-composer-quotes.js'; export { useComposerStaging } from './ui/composer-staging-context.js'; +export { deriveTaskReadinessNotice, isTaskSubmissionHardBlocked } from './model/task-readiness-notice.js'; diff --git a/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx new file mode 100644 index 0000000000..5e7643a050 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx @@ -0,0 +1,93 @@ +/* + * 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, createElement, useContext, useMemo, type ComponentType, type ReactNode } from 'react'; +import type { TaskSubmissionReadinessSnapshot } from '@maka/core/task-submission-readiness'; +import { useUiLocale } from '@maka/ui'; +import { useTaskSubmissionReadiness } from '../controller/use-task-submission-readiness.js'; +import { deriveTaskReadinessNotice } from '../model/task-readiness-notice.js'; +import type { ConversationNewTaskTarget } from '../ports.js'; +import { useTaskReadinessServices, type TaskReadinessRequest } from '../readiness-services.js'; + +interface TaskReadinessOwner { + readonly snapshot: TaskSubmissionReadinessSnapshot | undefined; + readonly refresh: () => void; + readonly openWorkspacePicker: (() => void) | undefined; +} + +// Module-local: nothing outside this file can read the snapshot. +const TaskReadinessContext = createContext(undefined); + +/** + * Sole owner of the Composer's task readiness. It stays mounted beside + * `ComposerStagingProvider`, so section switches and transcript unmounts + * neither restart nor drop the read. AppShell supplies the request projection, + * the targets and the workspace-picker command; it receives no snapshot. + */ +export function TaskReadinessProvider(props: { + readonly request: TaskReadinessRequest; + /** Any new value reads again; AppShell passes the onboarding snapshot. */ + readonly refreshKey: unknown; + readonly sessionId?: string; + readonly newTaskTarget?: ConversationNewTaskTarget; + /** Where a workspace blocker's action leads; absent when nothing can be picked. */ + readonly openWorkspacePicker?: () => void; + readonly children?: ReactNode; +}) { + const { snapshot, refresh } = useTaskSubmissionReadiness( + useTaskReadinessServices(), + props.request, + props.refreshKey, + props.sessionId, + props.newTaskTarget, + ); + const owner = useMemo( + () => ({ snapshot, refresh, openWorkspacePicker: props.openWorkspacePicker }), + [snapshot, refresh, props.openWorkspacePicker], + ); + return {props.children}; +} + +export interface TaskReadinessNoticeView { + readonly status: 'error' | 'warning'; + readonly title: string; + readonly description: string; + readonly actionLabel: string; + readonly onAction?: () => void; +} + +/** + * The notice's only reader. Runtime and workspace blockers render through + * `surface`; a workspace blocker opens the picker, every other action reads + * readiness again. + */ +export function TaskReadinessNoticeConsumer(props: { readonly surface: ComponentType }) { + const locale = useUiLocale(); + const owner = useContext(TaskReadinessContext); + if (!owner) throw new Error('TaskReadinessProvider is required'); + const notice = deriveTaskReadinessNotice(owner.snapshot, locale); + if (!notice) return null; + return createElement(props.surface, { + status: notice.tone === 'destructive' ? 'error' : 'warning', + title: notice.title, + description: notice.description, + actionLabel: notice.actionLabel, + onAction: notice.action === 'workspace_picker' ? owner.openWorkspacePicker : owner.refresh, + }); +} diff --git a/apps/desktop/src/renderer/task-readiness-notice.ts b/apps/desktop/src/renderer/platform/desktop/create-task-readiness-services.ts similarity index 54% rename from apps/desktop/src/renderer/task-readiness-notice.ts rename to apps/desktop/src/renderer/platform/desktop/create-task-readiness-services.ts index 201410fdd3..e6670a14c4 100644 --- a/apps/desktop/src/renderer/task-readiness-notice.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-task-readiness-services.ts @@ -17,9 +17,19 @@ * under the License. */ -export { - deriveTaskReadinessNotice, - isTaskSubmissionHardBlocked, - resolveTaskReadinessModelTarget, - type TaskReadinessNotice, -} from './features/conversation/index.js'; +import type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { TaskReadinessServices } from '../../features/conversation/index.js'; + +export type DesktopTaskReadinessBridge = { + readonly taskReadiness: Pick; + readonly newTasks: Pick; +}; + +export function createDesktopTaskReadinessServices( + bridge: DesktopTaskReadinessBridge = window.maka, +): TaskReadinessServices { + return { + readSession: (sessionId, request) => bridge.taskReadiness.getSnapshot(request, sessionId), + readNewTask: (target, request) => bridge.newTasks.getReadiness(target, request), + }; +} diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index eb7ff9240c..36635c26bd 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 325 files — blocker 0, reimplementation 0, polish 4, aligned 321. +**Totals:** 326 files — blocker 0, reimplementation 0, polish 4, aligned 322. ## Exclusions (explicit) @@ -75,6 +75,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/conversation/ui/plan-surfaces.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/staged-composer.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/diagnostics/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/diagnostics/ui/diagnostic-report-toast-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/diagnostics/ui/previous-main-process-interruption-notice.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index c31b928233..039f112336 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -45,6 +45,7 @@ apps/desktop/src/renderer/features/conversation/ui/plan-provider.tsx apps/desktop/src/renderer/features/conversation/ui/plan-surfaces.tsx apps/desktop/src/renderer/features/conversation/ui/staged-composer.tsx apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx +apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx apps/desktop/src/renderer/features/diagnostics/services-context.tsx apps/desktop/src/renderer/features/diagnostics/ui/diagnostic-report-toast-provider.tsx apps/desktop/src/renderer/features/diagnostics/ui/previous-main-process-interruption-notice.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index b9ee6c7830..4b7c4db91d 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -139,7 +139,6 @@ export const ALLOWED = { useShellResume: 1, useStableActions: 4, useState: 8, - useTaskSubmissionReadiness: 1, useToast: 1, // The last of the three `useKeyedPendingRegistry` call sites this entry // replaces: #4113 moved the other two onto the session UI store, which is From ba5b03d6ef27b7b54bdbe90f547f734b9637670b Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 17:41:28 +0800 Subject: [PATCH 2/5] refactor(desktop): let Session Settings own new-task mode choices The new chat's Plan toggle, orchestration value and permission choice were AppShell state (two useState calls and useNewTaskChoice). SessionSettingsProvider already decided between the selected Session and the new task for permission writes, so it now holds all three. The shell reads them through the existing useSessionSettingIntent hook and writes them through setNewTaskPlanMode, setNewTaskOrchestrationMode and clearNewTaskPermissionChoice. useNewTaskChoice moves to application contracts, because Session Settings and the Conversation chat-model hook both use it. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 5 +- .../session-settings-controller.test.ts | 13 ++---- .../session-settings-provider-scope.test.ts | 46 ++++++++++++++++++- apps/desktop/src/renderer/app-shell.tsx | 40 ++++++++-------- .../contracts}/use-new-task-choice.ts | 2 +- .../controller/use-shell-chat-model.ts | 2 +- .../renderer/features/conversation/index.ts | 1 - .../features/session-settings/README.md | 17 ++++++- .../controller/session-settings-bridge.ts | 28 +++++++---- .../use-session-settings-controller.ts | 21 ++++++++- .../model/session-settings-contract.ts | 20 +++++++- .../ui/use-session-setting-intent.ts | 32 +++++++------ scripts/check-app-shell-hooks.mjs | 3 +- 13 files changed, 161 insertions(+), 69 deletions(-) rename apps/desktop/src/renderer/{features/conversation/controller => application/contracts}/use-new-task-choice.ts (96%) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 197b079c6b..a459a86465 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -641,7 +641,6 @@ "useAppShellTurnPresentation": 1, "useEffect": 2, "useLayoutEffect": 1, - "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, "useRef": 7, "useSessionNavigationReads": 1, @@ -653,7 +652,7 @@ "useShellMemoryPill": 1, "useShellResume": 1, "useStableActions": 4, - "useState": 10, + "useState": 8, "useSystemUiLocale": 1, "useToast": 1, "useTurnActionRegistry": 1 @@ -732,7 +731,7 @@ "react": 1 }, "importSpecifiers": 73, - "nonTriviaTokens": 9062 + "nonTriviaTokens": 9054 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, diff --git a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts index 58a94dd9db..378787cb04 100644 --- a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts @@ -55,7 +55,6 @@ afterEach(async () => { test('rejects non-chat permission modes before confirmation or persistence', async () => { let permissionWrites = 0; - let draftWrites = 0; let confirmations = 0; const { controller } = await mountController({ services: createServices({ @@ -64,9 +63,6 @@ test('rejects non-chat permission modes before confirmation or persistence', asy return {} as DesktopSessionSummary; }, }), - setNewTaskPermissionMode: () => { - draftWrites += 1; - }, confirmBypass: async () => { confirmations += 1; return true; @@ -80,7 +76,7 @@ test('rejects non-chat permission modes before confirmation or persistence', asy assert.equal(accepted, false); assert.equal(permissionWrites, 0); - assert.equal(draftWrites, 0); + assert.equal(controller().newTask.permissionChoice, undefined); assert.equal(confirmations, 0); }); @@ -418,7 +414,6 @@ async function mountController(overrides: { services?: SessionSettingsServices; owner?: { sessionId?: string }; sessions?: readonly DesktopSessionSummary[]; - setNewTaskPermissionMode?(mode: 'ask' | 'bypass'): void; confirmBypass?(): Promise; saveComposerDefaults?(model: { llmConnectionId: string; @@ -452,7 +447,6 @@ async function mountController(overrides: { }, owner: overrides.owner ?? {}, catalog, - setNewTaskPermissionMode: overrides.setNewTaskPermissionMode ?? (() => {}), confirmBypass: overrides.confirmBypass ?? (async () => true), saveComposerDefaults: overrides.saveComposerDefaults ?? (() => {}), }), @@ -471,7 +465,6 @@ function Harness(props: { capture(controller: Controller): void; owner: { sessionId?: string }; catalog: SessionCatalogController; - setNewTaskPermissionMode(mode: 'ask' | 'bypass'): void; confirmBypass(): Promise; saveComposerDefaults(model: { llmConnectionId: string; @@ -482,6 +475,7 @@ function Harness(props: { const controller = useSessionSettingsController({ catalog: props.catalog, isActiveSession: () => true, + newTaskChoiceKey: 'new-task:test', newSessionPermissionMode: 'ask', refreshCatalog: async () => {}, saveComposerDefaults: props.saveComposerDefaults, @@ -490,7 +484,6 @@ function Harness(props: { planMode: { reportExecutionActive: () => {}, confirmDiscard: async () => true }, captureOwner: () => props.owner, isOwnerActive: () => true, - setNewTaskPermissionMode: props.setNewTaskPermissionMode, confirmBypass: props.confirmBypass, }); props.capture(controller); @@ -504,6 +497,7 @@ function CausalRetirementHarness(props: { const controller = useSessionSettingsController({ catalog: props.catalog, isActiveSession: () => true, + newTaskChoiceKey: 'new-task:test', newSessionPermissionMode: 'ask', refreshCatalog: async () => {}, saveComposerDefaults: () => {}, @@ -512,7 +506,6 @@ function CausalRetirementHarness(props: { planMode: { reportExecutionActive: () => {}, confirmDiscard: async () => true }, captureOwner: () => ({ sessionId: 'session-a' }), isOwnerActive: () => true, - setNewTaskPermissionMode: () => {}, confirmBypass: async () => true, }); props.capture(controller); diff --git a/apps/desktop/src/main/__tests__/session-settings-provider-scope.test.ts b/apps/desktop/src/main/__tests__/session-settings-provider-scope.test.ts index 48962fdc01..f88eab679d 100644 --- a/apps/desktop/src/main/__tests__/session-settings-provider-scope.test.ts +++ b/apps/desktop/src/main/__tests__/session-settings-provider-scope.test.ts @@ -66,6 +66,7 @@ async function mount(options: { catalog.commitSessions([session('a'), session('b')]); let selected: string | undefined = 'a'; let owner: { sessionId?: string } = { sessionId: selected }; + let newTaskChoiceKey = 'new-task:project-a'; let snapshot!: ReturnType; let renders = 0; let frameRenders = 0; @@ -80,6 +81,7 @@ async function mount(options: { input: { catalog, isActiveSession: (id) => id === selected, + newTaskChoiceKey, newSessionPermissionMode: 'ask', refreshCatalog: options.refreshCatalog ?? (async () => {}), saveComposerDefaults: () => {}, @@ -91,7 +93,6 @@ async function mount(options: { }, captureOwner: () => owner, isOwnerActive: (claim) => claim === owner, - setNewTaskPermissionMode: () => {}, confirmBypass: options.confirmBypass ?? (async () => true), }, }, createElement(Frame)); @@ -111,9 +112,50 @@ async function mount(options: { owner = { sessionId: selected }; await act(render); }, + retargetNewTask: async (key: string) => { + newTaskChoiceKey = key; + await act(render); + }, }; } +test('the provider owns what a new task starts with; the shell reads it without its overlay moving', async () => { + const h = await mount(); + await h.select(undefined); + const { commands, overlay } = h.current(); + assert.deepEqual(h.current().newTask, { planMode: false, orchestrationMode: 'default' }); + + await act(() => { commands.setNewTaskPlanMode(true); }); + await act(() => { commands.setNewTaskOrchestrationMode('swarm'); }); + await act(async () => { assert.equal(await commands.setPermissionMode('bypass'), true); }); + assert.deepEqual(h.current().newTask, { permissionChoice: 'bypass', planMode: true, orchestrationMode: 'swarm' }); + assert.equal(h.current().overlay, overlay, 'new-task choices leave the Session overlay identity alone'); + + // The permission choice belongs to its target; Plan and orchestration follow the draft. + await h.retargetNewTask('new-task:project-b'); + assert.deepEqual(h.current().newTask, { planMode: true, orchestrationMode: 'swarm' }); + await h.retargetNewTask('new-task:project-a'); + assert.equal(h.current().newTask.permissionChoice, 'bypass'); + + await act(() => { commands.clearNewTaskPermissionChoice(); }); + assert.equal(h.current().newTask.permissionChoice, undefined); +}); + +test('a Session owner writes its own permission, never the new-task choice', async () => { + const writes: unknown[] = []; + const h = await mount({ + services: services({ + setPermissionMode: async (id, mode) => { + writes.push([id, mode]); + return { ...session(id), permissionMode: mode, revision: 2 }; + }, + }), + }); + await act(async () => { assert.equal(await h.current().commands.setPermissionMode('bypass'), true); }); + assert.deepEqual(writes, [['a', 'bypass']]); + assert.equal(h.current().newTask.permissionChoice, undefined); +}); + test('inactive Session writes keep the shell and frame asleep; selection reads the right overlay immediately', async () => { const write = deferred(); const h = await mount({ services: services({ setModelConfiguration: () => write.promise }) }); @@ -171,7 +213,7 @@ test('catalog observations retire only the acknowledged Session overlay without const initialRenders = h.renders(); const initialFrames = h.frameRenders(); await act(async () => { assert.equal(await commands.setSessionModel('b', model), true); }); - const overlay = () => h.current().bridge.getState().modelConfiguration.b; + const overlay = () => h.current().bridge.getState().overlays.modelConfiguration.b; assert.equal(overlay()?.modelTarget.model, 'next'); await act(() => h.catalog.commitSessions([{ ...session('a'), revision: 2 }, session('b')])); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 92737590ae..83528e4436 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -34,7 +34,6 @@ import type { QuoteRef, } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; -import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; import { collapseSessionRevisions } from '@maka/core/session-revisions'; import { isLinkedSubagentSession } from '@maka/core/session'; @@ -147,7 +146,6 @@ import { useTurnActionRegistry } from './use-turn-action-registry'; import { desktopSlashCommandPresentation, useActiveExecutionBoundary, - useNewTaskChoice, useShellChatModel, useShellResume, } from './features/conversation/index.js'; @@ -334,12 +332,6 @@ function AppShellContent({ // Held for the whole of sendOwningItsTarget; see ChatComposerRegion. const [newTaskSendPending, setNewTaskSendPending] = useState(false); - // What a new chat will start with, held the way the Session holds it: a - // Plan toggle and one orchestration value, not one fused choice. - const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); - const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); - const [newTaskPermissionChoice, setNewTaskPermissionMode, clearNewTaskPermissionChoice] = - useNewTaskChoice(currentNewTaskDraftKey); const [petCompletionNonce, setPetCompletionNonce] = useState(0); const [navigationState, setNavigationState] = useState(() => readNavigationState()); const navSelection = navigationState.selection; @@ -476,6 +468,11 @@ function AppShellContent({ }); const shellCopy = getShellCopy(uiLocale).app; const desktopConversationCopy = getDesktopConversationCopy(uiLocale); + // Session Settings owns both the selected Session's mode writes and what a + // new chat will start with: a Plan toggle, one orchestration value and the + // draft's permission choice, not one fused choice. + const sessionSettingIntent = useSessionSettingIntent(activeId); + const newTaskSettings = sessionSettingIntent.newTask; /** * What this draft would start in: the user's choice for it if they made one, * otherwise the Host default it will inherit by omission. @@ -485,7 +482,7 @@ function AppShellContent({ * never written back to `chatDefaults` — the Settings surface owns that. */ const newSessionPermissionMode = - newTaskPermissionChoice ?? + newTaskSettings.permissionChoice ?? taskEntry.selectors.selectedHost?.chatDefaults.permissionMode ?? 'bypass'; // Persisted composer defaults seed the empty-state model, project path, and @@ -533,7 +530,6 @@ function AppShellContent({ }); const rendererMountedRef = useRef(true); const activeSession = activeCatalogSession; - const sessionSettingIntent = useSessionSettingIntent(activeId); const { setPermissionMode, setSessionModel, setSessionThinkingLevel, setSessionExecutor } = sessionSettingIntent.commands; const modelConfigurationOverlay = sessionSettingIntent.overlay.modelConfiguration; const activeSessionForModelControls = activeSession @@ -661,7 +657,7 @@ function AppShellContent({ function setPlanMode(active: boolean): Promise { const sessionId = activeIdRef.current; if (!sessionId) { - setNewChatPlanModeActive(active); + sessionSettingIntent.commands.setNewTaskPlanMode(active); return Promise.resolve(true); } if (active === activePlanMode) return Promise.resolve(true); @@ -678,7 +674,7 @@ function AppShellContent({ function setOrchestrationMode(mode: OrchestrationMode): Promise { const sessionId = activeIdRef.current; if (!sessionId) { - setNewChatOrchestrationMode(mode); + sessionSettingIntent.commands.setNewTaskOrchestrationMode(mode); return Promise.resolve(true); } if (mode === activeOrchestrationMode) return Promise.resolve(true); @@ -755,12 +751,12 @@ function AppShellContent({ const activePlanMode = activeId ? sessionSettingIntent.overlay.planMode ?? ((activeSessionForView?.collaborationMode ?? 'agent') === 'plan') - : newChatPlanModeActive; + : newTaskSettings.planMode; const activeOrchestrationMode: OrchestrationMode = activeId ? sessionSettingIntent.overlay.orchestrationMode ?? activeSessionForView?.orchestrationMode ?? 'default' - : newChatOrchestrationMode; + : newTaskSettings.orchestrationMode; /** * Why neither mode can be changed right now, if either cannot. Both controls * write the same Session configuration, so everything that holds one holds @@ -936,14 +932,14 @@ function AppShellContent({ const ownerToken = startNewSession(); // Only Plan resets: a new task starts out of Plan, in whatever // orchestration the last one was set to. - setNewChatPlanModeActive(false); + sessionSettingIntent.commands.setNewTaskPlanMode(false); setNavSelection({ section: 'sessions' }); setSearchScrollTarget(null); // New-task affordances reset to the empty-state composer; move focus // there so the user can start typing immediately. window.requestAnimationFrame(() => composerRef.current?.focus()); return ownerToken; - }, [composerStaging, setNavSelection, setSearchScrollTarget, startNewSession]); + }, [composerStaging, sessionSettingIntent.commands, setNavSelection, setSearchScrollTarget, startNewSession]); const createSession = useCallback(async () => { openNewTaskSurface(); @@ -978,7 +974,7 @@ function AppShellContent({ : taskEntry.selectors.projectPath, newTaskTarget: activeId ? undefined : taskEntry.selectors.target, newSessionModel: newChatModel, - newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', + newSessionCollaborationMode: newTaskSettings.planMode ? 'plan' : 'agent', // Refresh only; Desktop Main re-reads the authoritative default before // constructing the Runtime Host preview target. newSessionPermissionMode, @@ -1096,10 +1092,10 @@ function AppShellContent({ pendingNewChatThinkingLevel: executorTarget ? newChatExecutionThinkingLevel ?? null : pendingNewChatThinkingLevel, executorSelection: executor.selection, executorEntry: executor.entry, - newChatPermissionChoice: newTaskPermissionChoice, - clearNewChatPermissionChoice: clearNewTaskPermissionChoice, - newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', - newChatOrchestrationMode, + newChatPermissionChoice: newTaskSettings.permissionChoice, + clearNewChatPermissionChoice: sessionSettingIntent.commands.clearNewTaskPermissionChoice, + newChatCollaborationMode: newTaskSettings.planMode ? 'plan' : 'agent', + newChatOrchestrationMode: newTaskSettings.orchestrationMode, newTaskTarget: taskEntry.selectors.target, }); @@ -1457,6 +1453,7 @@ function AppShellContent({ input={{ catalog: sessionCatalogController, isActiveSession: (sessionId) => activeIdRef.current === sessionId, + newTaskChoiceKey: currentNewTaskDraftKey, newSessionPermissionMode, refreshCatalog: refreshSessions, saveComposerDefaults: (model) => saveComposerDefaults({ model }), @@ -1476,7 +1473,6 @@ function AppShellContent({ }, captureOwner: captureComposerImportOwner, isOwnerActive: isComposerImportOwnerActive, - setNewTaskPermissionMode, confirmBypass: () => confirmBypassPermission(toastApi, uiLocale), }} > diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-new-task-choice.ts b/apps/desktop/src/renderer/application/contracts/use-new-task-choice.ts similarity index 96% rename from apps/desktop/src/renderer/features/conversation/controller/use-new-task-choice.ts rename to apps/desktop/src/renderer/application/contracts/use-new-task-choice.ts index 105f3ed728..d9d8d66755 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-new-task-choice.ts +++ b/apps/desktop/src/renderer/application/contracts/use-new-task-choice.ts @@ -18,7 +18,7 @@ */ import { useCallback, useEffect, useState } from 'react'; -import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from '../../../application/contracts/new-task-reload-intent.js'; +import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent.js'; export function useNewTaskChoice( targetKey: string, diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-shell-chat-model.ts b/apps/desktop/src/renderer/features/conversation/controller/use-shell-chat-model.ts index e1b26ab2a1..35c4818711 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-shell-chat-model.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-shell-chat-model.ts @@ -44,7 +44,7 @@ import { } from '../model/session-health-notice.js'; type ComposerDefaults = { model: NewChatModelCandidate | null }; import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; -import { useNewTaskChoice } from './use-new-task-choice.js'; +import { useNewTaskChoice } from '../../../application/contracts/use-new-task-choice.js'; export type { NewChatModel } from '../model/shell-chat-model-selection.js'; export type NewChatExecutionTarget = NewChatModel | { executorId: string; model: string }; diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 97571608f4..74d0e63421 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -62,7 +62,6 @@ export * from './model/observation-visibility.js'; export { useExecutorSelection } from './controller/use-executor-selection.js'; export * from './model/shell-chat-model-selection.js'; export * from './model/session-health-notice.js'; -export * from './controller/use-new-task-choice.js'; export * from './controller/use-shell-chat-model.js'; export * from './model/executor-submission.js'; export * from './model/executor-composer.js'; diff --git a/apps/desktop/src/renderer/features/session-settings/README.md b/apps/desktop/src/renderer/features/session-settings/README.md index 46cc9bd3ce..c91178f002 100644 --- a/apps/desktop/src/renderer/features/session-settings/README.md +++ b/apps/desktop/src/renderer/features/session-settings/README.md @@ -31,7 +31,8 @@ reads `window.maka`. ## Shell boundary `useSessionSettingIntent(sessionId)` retains the shell's existing hook name, -but is now an equality-selected read of only that Session's four overlays. +but is now an equality-selected read of only that Session's four overlays and +the new-task settings below. It creates a per-shell bridge, without calling the write controller. The shell still needs these values to derive its model picker and mode controls. The hook inventory therefore stays at one call; it does not claim that all settings @@ -42,6 +43,20 @@ controller. The provider publishes after commit and reuses the shell's children on its own updates. Writes for other Sessions do not re-render the shell or its frame. Cleanup disconnects commands and clears the published overlays. +## New-task settings (R2 M3) + +The provider also owns what the next new task starts with: a Plan toggle, one +orchestration value and the draft's permission choice. They used to be shell +state. `setPermissionMode` already decided between the selected Session and the +new task and confirmed Bypass for both, so the choice now lives with that write. +The permission choice is keyed by the new-task target through the shared +`useNewTaskChoice` contract, and stays absent until the user makes one, so +creation can omit it and the Host applies its own default. +`setNewTaskPlanMode`, `setNewTaskOrchestrationMode` and +`clearNewTaskPermissionChoice` are the only writes. The shell reads `newTask` +from the same hook for the Composer's mode controls and passes it to creation; +a new-task change does not replace the Session overlay it returns. + ## Preserved behavior - Model and thinking remain one compound write. Only a successfully committed diff --git a/apps/desktop/src/renderer/features/session-settings/controller/session-settings-bridge.ts b/apps/desktop/src/renderer/features/session-settings/controller/session-settings-bridge.ts index 3f8983c450..8ba7f23476 100644 --- a/apps/desktop/src/renderer/features/session-settings/controller/session-settings-bridge.ts +++ b/apps/desktop/src/renderer/features/session-settings/controller/session-settings-bridge.ts @@ -18,23 +18,30 @@ */ import type { + NewTaskSettings, SessionSettingsCommands, SessionSettingsController, SessionSettingsOverlays, } from '../model/session-settings-contract.js'; -const EMPTY_OVERLAYS: SessionSettingsOverlays = { - modelConfiguration: {}, permissionMode: {}, planMode: {}, orchestrationMode: {}, +export interface SessionSettingsReadState { + readonly overlays: SessionSettingsOverlays; + readonly newTask: NewTaskSettings; +} + +const EMPTY_STATE: SessionSettingsReadState = { + overlays: { modelConfiguration: {}, permissionMode: {}, planMode: {}, orchestrationMode: {} }, + newTask: { planMode: false, orchestrationMode: 'default' }, }; /** Per-shell command port and read store; the provider alone owns write state. */ export function createSessionSettingsBridge() { let controller: SessionSettingsController | undefined; - let overlays = EMPTY_OVERLAYS; + let state = EMPTY_STATE; const listeners = new Set<() => void>(); - const publishOverlays = (next: SessionSettingsOverlays) => { - if (overlays === next) return; - overlays = next; + const publishState = (next: SessionSettingsReadState) => { + if (state.overlays === next.overlays && state.newTask === next.newTask) return; + state = next; for (const listener of [...listeners]) listener(); }; const commands: SessionSettingsCommands = { @@ -45,21 +52,24 @@ export function createSessionSettingsBridge() { setPermissionMode: (mode) => controller?.setPermissionMode(mode) ?? Promise.resolve(false), setPlanMode: (id, active) => controller?.setPlanMode(id, active) ?? Promise.resolve(false), setOrchestrationMode: (id, mode) => controller?.setOrchestrationMode(id, mode) ?? Promise.resolve(false), + setNewTaskPlanMode: (active) => controller?.setNewTaskPlanMode(active), + setNewTaskOrchestrationMode: (mode) => controller?.setNewTaskOrchestrationMode(mode), + clearNewTaskPermissionChoice: () => controller?.clearNewTaskPermissionChoice(), }; return { commands, - getState: () => overlays, + getState: () => state, subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, publish(next: SessionSettingsController) { controller = next; - publishOverlays(next.overlays); + publishState({ overlays: next.overlays, newTask: next.newTask }); }, disconnect() { controller = undefined; - publishOverlays(EMPTY_OVERLAYS); + publishState(EMPTY_STATE); }, }; } diff --git a/apps/desktop/src/renderer/features/session-settings/controller/use-session-settings-controller.ts b/apps/desktop/src/renderer/features/session-settings/controller/use-session-settings-controller.ts index 7f11246411..c9fe4d2724 100644 --- a/apps/desktop/src/renderer/features/session-settings/controller/use-session-settings-controller.ts +++ b/apps/desktop/src/renderer/features/session-settings/controller/use-session-settings-controller.ts @@ -17,12 +17,13 @@ * under the License. */ -import { useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import { isChatDefaultPermissionMode, + type ChatDefaultPermissionMode, } from '@maka/core/settings'; import { useSessionSettingIntent as useSharedSessionSettingIntent, @@ -35,8 +36,10 @@ import { type SessionModelTarget, } from '../session-model-configuration-intent.js'; import { useSessionSettingsServices } from '../services-context.js'; +import { useNewTaskChoice } from '../../../application/contracts/use-new-task-choice.js'; import type { + NewTaskSettings, SessionSettingValues, SessionSettingsController, SessionSettingsInput, @@ -47,6 +50,16 @@ export function useSessionSettingsController, ): SessionSettingsController { const services = useSessionSettingsServices(); + const [permissionChoice, setPermissionChoice, clearPermissionChoice] = + useNewTaskChoice(input.newTaskChoiceKey); + // A new task starts out of Plan, in whatever orchestration the last one used. + const [newTaskPlanMode, setNewTaskPlanMode] = useState(false); + const [newTaskOrchestrationMode, setNewTaskOrchestrationMode] = useState('default'); + const newTask = useMemo(() => ({ + ...(permissionChoice ? { permissionChoice } : {}), + planMode: newTaskPlanMode, + orchestrationMode: newTaskOrchestrationMode, + }), [permissionChoice, newTaskPlanMode, newTaskOrchestrationMode]); const reportWriteError = ( sessionId: string, error: unknown, @@ -123,6 +136,7 @@ export function useSessionSettingsController intent.request('modelConfiguration', sessionId, modelConfigurationIntentForModel(modelTarget)), setSessionThinkingLevel: (sessionId: string, thinkingLevel: ThinkingLevel | null) => { @@ -173,12 +187,15 @@ export function useSessionSettingsController intent.request('planMode', sessionId, active), setOrchestrationMode: (sessionId: string, mode: OrchestrationMode) => intent.request('orchestrationMode', sessionId, mode), + setNewTaskPlanMode, + setNewTaskOrchestrationMode, + clearNewTaskPermissionChoice: clearPermissionChoice, }; } diff --git a/apps/desktop/src/renderer/features/session-settings/model/session-settings-contract.ts b/apps/desktop/src/renderer/features/session-settings/model/session-settings-contract.ts index 514dd9be9b..0ae2fcd44a 100644 --- a/apps/desktop/src/renderer/features/session-settings/model/session-settings-contract.ts +++ b/apps/desktop/src/renderer/features/session-settings/model/session-settings-contract.ts @@ -35,9 +35,23 @@ export type SessionSettingsOverlays = { readonly [Key in keyof SessionSettingValues]: Readonly>; }; +/** + * What the next new task starts with. The permission choice is keyed by the + * new-task target and absent until the user makes one, so creation can omit it + * and let the Host apply its own default. + */ +export interface NewTaskSettings { + readonly permissionChoice?: ChatDefaultPermissionMode; + readonly planMode: boolean; + readonly orchestrationMode: OrchestrationMode; +} + export interface SessionSettingsInput { catalog: SessionCatalogController; isActiveSession(sessionId: string): boolean; + /** The new-task target the permission choice belongs to. */ + newTaskChoiceKey: string; + /** The choice, or the Host default when none was made. */ newSessionPermissionMode: ChatDefaultPermissionMode; refreshCatalog(): Promise; saveComposerDefaults(model: SessionModelTarget): void; @@ -52,7 +66,6 @@ export interface SessionSettingsInput { }; captureOwner(): Owner; isOwnerActive(owner: Owner): boolean; - setNewTaskPermissionMode(mode: ChatDefaultPermissionMode): void; confirmBypass(): Promise; } @@ -67,8 +80,13 @@ export interface SessionSettingsCommands { setPermissionMode(mode: PermissionMode): Promise; setPlanMode(sessionId: string, active: boolean): Promise; setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise; + setNewTaskPlanMode(active: boolean): void; + setNewTaskOrchestrationMode(mode: OrchestrationMode): void; + /** Drops a permission choice once a created Session has consumed it. */ + clearNewTaskPermissionChoice(): void; } export interface SessionSettingsController extends SessionSettingsCommands { readonly overlays: SessionSettingsOverlays; + readonly newTask: NewTaskSettings; } diff --git a/apps/desktop/src/renderer/features/session-settings/ui/use-session-setting-intent.ts b/apps/desktop/src/renderer/features/session-settings/ui/use-session-setting-intent.ts index 6e2cade72c..309e97fe19 100644 --- a/apps/desktop/src/renderer/features/session-settings/ui/use-session-setting-intent.ts +++ b/apps/desktop/src/renderer/features/session-settings/ui/use-session-setting-intent.ts @@ -18,13 +18,13 @@ */ import { useMemo, useSyncExternalStore } from 'react'; -import { createSessionSettingsBridge } from '../controller/session-settings-bridge.js'; -import type { SessionSettingValues, SessionSettingsOverlays } from '../model/session-settings-contract.js'; +import { createSessionSettingsBridge, type SessionSettingsReadState } from '../controller/session-settings-bridge.js'; +import type { NewTaskSettings, SessionSettingValues, SessionSettingsOverlays } from '../model/session-settings-contract.js'; import { equalSessionModelConfigurationIntent } from '../session-model-configuration-intent.js'; -type Selection = Partial; +type Selection = { readonly overlay: Partial; readonly newTask: NewTaskSettings }; -function select(overlays: SessionSettingsOverlays, sessionId?: string): Selection { +function selectOverlay(overlays: SessionSettingsOverlays, sessionId?: string): Partial { return sessionId ? { modelConfiguration: overlays.modelConfiguration[sessionId], permissionMode: overlays.permissionMode[sessionId], @@ -33,7 +33,7 @@ function select(overlays: SessionSettingsOverlays, sessionId?: string): Selectio } : {}; } -function equal(left: Selection, right: Selection): boolean { +function equalOverlay(left: Partial, right: Partial): boolean { const a = left.modelConfiguration; const b = right.modelConfiguration; return (a === b || Boolean(a && b && equalSessionModelConfigurationIntent(a, b))) && @@ -43,24 +43,28 @@ function equal(left: Selection, right: Selection): boolean { } /** - * The shell's remaining intent read: only its selected Session's four overlays. - * No write controller is called here. Inactive Session writes do not wake it. + * The shell's remaining intent read: only its selected Session's four overlays + * and what the next new task starts with. No write controller is called here. + * Inactive Session writes do not wake it. */ export function useSessionSettingIntent(sessionId?: string) { const bridge = useMemo(createSessionSettingsBridge, []); const getSnapshot = useMemo(() => { - let state: SessionSettingsOverlays | undefined; - let selection: Selection = {}; + let state: SessionSettingsReadState | undefined; + let selection: Selection | undefined; return () => { const nextState = bridge.getState(); - if (state !== nextState) { - const next = select(nextState, sessionId); - if (state === undefined || !equal(selection, next)) selection = next; + if (state !== nextState || !selection) { + const nextOverlay = selectOverlay(nextState.overlays, sessionId); + const overlay = selection && equalOverlay(selection.overlay, nextOverlay) ? selection.overlay : nextOverlay; + if (overlay !== selection?.overlay || nextState.newTask !== selection.newTask) { + selection = { overlay, newTask: nextState.newTask }; + } state = nextState; } return selection; }; }, [bridge, sessionId]); - const overlay = useSyncExternalStore(bridge.subscribe, getSnapshot, getSnapshot); - return { bridge, commands: bridge.commands, overlay }; + const { overlay, newTask } = useSyncExternalStore(bridge.subscribe, getSnapshot, getSnapshot); + return { bridge, commands: bridge.commands, overlay, newTask }; } diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index 4b7c4db91d..a91124b906 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -116,7 +116,6 @@ export const ALLOWED = { useAppShellTurnPresentation: 1, useEffect: 2, useLayoutEffect: 1, - useNewTaskChoice: 1, useOnboardingSnapshot: 1, // Replaces `useSessionNavigationController`, which is now called inside // `SessionNavigationProvider`. The entry shrinks rather than disappearing, @@ -138,7 +137,7 @@ export const ALLOWED = { useShellMemoryPill: 1, useShellResume: 1, useStableActions: 4, - useState: 8, + useState: 6, useToast: 1, // The last of the three `useKeyedPendingRegistry` call sites this entry // replaces: #4113 moved the other two onto the session UI store, which is From 82ef50181b076bc2903a21621c87292b7067cd88 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 18:13:45 +0800 Subject: [PATCH 3/5] refactor(desktop): own Composer submission below the shell Move the send-pending flag, the edit-and-resend draft and the submit, follow-up and interaction-answer paths out of AppShellContent into a persistent ComposerSubmissionProvider in Conversation. The provider assembles the chat and revision actions, the staged follow-up and createRevisionAwareOnSend. ConversationComposerRegion reads the provider in the Composer slot. The shell keeps one stable command handle, beginEditUserMessage, and supplies only commands it already owns: navigation, catalog refresh, Workbar side chat and form answers, and the selected Session's orchestration write. The Host calls (submitMessage, newTasks.create, sessions.remove, reviseBeforeTurn, abandonSessionCopy, and the sandbox-boundary and question answers) go through ComposerSubmissionServices and a Desktop adapter. Shared helpers move to application contracts or into the feature. The submit construction and unused exports leave the public entry, and the transitional adapter loses the commands only the moved actions used. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 167 +------- ...pp-shell-attachment-preflight-send.test.ts | 8 +- .../app-shell-busy-race-settlement.test.ts | 32 +- .../app-shell-chat-actions-fixture.ts | 20 +- .../app-shell-first-send-cleanup.test.ts | 44 +- .../app-shell-form-interaction.test.ts | 6 +- .../app-shell-revision-actions.test.ts | 12 +- .../app-shell-revision-resend.test.ts | 14 +- .../__tests__/attachment-preflight.test.ts | 2 +- .../__tests__/composer-staging-owner.test.ts | 21 +- .../__tests__/composer-submission-fixture.ts | 104 +++++ .../composer-submission-owner.test.ts | 393 ++++++++++++++++++ .../__tests__/conversation-compaction.test.ts | 8 +- .../main/__tests__/conversation-owner.test.ts | 3 +- .../__tests__/desktop-slash-command.test.ts | 2 +- .../follow-up-submit-routing.test.ts | 2 +- .../__tests__/session-copy-attempt.test.ts | 4 +- .../src/renderer/app-shell-turn-actions.ts | 2 +- apps/desktop/src/renderer/app-shell.tsx | 243 ++--------- .../contracts}/attachment-preflight.ts | 0 .../contracts}/desktop-slash-command.ts | 0 .../contracts}/session-copy-attempt.ts | 0 .../contracts}/side-chat-command.ts | 0 .../composition/desktop-feature-services.tsx | 12 +- .../renderer/features/conversation/README.md | 38 +- .../conversation/controller/chat-actions.ts} | 79 ++-- .../composer-submission-commands.ts | 34 ++ .../controller/revision-actions.ts} | 29 +- .../controller/use-composer-submission.ts | 217 ++++++++++ .../controller/use-conversation-target.ts | 7 - .../renderer/features/conversation/index.ts | 11 +- .../model/composer-submission-binding.ts | 26 ++ .../model/composer-submission-contract.ts | 77 ++++ .../model}/follow-up-submit-routing.ts | 0 .../model/session-workspace-toast.ts | 36 ++ .../model}/skill-invocation-feedback.ts | 2 +- .../conversation/submission-services.ts | 86 ++++ .../renderer/features/conversation/testing.ts | 12 + .../ui/composer-submission-context.ts | 30 ++ .../ui/composer-submission-provider.tsx | 70 ++++ .../conversation/ui/conversation-readers.tsx | 51 ++- .../controller/use-workbar-controller.ts | 2 +- .../tools/side-chat/quote-companion-core.ts | 2 +- .../tools/side-chat/quote-companion-panel.tsx | 2 +- .../create-composer-submission-services.ts | 49 +++ .../stories/composer-paste.stories.tsx | 2 +- .../stories/composer-slash-menu.stories.tsx | 2 +- docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + scripts/check-app-shell-hooks.mjs | 4 +- 50 files changed, 1452 insertions(+), 519 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/composer-submission-fixture.ts create mode 100644 apps/desktop/src/main/__tests__/composer-submission-owner.test.ts rename apps/desktop/src/renderer/{ => application/contracts}/attachment-preflight.ts (100%) rename apps/desktop/src/renderer/{ => application/contracts}/desktop-slash-command.ts (100%) rename apps/desktop/src/renderer/{ => application/contracts}/session-copy-attempt.ts (100%) rename apps/desktop/src/renderer/{ => application/contracts}/side-chat-command.ts (100%) rename apps/desktop/src/renderer/{app-shell-chat-actions.ts => features/conversation/controller/chat-actions.ts} (90%) create mode 100644 apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts rename apps/desktop/src/renderer/{app-shell-revision-actions.ts => features/conversation/controller/revision-actions.ts} (93%) create mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts create mode 100644 apps/desktop/src/renderer/features/conversation/model/composer-submission-binding.ts create mode 100644 apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts rename apps/desktop/src/renderer/{ => features/conversation/model}/follow-up-submit-routing.ts (100%) create mode 100644 apps/desktop/src/renderer/features/conversation/model/session-workspace-toast.ts rename apps/desktop/src/renderer/{ => features/conversation/model}/skill-invocation-feedback.ts (98%) create mode 100644 apps/desktop/src/renderer/features/conversation/submission-services.ts create mode 100644 apps/desktop/src/renderer/features/conversation/ui/composer-submission-context.ts create mode 100644 apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-composer-submission-services.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index a459a86465..747e811c42 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -4,7 +4,6 @@ "src/renderer/agent-graph-panel-visibility.ts", "src/renderer/agent-graph-panel.tsx", "src/renderer/agent-graph-refresh.ts", - "src/renderer/app-shell-chat-actions.ts", "src/renderer/app-shell-chrome-actions.tsx", "src/renderer/app-shell-command-actions.ts", "src/renderer/app-shell-copy.ts", @@ -13,7 +12,6 @@ "src/renderer/app-shell-effects.ts", "src/renderer/app-shell-overlays.tsx", "src/renderer/app-shell-project-actions.ts", - "src/renderer/app-shell-revision-actions.ts", "src/renderer/app-shell-stop-action.ts", "src/renderer/app-shell-turn-actions.ts", "src/renderer/app-shell.tsx", @@ -21,7 +19,6 @@ "src/renderer/astryx-theme-mode.ts", "src/renderer/astryx-theme/maka.js", "src/renderer/astryx-theme/makaTheme.ts", - "src/renderer/attachment-preflight.ts", "src/renderer/browser-storage.ts", "src/renderer/cached-theme-bootstrap.ts", "src/renderer/chat-composer-region.tsx", @@ -38,9 +35,7 @@ "src/renderer/custom-pet-companion.tsx", "src/renderer/daily-review-actions.ts", "src/renderer/desktop-execution-boundary-surface.ts", - "src/renderer/desktop-slash-command.ts", "src/renderer/error-boundary.tsx", - "src/renderer/follow-up-submit-routing.ts", "src/renderer/local-memory-digest.ts", "src/renderer/locales/agent-graph-copy.ts", "src/renderer/locales/artifact-copy.ts", @@ -88,7 +83,6 @@ "src/renderer/project-path-display.ts", "src/renderer/remote-project-directory-dialog.tsx", "src/renderer/scroll-motion-policy.ts", - "src/renderer/session-copy-attempt.ts", "src/renderer/session-read-state.ts", "src/renderer/session-workspace-errors.ts", "src/renderer/settings/about-settings-page.tsx", @@ -169,8 +163,6 @@ "src/renderer/settings/use-optimistic-settings-draft.ts", "src/renderer/settings/web-search-settings-page.tsx", "src/renderer/settled-message-merge.ts", - "src/renderer/side-chat-command.ts", - "src/renderer/skill-invocation-feedback.ts", "src/renderer/theme.ts", "src/renderer/titlebar-dim-color.ts", "src/renderer/titlebar-modal-sync.ts", @@ -201,12 +193,9 @@ "src/renderer/features/session-navigation/model/session-list-layout.ts -> src/renderer/browser-storage", "src/renderer/features/task-entry/ui/task-entry-host.tsx -> src/renderer/remote-project-directory-dialog", "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/browser-storage", - "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/side-chat-command", "src/renderer/features/workbar/model/workbar-layout.ts -> src/renderer/browser-storage", "src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx -> src/renderer/open-path", "src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts -> src/renderer/model-connection-errors", - "src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts -> src/renderer/session-copy-attempt", - "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/attachment-preflight", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-mentions", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/scroll-motion-policy", "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/settled-message-merge", @@ -229,6 +218,13 @@ "ownerSymbol": "ComposerStagingProvider", "count": 1 }, + { + "implementation": "src/renderer/features/conversation/controller/use-composer-submission.ts", + "symbol": "useComposerSubmission", + "owner": "src/renderer/features/conversation/ui/composer-submission-provider.tsx", + "ownerSymbol": "ComposerSubmissionProvider", + "count": 1 + }, { "implementation": "src/renderer/features/conversation/controller/use-conversation-controller.ts", "symbol": "useConversationController", @@ -319,6 +315,7 @@ "src/renderer/features/conversation/controller/use-composer-quotes.ts", "src/renderer/features/conversation/controller/use-conversation-observation.ts", "src/renderer/features/conversation/model/composer-staging-binding.ts", + "src/renderer/features/conversation/model/composer-submission-binding.ts", "src/renderer/features/conversation/model/context-compaction.ts", "src/renderer/features/conversation/model/conversation-workspace.ts", "src/renderer/features/conversation/model/session-events.ts", @@ -327,38 +324,11 @@ "src/renderer/features/conversation/model/session-workspace-actions.ts", "src/renderer/features/conversation/model/transcript-commands.ts", "src/renderer/features/conversation/ui/composer-staging-context.ts", + "src/renderer/features/conversation/ui/composer-submission-context.ts", "src/renderer/features/conversation/ui/conversation-context.ts" ], "legacyAppShell": { "files": { - "src/renderer/app-shell-chat-actions.ts": { - "importDeclarations": 5, - "bridgePaths": { - "window.maka.newTasks.create": 1, - "window.maka.sessions.remove": 1, - "window.maka.sessions.respondToSandboxBoundary": 1, - "window.maka.sessions.respondToUserQuestion": 1, - "window.maka.sessions.submitMessage": 1 - }, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [ - "createAppShellChatActions" - ], - "dependencyPaths": { - "./attachment-preflight.js": 1, - "./features/conversation/index.js": 1, - "./locales/shell-copy.js": 1, - "./model-connection-errors.js": 1, - "./session-workspace-errors.js": 1, - "./skill-invocation-feedback.js": 1, - "@maka/core/session-name": 1 - }, - "importSpecifiers": 8, - "nonTriviaTokens": 2931 - }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 1, "bridgePaths": {}, @@ -554,29 +524,6 @@ "importSpecifiers": 7, "nonTriviaTokens": 2157 }, - "src/renderer/app-shell-revision-actions.ts": { - "importDeclarations": 3, - "bridgePaths": { - "window.maka.sessions.abandonSessionCopy": 2, - "window.maka.sessions.reviseBeforeTurn": 1 - }, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [ - "createAppShellRevisionActions" - ], - "dependencyPaths": { - "./application/contracts/conversation-copy.js": 1, - "./locales/shell-copy.js": 1, - "./session-copy-attempt.js": 1, - "./session-workspace-errors.js": 1, - "@maka/core/session": 1 - }, - "importSpecifiers": 7, - "nonTriviaTokens": 2020 - }, "src/renderer/app-shell-stop-action.ts": { "importDeclarations": 0, "bridgePaths": { @@ -597,7 +544,7 @@ "nonTriviaTokens": 292 }, "src/renderer/app-shell-turn-actions.ts": { - "importDeclarations": 2, + "importDeclarations": 1, "bridgePaths": { "window.maka.sessions.branchFromTurn": 1 }, @@ -610,15 +557,15 @@ ], "dependencyPaths": { "./application/contracts/conversation-copy.js": 1, + "./application/contracts/session-copy-attempt.js": 1, "./locales/shell-copy.js": 1, - "./session-copy-attempt.js": 1, "./session-workspace-errors.js": 1 }, - "importSpecifiers": 3, + "importSpecifiers": 2, "nonTriviaTokens": 561 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 49, + "importDeclarations": 45, "bridgePaths": { "window.maka.attachments.readBytes": 1, "window.maka.onboarding.setMilestone": 1, @@ -642,7 +589,7 @@ "useEffect": 2, "useLayoutEffect": 1, "useOnboardingSnapshot": 1, - "useRef": 7, + "useRef": 5, "useSessionNavigationReads": 1, "useSessionSettingIntent": 1, "useShellAppearance": 1, @@ -651,8 +598,8 @@ "useShellLiveTurn": 1, "useShellMemoryPill": 1, "useShellResume": 1, - "useStableActions": 4, - "useState": 8, + "useStableActions": 2, + "useState": 6, "useSystemUiLocale": 1, "useToast": 1, "useTurnActionRegistry": 1 @@ -662,17 +609,15 @@ "actionFactories": [], "dependencyPaths": { "./agent-graph-panel": 1, - "./app-shell-chat-actions": 1, "./app-shell-chrome-actions": 1, "./app-shell-detail-panel": 1, "./app-shell-e2e-fixture": 1, "./app-shell-effects": 1, "./app-shell-overlays": 1, - "./app-shell-revision-actions": 1, "./app-shell-stop-action": 1, "./app-shell-turn-actions": 1, "./application/contracts/conversation-copy": 1, - "./application/contracts/session-catalog/catalog-row-watch.js": 1, + "./application/contracts/desktop-slash-command.js": 1, "./application/contracts/turn-presentation": 1, "./chat-composer-region": 1, "./chat-message-surface": 1, @@ -681,7 +626,6 @@ "./custom-pet-companion": 1, "./custom-pet-companion-model": 1, "./desktop-execution-boundary-surface": 1, - "./desktop-slash-command": 1, "./error-boundary": 1, "./features/app-update/index.js": 1, "./features/conversation": 1, @@ -697,7 +641,6 @@ "./features/task-entry": 1, "./features/workbar": 1, "./features/workhub": 1, - "./follow-up-submit-routing": 1, "./locales/shell-copy": 1, "./model-connection-errors": 1, "./nav-selection": 1, @@ -730,8 +673,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 73, - "nonTriviaTokens": 9054 + "importSpecifiers": 65, + "nonTriviaTokens": 8372 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -895,17 +838,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/attachment-preflight.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "@maka/core/attachments": 1 - } - }, "src/renderer/browser-storage.ts": { "bridgePaths": {}, "environmentCapabilities": { @@ -1083,19 +1015,6 @@ "@maka/core/sandbox-boundary": 1 } }, - "src/renderer/desktop-slash-command.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./side-chat-command.js": 1, - "@maka/core/graph-command": 1, - "@maka/core/swarm-command": 1 - } - }, "src/renderer/error-boundary.tsx": { "bridgePaths": { "window.maka.diagnostics": 3, @@ -1128,15 +1047,6 @@ "react": 1 } }, - "src/renderer/follow-up-submit-routing.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/local-memory-digest.ts": { "bridgePaths": {}, "environmentCapabilities": { @@ -1625,17 +1535,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/session-copy-attempt.ts": { - "bridgePaths": {}, - "environmentCapabilities": { - "sessionStorage": 2 - }, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/session-read-state.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -3314,26 +3213,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/side-chat-command.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, - "src/renderer/skill-invocation-feedback.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./locales/shell-copy.js": 1 - } - }, "src/renderer/theme.ts": { "bridgePaths": { "window.maka.appWindow.setThemeSource": 1, @@ -3806,18 +3685,10 @@ "src/renderer/app-shell.tsx" ] }, - { - "capability": "task-submission-and-composer-drafts", - "targetZone": "features/conversation", - "legacyPaths": [ - "src/renderer/app-shell-chat-actions.ts" - ] - }, { "capability": "session-controls-and-turn-actions", "targetZone": "features/conversation", "legacyPaths": [ - "src/renderer/app-shell-revision-actions.ts", "src/renderer/app-shell-stop-action.ts", "src/renderer/app-shell-turn-actions.ts" ] diff --git a/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts b/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts index 448cca4efc..92ab6f174b 100644 --- a/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts @@ -21,7 +21,7 @@ import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; import { strict as assert } from 'node:assert'; import { test } from 'node:test'; import type { PendingAttachment } from '@maka/ui/composer-attachments'; -import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; +import { createChatActions } from '../../renderer/features/conversation/testing.js'; import { getShellCopy } from '../../renderer/locales/shell-copy.js'; import { createActionsDeps, @@ -54,7 +54,7 @@ test('a nine-attachment new-task send shows the count reason, creates no session try { for (const locale of ['zh-CN', 'en'] as const) { toasts.length = 0; - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), uiLocale: locale, toastApi: { @@ -104,7 +104,7 @@ test('a main-side attachment rejection refuses the send instead of leaving it un }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), uiLocale: 'zh-CN', toastApi: { @@ -132,7 +132,7 @@ test('unsupported executor attachments preserve the pending draft before any Ses const pending = [fileAttachment(10, 0)]; const errors: string[] = []; try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), executorSelection: { executorId: 'external', configuration: { model: 'chosen' } }, executorEntry: { readiness: 'ready', supportsAttachments: false }, diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 7aad3b2aab..cdb797d75b 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -29,7 +29,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { TransientUserMessageProjection } from '@maka/ui'; -import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; +import { createChatActions } from '../../renderer/features/conversation/testing.js'; import { mergeTransientMessageProjection } from '../../renderer/application/contracts/transient-message-projection.js'; import { getShellCopy } from '../../renderer/locales/shell-copy.js'; @@ -49,7 +49,7 @@ describe('busy-raced send settlement', () => { }, } }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, getRunningTurnId: () => { throw new Error('Message intent must not depend on observation'); }, }); @@ -71,7 +71,7 @@ describe('busy-raced send settlement', () => { }, } }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, addTransientMessage: (_sessionId, message) => transient.set(message.id, message), }); @@ -89,7 +89,7 @@ describe('busy-raced send settlement', () => { }, } }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, getRunningTurnId: () => 'running-turn', addTransientMessage: (_sessionId, message) => transient.set(message.id, message), @@ -133,7 +133,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef, addTransientMessage: (_sessionId, message) => transient.set(message.id, message), @@ -165,7 +165,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, addTransientMessage: (_sessionId, message) => transient.set(message.id, message), @@ -202,7 +202,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, addTransientMessage: (_sessionId, message) => transient.set(message.id, message), @@ -232,7 +232,7 @@ describe('busy-raced send settlement', () => { }), } }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, toastApi: { @@ -262,7 +262,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, }); @@ -285,7 +285,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, }); @@ -331,7 +331,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, addTransientMessage: (_sessionId, message) => transient.set(message.id, message), @@ -389,7 +389,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef, addTransientMessage: (_sessionId, message) => transient.set(message.id, message), @@ -435,7 +435,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef, ...transientState.deps, @@ -470,7 +470,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef, activateSessionForFirstSend: async (session) => { activeIdRef.current = session.id; }, addTransientMessage: (_sessionId, message) => { @@ -511,7 +511,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef, activateSessionForFirstSend: async (session) => { @@ -552,7 +552,7 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef, activateSessionForFirstSend: async (session) => { diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts index 538a7b5379..1b306b7bad 100644 --- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -18,13 +18,17 @@ */ /** - * Shared scaffolding for the `createAppShellChatActions` suites. The dependency + * Shared scaffolding for the Composer submission action suites. The dependency * surface is wide and the suites only ever vary a handful of entries, so a * second copy of it drifts silently and has to be edited twice whenever the * actions gain a dependency. */ import type { TransientUserMessageProjection } from '@maka/ui'; +import { + createDesktopComposerSubmissionServices, + type DesktopComposerSubmissionBridge, +} from '../../renderer/platform/desktop/create-composer-submission-services.js'; /** Installs a `window.maka` bridge double; the returned function restores it. */ export function installWindow(maka: unknown): () => void { @@ -49,6 +53,19 @@ export function installWindow(maka: unknown): () => void { }; } +/** + * The production Desktop adapter over whichever `window.maka` double is + * installed when a call runs, so the suites keep stubbing the bridge they + * always stubbed and the adapter's mapping is exercised along the way. + */ +export function windowSubmissionServices() { + const installed = () => (globalThis as unknown as { window: { maka: DesktopComposerSubmissionBridge } }).window.maka; + return createDesktopComposerSubmissionServices({ + get sessions() { return installed().sessions; }, + get newTasks() { return installed().newTasks; }, + }); +} + /** * The transient arm as a real map. Transient rows are not `StoredMessage`s — * they have no Turn to belong to yet — so they are held apart from the @@ -75,6 +92,7 @@ export function createTransientState() { export function createActionsDeps() { const activeIdRef = { current: undefined as string | undefined }; return { + services: windowSubmissionServices(), onFollowLatest: (_sessionId: string) => true, uiLocale: 'en' as const, activeIdRef, diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 23f6ae815c..706c0c583e 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -45,7 +45,7 @@ import { createSessionCatalogController } from '../../renderer/application/contr import { LocaleProvider, ToastProvider, type LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; +import { createChatActions } from '../../renderer/features/conversation/testing.js'; import { prepareTranscriptForSend, stubConversationServices, createConversationWorkspace, createTranscriptCommands } from '../../renderer/features/conversation/testing.js'; import { @@ -68,7 +68,7 @@ describe('composer first-send cleanup', () => { }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef, captureComposerImportOwner: () => ({ @@ -98,7 +98,7 @@ describe('composer first-send cleanup', () => { newTasks: { create: async () => { creates += 1; return { id: 'stale-session' }; } }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), captureComposerImportOwner: () => ({ sessionId: undefined, @@ -140,7 +140,7 @@ describe('composer first-send cleanup', () => { }, }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), captureComposerImportOwner: () => ({ sessionId: undefined, @@ -196,7 +196,7 @@ describe('composer first-send cleanup', () => { model: 'mimo-v2.5-free', }, }; - assert.equal(await createAppShellChatActions(deps).send('hello'), true); + assert.equal(await createChatActions(deps).send('hello'), true); } finally { restoreWindow(); } @@ -243,7 +243,7 @@ describe('composer first-send cleanup', () => { ...createActionsDeps(), newChatPermissionChoice: 'bypass' as const, }; - assert.equal(await createAppShellChatActions(deps).send('hello'), true); + assert.equal(await createChatActions(deps).send('hello'), true); } finally { restoreWindow(); } @@ -288,15 +288,15 @@ describe('composer first-send cleanup', () => { choice = undefined; }, }); - assert.equal(await createAppShellChatActions(deps()).send('task A'), false); + assert.equal(await createChatActions(deps()).send('task A'), false); assert.equal(choice, 'ask'); assert.deepEqual(removed, ['session-1']); - assert.equal(await createAppShellChatActions(deps()).send('retry task A'), false); + assert.equal(await createChatActions(deps()).send('retry task A'), false); assert.equal(choice, 'ask'); assert.deepEqual(removed, ['session-1', 'session-2']); - assert.equal(await createAppShellChatActions(deps()).send('retry task A again'), true); + assert.equal(await createChatActions(deps()).send('retry task A again'), true); assert.equal(choice, undefined); - assert.equal(await createAppShellChatActions(deps()).send('task B'), true); + assert.equal(await createChatActions(deps()).send('task B'), true); } finally { restoreWindow(); } @@ -337,7 +337,7 @@ describe('composer first-send cleanup', () => { projectId: 'project-docs', }, }; - assert.equal(await createAppShellChatActions(deps).send('hello'), true); + assert.equal(await createChatActions(deps).send('hello'), true); } finally { restoreWindow(); } @@ -364,7 +364,7 @@ describe('composer first-send cleanup', () => { }); try { - assert.equal(await createAppShellChatActions(createActionsDeps()).send('hello'), false); + assert.equal(await createChatActions(createActionsDeps()).send('hello'), false); } finally { restoreWindow(); } @@ -392,7 +392,7 @@ describe('composer first-send cleanup', () => { }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), captureComposerImportOwner: () => ({ sessionId: undefined, @@ -435,7 +435,7 @@ describe('composer first-send cleanup', () => { }); try { - const actions = createAppShellChatActions(createActionsDeps()); + const actions = createChatActions(createActionsDeps()); const result = await actions.send('hello', undefined, { onSessionResolved: () => { resolved += 1; @@ -469,7 +469,7 @@ describe('composer first-send cleanup', () => { }); try { - const sending = createAppShellChatActions({ + const sending = createChatActions({ ...createActionsDeps(), activeIdRef, addTransientMessage: () => { @@ -512,7 +512,7 @@ describe('composer first-send cleanup', () => { }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef, activateSessionForFirstSend: async (session) => { @@ -557,7 +557,7 @@ describe('composer first-send cleanup', () => { }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'existing-session' }, }); @@ -587,7 +587,7 @@ describe('composer first-send cleanup', () => { }); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'existing-session' }, }); @@ -627,7 +627,7 @@ describe('composer first-send cleanup', () => { }); try { - const actions = createAppShellChatActions(createActionsDeps()); + const actions = createChatActions(createActionsDeps()); const result = await actions.send('hello', undefined, { onSessionResolved: () => { resolved += 1; @@ -657,7 +657,7 @@ describe('composer first-send cleanup', () => { }); try { - const sending = createAppShellChatActions({ + const sending = createChatActions({ ...createActionsDeps(), activeIdRef, onFollowLatest: (sessionId) => prepareTranscriptForSend({ @@ -749,7 +749,7 @@ describe('composer send failure feedback', () => { const restoreWindow = installWindow(readinessFailure()); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, // The user is on 技能 now. `activeId` is still 'session-a' — that is @@ -771,7 +771,7 @@ describe('composer send failure feedback', () => { const restoreWindow = installWindow(readinessFailure()); try { - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), activeIdRef: { current: 'session-a' }, isShellSurfaceOwnerActive: () => true, diff --git a/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts b/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts index 5009e34445..fe5690dd7f 100644 --- a/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts @@ -21,7 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { InteractionQueues } from '@maka/ui'; import { dequeueInteractionByRequestId } from '@maka/ui'; -import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; +import { createChatActions } from '../../renderer/features/conversation/testing.js'; import { createActionsDeps } from './app-shell-chat-actions-fixture.js'; function pendingForm(): InteractionQueues { @@ -46,7 +46,7 @@ describe('AppShell form interaction response', () => { deps.activeIdRef.current = 'session-1'; let interactions = pendingForm(); let submitted: unknown; - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...deps, respondToUserForm: async (sessionId, response) => { submitted = { sessionId, response }; @@ -68,7 +68,7 @@ describe('AppShell form interaction response', () => { deps.activeIdRef.current = 'session-1'; let interactions = pendingForm(); let errors = 0; - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...deps, respondToUserForm: async () => { throw new Error('Host unavailable'); diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts index 1082fadeec..6456c698b9 100644 --- a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts @@ -22,10 +22,10 @@ import { describe, it } from 'node:test'; import type { StoredMessage } from '@maka/core/session'; import { - createAppShellRevisionActions, + createRevisionActions, type TurnRevisionDraft, -} from '../../renderer/app-shell-revision-actions.js'; -import { installWindow } from './app-shell-chat-actions-fixture.js'; +} from '../../renderer/features/conversation/testing.js'; +import { installWindow, windowSubmissionServices } from './app-shell-chat-actions-fixture.js'; const SESSION_1 = JSON.stringify(['host-1', 'session-1']); const SESSION_2 = JSON.stringify(['host-1', 'session-2']); @@ -49,7 +49,8 @@ function createActions(input: { messages: StoredMessage[]; failRefresh?: boolean let selectionRevision = 0; const activeIdRef: { current: string | undefined } = { current: SESSION_1 }; const revisionDraftRef: { current: unknown } = { current: null }; - const actions = createAppShellRevisionActions({ + const actions = createRevisionActions({ + services: windowSubmissionServices(), uiLocale: 'en' as never, activeIdRef, captureSelection: () => { @@ -242,7 +243,8 @@ describe('revision draft lifecycle over a prepared send', () => { const clearedDrafts: string[] = []; let composerText = options.composerText ?? ''; const revisionDraftRef: { current: TurnRevisionDraft | null } = { current: null }; - const actions = createAppShellRevisionActions({ + const actions = createRevisionActions({ + services: windowSubmissionServices(), uiLocale: 'en' as never, activeIdRef, captureSelection: () => { diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts index 06a0196dfa..74cdc51274 100644 --- a/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts @@ -33,6 +33,7 @@ */ import { ComposerStagingFixture } from './composer-staging-fixture.js'; +import { windowSubmissionServices } from './app-shell-chat-actions-fixture.js'; import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement, createRef } from 'react'; @@ -47,19 +48,19 @@ import { import { ChatComposerRegion } from '../../renderer/chat-composer-region.js'; import { completeTurnRevisionCopyAttempt, - createAppShellRevisionActions, + createRevisionActions, type TurnRevisionDraft, -} from '../../renderer/app-shell-revision-actions.js'; -import { parseDesktopSlashCommand } from '../../renderer/desktop-slash-command.js'; +} from '../../renderer/features/conversation/testing.js'; +import { parseDesktopSlashCommand } from '../../renderer/application/contracts/desktop-slash-command.js'; import { mergeWorkspaceReferences, rebaseWorkspaceFileReferences, -} from '../../renderer/follow-up-submit-routing.js'; +} from '../../renderer/features/conversation/testing.js'; import { getDesktopConversationCopy } from '../../renderer/application/contracts/conversation-copy.js'; import { createRevisionAwareOnSend, type RevisionSendPorts, -} from '../../renderer/features/conversation/index.js'; +} from '../../renderer/features/conversation/testing.js'; const SESSION_1 = JSON.stringify(['host-1', 'session-1']); const SESSION_2 = JSON.stringify(['host-1', 'session-2']); @@ -203,7 +204,8 @@ async function mountRevisionWorld(): Promise { }; const composer = createRef(); - const revisionActions = createAppShellRevisionActions({ + const revisionActions = createRevisionActions({ + services: windowSubmissionServices(), uiLocale: 'en' as never, activeIdRef, captureSelection: () => { diff --git a/apps/desktop/src/main/__tests__/attachment-preflight.test.ts b/apps/desktop/src/main/__tests__/attachment-preflight.test.ts index 0e8d7f525d..f312e3d819 100644 --- a/apps/desktop/src/main/__tests__/attachment-preflight.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-preflight.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; -import { preflightAttachmentItems } from '../../renderer/attachment-preflight.js'; +import { preflightAttachmentItems } from '../../renderer/application/contracts/attachment-preflight.js'; describe('attachment preflight (before session create)', () => { test('rejects more than 8 items before any session is created', () => { diff --git a/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts index b4d7add2b6..0f45cb27f2 100644 --- a/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts @@ -25,14 +25,16 @@ import { parseHTML } from 'linkedom'; import { ChatSurfaceLayout, LocaleProvider, type ComposerHandle } from '@maka/ui'; import type { AttachmentRef, DirectoryReference } from '@maka/core/events'; import { - createComposerStagingCommands, createRevisionAwareOnSend, createStagedFollowUp, + createComposerStagingCommands, StagedComposer, StagedQuoteChatView, PlanProvider, PlanServicesProvider, - type PlanServices, type RevisionSendPorts, type ComposerStagingSubmission, + type PlanServices, type ComposerStagingSubmission, } from '../../renderer/features/conversation/index.js'; -import { useComposerStaging } from '../../renderer/features/conversation/testing.js'; -import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -import { createAppShellRevisionActions, type TurnRevisionDraft } from '../../renderer/app-shell-revision-actions.js'; -import { createActionsDeps, createTransientState, EMPTY_SKILL_INVOCATION } from './app-shell-chat-actions-fixture.js'; +import { + createRevisionAwareOnSend, createStagedFollowUp, useComposerStaging, type RevisionSendPorts, +} from '../../renderer/features/conversation/testing.js'; +import { createChatActions } from '../../renderer/features/conversation/testing.js'; +import { createRevisionActions, type TurnRevisionDraft } from '../../renderer/features/conversation/testing.js'; +import { createActionsDeps, createTransientState, EMPTY_SKILL_INVOCATION, windowSubmissionServices } from './app-shell-chat-actions-fixture.js'; import { ComposerStagingFixture } from './composer-staging-fixture.js'; const saved = Object.fromEntries([ @@ -282,7 +284,7 @@ for (const mode of ['queue', 'steer'] as const) { attachments: [], inlineReferences: [], skillInvocation: EMPTY_SKILL_INVOCATION }; }, } } }); - const actions = createAppShellChatActions({ + const actions = createChatActions({ ...createActionsDeps(), ...transient.deps, activeIdRef, getRunningTurnId: () => 'running-turn', }); const enqueue = createStagedFollowUp({ @@ -343,7 +345,7 @@ test('Shell follow-up without quotes still submits through the production enqueu }, } } }); const transient = createTransientState(); - const actions = createAppShellChatActions({ ...createActionsDeps(), ...transient.deps, activeIdRef: { current: 'draft-a' } }); + const actions = createChatActions({ ...createActionsDeps(), ...transient.deps, activeIdRef: { current: 'draft-a' } }); const enqueue = createStagedFollowUp({ captureStaging: view.commands.captureSubmission, enqueueMessage: actions.enqueueMessage, onError: (_sessionId, error) => assert.fail(String(error)), @@ -360,7 +362,8 @@ for (const context of ['attachment', 'directory'] as const) { const activeIdRef = { current: 'draft-a' }; const revisionDraftRef = { current: null as TurnRevisionDraft | null }; const currentRevision = () => revisionDraftRef.current; - const actions = createAppShellRevisionActions({ + const actions = createRevisionActions({ + services: windowSubmissionServices(), uiLocale: 'en', activeIdRef, captureSelection: () => () => true, composerRef: view.composer, readMessages: () => [{ type: 'user', id: 'message', turnId: `guard-${context}`, text: 'original', ts: 1 }], hasPendingAttachments: () => view.commands.captureSubmission().hasPendingContext, diff --git a/apps/desktop/src/main/__tests__/composer-submission-fixture.ts b/apps/desktop/src/main/__tests__/composer-submission-fixture.ts new file mode 100644 index 0000000000..e422604b25 --- /dev/null +++ b/apps/desktop/src/main/__tests__/composer-submission-fixture.ts @@ -0,0 +1,104 @@ +/* + * 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 { createElement, type ReactNode } from 'react'; +import { + ComposerSubmissionProvider, + ComposerSubmissionServicesProvider, + createComposerStagingCommands, + createComposerSubmissionCommands, + type ComposerSubmissionServices, +} from '../../renderer/features/conversation/index.js'; + +type ProviderProps = Parameters>[0]; + +const unexpected = (name: string) => async (): Promise => { + throw new Error(`${name} is not stubbed`); +}; + +/** Host operations a suite has not stubbed fail loudly instead of resolving. */ +export function stubSubmissionServices(overrides: Partial = {}): ComposerSubmissionServices { + return { + submitMessage: unexpected('submitMessage'), + createNewTask: unexpected('createNewTask'), + removeUnsentSession: unexpected('removeUnsentSession'), + reviseBeforeTurn: unexpected('reviseBeforeTurn'), + abandonSessionCopy: unexpected('abandonSessionCopy'), + respondToSandboxBoundary: unexpected('respondToSandboxBoundary'), + respondToUserQuestion: unexpected('respondToUserQuestion'), + ...overrides, + }; +} + +/** A shell whose navigation and other-feature commands are inert unless a suite overrides them. */ +export function stubSubmissionShell( + overrides: Partial = {}, +): ProviderProps['shell'] { + return { + captureOwner: () => ({ sessionId: undefined }), + isOwnerActive: () => true, + isNewChatOwnerActive: () => true, + activateFirstSendSession: async () => {}, + openSession: () => {}, + retireSession: () => {}, + refreshSessions: async () => [], + reloadExecutionBoundary: () => {}, + respondToUserForm: async () => {}, + showModelSetupToast: () => {}, + bindNewTaskSessionResolver: () => () => {}, + openSideChat: () => {}, + orchestrationMode: () => 'default', + setOrchestrationModeActive: async () => true, + ...overrides, + }; +} + +export function stubNewTaskSubmission( + overrides: Partial = {}, +): ProviderProps['newTask'] { + return { + target: { profileId: 'local', hostId: 'host-local', projectId: null }, + model: null, + thinkingLevel: null, + permissionChoice: undefined, + collaborationMode: 'agent', + orchestrationMode: 'default', + clearPermissionChoice: () => {}, + ...overrides, + }; +} + +/** Mounts the Composer submission owner the way AppShell does, over inert ports. */ +export function withComposerSubmission(children: ReactNode, options: { + services?: ComposerSubmissionServices; + props?: Partial; +} = {}) { + return createElement(ComposerSubmissionServicesProvider, { + services: options.services ?? stubSubmissionServices(), + children: createElement(ComposerSubmissionProvider<{ sessionId: string | undefined }>, { + commands: createComposerSubmissionCommands(), + staging: createComposerStagingCommands(), + shell: stubSubmissionShell(), + newTask: stubNewTaskSubmission(), + sharedSessionActive: false, + ...options.props, + children, + }), + }); +} diff --git a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts new file mode 100644 index 0000000000..56eea4ac6c --- /dev/null +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -0,0 +1,393 @@ +/* + * 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 assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { afterEach, describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { act, createElement, Fragment } from 'react'; +import type { StoredMessage } from '@maka/core/session'; +import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; +import { + createSessionCatalogController, + SessionCatalogContext, +} from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { getDesktopConversationCopy } from '../../renderer/application/contracts/conversation-copy.js'; +import * as Conversation from '../../renderer/features/conversation/index.js'; +import { + ComposerSubmissionProvider, + ComposerSubmissionServicesProvider, + ConversationComposerRegion, + ConversationLifecycle, + ConversationProvider, + ConversationServicesProvider, + createComposerStagingCommands, + createComposerSubmissionCommands, + useAppShellSessionUiState, + type ComposerSubmissionServices, +} from '../../renderer/features/conversation/index.js'; +import { stubConversationServices } from '../../renderer/features/conversation/testing.js'; +import { + createDesktopComposerSubmissionServices, + type DesktopComposerSubmissionBridge, +} from '../../renderer/platform/desktop/create-composer-submission-services.js'; +import { + stubNewTaskSubmission, + stubSubmissionServices, + stubSubmissionShell, +} from './composer-submission-fixture.js'; +import { ComposerStagingFixture } from './composer-staging-fixture.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +type Owner = { sessionId: string | undefined }; +type ProviderProps = Parameters>[0]; + +interface RegionProps { + onSend(text: string): Promise; + newTaskSendPending: boolean; + revisionNotice?: { title: string; detail: string; cancelLabel: string; onCancel(): void }; + contextPickEnabled: boolean; + directoryPickerEnabled: boolean; + respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; + respondToUserQuestion(response: UserQuestionResponse): Promise; + respondToUserForm(response: { requestId: string }): Promise; +} + +const row = (id: string): DesktopSessionSummary => ({ + id, name: id, isFlagged: false, isArchived: false, labels: [], hasUnread: false, + status: 'active', backend: 'ai-sdk', revision: 1, runtimeHostId: 'local', + profileId: 'local', profileName: 'Local', profileKind: 'local', + llmConnectionSlug: 'test', connectionLocked: false, model: 'test', permissionMode: 'ask', +}); +const userTurn = (turnId: string, text: string): StoredMessage => ({ type: 'user', id: `message-${turnId}`, text, turnId, ts: 1 }); + +function deferred() { + let resolvePromise!: (value: T) => void; + const promise = new Promise((resolveValue) => { resolvePromise = resolveValue; }); + return { promise, resolve: resolvePromise }; +} + +/** The real Conversation, staging and submission owners, as AppShell mounts them. */ +function harness(options: { + services?: Partial; + shell?: Partial; + newTask?: Partial; + sharedSessionActive?: boolean; +} = {}) { + const { root } = installReactRenderer(); + const catalog = createSessionCatalogController(); + catalog.commitSessions(['A', 'B'].map(row)); + const published: Array<(messages: StoredMessage[]) => void> = []; + const conversationServices = stubConversationServices(); + conversationServices.observation.openTranscript = (sessionId) => { + let messages: StoredMessage[] = []; + let ready = false; + const listeners = new Set<() => void>(); + published.push((next) => { messages = next; ready = true; for (const listener of listeners) listener(); }); + return { + store: { + range: () => ({ sessionId, hasOlder: false, ready, generation: 'range' }), + snapshot: () => { + if (!ready) throw new Error('Desktop transcript range is not initialized'); + return { sessionId, messages, ready }; + }, + subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, + hasDurableMessage: (id) => messages.some((message) => message.id === id), + }, + ready: async () => {}, waitForDurableMessage: async () => true, + reload: async () => {}, loadEarlier: async () => {}, observationChanged: () => {}, + close: async () => {}, + }; + }; + conversationServices.observation.subscribeEvents = (_sessionId, _event, phase) => { + phase('ready'); + return () => {}; + }; + const commands = createComposerSubmissionCommands(); + const staging = createComposerStagingCommands(); + const services = stubSubmissionServices(options.services); + let target!: ReturnType; + let region: RegionProps | undefined; + function Composer(props: RegionProps) { region = props; return null; } + function Shell() { + target = useAppShellSessionUiState(); + return createElement(Fragment, null, + createElement(ConversationLifecycle, { + refreshSessions: async () => [], onExecutionBoundaryChanged() {}, + showModelSetupToast() {}, onTurnCompleted() {}, + searchTarget: null, clearSearchTarget() {}, listTurnLandmarks: async () => ({ landmarks: [] }), + }), + createElement(ConversationComposerRegion, { + surface: Composer, contextPickEnabled: true, directoryPickerEnabled: true, + }), + ); + } + act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(AstryxLocaleProvider, { children: createElement(ToastProvider, { children: + createElement(SessionCatalogContext.Provider, { value: catalog, children: + createElement(ConversationServicesProvider, { services: conversationServices, children: + createElement(ConversationProvider, { children: + createElement(ComposerStagingFixture, { draftKey: 'staging', commands: staging, children: + createElement(ComposerSubmissionServicesProvider, { services, children: + createElement(ComposerSubmissionProvider, { + commands, + staging, + shell: stubSubmissionShell(options.shell), + newTask: stubNewTaskSubmission(options.newTask), + sharedSessionActive: options.sharedSessionActive ?? false, + children: createElement(Shell), + }), + }), + }), + }), + }), + }), + }) }), + }))); + return { + root, commands, published, + get target() { return target; }, + get region() { assert.ok(region, 'the Composer slot rendered'); return region; }, + notice: () => region?.revisionNotice, + }; +} + +describe('ComposerSubmissionProvider', () => { + test('holds send-pending for the whole send and submits into the active Session through its port', async () => { + const submitted: unknown[] = []; + const admission = deferred>>(); + const h = harness({ + services: { + submitMessage: (sessionId, placement, command) => { + submitted.push({ sessionId, placement, text: command.text }); + return admission.promise; + }, + }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'earlier')])); + assert.equal(h.region.newTaskSendPending, false); + + let sending!: Promise; + await act(async () => { sending = h.region.onSend('hello'); }); + assert.equal(h.region.newTaskSendPending, true, 'the flag is the owner\'s, read by the Composer slot'); + assert.deepEqual(submitted, [{ sessionId: 'A', placement: 'next_turn', text: 'hello' }]); + + await act(async () => { + admission.resolve({ + ok: true, disposition: 'turn_started', turnId: 'turn-2', attachments: [], inlineReferences: [], + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }); + assert.equal(await sending, true); + }); + assert.equal(h.region.newTaskSendPending, false); + }); + + test('creates a new task with the new-task settings it was given and keeps an unconsumed choice', async () => { + const created: unknown[] = []; + let cleared = 0; + const h = harness({ + services: { + createNewTask: async (taskTarget, input) => { + created.push({ taskTarget, input }); + throw new Error('Host unavailable'); + }, + }, + newTask: { + permissionChoice: 'ask', + collaborationMode: 'plan', + orchestrationMode: 'swarm', + clearPermissionChoice: () => { cleared += 1; }, + }, + }); + let sent: boolean | void = true; + await act(async () => { sent = await h.region.onSend('start a task'); }); + assert.equal(sent, false); + assert.equal(created.length, 1); + const [{ taskTarget, input }] = created as Array<{ taskTarget: unknown; input: Record }>; + assert.deepEqual(taskTarget, { profileId: 'local', hostId: 'host-local', projectId: null }); + assert.equal(input.permissionMode, 'ask'); + assert.equal(input.collaborationMode, 'plan'); + assert.equal(input.orchestrationMode, 'swarm'); + assert.equal(cleared, 0, 'a send that created nothing leaves the choice for the retry'); + assert.equal(h.region.newTaskSendPending, false); + }); + + test('a shared Session cannot submit', async () => { + const calls: string[] = []; + const h = harness({ + sharedSessionActive: true, + services: { + submitMessage: async () => { calls.push('submitMessage'); throw new Error('must not submit'); }, + createNewTask: async () => { calls.push('createNewTask'); throw new Error('must not create'); }, + }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'theirs')])); + let sent: boolean | void = true; + await act(async () => { sent = await h.region.onSend('not mine'); }); + assert.equal(sent, false); + assert.deepEqual(calls, []); + }); + + test('owns the edit-and-resend draft: the shell starts it, the Composer slot shows and cancels it', async () => { + const copy = getDesktopConversationCopy('en').actions; + const h = harness(); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'original prompt')])); + assert.equal(h.notice(), undefined); + + await act(async () => h.commands.beginEditUserMessage('turn-1')); + assert.equal(h.notice()?.title, copy.revisionBannerTitle); + assert.equal(h.region.contextPickEnabled, false, 'the edited draft cannot stage new context'); + assert.equal(h.region.directoryPickerEnabled, false); + + // The Composer follows the published Session, which switches once B's transcript arrives. + await act(async () => h.target.setActiveId('B')); + await act(async () => h.published.at(-1)!([userTurn('turn-b', 'other')])); + assert.equal(h.notice(), undefined, 'the notice belongs to the draft\'s Session'); + assert.equal(h.region.contextPickEnabled, true); + assert.equal(h.region.directoryPickerEnabled, false, 'any open draft still blocks directory staging'); + + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published.at(-1)!([userTurn('turn-1', 'original prompt')])); + const notice = h.notice(); + assert.ok(notice); + await act(async () => notice.onCancel()); + assert.equal(h.notice(), undefined); + assert.equal(h.region.contextPickEnabled, true); + assert.equal(h.region.directoryPickerEnabled, true); + }); + + test('answers interactions for the active Session through the port and the shell\'s form command', async () => { + const calls: unknown[] = []; + const h = harness({ + services: { + respondToUserQuestion: async (sessionId, response) => { calls.push(['question', sessionId, response.requestId]); }, + respondToSandboxBoundary: async (sessionId, response) => { calls.push(['boundary', sessionId, response.requestId]); }, + }, + shell: { + reloadExecutionBoundary: (sessionId) => { calls.push(['reload-boundary', sessionId]); }, + respondToUserForm: async (sessionId, response) => { calls.push(['form', sessionId, response.requestId]); }, + }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.region.respondToUserQuestion({ requestId: 'q-1' } as UserQuestionResponse)); + await act(async () => h.region.respondToSandboxBoundary({ requestId: 'b-1' } as SandboxBoundaryResponse)); + await act(async () => h.region.respondToUserForm({ requestId: 'f-1' })); + assert.deepEqual(calls, [ + ['question', 'A', 'q-1'], + ['boundary', 'A', 'b-1'], + ['reload-boundary', 'A'], + ['form', 'A', 'f-1'], + ]); + }); + + test('the shell\'s command handle works only while the owner is mounted', async () => { + const unmounted = createComposerSubmissionCommands(); + assert.throws(() => unmounted.beginEditUserMessage('turn-1'), /ComposerSubmissionProvider is not mounted/); + const h = harness(); + await act(async () => h.root.unmount()); + assert.throws(() => h.commands.beginEditUserMessage('turn-1'), /ComposerSubmissionProvider is not mounted/); + }); +}); + +describe('Desktop Composer submission adapter', () => { + test('maps each named operation onto its bridge call', async () => { + const calls: unknown[] = []; + const record = (name: string) => async (...args: unknown[]) => { calls.push([name, ...args]); return {} as never; }; + const bridge = { + sessions: { + submitMessage: record('sessions.submitMessage'), + remove: record('sessions.remove'), + reviseBeforeTurn: record('sessions.reviseBeforeTurn'), + abandonSessionCopy: record('sessions.abandonSessionCopy'), + respondToSandboxBoundary: record('sessions.respondToSandboxBoundary'), + respondToUserQuestion: record('sessions.respondToUserQuestion'), + }, + newTasks: { create: record('newTasks.create') }, + } as unknown as DesktopComposerSubmissionBridge; + const services = createDesktopComposerSubmissionServices(bridge); + const target = { profileId: 'local', hostId: 'host', projectId: null }; + await services.submitMessage('s', 'next_turn', { messageId: 'm', text: 't' }, { waitForHostAdmission: true }); + await services.createNewTask(target, { name: 'New' }); + await services.removeUnsentSession('s'); + await services.reviseBeforeTurn('s', { sourceTurnId: 'turn', copyId: 'copy' }); + await services.abandonSessionCopy('s', 'copy'); + await services.respondToSandboxBoundary('s', { requestId: 'b' } as SandboxBoundaryResponse); + await services.respondToUserQuestion('s', { requestId: 'q' } as UserQuestionResponse); + assert.deepEqual(calls, [ + ['sessions.submitMessage', 's', 'next_turn', { messageId: 'm', text: 't' }, { waitForHostAdmission: true }], + ['newTasks.create', target, { name: 'New' }], + ['sessions.remove', 's'], + ['sessions.reviseBeforeTurn', 's', { sourceTurnId: 'turn', copyId: 'copy' }], + ['sessions.abandonSessionCopy', 's', 'copy'], + ['sessions.respondToSandboxBoundary', 's', { requestId: 'b' }], + ['sessions.respondToUserQuestion', 's', { requestId: 'q' }], + ]); + }); +}); + +describe('Composer submission ownership', () => { + const rendererRoot = resolve(fileURLToPath(new URL('../../../src/renderer/', import.meta.url))); + + function productionSources(root: string): string[] { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return entry.name === '__tests__' || entry.name === 'stories' ? [] : productionSources(path); + return /\.tsx?$/.test(entry.name) && entry.name !== 'testing.ts' ? [path] : []; + }); + } + + function sourcesMatching(pattern: RegExp): string[] { + return productionSources(rendererRoot) + .filter((path) => pattern.test(readFileSync(path, 'utf8'))) + .map((path) => relative(rendererRoot, path).replace(/\\/g, '/')) + .sort(); + } + + test('mounts one owner and reaches the Host through one adapter', () => { + assert.deepEqual(sourcesMatching(/<(?:Conversation\.)?ComposerSubmissionProvider\b/), ['app-shell.tsx']); + assert.deepEqual(sourcesMatching(/create-composer-submission-services/), ['composition/desktop-feature-services.tsx']); + // Workbar and WorkHub keep their own adapters for their own Composers. + const hostCalls = sourcesMatching( + /\bsessions\s*\.\s*(?:submitMessage|reviseBeforeTurn|abandonSessionCopy|respondToSandboxBoundary|respondToUserQuestion)\b|\bnewTasks\s*\.\s*create\b/, + ); + assert.ok(hostCalls.includes('platform/desktop/create-composer-submission-services.ts')); + assert.deepEqual(hostCalls.filter((path) => !path.startsWith('platform/desktop/')), []); + assert.deepEqual(sourcesMatching(/\bcreateRevisionAwareOnSend\b/), [ + 'features/conversation/controller/composer-submit.ts', + 'features/conversation/controller/use-composer-submission.ts', + ]); + }); + + test('AppShell holds no submission state and the public entry no submit construction', () => { + const shell = readFileSync(join(rendererRoot, 'app-shell.tsx'), 'utf8'); + assert.doesNotMatch(shell, /revisionDraft|newTaskSendPending|createRevisionAwareOnSend|createStagedFollowUp|ChatActions\b|RevisionActions\b/); + for (const name of ['createRevisionAwareOnSend', 'createStagedFollowUp', 'useComposerSubmission', 'createChatActions', 'createRevisionActions']) { + assert.equal(name in Conversation, false, `${name} is not a public Conversation capability`); + } + }); +}); diff --git a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts index a4af12465b..8d1109ce7c 100644 --- a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts @@ -33,7 +33,7 @@ import { useAppShellSessionUiState, type ConversationObservationServices, } from '../../renderer/features/conversation/index.js'; -import { createContextCompactionCommands, stubConversationServices } from '../../renderer/features/conversation/testing.js'; +import { createContextCompactionCommands, stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; type CompactionToast = Pick; @@ -160,8 +160,10 @@ describe('Conversation owner context compaction', () => { return () => {}; }; let target!: ReturnType; + let commands!: ReturnType['commands']; function Shell() { target = useAppShellSessionUiState(); + commands = useConversationOwner().commands; return createElement(Fragment, null, createElement(ConversationLifecycle, { refreshSessions: async () => [], onExecutionBoundaryChanged() {}, showModelSetupToast() {}, onTurnCompleted() {}, @@ -181,7 +183,7 @@ describe('Conversation owner context compaction', () => { const text = () => container.textContent ?? ''; let accepted: boolean | undefined; - await act(async () => { accepted = await target.compactSession('A'); }); + await act(async () => { accepted = await commands.compactSession('A'); }); assert.equal(accepted, true); assert.deepEqual(requested, ['A']); assert.match(text(), /Compacting context/); @@ -199,7 +201,7 @@ describe('Conversation owner context compaction', () => { contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, })); reply = { kind: 'finished', turn: { ...turn, turnId: 'compact-2' }, outcome: { kind: 'failed', reason: 'write_failed' } }; - await act(async () => { await target.compactSession('A'); }); + await act(async () => { await commands.compactSession('A'); }); assert.match(text(), /Nothing to compact/); assert.doesNotMatch(text(), /Compaction failed/); }); diff --git a/apps/desktop/src/main/__tests__/conversation-owner.test.ts b/apps/desktop/src/main/__tests__/conversation-owner.test.ts index 400442c59e..2180de3e3f 100644 --- a/apps/desktop/src/main/__tests__/conversation-owner.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -28,6 +28,7 @@ import { createSessionCatalogController, SessionCatalogContext } from '../../ren import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, ConversationComposerRegion, useAppShellSessionUiState, type ConversationObservationServices } from '../../renderer/features/conversation/index.js'; import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { withComposerSubmission } from './composer-submission-fixture.js'; const row = (id: string): DesktopSessionSummary => ({ id, name: id, isFlagged: false, isArchived: false, labels: [], hasUnread: false, @@ -118,7 +119,7 @@ function harness(options: { createElement(ToastProvider, { children: createElement(SessionCatalogContext.Provider, { value: catalog, children: createElement(ConversationServicesProvider, { services, children: - createElement(ConversationProvider, { children: createElement(Shell) }), + createElement(ConversationProvider, { children: withComposerSubmission(createElement(Shell)) }), }), }), }), diff --git a/apps/desktop/src/main/__tests__/desktop-slash-command.test.ts b/apps/desktop/src/main/__tests__/desktop-slash-command.test.ts index 403464590f..8b66b63d65 100644 --- a/apps/desktop/src/main/__tests__/desktop-slash-command.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-slash-command.test.ts @@ -20,7 +20,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { slashCommandsForSurface } from '@maka/core/slash-command-catalog'; -import { desktopSlashCommandAvailability } from '../../renderer/desktop-slash-command.js'; +import { desktopSlashCommandAvailability } from '../../renderer/application/contracts/desktop-slash-command.js'; const offered = (state: { hasSession: boolean; streaming: boolean }): readonly string[] => slashCommandsForSurface('desktop') diff --git a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts index 96eeb36180..90ff94d15d 100644 --- a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts +++ b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts @@ -21,7 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { mergeWorkspaceReferences, -} from '../../renderer/follow-up-submit-routing.js'; +} from '../../renderer/features/conversation/testing.js'; describe('follow-up submit routing', () => { it('restores workspace references after queued text returns to the draft', () => { diff --git a/apps/desktop/src/main/__tests__/session-copy-attempt.test.ts b/apps/desktop/src/main/__tests__/session-copy-attempt.test.ts index 54de542ab9..7a97df2119 100644 --- a/apps/desktop/src/main/__tests__/session-copy-attempt.test.ts +++ b/apps/desktop/src/main/__tests__/session-copy-attempt.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import type * as SessionCopyAttemptModule from '../../renderer/session-copy-attempt.js'; +import type * as SessionCopyAttemptModule from '../../renderer/application/contracts/session-copy-attempt.js'; test('one logical Session copy preserves its target and source boundary across renderer reload', async () => { const storage = memoryStorage(); @@ -158,7 +158,7 @@ test('renderer reload can enumerate every orphaned Side Chat copy owner', async }); async function loadFreshModule(name: string): Promise { - const url = new URL('../../renderer/session-copy-attempt.js', import.meta.url); + const url = new URL('../../renderer/application/contracts/session-copy-attempt.js', import.meta.url); url.searchParams.set('instance', name); return import(url.href) as Promise; } diff --git a/apps/desktop/src/renderer/app-shell-turn-actions.ts b/apps/desktop/src/renderer/app-shell-turn-actions.ts index 6db7f7859d..afc2f62f67 100644 --- a/apps/desktop/src/renderer/app-shell-turn-actions.ts +++ b/apps/desktop/src/renderer/app-shell-turn-actions.ts @@ -27,7 +27,7 @@ import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast, } from './session-workspace-errors.js'; -import { acquireSessionCopyAttempt } from './session-copy-attempt.js'; +import { acquireSessionCopyAttempt } from './application/contracts/session-copy-attempt.js'; type ToastApi = { info(title: string, description?: string): void; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 83528e4436..caa30a0729 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -29,10 +29,7 @@ import { type Dispatch, type SetStateAction, } from 'react'; -import type { - InlineReference, - QuoteRef, -} from '@maka/core/events'; +import type { QuoteRef } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; import { collapseSessionRevisions } from '@maka/core/session-revisions'; @@ -77,10 +74,6 @@ import { type SessionNavigationPorts, type SessionNavigationRowActions, } from './features/session-navigation'; -import { - CatalogRowWatch, - catalogWatchedRowsUsable, -} from './application/contracts/session-catalog/catalog-row-watch.js'; import * as TaskEntry from './features/task-entry'; import type { TaskEntryShellProjection } from './features/task-entry'; import * as Overlays from './features/overlays/index.js'; @@ -88,8 +81,7 @@ import type { OverlaysShellProjection } from './features/overlays/index.js'; import * as SessionCollaboration from './features/session-collaboration'; import type { SessionCollaborationDialogProjection } from './features/session-collaboration'; import { NEW_TASK_PENDING_KEY } from './pending-items'; -import { desktopSlashCommandAvailability, parseDesktopSlashCommand } from './desktop-slash-command'; -import { mergeWorkspaceReferences, rebaseWorkspaceFileReferences } from './follow-up-submit-routing'; +import { desktopSlashCommandAvailability } from './application/contracts/desktop-slash-command.js'; import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; @@ -125,14 +117,7 @@ import { } from './platform/desktop/default-runtime-host-operation.js'; import { useAppShellProjectContext } from './use-project-context'; import { createAppShellE2eFixtureActions } from './app-shell-e2e-fixture'; -import { createAppShellChatActions } from './app-shell-chat-actions'; import { createAppShellTurnActions } from './app-shell-turn-actions'; -import { - abandonTurnRevisionCopyAttempt, - completeTurnRevisionCopyAttempt, - createAppShellRevisionActions, - type TurnRevisionDraft, -} from './app-shell-revision-actions'; import { createAppShellStopAction } from './app-shell-stop-action'; import { useStableActions } from './use-stable-actions'; import { @@ -255,7 +240,6 @@ function AppShellContent({ const { readMessages, refreshMessages, - prepareSend, transcriptEmpty, transcriptHasHistory, authoritativeSessionIds, @@ -267,21 +251,15 @@ function AppShellContent({ bootstrapSelectionLease, setActiveId, startNewSession, - readSelectionRevision, clearOwnedSessionState, captureSelection, isSessionSelected, retiredSessionIds, addTransientMessage, - updateTransientMessage, removeTransientMessage, sessionUiReads, stopPendingClaims, - markInteractionChanged, - settleInteraction, - clearMessageLoadError, recordSessionChange, - compactSession, sessionCatalogController, commitSession, activeCatalogSession, @@ -329,9 +307,7 @@ function AppShellContent({ ? taskEntry.selectors.target?.hostId : undefined); const composerStaging = useMemo(Conversation.createComposerStagingCommands, []); - - // Held for the whole of sendOwningItsTarget; see ChatComposerRegion. - const [newTaskSendPending, setNewTaskSendPending] = useState(false); + const composerSubmission = useMemo(Conversation.createComposerSubmissionCommands, []); const [petCompletionNonce, setPetCompletionNonce] = useState(0); const [navigationState, setNavigationState] = useState(() => readNavigationState()); const navSelection = navigationState.selection; @@ -494,32 +470,6 @@ function AppShellContent({ const openComposerModelPicker = useCallback(() => { composerRef.current?.openModelPicker(); }, []); - const retractedWorkspaceReferencesRef = useRef>({}); - const [revisionDraft, setRevisionDraft] = useState(null); - const revisionDraftRef = useRef(null); - const commitRevisionDraft = useCallback((draft: TurnRevisionDraft | null) => { - revisionDraftRef.current = draft; - setRevisionDraft(draft); - }, []); - // The draft survives on exactly two catalog rows; CatalogRowWatch below - // selects them so their changes alone can retire it. - const retireRevisionDraftIfRowsLeave = useCallback( - (rows: Parameters[0]) => { - const draft = revisionDraftRef.current; - if (!draft) return; - // A watched row that is merely pending — never observed, never reported - // removed — is admission lag, not a departure. - if (catalogWatchedRowsUsable(rows)) return; - composerRef.current?.clearDraft(draft.draftSessionId); - if (draft.sourceSessionId !== draft.draftSessionId) - composerRef.current?.clearDraft(draft.sourceSessionId); - if (draft.copyPhase === 'reserved') completeTurnRevisionCopyAttempt(draft); - else void abandonTurnRevisionCopyAttempt(draft); - commitRevisionDraft(null); - }, - [commitRevisionDraft], - ); - const { safeResumeAction, composerResumeAction, noteUserStoppedTurn } = useShellResume({ activeId, ownerActiveId, @@ -1052,53 +1002,6 @@ function AppShellContent({ setUiLocaleOverride, }); - const { - send, - enqueueMessage, - respondToSandboxBoundary, - respondToUserQuestion, - respondToUserForm, - } = useStableActions(createAppShellChatActions, { - uiLocale, - getRunningTurnId: (sessionId) => { - if (sessionId !== activeId) return undefined; - return Conversation.activeHostTurn(sessionUiReads.summary(sessionId).getSnapshot().activeExecution)?.turnId; - }, - activeIdRef, - captureComposerImportOwner, - captureSelection, - checkTaskSubmissionReadiness: taskSubmissionReadyAtSend, - isNewChatSendSurfaceActive, - isShellSurfaceOwnerActive, - refreshSessions, - activateSessionForFirstSend: async (session) => { - commitSession(session); - setNavSelection({ section: 'sessions' }); - setActiveId(session.id); - }, - retireSession: clearSessionRendererState, - clearMessageLoadError, - addTransientMessage, - updateTransientMessage, - removeTransientMessage, - onFollowLatest: prepareSend, - settleInteraction, - onInteractionChanged: markInteractionChanged, - onExecutionBoundaryChanged: reloadActiveExecutionBoundary, - respondToUserForm: commands.respondToUserForm, - showModelSetupToast, - toastApi, - newChatModel: newChatExecutionTarget ?? null, - pendingNewChatThinkingLevel: executorTarget ? newChatExecutionThinkingLevel ?? null : pendingNewChatThinkingLevel, - executorSelection: executor.selection, - executorEntry: executor.entry, - newChatPermissionChoice: newTaskSettings.permissionChoice, - clearNewChatPermissionChoice: sessionSettingIntent.commands.clearNewTaskPermissionChoice, - newChatCollaborationMode: newTaskSettings.planMode ? 'plan' : 'agent', - newChatOrchestrationMode: newTaskSettings.orchestrationMode, - newTaskTarget: taskEntry.selectors.target, - }); - const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, { uiLocale, activeIdRef, @@ -1108,78 +1011,6 @@ function AppShellContent({ refreshSessions, toastApi, }); - const { - beginEditUserMessage, - prepareRevisionSend, - cancelRevisionDraft, - } = useStableActions(createAppShellRevisionActions, { - uiLocale, - activeIdRef, - captureSelection, - composerRef, - readMessages, - hasPendingAttachments: () => composerStaging.captureSubmission().hasPendingContext, - openSessionInChat, - refreshSessions, - commitRevisionDraft, - revisionDraftRef, - toastApi, - }); - - async function taskSubmissionReadyAtSend(): Promise { - return !sharedSessionActive && (!!activeIdRef.current || !!taskEntry.selectors.target); - } - - function settleNewTaskImageNoticeOwner(sourceSessionId?: string) { - const createdSessionId = activeIdRef.current; - if (!sourceSessionId && createdSessionId) - composerStaging.transferImageNotice(NEW_TASK_PENDING_KEY, createdSessionId); - } - - const enqueueFollowUp = Conversation.createStagedFollowUp({ - captureStaging: composerStaging.captureSubmission, - enqueueMessage, - onError(sessionId, error) { - if (activeIdRef.current === sessionId) { - const copy = getDesktopConversationCopy(uiLocale).actions; - showSessionError(sessionId, copy.operationFailedTitle, - localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale)); - } - }, - }); - - // The composer's submit callback. Built by the shared factory (same one the - // regression test drives), so there is no local submit logic here that could - // bypass the covered path. - const sendOwningItsTarget = Conversation.createRevisionAwareOnSend( - { - shellCopy, - toastApi, - activeIdRef, - revisionDraftRef, - composerRef, - retractedWorkspaceReferencesRef, - captureStaging: composerStaging.captureSubmission, - prepareRevisionSend, - send, - completeRevisionCopyAttempt: completeTurnRevisionCopyAttempt, - parseSlashCommand: parseDesktopSlashCommand, - mergeWorkspaceReferences, - rebaseWorkspaceFileReferences, - revisionUnavailableCopy: desktopConversationCopy.actions, - compactSession, - enqueueFollowUp, - settleNewTaskImageNoticeOwner, - commitRevisionDraft, - resolveNewTaskSessionHandler: () => - commands.bindNewTaskSessionResolver(readSelectionRevision()), - openSideChat: (options) => commands.openTool('side-chat', 'right', options), - getActiveOrchestrationMode: () => activeOrchestrationMode, - setOrchestrationModeActive, - setNewTaskSendPending, - }, - ); - const stop = createAppShellStopAction({ uiLocale, activeIdRef, @@ -1373,10 +1204,8 @@ function AppShellContent({ const canStageComposerContext = activeId !== undefined || taskEntry.selectors.target !== undefined; // #4804: attachment-only sends are opt-in per host surface, and the Desktop - // host now admits them. The pickers share the same edit-mode condition. - const contextPickEnabled = - canStageComposerContext && - !(revisionDraft && activeId === revisionDraft.draftSessionId); + // host now admits them. An edit-and-resend draft narrows both pickers in the + // Composer slot, where the submission owner's draft is read. const homeSurfaceActive = sessionsSelected && @@ -1447,6 +1276,39 @@ function AppShellContent({ openWorkspacePicker={activeSession ? () => openSessionWorkspaceRecovery(activeSession.id) : taskEntry.selectors.canAddProject ? taskEntry.commands.addProject : undefined}> + { + commitSession(session); + setNavSelection({ section: 'sessions' }); + setActiveId(session.id); + }, + openSession: openSessionInChat, + retireSession: clearSessionRendererState, + refreshSessions, + reloadExecutionBoundary: reloadActiveExecutionBoundary, + respondToUserForm: commands.respondToUserForm, + showModelSetupToast, + bindNewTaskSessionResolver: commands.bindNewTaskSessionResolver, + openSideChat: (options) => commands.openTool('side-chat', 'right', options), + orchestrationMode: () => activeOrchestrationMode, + setOrchestrationModeActive, + }}> - { void cancelRevisionDraft(); }, - } - : undefined - } slashCommands={desktopSlashCommands} allowAttachmentOnlySend={canStageComposerContext} canStageContext={canStageComposerContext} - contextPickEnabled={contextPickEnabled} + contextPickEnabled={canStageComposerContext} {...Conversation.executorComposerProps(executor, {activeId, turnActive, taskSubmissionHardBlocked, connectionCount: connections.length, onSetup: () => openSettingsSection('external-agents'), onNewTask: openNewTaskSurface})} activeSession={activeSessionForView} {...{ executorTarget, onExecutorTargetChange }} @@ -1865,7 +1705,7 @@ function AppShellContent({ onOpenMemorySettings={sharedSessionActive ? undefined : () => openSettingsSection('memory')} deriveTurnPresentation={deriveTurnPresentation} onTurnFooterAction={sharedSessionActive ? undefined : handleTurnFooterAction} - onEditUserMessage={sharedSessionActive ? undefined : (turnId) => { void beginEditUserMessage(turnId); }} + onEditUserMessage={sharedSessionActive ? undefined : composerSubmission.beginEditUserMessage} safeResumeAction={safeResumeAction} onLineageBadgeClick={(turnId) => { if (activeId) openSessionInChat(activeId, turnId); }} onReadAttachmentBytes={window.maka.attachments.readBytes} @@ -1997,6 +1837,7 @@ function AppShellContent({ + ); diff --git a/apps/desktop/src/renderer/attachment-preflight.ts b/apps/desktop/src/renderer/application/contracts/attachment-preflight.ts similarity index 100% rename from apps/desktop/src/renderer/attachment-preflight.ts rename to apps/desktop/src/renderer/application/contracts/attachment-preflight.ts diff --git a/apps/desktop/src/renderer/desktop-slash-command.ts b/apps/desktop/src/renderer/application/contracts/desktop-slash-command.ts similarity index 100% rename from apps/desktop/src/renderer/desktop-slash-command.ts rename to apps/desktop/src/renderer/application/contracts/desktop-slash-command.ts diff --git a/apps/desktop/src/renderer/session-copy-attempt.ts b/apps/desktop/src/renderer/application/contracts/session-copy-attempt.ts similarity index 100% rename from apps/desktop/src/renderer/session-copy-attempt.ts rename to apps/desktop/src/renderer/application/contracts/session-copy-attempt.ts diff --git a/apps/desktop/src/renderer/side-chat-command.ts b/apps/desktop/src/renderer/application/contracts/side-chat-command.ts similarity index 100% rename from apps/desktop/src/renderer/side-chat-command.ts rename to apps/desktop/src/renderer/application/contracts/side-chat-command.ts diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 7421b57ca7..1a808c08b0 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -20,9 +20,16 @@ import type { ReactNode } from 'react'; import { WorkHubServicesProvider } from '../features/workhub'; import { createDesktopWorkHubServices } from '../platform/desktop/create-workhub-services'; -import { ConversationServicesProvider, PlanServicesProvider, ComposerStagingServicesProvider, TaskReadinessServicesProvider } from '../features/conversation'; +import { + ComposerStagingServicesProvider, + ComposerSubmissionServicesProvider, + ConversationServicesProvider, + PlanServicesProvider, + TaskReadinessServicesProvider, +} from '../features/conversation'; import { createDesktopComposerStagingServices } from '../platform/desktop/create-composer-staging-services.js'; import { createDesktopTaskReadinessServices } from '../platform/desktop/create-task-readiness-services.js'; +import { createDesktopComposerSubmissionServices } from '../platform/desktop/create-composer-submission-services.js'; import { createDesktopConversationPlanServices } from '../platform/desktop/create-conversation-plan-services.js'; import { createDesktopConversationServices } from '../platform/desktop/create-conversation-services'; import { AppUpdateServicesProvider } from '../features/app-update/index.js'; @@ -86,6 +93,7 @@ export function createDesktopFeatureServices() { conversationPlan: createDesktopConversationPlanServices(), composerStaging: createDesktopComposerStagingServices(), taskReadiness: createDesktopTaskReadinessServices(), + composerSubmission: createDesktopComposerSubmissionServices(), connectionSettings: createDesktopConnectionSettingsServices(), externalAgentSettings: createDesktopExternalAgentSettingsServices(), goal: createDesktopGoalServices(), @@ -126,6 +134,7 @@ export function DesktopFeatureServicesProvider(props: { + @@ -135,6 +144,7 @@ export function DesktopFeatureServicesProvider(props: { + diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index a089572eca..cc12ac3117 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -47,7 +47,8 @@ published Session reference is a frozen getter, and consuming contracts declare it readonly. It has no map setters, range controller, publication callback, writable refs, or whole-state getter. `readMessages()` is an invocation-time, readonly view of the **published range**; -it is used by Copy/Save and revision commands and is not a full-history promise. +it is used by Copy/Save and is not a full-history promise. Revision commands read +the same range from the controller inside the Composer submission owner. The Desktop adapter supplies `ConversationObservationServices`. The feature never imports the Desktop range implementation or accesses `window.maka`. @@ -124,10 +125,9 @@ Remaining transitional capabilities have explicit consumers and removal work: | Capability | Current consumer | Removal module | | --- | --- | --- | -| Stop pending claim and semantic send/transient/interaction commands | AppShell chat actions and composer submission | M3 persistent Composer owner | +| Stop pending claim and transient add/remove | AppShell Stop action and `SessionLocalMessages` props | M3 persistent Composer owner | | `useAppShellSessionUiReads` | AppShell chrome and Composer prop assembly | M3 regional readers; retain only required chrome | -| Invocation-time published-message read | Copy/Save and revision commands | M3 command ownership / bounded-history export integration | -| `compactSession` command | AppShell composer submission (`/compact` port) | M3 submission ownership | +| Invocation-time published-message read | Copy/Save | M3 command ownership / bounded-history export integration | M2 owns presentation and observation; it does not add a Catalog, Host cache or execution state machine, or complete the remaining Composer migration. @@ -201,10 +201,36 @@ directory pickers still require the original draft and Host to remain current. Staging uses `activeId ?? NEW_TASK_PENDING_KEY`; the editor's new-task persistence key remains distinct. Do not key this provider or the Composer's parent by Session. -Revision draft state, send-pending state, delivery recovery and the remaining -send orchestration are later M3 work. They can use captured submission commands +Delivery recovery is later M3 work. It can use captured submission commands without restoring root subscriptions or acquiring the private controller. +## Composer submission ownership (R2 M3) + +`ComposerSubmissionProvider` alone calls the submission controller. It is +mounted beside the staging and readiness owners and stays mounted across Session +and section switches. It owns the send-pending flag, the edit-and-resend draft +(with the catalog watch that retires it), the retracted workspace references and +the submit, follow-up and interaction-answer paths. `createRevisionAwareOnSend`, +the staged follow-up and the chat and revision actions are assembled here, not +in AppShell, and none of them is exported from `index.ts`. + +The Host operations reach the owner as `ComposerSubmissionServices`, one named +operation each; the Desktop adapter is the only caller of those bridge paths. +The shell supplies a `shell` port of commands it already owns (surface +ownership, navigation, catalog refresh, execution-boundary reload, the Workbar's +form answer, side chat and new-task resolver, the model-setup toast, and the +selected Session's orchestration write) and a `newTask` projection read at send +time. Session Settings owns the new-task Plan, orchestration and permission +choices; the projection carries them, and creation consumes the permission +choice through `clearPermissionChoice`. + +`ConversationComposerRegion` reads the owner in the persistent Composer slot: it +injects `onSend`, `newTaskSendPending`, the interaction answers and the revision +notice, and narrows the shell's picker gates while a draft is open. The shell +keeps only the stable `ComposerSubmissionCommands` handle, whose +`beginEditUserMessage` starts a draft from the transcript. The handle throws +while the owner is unmounted. The binding and reader context are private. + ## Task readiness ownership (R2 M3) `TaskReadinessProvider` alone calls the readiness controller. It is mounted diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/features/conversation/controller/chat-actions.ts similarity index 90% rename from apps/desktop/src/renderer/app-shell-chat-actions.ts rename to apps/desktop/src/renderer/features/conversation/controller/chat-actions.ts index 6947bae3fd..9ec8021efa 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/chat-actions.ts @@ -19,8 +19,8 @@ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { CollaborationMode } from '@maka/core/collaboration'; -import type * as DesktopBridge from '../preload/bridge-contract.js'; import type { QuoteRef } from '@maka/core/events'; +import type { InteractionFormResponse } from '@maka/core/interaction'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; @@ -29,19 +29,21 @@ import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import type { UiLocale } from '@maka/core/ui-locale'; import type { UserQuestionResponse } from '@maka/core/user-question'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; -import { - type NavSelection, - type TransientUserMessageProjection, -} from '@maka/ui'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; -import { preflightAttachmentItems } from './attachment-preflight.js'; +import type { TransientUserMessageProjection } from '@maka/ui'; +import { toSubmittedAttachments, type PendingAttachment } from '@maka/ui/composer-attachments'; +import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +import { preflightAttachmentItems } from '../../../application/contracts/attachment-preflight.js'; +import type { DesktopSessionSummary } from '../../../../shared/desktop-session-projection.js'; import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast, -} from './session-workspace-errors.js'; -import * as skillFeedback from './skill-invocation-feedback.js'; -import * as Conversation from './features/conversation/index.js'; -import type { NewChatExecutionTarget, PendingAttachment, ExecutorSubmission } from './features/conversation/index.js'; +} from '../model/session-workspace-toast.js'; +import * as skillFeedback from '../model/skill-invocation-feedback.js'; +import { canSubmitExecutor, newTaskConfiguration, type ExecutorSubmission } from '../model/executor-submission.js'; +import type { NewChatExecutionTarget } from './use-shell-chat-model.js'; +import type { ConversationNewTaskTarget } from '../ports.js'; +import type { ComposerSubmissionServices, ConversationMessageCommand } from '../submission-services.js'; +import type { ComposerSurfaceOwner } from '../model/composer-submission-contract.js'; export interface WorkspaceFileReferencePosition { value: string; @@ -51,24 +53,14 @@ import { isNoRealConnectionError, noRealConnectionReasonFromError, noRealConnectionSetupDescription, -} from './model-connection-errors.js'; +} from '../../../application/contracts/model-connection-errors.js'; -type ComposerImportOwner = { - sessionId: string | undefined; - navSection: NavSelection['section']; - newTaskDraftKey?: string; -}; type RefBox = { current: T }; type PendingNewChatModel = NewChatExecutionTarget | null; type PendingNewChatThinkingLevel = ThinkingLevel | null | undefined; -type DesktopNewTaskTarget = DesktopBridge.DesktopNewTaskTarget; -type DesktopSessionSummary = DesktopBridge.DesktopSessionSummary; -type InteractionFormResponse = Parameters< - DesktopBridge.MakaBridge['sessions']['respondToUserForm'] ->[1]; type ToastApi = { error( @@ -101,7 +93,7 @@ function copiedArray( return values?.length ? { [key]: [...values] } as Record : {}; } -export interface AppShellChatActions { +export interface ChatActions { send( text: string, pending?: readonly PendingAttachment[], @@ -124,19 +116,20 @@ export interface AppShellChatActions { respondToUserForm(response: InteractionFormResponse): Promise; } -export function createAppShellChatActions(deps: { +export function createChatActions(deps: { + services: ComposerSubmissionServices; uiLocale: UiLocale; getRunningTurnId?: (sessionId: string) => string | undefined; activeIdRef: Readonly>; - captureComposerImportOwner: () => ComposerImportOwner; + captureComposerImportOwner: () => Owner; captureSelection: () => () => boolean; checkTaskSubmissionReadiness: () => Promise; - isNewChatSendSurfaceActive: (owner: ComposerImportOwner) => boolean; + isNewChatSendSurfaceActive: (owner: Owner) => boolean; /** The shell's one answer to "is this owner still the surface the user is * looking at". Both halves matter — the section AND the session id — which * is why the send path asks it instead of comparing the id itself. */ - isShellSurfaceOwnerActive: (owner: ComposerImportOwner) => boolean; - refreshSessions: () => Promise; + isShellSurfaceOwnerActive: (owner: Owner) => boolean; + refreshSessions: () => Promise; activateSessionForFirstSend: (session: DesktopSessionSummary) => Promise; retireSession: (sessionId: string) => void; clearMessageLoadError(sessionId: string): void; @@ -156,7 +149,7 @@ export function createAppShellChatActions(deps: { onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ onExecutionBoundaryChanged?: (sessionId: string) => void; - respondToUserForm: DesktopBridge.MakaBridge['sessions']['respondToUserForm']; + respondToUserForm: (sessionId: string, response: InteractionFormResponse) => Promise; showModelSetupToast: ( description: string, reason?: string, @@ -182,9 +175,10 @@ export function createAppShellChatActions(deps: { clearNewChatPermissionChoice: () => void; newChatCollaborationMode: CollaborationMode; newChatOrchestrationMode: OrchestrationMode; - newTaskTarget: DesktopNewTaskTarget | undefined; -}): AppShellChatActions { + newTaskTarget: ConversationNewTaskTarget | undefined; +}): ChatActions { const { + services, uiLocale, activeIdRef, captureComposerImportOwner, @@ -225,10 +219,7 @@ export function createAppShellChatActions(deps: { sessionId: string; messageId: string; placement: 'current_turn' | 'next_turn'; - command: Omit< - Parameters[2], - 'messageId' - >; + command: Omit; displayText?: string; quotes?: readonly QuoteRef[]; waitForHostAdmission?: boolean; @@ -237,7 +228,7 @@ export function createAppShellChatActions(deps: { }): Promise { const { sessionId, messageId, placement } = input; const directoryReferences = input.command.directoryReferences; - const result = await window.maka.sessions.submitMessage(sessionId, placement, { + const result = await services.submitMessage(sessionId, placement, { ...input.command, messageId, }, { waitForHostAdmission: input.waitForHostAdmission }); @@ -283,7 +274,7 @@ export function createAppShellChatActions(deps: { ): Promise { const { directoryReferences, quotes } = options; const initialSessionId = options.targetSessionId ?? activeIdRef.current; - if (!Conversation.canSubmitExecutor(deps, pending?.length ?? 0)) return false; + if (!canSubmitExecutor(deps, pending?.length ?? 0)) return false; const sendOwner = captureComposerImportOwner(); const selectionIsCurrent = captureSelection(); if (!initialSessionId && !newTaskTarget) return false; @@ -310,7 +301,7 @@ export function createAppShellChatActions(deps: { const sessionId = unsentSessionId; unsentSessionId = undefined; try { - await window.maka.sessions.remove(sessionId); + await services.removeUnsentSession(sessionId); retireSession(sessionId); await refreshSessions(); } catch { @@ -319,7 +310,7 @@ export function createAppShellChatActions(deps: { }; try { async function submitIntoSession(sessionId: string, messageId: string) { - const attachments = Conversation.toSubmittedAttachments(pending ?? []); + const attachments = toSubmittedAttachments(pending ?? []); const sendCommand = { text, localDisplayPlacement: 'current_turn' as const, @@ -347,9 +338,9 @@ export function createAppShellChatActions(deps: { if (!initialSessionId) { if (!newTaskTarget) return false; if (pending?.length) preflightAttachmentItems(pending); - const session = await window.maka.newTasks.create(newTaskTarget, { + const session = await services.createNewTask(newTaskTarget, { name: DEFAULT_SESSION_NAME, - ...Conversation.newTaskConfiguration(deps), + ...newTaskConfiguration(deps), }); unsentSessionId = session.id; // Creation can also yield while a same-target New Task is reopened. @@ -469,7 +460,7 @@ export function createAppShellChatActions(deps: { const steeringTurnId = placement === 'current_turn' ? deps.getRunningTurnId?.(sessionId) : undefined; const directoryReferences = options.directoryReferences; const quotes = options.quotes ?? []; - const { attachmentItems, retainedAttachments = [] } = Conversation.toSubmittedAttachments(pending ?? []); + const { attachmentItems, retainedAttachments = [] } = toSubmittedAttachments(pending ?? []); publishTransientUserMessage(sessionId, { id: messageId, text, attachments: retainedAttachments, ...(steeringTurnId ? { hostTurnId: steeringTurnId } : {}), @@ -546,11 +537,11 @@ export function createAppShellChatActions(deps: { respondToSandboxBoundary: (response) => respondToInteraction( response, - window.maka.sessions.respondToSandboxBoundary, + services.respondToSandboxBoundary, onExecutionBoundaryChanged, ), respondToUserQuestion: (response) => - respondToInteraction(response, window.maka.sessions.respondToUserQuestion), + respondToInteraction(response, services.respondToUserQuestion), respondToUserForm: (response) => respondToInteraction(response, submitUserForm), }; } diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts new file mode 100644 index 0000000000..b07965f80d --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts @@ -0,0 +1,34 @@ +/* + * 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 { ComposerSubmissionCommands } from '../model/composer-submission-contract.js'; +import { submissionBindings } from '../model/composer-submission-binding.js'; + +export function createComposerSubmissionCommands(): ComposerSubmissionCommands { + const binding: { current?: ComposerSubmissionCommands } = {}; + const requireOwner = () => { + if (!binding.current) throw new Error('ComposerSubmissionProvider is not mounted'); + return binding.current; + }; + const commands: ComposerSubmissionCommands = { + beginEditUserMessage: (turnId) => requireOwner().beginEditUserMessage(turnId), + }; + submissionBindings.set(commands, binding); + return commands; +} diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/features/conversation/controller/revision-actions.ts similarity index 93% rename from apps/desktop/src/renderer/app-shell-revision-actions.ts rename to apps/desktop/src/renderer/features/conversation/controller/revision-actions.ts index f90b0fd37b..cb0a440829 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/revision-actions.ts @@ -19,15 +19,15 @@ import type { StoredMessage } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; -import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; import { userFacingText } from '@maka/core/session'; import type { ComposerHandle } from '@maka/ui'; -import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; -import { localizedShellErrorMessage } from './locales/shell-copy.js'; +import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; +import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast, -} from './session-workspace-errors.js'; +} from '../model/session-workspace-toast.js'; +import type { ComposerSubmissionServices } from '../submission-services.js'; import { acquireSessionCopyAttempt, abandonSessionCopyAttempt, @@ -35,7 +35,7 @@ import { startSessionCopyAttempt, type SessionCopyAttemptPhase, type SessionCopyAttemptKey, -} from './session-copy-attempt.js'; +} from '../../../application/contracts/session-copy-attempt.js'; type ReadonlyRef = { readonly current: T }; @@ -49,7 +49,7 @@ type ToastApi = { ): void; }; -/** Active edit-and-resend draft owned by the desktop shell. */ +/** Active edit-and-resend draft, owned by the Composer submission owner. */ export type TurnRevisionDraft = { sourceSessionId: string; sourceTurnId: string; @@ -62,7 +62,7 @@ export type TurnRevisionDraft = { previousComposerText: string; }; -export interface AppShellRevisionActions { +export interface RevisionActions { beginEditUserMessage(turnId: string): void; /** Lazily create the before-turn branch immediately before normal send. */ prepareRevisionSend(text: string): Promise; @@ -83,7 +83,8 @@ export interface AppShellRevisionActions { * retained historical attachments are fine — the Host revision copier * rewrites their Session refs losslessly. */ -export function createAppShellRevisionActions(deps: { +export function createRevisionActions(deps: { + services: Pick; uiLocale: UiLocale; activeIdRef: ReadonlyRef; captureSelection(): () => boolean; @@ -91,12 +92,13 @@ export function createAppShellRevisionActions(deps: { readMessages(): readonly StoredMessage[]; hasPendingAttachments: () => boolean; openSessionInChat: (sessionId: string, turnId?: string) => void; - refreshSessions: () => Promise; + refreshSessions: () => Promise; commitRevisionDraft: (draft: TurnRevisionDraft | null) => void; revisionDraftRef: ReadonlyRef; toastApi: ToastApi; -}): AppShellRevisionActions { +}): RevisionActions { const { + services, uiLocale, activeIdRef, captureSelection, @@ -239,7 +241,7 @@ export function createAppShellRevisionActions(deps: { try { // Main acknowledges only after the cleanup intent is durable; physical // removal may finish after this renderer has closed the draft. - await window.maka.sessions.abandonSessionCopy(draft.sourceSessionId, draft.copyId); + await services.abandonSessionCopy(draft.sourceSessionId, draft.copyId); completeTurnRevisionCopyAttempt(draft); return { acknowledged: true, draft: abandoningDraft }; } catch { @@ -293,7 +295,7 @@ export function createAppShellRevisionActions(deps: { const sourceSessionId = startedDraft.sourceSessionId; let preparedSessionId: string | undefined; try { - const newSession = await window.maka.sessions.reviseBeforeTurn(sourceSessionId, { + const newSession = await services.reviseBeforeTurn(sourceSessionId, { sourceTurnId: startedDraft.sourceTurnId, copyId: startedDraft.copyId, }); @@ -384,6 +386,7 @@ export function completeTurnRevisionCopyAttempt(draft: TurnRevisionDraft): void } export async function abandonTurnRevisionCopyAttempt( + services: Pick, draft: TurnRevisionDraft, ): Promise { const key: SessionCopyAttemptKey = { @@ -393,7 +396,7 @@ export async function abandonTurnRevisionCopyAttempt( }; abandonSessionCopyAttempt(key, draft.copyId); try { - await window.maka.sessions.abandonSessionCopy(draft.sourceSessionId, draft.copyId); + await services.abandonSessionCopy(draft.sourceSessionId, draft.copyId); completeSessionCopyAttempt(key, draft.copyId); return true; } catch { diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts new file mode 100644 index 0000000000..a3b0311d34 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts @@ -0,0 +1,217 @@ +/* + * 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, useMemo, useRef, useState } from 'react'; +import type { InlineReference } from '@maka/core/events'; +import { useToast, useUiLocale } from '@maka/ui'; +import { NEW_TASK_PENDING_KEY } from '@maka/ui/pending-items'; +import { activeHostTurn } from '../../../application/contracts/session-execution.js'; +import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; +import { parseDesktopSlashCommand } from '../../../application/contracts/desktop-slash-command.js'; +import { catalogWatchedRowsUsable } from '../../../application/contracts/session-catalog/catalog-row-watch.js'; +import { useStableActions } from '../../../application/contracts/use-stable-actions.js'; +import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +import type { + ComposerNewTaskSubmission, + ComposerSubmissionCommands, + ComposerSubmissionShell, + ComposerSurfaceOwner, +} from '../model/composer-submission-contract.js'; +import type { ComposerStagingCommands } from '../model/composer-staging-contract.js'; +import { mergeWorkspaceReferences, rebaseWorkspaceFileReferences } from '../model/follow-up-submit-routing.js'; +import { useComposerSubmissionServices } from '../submission-services.js'; +import { useConversationOwner } from '../ui/conversation-context.js'; +import { useConversationQueueCommands } from '../ui/conversation-provider.js'; +import { createChatActions } from './chat-actions.js'; +import { createRevisionAwareOnSend, createStagedFollowUp } from './composer-submit.js'; +import { + abandonTurnRevisionCopyAttempt, + completeTurnRevisionCopyAttempt, + createRevisionActions, + type TurnRevisionDraft, +} from './revision-actions.js'; + +/** + * Called only by `ComposerSubmissionProvider`. Owns the send-pending flag, the + * edit-and-resend draft and the Composer's submit path; the shell supplies + * navigation and other features' commands, and reads none of this state. + */ +export function useComposerSubmission(input: { + readonly staging: ComposerStagingCommands; + readonly shell: ComposerSubmissionShell; + readonly newTask: ComposerNewTaskSubmission; + readonly sharedSessionActive: boolean; +}) { + const { staging, shell, newTask, sharedSessionActive } = input; + const services = useComposerSubmissionServices(); + const { workspace, commands } = useConversationOwner(); + const composerRef = useConversationQueueCommands().composer; + const uiLocale = useUiLocale(); + const toastApi = useToast(); + const activeIdRef = workspace.publishedSession; + + // Held for the whole of a send; see ChatComposerRegion. + const [newTaskSendPending, setNewTaskSendPending] = useState(false); + const [revisionDraft, setRevisionDraft] = useState(null); + const revisionDraftRef = useRef(null); + const retractedWorkspaceReferencesRef = useRef>({}); + const commitRevisionDraft = useCallback((draft: TurnRevisionDraft | null) => { + revisionDraftRef.current = draft; + setRevisionDraft(draft); + }, []); + + const chat = useStableActions(createChatActions, { + services, + uiLocale, + getRunningTurnId: (sessionId) => { + if (sessionId !== activeIdRef.current) return undefined; + return activeHostTurn(workspace.ui.reads.summary(sessionId).getSnapshot().activeExecution)?.turnId; + }, + activeIdRef, + captureComposerImportOwner: shell.captureOwner, + captureSelection: commands.captureSelection, + checkTaskSubmissionReadiness: async () => + !sharedSessionActive && (!!activeIdRef.current || !!newTask.target), + isNewChatSendSurfaceActive: shell.isNewChatOwnerActive, + isShellSurfaceOwnerActive: shell.isOwnerActive, + refreshSessions: shell.refreshSessions, + activateSessionForFirstSend: shell.activateFirstSendSession, + retireSession: shell.retireSession, + clearMessageLoadError: commands.clearMessageLoadError, + addTransientMessage: commands.addTransientMessage, + updateTransientMessage: commands.updateTransientMessage, + removeTransientMessage: commands.removeTransientMessage, + onFollowLatest: commands.prepareSend, + settleInteraction: commands.settleInteraction, + onInteractionChanged: commands.markInteractionChanged, + onExecutionBoundaryChanged: shell.reloadExecutionBoundary, + respondToUserForm: shell.respondToUserForm, + showModelSetupToast: shell.showModelSetupToast, + toastApi, + newChatModel: newTask.model, + pendingNewChatThinkingLevel: newTask.thinkingLevel, + executorSelection: newTask.executorSelection, + executorEntry: newTask.executorEntry, + newChatPermissionChoice: newTask.permissionChoice, + clearNewChatPermissionChoice: newTask.clearPermissionChoice, + newChatCollaborationMode: newTask.collaborationMode, + newChatOrchestrationMode: newTask.orchestrationMode, + newTaskTarget: newTask.target, + }); + + const revision = useStableActions(createRevisionActions, { + services, + uiLocale, + activeIdRef, + captureSelection: commands.captureSelection, + composerRef, + readMessages: commands.readMessages, + hasPendingAttachments: () => staging.captureSubmission().hasPendingContext, + openSessionInChat: shell.openSession, + refreshSessions: shell.refreshSessions, + commitRevisionDraft, + revisionDraftRef, + toastApi, + }); + + // The Composer's submit callback, built by the shared factory its tests drive. + const { onSend } = useStableActions((ports: Parameters>[0]) => ({ + onSend: createRevisionAwareOnSend(ports), + }), { + shellCopy: getShellCopy(uiLocale).app, + toastApi, + activeIdRef, + revisionDraftRef, + composerRef, + retractedWorkspaceReferencesRef, + captureStaging: staging.captureSubmission, + prepareRevisionSend: revision.prepareRevisionSend, + send: chat.send, + completeRevisionCopyAttempt: completeTurnRevisionCopyAttempt, + parseSlashCommand: parseDesktopSlashCommand, + mergeWorkspaceReferences, + rebaseWorkspaceFileReferences, + revisionUnavailableCopy: getDesktopConversationCopy(uiLocale).actions, + compactSession: commands.compactSession, + enqueueFollowUp: createStagedFollowUp({ + captureStaging: staging.captureSubmission, + enqueueMessage: chat.enqueueMessage, + onError(sessionId, error) { + if (activeIdRef.current !== sessionId) return; + const copy = getDesktopConversationCopy(uiLocale).actions; + toastApi.error( + copy.operationFailedTitle, + localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), + undefined, + { sessionId }, + ); + }, + }), + settleNewTaskImageNoticeOwner: (sourceSessionId) => { + const createdSessionId = activeIdRef.current; + if (!sourceSessionId && createdSessionId) { + staging.transferImageNotice(NEW_TASK_PENDING_KEY, createdSessionId); + } + }, + commitRevisionDraft, + resolveNewTaskSessionHandler: () => shell.bindNewTaskSessionResolver(commands.readSelectionRevision()), + openSideChat: shell.openSideChat, + getActiveOrchestrationMode: shell.orchestrationMode, + setOrchestrationModeActive: shell.setOrchestrationModeActive, + setNewTaskSendPending, + }); + + // The draft survives on exactly two catalog rows; their departure retires it. + const retireRevisionDraftIfRowsLeave = useCallback( + (rows: Parameters[0]) => { + const draft = revisionDraftRef.current; + if (!draft) return; + // A watched row that is merely pending — never observed, never reported + // removed — is admission lag, not a departure. + if (catalogWatchedRowsUsable(rows)) return; + composerRef.current?.clearDraft(draft.draftSessionId); + if (draft.sourceSessionId !== draft.draftSessionId) composerRef.current?.clearDraft(draft.sourceSessionId); + if (draft.copyPhase === 'reserved') completeTurnRevisionCopyAttempt(draft); + else void abandonTurnRevisionCopyAttempt(services, draft); + commitRevisionDraft(null); + }, + [commitRevisionDraft, composerRef, services], + ); + + const shellCommands = useMemo(() => ({ + beginEditUserMessage: (turnId) => revision.beginEditUserMessage(turnId), + }), [revision]); + const reader = useMemo(() => ({ + onSend, + newTaskSendPending, + revisionDraft, + cancelRevisionDraft: () => { void revision.cancelRevisionDraft(); }, + respondToSandboxBoundary: chat.respondToSandboxBoundary, + respondToUserQuestion: chat.respondToUserQuestion, + respondToUserForm: chat.respondToUserForm, + }), [chat, newTaskSendPending, onSend, revision, revisionDraft]); + return { + shellCommands, + reader, + revisionWatch: { + sessionIds: [revisionDraft?.sourceSessionId, revisionDraft?.draftSessionId] as const, + onRows: retireRevisionDraftIfRowsLeave, + }, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts index f631d497b0..e51d703dfb 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts @@ -41,18 +41,11 @@ export function useConversationTarget() { captureSelection: commands.captureSelection, isSessionSelected: commands.isSessionSelected, retiredSessionIds: commands.retiredSessionIds, - readSelectionRevision: commands.readSelectionRevision, addTransientMessage: commands.addTransientMessage, - updateTransientMessage: commands.updateTransientMessage, removeTransientMessage: commands.removeTransientMessage, readMessages: commands.readMessages, refreshMessages: commands.refreshMessages, - prepareSend: commands.prepareSend, - clearMessageLoadError: commands.clearMessageLoadError, - markInteractionChanged: commands.markInteractionChanged, - settleInteraction: commands.settleInteraction, recordSessionChange: commands.recordSessionChange, - compactSession: commands.compactSession, activeId, activeIdRef: workspace.publishedSession, bootstrapSelectionLease: workspace.bootstrapSelectionLease, diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 74d0e63421..7ba3f990d6 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -30,7 +30,6 @@ export { SessionLocalMessages } from './controller/session-local-messages.js'; export type { ComposerAttachmentService } from '@maka/ui/use-composer-attachments'; -export { type PendingAttachment, toSubmittedAttachments } from '@maka/ui/composer-attachments'; export { NEW_TASK_PENDING_KEY, selectPending, @@ -50,13 +49,9 @@ export { type ComposerMentionsSurface, } from './ui/composer-mentions-provider.js'; -export { activeHostTurn, chatTurnActivity } from '../../application/contracts/session-execution.js'; +export { chatTurnActivity } from '../../application/contracts/session-execution.js'; export { sessionIdSetsEqual, type LiveTurnSnapshot } from './model/live-turn-snapshot.js'; export { createAppShellQueueActions } from './controller/app-shell-queue-actions.js'; -export { - createRevisionAwareOnSend, - type RevisionSendPorts, -} from './controller/composer-submit.js'; export * from './model/observation-visibility.js'; export { useExecutorSelection } from './controller/use-executor-selection.js'; @@ -80,7 +75,6 @@ export { ConversationLifecycle } from './ui/conversation-lifecycle.js'; export { ConversationTranscriptRegion, ConversationComposerRegion, ConversationMessageConsumer } from './ui/conversation-readers.js'; export { createComposerStagingCommands } from './controller/composer-staging-commands.js'; -export { createStagedFollowUp } from './controller/composer-submit.js'; export type { ComposerStagingCommands, ComposerStagingSubmission } from './model/composer-staging-contract.js'; export { ComposerStagingServicesProvider, type ComposerStagingServices } from './staging-services.js'; export { ComposerStagingProvider } from './ui/composer-staging-provider.js'; @@ -88,3 +82,6 @@ export { StagedComposer, type ComposerStagingProp } from './ui/staged-composer.j export { StagedQuoteChatView } from './ui/staged-quote-chat-view.js'; export { TaskReadinessServicesProvider, type TaskReadinessServices } from './readiness-services.js'; export { TaskReadinessProvider, TaskReadinessNoticeConsumer } from './ui/task-readiness-provider.js'; +export { createComposerSubmissionCommands } from './controller/composer-submission-commands.js'; +export { ComposerSubmissionServicesProvider, type ComposerSubmissionServices } from './submission-services.js'; +export { ComposerSubmissionProvider } from './ui/composer-submission-provider.js'; diff --git a/apps/desktop/src/renderer/features/conversation/model/composer-submission-binding.ts b/apps/desktop/src/renderer/features/conversation/model/composer-submission-binding.ts new file mode 100644 index 0000000000..7641898bbc --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/composer-submission-binding.ts @@ -0,0 +1,26 @@ +/* + * 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 { ComposerSubmissionCommands } from './composer-submission-contract.js'; + +// Binding is feature-private. The shell cannot publish an implementation or +// reach the owner's draft state through the public command handle. +export const submissionBindings = new WeakMap(); diff --git a/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts b/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts new file mode 100644 index 0000000000..ffa1a0eca9 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts @@ -0,0 +1,77 @@ +/* + * 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 { CollaborationMode } from '@maka/core/collaboration'; +import type { InteractionFormResponse } from '@maka/core/interaction'; +import type { ThinkingLevel } from '@maka/core/model-thinking'; +import type { OrchestrationMode } from '@maka/core/orchestration'; +import type { ChatDefaultPermissionMode } from '@maka/core/settings'; +import type { ToastDiagnosticTarget } from '@maka/ui'; +import type { DesktopSessionSummary } from '../../../../shared/desktop-session-projection.js'; +import type { NewChatExecutionTarget } from '../controller/use-shell-chat-model.js'; +import type { ConversationNewTaskTarget } from '../ports.js'; +import type { ExecutorSubmission } from './executor-submission.js'; + +/** The shell surface a send started on; only the shell can say whether it is still current. */ +export interface ComposerSurfaceOwner { + readonly sessionId: string | undefined; + readonly newTaskDraftKey?: string; +} + +/** Commands only: the shell gets no draft, pending flag or setter through this handle. */ +export interface ComposerSubmissionCommands { + beginEditUserMessage(turnId: string): void; +} + +/** + * What the shell supplies to submission: navigation, the Session catalog, and + * other features' commands. Each is a named operation it already owns. + */ +export interface ComposerSubmissionShell { + captureOwner(): Owner; + /** Whether the user is still on the surface the owner was captured from. */ + isOwnerActive(owner: Owner): boolean; + /** The same question, for an owner captured on the new-chat surface. */ + isNewChatOwnerActive(owner: Owner): boolean; + activateFirstSendSession(session: DesktopSessionSummary): Promise; + openSession(sessionId: string, turnId?: string): void; + /** Drops a Session's renderer state after its unsent first message was retracted. */ + retireSession(sessionId: string): void; + refreshSessions(): Promise; + reloadExecutionBoundary(sessionId: string): void; + respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise; + showModelSetupToast(description: string, reason?: string, diagnosticTarget?: ToastDiagnosticTarget): void; + bindNewTaskSessionResolver(selectionRevision: number): (sessionId: string, newTaskDraftKey?: string) => void; + openSideChat(options: { initialPrompt?: string }): void; + orchestrationMode(): OrchestrationMode; + setOrchestrationModeActive(mode: Exclude, active: boolean): Promise; +} + +/** What a new task is created with, read when its first send starts. */ +export interface ComposerNewTaskSubmission extends ExecutorSubmission { + readonly target: ConversationNewTaskTarget | undefined; + readonly model: NewChatExecutionTarget | null; + /** Undefined applies the Host's model default; null keeps the provider default. */ + readonly thinkingLevel: ThinkingLevel | null | undefined; + readonly permissionChoice: ChatDefaultPermissionMode | undefined; + readonly collaborationMode: CollaborationMode; + readonly orchestrationMode: OrchestrationMode; + /** Drops the permission choice once a created Session has consumed it. */ + clearPermissionChoice(): void; +} diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/features/conversation/model/follow-up-submit-routing.ts similarity index 100% rename from apps/desktop/src/renderer/follow-up-submit-routing.ts rename to apps/desktop/src/renderer/features/conversation/model/follow-up-submit-routing.ts diff --git a/apps/desktop/src/renderer/features/conversation/model/session-workspace-toast.ts b/apps/desktop/src/renderer/features/conversation/model/session-workspace-toast.ts new file mode 100644 index 0000000000..c86b61ac85 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/session-workspace-toast.ts @@ -0,0 +1,36 @@ +/* + * 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 { UiLocale } from '@maka/core/ui-locale'; +import type { ToastDiagnosticTarget } from '@maka/ui'; +import { getShellCopy } from '../../../locales/shell-copy.js'; + +export { isSessionWorkspaceUnavailableError } from '../../../application/contracts/session-workspace-errors.js'; + +/** The Composer owner's copy of the shell's missing-working-directory toast. */ +export function showSessionWorkspaceUnavailableToast( + toastApi: { + error(title: string, description?: string, diagnosticDetails?: string, diagnosticTarget?: ToastDiagnosticTarget): void; + }, + locale: UiLocale, + diagnosticTarget?: ToastDiagnosticTarget, +): void { + const copy = getShellCopy(locale).errors; + toastApi.error(copy.workspaceUnavailableTitle, copy.workspaceUnavailableDescription, undefined, diagnosticTarget); +} diff --git a/apps/desktop/src/renderer/skill-invocation-feedback.ts b/apps/desktop/src/renderer/features/conversation/model/skill-invocation-feedback.ts similarity index 98% rename from apps/desktop/src/renderer/skill-invocation-feedback.ts rename to apps/desktop/src/renderer/features/conversation/model/skill-invocation-feedback.ts index 4e71b0817c..885df605bf 100644 --- a/apps/desktop/src/renderer/skill-invocation-feedback.ts +++ b/apps/desktop/src/renderer/features/conversation/model/skill-invocation-feedback.ts @@ -20,7 +20,7 @@ import type { AttachmentIngestBlockedCode } from '@maka/core/attachments'; import type { UiLocale } from '@maka/core/ui-locale'; import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; -import { getShellCopy } from './locales/shell-copy.js'; +import { getShellCopy } from '../../../locales/shell-copy.js'; type FeedbackToastApi = { error( diff --git a/apps/desktop/src/renderer/features/conversation/submission-services.ts b/apps/desktop/src/renderer/features/conversation/submission-services.ts new file mode 100644 index 0000000000..c77cc9c8f1 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/submission-services.ts @@ -0,0 +1,86 @@ +/* + * 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 { AttachmentIngestBlockedCode } from '@maka/core/attachments'; +import type { AttachmentRef, DirectoryReference, InlineReference, QuoteRef } from '@maka/core/events'; +import type { CreateSessionRequestInput, TurnOrchestration } from '@maka/core/runtime-inputs'; +import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; +import type { ComposerIngestInput } from '@maka/ui/composer-attachments'; +import { createServicesContext } from '../../application/contracts/feature-services.js'; +import type { DesktopSessionSummary } from '../../../shared/desktop-session-projection.js'; +import type { ConversationNewTaskTarget } from './ports.js'; + +export type MessagePlacement = 'current_turn' | 'next_turn'; + +/** One Message as the Composer submits it; the Host decides its admission. */ +export interface ConversationMessageCommand { + readonly messageId: string; + readonly text: string; + readonly localDisplayPlacement?: MessagePlacement; + readonly displayText?: string; + readonly turnOrchestration?: TurnOrchestration; + readonly attachmentItems?: ComposerIngestInput[]; + readonly retainedAttachments?: AttachmentRef[]; + readonly directoryReferences?: DirectoryReference[]; + readonly quotes?: QuoteRef[]; + readonly workspaceFileReferences?: Array>; +} + +export type ConversationMessageSubmission = + | { + readonly ok: true; + readonly disposition: 'turn_started' | 'steering' | 'followup' | 'locally_saved'; + readonly turnId?: string; + readonly attachments: AttachmentRef[]; + readonly inlineReferences: InlineReference[]; + readonly skillInvocation: SkillInvocationResult; + } + | { readonly ok: false; readonly reason: 'skill_invocation_failed'; readonly skillInvocation: SkillInvocationResult } + | { readonly ok: false; readonly reason: 'attachment_blocked'; readonly code: AttachmentIngestBlockedCode } + | { readonly ok: false; readonly reason: 'outcome_unknown' }; + +/** + * The Host operations behind the Composer's sends, edit-and-resend and + * interaction answers. Each is one named operation; the owner never receives a + * bridge namespace. + */ +export interface ComposerSubmissionServices { + submitMessage( + sessionId: string, + placement: MessagePlacement, + command: ConversationMessageCommand, + options?: { readonly waitForHostAdmission?: boolean }, + ): Promise; + createNewTask(target: ConversationNewTaskTarget, input: CreateSessionRequestInput): Promise; + /** Deletes a Session whose first Message never landed. */ + removeUnsentSession(sessionId: string): Promise; + reviseBeforeTurn( + sessionId: string, + input: { readonly sourceTurnId: string; readonly copyId: string }, + ): Promise; + abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; + respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise; + respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise; +} + +const context = createServicesContext('ComposerSubmissionServicesProvider'); +export const ComposerSubmissionServicesProvider = context.Provider; +export const useComposerSubmissionServices = context.useServices; diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index ae749f2ffa..293937d94c 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -123,3 +123,15 @@ export { useComposerAttachments } from './controller/use-composer-attachments.js export { useComposerQuotes } from './controller/use-composer-quotes.js'; export { useComposerStaging } from './ui/composer-staging-context.js'; export { deriveTaskReadinessNotice, isTaskSubmissionHardBlocked } from './model/task-readiness-notice.js'; +export { mergeWorkspaceReferences, rebaseWorkspaceFileReferences } from './model/follow-up-submit-routing.js'; +export { createChatActions } from './controller/chat-actions.js'; +export { + completeTurnRevisionCopyAttempt, + createRevisionActions, + type TurnRevisionDraft, +} from './controller/revision-actions.js'; +export { + createRevisionAwareOnSend, + createStagedFollowUp, + type RevisionSendPorts, +} from './controller/composer-submit.js'; diff --git a/apps/desktop/src/renderer/features/conversation/ui/composer-submission-context.ts b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-context.ts new file mode 100644 index 0000000000..4fe75a6e99 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-context.ts @@ -0,0 +1,30 @@ +/* + * 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 } from 'react'; +import type { useComposerSubmission } from '../controller/use-composer-submission.js'; + +export type ComposerSubmissionReader = ReturnType['reader']; + +export const ComposerSubmissionContext = createContext(undefined); +export function useComposerSubmissionReader() { + const reader = useContext(ComposerSubmissionContext); + if (!reader) throw new Error('ComposerSubmissionProvider is required'); + return reader; +} diff --git a/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx new file mode 100644 index 0000000000..6de6fb2948 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx @@ -0,0 +1,70 @@ +/* + * 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 { useLayoutEffect, type ReactNode } from 'react'; +import { CatalogRowWatch } from '../../../application/contracts/session-catalog/catalog-row-watch.js'; +import { useSessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import { useComposerSubmission } from '../controller/use-composer-submission.js'; +import { submissionBindings } from '../model/composer-submission-binding.js'; +import type { ComposerStagingCommands } from '../model/composer-staging-contract.js'; +import type { + ComposerNewTaskSubmission, + ComposerSubmissionCommands, + ComposerSubmissionShell, + ComposerSurfaceOwner, +} from '../model/composer-submission-contract.js'; +import { ComposerSubmissionContext } from './composer-submission-context.js'; + +/** + * Sole owner of the Composer's submission: the send-pending flag, the + * edit-and-resend draft and the submit, follow-up and interaction-answer paths. + * It stays mounted beside the staging owner, so a draft and an in-flight send + * keep their owner across Session and section switches. The shell holds only + * the stable command handle; the Composer slot reads the rest from here. + */ +export function ComposerSubmissionProvider(props: { + readonly commands: ComposerSubmissionCommands; + readonly staging: ComposerStagingCommands; + readonly shell: ComposerSubmissionShell; + readonly newTask: ComposerNewTaskSubmission; + readonly sharedSessionActive: boolean; + readonly children?: ReactNode; +}) { + const submission = useComposerSubmission({ + staging: props.staging, + shell: props.shell, + newTask: props.newTask, + sharedSessionActive: props.sharedSessionActive, + }); + const catalog = useSessionCatalogController(); + const { shellCommands } = submission; + useLayoutEffect(() => { + const binding = submissionBindings.get(props.commands); + if (!binding) throw new Error('Unknown Composer submission commands'); + binding.current = shellCommands; + return () => { if (binding.current === shellCommands) binding.current = undefined; }; + }, [props.commands, shellCommands]); + return ( + + + {props.children} + + ); +} diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx index f45c8baaaf..12041865f1 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -18,12 +18,14 @@ */ import { createElement, useMemo, useSyncExternalStore, type ComponentType, type ComponentProps } from 'react'; -import { ChatView } from '@maka/ui'; +import { ChatView, useUiLocale } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; import type { SessionUiReads } from '../model/session-ui-reads.js'; import { useSessionUiRead } from '../controller/use-session-ui-read.js'; import { transcriptRestoreTarget } from '../controller/transcript-reading-position.js'; import { useConversationOwner } from './conversation-context.js'; +import { useComposerSubmissionReader, type ComposerSubmissionReader } from './composer-submission-context.js'; +import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; type ChatProps = ComponentProps; type TranscriptProps = Pick( return createElement(surface, { ...presentation, ...owned } as unknown as P); } -type ComposerProps = { processing: boolean; pendingMessages: ChatProps['transientMessages']; latestRequestUsageTokens?: number }; -/** Lives in the persistent composer slot, outside the conditional transcript. */ +type SubmissionProps = Pick & { + revisionNotice?: { title: string; detail: string; cancelLabel: string; onCancel(): void }; +}; +type ComposerProps = SubmissionProps & { + processing: boolean; pendingMessages: ChatProps['transientMessages']; latestRequestUsageTokens?: number; +}; +/** The shell's picker gates; an edit-and-resend draft narrows them here. */ +type ComposerPickGates = { contextPickEnabled?: boolean; directoryPickerEnabled?: boolean }; +/** + * Lives in the persistent composer slot, outside the conditional transcript. + * Submission state (send pending, the edit-and-resend draft) and the submit + * and interaction-answer callbacks come from the Composer submission owner. + */ export function ConversationComposerRegion

( props: { surface: ComponentType

; usageModel?: string; usageRoute?: { llmConnectionId?: string } } & Omit, ) { const { surface, usageModel, usageRoute, ...presentation } = props; const { workspace } = useConversationOwner(); + const submission = useComposerSubmissionReader(); + const actionCopy = getDesktopConversationCopy(useUiLocale()).actions; + const activeId = useSyncExternalStore(workspace.target.subscribe, workspace.target.getSnapshot); const view = useSyncExternalStore(workspace.composer.subscribe, workspace.composer.getSnapshot); const usage = useMemo(() => workspace.usage(usageModel, usageRoute?.llmConnectionId), [workspace, usageModel, usageRoute?.llmConnectionId]); const latestRequestUsageTokens = useSyncExternalStore(usage.subscribe, usage.getSnapshot); - return createElement(surface, { - ...presentation, + const draft = submission.revisionDraft; + const editing = draft !== null && activeId === draft.draftSessionId; + const gates = presentation as ComposerPickGates; + const owned: ComposerProps & ComposerPickGates = { + onSend: submission.onSend, + newTaskSendPending: submission.newTaskSendPending, + respondToSandboxBoundary: submission.respondToSandboxBoundary, + respondToUserQuestion: submission.respondToUserQuestion, + respondToUserForm: submission.respondToUserForm, + revisionNotice: editing + ? { + title: actionCopy.revisionBannerTitle, + detail: actionCopy.revisionBannerDetail, + cancelLabel: actionCopy.revisionCancelLabel, + onCancel: submission.cancelRevisionDraft, + } + : undefined, + ...(gates.contextPickEnabled !== undefined ? { contextPickEnabled: gates.contextPickEnabled && !editing } : {}), + ...(gates.directoryPickerEnabled !== undefined + ? { directoryPickerEnabled: gates.directoryPickerEnabled && draft === null } + : {}), processing: view.transientMessages.length > 0, pendingMessages: view.transientMessages, latestRequestUsageTokens, - } as unknown as P); + }; + return createElement(surface, { ...presentation, ...owned } as unknown as P); } -/** A cross-feature leaf can read published messages without routing them through Shell. */ export function ConversationMessageConsumer

( props: { surface: ComponentType

} & Omit, ) { diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index b0438eb072..604033f9f9 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -38,7 +38,7 @@ import type { ChatModelChoice } from '@maka/ui'; import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js'; import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; -import { sideChatTitleFromPrompt } from '../../../side-chat-command.js'; +import { sideChatTitleFromPrompt } from '../../../application/contracts/side-chat-command.js'; import { desktopSessionKey, parseDesktopSessionKey } from '../../../../shared/runtime-host-identity.js'; import { useWorkHubWorkspace } from '../../../application/contracts/workhub-workspace/use-workhub-workspace.js'; import { useWorkbarServices } from '../services-context.js'; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index 0544fee6c8..705c454828 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -48,7 +48,7 @@ import { readSessionCopyAttempt, startSessionCopyAttempt, type SessionCopyAttemptKey, -} from '../../../../session-copy-attempt.js'; +} from '../../../../application/contracts/session-copy-attempt.js'; import { sessionEventErrorMessage } from '../../../../model-connection-errors.js'; import { parseDesktopSessionKey } from '../../../../../shared/runtime-host-identity.js'; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 208c36bb4d..bb71c37a9d 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -39,7 +39,7 @@ import { useQuoteCompanion } from './use-quote-companion'; import { useComposerAttachments } from '@maka/ui/use-composer-attachments'; import { localizedShellErrorMessage } from '../../../../locales/shell-copy.js'; import { useComposerMentionsContext } from '../../../../composer-mentions.js'; -import { preflightAttachmentItems } from '../../../../attachment-preflight'; +import { preflightAttachmentItems } from '../../../../application/contracts/attachment-preflight.js'; import { getDesktopConversationCopy } from '../../../../application/contracts/conversation-copy.js'; import { useAppShellTurnPresentation } from '../../../../application/contracts/turn-presentation.js'; import { diff --git a/apps/desktop/src/renderer/platform/desktop/create-composer-submission-services.ts b/apps/desktop/src/renderer/platform/desktop/create-composer-submission-services.ts new file mode 100644 index 0000000000..0fa113125c --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-composer-submission-services.ts @@ -0,0 +1,49 @@ +/* + * 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 { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { ComposerSubmissionServices } from '../../features/conversation/index.js'; + +export type DesktopComposerSubmissionBridge = { + readonly sessions: Pick< + MakaBridge['sessions'], + | 'submitMessage' + | 'remove' + | 'reviseBeforeTurn' + | 'abandonSessionCopy' + | 'respondToSandboxBoundary' + | 'respondToUserQuestion' + >; + readonly newTasks: Pick; +}; + +export function createDesktopComposerSubmissionServices( + bridge: DesktopComposerSubmissionBridge = window.maka, +): ComposerSubmissionServices { + return { + submitMessage: (sessionId, placement, command, options) => + bridge.sessions.submitMessage(sessionId, placement, command, options), + createNewTask: (target, input) => bridge.newTasks.create(target, input), + removeUnsentSession: (sessionId) => bridge.sessions.remove(sessionId), + reviseBeforeTurn: (sessionId, input) => bridge.sessions.reviseBeforeTurn(sessionId, input), + abandonSessionCopy: (sourceSessionId, copyId) => bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), + respondToSandboxBoundary: (sessionId, response) => bridge.sessions.respondToSandboxBoundary(sessionId, response), + respondToUserQuestion: (sessionId, response) => bridge.sessions.respondToUserQuestion(sessionId, response), + }; +} diff --git a/apps/desktop/stories/composer-paste.stories.tsx b/apps/desktop/stories/composer-paste.stories.tsx index 24ef06bc93..1aa703808d 100644 --- a/apps/desktop/stories/composer-paste.stories.tsx +++ b/apps/desktop/stories/composer-paste.stories.tsx @@ -37,7 +37,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import { slashCommandsForSurface } from '@maka/core/slash-command-catalog'; import { Composer } from '@maka/ui'; -import { desktopSlashCommandAvailability } from '../src/renderer/desktop-slash-command'; +import { desktopSlashCommandAvailability } from '../src/renderer/application/contracts/desktop-slash-command'; import { getShellCopy } from '../src/renderer/locales/shell-copy'; const COMPOSER_INPUT = '.maka-composer-editor [contenteditable="true"]'; diff --git a/apps/desktop/stories/composer-slash-menu.stories.tsx b/apps/desktop/stories/composer-slash-menu.stories.tsx index da78e285f7..430f4c884e 100644 --- a/apps/desktop/stories/composer-slash-menu.stories.tsx +++ b/apps/desktop/stories/composer-slash-menu.stories.tsx @@ -54,7 +54,7 @@ import { SessionCatalogContext, } from '../src/renderer/application/contracts/session-catalog/session-catalog-state.js'; import type { DesktopSessionSummary } from '../src/shared/desktop-session-projection.js'; -import { desktopSlashCommandAvailability } from '../src/renderer/desktop-slash-command'; +import { desktopSlashCommandAvailability } from '../src/renderer/application/contracts/desktop-slash-command'; import { getShellCopy } from '../src/renderer/locales/shell-copy'; import { withScopedMakaBridge } from './maka-bridge'; diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 36635c26bd..7b5bd5408f 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 326 files — blocker 0, reimplementation 0, polish 4, aligned 322. +**Totals:** 327 files — blocker 0, reimplementation 0, polish 4, aligned 323. ## Exclusions (explicit) @@ -67,6 +67,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/composer-staging-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 039f112336..a16651af14 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -37,6 +37,7 @@ apps/desktop/src/renderer/features/conversation/controller/session-local-message apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx apps/desktop/src/renderer/features/conversation/ui/composer-staging-provider.tsx +apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index a91124b906..b6d7acb07a 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -136,8 +136,8 @@ export const ALLOWED = { useShellLiveTurn: 1, useShellMemoryPill: 1, useShellResume: 1, - useStableActions: 4, - useState: 6, + useStableActions: 2, + useState: 4, useToast: 1, // The last of the three `useKeyedPendingRegistry` call sites this entry // replaces: #4113 moved the other two onto the session UI store, which is From e0b1746b41a7e6e2147798647d0dd38cd217bd56 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 18:27:30 +0800 Subject: [PATCH 4/5] refactor(desktop): move Stop, Turn branch and local recovery under the Composer owner The Stop and Turn-branch actions move from AppShell-family legacy files into ComposerSubmissionProvider. Their Host calls, sessions.stop and branchFromTurn, now go through ComposerSubmissionServices. The Composer slot reads the published Session's Stop claim and receives onStop/stop from the owner; Stop still notes the stopped Turn for the resume offer. The shell's command handle gains handleTurnFooterAction and receives the Turn-action pending registry as a port, because the shell still renders that registry's mask. SessionLocalMessages, the local delivery-recovery reader, now mounts inside the owner for the published Session. Its recovery policy is unchanged. The transitional adapter drops the Stop claim, the transient add/remove commands and captureSelection, and the README's matching transitional row is removed. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 60 +----------- .../__tests__/app-shell-stop-action.test.ts | 6 +- .../__tests__/app-shell-turn-actions.test.ts | 6 +- .../__tests__/composer-submission-fixture.ts | 4 + .../composer-submission-owner.test.ts | 91 ++++++++++++++++++- .../__tests__/message-queue-ui-state.test.ts | 4 +- .../session-workspace-action-identity.test.ts | 4 +- apps/desktop/src/renderer/app-shell.tsx | 49 +--------- .../contracts/use-stable-actions.ts | 4 +- .../renderer/features/conversation/README.md | 37 +++++--- .../composer-submission-commands.ts | 1 + .../conversation/controller/stop-action.ts} | 13 ++- .../conversation/controller/turn-actions.ts} | 22 +++-- .../controller/use-composer-submission.ts | 60 +++++++++++- .../controller/use-conversation-target.ts | 4 - .../renderer/features/conversation/index.ts | 6 +- .../model/composer-submission-contract.ts | 11 ++- .../conversation/submission-services.ts | 15 ++- .../renderer/features/conversation/testing.ts | 3 + .../ui/composer-submission-provider.tsx | 7 +- .../conversation/ui/conversation-readers.tsx | 13 ++- .../create-composer-submission-services.ts | 4 + scripts/check-app-shell-hooks.mjs | 2 +- 23 files changed, 258 insertions(+), 168 deletions(-) rename apps/desktop/src/renderer/{app-shell-stop-action.ts => features/conversation/controller/stop-action.ts} (82%) rename apps/desktop/src/renderer/{app-shell-turn-actions.ts => features/conversation/controller/turn-actions.ts} (84%) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 747e811c42..df91cfac03 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -12,8 +12,6 @@ "src/renderer/app-shell-effects.ts", "src/renderer/app-shell-overlays.tsx", "src/renderer/app-shell-project-actions.ts", - "src/renderer/app-shell-stop-action.ts", - "src/renderer/app-shell-turn-actions.ts", "src/renderer/app-shell.tsx", "src/renderer/app.tsx", "src/renderer/astryx-theme-mode.ts", @@ -524,48 +522,8 @@ "importSpecifiers": 7, "nonTriviaTokens": 2157 }, - "src/renderer/app-shell-stop-action.ts": { - "importDeclarations": 0, - "bridgePaths": { - "window.maka.sessions.stop": 1 - }, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [ - "createAppShellStopAction" - ], - "dependencyPaths": { - "./application/contracts/conversation-copy.js": 1, - "./locales/shell-copy.js": 1 - }, - "importSpecifiers": 0, - "nonTriviaTokens": 292 - }, - "src/renderer/app-shell-turn-actions.ts": { - "importDeclarations": 1, - "bridgePaths": { - "window.maka.sessions.branchFromTurn": 1 - }, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [ - "createAppShellTurnActions" - ], - "dependencyPaths": { - "./application/contracts/conversation-copy.js": 1, - "./application/contracts/session-copy-attempt.js": 1, - "./locales/shell-copy.js": 1, - "./session-workspace-errors.js": 1 - }, - "importSpecifiers": 2, - "nonTriviaTokens": 561 - }, "src/renderer/app-shell.tsx": { - "importDeclarations": 45, + "importDeclarations": 43, "bridgePaths": { "window.maka.attachments.readBytes": 1, "window.maka.onboarding.setMilestone": 1, @@ -598,7 +556,7 @@ "useShellLiveTurn": 1, "useShellMemoryPill": 1, "useShellResume": 1, - "useStableActions": 2, + "useStableActions": 1, "useState": 6, "useSystemUiLocale": 1, "useToast": 1, @@ -614,8 +572,6 @@ "./app-shell-e2e-fixture": 1, "./app-shell-effects": 1, "./app-shell-overlays": 1, - "./app-shell-stop-action": 1, - "./app-shell-turn-actions": 1, "./application/contracts/conversation-copy": 1, "./application/contracts/desktop-slash-command.js": 1, "./application/contracts/turn-presentation": 1, @@ -673,8 +629,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 65, - "nonTriviaTokens": 8372 + "importSpecifiers": 63, + "nonTriviaTokens": 8225 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -3685,14 +3641,6 @@ "src/renderer/app-shell.tsx" ] }, - { - "capability": "session-controls-and-turn-actions", - "targetZone": "features/conversation", - "legacyPaths": [ - "src/renderer/app-shell-stop-action.ts", - "src/renderer/app-shell-turn-actions.ts" - ] - }, { "capability": "conversation-runtime-and-presentation", "targetZone": "features/conversation", diff --git a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts index a16796aa15..171c05c8bd 100644 --- a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts @@ -19,7 +19,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createAppShellStopAction } from '../../renderer/app-shell-stop-action.js'; +import { createStopAction } from '../../renderer/features/conversation/testing.js'; +import { windowSubmissionServices } from './app-shell-chat-actions-fixture.js'; test('removes exactly the transient messages the Host retracts while stopping', async () => { const removed: Array<{ sessionId: string; messageId: string }> = []; @@ -36,7 +37,8 @@ test('removes exactly the transient messages the Host retracts while stopping', }, }; try { - const stop = createAppShellStopAction({ + const stop = createStopAction({ + services: windowSubmissionServices(), uiLocale: 'en', activeIdRef: { current: 'session-1' }, stopPending: { claim: () => true, release: () => undefined }, diff --git a/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts index b11fffa46b..1c67e5a044 100644 --- a/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts @@ -20,7 +20,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { SessionSummary } from '@maka/core/session'; -import { createAppShellTurnActions } from '../../renderer/app-shell-turn-actions.js'; +import { createTurnActions } from '../../renderer/features/conversation/testing.js'; +import { windowSubmissionServices } from './app-shell-chat-actions-fixture.js'; import { deriveTurnFooterActions } from '../../renderer/application/contracts/turn-footer-actions.js'; test('footer no longer exposes Regenerate', () => { @@ -58,7 +59,8 @@ test('preserves a Branch copy identity after an ambiguous failure and completes }); const pending = new Set(); const opened: string[] = []; - const actions = createAppShellTurnActions({ + const actions = createTurnActions({ + services: windowSubmissionServices(), uiLocale: 'en', activeIdRef: { current: 'branch-action-source' }, captureSelection: () => { diff --git a/apps/desktop/src/main/__tests__/composer-submission-fixture.ts b/apps/desktop/src/main/__tests__/composer-submission-fixture.ts index e422604b25..433fe32382 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-fixture.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-fixture.ts @@ -40,6 +40,8 @@ export function stubSubmissionServices(overrides: Partial {}, bindNewTaskSessionResolver: () => () => {}, openSideChat: () => {}, + noteUserStoppedTurn: () => {}, + turnActions: { addKey: () => true, clearKey: () => {}, keyOf: (...parts) => parts.join(':') }, orchestrationMode: () => 'default', setOrchestrationModeActive: async () => true, ...overrides, diff --git a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts index 56eea4ac6c..489b48bf3e 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -73,6 +73,10 @@ interface RegionProps { respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; respondToUserForm(response: { requestId: string }): Promise; + stop(): void; + onStop(): void; + stopPending: boolean; + pendingMessages: ReadonlyArray<{ id: string }>; } const row = (id: string): DesktopSessionSummary => ({ @@ -95,12 +99,15 @@ function harness(options: { shell?: Partial; newTask?: Partial; sharedSessionActive?: boolean; + listMessages?: ReturnType['listMessages']; } = {}) { const { root } = installReactRenderer(); const catalog = createSessionCatalogController(); catalog.commitSessions(['A', 'B'].map(row)); const published: Array<(messages: StoredMessage[]) => void> = []; - const conversationServices = stubConversationServices(); + const conversationServices = stubConversationServices( + options.listMessages ? { listMessages: options.listMessages } : {}, + ); conversationServices.observation.openTranscript = (sessionId) => { let messages: StoredMessage[] = []; let ready = false; @@ -305,9 +312,73 @@ describe('ComposerSubmissionProvider', () => { ]); }); + test('owns Stop: claims it for the published Session, drops retracted rows and notes the stopped Turn', async () => { + const stopping = deferred>>(); + const calls: unknown[] = []; + const h = harness({ + services: { + stop: (sessionId, input) => { calls.push(['stop', sessionId, input]); return stopping.promise; }, + }, + shell: { noteUserStoppedTurn: (sessionId) => { calls.push(['noted', sessionId]); } }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'running')])); + await act(async () => h.region.onStop()); + assert.deepEqual(calls, [['noted', 'A'], ['stop', 'A', { source: 'stop_button' }]]); + assert.equal(h.region.stopPending, true, 'the Stop claim is read in the Composer slot'); + await act(async () => h.region.onStop()); + assert.equal(calls.length, 3, 'a second Stop notes the Turn but the claim refuses a second request'); + await act(async () => stopping.resolve({ kind: 'interrupted', retractedMessageIds: [] })); + assert.equal(h.region.stopPending, false); + + calls.length = 0; + await act(async () => h.region.stop()); + assert.deepEqual(calls.map((call) => (call as unknown[])[0]), ['stop'], 'a question prompt\'s Stop does not suppress Resume'); + }); + + test('branches a Turn for the shell through its port and opens the copy', async () => { + const calls: unknown[] = []; + const h = harness({ + services: { + branchFromTurn: async (sessionId, input) => { + calls.push(['branch', sessionId, input.sourceTurnId, typeof input.copyId]); + return { ...row('C'), name: 'Copy' }; + }, + }, + shell: { + refreshSessions: async () => { calls.push(['refresh']); return []; }, + openSession: (sessionId) => { calls.push(['open', sessionId]); }, + }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'source')])); + await act(async () => h.commands.handleTurnFooterAction('turn-1', 'branch')); + assert.deepEqual(calls, [['branch', 'A', 'turn-1', 'string'], ['refresh'], ['open', 'C']]); + }); + + test('runs local delivery recovery for the published Session below the owner', async () => { + const listed: string[] = []; + const h = harness({ + listMessages: async (sessionId) => { + listed.push(sessionId); + return sessionId === 'A' + ? [{ + sessionId: 'A', messageId: 'saved-follow-up', createdAt: 1, state: 'unknown', canCancel: false, + placement: 'next_turn', text: 'queued while offline', attachments: [], inlineReferences: [], + }] + : []; + }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'earlier')])); + assert.deepEqual(listed, ['A']); + assert.deepEqual(h.region.pendingMessages.map((message) => message.id), ['saved-follow-up']); + }); + test('the shell\'s command handle works only while the owner is mounted', async () => { const unmounted = createComposerSubmissionCommands(); assert.throws(() => unmounted.beginEditUserMessage('turn-1'), /ComposerSubmissionProvider is not mounted/); + assert.throws(() => unmounted.handleTurnFooterAction('turn-1', 'branch'), /ComposerSubmissionProvider is not mounted/); const h = harness(); await act(async () => h.root.unmount()); assert.throws(() => h.commands.beginEditUserMessage('turn-1'), /ComposerSubmissionProvider is not mounted/); @@ -324,6 +395,8 @@ describe('Desktop Composer submission adapter', () => { remove: record('sessions.remove'), reviseBeforeTurn: record('sessions.reviseBeforeTurn'), abandonSessionCopy: record('sessions.abandonSessionCopy'), + stop: record('sessions.stop'), + branchFromTurn: record('sessions.branchFromTurn'), respondToSandboxBoundary: record('sessions.respondToSandboxBoundary'), respondToUserQuestion: record('sessions.respondToUserQuestion'), }, @@ -336,6 +409,8 @@ describe('Desktop Composer submission adapter', () => { await services.removeUnsentSession('s'); await services.reviseBeforeTurn('s', { sourceTurnId: 'turn', copyId: 'copy' }); await services.abandonSessionCopy('s', 'copy'); + await services.stop('s', { source: 'stop_button' }); + await services.branchFromTurn('s', { sourceTurnId: 'turn', copyId: 'copy' }); await services.respondToSandboxBoundary('s', { requestId: 'b' } as SandboxBoundaryResponse); await services.respondToUserQuestion('s', { requestId: 'q' } as UserQuestionResponse); assert.deepEqual(calls, [ @@ -344,6 +419,8 @@ describe('Desktop Composer submission adapter', () => { ['sessions.remove', 's'], ['sessions.reviseBeforeTurn', 's', { sourceTurnId: 'turn', copyId: 'copy' }], ['sessions.abandonSessionCopy', 's', 'copy'], + ['sessions.stop', 's', { source: 'stop_button' }], + ['sessions.branchFromTurn', 's', { sourceTurnId: 'turn', copyId: 'copy' }], ['sessions.respondToSandboxBoundary', 's', { requestId: 'b' }], ['sessions.respondToUserQuestion', 's', { requestId: 'q' }], ]); @@ -373,7 +450,7 @@ describe('Composer submission ownership', () => { assert.deepEqual(sourcesMatching(/create-composer-submission-services/), ['composition/desktop-feature-services.tsx']); // Workbar and WorkHub keep their own adapters for their own Composers. const hostCalls = sourcesMatching( - /\bsessions\s*\.\s*(?:submitMessage|reviseBeforeTurn|abandonSessionCopy|respondToSandboxBoundary|respondToUserQuestion)\b|\bnewTasks\s*\.\s*create\b/, + /\bsessions\s*\.\s*(?:submitMessage|reviseBeforeTurn|abandonSessionCopy|respondToSandboxBoundary|respondToUserQuestion|stop|branchFromTurn)\b|\bnewTasks\s*\.\s*create\b/, ); assert.ok(hostCalls.includes('platform/desktop/create-composer-submission-services.ts')); assert.deepEqual(hostCalls.filter((path) => !path.startsWith('platform/desktop/')), []); @@ -385,8 +462,14 @@ describe('Composer submission ownership', () => { test('AppShell holds no submission state and the public entry no submit construction', () => { const shell = readFileSync(join(rendererRoot, 'app-shell.tsx'), 'utf8'); - assert.doesNotMatch(shell, /revisionDraft|newTaskSendPending|createRevisionAwareOnSend|createStagedFollowUp|ChatActions\b|RevisionActions\b/); - for (const name of ['createRevisionAwareOnSend', 'createStagedFollowUp', 'useComposerSubmission', 'createChatActions', 'createRevisionActions']) { + assert.doesNotMatch( + shell, + /revisionDraft|newTaskSendPending|stopPending|createRevisionAwareOnSend|createStagedFollowUp|SessionLocalMessages|createAppShell(?:Chat|Revision|Turn)Actions|createAppShellStopAction/, + ); + for (const name of [ + 'createRevisionAwareOnSend', 'createStagedFollowUp', 'useComposerSubmission', 'createChatActions', + 'createRevisionActions', 'createStopAction', 'createTurnActions', 'SessionLocalMessages', + ]) { assert.equal(name in Conversation, false, `${name} is not a public Conversation capability`); } }); diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index ac6b46181b..e5f7c23d4b 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -26,8 +26,8 @@ import { type ComposerHandle, type TransientUserMessageProjection, } from '@maka/ui'; -import { ConversationServicesProvider, SessionLocalMessages } from '../../renderer/features/conversation/index.js'; -import { stubConversationServices, useSessionMessageQueue } from '../../renderer/features/conversation/testing.js'; +import { ConversationServicesProvider } from '../../renderer/features/conversation/index.js'; +import { SessionLocalMessages, stubConversationServices, useSessionMessageQueue } from '../../renderer/features/conversation/testing.js'; import type { RestoredDraftContent } from '../../renderer/application/contracts/transient-message-projection.js'; import type { DesktopLocalMessage } from '../../shared/session-local-contract.js'; import { mergeTransientMessageProjection } from '../../renderer/application/contracts/transient-message-projection.js'; diff --git a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts index 6befea5044..dd4a3094df 100644 --- a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts +++ b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts @@ -44,9 +44,9 @@ import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; */ function useTestWorkspace() { const target = useAppShellSessionWorkspace({ error: () => {} }); - const { workspace } = useConversationOwner(); + const { workspace, commands } = useConversationOwner(); const view = useSyncExternalStore(workspace.publication.subscribe, workspace.publication.getSnapshot); - return { ...target, ...workspace, messages: view.messages, publishedTranscriptRange: view.range, messageLoadPending: view.loading, + return { ...target, ...workspace, captureSelection: commands.captureSelection, messages: view.messages, publishedTranscriptRange: view.range, messageLoadPending: view.loading, get requestedSessionId() { return target.sessionCatalogController.getState().activeSessionId; } }; } type Workspace = ReturnType; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index caa30a0729..ed984a2994 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -117,8 +117,6 @@ import { } from './platform/desktop/default-runtime-host-operation.js'; import { useAppShellProjectContext } from './use-project-context'; import { createAppShellE2eFixtureActions } from './app-shell-e2e-fixture'; -import { createAppShellTurnActions } from './app-shell-turn-actions'; -import { createAppShellStopAction } from './app-shell-stop-action'; import { useStableActions } from './use-stable-actions'; import { useAppShellBootstrapSubscriptions, @@ -252,13 +250,9 @@ function AppShellContent({ setActiveId, startNewSession, clearOwnedSessionState, - captureSelection, isSessionSelected, retiredSessionIds, - addTransientMessage, - removeTransientMessage, sessionUiReads, - stopPendingClaims, recordSessionChange, sessionCatalogController, commitSession, @@ -349,7 +343,6 @@ function AppShellContent({ // streaming membership subscribe inside their consuming regions. const { messageLoadError: activeMessageLoadError, - stopPending, activeInteraction, activeMessageQueue, activeLiveTurnSnapshot, @@ -466,7 +459,6 @@ function AppShellContent({ // `app:info` round-trip completes on mount. const persistedComposerDefaults = loadComposerDefaults(); const composerRef = queueSurface.composer; - const restoreLocalMessageDraft = queueSurface.restoreDraft; const openComposerModelPicker = useCallback(() => { composerRef.current?.openModelPicker(); }, []); @@ -1002,33 +994,6 @@ function AppShellContent({ setUiLocaleOverride, }); - const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, { - uiLocale, - activeIdRef, - captureSelection, - turnActionRegistry, - openSessionInChat, - refreshSessions, - toastApi, - }); - const stop = createAppShellStopAction({ - uiLocale, - activeIdRef, - stopPending: stopPendingClaims, - removeTransientMessage, - toastApi, - }); - // #5904: the composer's Stop and its Resume offer share one send slot, so - // the slot must never offer to restart the very Turn the user just stopped - // from it — a repeated click would. Every composer stop path (the Stop - // button and Escape, both gated on streaming) notes the stop here, and the - // resume tracker suppresses exactly the offer that stop produces; the - // interrupted-Turn banner remains the deliberate resume path for it. - const stopOwningItsTarget = () => { - noteUserStoppedTurn(activeIdRef.current); - void stop(); - }; - useAppShellNavRefSync({ navSelection, navSelectionRef, @@ -1306,6 +1271,8 @@ function AppShellContent({ showModelSetupToast, bindNewTaskSessionResolver: commands.bindNewTaskSessionResolver, openSideChat: (options) => commands.openTool('side-chat', 'right', options), + noteUserStoppedTurn, + turnActions: turnActionRegistry, orchestrationMode: () => activeOrchestrationMode, setOrchestrationModeActive, }}> @@ -1343,13 +1310,6 @@ function AppShellContent({ canOpenDialog={activeBoundarySurface.localInteractionAvailable} reportError={showSessionError} > - openSettingsSection('memory')} deriveTurnPresentation={deriveTurnPresentation} - onTurnFooterAction={sharedSessionActive ? undefined : handleTurnFooterAction} + onTurnFooterAction={sharedSessionActive ? undefined : composerSubmission.handleTurnFooterAction} onEditUserMessage={sharedSessionActive ? undefined : composerSubmission.beginEditUserMessage} safeResumeAction={safeResumeAction} onLineageBadgeClick={(turnId) => { if (activeId) openSessionInChat(activeId, turnId); }} diff --git a/apps/desktop/src/renderer/application/contracts/use-stable-actions.ts b/apps/desktop/src/renderer/application/contracts/use-stable-actions.ts index 18a901c654..18ac1038c6 100644 --- a/apps/desktop/src/renderer/application/contracts/use-stable-actions.ts +++ b/apps/desktop/src/renderer/application/contracts/use-stable-actions.ts @@ -50,8 +50,8 @@ function createDelegatingActions(latestRef: { current: A }): A * supersedes hand-rolled `handlersRef.current = handlers` mirrors, which * published during render. * - * `createAppShellStopAction` is deliberately NOT wrapped: it returns a bare - * function (no object to facade) and only feeds JSX props, never effect deps. + * A factory that returns a bare function (no object to facade) wraps it in an + * object first, as the Composer submission owner does for its Stop action. * * The identity guarantee is asserted in `use-stable-actions.test.ts` rather * than argued for in review — see that file for why this is a contract. diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index cc12ac3117..a7530d9b43 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -42,7 +42,10 @@ status and profile changes still reach recovery and observation ownership. `useAppShellSessionUiState` is now a transitional **reader/command adapter**, not a construction hook. It exposes published/Host target identity, empty/history -facts, fixed Session reads, a keyed Stop claim, and semantic commands. Its +facts, fixed Session reads and the selection commands the shell still owns +(activate, start a new task, retire a Session). The Stop claim and the send, +transient and interaction commands are no longer on it; the Composer +submission owner reads them from the controller. Its published Session reference is a frozen getter, and consuming contracts declare it readonly. It has no map setters, range controller, publication callback, writable refs, or whole-state getter. @@ -72,9 +75,9 @@ and issues the viewport command without waiting for history reads. - Composer migration keeps `activeId` as the published draft target and `ownerActiveId` as the readable, non-shared Host target. Selection leases, transient add/update/remove, `prepareSend`, `refreshMessages`, interaction - settlement and draft restoration are semantic ports; do not re-export the - private workspace to finish M3. Composer staging/readiness/send policy stays - with that migration. + settlement and draft restoration are semantic ports that the Composer + submission owner consumes inside the feature; do not re-export the private + workspace to reach them from the shell. - The visible range is not an event watermark. Bounded-window work may extend the injected range controller and the private reading lifecycle, including return-to-latest and full-history export commands. It must preserve one @@ -125,7 +128,6 @@ Remaining transitional capabilities have explicit consumers and removal work: | Capability | Current consumer | Removal module | | --- | --- | --- | -| Stop pending claim and transient add/remove | AppShell Stop action and `SessionLocalMessages` props | M3 persistent Composer owner | | `useAppShellSessionUiReads` | AppShell chrome and Composer prop assembly | M3 regional readers; retain only required chrome | | Invocation-time published-message read | Copy/Save | M3 command ownership / bounded-history export integration | @@ -210,25 +212,30 @@ without restoring root subscriptions or acquiring the private controller. mounted beside the staging and readiness owners and stays mounted across Session and section switches. It owns the send-pending flag, the edit-and-resend draft (with the catalog watch that retires it), the retracted workspace references and -the submit, follow-up and interaction-answer paths. `createRevisionAwareOnSend`, -the staged follow-up and the chat and revision actions are assembled here, not -in AppShell, and none of them is exported from `index.ts`. +the submit, follow-up, Stop, Turn-branch and interaction-answer paths. +`createRevisionAwareOnSend`, the staged follow-up and the chat, revision, Stop +and Turn actions are assembled here, not in AppShell, and none of them is +exported from `index.ts`. Local delivery recovery (`SessionLocalMessages`) +mounts inside the owner for the published Session; its recovery policy is +unchanged. The Host operations reach the owner as `ComposerSubmissionServices`, one named operation each; the Desktop adapter is the only caller of those bridge paths. The shell supplies a `shell` port of commands it already owns (surface ownership, navigation, catalog refresh, execution-boundary reload, the Workbar's -form answer, side chat and new-task resolver, the model-setup toast, and the -selected Session's orchestration write) and a `newTask` projection read at send -time. Session Settings owns the new-task Plan, orchestration and permission +form answer, side chat and new-task resolver, the model-setup toast, the resume +tracker's stopped-Turn note, the Turn-action pending registry the transcript +renders, and the selected Session's orchestration write) and a `newTask` +projection read at send time. Session Settings owns the new-task Plan, orchestration and permission choices; the projection carries them, and creation consumes the permission choice through `clearPermissionChoice`. `ConversationComposerRegion` reads the owner in the persistent Composer slot: it -injects `onSend`, `newTaskSendPending`, the interaction answers and the revision -notice, and narrows the shell's picker gates while a draft is open. The shell -keeps only the stable `ComposerSubmissionCommands` handle, whose -`beginEditUserMessage` starts a draft from the transcript. The handle throws +injects `onSend`, `newTaskSendPending`, `onStop`/`stop` with the published +Session's Stop claim, the interaction answers and the revision notice, and +narrows the shell's picker gates while a draft is open. The shell keeps only the +stable `ComposerSubmissionCommands` handle, whose `beginEditUserMessage` and +`handleTurnFooterAction` serve the transcript. The handle throws while the owner is unmounted. The binding and reader context are private. ## Task readiness ownership (R2 M3) diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts index b07965f80d..f6d76888df 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts @@ -28,6 +28,7 @@ export function createComposerSubmissionCommands(): ComposerSubmissionCommands { }; const commands: ComposerSubmissionCommands = { beginEditUserMessage: (turnId) => requireOwner().beginEditUserMessage(turnId), + handleTurnFooterAction: (turnId, actionId) => requireOwner().handleTurnFooterAction(turnId, actionId), }; submissionBindings.set(commands, binding); return commands; diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/features/conversation/controller/stop-action.ts similarity index 82% rename from apps/desktop/src/renderer/app-shell-stop-action.ts rename to apps/desktop/src/renderer/features/conversation/controller/stop-action.ts index 8cecd319ab..8b3b319e60 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/stop-action.ts @@ -18,9 +18,10 @@ */ import type { UiLocale } from '@maka/core/ui-locale'; -import { localizedShellErrorMessage } from './locales/shell-copy.js'; -import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; -import type { SessionPendingClaim } from './features/conversation/index.js'; +import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; +import type { SessionPendingClaim } from '../model/session-ui-state.js'; +import type { ComposerSubmissionServices } from '../submission-services.js'; type ToastApi = { error( @@ -31,7 +32,8 @@ type ToastApi = { ): void; }; -export function createAppShellStopAction(deps: { +export function createStopAction(deps: { + services: Pick; uiLocale: UiLocale; activeIdRef: { readonly current: string | undefined }; stopPending: SessionPendingClaim; @@ -39,6 +41,7 @@ export function createAppShellStopAction(deps: { toastApi: ToastApi; }): () => Promise { const { + services, uiLocale, activeIdRef, stopPending, @@ -50,7 +53,7 @@ export function createAppShellStopAction(deps: { const sessionId = activeIdRef.current; if (!sessionId || !stopPending.claim(sessionId)) return; try { - const result = await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); + const result = await services.stop(sessionId, { source: 'stop_button' }); if (result?.kind === 'interrupted') { for (const messageId of result.retractedMessageIds) { removeTransientMessage(sessionId, messageId); diff --git a/apps/desktop/src/renderer/app-shell-turn-actions.ts b/apps/desktop/src/renderer/features/conversation/controller/turn-actions.ts similarity index 84% rename from apps/desktop/src/renderer/app-shell-turn-actions.ts rename to apps/desktop/src/renderer/features/conversation/controller/turn-actions.ts index afc2f62f67..c48fabc08d 100644 --- a/apps/desktop/src/renderer/app-shell-turn-actions.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/turn-actions.ts @@ -19,15 +19,15 @@ import type { StoredMessage } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; -import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; import type { TurnFooterActionMeta } from '@maka/ui'; -import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; -import { localizedShellErrorMessage } from './locales/shell-copy.js'; +import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; +import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast, -} from './session-workspace-errors.js'; -import { acquireSessionCopyAttempt } from './application/contracts/session-copy-attempt.js'; +} from '../model/session-workspace-toast.js'; +import { acquireSessionCopyAttempt } from '../../../application/contracts/session-copy-attempt.js'; +import type { ComposerSubmissionServices } from '../submission-services.js'; type ToastApi = { info(title: string, description?: string): void; @@ -40,11 +40,12 @@ type ToastApi = { ): void; }; -export interface AppShellTurnActions { +export interface TurnActions { handleTurnFooterAction(turnId: string, actionId: TurnFooterActionMeta['id']): Promise; } -export function createAppShellTurnActions(deps: { +export function createTurnActions(deps: { + services: Pick; uiLocale: UiLocale; activeIdRef: { readonly current: string | undefined }; captureSelection(): () => boolean; @@ -54,10 +55,11 @@ export function createAppShellTurnActions(deps: { keyOf(sessionId: string, turnId: string, actionId: string): string; }; openSessionInChat: (sessionId: string, turnId?: string) => void; - refreshSessions: () => Promise; + refreshSessions: () => Promise; toastApi: ToastApi; -}): AppShellTurnActions { +}): TurnActions { const { + services, uiLocale, activeIdRef, captureSelection, @@ -88,7 +90,7 @@ export function createAppShellTurnActions(deps: { }, turnId, ); - const newSession = await window.maka.sessions.branchFromTurn(sessionId, { + const newSession = await services.branchFromTurn(sessionId, { sourceTurnId: copyAttempt.sourceTurnId, copyId: copyAttempt.copyId, }); diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts index a3b0311d34..af0859e4a6 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts @@ -40,6 +40,8 @@ import { useConversationOwner } from '../ui/conversation-context.js'; import { useConversationQueueCommands } from '../ui/conversation-provider.js'; import { createChatActions } from './chat-actions.js'; import { createRevisionAwareOnSend, createStagedFollowUp } from './composer-submit.js'; +import { createStopAction } from './stop-action.js'; +import { createTurnActions } from './turn-actions.js'; import { abandonTurnRevisionCopyAttempt, completeTurnRevisionCopyAttempt, @@ -49,8 +51,9 @@ import { /** * Called only by `ComposerSubmissionProvider`. Owns the send-pending flag, the - * edit-and-resend draft and the Composer's submit path; the shell supplies - * navigation and other features' commands, and reads none of this state. + * edit-and-resend draft and the Composer's submit, Stop, Turn-branch and + * interaction-answer paths; the shell supplies navigation and other features' + * commands, and reads none of this state. */ export function useComposerSubmission(input: { readonly staging: ComposerStagingCommands; @@ -61,7 +64,8 @@ export function useComposerSubmission(input: const { staging, shell, newTask, sharedSessionActive } = input; const services = useComposerSubmissionServices(); const { workspace, commands } = useConversationOwner(); - const composerRef = useConversationQueueCommands().composer; + const queue = useConversationQueueCommands(); + const composerRef = queue.composer; const uiLocale = useUiLocale(); const toastApi = useToast(); const activeIdRef = workspace.publishedSession; @@ -177,6 +181,41 @@ export function useComposerSubmission(input: setNewTaskSendPending, }); + const turn = useStableActions(createTurnActions, { + services, + uiLocale, + activeIdRef, + captureSelection: commands.captureSelection, + turnActionRegistry: shell.turnActions, + openSessionInChat: shell.openSession, + refreshSessions: shell.refreshSessions, + toastApi, + }); + const { stop, onStop } = useStableActions((deps: Parameters[0] & { + noteUserStoppedTurn(sessionId: string | undefined): void; + }) => { + const stopSession = createStopAction(deps); + return { + stop: () => { void stopSession(); }, + // #5904: the Composer's Stop and its Resume offer share one send slot, + // so the slot must never offer to restart the very Turn the user just + // stopped from it. Stop and Escape note the stop here; the interrupted- + // Turn banner remains the deliberate resume path for it. + onStop: () => { + deps.noteUserStoppedTurn(activeIdRef.current); + void stopSession(); + }, + }; + }, { + services, + uiLocale, + activeIdRef, + stopPending: workspace.ui.stopPending, + removeTransientMessage: commands.removeTransientMessage, + toastApi, + noteUserStoppedTurn: shell.noteUserStoppedTurn, + }); + // The draft survives on exactly two catalog rows; their departure retires it. const retireRevisionDraftIfRowsLeave = useCallback( (rows: Parameters[0]) => { @@ -196,7 +235,8 @@ export function useComposerSubmission(input: const shellCommands = useMemo(() => ({ beginEditUserMessage: (turnId) => revision.beginEditUserMessage(turnId), - }), [revision]); + handleTurnFooterAction: (turnId, actionId) => turn.handleTurnFooterAction(turnId, actionId), + }), [revision, turn]); const reader = useMemo(() => ({ onSend, newTaskSendPending, @@ -205,10 +245,20 @@ export function useComposerSubmission(input: respondToSandboxBoundary: chat.respondToSandboxBoundary, respondToUserQuestion: chat.respondToUserQuestion, respondToUserForm: chat.respondToUserForm, - }), [chat, newTaskSendPending, onSend, revision, revisionDraft]); + stop, + onStop, + }), [chat, newTaskSendPending, onSend, onStop, revision, revisionDraft, stop]); return { shellCommands, reader, + // Local delivery recovery publishes into, and restores drafts for, the + // Session the Composer shows. + localMessages: { + publish: commands.addTransientMessage, + retire: commands.removeTransientMessage, + reportError: toastApi.error, + restoreDraft: queue.restoreDraft, + }, revisionWatch: { sessionIds: [revisionDraft?.sourceSessionId, revisionDraft?.draftSessionId] as const, onRows: retireRevisionDraftIfRowsLeave, diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts index e51d703dfb..56593a9fc3 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts @@ -38,11 +38,8 @@ export function useConversationTarget() { setActiveId: commands.setActiveId, startNewSession: commands.startNewSession, clearOwnedSessionState: commands.clearOwnedSessionState, - captureSelection: commands.captureSelection, isSessionSelected: commands.isSessionSelected, retiredSessionIds: commands.retiredSessionIds, - addTransientMessage: commands.addTransientMessage, - removeTransientMessage: commands.removeTransientMessage, readMessages: commands.readMessages, refreshMessages: commands.refreshMessages, recordSessionChange: commands.recordSessionChange, @@ -56,6 +53,5 @@ export function useConversationTarget() { transcriptHasHistory: chrome.hasHistory, queueSurface: useConversationQueueCommands(), sessionUiReads: workspace.ui.reads, - stopPendingClaims: workspace.ui.stopPending, }; } diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 7ba3f990d6..41076c38b3 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -18,15 +18,11 @@ */ export { resolveTaskReadinessModelTarget } from './model/task-readiness-notice.js'; -export type { - AppShellSessionUiStateController, - SessionPendingClaim, -} from './model/session-ui-state.js'; +export type { AppShellSessionUiStateController } from './model/session-ui-state.js'; export type { SessionUiReads } from './model/session-ui-reads.js'; export { useAppShellSessionUiReads } from './controller/use-session-ui-reads.js'; export type { ConversationHostChange, ConversationServices } from './ports.js'; export { ConversationServicesProvider } from './services.js'; -export { SessionLocalMessages } from './controller/session-local-messages.js'; export type { ComposerAttachmentService } from '@maka/ui/use-composer-attachments'; diff --git a/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts b/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts index ffa1a0eca9..1dea1a9522 100644 --- a/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts +++ b/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts @@ -22,7 +22,7 @@ import type { InteractionFormResponse } from '@maka/core/interaction'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; -import type { ToastDiagnosticTarget } from '@maka/ui'; +import type { ToastDiagnosticTarget, TurnFooterActionMeta } from '@maka/ui'; import type { DesktopSessionSummary } from '../../../../shared/desktop-session-projection.js'; import type { NewChatExecutionTarget } from '../controller/use-shell-chat-model.js'; import type { ConversationNewTaskTarget } from '../ports.js'; @@ -37,6 +37,7 @@ export interface ComposerSurfaceOwner { /** Commands only: the shell gets no draft, pending flag or setter through this handle. */ export interface ComposerSubmissionCommands { beginEditUserMessage(turnId: string): void; + handleTurnFooterAction(turnId: string, actionId: TurnFooterActionMeta['id']): Promise; } /** @@ -59,6 +60,14 @@ export interface ComposerSubmissionShell { showModelSetupToast(description: string, reason?: string, diagnosticTarget?: ToastDiagnosticTarget): void; bindNewTaskSessionResolver(selectionRevision: number): (sessionId: string, newTaskDraftKey?: string) => void; openSideChat(options: { initialPrompt?: string }): void; + /** Lets the resume offer skip the Turn the user just stopped from the Composer. */ + noteUserStoppedTurn(sessionId: string | undefined): void; + /** The shell's pending mask for Turn footer actions; it also renders the mask. */ + turnActions: { + addKey(key: string): boolean; + clearKey(key: string): void; + keyOf(sessionId: string, turnId: string, actionId: string): string; + }; orchestrationMode(): OrchestrationMode; setOrchestrationModeActive(mode: Exclude, active: boolean): Promise; } diff --git a/apps/desktop/src/renderer/features/conversation/submission-services.ts b/apps/desktop/src/renderer/features/conversation/submission-services.ts index c77cc9c8f1..f419af00c5 100644 --- a/apps/desktop/src/renderer/features/conversation/submission-services.ts +++ b/apps/desktop/src/renderer/features/conversation/submission-services.ts @@ -57,9 +57,15 @@ export type ConversationMessageSubmission = | { readonly ok: false; readonly reason: 'attachment_blocked'; readonly code: AttachmentIngestBlockedCode } | { readonly ok: false; readonly reason: 'outcome_unknown' }; +/** What a Stop retracted, so the Composer can drop the matching pending rows. */ +export type ConversationStopResult = + | { readonly kind: 'retracted'; readonly messageId: string } + | { readonly kind: 'interrupted'; readonly retractedMessageIds: readonly string[] } + | undefined; + /** - * The Host operations behind the Composer's sends, edit-and-resend and - * interaction answers. Each is one named operation; the owner never receives a + * The Host operations behind the Composer's sends, edit-and-resend, Stop, Turn + * branching and interaction answers. Each is one named operation; the owner never receives a * bridge namespace. */ export interface ComposerSubmissionServices { @@ -77,6 +83,11 @@ export interface ComposerSubmissionServices { input: { readonly sourceTurnId: string; readonly copyId: string }, ): Promise; abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; + stop(sessionId: string, input: { readonly source: 'stop_button' }): Promise; + branchFromTurn( + sessionId: string, + input: { readonly sourceTurnId: string; readonly copyId: string }, + ): Promise; respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise; respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise; } diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index 293937d94c..51816a21ab 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -135,3 +135,6 @@ export { createStagedFollowUp, type RevisionSendPorts, } from './controller/composer-submit.js'; +export { SessionLocalMessages } from './controller/session-local-messages.js'; +export { createStopAction } from './controller/stop-action.js'; +export { createTurnActions } from './controller/turn-actions.js'; diff --git a/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx index 6de6fb2948..8b4670d833 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx @@ -17,9 +17,10 @@ * under the License. */ -import { useLayoutEffect, type ReactNode } from 'react'; +import { useLayoutEffect, useSyncExternalStore, type ReactNode } from 'react'; import { CatalogRowWatch } from '../../../application/contracts/session-catalog/catalog-row-watch.js'; import { useSessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import { SessionLocalMessages } from '../controller/session-local-messages.js'; import { useComposerSubmission } from '../controller/use-composer-submission.js'; import { submissionBindings } from '../model/composer-submission-binding.js'; import type { ComposerStagingCommands } from '../model/composer-staging-contract.js'; @@ -30,6 +31,7 @@ import type { ComposerSurfaceOwner, } from '../model/composer-submission-contract.js'; import { ComposerSubmissionContext } from './composer-submission-context.js'; +import { useConversationOwner } from './conversation-context.js'; /** * Sole owner of the Composer's submission: the send-pending flag, the @@ -53,6 +55,8 @@ export function ComposerSubmissionProvider(p sharedSessionActive: props.sharedSessionActive, }); const catalog = useSessionCatalogController(); + const { workspace } = useConversationOwner(); + const activeId = useSyncExternalStore(workspace.target.subscribe, workspace.target.getSnapshot); const { shellCommands } = submission; useLayoutEffect(() => { const binding = submissionBindings.get(props.commands); @@ -64,6 +68,7 @@ export function ComposerSubmissionProvider(p + {props.children} ); diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx index 12041865f1..c547445890 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -66,8 +66,10 @@ export function ConversationTranscriptRegion

( } type SubmissionProps = Pick & { + stopPending: boolean; revisionNotice?: { title: string; detail: string; cancelLabel: string; onCancel(): void }; }; type ComposerProps = SubmissionProps & { @@ -77,8 +79,9 @@ type ComposerProps = SubmissionProps & { type ComposerPickGates = { contextPickEnabled?: boolean; directoryPickerEnabled?: boolean }; /** * Lives in the persistent composer slot, outside the conditional transcript. - * Submission state (send pending, the edit-and-resend draft) and the submit - * and interaction-answer callbacks come from the Composer submission owner. + * Submission state (send pending, Stop pending, the edit-and-resend draft) and + * the submit, Stop and interaction-answer callbacks come from the Composer + * submission owner. */ export function ConversationComposerRegion

( props: { surface: ComponentType

; usageModel?: string; usageRoute?: { llmConnectionId?: string } } & Omit, @@ -91,12 +94,16 @@ export function ConversationComposerRegion

( const view = useSyncExternalStore(workspace.composer.subscribe, workspace.composer.getSnapshot); const usage = useMemo(() => workspace.usage(usageModel, usageRoute?.llmConnectionId), [workspace, usageModel, usageRoute?.llmConnectionId]); const latestRequestUsageTokens = useSyncExternalStore(usage.subscribe, usage.getSnapshot); + const stopPending = useSessionUiRead(workspace.ui.reads, 'stop', activeId); const draft = submission.revisionDraft; const editing = draft !== null && activeId === draft.draftSessionId; const gates = presentation as ComposerPickGates; const owned: ComposerProps & ComposerPickGates = { onSend: submission.onSend, newTaskSendPending: submission.newTaskSendPending, + onStop: submission.onStop, + stop: submission.stop, + stopPending, respondToSandboxBoundary: submission.respondToSandboxBoundary, respondToUserQuestion: submission.respondToUserQuestion, respondToUserForm: submission.respondToUserForm, diff --git a/apps/desktop/src/renderer/platform/desktop/create-composer-submission-services.ts b/apps/desktop/src/renderer/platform/desktop/create-composer-submission-services.ts index 0fa113125c..7b43ffe106 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-composer-submission-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-composer-submission-services.ts @@ -27,6 +27,8 @@ export type DesktopComposerSubmissionBridge = { | 'remove' | 'reviseBeforeTurn' | 'abandonSessionCopy' + | 'stop' + | 'branchFromTurn' | 'respondToSandboxBoundary' | 'respondToUserQuestion' >; @@ -43,6 +45,8 @@ export function createDesktopComposerSubmissionServices( removeUnsentSession: (sessionId) => bridge.sessions.remove(sessionId), reviseBeforeTurn: (sessionId, input) => bridge.sessions.reviseBeforeTurn(sessionId, input), abandonSessionCopy: (sourceSessionId, copyId) => bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), + stop: (sessionId, input) => bridge.sessions.stop(sessionId, input), + branchFromTurn: (sessionId, input) => bridge.sessions.branchFromTurn(sessionId, input), respondToSandboxBoundary: (sessionId, response) => bridge.sessions.respondToSandboxBoundary(sessionId, response), respondToUserQuestion: (sessionId, response) => bridge.sessions.respondToUserQuestion(sessionId, response), }; diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index b6d7acb07a..07b54d46eb 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -136,7 +136,7 @@ export const ALLOWED = { useShellLiveTurn: 1, useShellMemoryPill: 1, useShellResume: 1, - useStableActions: 2, + useStableActions: 1, useState: 4, useToast: 1, // The last of the three `useKeyedPendingRegistry` call sites this entry From cd3345ceb4b06f26ba52dcf8206b1d30228f3294 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 22:10:51 +0800 Subject: [PATCH 5/5] refactor(desktop): address #5935 review follow-ups - Move the missing-working-directory toast into application/contracts/session-workspace-errors.ts. Contracts cannot import copy catalogs, so callers pass the copy. The Conversation copy is deleted, and the chat, revision and Turn actions call the contract. - Pin the steering Turn id: an owner test sends with followUpMode "steer" while a Host Turn runs and checks that the pending row carries that Turn's id before the Host answers. - TaskReadinessProvider takes the recovery Session id and the stable recovery and Add Project commands instead of a closure built on every shell render, and memoizes the picker action. A shell render with the same facts no longer republishes the notice context. Refs #4582 Generated-by: Claude Opus 5.5 --- .../composer-submission-owner.test.ts | 41 +++++++++++- .../__tests__/task-readiness-owner.test.ts | 64 +++++++++++++++---- apps/desktop/src/renderer/app-shell.tsx | 5 +- .../contracts/session-workspace-errors.ts | 16 +++++ .../renderer/features/conversation/README.md | 5 +- .../conversation/controller/chat-actions.ts | 6 +- .../controller/revision-actions.ts | 6 +- .../conversation/controller/turn-actions.ts | 6 +- .../model/session-workspace-toast.ts | 36 ----------- .../ui/task-readiness-provider.tsx | 21 ++++-- 10 files changed, 136 insertions(+), 70 deletions(-) delete mode 100644 apps/desktop/src/renderer/features/conversation/model/session-workspace-toast.ts diff --git a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts index 489b48bf3e..66bd49629e 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -46,7 +46,7 @@ import { useAppShellSessionUiState, type ComposerSubmissionServices, } from '../../renderer/features/conversation/index.js'; -import { stubConversationServices } from '../../renderer/features/conversation/testing.js'; +import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; import { createDesktopComposerSubmissionServices, type DesktopComposerSubmissionBridge, @@ -65,7 +65,7 @@ type Owner = { sessionId: string | undefined }; type ProviderProps = Parameters>[0]; interface RegionProps { - onSend(text: string): Promise; + onSend(text: string, metadata?: { followUpMode?: 'steer' | 'queue' }): Promise; newTaskSendPending: boolean; revisionNotice?: { title: string; detail: string; cancelLabel: string; onCancel(): void }; contextPickEnabled: boolean; @@ -136,10 +136,12 @@ function harness(options: { const staging = createComposerStagingCommands(); const services = stubSubmissionServices(options.services); let target!: ReturnType; + let conversation!: ReturnType; let region: RegionProps | undefined; function Composer(props: RegionProps) { region = props; return null; } function Shell() { target = useAppShellSessionUiState(); + conversation = useConversationOwner(); return createElement(Fragment, null, createElement(ConversationLifecycle, { refreshSessions: async () => [], onExecutionBoundaryChanged() {}, @@ -176,6 +178,7 @@ function harness(options: { return { root, commands, published, get target() { return target; }, + get conversation() { return conversation; }, get region() { assert.ok(region, 'the Composer slot rendered'); return region; }, notice: () => region?.revisionNotice, }; @@ -212,6 +215,40 @@ describe('ComposerSubmissionProvider', () => { assert.equal(h.region.newTaskSendPending, false); }); + test('steers into the running Host Turn: the pending row names that Turn before the Host answers', async () => { + const submitted: unknown[] = []; + const admission = deferred>>(); + const h = harness({ + services: { + submitMessage: (sessionId, placement, command) => { + submitted.push({ sessionId, placement, text: command.text }); + return admission.promise; + }, + }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'running')])); + await act(async () => h.conversation.workspace.ui.setExecution('A', { + type: 'host_execution', available: true, + rootTurn: { sessionId: 'A', turnId: 'host-turn-1', runId: 'run-1', status: 'running' }, + })); + + let sending!: Promise; + await act(async () => { sending = h.region.onSend('steer here', { followUpMode: 'steer' }); }); + assert.deepEqual(submitted, [{ sessionId: 'A', placement: 'current_turn', text: 'steer here' }]); + const pending = h.conversation.workspace.publication.getSnapshot().transientMessages + .find((message) => message.text === 'steer here'); + assert.equal(pending?.hostTurnId, 'host-turn-1'); + + await act(async () => { + admission.resolve({ + ok: true, disposition: 'steering', turnId: 'host-turn-1', attachments: [], inlineReferences: [], + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }); + assert.equal(await sending, true); + }); + }); + test('creates a new task with the new-task settings it was given and keeps an unconsumed choice', async () => { const created: unknown[] = []; let cleared = 0; diff --git a/apps/desktop/src/main/__tests__/task-readiness-owner.test.ts b/apps/desktop/src/main/__tests__/task-readiness-owner.test.ts index 4d67de7e3f..b64affb07d 100644 --- a/apps/desktop/src/main/__tests__/task-readiness-owner.test.ts +++ b/apps/desktop/src/main/__tests__/task-readiness-owner.test.ts @@ -111,16 +111,22 @@ function noticeRecorder() { return { Surface, rendered, latest: () => rendered.at(-1) }; } -function owner( - services: TaskReadinessServices, - props: Omit[0], 'children'>, - children: ReactNode, -) { +type OwnerProps = Omit[0], 'children' | 'openSessionWorkspaceRecovery'> & { + openSessionWorkspaceRecovery?: (sessionId: string) => void; +}; + +const ignoreRecovery = () => {}; + +function owner(services: TaskReadinessServices, props: OwnerProps, children: ReactNode) { return createElement(LocaleProvider, { locale: 'en', children: createElement(TaskReadinessServicesProvider, { services, - children: createElement(TaskReadinessProvider, { ...props, children }), + children: createElement(TaskReadinessProvider, { + openSessionWorkspaceRecovery: ignoreRecovery, + ...props, + children, + }), }), }); } @@ -215,33 +221,63 @@ describe('TaskReadinessProvider', () => { assert.equal(container.textContent, '', 'retry clears the notice until the new answer'); }); - test('routes a workspace blocker to the picker, and hides the action without one', async () => { + test('routes a workspace blocker to its Session\'s recovery or to Add Project, and hides the action without one', async () => { const { root } = installReactRenderer(); const { services, reads } = recordingServices(); const notice = noticeRecorder(); const view = createElement(TaskReadinessNoticeConsumer, { surface: notice.Surface }); const refreshKey = {}; - let picks = 0; - const openWorkspacePicker = () => { picks += 1; }; - - await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey, sessionId: 'a', openWorkspacePicker }, view))); + const recovered: string[] = []; + const openSessionWorkspaceRecovery = (sessionId: string) => { recovered.push(sessionId); }; + let added = 0; + const addProject = () => { added += 1; }; + + await act(async () => root.render(owner(services, { + request: sessionRequest, refreshKey, sessionId: 'a', workspaceRecoverySessionId: 'a', openSessionWorkspaceRecovery, addProject, + }, view))); await act(async () => reads[0]!.answer.resolve(snapshot('workspace', { picker: true }))); assert.equal(notice.latest()?.actionLabel, copy.workspace.actionLabel.workspace_picker); await act(async () => notice.latest()?.onAction?.()); - assert.equal(picks, 1); + assert.deepEqual(recovered, ['a'], 'a Session\'s blocker opens that Session\'s recovery'); + assert.equal(added, 0); assert.equal(reads.length, 1, 'the picker action does not read again'); + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey, sessionId: 'a', addProject }, view))); + await act(async () => notice.latest()?.onAction?.()); + assert.equal(added, 1, 'without a Session the blocker adds a project'); + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey, sessionId: 'a' }, view))); assert.equal(notice.latest()?.onAction, undefined); - await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey: {}, sessionId: 'a', openWorkspacePicker }, view))); + await act(async () => root.render(owner(services, { request: sessionRequest, refreshKey: {}, sessionId: 'a', addProject }, view))); await act(async () => reads[1]!.answer.resolve(snapshot('workspace'))); assert.equal(notice.latest()?.actionLabel, copy.workspace.actionLabel.retry); await act(async () => notice.latest()?.onAction?.()); - assert.equal(picks, 1); + assert.equal(added, 1); assert.equal(reads.length, 3, 'a workspace blocker without a picker target retries'); }); + test('a shell render with the same facts and commands leaves the notice reader alone', async () => { + const { root } = installReactRenderer(); + const { services, reads } = recordingServices(); + const notice = noticeRecorder(); + const view = createElement(TaskReadinessNoticeConsumer, { surface: notice.Surface }); + const refreshKey = {}; + const openSessionWorkspaceRecovery = () => {}; + const render = (recoverySessionId: string) => owner(services, { + request: { ...sessionRequest }, refreshKey, sessionId: 'a', + workspaceRecoverySessionId: recoverySessionId, openSessionWorkspaceRecovery, + }, view); + + await act(async () => root.render(render('a'))); + await act(async () => reads[0]!.answer.resolve(snapshot('workspace', { picker: true }))); + const rendered = notice.rendered.length; + await act(async () => root.render(render('a'))); + assert.equal(notice.rendered.length, rendered, 'a fresh request object and equal facts publish nothing new'); + await act(async () => root.render(render('b'))); + assert.equal(notice.rendered.length, rendered + 1, 'a new recovery target does'); + }); + test('model blockers stay with their own recovery surfaces', async () => { const { root, container } = installReactRenderer(); const { services, reads } = recordingServices(); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index ed984a2994..4b60b29c85 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1238,9 +1238,8 @@ function AppShellContent({ draftKey={attachmentDraftKey} directoryHostId={directoryHostId} supportsVision={composerSupportsVision}> openSessionWorkspaceRecovery(activeSession.id) - : taskEntry.selectors.canAddProject ? taskEntry.commands.addProject : undefined}> + workspaceRecoverySessionId={activeSession?.id} openSessionWorkspaceRecovery={openSessionWorkspaceRecovery} + addProject={taskEntry.selectors.canAddProject ? taskEntry.commands.addProject : undefined}> (deps: { diagnosticTarget, ); } else if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, diagnosticTarget); + showSessionWorkspaceUnavailableToast(toastApi, getShellCopy(uiLocale).errors, diagnosticTarget); } else { toastApi.error( copy.sendFailedTitle, @@ -509,7 +509,7 @@ export function createChatActions(deps: { } catch (error) { if (activeIdRef.current !== sessionId) return; if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); + showSessionWorkspaceUnavailableToast(toastApi, getShellCopy(uiLocale).errors, { sessionId }); } else { toastApi.error( copy.responseFailedTitle, diff --git a/apps/desktop/src/renderer/features/conversation/controller/revision-actions.ts b/apps/desktop/src/renderer/features/conversation/controller/revision-actions.ts index cb0a440829..009f9b075f 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/revision-actions.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/revision-actions.ts @@ -22,11 +22,11 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { userFacingText } from '@maka/core/session'; import type { ComposerHandle } from '@maka/ui'; import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; -import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast, -} from '../model/session-workspace-toast.js'; +} from '../../../application/contracts/session-workspace-errors.js'; import type { ComposerSubmissionServices } from '../submission-services.js'; import { acquireSessionCopyAttempt, @@ -323,7 +323,7 @@ export function createRevisionActions(deps: { // must be surfaced before it runs — checking after it is always stale. if (selectionIsCurrent()) { if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { + showSessionWorkspaceUnavailableToast(toastApi, getShellCopy(uiLocale).errors, { sessionId: sourceSessionId, }); } else { diff --git a/apps/desktop/src/renderer/features/conversation/controller/turn-actions.ts b/apps/desktop/src/renderer/features/conversation/controller/turn-actions.ts index c48fabc08d..05999951af 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/turn-actions.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/turn-actions.ts @@ -21,11 +21,11 @@ import type { StoredMessage } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import type { TurnFooterActionMeta } from '@maka/ui'; import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; -import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast, -} from '../model/session-workspace-toast.js'; +} from '../../../application/contracts/session-workspace-errors.js'; import { acquireSessionCopyAttempt } from '../../../application/contracts/session-copy-attempt.js'; import type { ComposerSubmissionServices } from '../submission-services.js'; @@ -104,7 +104,7 @@ export function createTurnActions(deps: { } catch (error) { if (!selectionIsCurrent()) return; if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); + showSessionWorkspaceUnavailableToast(toastApi, getShellCopy(uiLocale).errors, { sessionId }); } else { toastApi.error( copy.operationFailedTitle, diff --git a/apps/desktop/src/renderer/features/conversation/model/session-workspace-toast.ts b/apps/desktop/src/renderer/features/conversation/model/session-workspace-toast.ts deleted file mode 100644 index c86b61ac85..0000000000 --- a/apps/desktop/src/renderer/features/conversation/model/session-workspace-toast.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 { UiLocale } from '@maka/core/ui-locale'; -import type { ToastDiagnosticTarget } from '@maka/ui'; -import { getShellCopy } from '../../../locales/shell-copy.js'; - -export { isSessionWorkspaceUnavailableError } from '../../../application/contracts/session-workspace-errors.js'; - -/** The Composer owner's copy of the shell's missing-working-directory toast. */ -export function showSessionWorkspaceUnavailableToast( - toastApi: { - error(title: string, description?: string, diagnosticDetails?: string, diagnosticTarget?: ToastDiagnosticTarget): void; - }, - locale: UiLocale, - diagnosticTarget?: ToastDiagnosticTarget, -): void { - const copy = getShellCopy(locale).errors; - toastApi.error(copy.workspaceUnavailableTitle, copy.workspaceUnavailableDescription, undefined, diagnosticTarget); -} diff --git a/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx index 5e7643a050..7431c56a15 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx @@ -38,7 +38,8 @@ const TaskReadinessContext = createContext(undef * Sole owner of the Composer's task readiness. It stays mounted beside * `ComposerStagingProvider`, so section switches and transcript unmounts * neither restart nor drop the read. AppShell supplies the request projection, - * the targets and the workspace-picker command; it receives no snapshot. + * the targets and the stable workspace-recovery commands; it receives no + * snapshot. */ export function TaskReadinessProvider(props: { readonly request: TaskReadinessRequest; @@ -46,8 +47,11 @@ export function TaskReadinessProvider(props: { readonly refreshKey: unknown; readonly sessionId?: string; readonly newTaskTarget?: ConversationNewTaskTarget; - /** Where a workspace blocker's action leads; absent when nothing can be picked. */ - readonly openWorkspacePicker?: () => void; + /** A workspace blocker on this Session opens its workspace recovery. */ + readonly workspaceRecoverySessionId?: string; + readonly openSessionWorkspaceRecovery: (sessionId: string) => void; + /** Without a Session, a workspace blocker adds a project; absent when it cannot. */ + readonly addProject?: () => void; readonly children?: ReactNode; }) { const { snapshot, refresh } = useTaskSubmissionReadiness( @@ -57,9 +61,16 @@ export function TaskReadinessProvider(props: { props.sessionId, props.newTaskTarget, ); + const { workspaceRecoverySessionId: recoverySessionId, openSessionWorkspaceRecovery, addProject } = props; + // Facts and stable commands, so a shell render with the same Session and + // permission leaves the notice reader alone. + const openWorkspacePicker = useMemo( + () => recoverySessionId ? () => openSessionWorkspaceRecovery(recoverySessionId) : addProject, + [recoverySessionId, openSessionWorkspaceRecovery, addProject], + ); const owner = useMemo( - () => ({ snapshot, refresh, openWorkspacePicker: props.openWorkspacePicker }), - [snapshot, refresh, props.openWorkspacePicker], + () => ({ snapshot, refresh, openWorkspacePicker }), + [snapshot, refresh, openWorkspacePicker], ); return {props.children}; }