From e4407404b0372db3cfd7bf6cb0a1bbe485b0e687 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 3 Oct 2026 17:33:34 +0800 Subject: [PATCH 1/8] refactor(desktop): replace Conversation's export-star lines with explicit exports The public entry re-exported six modules wholesale. Production code outside the feature uses three of their names: `useShellChatModel` and `SessionHealthNoticeView` (AppShell and the chat surfaces) and `executorComposerProps` (AppShell). Only those stay public. The names that only tests read move to `testing.ts`. Refs #4582 Generated-by: Claude Opus 5.5 --- .../src/main/__tests__/executor-selection.test.ts | 3 ++- .../main/__tests__/live-content-visibility.test.ts | 2 +- .../main/__tests__/model-catalog-choices.test.ts | 2 +- .../__tests__/new-chat-executor-selection.test.ts | 2 +- .../main/__tests__/new-task-staged-content.test.ts | 2 +- .../main/__tests__/session-health-notice.test.ts | 2 +- .../src/renderer/features/conversation/index.ts | 8 ++------ .../src/renderer/features/conversation/testing.ts | 13 +++++++++++++ 8 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/__tests__/executor-selection.test.ts b/apps/desktop/src/main/__tests__/executor-selection.test.ts index 1616973d24e..78e695b1076 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, ConversationServicesProvider, type ConversationServices } from '../../renderer/features/conversation/index.js'; +import { useExecutorSelection, ConversationServicesProvider, type ConversationServices } from '../../renderer/features/conversation/index.js'; +import { 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__/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/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 41076c38b39..5f0cf6b96d0 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -48,14 +48,10 @@ export { export { chatTurnActivity } from '../../application/contracts/session-execution.js'; export { sessionIdSetsEqual, type LiveTurnSnapshot } from './model/live-turn-snapshot.js'; export { createAppShellQueueActions } from './controller/app-shell-queue-actions.js'; -export * 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 { useShellChatModel, type SessionHealthNoticeView } from './controller/use-shell-chat-model.js'; +export { executorComposerProps } from './model/executor-composer.js'; export { PlanProvider } from './ui/plan-provider.js'; export { PlanChatView, PlanExecutionSurface } from './ui/plan-surfaces.js'; diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index 6d4baf98cdf..f895b2d2132 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -139,3 +139,16 @@ 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 { newTaskConfiguration } from './model/executor-submission.js'; From d257e697d15e65cab54e60e424f4b010f469cc4b Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 3 Oct 2026 17:43:13 +0800 Subject: [PATCH 2/8] refactor(desktop): give Copy and Save a Conversation export command Copy and Save read the published messages through the transitional adapter's `readMessages` and rendered them in the command palette. The Conversation controller now owns `renderPublishedConversation`. It renders the published range as Markdown when called, and the palette receives that export, not the messages. `conversation-markdown` moves into the feature. The adapter drops `readMessages`, and the corresponding row leaves the Conversation README's transitional table. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 21 +++--------------- .../__tests__/app-shell-command-options.ts | 2 +- .../command-palette-desktop-actions.test.ts | 11 +++++++++- .../__tests__/conversation-markdown.test.ts | 2 +- .../main/__tests__/conversation-owner.test.ts | 15 ++++++++++++- .../src/renderer/app-shell-command-actions.ts | 22 ++++++------------- apps/desktop/src/renderer/app-shell.tsx | 4 ++-- .../renderer/features/conversation/README.md | 8 +++---- .../controller/use-conversation-controller.ts | 8 +++++++ .../controller/use-conversation-target.ts | 2 +- .../model}/conversation-markdown.ts | 2 +- .../renderer/features/conversation/testing.ts | 1 + 12 files changed, 53 insertions(+), 45 deletions(-) rename apps/desktop/src/renderer/{ => features/conversation/model}/conversation-markdown.ts (98%) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d3407ae1454..0a3c16e21a8 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -27,7 +27,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", @@ -530,7 +529,7 @@ "nonTriviaTokens": 122 }, "src/renderer/app-shell-command-actions.ts": { - "importDeclarations": 5, + "importDeclarations": 4, "bridgePaths": {}, "environmentCapabilities": { "navigator.clipboard.writeText": 1 @@ -544,15 +543,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, @@ -1023,19 +1021,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": {}, 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__/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..77c7515104a 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, stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { withComposerSubmission } from './composer-submission-fixture.js'; @@ -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/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.tsx b/apps/desktop/src/renderer/app-shell.tsx index 5b136e2ca31..da5d3a59f78 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -244,7 +244,7 @@ function AppShellContent({ }) { const toastApi = useToast(); const { - readMessages, + renderPublishedConversation, refreshMessages, transcriptEmpty, transcriptHasHistory, @@ -1159,7 +1159,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, diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 025f376269b..902841978b2 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`. @@ -129,7 +130,6 @@ Remaining transitional capabilities have explicit consumers and removal work: | 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. 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..c1b50e8268a 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 @@ -40,7 +40,7 @@ export function useConversationTarget() { clearOwnedSessionState: commands.clearOwnedSessionState, isSessionSelected: commands.isSessionSelected, retiredSessionIds: commands.retiredSessionIds, - readMessages: commands.readMessages, + renderPublishedConversation: commands.renderPublishedConversation, refreshMessages: commands.refreshMessages, recordSessionChange: commands.recordSessionChange, activeId, 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/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index f895b2d2132..1931a8e4561 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -152,3 +152,4 @@ export { composerModelSupportsVision, pickNewChatModel, type NewChatModel } from export { deriveSessionHealthNotice } from './model/session-health-notice.js'; export { resolveNewChatExecutionThinkingLevel } from './controller/use-shell-chat-model.js'; export { newTaskConfiguration } from './model/executor-submission.js'; +export { renderConversationMarkdown } from './model/conversation-markdown.js'; From 089306b7681050bb941534e82cf42b061e9dcdd4 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 3 Oct 2026 17:49:03 +0800 Subject: [PATCH 3/8] refactor(desktop): replace the shell's Composer handle with named edits AppShell held the main Composer's editor handle in 16 places and passed it to Workbar and Session Collaboration. Those callers could read drafts back and write any draft key. Conversation now builds `ComposerEditingCommands` from its own handle: - appendText, replaceText, focus and openModelPicker for the visible draft; - seedDraft for Work Board 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 these reads a draft back. The shell's queue surface carries these intents and the plate's entry actions, and nothing else. `ConversationComposerRegion` attaches the handle to the Composer itself. Workbar and Session Collaboration take the two keyed intents structurally. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 2 +- .../composer-submission-owner.test.ts | 45 +++++++++++++- .../__tests__/guest-turn-requests.test.ts | 2 +- .../main/__tests__/workbar-controller.test.ts | 2 +- apps/desktop/src/renderer/app-shell.tsx | 53 +++++++--------- .../renderer/features/conversation/README.md | 16 +++++ .../controller/use-conversation-target.ts | 20 +++++- .../conversation/model/composer-editing.ts | 62 +++++++++++++++++++ .../conversation/ui/conversation-provider.tsx | 6 +- .../conversation/ui/conversation-readers.tsx | 5 ++ .../controller/use-guest-turn-requests.ts | 11 ++-- .../ui/guest-turn-requests.tsx | 7 +-- .../controller/use-workbar-controller.ts | 9 +-- .../stories/shared-session-guest.stories.tsx | 2 +- 14 files changed, 191 insertions(+), 51 deletions(-) create mode 100644 apps/desktop/src/renderer/features/conversation/model/composer-editing.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 0a3c16e21a8..720ac9acbbb 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -759,7 +759,7 @@ "react": 1 }, "importSpecifiers": 60, - "nonTriviaTokens": 8017 + "nonTriviaTokens": 7961 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, 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 31cd9a2b0af..29afcc30945 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,7 @@ import { act, createElement, Fragment } from 'react'; import type { StoredMessage } from '@maka/core/session'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; -import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider, type ComposerHandle } from '@maka/ui'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import { createSessionCatalogController, @@ -65,6 +65,7 @@ type Owner = { sessionId: string | undefined }; type ProviderProps = Parameters>[0]; interface RegionProps { + composerRef: { current: Partial | null }; onSend(text: string, metadata?: { followUpMode?: 'steer' | 'queue' }): Promise; newTaskSendPending: boolean; revisionNotice?: { title: string; detail: string; cancelLabel: string; onCancel(): void }; @@ -415,6 +416,47 @@ describe('ComposerSubmissionProvider', () => { assert.deepEqual(h.region.pendingMessages.map((message) => message.id), ['saved-follow-up']); }); + 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 () => { const unmounted = createComposerSubmissionCommands(); assert.throws(() => unmounted.beginEditUserMessage('turn-1'), /ComposerSubmissionProvider is not mounted/); @@ -502,6 +544,7 @@ 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/, 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__/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/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index da5d3a59f78..612b72d7ca7 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -442,10 +442,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(); - }, []); + // Named Composer edits; the editor handle stays with Conversation. + const composerEditing = queueSurface.editing; const { safeResumeAction, composerResumeAction } = useShellResume({ activeId, ownerActiveId, @@ -557,7 +555,7 @@ function AppShellContent({ : true, modelPickerDisabled: !modelSwitchAvailability.available, openSettingsSection, - openModelPicker: openComposerModelPicker, + openModelPicker: composerEditing.openModelPicker, refreshModelChoices: sessionHostConnections.refreshConnections, setSessionExecutor, }); @@ -647,8 +645,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)); @@ -807,22 +805,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. @@ -852,9 +846,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(); @@ -1057,8 +1051,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. @@ -1074,8 +1068,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; @@ -1287,7 +1281,7 @@ function AppShellContent({ clientPathsAccessible={projectCapabilities.viewClientPath} useSkillInChat={useSkillInChat} openSession={openSessionInChat} - appendComposerText={(text) => composerRef.current?.appendText(text)} + appendComposerText={composerEditing.appendText} captureActiveComposerClaim={captureActiveComposerClaim} commandPort={moduleHubCommands} > @@ -1311,7 +1305,7 @@ function AppShellContent({ shellObscured, modelChoices: chatModelChoices, toastApi, - composerRef, + composerDraft: composerEditing, openNewTaskSurface, openSessionInChat, resolveWorkBoardTarget, @@ -1511,12 +1505,11 @@ function AppShellContent({ {(workspacePicker) => ( {(guest) => ( composerRef.current?.appendText(prompt)} + onPromptSuggestion={composerEditing.appendText} onQuoteSelection={ sharedSessionActive ? undefined : (selection) => { composerStaging.addQuote(selection); - composerRef.current?.focus(); + composerEditing.focus(); } } onAskAboutSelection={ diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 902841978b2..57b3ad9aabd 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -240,6 +240,22 @@ 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. +## 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) `TaskReadinessProvider` alone calls the readiness controller. It is mounted 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 c1b50e8268a..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,6 +48,8 @@ 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, @@ -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/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/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 1410ba2fbb3..8c191dceaa5 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -24,6 +24,7 @@ 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 { useConversationQueueCommands } from './conversation-provider.js'; import { useComposerSubmissionReader, type ComposerSubmissionReader } from './composer-submission-context.js'; import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; @@ -74,6 +75,8 @@ type SubmissionProps = Pick['composer']; processing: boolean; pendingMessages: ChatProps['transientMessages']; latestRequestUsageTokens?: number; }; /** The shell's picker gates; an edit-and-resend draft narrows them here. */ @@ -90,6 +93,7 @@ export function ConversationComposerRegion

