diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index bb6369a8dce..c7e27ba7e56 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -26,7 +26,6 @@ "src/renderer/computer-use-overlay/engine/cursor-candidate-grid.ts", "src/renderer/computer-use-overlay/engine/cursor-engine.ts", "src/renderer/computer-use-overlay/engine/palette.ts", - "src/renderer/conversation-markdown.ts", "src/renderer/custom-pet-companion-model.ts", "src/renderer/custom-pet-companion.tsx", "src/renderer/daily-review-actions.ts", @@ -162,16 +161,13 @@ "src/renderer/titlebar-dim-color.ts", "src/renderer/titlebar-modal-sync.ts", "src/renderer/use-app-shell-session-list.ts", - "src/renderer/use-app-shell-session-ui-reads.ts", "src/renderer/use-app-shell-session-workspace.ts", "src/renderer/use-project-context.ts", "src/renderer/use-shell-appearance.ts", "src/renderer/use-shell-connections.ts", - "src/renderer/use-shell-live-turn.ts", "src/renderer/use-shell-memory-pill.ts", "src/renderer/use-stable-actions.ts", "src/renderer/use-system-ui-locale.ts", - "src/renderer/use-turn-action-registry.ts", "src/renderer/use-work-board.ts", "src/renderer/work-board-panel.tsx", "src/renderer/workspace-readiness-recovery.ts" @@ -345,7 +341,9 @@ "appShell": [ "ComposerStagingProvider", "ComposerSubmissionProvider", + "ConversationActivityConsumer", "ConversationComposerRegion", + "ConversationHomeSurface", "ConversationLifecycle", "ConversationMessageConsumer", "ConversationProvider", @@ -354,18 +352,13 @@ "PlanExecutionSurface", "PlanProvider", "TaskReadinessProvider", - "chatTurnActivity", "createComposerStagingCommands", "createComposerSubmissionCommands", "createExecutorSessionActivator", - "desktopSlashCommandPresentation", - "executorComposerProps", "resolveTaskReadinessModelTarget", "useActiveExecutionBoundary", - "useAppShellSessionUiReads", "useAppShellSessionUiState", - "useShellChatModel", - "useShellResume" + "useShellChatModel" ], "composition": [ "ComposerStagingServicesProvider", @@ -530,7 +523,7 @@ "nonTriviaTokens": 122 }, "src/renderer/app-shell-command-actions.ts": { - "importDeclarations": 5, + "importDeclarations": 4, "bridgePaths": {}, "environmentCapabilities": { "navigator.clipboard.writeText": 1 @@ -544,15 +537,14 @@ "dependencyPaths": { "./app-shell-copy.js": 1, "./command-palette-commands.js": 1, - "./conversation-markdown.js": 1, "./locales/settings-memory-copy.js": 1, "./locales/settings-test-result-copy.js": 1, "./locales/shell-copy.js": 1, "./platform/desktop/default-runtime-host-operation.js": 1, "react": 1 }, - "importSpecifiers": 8, - "nonTriviaTokens": 2172 + "importSpecifiers": 7, + "nonTriviaTokens": 2158 }, "src/renderer/app-shell-copy.ts": { "importDeclarations": 1, @@ -614,7 +606,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 856 + "nonTriviaTokens": 843 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -647,7 +639,7 @@ "nonTriviaTokens": 858 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 40, + "importDeclarations": 35, "bridgePaths": {}, "environmentCapabilities": { "window.requestAnimationFrame": 3 @@ -659,9 +651,7 @@ "useAppShellNavRefSync": 1, "useAppShellPersistenceEffects": 1, "useAppShellProjectContext": 1, - "useAppShellSessionUiReads": 1, "useAppShellSessionWorkspace": 1, - "useAppShellTurnPresentation": 1, "useLayoutEffect": 1, "useRef": 4, "useSessionNavigationReads": 1, @@ -669,14 +659,11 @@ "useShellAppearance": 1, "useShellChatModel": 1, "useShellConnections": 3, - "useShellLiveTurn": 1, "useShellMemoryPill": 1, - "useShellResume": 1, "useStableActions": 1, "useState": 5, "useSystemUiLocale": 1, - "useToast": 1, - "useTurnActionRegistry": 1 + "useToast": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -688,17 +675,14 @@ "./app-shell-effects": 1, "./app-shell-overlays": 1, "./application/contracts/conversation-copy": 1, - "./application/contracts/desktop-slash-command.js": 1, "./application/contracts/onboarding/onboarding-authority.js": 1, "./application/contracts/shell-lifecycle.js": 1, - "./application/contracts/turn-presentation": 1, "./application/contracts/workhub-workspace/workhub-enablement.js": 1, "./chat-composer-region": 1, "./chat-message-surface": 1, "./composer-defaults": 1, "./composer-mentions": 1, "./custom-pet-companion": 1, - "./custom-pet-companion-model": 1, "./desktop-execution-boundary-surface": 1, "./error-boundary": 1, "./features/app-update/index.js": 1, @@ -726,28 +710,24 @@ "./settings/runtime-host-ssh-terminal-dialog.js": 1, "./shell/detail-panel": 1, "./shell/frame-style": 1, - "./use-app-shell-session-ui-reads": 1, "./use-app-shell-session-workspace": 1, "./use-project-context": 1, "./use-shell-appearance": 1, "./use-shell-connections": 1, - "./use-shell-live-turn": 1, "./use-shell-memory-pill": 1, "./use-stable-actions": 1, "./use-system-ui-locale": 1, - "./use-turn-action-registry": 1, "./workspace-readiness-recovery": 1, "@astryxdesign/core/AppShell": 1, "@maka/core/onboarding-milestone": 1, "@maka/core/session": 1, "@maka/core/session-revisions": 1, - "@maka/core/slash-command-catalog": 1, "@maka/core/ui-locale": 1, "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 57, - "nonTriviaTokens": 7852 + "importSpecifiers": 52, + "nonTriviaTokens": 7333 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -776,20 +756,6 @@ "importSpecifiers": 5, "nonTriviaTokens": 479 }, - "src/renderer/use-app-shell-session-ui-reads.ts": { - "importDeclarations": 0, - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./features/conversation/index.js": 1 - }, - "importSpecifiers": 0, - "nonTriviaTokens": 9 - }, "src/renderer/use-app-shell-session-workspace.ts": { "importDeclarations": 1, "bridgePaths": {}, @@ -1009,19 +975,6 @@ "./features/conversation/index.js": 2 } }, - "src/renderer/conversation-markdown.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./locales/shell-remaining-copy.js": 1, - "@maka/core/session": 1, - "@maka/ui": 1 - } - }, "src/renderer/custom-pet-companion-model.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -3381,17 +3334,6 @@ "react": 1 } }, - "src/renderer/use-shell-live-turn.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./application/contracts/session-execution.js": 1 - } - }, "src/renderer/use-shell-memory-pill.ts": { "bridgePaths": { "window.maka.memory.getState": 2 @@ -3443,23 +3385,6 @@ "react": 1 } }, - "src/renderer/use-turn-action-registry.ts": { - "bridgePaths": {}, - "environmentCapabilities": { - "clearTimeout": 2, - "setTimeout": 1 - }, - "hookCalls": { - "useRef": 3, - "useState": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "react": 1 - } - }, "src/renderer/use-work-board.ts": { "bridgePaths": { "window.maka.workBoard.archive": 1, @@ -3718,13 +3643,6 @@ "src/renderer/app-shell.tsx" ] }, - { - "capability": "conversation-runtime-and-presentation", - "targetZone": "features/conversation", - "legacyPaths": [ - "src/renderer/use-app-shell-session-ui-reads.ts" - ] - }, { "capability": "session-catalog-and-scope", "targetZone": "application/session", diff --git a/apps/desktop/src/main/__tests__/app-shell-command-options.ts b/apps/desktop/src/main/__tests__/app-shell-command-options.ts index 159e0ff9a3c..bae422a7f86 100644 --- a/apps/desktop/src/main/__tests__/app-shell-command-options.ts +++ b/apps/desktop/src/main/__tests__/app-shell-command-options.ts @@ -42,7 +42,7 @@ export function appShellCommandOptions( clientPathsAccessible: false, connections: [], defaultConnection: null, - readMessages: () => [], + renderPublishedConversation: () => '', newTaskProfileId: 'new-task-profile', settingsOpen: false, settingsProfileId: undefined, diff --git a/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts b/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts index c69af75c406..b1e9439d8d6 100644 --- a/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts +++ b/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts @@ -132,7 +132,14 @@ describe('command palette Desktop actions', () => { saveConversationToFile: async () => outcomes.shift()!, }); const toasts: string[] = []; - const options = appShellCommandOptions(toasts, { paletteActions: palette }); + const exports: unknown[][] = []; + const options = appShellCommandOptions(toasts, { + paletteActions: palette, + renderPublishedConversation: (sessionName, locale) => { + exports.push([sessionName, locale]); + return '# Long task export'; + }, + }); for (let index = 0; index < 3; index += 1) { await runPaletteCommand(options, 'diag:save-conversation-file'); @@ -142,6 +149,8 @@ describe('command palette Desktop actions', () => { const [name, input] = calls[0] ?? []; assert.equal(name, 'saveConversationToFile'); assert.deepEqual(Object.keys(input as object), ['markdown', 'defaultName']); + assert.equal((input as { markdown: string }).markdown, '# Long task export', 'Save writes the Conversation-owned export'); + assert.deepEqual(exports, Array(3).fill(['Long task', 'en'])); assert.match((input as { defaultName: string }).defaultName, /^maka-Long-task-\d{4}-\d{2}-\d{2}\.md$/); assert.equal(toasts.length, 2); assert.equal(toasts[0], `success:${copy.conversationSavedTitle}`); diff --git a/apps/desktop/src/main/__tests__/composer-submission-fixture.ts b/apps/desktop/src/main/__tests__/composer-submission-fixture.ts index ec54d7c0be2..dbb0aca7d62 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-fixture.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-fixture.ts @@ -65,7 +65,6 @@ export function stubSubmissionShell( showModelSetupToast: () => {}, bindNewTaskSessionResolver: () => () => {}, openSideChat: () => {}, - turnActions: { addKey: () => true, clearKey: () => {}, keyOf: (...parts) => parts.join(':') }, orchestrationMode: () => 'default', setOrchestrationModeActive: async () => true, ...overrides, @@ -100,6 +99,7 @@ export function withComposerSubmission(children: ReactNode, options: { shell: stubSubmissionShell(), newTask: stubNewTaskSubmission(), sharedSessionActive: false, + ownerSessionId: undefined, ...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 index 0fcdfa4e576..25db639bcd4 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -26,7 +26,10 @@ 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, type ComposerProps } from '@maka/ui'; +import { + AstryxLocaleProvider, LocaleProvider, ToastProvider, + type ComposerHandle, type ComposerInteraction, type ComposerProps, type LiveTurnBuffer, type TurnPresentation, type TurnViewModel, +} from '@maka/ui'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import { createSessionCatalogController, @@ -37,16 +40,25 @@ import * as Conversation from '../../renderer/features/conversation/index.js'; import { ComposerSubmissionProvider, ComposerSubmissionServicesProvider, + ConversationActivityConsumer, ConversationComposerRegion, + ConversationHomeSurface, ConversationLifecycle, ConversationProvider, ConversationServicesProvider, + ConversationTranscriptRegion, createComposerStagingCommands, createComposerSubmissionCommands, useAppShellSessionUiState, type ComposerSubmissionServices, + type ConversationActivity, } from '../../renderer/features/conversation/index.js'; -import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; +import { getSessionLocalCopy } from '../../renderer/locales/session-local-copy.js'; +import { getShellCopy } from '../../renderer/locales/shell-copy.js'; +import { + stubComposerGateInputs, stubConversationServices, useComposerStaging, useConversationOwner, useConversationQueue, + useTurnActionRegistry, +} from '../../renderer/features/conversation/testing.js'; import { createDesktopComposerSubmissionServices, type DesktopComposerSubmissionBridge, @@ -57,7 +69,7 @@ import { stubSubmissionShell, } from './composer-submission-fixture.js'; import { ComposerStagingFixture } from './composer-staging-fixture.js'; -import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { cleanupFakeDom, installReactRenderer, type FakeElement } from './fake-dom.js'; afterEach(cleanupFakeDom); @@ -65,8 +77,7 @@ type Owner = { sessionId: string | undefined }; type ProviderProps = Parameters>[0]; interface RegionProps { - executorPicker?: ComposerProps['executorPicker']; - sendBlocked?: boolean; + composerRef: { current: Partial | null }; onSend(text: string, metadata?: { followUpMode?: 'steer' | 'queue' }): Promise; newTaskSendPending: boolean; revisionNotice?: { title: string; detail: string; cancelLabel: string; onCancel(): void }; @@ -78,7 +89,26 @@ interface RegionProps { stop(): void; onStop(): void; stopPending: boolean; - pendingMessages: ReadonlyArray<{ id: string }>; + pendingMessages: ReadonlyArray<{ id: string; deliveryActions?: ReadonlyArray<{ label: string; onClick(): void }> }>; + resumeAction?: { pending: boolean; onResume(): void }; + activeInteraction: ComposerInteraction | undefined; + queuedMessages: ComposerProps['queuedMessages']; + streaming: boolean; + slashCommands: NonNullable; + modelSwitchAvailability: NonNullable; + planModeDisabledReason?: string; + orchestrationModeDisabledReason?: string; + permissionModeDisabledReason?: string; + goalDisabledReason?: string; + executorPicker: ComposerProps['executorPicker']; + sendBlocked: ComposerProps['sendBlocked']; +} + +interface TranscriptProps { + deriveTurnPresentation(turns: readonly TurnViewModel[]): TurnPresentation; + safeResumeAction?: { pending: boolean; detail: string | undefined; onResume(): void }; + activeTurn: { turnId: string; awaitingInput: boolean; compacting: boolean } | undefined; + sessionHealthModelPickerAvailable: boolean; } const row = (id: string): DesktopSessionSummary => ({ @@ -89,6 +119,18 @@ const row = (id: string): DesktopSessionSummary => ({ }); const userTurn = (turnId: string, text: string): StoredMessage => ({ type: 'user', id: `message-${turnId}`, text, turnId, ts: 1 }); +/** Gate inputs with a ready executor selected; `overrides` adjust its selection state. */ +function selectedExecutor(overrides: { changing?: boolean } = {}) { + const gateInputs = stubComposerGateInputs(); + gateInputs.executorComposer.selection = { + ...gateInputs.executorComposer.selection, + selection: { executorId: 'codex' }, + entry: { id: 'codex', readiness: 'ready' }, + ...overrides, + } as unknown as typeof gateInputs.executorComposer.selection; + return gateInputs; +} + function deferred() { let resolvePromise!: (value: T) => void; const promise = new Promise((resolveValue) => { resolvePromise = resolveValue; }); @@ -101,17 +143,28 @@ function harness(options: { shell?: Partial; newTask?: Partial; sharedSessionActive?: boolean; + ownerSessionId?: string; listMessages?: ReturnType['listMessages']; - executorPicker?: ComposerProps['executorPicker']; - sendBlocked?: boolean; + resume?: ReturnType['resume']; + cancelMessage?: ReturnType['cancelMessage']; + reconcileMessage?: ReturnType['reconcileMessage']; + retractQueueEntry?: ReturnType['sessions']['retractQueueEntry']; + stagingDraftKey?: string; + gateInputs?: ReturnType; + homeEligible?: boolean; + localInteractionAvailable?: boolean; } = {}) { - const { root } = installReactRenderer(); + const { root, container } = installReactRenderer(); const catalog = createSessionCatalogController(); catalog.commitSessions(['A', 'B'].map(row)); const published: Array<(messages: StoredMessage[]) => void> = []; - const conversationServices = stubConversationServices( - options.listMessages ? { listMessages: options.listMessages } : {}, - ); + const conversationServices = stubConversationServices({ + ...(options.listMessages ? { listMessages: options.listMessages } : {}), + ...(options.resume ? { resume: options.resume } : {}), + ...(options.cancelMessage ? { cancelMessage: options.cancelMessage } : {}), + ...(options.reconcileMessage ? { reconcileMessage: options.reconcileMessage } : {}), + ...(options.retractQueueEntry ? { sessions: { retractQueueEntry: options.retractQueueEntry } } : {}), + }); conversationServices.observation.openTranscript = (sessionId) => { let messages: StoredMessage[] = []; let ready = false; @@ -142,8 +195,18 @@ function harness(options: { let target!: ReturnType; let conversation!: ReturnType; let region: RegionProps | undefined; + let transcript: TranscriptProps | undefined; + let activity: ConversationActivity | undefined; + let staged!: ReturnType; + let queued!: ReturnType; + let shellRenders = 0; + function Probe() { staged = useComposerStaging(); queued = useConversationQueue(); return null; } function Composer(props: RegionProps) { region = props; return null; } + let transcriptRenders = 0; + function Transcript(props: TranscriptProps) { transcript = props; transcriptRenders += 1; return null; } + function Activity(props: { activity: ConversationActivity }) { activity = props.activity; return null; } function Shell() { + shellRenders += 1; target = useAppShellSessionUiState(); conversation = useConversationOwner(); return createElement(Fragment, null, @@ -152,10 +215,16 @@ function harness(options: { showModelSetupToast() {}, onTurnCompleted() {}, searchTarget: null, clearSearchTarget() {}, }), + createElement(ConversationTranscriptRegion, { + surface: Transcript, localInteractionAvailable: options.localInteractionAvailable ?? true, + }), createElement(ConversationComposerRegion, { surface: Composer, contextPickEnabled: true, directoryPickerEnabled: true, - executorPicker: options.executorPicker, sendBlocked: options.sendBlocked, + ...(options.gateInputs ?? stubComposerGateInputs()), }), + createElement(ConversationActivityConsumer<{ activity: ConversationActivity }>, { surface: Activity }), + createElement(ConversationHomeSurface, { eligible: options.homeEligible ?? true, id: 'column' }), + createElement(Probe), ); } act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: @@ -163,7 +232,8 @@ function harness(options: { createElement(SessionCatalogContext.Provider, { value: catalog, children: createElement(ConversationServicesProvider, { services: conversationServices, children: createElement(ConversationProvider, { children: - createElement(ComposerStagingFixture, { draftKey: 'staging', commands: staging, children: + createElement(ComposerStagingFixture, { + draftKey: options.stagingDraftKey ?? 'staging', directoryHostId: 'local', commands: staging, children: createElement(ComposerSubmissionServicesProvider, { services, children: createElement(ComposerSubmissionProvider, { commands, @@ -171,6 +241,7 @@ function harness(options: { shell: stubSubmissionShell(options.shell), newTask: stubNewTaskSubmission(options.newTask), sharedSessionActive: options.sharedSessionActive ?? false, + ownerSessionId: options.ownerSessionId, children: createElement(Shell), }), }), @@ -185,6 +256,17 @@ function harness(options: { get target() { return target; }, get conversation() { return conversation; }, get region() { assert.ok(region, 'the Composer slot rendered'); return region; }, + get transcript() { assert.ok(transcript, 'the transcript region rendered'); return transcript; }, + get activity() { assert.ok(activity, 'the activity reader rendered'); return activity; }, + get shellRenders() { return shellRenders; }, + get transcriptRenders() { return transcriptRenders; }, + get staged() { return staged; }, + get queued() { return queued; }, + homeSurface() { + const column = container.childNodes.find((node): node is FakeElement => 'getAttribute' in node && node.getAttribute('id') === 'column'); + assert.ok(column, 'the column rendered'); + return column.getAttribute('data-home-surface'); + }, notice: () => region?.revisionNotice, }; } @@ -194,9 +276,7 @@ describe('ComposerSubmissionProvider', () => { const submitted: unknown[] = []; const admission = deferred>>(); const h = harness({ - executorPicker: { - catalog: [], onSelect() {}, onSetup() {}, onRetry() {}, onNewTask() {}, - }, + gateInputs: selectedExecutor(), services: { submitMessage: (sessionId, placement, command) => { submitted.push({ sessionId, placement, text: command.text }); @@ -230,12 +310,9 @@ describe('ComposerSubmissionProvider', () => { }); test('retains existing executor and send gates while no submission is pending', () => { - const h = harness({ - executorPicker: { - catalog: [], disabled: true, onSelect() {}, onSetup() {}, onRetry() {}, onNewTask() {}, - }, - sendBlocked: true, - }); + const gateInputs = selectedExecutor({ changing: true }); + gateInputs.executorComposer.taskSubmissionHardBlocked = true; + const h = harness({ gateInputs }); assert.equal(h.region.newTaskSendPending, false); assert.equal(h.region.executorPicker?.disabled, true); assert.equal(h.region.sendBlocked, true); @@ -422,9 +499,95 @@ describe('ComposerSubmissionProvider', () => { assert.deepEqual(calls, [['branch', 'A', 'turn-1', 'string'], ['refresh'], ['open', 'C']]); }); + test('marks a running Turn action in the transcript it owns and drops it on Session cleanup', async () => { + const branches: Array>> = []; + const h = harness({ + services: { branchFromTurn: () => { const next = deferred(); branches.push(next); return next.promise; } }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'source')])); + const turns: TurnViewModel[] = [{ turnId: 'turn-1', status: 'completed', tools: [], timeline: [], notes: [], startedAt: 1 }]; + const branchPending = () => h.transcript.deriveTurnPresentation(turns) + .footerActionsByTurn['turn-1']?.find((action) => action.id === 'branch')?.pending === true; + assert.equal(branchPending(), false); + + const settled: Array> = []; + await act(async () => { settled.push(h.commands.handleTurnFooterAction('turn-1', 'branch')); }); + assert.equal(branchPending(), true, 'the mark the owner sets reaches the transcript\'s footer'); + await act(async () => h.commands.clearPendingTurnActions('B')); + assert.equal(branchPending(), true, 'another Session\'s cleanup keeps it'); + await act(async () => h.commands.clearPendingTurnActions('A')); + assert.equal(branchPending(), false, 'the retired Session\'s mark is dropped'); + + await act(async () => { settled.push(h.commands.handleTurnFooterAction('turn-1', 'branch')); }); + assert.equal(branches.length, 2, 'a dropped mark no longer swallows the next click'); + assert.equal(branchPending(), true); + await act(async () => { + for (const branch of branches) branch.resolve({ ...row('C'), name: 'Copy' }); + await Promise.all(settled); + }); + }); + + test('the Turn-action registry ends its marks when its owner unmounts', () => { + const { root } = installReactRenderer(); + let registry!: ReturnType; + function Owner() { registry = useTurnActionRegistry(); return null; } + act(() => root.render(createElement(Owner))); + act(() => { registry.addKey(registry.keyOf('A', 'turn-1', 'branch')); }); + const marks = registry.keysRef.current; + assert.equal(marks.size, 1); + act(() => root.unmount()); + assert.equal(marks.size, 0, 'no mark, and no timer that would clear it, outlives the owner'); + }); + + test('a shared Session\'s transcript offers no Branch', async () => { + const h = harness({ sharedSessionActive: true }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'source')])); + const turn: TurnViewModel = { turnId: 'turn-1', status: 'completed', tools: [], timeline: [], notes: [], startedAt: 1 }; + assert.deepEqual(h.transcript.deriveTurnPresentation([turn]).footerActionsByTurn['turn-1']?.map((action) => action.id), ['copy']); + assert.equal(h.transcript.safeResumeAction, undefined); + }); + + test('reads the owner Session\'s resume offer into the Composer slot and the transcript banner', async () => { + const calls: unknown[] = []; + const started = deferred['resume']['start']>>>(); + const h = harness({ + ownerSessionId: 'A', + resume: { + queryPlan: async (sessionId) => { + calls.push(['plan', sessionId]); + return { disposition: 'ready' } as Awaited['resume']['queryPlan']>>; + }, + start: (sessionId) => { calls.push(['start', sessionId]); return started.promise; }, + subscribeChanges: () => () => undefined, + }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'interrupted')])); + assert.deepEqual(calls, [['plan', 'A']]); + assert.equal(h.region.resumeAction?.pending, false, 'the Host\'s ready plan offers Resume in the send slot'); + assert.equal(h.transcript.safeResumeAction?.pending, false); + const offer = h.region.resumeAction; + await act(async () => h.commands.beginEditUserMessage('turn-1')); + assert.ok(h.notice(), 'the owner rendered again'); + assert.equal(h.region.resumeAction, offer, 'the offer keeps its identity while nothing it shows changes'); + await act(async () => h.notice()!.onCancel()); + + await act(async () => h.region.resumeAction!.onResume()); + await act(async () => h.transcript.safeResumeAction!.onResume()); + assert.deepEqual(calls, [['plan', 'A'], ['start', 'A']], 'one instance behind both, so the banner cannot race the slot'); + assert.equal(h.transcript.safeResumeAction?.pending, true); + await act(async () => started.resolve({ disposition: 'started', runId: 'run-1', turnId: 'turn-2' })); + assert.equal(h.region.resumeAction, undefined, 'a started resume retracts the offer'); + assert.equal(h.transcript.safeResumeAction?.pending, false); + }); + test('runs local delivery recovery for the published Session below the owner', async () => { const listed: string[] = []; + const reconciled: unknown[] = []; const h = harness({ + reconcileMessage: async (sessionId, messageId) => { reconciled.push([sessionId, messageId]); }, listMessages: async (sessionId) => { listed.push(sessionId); return sessionId === 'A' @@ -439,6 +602,117 @@ describe('ComposerSubmissionProvider', () => { 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']); + const unknown = h.region.pendingMessages[0]!; + assert.deepEqual(unknown.deliveryActions?.map((action) => action.label), [getSessionLocalCopy('en').check], + 'an unknown outcome is checked with the Host, never edited or removed as if unsent'); + await act(async () => unknown.deliveryActions![0]!.onClick()); + assert.deepEqual(reconciled, [['A', 'saved-follow-up']], 'the check keeps the Message\'s identity'); + }); + + test('a cancelled local message hands its text and staged context back to the Session it left', async () => { + const cancelling = deferred(); + const calls: unknown[] = []; + const attachment = { + kind: 'doc' as const, name: 'notes.md', mimeType: 'text/markdown', bytes: 12, + ref: { kind: 'workspace_file' as const, relativePath: 'notes.md' }, + }; + const h = harness({ + stagingDraftKey: 'A', + listMessages: async (sessionId) => sessionId === 'A' + ? [{ + sessionId: 'A', messageId: 'saved-1', createdAt: 1, state: 'saved', canCancel: true, + placement: 'next_turn', text: 'try again', error: 'Host unavailable', + attachments: [attachment], directoryReferences: [{ hostId: 'local', path: '/repo' }], + quotes: [{ text: 'quoted' }], inlineReferences: [], + }] + : [], + cancelMessage: async (sessionId, messageId) => { calls.push(['cancel', sessionId, messageId]); await cancelling.promise; }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'earlier')])); + h.region.composerRef.current = { + appendDraft: (key: string, text: string) => { calls.push(['append', key, text]); }, + } as Partial; + const saved = h.region.pendingMessages.find((message) => message.id === 'saved-1'); + const edit = saved?.deliveryActions?.[0]; + assert.ok(edit, 'a never-dispatched message offers Edit'); + + await act(async () => edit.onClick()); + await act(async () => h.target.setActiveId('B')); + await act(async () => h.published[1]!([userTurn('turn-b', 'elsewhere')])); + assert.equal(h.conversation.workspace.target.getSnapshot(), 'B', 'the user has moved on before the Host answers'); + await act(async () => cancelling.resolve()); + assert.deepEqual(calls, [['cancel', 'A', 'saved-1'], ['append', 'A', 'try again']]); + assert.deepEqual(h.staged.pendingAttachments.map((item) => item.displayName), ['notes.md'], + 'the attachment returns to the draft of the Session it left, after navigation'); + assert.deepEqual(h.staged.pendingDirectories.map((item) => item.path), ['/repo']); + assert.deepEqual(h.staged.pendingQuotes.map((quote) => quote.text), ['quoted']); + }); + + test('editing a queued steering bubble hands its staged context back to its Session', async () => { + const calls: unknown[] = []; + const h = harness({ + stagingDraftKey: 'A', + retractQueueEntry: async (sessionId, entryId) => { calls.push(['retract', sessionId, entryId]); }, + }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'running')])); + await act(async () => h.conversation.workspace.ui.setMessageQueueBySession((current) => ({ + ...current, + A: { + ts: 1, queueRevision: 1, + entries: [{ + entryId: 'entry-1', messageId: 'message-steer', placement: 'current_turn', + content: { text: 'steer this way', quotes: [{ text: 'queued quote' }] }, + }], + }, + } as never))); + const bubble = h.queued.transientMessages.find((message) => message.id === 'message-steer'); + assert.ok(bubble?.deliveryActions?.[0], 'the steering bubble offers Edit'); + await act(async () => bubble.deliveryActions![0]!.onClick()); + assert.deepEqual(calls, [['retract', 'A', 'entry-1']]); + assert.deepEqual(h.staged.pendingQuotes.map((quote) => quote.text), ['queued quote']); + }); + + test('the Composer slot holds the editor handle; the shell edits it only through named intents', async () => { + const h = harness(); + assert.deepEqual(Object.keys(h.target.queueSurface).sort(), [ + 'deleteQueuedEntry', 'editing', 'promoteQueuedEntry', 'reorderQueuedEntries', 'updateQueuedEntry', + ], 'no editor handle, draft restore or restorer slot reaches the shell'); + const calls: unknown[] = []; + const editor = (name: string) => ({ + appendText: (text: string) => { calls.push([name, 'append', text]); }, + setText: (text: string) => { calls.push([name, 'set', text]); }, + setDraft: (key: string, text: string) => { calls.push([name, 'setDraft', key, text]); }, + clearDraft: (key: string) => { calls.push([name, 'clearDraft', key]); }, + focus: () => { calls.push([name, 'focus']); }, + openModelPicker: () => { calls.push([name, 'picker']); }, + }); + const { editing } = h.target.queueSurface; + h.region.composerRef.current = editor('first'); + editing.appendText('suggested'); + editing.replaceText('maka://compose text'); + editing.seedDraft('new-task:board', 'board draft'); + editing.discardDraft('session-A'); + editing.focus(); + editing.openModelPicker(); + const claim = editing.claimVisibleDraft(); + assert.ok(claim); + assert.equal(claim.isCurrent(), true); + h.region.composerRef.current = editor('second'); + assert.equal(claim.isCurrent(), false, 'a claim ends when another editor mounts'); + claim.append('late'); + assert.deepEqual(calls, [ + ['first', 'append', 'suggested'], + ['first', 'set', 'maka://compose text'], + ['first', 'setDraft', 'new-task:board', 'board draft'], + ['first', 'clearDraft', 'session-A'], + ['first', 'focus'], + ['first', 'picker'], + ['first', 'append', 'late'], + ]); + h.region.composerRef.current = null; + assert.equal(editing.claimVisibleDraft(), undefined); }); test('the shell\'s command handle works only while the owner is mounted', async () => { @@ -451,6 +725,176 @@ describe('ComposerSubmissionProvider', () => { }); }); +const liveText = (text: string): LiveTurnBuffer => [{ + turnId: 'turn-run', + steps: [{ stepId: 'message', tools: [], text: { text, complete: false, truncated: false } }], +}]; +const running = (sessionId: string, available = true) => ({ + type: 'host_execution' as const, available, + rootTurn: { sessionId, turnId: 'turn-run', runId: 'run', status: 'running' as const }, +}); +const settled = (sessionId: string) => ({ + type: 'host_execution' as const, available: true, + rootTurn: { sessionId, turnId: 'turn-run', runId: 'run', status: 'completed' as const, terminalEventId: 'turn-end' }, +}); +const sandboxRequest = (requestId: string) => ({ + type: 'sandbox_boundary_request' as const, id: requestId, turnId: 'turn-run', ts: 1, + requestId, toolUseId: requestId, justification: 'Read a file.', + expansion: { filesystem: { entries: [{ path: '/file', access: 'read' as const, scope: 'exact' as const }] } }, +}); + +describe('Conversation Turn readers', () => { + const copy = getShellCopy('en').app; + const hasCompact = (region: RegionProps) => region.slashCommands.some((command) => command.id === 'compact'); + + test('a running Turn repaints the Composer\'s held controls and the transcript, not the shell', async () => { + const h = harness({ ownerSessionId: 'A', gateInputs: selectedExecutor() }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'earlier')])); + const ui = h.conversation.workspace.ui; + assert.equal(h.region.streaming, false); + assert.deepEqual(h.region.modelSwitchAvailability, { available: true, pending: false }); + assert.equal(h.region.permissionModeDisabledReason, undefined); + assert.equal(h.region.goalDisabledReason, undefined); + assert.equal(h.region.sendBlocked, false); + assert.equal(h.region.executorPicker?.disabled, false); + assert.equal(hasCompact(h.region), true); + assert.equal(h.transcript.activeTurn, undefined); + assert.equal(h.transcript.sessionHealthModelPickerAvailable, true); + const shellRenders = h.shellRenders; + + await act(async () => ui.setExecution('B', running('B'))); + assert.equal(h.region.streaming, false, 'a background Session\'s Turn holds nothing here'); + + await act(async () => ui.setExecution('A', running('A'))); + assert.equal(h.region.streaming, true, 'Stop is offered for the whole Turn'); + assert.deepEqual(h.region.modelSwitchAvailability, { available: false, pending: false, reason: 'streaming' }); + assert.equal(h.region.planModeDisabledReason, copy.modeChangeRunning); + assert.equal(h.region.orchestrationModeDisabledReason, copy.modeChangeRunning); + assert.equal(h.region.permissionModeDisabledReason, copy.permissionModeRunning); + assert.equal(h.region.goalDisabledReason, copy.goalTurnActive); + assert.equal(h.region.sendBlocked, true); + assert.equal(h.region.executorPicker?.disabled, true); + assert.equal(hasCompact(h.region), false, '/compact is withheld while the Turn reads its context'); + assert.deepEqual(h.transcript.activeTurn, { turnId: 'turn-run', awaitingInput: false, compacting: false }); + assert.equal(h.transcript.sessionHealthModelPickerAvailable, false, 'the health notice\'s picker is held too'); + + await act(async () => ui.setLiveTurnBySession((current) => ({ ...current, A: liveText('first') }))); + assert.equal(h.region.planModeDisabledReason, copy.modeChangeStreaming); + assert.equal(h.region.permissionModeDisabledReason, copy.permissionModeStreaming); + + await act(async () => ui.setExecution('A', settled('A'))); + assert.equal(h.region.streaming, false); + assert.equal(h.region.permissionModeDisabledReason, undefined); + assert.equal(hasCompact(h.region), true); + assert.equal(h.transcript.activeTurn, undefined); + assert.equal(h.transcript.sessionHealthModelPickerAvailable, true); + assert.equal(h.shellRenders, shellRenders, 'the shell reads no Turn'); + }); + + test('a send-pending or edit-draft change does not repaint the transcript', async () => { + const h = harness(); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'source')])); + const renders = h.transcriptRenders; + await act(async () => h.commands.beginEditUserMessage('turn-1')); + assert.ok(h.notice(), 'the Composer slot shows the edit draft'); + await act(async () => h.notice()!.onCancel()); + assert.equal(h.notice(), undefined); + assert.equal(h.transcriptRenders, renders, 'the transcript reads only the narrow Turn reader'); + }); + + test('the health notice\'s picker needs the shell\'s local-interaction gate', async () => { + const h = harness({ localInteractionAvailable: false }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'source')])); + assert.equal(h.transcript.sessionHealthModelPickerAvailable, false); + }); + + test('the mode gates combine the Turn with the shell\'s catalog row', async () => { + const loading = harness({ gateInputs: stubComposerGateInputs({ sessionState: { loaded: false, status: undefined } }) }); + await act(async () => loading.target.setActiveId('A')); + assert.equal(loading.region.planModeDisabledReason, copy.modeChangeLoading); + assert.equal(loading.region.permissionModeDisabledReason, undefined); + await act(async () => loading.root.unmount()); + + const waiting = harness({ gateInputs: stubComposerGateInputs({ sessionState: { loaded: true, status: 'waiting_for_user' } }) }); + await act(async () => waiting.target.setActiveId('A')); + assert.equal(waiting.region.planModeDisabledReason, copy.modeChangeWaiting); + assert.equal(waiting.region.permissionModeDisabledReason, copy.permissionModeWaiting); + assert.deepEqual(waiting.region.modelSwitchAvailability, { available: false, pending: false, reason: 'permission' }); + }); + + test('the Composer answers the owner Session\'s interaction and lists the displayed Session\'s queue', async () => { + const h = harness({ ownerSessionId: 'A' }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([userTurn('turn-1', 'earlier')])); + const ui = h.conversation.workspace.ui; + const shellRenders = h.shellRenders; + await act(async () => ui.setInteractionBySession((current) => ({ ...current, B: [sandboxRequest('background')] }))); + assert.equal(h.region.activeInteraction, undefined); + await act(async () => ui.setInteractionBySession((current) => ({ ...current, A: [sandboxRequest('owner')] }))); + assert.equal((h.region.activeInteraction as { requestId?: string } | undefined)?.requestId, 'owner'); + const entries = [{ id: 'queued-1', text: 'next', createdAt: 1 }]; + await act(async () => ui.setMessageQueueBySession((current) => ({ ...current, A: { ts: 1, entries, queueRevision: 3 } } as never))); + assert.deepEqual(h.region.queuedMessages, entries); + assert.equal(h.shellRenders, shellRenders, 'the shell reads no interaction or queue'); + await act(async () => h.root.unmount()); + + // A shared Session has no owner Session here, so its Host's prompt is not ours to answer. + const guest = harness({ ownerSessionId: undefined }); + await act(async () => guest.target.setActiveId('A')); + await act(async () => guest.conversation.workspace.ui.setInteractionBySession((current) => ({ ...current, A: [sandboxRequest('host')] }))); + assert.equal(guest.region.activeInteraction, undefined); + }); + + test('the activity reader and the home surface follow the displayed Session without the shell', async () => { + const h = harness({ ownerSessionId: 'A' }); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.published[0]!([])); + const ui = h.conversation.workspace.ui; + const shellRenders = h.shellRenders; + assert.deepEqual(h.activity, { turnRunning: false, awaitingInteraction: false }); + assert.equal(h.homeSurface(), 'true'); + + await act(async () => ui.setExecution('A', running('A', false))); + assert.equal(h.activity.turnRunning, false, 'an unobservable Turn does not animate the companion'); + await act(async () => ui.setExecution('A', running('A'))); + assert.equal(h.activity.turnRunning, true); + assert.equal(h.homeSurface(), 'true', 'a Turn with nothing on screen keeps the home surface'); + await act(async () => ui.setLiveTurnBySession((current) => ({ ...current, A: liveText('first') }))); + assert.equal(h.homeSurface(), null, 'live content leaves the home surface'); + await act(async () => ui.setInteractionBySession((current) => ({ ...current, A: [sandboxRequest('owner')] }))); + assert.equal(h.activity.awaitingInteraction, true); + + await act(async () => { + ui.setLiveTurnBySession((current) => ({ ...current, A: [] })); + ui.setExecution('A', settled('A')); + }); + assert.equal(h.homeSurface(), 'true'); + await act(async () => ui.setMessageLoadErrorBySession((current) => ({ ...current, A: 'failed' }))); + assert.equal(h.homeSurface(), null, 'a failed load is not a home surface'); + assert.equal(h.shellRenders, shellRenders, 'the shell reads no activity'); + }); + + test('the home surface needs the shell\'s empty-transcript condition', async () => { + const h = harness({ homeEligible: false }); + await act(async () => h.target.setActiveId('A')); + assert.equal(h.homeSurface(), null); + }); + + test('AppShell reads no displayed-Session Turn, interaction or queue', () => { + const shell = readFileSync(resolve(fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url))), 'utf8'); + assert.doesNotMatch( + shell, + /useAppShellSessionUiReads|useShellLiveTurn|turnActive|activeStreamingLive|hasLiveTurnContent|activeInteraction|activeMessageQueue|activeExecution\b|chatTurnActivity|executorComposerProps|desktopSlashCommand/, + ); + for (const name of ['useAppShellSessionUiReads', 'chatTurnActivity', 'executorComposerProps', 'desktopSlashCommandPresentation']) { + assert.equal(name in Conversation, false, `${name} is not a public Conversation capability`); + } + }); +}); + describe('Desktop Composer submission adapter', () => { test('maps each named operation onto its bridge call', async () => { const calls: unknown[] = []; @@ -528,13 +972,20 @@ describe('Composer submission ownership', () => { test('AppShell holds no submission state and the public entry no submit construction', () => { const shell = readFileSync(join(rendererRoot, 'app-shell.tsx'), 'utf8'); + assert.doesNotMatch(shell, /composerRef/, 'the shell keeps no editor handle'); assert.doesNotMatch( shell, /revisionDraft|newTaskSendPending|stopPending|createRevisionAwareOnSend|createStagedFollowUp|SessionLocalMessages|createAppShell(?:Chat|Revision|Turn)Actions|createAppShellStopAction/, ); + assert.doesNotMatch( + shell, + /useTurnActionRegistry|useShellResume|useAppShellTurnPresentation|deriveTurnPresentation|ResumeAction/, + 'pending Turn marks, the resume offer and the footer derivation stay below the owner', + ); for (const name of [ 'createRevisionAwareOnSend', 'createStagedFollowUp', 'useComposerSubmission', 'createChatActions', 'createRevisionActions', 'createStopAction', 'createTurnActions', 'SessionLocalMessages', + 'useShellResume', 'useTurnActionRegistry', ]) { assert.equal(name in Conversation, false, `${name} is not a public Conversation capability`); } diff --git a/apps/desktop/src/main/__tests__/conversation-markdown.test.ts b/apps/desktop/src/main/__tests__/conversation-markdown.test.ts index ad11f95c9d2..95b0e5314e0 100644 --- a/apps/desktop/src/main/__tests__/conversation-markdown.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-markdown.test.ts @@ -25,7 +25,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { StoredMessage } from '@maka/core/session'; -import { renderConversationMarkdown } from '../../renderer/conversation-markdown.js'; +import { renderConversationMarkdown } from '../../renderer/features/conversation/testing.js'; describe('renderConversationMarkdown', () => { it('uses displayText for user turns when the model text is a skill envelope', () => { diff --git a/apps/desktop/src/main/__tests__/conversation-owner.test.ts b/apps/desktop/src/main/__tests__/conversation-owner.test.ts index c17929d5737..69892c3280b 100644 --- a/apps/desktop/src/main/__tests__/conversation-owner.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -26,7 +26,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, ConversationComposerRegion, useAppShellSessionUiState, type ConversationObservationServices, type ConversationServices } from '../../renderer/features/conversation/index.js'; -import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; +import { renderConversationMarkdown, stubComposerGateInputs, stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { withComposerSubmission } from './composer-submission-fixture.js'; @@ -113,8 +113,8 @@ function harness(options: { showModelSetupToast() {}, onTurnCompleted() {}, searchTarget: null, clearSearchTarget() {}, })), - visible ? createElement(ConversationTranscriptRegion[0]>, { surface: Transcript }) : null, - createElement(ConversationComposerRegion[0]>, { surface: Composer }), + visible ? createElement(ConversationTranscriptRegion[0]>, { surface: Transcript, localInteractionAvailable: true }) : null, + createElement(ConversationComposerRegion[0]>, { surface: Composer, ...stubComposerGateInputs() }), ); } act(() => root.render(createElement(LocaleProvider, { locale: options.locale ?? 'en', children: @@ -183,6 +183,19 @@ describe('Conversation ownership', () => { }); } + it('exports the published range for Copy and Save without handing the shell its messages', async () => { + const h = harness(); + await act(async () => h.target.setActiveId('A')); + const published = [message('first'), message('second')]; + await act(async () => h.opened[0]!.publish(published)); + assert.equal( + h.target.renderPublishedConversation('Task A', 'en'), + renderConversationMarkdown('Task A', published, 'en'), + ); + assert.equal('readMessages' in h.target, false, 'the shell has no invocation-time message read'); + await act(async () => h.root.unmount()); + }); + it('publishes only to regional readers and preserves the persistent composer across transcript remounts', async () => { const h = harness(); await act(async () => h.target.setActiveId('A')); diff --git a/apps/desktop/src/main/__tests__/custom-pet-activity.test.ts b/apps/desktop/src/main/__tests__/custom-pet-activity.test.ts new file mode 100644 index 00000000000..23bcb6f2be1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/custom-pet-activity.test.ts @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { petActivityForSession } from '../../renderer/custom-pet-companion-model.js'; + +test('the companion reads the displayed Session\'s activity and catalog row', () => { + const idle = { turnRunning: false, awaitingInteraction: false }; + const at = (activity: typeof idle, sessionStatus?: 'active' | 'blocked' | 'waiting_for_user', hasActiveSession = true) => + petActivityForSession({ activity, hasActiveSession, sessionStatus }); + assert.equal(at(idle, 'active'), 'idle'); + assert.equal(at({ ...idle, turnRunning: true }, 'active'), 'working'); + assert.equal(at({ turnRunning: true, awaitingInteraction: true }, 'active'), 'needs-input', 'an open prompt outranks the running Turn'); + assert.equal(at(idle, 'waiting_for_user'), 'needs-input'); + assert.equal(at({ ...idle, turnRunning: true }, 'blocked'), 'blocked'); + assert.equal(at({ turnRunning: true, awaitingInteraction: true }, 'active', false), 'idle', 'no Session, no activity'); +}); diff --git a/apps/desktop/src/main/__tests__/executor-selection.test.ts b/apps/desktop/src/main/__tests__/executor-selection.test.ts index d41e37b806f..192b3d9a36d 100644 --- a/apps/desktop/src/main/__tests__/executor-selection.test.ts +++ b/apps/desktop/src/main/__tests__/executor-selection.test.ts @@ -24,7 +24,8 @@ import { act, createElement } from 'react'; import { createRoot } from 'react-dom/client'; import type { ExecutorCatalogEntry } from '@maka/core/executor-catalog'; import type { SessionSummary } from '@maka/core/session'; -import { useExecutorSelection, newTaskConfiguration, executorSubmissionError, executorComposerProps, createExecutorSessionActivator, ConversationServicesProvider, type ConversationServices } from '../../renderer/features/conversation/index.js'; +import { useExecutorSelection, createExecutorSessionActivator, ConversationServicesProvider, type ConversationServices } from '../../renderer/features/conversation/index.js'; +import { executorComposerProps, executorSubmissionError, newTaskConfiguration } from '../../renderer/features/conversation/testing.js'; const entry: ExecutorCatalogEntry = { id: 'external', displayName: 'External', readiness: 'ready', models: [{ id: 'selected', name: 'Selected' }], supportsAttachments: false, supportsModelChange: true }; diff --git a/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts b/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts index 463528cca00..7f6f86cdbbe 100644 --- a/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts +++ b/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts @@ -164,7 +164,7 @@ async function mountShell(services: SessionCollaborationServices) { services, children: createElement(GuestTurnRequests, { sessionId: shared ? sessionId : undefined, - composerRef: composer, + discardDraft: (draftKey: string) => composer.current?.clearDraft(draftKey), children: (guest) => createElement(ChatComposerRegion, { composerRef: composer, guest, diff --git a/apps/desktop/src/main/__tests__/live-content-visibility.test.ts b/apps/desktop/src/main/__tests__/live-content-visibility.test.ts index a1ad501e708..0e7da5abf71 100644 --- a/apps/desktop/src/main/__tests__/live-content-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/live-content-visibility.test.ts @@ -27,7 +27,7 @@ import { reconcileObservationAuthority, revealLiveContentSeed, visibleLiveContentGeneration, -} from '../../renderer/features/conversation/index.js'; +} from '../../renderer/features/conversation/testing.js'; test('observation generations change only when the owning source changes', () => { const selected = reconcileObservationAuthority( diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index 884e2ca333d..9f7a267528e 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -28,7 +28,7 @@ import { type ModelCatalogEntry, } from '@maka/core/model-catalog'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; -import { pickNewChatModel } from '../../renderer/features/conversation/index.js'; +import { pickNewChatModel } from '../../renderer/features/conversation/testing.js'; import { buildCatalogDailyReviewModelOptions } from '../../renderer/model-catalog-choices.js'; function connection( diff --git a/apps/desktop/src/main/__tests__/new-chat-executor-selection.test.ts b/apps/desktop/src/main/__tests__/new-chat-executor-selection.test.ts index 4f5ac6d3680..f2a4e46d73e 100644 --- a/apps/desktop/src/main/__tests__/new-chat-executor-selection.test.ts +++ b/apps/desktop/src/main/__tests__/new-chat-executor-selection.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { resolveNewChatExecutionThinkingLevel } from '../../renderer/features/conversation/index.js'; +import { resolveNewChatExecutionThinkingLevel } from '../../renderer/features/conversation/testing.js'; test('new plugin-executor Sessions preserve the selected thinking level', () => { assert.equal( diff --git a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts index 3ec86613cc7..8ab898b9bb4 100644 --- a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts +++ b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts @@ -34,7 +34,7 @@ import { import { composerModelSupportsVision, type NewChatModel, -} from '../../renderer/features/conversation/index.js'; +} from '../../renderer/features/conversation/testing.js'; /** * #3408 for what the composer STAGES. The draft text is covered by diff --git a/apps/desktop/src/main/__tests__/session-health-notice.test.ts b/apps/desktop/src/main/__tests__/session-health-notice.test.ts index dac6b20d8f9..a01f35e6b8f 100644 --- a/apps/desktop/src/main/__tests__/session-health-notice.test.ts +++ b/apps/desktop/src/main/__tests__/session-health-notice.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { SessionSendProjection } from '@maka/core/session-send-projection'; -import { deriveSessionHealthNotice } from '../../renderer/features/conversation/index.js'; +import { deriveSessionHealthNotice } from '../../renderer/features/conversation/testing.js'; const legacySession = { backend: 'ai-sdk', diff --git a/apps/desktop/src/main/__tests__/session-ui-reads.test.ts b/apps/desktop/src/main/__tests__/session-ui-reads.test.ts index f649e17fa79..fecebf17125 100644 --- a/apps/desktop/src/main/__tests__/session-ui-reads.test.ts +++ b/apps/desktop/src/main/__tests__/session-ui-reads.test.ts @@ -33,8 +33,8 @@ import { stubConversationServices, useConversationOwner, useConversationQueue, + useSessionUiRead, } from '../../renderer/features/conversation/testing.js'; -import { useAppShellSessionUiReads } from '../../renderer/use-app-shell-session-ui-reads.js'; import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; @@ -204,17 +204,29 @@ describe('Session UI public read capabilities', () => { }); }); +/** The fixed-purpose reads the Conversation regions take for the displayed and owner Session. */ +function useDisplayedSessionReads(reads: AppShellSessionUiStateController['reads'], activeId?: string, ownerId?: string) { + return { + ...useSessionUiRead(reads, 'load', activeId), + ...useSessionUiRead(reads, 'summary', activeId), + messageRetryPending: useSessionUiRead(reads, 'retry', activeId), + stopPending: useSessionUiRead(reads, 'stop', activeId), + activeInteraction: useSessionUiRead(reads, 'interaction', ownerId), + activeMessageQueue: useSessionUiRead(reads, 'queue', activeId), + }; +} + describe('production Session UI consumers', () => { - it('keeps background state and pulse out of the shell, while respecting owner Session identity', async () => { + it('keeps background state and pulse out of a displayed-Session reader, while respecting owner Session identity', async () => { const { root } = installReactRenderer(); const c = createController(); let renders = 0; let pulseRenders = 0; - let value!: ReturnType; + let value!: ReturnType; let pulse!: ReadonlySet; function Shell(props: { activeId?: string; ownerId?: string }) { renders += 1; - value = useAppShellSessionUiReads(c.reads, props.activeId, props.ownerId); + value = useDisplayedSessionReads(c.reads, props.activeId, props.ownerId); return null; } function Rail() { diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index e000448e4bc..14a84da959f 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -251,7 +251,7 @@ function workBoardInput( ownerRef.current += 1; return ownerRef.current; }, - composerRef: { current: { setDraft: () => undefined, focus: () => undefined } }, + composerDraft: { seedDraft: () => undefined, focus: () => undefined }, ...overrides, }; } diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index 3c260f4c85a..ee37545a5de 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -278,32 +278,27 @@ 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`, whose result reaches `LocaleProvider`, `ErrorBoundary` and `AppShellContent` | legacy `use-system-ui-locale.ts` | read the OS locale and its changes | locale | — | -| `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` | lifecycle handlers and startup | `ShellLifecycleSubscriptions` (Session, connection, Host-profile and settings refreshes, window commands); the startup Session and E2E-fixture refresh; ⌘, and ⌘N | legacy `app-shell-effects.ts` | startup refreshes, the global shortcuts, the mounted flag and pending-ref cleanup; returns the handlers `ShellLifecycleSubscriptions` subscribes with the injected `ShellLifecycleSources`; no bridge access | application lifecycle | — | +| `AppShellContent` | `useActiveExecutionBoundary` | `activeExecutionBoundary` | Composer permission control and unreadable notice; the palette's permission-mode command; the Session Collaboration dialog gate; the health notice's picker gate; reloaded by the Composer submission owner and Conversation lifecycle | Conversation | read and reload the owner Session's execution boundary | cross-region command | — | +| `AppShellContent` | `useAppShellBootstrapSubscriptions` | lifecycle handlers and startup | `ShellLifecycleSubscriptions` (Session, connection, Host-profile and settings refreshes, window commands); the startup Session and E2E-fixture refresh; ⌘, and ⌘N | legacy `app-shell-effects.ts` | startup refreshes, the global shortcuts and the mounted flag; returns the handlers `ShellLifecycleSubscriptions` subscribes with the injected `ShellLifecycleSources`; no bridge access | application lifecycle | — | | `AppShellContent` | `useAppShellHostEffects` | titlebar modal sync | the native window controls, dimmed through `titlebar-modal-sync.ts` | legacy `app-shell-effects.ts` | observe top-layer modals; the `data-os` platform tag is applied by `ShellLifecycleSources` | layout | — | | `AppShellContent` | `useAppShellNavRefSync` | `navSelectionRef` | ownership checks of async results (Composer claim, surface-owner checks) | legacy `app-shell-effects.ts` | 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, path and open-folder gate; Workbar project id and aliases; Module Hub and command-palette client-path access; composer mentions project path; the default-Host refresh in the lifecycle handlers | legacy `use-project-context.ts` | read the owner Session's project (path, Git state, current project, capabilities) and, with no Session, the default Host's, and re-read it on change; no writes: project mutations and the open-folder commands belong to Task Entry | application lifecycle | — | -| `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; transcript flags; the queue surface and Composer ref; the Session UI reads store | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection, the catalog controller, list refresh and row patches, message reads and refresh | navigation | — | -| `AppShellContent` | `useAppShellTurnPresentation` | `deriveTurnPresentation` | `ChatView` turn footer | application contract `turn-presentation` | derive turn presentation from the transcript projection and pending turn actions | — | M3 | +| `AppShellContent` | `useAppShellSessionWorkspace` | Session workspace | every region's requested, published and owner Session; transcript flags; the queue surface and named Composer edits; the Session UI reads store | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection, the catalog controller, list refresh and row patches, message reads and refresh | navigation | — | | `AppShellContent` | `useLayoutEffect` | `openSessionInChatRef` publication | every caller of `openSessionInChat`: turn footer, transcript lineage, linked-Session and revision links, Composer submission, Workbar, Module Hub, command palette, Settings overlays, turn-request approval, agent graph, titlebar parent link | AppShell | publish the current open-Session command into a ref | cross-region command | — | | `AppShellContent` | `useSessionNavigationReads` | rail reads | transcript revision navigation, titlebar parent and `sidebarCollapsed`, frame `data-sidebar-state` and `--maka-sidenav-width` | Session Navigation | revision navigation, the active parent Session and the rail layout; writes the compact flag into `sessionRailLayoutStore` | navigation | — | -| `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` | `useSessionSettingIntent` | selected-Session setting overlay | Composer model, thinking and mode controls; the palette's permission-mode command; new-task settings for creation; Session teardown | Session Settings | an equality-selected overlay read, the new-task settings and setting commands | cross-region command | — | | `AppShellContent` | `useShellAppearance` | appearance settings | theme, palette, user label, Workbar toggle position, locale update gate; `appearanceHydrated` for the previous-shutdown notice | legacy `use-shell-appearance.ts` | read client and Host appearance settings and E2E state, hydrate the locale, apply theme, palette and font sizes, and expose local setters | layout | — | -| `AppShellContent` | `useShellChatModel` | Composer model selection | model picker, health notice, new-chat model | Conversation (transitional) | derive model, thinking and executor selection | — | M3 | +| `AppShellContent` | `useShellChatModel` | Composer model selection | Composer model and executor pickers; the transcript's health notice; the staging vision gate; the readiness and new-task submission model; the first-send Session activation, which adopts the submitted executor catalog; Workbar model choices | Conversation | derive the model, thinking and executor selection and set the new-task choice | cross-region command | — | | `AppShellContent` | `useShellConnections` | `newTaskConnections` | new-task model choices; transcript and onboarding connections and refresh; executor connection count | legacy `use-shell-connections.ts` | the new-task target's connection snapshot and refresh, falling back to the default Host or onboarding snapshot | application lifecycle | — | | `AppShellContent` | `useShellConnections` | `defaultHostConnections` | `OnboardingConnectionSeed`; the new-task fallback; the refresh when Settings closes; the connection-event fan-out | legacy `use-shell-connections.ts` | the default Host's connection snapshot and refresh | application lifecycle | — | | `AppShellContent` | `useShellConnections` | `sessionHostConnections` | owner Session model choices and readiness; transcript connections and refresh; the WorkHub snapshot | 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 indicator | the transcript's session-context memory indicator; refreshed by the lifecycle handlers and when Settings closes | legacy `use-shell-memory-pill.ts` | read and refresh the owner Session's memory state, or the default Host's without a Session | layout | — | -| `AppShellContent` | `useShellResume` | resume offer | Composer send slot | Conversation | per-Session resume availability | — | M3 | | `AppShellContent` | `useStableActions` | `createAppShellE2eFixtureActions` | the startup E2E-fixture refresh | legacy `app-shell-e2e-fixture.ts` over `application/contracts/use-stable-actions.ts` | apply test fixtures across Session selection and refresh, navigation, rail, Workbar, Settings, search and appearance | cross-region command | — | | `AppShellContent` | `useState` | `petCompletionNonce` | custom pet companion | AppShell | a counter `ConversationLifecycle`'s `onTurnCompleted` 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` | `workHubActive` | WorkHub or Session surface; Workbar workspace; the rail entry; the Session-Host connection snapshot; the titlebar identity | AppShell | whether the WorkHub surface is shown; enablement is the WorkHub enablement authority's | navigation | — | | `AppShellContent` | `useToast` | `toastApi` | toasts and confirm dialogs of every legacy action | `@maka/ui` `ToastProvider` (Astryx) | show toasts and confirmations | cross-region command | — | -| `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 @@ -312,21 +307,22 @@ Conversation keeps its own table of transitional capabilities in its README. Outside it, the public exports the root takes that are not plain assembly components (providers, roots, hosts and overlays mounted through JSX) are the following. Exports only tests or Storybook read live in each feature's -`testing.ts`, not its public entry. +`testing.ts`, not its public entry. A feature cannot depend on another, so a +projection one feature makes into another's region is composed here. | Feature | Export | Root consumer | Kind | Stays because / Removal | | --- | --- | --- | --- | --- | | overlays | `OverlaysConsumer` | `app-shell-overlays.tsx` (Settings modal, palette command list) | render-prop projection of overlay state | stays: root composition of the legacy Settings surface and the palette command list, which a feature cannot import; it moves when the Settings surface migrates, outside R2 | | diagnostics | `ManualDiagnosticReportConsumer` | command palette options in `app-shell.tsx` | render-prop manual report command | stays: cross-region command (the diagnostics owner's manual report, handed to the shell-built palette options) | -| task-entry | `TaskEntryWorkspacePickerConsumer` | Composer region in `app-shell.tsx` | render-prop workspace picker | M3 | -| session-collaboration | `GuestTurnRequests` | Composer region in `app-shell.tsx` | render-prop guest composer projection over the Composer ref | M3 | -| module-hub | `ModuleHubSkillCatalogRevisionBoundary` | Composer mentions provider | render-prop skill catalog revision | M3 | +| task-entry | `TaskEntryWorkspacePickerConsumer` | Composer region in `app-shell.tsx` | render-prop workspace picker | stays: cross-region projection into the Composer | +| session-collaboration | `GuestTurnRequests` | Composer region in `app-shell.tsx` | render-prop guest composer projection; discards a settled request's draft through the named Composer edit | stays: cross-region projection into the Composer | +| module-hub | `ModuleHubSkillCatalogRevisionBoundary` | Composer mentions provider | render-prop skill catalog revision | stays: cross-region projection into the Composer | | module-hub | `ModuleHubScheduledTasksBoundary` | Session rail (`SessionNavigationProvider`) | render-prop scheduled tasks | stays: cross-region projection into navigation | | module-hub | `createModuleHubCommandPort` | command palette; project selection | command port | stays: cross-region command | | session-navigation | `createSessionOpenCommand` | open-Session command | command factory | stays: cross-region command | | session-navigation | `sessionRailLayoutStore` | rail collapse handle; E2E fixture | layout store | stays: layout | | session-navigation | `useSessionNavigationReads` | see the retained-root table | read hook | stays: navigation | -| session-settings | `useSessionSettingIntent` | Composer model and mode controls | read hook and commands | M3 | +| session-settings | `useSessionSettingIntent` | see the retained-root table | read hook and commands | stays: cross-region command | `settings/` holds the settings pages and the `SettingsModal` shell — one page per `SettingsSection` (defined in `@maka/core`); the models/providers page is `ProvidersPanel`. Plus the `provider-*` files and the shared `settings-rows` / `settings-skeleton` / `settings-surface` helpers. diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index d33e899f0c8..f59dc758a5c 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -20,7 +20,7 @@ import { useMemo, useRef } from "react"; import type { LlmConnection } from '@maka/core/llm-connections'; import type { PermissionMode } from '@maka/core/permission'; -import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import type { SessionSummary } from '@maka/core/session'; import type { SettingsSection, ThemePreference } from '@maka/core/settings'; import type { UiLocale } from '@maka/core/ui-locale'; import type { NavSelection } from "@maka/ui"; @@ -33,7 +33,6 @@ import { buildCommandList } from "./command-palette-commands.js"; import type { CopyManualDiagnosticReport } from './features/diagnostics/index.js'; import type { Command, OverlayPaletteActions } from './features/overlays/index.js'; import type { SessionCatalogController } from './application/contracts/session-catalog/session-catalog-state.js'; -import { renderConversationMarkdown } from "./conversation-markdown.js"; import { commandPaletteActionErrorMessage, commandPaletteConnectionTestFailureMessage, @@ -69,7 +68,8 @@ export interface AppShellCommandListOptions { clientPathsAccessible: boolean; connections: LlmConnection[]; defaultConnection: string | null; - readMessages(): readonly StoredMessage[]; + /** Copy and Save's Markdown export of the published conversation. */ + renderPublishedConversation(sessionName: string, locale: UiLocale): string; newTaskProfileId: string | undefined; settingsOpen: boolean; settingsProfileId: string | undefined; @@ -217,14 +217,10 @@ export function buildAppShellCommandList( : {}), onSelectModule: (selection) => optionsRef.current.setNavSelection(selection), onExportActiveConversation: async () => { - const { activeId, readMessages, sessionCatalog, toastApi } = optionsRef.current; + const { activeId, renderPublishedConversation, sessionCatalog, toastApi } = optionsRef.current; if (!activeId) return; const session = sessionCatalog.getState().sessions.find((s) => s.id === activeId); - const markdown = renderConversationMarkdown( - session?.name ?? copy.newConversation, - readMessages(), - locale, - ); + const markdown = renderPublishedConversation(session?.name ?? copy.newConversation, locale); try { await navigator.clipboard.writeText(markdown); toastApi.success( @@ -236,15 +232,11 @@ export function buildAppShellCommandList( } }, onSaveActiveConversationToFile: async () => { - const { activeId, paletteActions, readMessages, sessionCatalog, toastApi } = optionsRef.current; + const { activeId, paletteActions, renderPublishedConversation, sessionCatalog, toastApi } = optionsRef.current; if (!activeId) return; const session = sessionCatalog.getState().sessions.find((s) => s.id === activeId); const sessionName = session?.name ?? copy.newConversation; - const markdown = renderConversationMarkdown( - sessionName, - readMessages(), - locale, - ); + const markdown = renderPublishedConversation(sessionName, locale); const now = new Date(); const yyyy = now.getFullYear(); const mm = String(now.getMonth() + 1).padStart(2, "0"); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 23e4b721a1f..0232d8a273b 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -105,7 +105,6 @@ export function useAppShellBootstrapSubscriptions(options: { handleConnectionEvent: (event: ConnectionEvent) => void; openHelp: () => void; openSettings: () => void; - clearPendingTurnActions: () => void; refreshConnections: () => Promise; refreshMemoryActive: (failureContext?: 'load') => Promise; refreshMessages: (sessionId: string) => Promise; @@ -157,7 +156,6 @@ export function useAppShellBootstrapSubscriptions(options: { }); const cleanupPendingRefs = useEffectEvent(() => { options.rendererMountedRef.current = false; - options.clearPendingTurnActions(); }); useEffect(() => { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index c05455fb332..5f6cb763482 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -35,12 +35,10 @@ import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; import { collapseSessionRevisions } from '@maka/core/session-revisions'; import { isLinkedSubagentSession } from '@maka/core/session'; import { resolveUiLocale } from '@maka/core/ui-locale'; -import { slashCommandsForSurface } from '@maka/core/slash-command-catalog'; import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; import { ChatSurfaceLayout, type ComposerHandle, - type ComposerSlashCommandOption, type MakaUriDest, MakaUriContext, AstryxLocaleProvider, @@ -57,7 +55,6 @@ import { } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; import { ChatMessageSurface } from './chat-message-surface'; -import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; import * as Conversation from './features/conversation'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { AgentGraphPanel } from './agent-graph-panel'; @@ -81,7 +78,6 @@ 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 } from './application/contracts/desktop-slash-command.js'; import { getOnboardingActivationCandidate, OnboardingConnectionSeed, @@ -103,7 +99,6 @@ import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; import { SessionSettingsProvider, useSessionSettingIntent } from './features/session-settings'; import { pendingSessionView } from './pending-session-view'; -import { useAppShellTurnPresentation } from './application/contracts/turn-presentation'; import { readScrollMotionBehavior } from './scroll-motion-policy'; import { readNavigationState, selectNavigation } from './nav-selection'; import { deriveDesktopExecutionBoundarySurface } from './desktop-execution-boundary-surface'; @@ -114,8 +109,7 @@ import { AppShellDetailPanel } from './shell/detail-panel'; import { appShellFrameStyle } from './shell/frame-style'; import { AppShellOverlays } from './app-shell-overlays'; import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; -import { CustomPetCompanion } from './custom-pet-companion'; -import { derivePetActivityState } from './custom-pet-companion-model'; +import { CustomPetCompanionForSession } from './custom-pet-companion'; import { defaultRuntimeHostDiagnosticTarget } from './platform/desktop/default-runtime-host-operation.js'; import { useAppShellProjectContext } from './use-project-context'; import { createAppShellE2eFixtureActions } from './app-shell-e2e-fixture'; @@ -127,12 +121,9 @@ import { useAppShellNavRefSync, } from './app-shell-effects'; import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; -import { useTurnActionRegistry } from './use-turn-action-registry'; import { - desktopSlashCommandPresentation, useActiveExecutionBoundary, useShellChatModel, - useShellResume, } from './features/conversation/index.js'; import { type ComposerMentionsSurfaceInput, @@ -141,7 +132,6 @@ import { import { useAppShellSessionWorkspace } from './use-app-shell-session-workspace'; import { useShellMemoryPill } from './use-shell-memory-pill'; import { useShellConnections } from './use-shell-connections'; -import { useShellLiveTurn } from './use-shell-live-turn'; import { useSystemUiLocale } from './use-system-ui-locale'; import { AppShell as AstryxAppShell } from '@astryxdesign/core/AppShell'; @@ -241,7 +231,7 @@ function AppShellContent({ }) { const toastApi = useToast(); const { - readMessages, + renderPublishedConversation, refreshMessages, transcriptEmpty, transcriptHasHistory, @@ -320,15 +310,6 @@ function AppShellContent({ // Navigation only: whether WorkHub is enabled at all is the client switch // the WorkHub enablement authority owns (see WorkHubEnablementWatch below). const [workHubActive, setWorkHubActive] = useState(false); - // #4582: read only displayed/owner Session chrome. Token content and global - // streaming membership subscribe inside their consuming regions. - const { - messageLoadError: activeMessageLoadError, - activeInteraction, - activeMessageQueue, - activeLiveTurnSnapshot, - activeExecution, - } = useAppShellSessionUiReads(sessionUiReads, activeId, ownerActiveId); // The chat surface follows the active Session's Host. Settings and global // commands remain owned by the default Host. const { memoryActive, refreshMemoryActive } = useShellMemoryPill({ @@ -439,18 +420,8 @@ function AppShellContent({ // recent workspace history so the home view is populated before the async // `app:info` round-trip completes on mount. const persistedComposerDefaults = loadComposerDefaults(); - const composerRef = queueSurface.composer; - const openComposerModelPicker = useCallback(() => { - composerRef.current?.openModelPicker(); - }, []); - const { safeResumeAction, composerResumeAction } = useShellResume({ - activeId, - ownerActiveId, - sharedSessionActive, - toastApi, - shellCopy, - uiLocale, - }); + // Named Composer edits; the editor handle stays with Conversation. + const composerEditing = queueSurface.editing; const rendererMountedRef = useRef(true); const activeSession = activeCatalogSession; const { setPermissionMode, setSessionModel, setSessionThinkingLevel, setSessionExecutor } = sessionSettingIntent.commands; @@ -468,23 +439,6 @@ function AppShellContent({ : {}), } : undefined; - // The shell's reading of the active live turn: streaming/settled flags, the - // in-flight tool signal, and the #646 turn-wait cues, all derived from the - // semantic snapshot rather than the projection (#1985). - const { - activeStreamingLive, - hasLiveTurnContent, - turnActive, - } = useShellLiveTurn({ - liveTurn: activeLiveTurnSnapshot, - execution: activeExecution, - }); - const petActivityState = derivePetActivityState({ - hasActiveSession: activeSession !== undefined, - hasActiveInteraction: activeInteraction !== undefined, - turnActive: activeExecution?.available === true && turnActive, - sessionStatus: activeSession?.status, - }); // Surface a credential-lifecycle alert directly in the chat header when // the active session's connection is in `needs_reauth` / `error` or has // been deleted entirely with no usable default. Main resolves credential @@ -506,11 +460,12 @@ function AppShellContent({ const modelSettingsOwnsComposerHost = composerProfileId !== undefined && composerProfileId === taskEntry.selectors.defaultProfileId; - const modelSwitchAvailability = deriveComposerModelSwitchAvailability({ - streaming: turnActive, + // The status half of the Composer's model-switch gate; the transcript region + // adds the running Turn to the health notice's picker. + const modelPickerStatusBlocked = !deriveComposerModelSwitchAvailability({ sessionStatus: activeSession?.status, pending: false, - }); + }).available; const { chatModelChoices, activeModel, @@ -550,26 +505,17 @@ function AppShellContent({ connectionSnapshotReady: activeId ? sessionHostConnections.projection.status === 'ready' : true, - modelPickerDisabled: !modelSwitchAvailability.available, + modelPickerDisabled: modelPickerStatusBlocked, openSettingsSection, - openModelPicker: openComposerModelPicker, + openModelPicker: composerEditing.openModelPicker, refreshModelChoices: sessionHostConnections.refreshConnections, setSessionExecutor, }); - // PR109d-b: turn footer actions per turn. Derived from the - // materialized turn list (status + lineage descendants) + pending - // mask. Per @kenji PR109d review: pending state prevents double-click - // duplicate sibling turns by disabling the action button between - // click and `sessions:changed turn-status-change` arriving. - // Session-row mutations live in Session Navigation; the per-session mode and - // model claims live in the session UI store. - const turnActionRegistry = useTurnActionRegistry(); - function clearSessionRendererState(sessionId: string): void { // `clearOwnedSessionState` ends in `clearSessionUiState`, which drops this // session from every session-UI map — the four pending claims included. clearOwnedSessionState(sessionId); - turnActionRegistry.clearForSession(sessionId); + composerSubmission.clearPendingTurnActions(sessionId); sessionSettingIntent.commands.clear(sessionId); } @@ -611,17 +557,6 @@ function AppShellContent({ return setOrchestrationMode('default'); } - // Handed to ChatView, which calls it with the turns its transcript projection - // produced. The shell no longer materializes the transcript a second time to - // derive these props, so the turn objects the projection kept are also what - // keeps the props a memoized TurnView reads stable (#2030). - const deriveTurnPresentation = useAppShellTurnPresentation({ - allowBranch: !sharedSessionActive, - activeId, - pendingTurnActions: turnActionRegistry.keys, - uiLocale, - }); - const openSessionInChatRef = useRef< (sessionId: string, turnId?: string, sequence?: number) => void >(() => undefined); @@ -642,8 +577,8 @@ function AppShellContent({ (_skillId: string, skillName: string) => { setNavSelection({ section: 'sessions' }); const seed = () => { - composerRef.current?.appendText(shellCopy.useSkillPrompt(skillName)); - composerRef.current?.focus(); + composerEditing.appendText(shellCopy.useSkillPrompt(skillName)); + composerEditing.focus(); }; if (activeIdRef.current) window.requestAnimationFrame(seed); else void createSession().then(() => window.requestAnimationFrame(seed)); @@ -677,20 +612,6 @@ function AppShellContent({ ?? activeSessionForView?.orchestrationMode ?? 'default' : 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 - * the other; only "this one is already changing" is per-control. - */ - const modeChangeDisabledReason = activeId && !activeSession - ? shellCopy.modeChangeLoading - : activeStreamingLive - ? shellCopy.modeChangeStreaming - : activeId && turnActive - ? shellCopy.modeChangeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.modeChangeWaiting - : undefined; const { boundary: activeExecutionBoundary, unreadable: activeExecutionBoundaryUnreadable, @@ -754,19 +675,6 @@ function AppShellContent({ onRetry: () => reloadActiveExecutionBoundary(activeId), } : undefined; - const desktopSlashCommands = useMemo( - () => { - const availableCommands = slashCommandsForSurface('desktop').filter( - desktopSlashCommandAvailability({ - hasSession: Boolean(activeId), - streaming: turnActive, - }), - ); - const presentation = desktopSlashCommandPresentation(shellCopy.slashCommands); - return availableCommands.map(({ id }) => ({ id, ...presentation[id] })); - }, - [activeId, activeStreamingLive, shellCopy.slashCommands, turnActive], - ); const moduleHubCommands = useMemo(ModuleHub.createModuleHubCommandPort, []); const { projectInfo, @@ -788,22 +696,18 @@ function AppShellContent({ ); const captureActiveComposerClaim = useCallback(() => { const sessionId = activeIdRef.current; - const composer = composerRef.current; - if ( - !sessionId || - !composer || - navSelectionRef.current.section !== 'sessions' - ) { - return undefined; - } + const claim = navSelectionRef.current.section === 'sessions' && sessionId + ? composerEditing.claimVisibleDraft() + : undefined; + if (!claim) return undefined; return { isCurrent: () => activeIdRef.current === sessionId && navSelectionRef.current.section === 'sessions' && - composerRef.current === composer, - append: (text: string) => composer.appendText(text), + claim.isCurrent(), + append: claim.append, }; - }, []); + }, [composerEditing]); // Where a NEW chat starts. Built unconditionally and handed to the composer, // which renders it only while no session owns it — the project is fixed once // the first message creates one, so there is nothing to pick after that. @@ -833,9 +737,9 @@ function AppShellContent({ 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()); + window.requestAnimationFrame(() => composerEditing.focus()); return ownerToken; - }, [composerStaging, sessionSettingIntent.commands, setNavSelection, setSearchScrollTarget, startNewSession]); + }, [composerEditing, composerStaging, sessionSettingIntent.commands, setNavSelection, setSearchScrollTarget, startNewSession]); const createSession = useCallback(async () => { openNewTaskSurface(); @@ -957,13 +861,12 @@ function AppShellContent({ activeIdRef, applyE2eFixture, bootstrapSessions, - clearPendingTurnActionsForSession: turnActionRegistry.clearForSession, + clearPendingTurnActionsForSession: composerSubmission.clearPendingTurnActions, createSession, handleConnectionEvent, openHelp, openSettings, - clearPendingTurnActions: turnActionRegistry.clearAll, refreshConnections: refreshConnectionProjections, refreshMemoryActive, refreshMessages, @@ -1037,8 +940,8 @@ function AppShellContent({ * * - `kind: 'settings'` → `openSettingsSection(section)` (existing * Settings modal jump, persisted via localStorage). - * - `kind: 'compose'` → write text into the composer via - * `composerRef.current.setText(...)` and focus it. We do NOT + * - `kind: 'compose'` → replace the composer's text through + * `composerEditing.replaceText(...)` and focus it. We do NOT * auto-submit the prompt; the user still presses Enter. That * keeps an injected `maka://compose?text=ransfer my keys...` * from sending without a human in the loop. @@ -1054,8 +957,8 @@ function AppShellContent({ openSettingsSection(dest.section); return; case 'compose': - composerRef.current?.setText(dest.text); - composerRef.current?.focus(); + composerEditing.replaceText(dest.text); + composerEditing.focus(); return; default: { const _exhaustive: never = dest; @@ -1123,11 +1026,9 @@ function AppShellContent({ // 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 && - transcriptEmpty && - !hasLiveTurnContent && - !activeMessageLoadError; + // The home surface also needs no live Turn content and no failed load, which + // `ConversationHomeSurface` reads for the displayed Session. + const homeSurfaceEligible = sessionsSelected && transcriptEmpty; const commandOptions: AppShellCommandListOptions = { uiLocale, activeId, @@ -1139,7 +1040,7 @@ function AppShellContent({ : projectCapabilities.viewClientPath, connections: defaultHostConnections.snapshot.connections, defaultConnection: defaultHostConnections.snapshot.defaultConnection, - readMessages, + renderPublishedConversation, newTaskProfileId: taskEntry.selectors.selectedProfileId, settingsOpen, settingsProfileId: overlays.selectors.settings.request.profileId, @@ -1194,7 +1095,7 @@ function AppShellContent({ workspaceRecoverySessionId={activeSession?.id} openSessionWorkspaceRecovery={openSessionWorkspaceRecovery} addProject={taskEntry.selectors.canAddProject ? taskEntry.commands.addProject : undefined}> commands.openTool('side-chat', 'right', options), - turnActions: turnActionRegistry, orchestrationMode: () => activeOrchestrationMode, setOrchestrationModeActive, }}> @@ -1263,7 +1163,7 @@ function AppShellContent({ clientPathsAccessible={projectCapabilities.viewClientPath} useSkillInChat={useSkillInChat} openSession={openSessionInChat} - appendComposerText={(text) => composerRef.current?.appendText(text)} + appendComposerText={composerEditing.appendText} captureActiveComposerClaim={captureActiveComposerClaim} commandPort={moduleHubCommands} > @@ -1287,7 +1187,7 @@ function AppShellContent({ shellObscured, modelChoices: chatModelChoices, toastApi, - composerRef, + composerDraft: composerEditing, openNewTaskSurface, openSessionInChat, resolveWorkBoardTarget, @@ -1443,7 +1343,7 @@ function AppShellContent({ navigation entry point. */}
-
@@ -1488,39 +1388,30 @@ function AppShellContent({ {(workspacePicker) => ( {(guest) => ( openSettingsSection('external-agents'), onNewTask: openNewTaskSurface})} + executorComposer={{ selection: executor, taskSubmissionHardBlocked, connectionCount: connections.length, onSetup: () => openSettingsSection('external-agents'), onNewTask: openNewTaskSurface }} activeSession={activeSessionForView} {...{ executorTarget, onExecutorTargetChange }} usageModel={activeModel} @@ -1531,7 +1422,6 @@ function AppShellContent({ modelSwitchHasHistory={modelSwitchHasHistory} renderProviderMark={(type) => } onModelChange={(input) => activeId ? void setSessionModel(activeId, input) : undefined} - modelSwitchAvailability={modelSwitchAvailability} onThinkingLevelChange={(level) => { if (activeId) void setSessionThinkingLevel(activeId, level ?? null); }} @@ -1548,39 +1438,13 @@ function AppShellContent({ ? shellCopy.configureModelsOnHost(composerProfileName) : undefined} permissionMode={activePermissionMode} - // Every "cannot change this mid-turn" gate reads `turnActive`, - // the same witness Stop reads. Reading the persisted status - // here instead left these toggles live through the whole - // send→run-start window — long enough on a cold backend for a - // mode change to land before the run registers and alter the - // execution config of the turn already sent. - permissionModeDisabledReason={ - activeStreamingLive - ? shellCopy.permissionModeStreaming - : activeId && turnActive - ? shellCopy.permissionModeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.permissionModeWaiting - : undefined - } onPermissionModeChange={async mode => { await setPermissionMode(mode) }} planModeActive={activePlanMode} - // No pending-keyed disable while a toggle commits: the - // pending registries already swallow re-entrant toggles, and - // a reason here would gray the row mid-click — the blink - // this control had. The rows repaint when the write lands. - planModeDisabledReason={modeChangeDisabledReason} onPlanModeChange={(active) => void setPlanMode(active)} orchestrationMode={activeOrchestrationMode} - orchestrationModeDisabledReason={modeChangeDisabledReason} onOrchestrationModeChange={(mode) => void setOrchestrationMode(mode)} - goalDisabledReason={ - activeStreamingLive || (activeId && turnActive) - ? shellCopy.goalTurnActive - : undefined - } /> )} @@ -1591,15 +1455,12 @@ function AppShellContent({ > {sessionsSelected ? ( openSettingsSection('memory')} - deriveTurnPresentation={deriveTurnPresentation} onTurnFooterAction={sharedSessionActive ? undefined : composerSubmission.handleTurnFooterAction} onEditUserMessage={sharedSessionActive ? undefined : composerSubmission.beginEditUserMessage} - safeResumeAction={safeResumeAction} onLineageBadgeClick={(turnId) => { if (activeId) openSessionInChat(activeId, turnId); }} onOpenLinkedSession={openSessionInChat} scrollTargetTurn={ @@ -1614,13 +1475,13 @@ function AppShellContent({ revisionNavigation={revisionNavigation} onRevisionNavigate={openSessionInChat} onNew={createSession} - onPromptSuggestion={(prompt) => composerRef.current?.appendText(prompt)} + onPromptSuggestion={composerEditing.appendText} onQuoteSelection={ sharedSessionActive ? undefined : (selection) => { composerStaging.addQuote(selection); - composerRef.current?.focus(); + composerEditing.focus(); } } onAskAboutSelection={ @@ -1635,9 +1496,7 @@ function AppShellContent({ : undefined } sessionHealthNotice={sessionHealthNotice} - sessionHealthModelPickerAvailable={ - activeBoundarySurface.localInteractionAvailable - } + localInteractionAvailable={activeBoundarySurface.localInteractionAvailable} workspaceReadinessRecovery={workspaceReadinessRecovery} showOnboardingHero={showOnboardingHero} onboardingState={onboardingState} @@ -1663,7 +1522,7 @@ function AppShellContent({ ) : null} -
+ {/* Collapse hides the Workbar surface without unmounting its tools. */}
@@ -1671,8 +1530,9 @@ function AppShellContent({ {!shellObscured && ( - diff --git a/apps/desktop/src/renderer/application/contracts/turn-presentation.ts b/apps/desktop/src/renderer/application/contracts/turn-presentation.ts index 84d536c9077..ef6742a224f 100644 --- a/apps/desktop/src/renderer/application/contracts/turn-presentation.ts +++ b/apps/desktop/src/renderer/application/contracts/turn-presentation.ts @@ -246,7 +246,7 @@ export function deriveChatTurnPresentation( } /** - * The shell's `deriveTurnPresentation` prop. What matters is that the + * A transcript's `deriveTurnPresentation` prop. What matters is that the * derivation — and therefore its cache — survives across renders, which is why * it lives in a ref rather than being rebuilt in the render body. The returned * function's own identity is not load-bearing: nothing memoizes on it, and a diff --git a/apps/desktop/src/renderer/custom-pet-companion-model.ts b/apps/desktop/src/renderer/custom-pet-companion-model.ts index 14e82b21cc7..d5e844cb0d5 100644 --- a/apps/desktop/src/renderer/custom-pet-companion-model.ts +++ b/apps/desktop/src/renderer/custom-pet-companion-model.ts @@ -66,6 +66,20 @@ export function derivePetActivityState( return 'idle'; } +/** The pack state for the displayed Session: Conversation's activity reading plus its catalog row. */ +export function petActivityForSession(input: { + readonly activity: { readonly turnRunning: boolean; readonly awaitingInteraction: boolean }; + readonly hasActiveSession: boolean; + readonly sessionStatus: SessionStatus | undefined; +}): PetActivityState { + return derivePetActivityState({ + hasActiveSession: input.hasActiveSession, + hasActiveInteraction: input.activity.awaitingInteraction, + turnActive: input.activity.turnRunning, + sessionStatus: input.sessionStatus, + }); +} + export function syncPetPlaybackState(input: { readonly currentState: PetActivityState; readonly activityState: PetActivityState; diff --git a/apps/desktop/src/renderer/custom-pet-companion.tsx b/apps/desktop/src/renderer/custom-pet-companion.tsx index 9f85e4c15a5..16f470c9e94 100644 --- a/apps/desktop/src/renderer/custom-pet-companion.tsx +++ b/apps/desktop/src/renderer/custom-pet-companion.tsx @@ -30,6 +30,7 @@ import { samplePetAnimation, shouldReducePetMotion, syncPetPlaybackState, + petActivityForSession, type SelectedPetCompanion, } from './custom-pet-companion-model.js'; @@ -237,3 +238,20 @@ export function CustomPetCompanion(props: { ); } + +/** + * The companion for the displayed Session. Its Turn and interaction come from + * the Conversation activity reader, so a running Turn repaints only this. + */ +export function CustomPetCompanionForSession(props: Parameters[0] & { + completionNonce: number; + contextKey: string | undefined; +}) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 025f376269b..4ef672c4451 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -49,9 +49,10 @@ 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 is not a full-history promise. Revision commands read -the same range from the controller inside the Composer submission owner. +Copy and Save call `renderPublishedConversation(sessionName, locale)`, which +renders the **published range** as Markdown at invocation; the shell gets the +export, not the messages, and it 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`. @@ -113,8 +114,22 @@ with viewport navigation passed separately. `LiveTurnReconciler` receives only the live-content reader and its existing semantic reconciliation command. Neither reader receives the complete controller. -The shell's temporary `useAppShellSessionUiReads` projection uses the published -Session for content/queue/pending and the owner Session for interactions. +The shell takes no Session UI read. Each region reads what it renders, for the +displayed Session and, for interactions, the owner Session: +- `ConversationComposerRegion`: the Turn summary behind Stop and the controls a + running Turn holds (mode, permission, goal, model switch, executor and + `/compact`), the queue and the owner Session's interaction. The shell passes + only what the gates combine with: whether the catalog row has arrived, its + status, and the executor selection. +- `ConversationTranscriptRegion`: the running Turn's activity, which also holds + the health notice's model picker. The shell passes the boundary's + `localInteractionAvailable` as a typed region input. +- `ConversationActivityConsumer`: whether an observable Turn runs and whether + the owner Session waits on an answer, for the custom pet. +- `ConversationHomeSurface`: the main column, marked as the home surface while + the shell's empty-transcript condition holds and there is no live Turn + content and no failed load. + The Conversation provider separately reads only its published queue. `LiveTurnReconciler` still follows all retained Turns within that Session, including predecessors; it must not subscribe only to the execution root. @@ -124,12 +139,11 @@ State and reader construction modules are private under exports reader contracts and consumer hooks, not state constructors or arbitrary selectors. Whole-state inspection is available from `testing.ts` only. -Remaining transitional capabilities have explicit consumers and removal work: +Remaining transitional capabilities have explicit consumers and removal work. +None remain; a new one needs a row here. | Capability | Current consumer | Removal module | | --- | --- | --- | -| `useAppShellSessionUiReads` | AppShell chrome and Composer prop assembly | M3 regional readers; retain only required chrome | -| Invocation-time published-message read | Copy/Save | M3 command ownership / bounded-history export integration | M2 owns presentation and observation; it does not add a Catalog, Host cache or execution state machine, or complete the remaining Composer migration. @@ -205,16 +219,22 @@ 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. -Delivery recovery is later M3 work. It can use captured submission commands -without restoring root subscriptions or acquiring the private controller. +Delivery recovery stays bound to the Session and Message it started from. A +cancelled local message or an edited queue entry hands its text to the editor's +keyed draft and its attachments, directories and quotes to staging's +`restoreContext`, both under the key of the Session it left, so the restore +lands there even after navigation. The submission owner binds that restore; with +no staging owner mounted it does nothing. A message whose outcome is unknown +keeps its id and offers only the Host check. ## 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. +(with the catalog watch that retires it), the retracted workspace references, +the Turn-footer pending marks, the safe-boundary resume offer and the submit, +follow-up, Stop, Turn-branch, resume 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`) @@ -225,20 +245,45 @@ 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` +form answer, side chat and new-task resolver, the model-setup toast and the +selected Session's orchestration write), the owner Session the resume offer is +read for, 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. +Session's Stop claim, the send-slot resume offer, the interaction answers and +the revision notice, and narrows the shell's picker gates while a draft is open. +`ConversationTranscriptRegion` derives the Turn presentation from the same +owner's pending marks (Branch is withheld from a shared Session) and injects the +interrupted-Turn banner's resume action; one resume instance sits behind both, +so the banner cannot race the send slot. The transcript and the activity reader +read a narrow Turn reader (displayed and owner Session, pending marks, the +banner action), so a send or an edit draft does not repaint the transcript. The +marks end with the owner: the registry clears its timers when it unmounts. The +shell keeps only the stable `ComposerSubmissionCommands` handle, whose +`beginEditUserMessage` and `handleTurnFooterAction` serve the transcript and +whose `clearPendingTurnActions(sessionId)` serves Session teardown. The first +two throw while the owner is unmounted; cleanup is a no-op then. The binding and +reader contexts are private. + +## Composer editing intents (R2 M3) + +The editor handle stays inside Conversation: `ConversationComposerRegion` +attaches it to the Composer, and the submission owner and draft restoration use +it internally. Everyone else gets `ComposerEditingCommands`: +- `appendText`, `replaceText`, `focus` and `openModelPicker` for the visible draft; +- `seedDraft` for Work Board's new-task draft, and `discardDraft` for a Guest's + settled turn request; +- `claimVisibleDraft` for Module Hub's later append, which stays current only + while the same editor is mounted. + +None of them reads a draft back. The shell reaches them through its queue +surface, which carries only these intents and the plate's entry actions. Workbar +and Session Collaboration take the two keyed intents structurally, without +importing Conversation. ## Task readiness ownership (R2 M3) diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-staging-commands.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-staging-commands.ts index df80b89d6d5..dda092bdd6a 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/composer-staging-commands.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-staging-commands.ts @@ -31,6 +31,8 @@ export function createComposerStagingCommands(): ComposerStagingCommands { addQuote: (quote) => requireOwner().addQuote(quote), resetImageNotice: (key) => requireOwner().resetImageNotice(key), transferImageNotice: (from, to) => requireOwner().transferImageNotice(from, to), + // Recovery only: with no owner mounted there is no draft to restore into. + restoreContext: (draftKey, context) => binding.current?.restoreContext(draftKey, context), }; stagingBindings.set(commands, binding); return commands; diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts index f6d76888df8..165b315ca8c 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts @@ -29,6 +29,8 @@ export function createComposerSubmissionCommands(): ComposerSubmissionCommands { const commands: ComposerSubmissionCommands = { beginEditUserMessage: (turnId) => requireOwner().beginEditUserMessage(turnId), handleTurnFooterAction: (turnId, actionId) => requireOwner().handleTurnFooterAction(turnId, actionId), + // Cleanup only: with no owner mounted there is nothing pending to drop. + clearPendingTurnActions: (sessionId) => binding.current?.clearPendingTurnActions(sessionId), }; submissionBindings.set(commands, binding); return commands; diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts index 5f5da6dea70..72174df642e 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } 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'; @@ -42,6 +42,8 @@ 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 { useTurnActionRegistry } from './use-turn-action-registry.js'; +import { useShellResume } from './use-shell-resume.js'; import { abandonTurnRevisionCopyAttempt, completeTurnRevisionCopyAttempt, @@ -60,8 +62,10 @@ export function useComposerSubmission(input: readonly shell: ComposerSubmissionShell; readonly newTask: ComposerNewTaskSubmission; readonly sharedSessionActive: boolean; + /** The readable, non-shared Host Session; the resume offer is read for it. */ + readonly ownerSessionId: string | undefined; }) { - const { staging, shell, newTask, sharedSessionActive } = input; + const { staging, shell, newTask, sharedSessionActive, ownerSessionId } = input; const services = useComposerSubmissionServices(); const { workspace, commands } = useConversationOwner(); const queue = useConversationQueueCommands(); @@ -69,6 +73,29 @@ export function useComposerSubmission(input: const uiLocale = useUiLocale(); const toastApi = useToast(); const activeIdRef = workspace.publishedSession; + const activeId = useSyncExternalStore(workspace.target.subscribe, workspace.target.getSnapshot); + // Pending Turn-footer marks: the actions below set them, the transcript reads them. + const turnActionRegistry = useTurnActionRegistry(); + // One instance behind both the banner and the send slot, so the two can never + // race a second resume request past the first. + const resume = useShellResume({ + activeId, + ownerActiveId: ownerSessionId, + sharedSessionActive, + toastApi, + shellCopy: getShellCopy(uiLocale).app, + uiLocale, + }); + // A withdrawn send — an edited queue entry or a cancelled local message — + // hands its staged context back under the key of the Session it left, so the + // restore lands there even after navigation; the text goes through the + // editor's keyed draft beside it. + useLayoutEffect(() => { + const slot = queue.draftContextRestorer; + const restore: NonNullable = (sessionId, draft) => staging.restoreContext(sessionId, draft); + slot.current = restore; + return () => { if (slot.current === restore) slot.current = undefined; }; + }, [queue, staging]); // Held for the whole of a send; see ChatComposerRegion. const [newTaskSendPending, setNewTaskSendPending] = useState(false); @@ -186,7 +213,7 @@ export function useComposerSubmission(input: uiLocale, activeIdRef, captureSelection: commands.captureSelection, - turnActionRegistry: shell.turnActions, + turnActionRegistry, openSessionInChat: shell.openSession, refreshSessions: shell.refreshSessions, toastApi, @@ -225,7 +252,8 @@ export function useComposerSubmission(input: const shellCommands = useMemo(() => ({ beginEditUserMessage: (turnId) => revision.beginEditUserMessage(turnId), handleTurnFooterAction: (turnId, actionId) => turn.handleTurnFooterAction(turnId, actionId), - }), [revision, turn]); + clearPendingTurnActions: (sessionId) => turnActionRegistry.clearForSession(sessionId), + }), [revision, turn, turnActionRegistry.clearForSession]); const reader = useMemo(() => ({ onSend, newTaskSendPending, @@ -235,10 +263,21 @@ export function useComposerSubmission(input: respondToUserQuestion: chat.respondToUserQuestion, respondToUserForm: chat.respondToUserForm, stop, - }), [chat, newTaskSendPending, onSend, revision, revisionDraft, stop]); + composerResumeAction: resume.composerResumeAction, + }), [chat, newTaskSendPending, onSend, resume.composerResumeAction, revision, revisionDraft, stop]); + // What the transcript and the chrome readers take, apart from the Composer's + // submission state, so a send or an edit draft does not repaint the transcript. + const turnReader = useMemo(() => ({ + activeId, + ownerSessionId, + sharedSessionActive, + pendingTurnActions: turnActionRegistry.keys, + safeResumeAction: resume.safeResumeAction, + }), [activeId, ownerSessionId, resume.safeResumeAction, sharedSessionActive, turnActionRegistry.keys]); return { shellCommands, reader, + turnReader, // Local delivery recovery publishes into, and restores drafts for, the // Session the Composer shows. localMessages: { diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-controller.ts b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-controller.ts index 40e386f9889..795cdd4da40 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-controller.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-controller.ts @@ -18,12 +18,14 @@ */ import { useLayoutEffect, useRef, useState } from 'react'; +import type { UiLocale } from '@maka/core/ui-locale'; import { useToast, useUiLocale, dequeueInteractionByRequestId } from '@maka/ui'; import { useSessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; import { recordSessionEventStreamChange } from '../../../application/contracts/session-catalog/session-event-health.js'; import { createTranscriptCommands } from '../model/transcript-commands.js'; import { createContextCompactionCommands } from '../model/context-compaction.js'; import { createConversationWorkspace } from '../model/conversation-workspace.js'; +import { renderConversationMarkdown } from '../model/conversation-markdown.js'; import { useConversationServices } from '../services.js'; /** Constructed only by ConversationProvider; mutable publication never leaves it. */ @@ -66,6 +68,12 @@ export function useConversationController() { }, settleAssistantStreaming: async (sessionId: string, messageId?: string) => events.current?.settleAssistantStreaming(sessionId, messageId), prepareSend: (sessionId: string) => readingCommands.current?.prepareSend(sessionId) ?? true, + /** + * Copy and Save's export of the published range, rendered as Markdown at + * invocation. Callers get the export, not the published messages. + */ + renderPublishedConversation: (sessionName: string, locale: UiLocale) => + renderConversationMarkdown(sessionName, workspace.commands.readMessages(), locale), }; }); return { workspace, commands, readingCommands, events, interactionHydration }; 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 56593a9fc3b..39732fe0938 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 @@ -17,13 +17,27 @@ * under the License. */ -import { useSyncExternalStore } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { selectActiveSessionId, selectSessionById, useSessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; import { shellSessionRowEqual } from '../model/conversation-catalog-row.js'; import { useConversationOwner } from '../ui/conversation-context.js'; import { useConversationQueueCommands } from '../ui/conversation-provider.js'; +/** + * The shell's slice of the queue surface: the plate's entry actions and named + * Composer edits. The editor handle and draft restoration stay in the feature. + */ +function shellQueueSurface(queue: ReturnType) { + return { + editing: queue.editing, + promoteQueuedEntry: queue.promoteQueuedEntry, + updateQueuedEntry: queue.updateQueuedEntry, + deleteQueuedEntry: queue.deleteQueuedEntry, + reorderQueuedEntries: queue.reorderQueuedEntries, + }; +} + /** Shell gets target identity and finite chrome facts, never a transcript projection. */ export function useConversationTarget() { const { workspace, commands } = useConversationOwner(); @@ -34,13 +48,15 @@ export function useConversationTarget() { const activeCatalogSession = useExternalStoreSelector(catalog, selectSessionById, activeId, shellSessionRowEqual); const activeHostSession = activeCatalogSession?.localState !== 'pending' ? activeCatalogSession : undefined; const sharedSessionActive = activeCatalogSession?.shared === true; + const queue = useConversationQueueCommands(); + const queueSurface = useMemo(() => shellQueueSurface(queue), [queue]); return { setActiveId: commands.setActiveId, startNewSession: commands.startNewSession, clearOwnedSessionState: commands.clearOwnedSessionState, isSessionSelected: commands.isSessionSelected, retiredSessionIds: commands.retiredSessionIds, - readMessages: commands.readMessages, + renderPublishedConversation: commands.renderPublishedConversation, refreshMessages: commands.refreshMessages, recordSessionChange: commands.recordSessionChange, activeId, @@ -51,7 +67,7 @@ export function useConversationTarget() { switchingSession: activeId !== requestedSessionId, transcriptEmpty: chrome.empty, transcriptHasHistory: chrome.hasHistory, - queueSurface: useConversationQueueCommands(), + queueSurface, sessionUiReads: workspace.ui.reads, }; } diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-session-message-queue.ts b/apps/desktop/src/renderer/features/conversation/controller/use-session-message-queue.ts index 2df64186b1a..dfc174473dd 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-session-message-queue.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-session-message-queue.ts @@ -53,9 +53,9 @@ export function useSessionMessageQueue(options: { transientMessages: TransientUserMessageProjection[]; restoreDraft: (sessionId: string, draft: RestoredDraftContent) => void; /** - * Staged-context restorer slot: the shell owns the attachments/quotes stores - * and fills this once, so a retract can hand the entry's context back even - * after the owning Session navigated away. + * Staged-context restorer slot: the Composer submission owner fills it with + * the staging owner's keyed restore, so a retract can hand the entry's + * context back even after the owning Session navigated away. */ draftContextRestorer: RefObject< ((sessionId: string, draft: RestoredDraftContent) => void) | undefined diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-session-ui-reads.ts b/apps/desktop/src/renderer/features/conversation/controller/use-session-ui-reads.ts deleted file mode 100644 index b16190c9203..00000000000 --- a/apps/desktop/src/renderer/features/conversation/controller/use-session-ui-reads.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { useSessionUiRead } from './use-session-ui-read.js'; -import type { SessionUiReads } from '../model/session-ui-reads.js'; - -/** Shell subscribes to low-frequency execution and content summaries, never raw tokens. */ -export function useAppShellSessionUiReads(reads: SessionUiReads, activeId: string | undefined, ownerId: string | undefined) { - return { - ...useSessionUiRead(reads, 'load', activeId), - ...useSessionUiRead(reads, 'summary', activeId), - messageRetryPending: useSessionUiRead(reads, 'retry', activeId), - stopPending: useSessionUiRead(reads, 'stop', activeId), - activeInteraction: useSessionUiRead(reads, 'interaction', ownerId), - activeMessageQueue: useSessionUiRead(reads, 'queue', activeId), - }; -} diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-shell-resume.ts b/apps/desktop/src/renderer/features/conversation/controller/use-shell-resume.ts index 4ad24535efe..cdaddfbd3c0 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-shell-resume.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-shell-resume.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { UiLocale } from '@maka/core/ui-locale'; import { resumeParkToastCopy } from '@maka/ui'; import { localizedShellErrorMessage, type getShellCopy } from '../../../locales/shell-copy.js'; @@ -160,19 +160,19 @@ export function useShellResume(options: { } } - const safeResumeAction = !sharedSessionActive && activeId - ? { - pending: resumePendingSessionId === activeId, - detail: resumeParkDescriptionBySession[activeId], - onResume: () => { void resumeInterruptedSession(); }, - } - : undefined; - const composerResumeAction = activeId && resumeAvailableBySession[activeId] - ? { - pending: resumePendingSessionId === activeId, - onResume: () => { void resumeInterruptedSession(); }, - } - : undefined; + // The click reaches the latest handler, so the actions keep their identity + // until something they render changes. + const resumeRef = useRef(resumeInterruptedSession); + resumeRef.current = resumeInterruptedSession; + const pending = activeId !== undefined && resumePendingSessionId === activeId; + const detail = activeId ? resumeParkDescriptionBySession[activeId] : undefined; + const available = activeId ? resumeAvailableBySession[activeId] === true : false; + const safeResumeAction = useMemo(() => !sharedSessionActive && activeId + ? { pending, detail, onResume: () => { void resumeRef.current(); } } + : undefined, [activeId, detail, pending, sharedSessionActive]); + const composerResumeAction = useMemo(() => available + ? { pending, onResume: () => { void resumeRef.current(); } } + : undefined, [available, pending]); return { safeResumeAction, composerResumeAction }; } diff --git a/apps/desktop/src/renderer/use-turn-action-registry.ts b/apps/desktop/src/renderer/features/conversation/controller/use-turn-action-registry.ts similarity index 91% rename from apps/desktop/src/renderer/use-turn-action-registry.ts rename to apps/desktop/src/renderer/features/conversation/controller/use-turn-action-registry.ts index 08da88f5182..0b4af8394e9 100644 --- a/apps/desktop/src/renderer/use-turn-action-registry.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-turn-action-registry.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; type RefBox = { current: T }; @@ -58,8 +58,6 @@ export interface TurnActionRegistry { clearKey(key: string): void; /** Clears every pending key prefixed with `${sessionId}:` (session teardown). */ clearForSession(sessionId: string): void; - /** Clears every key and timer (unmount, or a Runtime Host generation change). */ - clearAll(): void; } export function useTurnActionRegistry(): TurnActionRegistry { @@ -95,16 +93,16 @@ export function useTurnActionRegistry(): TurnActionRegistry { if (key.startsWith(prefix)) clearKey(key); } }; - const clearAll = (): void => { - for (const timeoutHandle of timersRef.current.values()) { - clearTimeout(timeoutHandle); - } - timersRef.current.clear(); - keysRef.current.clear(); - syncState(); - }; - controllerRef.current = { keysRef, keyOf, addKey, clearKey, clearForSession, clearAll }; + controllerRef.current = { keysRef, keyOf, addKey, clearKey, clearForSession }; } + // The owner unmounting ends every mark: nothing renders them afterwards, and + // a timer must not outlive the registry it would clear. + useEffect(() => () => { + for (const timeoutHandle of timersRef.current.values()) clearTimeout(timeoutHandle); + timersRef.current.clear(); + keysRef.current.clear(); + }, []); + return { keys, ...controllerRef.current }; } diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 41076c38b39..24c93e9712e 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -20,7 +20,6 @@ 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'; @@ -34,9 +33,7 @@ export { removePendingItems, clearPending, } from '@maka/ui/pending-items'; -export { desktopSlashCommandPresentation } from './model/slash-command-presentation.js'; export { useActiveExecutionBoundary } from './controller/use-active-execution-boundary.js'; -export { useShellResume } from './controller/use-shell-resume.js'; export { useSessionReferenceComposer } from './controller/use-session-reference-composer.js'; export { ComposerMentionsProvider, @@ -45,17 +42,12 @@ export { type ComposerMentionsSurface, } from './ui/composer-mentions-provider.js'; -export { chatTurnActivity } from '../../application/contracts/session-execution.js'; -export { sessionIdSetsEqual, type LiveTurnSnapshot } from './model/live-turn-snapshot.js'; +export { sessionIdSetsEqual } from './model/live-turn-snapshot.js'; export { createAppShellQueueActions } from './controller/app-shell-queue-actions.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-shell-chat-model.js'; -export * from './model/executor-submission.js'; -export * from './model/executor-composer.js'; +export { createExecutorSessionActivator } from './model/executor-submission.js'; +export { useShellChatModel, type SessionHealthNoticeView } from './controller/use-shell-chat-model.js'; export { PlanProvider } from './ui/plan-provider.js'; export { PlanChatView, PlanExecutionSurface } from './ui/plan-surfaces.js'; @@ -69,7 +61,14 @@ export { useConversationTarget as useAppShellSessionUiState } from './controller export { ConversationLifecycle } from './ui/conversation-lifecycle.js'; -export { ConversationTranscriptRegion, ConversationComposerRegion, ConversationMessageConsumer } from './ui/conversation-readers.js'; +export { + ConversationTranscriptRegion, + ConversationComposerRegion, + ConversationMessageConsumer, + ConversationActivityConsumer, + ConversationHomeSurface, + type ConversationActivity, +} from './ui/conversation-readers.js'; export { createComposerStagingCommands } from './controller/composer-staging-commands.js'; export type { ComposerStagingCommands, ComposerStagingSubmission } from './model/composer-staging-contract.js'; export { ComposerStagingServicesProvider, type ComposerStagingServices } from './staging-services.js'; diff --git a/apps/desktop/src/renderer/features/conversation/model/composer-editing.ts b/apps/desktop/src/renderer/features/conversation/model/composer-editing.ts new file mode 100644 index 00000000000..501b5cce3e7 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/composer-editing.ts @@ -0,0 +1,62 @@ +/* + * 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 { RefObject } from 'react'; +import type { ComposerHandle } from '@maka/ui'; + +/** + * Named edits other regions may make to the main Composer, in place of the + * editor handle itself. None of them reads a draft back. + */ +export interface ComposerEditingCommands { + /** Prompt suggestions, Module Hub and skills add text after the visible draft. */ + appendText(text: string): void; + /** `maka://compose` replaces the visible draft; the user still sends it. */ + replaceText(text: string): void; + focus(): void; + openModelPicker(): void; + /** Work Board writes a new-task draft before navigation shows it. */ + seedDraft(draftKey: string, text: string): void; + /** A Guest's settled turn request takes its text out of that Session's draft. */ + discardDraft(draftKey: string): void; + /** + * A claim on the editor showing now, for an append that lands later; it is + * current only while the same editor stays mounted. Undefined with no editor. + */ + claimVisibleDraft(): { isCurrent(): boolean; append(text: string): void } | undefined; +} + +export function createComposerEditing(composer: RefObject): ComposerEditingCommands { + return { + appendText: (text) => composer.current?.appendText(text), + replaceText: (text) => composer.current?.setText(text), + focus: () => composer.current?.focus(), + openModelPicker: () => composer.current?.openModelPicker(), + seedDraft: (draftKey, text) => composer.current?.setDraft(draftKey, text), + discardDraft: (draftKey) => composer.current?.clearDraft(draftKey), + claimVisibleDraft: () => { + const handle = composer.current; + if (!handle) return undefined; + return { + isCurrent: () => composer.current === handle, + append: (text) => handle.appendText(text), + }; + }, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/model/composer-staging-contract.ts b/apps/desktop/src/renderer/features/conversation/model/composer-staging-contract.ts index 9d8a9fabeeb..3447607ac7d 100644 --- a/apps/desktop/src/renderer/features/conversation/model/composer-staging-contract.ts +++ b/apps/desktop/src/renderer/features/conversation/model/composer-staging-contract.ts @@ -19,6 +19,7 @@ import type { DirectoryReference, QuoteRef } from '@maka/core/events'; import type { PendingAttachment } from '@maka/ui/composer-attachments'; +import type { RestoredDraftContent } from '../../../application/contracts/transient-message-projection.js'; /** A submission owns the staging it captured, even after navigation or another edit. */ export interface ComposerStagingSubmission { @@ -43,4 +44,6 @@ export interface ComposerStagingCommands { }): void; resetImageNotice(draftKey: string): void; transferImageNotice(from: string, to: string): void; + /** Hands a withdrawn send's attachments, directories and quotes back to the draft it left. */ + restoreContext(draftKey: string, context: Omit): void; } diff --git a/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts b/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts index 1b857842caf..e747bb9fcd4 100644 --- a/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts +++ b/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts @@ -38,6 +38,8 @@ export interface ComposerSurfaceOwner { export interface ComposerSubmissionCommands { beginEditUserMessage(turnId: string): void; handleTurnFooterAction(turnId: string, actionId: TurnFooterActionMeta['id']): Promise; + /** Drops the pending Turn-footer marks of one retired Session. */ + clearPendingTurnActions(sessionId: string): void; } /** @@ -60,12 +62,6 @@ export interface ComposerSubmissionShell { 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; } diff --git a/apps/desktop/src/renderer/features/conversation/model/composer-turn-gates.ts b/apps/desktop/src/renderer/features/conversation/model/composer-turn-gates.ts new file mode 100644 index 00000000000..1d5716bfe7e --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/composer-turn-gates.ts @@ -0,0 +1,96 @@ +/* + * 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 { SessionStatus } from '@maka/core/session'; +import { slashCommandsForSurface } from '@maka/core/slash-command-catalog'; +import { deriveComposerModelSwitchAvailability, type ComposerSlashCommandOption } from '@maka/ui'; +import { desktopSlashCommandAvailability } from '../../../application/contracts/desktop-slash-command.js'; +import type { getShellCopy } from '../../../locales/shell-copy.js'; +import { desktopSlashCommandPresentation } from './slash-command-presentation.js'; + +type ShellAppCopy = ReturnType['app']; + +/** + * The Composer controls the displayed Session's Turn holds, and why. + * + * Every "cannot change this mid-turn" gate reads `turnActive`, the same witness + * Stop reads. Reading the persisted status instead left these toggles live + * through the whole send→run-start window — long enough on a cold backend for a + * mode change to land before the run registers and alter the execution config + * of the Turn already sent. + */ +export function composerTurnGates(input: { + activeId: string | undefined; + /** The opened Session's catalog row has arrived. */ + sessionLoaded: boolean; + sessionStatus: SessionStatus | undefined; + turnActive: boolean; + streamingLive: boolean; + copy: ShellAppCopy; +}) { + const { activeId, sessionStatus, turnActive, streamingLive, copy } = input; + const running = Boolean(activeId) && turnActive; + const waiting = Boolean(activeId) && sessionStatus === 'waiting_for_user'; + // Plan and orchestration write the same Session configuration, so everything + // that holds one holds the other. There is no pending-keyed reason while a + // toggle commits: the pending registries already swallow re-entrant toggles, + // and a reason would gray the row mid-click — the blink this control had. + const modeChangeDisabledReason = activeId && !input.sessionLoaded + ? copy.modeChangeLoading + : streamingLive + ? copy.modeChangeStreaming + : running + ? copy.modeChangeRunning + : waiting + ? copy.modeChangeWaiting + : undefined; + return { + // #646: Stop must be available for the WHOLE turn - the moment the user + // most wants to interrupt is a long wait with nothing on screen (first + // token, or a slow provider's step-to-step lull). + streaming: turnActive, + modelSwitchAvailability: deriveComposerModelSwitchAvailability({ + streaming: turnActive, + sessionStatus, + pending: false, + }), + planModeDisabledReason: modeChangeDisabledReason, + orchestrationModeDisabledReason: modeChangeDisabledReason, + permissionModeDisabledReason: streamingLive + ? copy.permissionModeStreaming + : running + ? copy.permissionModeRunning + : waiting + ? copy.permissionModeWaiting + : undefined, + goalDisabledReason: streamingLive || running ? copy.goalTurnActive : undefined, + }; +} + +/** The catalog commands the Desktop Composer offers for this Session and Turn. */ +export function desktopComposerSlashCommands( + hasSession: boolean, + streaming: boolean, + copy: ShellAppCopy['slashCommands'], +): readonly ComposerSlashCommandOption[] { + const presentation = desktopSlashCommandPresentation(copy); + return slashCommandsForSurface('desktop') + .filter(desktopSlashCommandAvailability({ hasSession, streaming })) + .map(({ id }) => ({ id, ...presentation[id] })); +} diff --git a/apps/desktop/src/renderer/conversation-markdown.ts b/apps/desktop/src/renderer/features/conversation/model/conversation-markdown.ts similarity index 98% rename from apps/desktop/src/renderer/conversation-markdown.ts rename to apps/desktop/src/renderer/features/conversation/model/conversation-markdown.ts index 458e656ac98..9e654501f62 100644 --- a/apps/desktop/src/renderer/conversation-markdown.ts +++ b/apps/desktop/src/renderer/features/conversation/model/conversation-markdown.ts @@ -21,7 +21,7 @@ import type { StoredMessage } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import { userFacingText } from '@maka/core/session'; import { redactSecrets } from '@maka/ui'; -import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; +import { getShellRemainingCopy } from '../../../locales/shell-remaining-copy.js'; /** * Serialize a conversation to a Markdown document suitable for pasting into diff --git a/apps/desktop/src/renderer/features/conversation/model/live-turn-flags.ts b/apps/desktop/src/renderer/features/conversation/model/live-turn-flags.ts new file mode 100644 index 00000000000..ead76e14b85 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/live-turn-flags.ts @@ -0,0 +1,48 @@ +/* + * 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 { SessionExecutionProjection } from '../../../application/contracts/session-execution.js'; +import { activeHostTurn } from '../../../application/contracts/session-execution.js'; +import type { LiveTurnSnapshot } from './live-turn-snapshot.js'; + +/** A Session's Turn as chrome reads it, at low frequency; content never establishes liveness. */ +export interface LiveTurnFlags { + /** The Host reports a nonterminal root Turn, whether or not it is observable now. */ + turnActive: boolean; + /** The Session's execution projection is observable. */ + executionAvailable: boolean; + /** Assistant text streams outside a settled message. */ + streamingLive: boolean; + /** The live Turn has streamed text, thinking or tools. */ + hasLiveContent: boolean; +} + +export function liveTurnFlags( + liveTurn: LiveTurnSnapshot, + execution: SessionExecutionProjection | undefined, +): LiveTurnFlags { + const turn = activeHostTurn(execution); + const content = turn?.turnId === liveTurn.turnId ? liveTurn : undefined; + return { + turnActive: Boolean(turn), + executionAvailable: execution?.available === true, + streamingLive: Boolean(content?.hasStreamingText && content.streamingMessageId === undefined), + hasLiveContent: Boolean(content?.hasStreamingText || content?.hasThinkingText || content?.hasLiveTools), + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index 6d4baf98cdf..ac0dcb4bff4 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { SessionSummary } from '@maka/core/session'; import type { ConversationServices } from './ports.js'; import { createSessionUiState, type AppShellSessionUiState } from './model/session-ui-state.js'; @@ -46,6 +47,29 @@ export { startActiveExecutionBoundaryRead, } from './controller/use-active-execution-boundary.js'; export { useSessionMessageQueue } from './controller/use-session-message-queue.js'; +export { useSessionUiRead } from './controller/use-session-ui-read.js'; +export { useTurnActionRegistry } from './controller/use-turn-action-registry.js'; + +/** The shell inputs `ConversationComposerRegion` combines with the Turn: a loaded, idle Session without an executor. */ +export function stubComposerGateInputs(overrides: { + sessionState?: { loaded: boolean; status: SessionSummary['status'] | undefined }; + connectionCount?: number; +} = {}) { + return { + sessionState: overrides.sessionState ?? { loaded: true, status: 'active' as const }, + executorComposer: { + selection: { + selection: undefined, catalog: [], entry: undefined, + select: async () => {}, restore: async () => {}, refresh: async () => {}, adoptSession: () => {}, + changing: false, loading: false, error: undefined, + }, + taskSubmissionHardBlocked: false, + connectionCount: overrides.connectionCount ?? 1, + onSetup() {}, + onNewTask() {}, + }, + }; +} /** Inert conversation services; a test overrides only the calls it observes. */ export function stubConversationServices( @@ -139,3 +163,18 @@ export { export { SessionLocalMessages } from './controller/session-local-messages.js'; export { createStopAction } from './controller/stop-action.js'; export { createTurnActions } from './controller/turn-actions.js'; +export { + INITIAL_LIVE_CONTENT_SEED, + INITIAL_OBSERVATION_AUTHORITY, + beginLiveContentSeed, + ownsLiveContentSeed, + reconcileObservationAuthority, + revealLiveContentSeed, + visibleLiveContentGeneration, +} from './model/observation-visibility.js'; +export { composerModelSupportsVision, pickNewChatModel, type NewChatModel } from './model/shell-chat-model-selection.js'; +export { deriveSessionHealthNotice } from './model/session-health-notice.js'; +export { resolveNewChatExecutionThinkingLevel } from './controller/use-shell-chat-model.js'; +export { executorSubmissionError, newTaskConfiguration } from './model/executor-submission.js'; +export { executorComposerProps } from './model/executor-composer.js'; +export { renderConversationMarkdown } from './model/conversation-markdown.js'; diff --git a/apps/desktop/src/renderer/features/conversation/ui/composer-staging-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/composer-staging-provider.tsx index eaadf607dc0..a234a64c36f 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/composer-staging-provider.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-staging-provider.tsx @@ -64,6 +64,11 @@ export function ComposerStagingProvider(props: { addQuote: staging.addQuote, resetImageNotice: (key) => staging.imageNoticeLifecycle.reset(key), transferImageNotice: (from, to) => staging.imageNoticeLifecycle.transfer(from, to), + restoreContext: (draftKey, context) => { + if (context.attachments?.length) staging.restoreAttachments(draftKey, context.attachments); + if (context.directoryReferences?.length) staging.restoreDirectories(draftKey, context.directoryReferences); + if (context.quotes?.length) staging.restoreQuotes(draftKey, context.quotes); + }, }; binding.current = commands; return () => { if (binding.current === commands) binding.current = undefined; }; 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 index 4fe75a6e991..f87b0cf8707 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/composer-submission-context.ts +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-context.ts @@ -22,9 +22,19 @@ import type { useComposerSubmission } from '../controller/use-composer-submissio export type ComposerSubmissionReader = ReturnType['reader']; +export type ComposerTurnReader = ReturnType['turnReader']; + export const ComposerSubmissionContext = createContext(undefined); export function useComposerSubmissionReader() { const reader = useContext(ComposerSubmissionContext); if (!reader) throw new Error('ComposerSubmissionProvider is required'); return reader; } + +/** The displayed and owner Session, pending Turn marks and the banner's resume action. */ +export const ComposerTurnContext = createContext(undefined); +export function useComposerTurnReader() { + const reader = useContext(ComposerTurnContext); + 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 index 8b4670d833c..82bde7c5642 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useLayoutEffect, useSyncExternalStore, type ReactNode } from 'react'; +import { useLayoutEffect, type ReactNode } from 'react'; import { CatalogRowWatch } from '../../../application/contracts/session-catalog/catalog-row-watch.js'; import { useSessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; import { SessionLocalMessages } from '../controller/session-local-messages.js'; @@ -30,8 +30,7 @@ import type { ComposerSubmissionShell, ComposerSurfaceOwner, } from '../model/composer-submission-contract.js'; -import { ComposerSubmissionContext } from './composer-submission-context.js'; -import { useConversationOwner } from './conversation-context.js'; +import { ComposerSubmissionContext, ComposerTurnContext } from './composer-submission-context.js'; /** * Sole owner of the Composer's submission: the send-pending flag, the @@ -46,6 +45,7 @@ export function ComposerSubmissionProvider(p readonly shell: ComposerSubmissionShell; readonly newTask: ComposerNewTaskSubmission; readonly sharedSessionActive: boolean; + readonly ownerSessionId: string | undefined; readonly children?: ReactNode; }) { const submission = useComposerSubmission({ @@ -53,10 +53,9 @@ export function ComposerSubmissionProvider(p shell: props.shell, newTask: props.newTask, sharedSessionActive: props.sharedSessionActive, + ownerSessionId: props.ownerSessionId, }); 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); @@ -66,10 +65,12 @@ export function ComposerSubmissionProvider(p }, [props.commands, shellCommands]); return ( - - - {props.children} + + + + {props.children} + ); } diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx index 1ae095cf748..ff2d68a43c2 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx @@ -21,10 +21,13 @@ import { createContext, useContext, useLayoutEffect, useRef, useState, useSyncEx import { useConversationController } from '../controller/use-conversation-controller.js'; import { useSessionMessageQueue } from '../controller/use-session-message-queue.js'; import { useSessionUiRead } from '../controller/use-session-ui-read.js'; +import { createComposerEditing, type ComposerEditingCommands } from '../model/composer-editing.js'; import { ConversationContext } from './conversation-context.js'; const QueueContext = createContext | null>(null); -const QueueCommandsContext = createContext, 'transientMessages'> | null>(null); +const QueueCommandsContext = createContext< + (Omit, 'transientMessages'> & { readonly editing: ComposerEditingCommands }) | null +>(null); export function useConversationQueue() { const value = useContext(QueueContext); if (!value) throw new Error('ConversationProvider is required'); @@ -46,6 +49,7 @@ export function ConversationProvider({ children }: { children: ReactNode }) { useLayoutEffect(() => { current.current = surface; }); const [commands] = useState(() => ({ composer: surface.composer, + editing: createComposerEditing(surface.composer), draftContextRestorer: surface.draftContextRestorer, restoreDraft: surface.restoreDraft, promoteQueuedEntry: (...args: Parameters) => current.current.promoteQueuedEntry(...args), 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 e2b8eb9849e..f63ce9e5ba3 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -18,34 +18,70 @@ */ import { createElement, useMemo, useSyncExternalStore, type ComponentType, type ComponentProps } from 'react'; -import { ChatView, useUiLocale, type ComposerProps as UiComposerProps } from '@maka/ui'; -import type { StoredMessage } from '@maka/core/session'; +import { ChatView, useUiLocale, type ComposerInteraction, type ComposerProps as UiComposerProps } from '@maka/ui'; +import type { SessionStatus, 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 { useConversationQueueCommands } from './conversation-provider.js'; +import { + useComposerSubmissionReader, useComposerTurnReader, type ComposerSubmissionReader, +} from './composer-submission-context.js'; import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; +import { chatTurnActivity } from '../../../application/contracts/session-execution.js'; +import { useAppShellTurnPresentation } from '../../../application/contracts/turn-presentation.js'; +import { getShellCopy } from '../../../locales/shell-copy.js'; +import { composerTurnGates, desktopComposerSlashCommands } from '../model/composer-turn-gates.js'; +import { executorComposerProps } from '../model/executor-composer.js'; +import { liveTurnFlags } from '../model/live-turn-flags.js'; + +/** The displayed Session's Turn, read at chrome frequency. */ +function useDisplayedTurn(reads: SessionUiReads, sessionId: string | undefined) { + const summary = useSessionUiRead(reads, 'summary', sessionId); + return { execution: summary.activeExecution, ...liveTurnFlags(summary.activeLiveTurnSnapshot, summary.activeExecution) }; +} type ChatProps = ComponentProps; type TranscriptProps = Pick & { activeSessionId: string | undefined; liveContentSeedGeneration: number; sessionUiReads: SessionUiReads }; + 'onLoadTranscriptTurn' | 'restoreTargetTurn' | 'onReadingAnchorChange' | 'viewportNavigation' | + 'deriveTurnPresentation' | 'safeResumeAction' +> & { + activeSessionId: string | undefined; liveContentSeedGeneration: number; sessionUiReads: SessionUiReads; + activeTurn: ReturnType; + sessionHealthModelPickerAvailable: boolean; +}; +/** What the shell knows that the health notice's picker gate combines with the running Turn. */ +type TranscriptGateInputs = { + /** The owner Session's execution boundary admits local interaction. */ + localInteractionAvailable: boolean; +}; /** The actual transcript reader. Shell supplies presentation and navigation only. */ export function ConversationTranscriptRegion

( - props: { surface: ComponentType

} & Omit, + props: { surface: ComponentType

} & TranscriptGateInputs & Omit, ) { - const { surface, ...presentation } = props; + const { surface, localInteractionAvailable, ...presentation } = props; const { workspace, commands, readingCommands } = useConversationOwner(); + // The narrow reader: a send or an edit draft does not repaint the transcript. + const turnReader = useComposerTurnReader(); + // Pending Turn-footer marks come from the submission owner that sets them. + const deriveTurnPresentation = useAppShellTurnPresentation({ + allowBranch: !turnReader.sharedSessionActive, + activeId: turnReader.activeId, + pendingTurnActions: turnReader.pendingTurnActions, + uiLocale: useUiLocale(), + }); + const turn = useDisplayedTurn(workspace.ui.reads, turnReader.activeId); const view = useSyncExternalStore(workspace.publication.subscribe, workspace.publication.getSnapshot); const load = useSessionUiRead(workspace.ui.reads, 'load', view.sessionId); const retryPending = useSessionUiRead(workspace.ui.reads, 'retry', view.sessionId); const sessionId = view.sessionId; const owned: TranscriptProps = { activeSessionId: sessionId, + activeTurn: chatTurnActivity(turn.execution), onStreamingSettled: sessionId ? (messageId) => commands.settleAssistantStreaming(sessionId, messageId) : undefined, sessionUiReads: workspace.ui.reads, messages: view.messages, transientMessages: view.transientMessages, @@ -61,6 +97,11 @@ export function ConversationTranscriptRegion

( onLoadTranscriptTurn: (turn) => readingCommands.current?.loadEarlier(turn.sequence), restoreTargetTurn: transcriptRestoreTarget(sessionId ? workspace.ui.transcriptReadingAnchorBySessionRef.current[sessionId] : undefined, load.unavailableTranscriptRestore), onReadingAnchorChange: sessionId ? (turnId) => readingCommands.current?.captureAnchor(turnId) : undefined, + deriveTurnPresentation, + safeResumeAction: turnReader.safeResumeAction, + // The notice's picker is held while a Turn runs, as the Composer's is; + // `useShellChatModel` holds it for the Session's status. + sessionHealthModelPickerAvailable: localInteractionAvailable && !turn.turnActive, }; return createElement(surface, { ...presentation, ...owned } as unknown as P); } @@ -70,29 +111,61 @@ type SubmissionProps = Pick & { onStop: ComposerSubmissionReader['stop']; + resumeAction: ComposerSubmissionReader['composerResumeAction']; stopPending: boolean; revisionNotice?: { title: string; detail: string; cancelLabel: string; onCancel(): void }; }; -type ComposerProps = SubmissionProps & { +type TurnGatedProps = ReturnType & ReturnType & { + slashCommands: UiComposerProps['slashCommands']; +}; +type ComposerProps = SubmissionProps & TurnGatedProps & { + /** The owner's editor handle; regions outside Conversation get named edits instead. */ + composerRef: ReturnType['composer']; processing: boolean; pendingMessages: ChatProps['transientMessages']; latestRequestUsageTokens?: number; + /** The owner Session's open interaction; the Composer slot answers it. */ + activeInteraction: ComposerInteraction | undefined; + queuedMessages: UiComposerProps['queuedMessages']; + queuedMessageRevision: UiComposerProps['queuedMessageRevision']; +}; +/** What the shell knows that the Turn gates combine with; the region reads the Turn itself. */ +type ComposerGateInputs = { + /** The displayed Session's catalog row: whether it has arrived, and its status. */ + sessionState: { loaded: boolean; status: SessionStatus | undefined }; + executorComposer: Omit[1], 'activeId' | 'turnActive' | 'sendPending'> & { + selection: Parameters[0]; + }; }; /** The shell's picker gates; an edit-and-resend draft narrows them here. */ -type ComposerPickGates = { contextPickEnabled?: boolean; directoryPickerEnabled?: boolean } & - Pick; +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. + * submission owner. The displayed Session's Turn, its queue and the owner + * Session's interaction are read here, so the controls a running Turn holds + * repaint without the shell. */ export function ConversationComposerRegion

( - props: { surface: ComponentType

; usageModel?: string; usageRoute?: { llmConnectionId?: string } } & Omit, + props: { surface: ComponentType

; usageModel?: string; usageRoute?: { llmConnectionId?: string } } + & ComposerGateInputs & Omit, ) { - const { surface, usageModel, usageRoute, ...presentation } = props; + const { surface, usageModel, usageRoute, sessionState, executorComposer, ...presentation } = props; const { workspace } = useConversationOwner(); const submission = useComposerSubmissionReader(); - const actionCopy = getDesktopConversationCopy(useUiLocale()).actions; + const { ownerSessionId } = useComposerTurnReader(); + const composerRef = useConversationQueueCommands().composer; + const uiLocale = useUiLocale(); + const actionCopy = getDesktopConversationCopy(uiLocale).actions; + const shellCopy = getShellCopy(uiLocale).app; const activeId = useSyncExternalStore(workspace.target.subscribe, workspace.target.getSnapshot); + const turn = useDisplayedTurn(workspace.ui.reads, activeId); + const interaction = useSessionUiRead(workspace.ui.reads, 'interaction', ownerSessionId); + const queue = useSessionUiRead(workspace.ui.reads, 'queue', activeId); + const slashCommands = useMemo( + () => desktopComposerSlashCommands(Boolean(activeId), turn.turnActive, shellCopy.slashCommands), + [activeId, turn.turnActive, shellCopy.slashCommands], + ); + const { selection: executor, ...executorInput } = executorComposer; 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); @@ -101,12 +174,26 @@ export function ConversationComposerRegion

( const editing = draft !== null && activeId === draft.draftSessionId; const gates = presentation as ComposerPickGates; const owned: ComposerProps & ComposerPickGates = { + composerRef, + activeInteraction: interaction, + queuedMessages: queue?.entries, + queuedMessageRevision: queue?.queueRevision, + slashCommands, + ...composerTurnGates({ + activeId, + sessionLoaded: sessionState.loaded, + sessionStatus: sessionState.status, + turnActive: turn.turnActive, + streamingLive: turn.streamingLive, + copy: shellCopy, + }), + // Executor changes and sends stay locked until the pending send's Host admission settles. + ...executorComposerProps(executor, { + ...executorInput, activeId, turnActive: turn.turnActive, sendPending: submission.newTaskSendPending, + }), onSend: submission.onSend, newTaskSendPending: submission.newTaskSendPending, - sendBlocked: gates.sendBlocked || submission.newTaskSendPending, - ...(gates.executorPicker ? { - executorPicker: { ...gates.executorPicker, disabled: gates.executorPicker.disabled || submission.newTaskSendPending }, - } : {}), + resumeAction: submission.composerResumeAction, onStop: submission.stop, stop: submission.stop, stopPending, @@ -140,3 +227,40 @@ export function ConversationMessageConsumer

( + props: { surface: ComponentType

} & Omit, +) { + const { surface, ...presentation } = props; + const { workspace } = useConversationOwner(); + const { activeId, ownerSessionId } = useComposerTurnReader(); + const turn = useDisplayedTurn(workspace.ui.reads, activeId); + const interaction = useSessionUiRead(workspace.ui.reads, 'interaction', ownerSessionId); + const turnRunning = turn.executionAvailable && turn.turnActive; + const awaitingInteraction = interaction !== undefined; + const activity = useMemo(() => ({ turnRunning, awaitingInteraction }), [turnRunning, awaitingInteraction]); + return createElement(surface, { ...presentation, activity } as unknown as P); +} + +/** + * The column a Session's transcript and Composer share. It is the home surface + * while the shell's empty-transcript condition holds and the displayed Session + * has neither live Turn content nor a failed load. + */ +export function ConversationHomeSurface(props: { eligible: boolean } & ComponentProps<'div'>) { + const { eligible, ...column } = props; + const { workspace } = useConversationOwner(); + const activeId = useSyncExternalStore(workspace.target.subscribe, workspace.target.getSnapshot); + const turn = useDisplayedTurn(workspace.ui.reads, activeId); + const { messageLoadError } = useSessionUiRead(workspace.ui.reads, 'load', activeId); + const home = eligible && !turn.hasLiveContent && !messageLoadError; + return createElement('div', { ...column, 'data-home-surface': home ? 'true' : undefined }); +} diff --git a/apps/desktop/src/renderer/features/session-collaboration/controller/use-guest-turn-requests.ts b/apps/desktop/src/renderer/features/session-collaboration/controller/use-guest-turn-requests.ts index 7be7d3bf754..03187c55fea 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/controller/use-guest-turn-requests.ts +++ b/apps/desktop/src/renderer/features/session-collaboration/controller/use-guest-turn-requests.ts @@ -17,8 +17,8 @@ * under the License. */ -import { createElement, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; -import { useToast, useUiLocale, type ComposerHandle, type TransientUserMessageProjection } from '@maka/ui'; +import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useToast, useUiLocale, type TransientUserMessageProjection } from '@maka/ui'; import { ICON_SIZE, Undo2, X } from '@maka/ui/icons'; import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol'; import { getSessionCollaborationCopy } from '../../../locales/session-collaboration-copy.js'; @@ -55,7 +55,8 @@ const REFRESH_INTERVAL_MS = 2_000; export function useGuestTurnRequests( sessionId: string | undefined, - composerRef: RefObject, + /** Takes a settled request's text out of the Composer's draft for that Session. */ + discardDraft: (draftKey: string) => void, ): GuestComposerProjection | undefined { const services = useSessionCollaborationServices(); const copy = getSessionCollaborationCopy(useUiLocale()); @@ -97,7 +98,7 @@ export function useGuestTurnRequests( // A request whose response was lost can surface here later; its text // leaves the draft only if the user has not changed it since. const settled = apply(result); - if (settled) composerRef.current?.clearDraft(sessionId); + if (settled) discardDraft(sessionId); } catch { if (!disposed) markUnavailable(sessionId); } finally { @@ -109,7 +110,7 @@ export function useGuestTurnRequests( disposed = true; if (timer !== undefined) window.clearTimeout(timer); }; - }, [sessionId, services, apply, markUnavailable, composerRef]); + }, [sessionId, services, apply, markUnavailable, discardDraft]); const submit = useCallback(async (text: string): Promise => { const content = text.trim(); diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/guest-turn-requests.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/guest-turn-requests.tsx index 2e6a4883b95..5fff46d468e 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/ui/guest-turn-requests.tsx +++ b/apps/desktop/src/renderer/features/session-collaboration/ui/guest-turn-requests.tsx @@ -17,8 +17,7 @@ * under the License. */ -import type { ReactNode, RefObject } from 'react'; -import type { ComposerHandle } from '@maka/ui'; +import type { ReactNode } from 'react'; import { useGuestTurnRequests, type GuestComposerProjection, @@ -31,8 +30,8 @@ import { */ export function GuestTurnRequests(props: { readonly sessionId: string | undefined; - readonly composerRef: RefObject; + readonly discardDraft: (draftKey: string) => void; readonly children: (guest: GuestComposerProjection | undefined) => ReactNode; }) { - return <>{props.children(useGuestTurnRequests(props.sessionId, props.composerRef))}; + return <>{props.children(useGuestTurnRequests(props.sessionId, props.discardDraft))}; } 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 e3ff59a5cf5..d693ccdf097 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 @@ -33,7 +33,7 @@ import type { QuoteRef } from '@maka/core/events'; import type { InteractionFormResponse } from '@maka/core/interaction'; import type { SessionSummary } from '@maka/core/session'; import type { WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board'; -import { useUiLocale, type ComposerHandle, type ToastApi } from '@maka/ui'; +import { useUiLocale, type ToastApi } from '@maka/ui'; import type { ChatModelChoice } from '@maka/ui'; import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js'; import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; @@ -125,7 +125,8 @@ export interface UseWorkbarControllerInput { modelChoices: readonly ChatModelChoice[]; /** Toast surface owned by the shell composition zone. */ toastApi: ToastApi; - composerRef?: { current: Pick | null }; + /** The main Composer's Work Board edits: write a new-task draft, then focus it. */ + composerDraft?: { seedDraft(draftKey: string, text: string): void; focus(): void }; openNewTaskSurface?(): number; openSessionInChat?(sessionId: string, turnId?: string): void; resolveWorkBoardTarget?(item: WorkBoardItem): @@ -346,8 +347,8 @@ export function useWorkbarController( draftKey, }; globalThis.requestAnimationFrame(() => { - input.composerRef?.current?.setDraft(draftKey, draft); - input.composerRef?.current?.focus(); + input.composerDraft?.seedDraft(draftKey, draft); + input.composerDraft?.focus(); }); }, [input, locale, settlePendingWorkBoardLink], diff --git a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts deleted file mode 100644 index 61e9cdd8890..00000000000 --- a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { useAppShellSessionUiReads } from './features/conversation/index.js'; diff --git a/apps/desktop/src/renderer/use-shell-live-turn.ts b/apps/desktop/src/renderer/use-shell-live-turn.ts deleted file mode 100644 index 531b1d089df..00000000000 --- a/apps/desktop/src/renderer/use-shell-live-turn.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { LiveTurnSnapshot } from './features/conversation/index.js'; -import { activeHostTurn, type SessionExecutionProjection } from './application/contracts/session-execution.js'; - -/** Shell observes execution at low frequency; content never establishes liveness. */ -export function useShellLiveTurn(options: { - liveTurn: LiveTurnSnapshot; - execution: SessionExecutionProjection | undefined; -}) { - const turn = activeHostTurn(options.execution); - const content = turn?.turnId === options.liveTurn.turnId ? options.liveTurn : undefined; - return { - turnActive: Boolean(turn), - activeStreamingLive: Boolean(content?.hasStreamingText && content.streamingMessageId === undefined), - activeStreamingMessageId: content?.streamingMessageId, - hasInFlightLiveTools: content?.hasInFlightTools ?? false, - hasLiveTurnContent: Boolean(content?.hasStreamingText || content?.hasThinkingText || content?.hasLiveTools), - }; -} diff --git a/apps/desktop/stories/shared-session-guest.stories.tsx b/apps/desktop/stories/shared-session-guest.stories.tsx index af182d31a4c..607e8050031 100644 --- a/apps/desktop/stories/shared-session-guest.stories.tsx +++ b/apps/desktop/stories/shared-session-guest.stories.tsx @@ -158,7 +158,7 @@ function GuestComposer(props: { sessionId: string }) { return ( - + composerRef.current?.clearDraft(draftKey)}> {(guest) => (