diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 886404f416..297632fea0 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -124,6 +124,7 @@ on: - 'packages/storage/src/native-file-lock.ts' - 'packages/storage/src/root-authority.ts' - 'packages/storage/src/runtime-policy/document-io.ts' + - 'packages/storage/src/session-bundle-file-service.ts' - 'packages/storage/src/sqlite-long-term-memory-store.ts' - 'packages/storage/src/stable-storage.ts' - '.github/workflows/windows-recovery.yml' diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 800dc4b634..0dad6f6fa4 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -2725,6 +2725,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "../features/session-bundle/index.js": 1, "../locales/external-session-import-copy.js": 1, "../locales/shell-copy.js": 1, "./runtime-host-settings-target.js": 1, @@ -3668,6 +3669,7 @@ "../../shared/settings-ownership.js": 1, "../browser-storage": 1, "../features/connection-settings": 1, + "../features/session-bundle": 1, "../locales/settings-navigation-copy.js": 1, "../locales/settings-shared-copy.js": 1, "./about-settings-page": 1, diff --git a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts index 65ec8f1c33..9918e15ea3 100644 --- a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts +++ b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts @@ -22,7 +22,7 @@ import { afterEach, describe, it } from 'node:test'; import { parseHTML } from 'linkedom'; import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js'; import type { DesktopExternalSessionCatalogItem } from '../../preload/external-session-catalog.js'; import type { ExternalSessionImportFailureReason } from '../../preload/external-session-import-result.js'; @@ -1359,7 +1359,11 @@ async function renderPage(options: { host: TEST_RUNTIME_HOST, children: page, }); - const localized = createElement(AstryxLocaleProvider, { children: targeted }); + // The page asks for a confirmation before exporting a subtree, and a + // confirmation is a toast. The app has always provided one; the harness did + // not, which made every case fail on the provider rather than the case. + const withToasts = createElement(ToastProvider, { children: targeted }); + const localized = createElement(AstryxLocaleProvider, { children: withToasts }); root.render( createElement(LocaleProvider, { locale: options.locale ?? 'en', children: localized }), ); 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 f12f5cb93f..3856a9a566 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 @@ -259,6 +259,10 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn candidateEntrypoint: new URL('file:///unused-runtime-host-candidate.js'), ipcMain: ipc, workspaceRoot: base, + mainWindowController: { + showSaveDialog: async () => ({ canceled: true }), + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + }, attachmentApprovals: createAttachmentApprovalRegistry(), stat: async () => ({ size: 0 }), resizeImage: async (bytes) => bytes, diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index bccc5ace56..ee10340cfd 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -1259,6 +1259,10 @@ function deps( }, ): DesktopRuntimeHostCandidateDeps { return { + mainWindowController: { + showSaveDialog: async () => ({ canceled: true }), + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + }, ipcMain, workspaceRoot: '/workspace', attachmentApprovals: createAttachmentApprovalRegistry(), diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-bundle-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-bundle-ipc-main.test.ts new file mode 100644 index 0000000000..01f2be8704 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-session-bundle-ipc-main.test.ts @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { IpcMain } from 'electron'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import { + __bundleFileNameForTests as bundleFileName, + registerRuntimeHostSessionBundleIpc, + type RuntimeHostSessionBundleIpcDeps, +} from '../runtime-host-session-bundle-ipc-main.js'; + +type IpcHandler = Parameters[1]; + +test('writes to the picked destination and reports what travelled', async () => { + const asked: unknown[] = []; + const ipc = ipcHarness(); + registerRuntimeHostSessionBundleIpc( + deps({ + client: { + exportSessionBundle: async (input) => { + asked.push(input); + return { sessionIds: ['root', 'child'], compressedBytes: 2048 }; + }, + }, + dialog: { save: { canceled: false, filePath: '/picked/Hello.maka-session' } }, + }), + ipc, + ); + + const result = await ipc.invoke('session-bundle:export', 'session-1', 'Hello'); + + assert.deepEqual(asked, [{ sessionId: 'session-1', destination: '/picked/Hello.maka-session' }]); + // The count is the subtree, not one: a bundle rooted at a Session carries the + // subagent conversations under it, and the page says so. + assert.deepEqual(result, { + ok: true, + sessionCount: 2, + path: '/picked/Hello.maka-session', + }); +}); + +test('closing the save dialog asks the Host for nothing', async () => { + let called = false; + const ipc = ipcHarness(); + registerRuntimeHostSessionBundleIpc( + deps({ + client: { + exportSessionBundle: async () => { + called = true; + return { sessionIds: [], compressedBytes: 0 }; + }, + }, + dialog: { save: { canceled: true } }, + }), + ipc, + ); + + const result = await ipc.invoke('session-bundle:export', 'session-1', 'Hello'); + + assert.deepEqual(result, { ok: false, reason: 'canceled' }); + assert.equal(called, false, 'a cancelled dialog is a decision, not a request'); +}); + +test('publishes each imported Session so the shell reads the catalog again', async () => { + const events: Array<{ reason: string; sessionId?: string }> = []; + const ipc = ipcHarness(); + registerRuntimeHostSessionBundleIpc( + deps({ + client: { + importSessionBundle: async () => ({ + sessionIds: ['root', 'child'], + artifactFiles: 3, + }), + }, + dialog: { open: { canceled: false, filePaths: ['/picked/bundle.maka-session'] } }, + onSessionsChanged: (reason, sessionId) => events.push({ reason, ...(sessionId ? { sessionId } : {}) }), + }), + ipc, + ); + + const result = await ipc.invoke('session-bundle:import'); + + assert.deepEqual(result, { ok: true, sessionIds: ['root', 'child'] }); + // The Host republishes its own catalog; the desktop keeps a separate list and + // only re-reads when told, so an imported task is invisible without this. + assert.deepEqual(events, [ + { reason: 'created', sessionId: 'root' }, + { reason: 'created', sessionId: 'child' }, + ]); +}); + +test('keeps the reason a reader can act on', async () => { + const ipc = ipcHarness(); + registerRuntimeHostSessionBundleIpc( + deps({ + client: { + exportSessionBundle: async () => { + throw new RuntimeHostOperationError( + 'session-bundle.export', + 'session_busy', + 'Session is running', + ); + }, + }, + dialog: { save: { canceled: false, filePath: '/picked/Hello.maka-session' } }, + }), + ipc, + ); + + assert.deepEqual(await ipc.invoke('session-bundle:export', 'session-1', 'Hello'), { + ok: false, + reason: 'session_busy', + }); +}); + +test('carries the message of a failure no reason code describes', async () => { + const ipc = ipcHarness(); + registerRuntimeHostSessionBundleIpc( + deps({ + client: { + importSessionBundle: async () => { + throw new Error('Invalid Session id'); + }, + }, + dialog: { open: { canceled: false, filePaths: ['/picked/bundle.maka-session'] } }, + }), + ipc, + ); + + // `failed` is the code nothing downstream can act on, so the message is all + // the user has -- and losing it is how a wrong id reads as "that did not + // work" with no way to tell what was wrong. + assert.deepEqual(await ipc.invoke('session-bundle:import'), { + ok: false, + reason: 'failed', + detail: 'Invalid Session id', + }); +}); + +function deps(input: { + client?: Partial; + dialog?: { + save?: { canceled: boolean; filePath?: string }; + open?: { canceled: boolean; filePaths: string[] }; + }; + onSessionsChanged?: (reason: string, sessionId?: string) => void; +}): RuntimeHostSessionBundleIpcDeps { + return { + client: { + exportSessionBundle: async () => ({ sessionIds: [], compressedBytes: 0 }), + importSessionBundle: async () => ({ sessionIds: [], artifactFiles: 0 }), + ...input.client, + }, + mainWindowController: { + showSaveDialog: async () => input.dialog?.save ?? { canceled: true }, + showOpenDialog: async () => input.dialog?.open ?? { canceled: true, filePaths: [] }, + }, + emitSessionsChanged: (reason, sessionId) => input.onSessionsChanged?.(reason, sessionId), + }; +} + +function ipcHarness() { + const handlers = new Map(); + return { + handle(channel: string, handler: IpcHandler): void { + handlers.set(channel, handler); + }, + async invoke(channel: string, ...args: unknown[]): Promise { + const handler = handlers.get(channel); + assert.ok(handler, `missing handler: ${channel}`); + return handler({ sender: { id: 1 } } as never, ...args); + }, + }; +} + +test('proposes a filename a task name cannot steer', () => { + // The dialog still decides the path. This decides what it opens holding, and + // a task name is written by a person. + assert.equal(bundleFileName('Refactor compaction'), 'Refactor compaction'); + assert.equal(bundleFileName('../../etc/passwd'), 'etc passwd', 'no separators survive'); + assert.equal(bundleFileName('.hidden'), 'hidden', 'not a dotfile'); + assert.equal(bundleFileName(' '), 'maka-session', 'a blank name still names something'); + assert.equal(bundleFileName(undefined), 'maka-session'); + assert.ok(bundleFileName('x'.repeat(500)).length <= 80, 'bounded for the filesystem'); +}); diff --git a/apps/desktop/src/main/__tests__/session-bundle-export-tree.test.ts b/apps/desktop/src/main/__tests__/session-bundle-export-tree.test.ts new file mode 100644 index 0000000000..8bc08f61e2 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-bundle-export-tree.test.ts @@ -0,0 +1,195 @@ +/* + * 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 { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; +import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; +import { ExportTree } from '../../renderer/features/session-bundle/testing.js'; + +// Named, not spread: `globalThis` carries getter-only properties, and copying +// the whole object back throws on the first one. +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, +}; + +afterEach(() => { + Object.assign(globalThis, originalGlobals); +}); + +describe('session bundle export tree', () => { + it('nests a subagent subtree and counts the whole thing', async () => { + const rendered = await render([ + task('root', 'Refactor compaction'), + subagentTask('child-a', 'Find the call sites', 'root', 'Explore'), + subagentTask('grandchild', 'What the child spawned', 'child-a', 'Explore'), + subagentTask('child-b', 'Check the checkpoint path', 'root', 'general-purpose'), + task('unrelated', 'Say hello'), + ]); + + // Three descendants, not the two children: a subagent spawns its own, and a + // bundle rooted here carries all of them. + assert.match(rendered.container.textContent, /Carries 3 subagent conversations/); + // The child that has one of its own says so too. + assert.match(rendered.container.textContent, /Carries 1 subagent conversation/); + // Nesting is structural, not an indent class: a child lives inside the list + // that belongs to its parent, which is what draws one continuous rule. + assert.equal( + rendered.container.querySelectorAll('.maka-export-subtree').length, + 2, + 'one per parent that has children', + ); + assert.equal( + rendered.container.querySelectorAll('.maka-export-tree > .maka-export-node').length, + 2, + 'two roots: the parent task and the unrelated one', + ); + await rendered.dispose(); + }); + + it('reads the link the catalog actually publishes', async () => { + // The catalog projection calls it `subagent`; `subagentParent` is on the + // full header and absent from a list. Reading only the second one made + // every row a root -- a flat list claiming every task stands alone. + const rendered = await render([ + task('root', 'Refactor compaction'), + { ...task('child', 'A child'), subagent: { parentSessionId: 'root', agentName: 'Explore' } }, + ]); + assert.equal(rendered.container.querySelectorAll('.maka-export-subtree').length, 1); + assert.match(rendered.container.textContent, /Carries 1 subagent conversation/); + await rendered.dispose(); + }); + + it('shows a task whose parent is not in the list', async () => { + // Its parent was archived, or paging never reached it. It is not a root and + // nothing here can nest it, so without this it is simply gone. + const rendered = await render([ + subagentTask('orphan', 'Parent is elsewhere', 'absent-parent', 'Explore'), + ]); + assert.match(rendered.container.textContent, /Parent is elsewhere/); + await rendered.dispose(); + }); + + it('a Session that names itself as its parent still renders', async () => { + // `subagentParent` is an ordinary field with no schema guarantee behind it, + // and a cycle here is an infinite render rather than a wrong number. + const rendered = await render([subagentTask('loop', 'Points at itself', 'loop', 'Explore')]); + assert.match(rendered.container.textContent, /Points at itself/); + await rendered.dispose(); + }); + + it('leaves archived tasks out', async () => { + const rendered = await render([{ ...task('archived', 'Put away'), isArchived: true }]); + assert.doesNotMatch(rendered.container.textContent, /Put away/); + await rendered.dispose(); + }); + + it('offers every row, because a bundle can be rooted at any node', async () => { + const rendered = await render([ + task('root', 'Refactor compaction'), + subagentTask('child', 'Find the call sites', 'root', 'Explore'), + ]); + const exports = Array.from( + rendered.container.querySelectorAll('button'), + ).filter((button) => button.textContent === 'Export'); + assert.equal(exports.length, 2, 'the child exports on its own too'); + await rendered.dispose(); + }); + it('shows an active grandchild whose parent is archived', async () => { + // Lineage decided over the whole catalog and rows hidden afterwards loses + // this one: it nests under a parent that is never drawn, so nobody renders + // it. Filtering first is what makes it a root of its own. + const rendered = await render([ + task('root', 'Refactor compaction'), + { ...subagentTask('gone', 'Archived middle', 'root', 'Explore'), isArchived: true }, + subagentTask('grandchild', 'Still running', 'gone', 'Explore'), + ]); + assert.match(rendered.container.textContent, /Still running/); + assert.doesNotMatch(rendered.container.textContent, /Archived middle/); + await rendered.dispose(); + }); +}); + +async function render(sessions: readonly DesktopSessionSummary[]): Promise<{ + container: HTMLElement; + dispose(): Promise; +}> { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.getElementById('root') as unknown as HTMLElement; + const root = createRoot(container); + await act(async () => { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ExportTree, { sessions, isBusy: false, onExport: () => {} }), + }), + }), + ); + }); + return { container, dispose: async () => void (await act(async () => root.unmount())) }; +} + +function task(id: string, name: string): DesktopSessionSummary { + return { + id, + name, + createdAt: 0, + updatedAt: 0, + cwd: '/workspace', + isArchived: false, + status: 'idle', + backend: 'ai-sdk', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + revision: 1, + } as unknown as DesktopSessionSummary; +} + +function subagentTask( + id: string, + name: string, + parentSessionId: string, + agentName: string, +): DesktopSessionSummary { + return { + ...task(id, name), + // The full shape the guard requires: a partial one is not a link, and a + // fixture that omits `spawnedBy` silently renders five unrelated roots. + subagentParent: { + kind: 'subagent', + parentSessionId, + lifecycle: 'foreground', + spawnedBy: { parentRunId: 'run-1', parentTurnId: 'turn-1', toolCallId: 'call-1' }, + }, + subagentRuntime: { agentName }, + } as unknown as DesktopSessionSummary; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 2242f354c4..88bcf9375b 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1128,6 +1128,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( attachmentApprovals, stat: (path) => import("node:fs/promises").then(({ stat }) => stat(path)), resizeImage: resizeImageForAttachment, + mainWindowController, nativeCapabilities: { browserTools: native.browserTools, resolveBrowserUrl: ({ sessionId, toolName, arguments: args }) => { diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 7fcf4a57e8..b518b421a8 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1013,6 +1013,20 @@ export class DesktopRuntimeHostClient { return requireSessionProjection(result.session); } + exportSessionBundle(input: { + readonly sessionId: string; + readonly destination: string; + readonly expectedSubtreeSize?: number; + }): Promise<{ readonly sessionIds: readonly string[]; readonly compressedBytes: number }> { + return this.request("session-bundle.export", input); + } + + importSessionBundle(input: { + readonly source: string; + }): Promise<{ readonly sessionIds: readonly string[]; readonly artifactFiles: number }> { + return this.request("session-bundle.import", input); + } + updateSessionMetadata( sessionId: string, patch: SessionMetadataPatch, diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 081b592e1b..b54dfe76a2 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -81,6 +81,7 @@ import { } from "./runtime-host-session-catalog-ipc-main.js"; import { registerRuntimeHostWorkHubIpc } from "./runtime-host-workhub-ipc-main.js"; 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 type { DesktopCollaborationConnectionTarget } from './runtime-host-collaboration-invitation.js'; import { registerRuntimeHostAttachmentPreviewIpc } from './runtime-host-artifacts-ipc-main.js'; @@ -124,6 +125,19 @@ export interface DesktopRuntimeHostCandidateDeps { readonly attachmentApprovals: AttachmentApprovalRegistry; readonly stat: (path: string) => Promise<{ size: number }>; readonly resizeImage: (bytes: Uint8Array) => Promise; + /** Native file dialogs for moving a Session in or out as a bundle. */ + readonly mainWindowController: { + showSaveDialog(options: { + title?: string; + defaultPath?: string; + filters?: Array<{ name: string; extensions: string[] }>; + }): Promise<{ canceled: boolean; filePath?: string }>; + showOpenDialog(options: { + title?: string; + properties?: string[]; + filters?: Array<{ name: string; extensions: string[] }>; + }): Promise<{ canceled: boolean; filePaths: string[] }>; + }; readonly nativeCapabilities: DesktopNativeCapabilityProviderInput; readonly botRegistry: BotRegistry; readonly resolveBotCreateTarget: ( @@ -878,6 +892,14 @@ export async function createDesktopRuntimeHostCandidate( }, ipc, ); + registerRuntimeHostSessionBundleIpc( + { + client, + mainWindowController: deps.mainWindowController, + emitSessionsChanged, + }, + ipc, + ); } const stopSession = sessionCopyCleanup ? registerRuntimeHostSessionExecutionIpc( diff --git a/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts new file mode 100644 index 0000000000..c53e602431 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts @@ -0,0 +1,171 @@ +/* + * 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 { IpcMain } from 'electron'; +import type { SessionChangedReason } from '@maka/core/session'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import type { + SessionBundleExportIpcResult, + SessionBundleFailure, + SessionBundleFailureReason, + SessionBundleImportIpcResult, +} from '../preload/bridge-contract.js'; + +/** + * Settings › Import/export tasks — moving a Session between installations. + * + * The file is picked here and the work happens in the Runtime Host, because the + * Host holds the Storage Root authority for its whole lifetime and the lock + * that grants it is an election: it refuses a second exclusive hold even from + * the process already holding one. Doing this in the desktop process would + * require stopping the Host the user is looking at. + */ +type SessionBundleClient = { + exportSessionBundle(input: { + readonly sessionId: string; + readonly destination: string; + readonly expectedSubtreeSize?: number; + }): Promise<{ readonly sessionIds: readonly string[]; readonly compressedBytes: number }>; + importSessionBundle(input: { + readonly source: string; + }): Promise<{ readonly sessionIds: readonly string[]; readonly artifactFiles: number }>; +}; + +interface DialogController { + showSaveDialog(options: { + title?: string; + defaultPath?: string; + filters?: Array<{ name: string; extensions: string[] }>; + }): Promise<{ canceled: boolean; filePath?: string }>; + showOpenDialog(options: { + title?: string; + properties?: string[]; + filters?: Array<{ name: string; extensions: string[] }>; + }): Promise<{ canceled: boolean; filePaths: string[] }>; +} + +export interface RuntimeHostSessionBundleIpcDeps { + readonly client: SessionBundleClient; + readonly mainWindowController: DialogController; + readonly emitSessionsChanged: (reason: SessionChangedReason, sessionId?: string) => void; +} + +const BUNDLE_EXTENSION = 'maka-session'; + +export { bundleFileName as __bundleFileNameForTests }; + +export function registerRuntimeHostSessionBundleIpc( + deps: RuntimeHostSessionBundleIpcDeps, + ipcMain: { handle(channel: string, listener: Parameters[1]): void }, +): void { + ipcMain.handle('session-bundle:export', async (_event, ...args: unknown[]) => { + const [sessionId, suggestedName, expectedSubtreeSize] = args as [ + string, + string, + number | undefined, + ]; + const picked = await deps.mainWindowController.showSaveDialog({ + defaultPath: `${bundleFileName(suggestedName)}.${BUNDLE_EXTENSION}`, + filters: [{ name: 'Maka session', extensions: [BUNDLE_EXTENSION] }], + }); + if (picked.canceled || !picked.filePath) { + return { ok: false, reason: 'canceled' } satisfies SessionBundleExportIpcResult; + } + try { + const result = await deps.client.exportSessionBundle({ + sessionId, + destination: picked.filePath, + ...(typeof expectedSubtreeSize === 'number' ? { expectedSubtreeSize } : {}), + }); + return { + ok: true, + sessionCount: result.sessionIds.length, + path: picked.filePath, + } satisfies SessionBundleExportIpcResult; + } catch (error) { + return failure(error) satisfies SessionBundleExportIpcResult; + } + }); + + ipcMain.handle('session-bundle:import', async () => { + const picked = await deps.mainWindowController.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Maka session', extensions: [BUNDLE_EXTENSION] }], + }); + const source = picked.filePaths[0]; + if (picked.canceled || !source) { + return { ok: false, reason: 'canceled' } satisfies SessionBundleImportIpcResult; + } + try { + const result = await deps.client.importSessionBundle({ source }); + // The Host republishes the catalog, but the desktop shell keeps its own + // list and only reads again when told. + for (const sessionId of result.sessionIds) deps.emitSessionsChanged('created', sessionId); + return { ok: true, sessionIds: result.sessionIds } satisfies SessionBundleImportIpcResult; + } catch (error) { + return failure(error) satisfies SessionBundleImportIpcResult; + } + }); +} + +/** + * A task name is written by a person, and this one becomes a proposed filename. + * + * Separators would move the dialog somewhere the name does not say, a leading + * dot proposes a hidden file, and a long name proposes one the filesystem may + * refuse. The dialog is still where the path is decided -- this only decides + * what it opens holding. + */ +function bundleFileName(name: unknown): string { + const proposed = (typeof name === 'string' ? name : '') + .replace(/[\u0000-\u001f/\\:*?"<>|]/g, ' ') + .replace(/\s+/g, ' ') + .replace(/^[.\s]+|[.\s]+$/g, '') + .slice(0, 80) + .trim(); + return proposed.length > 0 ? proposed : 'maka-session'; +} + +function failure(error: unknown): SessionBundleFailure { + const reason = classify(error); + if (reason !== 'failed') return { ok: false, reason }; + // Nothing downstream can act on `failed`, so the message is all the user has. + // It also reaches the main-process log, because a reason code that says only + // "no" is how a cause gets lost. + console.error('[session-bundle] unclassified failure:', error); + return { + ok: false, + reason, + ...(error instanceof Error && error.message ? { detail: error.message } : {}), + }; +} + +function classify(error: unknown): SessionBundleFailureReason { + if (!(error instanceof RuntimeHostOperationError)) return 'failed'; + switch (error.code) { + case 'not_found': + case 'session_busy': + case 'operation_conflict': + case 'source_unreadable': + case 'candidate_set_stale': + return error.code; + default: + return 'failed'; + } +} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index e389b888b4..d56dfd45fb 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -143,6 +143,39 @@ import type { UsageProvenance } from '@maka/core/usage-ledger-merge'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import type { TestProxyInput } from '@maka/core/settings/network-settings'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; +/** + * What Settings › Import/export tasks gets back from a bundle operation. + * + * Stated here rather than in a file of its own: the renderer reaches this + * contract through the bridge, and a separate module would join the legacy + * AppShell closure, which the renderer architecture check freezes. + * + * `detail` is only set for `failed` -- the reason code no reader can act on. + * Without it the page says "that did not work" and the cause is gone, which is + * exactly the case where the user has nothing else to go on. + */ +export type SessionBundleFailureReason = + | 'canceled' + | 'candidate_set_stale' + | 'not_found' + | 'session_busy' + | 'operation_conflict' + | 'source_unreadable' + | 'failed'; + +export type SessionBundleFailure = { + readonly ok: false; + readonly reason: SessionBundleFailureReason; + readonly detail?: string; +}; + +export type SessionBundleExportIpcResult = + | { readonly ok: true; readonly sessionCount: number; readonly path: string } + | SessionBundleFailure; + +export type SessionBundleImportIpcResult = + | { readonly ok: true; readonly sessionIds: readonly string[] } + | SessionBundleFailure; import type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; import type { SessionCollaborationCancelResult, @@ -1273,6 +1306,23 @@ export interface MakaBridge { sourceSessionId: string; }, host?: DesktopRuntimeHostRef): Promise>; }; + sessionBundles: { + /** + * Picks a destination, then writes the Session and its subagent subtree. + * + * `sessionId` is the projected, host-scoped id the renderer holds. Always + * routed to the Local Host: the picker returns a path on this machine, and + * that is the Host whose filesystem it names. + */ + export(input: { + sessionId: string; + suggestedName: string; + /** Sessions the user was shown, counting the root. See the Host operation. */ + expectedSubtreeSize?: number; + }): Promise; + /** Picks a `.maka-session` file and merges it into the Local workspace. */ + import(): Promise; + }; projects: { getDefaultContext(host?: DesktopRuntimeHostRef): Promise<{ snapshot: DesktopProjectSnapshot; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 57d097c7e1..006b44ae5f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -17,6 +17,11 @@ * under the License. */ +import type { + SessionBundleExportIpcResult, + SessionBundleImportIpcResult, +} from './bridge-contract.js'; + import type { WorkHubAnswerInput, WorkHubAnswerResult } from '../shared/workhub-conversation.js'; import { contextBridge, ipcRenderer } from 'electron'; import { workHubControlBridge } from './workhub-control.js'; @@ -2676,6 +2681,29 @@ const makaBridge = { : result; }, }, + sessionBundles: { + // Both halves name a path the Electron picker chose, which is a path on + // THIS machine, and the protocol interprets it on the Host's filesystem. + // Those are the same filesystem only for the Local Host, so both are routed + // there explicitly -- not to whichever Host is active, and not to whichever + // one Settings happens to be pointed at. Carrying a bundle to or from a + // remote Host needs a byte transfer, not a path string. + async export(input: { + sessionId: string; + suggestedName: string; + expectedSubtreeSize?: number; + }): Promise { + const scope = await localRuntimeHostRef(); + const { sessionId } = parseDesktopSessionKey(input.sessionId); + return (await ipcRenderer.invoke( + 'session-bundle:export', scope, sessionId, input.suggestedName, input.expectedSubtreeSize, + )) as SessionBundleExportIpcResult; + }, + async import(): Promise { + const scope = await localRuntimeHostRef(); + return (await ipcRenderer.invoke('session-bundle:import', scope)) as SessionBundleImportIpcResult; + }, + }, projects: { async getDefaultContext(host?: DesktopRuntimeHostRef): Promise<{ snapshot: DesktopProjectSnapshot; diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 5c21481fdc..73f6a85885 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -39,6 +39,8 @@ import { createDesktopModuleHubServices } from '../platform/desktop/create-modul import { createDesktopRuntimeHostManagementServices } from '../platform/desktop/create-runtime-host-management-services'; import { createDesktopSessionCollaborationServices } from '../platform/desktop/create-session-collaboration-services'; import { createDesktopSessionNavigationServices } from '../platform/desktop/create-session-navigation-services'; +import { SessionBundleServicesProvider } from '../features/session-bundle'; +import { createDesktopSessionBundleServices } from '../platform/desktop/create-session-bundle-services.js'; import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services'; import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services'; import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services'; @@ -60,6 +62,7 @@ export function createDesktopFeatureServices() { runtimeHostManagement: createDesktopRuntimeHostManagementServices(), sessionCollaboration: createDesktopSessionCollaborationServices(), sessionNavigation: createDesktopSessionNavigationServices(), + sessionBundle: createDesktopSessionBundleServices(), sessionSettings: createDesktopSessionSettingsServices(), taskEntry: createDesktopTaskEntryServices(), workbar: createDesktopWorkbarServices(), @@ -82,7 +85,11 @@ export function DesktopFeatureServicesProvider(props: { - {props.children} + + + {props.children} + + diff --git a/apps/desktop/src/renderer/features/session-bundle/export-tree.tsx b/apps/desktop/src/renderer/features/session-bundle/export-tree.tsx new file mode 100644 index 0000000000..37456c3a62 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-bundle/export-tree.tsx @@ -0,0 +1,117 @@ +/* + * 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 { ReactElement } from 'react'; +import { Badge } from '@astryxdesign/core/Badge'; +import { Button } from '@astryxdesign/core/Button'; +import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { HStack, VStack } from '@astryxdesign/core/Stack'; +import { projectLinkedSessionTree } from '@maka/core/session'; +import { useUiLocale } from '@maka/ui'; +import type { DesktopSessionSummary } from '../../../shared/desktop-session-projection.js'; +import { getExternalSessionImportCopy } from '../../locales/external-session-import-copy.js'; + +/** + * The local tasks, as the tree a bundle would carry. + * + * A bundle can be rooted at any node -- a subagent Session has its own complete + * history -- so every row exports, and the nesting says which subtree a row + * would take with it. + */ +export function ExportTree(props: { + sessions: readonly DesktopSessionSummary[]; + isBusy: boolean; + onExport: (session: DesktopSessionSummary, carried: number) => void; +}): ReactElement { + const copy = getExternalSessionImportCopy(useUiLocale()); + + // Filtered first, then projected. Deciding lineage over the whole catalog and + // hiding rows afterwards loses a Session: an active grandchild under an + // archived child nests under a parent that never draws it, so nobody renders + // it at all. + const visible = props.sessions.filter((session) => !session.isArchived); + // The read model the rest of the app projects lineage with, rather than a + // second one maintained here. It resolves both spellings of the link, drops a + // parent that is not in the list, and refuses a cycle -- which `subagentParent` + // permits, being an ordinary field with no schema guarantee behind it. + const tree = projectLinkedSessionTree(visible); + const childrenOf = (sessionId: string): readonly DesktopSessionSummary[] => + (tree.childrenByParentId.get(sessionId) ?? []) as readonly DesktopSessionSummary[]; + + const descendantCount = (sessionId: string): number => { + // A subagent spawns its own, so this walks the subtree rather than counting + // one level. + let count = 0; + const pending = [...childrenOf(sessionId)]; + while (pending.length > 0) { + const next = pending.pop(); + if (next === undefined) continue; + count += 1; + pending.push(...childrenOf(next.id)); + } + return count; + }; + + if (tree.roots.length === 0) return ; + + const node = (session: DesktopSessionSummary, depth: number): ReactElement => { + const carried = descendantCount(session.id); + const agent = session.subagent?.agentName ?? session.subagentRuntime?.agentName; + const children = childrenOf(session.id); + return ( +
  • +
    + + + {session.name ?? session.id} + {agent ? : null} + + {carried > 0 && ( + {copy.exportCarriesSubagents(carried)} + )} + +
    + {children.length > 0 && ( + // The rule belongs to the container, not to each row: a border on the + // list that holds the children is exactly as tall as they are, while a + // mark drawn beside every child draws nothing between them. +
      + {children.map((child) => node(child, depth + 1))} +
    + )} +
  • + ); + }; + + return ( +
      + {tree.roots.map((session) => node(session as DesktopSessionSummary, 0))} +
    + ); +} diff --git a/apps/desktop/src/renderer/features/session-bundle/index.ts b/apps/desktop/src/renderer/features/session-bundle/index.ts new file mode 100644 index 0000000000..c38e78a938 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-bundle/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { SessionBundleImportPanel, SessionBundleTasks } from './session-bundle-tasks.js'; +export { SessionBundleServicesProvider } from './services-context.js'; +export type { SessionBundleServices } from './ports.js'; +/** The id the catalog page offers beside its adapters. Not an adapter: a file. */ +export const MAKA_BUNDLE_SOURCE_ID = 'maka-bundle'; diff --git a/apps/desktop/src/renderer/features/session-bundle/ports.ts b/apps/desktop/src/renderer/features/session-bundle/ports.ts new file mode 100644 index 0000000000..6c85075825 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-bundle/ports.ts @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + + +/** + * What this feature needs from the Desktop, and nothing more. + * + * The bridge is the composition zone's to hold. A feature that reached for it + * directly would be a feature that only runs in Electron, which is the coupling + * these ports exist to refuse. + */ +export type SessionBundleFailureReason = + | 'canceled' + // The subtree changed between the confirmation and the fence. + | 'candidate_set_stale' + | 'not_found' + | 'session_busy' + | 'operation_conflict' + | 'source_unreadable' + | 'failed'; + +/** Stated here, not imported: a feature that named a preload module would only + * run inside Electron. The Desktop's bridge satisfies this structurally. */ +export type SessionBundleExportOutcome = + | { readonly ok: true; readonly sessionCount: number; readonly path: string } + | { readonly ok: false; readonly reason: SessionBundleFailureReason; readonly detail?: string }; + +export type SessionBundleImportOutcome = + | { readonly ok: true; readonly sessionIds: readonly string[] } + | { readonly ok: false; readonly reason: SessionBundleFailureReason; readonly detail?: string }; + +export interface SessionBundleServices { + /** Picks a destination, then writes the Session and its subagent subtree. */ + exportBundle(input: { + readonly sessionId: string; + readonly suggestedName: string; + /** + * How many Sessions the user was shown, counting the root. + * + * The renderer decides whether to confirm from the catalog it has, the save + * dialog opens, and only then does the Host discover and fence the real + * subtree. Another client can finish spawning a child in that gap, so the + * file would hold Sessions nobody was asked about. The Host compares this + * against what it fenced and refuses rather than exporting more than was + * confirmed. + */ + readonly expectedSubtreeSize?: number; + }): Promise; + /** Picks a `.maka-session` file and merges it into this workspace. */ + importBundle(): Promise; +} diff --git a/apps/desktop/src/renderer/features/session-bundle/services-context.tsx b/apps/desktop/src/renderer/features/session-bundle/services-context.tsx new file mode 100644 index 0000000000..a7ea5c4402 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-bundle/services-context.tsx @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext, type ReactNode, useContext } from 'react'; +import type { SessionBundleServices } from './ports.js'; + +const SessionBundleServicesContext = createContext(undefined); + +export function SessionBundleServicesProvider(props: { + readonly services: SessionBundleServices; + readonly children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useSessionBundleServices(): SessionBundleServices { + const services = useContext(SessionBundleServicesContext); + if (!services) throw new Error('Session bundle services are unavailable'); + return services; +} diff --git a/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx b/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx new file mode 100644 index 0000000000..35b2190aa1 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx @@ -0,0 +1,255 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext, type ReactElement, type ReactNode, useContext, useState } from 'react'; +import { Banner } from '@astryxdesign/core/Banner'; +import { Button } from '@astryxdesign/core/Button'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; +import { HStack, VStack } from '@astryxdesign/core/Stack'; +import { useMountedRef, useToast, useUiLocale } from '@maka/ui'; +import type { DesktopSessionSummary } from '../../../shared/desktop-session-projection.js'; +import { getExternalSessionImportCopy } from '../../locales/external-session-import-copy.js'; +import { getSettingsSharedCopy } from '../../locales/settings-shared-copy.js'; +import { ExportTree } from './export-tree.js'; +import { useSessionBundleServices } from './services-context.js'; + +/** + * Settings › Import/export tasks — moving a Session between installations. + * + * The catalog of other agents' conversations stays where it was; this wraps it + * with the second half of the subject and owns everything the bundle needs, so + * the legacy page keeps its shape and gains no state, no bridge call and no + * component of its own. + */ +export function SessionBundleTasks(props: { + /** The adapter catalog, rendered when the import half is showing. */ + children: ReactNode; + /** Local tasks for the export half. Archived ones are left out here. */ + sessions?: readonly DesktopSessionSummary[]; + /** The settings surface's own section chrome, supplied rather than imported. */ + renderSection: (input: { + title?: string; + description?: string; + variant?: 'bare'; + children: ReactNode; + }) => ReactElement; +}): ReactElement { + const locale = useUiLocale(); + const copy = getExternalSessionImportCopy(locale); + const [mode, setMode] = useState<'import' | 'export'>('import'); + const bundle = useSessionBundleActions(); + + // Local-owner Sessions only. A bundle names a path the Electron picker chose, + // which is a path on this machine; a remote Host would read it on its own + // filesystem. A Guest projection is not ours to carry at all -- a Guest's + // Desktop does not even register these channels, and a remote owner is not + // granted the operations. + const exportable = (props.sessions ?? []).filter( + (session) => session.profileKind === 'local' && session.shared !== true, + ); + + // Its own label, and segment names no row action shares. `来源` is what an + // adapter is; this switch is not that. And a row's action is called 导出 too, + // so two controls answering to one name is a person tabbing to the wrong one. + const modeSwitch = ( + { + // The note reports what the other half just did. Carrying it across + // makes it read as this half's result. + bundle.clearNote(); + setMode(next as 'import' | 'export'); + }} + > + + + + ); + + // The section chrome belongs to the settings surface, not here: a feature + // that reached into the legacy page for it would be a feature that only + // renders inside that page. + return ( + <> + {props.renderSection({ variant: 'bare', children: modeSwitch })} + {mode === 'export' + ? props.renderSection({ + title: copy.exportTitle, + description: copy.exportDescription, + children: ( + + {bundle.banner} + + + ), + }) + : ( + + {props.children} + + )} + + ); +} + +function messageOf(error: unknown): string | undefined { + return error instanceof Error && error.message ? error.message : undefined; +} + +/** The Maka source's panel, rendered by the catalog page when it is selected. */ +export function SessionBundleImportPanel(): ReactElement { + const locale = useUiLocale(); + const copy = getExternalSessionImportCopy(locale); + const bundle = useSessionBundleActionsContext(); + return ( + + {bundle.banner} +

    {copy.makaImportDescription}

    + +