diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 07ad945c7e..c9ac1b3cd7 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", @@ -24,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", @@ -93,7 +91,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", @@ -173,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", @@ -230,6 +226,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 +305,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 +334,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": 2931 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 1, @@ -379,7 +394,7 @@ "react": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 2189 + "nonTriviaTokens": 2188 }, "src/renderer/app-shell-context-compaction.ts": { "importDeclarations": 0, @@ -409,7 +424,7 @@ "@maka/core/redaction": 1 }, "importSpecifiers": 1, - "nonTriviaTokens": 391 + "nonTriviaTokens": 311 }, "src/renderer/app-shell-detail-panel.tsx": { "importDeclarations": 0, @@ -445,57 +460,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": 1361 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -580,29 +577,6 @@ "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 - }, "src/renderer/app-shell-stop-action.ts": { "importDeclarations": 0, "bridgePaths": { @@ -620,7 +594,7 @@ "./locales/shell-copy.js": 1 }, "importSpecifiers": 0, - "nonTriviaTokens": 302 + "nonTriviaTokens": 292 }, "src/renderer/app-shell-turn-actions.ts": { "importDeclarations": 2, @@ -641,10 +615,10 @@ "./session-workspace-errors.js": 1 }, "importSpecifiers": 3, - "nonTriviaTokens": 571 + "nonTriviaTokens": 561 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 54, + "importDeclarations": 53, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -653,20 +627,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 +645,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 +658,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 +678,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 +742,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 +789,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 +918,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 +1496,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 +1656,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 +3334,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 +3549,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 +3893,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__/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 new file mode 100644 index 0000000000..e9cbe5bf0e --- /dev/null +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -0,0 +1,245 @@ +/* + * 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, 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'; +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(options: { + locale?: UiLocale; + hasOlder?: boolean; + listTurnLandmarks?: ComponentProps['listTurnLandmarks']; +} = {}) { + 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: options.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 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 }) { + 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(Profiler, { id: 'conversation-lifecycle', onRender: () => { lifecycleCommits += 1; } }, createElement(ConversationLifecycle, { + refreshSessions: async () => [], onExecutionBoundaryChanged() {}, + onContextCompactionOutcome() {}, showModelSetupToast() {}, onTurnCompleted() {}, + 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: options.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, 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')); + 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__/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__/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__/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__/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..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 @@ -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, @@ -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 52a24d6700..a37514f2a8 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']>; @@ -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 1a095681cf..6947bae3fd 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,14 +122,12 @@ 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: { uiLocale: UiLocale; getRunningTurnId?: (sessionId: string) => string | undefined; - activeIdRef: RefBox; + activeIdRef: Readonly>; captureComposerImportOwner: () => ComposerImportOwner; captureSelection: () => () => boolean; checkTaskSubmissionReadiness: () => Promise; @@ -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-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 dad55ddc13..2f824de62c 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, @@ -143,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; @@ -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..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; - messages: readonly StoredMessage[]; + 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 { @@ -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-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/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/bootstrap-selection-lease.ts b/apps/desktop/src/renderer/application/contracts/bootstrap-selection-lease.ts similarity index 100% rename from apps/desktop/src/renderer/bootstrap-selection-lease.ts rename to apps/desktop/src/renderer/application/contracts/bootstrap-selection-lease.ts 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..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 @@ -18,19 +18,13 @@ */ 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: { - activeIdRef: RefBox; + activeIdRef: Readonly>; clearPendingTurnActionsForSession: (sessionId: string) => void; refreshMessages: (sessionId: string) => Promise; refreshProjects: () => Promise; @@ -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/shell-run-update-state.ts b/apps/desktop/src/renderer/application/contracts/shell-run-update-state.ts similarity index 100% rename from apps/desktop/src/renderer/shell-run-update-state.ts rename to apps/desktop/src/renderer/application/contracts/shell-run-update-state.ts 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..d748cfa263 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/transcript-copy.ts @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { 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' | '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/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/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..d2f6f1a737 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -19,34 +19,74 @@ # 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. +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, 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. + +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 +111,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 +124,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/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>; - -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..3265e3778d --- /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: Readonly>; + 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/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/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..bd7ec3b919 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; @@ -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/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..51cdf687af --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx @@ -0,0 +1,155 @@ +/* + * 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 { 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'; +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, 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); + 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, 'restore') }))} + /> + + ; +} 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/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/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/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