diff --git a/LICENSE b/LICENSE index 8c47e3748c..1603955362 100644 --- a/LICENSE +++ b/LICENSE @@ -278,6 +278,40 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +xterm.js dependency patch + +Source: https://www.npmjs.com/package/@xterm/xterm/v/6.0.0 +Repository: https://github.com/xtermjs/xterm.js +Version: 6.0.0 +Dependency patch: patches/@xterm+xterm+6.0.0.patch +License: MIT + +Maka redistributes a patch to the TypeScript source and both shipped JavaScript +bundles that defers selection rendering while the terminal is hidden. The +following upstream license applies to that material: + +Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) +Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com) +Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + Pi TUI dependency patch Source: https://www.npmjs.com/package/@earendil-works/pi-tui/v/0.84.4 diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 70ec0ce0f0..8e276bad4f 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -43,7 +43,7 @@ }, "session-workbar.spec.ts": { "tests": 5, - "electron": "Git changes re-read on native window focus; terminal PTY ownership changes across Sessions; Side Chat owns a Host fork lifecycle; first send reaches the Host; the composer usage entry opens a workbar whose per-Session visibility survives renderer reload." + "electron": "Git changes re-read on native window focus; native PTY ownership survives Session navigation and renderer replacement, while Stop/exit cross preload/main into the terminal view; Side Chat owns a Host fork lifecycle; first send reaches the Host; the composer usage entry opens a workbar whose per-Session visibility survives renderer reload." }, "settings.spec.ts": { "tests": 2, diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 8e9b909230..52b58bba9f 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -172,7 +172,10 @@ test('Git changes re-read the workspace after the app regains focus', async ({ await expect(panel.getByText('新增 5 行')).toBeVisible(); }); -test('Terminal ownership follows the active Session and stops the old resource', async ({ +// Exercises the real Electron preload/main controller lease across renderer +// replacement and native PTY Stop/exit delivery to the mounted xterm. Node +// controller tests cover ordering; they do not mount the production bridge. +test('Terminal survives navigation and reload, then stops on explicit close', async ({ window: page, }) => { const { composer, sessionId, sidebar } = await createSession( @@ -204,7 +207,7 @@ test('Terminal ownership follows the active Session and stops the old resource', .find((update) => update.result.ref === terminalRef) ?.result.status, ) - .not.toBe('running'); + .toBe('running'); await composer.fill('create replacement session'); await awaitSendReady(page); @@ -212,6 +215,37 @@ test('Terminal ownership follows the active Session and stops the old resource', await expect(page.getByText('Fake backend received: create replacement session')).toBeVisible(); await page.getByRole('button', { name: '展开任务工作栏' }).click(); await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible(); + await sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click(); + await expect(terminal).toBeVisible(); + await expect(terminal).toHaveAttribute('data-terminal-ref', terminalRef!); + await page.reload(); + await sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click(); + await expect(terminal).toBeVisible(); + await expect(terminal).toHaveAttribute('data-terminal-ref', terminalRef!); + await page.getByRole('button', { name: '打开或关闭工作栏的面' }).click(); + await page.getByRole('menu').getByRole('menuitem', { name: /终端/ }).click(); + await expect(terminal).toHaveCount(0); + await expect.poll(async () => + (await page.evaluate((id) => window.maka.shellRuns.list(id), sessionId)) + .find((update) => update.result.ref === terminalRef)?.result.status, + ).not.toBe('running'); + + // Natural exit ends live controls while the local picture remains. Reload + // does not promise to recover a completed terminal's contents or its tab. + await page.getByRole('button', { name: '展开任务工作栏' }).click(); + await page.getByRole('button', { name: /终端.*查看当前任务的终端运行和实时输出/ }).click(); + await expect(terminal).toBeVisible(); + const completedRef = await terminal.getAttribute('data-terminal-ref'); + await page.evaluate(async ({ sessionId, ref }) => { + await window.maka.shellRuns.write({ sessionId, ref: ref!, input: 'exit 0\r' }); + }, { sessionId, ref: completedRef }); + await expect.poll(async () => page.evaluate(async ({ sessionId, ref }) => + (await window.maka.shellRuns.list(sessionId)).find((update) => update.result.ref === ref)?.result, + { sessionId, ref: completedRef })).toMatchObject({ status: 'completed', exitCode: 0 }); + await expect(terminal).toBeVisible(); + await page.reload(); + await sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click(); + await expect(terminal).toHaveCount(0); }); test('Side Chat survives collapse, confirms close, and cleans up on source switch', async ({ diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index b8d5105b09..542ce927d5 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -325,7 +325,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 3646 + "nonTriviaTokens": 3636 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 4, @@ -442,7 +442,7 @@ "nonTriviaTokens": 642 }, "src/renderer/app-shell-effects.ts": { - "importDeclarations": 11, + "importDeclarations": 10, "bridgePaths": { "window.maka.app.info": 1, "window.maka.appWindow.subscribeCommand": 1, @@ -478,7 +478,6 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../shared/runtime-host-identity.js": 1, "./app-shell-copy": 1, "./browser-storage": 1, "./locales/conversation-copy.js": 1, @@ -491,7 +490,7 @@ "@maka/core/session-event-health": 1, "react": 1 }, - "importSpecifiers": 19, + "importSpecifiers": 18, "nonTriviaTokens": 3718 }, "src/renderer/app-shell-overlays.tsx": { @@ -577,7 +576,7 @@ "@maka/core/session": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 2156 + "nonTriviaTokens": 2155 }, "src/renderer/app-shell-session-events.ts": { "importDeclarations": 2, @@ -676,7 +675,7 @@ "./session-workspace-errors.js": 1 }, "importSpecifiers": 3, - "nonTriviaTokens": 650 + "nonTriviaTokens": 620 }, "src/renderer/app-shell-turn-view-model.ts": { "importDeclarations": 6, @@ -860,7 +859,7 @@ "react": 1 }, "importSpecifiers": 104, - "nonTriviaTokens": 13459 + "nonTriviaTokens": 13402 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -932,9 +931,8 @@ "useAppShellSessionList": 1, "useAppShellSessionUiState": 1, "useExternalStoreSelector": 1, - "useRef": 5, - "useSessionCatalogController": 1, - "useState": 2 + "useRef": 4, + "useSessionCatalogController": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, @@ -949,8 +947,8 @@ "./use-external-store-selector.js": 1, "react": 1 }, - "importSpecifiers": 9, - "nonTriviaTokens": 464 + "importSpecifiers": 8, + "nonTriviaTokens": 461 } }, "closure": { diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 21d971aae4..2b41c3fb8b 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -420,8 +420,8 @@ describe('busy-raced send settlement', () => { activated.push(sessionId); activeIdRef.current = sessionId; }, - setActiveId: (sessionId: string | undefined) => { - activeIdRef.current = sessionId; + retireSession: (sessionId: string) => { + if (activeIdRef.current === sessionId) activeIdRef.current = undefined; }, ...transientState.deps, }); @@ -460,8 +460,8 @@ describe('busy-raced send settlement', () => { activateSessionForFirstSend: async (sessionId) => { activeIdRef.current = sessionId; }, - setActiveId: (sessionId: string | undefined) => { - activeIdRef.current = sessionId; + retireSession: (sessionId: string) => { + if (activeIdRef.current === sessionId) activeIdRef.current = undefined; }, ...transientState.deps, }); 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 cc9cc16198..26e460f02b 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 @@ -91,7 +91,7 @@ export function createActionsDeps() { activateSessionForFirstSend: async (sessionId: string) => { activeIdRef.current = sessionId; }, - setActiveId: () => undefined, + retireSession: (_sessionId: string) => undefined, setMessageLoadErrorBySession: () => undefined, addTransientMessage: () => undefined, updateTransientMessage: () => 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 8af560d34d..35136fa3bf 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 @@ -377,8 +377,8 @@ describe('composer first-send cleanup', () => { activeIdRef.current = sessionId; throw new Error('Timed out while preparing the new Session event stream'); }, - setActiveId: (sessionId) => { - activeIdRef.current = sessionId; + retireSession: (sessionId) => { + if (activeIdRef.current === sessionId) activeIdRef.current = undefined; }, isNewChatSendSurfaceActive: () => activeIdRef.current === undefined, isShellSurfaceOwnerActive: (owner) => @@ -591,7 +591,10 @@ describe('composer first-send cleanup', () => { const { root } = installReactRenderer(); let publication!: ReturnType['publication']; function Probe(): null { - publication = useAppShellSessionUiState(deps.activeIdRef, () => {}).publication; + publication = useAppShellSessionUiState( + [], undefined, deps.activeIdRef, + (_sessionId, _messages, _controller: DesktopTranscriptRangeController) => true, + ).publication; return null; } try { diff --git a/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts index 244751ccd5..90b94d57aa 100644 --- a/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts @@ -25,12 +25,15 @@ import { createAppShellTurnActions } from '../../renderer/app-shell-turn-actions test('preserves a Branch copy identity after an ambiguous failure and completes it on success', async () => { const calls: Array<{ sourceTurnId: string; copyId?: string }> = []; let loseFirstResponse = true; + let selectionRevision = 0; + let navigateDuringBranch = false; const restoreWindow = installWindow(async (_sessionId, input) => { calls.push(input); if (loseFirstResponse) { loseFirstResponse = false; throw new Error('Committed response was lost'); } + if (navigateDuringBranch) selectionRevision += 1; return session(input.copyId ?? 'missing-copy-id'); }); const pending = new Set(); @@ -38,6 +41,10 @@ test('preserves a Branch copy identity after an ambiguous failure and completes const actions = createAppShellTurnActions({ uiLocale: 'en', activeIdRef: { current: 'branch-action-source' }, + captureSelection: () => { + const revision = selectionRevision; + return () => revision === selectionRevision; + }, turnActionRegistry: { addKey: (key) => { if (pending.has(key)) return false; @@ -52,9 +59,7 @@ test('preserves a Branch copy identity after an ambiguous failure and completes openSessionInChat: (sessionId) => { opened.push(sessionId); }, - refreshMessages: async () => true, refreshSessions: async () => [], - setMessages: () => undefined, toastApi: { info() {}, success() {}, error() {} }, }); @@ -65,8 +70,11 @@ test('preserves a Branch copy identity after an ambiguous failure and completes assert.equal(calls[0]?.copyId, calls[1]?.copyId); assert.deepEqual(opened, [calls[0]?.copyId]); + // The display may still be the source while a newer navigation is loading. + navigateDuringBranch = true; await actions.handleTurnFooterAction('branch-action-turn', 'branch'); assert.equal(calls.length, 3); + assert.equal(opened.length, 1, 'late Branch must not replace the newer selection'); assert.notEqual(calls[2]?.copyId, calls[1]?.copyId); } finally { restoreWindow(); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 1ebe22c93b..faf74c8fca 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -520,7 +520,8 @@ test('drives bounded Session domain projections through real UDS framing', async const client = new DesktopRuntimeHostClient(connected.connection); const ipc = ipcHarness(); registerRuntimeHostSessionDomainsIpc( - { client, emitModeChanged() {}, sessionObserver: unusedSessionObserver() }, + { client, emitModeChanged() {}, sessionObserver: unusedSessionObserver(), + terminalCloses: new (await import('../terminal-close-intents.js')).TerminalCloseIntents() }, ipc, ); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts index 9f5c7989af..c23cd435b1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts @@ -19,6 +19,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import { TerminalCloseIntents } from '../terminal-close-intents.js'; +import type { TerminalCloseChange, TerminalRecovery } from '../../shared/runtime-host-identity.js'; import type { IpcMain } from 'electron'; import { projectDeepResearchClientProgress } from '@maka/core/deep-research-client-progress'; import { type DeepResearchRun } from '@maka/core/deep-research-run'; @@ -571,6 +574,49 @@ test('adapts bounded Agent Graph epoch reads without changing graph identity', a ]); }); +test('keeps a failed Close across connection replacement and acknowledges Stop without a post-read', async () => { + const changes: TerminalCloseChange[] = []; + const closes = new TerminalCloseIntents((change) => changes.push(change)); + const firstStop = deferred>>(); + const identity = { sessionId: 'session-1', ref: 'terminal' }; + let attempts = 0; + const first = ipcHarness(); + const old = registerDomainsIpc({ + terminalCloses: closes, emitModeChanged() {}, + client: domainClient({ stopRuntimeResource: () => { attempts += 1; return firstStop.promise; } }), + }, first); + const stopping = first.invoke('shell-runs:stop', identity); + const rejected = assert.rejects(stopping, /disconnected/); + await old.close(); + + const second = ipcHarness(); + registerDomainsIpc({ + terminalCloses: closes, emitModeChanged() {}, + client: domainClient({ + listRuntimeResources: async () => [], + getRuntimeResource: async () => { throw new Error('must not reread after Stop'); }, + stopRuntimeResource: async () => { attempts += 1; return { resource: shellRunUpdate().result as never }; }, + }), + }, second); + const recovering = await second.invoke('shell-runs:recover', identity.sessionId) as TerminalRecovery; + assert.deepEqual(recovering.closes, [{ ...identity, status: 'pending' }]); + firstStop.reject(new Error('disconnected after old view closed')); + await rejected; + const unknown = await second.invoke('shell-runs:recover', identity.sessionId) as TerminalRecovery; + assert.deepEqual(unknown.closes, [{ ...identity, status: 'unknown' }]); + await second.invoke('shell-runs:stop', identity); + assert.equal(attempts, 2); + assert.deepEqual(changes.map((change) => change.status), ['pending', 'unknown', 'pending', 'closed']); + assert.deepEqual((await second.invoke('shell-runs:recover', identity.sessionId) as TerminalRecovery).closes, []); + const retiringStop = deferred(); + const lateFailure = assert.rejects(closes.stop(identity, () => retiringStop.promise), /late/); + closes.retireSession(identity.sessionId); + retiringStop.reject(new Error('late response after successful owner retirement')); + await lateFailure; + assert.deepEqual((await second.invoke('shell-runs:recover', identity.sessionId) as TerminalRecovery).closes, []); + assert.equal(changes.at(-1)?.status, 'closed'); +}); + test('adapts interactive terminal ownership to one Host controller lease', async () => { const calls: Array<{ operation: string; input: unknown }> = []; const update = shellRunUpdate({ @@ -1387,13 +1433,14 @@ function reconciledIpcHarness() { } function registerDomainsIpc( - deps: Omit & - Partial>, + deps: Omit & + Partial>, ipcMain: ReconnectableReadIpcMain, ) { return registerRuntimeHostSessionDomainsIpc( { ...deps, + terminalCloses: deps.terminalCloses ?? new TerminalCloseIntents(), sessionObserver: deps.sessionObserver ?? { async observe() {}, async unobserve() {}, diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index 77ae054556..a009138a35 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -102,15 +102,13 @@ function controller(): SessionNavigationController { function ports( sessions: SessionNavigationSession[], - activeSessionId: string | undefined, + _activeSessionId: string | undefined, calls: string[] = [], ): SessionNavigationPorts { return { - activeIdRef: { current: activeSessionId }, sessionsRef: { current: sessions }, pendingSessionRowActionsRef: { current: new Set() }, activateSession: (sessionId) => calls.push(`activate:${sessionId ?? 'none'}`), - clearActiveMessages: () => calls.push('clear-messages'), clearSessionRendererState: (sessionId) => calls.push(`clear:${sessionId}`), refreshSessions: async () => sessions, toastApi: { diff --git a/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts index 7e7a59f9ef..c6bbb469ea 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts @@ -82,24 +82,19 @@ describe('revision-family session row actions', () => { it('applies conversation metadata/lifecycle to versions but not ordinary branches', async () => { const calls: string[] = []; const cleared: string[] = []; - const selections: Array = []; const root = summary('root'); const version = summary('version', { revisionRootSessionId: 'root', revisionParentSessionId: 'root', }); const branch = summary('branch', { parentSessionId: 'root', branchOfTurnId: 'turn-1' }); - const activeIdRef = { current: 'root' as string | undefined }; const actions = createSessionNavigationRowActions({ uiLocale: 'en', - activeIdRef, - clearActiveMessages: () => undefined, clearSessionRendererState: (id) => { cleared.push(id); }, pendingSessionRowActionsRef: { current: new Set() }, refreshSessions: async () => [root, version, branch], service: createService(calls), sessionsRef: { current: [root, version, branch] }, - setActiveId: (id) => { selections.push(id); activeIdRef.current = id; }, toastApi: { success: () => undefined, error: () => undefined, @@ -110,7 +105,6 @@ describe('revision-family session row actions', () => { await actions.flagSession('version', true); await actions.renameSession('branch', 'Independent branch'); await actions.archiveSession('version'); - activeIdRef.current = 'version'; await actions.deleteSession('root'); assert.deepEqual(calls, [ @@ -124,7 +118,6 @@ describe('revision-family session row actions', () => { // requiring one would refuse every delete from the rail. 'remove:root:true:false', ]); - assert.deepEqual(selections, [undefined, undefined]); assert.deepEqual(cleared, ['root', 'version', 'root', 'version']); }); }); @@ -140,14 +133,11 @@ function deleteHarness( const successes: Array<{ title: string; description?: string }> = []; const actions = createSessionNavigationRowActions({ uiLocale: 'en', - activeIdRef: { current: undefined }, - clearActiveMessages: () => undefined, clearSessionRendererState: () => undefined, pendingSessionRowActionsRef: { current: new Set() }, refreshSessions: async () => [...sessions], service: createService(calls, { disposition, archivedSubtaskCount, preview }), sessionsRef: { current: [...sessions] }, - setActiveId: () => undefined, toastApi: { success: (title, description) => { successes.push({ title, description }); }, error: () => undefined, diff --git a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts index 83a5f7099b..30dca5b523 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts @@ -57,7 +57,6 @@ type SweepHarness = { /** Each `remove` call as `[sessionId, requireArchived]`. */ removeOptions: Array<[string, boolean]>; cleared: string[]; - selections: Array; /** Titles of the success toasts a row action raised. */ toasts: string[]; /** Their descriptions, positionally — the half that carries the counts. */ @@ -141,8 +140,6 @@ function createActions(input: { }) { return createSessionNavigationRowActions({ uiLocale: 'en', - activeIdRef: input.activeIdRef, - clearActiveMessages: () => undefined, clearSessionRendererState: (id) => { input.harness.cleared.push(id); }, @@ -150,10 +147,6 @@ function createActions(input: { refreshSessions: async () => input.refreshed ?? [], service: input.service, sessionsRef: { current: input.sessions }, - setActiveId: (id) => { - input.harness.selections.push(id); - input.activeIdRef.current = id; - }, toastApi: { success: (title: string, description?: string) => { input.harness.toasts.push(title); @@ -172,7 +165,6 @@ function harness(): SweepHarness { archived: [], removeOptions: [], cleared: [], - selections: [], toasts: [], toastDescriptions: [], listCalls: 0, @@ -210,7 +202,6 @@ describe('purgeSessions', () => { // The family goes, not just the representative, and the open member of it // stops being the active session. assert.deepEqual(h.cleared.sort(), ['a', 'a-v2', 'b']); - assert.deepEqual(h.selections, [undefined]); // Nothing rejected, so there is nothing to check back. assert.equal(h.listCalls, 0); }); @@ -295,7 +286,6 @@ describe('purgeSessions', () => { // A task that is still there keeps its renderer state, including being the // open one. assert.deepEqual(h.cleared, ['first']); - assert.deepEqual(h.selections, []); assert.equal(activeIdRef.current, 'rescued'); }); 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 baa6e161ef..65aa195944 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 @@ -21,8 +21,11 @@ import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import { act, createElement } from 'react'; import { LocaleProvider } from '@maka/ui'; +import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { useAppShellSessionWorkspace } from '../../renderer/use-app-shell-session-workspace.js'; +import { createRecoveringDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; /** * The session workspace hands its actions to consumers that put them in @@ -50,6 +53,126 @@ function actionKeys(workspace: Workspace): string[] { describe('session workspace action identity', () => { afterEach(cleanupFakeDom); + it('hands over identity and rows together and rejects superseded reads', async () => { + const sessionA = JSON.stringify(['local', 'a']); + const sessionB = JSON.stringify(['local', 'b']); + const sessionC = JSON.stringify(['local', 'c']); + const { root } = installReactRenderer(); + let workspace!: Workspace; + const displays: Array<{ id: string | undefined; messages: StoredMessage[] }> = []; + function Probe(): null { + workspace = useAppShellSessionWorkspace({ error: () => {} }); + displays.push({ id: workspace.activeId, messages: workspace.messages }); + return null; + } + act(() => root.render(createElement(LocaleProvider, { + locale: 'en', children: createElement(Probe), + }))); + act(() => workspace.seedSessions([sessionA, sessionB, sessionC].map((id) => ({ + id, name: id, isFlagged: false, isArchived: false, labels: [], + hasUnread: false, status: 'active' as const, backend: 'ai-sdk' as const, + revision: 1, runtimeHostId: 'local', profileId: 'local', profileName: 'Local', + llmConnectionSlug: 'test', connectionLocked: false, model: 'test', + permissionMode: 'ask' as const, profileKind: 'local' as const, + })))); + const row = (id: string): StoredMessage => ({ id, type: 'user', text: id, turnId: id, ts: 1 }); + const a = [row('a-message')]; + const c = [row('c-message')]; + const reader = (id: string) => createRecoveringDesktopTranscriptRangeController( + new DesktopTranscriptRangeStore(id), async () => { throw new Error('unexpected read'); }, { onError() {} }, + ); + const readerA = reader(sessionA); + const readerC = reader(sessionC); + act(() => { workspace.setActiveId(sessionA); workspace.commitTranscript(sessionA, a, readerA); }); + assert.equal(workspace.transcriptRangeRef.current, readerA); + assert.equal(workspace.isSessionSelected(sessionA), true); + displays.length = 0; + act(() => workspace.setActiveId(sessionB)); + assert.equal(workspace.requestedSessionId, sessionB); + assert.equal(workspace.activeId, sessionA); + assert.equal(workspace.messages, a); + assert.equal(workspace.isSessionSelected(sessionA), false); + assert.equal(workspace.isSessionSelected(sessionB), false); + assert.equal(workspace.transcriptRangeRef.current, undefined, 'the old picture has no reader during handoff'); + act(() => workspace.setActiveId(sessionC)); + act(() => workspace.commitTranscript(sessionB, [row('b-message')])); + assert.equal(workspace.activeId, sessionA); + act(() => workspace.commitTranscript(sessionC, c, readerC)); + assert.equal(workspace.transcriptRangeRef.current, readerC); + assert.equal(workspace.isSessionSelected(sessionC), true); + assert.equal(workspace.activeId, sessionC); + assert.equal(workspace.messageLoadPending, false); + assert.ok(displays.every((display) => + (display.id === sessionA && display.messages === a) || + (display.id === sessionC && display.messages === c))); + + act(() => workspace.setActiveId(sessionB)); + act(() => workspace.startNewSession()); + act(() => workspace.commitTranscript(sessionB, [row('b-message')])); + assert.equal(workspace.activeId, undefined); + assert.deepEqual(workspace.messages, []); + // A first-send task has no readable Host history yet; it must activate + // immediately rather than waiting for its own first message to be sent. + act(() => workspace.setActiveId('new-local-task')); + assert.equal(workspace.activeId, 'new-local-task'); + + // Retiring the old display must not cancel a newer navigation intent. + act(() => { workspace.setActiveId(sessionA); workspace.commitTranscript(sessionA, a); }); + const selectionIsCurrent = workspace.captureSelection(); + act(() => workspace.setActiveId(sessionB)); + assert.equal(selectionIsCurrent(), false); + act(() => workspace.clearOwnedSessionState(sessionA)); + assert.equal(workspace.requestedSessionId, sessionB); + assert.equal(workspace.activeId, undefined); + act(() => workspace.commitTranscript(sessionB, [row('b-message')])); + assert.equal(workspace.activeId, sessionB); + + // Retiring the destination revokes its read and falls back to the display. + act(() => workspace.setActiveId(sessionC)); + act(() => workspace.clearOwnedSessionState(sessionC)); + act(() => workspace.commitTranscript(sessionC, c)); + assert.equal(workspace.requestedSessionId, sessionB); + assert.equal(workspace.activeId, sessionB); + assert.equal(workspace.switchingSession, false); + act(() => workspace.setActiveId(sessionA)); + assert.deepEqual(workspace.retiredSessionIds([{ id: sessionB }]), [sessionA]); + + // The real publication scheduler may hold a ready source while the reader + // is interacting. A retired queued source may not replace the displayed + // Session or publish its reader. + act(() => workspace.setActiveId(sessionC)); + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'c', generation: 'publication', hostEpoch: 'host', + durableThrough: null, durable: [], overlay: c, hasOlder: false, hasNewer: false, + })) readerC.store.accept(batch); + let blocked = true; + let idle!: () => void; + const detach = workspace.sessionUiController.transcriptViewportNavigation.attachCommitScheduler(sessionC, { + commitIfIdle: (commit) => { if (blocked) return false; commit(); return true; }, + subscribeToIdle: (listener) => { idle = listener; return () => {}; }, + }); + let publications = 0; + await act(async () => workspace.publishTranscript( + sessionC, readerC, workspace.captureSelection(), () => { publications += 1; }, + )); + assert.equal(workspace.activeId, sessionB); + act(() => workspace.clearOwnedSessionState(sessionC)); + await act(async () => { blocked = false; idle(); }); + assert.equal(publications, 0, 'retirement revokes queued publication'); + assert.equal(workspace.activeId, sessionB); + assert.equal(workspace.transcriptRangeRef.current, undefined); + await act(async () => { + workspace.setActiveId(sessionC); + workspace.publishTranscript(sessionC, readerC, workspace.captureSelection(), () => { publications += 1; }); + }); + assert.equal(publications, 1); + assert.equal(workspace.activeId, sessionC); + assert.equal((workspace.messages as StoredMessage[])[0]?.id, 'c-message'); + assert.equal(workspace.publishedTranscriptRange?.sessionId, sessionC); + assert.equal(workspace.transcriptRangeRef.current, readerC); + await act(async () => detach()); + }); + it('keeps every action identity fixed across re-renders', () => { const { root } = installReactRenderer(); const reads: Workspace[] = []; diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index cb2c268c65..4e068e06fb 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -25,8 +25,11 @@ import type { ShellRunUpdate } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; import { LocaleProvider } from '@maka/ui'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { TerminalCloseIntents } from '../terminal-close-intents.js'; +import type { TerminalCloseChange } from '../../shared/runtime-host-identity.js'; import { createFakeWorkbarServices, + projectWorkbarPanelsForSession, useWorkbarController, WorkbarServicesProvider, type UseWorkbarControllerInput, @@ -71,14 +74,18 @@ type ControllerProbeInput = UseWorkbarControllerInput & { openOnActivation?: boo function ControllerProbe(props: ControllerProbeInput) { const workbar = useWorkbarController(props); latestController = workbar; + const visiblePanels = projectWorkbarPanelsForSession( + workbar.host.panelsState, workbar.host.activeId, + new Set(workbar.host.quotes?.map((quote) => `side-chat:${quote.id}`)), + ); useLayoutEffect(() => { if (props.openOnActivation) workbar.host.onOpenLauncher('right'); }, [props.activeSession?.id, props.openOnActivation]); controllerRenderSnapshots.push({ activeId: latestController.host.activeId, terminalOwnerIds: [ - ...latestController.host.panelsState.right.tabs, - ...latestController.host.panelsState.bottom.tabs, + ...visiblePanels.right.tabs, + ...visiblePanels.bottom.tabs, ] .filter((tab) => tab.kind === 'terminal') .map((tab) => tab.ownerSessionId), @@ -86,12 +93,26 @@ function ControllerProbe(props: ControllerProbeInput) { return null; } +const connectedServices = new WeakSet(); + function renderController( root: ReturnType['root'], services: WorkbarServices, input: ControllerProbeInput, strictMode = false, ) { + if (!connectedServices.has(services)) { + connectedServices.add(services); + const listeners = new Set<(change: TerminalCloseChange) => void>(); + const closes = new TerminalCloseIntents((change) => listeners.forEach((listener) => listener(change))); + const { stop, recover } = services.terminal; + services.terminal.stop = (identity) => closes.stop(identity, () => stop(identity)); + services.terminal.recover = (id) => closes.recover(id, async () => (await recover(id)).resources); + services.terminal.subscribeCloseChanges = (listener) => { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }; + } const probe = createElement( LocaleProvider, { @@ -125,7 +146,7 @@ function input( activeSession, projectId: activeSession?.projectId, projectAliases: [], - authoritativeSessionIds: new Set(activeSession ? [activeSession.id] : []), + authoritativeSessionIds: new Set(['a', 'b', ...(activeSession ? [activeSession.id] : [])]), shellObscured: false, modelChoices: [], reportError: (title, description) => errors.push(`${title}: ${description}`), @@ -194,7 +215,7 @@ describe('useWorkbarController', () => { assert.equal(controller().host.rightCollapsed, false); }); - it("preserves the active Session's visibility while removing the previous Session's Terminal", async () => { + it("preserves visibility while hiding, rather than removing, another Session's Terminal", async () => { const { root } = installReactRenderer(); const defaults = createFakeWorkbarServices(); const services = createFakeWorkbarServices({ @@ -217,7 +238,10 @@ describe('useWorkbarController', () => { assert.equal(controller().host.panelsState.right.tabs.length, 1); await act(async () => show('b')); - assert.equal(controller().host.panelsState.right.tabs.length, 0); + assert.equal(controller().host.panelsState.right.tabs.length, 1); + assert.equal(projectWorkbarPanelsForSession( + controller().host.panelsState, 'b', new Set(), + ).right.tabs.length, 0); assert.equal(controller().host.rightCollapsed, false); }); @@ -264,16 +288,8 @@ describe('useWorkbarController', () => { it('keeps the initial Session active after StrictMode replays mount effects', async () => { const { root } = installReactRenderer(); const starts: string[] = []; - let browserSubscriptions = 0; const defaults = createFakeWorkbarServices(); const services = createFakeWorkbarServices({ - browser: { - ...defaults.browser, - subscribeLive: () => { - browserSubscriptions += 1; - return () => undefined; - }, - }, terminal: { ...defaults.terminal, start: async (sessionId) => { @@ -288,7 +304,6 @@ describe('useWorkbarController', () => { ); await act(async () => controller().commands.openTool('terminal')); - assert.equal(browserSubscriptions, 2); assert.deepEqual(starts, ['a']); assert.equal( controller().host.panelsState.right.tabs.some( @@ -340,7 +355,7 @@ describe('useWorkbarController', () => { ); }); - it('stops a Terminal whose start resolves after the owner Session changes', async () => { + it('retains a Terminal whose start resolves after navigation without revealing it in the new Session', async () => { const { root } = installReactRenderer(); const start = deferred(); const starts: string[] = []; @@ -355,7 +370,7 @@ describe('useWorkbarController', () => { }, stop: async (request) => { stops.push(request); - return null; + return; }, }, }); @@ -367,16 +382,21 @@ describe('useWorkbarController', () => { await act(async () => renderController(root, services, input(session('b')))); await act(async () => start.resolve(shellUpdate('a', 'terminal-a'))); - assert.deepEqual(stops, [{ sessionId: 'a', ref: 'terminal-a' }]); + assert.deepEqual(stops, []); assert.equal( controller().host.panelsState.right.tabs.some( (tab) => tab.kind === 'terminal', ), - false, + true, ); + assert.equal(controller().host.rightCollapsed, true); + await act(async () => renderController(root, services, input(session('a')))); + const owned = controller().host.panelsState.right.tabs.find((tab) => tab.kind === 'terminal'); + assert.equal(owned?.ownerSessionId, 'a'); + assert.equal(owned?.resourceRef, 'terminal-a'); }); - it('stops an opened Terminal exactly once on close and on Session switch', async () => { + it('stops only explicitly closed Terminals and retains the same resource across navigation', async () => { const { root } = installReactRenderer(); const stops: Array<{ sessionId: string; ref: string }> = []; const defaults = createFakeWorkbarServices(); @@ -388,7 +408,7 @@ describe('useWorkbarController', () => { shellUpdate(sessionId, `terminal-${++ordinal}`), stop: async (request) => { stops.push(request); - return null; + return; }, }, }); @@ -407,7 +427,6 @@ describe('useWorkbarController', () => { await act(async () => renderController(root, services, input(session('b')))); assert.deepEqual(stops, [ { sessionId: 'a', ref: 'terminal-1' }, - { sessionId: 'a', ref: 'terminal-2' }, ]); assert.equal( controllerRenderSnapshots @@ -419,11 +438,16 @@ describe('useWorkbarController', () => { ), false, ); + await act(async () => renderController(root, services, input(session('a')))); + const retained = controller().host.panelsState.right.tabs.find((tab) => tab.kind === 'terminal'); + assert.equal(retained?.resourceRef, 'terminal-2'); + assert.deepEqual(stops, [{ sessionId: 'a', ref: 'terminal-1' }]); }); - it('retries a failed Terminal stop during later Session cleanup', async () => { + it('retains a failed Terminal close for visible retry and removes it only after Stop succeeds', async () => { const { root } = installReactRenderer(); - const firstStop = deferred(); + const firstStop = deferred(); + const retryStop = deferred(); const stops: Array<{ sessionId: string; ref: string }> = []; const defaults = createFakeWorkbarServices(); const services = createFakeWorkbarServices({ @@ -432,7 +456,7 @@ describe('useWorkbarController', () => { start: async (sessionId) => shellUpdate(sessionId, 'terminal-retry'), stop: (request) => { stops.push(request); - return stops.length === 1 ? firstStop.promise : Promise.resolve(null); + return stops.length === 1 ? firstStop.promise : retryStop.promise; }, }, }); @@ -444,23 +468,68 @@ describe('useWorkbarController', () => { ); assert.ok(tab); await act(async () => controller().host.onCloseTab('right', tab)); + await act(async () => controller().host.onCloseTab('right', tab)); assert.deepEqual(stops, [{ sessionId: 'a', ref: 'terminal-retry' }]); + assert.ok(controller().host.panelsState.right.tabs.includes(tab)); await act(async () => { firstStop.reject(new Error('Host disconnected')); await Promise.resolve(); }); await act(async () => renderController(root, services, input(session('b')))); + assert.equal(stops.length, 1); + await act(async () => renderController(root, services, input(session('a')))); + assert.ok(controller().host.panelsState.right.tabs.includes(tab)); + await act(async () => controller().host.onCloseTab('right', tab)); + await act(async () => renderController(root, services, input(session('b')))); + await act(async () => controller().commands.toggleRight()); + assert.equal(controller().host.rightCollapsed, false); + await act(async () => retryStop.resolve()); + assert.equal(controller().host.panelsState.right.tabs.includes(tab), false); + assert.equal(controller().host.rightCollapsed, false); assert.deepEqual(stops, [ { sessionId: 'a', ref: 'terminal-retry' }, { sessionId: 'a', ref: 'terminal-retry' }, ]); - await act(async () => renderController(root, services, input(session('c')))); + await act(async () => root.unmount()); assert.equal(stops.length, 2); }); - it('owns a resolved Terminal before its tab state commits', async () => { + it('releases a retired owner’s terminal topology without stopping Host resources', async () => { + const { root } = installReactRenderer(); + const lateStart = deferred(); + const stops: string[] = []; + const defaults = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ terminal: { + ...defaults.terminal, + start: async (id) => id === 'c' ? lateStart.promise : shellUpdate(id, `terminal-${id}`), + stop: async ({ ref }) => { stops.push(ref); return; }, + } }); + await act(async () => renderController(root, services, input(session('a')))); + await act(async () => controller().commands.openTool('terminal')); + await act(async () => renderController(root, services, input(session('b')))); + await act(async () => controller().commands.openTool('terminal')); + // Host admits retirement only after A's resource is terminal. Its catalog + // removal is authoritative; the renderer does not issue another Stop. + await act(async () => renderController(root, services, { + ...input(session('b')), authoritativeSessionIds: new Set(['b']), + })); + assert.deepEqual(controller().host.panelsState.right.tabs.map((tab) => tab.ownerSessionId), ['b']); + await act(async () => renderController(root, services, input(session('c')))); + await act(async () => controller().commands.openTool('terminal')); + await act(async () => renderController(root, services, { + ...input(session('b')), authoritativeSessionIds: new Set(['b']), + })); + // A delayed response cannot resurrect a terminal whose owner has retired. + await act(async () => lateStart.resolve(shellUpdate('c', 'terminal-c'))); + assert.deepEqual(controller().host.panelsState.right.tabs.map((tab) => tab.ownerSessionId), ['b']); + assert.deepEqual(stops, []); + await act(async () => root.unmount()); + assert.deepEqual(stops, []); + }); + + it('leaves a late Terminal start with its Host Session after view disposal', async () => { const { root } = installReactRenderer(); const start = deferred(); const stops: Array<{ sessionId: string; ref: string }> = []; @@ -471,7 +540,7 @@ describe('useWorkbarController', () => { start: () => start.promise, stop: async (request) => { stops.push(request); - return null; + return; }, }, }); @@ -484,35 +553,156 @@ describe('useWorkbarController', () => { root.unmount(); }); - assert.deepEqual(stops, [ - { sessionId: 'a', ref: 'terminal-before-commit' }, - ]); + assert.deepEqual(stops, []); }); - it('stops an opened Terminal exactly once when the controller unmounts', async () => { + it('recovers a live Terminal after Stop rejects following controller unmount', async () => { const { root } = installReactRenderer(); const stops: Array<{ sessionId: string; ref: string }> = []; const defaults = createFakeWorkbarServices(); + const pendingStop = deferred(); + let live = true; const services = createFakeWorkbarServices({ terminal: { ...defaults.terminal, + recover: async (sessionId) => ({ resources: live ? [shellUpdate(sessionId, 'terminal-unmount')] : [], closes: [] }), start: async (sessionId) => shellUpdate(sessionId, 'terminal-unmount'), - stop: async (request) => { + stop: (request) => { stops.push(request); - return null; + if (stops.length === 1) return pendingStop.promise; + live = false; + return Promise.resolve(); }, }, }); await act(async () => renderController(root, services, input(session('a')))); - await act(async () => controller().commands.openTool('terminal')); + const tab = controller().host.panelsState.right.tabs[0]!; + assert.equal(tab.resourceRef, 'terminal-unmount'); + await act(async () => controller().host.onCloseTab('right', tab)); await act(async () => root.unmount()); + await act(async () => pendingStop.reject(new Error('disconnected after unmount'))); assert.deepEqual(stops, [ { sessionId: 'a', ref: 'terminal-unmount' }, ]); + const reopened = installReactRenderer(); + await act(async () => renderController(reopened.root, services, input(session('a')))); + const restored = controller().host.panelsState.right.tabs[0]!; + assert.equal(restored.resourceRef, tab.resourceRef); + assert.equal(controller().host.panelsState.right.launcherOpen, false); + await act(async () => controller().host.onCloseTab('right', restored)); + assert.equal(controller().host.panelsState.right.tabs.length, 0); + assert.equal(stops.length, 2); + }); + + it('delivers an old pending Close to the rebuilt view', async () => { + const { root } = installReactRenderer(); + const stop = deferred(); + let live = true; + const defaults = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ terminal: { + ...defaults.terminal, + recover: async (id) => ({ resources: live ? [shellUpdate(id, 'pending-close')] : [], closes: [] }), + stop: async () => { await stop.promise; live = false; }, + } }); + await act(async () => renderController(root, services, input(session('a')))); + const tab = controller().host.panelsState.right.tabs[0]!; + await act(async () => controller().host.onCloseTab('right', tab)); + await act(async () => root.unmount()); + const reopened = installReactRenderer(); + await act(async () => renderController(reopened.root, services, input(session('a')))); + assert.equal(controller().host.panelsState.right.tabs[0]?.resourceRef, 'pending-close'); + await act(async () => stop.resolve()); + assert.equal(controller().host.panelsState.right.tabs.length, 0); }); + it('retries a failed inventory without needing output, navigation or reconnect', async (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }); + const { root } = installReactRenderer(); + let reads = 0; + const defaults = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ terminal: { + ...defaults.terminal, + recover: async (id) => { + if (++reads === 1) throw new Error('catalog changed during pagination'); + return { resources: [shellUpdate(id, 'quiet-terminal')], closes: [] }; + }, + } }); + await act(async () => renderController(root, services, input(session('a')))); + assert.equal(controller().host.panelsState.right.tabs.length, 0); + await act(async () => context.mock.timers.tick(100)); + assert.equal(controller().host.panelsState.right.tabs[0]?.resourceRef, 'quiet-terminal'); + await act(async () => root.unmount()); + context.mock.timers.tick(10_000); + assert.equal(reads, 2); + }); + + it('restores on reconnect without stealing selection or resurrecting a closed tab from an older read', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeWorkbarServices(); + const staleRead = deferred(); + let resync: ((event: { sessionId: string }) => void) | undefined; + let reads = 0; + const services = createFakeWorkbarServices({ terminal: { + ...defaults.terminal, + recover: async (id) => ({ resources: ++reads === 1 ? [] : reads === 2 + ? [shellUpdate(id, 'recovered')] : reads === 3 ? await staleRead.promise : [], closes: [] }), + subscribeResync: (handler) => { resync = handler; return () => { resync = undefined; }; }, + } }); + await act(async () => renderController(root, services, input(session('a')))); + await act(async () => controller().commands.openTool('files')); + const selected = controller().host.panelsState.right.activeTabId; + await act(async () => controller().commands.toggleRight()); + await act(async () => resync?.({ sessionId: 'a' })); + assert.equal(controller().host.panelsState.right.activeTabId, selected); + assert.equal(controller().host.rightCollapsed, true); + const tab = controller().host.panelsState.right.tabs.find((tab) => tab.resourceRef === 'recovered'); + assert.ok(tab); + await act(async () => resync?.({ sessionId: 'a' })); + await act(async () => { for (let i = 0; i < 100; i++) resync?.({ sessionId: 'a' }); }); + assert.equal(reads, 3); + await act(async () => controller().host.onCloseTab('right', tab)); + await act(async () => staleRead.resolve([shellUpdate('a', 'recovered')])); + assert.equal(controller().host.panelsState.right.tabs.includes(tab), false); + assert.equal(reads, 4); + await act(async () => root.unmount()); + assert.equal(resync, undefined); + }); + + for (const recoverySessionId of ['a', 'b']) { + it(`completes ${recoverySessionId === 'a' ? 'same' : 'another'} Session recovery when Stop is the only invalidator`, async () => { + const { root } = installReactRenderer(); + const defaults = createFakeWorkbarServices(); + const stop = deferred(); + const inventory = deferred(); + let recovering = false; + let reads = 0; + let resync: ((event: { sessionId: string }) => void) | undefined; + const other = shellUpdate(recoverySessionId, 'other-live-terminal'); + const services = createFakeWorkbarServices({ terminal: { + ...defaults.terminal, + start: async () => shellUpdate('a', 'closing-terminal'), + stop: () => stop.promise, + recover: async () => ({ resources: !recovering ? [] : ++reads === 1 ? await inventory.promise : [other], closes: [] }), + subscribeResync: (handler) => { resync = handler; return () => { resync = undefined; }; }, + } }); + await act(async () => renderController(root, services, input(session('a')))); + await act(async () => controller().commands.openTool('terminal')); + const closing = controller().host.panelsState.right.tabs[0]!; + await act(async () => controller().host.onCloseTab('right', closing)); + recovering = true; + await act(async () => { + if (recoverySessionId === 'a') resync?.({ sessionId: 'a' }); + else renderController(root, services, input(session('b'))); + }); + await act(async () => stop.resolve()); + await act(async () => inventory.resolve([other])); + assert.deepEqual(controller().host.panelsState.right.tabs.map((tab) => tab.resourceRef), ['other-live-terminal']); + assert.equal(reads, recoverySessionId === 'a' ? 2 : 1); + }); + } + it('reports only a Terminal start failure that still belongs to the active Session', async () => { const { root } = installReactRenderer(); const currentErrors: string[] = []; @@ -591,11 +781,11 @@ describe('useWorkbarController', () => { ); }); - it('hides created companion Sessions until cleanup or reconciliation', async () => { + it('keeps a newly created companion hidden through panel changes and stale catalogs until cleanup', async () => { const { root } = installReactRenderer(); const services = createFakeWorkbarServices(); const firstInput = input(session('a')); - firstInput.authoritativeSessionIds = new Set(['a', 'fork']); + firstInput.authoritativeSessionIds = new Set(['a']); await act(async () => renderController(root, services, firstInput)); await act(async () => @@ -605,29 +795,25 @@ describe('useWorkbarController', () => { }), ); assert.equal(controller().selectors.hiddenSessionIds.has('fork'), true); + await act(async () => controller().commands.openTool('files')); + assert.equal(controller().selectors.hiddenSessionIds.has('fork'), true); const reconciled = input(session('a')); - reconciled.authoritativeSessionIds = new Set(['a']); + reconciled.authoritativeSessionIds = new Set(['a', 'fork']); await act(async () => renderController(root, services, reconciled)); + assert.equal(controller().selectors.hiddenSessionIds.has('fork'), true); + await act(async () => controller().host.onForkVisibilityChange?.({ type: 'cleanup-succeeded', sessionId: 'fork' })); assert.equal(controller().selectors.hiddenSessionIds.has('fork'), false); }); - it('binds Browser ownership and disposes its live subscription once', async () => { + it('binds Browser ownership to the selected Session', async () => { const { root } = installReactRenderer(); const activeSessions: Array = []; - let subscriptions = 0; - let disposals = 0; const defaults = createFakeWorkbarServices(); const services = createFakeWorkbarServices({ browser: { ...defaults.browser, setActiveSession: (sessionId) => activeSessions.push(sessionId), - subscribeLive: () => { - subscriptions += 1; - return () => { - disposals += 1; - }; - }, }, }); @@ -635,8 +821,6 @@ describe('useWorkbarController', () => { await act(async () => renderController(root, services, input(session('b')))); await act(async () => root.unmount()); - assert.equal(subscriptions, 1); - assert.equal(disposals, 1); assert.deepEqual(activeSessions, ['a', 'b']); }); }); diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 9156fdb9a5..30e0665fef 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -19,6 +19,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; +import type { ShellRunUpdate } from '@maka/core/events'; import type { MakaBridge } from '../../preload/bridge-contract.js'; import { createDesktopWorkbarServices } from '../../renderer/platform/desktop/create-workbar-services.js'; @@ -36,7 +37,6 @@ function createBridgeRecorder(): { 'browser.setActiveSession', 'browser.setViewport', 'browser.onState', - 'browser.onLive', 'inspector.subscribeUsageChanges', ]); // Adapters that reshape a bridge answer need one to reshape. @@ -79,6 +79,35 @@ function createBridgeRecorder(): { } describe('createDesktopWorkbarServices', () => { + it('recovers only live local desktop PTYs from the Host inventory and updates', async () => { + const { bridge } = createBridgeRecorder(); + const manual = { + sessionId: 's', ownership: { kind: 'local' }, + sourceTurnId: 'desktop-terminal-one', sourceToolCallId: 'desktop-terminal-one', + result: { ref: 'manual', mode: 'pty', status: 'running' }, + } as ShellRunUpdate; + const updates = [ + manual, + { ...manual, sourceTurnId: 'agent-turn' }, + { ...manual, result: { ...manual.result, mode: 'pipes' } }, + { ...manual, result: { ...manual.result, status: 'completed' } }, + { ...manual, ownership: { kind: 'source_unavailable', sourceSessionId: 'source' } }, + ] as ShellRunUpdate[]; + let listener: ((update: ShellRunUpdate) => void) | undefined; + bridge.shellRuns = { + ...bridge.shellRuns, + recover: async () => ({ resources: updates, closes: [] }), + subscribeUpdates: (handler) => { listener = handler; return () => { listener = undefined; }; }, + }; + const services = createDesktopWorkbarServices(bridge); + assert.deepEqual(await services.terminal.recover('s'), { resources: [manual], closes: [] }); + const received: ShellRunUpdate[] = []; + const dispose = services.terminal.subscribeUpdates((update) => received.push(update)); + for (const update of updates) listener?.(update); + assert.deepEqual(received, [manual, updates[3]]); + dispose(); + assert.equal(listener, undefined); + }); it('waits for Host admission before accepting a Side Conversation follow-up', async () => { const { bridge, calls } = createBridgeRecorder(); const services = createDesktopWorkbarServices(bridge, { @@ -177,7 +206,6 @@ describe('createDesktopWorkbarServices', () => { await services.browser.close('s'); await services.browser.getState('s'); services.browser.subscribeState(eventHandler)(); - services.browser.subscribeLive(eventHandler)(); await services.artifacts.list('s'); await services.artifacts.readText('s', 'a'); @@ -265,7 +293,6 @@ describe('createDesktopWorkbarServices', () => { 'browser.close', 'browser.getState', 'browser.onState', - 'browser.onLive', 'artifacts.list', 'artifacts.readText', 'artifacts.readBinary', diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 478919b84d..041300e768 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -83,6 +83,7 @@ import { registerRuntimeHostWorkHubIpc } from "./runtime-host-workhub-ipc-main.j import { registerRuntimeHostExternalSessionsIpc } from "./runtime-host-external-sessions-ipc-main.js"; import { registerRuntimeHostSessionBundleIpc } from "./runtime-host-session-bundle-ipc-main.js"; import { registerRuntimeHostCollaborationIpc } from './runtime-host-collaboration-ipc-main.js'; +import { TerminalCloseIntents } from './terminal-close-intents.js'; import type { DesktopCollaborationConnectionTarget } from './runtime-host-collaboration-invitation.js'; import { registerRuntimeHostAttachmentPreviewIpc } from './runtime-host-artifacts-ipc-main.js'; import { @@ -119,6 +120,7 @@ import { type CandidateIpcMain = ReconnectableReadIpcMain & Pick; export interface DesktopRuntimeHostCandidateDeps { + readonly terminalCloses?: import('./terminal-close-intents.js').TerminalCloseIntents; readonly cacheTranscript?: (scope: DesktopTargetScope, snapshot: DesktopTranscriptReplicaSnapshot) => void; readonly ipcMain: RuntimeHostTargetIpcMain; readonly workspaceRoot: string; @@ -565,6 +567,11 @@ export async function createDesktopRuntimeHostCandidate( const reportError = (error: unknown): void => { if (isTargetActive()) deps.onError?.(error); }; + // Managed candidates receive their target's owner. Direct standalone starts + // have no replacement lifetime, and own this instance until they close. + const terminalCloses = deps.terminalCloses ?? new TerminalCloseIntents( + (change) => sendToRenderer?.('shell-runs:close-changed', change), + ); const sessionObservations = observationRegistry ?? new RuntimeHostSessionObservationRegistry((error) => deps.onError?.(error)); @@ -594,6 +601,7 @@ export async function createDesktopRuntimeHostCandidate( if (failed) throw failed.reason; }; const releaseNativeSession = async (sessionId: string): Promise => { + terminalCloses.retireSession(sessionId); const abortResults = await Promise.allSettled( [...providers].map((provider) => provider.abortSession(sessionId)), ); @@ -686,6 +694,7 @@ export async function createDesktopRuntimeHostCandidate( domains = registerRuntimeHostSessionDomainsIpc( { client, + terminalCloses, sessionObserver, emitModeChanged, ...(deps.renderer ? { sendToRenderer } : {}), diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 5f67a6db65..ffa9837eef 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -62,6 +62,7 @@ import { } from './runtime-host-desktop-candidate.js'; import { RuntimeHostReconnectingIpcMain } from './runtime-host-reconnecting-ipc-main.js'; import { RuntimeHostSessionObservationRegistry } from './runtime-host-session-observation-registry.js'; +import { TerminalCloseIntents } from './terminal-close-intents.js'; import { canRepairManagedRuntimeHostStartup } from './runtime-host-startup-recovery.js'; export interface RuntimeHostDesktopManager { @@ -224,6 +225,7 @@ export type RuntimeHostWaitConflict = | Extract; interface DesktopRuntimeHostTargetGeneration { + readonly terminalCloses: TerminalCloseIntents; readonly epoch: string; readonly input: DesktopRuntimeHostCandidateStartInput; readonly target: ResolvedRuntimeHostProfile; @@ -1135,6 +1137,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { result = await this.startCandidate( { ...target.input, + terminalCloses: target.terminalCloses, onExit: (details) => this.#reportCandidateExit(inheritedExit, details), ...(target.input.profileTarget ? { @@ -1382,11 +1385,18 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } : { profile: LOCAL_RUNTIME_HOST_PROFILE }; const epoch = randomUUID(); - return { + const generation: DesktopRuntimeHostTargetGeneration = { epoch, input, target, observations, + terminalCloses: new TerminalCloseIntents((change) => { + if (generation.valid && generation.hostId) { + input.renderer?.send('shell-runs:close-changed', { + hostId: generation.hostId, targetEpoch: epoch, + }, change); + } + }), state: { epoch, target, @@ -1394,6 +1404,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { }, valid: true, }; + return generation; } async #closeObservations(observations: RuntimeHostSessionObservationRegistry): Promise { diff --git a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts index 348551646a..ca26e2eca8 100644 --- a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts @@ -77,6 +77,7 @@ type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient & >; export interface RuntimeHostSessionDomainsIpcDeps { + terminalCloses: import('./terminal-close-intents.js').TerminalCloseIntents; client: RuntimeHostSessionDomainClient; emitModeChanged(sessionId: string): void; sessionObserver: Pick; @@ -106,7 +107,8 @@ export function registerRuntimeHostSessionDomainsIpc( const newId = deps.newId ?? randomUUID; const now = deps.now ?? Date.now; const shellRuns = registerRuntimeHostShellRunsIpc( - { client: deps.client, newId, sessionObserver: deps.sessionObserver }, + { client: deps.client, newId, sessionObserver: deps.sessionObserver, + terminalCloses: deps.terminalCloses }, ipcMain, ); const shellRunQueries = registerRuntimeHostShellRunQueriesIpc( diff --git a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts index 5bb35d9131..e5f4d79bdb 100644 --- a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto'; +import { DESKTOP_TERMINAL_LAUNCH_PREFIX } from '../shared/runtime-host-identity.js'; import type { ShellRunUpdate } from '@maka/core/events'; import type { ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; import type { SessionDomainChange } from '@maka/runtime-host/protocol'; @@ -28,6 +29,7 @@ import { } from './ipc-reconnect-policy.js'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import type { RuntimeHostSessionObserverTarget } from './runtime-host-session-observer.js'; +import type { TerminalCloseIntents } from './terminal-close-intents.js'; export type RuntimeHostShellRunsClient = Pick< DesktopRuntimeHostClient, @@ -75,6 +77,7 @@ export function registerRuntimeHostShellRunQueriesIpc( export function registerRuntimeHostShellRunsIpc( deps: { client: RuntimeHostShellRunsClient; + terminalCloses: TerminalCloseIntents; newId?: () => string; sessionObserver: { observe( @@ -90,6 +93,7 @@ export function registerRuntimeHostShellRunsIpc( ipcMain: ReconnectableReadIpcMain, ): { close(): Promise } { const newId = deps.newId ?? randomUUID; + const closes = deps.terminalCloses; const controllers = new RuntimeResourceControllers( deps.client, newId, @@ -99,7 +103,7 @@ export function registerRuntimeHostShellRunsIpc( const normalizedSessionId = requiredId(sessionId, 'Session'); const started = await deps.client.startRuntimeResource({ sessionId: normalizedSessionId, - launchId: `desktop-terminal-${newId()}`, + launchId: `${DESKTOP_TERMINAL_LAUNCH_PREFIX}${newId()}`, }); return requiredRuntimeResource( await deps.client.getRuntimeResource(normalizedSessionId, started.resource.ref), @@ -117,10 +121,13 @@ export function registerRuntimeHostShellRunsIpc( ipcMain.handle('shell-runs:write', (_event, value: unknown) => controllers.control(runtimeResourceControl(value)), ); - ipcMain.handle('shell-runs:stop', async (_event, value: unknown) => { + handleReconnectableRead(ipcMain, 'shell-runs:recover', (_event, sessionId: unknown) => { + const id = requiredId(sessionId, 'Session'); + return closes.recover(id, () => deps.client.listRuntimeResources(id)); + }); + ipcMain.handle('shell-runs:stop', (_event, value: unknown) => { const input = runtimeResourceIdentity(value, 'stop'); - await controllers.stop(input); - return deps.client.getRuntimeResource(input.sessionId, input.ref); + return closes.stop(input, () => controllers.stop(input)); }); return { close: () => controllers.close() }; @@ -224,7 +231,6 @@ class RuntimeResourceControllers { control: protocolControl(input), }); state.nextSequence = sequence + 1; - return this.#client.getRuntimeResource(input.sessionId, input.ref); }); } @@ -246,7 +252,7 @@ class RuntimeResourceControllers { await this.#client.stopRuntimeResource(input); const state = this.#states.get(resourceIdentity(input)); this.#states.delete(resourceIdentity(input)); - if (state) await this.#releaseObservation(state); + if (state) await this.#releaseObservation(state).catch(() => undefined); }); } diff --git a/apps/desktop/src/main/terminal-close-intents.ts b/apps/desktop/src/main/terminal-close-intents.ts new file mode 100644 index 0000000000..09efaad368 --- /dev/null +++ b/apps/desktop/src/main/terminal-close-intents.ts @@ -0,0 +1,87 @@ +/* + * 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 { ShellRunUpdate } from '@maka/core/events'; +import { isTerminalShellRunStatus } from '@maka/core/shell-run'; +import type { TerminalCloseChange, TerminalRecovery } from '../shared/runtime-host-identity.js'; + +type Identity = Pick; + +/** Target-owned user intent, independent of both the renderer and connection leases. */ +export class TerminalCloseIntents { + readonly #entries = new Map }>(); + + constructor(private readonly changed: (change: TerminalCloseChange) => void = () => {}) {} + + stop(identity: Identity, stop: () => Promise): Promise { + const key = JSON.stringify([identity.sessionId, identity.ref]); + const previous = this.#entries.get(key); + if (previous?.pending) return previous.pending; + const entry: { change: TerminalCloseChange; pending?: Promise } = { + change: { ...identity, status: 'pending' }, + }; + this.#entries.set(key, entry); + // Register before the operation enters a connection's controller queue. + entry.pending = Promise.resolve().then(stop).then(() => { + if (this.#entries.get(key) !== entry) return; + this.#entries.delete(key); + this.#publish({ ...identity, status: 'closed' }); + }, (error: unknown) => { + if (this.#entries.get(key) !== entry) throw error; + entry.pending = undefined; + entry.change = { ...identity, status: 'unknown' }; + this.#publish(entry.change); + throw error; + }); + this.#publish(entry.change); + return entry.pending; + } + + async recover(sessionId: string, read: () => Promise): Promise { + // The renderer subscribes before this read and invalidates its snapshot on + // Close changes. Keep one retry owner instead of rereading in both layers. + const resources = await read(); + for (const resource of resources) { + const key = JSON.stringify([sessionId, resource.result.ref]); + const entry = this.#entries.get(key); + if (entry && !entry.pending && isTerminalShellRunStatus(resource.result.status)) { + this.#entries.delete(key); + this.#publish({ ...entry.change, status: 'closed' }); + } + } + return { + resources, + closes: [...this.#entries.values()].filter((entry) => entry.change.sessionId === sessionId) + .map((entry) => entry.change), + }; + } + + retireSession(sessionId: string): void { + for (const [key, entry] of this.#entries) { + if (entry.change.sessionId !== sessionId) continue; + this.#entries.delete(key); + this.#publish({ ...entry.change, status: 'closed' }); + } + } + + #publish(change: TerminalCloseChange): void { + // UI delivery cannot change an acknowledged Host operation into a failure. + try { this.changed(change); } catch { /* A new view recovers from Host and pending intent. */ } + } +} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 27071163f6..e2f55579e6 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1365,6 +1365,8 @@ export interface MakaBridge { restore(projectId: string, host?: DesktopRuntimeHostRef): Promise; }; shellRuns: { + recover(sessionId: string): Promise; + subscribeCloseChanges(handler: (change: import('../shared/runtime-host-identity.js').TerminalCloseChange) => void): () => void; list(sessionId: string): Promise; attach(input: { sessionId: string; @@ -1377,11 +1379,11 @@ export interface MakaBridge { ref: string; input?: string; size?: { cols: number; rows: number }; - }): Promise; + }): Promise; stop(input: { sessionId: string; ref: string; - }): Promise; + }): Promise; subscribeUpdates(handler: (update: ShellRunUpdate) => void): () => void; subscribePtyData(handler: (event: ShellRunPtyDataEvent) => void): () => void; subscribeResync(handler: (event: { sessionId: string }) => void): () => void; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 81db4d9305..ceff539b6e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2814,6 +2814,22 @@ const makaBridge = { }, }, shellRuns: { + async recover(sessionId: string) { + const session = await runtimeHostSessionRef(sessionId); + const result = await ipcRenderer.invoke('shell-runs:recover', session.scope, session.sessionId) as + import('../shared/runtime-host-identity.js').TerminalRecovery; + return { + resources: result.resources.map((update) => projectShellRunUpdate(session.scope, update)), + closes: result.closes.map((change) => ({ ...change, + sessionId: recordRuntimeHostSessionScope(session.scope, change.sessionId), + })), + }; + }, + subscribeCloseChanges(handler: (change: import('../shared/runtime-host-identity.js').TerminalCloseChange) => void) { + return subscribeEveryRuntimeHostEvent('shell-runs:close-changed', (scope, change: import('../shared/runtime-host-identity.js').TerminalCloseChange) => + handler({ ...change, sessionId: recordRuntimeHostSessionScope(scope, change.sessionId) }), + ); + }, async list(sessionId: string): Promise { const session = await runtimeHostSessionRef(sessionId); const updates = await ipcRenderer.invoke( @@ -2847,29 +2863,19 @@ const makaBridge = { ) as ShellRunUpdate; return projectShellRunUpdate(session.scope, update); }, - async write(input: { + write(input: { sessionId: string; ref: string; input?: string; size?: { cols: number; rows: number }; - }): Promise { - const session = await runtimeHostSessionRef(input.sessionId); - const update = await ipcRenderer.invoke('shell-runs:write', session.scope, { - ...input, - sessionId: session.sessionId, - }) as ShellRunUpdate | null; - return update ? projectShellRunUpdate(session.scope, update) : null; + }): Promise { + return invokeSessionInput('shell-runs:write', input); }, - async stop(input: { + stop(input: { sessionId: string; ref: string; - }): Promise { - const session = await runtimeHostSessionRef(input.sessionId); - const update = await ipcRenderer.invoke('shell-runs:stop', session.scope, { - ...input, - sessionId: session.sessionId, - }) as ShellRunUpdate | null; - return update ? projectShellRunUpdate(session.scope, update) : null; + }): Promise { + return invokeSessionInput('shell-runs:stop', input); }, subscribeUpdates(handler: (update: ShellRunUpdate) => void): () => void { return subscribeEveryRuntimeHostEvent('shell-runs:update', (scope, update: ShellRunUpdate) => diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 1e00fc530d..359872e4b7 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -154,7 +154,7 @@ export function createAppShellChatActions(deps: { messageRetryPending: SessionPendingClaim; refreshSessions: () => Promise; activateSessionForFirstSend: (sessionId: string) => Promise; - setActiveId: (sessionId: string | undefined) => void; + retireSession: (sessionId: string) => void; setMessageLoadErrorBySession: MessageLoadErrorUpdater; addTransientMessage: ( sessionId: string, @@ -209,7 +209,7 @@ export function createAppShellChatActions(deps: { messageRetryPending, refreshSessions, activateSessionForFirstSend, - setActiveId, + retireSession, setMessageLoadErrorBySession, removeTransientMessage, transcriptRangeRef, @@ -351,7 +351,7 @@ export function createAppShellChatActions(deps: { unsentSessionId = undefined; try { await window.maka.sessions.remove(sessionId); - if (activeIdRef.current === sessionId) setActiveId(undefined); + retireSession(sessionId); await refreshSessions(); } catch { // Best-effort: a failed cleanup must not replace the real error. diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 15bd1e236c..c3d13c4f99 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -28,6 +28,7 @@ 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 { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { applyTheme, applyThemePalette } from './theme'; @@ -50,7 +51,6 @@ import { ShellRunHydration, type ShellRunUpdatesBySession, } from './shell-run-update-state.js'; -import { sessionCatalogRetiresSession } from '../shared/runtime-host-identity.js'; import * as desktopTranscript from './platform/desktop/desktop-transcript-range-store.js'; type RefBox = { current: T }; @@ -163,6 +163,7 @@ export function useAppShellBootstrapSubscriptions(options: { refreshSessions: () => Promise; rendererMountedRef: RefBox; retireSession: (sessionId: string) => void; + retiredSessionIds(sessions: readonly { id: string }[]): string[]; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: ToastApi; }) { @@ -175,9 +176,7 @@ export function useAppShellBootstrapSubscriptions(options: { }); const handleRuntimeHostChange = useEffectEvent((event: DesktopRuntimeHostProfileChangedEvent) => { void options.refreshSessions().then((sessions) => { - const activeSessionId = options.activeIdRef.current; - if (!sessionCatalogRetiresSession(activeSessionId, sessions)) return; - options.retireSession(activeSessionId); + options.retiredSessionIds(sessions).forEach(options.retireSession); }); if (event.readiness !== 'ready') return; if (!event.isDefault) return; @@ -200,6 +199,7 @@ export function useAppShellBootstrapSubscriptions(options: { const handleSessionChange = useEffectEvent( (event: SessionChangedEvent) => { const refreshedSessions = options.refreshSessions(); + if (event.reason === 'archived' && event.sessionId) options.retireSession(event.sessionId); if (event.reason === 'created' || event.reason === 'migrated') { void options.refreshProjects(); } @@ -228,9 +228,7 @@ export function useAppShellBootstrapSubscriptions(options: { options.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(event.modelId)); } void refreshedSessions.then((sessions) => { - const activeSessionId = options.activeIdRef.current; - if (!sessionCatalogRetiresSession(activeSessionId, sessions)) return; - options.retireSession(activeSessionId); + options.retiredSessionIds(sessions).forEach(options.retireSession); }); }, ); @@ -318,33 +316,33 @@ export function useActiveSessionEvents(options: { beginObservationSeed: (sessionId: string) => void; completeObservationSeed: (sessionId: string) => void; setMessageLoadErrorBySession: (updater: (current: Record) => Record) => void; + clearMessageLoadError(sessionId: string): void; setMessageLoadPending: (pending: boolean) => void; - publishTranscript: (sessionId: string, store: desktopTranscript.DesktopTranscriptRangeStore, onReady: () => void) => 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((sessionId: string) => { - options.setMessageLoadErrorBySession((current) => { - if (!current[sessionId]) return current; - const next = { ...current }; - delete next[sessionId]; - return next; - }); - }); - // Publication rechecks the controller's store identity after any input wait. + 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, - store: desktopTranscript.DesktopTranscriptRangeStore, + controller: desktopTranscript.DesktopTranscriptRangeController, + effectIsCurrent: () => boolean, ) => { - options.publishTranscript(sessionId, store, () => { + options.publishTranscript(sessionId, controller, effectIsCurrent, () => { clearMessageLoadError(sessionId); options.setMessageLoadPending(false); }); }); const applyReadError = useEffectEvent((sessionId: string, error: unknown) => { - if (options.activeIdRef.current === sessionId) { + if (options.activeId === sessionId) { + if (options.activeIdRef.current !== sessionId) options.commitTranscript(sessionId, []); const message = messageReadErrorMessage(error, options.uiLocale); options.setMessageLoadErrorBySession((current) => ({ ...current, @@ -404,7 +402,6 @@ export function useActiveSessionEvents(options: { now: Date.now(), }), })); - const unsubscribeTranscript = transcript.subscribe(() => applyTranscript(activeId, transcript)); const openTranscript = (signal: AbortSignal) => window.maka.transcripts.open( activeId, @@ -428,7 +425,8 @@ export function useActiveSessionEvents(options: { onError: (error) => { if (!disposed) applyReadError(activeId, error); }, }, ); - options.transcriptRangeRef.current = controller; + const unsubscribeTranscript = transcript.subscribe(() => + applyTranscript(activeId, controller, () => !disposed)); const subscribeSessionEvents = () => { const attempt = ++observationAttempt; beginObservationSeed(activeId); diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index a59fb09da7..9325d66de7 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -89,11 +89,11 @@ export interface AppShellRevisionActions { export function createAppShellRevisionActions(deps: { uiLocale: UiLocale; activeIdRef: RefBox; + captureSelection(): () => boolean; composerRef: RefBox; messages: readonly StoredMessage[]; hasPendingAttachments: () => boolean; openSessionInChat: (sessionId: string, turnId?: string) => void; - refreshMessages: (sessionId: string) => Promise; refreshSessions: () => Promise; setMessages: MessageListUpdater; commitRevisionDraft: (draft: TurnRevisionDraft | null) => void; @@ -103,11 +103,11 @@ export function createAppShellRevisionActions(deps: { const { uiLocale, activeIdRef, + captureSelection, composerRef, messages, hasPendingAttachments, openSessionInChat, - refreshMessages, refreshSessions, setMessages, commitRevisionDraft, @@ -190,13 +190,13 @@ export function createAppShellRevisionActions(deps: { draft: TurnRevisionDraft, revisionSessionId: string, text: string, + selectionIsCurrent: () => boolean, ): Promise { composerRef.current?.clearDraft(revisionSessionId); const current = revisionDraftRef.current; - if (activeIdRef.current === revisionSessionId) { + if (selectionIsCurrent() && activeIdRef.current === revisionSessionId) { openSessionInChat(draft.sourceSessionId); - setMessages([]); - await refreshMessages(draft.sourceSessionId).catch(() => false); + selectionIsCurrent = captureSelection(); } const abandonment = await abandonRevisionCopy(draft); const abandoningDraft = abandonment.draft; @@ -220,17 +220,13 @@ export function createAppShellRevisionActions(deps: { composerRef.current?.setDraft(draft.sourceSessionId, text); commitRevisionDraft(restored); } - if (activeIdRef.current === draft.sourceSessionId && revisionDraftRef.current === restored) { + if (selectionIsCurrent() && activeIdRef.current === draft.sourceSessionId && revisionDraftRef.current === restored) { composerRef.current?.setText(text); composerRef.current?.focus(); } await refreshSessions().catch(() => []); } - function completeRevisionCopyAttempt(draft: TurnRevisionDraft): void { - completeTurnRevisionCopyAttempt(draft); - } - async function abandonRevisionCopy( draft: TurnRevisionDraft, ): Promise<{ acknowledged: boolean; draft: TurnRevisionDraft }> { @@ -251,7 +247,7 @@ export function createAppShellRevisionActions(deps: { // Main acknowledges only after the cleanup intent is durable; physical // removal may finish after this renderer has closed the draft. await window.maka.sessions.abandonSessionCopy(draft.sourceSessionId, draft.copyId); - completeRevisionCopyAttempt(draft); + completeTurnRevisionCopyAttempt(draft); return { acknowledged: true, draft: abandoningDraft }; } catch { // An ambiguous cleanup acknowledgement stays in `abandoning`; this @@ -261,6 +257,7 @@ export function createAppShellRevisionActions(deps: { } async function prepareRevisionSend(text: string): Promise { + let selectionIsCurrent = captureSelection(); let draft = revisionDraftRef.current; if (!draft || activeIdRef.current !== draft.draftSessionId) return false; // A previous attempt already prepared the version; retry normal send there. @@ -269,7 +266,7 @@ export function createAppShellRevisionActions(deps: { if (draft.copyPhase === 'abandoning') { const abandonment = await abandonRevisionCopy(draft); if ( - !abandonment.acknowledged || + !selectionIsCurrent() || !abandonment.acknowledged || revisionDraftRef.current !== abandonment.draft || activeIdRef.current !== draft.sourceSessionId ) { @@ -311,8 +308,8 @@ export function createAppShellRevisionActions(deps: { copyId: startedDraft.copyId, }); preparedSessionId = newSession.id; - if (activeIdRef.current !== sourceSessionId || revisionDraftRef.current !== startedDraft) { - await rollbackPreparedRevision(startedDraft, newSession.id, text); + if (!selectionIsCurrent() || revisionDraftRef.current !== startedDraft) { + await rollbackPreparedRevision(startedDraft, newSession.id, text, selectionIsCurrent); return false; } @@ -320,16 +317,16 @@ export function createAppShellRevisionActions(deps: { composerRef.current?.setDraft(newSession.id, text); commitRevisionDraft(prepared); openSessionInChat(newSession.id); - setMessages([]); + selectionIsCurrent = captureSelection(); const { messages: preparedMessages, settled } = await readSettledMessages(newSession.id, { signal: preparationAbort.signal, }); if (!settled) throw new Error('Revised Session transcript did not become ready'); if ( - activeIdRef.current !== newSession.id || + !selectionIsCurrent() || activeIdRef.current !== newSession.id || revisionDraftRef.current !== prepared ) { - await rollbackPreparedRevision(startedDraft, newSession.id, text); + await rollbackPreparedRevision(startedDraft, newSession.id, text, selectionIsCurrent); return false; } setMessages(preparedMessages); @@ -340,9 +337,9 @@ export function createAppShellRevisionActions(deps: { } catch (error) { if (preparationAbort.signal.aborted) return false; if (preparedSessionId) { - await rollbackPreparedRevision(startedDraft, preparedSessionId, text); + await rollbackPreparedRevision(startedDraft, preparedSessionId, text, selectionIsCurrent); } - if (activeIdRef.current !== sourceSessionId) return false; + if (!selectionIsCurrent()) return false; if (isSessionWorkspaceUnavailableError(error)) { showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId: sourceSessionId, @@ -362,6 +359,7 @@ export function createAppShellRevisionActions(deps: { } async function cancelRevisionDraft(): Promise { + let selectionIsCurrent = captureSelection(); revisionPreparationAbort?.abort(); const draft = revisionDraftRef.current; if (!draft) return; @@ -371,21 +369,20 @@ export function createAppShellRevisionActions(deps: { : draft.copyId : undefined; if (cleanupSessionId) await abandonRevisionCopy(draft); - else completeRevisionCopyAttempt(draft); + else completeTurnRevisionCopyAttempt(draft); commitRevisionDraft(null); composerRef.current?.setDraft(draft.sourceSessionId, draft.previousComposerText); if (draft.draftSessionId !== draft.sourceSessionId) { composerRef.current?.clearDraft(draft.draftSessionId); } - if (activeIdRef.current !== draft.sourceSessionId) { + if (selectionIsCurrent() && activeIdRef.current !== draft.sourceSessionId) { openSessionInChat(draft.sourceSessionId); - setMessages([]); - await refreshMessages(draft.sourceSessionId).catch(() => false); + selectionIsCurrent = captureSelection(); } if (cleanupSessionId) { await refreshSessions().catch(() => []); } - if (activeIdRef.current === draft.sourceSessionId) { + if (selectionIsCurrent() && activeIdRef.current === draft.sourceSessionId) { composerRef.current?.setText(draft.previousComposerText); composerRef.current?.focus(); } diff --git a/apps/desktop/src/renderer/app-shell-turn-actions.ts b/apps/desktop/src/renderer/app-shell-turn-actions.ts index 4aca3e4490..6d711d124f 100644 --- a/apps/desktop/src/renderer/app-shell-turn-actions.ts +++ b/apps/desktop/src/renderer/app-shell-turn-actions.ts @@ -28,7 +28,6 @@ import { showSessionWorkspaceUnavailableToast, } from './session-workspace-errors.js'; import { acquireSessionCopyAttempt } from './session-copy-attempt.js'; -import type { MessageListUpdater } from './session-workspace-actions.js'; type RefBox = { current: T }; @@ -50,25 +49,23 @@ export interface AppShellTurnActions { export function createAppShellTurnActions(deps: { uiLocale: UiLocale; activeIdRef: RefBox; + captureSelection(): () => boolean; turnActionRegistry: { addKey(key: string): boolean; clearKey(key: string): void; keyOf(sessionId: string, turnId: string, actionId: string): string; }; openSessionInChat: (sessionId: string, turnId?: string) => void; - refreshMessages: (sessionId: string) => Promise; refreshSessions: () => Promise; - setMessages: MessageListUpdater; toastApi: ToastApi; }): AppShellTurnActions { const { uiLocale, activeIdRef, + captureSelection, turnActionRegistry, openSessionInChat, - refreshMessages, refreshSessions, - setMessages, toastApi, } = deps; const copy = getDesktopConversationCopy(uiLocale).actions; @@ -77,6 +74,7 @@ export function createAppShellTurnActions(deps: { if (actionId === 'copy') return; // handled in-component const sessionId = activeIdRef.current; if (!sessionId) return; + const selectionIsCurrent = captureSelection(); const key = turnActionRegistry.keyOf(sessionId, turnId, actionId); // Ref-backed guard blocks same-frame double clicks before React has // committed the disabled state. State alone is too late here because @@ -87,7 +85,7 @@ export function createAppShellTurnActions(deps: { await window.maka.sessions.regenerateTurn(sessionId, { sourceTurnId: turnId, }); - if (activeIdRef.current === sessionId) { + if (selectionIsCurrent()) { toastApi.info(copy.regenerateStartedTitle, copy.regenerateStartedDescription); } } else if (actionId === 'branch') { @@ -104,16 +102,14 @@ export function createAppShellTurnActions(deps: { copyId: copyAttempt.copyId, }); copyAttempt.complete(); - if (activeIdRef.current === sessionId) { + await refreshSessions(); + if (selectionIsCurrent()) { openSessionInChat(newSession.id); - setMessages([]); - await refreshMessages(newSession.id); toastApi.success(copy.branchCreatedTitle, copy.branchCreatedDescription(newSession.name)); } - await refreshSessions(); } } catch (error) { - if (activeIdRef.current !== sessionId) return; + if (!selectionIsCurrent()) return; if (isSessionWorkspaceUnavailableError(error)) { showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); } else { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index db65839b2e..c6650c588f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -313,13 +313,18 @@ function AppShellContent({ seedSessions, activeId, activeIdRef, + requestedSessionId, bootstrapSelectionLease, setActiveId, startNewSession, clearOwnedSessionState, + captureSelection, + isSessionSelected, + retiredSessionIds, messages, transientMessages, setMessages, + commitTranscript, addTransientMessage, updateTransientMessage, retireCancelledTransientMessages, @@ -331,14 +336,14 @@ function AppShellContent({ messageLoadPending, setMessageLoadPending, sessionUiController, + activeCatalogSession, + activeHostSession, + requestedCatalogSession, + requestedHostSession, + sharedSessionActive, + ownerActiveId, + switchingSession, } = useAppShellSessionWorkspace(toastApi); - // A locally created task can become active before its catalog row arrives, - // and remains pending until Host creation finishes. Neither state admits - // Host reads; cached rows already have a Host identity and may reconnect. - const activeCatalogSession = sessions.find((session) => session.id === activeId); - const activeHostSession = activeCatalogSession?.localState !== 'pending' ? activeCatalogSession : undefined; - const sharedSessionActive = activeCatalogSession?.shared === true; - const ownerActiveId = sharedSessionActive ? undefined : activeHostSession?.id; // 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) => { @@ -1354,7 +1359,6 @@ function AppShellContent({ () => setNavSelection({ section: 'sessions' }), [setNavSelection], ); - const clearActiveMessages = useCallback(() => setMessages([]), [setMessages]); const openSession = useMemo( () => createSessionOpenCommand({ @@ -1374,11 +1378,9 @@ function AppShellContent({ // their identity carries no information and this object never has to be // held still by hand (#4109). const sessionNavigationPorts: SessionNavigationPorts = { - activeIdRef, sessionsRef, pendingSessionRowActionsRef, activateSession: setActiveId, - clearActiveMessages, clearSessionRendererState, refreshSessions, toastApi, @@ -1465,7 +1467,7 @@ function AppShellContent({ messageRetryPending: sessionUiController.messageRetryPending, refreshSessions, activateSessionForFirstSend, - setActiveId, + retireSession: clearSessionRendererState, setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, addTransientMessage, updateTransientMessage, @@ -1491,22 +1493,20 @@ function AppShellContent({ const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, { uiLocale, activeIdRef, + captureSelection, turnActionRegistry, openSessionInChat, - refreshMessages, refreshSessions, - setMessages, toastApi, }); const handleSwitchToBypassAndRetry = useCallback( async (turnId: string) => { - const sessionId = activeIdRef.current; - if (!sessionId) return; + const selectionIsCurrent = captureSelection(); const switched = await setPermissionMode('bypass'); - if (!switched || activeIdRef.current !== sessionId) return; + if (!switched || !selectionIsCurrent()) return; await handleTurnFooterAction(turnId, 'regenerate'); }, - [handleTurnFooterAction, setPermissionMode], + [captureSelection, handleTurnFooterAction, setPermissionMode], ); const { @@ -1516,11 +1516,11 @@ function AppShellContent({ } = useStableActions(createAppShellRevisionActions, { uiLocale, activeIdRef, + captureSelection, composerRef, messages, hasPendingAttachments: () => hasPendingContext, openSessionInChat, - refreshMessages, refreshSessions, setMessages, commitRevisionDraft, @@ -1946,11 +1946,8 @@ function AppShellContent({ refreshShellSettings, refreshSessions, rendererMountedRef, - retireSession: (sessionId) => { - setActiveId(undefined); - setMessages([]); - clearSessionRendererState(sessionId); - }, + retireSession: clearSessionRendererState, + retiredSessionIds, setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, }); @@ -1983,13 +1980,13 @@ function AppShellContent({ const observationAuthorityRef = useRef(liveContent.EMPTY_SESSION_OBSERVATION_AUTHORITY); observationAuthorityRef.current = liveContent.advanceSessionObservationAuthority( observationAuthorityRef.current, - activeId, - activeSession?.profileId, + requestedSessionId, + requestedCatalogSession?.profileId, ); useActiveSessionEvents({ publishTranscript, uiLocale, - activeId: activeHostSession?.id, + activeId: requestedHostSession?.id, observationAuthorityRevision: observationAuthorityRef.current.revision, activeIdRef, handleEvent, @@ -1997,7 +1994,9 @@ function AppShellContent({ setExecution: sessionUiController.setExecution, completeObservationSeed, setMessageLoadErrorBySession: sessionUiController.setMessageLoadErrorBySession, + clearMessageLoadError: sessionUiController.clearMessageLoadError, setMessageLoadPending, + commitTranscript, transcriptRangeRef, setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, @@ -2042,7 +2041,7 @@ function AppShellContent({ */ function isShellSurfaceOwnerActive(owner: ComposerImportOwner): boolean { return navSelectionRef.current.section === owner.navSection && - activeIdRef.current === owner.sessionId && + isSessionSelected(owner.sessionId) && (owner.sessionId !== undefined || owner.newTaskDraftKey === currentNewTaskDraftKey); } @@ -2429,7 +2428,9 @@ function AppShellContent({ navigation entry point. */}
-
+
{ closeSettings(); openSession(sessionId); }} /> diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts index dc22e799df..80ce52fab1 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts @@ -18,7 +18,8 @@ */ import { useRef, useState } from 'react'; -import type { StoredMessage } from '@maka/core/session'; +import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import type { TransientUserMessageProjection } from '@maka/ui'; import { currentTranscriptRange } from './transcript-reading-position.js'; import { createAppShellSessionUiStateController, type AppShellSessionUiStateController } from '../model/session-ui-state.js'; @@ -27,11 +28,23 @@ interface TranscriptSource { snapshot(): { readonly messages: readonly StoredMessage[]; readonly ready: boolean }; } +export type TranscriptPublisher = ( + sessionId: string, + controller: Controller, + isCurrent: () => boolean, + onReady: () => void, +) => void; + /** The rendered messages and gap flags are a single publication. The source * may advance during reader input, but only the scroll authority admits it. */ -export function useAppShellSessionUiState( +export function useAppShellSessionUiState< + Controller extends { readonly store: TranscriptSource }, + Session extends SessionSummary & { localState?: string; shared?: boolean }, +>( + sessions: readonly Session[], + requestedSessionId: string | undefined, activeIdRef: { current: string | undefined }, - publishMessages: (messages: StoredMessage[]) => void, + 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. @@ -40,10 +53,16 @@ export function useAppShellSessionUiState(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; - }>({ messages: [], range: 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 @@ -52,22 +71,35 @@ export function useAppShellSessionUiState messagesRef.current.includes(message), setMessagesState(messages: StoredMessage[]) { setView({ + sessionId: activeIdRef.current, messages, range: messages.length ? currentTranscriptRange(transcriptRangeRef.current, activeIdRef.current) : undefined, }); }, - publishTranscript(sessionId: string, store: TranscriptSource, onReady: () => void) { + publishTranscript( + sessionId: string, + rangeController: Controller, + isCurrent: () => boolean, + onReady: () => void, + ) { controller.transcriptViewportNavigation.commitRange(sessionId, () => { - if (transcriptRangeRef.current?.store !== store || activeIdRef.current !== sessionId) return; - const snapshot = store.snapshot(); - publishMessages([...snapshot.messages]); - if (snapshot.ready) onReady(); + if (!isCurrent()) return; + const snapshot = rangeController.store.snapshot(); + if (!snapshot.ready || !commitTranscript(sessionId, [...snapshot.messages], rangeController)) return; + onReady(); }); }, })); + const activeCatalogSession = sessions.find((session) => session.id === view.sessionId); + const requestedCatalogSession = sessions.find((session) => session.id === requestedSessionId); + // 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: { @@ -77,5 +109,20 @@ export function useAppShellSessionUiState updateMap( + 'messageLoadErrorBySession', (current) => omitSessionKey(current, sessionId), + ), messageRetryPending: createPendingClaim('messageRetryPendingBySession'), stopPending: createPendingClaim('stopPendingBySession'), setLiveTurnBySession: createMapSetter('liveTurnBySession'), diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts index a7fa25efb0..e0c4ca4f8c 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts @@ -89,26 +89,20 @@ export interface SessionNavigationRowActions { export function createSessionNavigationRowActions(deps: { uiLocale: UiLocale; - activeIdRef: RefObject; - clearActiveMessages: () => void; clearSessionRendererState: (sessionId: string) => void; pendingSessionRowActionsRef: RefObject>; refreshSessions: () => Promise>; service: SessionNavigationSessionService; sessionsRef: RefObject>; - setActiveId: (sessionId: string | undefined) => void; toastApi: SessionNavigationToastApi; }): SessionNavigationRowActions { const { uiLocale, - activeIdRef, - clearActiveMessages, clearSessionRendererState, pendingSessionRowActionsRef, refreshSessions, service, sessionsRef, - setActiveId, toastApi, } = deps; const copy = getShellCopy(uiLocale).sessionRowActions; @@ -148,10 +142,6 @@ export function createSessionNavigationRowActions(deps: { return runSessionRowAction(sessionId, 'archive', copy.archiveFailedTitle, async () => { const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); await service.archive(sessionId, { revisionFamily: true }); - if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { - setActiveId(undefined); - clearActiveMessages(); - } for (const id of familyIds) clearSessionRendererState(id); await refreshSessions(); }); @@ -240,10 +230,6 @@ export function createSessionNavigationRowActions(deps: { requireArchived: options.requireArchived, }); if (outcome.disposition === 'restored') return outcome; - if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { - setActiveId(undefined); - clearActiveMessages(); - } for (const id of familyIds) clearSessionRendererState(id); return outcome; } @@ -377,10 +363,6 @@ export function createSessionNavigationRowActions(deps: { try { const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); await service.archive(sessionId, { revisionFamily: true }); - if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { - setActiveId(undefined); - clearActiveMessages(); - } for (const id of familyIds) clearSessionRendererState(id); archived += 1; } catch (error) { diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts index c67a460271..0b3b9444b7 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts @@ -106,15 +106,12 @@ export function useSessionNavigationController( () => createSessionNavigationRowActions({ uiLocale: locale, - activeIdRef: portsRef.current.activeIdRef, - clearActiveMessages: () => portsRef.current.clearActiveMessages(), clearSessionRendererState: (sessionId) => portsRef.current.clearSessionRendererState(sessionId), pendingSessionRowActionsRef: portsRef.current.pendingSessionRowActionsRef, refreshSessions: () => portsRef.current.refreshSessions(), service, sessionsRef: portsRef.current.sessionsRef, - setActiveId: (sessionId) => portsRef.current.activateSession(sessionId), toastApi: { success: (title, description) => portsRef.current.toastApi.success(title, description), error: (title, description, details, target) => diff --git a/apps/desktop/src/renderer/features/session-navigation/ports.ts b/apps/desktop/src/renderer/features/session-navigation/ports.ts index 8461fa502d..2731d61eba 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ports.ts +++ b/apps/desktop/src/renderer/features/session-navigation/ports.ts @@ -110,11 +110,9 @@ export interface SessionNavigationServices { * actions capture them once and dereference at call time. */ export interface SessionNavigationPorts { - activeIdRef: RefObject; sessionsRef: RefObject>; pendingSessionRowActionsRef: RefObject>; activateSession(sessionId: string | undefined): void; - clearActiveMessages(): void; clearSessionRendererState(sessionId: string): void; refreshSessions(): Promise>; toastApi: SessionNavigationToastApi; diff --git a/apps/desktop/src/renderer/features/workbar/README.md b/apps/desktop/src/renderer/features/workbar/README.md index 85c141f985..f49cbdd94a 100644 --- a/apps/desktop/src/renderer/features/workbar/README.md +++ b/apps/desktop/src/renderer/features/workbar/README.md @@ -62,17 +62,27 @@ remounted when the active session changes. `Tab` renders `endContent` inside its own `