From 7524313ab3ff7b47844a5a4e4dd1750d7341fd0a Mon Sep 17 00:00:00 2001 From: chihumyum Date: Wed, 30 Sep 2026 14:28:57 +0800 Subject: [PATCH 1/2] refactor(desktop): own Conversation publication and observation Move transcript publication, observation resources and actual readers below the Conversation owner. Keep AppShell on explicit target, chrome and semantic command capabilities while preserving the persistent Composer. Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 181 +++------ ...tive-execution-boundary-read-model.test.ts | 2 +- .../app-shell-chat-actions-fixture.ts | 4 +- .../app-shell-first-send-cleanup.test.ts | 74 ++-- .../app-shell-form-interaction.test.ts | 9 +- .../app-shell-revision-actions.test.ts | 4 +- .../app-shell-revision-resend.test.ts | 2 +- .../app-shell-session-ui-state.test.ts | 2 +- .../main/__tests__/conversation-owner.test.ts | 192 ++++++++++ .../__tests__/guest-turn-requests.test.ts | 2 +- .../__tests__/message-queue-ui-state.test.ts | 2 +- .../session-change-retirement.test.ts | 5 +- .../session-reference-composer.test.ts | 4 + .../main/__tests__/session-ui-reads.test.ts | 18 +- .../session-workspace-action-identity.test.ts | 27 +- .../main/__tests__/streaming-handoff.test.ts | 2 +- ...script-reading-position-controller.test.ts | 2 +- .../transcript-send-viewport.test.ts | 2 +- .../src/renderer/app-shell-chat-actions.ts | 102 +---- .../src/renderer/app-shell-command-actions.ts | 18 +- .../desktop/src/renderer/app-shell-effects.ts | 347 +----------------- .../renderer/app-shell-revision-actions.ts | 8 +- apps/desktop/src/renderer/app-shell.tsx | 311 +++------------- .../contracts/bootstrap-selection-lease.ts | 60 +++ .../contracts/model-connection-errors.ts | 85 +++++ .../session-catalog/session-change-effects.ts | 17 +- .../contracts/shell-run-update-state.ts | 92 +++++ .../application/contracts/transcript-copy.ts | 30 ++ .../contracts/transcript-refresh.ts | 21 ++ .../contracts/use-stable-actions.ts | 70 ++++ .../src/renderer/bootstrap-selection-lease.ts | 42 +-- .../src/renderer/conversation-markdown.ts | 2 +- .../renderer/features/conversation/README.md | 105 ++++-- .../use-app-shell-session-ui-state.ts | 187 ---------- .../controller/use-conversation-controller.ts | 66 ++++ .../use-conversation-observation.ts | 193 ++++++++++ .../controller/use-conversation-recovery.ts | 173 +++++++++ .../controller/use-conversation-target.ts | 67 ++++ .../renderer/features/conversation/index.ts | 33 +- .../model/conversation-catalog-row.ts | 58 +++ .../model/conversation-workspace.ts | 153 ++++++++ .../conversation/model/session-events.ts} | 12 +- .../model}/session-workspace-actions.ts | 9 +- .../conversation/model/transcript-commands.ts | 61 +++ .../renderer/features/conversation/ports.ts | 1 + .../renderer/features/conversation/testing.ts | 15 +- .../features/conversation/transcript-ports.ts | 59 +++ .../ui/composer-mentions-provider.tsx | 2 +- .../conversation/ui/conversation-context.ts | 27 ++ .../ui/conversation-lifecycle.tsx | 154 ++++++++ .../conversation/ui/conversation-provider.tsx | 62 ++++ .../conversation/ui/conversation-readers.tsx | 92 +++++ .../src/renderer/model-connection-errors.ts | 67 +--- ...reate-conversation-observation-services.ts | 50 +++ .../desktop/create-conversation-services.ts | 5 +- .../src/renderer/shell-run-update-state.ts | 74 +--- .../use-app-shell-session-workspace.ts | 97 +---- .../src/renderer/use-stable-actions.ts | 52 +-- .../stories/composer-slash-menu.stories.tsx | 2 + docs/astryx-surface-file-inventory.md | 5 +- docs/astryx-surface-file-inventory.paths | 3 + scripts/check-app-shell-hooks.mjs | 9 +- 62 files changed, 2085 insertions(+), 1547 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/conversation-owner.test.ts create mode 100644 apps/desktop/src/renderer/application/contracts/bootstrap-selection-lease.ts create mode 100644 apps/desktop/src/renderer/application/contracts/model-connection-errors.ts create mode 100644 apps/desktop/src/renderer/application/contracts/shell-run-update-state.ts create mode 100644 apps/desktop/src/renderer/application/contracts/transcript-copy.ts create mode 100644 apps/desktop/src/renderer/application/contracts/transcript-refresh.ts create mode 100644 apps/desktop/src/renderer/application/contracts/use-stable-actions.ts delete mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts create mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-conversation-controller.ts create mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts create mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-conversation-recovery.ts create mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts create mode 100644 apps/desktop/src/renderer/features/conversation/model/conversation-catalog-row.ts create mode 100644 apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts rename apps/desktop/src/renderer/{app-shell-session-events.ts => features/conversation/model/session-events.ts} (96%) rename apps/desktop/src/renderer/{ => features/conversation/model}/session-workspace-actions.ts (95%) create mode 100644 apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts create mode 100644 apps/desktop/src/renderer/features/conversation/transcript-ports.ts create mode 100644 apps/desktop/src/renderer/features/conversation/ui/conversation-context.ts create mode 100644 apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx create mode 100644 apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx create mode 100644 apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-conversation-observation-services.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 07ad945c7e..1e121af1aa 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -15,7 +15,6 @@ "src/renderer/app-shell-overlays.tsx", "src/renderer/app-shell-project-actions.ts", "src/renderer/app-shell-revision-actions.ts", - "src/renderer/app-shell-session-events.ts", "src/renderer/app-shell-stop-action.ts", "src/renderer/app-shell-turn-actions.ts", "src/renderer/app-shell.tsx", @@ -93,7 +92,6 @@ "src/renderer/scroll-motion-policy.ts", "src/renderer/session-copy-attempt.ts", "src/renderer/session-read-state.ts", - "src/renderer/session-workspace-actions.ts", "src/renderer/session-workspace-errors.ts", "src/renderer/settings/about-settings-page.tsx", "src/renderer/settings/about-update-status.ts", @@ -230,6 +228,20 @@ "ownerSymbol": "AppUpdateProvider", "count": 1 }, + { + "implementation": "src/renderer/features/conversation/controller/use-conversation-controller.ts", + "symbol": "useConversationController", + "owner": "src/renderer/features/conversation/ui/conversation-provider.tsx", + "ownerSymbol": "ConversationProvider", + "count": 1 + }, + { + "implementation": "src/renderer/features/conversation/controller/use-conversation-observation.ts", + "symbol": "useConversationObservation", + "owner": "src/renderer/features/conversation/ui/conversation-lifecycle.tsx", + "ownerSymbol": "ConversationLifecycle", + "count": 1 + }, { "implementation": "src/renderer/features/conversation/controller/use-plan-mode-state.ts", "symbol": "usePlanModeState", @@ -295,13 +307,20 @@ } ], "featurePrivateModules": [ + "src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx", + "src/renderer/features/conversation/controller/use-conversation-observation.ts", + "src/renderer/features/conversation/model/conversation-workspace.ts", + "src/renderer/features/conversation/model/session-events.ts", "src/renderer/features/conversation/model/session-ui-reads.ts", - "src/renderer/features/conversation/model/session-ui-state.ts" + "src/renderer/features/conversation/model/session-ui-state.ts", + "src/renderer/features/conversation/model/session-workspace-actions.ts", + "src/renderer/features/conversation/model/transcript-commands.ts", + "src/renderer/features/conversation/ui/conversation-context.ts" ], "legacyAppShell": { "files": { "src/renderer/app-shell-chat-actions.ts": { - "importDeclarations": 7, + "importDeclarations": 5, "bridgePaths": { "window.maka.newTasks.create": 1, "window.maka.sessions.remove": 1, @@ -317,18 +336,16 @@ "createAppShellChatActions" ], "dependencyPaths": { - "./app-shell-copy.js": 1, "./attachment-preflight.js": 1, "./features/conversation/index.js": 1, "./locales/shell-copy.js": 1, "./model-connection-errors.js": 1, "./session-workspace-errors.js": 1, "./skill-invocation-feedback.js": 1, - "@maka/core/session-name": 1, - "@maka/ui": 1 + "@maka/core/session-name": 1 }, - "importSpecifiers": 10, - "nonTriviaTokens": 3525 + "importSpecifiers": 8, + "nonTriviaTokens": 2928 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 1, @@ -379,7 +396,7 @@ "react": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 2189 + "nonTriviaTokens": 2188 }, "src/renderer/app-shell-context-compaction.ts": { "importDeclarations": 0, @@ -445,57 +462,39 @@ "nonTriviaTokens": 623 }, "src/renderer/app-shell-effects.ts": { - "importDeclarations": 9, + "importDeclarations": 5, "bridgePaths": { "window.maka.app.info": 1, "window.maka.appWindow.subscribeCommand": 1, "window.maka.connections.subscribeEvents": 1, "window.maka.runtimeHostProfiles.subscribeChanges": 1, "window.maka.sessions.subscribeChanges": 1, - "window.maka.sessions.subscribeEvents": 1, "window.maka.settings.subscribeClientChanged": 1, - "window.maka.settings.subscribeExternalChanged": 1, - "window.maka.shellRuns.list": 1, - "window.maka.shellRuns.subscribeResync": 1, - "window.maka.shellRuns.subscribeUpdates": 1, - "window.maka.transcripts.open": 1 + "window.maka.settings.subscribeExternalChanged": 1 }, "environmentCapabilities": { "cancelAnimationFrame": 1, - "document.addEventListener": 1, "document.documentElement.setAttribute": 1, - "document.removeEventListener": 1, - "document.visibilityState": 1, - "requestAnimationFrame": 1, - "window.clearInterval": 1, - "window.clearTimeout": 3, - "window.setInterval": 1, - "window.setTimeout": 2 + "requestAnimationFrame": 1 }, "hookCalls": { - "useEffect": 9, - "useHotkeys": 1, - "useLayoutEffect": 1 + "useEffect": 7, + "useHotkeys": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./app-shell-copy": 1, "./application/contracts/conversation-copy.js": 1, "./application/contracts/session-catalog/session-change-effects.js": 1, - "./application/contracts/session-catalog/session-event-health.js": 1, "./browser-storage": 1, - "./platform/desktop/desktop-transcript-range-store.js": 1, - "./shell-run-update-state.js": 1, "./theme": 1, "./titlebar-modal-sync": 1, "@astryxdesign/core/hooks": 1, - "@maka/core/session-event-health": 1, "react": 1 }, - "importSpecifiers": 14, - "nonTriviaTokens": 3494 + "importSpecifiers": 7, + "nonTriviaTokens": 1358 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -578,30 +577,7 @@ "@maka/core/session": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2020 - }, - "src/renderer/app-shell-session-events.ts": { - "importDeclarations": 2, - "bridgePaths": {}, - "environmentCapabilities": { - "window.setTimeout": 1 - }, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [ - "createAppShellSessionDisplayBatch", - "createAppShellSessionEventHandlers" - ], - "dependencyPaths": { - "./application/contracts/conversation-copy.js": 1, - "./application/contracts/message-queue-projection.js": 1, - "./features/conversation/index.js": 1, - "./model-connection-errors.js": 1, - "@maka/ui": 1 - }, - "importSpecifiers": 7, - "nonTriviaTokens": 2557 + "nonTriviaTokens": 2019 }, "src/renderer/app-shell-stop-action.ts": { "importDeclarations": 0, @@ -644,7 +620,7 @@ "nonTriviaTokens": 571 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 54, + "importDeclarations": 53, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -653,20 +629,15 @@ "window.maka.diagnostics.takePreviousMainProcessInterruption": 1, "window.maka.onboarding.setMilestone": 1, "window.maka.sessions.compact": 1, - "window.maka.sessions.listActiveInteractions": 1, "window.maka.sessions.listTurnLandmarks": 1, - "window.maka.sessions.subscribeActiveInteractions": 1, "window.maka.settings.getClient": 1, "window.maka.settings.subscribeClientChanged": 1 }, "environmentCapabilities": { - "window.clearTimeout": 1, - "window.requestAnimationFrame": 3, - "window.setTimeout": 1 + "window.requestAnimationFrame": 3 }, "hookCalls": { "useActiveExecutionBoundary": 1, - "useActiveSessionEvents": 1, "useAppShellBootstrapSubscriptions": 1, "useAppShellHostEffects": 1, "useAppShellNavRefSync": 1, @@ -676,12 +647,11 @@ "useAppShellSessionWorkspace": 1, "useAppShellTurnPresentation": 1, "useComposerAttachments": 1, - "useEffect": 6, + "useEffect": 3, "useLayoutEffect": 1, "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, - "useRef": 12, - "useSessionEventHealthPolling": 1, + "useRef": 8, "useSessionNavigationReads": 1, "useSessionSettingIntent": 1, "useShellAppearance": 1, @@ -690,9 +660,8 @@ "useShellLiveTurn": 1, "useShellMemoryPill": 1, "useShellResume": 1, - "useShellRunUpdates": 1, - "useStableActions": 5, - "useState": 13, + "useStableActions": 4, + "useState": 10, "useSystemUiLocale": 1, "useTaskSubmissionReadiness": 1, "useToast": 1, @@ -711,7 +680,6 @@ "./app-shell-effects": 1, "./app-shell-overlays": 1, "./app-shell-revision-actions": 1, - "./app-shell-session-events": 1, "./app-shell-stop-action": 1, "./app-shell-turn-actions": 1, "./application/contracts/conversation-copy": 1, @@ -776,8 +744,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 87, - "nonTriviaTokens": 11012 + "importSpecifiers": 80, + "nonTriviaTokens": 9769 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -823,31 +791,24 @@ "nonTriviaTokens": 9 }, "src/renderer/use-app-shell-session-workspace.ts": { - "importDeclarations": 4, + "importDeclarations": 1, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { "useAppShellSessionList": 1, "useAppShellSessionUiState": 1, - "useExternalStoreSelector": 1, - "useRef": 4, "useSessionCatalogController": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./application/contracts/new-task-reload-intent.js": 1, "./application/contracts/session-catalog/session-catalog-state.js": 1, - "./application/contracts/session-catalog/use-external-store-selector.js": 1, - "./bootstrap-selection-lease.js": 1, "./features/conversation/index.js": 1, - "./session-workspace-actions.js": 1, - "./use-app-shell-session-list.js": 1, - "react": 1 + "./use-app-shell-session-list.js": 1 }, - "importSpecifiers": 4, - "nonTriviaTokens": 459 + "importSpecifiers": 1, + "nonTriviaTokens": 81 } }, "closure": { @@ -959,15 +920,6 @@ "@maka/core/attachments": 1 } }, - "src/renderer/bootstrap-selection-lease.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/browser-storage.ts": { "bridgePaths": {}, "environmentCapabilities": { @@ -1546,10 +1498,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./application/contracts/connection-error-cleaner.js": 1, - "./application/contracts/conversation-copy.js": 1, - "./application/contracts/session-error-presentation.js": 1, - "./locales/shell-copy.js": 1 + "./application/contracts/model-connection-errors.js": 1 } }, "src/renderer/nav-selection.ts": { @@ -1709,20 +1658,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/session-workspace-actions.ts": { - "bridgePaths": { - "window.maka.sessions.queryCancelledMessages": 1 - }, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./application/contracts/new-task-reload-intent.js": 1, - "./application/contracts/transient-message-projection.js": 1 - } - }, "src/renderer/session-workspace-errors.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -3401,17 +3336,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/shell-run-update-state.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "@maka/core/shell-run-result": 1 - } - }, "src/renderer/side-chat-command.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -3627,16 +3551,12 @@ "src/renderer/use-stable-actions.ts": { "bridgePaths": {}, "environmentCapabilities": {}, - "hookCalls": { - "useLayoutEffect": 1, - "useRef": 1, - "useState": 1 - }, + "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "react": 1 + "./application/contracts/use-stable-actions.js": 1 } }, "src/renderer/use-system-ui-locale.ts": { @@ -3975,7 +3895,6 @@ "capability": "conversation-runtime-and-presentation", "targetZone": "features/conversation", "legacyPaths": [ - "src/renderer/app-shell-session-events.ts", "src/renderer/use-app-shell-session-ui-reads.ts" ] }, diff --git a/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts b/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts index 0ebde96ffb..b82e236691 100644 --- a/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts +++ b/apps/desktop/src/main/__tests__/active-execution-boundary-read-model.test.ts @@ -28,7 +28,7 @@ import type { SessionEvent } from '@maka/core/events'; import { parseHTML } from 'linkedom'; import { act, createElement } from 'react'; import { createRoot } from 'react-dom/client'; -import { createAppShellSessionEventHandlers } from '../../renderer/app-shell-session-events.js'; +import { createAppShellSessionEventHandlers } from '../../renderer/features/conversation/testing.js'; import { ConversationServicesProvider, type ConversationHostChange, diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts index f505d10dea..538a7b5379 100644 --- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -93,13 +93,13 @@ export function createActionsDeps() { activeIdRef.current = session.id; }, retireSession: (_sessionId: string) => undefined, - setMessageLoadErrorBySession: () => undefined, + clearMessageLoadError: () => undefined, addTransientMessage: () => undefined, updateTransientMessage: () => undefined, removeTransientMessage: () => undefined, transcriptRangeRef: { current: undefined }, isMessagePublished: (_message: unknown) => false, - setInteractionBySession: () => undefined, + settleInteraction: () => undefined, respondToUserForm: async () => undefined, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 0b287bcc6f..23f6ae815c 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -40,14 +40,13 @@ import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { ConversationServicesProvider, - useAppShellSessionUiState, } from '../../renderer/features/conversation/index.js'; import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { LocaleProvider, ToastProvider, type LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -import { prepareTranscriptForSend, stubConversationServices } from '../../renderer/features/conversation/testing.js'; +import { prepareTranscriptForSend, stubConversationServices, createConversationWorkspace, createTranscriptCommands } from '../../renderer/features/conversation/testing.js'; import { createActionsDeps, @@ -675,83 +674,52 @@ describe('composer first-send cleanup', () => { }); it('refresh waits for durable messages without bypassing range publication', async () => { - const deps = createActionsDeps(); - deps.activeIdRef.current = 'session'; + const catalog = createSessionCatalogController(); + const workspace = createConversationWorkspace(catalog, stubConversationServices().observation); + workspace.commands.setActiveId('session'); let durable = false; - const durableAnswer = { id: 'answer' }; - let publishedAnswer = { id: 'answer' }; + const answer = { type: 'assistant', id: 'answer', text: 'done', ts: 1 } as StoredMessage; const ready = deferred(); const controller = { ready: () => ready.promise, waitForDurableMessage: async () => { durable = true; return true; }, store: { - snapshot: () => ({ sessionId: 'session', messages: [durableAnswer] }), + range: () => ({ sessionId: 'session', ready: true, hasOlder: false }), + snapshot: () => ({ sessionId: 'session', messages: [answer] }), hasDurableMessage: () => durable, }, } as unknown as DesktopTranscriptRangeController; - const dependencies = { - ...deps, - transcriptRangeRef: { current: controller }, - isMessagePublished: (message: unknown) => message === publishedAnswer, - }; - const actions = createAppShellChatActions(dependencies); + workspace.transcriptRangeRef.current = controller; + const actions = createTranscriptCommands(workspace, { current: { locale: 'en', toast: { error: () => '' } } }); const refresh = actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }); assert.equal(durable, false); ready.resolve(); assert.equal(await refresh, false, 'durability cannot retire the live answer before publication'); - publishedAnswer = durableAnswer; + workspace.commitTranscript('session', [answer], controller); assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), true); }); it('an in-flight refresh reads publication that commits after the call began', async () => { - const deps = createActionsDeps(); - deps.activeIdRef.current = 'session'; + const catalog = createSessionCatalogController(); + const workspace = createConversationWorkspace(catalog, stubConversationServices().observation); + workspace.commands.setActiveId('session'); const answer = { type: 'assistant', id: 'answer', text: 'done', ts: 1 } as StoredMessage; const ready = deferred(); const controller = { ready: () => ready.promise, store: { + range: () => ({ sessionId: 'session', ready: true, hasOlder: false }), snapshot: () => ({ sessionId: 'session', messages: [answer] }), hasDurableMessage: () => true, }, } as unknown as DesktopTranscriptRangeController; - const { root } = installReactRenderer(); - let publication!: ReturnType['publication']; - const catalog = createSessionCatalogController(); - const services = stubConversationServices(); - function Probe(): null { - publication = useAppShellSessionUiState( - catalog, undefined, deps.activeIdRef, - (_sessionId, _messages, _controller: DesktopTranscriptRangeController) => true, - ).publication; - return null; - } - try { - act(() => root.render( - createElement(LocaleProvider, { - locale: 'en', - children: createElement(ToastProvider, { - children: createElement(ConversationServicesProvider, { - services, children: createElement(Probe), - }), - }), - }), - )); - const actions = createAppShellChatActions({ - ...deps, transcriptRangeRef: { current: controller }, - isMessagePublished: publication.isMessagePublished, - }); - const refresh = actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }); - act(() => { - publication.messagesRef.current = [answer]; - publication.setMessagesState([answer]); - }); - ready.resolve(); - assert.equal(await refresh, true, 'the original invocation must see the new publication'); - assert.equal(publication.isMessagePublished({ ...answer }), false, 'same id is not the published version'); - } finally { - cleanupFakeDom(); - } + workspace.transcriptRangeRef.current = controller; + const actions = createTranscriptCommands(workspace, { current: { locale: 'en', toast: { error: () => '' } } }); + const refresh = actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }); + workspace.commitTranscript('session', [answer], controller); + ready.resolve(); + assert.equal(await refresh, true, 'the original invocation must see the new publication'); + assert.equal(workspace.isMessagePublished({ ...answer }), false, 'same id is not the published version'); }); }); diff --git a/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts b/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts index 2c25b9fab6..5009e34445 100644 --- a/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-form-interaction.test.ts @@ -20,6 +20,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { InteractionQueues } from '@maka/ui'; +import { dequeueInteractionByRequestId } from '@maka/ui'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; import { createActionsDeps } from './app-shell-chat-actions-fixture.js'; @@ -50,8 +51,8 @@ describe('AppShell form interaction response', () => { respondToUserForm: async (sessionId, response) => { submitted = { sessionId, response }; }, - setInteractionBySession: (update) => { - interactions = update(interactions); + settleInteraction: (sessionId, requestId) => { + interactions = dequeueInteractionByRequestId(interactions, sessionId, requestId); }, }); @@ -72,8 +73,8 @@ describe('AppShell form interaction response', () => { respondToUserForm: async () => { throw new Error('Host unavailable'); }, - setInteractionBySession: (update) => { - interactions = update(interactions); + settleInteraction: (sessionId, requestId) => { + interactions = dequeueInteractionByRequestId(interactions, sessionId, requestId); }, toastApi: { error: () => { errors += 1; }, info: () => undefined }, }); diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts index 1a6eb60cbf..1082fadeec 100644 --- a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts @@ -69,7 +69,7 @@ function createActions(input: { messages: StoredMessage[]; failRefresh?: boolean clearDraft: () => {}, } as never, }, - messages: input.messages, + readMessages: () => input.messages, hasPendingAttachments: () => false, openSessionInChat: (sessionId: string) => { selectionRevision += 1; @@ -261,7 +261,7 @@ describe('revision draft lifecycle over a prepared send', () => { }, }, }, - messages: [userMessage('turn-1', 'original message')], + readMessages: () => [userMessage('turn-1', 'original message')], hasPendingAttachments: () => false, openSessionInChat: (sessionId: string) => { selectionRevision += 1; diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts index 042a7b7059..6ea6916e2c 100644 --- a/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts @@ -211,7 +211,7 @@ async function mountRevisionWorld(): Promise { return () => selectionRevision === revision; }, composerRef: composer, - messages: [userMessage('turn-1', ORIGINAL_TEXT)], + readMessages: () => [userMessage('turn-1', ORIGINAL_TEXT)], hasPendingAttachments: () => false, openSessionInChat: (sessionId: string) => { selectionRevision += 1; diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 314e8cd1f7..b4bbe75939 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -25,7 +25,7 @@ import type { SessionSummary } from '@maka/core/session'; import { armLiveTurn, applyLiveTurnBufferEvent, reconcileLiveTurnBuffer } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; import { act, createElement } from 'react'; -import { LiveTurnReconciler } from '../../renderer/features/conversation/index.js'; +import { LiveTurnReconciler } from '../../renderer/features/conversation/testing.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { normalizeSessionSummaryForDisplay } from '../../renderer/application/contracts/session-status-presentation.js'; import { diff --git a/apps/desktop/src/main/__tests__/conversation-owner.test.ts b/apps/desktop/src/main/__tests__/conversation-owner.test.ts new file mode 100644 index 0000000000..3ec2a53224 --- /dev/null +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -0,0 +1,192 @@ +/* + * 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 { afterEach, describe, it } from 'node:test'; +import { act, createElement, Fragment, useEffect, useState } from 'react'; +import { LocaleProvider, ToastProvider, type TransientUserMessageProjection } from '@maka/ui'; +import type { StoredMessage } from '@maka/core/session'; +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 } from '../../renderer/features/conversation/index.js'; +import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +const row = (id: string): DesktopSessionSummary => ({ + id, name: id, isFlagged: false, isArchived: false, labels: [], hasUnread: false, + status: 'active', backend: 'ai-sdk', revision: 1, runtimeHostId: 'local', + profileId: 'local', profileName: 'Local', profileKind: 'local', + llmConnectionSlug: 'test', connectionLocked: false, model: 'test', permissionMode: 'ask', +}); +const message = (id: string): StoredMessage => ({ type: 'user', id, text: id, turnId: id, ts: 1 }); + +function harness() { + const { root } = installReactRenderer(); + const catalog = createSessionCatalogController(); + catalog.commitSessions(['A', 'B', 'C'].map(row)); + const opened: Array<{ + sessionId: string; closed: boolean; listeners: Set<() => void>; + publish(messages: StoredMessage[]): void; error(error: unknown): void; + }> = []; + const observations: Array<{ sessionId: string; closed: boolean; phase: Parameters[2]; fail: () => void }> = []; + const services = stubConversationServices(); + services.observation.openTranscript = (sessionId, error) => { + let messages: StoredMessage[] = []; + let ready = false; + const listeners = new Set<() => void>(); + const resource = { + sessionId, closed: false, listeners, error, + publish(next: StoredMessage[]) { messages = next; ready = true; for (const listener of listeners) listener(); }, + }; + opened.push(resource); + return { + store: { + range: () => ({ sessionId, hasOlder: false, ready, generation: 'range' }), + snapshot: () => { + if (!ready) throw new Error('Desktop transcript range is not initialized'); + return { sessionId, messages, ready }; + }, + subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, + hasDurableMessage: (id) => messages.some((message) => message.id === id), + }, + ready: async () => {}, waitForDurableMessage: async () => true, + reload: async () => {}, loadEarlier: async () => {}, observationChanged: () => {}, + close: async () => { resource.closed = true; }, + }; + }; + services.observation.subscribeEvents = (sessionId, _event, phase, fail) => { + const observation = { sessionId, closed: false, phase, fail }; + observations.push(observation); + // Host seeding may finish before the transcript read; both orders are valid. + phase('ready'); + return () => { observation.closed = true; }; + }; + let owner!: ReturnType; + let target!: ReturnType; + let transcript: { activeSessionId: string | undefined; messages: StoredMessage[]; liveContentSeedGeneration: number } | undefined; + let shellRenders = 0; + let transcriptRenders = 0; + let composerRenders = 0; + let composerMounts = 0; + let composerUnmounts = 0; + let setVisible!: (visible: boolean) => void; + function Transcript(props: NonNullable) { transcript = props; transcriptRenders += 1; return null; } + function Composer(_props: { processing: boolean; pendingMessages?: readonly TransientUserMessageProjection[]; latestRequestUsageTokens?: number }) { + composerRenders += 1; + useEffect(() => { composerMounts += 1; return () => { composerUnmounts += 1; }; }, []); + return null; + } + function Shell() { + shellRenders += 1; + target = useAppShellSessionUiState(); + owner = useConversationOwner(); + const [visible, updateVisible] = useState(true); + setVisible = updateVisible; + return createElement(Fragment, null, + createElement(ConversationLifecycle, { + refreshSessions: async () => [], onExecutionBoundaryChanged() {}, + onContextCompactionOutcome() {}, showModelSetupToast() {}, onTurnCompleted() {}, + searchTarget: null, clearSearchTarget() {}, listTurnLandmarks: async () => ({ landmarks: [] }), + }), + visible ? createElement(ConversationTranscriptRegion[0]>, { surface: Transcript }) : null, + createElement(ConversationComposerRegion[0]>, { surface: Composer }), + ); + } + act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(ToastProvider, { children: + createElement(SessionCatalogContext.Provider, { value: catalog, children: + createElement(ConversationServicesProvider, { services, children: + createElement(ConversationProvider, { children: createElement(Shell) }), + }), + }), + }), + }))); + return { + root, catalog, opened, observations, + get owner() { return owner; }, get target() { return target; }, get transcript() { return transcript; }, + get counts() { return { shellRenders, transcriptRenders, composerRenders, composerMounts, composerUnmounts }; }, + showTranscript(visible: boolean) { setVisible(visible); }, + }; +} + +describe('Conversation ownership', () => { + afterEach(cleanupFakeDom); + 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')); + await act(async () => h.opened[0]!.publish([message('one')])); + assert.equal(h.transcript?.activeSessionId, 'A'); + assert.ok(h.transcript!.liveContentSeedGeneration > 0, 'seed survives publication arriving after event seeding'); + const before = h.counts; + await act(async () => h.opened[0]!.publish([message('one'), message('two')])); + assert.equal(h.counts.shellRenders, before.shellRenders, 'durable content does not revisit Shell'); + assert.equal(h.counts.composerRenders, before.composerRenders, 'ordinary content does not revisit Composer'); + assert.ok(h.counts.transcriptRenders > before.transcriptRenders); + assert.deepEqual(h.transcript?.messages.map((row) => row.id), ['one', 'two']); + await act(async () => h.showTranscript(false)); + await act(async () => h.opened[0]!.publish([message('three')])); + await act(async () => h.showTranscript(true)); + assert.equal(h.counts.composerMounts, 1); + assert.equal(h.counts.composerUnmounts, 0); + assert.equal(h.opened.length, 1, 'conditional readers do not own observation lifetime'); + assert.equal(h.transcript?.messages[0]?.id, 'three'); + assert.equal('commitTranscript' in h.target, false); + assert.equal('sessionUiController' in h.target, false); + assert.throws(() => { (h.target.activeIdRef as { current: string }).current = 'B'; }); + }); + + it('closes superseded readers and rejects late publication, read errors and seed completion', async () => { + const h = harness(); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.opened[0]!.publish([message('a')])); + await act(async () => h.target.setActiveId('B')); + assert.equal(h.target.activeId, 'A', 'requested B does not relabel the published A'); + assert.equal(h.target.switchingSession, true); + await act(async () => h.target.setActiveId('C')); + await act(async () => { + h.opened[1]!.publish([message('stale-b')]); + h.opened[1]!.error(new Error('late B read')); + h.observations[1]!.phase('ready'); + h.opened[2]!.publish([message('c')]); + }); + assert.equal(h.target.activeId, 'C'); + assert.equal(h.transcript?.messages[0]?.id, 'c'); + assert.equal(h.owner.workspace.ui.reads.load('C').getSnapshot().messageLoadError, undefined); + assert.ok(h.opened.slice(0, 2).every((item) => item.closed && item.listeners.size === 0)); + await act(async () => h.root.unmount()); + assert.ok(h.opened.every((item) => item.closed && item.listeners.size === 0)); + assert.ok(h.observations.every((item) => item.closed)); + }); + + it('retries observation on the existing transcript controller and cancels scheduled retries on disposal', async () => { + const h = harness(); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.opened[0]!.publish([message('a')])); + const firstGeneration = h.transcript!.liveContentSeedGeneration; + await act(async () => { h.observations[0]!.fail(); await new Promise((resolve) => setTimeout(resolve, 120)); }); + assert.equal(h.opened.length, 1); + assert.equal(h.observations.length, 2); + assert.ok(h.observations[0]!.closed); + assert.ok(h.transcript!.liveContentSeedGeneration > firstGeneration); + await act(async () => { h.observations[1]!.fail(); h.root.unmount(); }); + await new Promise((resolve) => setTimeout(resolve, 120)); + assert.equal(h.observations.length, 2); + assert.ok(h.opened[0]!.closed); + }); +}); 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 3538956349..27f9a6f927 100644 --- a/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts +++ b/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts @@ -223,7 +223,7 @@ async function mountShell(services: SessionCollaborationServices) { // app-shell.tsx is not in the node test build. test('AppShell mounts the one ChatComposerRegion inside GuestTurnRequests with no owner/Guest remount', () => { const source = readFileSync(new URL('../../../src/renderer/app-shell.tsx', import.meta.url), 'utf8'); - const regions = [...source.matchAll(/'); diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index d3db5be8e9..ac6b46181b 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -32,7 +32,7 @@ import type { RestoredDraftContent } from '../../renderer/application/contracts/ import type { DesktopLocalMessage } from '../../shared/session-local-contract.js'; import { mergeTransientMessageProjection } from '../../renderer/application/contracts/transient-message-projection.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; -import { createAppShellSessionEventHandlers } from '../../renderer/app-shell-session-events.js'; +import { createAppShellSessionEventHandlers } from '../../renderer/features/conversation/testing.js'; import { createAppShellSessionUiStateController } from '../../renderer/features/conversation/testing.js'; afterEach(cleanupFakeDom); diff --git a/apps/desktop/src/main/__tests__/session-change-retirement.test.ts b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts index 09c8ca348a..7df36b95ba 100644 --- a/apps/desktop/src/main/__tests__/session-change-retirement.test.ts +++ b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts @@ -29,7 +29,7 @@ import { selectWatchedCatalogRows, type DesktopSessionSummary, } from '../../renderer/application/contracts/session-catalog/catalog-row-watch.js'; -import { createSessionWorkspaceActions } from '../../renderer/session-workspace-actions.js'; +import { createSessionWorkspaceActions } from '../../renderer/features/conversation/testing.js'; import { createSessionPatchDrain } from '../../renderer/platform/desktop/session-catalog-sync.js'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; @@ -57,6 +57,7 @@ function harness( const requestedRef = { current: activeId }; const retired: string[] = []; const workspace = createSessionWorkspaceActions({ + queryCancelledMessages: async () => ({ cancelledMessageIds: [] }), activeIdRef, readRequestedSessionId: () => requestedRef.current, isReadableSession: () => true, @@ -96,7 +97,7 @@ function harness( if (!(id in source)) throw new Error('read failed'); return source[id]; } } }).request, - setSessionEventHealthBySession: () => {}, + recordSessionChange: () => {}, notifyModelRebound: () => {}, toastApi: { error: () => {}, diff --git a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts index 2738fc0e0a..4f0a19e3c7 100644 --- a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts +++ b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts @@ -17,6 +17,7 @@ * under the License. */ +import { stubConversationServices } from '../../renderer/features/conversation/testing.js'; import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement } from 'react'; @@ -110,6 +111,7 @@ test('Session reference picker keeps same-Host sessions and send waits for the s releaseSnapshot = resolve; }); const services: ConversationServices = { + observation: stubConversationServices().observation, ...sessionLocalServices, sessions: { readSnapshot: async () => snapshot, @@ -252,6 +254,7 @@ test('send resolves the selected Session snapshot at the send boundary', async ( }; let reads = 0; const services: ConversationServices = { + observation: stubConversationServices().observation, ...sessionLocalServices, sessions: { ...queueStubs, @@ -358,6 +361,7 @@ test('ignores a snapshot that resolves after the Composer owner changes', async }); let release!: (snapshot: SessionSnapshot) => void; const services: ConversationServices = { + observation: stubConversationServices().observation, ...sessionLocalServices, sessions: { ...queueStubs, 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 aae61ef7e3..f649e17fa7 100644 --- a/apps/desktop/src/main/__tests__/session-ui-reads.test.ts +++ b/apps/desktop/src/main/__tests__/session-ui-reads.test.ts @@ -24,16 +24,18 @@ import { LocaleProvider, ToastProvider, type LiveTurnBuffer } from '@maka/ui'; import type { SandboxBoundaryRequestEvent } from '@maka/core/events'; import { ConversationServicesProvider, - useAppShellSessionUiState, + ConversationProvider, type AppShellSessionUiStateController, } from '../../renderer/features/conversation/index.js'; import * as conversation from '../../renderer/features/conversation/index.js'; import { createProductionSessionUiStateController as createController, stubConversationServices, + useConversationOwner, + useConversationQueue, } from '../../renderer/features/conversation/testing.js'; import { useAppShellSessionUiReads } from '../../renderer/use-app-shell-session-ui-reads.js'; -import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; function interaction(requestId: string): SandboxBoundaryRequestEvent { @@ -259,12 +261,12 @@ describe('production Session UI consumers', () => { it('scopes the workspace publication hook queue read to its published Session', async () => { const { root } = installReactRenderer(); const catalog = createSessionCatalogController(); - const activeId = { current: 'A' as string | undefined }; - let value!: ReturnType; + let value!: ReturnType['workspace']; let renders = 0; function Workspace() { renders += 1; - value = useAppShellSessionUiState(catalog, 'A', activeId, () => true); + value = useConversationOwner().workspace; + useConversationQueue(); return null; } try { @@ -272,13 +274,13 @@ describe('production Session UI consumers', () => { locale: 'en', children: createElement(ToastProvider, { children: createElement(ConversationServicesProvider, { - services: stubConversationServices(), children: createElement(Workspace), + services: stubConversationServices(), children: createElement(SessionCatalogContext.Provider, { value: catalog, children: createElement(ConversationProvider, { children: createElement(Workspace) }) }), }), }), }))); - await act(async () => { value.publication.setMessagesState([]); }); + await act(async () => { value.commands.setActiveId('A'); }); const initial = renders; - const c = value.controller; + const c = value.ui; await act(async () => { c.setMessageQueueBySession((s) => ({ ...s, B: { ts: 1, entries: [] } })); }); assert.equal(renders, initial); await act(async () => { c.setMessageQueueBySession((s) => ({ ...s, A: { ts: 2, entries: [] } })); }); diff --git a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts index 02b725dfa6..6befea5044 100644 --- a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts +++ b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts @@ -19,7 +19,7 @@ import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; -import { act, createElement } from 'react'; +import { act, createElement, useSyncExternalStore } from 'react'; import { LocaleProvider, ToastProvider } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; @@ -28,8 +28,8 @@ import { SessionCatalogContext, } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { useAppShellSessionWorkspace } from '../../renderer/use-app-shell-session-workspace.js'; -import { ConversationServicesProvider } from '../../renderer/features/conversation/index.js'; -import { stubConversationServices } from '../../renderer/features/conversation/testing.js'; +import { ConversationServicesProvider, ConversationProvider } from '../../renderer/features/conversation/index.js'; +import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; @@ -42,7 +42,14 @@ import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; * for a single session switch. Identity is a contract, not an implementation * detail, so it is asserted here rather than left to review. */ -type Workspace = ReturnType; +function useTestWorkspace() { + const target = useAppShellSessionWorkspace({ error: () => {} }); + const { workspace } = useConversationOwner(); + const view = useSyncExternalStore(workspace.publication.subscribe, workspace.publication.getSnapshot); + return { ...target, ...workspace, messages: view.messages, publishedTranscriptRange: view.range, messageLoadPending: view.loading, + get requestedSessionId() { return target.sessionCatalogController.getState().activeSessionId; } }; +} +type Workspace = ReturnType; /** * Every function the hook returns, read off the first render rather than @@ -69,7 +76,7 @@ describe('session workspace action identity', () => { const displays: Array<{ id: string | undefined; messages: StoredMessage[] }> = []; const services = stubConversationServices(); function Probe(): null { - workspace = useAppShellSessionWorkspace({ error: () => {} }); + workspace = useTestWorkspace(); displays.push({ id: workspace.activeId, messages: workspace.messages }); return null; } @@ -79,7 +86,7 @@ describe('session workspace action identity', () => { children: createElement(ConversationServicesProvider, { services, children: createElement(SessionCatalogContext.Provider, { - value: catalog, children: createElement(Probe), + value: catalog, children: createElement(ConversationProvider, { children: createElement(Probe) }), }), }), }), @@ -186,7 +193,7 @@ describe('session workspace action identity', () => { const reads: Workspace[] = []; function Probe(): null { - reads.push(useAppShellSessionWorkspace({ error: () => {} })); + reads.push(useTestWorkspace()); return null; } @@ -198,7 +205,7 @@ describe('session workspace action identity', () => { children: createElement(ConversationServicesProvider, { services: stubConversationServices(), children: createElement(SessionCatalogContext.Provider, { - value: catalog, children: createElement(Probe), + value: catalog, children: createElement(ConversationProvider, { children: createElement(Probe) }), }), }), }), @@ -209,8 +216,8 @@ describe('session workspace action identity', () => { // Three unrelated state changes, each of which re-renders the hook. act(() => reads[0]!.setActiveId('session-a')); - act(() => reads[0]!.setMessages([])); - act(() => reads[0]!.setMessageLoadPending(true)); + act(() => reads[0]!.setActiveId('session-b')); + act(() => reads[0]!.setLoading(true)); assert.ok(reads.length > 1, 'the probe should have re-rendered'); const first = reads[0]!; diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 37e683904f..e5106315d3 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -33,7 +33,7 @@ import { import { createAppShellSessionDisplayBatch, createAppShellSessionEventHandlers, -} from '../../renderer/app-shell-session-events.js'; +} from '../../renderer/features/conversation/testing.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; import { renderTranscriptMarkup } from './transcript-test-dom.js'; diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index 34eb5b5e08..549e56cee0 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -28,7 +28,7 @@ import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } f import { TranscriptReadingPositionController, type TranscriptReadingPositionCommands, -} from '../../renderer/features/conversation/index.js'; +} from '../../renderer/features/conversation/testing.js'; import { createAppShellSessionUiStateController } from '../../renderer/features/conversation/testing.js'; import { createTranscriptRestoreLifecycle, diff --git a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts index 52a24d6700..571382607b 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -33,7 +33,7 @@ import { import { TranscriptReadingPositionController, type TranscriptReadingPositionCommands, -} from '../../renderer/features/conversation/index.js'; +} from '../../renderer/features/conversation/testing.js'; import { createAppShellSessionUiStateController } from '../../renderer/features/conversation/testing.js'; type VirtualizerHandle = NonNullable[0]['virtualizerRef']['current']>; diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 1a095681cf..4b171bfcaa 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -18,7 +18,6 @@ */ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; -import type { StoredMessage } from '@maka/core/session'; import type { CollaborationMode } from '@maka/core/collaboration'; import type * as DesktopBridge from '../preload/bridge-contract.js'; import type { QuoteRef } from '@maka/core/events'; @@ -31,12 +30,9 @@ import type { UiLocale } from '@maka/core/ui-locale'; import type { UserQuestionResponse } from '@maka/core/user-question'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; import { - dequeueInteractionByRequestId, - type InteractionQueues, type NavSelection, type TransientUserMessageProjection, } from '@maka/ui'; -import { messageRefreshErrorMessage } from './app-shell-copy.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; import { preflightAttachmentItems } from './attachment-preflight.js'; import { @@ -44,8 +40,6 @@ import { showSessionWorkspaceUnavailableToast, } from './session-workspace-errors.js'; import * as skillFeedback from './skill-invocation-feedback.js'; -import type { DesktopTranscriptRangeController } from './platform/desktop/desktop-transcript-range-store.js'; -import type { SessionPendingClaim } from './features/conversation/index.js'; import * as Conversation from './features/conversation/index.js'; import type { NewChatExecutionTarget, PendingAttachment, ExecutorSubmission } from './features/conversation/index.js'; @@ -58,9 +52,6 @@ import { noRealConnectionReasonFromError, noRealConnectionSetupDescription, } from './model-connection-errors.js'; -import type { RefreshMessagesOptions } from './platform/desktop/session-message-settlement.js'; - -export type { RefreshMessagesOptions }; type ComposerImportOwner = { sessionId: string | undefined; @@ -69,8 +60,6 @@ type ComposerImportOwner = { }; type RefBox = { current: T }; -type MessageLoadErrorUpdater = (updater: (current: Record) => Record) => void; -type InteractionQueueUpdater = (updater: (current: InteractionQueues) => InteractionQueues) => void; type PendingNewChatModel = NewChatExecutionTarget | null; @@ -133,8 +122,6 @@ export interface AppShellChatActions { respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion(response: UserQuestionResponse): Promise; respondToUserForm(response: InteractionFormResponse): Promise; - refreshMessages(sessionId: string, options?: RefreshMessagesOptions): Promise; - retryMessages(sessionId: string): Promise; } export function createAppShellChatActions(deps: { @@ -149,11 +136,10 @@ export function createAppShellChatActions(deps: { * looking at". Both halves matter — the section AND the session id — which * is why the send path asks it instead of comparing the id itself. */ isShellSurfaceOwnerActive: (owner: ComposerImportOwner) => boolean; - messageRetryPending: SessionPendingClaim; refreshSessions: () => Promise; activateSessionForFirstSend: (session: DesktopSessionSummary) => Promise; retireSession: (sessionId: string) => void; - setMessageLoadErrorBySession: MessageLoadErrorUpdater; + clearMessageLoadError(sessionId: string): void; addTransientMessage: ( sessionId: string, message: TransientUserMessageProjection, @@ -163,12 +149,10 @@ export function createAppShellChatActions(deps: { message: TransientUserMessageProjection, ) => void; removeTransientMessage: (sessionId: string, messageId: string) => void; - transcriptRangeRef: RefBox; - isMessagePublished: (message: StoredMessage) => boolean; onFollowLatest: (sessionId: string) => boolean; /** #646: arm the "正在处理…" indicator locally at send() — the model-wait * window opens before any SessionEvent arrives (turn_started is not one). */ - setInteractionBySession: InteractionQueueUpdater; + settleInteraction(sessionId: string, requestId: string): void; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ onExecutionBoundaryChanged?: (sessionId: string) => void; @@ -208,15 +192,13 @@ export function createAppShellChatActions(deps: { checkTaskSubmissionReadiness, isNewChatSendSurfaceActive, isShellSurfaceOwnerActive, - messageRetryPending, refreshSessions, activateSessionForFirstSend, retireSession, - setMessageLoadErrorBySession, + clearMessageLoadError, removeTransientMessage, - transcriptRangeRef, onFollowLatest, - setInteractionBySession, + settleInteraction, onInteractionChanged, onExecutionBoundaryChanged, respondToUserForm: submitUserForm, @@ -532,9 +514,7 @@ export function createAppShellChatActions(deps: { await submit(sessionId, response); onInteractionChanged?.(sessionId); onApplied?.(sessionId); - setInteractionBySession((current) => - dequeueInteractionByRequestId(current, sessionId, response.requestId), - ); + settleInteraction(sessionId, response.requestId); } catch (error) { if (activeIdRef.current !== sessionId) return; if (isSessionWorkspaceUnavailableError(error)) { @@ -550,69 +530,6 @@ export function createAppShellChatActions(deps: { } } - async function refreshMessages(sessionId: string, options: RefreshMessagesOptions = {}): Promise { - try { - if (activeIdRef.current !== sessionId) return false; - const controller = transcriptRangeRef.current; - if (!controller) return false; - await controller.ready(); - if (activeIdRef.current !== sessionId || transcriptRangeRef.current !== controller) return false; - const requiredMessageId = options.requiredAssistantMessageId; - if ( - requiredMessageId !== undefined && - !controller.store.hasDurableMessage(requiredMessageId) && - !(await controller.waitForDurableMessage(requiredMessageId, 480)) - ) { - return false; - } - if (activeIdRef.current !== sessionId || transcriptRangeRef.current !== controller) { - return false; - } - const snapshot = controller.store.snapshot(); - if (snapshot.sessionId !== sessionId) return false; - // Store changes already publish through its active subscription. A - // refresh checks readiness; it must not bypass input-held publication. - setMessageLoadErrorBySession((current) => { - if (!current[sessionId]) return current; - const updated = { ...current }; - delete updated[sessionId]; - return updated; - }); - // The live answer stays visible until the durable answer reaches the - // published view. Its existing publication effect retries this handoff. - return requiredMessageId === undefined || snapshot.messages.some( - (message) => message.id === requiredMessageId && deps.isMessagePublished(message), - ); - } catch (error) { - if (activeIdRef.current === sessionId) { - const message = messageRefreshErrorMessage(error, uiLocale); - setMessageLoadErrorBySession((current) => ({ - ...current, - [sessionId]: message, - })); - toastApi.error(copy.refreshFailedTitle, message, undefined, { sessionId }); - } - return false; - } - } - async function retryMessages(sessionId: string) { - if (!messageRetryPending.claim(sessionId)) return; - try { - if (activeIdRef.current !== sessionId) return; - await transcriptRangeRef.current?.reload(); - } catch (error) { - if (activeIdRef.current !== sessionId) return; - const message = messageRefreshErrorMessage(error, uiLocale); - setMessageLoadErrorBySession((current) => ({ - ...current, - [sessionId]: message, - })); - toastApi.error(copy.refreshFailedTitle, message, undefined, { sessionId }); - } finally { - messageRetryPending.release(sessionId); - } - } - function publishTransientUserMessage( sessionId: string, message: Omit, @@ -620,12 +537,7 @@ export function createAppShellChatActions(deps: { ): void { (updateOnly ? deps.updateTransientMessage : deps.addTransientMessage)(sessionId, { ...message, ts: Date.now() }); if (activeIdRef.current !== sessionId) return; - setMessageLoadErrorBySession((current) => { - if (!current[sessionId]) return current; - const cleared = { ...current }; - delete cleared[sessionId]; - return cleared; - }); + clearMessageLoadError(sessionId); } return { @@ -640,7 +552,5 @@ export function createAppShellChatActions(deps: { respondToUserQuestion: (response) => respondToInteraction(response, window.maka.sessions.respondToUserQuestion), respondToUserForm: (response) => respondToInteraction(response, submitUserForm), - refreshMessages, - retryMessages, }; } diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index b90c7ab83f..0acf9bccb0 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -68,7 +68,7 @@ export interface AppShellCommandListOptions { clientPathsAccessible: boolean; connections: LlmConnection[]; defaultConnection: string | null; - messages: StoredMessage[]; + readMessages(): readonly StoredMessage[]; newTaskProfileId: string | undefined; settingsOpen: boolean; settingsProfileId: string | undefined; @@ -205,24 +205,20 @@ export function buildAppShellCommandList( ); } }, - onOpenWorkspace: async () => { - await optionsRef.current.openWorkspaceFolder(); - }, + onOpenWorkspace: () => optionsRef.current.openWorkspaceFolder(), ...(options.clientPathsAccessible ? { onOpenProjectFolder: () => optionsRef.current.openProjectFolder(), } : {}), - onSelectModule: (selection) => { - optionsRef.current.setNavSelection(selection); - }, + onSelectModule: (selection) => optionsRef.current.setNavSelection(selection), onExportActiveConversation: async () => { - const { activeId, messages, sessionCatalog, toastApi } = optionsRef.current; + const { activeId, readMessages, sessionCatalog, toastApi } = optionsRef.current; if (!activeId) return; const session = sessionCatalog.getState().sessions.find((s) => s.id === activeId); const markdown = renderConversationMarkdown( session?.name ?? copy.newConversation, - messages, + readMessages(), locale, ); try { @@ -236,13 +232,13 @@ export function buildAppShellCommandList( } }, onSaveActiveConversationToFile: async () => { - const { activeId, messages, sessionCatalog, toastApi } = optionsRef.current; + const { activeId, readMessages, 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, - messages, + readMessages(), locale, ); const now = new Date(); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index dad55ddc13..3a58355152 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -17,48 +17,26 @@ * under the License. */ -import { useEffect, useEffectEvent, useLayoutEffect } from 'react'; +import { useEffect, useEffectEvent } from 'react'; import { useHotkeys } from '@astryxdesign/core/hooks'; import type { ConnectionEvent } from '@maka/core/connections'; import type { SessionChangedEvent, SessionSummary } from '@maka/core/session'; -import type { SessionEvent } from '@maka/core/events'; -import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; import type { ThemePalette, ThemePreference } from '@maka/core/settings'; import type { UiLocale } from '@maka/core/ui-locale'; -import { sessionExpectsEventStream } from '@maka/core/session-event-health'; -import { type ShellRunUpdate } from '@maka/core/events'; -import type { LiveTurnProjection, NavSelection } from '@maka/ui'; -import type { TranscriptPublisher } from './features/conversation/index.js'; -import { messageReadErrorMessage } from './app-shell-copy'; +import type { NavSelection } from '@maka/ui'; import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; import { applyTheme, applyThemePalette } from './theme'; import { startTitlebarModalSync } from './titlebar-modal-sync'; import { safeLocalStorageSet } from './browser-storage'; import type { NavigationState } from './nav-selection.js'; -import { - createSessionEventStreamSubscription, - evaluateSessionEventStreamSnapshot, - recordSessionEventStreamEvent, -} from './application/contracts/session-catalog/session-event-health.js'; import { handleSessionChangedEvent } from './application/contracts/session-catalog/session-change-effects.js'; import type { DesktopRuntimeHostProfileChangedEvent, WindowCommand, } from '../preload/bridge-contract.js'; -import { - mergeShellRunNotification, - mergeShellRunUpdates, - ShellRunHydration, - type ShellRunUpdatesBySession, -} from './shell-run-update-state.js'; -import * as desktopTranscript from './platform/desktop/desktop-transcript-range-store.js'; type RefBox = { current: T }; -type SessionEventHealthUpdater = ( - updater: (current: Record) => Record, -) => void; - type ToastApi = { error( title: string, @@ -169,7 +147,7 @@ export function useAppShellBootstrapSubscriptions(options: { isSessionRemoved(sessionId: string): boolean; /** Mirrors the committed catalog; refresh promises resolve after commit. */ sessionsRef: RefBox; - setSessionEventHealthBySession: SessionEventHealthUpdater; + recordSessionChange(sessionId: string, ts: number): void; toastApi: ToastApi; }) { const runDeferredStartupRefreshes = useEffectEvent(() => { @@ -284,322 +262,3 @@ export function useAppShellBootstrapSubscriptions(options: { }; }, []); } - -export function useActiveSessionEvents(options: { - uiLocale: UiLocale; - activeId: string | undefined; - observationAuthorityRevision: number; - activeIdRef: RefBox; - handleEvent: (sessionId: string, event: SessionEvent) => void; - setExecution: import('./features/conversation/index.js').AppShellSessionUiStateController['setExecution']; - beginObservationSeed: (sessionId: string) => () => void; - setMessageLoadErrorBySession: (updater: (current: Record) => Record) => void; - clearMessageLoadError(sessionId: string): void; - setMessageLoadPending: (pending: boolean) => void; - commitTranscript: import('./session-workspace-actions.js').SessionWorkspaceActions['commitTranscript']; - publishTranscript: TranscriptPublisher< - desktopTranscript.DesktopTranscriptRangeController - >; - transcriptRangeRef: RefBox; - setSessionEventHealthBySession: SessionEventHealthUpdater; - toastApi: Pick; -}) { - const activeId = options.activeId; - const clearMessageLoadError = useEffectEvent(options.clearMessageLoadError); - // Publication rechecks both the requested Session and the effect instance - // after any reader input wait before handing over the displayed transcript. - const applyTranscript = useEffectEvent(( - sessionId: string, - controller: desktopTranscript.DesktopTranscriptRangeController, - effectIsCurrent: () => boolean, - ) => { - options.publishTranscript(sessionId, controller, effectIsCurrent, () => { - clearMessageLoadError(sessionId); - options.setMessageLoadPending(false); - }); - }); - const applyReadError = useEffectEvent((sessionId: string, error: unknown) => { - if (options.activeId === sessionId) { - if (options.activeIdRef.current !== sessionId) options.commitTranscript(sessionId, []); - const message = messageReadErrorMessage(error, options.uiLocale); - options.setMessageLoadErrorBySession((current) => ({ - ...current, - [sessionId]: message, - })); - options.setMessageLoadPending(false); - options.toastApi.error( - getDesktopConversationCopy(options.uiLocale).actions.messageReadFailedTitle, - message, - undefined, - { sessionId }, - ); - } - }); - const handleSessionEvent = useEffectEvent((sessionId: string, event: SessionEvent) => { - options.setSessionEventHealthBySession((current) => { - const previous = current[sessionId]; - if (!previous) return current; - return { - ...current, - [sessionId]: recordSessionEventStreamEvent(previous, Date.now()), - }; - }); - options.handleEvent(sessionId, event); - }); - const beginObservationSeed = useEffectEvent(options.beginObservationSeed); - const markSessionEventStreamClosed = useEffectEvent((sessionId: string) => { - options.setSessionEventHealthBySession((current) => { - const previous = current[sessionId]; - if (!previous) return current; - return { - ...current, - [sessionId]: { - ...previous, - status: 'closed', - checkedAt: Date.now(), - staleSince: undefined, - }, - }; - }); - }); - - useLayoutEffect(() => { - if (!activeId) return; - let disposed = false; - let observationAttempt = 0; - let observationFailures = 0; - let observationRetryTimer: ReturnType | undefined; - let unsubscribeSessionEvents = () => {}; - const transcript = new desktopTranscript.DesktopTranscriptRangeStore(activeId); - clearMessageLoadError(activeId); - options.setSessionEventHealthBySession((current) => ({ - ...current, - [activeId]: createSessionEventStreamSubscription({ - sessionId: activeId, - now: Date.now(), - }), - })); - const controller = desktopTranscript.createDesktopTranscriptRangeController( - transcript, - desktopTranscript.openDesktopTranscriptHistory(window.maka.transcripts.open, activeId, (batch) => { - if (disposed) return; - try { - transcript.accept(batch); - } catch (error) { - applyReadError(activeId, error); - } - }), - { - onError: (error) => { if (!disposed) applyReadError(activeId, error); }, - }, - ); - const unsubscribeTranscript = transcript.subscribe(() => - applyTranscript(activeId, controller, () => !disposed)); - const subscribeSessionEvents = () => { - const attempt = ++observationAttempt; - let completeObservationSeed = beginObservationSeed(activeId); - let unsubscribeRequested = false; - let unsubscribeCurrent = () => { - unsubscribeRequested = true; - }; - const unsubscribe = window.maka.sessions.subscribeEvents( - activeId, - (event) => { - if (attempt !== observationAttempt) return; - handleSessionEvent(activeId, event); - }, - (phase) => { - if (attempt !== observationAttempt) return; - controller.observationChanged(phase); - if (phase === 'pending') completeObservationSeed = beginObservationSeed(activeId); - else { - observationFailures = 0; - completeObservationSeed(); - } - }, - () => { - if (attempt !== observationAttempt) return; - controller.observationChanged('pending'); - options.setExecution(activeId, undefined); - unsubscribeCurrent(); - observationFailures += 1; - const retryDelayMs = Math.min(100 * (2 ** (observationFailures - 1)), 2_000); - observationRetryTimer = globalThis.setTimeout(() => { - observationRetryTimer = undefined; - if (!disposed && attempt === observationAttempt) subscribeSessionEvents(); - }, retryDelayMs); - }, - (projection) => { - if (attempt === observationAttempt) options.setExecution(activeId, projection); - }, - ); - unsubscribeCurrent = unsubscribe; - unsubscribeSessionEvents = unsubscribe; - if (unsubscribeRequested) unsubscribe(); - }; - subscribeSessionEvents(); - return () => { - disposed = true; - observationAttempt += 1; - if (observationRetryTimer !== undefined) { - globalThis.clearTimeout(observationRetryTimer); - } - if (options.transcriptRangeRef.current?.store === transcript) { - options.transcriptRangeRef.current = undefined; - } - void controller.close(); - unsubscribeTranscript(); - unsubscribeSessionEvents(); - options.setExecution(activeId, undefined); - markSessionEventStreamClosed(activeId); - }; - }, [activeId, options.observationAuthorityRevision]); -} - -export function useShellRunUpdates(options: { - activeId: string | undefined; - hydrate?: boolean; - setShellRunUpdatesBySession: (updater: (current: ShellRunUpdatesBySession) => ShellRunUpdatesBySession) => void; -}) { - const applyUpdates = useEffectEvent( - (sessionId: string, updates: Awaited>) => { - options.setShellRunUpdatesBySession((current) => { - const active = current[sessionId]; - const retained = active ? { [sessionId]: active } : {}; - return mergeShellRunUpdates( - retained, - updates.filter((update) => update.sessionId === sessionId), - ); - }); - }, - ); - - useEffect(() => { - const sessionId = options.activeId; - options.setShellRunUpdatesBySession((current) => { - if (!sessionId) return {}; - const active = current[sessionId]; - return active ? { [sessionId]: active } : {}; - }); - if (!sessionId) return; - - let disposed = false; - let retryTimer: ReturnType | undefined; - let retryDelayMs = 250; - const hydration = new ShellRunHydration(); - if (options.hydrate === false) hydration.commit(0); - const unsubscribe = window.maka.shellRuns.subscribeUpdates((update) => { - if (disposed) return; - const live = hydration.accept(update); - if (live) { - options.setShellRunUpdatesBySession((current) => - mergeShellRunNotification(current, sessionId, live), - ); - } - }); - const hydrate = (epoch: number) => { - if (options.hydrate === false) return; - void window.maka.shellRuns - .list(sessionId) - .then((updates) => { - if (disposed) return; - const buffered = hydration.commit(epoch); - if (!buffered) return; - applyUpdates(sessionId, updates); - retryDelayMs = 250; - for (const update of buffered.updates) { - options.setShellRunUpdatesBySession((current) => mergeShellRunNotification(current, sessionId, update)); - } - if (buffered.overflowed) hydrate(epoch); - }) - .catch(() => { - if (disposed || !hydration.isCurrent(epoch)) return; - retryTimer = globalThis.setTimeout(() => { - retryTimer = undefined; - hydrate(epoch); - }, retryDelayMs); - retryDelayMs = Math.min(retryDelayMs * 2, 5_000); - }); - }; - const unsubscribeResync = window.maka.shellRuns.subscribeResync((event) => { - if (disposed || options.hydrate === false || event.sessionId !== sessionId) return; - const epoch = hydration.begin(); - retryDelayMs = 250; - if (retryTimer !== undefined) { - globalThis.clearTimeout(retryTimer); - retryTimer = undefined; - } - hydrate(epoch); - }); - if (options.hydrate !== false) hydrate(hydration.begin()); - return () => { - disposed = true; - if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer); - unsubscribe(); - unsubscribeResync(); - }; - }, [options.activeId, options.hydrate]); -} - -export function useSessionEventHealthPolling(options: { - activeId: string | undefined; - activeInteraction: { requestId: string } | undefined; - activeSession: SessionSummary | undefined; - activeStreamingLive: boolean; - hasInFlightLiveTools: boolean; - refreshMessages: (sessionId: string) => Promise; - refreshSessions: () => Promise; - sessionEventHealthBySessionRef: RefBox>; - setSessionEventHealthBySession: SessionEventHealthUpdater; -}) { - const { - activeId, - activeInteraction, - activeSession, - activeStreamingLive, - hasInFlightLiveTools, - refreshMessages, - refreshSessions, - sessionEventHealthBySessionRef, - setSessionEventHealthBySession, - } = options; - - useEffect(() => { - if (!activeId) return; - const hasLiveActivity = activeStreamingLive || hasInFlightLiveTools || Boolean(activeInteraction); - const evaluate = () => { - const result = evaluateSessionEventStreamSnapshot({ - previous: sessionEventHealthBySessionRef.current[activeId], - now: Date.now(), - sessionStatus: activeSession?.status, - hasLiveActivity, - }); - if (!result.snapshot) return; - setSessionEventHealthBySession((current) => ({ - ...current, - [activeId]: result.snapshot!, - })); - if (result.shouldRefresh) { - void refreshSessions(); - void refreshMessages(activeId); - } - }; - // #1979: a stream nobody expects has nothing to observe — `evaluate` can only - // derive `closed` and can never ask for a refresh, and no one renders either - // field. So an idle session gets no probe at all, not merely a cheaper one. - // Both inputs to `expected` are deps of this effect, so a session that starts - // running re-arms on its own; `markSessionEventStreamClosed` still records the - // closed stream when the subscription itself goes away. - if (!sessionExpectsEventStream(activeSession?.status, hasLiveActivity)) return; - evaluate(); - const interval = window.setInterval(evaluate, 5_000); - const onVisibilityChange = () => { - if (document.visibilityState === 'visible') evaluate(); - }; - document.addEventListener('visibilitychange', onVisibilityChange); - return () => { - window.clearInterval(interval); - document.removeEventListener('visibilitychange', onVisibilityChange); - }; - }, [activeId, activeSession?.status, activeStreamingLive, hasInFlightLiveTools, activeInteraction?.requestId]); -} diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index 0849b86741..375977ed4e 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -88,7 +88,7 @@ export function createAppShellRevisionActions(deps: { activeIdRef: RefBox; captureSelection(): () => boolean; composerRef: RefBox; - messages: readonly StoredMessage[]; + readMessages(): readonly StoredMessage[]; hasPendingAttachments: () => boolean; openSessionInChat: (sessionId: string, turnId?: string) => void; refreshSessions: () => Promise; @@ -101,7 +101,7 @@ export function createAppShellRevisionActions(deps: { activeIdRef, captureSelection, composerRef, - messages, + readMessages, hasPendingAttachments, openSessionInChat, refreshSessions, @@ -135,7 +135,7 @@ export function createAppShellRevisionActions(deps: { toastApi.info(copy.revisionUnavailableTitle, copy.revisionDraftAttachmentConflict); return; } - const userMessage = messages.find( + const userMessage = readMessages().find( (message): message is Extract => message.type === 'user' && message.turnId === turnId, ); @@ -340,7 +340,7 @@ export function createAppShellRevisionActions(deps: { } } - async function cancelRevisionDraft(): Promise { + async function cancelRevisionDraft() { let selectionIsCurrent = captureSelection(); const draft = revisionDraftRef.current; if (!draft) return; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d24b356709..80c2e2018c 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -62,17 +62,15 @@ import { useToast, deriveComposerModelSwitchAvailability, deriveTitlebarProjectName, - reconcileInteractions, } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; import { ChatMessageSurface } from './chat-message-surface'; import { useTaskSubmissionReadiness } from './use-task-submission-readiness'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; import * as Conversation from './features/conversation'; -import type { TranscriptReadingPositionCommands, TranscriptTurnIndex, LiveContentSeedState } from './features/conversation'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { AgentGraphPanel } from './agent-graph-panel'; -import { ChatComposerRegion, selectLatestRequestUsage } from './chat-composer-region'; +import { ChatComposerRegion } from './chat-composer-region'; import { WorkbarHost, WorkbarProvider, WorkbarShellRoot, type WorkbarShellProjection } from './features/workbar'; import { AppUpdateProvider } from './features/app-update/index.js'; import * as Goals from './features/goals'; @@ -133,10 +131,6 @@ import { runOnDefaultRuntimeHost, } from './platform/desktop/default-runtime-host-operation.js'; import { useAppShellProjectContext } from './use-project-context'; -import { - createAppShellSessionDisplayBatch, - createAppShellSessionEventHandlers, -} from './app-shell-session-events'; import { createAppShellE2eFixtureActions } from './app-shell-e2e-fixture'; import { createAppShellChatActions } from './app-shell-chat-actions'; import { createAppShellTurnActions } from './app-shell-turn-actions'; @@ -153,13 +147,10 @@ import { showSessionWorkspaceUnavailableToast, } from './session-workspace-errors'; import { - useActiveSessionEvents, useAppShellBootstrapSubscriptions, useAppShellHostEffects, useAppShellPersistenceEffects, useAppShellNavRefSync, - useSessionEventHealthPolling, - useShellRunUpdates, } from './app-shell-effects'; import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; import { useTurnActionRegistry } from './use-turn-action-registry'; @@ -189,11 +180,6 @@ type ComposerImportOwner = { newTaskDraftKey?: string; }; -/** - * Grace period before the committed-history fallback force-settles an - * assistant stream slot when the primary post-commit signal is missed. - */ -const SETTLE_FALLBACK_GRACE_MS = 1000; export function AppShell() { const [uiLocalePreference, setUiLocalePreference] = useState('auto'); const [uiLocaleOverride, setUiLocaleOverride] = useState(null); @@ -235,9 +221,11 @@ export function AppShell() { {(sharedSessionDialog) => ( {(workbar) => ( - + + + )} )} @@ -287,13 +275,17 @@ function AppShellContent({ const toastApi = useToast(); const previousInterruptionShownRef = useRef(false); const { + readMessages, + refreshMessages, + prepareSend, + transcriptEmpty, + transcriptHasHistory, authoritativeSessionIds, sessionsRef, refreshSessions, refreshChangedSession, activeId, activeIdRef, - requestedSessionId, bootstrapSelectionLease, setActiveId, startNewSession, @@ -302,27 +294,19 @@ function AppShellContent({ captureSelection, isSessionSelected, retiredSessionIds, - messages, - transientMessages, - setMessages, - commitTranscript, addTransientMessage, updateTransientMessage, - retireCancelledTransientMessages, removeTransientMessage, - transcriptRangeRef, - publishedTranscriptRange, - publishTranscript, - isMessagePublished, - messageLoadPending, - setMessageLoadPending, - sessionUiController, + sessionUiReads, + stopPendingClaims, + markInteractionChanged, + settleInteraction, + clearMessageLoadError, + recordSessionChange, sessionCatalogController, commitSession, activeCatalogSession, activeHostSession, - requestedCatalogSession, - requestedHostSession, sharedSessionActive, ownerActiveId, switchingSession, @@ -332,12 +316,6 @@ function AppShellContent({ // hook already publishes — background row churn belongs to the rail, which // subscribes the catalog inside SessionNavigationProvider (#4109). const sessionCount = authoritativeSessionIds?.size ?? 0; - // Only the outstanding read needs a fence; past Sessions leave no hydration metadata. - const interactionHydrationRef = useRef<{ sessionId: string } | null>(null); - const markInteractionChanged = useCallback((sessionId: string) => { - const pending = interactionHydrationRef.current; - if (pending?.sessionId === sessionId) interactionHydrationRef.current = null; - }, []); const { openHelp, @@ -411,8 +389,6 @@ function AppShellContent({ const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); const [newTaskPermissionChoice, setNewTaskPermissionMode, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); - const transcriptReadingCommands = useRef(null); - const [transcriptTurnIndex, setTranscriptTurnIndex] = useState(); const [petCompletionNonce, setPetCompletionNonce] = useState(0); const [navigationState, setNavigationState] = useState(() => readNavigationState()); const navSelection = navigationState.selection; @@ -454,14 +430,12 @@ function AppShellContent({ // streaming membership subscribe inside their consuming regions. const { messageLoadError: activeMessageLoadError, - messageRetryPending, stopPending, activeInteraction, activeMessageQueue, - unavailableTranscriptRestore: activeUnavailableTranscriptRestore, activeLiveTurnSnapshot, activeExecution, - } = useAppShellSessionUiReads(sessionUiController.reads, activeId, ownerActiveId); + } = 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({ @@ -655,8 +629,6 @@ function AppShellContent({ // semantic snapshot rather than the projection (#1985). const { activeStreamingLive, - activeStreamingMessageId, - hasInFlightLiveTools, hasLiveTurnContent, turnActive, } = useShellLiveTurn({ @@ -751,11 +723,7 @@ function AppShellContent({ // model claims live in the session UI store. const turnActionRegistry = useTurnActionRegistry(); - // A hoisted declaration on purpose: `discardDisplayEvents` is destructured - // hundreds of lines below, and the rail does not need this identity held - // still — the rail's controller reads it through `portsRef`. function clearSessionRendererState(sessionId: string): void { - discardDisplayEvents(sessionId); // `clearOwnedSessionState` ends in `clearSessionUiState`, which drops this // session from every session-UI map — the four pending claims included. clearOwnedSessionState(sessionId); @@ -888,38 +856,6 @@ function AppShellContent({ reading: activeExecutionBoundaryReading, reload: reloadActiveExecutionBoundary, } = useActiveExecutionBoundary(ownerActiveId, activeSessionForView?.permissionMode); - // The session view only subscribes to the session it shows, so a request - // raised while another session was active never reaches this surface as a - // live event — and neither does one raised before the window existed. The - // runtime holds every unanswered request, so read them back whenever the - // active session changes (#2072). - useEffect(() => { - if (!ownerActiveId) return; - const pending = { sessionId: ownerActiveId }; - interactionHydrationRef.current = pending; - const release = () => { - if (interactionHydrationRef.current === pending) interactionHydrationRef.current = null; - }; - void window.maka.sessions - .listActiveInteractions(ownerActiveId) - .then((requests) => { - if (interactionHydrationRef.current !== pending) return; - sessionUiController.setInteractionBySession((current) => reconcileInteractions(current, ownerActiveId, requests)); - }) - .catch(() => {}) - .finally(release); - return release; - }, [ownerActiveId, sessionUiController.setInteractionBySession]); - useEffect( - () => - window.maka.sessions.subscribeActiveInteractions(({ sessionId, interactions }) => { - markInteractionChanged(sessionId); - sessionUiController.setInteractionBySession((current) => - reconcileInteractions(current, sessionId, interactions), - ); - }), - [markInteractionChanged, sessionUiController.setInteractionBySession], - ); const activeBoundarySurface = deriveDesktopExecutionBoundarySurface( activeId, activeExecutionBoundary, @@ -927,14 +863,11 @@ function AppShellContent({ sessionSettingIntent.overlay.permissionMode, ); const activePermissionMode = activeBoundarySurface.permissionMode; - const activeMessageLoading = Boolean(activeId && messageLoadPending); - // Session switches clear the transcript projection before its async read. - // Keep the switch warning anchored to the durable session summary, while - // retaining the local projection for an optimistic first message that has - // not reached the catalog yet. + // The published target retains its picture during handoff. Pending local + // first messages can establish history before the durable row catches up. const modelSwitchHasHistory = activeSessionForView?.lastMessageAt !== undefined || - messages.some((message) => message.type === 'user' || message.type === 'assistant'); + transcriptHasHistory; // PR110c: OnboardingState is now the single source of truth for // first-run UI. The renderer never re-derives provider readiness; // `useOnboardingSnapshot()` pulls the derived state from the main @@ -1233,13 +1166,11 @@ function AppShellContent({ respondToSandboxBoundary, respondToUserQuestion, respondToUserForm, - refreshMessages, - retryMessages, } = useStableActions(createAppShellChatActions, { uiLocale, getRunningTurnId: (sessionId) => { if (sessionId !== activeId) return undefined; - return Conversation.activeHostTurn(sessionUiController.reads.summary(sessionId).getSnapshot().activeExecution)?.turnId; + return Conversation.activeHostTurn(sessionUiReads.summary(sessionId).getSnapshot().activeExecution)?.turnId; }, activeIdRef, captureComposerImportOwner, @@ -1247,7 +1178,6 @@ function AppShellContent({ checkTaskSubmissionReadiness: taskSubmissionReadyAtSend, isNewChatSendSurfaceActive, isShellSurfaceOwnerActive, - messageRetryPending: sessionUiController.messageRetryPending, refreshSessions, activateSessionForFirstSend: async (session) => { commitSession(session); @@ -1255,14 +1185,12 @@ function AppShellContent({ setActiveId(session.id); }, retireSession: clearSessionRendererState, - setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, + clearMessageLoadError, addTransientMessage, updateTransientMessage, removeTransientMessage, - transcriptRangeRef, - onFollowLatest: (sessionId) => transcriptReadingCommands.current?.prepareSend(sessionId) ?? true, - isMessagePublished, - setInteractionBySession: sessionUiController.setInteractionBySession, + onFollowLatest: prepareSend, + settleInteraction, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, respondToUserForm: commands.respondToUserForm, @@ -1297,7 +1225,7 @@ function AppShellContent({ activeIdRef, captureSelection, composerRef, - messages, + readMessages, hasPendingAttachments: () => hasPendingContext, openSessionInChat, refreshSessions, @@ -1407,56 +1335,10 @@ function AppShellContent({ const stop = createAppShellStopAction({ uiLocale, activeIdRef, - stopPending: sessionUiController.stopPending, - removeTransientMessage, - toastApi, - }); - - const [sessionDisplayBatch] = useState(createAppShellSessionDisplayBatch); - const sessionEventHandlers = useStableActions(createAppShellSessionEventHandlers, { - uiLocale, - activeIdRef, - liveTurnBySessionRef: sessionUiController.liveTurnBySessionRef, - refreshMessages, - refreshSessions, - setLiveTurnBySession: sessionUiController.setLiveTurnBySession, - setInteractionBySession: sessionUiController.setInteractionBySession, - setMessageQueueBySession: sessionUiController.setMessageQueueBySession, + stopPending: stopPendingClaims, removeTransientMessage, - displayBatch: sessionDisplayBatch, - onInteractionChanged: markInteractionChanged, - onExecutionBoundaryChanged: reloadActiveExecutionBoundary, - onContextCompactionOutcome: (sessionId, turnId, outcome) => - contextCompactionPresentation.finished(sessionId, turnId, outcome, uiLocale), - showModelSetupToast, toastApi, - onTurnCompleted: (sessionId) => { - if (activeIdRef.current === sessionId) - setPetCompletionNonce((current) => current + 1); - }, }); - const handleEvent = sessionEventHandlers.handleEvent; - const reconcilePersistedMessages = sessionEventHandlers.reconcilePersistedMessages; - const settleAssistantStreaming = sessionEventHandlers.settleAssistantStreaming; - const holdDisplayEvents = sessionEventHandlers.holdDisplayEvents; - const releaseDisplayEvents = sessionEventHandlers.releaseDisplayEvents; - const discardDisplayEvents = sessionEventHandlers.discardDisplayEvents; - - // Streaming-settle handoff, FALLBACK path only. The bubble's primary - // `onStreamingSettled` signal runs after Astryx commits the terminal text. - // Keep a delayed fallback because a stuck slot would otherwise hide the - // committed answer forever (`streamingMessageId` suppresses it while live). - useEffect(() => { - if (!activeId || !activeStreamingMessageId) return; - const committedAssistantArrived = messages.some( - (message) => message.type === 'assistant' && message.id === activeStreamingMessageId, - ); - if (!committedAssistantArrived) return; - const timer = window.setTimeout(() => { - void settleAssistantStreaming(activeId, activeStreamingMessageId); - }, SETTLE_FALLBACK_GRACE_MS); - return () => window.clearTimeout(timer); - }, [activeId, activeStreamingMessageId, messages, settleAssistantStreaming]); useAppShellNavRefSync({ navSelection, @@ -1489,7 +1371,7 @@ function AppShellContent({ retiredSessionIds, isSessionRemoved: sessionCatalogController.isRemoved, sessionsRef, - setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, + recordSessionChange, toastApi, }); useAppShellPersistenceEffects({ @@ -1497,66 +1379,6 @@ function AppShellContent({ themePalette, themePref, }); - const [liveContentSeed, setLiveContentSeed] = useState( - Conversation.INITIAL_LIVE_CONTENT_SEED, - ); - const liveContentSeedRef = useRef(liveContentSeed); - liveContentSeedRef.current = liveContentSeed; - function beginObservationSeed(sessionId: string) { - const seed = Conversation.beginLiveContentSeed(liveContentSeedRef.current, sessionId); - liveContentSeedRef.current = seed.state; - holdDisplayEvents(sessionId); - setLiveContentSeed(seed.state); - function finishObservationSeed() { - if (!Conversation.ownsLiveContentSeed(liveContentSeedRef.current, seed.token)) return; - releaseDisplayEvents(sessionId); - const revealed = Conversation.revealLiveContentSeed(liveContentSeedRef.current, seed.token); - liveContentSeedRef.current = revealed; - setLiveContentSeed(revealed); - void retireCancelledTransientMessages(sessionId); - } - return finishObservationSeed; - } - const observationAuthorityRef = useRef(Conversation.INITIAL_OBSERVATION_AUTHORITY); - observationAuthorityRef.current = Conversation.reconcileObservationAuthority( - observationAuthorityRef.current, - { - sessionId: requestedSessionId, - profileId: requestedCatalogSession?.profileId, - }, - ); - useActiveSessionEvents({ - publishTranscript, - uiLocale, - activeId: requestedHostSession?.id, - observationAuthorityRevision: observationAuthorityRef.current.generation, - activeIdRef, - handleEvent, - beginObservationSeed: (sessionId) => beginObservationSeed(sessionId), - setExecution: sessionUiController.setExecution, - setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, - clearMessageLoadError: sessionUiController.clearMessageLoadError, - setMessageLoadPending, - commitTranscript, - transcriptRangeRef, - setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, - toastApi, - }); - useShellRunUpdates({ - activeId: ownerActiveId, - setShellRunUpdatesBySession: sessionUiController.setShellRunUpdatesBySession, - }); - useSessionEventHealthPolling({ - activeId: activeHostSession?.id, - activeInteraction, - activeSession, - activeStreamingLive, - hasInFlightLiveTools, - refreshMessages, - refreshSessions, - sessionEventHealthBySessionRef: sessionUiController.sessionEventHealthBySessionRef, - setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, - }); function captureComposerImportOwner(): ComposerImportOwner { return { sessionId: activeIdRef.current, @@ -1698,15 +1520,9 @@ function AppShellContent({ canStageComposerContext && !(revisionDraft && activeId === revisionDraft.draftSessionId); - const activeTranscriptReadingAnchor = activeId - ? sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId] - : undefined; - const activeTranscriptRange = publishedTranscriptRange?.sessionId === activeId - ? publishedTranscriptRange : undefined; const homeSurfaceActive = sessionsSelected && - messages.length === 0 && - transientMessages.length === 0 && + transcriptEmpty && !hasLiveTurnContent && !activeMessageLoadError; const commandOptions: AppShellCommandListOptions = { @@ -1720,7 +1536,7 @@ function AppShellContent({ : projectCapabilities.viewClientPath, connections: defaultHostConnections.snapshot.connections, defaultConnection: defaultHostConnections.snapshot.defaultConnection, - messages, + readMessages, newTaskProfileId: taskEntry.selectors.selectedProfileId, settingsOpen, settingsProfileId: overlays.selectors.settings.request.profileId, @@ -1868,29 +1684,14 @@ function AppShellContent({ sessionListWidth, })} > - setSearchScrollTarget(null)} - sessionUi={sessionUiController} - landmarkSessionId={ownerActiveId ?? null} + contextCompactionPresentation.finished(sessionId, turnId, outcome, uiLocale)} + showModelSetupToast={showModelSetupToast} + onTurnCompleted={(sessionId) => { if (activeIdRef.current === sessionId) setPetCompletionNonce((current) => current + 1); }} + searchTarget={searchScrollTarget} clearSearchTarget={() => setSearchScrollTarget(null)} listTurnLandmarks={(sessionId, turnId) => window.maka.sessions.listTurnLandmarks(sessionId, turnId)} - setTurnIndex={setTranscriptTurnIndex} - onRestoreError={(error, sessionId) => sessionUiController.setMessageLoadErrorBySession((current) => ({ - ...current, - [sessionId]: localizedShellErrorMessage(error, desktopConversationCopy.actions.operationFailedFallback, uiLocale), - }))} - /> - {/* Window chrome is frame-level hit-test only (not AppShell topNav): a transparent drag overlay so column surfaces paint to the window top. @@ -1973,7 +1774,7 @@ function AppShellContent({ activeSessionId={activeId} hiddenSessionIds={selectors.hiddenSessionIds} projectScopes={taskEntry.selectors.projectScopes} - streamingSessions={sessionUiController.reads.streaming} + streamingSessions={sessionUiReads.streaming} sessionSendOutcomes={onboarding.snapshot?.sessionSendOutcomes} SessionBadge={SessionCollaboration.SessionTurnRequestBadge} NavigationExtras={SessionCollaboration.SessionCollaborationNavigation} @@ -2038,9 +1839,8 @@ function AppShellContent({ composer={ <> {ownerActiveId ? ( - ) : null} @@ -2065,7 +1865,7 @@ function AppShellContent({ composerRef={composerRef} > {(guest) => ( - 0} onSend={sendOwningItsTarget} onStop={stop} - pendingMessages={transientMessages} queuedMessages={activeMessageQueue?.entries} queuedMessageRevision={activeMessageQueue?.queueRevision} onPromoteQueuedEntry={activeId ? queueSurface.promoteQueuedEntry : undefined} @@ -2122,7 +1920,8 @@ function AppShellContent({ {...Conversation.executorComposerProps(executor, {activeId, turnActive, taskSubmissionHardBlocked, connectionCount: connections.length, onSetup: () => openSettingsSection('external-agents'), onNewTask: openNewTaskSurface})} activeSession={activeSessionForView} {...{ executorTarget, onExecutorTargetChange }} - latestRequestUsageTokens={selectLatestRequestUsage(messages, activeModel, activeSessionForModelControls)} + usageModel={activeModel} + usageRoute={activeSessionForModelControls} onOpenContextUsage={() => commands.toggleTool('inspector')} LiveContextUsageProbe={LiveContextUsageProbe} contextUsageSessionId={ownerActiveId} @@ -2188,23 +1987,9 @@ function AppShellContent({ } > {sessionsSelected ? ( - transcriptReadingCommands.current?.loadEarlier()} - transcriptTurnIndex={activeId && transcriptTurnIndex?.sessionId === activeId ? transcriptTurnIndex.turns : undefined} - onLoadTranscriptTurn={(turn) => transcriptReadingCommands.current?.loadEarlier(turn.sequence)} - liveContentSeedGeneration={Conversation.visibleLiveContentGeneration(liveContentSeed, activeId)} - messages={messages} - transientMessages={transientMessages} - messageLoading={activeMessageLoading} - onStreamingSettled={ - activeId ? (messageId) => settleAssistantStreaming(activeId, messageId) : undefined - } activeSession={activeSessionForView} activeConnectionLabel={activeConnectionLabel} activeModelLabel={activeModelLabel} @@ -2217,9 +2002,6 @@ function AppShellContent({ userLabel={userLabel} memoryActive={memoryActive} onOpenMemorySettings={sharedSessionActive ? undefined : () => openSettingsSection('memory')} - messageLoadError={activeMessageLoadError} - messageLoadRetryPending={messageRetryPending} - onRetryMessages={activeId ? () => void retryMessages(activeId) : undefined} deriveTurnPresentation={deriveTurnPresentation} onTurnFooterAction={sharedSessionActive ? undefined : handleTurnFooterAction} onEditUserMessage={sharedSessionActive ? undefined : (turnId) => { void beginEditUserMessage(turnId); }} @@ -2239,13 +2021,6 @@ function AppShellContent({ } : undefined } - restoreTargetTurn={Conversation.transcriptReadingPosition.restoreTarget( - activeTranscriptReadingAnchor, - activeUnavailableTranscriptRestore, - )} - onReadingAnchorChange={activeId - ? (turnId) => transcriptReadingCommands.current?.captureAnchor(turnId) - : undefined} scrollBehavior={readScrollMotionBehavior()} revisionNavigation={revisionNavigation} onRevisionNavigate={openSessionInChat} diff --git a/apps/desktop/src/renderer/application/contracts/bootstrap-selection-lease.ts b/apps/desktop/src/renderer/application/contracts/bootstrap-selection-lease.ts new file mode 100644 index 0000000000..622eb2e384 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/bootstrap-selection-lease.ts @@ -0,0 +1,60 @@ +/* + * 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 { SessionSummary } from '@maka/core/session'; + +type BootstrapSession = Pick; + +export interface BootstrapSelectionLease { + reconcile(sessions: readonly Summary[]): boolean; + release(): void; +} + +export function createBootstrapSelectionLease(options: { + readActiveId: () => string | undefined; + readSelectionRevision: () => number; + select: (sessionId: string | undefined) => void; +}): BootstrapSelectionLease { + let ownedRevision = options.readSelectionRevision(); + let active = true; + + return { + reconcile(sessions): boolean { + if (!active || options.readSelectionRevision() !== ownedRevision) { + active = false; + return false; + } + + // The catalog includes archived history; automatic selection must not. + const candidates = sessions.filter((session) => !session.isArchived); + const current = options.readActiveId(); + const next = current && candidates.some((session) => session.id === current) + ? current + : candidates[0]?.lastMessageAt + ? candidates[0].id + : undefined; + if (next !== current) options.select(next); + ownedRevision = options.readSelectionRevision(); + return true; + }, + release(): void { + active = false; + }, + }; +} diff --git a/apps/desktop/src/renderer/application/contracts/model-connection-errors.ts b/apps/desktop/src/renderer/application/contracts/model-connection-errors.ts new file mode 100644 index 0000000000..be2dcd5a10 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/model-connection-errors.ts @@ -0,0 +1,85 @@ +/* + * 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 { ChatConfigurationReason } from '@maka/core/connection-readiness'; +import type { SessionEvent } from '@maka/core/events'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { + NO_REAL_CONNECTION_CODE, + parseNoRealConnectionError, +} from './connection-error-cleaner.js'; +import { getDesktopConversationCopy } from './conversation-copy.js'; +import { classifiedErrorFallback } from './operation-diagnostics.js'; +import { describeSessionErrorReason } from './session-error-presentation.js'; + +export function isNoRealConnectionError(error: unknown): boolean { + return parseNoRealConnectionError(error).matched; +} + +export function isNoRealConnectionEvent(event: Extract): boolean { + return event.code === NO_REAL_CONNECTION_CODE || parseNoRealConnectionError(event.message).matched; +} + +export function noRealConnectionReasonFromError(error: unknown): string | undefined { + return parseNoRealConnectionError(error).reason; +} + +export function noRealConnectionReasonFromEvent(event: Extract): string | undefined { + return parseNoRealConnectionError( + event.reason ? `${NO_REAL_CONNECTION_CODE}:${event.reason}` : event.message, + ).reason; +} + +export function noRealConnectionSetupDescription(reason: string | undefined, locale: UiLocale): string { + const copy = getDesktopConversationCopy(locale).model; + return reason && Object.hasOwn(copy.configurationReason, reason) + ? copy.configurationReason[reason as ChatConfigurationReason] + : copy.configurationFallback; +} + +export function sessionEventErrorMessage( + event: Extract, + locale: UiLocale, +): string { + if (isNoRealConnectionEvent(event)) { + return noRealConnectionSetupDescription(noRealConnectionReasonFromEvent(event), locale); + } + const reasonDescription = describeSessionErrorReason(event.reason, locale); + if (reasonDescription) return reasonDescription; + const fallback = getDesktopConversationCopy(locale).actions.conversationErrorFallback; + return classifiedErrorFallback(new Error(event.message), fallback, locale, 'desktop'); +} + +export function modelSetupToastCopy( + reason: string | undefined, + fallback: string, + locale: UiLocale, +): { title: string; description: string } { + const copy = getDesktopConversationCopy(locale).model; + if (reason === 'connection_missing') { + return { + title: copy.connectionMissingTitle, + description: noRealConnectionSetupDescription(reason, locale), + }; + } + return { + title: copy.setupTitle, + description: fallback, + }; +} diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts index 07d78ece7c..aeacbb3e54 100644 --- a/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts @@ -18,15 +18,9 @@ */ import type { SessionChangedEvent, SessionSummary } from '@maka/core/session'; -import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; -import { recordSessionEventStreamChange } from './session-event-health.js'; type RefBox = { current: T }; -type SessionEventHealthUpdater = ( - updater: (current: Record) => Record, -) => void; - export function handleSessionChangedEvent( event: SessionChangedEvent, options: { @@ -44,7 +38,7 @@ export function handleSessionChangedEvent( sessionsRef: RefBox; /** Surfaces a model rebound; the caller owns the copy. */ notifyModelRebound: (modelId: string | undefined) => void; - setSessionEventHealthBySession: SessionEventHealthUpdater; + recordSessionChange(sessionId: string, ts: number): void; }, ): void { const changedSessionId = event.sessionId; @@ -56,14 +50,7 @@ export function handleSessionChangedEvent( void options.refreshProjects(); } if (event.sessionId) { - options.setSessionEventHealthBySession((current) => { - const previous = current[event.sessionId!]; - if (!previous) return current; - return { - ...current, - [event.sessionId!]: recordSessionEventStreamChange(previous, event.ts), - }; - }); + options.recordSessionChange(event.sessionId, event.ts); } if ( event.sessionId && diff --git a/apps/desktop/src/renderer/application/contracts/shell-run-update-state.ts b/apps/desktop/src/renderer/application/contracts/shell-run-update-state.ts new file mode 100644 index 0000000000..718ac135ce --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/shell-run-update-state.ts @@ -0,0 +1,92 @@ +/* + * 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 { mergeShellRunUpdate, projectShellRunUpdateForSession, ShellRunUpdateBuffer } from '@maka/core/shell-run-result'; + +import { type ShellRunUpdate } from '@maka/core/events'; + +export type ShellRunUpdatesBySession = Record>; + +export function mergeShellRunUpdates( + current: ShellRunUpdatesBySession, + updates: readonly ShellRunUpdate[], +): ShellRunUpdatesBySession { + let next = current; + for (const update of updates) { + const session = next[update.sessionId] ?? {}; + const previous = session[update.sourceToolCallId]; + const merged = mergeShellRunUpdate( + previous, + update, + 'desktop.shell-run-update-state', + ); + if (!merged.changed) continue; + if (next === current) next = { ...current }; + next[update.sessionId] = { + ...session, + [update.sourceToolCallId]: merged.update, + }; + } + return next; +} + +export function mergeShellRunNotification( + current: ShellRunUpdatesBySession, + sessionId: string, + update: ShellRunUpdate, +): ShellRunUpdatesBySession { + return mergeShellRunUpdates( + current, + projectShellRunUpdateForSession( + sessionId, + Object.values(current[sessionId] ?? {}), + update, + ), + ); +} + +export class ShellRunHydration { + readonly #pending = new ShellRunUpdateBuffer('desktop.shell-run-hydration-buffer'); + #epoch = 0; + #hydrated = false; + + begin(): number { + this.#epoch += 1; + this.#hydrated = false; + this.#pending.clear(); + return this.#epoch; + } + + isCurrent(epoch: number): boolean { + return epoch === this.#epoch; + } + + accept(update: ShellRunUpdate): ShellRunUpdate | undefined { + if (this.#hydrated) return update; + this.#pending.add(update); + return undefined; + } + + commit(epoch: number): { updates: ShellRunUpdate[]; overflowed: boolean } | undefined { + if (!this.isCurrent(epoch)) return undefined; + const pending = this.#pending.drain(); + this.#hydrated = !pending.overflowed; + return pending; + } +} diff --git a/apps/desktop/src/renderer/application/contracts/transcript-copy.ts b/apps/desktop/src/renderer/application/contracts/transcript-copy.ts new file mode 100644 index 0000000000..bbd92805ae --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/transcript-copy.ts @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { classifiedErrorFallback } from './operation-diagnostics.js'; +const COPY = { + 'zh-CN': { read: '任务内容暂时无法读取,请稍后重试。', refresh: '任务内容暂时无法刷新,请稍后重试。', refreshTitle: '刷新任务失败' }, + 'zh-TW': { read: '任務內容暫時無法讀取,請稍後重試。', refresh: '任務內容暫時無法重新整理,請稍後重試。', refreshTitle: '重新整理任務失敗' }, + en: { read: 'Task content is temporarily unavailable. Try again later.', refresh: 'Task content could not be refreshed. Try again later.', refreshTitle: 'Could not refresh task' }, +} satisfies UiCatalog<{ read: string; refresh: string; refreshTitle: string }>; +export function transcriptErrorMessage(error: unknown, locale: UiLocale, kind: 'read' | 'refresh'): string { + return classifiedErrorFallback(error, COPY[locale][kind], locale, kind === 'read' ? 'message-read' : 'message-refresh'); +} +export function transcriptRefreshTitle(locale: UiLocale): string { return COPY[locale].refreshTitle; } diff --git a/apps/desktop/src/renderer/application/contracts/transcript-refresh.ts b/apps/desktop/src/renderer/application/contracts/transcript-refresh.ts new file mode 100644 index 0000000000..b4711fc8f4 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/transcript-refresh.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/** A readiness check; publication remains exclusively with the observation owner. */ +export interface RefreshMessagesOptions { requiredAssistantMessageId?: string; } diff --git a/apps/desktop/src/renderer/application/contracts/use-stable-actions.ts b/apps/desktop/src/renderer/application/contracts/use-stable-actions.ts new file mode 100644 index 0000000000..18a901c654 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/use-stable-actions.ts @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useLayoutEffect, useRef, useState } from 'react'; + +/** + * The facade itself: one method per key, each forwarding to whatever + * `latestRef` holds when it is called. + * + * Factories must return a constant key shape of function values — the key set + * is fixed at creation. + */ +function createDelegatingActions(latestRef: { current: A }): A { + const facade: Record unknown> = {}; + for (const key of Object.keys(latestRef.current)) { + facade[key] = (...args: unknown[]) => { + const action = Reflect.get(latestRef.current, key) as unknown as (...args: unknown[]) => unknown; + return action(...args); + }; + } + return facade as unknown as A; +} + +/** + * Runs an app-shell action factory in the render body but returns a stable + * identity (issue #1043). + * + * The factory still runs every render, so its closures always capture the + * latest deps — identical to calling it bare. What consumers receive is a + * once-created delegating facade whose method identities never change, and it + * delegates only to the latest COMMITTED render's actions: publication happens + * in useLayoutEffect, so an interrupted or discarded concurrent render never + * leaks its closures to event handlers, timers, or subscriptions. This + * supersedes hand-rolled `handlersRef.current = handlers` mirrors, which + * published during render. + * + * `createAppShellStopAction` is deliberately NOT wrapped: it returns a bare + * function (no object to facade) and only feeds JSX props, never effect deps. + * + * The identity guarantee is asserted in `use-stable-actions.test.ts` rather + * than argued for in review — see that file for why this is a contract. + */ +export function useStableActions(factory: (deps: D) => A, deps: D): A { + const actions = factory(deps); + const latestRef = useRef(null); + // Lazy initialization is the one render-phase ref write React permits; on + // updates the ref is already populated and only the layout effect publishes. + if (latestRef.current === null) latestRef.current = actions; + useLayoutEffect(() => { + latestRef.current = actions; + }); + const [facade] = useState(() => createDelegatingActions(latestRef as { current: A })); + return facade; +} diff --git a/apps/desktop/src/renderer/bootstrap-selection-lease.ts b/apps/desktop/src/renderer/bootstrap-selection-lease.ts index 622eb2e384..a7778bf0b0 100644 --- a/apps/desktop/src/renderer/bootstrap-selection-lease.ts +++ b/apps/desktop/src/renderer/bootstrap-selection-lease.ts @@ -17,44 +17,4 @@ * under the License. */ -import type { SessionSummary } from '@maka/core/session'; - -type BootstrapSession = Pick; - -export interface BootstrapSelectionLease { - reconcile(sessions: readonly Summary[]): boolean; - release(): void; -} - -export function createBootstrapSelectionLease(options: { - readActiveId: () => string | undefined; - readSelectionRevision: () => number; - select: (sessionId: string | undefined) => void; -}): BootstrapSelectionLease { - let ownedRevision = options.readSelectionRevision(); - let active = true; - - return { - reconcile(sessions): boolean { - if (!active || options.readSelectionRevision() !== ownedRevision) { - active = false; - return false; - } - - // The catalog includes archived history; automatic selection must not. - const candidates = sessions.filter((session) => !session.isArchived); - const current = options.readActiveId(); - const next = current && candidates.some((session) => session.id === current) - ? current - : candidates[0]?.lastMessageAt - ? candidates[0].id - : undefined; - if (next !== current) options.select(next); - ownedRevision = options.readSelectionRevision(); - return true; - }, - release(): void { - active = false; - }, - }; -} +export * from './application/contracts/bootstrap-selection-lease.js'; diff --git a/apps/desktop/src/renderer/conversation-markdown.ts b/apps/desktop/src/renderer/conversation-markdown.ts index d89d291fd7..458e656ac9 100644 --- a/apps/desktop/src/renderer/conversation-markdown.ts +++ b/apps/desktop/src/renderer/conversation-markdown.ts @@ -43,7 +43,7 @@ import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; * export that the user is going to paste somewhere public. * - **user text** left untouched (the user typed it, they own it). */ -export function renderConversationMarkdown(sessionName: string, messages: StoredMessage[], locale: UiLocale): string { +export function renderConversationMarkdown(sessionName: string, messages: readonly StoredMessage[], locale: UiLocale): string { const copy = getShellRemainingCopy(locale).conversationExport; const lines: string[] = []; lines.push(`# ${sessionName}`); diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 6375ead37a..bfaa6b277e 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -19,34 +19,71 @@ # Conversation feature -Conversation owns runtime-only Session presentation state and the policies -that connect transcript identity to the Desktop bounded-range controller. Its -public API includes task-readiness presentation and a headless -`TranscriptReadingPositionController` component. That component owns bookmark -restoration, landmark refresh, and history navigation, while exposing explicit -capture, send preparation, and history commands to AppShell. - -`LiveTurnReconciler` owns the handoff of every retained Turn's content to the -durable transcript. It subscribes to the whole buffer; selecting only the Host -execution root would miss late predecessor content. AppShell continues to use -the low-frequency summary for its chrome. - -Successful send preparation publishes a one-shot viewport command through the -Session UI controller. The message surface forwards that port to ChatView, -where the scroll authority follows the tail. History catches up in the background -so local Message admission does not wait for it. Message growth and bookmark -updates do not replay the command; the range controller rejects stale catch-up results. -Accepted store updates reach the message surface through the existing transcript -projection; navigation completion only settles bookmark state. - -Running Turns have no durable sequence in the RuntimeEvent transcript. Reading -intent therefore carries the Turn ID until persistence supplies its sequence; -later Turns must not displace it just because it began in the live projection. - -The feature does not access the Desktop bridge. AppShell supplies bounded-range -and landmark ports plus current Session and controller identities; the feature -rejects stale completions against those identities. Session Navigation supplies explicit navigation intent -only; it does not own transcript state. +`ConversationProvider` owns the renderer's single Conversation workspace. The +Session Catalog remains the authority for requested selection and Host rows; +Conversation owns published transcript identity, durable/transient presentation, +load/retry state, and its private Session UI controller. + +`ConversationLifecycle` owns transcript open/publication/disposal, event seed and +retry, interaction hydration, ShellRun hydration, health recovery, reading +position and live-to-durable handoff. It stays mounted when the transcript view +is hidden or unmounted. `ConversationTranscriptRegion` reads the publication in +the conditional message region. `ConversationComposerRegion` reads only pending +messages and matching request usage in the persistent Composer slot. The +cross-feature approval leaf uses `ConversationMessageConsumer`. + +These components inject owned presentation directly into leaf surfaces. They +never return a full model or a render-prop result to AppShell. The private +context contains stable capabilities, not a changing publication. A content +publication does not notify Shell when its target and finite chrome facts are +unchanged, or Composer when its pending/usage projection is unchanged. + +`useAppShellSessionUiState` is now a transitional **reader/command adapter**, +not a construction hook. It exposes published/Host target identity, empty/history +facts, fixed Session reads, a keyed Stop claim, and semantic commands. Its +published Session reference is a frozen getter. It has no map setters, range +controller, publication callback, writable refs, or whole-state getter. +`readMessages()` is an invocation-time, readonly view of the **published range**; +it is used by Copy/Save and revision commands and is not a full-history promise. + +The Desktop adapter supplies `ConversationObservationServices`. The feature +never imports the Desktop range implementation or accesses `window.maka`. +Requested Session, published Session, and non-shared Host owner are distinct: +a switch retains the old picture until an admitted publication arrives; a +pending local first-send Session is shown immediately without starting Host +reads. Effect-instance and selection fences reject retired publications and +errors. Seed completion before or after the first transcript publication is +supported. Subscription retry reuses the same transcript controller; disposal +closes it, unsubscribes both streams, discards held display events and cancels +pending retries. + +Reading position and `LiveTurnReconciler` are private lifecycle components. +Reconciliation follows **every retained Turn**, including predecessors. Running +Turns have no durable transcript sequence, so reading intent keeps the Turn ID +until persistence supplies its sequence. Send preparation cancels restoration +and issues the viewport command without waiting for history reads. + +### Integration seams and redesign triggers (R2 M2 / C) + +- Composer migration keeps `activeId` as the published draft target and + `ownerActiveId` as the readable, non-shared Host target. Selection leases, + transient add/update/remove, `prepareSend`, `refreshMessages`, interaction + settlement and draft restoration are semantic ports; do not re-export the + private workspace to finish M3. Composer staging/readiness/send policy stays + with that migration. +- The visible range is not an event watermark. Bounded-window work may extend + the injected range controller and the private reading lifecycle, including + return-to-latest and full-history export commands. It must preserve one + observation owner and atomic publication of Session, rows and range metadata. +- Revisit the owner if an accepted decision introduces multiple simultaneous + conversations or replaces the Host observer. Scope one workspace to each + admitted viewer; do not add a second cache/observer in Shell or key/remount the + persistent Composer to follow transcript windows. + +`controllerOwners` fixes construction and observation at their JSX owners. +`featurePrivateModules` seals workspace/event/publication/reading internals and +context against production import or re-export outside Conversation. Tests use +`testing.ts`; no production compatibility constructor is retained. ## Session read capabilities (R2 M0/M1) @@ -71,7 +108,7 @@ 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 workspace publication hook separately reads only its published queue. +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. @@ -84,12 +121,12 @@ Remaining transitional capabilities have explicit consumers and removal work: | Capability | Current consumer | Removal module | | --- | --- | --- | -| Controller map setters, live-content/health/reading refs and publication | AppShell subscription wiring, workspace and transcript lifecycle | M2 Conversation owner | -| Pending claims and send/retry mutation bundle | AppShell chat actions and composer submission | M2/M3 semantic commands and persistent Composer owner | -| `useAppShellSessionUiReads` | AppShell chrome and Composer prop assembly | M2/M3 regional readers; retain only required chrome | +| Stop pending claim and semantic send/transient/interaction commands | AppShell chat actions and composer submission | M3 persistent Composer owner | +| `useAppShellSessionUiReads` | AppShell chrome and Composer prop assembly | M3 regional readers; retain only required chrome | +| Invocation-time published-message read | Copy/Save and revision commands | M3 command ownership / bounded-history export integration | -This slice does not complete their regional ownership or introduce a second -Catalog/Host observer. +M2 owns presentation and observation; it does not add a Catalog, Host cache or +execution state machine, or complete the remaining Composer migration. ## Plan ownership diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts deleted file mode 100644 index 1a6ceb5608..0000000000 --- a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts +++ /dev/null @@ -1,187 +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 { useRef, useState } from 'react'; -import type { StoredMessage } from '@maka/core/session'; -import { valuesEqual, type TransientUserMessageProjection } from '@maka/ui'; -import type { DesktopSessionSummary } from '../../../../shared/desktop-session-projection.js'; -import { currentTranscriptRange } from './transcript-reading-position.js'; -import { createAppShellSessionUiStateController, type AppShellSessionUiStateController } from '../model/session-ui-state.js'; -import { useSessionUiRead } from './use-session-ui-read.js'; -import { useSessionMessageQueue } from './use-session-message-queue.js'; -import { - selectSessionById, - type SessionCatalogController, -} from '../../../application/contracts/session-catalog/session-catalog-state.js'; -import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; - -/** - * Catalog bookkeeping that republishes at event rate but renders only in the - * rail — ordering, unread and flag markers, preview text, admission revision. - * Nothing under the shell's whole-row read renders them, so a patch that moves - * only these fields must not re-render the whole chat surface. Every other - * field still compares, and a row field added later republishes until someone - * proves it belongs here — the failure direction is a re-render, not a stale - * value the UI swears is current. - */ -const NON_RENDERED_ROW_KEYS = { - activityAt: true, - hasUnread: true, - isFlagged: true, - lastMessagePreview: true, - localCreatedAt: true, - revision: true, - statusUpdatedAt: true, - subagentRuntime: true, -} satisfies Partial>; - -export function shellSessionRowEqual( - a: DesktopSessionSummary | undefined, - b: DesktopSessionSummary | undefined, -): boolean { - if (a === b) return true; - if (a === undefined || b === undefined) return false; - const keys = new Set([ - ...(Object.keys(a) as (keyof DesktopSessionSummary)[]), - ...(Object.keys(b) as (keyof DesktopSessionSummary)[]), - ]); - for (const key of keys) { - if (key in NON_RENDERED_ROW_KEYS) continue; - if (!valuesEqual(a[key], b[key])) return false; - } - return true; -} - -interface TranscriptSource { - range(): { readonly sessionId: string; readonly hasOlder: boolean }; - snapshot(): { readonly messages: readonly StoredMessage[]; readonly ready: boolean }; -} - -export type TranscriptPublisher = ( - sessionId: string, - controller: Controller, - isCurrent: () => boolean, - onReady: () => void, -) => void; - -/** The rendered messages and the earlier-history flag are a single publication. */ -export function useAppShellSessionUiState< - Controller extends { readonly store: TranscriptSource }, ->( - catalog: SessionCatalogController, - requestedSessionId: string | undefined, - activeIdRef: { current: string | undefined }, - commitTranscript: (sessionId: string, messages: StoredMessage[], controller: Controller) => boolean, -) { - // The observable controller retains its own identity and subscriptions; - // publication is the React view of the active transcript, not a store copy. - const controllerRef = useRef(null); - controllerRef.current ??= createAppShellSessionUiStateController(); - const controller = controllerRef.current; - const transcriptRangeRef = useRef(undefined); - const messagesRef = useRef([]); - const transientMessagesBySessionRef = useRef( - new Map>(), - ); - const [transientMessages, setTransientMessagesState] = useState([]); - const [messageLoadPending, setMessageLoadPending] = useState(false); - const [view, setView] = useState<{ - sessionId: string | undefined; - messages: StoredMessage[]; - range: ReturnType | undefined; - }>({ sessionId: undefined, messages: [], range: undefined }); - - // These actions capture only lifetime-stable refs, setters and the workspace - // callback that dispatches through its actions ref. Keep their identities as - // stable as the other workspace actions consumers receive. - const [actions] = useState(() => ({ - isMessagePublished: (message: StoredMessage) => messagesRef.current.includes(message), - setMessagesState(messages: StoredMessage[]) { - setView({ - sessionId: activeIdRef.current, - messages, - range: messages.length - ? currentTranscriptRange(transcriptRangeRef.current, activeIdRef.current) - : undefined, - }); - }, - publishTranscript( - sessionId: string, - rangeController: Controller, - isCurrent: () => boolean, - onReady: () => void, - ) { - if (!isCurrent()) return; - const snapshot = rangeController.store.snapshot(); - if (!snapshot.ready || !commitTranscript(sessionId, [...snapshot.messages], rangeController)) return; - onReady(); - }, - })); - - const queue = useSessionUiRead(controller.reads, 'queue', view.sessionId); - const messageQueueSurface = useSessionMessageQueue({ - sessionId: view.sessionId, - queue, - transientMessages, - activeSessionId: activeIdRef, - }); - const activeCatalogSession = useExternalStoreSelector( - catalog, - selectSessionById, - view.sessionId, - shellSessionRowEqual, - ); - const requestedCatalogSession = useExternalStoreSelector( - catalog, - selectSessionById, - requestedSessionId, - shellSessionRowEqual, - ); - // Locally staged tasks cannot admit Host reads until creation completes. - const activeHostSession = activeCatalogSession?.localState !== 'pending' ? activeCatalogSession : undefined; - const requestedHostSession = requestedCatalogSession?.localState !== 'pending' ? requestedCatalogSession : undefined; - const sharedSessionActive = activeCatalogSession?.shared === true; - - return { - controller, - publication: { - transcriptRangeRef, - messagesRef, - messages: view.messages, - publishedTranscriptRange: view.range, - ...actions, - }, - display: { - activeId: view.sessionId, - transientMessages, - transientMessagesBySessionRef, - setTransientMessagesState, - messageLoadPending, - setMessageLoadPending, - activeCatalogSession, - activeHostSession, - requestedCatalogSession, - requestedHostSession, - sharedSessionActive, - ownerActiveId: sharedSessionActive ? undefined : activeHostSession?.id, - switchingSession: view.sessionId !== requestedSessionId, - queueSurface: messageQueueSurface, - }, - }; -} 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 new file mode 100644 index 0000000000..ea390d68fd --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-controller.ts @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useLayoutEffect, useRef, useState } from 'react'; +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 { createConversationWorkspace } from '../model/conversation-workspace.js'; +import { useConversationServices } from '../services.js'; + +/** Constructed only by ConversationProvider; mutable publication never leaves it. */ +export function useConversationController() { + const catalog = useSessionCatalogController(); + const services = useConversationServices(); + const locale = useUiLocale(); + const toast = useToast(); + const feedback = useRef({ locale, toast }); + useLayoutEffect(() => { feedback.current = { locale, toast }; }, [locale, toast]); + const [workspace] = useState(() => createConversationWorkspace(catalog, services.observation)); + const interactionHydration = useRef<{ sessionId: string } | null>(null); + const events = useRef(null); + const readingCommands = useRef(null); + const [commands] = useState(() => { + return { + ...workspace.commands, + clearMessageLoadError: workspace.ui.clearMessageLoadError, + markInteractionChanged(sessionId: string) { + if (interactionHydration.current?.sessionId === sessionId) interactionHydration.current = null; + }, + settleInteraction(sessionId: string, requestId: string) { + workspace.ui.setInteractionBySession((current) => dequeueInteractionByRequestId(current, sessionId, requestId)); + }, + recordSessionChange(sessionId: string, ts: number) { + workspace.ui.setSessionEventHealthBySession((current) => { + const previous = current[sessionId]; + return previous ? { ...current, [sessionId]: recordSessionEventStreamChange(previous, ts) } : current; + }); + }, + ...createTranscriptCommands(workspace, feedback), + clearOwnedSessionState(sessionId: string) { + events.current?.discardDisplayEvents(sessionId); + workspace.commands.clearOwnedSessionState(sessionId); + }, + settleAssistantStreaming: async (sessionId: string, messageId?: string) => events.current?.settleAssistantStreaming(sessionId, messageId), + prepareSend: (sessionId: string) => readingCommands.current?.prepareSend(sessionId) ?? true, + }; + }); + return { workspace, commands, readingCommands, events, interactionHydration }; +} diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts new file mode 100644 index 0000000000..bfd4e33ef3 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-observation.ts @@ -0,0 +1,193 @@ +/* + * 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 { useEffectEvent, useLayoutEffect } from 'react'; +import type { UiLocale } from '@maka/core/ui-locale'; +import type { SessionEvent } from '@maka/core/events'; +import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; +import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; +import { transcriptErrorMessage } from '../../../application/contracts/transcript-copy.js'; +import { createSessionEventStreamSubscription, recordSessionEventStreamEvent } from '../../../application/contracts/session-catalog/session-event-health.js'; +import type { ConversationTranscriptController, ConversationObservationServices } from '../transcript-ports.js'; +type RefBox = { current: T }; +type SessionEventHealthUpdater = (updater: (current: Record) => Record) => void; +type TranscriptPublisher = (sessionId: string, controller: Controller, isCurrent: () => boolean, onReady: () => void) => void; +type ToastApi = { error(title: string, description?: string, diagnosticDetails?: string, target?: { sessionId: string }): void }; + +export function useConversationObservation(options: { + services: ConversationObservationServices; + uiLocale: UiLocale; + activeId: string | undefined; + observationAuthorityRevision: number; + activeIdRef: RefBox; + handleEvent: (sessionId: string, event: SessionEvent) => void; + setExecution: import('../model/session-ui-state.js').AppShellSessionUiStateController['setExecution']; + endObservation(sessionId: string): void; + beginObservationSeed: (sessionId: string) => () => void; + setMessageLoadErrorBySession: (updater: (current: Record) => Record) => void; + clearMessageLoadError(sessionId: string): void; + setMessageLoadPending: (pending: boolean) => void; + commitTranscript: import('../model/session-workspace-actions.js').SessionWorkspaceActions['commitTranscript']; + publishTranscript: TranscriptPublisher< + ConversationTranscriptController + >; + transcriptRangeRef: RefBox; + setSessionEventHealthBySession: SessionEventHealthUpdater; + toastApi: Pick; +}) { + const activeId = options.activeId; + const clearMessageLoadError = useEffectEvent(options.clearMessageLoadError); + // Publication rechecks both the requested Session and the effect instance + // after any reader input wait before handing over the displayed transcript. + const applyTranscript = useEffectEvent(( + sessionId: string, + controller: ConversationTranscriptController, + effectIsCurrent: () => boolean, + ) => { + options.publishTranscript(sessionId, controller, effectIsCurrent, () => { + clearMessageLoadError(sessionId); + options.setMessageLoadPending(false); + }); + }); + const applyReadError = useEffectEvent((sessionId: string, error: unknown) => { + if (options.activeId === sessionId) { + if (options.activeIdRef.current !== sessionId) options.commitTranscript(sessionId, []); + const message = transcriptErrorMessage(error, options.uiLocale, 'read'); + options.setMessageLoadErrorBySession((current) => ({ + ...current, + [sessionId]: message, + })); + options.setMessageLoadPending(false); + options.toastApi.error( + getDesktopConversationCopy(options.uiLocale).actions.messageReadFailedTitle, + message, + undefined, + { sessionId }, + ); + } + }); + const handleSessionEvent = useEffectEvent((sessionId: string, event: SessionEvent) => { + options.setSessionEventHealthBySession((current) => { + const previous = current[sessionId]; + if (!previous) return current; + return { + ...current, + [sessionId]: recordSessionEventStreamEvent(previous, Date.now()), + }; + }); + options.handleEvent(sessionId, event); + }); + const beginObservationSeed = useEffectEvent(options.beginObservationSeed); + const markSessionEventStreamClosed = useEffectEvent((sessionId: string) => { + options.setSessionEventHealthBySession((current) => { + const previous = current[sessionId]; + if (!previous) return current; + return { + ...current, + [sessionId]: { + ...previous, + status: 'closed', + checkedAt: Date.now(), + staleSince: undefined, + }, + }; + }); + }); + + useLayoutEffect(() => { + if (!activeId) return; + let disposed = false; + let observationAttempt = 0; + let observationFailures = 0; + let observationRetryTimer: ReturnType | undefined; + let unsubscribeSessionEvents = () => {}; + clearMessageLoadError(activeId); + options.setSessionEventHealthBySession((current) => ({ + ...current, + [activeId]: createSessionEventStreamSubscription({ sessionId: activeId, now: Date.now() }), + })); + const controller = options.services.openTranscript(activeId, (error) => { + if (!disposed) applyReadError(activeId, error); + }); + const transcript = controller.store; + const unsubscribeTranscript = transcript.subscribe(() => + applyTranscript(activeId, controller, () => !disposed)); + // The range becomes readable only after its first accepted batch. + // Opening a controller is not a publication/readiness signal. + const subscribeSessionEvents = () => { + const attempt = ++observationAttempt; + let completeObservationSeed = beginObservationSeed(activeId); + let unsubscribeRequested = false; + let unsubscribeCurrent = () => { + unsubscribeRequested = true; + }; + const unsubscribe = options.services.subscribeEvents( + activeId, + (event) => { + if (attempt !== observationAttempt) return; + handleSessionEvent(activeId, event); + }, + (phase) => { + if (attempt !== observationAttempt) return; + controller.observationChanged(phase); + if (phase === 'pending') completeObservationSeed = beginObservationSeed(activeId); + else { + observationFailures = 0; + completeObservationSeed(); + } + }, + () => { + if (attempt !== observationAttempt) return; + controller.observationChanged('pending'); + options.setExecution(activeId, undefined); + unsubscribeCurrent(); + observationFailures += 1; + const retryDelayMs = Math.min(100 * (2 ** (observationFailures - 1)), 2_000); + observationRetryTimer = globalThis.setTimeout(() => { + observationRetryTimer = undefined; + if (!disposed && attempt === observationAttempt) subscribeSessionEvents(); + }, retryDelayMs); + }, + (projection) => { + if (attempt === observationAttempt) options.setExecution(activeId, projection); + }, + ); + unsubscribeCurrent = unsubscribe; + unsubscribeSessionEvents = unsubscribe; + if (unsubscribeRequested) unsubscribe(); + }; + subscribeSessionEvents(); + return () => { + disposed = true; + options.endObservation(activeId); + observationAttempt += 1; + if (observationRetryTimer !== undefined) { + globalThis.clearTimeout(observationRetryTimer); + } + if (options.transcriptRangeRef.current?.store === transcript) { + options.transcriptRangeRef.current = undefined; + } + void controller.close(); + unsubscribeTranscript(); + unsubscribeSessionEvents(); + options.setExecution(activeId, undefined); + markSessionEventStreamClosed(activeId); + }; + }, [activeId, options.observationAuthorityRevision]); +} diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-conversation-recovery.ts b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-recovery.ts new file mode 100644 index 0000000000..893a0d3365 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-recovery.ts @@ -0,0 +1,173 @@ +/* + * 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 { useEffect, useEffectEvent } from 'react'; +import type { SessionSummary } from '@maka/core/session'; +import { sessionExpectsEventStream, type SessionEventStreamSnapshot } from '@maka/core/session-event-health'; +import { evaluateSessionEventStreamSnapshot } from '../../../application/contracts/session-catalog/session-event-health.js'; +import { mergeShellRunNotification, mergeShellRunUpdates, ShellRunHydration, type ShellRunUpdatesBySession } from '../../../application/contracts/shell-run-update-state.js'; +import type { ConversationObservationServices } from '../transcript-ports.js'; +type RefBox = { current: T }; +type SessionEventHealthUpdater = (updater: (current: Record) => Record) => void; +export function useShellRunUpdates(options: { + services: ConversationObservationServices; + activeId: string | undefined; + hydrate?: boolean; + setShellRunUpdatesBySession: (updater: (current: ShellRunUpdatesBySession) => ShellRunUpdatesBySession) => void; +}) { + const applyUpdates = useEffectEvent( + (sessionId: string, updates: Awaited>) => { + options.setShellRunUpdatesBySession((current) => { + const active = current[sessionId]; + const retained = active ? { [sessionId]: active } : {}; + return mergeShellRunUpdates( + retained, + updates.filter((update) => update.sessionId === sessionId), + ); + }); + }, + ); + + useEffect(() => { + const sessionId = options.activeId; + options.setShellRunUpdatesBySession((current) => { + if (!sessionId) return {}; + const active = current[sessionId]; + return active ? { [sessionId]: active } : {}; + }); + if (!sessionId) return; + + let disposed = false; + let retryTimer: ReturnType | undefined; + let retryDelayMs = 250; + const hydration = new ShellRunHydration(); + if (options.hydrate === false) hydration.commit(0); + const unsubscribe = options.services.shellRuns.subscribeUpdates((update) => { + if (disposed) return; + const live = hydration.accept(update); + if (live) { + options.setShellRunUpdatesBySession((current) => + mergeShellRunNotification(current, sessionId, live), + ); + } + }); + const hydrate = (epoch: number) => { + if (options.hydrate === false) return; + void options.services.shellRuns + .list(sessionId) + .then((updates) => { + if (disposed) return; + const buffered = hydration.commit(epoch); + if (!buffered) return; + applyUpdates(sessionId, updates); + retryDelayMs = 250; + for (const update of buffered.updates) { + options.setShellRunUpdatesBySession((current) => mergeShellRunNotification(current, sessionId, update)); + } + if (buffered.overflowed) hydrate(epoch); + }) + .catch(() => { + if (disposed || !hydration.isCurrent(epoch)) return; + retryTimer = globalThis.setTimeout(() => { + retryTimer = undefined; + hydrate(epoch); + }, retryDelayMs); + retryDelayMs = Math.min(retryDelayMs * 2, 5_000); + }); + }; + const unsubscribeResync = options.services.shellRuns.subscribeResync((event) => { + if (disposed || options.hydrate === false || event.sessionId !== sessionId) return; + const epoch = hydration.begin(); + retryDelayMs = 250; + if (retryTimer !== undefined) { + globalThis.clearTimeout(retryTimer); + retryTimer = undefined; + } + hydrate(epoch); + }); + if (options.hydrate !== false) hydrate(hydration.begin()); + return () => { + disposed = true; + if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer); + unsubscribe(); + unsubscribeResync(); + }; + }, [options.activeId, options.hydrate]); +} + +export function useSessionEventHealthPolling(options: { + services: ConversationObservationServices; + activeId: string | undefined; + activeInteraction: { requestId: string } | undefined; + activeSession: SessionSummary | undefined; + activeStreamingLive: boolean; + hasInFlightLiveTools: boolean; + refreshMessages: (sessionId: string) => Promise; + refreshSessions: () => Promise; + sessionEventHealthBySessionRef: RefBox>; + setSessionEventHealthBySession: SessionEventHealthUpdater; +}) { + const { + activeId, + activeInteraction, + activeSession, + activeStreamingLive, + hasInFlightLiveTools, + refreshMessages, + refreshSessions, + sessionEventHealthBySessionRef, + setSessionEventHealthBySession, + } = options; + + useEffect(() => { + if (!activeId) return; + const hasLiveActivity = activeStreamingLive || hasInFlightLiveTools || Boolean(activeInteraction); + const evaluate = () => { + const result = evaluateSessionEventStreamSnapshot({ + previous: sessionEventHealthBySessionRef.current[activeId], + now: Date.now(), + sessionStatus: activeSession?.status, + hasLiveActivity, + }); + if (!result.snapshot) return; + setSessionEventHealthBySession((current) => ({ + ...current, + [activeId]: result.snapshot!, + })); + if (result.shouldRefresh) { + void refreshSessions(); + void refreshMessages(activeId); + } + }; + // #1979: a stream nobody expects has nothing to observe — `evaluate` can only + // derive `closed` and can never ask for a refresh, and no one renders either + // field. So an idle session gets no probe at all, not merely a cheaper one. + // Both inputs to `expected` are deps of this effect, so a session that starts + // running re-arms on its own; `markSessionEventStreamClosed` still records the + // closed stream when the subscription itself goes away. + if (!sessionExpectsEventStream(activeSession?.status, hasLiveActivity)) return; + evaluate(); + const interval = globalThis.setInterval(evaluate, 5_000); + const unsubscribeVisible = options.services.subscribeVisible(evaluate); + return () => { + globalThis.clearInterval(interval); + unsubscribeVisible(); + }; + }, [activeId, activeSession?.status, activeStreamingLive, hasInFlightLiveTools, activeInteraction?.requestId]); +} 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 new file mode 100644 index 0000000000..f80fa9e378 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-conversation-target.ts @@ -0,0 +1,67 @@ +/* + * 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 { 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'; + +/** Shell gets target identity and finite chrome facts, never a transcript projection. */ +export function useConversationTarget() { + const { workspace, commands } = useConversationOwner(); + const catalog = useSessionCatalogController(); + const activeId = useSyncExternalStore(workspace.target.subscribe, workspace.target.getSnapshot); + const chrome = useSyncExternalStore(workspace.chrome.subscribe, workspace.chrome.getSnapshot); + const requestedSessionId = useExternalStoreSelector(catalog, selectActiveSessionId); + const activeCatalogSession = useExternalStoreSelector(catalog, selectSessionById, activeId, shellSessionRowEqual); + const activeHostSession = activeCatalogSession?.localState !== 'pending' ? activeCatalogSession : undefined; + const sharedSessionActive = activeCatalogSession?.shared === true; + return { + setActiveId: commands.setActiveId, + startNewSession: commands.startNewSession, + clearOwnedSessionState: commands.clearOwnedSessionState, + captureSelection: commands.captureSelection, + isSessionSelected: commands.isSessionSelected, + retiredSessionIds: commands.retiredSessionIds, + readSelectionRevision: commands.readSelectionRevision, + addTransientMessage: commands.addTransientMessage, + updateTransientMessage: commands.updateTransientMessage, + removeTransientMessage: commands.removeTransientMessage, + readMessages: commands.readMessages, + refreshMessages: commands.refreshMessages, + prepareSend: commands.prepareSend, + clearMessageLoadError: commands.clearMessageLoadError, + markInteractionChanged: commands.markInteractionChanged, + settleInteraction: commands.settleInteraction, + recordSessionChange: commands.recordSessionChange, + activeId, + activeIdRef: workspace.publishedSession, + bootstrapSelectionLease: workspace.bootstrapSelectionLease, + activeCatalogSession, activeHostSession, sharedSessionActive, + ownerActiveId: sharedSessionActive ? undefined : activeHostSession?.id, + switchingSession: activeId !== requestedSessionId, + transcriptEmpty: chrome.empty, + transcriptHasHistory: chrome.hasHistory, + queueSurface: useConversationQueueCommands(), + sessionUiReads: workspace.ui.reads, + stopPendingClaims: workspace.ui.stopPending, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 17cebac3f6..11a46dceec 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -17,22 +17,6 @@ * under the License. */ -import { - currentTranscriptRange, - transcriptRestoreTarget, -} from './controller/transcript-reading-position.js'; - -export const transcriptReadingPosition = { - currentRange: currentTranscriptRange, - restoreTarget: transcriptRestoreTarget, -}; - -export { - TranscriptReadingPositionController, - type TranscriptReadingPositionCommands, - type TranscriptTurnIndex, -} from './controller/transcript-reading-position-controller.js'; - export { deriveTaskReadinessNotice, isTaskSubmissionHardBlocked, @@ -41,7 +25,6 @@ export { } from './model/task-readiness-notice.js'; export type { AppShellSessionUiStateController, - MessageQueueUiState, SessionPendingClaim, } from './model/session-ui-state.js'; export type { SessionUiReads } from './model/session-ui-reads.js'; @@ -49,11 +32,7 @@ export { useAppShellSessionUiReads } from './controller/use-session-ui-reads.js' export type { ConversationHostChange, ConversationServices } from './ports.js'; export { ConversationServicesProvider } from './services.js'; export { SessionLocalMessages } from './controller/session-local-messages.js'; -export { createConversationDisplayFrameScheduler } from './controller/display-frame-scheduler.js'; -export { - useAppShellSessionUiState, - type TranscriptPublisher, -} from './controller/use-app-shell-session-ui-state.js'; + export { useComposerAttachments, type ComposerAttachmentService } from './controller/use-composer-attachments.js'; export { type PendingAttachment, toSubmittedAttachments } from '@maka/ui/composer-attachments'; @@ -77,7 +56,6 @@ export { } from './ui/composer-mentions-provider.js'; export { activeHostTurn, chatTurnActivity } from '../../application/contracts/session-execution.js'; -export { LiveTurnReconciler } from './controller/live-turn-reconciler.js'; export { sessionIdSetsEqual, type LiveTurnSnapshot } from './model/live-turn-snapshot.js'; export { createAppShellQueueActions } from './controller/app-shell-queue-actions.js'; export { @@ -98,3 +76,12 @@ export { PlanProvider } from './ui/plan-provider.js'; export { PlanChatView, PlanExecutionSurface } from './ui/plan-surfaces.js'; export { PlanServicesProvider } from './plan-services.js'; export type { PlanServices } from './plan-ports.js'; + +export type { ConversationObservationServices } from './transcript-ports.js'; + +export { ConversationProvider } from './ui/conversation-provider.js'; +export { useConversationTarget as useAppShellSessionUiState } from './controller/use-conversation-target.js'; + +export { ConversationLifecycle } from './ui/conversation-lifecycle.js'; + +export { ConversationTranscriptRegion, ConversationComposerRegion, ConversationMessageConsumer } from './ui/conversation-readers.js'; diff --git a/apps/desktop/src/renderer/features/conversation/model/conversation-catalog-row.ts b/apps/desktop/src/renderer/features/conversation/model/conversation-catalog-row.ts new file mode 100644 index 0000000000..f681631340 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/conversation-catalog-row.ts @@ -0,0 +1,58 @@ +/* + * 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 { valuesEqual } from '@maka/ui'; +import type { DesktopSessionSummary } from '../../../../shared/desktop-session-projection.js'; + +/** + * Catalog bookkeeping that republishes at event rate but renders only in the + * rail — ordering, unread and flag markers, preview text, admission revision. + * Nothing under the shell's whole-row read renders them, so a patch that moves + * only these fields must not re-render the whole chat surface. Every other + * field still compares, and a row field added later republishes until someone + * proves it belongs here — the failure direction is a re-render, not a stale + * value the UI swears is current. + */ +const NON_RENDERED_ROW_KEYS = { + activityAt: true, + hasUnread: true, + isFlagged: true, + lastMessagePreview: true, + localCreatedAt: true, + revision: true, + statusUpdatedAt: true, + subagentRuntime: true, +} satisfies Partial>; + +export function shellSessionRowEqual( + a: DesktopSessionSummary | undefined, + b: DesktopSessionSummary | undefined, +): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + const keys = new Set([ + ...(Object.keys(a) as (keyof DesktopSessionSummary)[]), + ...(Object.keys(b) as (keyof DesktopSessionSummary)[]), + ]); + for (const key of keys) { + if (key in NON_RENDERED_ROW_KEYS) continue; + if (!valuesEqual(a[key], b[key])) return false; + } + return true; +} diff --git a/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts b/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts new file mode 100644 index 0000000000..258ac69b7b --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts @@ -0,0 +1,153 @@ +/* + * 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 { INITIAL_LIVE_CONTENT_SEED, visibleLiveContentGeneration, type LiveContentSeedState } from './observation-visibility.js'; +import { currentTranscriptRange } from '../controller/transcript-reading-position.js'; +import { selectLatestRequestUsage } from '../../../application/contracts/session-inspector/latest-request-usage.js'; +import type { StoredMessage } from '@maka/core/session'; +import type { TransientUserMessageProjection } from '@maka/ui'; +import type { SessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import type { SnapshotReader } from '../../../application/contracts/snapshot-reader.js'; +import { createBootstrapSelectionLease } from '../../../application/contracts/bootstrap-selection-lease.js'; +import { hasNewTaskReloadIntent } from '../../../application/contracts/new-task-reload-intent.js'; +import { createSessionWorkspaceActions } from './session-workspace-actions.js'; +import { createAppShellSessionUiStateController } from './session-ui-state.js'; +import type { ConversationObservationServices, ConversationTranscriptController } from '../transcript-ports.js'; + +export interface ConversationPublication { + readonly sessionId: string | undefined; + readonly messages: StoredMessage[]; + readonly transientMessages: TransientUserMessageProjection[]; + readonly range: ReturnType | undefined; + readonly loading: boolean; + readonly seedGeneration: number; + readonly turnIndex?: import('../controller/transcript-reading-position-controller.js').TranscriptTurnIndex; +} + +/** One publication authority. Fixed projections notify only their actual readers. */ +export function createConversationWorkspace(catalog: SessionCatalogController, services: ConversationObservationServices) { + const ui = createAppShellSessionUiStateController(); + const activeIdRef = { current: undefined as string | undefined }; + const messagesRef = { current: [] as StoredMessage[] }; + const transcriptRangeRef = { current: undefined as ConversationTranscriptController | undefined }; + const transientMessagesBySessionRef = { current: new Map>() }; + const selectionRevisionRef = { current: 0 }; + let seed = INITIAL_LIVE_CONTENT_SEED; + let state: ConversationPublication = { + sessionId: undefined, messages: [], transientMessages: [], range: undefined, loading: false, seedGeneration: 0, + }; + const projections = new Set<() => (() => void) | undefined>(); + let transactionDepth = 0; + const publish = () => { + if (transactionDepth) return; + // Every cache is refreshed before any listener can read another projection. + const notifications = [...projections].flatMap((refresh) => refresh() ?? []); + notifications.forEach((notify) => notify()); + }; + const update = (patch: Partial) => { state = { ...state, ...patch }; publish(); }; + const transaction = (action: (...args: Args) => Result) => (...args: Args): Result => { + transactionDepth += 1; + try { return action(...args); } finally { transactionDepth -= 1; publish(); } + }; + function reader(select: (value: ConversationPublication) => T, equal: (a: T, b: T) => boolean = Object.is): SnapshotReader { + let snapshot = select(state); + const listeners = new Set<() => void>(); + const refresh = () => { + const next = select(state); + if (equal(snapshot, next)) return; + snapshot = next; + return () => [...listeners].forEach((notify) => notify()); + }; + return { + getSnapshot() { refresh(); return snapshot; }, + subscribe(listener) { + const registration = () => listener(); + listeners.add(registration); + projections.add(refresh); + return () => { listeners.delete(registration); if (!listeners.size) projections.delete(refresh); }; + }, + }; + } + const raw = createSessionWorkspaceActions({ + activeIdRef, messagesRef, transcriptRangeRef, transientMessagesBySessionRef, selectionRevisionRef, + readRequestedSessionId: () => catalog.getState().activeSessionId, + isReadableSession: (id) => catalog.getState().sessions.some((row) => row.id === id && row.localState !== 'pending'), + setActiveIdState: catalog.setActiveSessionId, + setMessagesState: (messages) => update({ sessionId: activeIdRef.current, messages, seedGeneration: visibleLiveContentGeneration(seed, activeIdRef.current), + range: messages.length ? currentTranscriptRange(transcriptRangeRef.current, activeIdRef.current) : undefined }), + setTransientMessagesState: (transientMessages) => { + if (transientMessages.length === state.transientMessages.length && + transientMessages.every((message, index) => message === state.transientMessages[index])) return; + update({ transientMessages }); + }, + setMessageLoadPending: (loading) => update({ loading }), + clearSessionUiState: ui.clearSessionUiState, + queryCancelledMessages: services.queryCancelledMessages, + }); + const commands = { + setActiveId: transaction(raw.setActiveId), + startNewSession: transaction(raw.startNewSession), + clearOwnedSessionState: transaction(raw.clearOwnedSessionState), + captureSelection: raw.captureSelection, + isSessionSelected: raw.isSessionSelected, + retiredSessionIds: raw.retiredSessionIds, + readSelectionRevision: raw.readSelectionRevision, + addTransientMessage: transaction(raw.addTransientMessage), + updateTransientMessage: transaction(raw.updateTransientMessage), + removeTransientMessage: transaction(raw.removeTransientMessage), + // Commands read on invocation; publication never flows through Shell render. + readMessages: (): readonly StoredMessage[] => state.messages, + }; + const bootstrapSelectionLease = createBootstrapSelectionLease({ + readActiveId: () => activeIdRef.current, + readSelectionRevision: raw.readSelectionRevision, + select: commands.setActiveId, + }); + if (hasNewTaskReloadIntent()) bootstrapSelectionLease.release(); + return { + ui, activeIdRef, transcriptRangeRef, bootstrapSelectionLease, commands, + publishedSession: Object.freeze({ get current() { return activeIdRef.current; } }), + messages: reader((value) => value.messages), + usage: (model: string | undefined, connectionId: string | undefined) => reader((value) => + selectLatestRequestUsage(value.messages, model, { llmConnectionId: connectionId })), + publication: reader((value) => value), + target: reader((value) => value.sessionId), + chrome: reader((value) => ({ + empty: value.messages.length === 0 && value.transientMessages.length === 0, + hasHistory: value.messages.some((message) => message.type === 'user' || message.type === 'assistant'), + }), (a, b) => a.empty === b.empty && a.hasHistory === b.hasHistory), + composer: reader((value) => ({ sessionId: value.sessionId, transientMessages: value.transientMessages }), + (a, b) => a.sessionId === b.sessionId && a.transientMessages === b.transientMessages), + commitTranscript: transaction(raw.commitTranscript), + retireCancelledTransientMessages: raw.retireCancelledTransientMessages, + setLoading: (loading: boolean) => update({ loading }), + setTurnIndex: (turnIndex: ConversationPublication['turnIndex']) => update({ turnIndex }), + revealSeed: (next: LiveContentSeedState) => { seed = next; update({ seedGeneration: visibleLiveContentGeneration(seed, activeIdRef.current) }); }, + publishTranscript(sessionId: string, controller: ConversationTranscriptController, isCurrent: () => boolean, onReady: () => void) { + if (!isCurrent()) return; + const snapshot = controller.store.snapshot(); + if (!snapshot.ready) return; + transaction(() => { + if (raw.commitTranscript(sessionId, [...snapshot.messages], controller)) onReady(); + })(); + }, + isMessagePublished: (message: StoredMessage) => messagesRef.current.includes(message), + }; +} +export type ConversationWorkspace = ReturnType; diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/features/conversation/model/session-events.ts similarity index 96% rename from apps/desktop/src/renderer/app-shell-session-events.ts rename to apps/desktop/src/renderer/features/conversation/model/session-events.ts index 1707cd9488..598cd8874d 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/features/conversation/model/session-events.ts @@ -29,12 +29,12 @@ import { TOOL_STREAM_MAX_TOTAL_CHARS, } from '@maka/ui'; import type { LiveTurnBuffer, LiveTurnProjection, InteractionQueues } from '@maka/ui'; -import type { RefreshMessagesOptions } from './app-shell-chat-actions.js'; -import { deriveMessageQueueProjection } from './application/contracts/message-queue-projection.js'; -import type { MessageQueueUiState } from './features/conversation/index.js'; -import * as modelConnectionErrors from './model-connection-errors.js'; -import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; -import { createConversationDisplayFrameScheduler } from './features/conversation/index.js'; +import type { RefreshMessagesOptions } from '../../../application/contracts/transcript-refresh.js'; +import { deriveMessageQueueProjection } from '../../../application/contracts/message-queue-projection.js'; +import type { MessageQueueUiState } from './session-ui-state.js'; +import * as modelConnectionErrors from '../../../application/contracts/model-connection-errors.js'; +import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; +import { createConversationDisplayFrameScheduler } from '../controller/display-frame-scheduler.js'; type RefBox = { current: T }; type StateUpdater = (updater: (current: T) => T) => void; diff --git a/apps/desktop/src/renderer/session-workspace-actions.ts b/apps/desktop/src/renderer/features/conversation/model/session-workspace-actions.ts similarity index 95% rename from apps/desktop/src/renderer/session-workspace-actions.ts rename to apps/desktop/src/renderer/features/conversation/model/session-workspace-actions.ts index a44b29f01d..75a5321366 100644 --- a/apps/desktop/src/renderer/session-workspace-actions.ts +++ b/apps/desktop/src/renderer/features/conversation/model/session-workspace-actions.ts @@ -38,12 +38,12 @@ import type { TransientUserMessageProjection } from '@maka/ui'; import { clearNewTaskReloadIntent, markNewTaskReloadIntent, -} from './application/contracts/new-task-reload-intent.js'; -import type { DesktopTranscriptRangeController } from './platform/desktop/desktop-transcript-range-store.js'; +} from '../../../application/contracts/new-task-reload-intent.js'; +import type { ConversationTranscriptController as DesktopTranscriptRangeController } from '../transcript-ports.js'; import { mergeTransientMessageProjection, reconcileTransientMessages, -} from './application/contracts/transient-message-projection.js'; +} from '../../../application/contracts/transient-message-projection.js'; type RefBox = { current: T }; @@ -82,6 +82,7 @@ export function createSessionWorkspaceActions(deps: { setTransientMessagesState: (next: TransientUserMessage[]) => void; setMessageLoadPending: (pending: boolean) => void; clearSessionUiState: (sessionId: string) => void; + queryCancelledMessages(sessionId: string, messageIds: string[]): Promise<{ cancelledMessageIds: readonly string[] }>; }): SessionWorkspaceActions { const { activeIdRef, @@ -151,7 +152,7 @@ export function createSessionWorkspaceActions(deps: { if (!pending || pending.size === 0) return; try { const messageIds = [...pending.keys()]; - const { cancelledMessageIds } = await window.maka.sessions.queryCancelledMessages( + const { cancelledMessageIds } = await deps.queryCancelledMessages( sessionId, messageIds, ); diff --git a/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts new file mode 100644 index 0000000000..3066fadda9 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/transcript-commands.ts @@ -0,0 +1,61 @@ +/* + * 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 { useToast } from '@maka/ui'; +import type { UiLocale } from '@maka/core/ui-locale'; +import type { ConversationWorkspace } from './conversation-workspace.js'; +import { transcriptErrorMessage, transcriptRefreshTitle } from '../../../application/contracts/transcript-copy.js'; +export function createTranscriptCommands(workspace: ConversationWorkspace, feedback: { current: { locale: UiLocale; toast: Pick, 'error'> } }) { + const { ui, activeIdRef, transcriptRangeRef } = workspace; + const reportError = (sessionId: string, error: unknown) => { + const { locale, toast } = feedback.current; + const message = transcriptErrorMessage(error, locale, 'refresh'); + ui.setMessageLoadErrorBySession((current) => ({ ...current, [sessionId]: message })); + toast.error(transcriptRefreshTitle(locale), message, undefined, { sessionId }); + }; + return { + async refreshMessages(sessionId: string, options: { requiredAssistantMessageId?: string } = {}): Promise { + const controller = transcriptRangeRef.current; + const isCurrent = () => activeIdRef.current === sessionId && transcriptRangeRef.current === controller; + if (!controller || !isCurrent()) return false; + try { + await controller.ready(); + if (!isCurrent()) return false; + const required = options.requiredAssistantMessageId; + if (required !== undefined && !controller.store.hasDurableMessage(required) + && !(await controller.waitForDurableMessage(required, 480))) return false; + if (!isCurrent()) return false; + const snapshot = controller.store.snapshot(); + if (snapshot.sessionId !== sessionId) return false; + ui.clearMessageLoadError(sessionId); + return required === undefined || snapshot.messages.some((message) => message.id === required && workspace.isMessagePublished(message)); + } catch (error) { + if (isCurrent()) reportError(sessionId, error); + return false; + } + }, + async retryMessages(sessionId: string) { + if (activeIdRef.current !== sessionId || !ui.messageRetryPending.claim(sessionId)) return; + const controller = transcriptRangeRef.current; + try { await controller?.reload(); } + catch (error) { if (activeIdRef.current === sessionId && transcriptRangeRef.current === controller) reportError(sessionId, error); } + finally { ui.messageRetryPending.release(sessionId); } + }, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/ports.ts b/apps/desktop/src/renderer/features/conversation/ports.ts index 83ffa98476..7f48c5baf1 100644 --- a/apps/desktop/src/renderer/features/conversation/ports.ts +++ b/apps/desktop/src/renderer/features/conversation/ports.ts @@ -55,6 +55,7 @@ export interface ConversationServices extends Pick< DesktopSessionLocalBridge, 'listMessages' | 'cancelMessage' | 'reconcileMessage' | 'subscribeChanges' > { + readonly observation: import('./transcript-ports.js').ConversationObservationServices; readonly promptSuggestions?: { generate(sessionId: string): Promise; readEnabled(): boolean; diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index 93efbdf1c7..e8dd5c1626 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -38,7 +38,7 @@ export { prepareTranscriptForSend, restoreSessionTranscriptRange, } from './controller/transcript-reading-position.js'; -export { shellSessionRowEqual } from './controller/use-app-shell-session-ui-state.js'; +export { shellSessionRowEqual } from './model/conversation-catalog-row.js'; export { type ActiveExecutionBoundarySnapshot, activeExecutionBoundaryOf, @@ -55,6 +55,7 @@ export function stubConversationServices( ): ConversationServices { const { sessions, ...rest } = overrides; return { + observation: { openTranscript() { throw new Error('Transcript observation not configured'); }, subscribeEvents: () => () => {}, listActiveInteractions: async () => [], subscribeActiveInteractions: () => () => {}, shellRuns: { list: async () => [], subscribeUpdates: () => () => {}, subscribeResync: () => () => {} }, subscribeVisible: () => () => {}, queryCancelledMessages: async () => ({ cancelledMessageIds: [] }) }, listMessages: async () => [], cancelMessage: async () => undefined, reconcileMessage: async () => undefined, @@ -88,3 +89,15 @@ export function stubConversationServices( export { usePlanModeState } from './controller/use-plan-mode-state.js'; export type { PlanModeState } from './model/plan-state.js'; export { PlanExecutionPanel } from './ui/plan-panels.js'; + +export { createSessionWorkspaceActions } from './model/session-workspace-actions.js'; + +export { createAppShellSessionDisplayBatch, createAppShellSessionEventHandlers } from './model/session-events.js'; +export { createConversationWorkspace } from './model/conversation-workspace.js'; +export { useConversationOwner } from './ui/conversation-context.js'; + +export { createTranscriptCommands } from './model/transcript-commands.js'; +export { useConversationQueue } from './ui/conversation-provider.js'; + +export { LiveTurnReconciler } from './controller/live-turn-reconciler.js'; +export { TranscriptReadingPositionController, type TranscriptReadingPositionCommands } from './controller/transcript-reading-position-controller.js'; diff --git a/apps/desktop/src/renderer/features/conversation/transcript-ports.ts b/apps/desktop/src/renderer/features/conversation/transcript-ports.ts new file mode 100644 index 0000000000..e83eaa025d --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/transcript-ports.ts @@ -0,0 +1,59 @@ +/* + * 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 { StoredMessage } from '@maka/core/session'; +import type { SessionEvent, ActiveInteractionRequestEvent, ShellRunUpdate } from '@maka/core/events'; +import type { AppShellSessionUiStateController } from './model/session-ui-state.js'; +type SessionExecutionProjection = Parameters[1]; + +/** The visible range is distinct from the event subscription and its watermark. */ +export interface ConversationTranscriptController { + readonly store: { + range(): { readonly sessionId: string; readonly hasOlder: boolean; readonly ready: boolean; readonly generation?: string }; + snapshot(): { readonly sessionId: string; readonly messages: readonly StoredMessage[]; readonly ready: boolean }; + subscribe(listener: () => void): () => void; + hasDurableMessage(messageId: string): boolean; + }; + ready(): Promise; + waitForDurableMessage(messageId: string, timeoutMs: number): Promise; + loadEarlier(throughSequence?: number): Promise; + reload(): Promise; + observationChanged(phase: 'pending' | 'ready'): void; + close(): Promise; +} + +export interface ConversationObservationServices { + openTranscript(sessionId: string, onError: (error: unknown) => void): ConversationTranscriptController; + subscribeEvents( + sessionId: string, + onEvent: (event: SessionEvent) => void, + onPhase: (phase: 'pending' | 'ready') => void, + onFailure: () => void, + onExecution: (projection: SessionExecutionProjection | undefined) => void, + ): () => void; + listActiveInteractions(sessionId: string): Promise; + subscribeActiveInteractions(handler: (event: { sessionId: string; interactions: ActiveInteractionRequestEvent[] }) => void): () => void; + readonly shellRuns: { + list(sessionId: string): Promise; + subscribeUpdates(handler: (update: ShellRunUpdate) => void): () => void; + subscribeResync(handler: (event: { sessionId: string }) => void): () => void; + }; + subscribeVisible(handler: () => void): () => void; + queryCancelledMessages(sessionId: string, messageIds: string[]): Promise<{ cancelledMessageIds: readonly string[] }>; +} diff --git a/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx index aff960b232..c2bbbfdd01 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx @@ -31,7 +31,7 @@ import { type SessionCatalogState, } from '../../../application/contracts/session-catalog/session-catalog-state.js'; import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; -import { shellSessionRowEqual } from '../controller/use-app-shell-session-ui-state.js'; +import { shellSessionRowEqual } from '../model/conversation-catalog-row.js'; import { useSessionReferenceComposer, type SessionReferenceSession, diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-context.ts b/apps/desktop/src/renderer/features/conversation/ui/conversation-context.ts new file mode 100644 index 0000000000..3271c2bc0f --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-context.ts @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext, useContext } from 'react'; +import type { useConversationController } from '../controller/use-conversation-controller.js'; +export const ConversationContext = createContext | null>(null); +export function useConversationOwner() { + const value = useContext(ConversationContext); + if (!value) throw new Error('ConversationProvider is required'); + return value; +} diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx new file mode 100644 index 0000000000..c45c1df782 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx @@ -0,0 +1,154 @@ +/* + * 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 { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { useToast, useUiLocale, reconcileInteractions } from '@maka/ui'; +import type { ContextCompactionOutcome } from '@maka/core/events'; +import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; +import { selectActiveSessionId, selectSessionById, useSessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import { transcriptErrorMessage } from '../../../application/contracts/transcript-copy.js'; +import { useStableActions } from '../../../application/contracts/use-stable-actions.js'; +import { createAppShellSessionEventHandlers, createAppShellSessionDisplayBatch } from '../model/session-events.js'; +import { useShellRunUpdates, useSessionEventHealthPolling } from '../controller/use-conversation-recovery.js'; +import { useSessionUiRead } from '../controller/use-session-ui-read.js'; +import { activeHostTurn } from '../../../application/contracts/session-execution.js'; +import { useConversationServices } from '../services.js'; +import { useConversationObservation } from '../controller/use-conversation-observation.js'; +import { TranscriptReadingPositionController } from '../controller/transcript-reading-position-controller.js'; +import { LiveTurnReconciler } from '../controller/live-turn-reconciler.js'; +import { useConversationOwner } from './conversation-context.js'; +import { INITIAL_LIVE_CONTENT_SEED, beginLiveContentSeed, ownsLiveContentSeed, revealLiveContentSeed } from '../model/observation-visibility.js'; +import { INITIAL_OBSERVATION_AUTHORITY, reconcileObservationAuthority } from '../model/observation-visibility.js'; + +export function ConversationLifecycle(props: { + refreshSessions(): Promise; + onExecutionBoundaryChanged(sessionId: string): void; + onContextCompactionOutcome(sessionId: string, turnId: string, outcome: ContextCompactionOutcome): void; + showModelSetupToast(description: string, reason?: string, diagnosticTarget?: { sessionId: string }): void; + onTurnCompleted(sessionId: string): void; + searchTarget: { sessionId: string; turnId: string; nonce?: number } | null; + clearSearchTarget(): void; + listTurnLandmarks: React.ComponentProps['listTurnLandmarks']; +}) { + const { workspace, commands, readingCommands, events, interactionHydration } = useConversationOwner(); + const { ui, activeIdRef, transcriptRangeRef } = workspace; + const view = useSyncExternalStore(workspace.publication.subscribe, workspace.publication.getSnapshot); + const catalog = useSessionCatalogController(); + const requestedId = useExternalStoreSelector(catalog, selectActiveSessionId); + const requested = useExternalStoreSelector(catalog, selectSessionById, requestedId); + const displayed = useExternalStoreSelector(catalog, selectSessionById, view.sessionId); + const authority = useRef(INITIAL_OBSERVATION_AUTHORITY); + authority.current = reconcileObservationAuthority(authority.current, { sessionId: requestedId, profileId: requested?.profileId }); + const seed = useRef(INITIAL_LIVE_CONTENT_SEED); + const uiLocale = useUiLocale(); + const toastApi = useToast(); + const services = useConversationServices(); + const hostSession = displayed?.localState !== 'pending' ? displayed : undefined; + const ownerId = displayed?.shared ? undefined : hostSession?.id; + const interaction = useSessionUiRead(ui.reads, 'interaction', ownerId); + const summary = useSessionUiRead(ui.reads, 'summary', view.sessionId); + const turn = activeHostTurn(summary.activeExecution); + const live = turn?.turnId === summary.activeLiveTurnSnapshot.turnId ? summary.activeLiveTurnSnapshot : undefined; + useEffect(() => { + if (!ownerId) return; + const pending = { sessionId: ownerId }; + interactionHydration.current = pending; + const release = () => { if (interactionHydration.current === pending) interactionHydration.current = null; }; + void services.observation.listActiveInteractions(ownerId).then((requests) => { + if (interactionHydration.current !== pending) return; + ui.setInteractionBySession((current) => reconcileInteractions(current, ownerId, requests)); + }).catch(() => {}).finally(release); + return release; + }, [ownerId, services.observation, ui, interactionHydration]); + useEffect(() => services.observation.subscribeActiveInteractions(({ sessionId, interactions }) => { + commands.markInteractionChanged(sessionId); + ui.setInteractionBySession((current) => reconcileInteractions(current, sessionId, interactions)); + }), [services.observation, ui, commands]); + useShellRunUpdates({ services: services.observation, activeId: ownerId, setShellRunUpdatesBySession: ui.setShellRunUpdatesBySession }); + useSessionEventHealthPolling({ + services: services.observation, activeId: hostSession?.id, activeSession: hostSession, + activeInteraction: interaction, + activeStreamingLive: Boolean(live?.hasStreamingText && live.streamingMessageId === undefined), + hasInFlightLiveTools: live?.hasInFlightTools ?? false, + refreshMessages: commands.refreshMessages, refreshSessions: props.refreshSessions, + sessionEventHealthBySessionRef: ui.sessionEventHealthBySessionRef, + setSessionEventHealthBySession: ui.setSessionEventHealthBySession, + }); + const [displayBatch] = useState(createAppShellSessionDisplayBatch); + const handlers = useStableActions(createAppShellSessionEventHandlers, { + ...props, uiLocale, toastApi, activeIdRef, displayBatch, + onInteractionChanged: commands.markInteractionChanged, + liveTurnBySessionRef: ui.liveTurnBySessionRef, + refreshMessages: commands.refreshMessages, + setLiveTurnBySession: ui.setLiveTurnBySession, + setInteractionBySession: ui.setInteractionBySession, + setMessageQueueBySession: ui.setMessageQueueBySession, + removeTransientMessage: commands.removeTransientMessage, + }); + useLayoutEffect(() => { + events.current = handlers; + return () => { events.current = null; }; + }, [events, handlers]); + useConversationObservation({ + services: services.observation, uiLocale, toastApi, + activeId: requested?.localState !== 'pending' ? requested?.id : undefined, + observationAuthorityRevision: authority.current.generation, + activeIdRef, transcriptRangeRef, + handleEvent: handlers.handleEvent, + publishTranscript: workspace.publishTranscript, + commitTranscript: workspace.commitTranscript, + setMessageLoadPending: workspace.setLoading, + setExecution: ui.setExecution, + setMessageLoadErrorBySession: ui.setMessageLoadErrorBySession, + clearMessageLoadError: ui.clearMessageLoadError, + setSessionEventHealthBySession: ui.setSessionEventHealthBySession, + endObservation: handlers.discardDisplayEvents, + beginObservationSeed(sessionId) { + const begun = beginLiveContentSeed(seed.current, sessionId); + seed.current = begun.state; + handlers.holdDisplayEvents(sessionId); + workspace.revealSeed(seed.current); + return () => { + if (!ownsLiveContentSeed(seed.current, begun.token)) return; + handlers.releaseDisplayEvents(sessionId); + seed.current = revealLiveContentSeed(seed.current, begun.token); + workspace.revealSeed(seed.current); + void workspace.retireCancelledTransientMessages(sessionId); + }; + }, + }); + useEffect(() => { + const sessionId = view.sessionId; + const messageId = live?.streamingMessageId; + if (!sessionId || !messageId || !view.messages.some((message) => message.type === 'assistant' && message.id === messageId)) return; + const timer = globalThis.setTimeout(() => { void handlers.settleAssistantStreaming(sessionId, messageId); }, 1000); + return () => globalThis.clearTimeout(timer); + }, [view.sessionId, view.messages, live?.streamingMessageId, handlers.settleAssistantStreaming]); + return <> + ui.setMessageLoadErrorBySession((current) => ({ ...current, [sessionId]: transcriptErrorMessage(error, uiLocale, 'read') }))} + /> + + ; +} diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx new file mode 100644 index 0000000000..1ae095cf74 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx @@ -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 { createContext, useContext, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; +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 { ConversationContext } from './conversation-context.js'; + +const QueueContext = createContext | null>(null); +const QueueCommandsContext = createContext, 'transientMessages'> | null>(null); +export function useConversationQueue() { + const value = useContext(QueueContext); + if (!value) throw new Error('ConversationProvider is required'); + return value; +} +export function useConversationQueueCommands() { + const value = useContext(QueueCommandsContext); + if (!value) throw new Error('ConversationProvider is required'); + return value; +} +export function ConversationProvider({ children }: { children: ReactNode }) { + const controller = useConversationController(); + const { workspace } = controller; + const [context] = useState(controller); + const composer = useSyncExternalStore(workspace.composer.subscribe, workspace.composer.getSnapshot); + const queue = useSessionUiRead(workspace.ui.reads, 'queue', composer.sessionId); + const surface = useSessionMessageQueue({ ...composer, queue, activeSessionId: workspace.activeIdRef }); + const current = useRef(surface); + useLayoutEffect(() => { current.current = surface; }); + const [commands] = useState(() => ({ + composer: surface.composer, + draftContextRestorer: surface.draftContextRestorer, + restoreDraft: surface.restoreDraft, + promoteQueuedEntry: (...args: Parameters) => current.current.promoteQueuedEntry(...args), + editQueuedEntry: (...args: Parameters) => current.current.editQueuedEntry(...args), + deleteQueuedEntry: (...args: Parameters) => current.current.deleteQueuedEntry(...args), + updateQueuedEntry: (...args: Parameters) => current.current.updateQueuedEntry(...args), + reorderQueuedEntries: (...args: Parameters) => current.current.reorderQueuedEntries(...args), + })); + return + + {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 new file mode 100644 index 0000000000..f45c8baaaf --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createElement, useMemo, useSyncExternalStore, type ComponentType, type ComponentProps } from 'react'; +import { ChatView } from '@maka/ui'; +import type { StoredMessage } from '@maka/core/session'; +import type { SessionUiReads } from '../model/session-ui-reads.js'; +import { useSessionUiRead } from '../controller/use-session-ui-read.js'; +import { transcriptRestoreTarget } from '../controller/transcript-reading-position.js'; +import { useConversationOwner } from './conversation-context.js'; + +type ChatProps = ComponentProps; +type TranscriptProps = Pick & { activeSessionId: string | undefined; liveContentSeedGeneration: number; sessionUiReads: SessionUiReads }; + +/** The actual transcript reader. Shell supplies presentation and navigation only. */ +export function ConversationTranscriptRegion

( + props: { surface: ComponentType

} & Omit, +) { + const { surface, ...presentation } = props; + const { workspace, commands, readingCommands } = useConversationOwner(); + const view = useSyncExternalStore(workspace.publication.subscribe, workspace.publication.getSnapshot); + const load = useSessionUiRead(workspace.ui.reads, 'load', view.sessionId); + const retryPending = useSessionUiRead(workspace.ui.reads, 'retry', view.sessionId); + const sessionId = view.sessionId; + const owned: TranscriptProps = { + activeSessionId: sessionId, + onStreamingSettled: sessionId ? (messageId) => commands.settleAssistantStreaming(sessionId, messageId) : undefined, + sessionUiReads: workspace.ui.reads, + messages: view.messages, transientMessages: view.transientMessages, + liveContentSeedGeneration: view.seedGeneration, + messageLoading: Boolean(sessionId && view.loading), + messageLoadError: load.messageLoadError, + messageLoadRetryPending: retryPending, + onRetryMessages: sessionId ? () => { void commands.retryMessages(sessionId); } : undefined, + viewportNavigation: workspace.ui.transcriptViewportNavigation, + hasEarlierHistory: view.range?.sessionId === sessionId ? view.range?.hasOlder : undefined, + onLoadEarlierHistory: () => readingCommands.current?.loadEarlier(), + transcriptTurnIndex: view.turnIndex?.sessionId === sessionId ? view.turnIndex?.turns : undefined, + 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, + }; + return createElement(surface, { ...presentation, ...owned } as unknown as P); +} + +type ComposerProps = { processing: boolean; pendingMessages: ChatProps['transientMessages']; latestRequestUsageTokens?: number }; +/** Lives in the persistent composer slot, outside the conditional transcript. */ +export function ConversationComposerRegion

( + props: { surface: ComponentType

; usageModel?: string; usageRoute?: { llmConnectionId?: string } } & Omit, +) { + const { surface, usageModel, usageRoute, ...presentation } = props; + const { workspace } = useConversationOwner(); + const view = useSyncExternalStore(workspace.composer.subscribe, workspace.composer.getSnapshot); + const usage = useMemo(() => workspace.usage(usageModel, usageRoute?.llmConnectionId), [workspace, usageModel, usageRoute?.llmConnectionId]); + const latestRequestUsageTokens = useSyncExternalStore(usage.subscribe, usage.getSnapshot); + return createElement(surface, { + ...presentation, + processing: view.transientMessages.length > 0, + pendingMessages: view.transientMessages, + latestRequestUsageTokens, + } as unknown as P); +} + +/** A cross-feature leaf can read published messages without routing them through Shell. */ +export function ConversationMessageConsumer

( + props: { surface: ComponentType

} & Omit, +) { + const { surface, ...presentation } = props; + const { workspace } = useConversationOwner(); + const messages = useSyncExternalStore(workspace.messages.subscribe, workspace.messages.getSnapshot); + return createElement(surface, { ...presentation, messages } as unknown as P); +} diff --git a/apps/desktop/src/renderer/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index 2843c93258..1ed5120bdf 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -17,69 +17,4 @@ * under the License. */ -import type { ChatConfigurationReason } from '@maka/core/connection-readiness'; -import type { SessionEvent } from '@maka/core/events'; -import type { UiLocale } from '@maka/core/ui-locale'; -import { - NO_REAL_CONNECTION_CODE, - parseNoRealConnectionError, -} from './application/contracts/connection-error-cleaner.js'; -import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; -import { localizedShellErrorMessage } from './locales/shell-copy.js'; -import { describeSessionErrorReason } from './application/contracts/session-error-presentation.js'; - -export function isNoRealConnectionError(error: unknown): boolean { - return parseNoRealConnectionError(error).matched; -} - -export function isNoRealConnectionEvent(event: Extract): boolean { - return event.code === NO_REAL_CONNECTION_CODE || parseNoRealConnectionError(event.message).matched; -} - -export function noRealConnectionReasonFromError(error: unknown): string | undefined { - return parseNoRealConnectionError(error).reason; -} - -export function noRealConnectionReasonFromEvent(event: Extract): string | undefined { - return parseNoRealConnectionError( - event.reason ? `${NO_REAL_CONNECTION_CODE}:${event.reason}` : event.message, - ).reason; -} - -export function noRealConnectionSetupDescription(reason: string | undefined, locale: UiLocale): string { - const copy = getDesktopConversationCopy(locale).model; - return reason && Object.hasOwn(copy.configurationReason, reason) - ? copy.configurationReason[reason as ChatConfigurationReason] - : copy.configurationFallback; -} - -export function sessionEventErrorMessage( - event: Extract, - locale: UiLocale, -): string { - if (isNoRealConnectionEvent(event)) { - return noRealConnectionSetupDescription(noRealConnectionReasonFromEvent(event), locale); - } - const reasonDescription = describeSessionErrorReason(event.reason, locale); - if (reasonDescription) return reasonDescription; - const fallback = getDesktopConversationCopy(locale).actions.conversationErrorFallback; - return localizedShellErrorMessage(new Error(event.message), fallback, locale); -} - -export function modelSetupToastCopy( - reason: string | undefined, - fallback: string, - locale: UiLocale, -): { title: string; description: string } { - const copy = getDesktopConversationCopy(locale).model; - if (reason === 'connection_missing') { - return { - title: copy.connectionMissingTitle, - description: noRealConnectionSetupDescription(reason, locale), - }; - } - return { - title: copy.setupTitle, - description: fallback, - }; -} +export * from './application/contracts/model-connection-errors.js'; diff --git a/apps/desktop/src/renderer/platform/desktop/create-conversation-observation-services.ts b/apps/desktop/src/renderer/platform/desktop/create-conversation-observation-services.ts new file mode 100644 index 0000000000..b0f8650283 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-conversation-observation-services.ts @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { ConversationObservationServices } from '../../features/conversation/index.js'; +import { DesktopTranscriptRangeStore, createDesktopTranscriptRangeController, openDesktopTranscriptHistory } from './desktop-transcript-range-store.js'; + +export function createDesktopConversationObservationServices( + bridge: Pick = window.maka, +): ConversationObservationServices { + return { + openTranscript(sessionId, onError) { + const store = new DesktopTranscriptRangeStore(sessionId); + return createDesktopTranscriptRangeController(store, + openDesktopTranscriptHistory(bridge.transcripts.open, sessionId, (batch) => { + try { store.accept(batch); } catch (error) { onError(error); } + }), { onError }); + }, + subscribeEvents: (...args) => bridge.sessions.subscribeEvents(...args), + listActiveInteractions: (sessionId) => bridge.sessions.listActiveInteractions(sessionId), + subscribeActiveInteractions: (handler) => bridge.sessions.subscribeActiveInteractions(handler), + shellRuns: { + list: (sessionId) => bridge.shellRuns.list(sessionId), + subscribeUpdates: (handler) => bridge.shellRuns.subscribeUpdates(handler), + subscribeResync: (handler) => bridge.shellRuns.subscribeResync(handler), + }, + subscribeVisible(handler) { + const onVisible = () => { if (document.visibilityState === 'visible') handler(); }; + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); + }, + queryCancelledMessages: (...args) => bridge.sessions.queryCancelledMessages(...args), + }; +} diff --git a/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts b/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts index ff8520f373..9c1e11a741 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts @@ -21,16 +21,19 @@ import type { MakaBridge } from '../../../preload/bridge-contract.js'; import type { ConversationServices } from '../../features/conversation/index.js'; +import { createDesktopConversationObservationServices } from './create-conversation-observation-services.js'; + const PROMPT_SUGGESTIONS_KEY = 'maka.promptSuggestions.enabled'; export function createDesktopConversationServices( bridge: Pick< MakaBridge, - 'sessionLocal' | 'sessions' | 'runtimeHostProfiles' | 'skills' | 'workspace' | 'newTasks' | 'mcp' + 'shellRuns' | 'transcripts' | 'sessionLocal' | 'sessions' | 'runtimeHostProfiles' | 'skills' | 'workspace' | 'newTasks' | 'mcp' > = window.maka, ): ConversationServices { return { ...bridge.sessionLocal, + observation: createDesktopConversationObservationServices(bridge), promptSuggestions: { generate: async (sessionId) => { const result = await bridge.sessions.generatePromptSuggestion(sessionId); diff --git a/apps/desktop/src/renderer/shell-run-update-state.ts b/apps/desktop/src/renderer/shell-run-update-state.ts index 718ac135ce..6504211c7e 100644 --- a/apps/desktop/src/renderer/shell-run-update-state.ts +++ b/apps/desktop/src/renderer/shell-run-update-state.ts @@ -17,76 +17,4 @@ * under the License. */ -import { mergeShellRunUpdate, projectShellRunUpdateForSession, ShellRunUpdateBuffer } from '@maka/core/shell-run-result'; - -import { type ShellRunUpdate } from '@maka/core/events'; - -export type ShellRunUpdatesBySession = Record>; - -export function mergeShellRunUpdates( - current: ShellRunUpdatesBySession, - updates: readonly ShellRunUpdate[], -): ShellRunUpdatesBySession { - let next = current; - for (const update of updates) { - const session = next[update.sessionId] ?? {}; - const previous = session[update.sourceToolCallId]; - const merged = mergeShellRunUpdate( - previous, - update, - 'desktop.shell-run-update-state', - ); - if (!merged.changed) continue; - if (next === current) next = { ...current }; - next[update.sessionId] = { - ...session, - [update.sourceToolCallId]: merged.update, - }; - } - return next; -} - -export function mergeShellRunNotification( - current: ShellRunUpdatesBySession, - sessionId: string, - update: ShellRunUpdate, -): ShellRunUpdatesBySession { - return mergeShellRunUpdates( - current, - projectShellRunUpdateForSession( - sessionId, - Object.values(current[sessionId] ?? {}), - update, - ), - ); -} - -export class ShellRunHydration { - readonly #pending = new ShellRunUpdateBuffer('desktop.shell-run-hydration-buffer'); - #epoch = 0; - #hydrated = false; - - begin(): number { - this.#epoch += 1; - this.#hydrated = false; - this.#pending.clear(); - return this.#epoch; - } - - isCurrent(epoch: number): boolean { - return epoch === this.#epoch; - } - - accept(update: ShellRunUpdate): ShellRunUpdate | undefined { - if (this.#hydrated) return update; - this.#pending.add(update); - return undefined; - } - - commit(epoch: number): { updates: ShellRunUpdate[]; overflowed: boolean } | undefined { - if (!this.isCurrent(epoch)) return undefined; - const pending = this.#pending.drain(); - this.#hydrated = !pending.overflowed; - return pending; - } -} +export * from './application/contracts/shell-run-update-state.js'; diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index d2367b6596..43edd6c9e1 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -17,101 +17,12 @@ * under the License. */ -import { useRef } from 'react'; -import * as Conversation from './features/conversation/index.js'; -import { - selectActiveSessionId, - useSessionCatalogController, -} from './application/contracts/session-catalog/session-catalog-state.js'; -import { useExternalStoreSelector } from './application/contracts/session-catalog/use-external-store-selector.js'; +import { useAppShellSessionUiState } from './features/conversation/index.js'; +import { useSessionCatalogController } from './application/contracts/session-catalog/session-catalog-state.js'; import { useAppShellSessionList } from './use-app-shell-session-list.js'; -import { createBootstrapSelectionLease } from './bootstrap-selection-lease.js'; -import { hasNewTaskReloadIntent } from './application/contracts/new-task-reload-intent.js'; -import type { DesktopTranscriptRangeController } from './platform/desktop/desktop-transcript-range-store.js'; -import { - createSessionWorkspaceActions, - type SessionWorkspaceActions, -} from './session-workspace-actions.js'; -type ToastApi = { - error(title: string, description?: string): void; -}; - -export function useAppShellSessionWorkspace(toastApi: ToastApi) { - // The catalog and the selection are one authority, and it is a store: the - // Session rail subscribes to it directly instead of receiving it from the - // shell's render (#4109). +export function useAppShellSessionWorkspace(toastApi: { error(title: string, description?: string): void }) { const catalog = useSessionCatalogController(); - const requestedSessionId = useExternalStoreSelector(catalog, selectActiveSessionId); - const activeIdRef = useRef(undefined); - const actionsRef = useRef(null); const sessionList = useAppShellSessionList(toastApi, { catalog }); - const { controller: sessionUiController, publication, display } = Conversation.useAppShellSessionUiState( - catalog, - requestedSessionId, - activeIdRef, - (sessionId, messages, controller: DesktopTranscriptRangeController) => - actionsRef.current!.commitTranscript(sessionId, messages, controller), - ); - const selectionRevisionRef = useRef(0); - const bootstrapSelectionLeaseRef = useRef | null>(null); - const { - messagesRef, transcriptRangeRef, setMessagesState, - messages, publishedTranscriptRange, publishTranscript, isMessagePublished, - } = publication; - const { - transientMessagesBySessionRef, - setTransientMessagesState, setMessageLoadPending, - } = display; - - // The captured publication setter only reads stable refs and writes React - // state. Along with the controller methods and other refs, it lets one - // actions instance serve the renderer's lifetime without defeating the - // Session rail's memo with new action identities on every render. - const actions = actionsRef.current ??= createSessionWorkspaceActions({ - activeIdRef, - readRequestedSessionId: () => catalog.getState().activeSessionId, - isReadableSession: (id) => catalog.getState().sessions.some( - (session) => session.id === id && session.localState !== 'pending', - ), - messagesRef, - transientMessagesBySessionRef, - transcriptRangeRef, - selectionRevisionRef, - // The store is the authority for the selection, so the factory's single - // write point goes to it rather than to a `useState` beside it. The - // controller is created once per renderer, so this identity is fixed and - // the once-created factory may capture it. - setActiveIdState: catalog.setActiveSessionId, - setMessagesState, - setTransientMessagesState, - setMessageLoadPending, - clearSessionUiState: sessionUiController.clearSessionUiState, - }); - if (!bootstrapSelectionLeaseRef.current) { - bootstrapSelectionLeaseRef.current = createBootstrapSelectionLease({ - readActiveId: () => activeIdRef.current, - readSelectionRevision: () => selectionRevisionRef.current, - select: actions.setActiveId, - }); - if (hasNewTaskReloadIntent()) bootstrapSelectionLeaseRef.current.release(); - } - - return { - ...sessionList, - sessionCatalogController: catalog, - requestedSessionId, - activeIdRef, - bootstrapSelectionLease: bootstrapSelectionLeaseRef.current, - ...actions, - messages, - publishedTranscriptRange, - publishTranscript, - isMessagePublished, - transcriptRangeRef, - ...display, - // The store's own surface, not a copy of it. Consumers reach setters and - // claims through the controller. - sessionUiController, - }; + return { ...sessionList, ...useAppShellSessionUiState(), sessionCatalogController: catalog }; } diff --git a/apps/desktop/src/renderer/use-stable-actions.ts b/apps/desktop/src/renderer/use-stable-actions.ts index 18a901c654..0fadc0ac61 100644 --- a/apps/desktop/src/renderer/use-stable-actions.ts +++ b/apps/desktop/src/renderer/use-stable-actions.ts @@ -17,54 +17,4 @@ * under the License. */ -import { useLayoutEffect, useRef, useState } from 'react'; - -/** - * The facade itself: one method per key, each forwarding to whatever - * `latestRef` holds when it is called. - * - * Factories must return a constant key shape of function values — the key set - * is fixed at creation. - */ -function createDelegatingActions(latestRef: { current: A }): A { - const facade: Record unknown> = {}; - for (const key of Object.keys(latestRef.current)) { - facade[key] = (...args: unknown[]) => { - const action = Reflect.get(latestRef.current, key) as unknown as (...args: unknown[]) => unknown; - return action(...args); - }; - } - return facade as unknown as A; -} - -/** - * Runs an app-shell action factory in the render body but returns a stable - * identity (issue #1043). - * - * The factory still runs every render, so its closures always capture the - * latest deps — identical to calling it bare. What consumers receive is a - * once-created delegating facade whose method identities never change, and it - * delegates only to the latest COMMITTED render's actions: publication happens - * in useLayoutEffect, so an interrupted or discarded concurrent render never - * leaks its closures to event handlers, timers, or subscriptions. This - * supersedes hand-rolled `handlersRef.current = handlers` mirrors, which - * published during render. - * - * `createAppShellStopAction` is deliberately NOT wrapped: it returns a bare - * function (no object to facade) and only feeds JSX props, never effect deps. - * - * The identity guarantee is asserted in `use-stable-actions.test.ts` rather - * than argued for in review — see that file for why this is a contract. - */ -export function useStableActions(factory: (deps: D) => A, deps: D): A { - const actions = factory(deps); - const latestRef = useRef(null); - // Lazy initialization is the one render-phase ref write React permits; on - // updates the ref is already populated and only the layout effect publishes. - if (latestRef.current === null) latestRef.current = actions; - useLayoutEffect(() => { - latestRef.current = actions; - }); - const [facade] = useState(() => createDelegatingActions(latestRef as { current: A })); - return facade; -} +export { useStableActions } from './application/contracts/use-stable-actions.js'; diff --git a/apps/desktop/stories/composer-slash-menu.stories.tsx b/apps/desktop/stories/composer-slash-menu.stories.tsx index dd1dc08c27..42ade72135 100644 --- a/apps/desktop/stories/composer-slash-menu.stories.tsx +++ b/apps/desktop/stories/composer-slash-menu.stories.tsx @@ -33,6 +33,7 @@ * that exists without a real Selection. */ +import { stubConversationServices } from '../src/renderer/features/conversation/testing.js'; import { useMemo, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, waitFor, within } from 'storybook/test'; @@ -149,6 +150,7 @@ const makaBridge = { }; const conversationServices: ConversationServices = { + observation: stubConversationServices().observation, listMessages: async () => [], cancelMessage: async () => undefined, reconcileMessage: async () => undefined, diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index b2b44c29e3..a9c492f280 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 315 files — blocker 0, reimplementation 0, polish 4, aligned 311. +**Totals:** 318 files — blocker 0, reimplementation 0, polish 4, aligned 314. ## Exclusions (explicit) @@ -66,6 +66,9 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/plan-panels.tsx` | shell-chrome-or-panel | Badge, Banner, Button, Collapsible | aligned — uses Astryx (Badge, Banner, Button, Collapsible) | aligned | | `apps/desktop/src/renderer/features/conversation/ui/plan-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/plan-surfaces.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 2bbb048efb..8a769e6d75 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -36,6 +36,9 @@ apps/desktop/src/renderer/features/conversation/controller/live-turn-reconciler. apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx +apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx +apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx +apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx apps/desktop/src/renderer/features/conversation/ui/plan-panels.tsx apps/desktop/src/renderer/features/conversation/ui/plan-provider.tsx apps/desktop/src/renderer/features/conversation/ui/plan-surfaces.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index eb9fb4b8c0..25cfe60ba6 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -106,7 +106,6 @@ export const ALLOWED = { }, AppShellContent: { useActiveExecutionBoundary: 1, - useActiveSessionEvents: 1, useAppShellBootstrapSubscriptions: 1, useAppShellHostEffects: 1, useAppShellNavRefSync: 1, @@ -116,11 +115,10 @@ export const ALLOWED = { useAppShellSessionWorkspace: 1, useAppShellTurnPresentation: 1, useComposerAttachments: 1, - useEffect: 6, + useEffect: 3, useLayoutEffect: 1, useNewTaskChoice: 1, useOnboardingSnapshot: 1, - useSessionEventHealthPolling: 1, // Replaces `useSessionNavigationController`, which is now called inside // `SessionNavigationProvider`. The entry shrinks rather than disappearing, // because the shell body does read the rail: the command palette lists the @@ -140,9 +138,8 @@ export const ALLOWED = { useShellLiveTurn: 1, useShellMemoryPill: 1, useShellResume: 1, - useShellRunUpdates: 1, - useStableActions: 5, - useState: 11, + useStableActions: 4, + useState: 8, useTaskSubmissionReadiness: 1, useToast: 1, // The last of the three `useKeyedPendingRegistry` call sites this entry From 7fb2baaa3a236626451fc84772ae82dd36206916 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Wed, 30 Sep 2026 18:52:09 +0800 Subject: [PATCH 2/2] fix(desktop): preserve Conversation read and error contracts Filter Catalog bookkeeping at the Conversation lifecycle, preserve restore-error copy and diagnostic scope, and test production transcript copy directly. Mark Session identity consumers readonly and remove unused legacy forwarding modules. Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 14 ++-- .../bootstrap-selection-lease.test.ts | 2 +- .../main/__tests__/conversation-owner.test.ts | 69 ++++++++++++++++--- .../expected-error-presentation.test.ts | 33 +++++++-- .../__tests__/shell-run-update-state.test.ts | 2 +- ...script-reading-position-controller.test.ts | 9 +-- .../transcript-send-viewport.test.ts | 5 +- .../src/renderer/app-shell-chat-actions.ts | 2 +- apps/desktop/src/renderer/app-shell-copy.ts | 10 --- .../desktop/src/renderer/app-shell-effects.ts | 2 +- .../renderer/app-shell-revision-actions.ts | 8 +-- .../src/renderer/app-shell-stop-action.ts | 4 +- .../src/renderer/app-shell-turn-actions.ts | 4 +- .../session-catalog/session-change-effects.ts | 2 +- .../application/contracts/transcript-copy.ts | 6 +- .../src/renderer/bootstrap-selection-lease.ts | 20 ------ .../renderer/features/conversation/README.md | 5 +- .../controller/composer-submit.ts | 2 +- ...transcript-reading-position-controller.tsx | 2 +- .../controller/transcript-reading-position.ts | 2 +- .../use-conversation-observation.ts | 2 +- .../controller/use-session-message-queue.ts | 2 +- .../conversation/model/session-events.ts | 2 +- .../ui/conversation-lifecycle.tsx | 7 +- .../src/renderer/locales/shell-copy.ts | 12 ---- .../src/renderer/shell-run-update-state.ts | 20 ------ 26 files changed, 131 insertions(+), 117 deletions(-) delete mode 100644 apps/desktop/src/renderer/bootstrap-selection-lease.ts delete mode 100644 apps/desktop/src/renderer/shell-run-update-state.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 1e121af1aa..c9ac1b3cd7 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -23,7 +23,6 @@ "src/renderer/astryx-theme/maka.js", "src/renderer/astryx-theme/makaTheme.ts", "src/renderer/attachment-preflight.ts", - "src/renderer/bootstrap-selection-lease.ts", "src/renderer/browser-storage.ts", "src/renderer/cached-theme-bootstrap.ts", "src/renderer/chat-composer-region.tsx", @@ -171,7 +170,6 @@ "src/renderer/settings/use-optimistic-settings-draft.ts", "src/renderer/settings/web-search-settings-page.tsx", "src/renderer/settled-message-merge.ts", - "src/renderer/shell-run-update-state.ts", "src/renderer/side-chat-command.ts", "src/renderer/skill-invocation-feedback.ts", "src/renderer/task-readiness-notice.ts", @@ -345,7 +343,7 @@ "@maka/core/session-name": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 2928 + "nonTriviaTokens": 2931 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 1, @@ -426,7 +424,7 @@ "@maka/core/redaction": 1 }, "importSpecifiers": 1, - "nonTriviaTokens": 391 + "nonTriviaTokens": 311 }, "src/renderer/app-shell-detail-panel.tsx": { "importDeclarations": 0, @@ -494,7 +492,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 1358 + "nonTriviaTokens": 1361 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -577,7 +575,7 @@ "@maka/core/session": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2019 + "nonTriviaTokens": 2020 }, "src/renderer/app-shell-stop-action.ts": { "importDeclarations": 0, @@ -596,7 +594,7 @@ "./locales/shell-copy.js": 1 }, "importSpecifiers": 0, - "nonTriviaTokens": 302 + "nonTriviaTokens": 292 }, "src/renderer/app-shell-turn-actions.ts": { "importDeclarations": 2, @@ -617,7 +615,7 @@ "./session-workspace-errors.js": 1 }, "importSpecifiers": 3, - "nonTriviaTokens": 571 + "nonTriviaTokens": 561 }, "src/renderer/app-shell.tsx": { "importDeclarations": 53, diff --git a/apps/desktop/src/main/__tests__/bootstrap-selection-lease.test.ts b/apps/desktop/src/main/__tests__/bootstrap-selection-lease.test.ts index 5f9be0f46f..bfb2d2dfa6 100644 --- a/apps/desktop/src/main/__tests__/bootstrap-selection-lease.test.ts +++ b/apps/desktop/src/main/__tests__/bootstrap-selection-lease.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { createBootstrapSelectionLease } from '../../renderer/bootstrap-selection-lease.js'; +import { createBootstrapSelectionLease } from '../../renderer/application/contracts/bootstrap-selection-lease.js'; import { clearNewTaskReloadIntent, hasNewTaskReloadIntent, diff --git a/apps/desktop/src/main/__tests__/conversation-owner.test.ts b/apps/desktop/src/main/__tests__/conversation-owner.test.ts index 3ec2a53224..e9cbe5bf0e 100644 --- a/apps/desktop/src/main/__tests__/conversation-owner.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -19,9 +19,10 @@ import assert from 'node:assert/strict'; import { afterEach, describe, it } from 'node:test'; -import { act, createElement, Fragment, useEffect, useState } from 'react'; +import { act, createElement, Fragment, Profiler, useEffect, useState, type ComponentProps } from 'react'; import { LocaleProvider, ToastProvider, type TransientUserMessageProjection } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; +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 } from '../../renderer/features/conversation/index.js'; @@ -36,7 +37,11 @@ const row = (id: string): DesktopSessionSummary => ({ }); const message = (id: string): StoredMessage => ({ type: 'user', id, text: id, turnId: id, ts: 1 }); -function harness() { +function harness(options: { + locale?: UiLocale; + hasOlder?: boolean; + listTurnLandmarks?: ComponentProps['listTurnLandmarks']; +} = {}) { const { root } = installReactRenderer(); const catalog = createSessionCatalogController(); catalog.commitSessions(['A', 'B', 'C'].map(row)); @@ -57,7 +62,7 @@ function harness() { opened.push(resource); return { store: { - range: () => ({ sessionId, hasOlder: false, ready, generation: 'range' }), + range: () => ({ sessionId, hasOlder: options.hasOlder ?? false, ready, generation: 'range' }), snapshot: () => { if (!ready) throw new Error('Desktop transcript range is not initialized'); return { sessionId, messages, ready }; @@ -85,6 +90,7 @@ function harness() { let composerRenders = 0; let composerMounts = 0; let composerUnmounts = 0; + let lifecycleCommits = 0; let setVisible!: (visible: boolean) => void; function Transcript(props: NonNullable) { transcript = props; transcriptRenders += 1; return null; } function Composer(_props: { processing: boolean; pendingMessages?: readonly TransientUserMessageProjection[]; latestRequestUsageTokens?: number }) { @@ -99,16 +105,16 @@ function harness() { const [visible, updateVisible] = useState(true); setVisible = updateVisible; return createElement(Fragment, null, - createElement(ConversationLifecycle, { + createElement(Profiler, { id: 'conversation-lifecycle', onRender: () => { lifecycleCommits += 1; } }, createElement(ConversationLifecycle, { refreshSessions: async () => [], onExecutionBoundaryChanged() {}, onContextCompactionOutcome() {}, showModelSetupToast() {}, onTurnCompleted() {}, - searchTarget: null, clearSearchTarget() {}, listTurnLandmarks: async () => ({ landmarks: [] }), - }), + searchTarget: null, clearSearchTarget() {}, listTurnLandmarks: options.listTurnLandmarks ?? (async () => ({ landmarks: [] })), + })), visible ? createElement(ConversationTranscriptRegion[0]>, { surface: Transcript }) : null, createElement(ConversationComposerRegion[0]>, { surface: Composer }), ); } - act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: + act(() => root.render(createElement(LocaleProvider, { locale: options.locale ?? 'en', children: createElement(ToastProvider, { children: createElement(SessionCatalogContext.Provider, { value: catalog, children: createElement(ConversationServicesProvider, { services, children: @@ -120,13 +126,60 @@ function harness() { return { root, catalog, opened, observations, get owner() { return owner; }, get target() { return target; }, get transcript() { return transcript; }, - get counts() { return { shellRenders, transcriptRenders, composerRenders, composerMounts, composerUnmounts }; }, + get counts() { return { shellRenders, transcriptRenders, composerRenders, composerMounts, composerUnmounts, lifecycleCommits }; }, showTranscript(visible: boolean) { setVisible(visible); }, }; } describe('Conversation ownership', () => { afterEach(cleanupFakeDom); + it('ignores catalog bookkeeping for both requested and displayed rows while retaining lifecycle updates', async () => { + const h = harness(); + const patchRow = (id: string, patch: Partial) => h.catalog.commitSessions( + h.catalog.getState().sessions.map((session) => session.id === id + ? { ...session, ...patch, revision: session.revision + 1 } + : session), + ); + await act(async () => h.target.setActiveId('A')); + await act(async () => h.opened[0]!.publish([message('a')])); + await act(async () => h.target.setActiveId('B')); + const before = h.counts.lifecycleCommits; + for (const id of ['A', 'B']) { + await act(async () => patchRow(id, { activityAt: 2, lastMessagePreview: 'streaming', hasUnread: true })); + assert.equal(h.counts.lifecycleCommits, before, `${id}: rail bookkeeping does not render the lifecycle`); + } + assert.equal(h.opened.length, 2); + await act(async () => patchRow('A', { status: 'running' })); + assert.ok(h.counts.lifecycleCommits > before, 'displayed status remains observable to health recovery'); + await act(async () => patchRow('B', { profileId: 'replacement-profile' })); + assert.equal(h.opened.length, 3, 'requested profile replacement reopens observation'); + assert.ok(h.opened[1]!.closed); + await act(async () => h.root.unmount()); + }); + + for (const [locale, fallback] of [ + ['en', 'The task action failed. Try again later.'], + ['zh-CN', '任务操作失败,请稍后重试。'], + ['zh-TW', '任務操作失敗,請稍後重試。'], + ] as const) { + it(`preserves the reading-position failure copy and desktop diagnostic scope (${locale})`, async (context) => { + const errors = context.mock.method(console, 'error', () => undefined); + const h = harness({ locale, hasOlder: true, listTurnLandmarks: async (_sessionId, turnId) => { + if (turnId) throw new Error('opaque landmark failure'); + return { landmarks: [] }; + } }); + await act(async () => { + h.owner.workspace.ui.setTranscriptReadingAnchor('A', { turnId: 'older' }); + h.target.setActiveId('A'); + }); + await act(async () => h.opened[0]!.publish([message('a')])); + assert.equal(h.owner.workspace.ui.reads.load('A').getSnapshot().messageLoadError, fallback); + assert.equal(errors.mock.callCount(), 1); + assert.equal(errors.mock.calls[0]!.arguments[0], '[desktop] operation failed:'); + await act(async () => h.root.unmount()); + }); + } + it('publishes only to regional readers and preserves the persistent composer across transcript remounts', async () => { const h = harness(); await act(async () => h.target.setActiveId('A')); diff --git a/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts index 5728426091..b2f240c9e6 100644 --- a/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts @@ -22,13 +22,12 @@ import test from 'node:test'; import { getWorkBoardErrorCopy } from '../../renderer/locales/work-board-error-copy.js'; import { workBoardActionErrorText } from '../../renderer/work-board-panel.js'; import { ExpectedOperationError, reportUnexpectedError } from '../../renderer/application/contracts/operation-diagnostics.js'; +import { transcriptErrorMessage, transcriptRefreshTitle } from '../../renderer/application/contracts/transcript-copy.js'; import { getSessionCollaborationCopy } from '../../renderer/locales/session-collaboration-copy.js'; import { sessionCollaborationImportErrorMessage } from '../../renderer/features/session-collaboration/testing.js'; import { commandPaletteActionErrorMessage, commandPaletteConnectionTestFailureMessage, - messageReadErrorMessage, - messageRefreshErrorMessage, openPathActionErrorMessage, } from '../../renderer/app-shell-copy.js'; import { @@ -140,8 +139,8 @@ test('routes structured collaboration failures through each locale catalog', () test('shell errors keep the generalized classifier over raw text', (context) => { context.mock.method(console, 'error', () => undefined); const raw = new Error('timeout 401 网络失败 MAKA_SESSION_READ_MESSAGES_ERROR: 后端中文'); - assert.equal(messageReadErrorMessage(raw, 'en'), 'Request timed out'); - assert.equal(messageReadErrorMessage(raw, 'zh-CN'), '请求超时'); + assert.equal(transcriptErrorMessage(raw, 'en', 'read'), 'Request timed out'); + assert.equal(transcriptErrorMessage(raw, 'zh-CN', 'read'), '请求超时'); assert.equal( commandPaletteActionErrorMessage(raw, 'English fallback', 'en'), 'Request timed out', @@ -159,7 +158,7 @@ test('shell errors keep the generalized classifier over raw text', (context) => assert.equal(localizedShellErrorMessage(unclassifiable, 'English fallback', 'en'), 'English fallback'); assert.equal(commandPaletteActionErrorMessage(unclassifiable, 'English fallback', 'en'), 'English fallback'); assert.equal( - messageReadErrorMessage(unclassifiable, 'zh-CN'), + transcriptErrorMessage(unclassifiable, 'zh-CN', 'read'), '任务内容暂时无法读取,请稍后重试。', ); }); @@ -170,8 +169,8 @@ test('every shell error-copy entry classifies, and keeps its contextual fallback const opaque = new Error('no category here'); const copy = getShellCopy('zh-CN'); - assert.equal(messageRefreshErrorMessage(timeout, 'zh-CN'), '请求超时'); - assert.equal(messageRefreshErrorMessage(opaque, 'zh-CN'), copy.errors.messageRefresh); + assert.equal(transcriptErrorMessage(timeout, 'zh-CN', 'refresh'), '请求超时'); + assert.equal(transcriptErrorMessage(opaque, 'zh-CN', 'refresh'), '任务内容暂时无法刷新,请稍后重试。'); assert.equal(openPathActionErrorMessage(timeout, 'workspace', 'zh-CN'), '请求超时'); assert.equal( @@ -192,6 +191,26 @@ test('every shell error-copy entry classifies, and keeps its contextual fallback assert.equal(errors.mock.callCount(), logged, 'a classified connection failure logs no diagnostic'); }); +test('production transcript copy keeps per-locale read/refresh fallbacks and diagnostic scopes', (context) => { + const errors = context.mock.method(console, 'error', () => undefined); + const cases = [ + ['en', 'Request timed out', 'Task content is temporarily unavailable. Try again later.', 'Task content could not be refreshed. Try again later.', 'Could not refresh task'], + ['zh-CN', '请求超时', '任务内容暂时无法读取,请稍后重试。', '任务内容暂时无法刷新,请稍后重试。', '刷新任务失败'], + ['zh-TW', '請求逾時', '任務內容暫時無法讀取,請稍後重試。', '任務內容暫時無法重新整理,請稍後重試。', '重新整理任務失敗'], + ] as const; + for (const [locale, timeout, read, refresh, title] of cases) { + for (const [kind, fallback] of [['read', read], ['refresh', refresh]] as const) { + const before = errors.mock.callCount(); + assert.equal(transcriptErrorMessage(new Error('request timeout'), locale, kind), timeout); + assert.equal(errors.mock.callCount(), before); + assert.equal(transcriptErrorMessage(new Error('opaque transcript failure'), locale, kind), fallback); + assert.equal(errors.mock.callCount(), before + 1); + assert.equal(errors.mock.calls.at(-1)!.arguments[0], `[message-${kind}] operation failed:`); + } + assert.equal(transcriptRefreshTitle(locale), title); + } +}); + test('one failure yields one diagnostic however many layers format it', (context) => { const errors = context.mock.method(console, 'error', () => undefined); const cause = new Error('an opaque backend fault'); diff --git a/apps/desktop/src/main/__tests__/shell-run-update-state.test.ts b/apps/desktop/src/main/__tests__/shell-run-update-state.test.ts index 01e10d5c83..09e0c50a55 100644 --- a/apps/desktop/src/main/__tests__/shell-run-update-state.test.ts +++ b/apps/desktop/src/main/__tests__/shell-run-update-state.test.ts @@ -24,7 +24,7 @@ import { mergeShellRunNotification, mergeShellRunUpdates, ShellRunHydration, -} from '../../renderer/shell-run-update-state.js'; +} from '../../renderer/application/contracts/shell-run-update-state.js'; test('ShellRun update state rejects a stale hydration result after a newer notification', () => { const current = mergeShellRunUpdates({}, [update(3)]); diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index 549e56cee0..145143b639 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -224,7 +224,7 @@ test('loading earlier history only reaches the current Session controller', asyn await fixture.commands.current!.loadEarlier(); assert.equal(first, 1); - fixture.props.currentSessionId.current = 'session-2'; + fixture.currentSessionId.current = 'session-2'; await fixture.commands.current!.loadEarlier(); assert.equal(first, 1, 'a superseded Session cannot load history'); @@ -255,7 +255,7 @@ test('captured reading anchors belong to the current Session and preparing a sen assert.deepEqual(followed, ['session-1']); assert.equal(anchors.current['session-1'], undefined); - fixture.props.currentSessionId.current = 'session-2'; + fixture.currentSessionId.current = 'session-2'; fixture.commands.current!.captureAnchor('turn-2'); assert.equal(anchors.current['session-1'], undefined, 'a superseded Session cannot capture an anchor'); }); @@ -289,6 +289,7 @@ test('a Turn index read that failed is read again when the transcript reopens', function controllerFixture() { const { root } = installReactRenderer(); const commands = createRef(); + const currentSessionId = { current: 'session-1' }; const controller = { loadEarlier: async () => {}, store: { @@ -299,7 +300,7 @@ function controllerFixture() { const props: ComponentProps = { commands, sessionId: 'session-1', - currentSessionId: { current: 'session-1' }, + currentSessionId, rangeController: { current: controller }, messages: [], searchTarget: undefined, @@ -311,7 +312,7 @@ function controllerFixture() { onRestoreError: (error) => assert.fail(String(error)), }; return { - commands, controller, props, + commands, controller, props, currentSessionId, render: () => act(() => root.render(createElement(TranscriptReadingPositionController, props))), }; } diff --git a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts index 571382607b..a37514f2a8 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -194,8 +194,9 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { }; const sessionUi = createAppShellSessionUiStateController(); const commands = createRef(); + const currentSessionId = { current: 'session-a' }; const props: ComponentProps = { - commands, sessionId: 'session-a', currentSessionId: { current: 'session-a' }, + commands, sessionId: 'session-a', currentSessionId, rangeController: { current: controller }, messages, sessionUi, searchTarget: undefined, clearSearchTarget: () => {}, landmarkSessionId: null, listTurnLandmarks: async () => ({ landmarks: [] }), setTurnIndex: () => {}, @@ -249,7 +250,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { }, async switchSession(sessionId: string) { props.sessionId = sessionId; - props.currentSessionId.current = sessionId; + currentSessionId.current = sessionId; props.rangeController.current = { ...controller, store: { ...controller.store, range: () => ({ sessionId, hasOlder: false, ready: true }) }, }; diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 4b171bfcaa..6947bae3fd 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -127,7 +127,7 @@ export interface AppShellChatActions { export function createAppShellChatActions(deps: { uiLocale: UiLocale; getRunningTurnId?: (sessionId: string) => string | undefined; - activeIdRef: RefBox; + activeIdRef: Readonly>; captureComposerImportOwner: () => ComposerImportOwner; captureSelection: () => () => boolean; checkTaskSubmissionReadiness: () => Promise; diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 4f84cc939c..70387989d2 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -24,16 +24,6 @@ import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; import { classifiedErrorFallback } from './application/contracts/operation-diagnostics.js'; import { getShellCopy } from './locales/shell-copy.js'; -export function messageReadErrorMessage(error: unknown, locale: UiLocale): string { - const copy = getShellCopy(locale); - return classifiedErrorFallback(error, copy.errors.messageRead, locale, 'message-read'); -} - -export function messageRefreshErrorMessage(error: unknown, locale: UiLocale): string { - const copy = getShellCopy(locale); - return classifiedErrorFallback(error, copy.errors.messageRefresh, locale, 'message-refresh'); -} - export function commandPaletteActionErrorMessage( error: unknown, fallback: string, diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 3a58355152..2f824de62c 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -121,7 +121,7 @@ export function useAppShellPersistenceEffects(options: { export function useAppShellBootstrapSubscriptions(options: { uiLocale: UiLocale; - activeIdRef: RefBox; + activeIdRef: Readonly>; applyE2eFixture: () => Promise; bootstrapSessions: () => Promise; clearPendingTurnActionsForSession: (sessionId: string) => void; diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index 375977ed4e..f90b0fd37b 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -37,7 +37,7 @@ import { type SessionCopyAttemptKey, } from './session-copy-attempt.js'; -type RefBox = { current: T }; +type ReadonlyRef = { readonly current: T }; type ToastApi = { info(title: string, description?: string): void; @@ -85,15 +85,15 @@ export interface AppShellRevisionActions { */ export function createAppShellRevisionActions(deps: { uiLocale: UiLocale; - activeIdRef: RefBox; + activeIdRef: ReadonlyRef; captureSelection(): () => boolean; - composerRef: RefBox; + composerRef: ReadonlyRef; readMessages(): readonly StoredMessage[]; hasPendingAttachments: () => boolean; openSessionInChat: (sessionId: string, turnId?: string) => void; refreshSessions: () => Promise; commitRevisionDraft: (draft: TurnRevisionDraft | null) => void; - revisionDraftRef: RefBox; + revisionDraftRef: ReadonlyRef; toastApi: ToastApi; }): AppShellRevisionActions { const { diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index 1de7527a18..8cecd319ab 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -22,8 +22,6 @@ import { localizedShellErrorMessage } from './locales/shell-copy.js'; import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; import type { SessionPendingClaim } from './features/conversation/index.js'; -type RefBox = { current: T }; - type ToastApi = { error( title: string, @@ -35,7 +33,7 @@ type ToastApi = { export function createAppShellStopAction(deps: { uiLocale: UiLocale; - activeIdRef: RefBox; + activeIdRef: { readonly current: string | undefined }; stopPending: SessionPendingClaim; removeTransientMessage: (sessionId: string, messageId: string) => void; toastApi: ToastApi; diff --git a/apps/desktop/src/renderer/app-shell-turn-actions.ts b/apps/desktop/src/renderer/app-shell-turn-actions.ts index dc5cc51c2e..6db7f7859d 100644 --- a/apps/desktop/src/renderer/app-shell-turn-actions.ts +++ b/apps/desktop/src/renderer/app-shell-turn-actions.ts @@ -29,8 +29,6 @@ import { } from './session-workspace-errors.js'; import { acquireSessionCopyAttempt } from './session-copy-attempt.js'; -type RefBox = { current: T }; - type ToastApi = { info(title: string, description?: string): void; success(title: string, description?: string): void; @@ -48,7 +46,7 @@ export interface AppShellTurnActions { export function createAppShellTurnActions(deps: { uiLocale: UiLocale; - activeIdRef: RefBox; + activeIdRef: { readonly current: string | undefined }; captureSelection(): () => boolean; turnActionRegistry: { addKey(key: string): boolean; diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts index aeacbb3e54..b386a2b06c 100644 --- a/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts @@ -24,7 +24,7 @@ type RefBox = { current: T }; export function handleSessionChangedEvent( event: SessionChangedEvent, options: { - activeIdRef: RefBox; + activeIdRef: Readonly>; clearPendingTurnActionsForSession: (sessionId: string) => void; refreshMessages: (sessionId: string) => Promise; refreshProjects: () => Promise; diff --git a/apps/desktop/src/renderer/application/contracts/transcript-copy.ts b/apps/desktop/src/renderer/application/contracts/transcript-copy.ts index bbd92805ae..d748cfa263 100644 --- a/apps/desktop/src/renderer/application/contracts/transcript-copy.ts +++ b/apps/desktop/src/renderer/application/contracts/transcript-copy.ts @@ -19,12 +19,16 @@ import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; import { classifiedErrorFallback } from './operation-diagnostics.js'; +import { getDesktopConversationCopy } from './conversation-copy.js'; const COPY = { 'zh-CN': { read: '任务内容暂时无法读取,请稍后重试。', refresh: '任务内容暂时无法刷新,请稍后重试。', refreshTitle: '刷新任务失败' }, 'zh-TW': { read: '任務內容暫時無法讀取,請稍後重試。', refresh: '任務內容暫時無法重新整理,請稍後重試。', refreshTitle: '重新整理任務失敗' }, en: { read: 'Task content is temporarily unavailable. Try again later.', refresh: 'Task content could not be refreshed. Try again later.', refreshTitle: 'Could not refresh task' }, } satisfies UiCatalog<{ read: string; refresh: string; refreshTitle: string }>; -export function transcriptErrorMessage(error: unknown, locale: UiLocale, kind: 'read' | 'refresh'): string { +export function transcriptErrorMessage(error: unknown, locale: UiLocale, kind: 'read' | 'refresh' | 'restore'): string { + if (kind === 'restore') { + return classifiedErrorFallback(error, getDesktopConversationCopy(locale).actions.operationFailedFallback, locale, 'desktop'); + } return classifiedErrorFallback(error, COPY[locale][kind], locale, kind === 'read' ? 'message-read' : 'message-refresh'); } export function transcriptRefreshTitle(locale: UiLocale): string { return COPY[locale].refreshTitle; } diff --git a/apps/desktop/src/renderer/bootstrap-selection-lease.ts b/apps/desktop/src/renderer/bootstrap-selection-lease.ts deleted file mode 100644 index a7778bf0b0..0000000000 --- a/apps/desktop/src/renderer/bootstrap-selection-lease.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 * from './application/contracts/bootstrap-selection-lease.js'; diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index bfaa6b277e..d2f6f1a737 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -37,11 +37,14 @@ never return a full model or a render-prop result to AppShell. The private context contains stable capabilities, not a changing publication. A content publication does not notify Shell when its target and finite chrome facts are unchanged, or Composer when its pending/usage projection is unchanged. +Catalog preview/activity bookkeeping also leaves the lifecycle reader unchanged; +status and profile changes still reach recovery and observation ownership. `useAppShellSessionUiState` is now a transitional **reader/command adapter**, not a construction hook. It exposes published/Host target identity, empty/history facts, fixed Session reads, a keyed Stop claim, and semantic commands. Its -published Session reference is a frozen getter. It has no map setters, range +published Session reference is a frozen getter, and consuming contracts declare +it readonly. It has no map setters, range controller, publication callback, writable refs, or whole-state getter. `readMessages()` is an invocation-time, readonly view of the **published range**; it is used by Copy/Save and revision commands and is not a full-history promise. diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts index a8bb5e1732..d8c09bc5ea 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts @@ -79,7 +79,7 @@ export interface RevisionSendPorts { toastApi: { info(title: string, description?: string): void; }; - activeIdRef: RefBox; + activeIdRef: Readonly>; revisionDraftRef: RefBox; composerRef: RefBox; retractedWorkspaceReferencesRef: RefBox>; diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx index 25006709bd..1c0e11634a 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx @@ -51,7 +51,7 @@ export function TranscriptReadingPositionController(props: { commands: Ref; sessionId?: string; profileId?: string; - currentSessionId: { current: string | undefined }; + currentSessionId: { readonly current: string | undefined }; rangeController: { current: RangeController | undefined }; messages: readonly StoredMessage[]; searchTarget: Parameters[0]['searchTarget']; diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index 186899f3b2..8ee7109353 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -104,7 +104,7 @@ export type TranscriptRestoreLifecycle = ReturnType; + activeIdRef: Readonly>; handleEvent: (sessionId: string, event: SessionEvent) => void; setExecution: import('../model/session-ui-state.js').AppShellSessionUiStateController['setExecution']; endObservation(sessionId: string): void; 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 381f924571..2df64186b1 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 @@ -47,7 +47,7 @@ export function useSessionMessageQueue(options: { sessionId: string | undefined; queue: MessageQueueUiState | undefined; transientMessages: readonly TransientUserMessageProjection[]; - activeSessionId: RefObject; + activeSessionId: Readonly>; }): { composer: RefObject; transientMessages: TransientUserMessageProjection[]; diff --git a/apps/desktop/src/renderer/features/conversation/model/session-events.ts b/apps/desktop/src/renderer/features/conversation/model/session-events.ts index 598cd8874d..bd7ec3b919 100644 --- a/apps/desktop/src/renderer/features/conversation/model/session-events.ts +++ b/apps/desktop/src/renderer/features/conversation/model/session-events.ts @@ -72,7 +72,7 @@ export function createAppShellSessionDisplayBatch(): AppShellSessionDisplayBatch export function createAppShellSessionEventHandlers(options: { uiLocale: UiLocale; - activeIdRef: RefBox; + activeIdRef: Readonly>; liveTurnBySessionRef: RefBox>; refreshMessages: (sessionId: string, options?: RefreshMessagesOptions) => Promise; refreshSessions: () => Promise; diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx index c45c1df782..51cdf687af 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx @@ -28,6 +28,7 @@ import { createAppShellSessionEventHandlers, createAppShellSessionDisplayBatch } import { useShellRunUpdates, useSessionEventHealthPolling } from '../controller/use-conversation-recovery.js'; import { useSessionUiRead } from '../controller/use-session-ui-read.js'; import { activeHostTurn } from '../../../application/contracts/session-execution.js'; +import { shellSessionRowEqual } from '../model/conversation-catalog-row.js'; import { useConversationServices } from '../services.js'; import { useConversationObservation } from '../controller/use-conversation-observation.js'; import { TranscriptReadingPositionController } from '../controller/transcript-reading-position-controller.js'; @@ -51,8 +52,8 @@ export function ConversationLifecycle(props: { const view = useSyncExternalStore(workspace.publication.subscribe, workspace.publication.getSnapshot); const catalog = useSessionCatalogController(); const requestedId = useExternalStoreSelector(catalog, selectActiveSessionId); - const requested = useExternalStoreSelector(catalog, selectSessionById, requestedId); - const displayed = useExternalStoreSelector(catalog, selectSessionById, view.sessionId); + const requested = useExternalStoreSelector(catalog, selectSessionById, requestedId, shellSessionRowEqual); + const displayed = useExternalStoreSelector(catalog, selectSessionById, view.sessionId, shellSessionRowEqual); const authority = useRef(INITIAL_OBSERVATION_AUTHORITY); authority.current = reconcileObservationAuthority(authority.current, { sessionId: requestedId, profileId: requested?.profileId }); const seed = useRef(INITIAL_LIVE_CONTENT_SEED); @@ -147,7 +148,7 @@ export function ConversationLifecycle(props: { searchTarget={props.searchTarget} clearSearchTarget={props.clearSearchTarget} sessionUi={ui} landmarkSessionId={displayed?.shared || displayed?.localState === 'pending' ? null : displayed?.id ?? null} listTurnLandmarks={props.listTurnLandmarks} setTurnIndex={workspace.setTurnIndex} - onRestoreError={(error, sessionId) => ui.setMessageLoadErrorBySession((current) => ({ ...current, [sessionId]: transcriptErrorMessage(error, uiLocale, 'read') }))} + onRestoreError={(error, sessionId) => ui.setMessageLoadErrorBySession((current) => ({ ...current, [sessionId]: transcriptErrorMessage(error, uiLocale, 'restore') }))} /> ; diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index ffd49faecf..d5e1602521 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -155,8 +155,6 @@ type ShellCopy = { }; paths: Record<'workspace' | 'project', string>; errors: { - messageRead: string; - messageRefresh: string; openPath(path: string): string; workspaceUnavailableTitle: string; workspaceUnavailableDescription: string; @@ -180,7 +178,6 @@ type ShellCopy = { >; responseFailedTitle: string; responseFailedFallback: string; - refreshFailedTitle: string; sessionStartFailedTitle: string; sessionStartFailedFallback: string; }; @@ -757,8 +754,6 @@ const SHELL_COPY_BY_LOCALE = { project: '项目目录', }, errors: { - messageRead: '任务内容暂时无法读取,请稍后重试。', - messageRefresh: '任务内容暂时无法刷新,请稍后重试。', openPath: (path: string) => `无法打开${path},请稍后重试。`, workspaceUnavailableTitle: '工作目录不可用', workspaceUnavailableDescription: '工作目录不存在或无法访问。请选择有效目录创建新任务。', @@ -781,7 +776,6 @@ const SHELL_COPY_BY_LOCALE = { }, responseFailedTitle: '响应失败', responseFailedFallback: '任务操作失败,请稍后重试。', - refreshFailedTitle: '刷新任务失败', sessionStartFailedTitle: '开始任务失败', sessionStartFailedFallback: '任务暂时无法开始,请稍后重试。', }, @@ -1284,8 +1278,6 @@ const SHELL_COPY_BY_LOCALE = { project: '專案目錄', }, errors: { - messageRead: '任務內容暫時無法讀取,請稍後重試。', - messageRefresh: '任務內容暫時無法重新整理,請稍後重試。', openPath: (path: string) => `無法開啟${path},請稍後重試。`, workspaceUnavailableTitle: '工作目錄不可用', workspaceUnavailableDescription: '工作目錄不存在或無法存取。請選擇有效目錄建立新任務。', @@ -1308,7 +1300,6 @@ const SHELL_COPY_BY_LOCALE = { }, responseFailedTitle: '響應失敗', responseFailedFallback: '任務操作失敗,請稍後重試。', - refreshFailedTitle: '重新整理任務失敗', sessionStartFailedTitle: '開始任務失敗', sessionStartFailedFallback: '任務暫時無法開始,請稍後重試。', }, @@ -1811,8 +1802,6 @@ const SHELL_COPY_BY_LOCALE = { project: 'project folder', }, errors: { - messageRead: 'Task content is temporarily unavailable. Try again later.', - messageRefresh: 'Task content could not be refreshed. Try again later.', openPath: (path: string) => `Could not open the ${path}. Try again later.`, workspaceUnavailableTitle: 'Working directory unavailable', workspaceUnavailableDescription: @@ -1837,7 +1826,6 @@ const SHELL_COPY_BY_LOCALE = { }, responseFailedTitle: 'Response failed', responseFailedFallback: 'The task action failed. Try again later.', - refreshFailedTitle: 'Could not refresh task', sessionStartFailedTitle: 'Could not start task', sessionStartFailedFallback: 'The task could not be started. Try again later.', }, diff --git a/apps/desktop/src/renderer/shell-run-update-state.ts b/apps/desktop/src/renderer/shell-run-update-state.ts deleted file mode 100644 index 6504211c7e..0000000000 --- a/apps/desktop/src/renderer/shell-run-update-state.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 * from './application/contracts/shell-run-update-state.js';