( const { surface, usageModel, usageRoute, ...presentation } = props; const { workspace } = useConversationOwner(); const submission = useComposerSubmissionReader(); + const composerRef = useConversationQueueCommands().composer; const actionCopy = getDesktopConversationCopy(useUiLocale()).actions; const activeId = useSyncExternalStore(workspace.target.subscribe, workspace.target.getSnapshot); const view = useSyncExternalStore(workspace.composer.subscribe, workspace.composer.getSnapshot); @@ -100,6 +104,7 @@ export function ConversationComposerRegion

( const editing = draft !== null && activeId === draft.draftSessionId; const gates = presentation as ComposerPickGates; const owned: ComposerProps & ComposerPickGates = { + composerRef, onSend: submission.onSend, newTaskSendPending: submission.newTaskSendPending, onStop: submission.stop, 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/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) => ( Date: Sat, 3 Oct 2026 18:04:13 +0800 Subject: [PATCH 4/8] refactor(desktop): move Turn marks and the resume offer into the submission owner AppShell called three hooks for the Turn footer and the resume offer: `useTurnActionRegistry` held the pending marks and lent them to the submission owner through its shell port, `useAppShellTurnPresentation` derived the footer from them, and `useShellResume` built the banner and send-slot resume actions. The submission owner now holds the marks and the resume instance. `ConversationTranscriptRegion` derives the Turn presentation from the owner's marks (Branch is still withheld from a shared Session) and injects the banner's resume action; `ConversationComposerRegion` injects the send-slot offer. One resume instance stays behind both. The shell's command handle gains `clearPendingTurnActions` for Session teardown and Host changes, a no-op while the owner is unmounted. The two resume actions are memoized, so the owner's reader keeps its identity while nothing the actions show changes. The hook gate and the retained-root table drop the three rows; the legacy registry file moves under Conversation. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 34 +----- .../__tests__/composer-submission-fixture.ts | 2 +- .../composer-submission-owner.test.ts | 103 +++++++++++++++++- apps/desktop/src/renderer/README.md | 3 - apps/desktop/src/renderer/app-shell.tsx | 43 +------- .../contracts/turn-presentation.ts | 2 +- .../renderer/features/conversation/README.md | 27 +++-- .../composer-submission-commands.ts | 2 + .../controller/use-composer-submission.ts | 39 ++++++- .../controller/use-shell-resume.ts | 28 ++--- .../controller}/use-turn-action-registry.ts | 0 .../renderer/features/conversation/index.ts | 1 - .../model/composer-submission-contract.ts | 8 +- .../ui/composer-submission-provider.tsx | 9 +- .../conversation/ui/conversation-readers.tsx | 16 ++- scripts/check-app-shell-hooks.mjs | 8 -- 16 files changed, 198 insertions(+), 127 deletions(-) rename apps/desktop/src/renderer/{ => features/conversation/controller}/use-turn-action-registry.ts (100%) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 720ac9acbbb..c6f7973d060 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -171,7 +171,6 @@ "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" @@ -363,8 +362,7 @@ "useActiveExecutionBoundary", "useAppShellSessionUiReads", "useAppShellSessionUiState", - "useShellChatModel", - "useShellResume" + "useShellChatModel" ], "composition": [ "ComposerStagingServicesProvider", @@ -657,7 +655,7 @@ "nonTriviaTokens": 858 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 42, + "importDeclarations": 41, "bridgePaths": {}, "environmentCapabilities": { "window.requestAnimationFrame": 3 @@ -671,7 +669,6 @@ "useAppShellProjectContext": 1, "useAppShellSessionUiReads": 1, "useAppShellSessionWorkspace": 1, - "useAppShellTurnPresentation": 1, "useEffect": 1, "useLayoutEffect": 1, "useRef": 4, @@ -682,12 +679,10 @@ "useShellConnections": 3, "useShellLiveTurn": 1, "useShellMemoryPill": 1, - "useShellResume": 1, "useStableActions": 1, "useState": 5, "useSystemUiLocale": 1, - "useToast": 1, - "useTurnActionRegistry": 1 + "useToast": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -703,7 +698,6 @@ "./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, @@ -747,7 +741,6 @@ "./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, @@ -758,8 +751,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 60, - "nonTriviaTokens": 7961 + "importSpecifiers": 59, + "nonTriviaTokens": 7869 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -3444,23 +3437,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, 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 29afcc30945..c0dd57b5bda 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,7 @@ 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 ComposerHandle } from '@maka/ui'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider, type ComposerHandle, type TurnPresentation, type TurnViewModel } from '@maka/ui'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import { createSessionCatalogController, @@ -41,6 +41,7 @@ import { ConversationLifecycle, ConversationProvider, ConversationServicesProvider, + ConversationTranscriptRegion, createComposerStagingCommands, createComposerSubmissionCommands, useAppShellSessionUiState, @@ -78,6 +79,12 @@ interface RegionProps { onStop(): void; stopPending: boolean; pendingMessages: ReadonlyArray<{ id: string }>; + resumeAction?: { pending: boolean; onResume(): void }; +} + +interface TranscriptProps { + deriveTurnPresentation(turns: readonly TurnViewModel[]): TurnPresentation; + safeResumeAction?: { pending: boolean; detail: string | undefined; onResume(): void }; } const row = (id: string): DesktopSessionSummary => ({ @@ -100,15 +107,18 @@ function harness(options: { shell?: Partial; newTask?: Partial; sharedSessionActive?: boolean; + ownerSessionId?: string; listMessages?: ReturnType['listMessages']; + resume?: ReturnType['resume']; } = {}) { const { root } = installReactRenderer(); const catalog = createSessionCatalogController(); catalog.commitSessions(['A', 'B'].map(row)); const published: Array<(messages: StoredMessage[]) => void> = []; - const conversationServices = stubConversationServices( - options.listMessages ? { listMessages: options.listMessages } : {}, - ); + const conversationServices = stubConversationServices({ + ...(options.listMessages ? { listMessages: options.listMessages } : {}), + ...(options.resume ? { resume: options.resume } : {}), + }); conversationServices.observation.openTranscript = (sessionId) => { let messages: StoredMessage[] = []; let ready = false; @@ -139,7 +149,9 @@ function harness(options: { let target!: ReturnType; let conversation!: ReturnType; let region: RegionProps | undefined; + let transcript: TranscriptProps | undefined; function Composer(props: RegionProps) { region = props; return null; } + function Transcript(props: TranscriptProps) { transcript = props; return null; } function Shell() { target = useAppShellSessionUiState(); conversation = useConversationOwner(); @@ -149,6 +161,7 @@ function harness(options: { showModelSetupToast() {}, onTurnCompleted() {}, searchTarget: null, clearSearchTarget() {}, }), + createElement(ConversationTranscriptRegion, { surface: Transcript }), createElement(ConversationComposerRegion, { surface: Composer, contextPickEnabled: true, directoryPickerEnabled: true, }), @@ -167,6 +180,7 @@ function harness(options: { shell: stubSubmissionShell(options.shell), newTask: stubNewTaskSubmission(options.newTask), sharedSessionActive: options.sharedSessionActive ?? false, + ownerSessionId: options.ownerSessionId, children: createElement(Shell), }), }), @@ -181,6 +195,7 @@ 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; }, notice: () => region?.revisionNotice, }; } @@ -397,6 +412,80 @@ 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 () => h.commands.clearPendingTurnActions()); + assert.equal(branchPending(), false, 'a Host change drops every mark'); + await act(async () => { + for (const branch of branches) branch.resolve({ ...row('C'), name: 'Copy' }); + await Promise.all(settled); + }); + }); + + 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 h = harness({ @@ -549,9 +638,15 @@ describe('Composer submission ownership', () => { 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/renderer/README.md b/apps/desktop/src/renderer/README.md index 075ee1c1c27..8f86f5874c1 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -286,7 +286,6 @@ or reason, which stays with review. | `AppShellContent` | `useAppShellProjectContext` | project context | titlebar project name and path; Workbar, Module Hub and palette project inputs; the default-Host project refresh | legacy `use-project-context.ts` | read the owner Session's and the default Host's project projection; project mutations and the open-folder commands belong to Task Entry | — | M5 | | `AppShellContent` | `useAppShellSessionUiReads` | displayed Session chrome | interaction, queue, live-turn and execution chrome; Composer props | Conversation (transitional reader) | fixed-purpose reads of the displayed and owner Session | — | M3 | | `AppShellContent` | `useAppShellSessionWorkspace` | Session workspace | every region's requested, published and owner Session | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection and the catalog controller | navigation | — | -| `AppShellContent` | `useAppShellTurnPresentation` | `deriveTurnPresentation` | `ChatView` turn footer | application contract `turn-presentation` | derive turn presentation from the transcript projection and pending turn actions | — | M3 | | `AppShellContent` | `useEffect` | `defaultHostConnections`: onboarding connection seed | default-Host connection projection | AppShell | seed default-Host connections from the onboarding authority's read-only projection | — | M5 | | `AppShellContent` | `useLayoutEffect` | `openSessionInChatRef` publication | turn footer, Module Hub, titlebar parent link | AppShell | publish the current open-Session command into a ref | cross-region command | — | | `AppShellContent` | `useSessionNavigationReads` | rail reads | command palette sessions, titlebar parent, `--maka-sidenav-width` | Session Navigation | revision navigation, the active parent Session and the rail layout | navigation | — | @@ -298,13 +297,11 @@ or reason, which stays with review. | `AppShellContent` | `useShellConnections` | `sessionHostConnections` | owner Session model choices | legacy `use-shell-connections.ts` | the owner Session Host's connection snapshot and refresh | application lifecycle | — | | `AppShellContent` | `useShellLiveTurn` | live-turn flags | mode-change gating, model switch, pet activity | Conversation reads | derive streaming and settled flags from the owner Session snapshot | — | M3 | | `AppShellContent` | `useShellMemoryPill` | memory pill | titlebar memory pill | legacy `use-shell-memory-pill.ts` | read and refresh the owner Session's memory state | layout | — | -| `AppShellContent` | `useShellResume` | resume offer | Composer send slot | Conversation | per-Session resume availability | — | M3 | | `AppShellContent` | `useStableActions` | `createAppShellE2eFixtureActions` | E2E fixture command | AppShell | apply test fixtures across navigation, rail, Workbar and appearance | cross-region command | — | | `AppShellContent` | `useState` | `petCompletionNonce` | custom pet companion | AppShell | a counter the transcript bumps when the active Turn completes | cross-region command | — | | `AppShellContent` | `useState` | `navigationState` | navigation sections; stored navigation | AppShell | the selected section and each hub's module | navigation | — | | `AppShellContent` | `useState` | `workHubActive` | WorkHub or Session surface | AppShell | whether the WorkHub surface is shown | navigation | — | | `AppShellContent` | `useToast` | `toastApi` | toasts of every legacy action | Astryx toast provider | show toasts | cross-region command | — | -| `AppShellContent` | `useTurnActionRegistry` | pending turn actions | turn footer disabled mask; the Composer submission owner's Turn branch; bootstrap clears | legacy `use-turn-action-registry.ts` | pending action keys per Session | — | M3 | ### Transitional feature exports outside Conversation diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 612b72d7ca7..73cf06a40fd 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -104,7 +104,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'; @@ -128,12 +127,10 @@ 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, @@ -444,14 +441,6 @@ function AppShellContent({ const persistedComposerDefaults = loadComposerDefaults(); // Named Composer edits; the editor handle stays with Conversation. const composerEditing = queueSurface.editing; - const { safeResumeAction, composerResumeAction } = useShellResume({ - activeId, - ownerActiveId, - sharedSessionActive, - toastApi, - shellCopy, - uiLocale, - }); const rendererMountedRef = useRef(true); const activeSession = activeCatalogSession; const { setPermissionMode, setSessionModel, setSessionThinkingLevel, setSessionExecutor } = sessionSettingIntent.commands; @@ -559,20 +548,11 @@ function AppShellContent({ 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); } @@ -614,17 +594,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); @@ -971,13 +940,13 @@ function AppShellContent({ activeIdRef, applyE2eFixture, bootstrapSessions, - clearPendingTurnActionsForSession: turnActionRegistry.clearForSession, + clearPendingTurnActionsForSession: composerSubmission.clearPendingTurnActions, createSession, handleConnectionEvent, openHelp, openSettings, - clearPendingTurnActions: turnActionRegistry.clearAll, + clearPendingTurnActions: () => composerSubmission.clearPendingTurnActions(), refreshConnections: refreshConnectionProjections, refreshMemoryActive, refreshMessages, @@ -1208,7 +1177,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, }}> @@ -1525,7 +1493,6 @@ function AppShellContent({ // 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} - resumeAction={composerResumeAction} queuedMessages={activeMessageQueue?.entries} queuedMessageRevision={activeMessageQueue?.queueRevision} onPromoteQueuedEntry={activeId ? queueSurface.promoteQueuedEntry : undefined} @@ -1620,10 +1587,8 @@ function AppShellContent({ userLabel={userLabel} memoryActive={memoryActive} onOpenMemorySettings={sharedSessionActive ? undefined : () => 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={ 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/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 57b3ad9aabd..5da4964d486 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -213,8 +213,9 @@ without restoring root subscriptions or acquiring the private controller. `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 +226,26 @@ 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 shell keeps only the stable +`ComposerSubmissionCommands` handle, whose `beginEditUserMessage` and +`handleTurnFooterAction` serve the transcript and whose +`clearPendingTurnActions` serves Session teardown and Host changes. The first +two throw while the owner is unmounted; cleanup is a no-op then. The binding and +reader context are private. ## Composer editing intents (R2 M3) diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-submission-commands.ts index 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..1fb9d1ee218 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, 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,19 @@ 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, + }); // Held for the whole of a send; see ChatComposerRegion. const [newTaskSendPending, setNewTaskSendPending] = useState(false); @@ -186,7 +203,7 @@ export function useComposerSubmission(input: uiLocale, activeIdRef, captureSelection: commands.captureSelection, - turnActionRegistry: shell.turnActions, + turnActionRegistry, openSessionInChat: shell.openSession, refreshSessions: shell.refreshSessions, toastApi, @@ -225,7 +242,11 @@ export function useComposerSubmission(input: const shellCommands = useMemo(() => ({ beginEditUserMessage: (turnId) => revision.beginEditUserMessage(turnId), handleTurnFooterAction: (turnId, actionId) => turn.handleTurnFooterAction(turnId, actionId), - }), [revision, turn]); + clearPendingTurnActions: (sessionId) => { + if (sessionId) turnActionRegistry.clearForSession(sessionId); + else turnActionRegistry.clearAll(); + }, + }), [revision, turn, turnActionRegistry.clearAll, turnActionRegistry.clearForSession]); const reader = useMemo(() => ({ onSend, newTaskSendPending, @@ -235,7 +256,15 @@ export function useComposerSubmission(input: respondToUserQuestion: chat.respondToUserQuestion, respondToUserForm: chat.respondToUserForm, stop, - }), [chat, newTaskSendPending, onSend, revision, revisionDraft, stop]); + activeId, + sharedSessionActive, + pendingTurnActions: turnActionRegistry.keys, + composerResumeAction: resume.composerResumeAction, + safeResumeAction: resume.safeResumeAction, + }), [ + activeId, chat, newTaskSendPending, onSend, resume.composerResumeAction, resume.safeResumeAction, + revision, revisionDraft, sharedSessionActive, stop, turnActionRegistry.keys, + ]); return { shellCommands, reader, 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 100% 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 diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 5f0cf6b96d0..11de1562b0f 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -36,7 +36,6 @@ export { } 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, 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..b1ca25d41fe 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 pending Turn-footer marks for one retired Session, or all of them on a Host change. */ + 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/ui/composer-submission-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx index 8b4670d833c..409c5e52cc6 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'; @@ -31,7 +31,6 @@ import type { ComposerSurfaceOwner, } from '../model/composer-submission-contract.js'; import { ComposerSubmissionContext } from './composer-submission-context.js'; -import { useConversationOwner } from './conversation-context.js'; /** * Sole owner of the Composer's submission: the send-pending flag, the @@ -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); @@ -68,7 +67,7 @@ export function ComposerSubmissionProvider(p - + {props.children} ); diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx index 8c191dceaa5..87e72871a33 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -27,12 +27,14 @@ import { useConversationOwner } from './conversation-context.js'; import { useConversationQueueCommands } from './conversation-provider.js'; import { useComposerSubmissionReader, type ComposerSubmissionReader } from './composer-submission-context.js'; import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; +import { useAppShellTurnPresentation } from '../../../application/contracts/turn-presentation.js'; type ChatProps = ComponentProps; type TranscriptProps = Pick & { activeSessionId: string | undefined; liveContentSeedGeneration: number; sessionUiReads: SessionUiReads }; /** The actual transcript reader. Shell supplies presentation and navigation only. */ @@ -41,6 +43,14 @@ export function ConversationTranscriptRegion

( ) { const { surface, ...presentation } = props; const { workspace, commands, readingCommands } = useConversationOwner(); + const submission = useComposerSubmissionReader(); + // Pending Turn-footer marks come from the submission owner that sets them. + const deriveTurnPresentation = useAppShellTurnPresentation({ + allowBranch: !submission.sharedSessionActive, + activeId: submission.activeId, + pendingTurnActions: submission.pendingTurnActions, + uiLocale: useUiLocale(), + }); 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); @@ -62,6 +72,8 @@ 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: submission.safeResumeAction, }; return createElement(surface, { ...presentation, ...owned } as unknown as P); } @@ -71,6 +83,7 @@ type SubmissionProps = Pick & { onStop: ComposerSubmissionReader['stop']; + resumeAction: ComposerSubmissionReader['composerResumeAction']; stopPending: boolean; revisionNotice?: { title: string; detail: string; cancelLabel: string; onCancel(): void }; }; @@ -107,6 +120,7 @@ export function ConversationComposerRegion

( composerRef, onSend: submission.onSend, newTaskSendPending: submission.newTaskSendPending, + resumeAction: submission.composerResumeAction, onStop: submission.stop, stop: submission.stop, stopPending, diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index 0dcb8d175c8..903b78260bf 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -113,7 +113,6 @@ export const ALLOWED = { useAppShellProjectContext: 1, useAppShellSessionUiReads: 1, useAppShellSessionWorkspace: 1, - useAppShellTurnPresentation: 1, useEffect: 1, useLayoutEffect: 1, // Replaces `useSessionNavigationController`, which is now called inside @@ -134,16 +133,9 @@ export const ALLOWED = { useShellConnections: 3, useShellLiveTurn: 1, useShellMemoryPill: 1, - useShellResume: 1, useStableActions: 1, useState: 3, useToast: 1, - // The last of the three `useKeyedPendingRegistry` call sites this entry - // replaces: #4113 moved the other two onto the session UI store, which is - // already an external store, so their scope left this fiber entirely. This - // one stays because the shell body reads `keys` to build the turn footer's - // disabled mask. - useTurnActionRegistry: 1, }, }; From a44b196ccf5cb76d6b4b0f2be5d902779e4ae2ea Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 3 Oct 2026 18:23:20 +0800 Subject: [PATCH 5/8] refactor(desktop): read the displayed Session's Turn in the regions that render it AppShell subscribed to the displayed Session's load, Turn summary, interaction and queue through `useAppShellSessionUiReads`, derived live-turn flags with `useShellLiveTurn`, and computed every control a running Turn holds in its own render body. Each Turn boundary therefore re-rendered the whole shell. Each region now reads what it renders: - `ConversationComposerRegion` reads the Turn summary, the queue and the owner Session's interaction. It derives Stop, the mode, permission and goal reasons, the model-switch gate, the executor picker's hold and the slash commands (`/compact` is withheld while a Turn runs). The shell passes only the catalog row's arrival and status and the executor selection. - `ConversationTranscriptRegion` injects the running Turn's activity and holds the health notice's model picker for the same Turn. The shell's `useShellChatModel` keeps the status half of that gate. - `ConversationActivityConsumer` feeds the custom pet whether an observable Turn runs and whether the owner Session waits on an answer; `petActivityForSession` maps that onto the pack state. - `ConversationHomeSurface` renders the main column and marks it as the home surface when the shell's empty-transcript condition holds and there is no live Turn content and no failed load. The gate logic moves unchanged into `composerTurnGates` and `liveTurnFlags`. The two legacy hooks, their gate entries and retained-root rows, and the Conversation README's last transitional row are gone, together with the `chatTurnActivity`, `executorComposerProps` and `desktopSlashCommandPresentation` exports. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 53 +---- .../composer-submission-owner.test.ts | 205 +++++++++++++++++- .../main/__tests__/conversation-owner.test.ts | 4 +- .../__tests__/custom-pet-activity.test.ts | 34 +++ .../main/__tests__/session-ui-reads.test.ts | 20 +- apps/desktop/src/renderer/README.md | 2 - apps/desktop/src/renderer/app-shell.tsx | 127 ++--------- .../renderer/custom-pet-companion-model.ts | 14 ++ .../src/renderer/custom-pet-companion.tsx | 18 ++ .../renderer/features/conversation/README.md | 21 +- .../controller/use-composer-submission.ts | 3 +- .../controller/use-session-ui-reads.ts | 33 --- .../renderer/features/conversation/index.ts | 15 +- .../conversation/model/composer-turn-gates.ts | 96 ++++++++ .../conversation/model/live-turn-flags.ts | 48 ++++ .../renderer/features/conversation/testing.ts | 23 ++ .../conversation/ui/conversation-readers.tsx | 119 +++++++++- .../use-app-shell-session-ui-reads.ts | 20 -- .../src/renderer/use-shell-live-turn.ts | 37 ---- scripts/check-app-shell-hooks.mjs | 2 - 20 files changed, 610 insertions(+), 284 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/custom-pet-activity.test.ts delete mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-session-ui-reads.ts create mode 100644 apps/desktop/src/renderer/features/conversation/model/composer-turn-gates.ts create mode 100644 apps/desktop/src/renderer/features/conversation/model/live-turn-flags.ts delete mode 100644 apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts delete mode 100644 apps/desktop/src/renderer/use-shell-live-turn.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c6f7973d060..e1b4e007816 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -162,12 +162,10 @@ "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", @@ -344,7 +342,9 @@ "appShell": [ "ComposerStagingProvider", "ComposerSubmissionProvider", + "ConversationActivityConsumer", "ConversationComposerRegion", + "ConversationHomeSurface", "ConversationLifecycle", "ConversationMessageConsumer", "ConversationProvider", @@ -353,14 +353,10 @@ "PlanExecutionSurface", "PlanProvider", "TaskReadinessProvider", - "chatTurnActivity", "createComposerStagingCommands", "createComposerSubmissionCommands", - "desktopSlashCommandPresentation", - "executorComposerProps", "resolveTaskReadinessModelTarget", "useActiveExecutionBoundary", - "useAppShellSessionUiReads", "useAppShellSessionUiState", "useShellChatModel" ], @@ -655,7 +651,7 @@ "nonTriviaTokens": 858 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 41, + "importDeclarations": 37, "bridgePaths": {}, "environmentCapabilities": { "window.requestAnimationFrame": 3 @@ -667,7 +663,6 @@ "useAppShellNavRefSync": 1, "useAppShellPersistenceEffects": 1, "useAppShellProjectContext": 1, - "useAppShellSessionUiReads": 1, "useAppShellSessionWorkspace": 1, "useEffect": 1, "useLayoutEffect": 1, @@ -677,7 +672,6 @@ "useShellAppearance": 1, "useShellChatModel": 1, "useShellConnections": 3, - "useShellLiveTurn": 1, "useShellMemoryPill": 1, "useStableActions": 1, "useState": 5, @@ -695,7 +689,6 @@ "./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/workhub-workspace/workhub-enablement.js": 1, @@ -704,7 +697,6 @@ "./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, @@ -732,12 +724,10 @@ "./settings/provider-display": 1, "./settings/runtime-host-ssh-terminal-dialog.js": 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, @@ -746,13 +736,12 @@ "@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": 59, - "nonTriviaTokens": 7869 + "importSpecifiers": 55, + "nonTriviaTokens": 7509 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -781,20 +770,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": {}, @@ -3375,17 +3350,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 @@ -3696,13 +3660,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__/composer-submission-owner.test.ts b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts index c0dd57b5bda..2fa3e4a3c3a 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 ComposerHandle, type TurnPresentation, type TurnViewModel } 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,7 +40,9 @@ import * as Conversation from '../../renderer/features/conversation/index.js'; import { ComposerSubmissionProvider, ComposerSubmissionServicesProvider, + ConversationActivityConsumer, ConversationComposerRegion, + ConversationHomeSurface, ConversationLifecycle, ConversationProvider, ConversationServicesProvider, @@ -46,8 +51,10 @@ import { createComposerSubmissionCommands, useAppShellSessionUiState, type ComposerSubmissionServices, + type ConversationActivity, } from '../../renderer/features/conversation/index.js'; -import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; +import { getShellCopy } from '../../renderer/locales/shell-copy.js'; +import { stubComposerGateInputs, stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; import { createDesktopComposerSubmissionServices, type DesktopComposerSubmissionBridge, @@ -58,7 +65,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); @@ -80,11 +87,24 @@ interface RegionProps { stopPending: boolean; pendingMessages: ReadonlyArray<{ id: string }>; 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 => ({ @@ -110,8 +130,10 @@ function harness(options: { ownerSessionId?: string; listMessages?: ReturnType['listMessages']; resume?: ReturnType['resume']; + gateInputs?: ReturnType; + homeEligible?: boolean; } = {}) { - const { root } = installReactRenderer(); + const { root, container } = installReactRenderer(); const catalog = createSessionCatalogController(); catalog.commitSessions(['A', 'B'].map(row)); const published: Array<(messages: StoredMessage[]) => void> = []; @@ -150,9 +172,13 @@ function harness(options: { let conversation!: ReturnType; let region: RegionProps | undefined; let transcript: TranscriptProps | undefined; + let activity: ConversationActivity | undefined; + let shellRenders = 0; function Composer(props: RegionProps) { region = props; return null; } function Transcript(props: TranscriptProps) { transcript = props; return null; } + function Activity(props: { activity: ConversationActivity }) { activity = props.activity; return null; } function Shell() { + shellRenders += 1; target = useAppShellSessionUiState(); conversation = useConversationOwner(); return createElement(Fragment, null, @@ -161,10 +187,15 @@ function harness(options: { showModelSetupToast() {}, onTurnCompleted() {}, searchTarget: null, clearSearchTarget() {}, }), - createElement(ConversationTranscriptRegion, { surface: Transcript }), + createElement(ConversationTranscriptRegion, { + surface: Transcript, sessionHealthModelPickerAvailable: true, + }), createElement(ConversationComposerRegion, { surface: Composer, contextPickEnabled: true, directoryPickerEnabled: true, + ...(options.gateInputs ?? stubComposerGateInputs()), }), + createElement(ConversationActivityConsumer<{ activity: ConversationActivity }>, { surface: Activity }), + createElement(ConversationHomeSurface, { eligible: options.homeEligible ?? true, id: 'column' }), ); } act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: @@ -196,6 +227,13 @@ function harness(options: { 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; }, + 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, }; } @@ -556,6 +594,163 @@ 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 gateInputs = stubComposerGateInputs(); + gateInputs.executorComposer.selection = { + ...gateInputs.executorComposer.selection, + selection: { executorId: 'codex' }, + entry: { id: 'codex', readiness: 'ready' }, + } as unknown as typeof gateInputs.executorComposer.selection; + const h = harness({ ownerSessionId: 'A', gateInputs }); + 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('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[] = []; diff --git a/apps/desktop/src/main/__tests__/conversation-owner.test.ts b/apps/desktop/src/main/__tests__/conversation-owner.test.ts index 77c7515104a..c9271154527 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 { renderConversationMarkdown, 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'; @@ -114,7 +114,7 @@ function harness(options: { searchTarget: null, clearSearchTarget() {}, })), visible ? createElement(ConversationTranscriptRegion[0]>, { surface: Transcript }) : null, - createElement(ConversationComposerRegion[0]>, { surface: Composer }), + createElement(ConversationComposerRegion[0]>, { surface: Composer, ...stubComposerGateInputs() }), ); } act(() => root.render(createElement(LocaleProvider, { locale: options.locale ?? 'en', children: 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__/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/renderer/README.md b/apps/desktop/src/renderer/README.md index 8f86f5874c1..b189e97e9a7 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -284,7 +284,6 @@ or reason, which stays with review. | `AppShellContent` | `useAppShellNavRefSync` | `navSelectionRef` | ownership checks of async results | AppShell | mirror the navigation selection into a ref | navigation | — | | `AppShellContent` | `useAppShellPersistenceEffects` | theme and navigation persistence | `` theme class and palette; stored navigation | legacy `app-shell-effects.ts` | apply the theme preference and palette; persist the navigation state | layout | — | | `AppShellContent` | `useAppShellProjectContext` | project context | titlebar project name and path; Workbar, Module Hub and palette project inputs; the default-Host project refresh | legacy `use-project-context.ts` | read the owner Session's and the default Host's project projection; project mutations and the open-folder commands belong to Task Entry | — | M5 | -| `AppShellContent` | `useAppShellSessionUiReads` | displayed Session chrome | interaction, queue, live-turn and execution chrome; Composer props | Conversation (transitional reader) | fixed-purpose reads of the displayed and owner Session | — | M3 | | `AppShellContent` | `useAppShellSessionWorkspace` | Session workspace | every region's requested, published and owner Session | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection and the catalog controller | navigation | — | | `AppShellContent` | `useEffect` | `defaultHostConnections`: onboarding connection seed | default-Host connection projection | AppShell | seed default-Host connections from the onboarding authority's read-only projection | — | M5 | | `AppShellContent` | `useLayoutEffect` | `openSessionInChatRef` publication | turn footer, Module Hub, titlebar parent link | AppShell | publish the current open-Session command into a ref | cross-region command | — | @@ -295,7 +294,6 @@ or reason, which stays with review. | `AppShellContent` | `useShellConnections` | `newTaskConnections` | new-task model choices | legacy `use-shell-connections.ts` | the new-task target's connection snapshot and refresh | application lifecycle | — | | `AppShellContent` | `useShellConnections` | `defaultHostConnections` | Settings, global commands, model setup | legacy `use-shell-connections.ts` | the default Host's connection snapshot and refresh | application lifecycle | — | | `AppShellContent` | `useShellConnections` | `sessionHostConnections` | owner Session model choices | legacy `use-shell-connections.ts` | the owner Session Host's connection snapshot and refresh | application lifecycle | — | -| `AppShellContent` | `useShellLiveTurn` | live-turn flags | mode-change gating, model switch, pet activity | Conversation reads | derive streaming and settled flags from the owner Session snapshot | — | M3 | | `AppShellContent` | `useShellMemoryPill` | memory pill | titlebar memory pill | legacy `use-shell-memory-pill.ts` | read and refresh the owner Session's memory state | layout | — | | `AppShellContent` | `useStableActions` | `createAppShellE2eFixtureActions` | E2E fixture command | AppShell | apply test fixtures across navigation, rail, Workbar and appearance | cross-region command | — | | `AppShellContent` | `useState` | `petCompletionNonce` | custom pet companion | AppShell | a counter the transcript bumps when the active Turn completes | cross-region command | — | diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 73cf06a40fd..ee5542b057f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -36,12 +36,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, @@ -58,7 +56,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'; @@ -82,7 +79,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, OnboardingProjectionRoot, @@ -114,8 +110,7 @@ import { AppShellDetailPanel } from './app-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'; @@ -128,7 +123,6 @@ import { } from './app-shell-effects'; import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; import { - desktopSlashCommandPresentation, useActiveExecutionBoundary, useShellChatModel, } from './features/conversation/index.js'; @@ -139,7 +133,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'; @@ -320,15 +313,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({ @@ -458,23 +442,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 @@ -496,11 +463,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, activeConnection, @@ -542,7 +510,7 @@ function AppShellContent({ connectionSnapshotReady: activeId ? sessionHostConnections.projection.status === 'ready' : true, - modelPickerDisabled: !modelSwitchAvailability.available, + modelPickerDisabled: modelPickerStatusBlocked, openSettingsSection, openModelPicker: composerEditing.openModelPicker, refreshModelChoices: sessionHostConnections.refreshConnections, @@ -649,20 +617,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, @@ -739,19 +693,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, @@ -1106,11 +1047,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, @@ -1428,7 +1367,7 @@ function AppShellContent({ navigation entry point. */}

-
@@ -1484,26 +1423,19 @@ function AppShellContent({ onboardingComposerHidden } boundaryUnreadableNotice={boundaryUnreadableNotice} - activeInteraction={activeInteraction} activeId={activeId} newTaskDraftKey={currentNewTaskDraftKey} respondToClientCapability={commands.respondToClientCapability} directoryPickerEnabled={Boolean(canStageComposerContext && directoryHostId)} - // #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} - queuedMessages={activeMessageQueue?.entries} - queuedMessageRevision={activeMessageQueue?.queueRevision} + sessionState={{ loaded: activeSession !== undefined, status: activeSession?.status }} onPromoteQueuedEntry={activeId ? queueSurface.promoteQueuedEntry : undefined} onUpdateQueuedEntry={activeId ? queueSurface.updateQueuedEntry : undefined} onDeleteQueuedEntry={activeId ? queueSurface.deleteQueuedEntry : undefined} onReorderQueuedEntries={activeId ? queueSurface.reorderQueuedEntries : undefined} - slashCommands={desktopSlashCommands} allowAttachmentOnlySend={canStageComposerContext} canStageContext={canStageComposerContext} contextPickEnabled={canStageComposerContext} - {...Conversation.executorComposerProps(executor, {activeId, turnActive, taskSubmissionHardBlocked, connectionCount: connections.length, onSetup: () => openSettingsSection('external-agents'), onNewTask: openNewTaskSurface})} + executorComposer={{ selection: executor, taskSubmissionHardBlocked, connectionCount: connections.length, onSetup: () => openSettingsSection('external-agents'), onNewTask: openNewTaskSurface }} activeSession={activeSessionForView} {...{ executorTarget, onExecutorTargetChange }} usageModel={activeModel} @@ -1514,7 +1446,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); }} @@ -1531,39 +1462,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 - } /> )} @@ -1574,7 +1479,6 @@ function AppShellContent({ > {sessionsSelected ? ( -
+ {/* Collapse hides the Workbar surface without unmounting its tools. */}
@@ -1660,8 +1564,9 @@ function AppShellContent({ {!shellObscured && ( - 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 5da4964d486..2f5172c84ea 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -114,8 +114,21 @@ 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. +- `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. @@ -125,11 +138,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 | M2 owns presentation and observation; it does not add a Catalog, Host cache or execution state machine, or complete the remaining Composer migration. 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 1fb9d1ee218..06a8d92b96a 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 @@ -257,12 +257,13 @@ export function useComposerSubmission(input: respondToUserForm: chat.respondToUserForm, stop, activeId, + ownerSessionId, sharedSessionActive, pendingTurnActions: turnActionRegistry.keys, composerResumeAction: resume.composerResumeAction, safeResumeAction: resume.safeResumeAction, }), [ - activeId, chat, newTaskSendPending, onSend, resume.composerResumeAction, resume.safeResumeAction, + activeId, chat, newTaskSendPending, onSend, ownerSessionId, resume.composerResumeAction, resume.safeResumeAction, revision, revisionDraft, sharedSessionActive, stop, turnActionRegistry.keys, ]); return { 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/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 11de1562b0f..32e53f765e8 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,7 +33,6 @@ 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 { useSessionReferenceComposer } from './controller/use-session-reference-composer.js'; export { @@ -44,13 +42,11 @@ 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 { useExecutorSelection } from './controller/use-executor-selection.js'; export { useShellChatModel, type SessionHealthNoticeView } from './controller/use-shell-chat-model.js'; -export { executorComposerProps } from './model/executor-composer.js'; export { PlanProvider } from './ui/plan-provider.js'; export { PlanChatView, PlanExecutionSurface } from './ui/plan-surfaces.js'; @@ -64,7 +60,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-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/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 1931a8e4561..67d1fe9298f 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,28 @@ 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'; + +/** 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 () => {}, + 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( 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 87e72871a33..168110e8c6c 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -18,8 +18,8 @@ */ import { createElement, useMemo, useSyncExternalStore, type ComponentType, type ComponentProps } from 'react'; -import { ChatView, useUiLocale } 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'; @@ -27,7 +27,18 @@ import { useConversationOwner } from './conversation-context.js'; import { useConversationQueueCommands } from './conversation-provider.js'; import { useComposerSubmissionReader, 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 }; +> & { + activeSessionId: string | undefined; liveContentSeedGeneration: number; sessionUiReads: SessionUiReads; + activeTurn: ReturnType; +}; +/** The shell's model-picker gate for the health notice; the running Turn narrows it here. */ +type TranscriptHealthGate = { sessionHealthModelPickerAvailable?: boolean }; /** The actual transcript reader. Shell supplies presentation and navigation only. */ export function ConversationTranscriptRegion

( @@ -51,12 +67,15 @@ export function ConversationTranscriptRegion

( pendingTurnActions: submission.pendingTurnActions, uiLocale: useUiLocale(), }); + const turn = useDisplayedTurn(workspace.ui.reads, submission.activeId); const view = useSyncExternalStore(workspace.publication.subscribe, workspace.publication.getSnapshot); const load = useSessionUiRead(workspace.ui.reads, 'load', view.sessionId); + const healthGate = presentation as TranscriptHealthGate; const retryPending = useSessionUiRead(workspace.ui.reads, 'retry', view.sessionId); const sessionId = view.sessionId; - const owned: TranscriptProps = { + const owned: TranscriptProps & TranscriptHealthGate = { activeSessionId: sessionId, + activeTurn: chatTurnActivity(turn.execution), onStreamingSettled: sessionId ? (messageId) => commands.settleAssistantStreaming(sessionId, messageId) : undefined, sessionUiReads: workspace.ui.reads, messages: view.messages, transientMessages: view.transientMessages, @@ -74,6 +93,10 @@ export function ConversationTranscriptRegion

( onReadingAnchorChange: sessionId ? (turnId) => readingCommands.current?.captureAnchor(turnId) : undefined, deriveTurnPresentation, safeResumeAction: submission.safeResumeAction, + // The notice's picker is held while a Turn runs, as the Composer's is. + ...(healthGate.sessionHealthModelPickerAvailable !== undefined + ? { sessionHealthModelPickerAvailable: healthGate.sessionHealthModelPickerAvailable && !turn.turnActive } + : {}), }; return createElement(surface, { ...presentation, ...owned } as unknown as P); } @@ -87,10 +110,25 @@ type SubmissionProps = Pick & 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'> & { + selection: Parameters[0]; + }; }; /** The shell's picker gates; an edit-and-resend draft narrows them here. */ type ComposerPickGates = { contextPickEnabled?: boolean; directoryPickerEnabled?: boolean }; @@ -98,17 +136,30 @@ type ComposerPickGates = { contextPickEnabled?: boolean; directoryPickerEnabled? * 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 composerRef = useConversationQueueCommands().composer; - const actionCopy = getDesktopConversationCopy(useUiLocale()).actions; + 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', submission.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); @@ -118,6 +169,19 @@ export function ConversationComposerRegion

( 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, + }), + ...executorComposerProps(executor, { ...executorInput, activeId, turnActive: turn.turnActive }), onSend: submission.onSend, newTaskSendPending: submission.newTaskSendPending, resumeAction: submission.composerResumeAction, @@ -154,3 +218,40 @@ export function ConversationMessageConsumer

( + props: { surface: ComponentType

} & Omit, +) { + const { surface, ...presentation } = props; + const { workspace } = useConversationOwner(); + const submission = useComposerSubmissionReader(); + const turn = useDisplayedTurn(workspace.ui.reads, submission.activeId); + const interaction = useSessionUiRead(workspace.ui.reads, 'interaction', submission.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/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/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index 903b78260bf..d3102e77354 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -111,7 +111,6 @@ export const ALLOWED = { useAppShellNavRefSync: 1, useAppShellPersistenceEffects: 1, useAppShellProjectContext: 1, - useAppShellSessionUiReads: 1, useAppShellSessionWorkspace: 1, useEffect: 1, useLayoutEffect: 1, @@ -131,7 +130,6 @@ export const ALLOWED = { useShellAppearance: 1, useShellChatModel: 1, useShellConnections: 3, - useShellLiveTurn: 1, useShellMemoryPill: 1, useStableActions: 1, useState: 3, From 99fc22b74301e92c1bd488ac837aa6560d76b682 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 3 Oct 2026 18:30:48 +0800 Subject: [PATCH 6/8] fix(desktop): give a withdrawn send's staged context back to the Session it left Editing a queued steering entry or a cancelled local message restores its text to the editor's keyed draft, and was meant to restore its attachments, directory references and quotes through the queue's `draftContextRestorer` slot. #5747 removed the only line that filled that slot when it moved the shell's staging calls, so since then the text came back and the staged context was dropped. Staging now offers `restoreContext(draftKey, context)` on its command handle, a no-op while no owner is mounted, and the Composer submission owner binds the queue's slot to it. The restore stays keyed by the Session the send left, so it lands there even after navigation. A message whose outcome is unknown keeps its identity: it offers only the Host check, bound to its Session and Message. Refs #4582 Generated-by: Claude Opus 5.5 --- .../composer-submission-owner.test.ts | 95 ++++++++++++++++++- .../renderer/features/conversation/README.md | 9 +- .../controller/composer-staging-commands.ts | 2 + .../controller/use-composer-submission.ts | 12 ++- .../controller/use-session-message-queue.ts | 6 +- .../model/composer-staging-contract.ts | 3 + .../ui/composer-staging-provider.tsx | 5 + 7 files changed, 123 insertions(+), 9 deletions(-) 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 2fa3e4a3c3a..ed437edd58a 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -53,8 +53,11 @@ import { type ComposerSubmissionServices, type ConversationActivity, } from '../../renderer/features/conversation/index.js'; +import { getSessionLocalCopy } from '../../renderer/locales/session-local-copy.js'; import { getShellCopy } from '../../renderer/locales/shell-copy.js'; -import { stubComposerGateInputs, stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; +import { + stubComposerGateInputs, stubConversationServices, useComposerStaging, useConversationOwner, useConversationQueue, +} from '../../renderer/features/conversation/testing.js'; import { createDesktopComposerSubmissionServices, type DesktopComposerSubmissionBridge, @@ -85,7 +88,7 @@ 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']; @@ -130,6 +133,10 @@ function harness(options: { ownerSessionId?: string; listMessages?: ReturnType['listMessages']; resume?: ReturnType['resume']; + cancelMessage?: ReturnType['cancelMessage']; + reconcileMessage?: ReturnType['reconcileMessage']; + retractQueueEntry?: ReturnType['sessions']['retractQueueEntry']; + stagingDraftKey?: string; gateInputs?: ReturnType; homeEligible?: boolean; } = {}) { @@ -140,6 +147,9 @@ function harness(options: { 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[] = []; @@ -173,7 +183,10 @@ function harness(options: { 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; } function Transcript(props: TranscriptProps) { transcript = props; return null; } function Activity(props: { activity: ConversationActivity }) { activity = props.activity; return null; } @@ -196,6 +209,7 @@ function harness(options: { }), createElement(ConversationActivityConsumer<{ activity: ConversationActivity }>, { surface: Activity }), createElement(ConversationHomeSurface, { eligible: options.homeEligible ?? true, id: 'column' }), + createElement(Probe), ); } act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: @@ -203,7 +217,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, @@ -229,6 +244,8 @@ function harness(options: { 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 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'); @@ -526,7 +543,9 @@ describe('ComposerSubmissionProvider', () => { 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' @@ -541,6 +560,76 @@ 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 () => { diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 2f5172c84ea..471bd104f72 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -218,8 +218,13 @@ 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) 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/use-composer-submission.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts index 06a8d92b96a..48417724a56 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, useSyncExternalStore } 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'; @@ -86,6 +86,16 @@ export function useComposerSubmission(input: 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); 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/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/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; }; From bc859b385480a8d33a5b5a00452f69f3a9b5b3d2 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 3 Oct 2026 18:34:49 +0800 Subject: [PATCH 7/8] docs(desktop): retain the last three M3 root hooks with their cross-region reasons `useActiveExecutionBoundary`, `useSessionSettingIntent` and `useShellChatModel` were the last retained-root rows scheduled for M3. Each serves several regions the root composes, so each stays at the root with the `cross-region command` reason and its full consumer list: - the execution boundary feeds the Composer's permission control and unreadable notice, the palette's permission-mode command, the Session Collaboration dialog gate and the health notice's picker gate, and is reloaded by the submission owner and Conversation lifecycle; - the setting overlay feeds the Composer's model, thinking and mode controls, the transcript's model picker, the palette's permission-mode command, new-task creation and Session teardown; - the model selection feeds the Composer's model and executor pickers, the transcript's labels and health notice, the staging vision gate, the readiness and new-task submission model and Workbar. The exports table's remaining M3 rows (the workspace picker, guest Turn requests and the skill catalog boundary) are other features' projections into the Composer; a feature cannot import another, so the root composes them and they stay. The guest row no longer mentions the Composer ref, which it lost to the named edits. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/src/renderer/README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index b189e97e9a7..4c0102a31ce 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -278,7 +278,7 @@ or reason, which stays with review. | `AppShell` | `useState` | `uiLocalePreference` | `LocaleProvider`; appearance settings | AppShell | the persisted locale preference and its setter | locale | — | | `AppShell` | `useState` | `uiLocaleOverride` | `LocaleProvider`; E2E locale override | AppShell | a runtime locale override above every region | locale | — | | `AppShell` | `useSystemUiLocale` | `systemUiLocale` | `resolveUiLocale` for `LocaleProvider` | AppShell | read the OS locale and its changes | locale | — | -| `AppShellContent` | `useActiveExecutionBoundary` | `activeExecutionBoundary` | Composer permission control; the Composer submission owner reloads it after a boundary answer | Conversation | read and reload the owner Session's execution boundary | — | M3 | +| `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` | Main change subscriptions | Session, connection, Host-profile and settings refreshers; app-window commands | legacy `app-shell-effects.ts` | startup refreshes, the global shortcuts and the handlers `ShellLifecycleSubscriptions` subscribes with the injected `ShellLifecycleSources`; no bridge access | application lifecycle | — | | `AppShellContent` | `useAppShellHostEffects` | titlebar modal sync | titlebar | 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 | AppShell | mirror the navigation selection into a ref | navigation | — | @@ -288,9 +288,9 @@ or reason, which stays with review. | `AppShellContent` | `useEffect` | `defaultHostConnections`: onboarding connection seed | default-Host connection projection | AppShell | seed default-Host connections from the onboarding authority's read-only projection | — | M5 | | `AppShellContent` | `useLayoutEffect` | `openSessionInChatRef` publication | turn footer, Module Hub, titlebar parent link | AppShell | publish the current open-Session command into a ref | cross-region command | — | | `AppShellContent` | `useSessionNavigationReads` | rail reads | command palette sessions, titlebar parent, `--maka-sidenav-width` | Session Navigation | revision navigation, the active parent Session and the rail layout | navigation | — | -| `AppShellContent` | `useSessionSettingIntent` | selected-Session setting overlay | Composer model and mode controls; 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 transcript's model picker; 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 | legacy `use-shell-appearance.ts` | read and write client appearance settings | layout | — | -| `AppShellContent` | `useShellChatModel` | Composer model selection | model picker, health notice, new-chat model | Conversation (transitional) | derive model, thinking and executor selection | — | M3 | +| `AppShellContent` | `useShellChatModel` | Composer model selection | Composer model and executor pickers; the transcript's model labels and health notice; the staging vision gate; the readiness and new-task submission model; 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 | legacy `use-shell-connections.ts` | the new-task target's connection snapshot and refresh | application lifecycle | — | | `AppShellContent` | `useShellConnections` | `defaultHostConnections` | Settings, global commands, model setup | legacy `use-shell-connections.ts` | the default Host's connection snapshot and refresh | application lifecycle | — | | `AppShellContent` | `useShellConnections` | `sessionHostConnections` | owner Session model choices | legacy `use-shell-connections.ts` | the owner Session Host's connection snapshot and refresh | application lifecycle | — | @@ -308,21 +308,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 | M5, with the legacy command actions | | diagnostics | `ManualDiagnosticReportConsumer` | command palette options in `app-shell.tsx` | render-prop manual report command | M5, with the legacy command actions | -| 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. From 2e6dacdc72b7fb34e054bfaffd317d413ae20a0c Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 3 Oct 2026 19:23:59 +0800 Subject: [PATCH 8/8] refactor(desktop): narrow the transcript's submission read and type its picker gate Review follow-ups on #5954: - The Turn-action registry clears its timers and marks when its owner unmounts. The shell's unmount call reached an already-unbound handle, so it is removed, and with it the no-argument "clear every Session" form: `clearPendingTurnActions(sessionId)` now needs a Session. - The transcript and the activity reader read a narrow Turn reader (displayed and owner Session, pending marks, the banner's resume action). A send-pending or edit-draft change no longer repaints the transcript. - The health notice's picker gate is a typed region input, `localInteractionAvailable`, instead of a prop read through a cast. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 4 +- .../composer-submission-owner.test.ts | 43 +++++++++++++++-- .../main/__tests__/conversation-owner.test.ts | 2 +- .../desktop/src/renderer/app-shell-effects.ts | 2 - apps/desktop/src/renderer/app-shell.tsx | 5 +- .../renderer/features/conversation/README.md | 16 ++++--- .../controller/use-composer-submission.ts | 19 ++++---- .../controller/use-turn-action-registry.ts | 22 ++++----- .../model/composer-submission-contract.ts | 4 +- .../renderer/features/conversation/testing.ts | 1 + .../ui/composer-submission-context.ts | 10 ++++ .../ui/composer-submission-provider.tsx | 12 +++-- .../conversation/ui/conversation-readers.tsx | 48 +++++++++++-------- 13 files changed, 118 insertions(+), 70 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index e1b4e007816..f5e5785c194 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -618,7 +618,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 856 + "nonTriviaTokens": 843 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -741,7 +741,7 @@ "react": 1 }, "importSpecifiers": 55, - "nonTriviaTokens": 7509 + "nonTriviaTokens": 7498 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, 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 ed437edd58a..e4e1dbe8a0e 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -57,6 +57,7 @@ import { getSessionLocalCopy } from '../../renderer/locales/session-local-copy.j import { getShellCopy } from '../../renderer/locales/shell-copy.js'; import { stubComposerGateInputs, stubConversationServices, useComposerStaging, useConversationOwner, useConversationQueue, + useTurnActionRegistry, } from '../../renderer/features/conversation/testing.js'; import { createDesktopComposerSubmissionServices, @@ -107,7 +108,7 @@ 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; + sessionHealthModelPickerAvailable: boolean; } const row = (id: string): DesktopSessionSummary => ({ @@ -139,6 +140,7 @@ function harness(options: { stagingDraftKey?: string; gateInputs?: ReturnType; homeEligible?: boolean; + localInteractionAvailable?: boolean; } = {}) { const { root, container } = installReactRenderer(); const catalog = createSessionCatalogController(); @@ -188,7 +190,8 @@ function harness(options: { let shellRenders = 0; function Probe() { staged = useComposerStaging(); queued = useConversationQueue(); return null; } function Composer(props: RegionProps) { region = props; return null; } - function Transcript(props: TranscriptProps) { transcript = 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; @@ -201,7 +204,7 @@ function harness(options: { searchTarget: null, clearSearchTarget() {}, }), createElement(ConversationTranscriptRegion, { - surface: Transcript, sessionHealthModelPickerAvailable: true, + surface: Transcript, localInteractionAvailable: options.localInteractionAvailable ?? true, }), createElement(ConversationComposerRegion, { surface: Composer, contextPickEnabled: true, directoryPickerEnabled: true, @@ -244,6 +247,7 @@ function harness(options: { 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() { @@ -490,14 +494,24 @@ describe('ComposerSubmissionProvider', () => { 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 () => h.commands.clearPendingTurnActions()); - assert.equal(branchPending(), false, 'a Host change drops every mark'); 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')); @@ -756,6 +770,25 @@ describe('Conversation Turn readers', () => { 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')); diff --git a/apps/desktop/src/main/__tests__/conversation-owner.test.ts b/apps/desktop/src/main/__tests__/conversation-owner.test.ts index c9271154527..69892c3280b 100644 --- a/apps/desktop/src/main/__tests__/conversation-owner.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -113,7 +113,7 @@ function harness(options: { showModelSetupToast() {}, onTurnCompleted() {}, searchTarget: null, clearSearchTarget() {}, })), - visible ? createElement(ConversationTranscriptRegion[0]>, { surface: Transcript }) : null, + visible ? createElement(ConversationTranscriptRegion[0]>, { surface: Transcript, localInteractionAvailable: true }) : null, createElement(ConversationComposerRegion[0]>, { surface: Composer, ...stubComposerGateInputs() }), ); } 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 ee5542b057f..cb2007ce113 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -887,7 +887,6 @@ function AppShellContent({ openHelp, openSettings, - clearPendingTurnActions: () => composerSubmission.clearPendingTurnActions(), refreshConnections: refreshConnectionProjections, refreshMemoryActive, refreshMessages, @@ -1528,9 +1527,7 @@ function AppShellContent({ : undefined } sessionHealthNotice={sessionHealthNotice} - sessionHealthModelPickerAvailable={ - activeBoundarySurface.localInteractionAvailable - } + localInteractionAvailable={activeBoundarySurface.localInteractionAvailable} workspaceReadinessRecovery={workspaceReadinessRecovery} showOnboardingHero={showOnboardingHero} onboardingState={onboardingState} diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 471bd104f72..4ef672c4451 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -122,7 +122,8 @@ displayed Session and, for interactions, the owner Session: 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 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 @@ -258,12 +259,15 @@ 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 shell keeps only the stable -`ComposerSubmissionCommands` handle, whose `beginEditUserMessage` and -`handleTurnFooterAction` serve the transcript and whose -`clearPendingTurnActions` serves Session teardown and Host changes. The first +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 context are private. +reader contexts are private. ## Composer editing intents (R2 M3) 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 48417724a56..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 @@ -252,11 +252,8 @@ export function useComposerSubmission(input: const shellCommands = useMemo(() => ({ beginEditUserMessage: (turnId) => revision.beginEditUserMessage(turnId), handleTurnFooterAction: (turnId, actionId) => turn.handleTurnFooterAction(turnId, actionId), - clearPendingTurnActions: (sessionId) => { - if (sessionId) turnActionRegistry.clearForSession(sessionId); - else turnActionRegistry.clearAll(); - }, - }), [revision, turn, turnActionRegistry.clearAll, turnActionRegistry.clearForSession]); + clearPendingTurnActions: (sessionId) => turnActionRegistry.clearForSession(sessionId), + }), [revision, turn, turnActionRegistry.clearForSession]); const reader = useMemo(() => ({ onSend, newTaskSendPending, @@ -266,19 +263,21 @@ export function useComposerSubmission(input: respondToUserQuestion: chat.respondToUserQuestion, respondToUserForm: chat.respondToUserForm, 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, - composerResumeAction: resume.composerResumeAction, safeResumeAction: resume.safeResumeAction, - }), [ - activeId, chat, newTaskSendPending, onSend, ownerSessionId, resume.composerResumeAction, resume.safeResumeAction, - revision, revisionDraft, sharedSessionActive, stop, turnActionRegistry.keys, - ]); + }), [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-turn-action-registry.ts b/apps/desktop/src/renderer/features/conversation/controller/use-turn-action-registry.ts index 08da88f5182..0b4af8394e9 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/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/model/composer-submission-contract.ts b/apps/desktop/src/renderer/features/conversation/model/composer-submission-contract.ts index b1ca25d41fe..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,8 +38,8 @@ export interface ComposerSurfaceOwner { export interface ComposerSubmissionCommands { beginEditUserMessage(turnId: string): void; handleTurnFooterAction(turnId: string, actionId: TurnFooterActionMeta['id']): Promise; - /** Drops pending Turn-footer marks for one retired Session, or all of them on a Host change. */ - clearPendingTurnActions(sessionId?: string): void; + /** Drops the pending Turn-footer marks of one retired Session. */ + clearPendingTurnActions(sessionId: string): void; } /** diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index 67d1fe9298f..43f1ab298e4 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -48,6 +48,7 @@ export { } 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: { 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 409c5e52cc6..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 @@ -30,7 +30,7 @@ import type { ComposerSubmissionShell, ComposerSurfaceOwner, } from '../model/composer-submission-contract.js'; -import { ComposerSubmissionContext } from './composer-submission-context.js'; +import { ComposerSubmissionContext, ComposerTurnContext } from './composer-submission-context.js'; /** * Sole owner of the Composer's submission: the send-pending flag, the @@ -65,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-readers.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx index 168110e8c6c..bc878e14c99 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -25,7 +25,9 @@ import { useSessionUiRead } from '../controller/use-session-ui-read.js'; import { transcriptRestoreTarget } from '../controller/transcript-reading-position.js'; import { useConversationOwner } from './conversation-context.js'; import { useConversationQueueCommands } from './conversation-provider.js'; -import { useComposerSubmissionReader, type ComposerSubmissionReader } from './composer-submission-context.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'; @@ -49,31 +51,35 @@ type TranscriptProps = Pick & { 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 shell's model-picker gate for the health notice; the running Turn narrows it here. */ -type TranscriptHealthGate = { sessionHealthModelPickerAvailable?: 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(); - const submission = useComposerSubmissionReader(); + // 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: !submission.sharedSessionActive, - activeId: submission.activeId, - pendingTurnActions: submission.pendingTurnActions, + allowBranch: !turnReader.sharedSessionActive, + activeId: turnReader.activeId, + pendingTurnActions: turnReader.pendingTurnActions, uiLocale: useUiLocale(), }); - const turn = useDisplayedTurn(workspace.ui.reads, submission.activeId); + 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 healthGate = presentation as TranscriptHealthGate; const retryPending = useSessionUiRead(workspace.ui.reads, 'retry', view.sessionId); const sessionId = view.sessionId; - const owned: TranscriptProps & TranscriptHealthGate = { + const owned: TranscriptProps = { activeSessionId: sessionId, activeTurn: chatTurnActivity(turn.execution), onStreamingSettled: sessionId ? (messageId) => commands.settleAssistantStreaming(sessionId, messageId) : undefined, @@ -92,11 +98,10 @@ export function ConversationTranscriptRegion

( restoreTargetTurn: transcriptRestoreTarget(sessionId ? workspace.ui.transcriptReadingAnchorBySessionRef.current[sessionId] : undefined, load.unavailableTranscriptRestore), onReadingAnchorChange: sessionId ? (turnId) => readingCommands.current?.captureAnchor(turnId) : undefined, deriveTurnPresentation, - safeResumeAction: submission.safeResumeAction, - // The notice's picker is held while a Turn runs, as the Composer's is. - ...(healthGate.sessionHealthModelPickerAvailable !== undefined - ? { sessionHealthModelPickerAvailable: healthGate.sessionHealthModelPickerAvailable && !turn.turnActive } - : {}), + 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); } @@ -147,13 +152,14 @@ export function ConversationComposerRegion

( const { surface, usageModel, usageRoute, sessionState, executorComposer, ...presentation } = props; const { workspace } = useConversationOwner(); const submission = useComposerSubmissionReader(); + 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', submission.ownerSessionId); + 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), @@ -232,9 +238,9 @@ export function ConversationActivityConsumer

({ turnRunning, awaitingInteraction }), [turnRunning, awaitingInteraction]);