diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c243a7e76f..0fdf034948 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,15 +12,11 @@ "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", "src/renderer/app.tsx", "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 +33,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 +81,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,9 +161,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/task-readiness-notice.ts", "src/renderer/theme.ts", "src/renderer/titlebar-dim-color.ts", "src/renderer/titlebar-modal-sync.ts", @@ -186,7 +175,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", @@ -203,12 +191,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", @@ -231,6 +216,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", @@ -252,6 +244,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", @@ -314,6 +313,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", @@ -322,6 +322,7 @@ "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" ], "rootSymbolUses": { @@ -347,6 +348,7 @@ "src/renderer/features/conversation/index.ts": { "appShell": [ "ComposerStagingProvider", + "ComposerSubmissionProvider", "ConversationComposerRegion", "ConversationLifecycle", "ConversationMessageConsumer", @@ -355,30 +357,25 @@ "NEW_TASK_PENDING_KEY", "PlanExecutionSurface", "PlanProvider", - "SessionLocalMessages", - "activeHostTurn", - "canSubmitExecutor", + "TaskReadinessProvider", "chatTurnActivity", "createComposerStagingCommands", - "createRevisionAwareOnSend", - "createStagedFollowUp", - "deriveTaskReadinessNotice", + "createComposerSubmissionCommands", "desktopSlashCommandPresentation", "executorComposerProps", - "newTaskConfiguration", "resolveTaskReadinessModelTarget", - "toSubmittedAttachments", "useActiveExecutionBoundary", "useAppShellSessionUiReads", "useAppShellSessionUiState", - "useNewTaskChoice", "useShellChatModel", "useShellResume" ], "composition": [ "ComposerStagingServicesProvider", + "ComposerSubmissionServicesProvider", "ConversationServicesProvider", - "PlanServicesProvider" + "PlanServicesProvider", + "TaskReadinessServicesProvider" ] }, "src/renderer/features/diagnostics/index.ts": { @@ -515,34 +512,6 @@ }, "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": {}, @@ -738,71 +707,8 @@ "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": { - "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": 2, - "bridgePaths": { - "window.maka.sessions.branchFromTurn": 1 - }, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [ - "createAppShellTurnActions" - ], - "dependencyPaths": { - "./application/contracts/conversation-copy.js": 1, - "./locales/shell-copy.js": 1, - "./session-copy-attempt.js": 1, - "./session-workspace-errors.js": 1 - }, - "importSpecifiers": 3, - "nonTriviaTokens": 561 - }, "src/renderer/app-shell.tsx": { - "importDeclarations": 50, + "importDeclarations": 43, "bridgePaths": { "window.maka.attachments.readBytes": 1, "window.maka.onboarding.setMilestone": 1, @@ -825,9 +731,8 @@ "useAppShellTurnPresentation": 1, "useEffect": 2, "useLayoutEffect": 1, - "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, - "useRef": 7, + "useRef": 5, "useSessionNavigationReads": 1, "useSessionSettingIntent": 1, "useShellAppearance": 1, @@ -836,10 +741,9 @@ "useShellLiveTurn": 1, "useShellMemoryPill": 1, "useShellResume": 1, - "useStableActions": 4, - "useState": 10, + "useStableActions": 1, + "useState": 6, "useSystemUiLocale": 1, - "useTaskSubmissionReadiness": 1, "useToast": 1, "useTurnActionRegistry": 1 }, @@ -848,17 +752,13 @@ "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, @@ -867,7 +767,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, @@ -883,7 +782,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, @@ -905,7 +803,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, @@ -917,8 +814,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 74, - "nonTriviaTokens": 9051 + "importSpecifiers": 63, + "nonTriviaTokens": 8221 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -1082,17 +979,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": { @@ -1270,19 +1156,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, @@ -1315,15 +1188,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": { @@ -1812,17 +1676,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": {}, @@ -3501,37 +3354,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/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, @@ -3728,24 +3550,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": { @@ -4022,22 +3826,6 @@ "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" - ] - }, { "capability": "conversation-runtime-and-presentation", "targetZone": "features/conversation", 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__/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__/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..ec54d7c0be --- /dev/null +++ b/apps/desktop/src/main/__tests__/composer-submission-fixture.ts @@ -0,0 +1,107 @@ +/* + * 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'), + stop: unexpected('stop'), + branchFromTurn: unexpected('branchFromTurn'), + 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: () => {}, + turnActions: { addKey: () => true, clearKey: () => {}, keyOf: (...parts) => parts.join(':') }, + 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..b7733caea8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -0,0 +1,516 @@ +/* + * 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, useConversationOwner } 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, metadata?: { followUpMode?: 'steer' | 'queue' }): 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; + stop(): void; + onStop(): void; + stopPending: boolean; + pendingMessages: ReadonlyArray<{ id: string }>; +} + +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; + listMessages?: ReturnType['listMessages']; +} = {}) { + const { root } = installReactRenderer(); + const catalog = createSessionCatalogController(); + catalog.commitSessions(['A', 'B'].map(row)); + const published: Array<(messages: StoredMessage[]) => void> = []; + const conversationServices = stubConversationServices( + options.listMessages ? { listMessages: options.listMessages } : {}, + ); + 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 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() {}, + 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 conversation() { return conversation; }, + 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('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; + 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('owns Stop: claims it for the published Session and drops the rows the Host retracts', async () => { + const stopping = deferred>>(); + const calls: unknown[] = []; + const h = harness({ + services: { + stop: (sessionId, input) => { calls.push([sessionId, input]); return stopping.promise; }, + }, + }); + const pendingIds = () => h.conversation.workspace.publication.getSnapshot().transientMessages.map((message) => message.id); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'running')])); + await act(async () => h.conversation.commands.addTransientMessage('A', { + id: 'queued-1', text: 'queued', ts: 1, transientPlacement: 'transcript', + })); + assert.deepEqual(pendingIds(), ['queued-1']); + + await act(async () => h.region.onStop()); + assert.deepEqual(calls, [['A', { source: 'stop_button' }]]); + assert.equal(h.region.stopPending, true, 'the Stop claim is read in the Composer slot'); + await act(async () => h.region.stop()); + assert.equal(calls.length, 1, 'a question prompt\'s Stop shares the claim, so no second request is sent'); + + await act(async () => stopping.resolve({ kind: 'interrupted', retractedMessageIds: ['queued-1'] })); + assert.equal(h.region.stopPending, false); + assert.deepEqual(pendingIds(), [], 'the retracted row leaves the transcript'); + }); + + 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/); + }); +}); + +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'), + stop: record('sessions.stop'), + branchFromTurn: record('sessions.branchFromTurn'), + 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.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, [ + ['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.stop', 's', { source: 'stop_button' }], + ['sessions.branchFromTurn', 's', { sourceTurnId: 'turn', copyId: '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|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/')), []); + 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|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__/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__/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-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/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/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/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..b64affb07d --- /dev/null +++ b/apps/desktop/src/main/__tests__/task-readiness-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, 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) }; +} + +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, { + openSessionWorkspaceRecovery: ignoreRecovery, + ...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 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 = {}; + 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.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', 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(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(); + 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/README.md b/apps/desktop/src/renderer/README.md index c74c8d541c..941a7482bc 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -278,22 +278,21 @@ or reason, which stays with review. | `AppShell` | `useState` | `uiLocalePreference` | `LocaleProvider`; appearance settings | AppShell | the persisted locale preference and its setter | locale | — | | `AppShell` | `useState` | `uiLocaleOverride` | `LocaleProvider`; E2E locale override | AppShell | a runtime locale override above every region | locale | — | | `AppShell` | `useSystemUiLocale` | `systemUiLocale` | `resolveUiLocale` for `LocaleProvider` | AppShell | read the OS locale and its changes | locale | — | -| `AppShellContent` | `useActiveExecutionBoundary` | `activeExecutionBoundary` | Composer permission control; chat actions reload it | Conversation | read and reload the owner Session's execution boundary | — | M3 | +| `AppShellContent` | `useActiveExecutionBoundary` | `activeExecutionBoundary` | Composer permission control; the Composer submission owner reloads it after a boundary answer | Conversation | read and reload the owner Session's execution boundary | — | M3 | | `AppShellContent` | `useAppShellBootstrapSubscriptions` | Main change subscriptions | Session, connection, Host-profile and settings refreshers; app-window commands | legacy `app-shell-effects.ts` | subscribe to Main change events and dispatch them to region refreshers | — | M5 | | `AppShellContent` | `useAppShellHostEffects` | platform tag and titlebar modal sync | ``; titlebar | legacy `app-shell-effects.ts` | read the platform once and observe top-layer modals; the `app.info` read moves behind an adapter in M5 | layout | — | | `AppShellContent` | `useAppShellNavRefSync` | `navSelectionRef` | ownership checks of async results | AppShell | mirror the navigation selection into a ref | navigation | — | | `AppShellContent` | `useAppShellPersistenceEffects` | theme and navigation persistence | `` theme class and palette; stored navigation | legacy `app-shell-effects.ts` | apply the theme preference and palette; persist the navigation state | layout | — | | `AppShellContent` | `useAppShellProjectContext` | project context | titlebar project name; project picker and commands | legacy `use-project-context.ts` | read the owner Session's project and run project commands | — | M5 | -| `AppShellContent` | `useAppShellSessionUiReads` | displayed Session chrome | stop, interaction, queue, live-turn and execution chrome; Composer props | Conversation (transitional reader) | fixed-purpose reads of the displayed and owner Session | — | M3 | -| `AppShellContent` | `useAppShellSessionWorkspace` | Session workspace | every region's requested, published and owner Session | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection and the catalog controller; its transient and interaction commands leave with M3 | navigation | — | +| `AppShellContent` | `useAppShellSessionUiReads` | displayed Session chrome | interaction, queue, live-turn and execution chrome; Composer props | Conversation (transitional reader) | fixed-purpose reads of the displayed and owner Session | — | M3 | +| `AppShellContent` | `useAppShellSessionWorkspace` | Session workspace | every region's requested, published and owner Session | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection and the catalog controller | navigation | — | | `AppShellContent` | `useAppShellTurnPresentation` | `deriveTurnPresentation` | `ChatView` turn footer | application contract `turn-presentation` | derive turn presentation from the transcript projection and pending turn actions | — | M3 | | `AppShellContent` | `useEffect` | `setWorkHubEnabled`: WorkHub enablement subscription | `workHubEnabled`, `workHubActive` | AppShell | read the client WorkHub setting and follow its changes | — | M5 | | `AppShellContent` | `useEffect` | `defaultHostConnections`: onboarding connection seed | default-Host connection projection | AppShell | seed default-Host connections from the onboarding snapshot | — | M5 | | `AppShellContent` | `useLayoutEffect` | `openSessionInChatRef` publication | turn footer, Module Hub, titlebar parent link | AppShell | publish the current open-Session command into a ref | cross-region command | — | -| `AppShellContent` | `useNewTaskChoice` | new-task permission choice | Composer permission control; chat actions | Conversation (transitional) | the per-draft permission choice | — | M3 | | `AppShellContent` | `useOnboardingSnapshot` | onboarding snapshot | hero, connection seed, readiness, send outcomes | legacy `use-onboarding-snapshot.ts` | read onboarding state from Main | — | M5 | | `AppShellContent` | `useSessionNavigationReads` | rail reads | command palette sessions, titlebar parent, `--maka-sidenav-width` | Session Navigation | revision navigation, the active parent Session and the rail layout | navigation | — | -| `AppShellContent` | `useSessionSettingIntent` | selected-Session setting overlay | Composer model and mode controls | Session Settings | an equality-selected overlay read and setting commands | — | M3 | +| `AppShellContent` | `useSessionSettingIntent` | selected-Session setting overlay | Composer model and mode controls; new-task settings for creation | Session Settings | an equality-selected overlay read, the new-task settings and setting commands | — | M3 | | `AppShellContent` | `useShellAppearance` | appearance settings | theme, palette, user label, Workbar toggle position, locale update gate | legacy `use-shell-appearance.ts` | read and write client appearance settings | layout | — | | `AppShellContent` | `useShellChatModel` | Composer model selection | model picker, health notice, new-chat model | Conversation (transitional) | derive model, thinking and executor selection | — | M3 | | `AppShellContent` | `useShellConnections` | `newTaskConnections` | new-task model choices | legacy `use-shell-connections.ts` | the new-task target's connection snapshot and refresh | application lifecycle | — | @@ -301,22 +300,14 @@ or reason, which stays with review. | `AppShellContent` | `useShellConnections` | `sessionHostConnections` | owner Session model choices | legacy `use-shell-connections.ts` | the owner Session Host's connection snapshot and refresh | application lifecycle | — | | `AppShellContent` | `useShellLiveTurn` | live-turn flags | mode-change gating, model switch, pet activity | Conversation reads | derive streaming and settled flags from the owner Session snapshot | — | M3 | | `AppShellContent` | `useShellMemoryPill` | memory pill | titlebar memory pill | legacy `use-shell-memory-pill.ts` | read and refresh the owner Session's memory state | layout | — | -| `AppShellContent` | `useShellResume` | resume offer | Composer send slot | Conversation | per-Session resume availability and stop notes | — | M3 | +| `AppShellContent` | `useShellResume` | resume offer | Composer send slot | Conversation | per-Session resume availability | — | M3 | | `AppShellContent` | `useStableActions` | `createAppShellE2eFixtureActions` | E2E fixture command | AppShell | apply test fixtures across navigation, rail, Workbar and appearance | cross-region command | — | -| `AppShellContent` | `useStableActions` | `createAppShellChatActions` | Composer send and interaction responses | legacy `app-shell-chat-actions.ts` | send, enqueue and interaction commands | — | M3 | -| `AppShellContent` | `useStableActions` | `createAppShellTurnActions` | turn footer | legacy `app-shell-turn-actions.ts` | turn footer commands | — | M3 | -| `AppShellContent` | `useStableActions` | `createAppShellRevisionActions` | edit and resend | legacy `app-shell-revision-actions.ts` | revision draft commands | — | M3 | -| `AppShellContent` | `useState` | `newTaskSendPending` | Composer send slot | AppShell | the pending flag of a new-task send | — | M3 | -| `AppShellContent` | `useState` | `newChatPlanModeActive` | Composer Plan toggle; mentions; chat actions | AppShell | the new chat's Plan choice | — | M3 | -| `AppShellContent` | `useState` | `newChatOrchestrationMode` | Composer orchestration control; chat actions | AppShell | the new chat's orchestration choice | — | M3 | | `AppShellContent` | `useState` | `petCompletionNonce` | custom pet companion | AppShell | a counter the transcript bumps when the active Turn completes | cross-region command | — | | `AppShellContent` | `useState` | `navigationState` | navigation sections; stored navigation | AppShell | the selected section and each hub's module | navigation | — | | `AppShellContent` | `useState` | `workHubEnabled` | WorkHub dock; Workbar input | AppShell | the client WorkHub setting | — | M5 | | `AppShellContent` | `useState` | `workHubActive` | WorkHub or Session surface | AppShell | whether the WorkHub surface is shown | navigation | — | -| `AppShellContent` | `useState` | `revisionDraft` | edit and resend; Composer | AppShell | the open revision draft | — | M3 | -| `AppShellContent` | `useTaskSubmissionReadiness` | task readiness | Composer readiness notice | legacy `use-task-submission-readiness.ts` | read readiness for the Composer target | — | M3 | | `AppShellContent` | `useToast` | `toastApi` | toasts of every legacy action | Astryx toast provider | show toasts | cross-region command | — | -| `AppShellContent` | `useTurnActionRegistry` | pending turn actions | turn footer disabled mask; bootstrap clears | legacy `use-turn-action-registry.ts` | pending action keys per Session | — | M3 | +| `AppShellContent` | `useTurnActionRegistry` | pending turn actions | turn footer disabled mask; the Composer submission owner's Turn branch; bootstrap clears | legacy `use-turn-action-registry.ts` | pending action keys per Session | — | M3 | ### Transitional feature exports outside Conversation diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 076424fcee..e9655d986b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -29,12 +29,8 @@ 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 { 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'; @@ -61,7 +57,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'; @@ -79,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'; @@ -90,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'; @@ -127,15 +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 { 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 { useAppShellBootstrapSubscriptions, @@ -148,7 +129,6 @@ import { useTurnActionRegistry } from './use-turn-action-registry'; import { desktopSlashCommandPresentation, useActiveExecutionBoundary, - useNewTaskChoice, useShellChatModel, useShellResume, } from './features/conversation/index.js'; @@ -258,7 +238,6 @@ function AppShellContent({ const { readMessages, refreshMessages, - prepareSend, transcriptEmpty, transcriptHasHistory, authoritativeSessionIds, @@ -270,21 +249,11 @@ function AppShellContent({ bootstrapSelectionLease, setActiveId, startNewSession, - readSelectionRevision, clearOwnedSessionState, - captureSelection, isSessionSelected, retiredSessionIds, - addTransientMessage, - updateTransientMessage, - removeTransientMessage, sessionUiReads, - stopPendingClaims, - markInteractionChanged, - settleInteraction, - clearMessageLoadError, recordSessionChange, - compactSession, sessionCatalogController, commitSession, activeCatalogSession, @@ -332,15 +301,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); - // 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 composerSubmission = useMemo(Conversation.createComposerSubmissionCommands, []); const [petCompletionNonce, setPetCompletionNonce] = useState(0); const [navigationState, setNavigationState] = useState(() => readNavigationState()); const navSelection = navigationState.selection; @@ -382,7 +343,6 @@ function AppShellContent({ // streaming membership subscribe inside their consuming regions. const { messageLoadError: activeMessageLoadError, - stopPending, activeInteraction, activeMessageQueue, activeLiveTurnSnapshot, @@ -477,6 +437,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. @@ -486,7 +451,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 @@ -494,36 +459,9 @@ 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(); }, []); - 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 } = useShellResume({ activeId, ownerActiveId, @@ -534,7 +472,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 @@ -662,7 +599,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); @@ -679,7 +616,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); @@ -756,12 +693,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 @@ -921,13 +858,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 @@ -944,14 +874,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(); @@ -986,7 +916,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, @@ -1064,142 +994,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: newTaskPermissionChoice, - clearNewChatPermissionChoice: clearNewTaskPermissionChoice, - newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', - newChatOrchestrationMode, - newTaskTarget: taskEntry.selectors.target, - }); - - const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, { - uiLocale, - activeIdRef, - captureSelection, - turnActionRegistry, - openSessionInChat, - 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, - stopPending: stopPendingClaims, - removeTransientMessage, - toastApi, - }); - useAppShellNavRefSync({ navSelection, navSelectionRef, @@ -1375,10 +1169,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 && @@ -1444,12 +1236,51 @@ function AppShellContent({ // on catalog moves. + + { + 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), + turnActions: turnActionRegistry, + orchestrationMode: () => activeOrchestrationMode, + setOrchestrationModeActive, + }}> activeIdRef.current === sessionId, + newTaskChoiceKey: currentNewTaskDraftKey, newSessionPermissionMode, refreshCatalog: refreshSessions, saveComposerDefaults: (model) => saveComposerDefaults({ model }), @@ -1469,7 +1300,6 @@ function AppShellContent({ }, captureOwner: captureComposerImportOwner, isOwnerActive: isComposerImportOwnerActive, - setNewTaskPermissionMode, confirmBypass: () => confirmBypassPermission(toastApi, uiLocale), }} > @@ -1478,18 +1308,6 @@ function AppShellContent({ canOpenDialog={activeBoundarySurface.localInteractionAvailable} reportError={showSessionError} > - - { 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 }} @@ -1861,8 +1659,8 @@ function AppShellContent({ memoryActive={memoryActive} onOpenMemorySettings={sharedSessionActive ? undefined : () => openSettingsSection('memory')} deriveTurnPresentation={deriveTurnPresentation} - onTurnFooterAction={sharedSessionActive ? undefined : handleTurnFooterAction} - onEditUserMessage={sharedSessionActive ? undefined : (turnId) => { void beginEditUserMessage(turnId); }} + onTurnFooterAction={sharedSessionActive ? undefined : composerSubmission.handleTurnFooterAction} + onEditUserMessage={sharedSessionActive ? undefined : composerSubmission.beginEditUserMessage} safeResumeAction={safeResumeAction} onLineageBadgeClick={(turnId) => { if (activeId) openSessionInChat(activeId, turnId); }} onReadAttachmentBytes={window.maka.attachments.readBytes} @@ -1904,16 +1702,6 @@ function AppShellContent({ activeBoundarySurface.localInteractionAvailable } workspaceReadinessRecovery={workspaceReadinessRecovery} - taskReadinessNotice={taskReadinessNotice} - onTaskReadinessAction={ - taskReadinessNotice?.action === 'workspace_picker' - ? activeSession - ? () => openSessionWorkspaceRecovery(activeSession.id) - : taskEntry.selectors.canAddProject - ? taskEntry.commands.addProject - : undefined - : taskReadiness.refresh - } showOnboardingHero={showOnboardingHero} onboardingState={onboardingState} onOpenSettings={openSettingsSection} @@ -2004,6 +1792,8 @@ 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/application/contracts/session-workspace-errors.ts b/apps/desktop/src/renderer/application/contracts/session-workspace-errors.ts index 1f68d43830..e0131e094d 100644 --- a/apps/desktop/src/renderer/application/contracts/session-workspace-errors.ts +++ b/apps/desktop/src/renderer/application/contracts/session-workspace-errors.ts @@ -17,6 +17,8 @@ * under the License. */ +import type { ToastDiagnosticTarget } from '@maka/ui'; + // Main raises this code (main/project-context-root.ts). An IPC rejection keeps // only the message, so the code is matched as its prefix as well. const SESSION_WORKSPACE_UNAVAILABLE_CODE = 'SESSION_WORKSPACE_UNAVAILABLE'; @@ -27,3 +29,17 @@ export function isSessionWorkspaceUnavailableError(error: unknown): boolean { return event.code === SESSION_WORKSPACE_UNAVAILABLE_CODE || (typeof event.message === 'string' && event.message.includes(`${SESSION_WORKSPACE_UNAVAILABLE_CODE}:`)); } + +/** + * The missing-working-directory toast. Contracts cannot import copy catalogs, + * so the caller passes its locale's copy. + */ +export function showSessionWorkspaceUnavailableToast( + toastApi: { + error(title: string, description?: string, diagnosticDetails?: string, diagnosticTarget?: ToastDiagnosticTarget): void; + }, + copy: { readonly workspaceUnavailableTitle: string; readonly workspaceUnavailableDescription: string }, + diagnosticTarget?: ToastDiagnosticTarget, +): void { + toastApi.error(copy.workspaceUnavailableTitle, copy.workspaceUnavailableDescription, undefined, diagnosticTarget); +} 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/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/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/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 +144,8 @@ 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..442a301e9c 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -42,12 +42,16 @@ 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. `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`. @@ -71,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 @@ -124,10 +128,8 @@ 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 | | `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,6 +203,61 @@ 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. +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, 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, 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`, `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) + +`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 +Session whose workspace recovery a blocker opens, with the stable recovery and +Add Project commands. The provider resolves the picker action from those facts, +so a shell render with the same facts leaves the notice reader alone. 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/app-shell-chat-actions.ts b/apps/desktop/src/renderer/features/conversation/controller/chat-actions.ts similarity index 89% 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..b62da16119 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 '../../../application/contracts/session-workspace-errors.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. @@ -445,7 +436,7 @@ export function createAppShellChatActions(deps: { diagnosticTarget, ); } else if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, diagnosticTarget); + showSessionWorkspaceUnavailableToast(toastApi, getShellCopy(uiLocale).errors, diagnosticTarget); } else { toastApi.error( copy.sendFailedTitle, @@ -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 } : {}), @@ -518,7 +509,7 @@ export function createAppShellChatActions(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, @@ -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..f6d76888df --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts @@ -0,0 +1,35 @@ +/* + * 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), + handleTurnFooterAction: (turnId, actionId) => requireOwner().handleTurnFooterAction(turnId, actionId), + }; + 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 92% 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..009f9b075f 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 { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast, -} from './session-workspace-errors.js'; +} from '../../../application/contracts/session-workspace-errors.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, }); @@ -321,7 +323,7 @@ export function createAppShellRevisionActions(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 { @@ -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/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 81% rename from apps/desktop/src/renderer/app-shell-turn-actions.ts rename to apps/desktop/src/renderer/features/conversation/controller/turn-actions.ts index 6db7f7859d..05999951af 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 { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast, -} from './session-workspace-errors.js'; -import { acquireSessionCopyAttempt } from './session-copy-attempt.js'; +} from '../../../application/contracts/session-workspace-errors.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, }); @@ -102,7 +104,7 @@ export function createAppShellTurnActions(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/controller/use-composer-submission.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts new file mode 100644 index 0000000000..5f5da6dea7 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts @@ -0,0 +1,255 @@ +/* + * 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 { createStopAction } from './stop-action.js'; +import { createTurnActions } from './turn-actions.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, 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; + readonly shell: ComposerSubmissionShell; + readonly newTask: ComposerNewTaskSubmission; + readonly sharedSessionActive: boolean; +}) { + const { staging, shell, newTask, sharedSessionActive } = input; + const services = useComposerSubmissionServices(); + const { workspace, commands } = useConversationOwner(); + const queue = useConversationQueueCommands(); + const composerRef = queue.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, + }); + + const turn = useStableActions(createTurnActions, { + services, + uiLocale, + activeIdRef, + captureSelection: commands.captureSelection, + turnActionRegistry: shell.turnActions, + openSessionInChat: shell.openSession, + refreshSessions: shell.refreshSessions, + toastApi, + }); + // The Composer's Stop button, Escape and a question prompt's Stop all land + // here; the send slot may then offer Resume for the stopped Turn (#5923). + const { stop } = useStableActions((deps: Parameters[0]) => { + const stopSession = createStopAction(deps); + return { stop: () => { void stopSession(); } }; + }, { + services, + uiLocale, + activeIdRef, + stopPending: workspace.ui.stopPending, + removeTransientMessage: commands.removeTransientMessage, + toastApi, + }); + + // 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), + handleTurnFooterAction: (turnId, actionId) => turn.handleTurnFooterAction(turnId, actionId), + }), [revision, turn]); + const reader = useMemo(() => ({ + onSend, + newTaskSendPending, + revisionDraft, + cancelRevisionDraft: () => { void revision.cancelRevisionDraft(); }, + respondToSandboxBoundary: chat.respondToSandboxBoundary, + respondToUserQuestion: chat.respondToUserQuestion, + respondToUserForm: chat.respondToUserForm, + stop, + }), [chat, newTaskSendPending, onSend, 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 f631d497b0..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,21 +38,11 @@ export function useConversationTarget() { setActiveId: commands.setActiveId, startNewSession: commands.startNewSession, clearOwnedSessionState: commands.clearOwnedSessionState, - 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, @@ -63,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/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/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..41076c38b3 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -17,25 +17,15 @@ * under the License. */ -export { - deriveTaskReadinessNotice, - isTaskSubmissionHardBlocked, - resolveTaskReadinessModelTarget, - type TaskReadinessNotice, -} from './model/task-readiness-notice.js'; -export type { - AppShellSessionUiStateController, - SessionPendingClaim, -} from './model/session-ui-state.js'; +export { resolveTaskReadinessModelTarget } from './model/task-readiness-notice.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'; -export { type PendingAttachment, toSubmittedAttachments } from '@maka/ui/composer-attachments'; export { NEW_TASK_PENDING_KEY, selectPending, @@ -55,19 +45,14 @@ 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'; 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'; @@ -86,9 +71,13 @@ 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'; 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'; +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/task-readiness-notice.ts b/apps/desktop/src/renderer/features/conversation/model/composer-submission-binding.ts similarity index 69% rename from apps/desktop/src/renderer/task-readiness-notice.ts rename to apps/desktop/src/renderer/features/conversation/model/composer-submission-binding.ts index 201410fdd3..7641898bbc 100644 --- a/apps/desktop/src/renderer/task-readiness-notice.ts +++ b/apps/desktop/src/renderer/features/conversation/model/composer-submission-binding.ts @@ -17,9 +17,10 @@ * under the License. */ -export { - deriveTaskReadinessNotice, - isTaskSubmissionHardBlocked, - resolveTaskReadinessModelTarget, - type TaskReadinessNotice, -} from './features/conversation/index.js'; +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..1b857842ca --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts @@ -0,0 +1,84 @@ +/* + * 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, 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'; +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; + handleTurnFooterAction(turnId: string, actionId: TurnFooterActionMeta['id']): Promise; +} + +/** + * 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; + /** 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; +} + +/** 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/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/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/submission-services.ts b/apps/desktop/src/renderer/features/conversation/submission-services.ts new file mode 100644 index 0000000000..f419af00c5 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/submission-services.ts @@ -0,0 +1,97 @@ +/* + * 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' }; + +/** 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, Stop, Turn + * branching 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; + 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; +} + +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 6593c627ab..51816a21ab 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -122,3 +122,19 @@ 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'; +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'; +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-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..8b4670d833 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx @@ -0,0 +1,75 @@ +/* + * 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, 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'; +import type { + ComposerNewTaskSubmission, + ComposerSubmissionCommands, + ComposerSubmissionShell, + 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 + * 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 { workspace } = useConversationOwner(); + const activeId = useSyncExternalStore(workspace.target.subscribe, workspace.target.getSnapshot); + 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..1410ba2fbb 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 & { + onStop: ComposerSubmissionReader['stop']; + stopPending: boolean; + 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, 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, ) { 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 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.stop, + stop: submission.stop, + stopPending, + 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/conversation/ui/task-readiness-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx new file mode 100644 index 0000000000..7431c56a15 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx @@ -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 { 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 stable workspace-recovery commands; 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; + /** 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( + useTaskReadinessServices(), + props.request, + props.refreshKey, + 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 }), + [snapshot, refresh, 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/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/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..7b43ffe106 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-composer-submission-services.ts @@ -0,0 +1,53 @@ +/* + * 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' + | 'stop' + | 'branchFromTurn' + | '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), + 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/apps/desktop/src/renderer/platform/desktop/create-task-readiness-services.ts b/apps/desktop/src/renderer/platform/desktop/create-task-readiness-services.ts new file mode 100644 index 0000000000..e6670a14c4 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-task-readiness-services.ts @@ -0,0 +1,35 @@ +/* + * 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 { 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/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 e7a38a7c10..f9ce4b3a30 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:** 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 | @@ -75,6 +76,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..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 @@ -45,6 +46,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..07b54d46eb 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, @@ -137,9 +136,8 @@ export const ALLOWED = { useShellLiveTurn: 1, useShellMemoryPill: 1, useShellResume: 1, - useStableActions: 4, - useState: 8, - useTaskSubmissionReadiness: 1, + useStableActions: 1, + 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