diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 88703c8816..d3407ae145 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -11,7 +11,6 @@ "src/renderer/app-shell-e2e-fixture.ts", "src/renderer/app-shell-effects.ts", "src/renderer/app-shell-overlays.tsx", - "src/renderer/app-shell-project-actions.ts", "src/renderer/app-shell.tsx", "src/renderer/app.tsx", "src/renderer/astryx-theme-mode.ts", @@ -82,7 +81,6 @@ "src/renderer/remote-project-directory-dialog.tsx", "src/renderer/scroll-motion-policy.ts", "src/renderer/session-read-state.ts", - "src/renderer/session-workspace-errors.ts", "src/renderer/settings/about-settings-page.tsx", "src/renderer/settings/about-update-status.ts", "src/renderer/settings/action-guard.ts", @@ -380,6 +378,7 @@ "src/renderer/features/diagnostics/index.ts": { "appShell": [ "DiagnosticReportToastProvider", + "ManualDiagnosticReportConsumer", "PreviousMainProcessInterruptionNotice" ], "composition": [ @@ -532,14 +531,7 @@ }, "src/renderer/app-shell-command-actions.ts": { "importDeclarations": 5, - "bridgePaths": { - "window.maka.connections.setDefault": 1, - "window.maka.connections.test": 1, - "window.maka.diagnostics.copyReport": 1, - "window.maka.memory.openFile": 1, - "window.maka.sessions.saveConversationToFile": 1, - "window.maka.settings.testNetworkProxy": 1 - }, + "bridgePaths": {}, "environmentCapabilities": { "navigator.clipboard.writeText": 1 }, @@ -560,7 +552,7 @@ "react": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 2188 + "nonTriviaTokens": 2172 }, "src/renderer/app-shell-copy.ts": { "importDeclarations": 1, @@ -576,7 +568,7 @@ "@maka/core/redaction": 1 }, "importSpecifiers": 1, - "nonTriviaTokens": 311 + "nonTriviaTokens": 249 }, "src/renderer/app-shell-detail-panel.tsx": { "importDeclarations": 0, @@ -634,7 +626,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 886 + "nonTriviaTokens": 856 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -666,36 +658,6 @@ "importSpecifiers": 8, "nonTriviaTokens": 858 }, - "src/renderer/app-shell-project-actions.ts": { - "importDeclarations": 4, - "bridgePaths": { - "window.maka.app.openPath": 3, - "window.maka.app.resolveProjectGitInfo": 1, - "window.maka.projects.add": 1, - "window.maka.projects.archive": 1, - "window.maka.projects.getDefaultContext": 1, - "window.maka.projects.relink": 1, - "window.maka.projects.rename": 1, - "window.maka.projects.restore": 1, - "window.maka.projects.select": 3 - }, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [ - "createAppShellProjectActions" - ], - "dependencyPaths": { - "./app-shell-copy": 1, - "./locales/shell-copy.js": 1, - "./open-path": 1, - "./platform/desktop/default-runtime-host-operation.js": 1, - "./session-workspace-errors": 1 - }, - "importSpecifiers": 7, - "nonTriviaTokens": 2157 - }, "src/renderer/app-shell.tsx": { "importDeclarations": 42, "bridgePaths": {}, @@ -799,7 +761,7 @@ "react": 1 }, "importSpecifiers": 60, - "nonTriviaTokens": 8037 + "nonTriviaTokens": 8017 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -1139,10 +1101,7 @@ } }, "src/renderer/error-boundary.tsx": { - "bridgePaths": { - "window.maka.diagnostics": 3, - "window.maka.diagnostics.copyReport": 1 - }, + "bridgePaths": {}, "environmentCapabilities": { "navigator": 1, "navigator.clipboard.writeText": 1, @@ -1163,6 +1122,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./features/diagnostics/index.js": 1, "./locales/shell-copy.js": 1, "@maka/core/diagnostic-log": 1, "@maka/ui": 1, @@ -1667,22 +1627,9 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/session-workspace-errors.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./application/contracts/session-workspace-errors.js": 1, - "./locales/shell-copy.js": 1 - } - }, "src/renderer/settings/about-settings-page.tsx": { "bridgePaths": { - "window.maka.app.info": 1, - "window.maka.diagnostics.copyReport": 1 + "window.maka.app.info": 1 }, "environmentCapabilities": {}, "hookCalls": { @@ -1698,6 +1645,7 @@ "actionFactories": [], "dependencyPaths": { "../features/app-update/index.js": 1, + "../features/diagnostics/index.js": 1, "../locales/settings-preferences-copy.js": 1, "../platform/desktop/default-runtime-host-operation.js": 1, "./about-update-status.js": 1, @@ -3395,17 +3343,14 @@ "environmentCapabilities": {}, "hookCalls": { "useEffect": 4, - "useRef": 3, - "useStableActions": 1, - "useState": 8 + "useRef": 1, + "useState": 7 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./app-shell-project-actions": 1, "./platform/desktop/default-runtime-host-operation.js": 1, - "./use-stable-actions.js": 1, "react": 1 } }, @@ -3805,13 +3750,6 @@ "src/renderer/use-app-shell-session-workspace.ts" ] }, - { - "capability": "workspace-projection", - "targetZone": "application/workspace", - "legacyPaths": [ - "src/renderer/app-shell-project-actions.ts" - ] - }, { "capability": "commands-and-overlays", "targetZone": "features/overlays", diff --git a/apps/desktop/src/main/__tests__/about-settings-page.test.ts b/apps/desktop/src/main/__tests__/about-settings-page.test.ts index 5bfe81621f..635f0fa462 100644 --- a/apps/desktop/src/main/__tests__/about-settings-page.test.ts +++ b/apps/desktop/src/main/__tests__/about-settings-page.test.ts @@ -18,22 +18,100 @@ */ import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { createElement } from 'react'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { + DiagnosticsServicesProvider, + createFakeDiagnosticsServices, + type DiagnosticsServices, + type ManualDiagnosticTarget, +} from '../../renderer/features/diagnostics/testing.js'; +import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js'; import { AboutSettingsPage } from '../../renderer/settings/about-settings-page.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; -test('keeps manual diagnostics available while About metadata is pending', () => { +type TreeNode = { readonly childNodes?: readonly TreeNode[]; readonly tagName?: string; readonly textContent: string }; + +afterEach(() => { + cleanupFakeDom(); +}); + +function aboutPage(services: DiagnosticsServices) { const page = createElement(AboutSettingsPage, {}); const withToasts = createElement(ToastProvider, { children: page }); - const withAstryxLocale = createElement(AstryxLocaleProvider, { children: withToasts }); - const markup = renderToStaticMarkup( - createElement(LocaleProvider, { locale: 'en', children: withAstryxLocale }), - ); + const withDiagnostics = createElement(DiagnosticsServicesProvider, { services, children: withToasts }); + const withAstryxLocale = createElement(AstryxLocaleProvider, { children: withDiagnostics }); + return createElement(LocaleProvider, { locale: 'en', children: withAstryxLocale }); +} + +async function clickCopyDiagnostics(root: TreeNode, label: string): Promise { + const buttons: TreeNode[] = []; + const visit = (node: TreeNode) => { + if (node.tagName === 'BUTTON') buttons.push(node); + for (const child of node.childNodes ?? []) visit(child); + }; + visit(root); + const key = (button: TreeNode) => Object.keys(button).find((candidate) => candidate.startsWith('__reactProps$')); + const props = (button: TreeNode) => + (button as unknown as Record)[key(button) ?? '']; + const button = buttons.find((candidate) => props(candidate)?.['aria-label'] === label); + assert.ok(button, `missing button ${label}`); + await act(async () => props(button)?.onClick?.({ preventDefault() {}, stopPropagation() {} })); + await act(async () => {}); +} + +function mountWithPendingInfo() { + const mounted = installReactRenderer(); + // About's metadata never arrives here: the copy row must not depend on it. + (globalThis.window as unknown as { maka: unknown }).maka = { + runtimeHostProfiles: { getDefaultHost: () => new Promise(() => {}) }, + }; + return mounted; +} + +test('keeps manual diagnostics available while About metadata is pending', () => { + const markup = renderToStaticMarkup(aboutPage(createFakeDiagnosticsServices())); // The row LABEL also reads "Copy diagnostics", so match the control itself: // its accessible name is the aria-label, not the verb on its face. assert.match(markup, /]*aria-label="Copy diagnostics"/); assert.match(markup, /role="status"[^>]*aria-busy="true"/); }); + +test('copies the manual report through the diagnostics feature, without a target', async () => { + const { root, container } = mountWithPendingInfo(); + const calls: Array[] = []; + const services = createFakeDiagnosticsServices({ + copyManualReport: async (...args) => { + calls.push(args); + }, + }); + const copy = getSettingsPreferencesCopy('en').about; + await act(async () => root.render(aboutPage(services))); + + await clickCopyDiagnostics(container, copy.copyDiagnostics); + + assert.deepEqual(calls, [[]]); + assert.ok(container.textContent.includes(copy.copied)); +}); + +test('reports a failed manual copy with About\'s own words', async () => { + const { root, container } = mountWithPendingInfo(); + let attempts = 0; + const services = createFakeDiagnosticsServices({ + copyManualReport: async () => { + attempts += 1; + throw new Error('denied'); + }, + }); + const copy = getSettingsPreferencesCopy('en').about; + await act(async () => root.render(aboutPage(services))); + + await clickCopyDiagnostics(container, copy.copyDiagnostics); + + assert.equal(attempts, 1); + assert.ok(container.textContent.includes(copy.copyFailed)); + assert.ok(container.textContent.includes(copy.clipboardUnavailable)); +}); diff --git a/apps/desktop/src/main/__tests__/app-shell-command-options.ts b/apps/desktop/src/main/__tests__/app-shell-command-options.ts new file mode 100644 index 0000000000..159e0ff9a3 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-command-options.ts @@ -0,0 +1,98 @@ +/* + * 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 type { LlmConnection } from '@maka/core/llm-connections'; +import type { SessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { + buildAppShellCommandList, + type AppShellCommandListOptions, +} from '../../renderer/app-shell-command-actions.js'; +import { createFakeOverlaysServices } from '../../renderer/features/overlays/testing.js'; + +/** + * Palette options for a shell showing `session-1`, whose toasts are recorded + * as `success:` / `error:<title>:<description>:<target>`. + */ +export function appShellCommandOptions( + toasts: string[], + overrides: Partial<AppShellCommandListOptions> = {}, +): AppShellCommandListOptions { + return { + uiLocale: 'en', + activeId: 'session-1', + activePermissionMode: undefined, + canSetPermissionMode: false, + clientPathsAccessible: false, + connections: [], + defaultConnection: null, + readMessages: () => [], + newTaskProfileId: 'new-task-profile', + settingsOpen: false, + settingsProfileId: undefined, + sessionCatalog: { + getState: () => ({ sessions: [{ id: 'session-1', name: 'Long task' }] }), + } as unknown as SessionCatalogController, + themePref: 'auto', + hiddenSessionIds: new Set(), + captureComposerImportOwner: () => ({ sessionId: 'session-1', navSection: 'sessions' }), + copyManualDiagnosticReport: async () => undefined, + paletteActions: createFakeOverlaysServices().palette, + createSession() {}, + openSideConversation() {}, + openHelp() {}, + openScheduledTaskCreate() {}, + openProjectFolder: async () => {}, + openSessionInChat() {}, + openSettings() {}, + openSettingsSection() {}, + openWorkspaceFolder: async () => {}, + refreshConnections: async () => {}, + copyTodayDailyReview: async () => {}, + pasteTodayDailyReview: async () => {}, + saveTodayDailyReview: async () => {}, + setNavSelection() {}, + setPermissionMode: async () => true, + setThemePref() {}, + toastApi: { + success: (title) => toasts.push(`success:${title}`), + info() {}, + error: (title, description, _details, target) => + toasts.push(`error:${title}:${description}:${JSON.stringify(target)}`), + }, + ...overrides, + }; +} + +/** An enabled connection the palette offers both per-connection rows for. */ +export function paletteConnection(slug: string, name: string): LlmConnection { + return { + slug, + name, + providerType: 'anthropic', + enabled: true, + defaultModel: 'model-1', + } as unknown as LlmConnection; +} + +export async function runPaletteCommand(options: AppShellCommandListOptions, id: string): Promise<void> { + const command = buildAppShellCommandList({ current: options }).find((candidate) => candidate.id === id); + assert.ok(command, `missing ${id}`); + await command.run(); +} diff --git a/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts deleted file mode 100644 index cc9fe153ad..0000000000 --- a/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { mkdir, mkdtemp } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { test } from 'node:test'; -import { build } from 'esbuild'; -import type * as ProjectActions from '../../renderer/app-shell-project-actions.js'; - -const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); -const NO_PROJECT_CAPABILITIES = { - chooseClientDirectory: false, - chooseHostDirectory: false, - selectNoProject: false, - setLocalDefault: false, - viewClientPath: false, -} as const; - -function createTestProjectActions( - actionsModule: typeof ProjectActions, - overrides: Partial<Parameters<typeof ProjectActions.createAppShellProjectActions>[0]> = {}, -) { - return actionsModule.createAppShellProjectActions({ - uiLocale: 'en', - projectPickerPendingRef: { current: false }, - projectPickerRequestRef: { current: 0 }, - rendererMountedRef: { current: true }, - setProjectPickerPending: () => {}, - refreshDefaultProjectState: async () => [], - selectedProjectId: null, - projects: [], - projectCapabilities: NO_PROJECT_CAPABILITIES, - onProjectSelected: () => {}, - toastApi: { success: () => {}, error: () => {} }, - ...overrides, - }); -} - -test('remote Project capabilities do not dispatch Client-local actions', async () => { - const actionsModule = await importProjectActions(); - let clientActionCalls = 0; - const previousWindow = globalThis.window; - globalThis.window = { - maka: { - projects: { - add: async () => { - clientActionCalls += 1; - return { ok: false, reason: 'cancelled' }; - }, - select: async () => { - clientActionCalls += 1; - return { project: null, path: '' }; - }, - relink: async () => { - clientActionCalls += 1; - return { ok: false, reason: 'cancelled' }; - }, - }, - }, - } as unknown as Window & typeof globalThis; - - try { - const actions = createTestProjectActions(actionsModule); - - assert.equal(await actions.addProject(), null); - await actions.selectNoProject(); - assert.equal(await actions.relinkProject('remote'), null); - assert.equal(clientActionCalls, 0); - } finally { - globalThis.window = previousWindow; - } -}); - -test('Project errors preserve the Host authority of the failed operation', async () => { - const actionsModule = await importProjectActions(); - const previousWindow = globalThis.window; - const diagnosticTargets: unknown[] = []; - const toastApi = { - success: () => {}, - error: (_title: string, _description?: string, _details?: string, target?: unknown) => { - diagnosticTargets.push(target); - }, - }; - globalThis.window = { - maka: { - runtimeHostProfiles: { - getDefaultHost: async () => ({ profileId: 'default-profile', hostId: 'default-host' }), - }, - app: { - openPath: async () => { - throw new Error('unavailable'); - }, - }, - }, - } as unknown as Window & typeof globalThis; - - try { - const actions = createTestProjectActions(actionsModule, { - sessionId: 'session-key', - toastApi, - }); - - await actions.openWorkspaceFolder(); - await actions.openProjectFolder(); - await createTestProjectActions(actionsModule, { - toastApi, - }).openProjectFolder(); - - assert.deepEqual(diagnosticTargets, [ - { profileId: 'default-profile' }, - { sessionId: 'session-key' }, - { profileId: 'default-profile' }, - ]); - } finally { - globalThis.window = previousWindow; - } -}); - -test('a Project mutation refresh stays bound to the operation Host', async () => { - const actionsModule = await importProjectActions(); - const previousWindow = globalThis.window; - const host = { profileId: 'profile-a', hostId: 'host-a' }; - let renamedOnHost: unknown; - let refreshedHost: unknown; - globalThis.window = { - maka: { - runtimeHostProfiles: { - getDefaultHost: async () => host, - }, - projects: { - rename: async (_projectId: string, _name: string, host: unknown) => { - renamedOnHost = host; - }, - }, - }, - } as unknown as Window & typeof globalThis; - - try { - const actions = createTestProjectActions(actionsModule, { - refreshDefaultProjectState: async (operationHost) => { - refreshedHost = operationHost; - return []; - }, - }); - - await actions.renameProject('project-1', 'Renamed'); - - assert.deepEqual(renamedOnHost, host); - assert.deepEqual(refreshedHost, host); - } finally { - globalThis.window = previousWindow; - } -}); - -async function importProjectActions(): Promise<typeof ProjectActions> { - const outdir = await mkdtemp(resolve(REPO_ROOT, 'apps/desktop/dist/main/__tests__/project-actions-')); - const outfile = resolve(outdir, 'app-shell-project-actions.mjs'); - await mkdir(dirname(outfile), { recursive: true }); - await build({ - entryPoints: [resolve(REPO_ROOT, 'apps/desktop/src/renderer/app-shell-project-actions.ts')], - outfile, - bundle: true, - platform: 'node', - format: 'esm', - target: 'node20', - logLevel: 'silent', - }); - return (await import(`${pathToFileURL(outfile).href}?t=${Date.now()}`)) as typeof ProjectActions; -} diff --git a/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts b/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts new file mode 100644 index 0000000000..c69af75c40 --- /dev/null +++ b/apps/desktop/src/main/__tests__/command-palette-desktop-actions.test.ts @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, test } from 'node:test'; +import type { OverlayPaletteActions } from '../../renderer/features/overlays/index.js'; +import { createFakeOverlaysServices } from '../../renderer/features/overlays/testing.js'; +import { getShellCopy } from '../../renderer/locales/shell-copy.js'; +import { + appShellCommandOptions, + paletteConnection, + runPaletteCommand, +} from './app-shell-command-options.js'; + +// The palette rows resolve the default Runtime Host themselves; the global +// bridge offers nothing else, so a row that still reached Desktop directly +// would fail instead of reaching the injected palette actions. +const defaultHost = { profileId: 'default-profile', hostId: 'default-host' }; +const previousWindow = globalThis.window; +const copy = getShellCopy('en').commandActions; + +beforeEach(() => { + globalThis.window = { + maka: { runtimeHostProfiles: { getDefaultHost: async () => defaultHost } }, + } as unknown as Window & typeof globalThis; +}); + +afterEach(() => { + globalThis.window = previousWindow; +}); + +function recordingPalette(overrides: Partial<OverlayPaletteActions> = {}) { + const calls: unknown[][] = []; + const fake = createFakeOverlaysServices().palette; + const palette: OverlayPaletteActions = { + testConnection: async (...args) => { + calls.push(['testConnection', ...args]); + return (overrides.testConnection ?? fake.testConnection)(...args); + }, + setDefaultConnection: async (...args) => { + calls.push(['setDefaultConnection', ...args]); + return (overrides.setDefaultConnection ?? fake.setDefaultConnection)(...args); + }, + testNetworkProxy: async (...args) => { + calls.push(['testNetworkProxy', ...args]); + return (overrides.testNetworkProxy ?? fake.testNetworkProxy)(...args); + }, + openLocalMemoryFile: async (...args) => { + calls.push(['openLocalMemoryFile', ...args]); + return (overrides.openLocalMemoryFile ?? fake.openLocalMemoryFile)(...args); + }, + saveConversationToFile: async (...args) => { + calls.push(['saveConversationToFile', ...args]); + return (overrides.saveConversationToFile ?? fake.saveConversationToFile)(...args); + }, + }; + return { palette, calls }; +} + +describe('command palette Desktop actions', () => { + test('tests and defaults a connection by slug on the default Host, then refreshes', async () => { + const { palette, calls } = recordingPalette({ + testConnection: async () => ({ ok: true, latencyMs: 12, modelTested: 'model-1' }), + }); + const toasts: string[] = []; + let refreshes = 0; + const options = appShellCommandOptions(toasts, { + paletteActions: palette, + connections: [paletteConnection('work', 'Work')], + refreshConnections: async () => { + refreshes += 1; + }, + }); + + await runPaletteCommand(options, 'connection:test:work'); + await runPaletteCommand(options, 'connection:set-default:work'); + + assert.deepEqual(calls, [ + ['testConnection', 'work', defaultHost], + ['setDefaultConnection', 'work', defaultHost], + ]); + assert.equal(refreshes, 2); + assert.deepEqual(toasts, [ + `success:${copy.connectionVerified('Work')}`, + `success:${copy.setDefaultSuccess('Work')}`, + ]); + }); + + test('tests the network proxy and opens the memory file on the default Host', async (t) => { + t.mock.method(console, 'error', () => undefined); + const { palette, calls } = recordingPalette({ + testNetworkProxy: async () => ({ ok: true, message: 'reachable', latencyMs: 20 }), + openLocalMemoryFile: async () => ({ ok: false, code: 'missing' }), + }); + const toasts: string[] = []; + const options = appShellCommandOptions(toasts, { paletteActions: palette }); + + await runPaletteCommand(options, 'diag:test-network-proxy'); + await runPaletteCommand(options, 'diag:open-local-memory'); + + assert.deepEqual(calls, [ + ['testNetworkProxy', defaultHost], + ['openLocalMemoryFile', defaultHost], + ]); + assert.equal(toasts[0], `success:${copy.networkPassedTitle}`); + assert.match(toasts[1] ?? '', new RegExp(`^error:${copy.memoryOpenFailedTitle}:.*:${JSON.stringify({ profileId: 'default-profile' })}$`)); + }); + + test('saves the conversation and keeps each outcome\'s toast', async () => { + const outcomes = [ + { ok: true as const, path: '/tmp/maka.md' }, + { ok: false as const, reason: 'canceled' as const }, + { ok: false as const, reason: 'invalid_input' as const }, + ]; + const { palette, calls } = recordingPalette({ + saveConversationToFile: async () => outcomes.shift()!, + }); + const toasts: string[] = []; + const options = appShellCommandOptions(toasts, { paletteActions: palette }); + + for (let index = 0; index < 3; index += 1) { + await runPaletteCommand(options, 'diag:save-conversation-file'); + } + + assert.equal(calls.length, 3); + const [name, input] = calls[0] ?? []; + assert.equal(name, 'saveConversationToFile'); + assert.deepEqual(Object.keys(input as object), ['markdown', 'defaultName']); + assert.match((input as { defaultName: string }).defaultName, /^maka-Long-task-\d{4}-\d{2}-\d{2}\.md$/); + assert.equal(toasts.length, 2); + assert.equal(toasts[0], `success:${copy.conversationSavedTitle}`); + assert.equal(toasts[1], `error:${copy.saveFailedTitle}:${copy.invalidExport}:undefined`); + }); +}); diff --git a/apps/desktop/src/main/__tests__/diagnostics-owner.test.ts b/apps/desktop/src/main/__tests__/diagnostics-owner.test.ts index e8e23fdeda..7de9bcc7c7 100644 --- a/apps/desktop/src/main/__tests__/diagnostics-owner.test.ts +++ b/apps/desktop/src/main/__tests__/diagnostics-owner.test.ts @@ -32,6 +32,8 @@ import { type ToastDiagnosticTarget, } from '@maka/ui'; import type { DesktopDiagnosticInput } from '../../preload/diagnostics-contract.js'; +import { ErrorBoundary } from '../../renderer/error-boundary.js'; +import { getShellCopy } from '../../renderer/locales/shell-copy.js'; import { createDesktopDiagnosticsServices, type DesktopDiagnosticsBridge, @@ -43,8 +45,11 @@ import { createFakeDiagnosticsServices, getDiagnosticsCopy, type DiagnosticsServices, + type ManualDiagnosticTarget, + type RendererCrashDiagnosticReport, type ToastDiagnosticReport, } from '../../renderer/features/diagnostics/testing.js'; +import { appShellCommandOptions, runPaletteCommand } from './app-shell-command-options.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; type TreeNode = { readonly childNodes?: readonly TreeNode[]; readonly tagName?: string; readonly textContent: string }; @@ -202,10 +207,11 @@ describe('PreviousMainProcessInterruptionNotice', () => { }); describe('DiagnosticReportToastProvider', () => { + const shellCopy = getShellCopy('en'); const labels = { - label: 'Copy report', - failureTitle: 'Copy failed', - failureDescription: 'Clipboard unavailable', + label: shellCopy.errorBoundary.copyReport, + failureTitle: shellCopy.commandActions.copyFailedTitle, + failureDescription: shellCopy.commandActions.clipboardDenied, }; const target: ToastDiagnosticTarget = { sessionId: 'session-1', turnId: 'turn-1', eventId: 'event-1' }; @@ -229,7 +235,7 @@ describe('DiagnosticReportToastProvider', () => { await act(async () => root.render(localized( 'en', services, - createElement(DiagnosticReportToastProvider, { labels, children: createElement(ErrorProbe) }), + createElement(DiagnosticReportToastProvider, { children: createElement(ErrorProbe) }), ))); await clickButton(container, labels.label); @@ -240,7 +246,21 @@ describe('DiagnosticReportToastProvider', () => { assert.deepEqual(reports[0]?.diagnosticTarget, target); }); - test('reports a failed copy with the supplied labels', async () => { + test('names the report action in the current locale', async () => { + const { root, container } = installReactRenderer(); + const services = createFakeDiagnosticsServices(); + await act(async () => root.render(localized( + 'zh-CN', + services, + createElement(DiagnosticReportToastProvider, { children: createElement(ErrorProbe) }), + ))); + + const label = getShellCopy('zh-CN').errorBoundary.copyReport; + assert.ok(elements(container, 'BUTTON').some((button) => button.textContent === label)); + assert.equal(occurrences(container, labels.label), 0); + }); + + test('reports a failed copy with the shell catalog\'s words', async () => { const { root, container } = installReactRenderer(); const services = createFakeDiagnosticsServices({ copyToastReport: async () => { @@ -250,7 +270,7 @@ describe('DiagnosticReportToastProvider', () => { await act(async () => root.render(localized( 'en', services, - createElement(DiagnosticReportToastProvider, { labels, children: createElement(ErrorProbe) }), + createElement(DiagnosticReportToastProvider, { children: createElement(ErrorProbe) }), ))); await clickButton(container, labels.label); @@ -305,6 +325,32 @@ describe('Desktop diagnostics adapter', () => { ]); }); + test('sends a manual report with the target only when the caller had one', async () => { + const { bridge, inputs } = recordingBridge(); + const services = createDesktopDiagnosticsServices(bridge); + + await services.copyManualReport(); + await services.copyManualReport({ sessionId: 'session-1' }); + await services.copyManualReport({ profileId: 'profile-1' }); + + assert.deepEqual(inputs, [ + { surface: 'manual' }, + { surface: 'manual', target: { sessionId: 'session-1' } }, + { surface: 'manual', target: { profileId: 'profile-1' } }, + ]); + }); + + test('sends a renderer crash report with its title and details', async () => { + const { bridge, inputs } = recordingBridge(); + const services = createDesktopDiagnosticsServices(bridge); + + await services.copyRendererCrashReport({ title: 'TypeError: boom', details: 'TypeError: boom\n\nStack:\nframe' }); + + assert.deepEqual(inputs, [ + { surface: 'renderer_crash', title: 'TypeError: boom', details: 'TypeError: boom\n\nStack:\nframe' }, + ]); + }); + test('delegates the previous-run read and report', async () => { const { bridge, calls } = recordingBridge(); const services = createDesktopDiagnosticsServices(bridge); @@ -314,6 +360,126 @@ describe('Desktop diagnostics adapter', () => { }); }); +describe('ErrorBoundary crash report', () => { + function Crash(): ReactNode { + const error = new TypeError('boom'); + error.stack = 'TypeError: boom\n at Crash'; + throw error; + } + + const boundary = createElement(ErrorBoundary, { locale: 'en', children: createElement(Crash) }); + const copy = getShellCopy('en').errorBoundary; + + function hasButton(root: TreeNode, label: string): boolean { + return elements(root, 'BUTTON').some((button) => button.textContent === label); + } + + test('copies the crash through the diagnostics feature', async (t) => { + t.mock.method(console, 'error', () => {}); + const { root, container } = installReactRenderer(); + const reports: RendererCrashDiagnosticReport[] = []; + const services = createFakeDiagnosticsServices({ + copyRendererCrashReport: async (report) => { + reports.push(report); + }, + }); + await act(async () => root.render(localized('en', services, boundary))); + assert.equal(occurrences(container, copy.title), 1); + + await clickButton(container, copy.copyReport); + await act(async () => {}); + + assert.equal(reports.length, 1); + assert.equal(reports[0]?.title, 'TypeError: boom'); + assert.match(reports[0]?.details ?? '', /^TypeError: boom\n\nStack:\nTypeError: boom\n {4}at Crash/); + assert.ok(hasButton(container, copy.copied)); + }); + + test('shows the failure when the diagnostics feature cannot copy', async (t) => { + t.mock.method(console, 'error', () => {}); + const { root, container } = installReactRenderer(); + let attempts = 0; + const services = createFakeDiagnosticsServices({ + copyRendererCrashReport: async () => { + attempts += 1; + throw new Error('denied'); + }, + }); + await act(async () => root.render(localized('en', services, boundary))); + + await clickButton(container, copy.copyReport); + await act(async () => {}); + + assert.equal(attempts, 1); + assert.ok(hasButton(container, copy.copyFailed)); + assert.equal(occurrences(container, copy.clipboardFailure), 1); + }); + + test('without Desktop composition, still renders the fallback and copies the browser report', async (t) => { + t.mock.method(console, 'error', () => {}); + const writes: string[] = []; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: async (text: string) => { + writes.push(text); + }, + }, + }); + restoreAfterEach.push(() => { + Reflect.deleteProperty(navigator, 'clipboard'); + }); + const { root, container } = installReactRenderer(); + await act(async () => root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { children: boundary }), + }))); + assert.equal(occurrences(container, copy.title), 1); + + await clickButton(container, copy.copyReport); + await act(async () => {}); + + assert.equal(writes.length, 1); + assert.match(writes[0] ?? '', /^Maka renderer error report\n/); + assert.match(writes[0] ?? '', /TypeError: boom/); + assert.ok(hasButton(container, copy.copied)); + }); +}); + +describe('Command palette manual report', () => { + const copy = getShellCopy('en').commandActions; + + test('copies through the injected command with the current target, then confirms', async () => { + const targets: Array<ManualDiagnosticTarget | undefined> = []; + const toasts: string[] = []; + await runPaletteCommand(appShellCommandOptions(toasts, { + copyManualDiagnosticReport: async (target) => { + targets.push(target); + }, + }), 'diag:copy-diagnostics'); + + assert.deepEqual(targets, [{ sessionId: 'session-1' }]); + assert.deepEqual(toasts, [`success:${copy.diagnosticsCopiedTitle}`]); + }); + + test('reports a failed copy against the same target', async (t) => { + t.mock.method(console, 'error', () => {}); + const toasts: string[] = []; + let attempts = 0; + await runPaletteCommand(appShellCommandOptions(toasts, { + copyManualDiagnosticReport: async () => { + attempts += 1; + throw new Error('denied'); + }, + }), 'diag:copy-diagnostics'); + + assert.equal(attempts, 1); + assert.deepEqual(toasts, [ + `error:${copy.copyFailedTitle}:${copy.clipboardDenied}:${JSON.stringify({ sessionId: 'session-1' })}`, + ]); + }); +}); + describe('Diagnostics ownership', () => { const rendererRoot = resolve(fileURLToPath(new URL('../../../src/renderer/', import.meta.url))); @@ -335,7 +501,14 @@ describe('Diagnostics ownership', () => { test('mounts each owner once and reaches Desktop diagnostics through one adapter', () => { assert.deepEqual(sourcesMatching(/<(?:Diagnostics\.)?PreviousMainProcessInterruptionNotice\b/), ['app-shell.tsx']); assert.deepEqual(sourcesMatching(/<(?:Diagnostics\.)?DiagnosticReportToastProvider\b/), ['app-shell.tsx']); + assert.deepEqual( + sourcesMatching(/<(?:Diagnostics\.)?ManualDiagnosticReportConsumer\b/), + ['app-shell.tsx', 'settings/about-settings-page.tsx'], + ); + assert.deepEqual(sourcesMatching(/<(?:Diagnostics\.)?RendererCrashReportConsumer\b/), ['error-boundary.tsx']); assert.deepEqual(sourcesMatching(/create-diagnostics-services/), ['composition/desktop-feature-services.tsx']); + assert.deepEqual(sourcesMatching(/\.\s*copyReport\(/), ['platform/desktop/create-diagnostics-services.ts']); + assert.deepEqual(sourcesMatching(/\bmaka\s*\??\.\s*diagnostics\b/), []); assert.deepEqual( sourcesMatching(/\.\s*(?:takePreviousMainProcessInterruption|copyPreviousMainProcessInterruption)\(/), ['features/diagnostics/ui/previous-main-process-interruption-notice.tsx', 'platform/desktop/create-diagnostics-services.ts'], diff --git a/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts index b2f240c9e6..3283839655 100644 --- a/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts @@ -28,8 +28,8 @@ import { sessionCollaborationImportErrorMessage } from '../../renderer/features/ import { commandPaletteActionErrorMessage, commandPaletteConnectionTestFailureMessage, - openPathActionErrorMessage, } from '../../renderer/app-shell-copy.js'; +import { folderOpenFailure } from '../../renderer/features/task-entry/testing.js'; import { getShellCopy, localizedShellErrorMessage, @@ -172,9 +172,12 @@ test('every shell error-copy entry classifies, and keeps its contextual fallback assert.equal(transcriptErrorMessage(timeout, 'zh-CN', 'refresh'), '请求超时'); assert.equal(transcriptErrorMessage(opaque, 'zh-CN', 'refresh'), '任务内容暂时无法刷新,请稍后重试。'); - assert.equal(openPathActionErrorMessage(timeout, 'workspace', 'zh-CN'), '请求超时'); assert.equal( - openPathActionErrorMessage(opaque, 'workspace', 'zh-CN'), + folderOpenFailure('workspace', { kind: 'failed', error: timeout }, 'zh-CN')?.description, + '请求超时', + ); + assert.equal( + folderOpenFailure('workspace', { kind: 'failed', error: opaque }, 'zh-CN')?.description, copy.errors.openPath(copy.paths.workspace), ); diff --git a/apps/desktop/src/main/__tests__/overlays-boundary.test.ts b/apps/desktop/src/main/__tests__/overlays-boundary.test.ts index e6f0fae635..36edbf671e 100644 --- a/apps/desktop/src/main/__tests__/overlays-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/overlays-boundary.test.ts @@ -210,6 +210,7 @@ describe('Overlays feature boundary', () => { } assert.deepEqual(imports.sort(), [ 'src/renderer/app-shell-command-actions.ts: Command', + 'src/renderer/app-shell-command-actions.ts: OverlayPaletteActions', 'src/renderer/app-shell-overlays.tsx: *', 'src/renderer/app-shell-overlays.tsx: OverlaysShellProjection', 'src/renderer/app-shell.tsx: *', diff --git a/apps/desktop/src/main/__tests__/overlays-search-lifecycle.test.ts b/apps/desktop/src/main/__tests__/overlays-search-lifecycle.test.ts index a53e030e99..4e61c54e4e 100644 --- a/apps/desktop/src/main/__tests__/overlays-search-lifecycle.test.ts +++ b/apps/desktop/src/main/__tests__/overlays-search-lifecycle.test.ts @@ -22,6 +22,7 @@ import test from 'node:test'; import { act, createElement } from 'react'; import { parseHTML } from 'linkedom'; import { deferred } from '@maka/core/test-only/async-primitives'; +import type { DesktopOverlaysBridge } from '../../renderer/platform/desktop/create-overlays-services.js'; import type { RecallSearchOutcome, RecallSearchRequest, @@ -71,7 +72,7 @@ test('overlay search preserves cancellation across supersession, close, reopen, }; const root = createRoot(document.getElementById('root')!); let overlays: import('../../renderer/features/overlays/testing.js').OverlaysShellProjection; - const services = createDesktopOverlaysServices({ search }, { + const services = createDesktopOverlaysServices({ search } as unknown as DesktopOverlaysBridge, { storage: { setItem() {} }, document, }); const navigate = () => {}; diff --git a/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts b/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts index 627a7395ad..74a6b45d4f 100644 --- a/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/overlays-services-adapter.test.ts @@ -67,7 +67,42 @@ test('the Desktop adapter hands the search namespace through and owns the browse assert.deepEqual(writes, [[SETTINGS_SECTION_STORAGE_KEY, 'models']]); assert.equal(SETTINGS_SECTION_STORAGE_KEY, 'maka-settings-section-v1'); assert.equal(blurred(), 1); - assert.deepEqual(Object.keys(services).sort(), ['focus', 'search', 'settingsSection']); + assert.deepEqual(Object.keys(services).sort(), ['focus', 'palette', 'search', 'settingsSection']); +}); + +test('the palette actions reach Desktop with the arguments the rows always sent', async () => { + const calls: unknown[][] = []; + const record = (name: string, result: unknown) => async (...args: unknown[]) => { + calls.push([name, ...args]); + return result; + }; + const bridge = { + connections: { + test: record('connections.test', { ok: true }), + setDefault: record('connections.setDefault', undefined), + }, + settings: { testNetworkProxy: record('settings.testNetworkProxy', { ok: true, message: '' }) }, + memory: { openFile: record('memory.openFile', { ok: true }) }, + sessions: { saveConversationToFile: record('sessions.saveConversationToFile', { ok: true, path: '/tmp/a.md' }) }, + } as unknown as DesktopOverlaysBridge; + const { palette } = createDesktopOverlaysServices(bridge, recordingEnvironment().environment); + const host = { profileId: 'profile-1', hostId: 'host-1' }; + const input = { markdown: '# Task', defaultName: 'maka-task.md' }; + + await palette.testConnection('work', host); + await palette.setDefaultConnection('work', host); + await palette.testNetworkProxy(host); + await palette.openLocalMemoryFile(host); + assert.deepEqual(await palette.saveConversationToFile(input), { ok: true, path: '/tmp/a.md' }); + + assert.deepEqual(calls, [ + ['connections.test', 'work', undefined, host], + ['connections.setDefault', 'work', host], + ['settings.testNetworkProxy', undefined, host], + ['memory.openFile', host], + ['sessions.saveConversationToFile', input], + ]); + assert.equal(calls[4]?.[1], input); }); test('the adapter tolerates an unavailable store and a missing active element', () => { diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts index 7429f9913a..3893bcb26f 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -22,6 +22,7 @@ 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 { getShellCopy } from '../../renderer/locales/shell-copy.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeTaskEntryServices, @@ -29,6 +30,7 @@ import { useTaskEntryController, type TaskEntryCatalog, type TaskEntryController, + type TaskEntryFolderOpenResult, type TaskEntryHost, type TaskEntryServices, } from '../../renderer/features/task-entry/testing.js'; @@ -768,4 +770,100 @@ describe('useTaskEntryController', () => { profileId: 'local', }]); }); + + describe('folders', () => { + const copy = getShellCopy('en'); + const projectTitle = copy.projectActions.openFailedTitle(copy.projectActions.openPathLabels.project); + const workspaceTitle = copy.projectActions.openFailedTitle(copy.projectActions.openPathLabels.workspace); + + async function openFolders( + results: { project?: TaskEntryFolderOpenResult; workspace?: TaskEntryFolderOpenResult }, + run: (commands: TaskEntryController['commands']) => Promise<void>, + ) { + const { root } = installReactRenderer(); + const errors: unknown[] = []; + const requests: Array<string | undefined> = []; + const services = createFakeTaskEntryServices({ + folders: { + openProjectFolder: async (sessionId) => { + requests.push(sessionId); + return results.project ?? { kind: 'opened' }; + }, + openWorkspaceFolder: async () => { + requests.push('workspace'); + return results.workspace ?? { kind: 'opened' }; + }, + }, + }); + await act(async () => renderController(root, services, errors)); + await act(async () => run(controller().commands)); + return { errors, requests }; + } + + it('reports nothing when the folder opens', async () => { + const { errors, requests } = await openFolders({}, async (commands) => { + await commands.openProjectFolder('session-1'); + await commands.openProjectFolder(); + await commands.openWorkspaceFolder(); + }); + + assert.deepEqual(requests, ['session-1', undefined, 'workspace']); + assert.deepEqual(errors, []); + }); + + it('reports a refusal with its closed reason and the target the adapter named', async () => { + const { errors } = await openFolders({ + project: { kind: 'refused', reason: 'missing', diagnosticTarget: { sessionId: 'session-1' } }, + workspace: { kind: 'refused', reason: 'raw-host-text', diagnosticTarget: { profileId: 'local' } }, + }, async (commands) => { + await commands.openProjectFolder('session-1'); + await commands.openWorkspaceFolder(); + }); + + assert.deepEqual(errors, [ + { + title: projectTitle, + description: copy.projectActions.openPathFailures.missing, + sessionId: 'session-1', + }, + { + title: workspaceTitle, + description: copy.projectActions.openPathFailures.unknown, + profileId: 'local', + }, + ]); + }); + + it('turns a vanished task workspace into the workspace-unavailable notice', async () => { + const unavailable = Object.assign(new Error('gone'), { code: 'SESSION_WORKSPACE_UNAVAILABLE' }); + const { errors } = await openFolders({ + project: { kind: 'failed', error: unavailable, diagnosticTarget: { sessionId: 'session-1' } }, + }, async (commands) => { + await commands.openProjectFolder('session-1'); + }); + + assert.deepEqual(errors, [{ + title: copy.errors.workspaceUnavailableTitle, + description: copy.errors.workspaceUnavailableDescription, + sessionId: 'session-1', + }]); + }); + + it('classifies other failures and keeps the Host authority, or none when it was never resolved', async (t) => { + t.mock.method(console, 'error', () => undefined); + const timeout = new Error('request timeout'); + const { errors } = await openFolders({ + project: { kind: 'failed', error: timeout }, + workspace: { kind: 'failed', error: timeout, diagnosticTarget: { profileId: 'local' } }, + }, async (commands) => { + await commands.openProjectFolder(); + await commands.openWorkspaceFolder(); + }); + + assert.deepEqual(errors, [ + { title: projectTitle, description: 'Request timed out' }, + { title: workspaceTitle, description: 'Request timed out', profileId: 'local' }, + ]); + }); + }); }); diff --git a/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts b/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts index c6aa62d3dc..fc02cbeb06 100644 --- a/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts @@ -20,7 +20,13 @@ import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import { act, createElement, Fragment, type ReactNode } from 'react'; -import { LocaleProvider, ToastProvider, type WorkspacePickerModel } from '@maka/ui'; +import { + AstryxLocaleProvider, + LocaleProvider, + ToastProvider, + type ToastErrorAction, + type WorkspacePickerModel, +} from '@maka/ui'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeTaskEntryServices, @@ -398,3 +404,60 @@ describe('TaskEntryRoot render scope', () => { await act(async () => root.unmount()); }); }); + +describe('TaskEntryRoot error reports', () => { + type TreeNode = { readonly childNodes?: readonly TreeNode[]; readonly tagName?: string; readonly textContent: string }; + + async function clickButton(root: TreeNode, label: string): Promise<void> { + const buttons: TreeNode[] = []; + const visit = (node: TreeNode) => { + if (node.tagName === 'BUTTON') buttons.push(node); + for (const child of node.childNodes ?? []) visit(child); + }; + visit(root); + const button = buttons.find((candidate) => candidate.textContent === label); + assert.ok(button, `missing button ${label}`); + const key = Object.keys(button).find((candidate) => candidate.startsWith('__reactProps$')); + assert.ok(key, 'missing React button props'); + const props = (button as unknown as Record<string, { onClick(event: unknown): void }>)[key]; + await act(async () => props.onClick({ preventDefault() {}, stopPropagation() {} })); + } + + it('reports a task folder failure against the task', async () => { + const { root, container } = installReactRenderer(); + const reports: Parameters<ToastErrorAction['onClick']>[0][] = []; + const errorAction: ToastErrorAction = { + label: 'Report', + failureTitle: 'Report failed', + failureDescription: 'Report failed', + onClick: async (report) => { + reports.push(report); + }, + }; + const services = createFakeTaskEntryServices({ + folders: { + openProjectFolder: async () => ({ + kind: 'refused', + reason: 'missing', + diagnosticTarget: { sessionId: 'session-1' }, + }), + openWorkspaceFolder: async () => ({ kind: 'opened' }), + }, + }); + await act(async () => root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + errorAction, + children: createElement(TaskEntryServicesProvider, { services }, createElement(ShellProbe)), + }), + }), + }))); + + await act(async () => latestTaskEntry?.commands.openProjectFolder('session-1')); + await clickButton(container, 'Report'); + + assert.equal(reports.length, 1); + assert.deepEqual(reports[0]?.diagnosticTarget, { sessionId: 'session-1' }); + }); +}); diff --git a/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts b/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts index f4a9635da2..656e899ded 100644 --- a/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts @@ -18,7 +18,7 @@ */ import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; +import { afterEach, describe, it } from 'node:test'; import type { MakaBridge } from '../../preload/bridge-contract.js'; import { createDesktopTaskEntryServices } from '../../renderer/platform/desktop/create-task-entry-services.js'; @@ -66,7 +66,7 @@ describe('createDesktopTaskEntryServices', () => { return { ok: true, session: {} }; }, }, - } as unknown as Pick<MakaBridge, 'newTasks' | 'projects' | 'sessions'>; + } as unknown as Pick<MakaBridge, 'app' | 'newTasks' | 'projects' | 'sessions'>; const services = createDesktopTaskEntryServices(bridge); const host = { profileId: 'remote', hostId: 'host-1' }; @@ -102,4 +102,79 @@ describe('createDesktopTaskEntryServices', () => { assert.equal(changes, 1); assert.equal(disposed, 1); }); + + describe('folders', () => { + const defaultHost = { profileId: 'default-profile', hostId: 'default-host' }; + const previousWindow = globalThis.window; + + afterEach(() => { + globalThis.window = previousWindow; + }); + + function folderServices(openPath: (...args: unknown[]) => Promise<unknown>) { + // The default Host is resolved through the shared default-Host helper, + // which reads the global bridge rather than the injected one. + globalThis.window = { + maka: { runtimeHostProfiles: { getDefaultHost: async () => defaultHost } }, + } as unknown as Window & typeof globalThis; + return createDesktopTaskEntryServices({ + app: { openPath }, + } as unknown as Pick<MakaBridge, 'app' | 'newTasks' | 'projects' | 'sessions'>).folders; + } + + it('opens a task folder through the task, and other folders on the default Host', async () => { + const calls: unknown[][] = []; + const folders = folderServices(async (...args) => { + calls.push(args); + return { ok: true, opened: '/tmp' }; + }); + + assert.deepEqual(await folders.openProjectFolder('session-1'), { kind: 'opened' }); + assert.deepEqual(await folders.openProjectFolder(), { kind: 'opened' }); + assert.deepEqual(await folders.openWorkspaceFolder(), { kind: 'opened' }); + assert.deepEqual(calls, [ + ['project', 'session-1'], + ['project', undefined, defaultHost], + ['workspace', undefined, defaultHost], + ]); + }); + + it('reports a refusal against the task or the default Host profile', async () => { + const folders = folderServices(async () => ({ ok: false, reason: 'missing' })); + + assert.deepEqual(await folders.openProjectFolder('session-1'), { + kind: 'refused', + reason: 'missing', + diagnosticTarget: { sessionId: 'session-1' }, + }); + assert.deepEqual(await folders.openWorkspaceFolder(), { + kind: 'refused', + reason: 'missing', + diagnosticTarget: { profileId: 'default-profile' }, + }); + }); + + it('keeps the Host authority of a failed request', async () => { + const failure = new Error('unavailable'); + const folders = folderServices(async () => { + throw failure; + }); + + const workspace = await folders.openWorkspaceFolder(); + const task = await folders.openProjectFolder('session-key'); + const project = await folders.openProjectFolder(); + + assert.equal(workspace.kind, 'failed'); + assert.deepEqual( + [workspace, task, project].map((result) => + result.kind === 'failed' ? result.diagnosticTarget : result.kind, + ), + [ + { profileId: 'default-profile' }, + { sessionId: 'session-key' }, + { profileId: 'default-profile' }, + ], + ); + }); + }); }); diff --git a/apps/desktop/src/main/__tests__/use-project-context.test.ts b/apps/desktop/src/main/__tests__/use-project-context.test.ts index b08e0d6dec..8f7273051d 100644 --- a/apps/desktop/src/main/__tests__/use-project-context.test.ts +++ b/apps/desktop/src/main/__tests__/use-project-context.test.ts @@ -82,10 +82,7 @@ test('discards a pending Project projection after the default Host changes', asy function Probe() { const context = projectContext.useAppShellProjectContext({ - uiLocale: 'en', rendererMountedRef: { current: true }, - onProjectSelected: () => {}, - toastApi: { success: () => {}, error: () => {} }, }); projects = context.projects; selectedProjectId = context.selectedProjectId; diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index ee0a1bd553..075ee1c1c2 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -283,7 +283,7 @@ or reason, which stays with review. | `AppShellContent` | `useAppShellHostEffects` | titlebar modal sync | titlebar | legacy `app-shell-effects.ts` | observe top-layer modals; the `data-os` platform tag is applied by `ShellLifecycleSources` | layout | — | | `AppShellContent` | `useAppShellNavRefSync` | `navSelectionRef` | ownership checks of async results | AppShell | mirror the navigation selection into a ref | navigation | — | | `AppShellContent` | `useAppShellPersistenceEffects` | theme and navigation persistence | `<html>` theme class and palette; stored navigation | legacy `app-shell-effects.ts` | apply the theme preference and palette; persist the navigation state | layout | — | -| `AppShellContent` | `useAppShellProjectContext` | project context | titlebar project name; project picker and commands | legacy `use-project-context.ts` | read the owner Session's project and run project commands | — | M5 | +| `AppShellContent` | `useAppShellProjectContext` | project context | titlebar project name and path; Workbar, Module Hub and palette project inputs; the default-Host project refresh | legacy `use-project-context.ts` | read the owner Session's and the default Host's project projection; project mutations and the open-folder commands belong to Task Entry | — | M5 | | `AppShellContent` | `useAppShellSessionUiReads` | displayed Session chrome | interaction, queue, live-turn and execution chrome; Composer props | Conversation (transitional reader) | fixed-purpose reads of the displayed and owner Session | — | M3 | | `AppShellContent` | `useAppShellSessionWorkspace` | Session workspace | every region's requested, published and owner Session | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection and the catalog controller | navigation | — | | `AppShellContent` | `useAppShellTurnPresentation` | `deriveTurnPresentation` | `ChatView` turn footer | application contract `turn-presentation` | derive turn presentation from the transcript projection and pending turn actions | — | M3 | @@ -318,6 +318,7 @@ following. Exports only tests or Storybook read live in each feature's | Feature | Export | Root consumer | Kind | Stays because / Removal | | --- | --- | --- | --- | --- | | overlays | `OverlaysConsumer` | `app-shell-overlays.tsx` (Settings modal, palette command list) | render-prop projection of overlay state | M5, with the legacy command actions | +| diagnostics | `ManualDiagnosticReportConsumer` | command palette options in `app-shell.tsx` | render-prop manual report command | M5, with the legacy command actions | | task-entry | `TaskEntryWorkspacePickerConsumer` | Composer region in `app-shell.tsx` | render-prop workspace picker | M3 | | session-collaboration | `GuestTurnRequests` | Composer region in `app-shell.tsx` | render-prop guest composer projection over the Composer ref | M3 | | module-hub | `ModuleHubSkillCatalogRevisionBoundary` | Composer mentions provider | render-prop skill catalog revision | M3 | diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 0acf9bccb0..d33e899f0c 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -30,7 +30,8 @@ import { runOnDefaultRuntimeHost, } from './platform/desktop/default-runtime-host-operation.js'; import { buildCommandList } from "./command-palette-commands.js"; -import type { Command } from './features/overlays/index.js'; +import type { CopyManualDiagnosticReport } from './features/diagnostics/index.js'; +import type { Command, OverlayPaletteActions } from './features/overlays/index.js'; import type { SessionCatalogController } from './application/contracts/session-catalog/session-catalog-state.js'; import { renderConversationMarkdown } from "./conversation-markdown.js"; import { @@ -77,6 +78,9 @@ export interface AppShellCommandListOptions { /** Sessions the rail hides (mounted side-chat forks) — the palette skips them too. */ hiddenSessionIds: ReadonlySet<string>; captureComposerImportOwner: () => ComposerImportOwner; + copyManualDiagnosticReport: CopyManualDiagnosticReport; + /** The overlays owner's Desktop operations for the rows below. */ + paletteActions: OverlayPaletteActions; createSession: () => void; openSideConversation: () => void; openHelp: () => void; @@ -146,10 +150,10 @@ export function buildAppShellCommandList( onOpenShortcuts: () => optionsRef.current.openHelp(), onSetTheme: (next) => optionsRef.current.setThemePref(next), onTestConnection: async (slug) => { - const { connections, refreshConnections, toastApi } = optionsRef.current; + const { connections, paletteActions, refreshConnections, toastApi } = optionsRef.current; try { const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.connections.test(slug, undefined, host), + paletteActions.testConnection(slug, host), ); const conn = connections.find((c) => c.slug === slug); const name = conn?.name ?? slug; @@ -184,10 +188,10 @@ export function buildAppShellCommandList( } }, onSetDefaultConnection: async (slug) => { - const { connections, refreshConnections, toastApi } = optionsRef.current; + const { connections, paletteActions, refreshConnections, toastApi } = optionsRef.current; try { await runOnDefaultRuntimeHost((host) => - window.maka.connections.setDefault(slug, host), + paletteActions.setDefaultConnection(slug, host), ); await refreshConnections(); const conn = connections.find((c) => c.slug === slug); @@ -232,7 +236,7 @@ export function buildAppShellCommandList( } }, onSaveActiveConversationToFile: async () => { - const { activeId, readMessages, sessionCatalog, toastApi } = optionsRef.current; + const { activeId, paletteActions, readMessages, sessionCatalog, toastApi } = optionsRef.current; if (!activeId) return; const session = sessionCatalog.getState().sessions.find((s) => s.id === activeId); const sessionName = session?.name ?? copy.newConversation; @@ -253,7 +257,7 @@ export function buildAppShellCommandList( .slice(0, 80); const defaultName = `maka-${sanitizedSession}-${yyyy}-${mm}-${dd}.md`; try { - const result = await window.maka.sessions.saveConversationToFile({ + const result = await paletteActions.saveConversationToFile({ markdown, defaultName, }); @@ -281,10 +285,10 @@ export function buildAppShellCommandList( } }, onOpenLocalMemoryFile: async () => { - const { toastApi } = optionsRef.current; + const { paletteActions, toastApi } = optionsRef.current; try { const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.memory.openFile(host), + paletteActions.openLocalMemoryFile(host), ); if (!result.ok) { toastApi.error( @@ -319,6 +323,7 @@ export function buildAppShellCommandList( onCopyDiagnostics: async () => { const { captureComposerImportOwner, + copyManualDiagnosticReport, newTaskProfileId, settingsOpen, settingsProfileId, @@ -332,10 +337,7 @@ export function buildAppShellCommandList( settingsProfileId, ); try { - await window.maka.diagnostics.copyReport({ - surface: "manual", - ...(target ? { target } : {}), - }); + await copyManualDiagnosticReport(target); toastApi.success(copy.diagnosticsCopiedTitle, copy.diagnosticsCopiedDescription); } catch (err) { toastApi.error( @@ -351,15 +353,15 @@ export function buildAppShellCommandList( } }, onTestNetworkProxy: async () => { - const { toastApi } = optionsRef.current; + const { paletteActions, toastApi } = optionsRef.current; try { // PR-CMD-PALETTE-NETWORK-PROXY-TEST-0: surface the // proxy test result via toast so a user debugging a // connection issue does not need to open Settings → - // 网络. `testNetworkProxy(undefined)` uses the - // current persisted proxy config. + // 网络. `testNetworkProxy` uses the current persisted + // proxy config. const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.settings.testNetworkProxy(undefined, host), + paletteActions.testNetworkProxy(host), ); const message = settingsTestResultMessage(result, locale); if (result.ok) { diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 70387989d2..454e6d0a1f 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -32,20 +32,6 @@ export function commandPaletteActionErrorMessage( return classifiedErrorFallback(error, fallback, locale, 'command-palette'); } -export function openPathActionErrorMessage( - error: unknown, - key: 'workspace' | 'project', - locale: UiLocale, -): string { - const copy = getShellCopy(locale); - return classifiedErrorFallback( - error, - copy.errors.openPath(copy.paths[key]), - locale, - `open-path:${key}`, - ); -} - export function commandPaletteConnectionTestFailureMessage(result: ConnectionTestResult, locale: UiLocale): string { const fallback = commandPaletteConnectionTestFailureFallback(result, locale); return result.errorMessage diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 95d1b85d8f..23e4b721a1 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -106,8 +106,6 @@ export function useAppShellBootstrapSubscriptions(options: { openHelp: () => void; openSettings: () => void; clearPendingTurnActions: () => void; - projectPickerPendingRef: RefBox<boolean>; - projectPickerRequestRef: RefBox<number>; refreshConnections: () => Promise<void>; refreshMemoryActive: (failureContext?: 'load') => Promise<void>; refreshMessages: (sessionId: string) => Promise<boolean>; @@ -159,8 +157,6 @@ export function useAppShellBootstrapSubscriptions(options: { }); const cleanupPendingRefs = useEffectEvent(() => { options.rendererMountedRef.current = false; - options.projectPickerRequestRef.current += 1; - options.projectPickerPendingRef.current = false; options.clearPendingTurnActions(); }); diff --git a/apps/desktop/src/renderer/app-shell-project-actions.ts b/apps/desktop/src/renderer/app-shell-project-actions.ts deleted file mode 100644 index 8974c5eec0..0000000000 --- a/apps/desktop/src/renderer/app-shell-project-actions.ts +++ /dev/null @@ -1,392 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { Dispatch, SetStateAction } from 'react'; -import type { ProjectRecord } from '@maka/core/project'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { - DesktopProjectCapabilities, - DesktopRuntimeHostRef, -} from '../preload/bridge-contract.js'; -import { openPathActionErrorMessage } from './app-shell-copy'; -import { openPathActionLabel, openPathFailureCopy } from './open-path'; -import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; -import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast } from './session-workspace-errors'; -import { - defaultRuntimeHostDiagnosticTarget, - runOnDefaultRuntimeHost, -} from './platform/desktop/default-runtime-host-operation.js'; - -export interface RendererAppInfo { - projectId?: string | null; - projectPath: string; - projectGit: { isGitRepo: boolean; branch?: string }; -} - -export interface SessionProjectInfoState extends RendererAppInfo { - sessionId: string; -} - -type RefBox<T> = { current: T }; - -type ToastApi = { - success(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { sessionId: string } | { profileId: string }, - ): void; -}; - -export interface AppShellProjectActions { - refreshProjects(): Promise<ProjectRecord[]>; - addProject(): Promise<ProjectRecord | null>; - selectProject(projectId: string): Promise<boolean>; - selectNoProject(): Promise<void>; - prepareDefaultProject(): Promise<boolean>; - prepareProject(projectId: string): Promise<boolean>; - relinkProject(projectId: string, selectAfter?: boolean): Promise<ProjectRecord | null>; - renameProject(projectId: string, name: string): Promise<void>; - archiveProject(projectId: string): Promise<void>; - restoreProject(projectId: string): Promise<void>; - openProjectFolder(): Promise<void>; - openWorkspaceFolder(): Promise<void>; -} - -export function createAppShellProjectActions(deps: { - uiLocale: UiLocale; - projectPickerPendingRef: RefBox<boolean>; - projectPickerRequestRef: RefBox<number>; - rendererMountedRef: RefBox<boolean>; - setProjectPickerPending: Dispatch<SetStateAction<boolean>>; - refreshDefaultProjectState(host: DesktopRuntimeHostRef): Promise<ProjectRecord[]>; - selectedProjectId: string | null | undefined; - projects: readonly ProjectRecord[]; - projectCapabilities: DesktopProjectCapabilities; - sessionId?: string; - onProjectSelected(ownerSessionId?: string): void; - toastApi: ToastApi; -}): AppShellProjectActions { - const { - uiLocale, - projectPickerPendingRef, - projectPickerRequestRef, - rendererMountedRef, - setProjectPickerPending, - refreshDefaultProjectState, - selectedProjectId, - projects, - projectCapabilities, - sessionId, - onProjectSelected, - toastApi, - } = deps; - const copy = getShellCopy(uiLocale).projectActions; - const sessionDiagnosticTarget = sessionId ? { sessionId } : undefined; - const showDefaultProjectError = (title: string, description: string | undefined, error: unknown) => { - toastApi.error( - title, - description, - undefined, - defaultRuntimeHostDiagnosticTarget(error), - ); - }; - const showSessionProjectError = (title: string, description?: string) => { - toastApi.error(title, description, undefined, sessionDiagnosticTarget); - }; - - async function refreshProjects(): Promise<ProjectRecord[]> { - return ( - await runOnDefaultRuntimeHost((host) => refreshDefaultProjectState(host)) - ).value; - } - - async function applySelectedProject( - project: ProjectRecord, - path: string, - notify: boolean, - host: DesktopRuntimeHostRef, - ): Promise<boolean> { - if (project.preferredPath) { - const info = await window.maka.app.resolveProjectGitInfo(path, host); - if (!info.ok) throw new Error(copy.selectedPathUnreadable); - } - await refreshDefaultProjectState(host); - if (notify) { - onProjectSelected(sessionId); - toastApi.success(copy.directorySwitchedTitle, project.name); - } - return true; - } - - async function selectProjectRecord( - project: ProjectRecord, - notify: boolean, - host: DesktopRuntimeHostRef, - ): Promise<boolean> { - if (!project.available || project.archivedAt !== undefined) return false; - const selected = await window.maka.projects.select(project.id, host); - if (!selected.project) return false; - return applySelectedProject(selected.project, selected.path, notify, host); - } - - async function addProject(): Promise<ProjectRecord | null> { - if (!projectCapabilities.chooseClientDirectory) return null; - if (projectPickerPendingRef.current) return null; - const requestId = projectPickerRequestRef.current + 1; - projectPickerRequestRef.current = requestId; - projectPickerPendingRef.current = true; - setProjectPickerPending(true); - const isCurrentProjectPickerRequest = () => - rendererMountedRef.current && projectPickerRequestRef.current === requestId; - try { - const result = await runOnDefaultRuntimeHost(async (host) => { - const added = await window.maka.projects.add(host); - if (!added.ok) return added; - await applySelectedProject(added.project, added.path, true, host); - return added; - }); - if (!isCurrentProjectPickerRequest()) return null; - if (!result.value.ok) return null; - return result.value.project; - } catch (error) { - if (isCurrentProjectPickerRequest()) { - showDefaultProjectError( - copy.selectDirectoryFailedTitle, - localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale), - error, - ); - } - return null; - } finally { - if (projectPickerRequestRef.current === requestId) { - projectPickerPendingRef.current = false; - if (rendererMountedRef.current) setProjectPickerPending(false); - } - } - } - - async function selectProject(projectId: string): Promise<boolean> { - try { - const project = projects.find((candidate) => candidate.id === projectId); - if (!project) return false; - return ( - await runOnDefaultRuntimeHost((host) => selectProjectRecord(project, true, host)) - ).value; - } catch (error) { - showDefaultProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale), error); - return false; - } - } - - async function selectNoProject(): Promise<void> { - if (!projectCapabilities.selectNoProject) return; - try { - await runOnDefaultRuntimeHost(async (host) => { - await window.maka.projects.select(null, host); - await refreshDefaultProjectState(host); - onProjectSelected(sessionId); - }); - } catch (error) { - showDefaultProjectError( - copy.selectDirectoryFailedTitle, - localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale), - error, - ); - } - } - - async function prepareProject(projectId: string): Promise<boolean> { - try { - const project = projects.find((candidate) => candidate.id === projectId); - return project - ? ( - await runOnDefaultRuntimeHost((host) => - selectProjectRecord(project, false, host), - ) - ).value - : false; - } catch (error) { - showDefaultProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale), error); - return false; - } - } - - async function prepareDefaultProject(): Promise<boolean> { - try { - return ( - await runOnDefaultRuntimeHost(async (host) => { - if (selectedProjectId === null && projectCapabilities.selectNoProject) return true; - const candidates = projects.length > 0 - ? projects - : [...(await window.maka.projects.getDefaultContext(host)).snapshot.projects]; - const project = candidates.find( - (candidate) => candidate.archivedAt === undefined && candidate.available, - ); - if (project) return selectProjectRecord(project, false, host); - if (!projectCapabilities.selectNoProject) return false; - await window.maka.projects.select(null, host); - await refreshDefaultProjectState(host); - onProjectSelected(sessionId); - return true; - }) - ).value; - } catch (error) { - showDefaultProjectError( - copy.selectDirectoryFailedTitle, - localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale), - error, - ); - return false; - } - } - - async function relinkProject(projectId: string, selectAfter = false): Promise<ProjectRecord | null> { - if (!projectCapabilities.chooseClientDirectory) return null; - try { - return ( - await runOnDefaultRuntimeHost(async (host) => { - const result = await window.maka.projects.relink(projectId, host); - if (!result.ok) return null; - if (selectAfter) await selectProjectRecord(result.project, true, host); - else await refreshDefaultProjectState(host); - return result.project; - }) - ).value; - } catch (error) { - showDefaultProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale), error); - return null; - } - } - - async function renameProject(projectId: string, name: string): Promise<void> { - try { - await runOnDefaultRuntimeHost(async (host) => { - await window.maka.projects.rename(projectId, name, host); - await refreshDefaultProjectState(host); - }); - } catch (error) { - showDefaultProjectError( - copy.projectUpdateFailedTitle, - localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale), - error, - ); - } - } - - async function archiveProject(projectId: string): Promise<void> { - try { - await runOnDefaultRuntimeHost(async (host) => { - await window.maka.projects.archive(projectId, host); - await refreshDefaultProjectState(host); - }); - } catch (error) { - showDefaultProjectError( - copy.projectUpdateFailedTitle, - localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale), - error, - ); - } - } - - async function restoreProject(projectId: string): Promise<void> { - try { - await runOnDefaultRuntimeHost(async (host) => { - await window.maka.projects.restore(projectId, host); - await refreshDefaultProjectState(host); - }); - } catch (error) { - showDefaultProjectError( - copy.projectUpdateFailedTitle, - localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale), - error, - ); - } - } - - async function openProjectFolder() { - try { - const { value: result, diagnosticTarget } = sessionId - ? { - value: await window.maka.app.openPath('project', sessionId), - diagnosticTarget: { sessionId }, - } - : await runOnDefaultRuntimeHost((host) => - window.maka.app.openPath('project', undefined, host), - ); - if (!result.ok) { - toastApi.error( - copy.openFailedTitle(openPathActionLabel('project', uiLocale)), - openPathFailureCopy(result.reason, uiLocale), - undefined, - diagnosticTarget, - ); - } - } catch (error) { - if (sessionId && isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, sessionDiagnosticTarget); - } else { - toastApi.error( - copy.openFailedTitle(openPathActionLabel('project', uiLocale)), - openPathActionErrorMessage(error, 'project', uiLocale), - undefined, - sessionDiagnosticTarget ?? defaultRuntimeHostDiagnosticTarget(error), - ); - } - } - } - - async function openWorkspaceFolder() { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.app.openPath('workspace', undefined, host), - ); - if (!result.ok) { - toastApi.error( - copy.openFailedTitle(openPathActionLabel('workspace', uiLocale)), - openPathFailureCopy(result.reason, uiLocale), - undefined, - diagnosticTarget, - ); - } - } catch (error) { - showDefaultProjectError( - copy.openFailedTitle(openPathActionLabel('workspace', uiLocale)), - openPathActionErrorMessage(error, 'workspace', uiLocale), - error, - ); - } - } - - return { - refreshProjects, - addProject, - selectProject, - selectNoProject, - prepareDefaultProject, - prepareProject, - relinkProject, - renameProject, - archiveProject, - restoreProject, - openProjectFolder, - openWorkspaceFolder, - }; -} diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 596f8b2115..5b136e2ca3 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -158,7 +158,6 @@ export function AppShell() { const [uiLocaleOverride, setUiLocaleOverride] = useState<UiLocale | null>(null); const systemUiLocale = useSystemUiLocale(); const uiLocale = resolveUiLocale(uiLocalePreference, systemUiLocale, uiLocaleOverride); - const copy = getShellCopy(uiLocale); return ( <LocaleProvider locale={uiLocale} override={uiLocaleOverride}> @@ -167,13 +166,7 @@ export function AppShell() { `useUiLocale()` throws before anything renders. Still above every Astryx subtree. */} <AstryxLocaleProvider> - <Diagnostics.DiagnosticReportToastProvider - labels={{ - label: copy.errorBoundary.copyReport, - failureTitle: copy.commandActions.copyFailedTitle, - failureDescription: copy.commandActions.clipboardDenied, - }} - > + <Diagnostics.DiagnosticReportToastProvider> <ErrorBoundary locale={uiLocale}> <AppUpdateProvider> <RuntimeHostHandoffOverlay /> @@ -189,9 +182,13 @@ export function AppShell() { <Conversation.ConversationProvider> <OnboardingProjectionRoot> {(onboarding) => ( - <AppShellContent - {...{ taskEntry, overlays, sharedSessionDialog, workbar, onboarding, uiLocale, uiLocaleOverride, setUiLocaleOverride, setUiLocalePreference }} - /> + <Diagnostics.ManualDiagnosticReportConsumer> + {(copyManualDiagnosticReport) => ( + <AppShellContent + {...{ taskEntry, overlays, sharedSessionDialog, workbar, onboarding, copyManualDiagnosticReport, uiLocale, uiLocaleOverride, setUiLocaleOverride, setUiLocalePreference }} + /> + )} + </Diagnostics.ManualDiagnosticReportConsumer> )} </OnboardingProjectionRoot> </Conversation.ConversationProvider> @@ -228,6 +225,7 @@ function AppShellContent({ sharedSessionDialog, workbar: { bridge, commands, selectors, LiveContextUsageProbe }, onboarding, + copyManualDiagnosticReport, uiLocale, uiLocaleOverride, setUiLocaleOverride, @@ -238,6 +236,7 @@ function AppShellContent({ sharedSessionDialog: SessionCollaborationDialogProjection; workbar: WorkbarShellProjection; onboarding: OnboardingShellProjection; + copyManualDiagnosticReport: Diagnostics.CopyManualDiagnosticReport; uiLocale: UiLocale; uiLocaleOverride: UiLocale | null; setUiLocaleOverride: Dispatch<SetStateAction<UiLocale | null>>; @@ -794,28 +793,18 @@ function AppShellContent({ activeProjectCapabilities, currentProjectId, currentProject, - projectPickerPendingRef, - projectPickerRequestRef, refreshProjects, - relinkProject, - renameProject, - archiveProject, - restoreProject, - openProjectFolder, - openWorkspaceFolder, } = useAppShellProjectContext({ - uiLocale, rendererMountedRef, sessionId: ownerActiveId, sessionCwd: sharedSessionActive ? undefined : activeSession?.cwd, sessionProjectId: sharedSessionActive ? undefined : activeSession?.projectId, sessionProfileKind: sharedSessionActive ? undefined : activeSession?.profileKind, - onProjectSelected: (ownerSessionId) => { - void moduleHubCommands.refreshProjectSkills(); - if (ownerSessionId && activeIdRef.current === ownerSessionId) openNewTaskSurface(); - }, - toastApi, }); + const openProjectFolder = useCallback( + () => taskEntry.commands.openProjectFolder(ownerActiveId), + [taskEntry.commands, ownerActiveId], + ); const captureActiveComposerClaim = useCallback(() => { const sessionId = activeIdRef.current; const composer = composerRef.current; @@ -995,8 +984,6 @@ function AppShellContent({ openHelp, openSettings, clearPendingTurnActions: turnActionRegistry.clearAll, - projectPickerPendingRef, - projectPickerRequestRef, refreshConnections: refreshConnectionProjections, refreshMemoryActive, refreshMessages, @@ -1180,6 +1167,8 @@ function AppShellContent({ themePref, hiddenSessionIds: selectors.hiddenSessionIds, captureComposerImportOwner, + copyManualDiagnosticReport, + paletteActions: overlays.paletteActions, createSession, openHelp, openScheduledTaskCreate: () => { @@ -1191,7 +1180,7 @@ function AppShellContent({ openSideConversation: () => commands.openTool('side-chat'), openSettings, openSettingsSection, - openWorkspaceFolder, + openWorkspaceFolder: taskEntry.commands.openWorkspaceFolder, refreshConnections: defaultHostConnections.refreshConnections, copyTodayDailyReview: moduleHubCommands.copyTodayDailyReview, pasteTodayDailyReview: moduleHubCommands.pasteTodayDailyReview, diff --git a/apps/desktop/src/renderer/application/contracts/feature-services.tsx b/apps/desktop/src/renderer/application/contracts/feature-services.tsx index 387661f1f0..421276f427 100644 --- a/apps/desktop/src/renderer/application/contracts/feature-services.tsx +++ b/apps/desktop/src/renderer/application/contracts/feature-services.tsx @@ -27,6 +27,11 @@ export interface ServicesContext<S> { }) => ReactElement; /** Reads the mounted services; throws when the Provider is missing. */ readonly useServices: () => S; + /** + * Reads the mounted services, or undefined when the Provider is missing. For + * a reader that has to keep working outside Desktop composition. + */ + readonly useOptionalServices: () => S | undefined; } /** @@ -52,5 +57,8 @@ export function createServicesContext<S>(providerName: string): ServicesContext< if (!services) throw new Error(`${providerName} is missing`); return services; } - return { Provider, useServices }; + function useOptionalServices(): S | undefined { + return useContext(Context) ?? undefined; + } + return { Provider, useServices, useOptionalServices }; } diff --git a/apps/desktop/src/renderer/error-boundary.tsx b/apps/desktop/src/renderer/error-boundary.tsx index 28852891f1..8f9bc5cdb3 100644 --- a/apps/desktop/src/renderer/error-boundary.tsx +++ b/apps/desktop/src/renderer/error-boundary.tsx @@ -22,6 +22,7 @@ import { truncateUtf8 } from '@maka/core/diagnostic-log'; import type { UiLocale } from '@maka/core/ui-locale'; import { ICON_SIZE, AlertTriangle, Check, Clipboard, RotateCw } from '@maka/ui/icons'; import { Button as UiButton, Card, redactSecrets } from '@maka/ui'; +import * as Diagnostics from './features/diagnostics/index.js'; import { getShellCopy } from './locales/shell-copy.js'; export type ErrorBoundaryCopyState = 'idle' | 'pending' | 'copied' | 'failed'; @@ -64,7 +65,25 @@ export function formatRendererErrorReport(error: Error, info?: ErrorInfo | null) ); } -export class ErrorBoundary extends Component<{ children: ReactNode; locale: UiLocale }, State> { +type ErrorBoundaryProps = { children: ReactNode; locale: UiLocale }; + +/** + * The renderer's crash surface. Desktop composition supplies the crash report + * through the diagnostics feature; without it (Storybook, renderer tests) the + * boundary still renders its fallback and copies its own browser report. + */ +export function ErrorBoundary(props: ErrorBoundaryProps): ReactNode { + return ( + <Diagnostics.RendererCrashReportConsumer> + {(copyCrashReport) => <RendererErrorBoundary {...props} copyCrashReport={copyCrashReport} />} + </Diagnostics.RendererCrashReportConsumer> + ); +} + +class RendererErrorBoundary extends Component< + ErrorBoundaryProps & { copyCrashReport: Diagnostics.CopyRendererCrashReport | undefined }, + State +> { state: State = { error: null, errorInfo: null, copyState: 'idle' }; private mounted = false; private copyRequestSeq = 0; @@ -110,10 +129,9 @@ export class ErrorBoundary extends Component<{ children: ReactNode; locale: UiLo const copyRequestId = ++this.copyRequestSeq; this.setState({ copyState: 'pending' }); try { - const diagnostics = window.maka?.diagnostics; - if (diagnostics) { - await diagnostics.copyReport({ - surface: 'renderer_crash', + const { copyCrashReport } = this.props; + if (copyCrashReport) { + await copyCrashReport({ title: `${error.name}: ${error.message}`, details: formatRendererErrorDetails(error, errorInfo), }); diff --git a/apps/desktop/src/renderer/features/diagnostics/README.md b/apps/desktop/src/renderer/features/diagnostics/README.md index 7db52d4b7b..5d56262efd 100644 --- a/apps/desktop/src/renderer/features/diagnostics/README.md +++ b/apps/desktop/src/renderer/features/diagnostics/README.md @@ -19,31 +19,32 @@ # Diagnostics feature -This slice owns the renderer root's use of Desktop diagnostics. +This slice owns the renderer's use of Desktop diagnostics. ## Ownership - `DiagnosticReportToastProvider` is the renderer's toast layer. It offers the Desktop diagnostic report on error toasts and copies it through the injected - `copyToastReport` service. AppShell supplies only the action's labels, which - stay in the shared shell catalog beside the Error Boundary and command - palette copy that use the same words. + `copyToastReport` service. It reads the action's words from the shared shell + catalog, where they stay beside the Error Boundary and command palette copy + that use the same ones. - `PreviousMainProcessInterruptionNotice` alone reads whether the previous main process ended without finishing its shutdown, once the shell's appearance has hydrated, and shows that notice at most once per renderer. A read that resolves after its effect was replaced (for example by a locale change) is dropped; the replacement read shows the notice in the current locale. +- `ManualDiagnosticReportConsumer` hands the manual report command to its two + callers: About, and AppShell, which passes it to the command palette in its + command options. Each caller keeps its own target, toasts and pending state; + the command takes only the optional task or Host profile target. +- `RendererCrashReportConsumer` hands the crash report command to the Error + Boundary, or nothing outside Desktop composition (Storybook, renderer tests). + The boundary then copies its own bounded browser report, so the crash surface + never depends on a provider being mounted. - `platform/desktop/create-diagnostics-services.ts` is the only adapter from the Desktop bridge into this feature, and Desktop feature-services composition is - its only production importer. The adapter forwards only the fields an error - toast carried, as the `toast` report surface. + its only production importer. Each service fixes its report surface (`toast`, + `manual`, `renderer_crash`) and forwards only the fields its caller supplied. -AppShell calls no diagnostics bridge method and holds no notice state. - -## Not in this slice - -The Error Boundary's crash report, the command palette's manual report -(`app-shell-command-actions.ts`) and About's manual report still call the -Desktop bridge from legacy renderer files. Each can move onto this feature's -services when its owner is next changed; none of them runs in AppShell's render -body. +No renderer file outside that adapter calls the diagnostics bridge. AppShell +holds no notice state and receives only the manual report command. diff --git a/apps/desktop/src/renderer/features/diagnostics/index.ts b/apps/desktop/src/renderer/features/diagnostics/index.ts index 7dd6412c49..c070c690c7 100644 --- a/apps/desktop/src/renderer/features/diagnostics/index.ts +++ b/apps/desktop/src/renderer/features/diagnostics/index.ts @@ -20,4 +20,10 @@ export { DiagnosticsServicesProvider } from './services-context.js'; export { DiagnosticReportToastProvider } from './ui/diagnostic-report-toast-provider.js'; export { PreviousMainProcessInterruptionNotice } from './ui/previous-main-process-interruption-notice.js'; +export { + ManualDiagnosticReportConsumer, + RendererCrashReportConsumer, + type CopyManualDiagnosticReport, + type CopyRendererCrashReport, +} from './ui/report-consumers.js'; export type { DiagnosticsServices } from './ports.js'; diff --git a/apps/desktop/src/renderer/features/diagnostics/ports.ts b/apps/desktop/src/renderer/features/diagnostics/ports.ts index 85f696bdf9..ea0ac2b634 100644 --- a/apps/desktop/src/renderer/features/diagnostics/ports.ts +++ b/apps/desktop/src/renderer/features/diagnostics/ports.ts @@ -22,10 +22,26 @@ import type { ToastErrorAction } from '@maka/ui'; /** What an error toast hands to its report action. */ export type ToastDiagnosticReport = Parameters<ToastErrorAction['onClick']>[0]; -/** The Desktop diagnostics capabilities the renderer root used to reach directly. */ +/** The task or Host profile a manual report is about, when the user is looking at one. */ +export type ManualDiagnosticTarget = + | { readonly sessionId: string; readonly profileId?: never } + | { readonly profileId: string; readonly sessionId?: never }; + +/** What the Error Boundary hands to the report of a renderer crash. */ +export interface RendererCrashDiagnosticReport { + readonly title: string; + /** The error and its stacks, already redacted. */ + readonly details: string; +} + +/** The Desktop diagnostics capabilities the renderer used to reach directly. */ export interface DiagnosticsServices { /** Copies a diagnostic report for an error toast the user chose to report. */ copyToastReport(report: ToastDiagnosticReport): Promise<void>; + /** Copies the report the user asked for from About or the command palette. */ + copyManualReport(target?: ManualDiagnosticTarget): Promise<void>; + /** Copies the report for a renderer crash the Error Boundary caught. */ + copyRendererCrashReport(report: RendererCrashDiagnosticReport): Promise<void>; /** * Reads whether the previous main process ended without finishing its * shutdown. Desktop reads it once per renderer; later calls return the same diff --git a/apps/desktop/src/renderer/features/diagnostics/services-context.tsx b/apps/desktop/src/renderer/features/diagnostics/services-context.tsx index 5613584a84..4cd45dc525 100644 --- a/apps/desktop/src/renderer/features/diagnostics/services-context.tsx +++ b/apps/desktop/src/renderer/features/diagnostics/services-context.tsx @@ -20,10 +20,15 @@ import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { DiagnosticsServices } from './ports.js'; -const { Provider, useServices } = createServicesContext<DiagnosticsServices>('DiagnosticsServicesProvider'); +const { Provider, useServices, useOptionalServices } = + createServicesContext<DiagnosticsServices>('DiagnosticsServicesProvider'); export const DiagnosticsServicesProvider = Provider; export function useDiagnosticsServices(): DiagnosticsServices { return useServices(); } + +export function useOptionalDiagnosticsServices(): DiagnosticsServices | undefined { + return useOptionalServices(); +} diff --git a/apps/desktop/src/renderer/features/diagnostics/testing.ts b/apps/desktop/src/renderer/features/diagnostics/testing.ts index 2b9b3a0888..21ef2a37a5 100644 --- a/apps/desktop/src/renderer/features/diagnostics/testing.ts +++ b/apps/desktop/src/renderer/features/diagnostics/testing.ts @@ -23,13 +23,20 @@ export { DiagnosticsServicesProvider } from './services-context.js'; export { DiagnosticReportToastProvider } from './ui/diagnostic-report-toast-provider.js'; export { PreviousMainProcessInterruptionNotice } from './ui/previous-main-process-interruption-notice.js'; export { getDiagnosticsCopy } from './locales/diagnostics-copy.js'; -export type { DiagnosticsServices, ToastDiagnosticReport } from './ports.js'; +export type { + DiagnosticsServices, + ManualDiagnosticTarget, + RendererCrashDiagnosticReport, + ToastDiagnosticReport, +} from './ports.js'; export function createFakeDiagnosticsServices( overrides: Partial<DiagnosticsServices> = {}, ): DiagnosticsServices { return { copyToastReport: async () => undefined, + copyManualReport: async () => undefined, + copyRendererCrashReport: async () => undefined, takePreviousMainProcessInterruption: async () => false, copyPreviousMainProcessInterruption: async () => undefined, ...overrides, diff --git a/apps/desktop/src/renderer/features/diagnostics/ui/diagnostic-report-toast-provider.tsx b/apps/desktop/src/renderer/features/diagnostics/ui/diagnostic-report-toast-provider.tsx index 338dc16697..baa89995b7 100644 --- a/apps/desktop/src/renderer/features/diagnostics/ui/diagnostic-report-toast-provider.tsx +++ b/apps/desktop/src/renderer/features/diagnostics/ui/diagnostic-report-toast-provider.tsx @@ -18,30 +18,26 @@ */ import { useMemo, type ReactNode } from 'react'; -import { ToastProvider, type ToastErrorAction } from '@maka/ui'; +import { ToastProvider, useUiLocale, type ToastErrorAction } from '@maka/ui'; +import { getShellCopy } from '../../../locales/shell-copy.js'; import { useDiagnosticsServices } from '../services-context.js'; -/** The report action's copy. AppShell supplies it from the shared shell catalog. */ -interface DiagnosticReportToastLabels { - readonly label: string; - readonly failureTitle: string; - readonly failureDescription: string; -} - /** * The renderer's toast layer, with the Desktop diagnostic report offered on * error toasts. * - * The report action is rebuilt only when a label changes; the injected - * services are created once at composition, so toast consumers do not see a - * new action on unrelated renders. + * The action's words stay in the shared shell catalog beside the Error + * Boundary and command palette copy that use the same ones. The report action + * is rebuilt only when the locale changes; the injected services are created + * once at composition, so toast consumers do not see a new action on + * unrelated renders. */ -export function DiagnosticReportToastProvider(props: { - readonly labels: DiagnosticReportToastLabels; - readonly children?: ReactNode; -}) { +export function DiagnosticReportToastProvider(props: { readonly children?: ReactNode }) { const services = useDiagnosticsServices(); - const { label, failureTitle, failureDescription } = props.labels; + const copy = getShellCopy(useUiLocale()); + const label = copy.errorBoundary.copyReport; + const failureTitle = copy.commandActions.copyFailedTitle; + const failureDescription = copy.commandActions.clipboardDenied; const errorAction = useMemo<ToastErrorAction>( () => ({ label, diff --git a/apps/desktop/src/renderer/features/diagnostics/ui/report-consumers.tsx b/apps/desktop/src/renderer/features/diagnostics/ui/report-consumers.tsx new file mode 100644 index 0000000000..5a14c545e0 --- /dev/null +++ b/apps/desktop/src/renderer/features/diagnostics/ui/report-consumers.tsx @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ReactNode } from 'react'; +import type { DiagnosticsServices } from '../ports.js'; +import { useDiagnosticsServices, useOptionalDiagnosticsServices } from '../services-context.js'; + +export type CopyManualDiagnosticReport = DiagnosticsServices['copyManualReport']; +export type CopyRendererCrashReport = DiagnosticsServices['copyRendererCrashReport']; + +/** + * Hands the manual report command to About, and to AppShell for the command + * palette's options. Both are legacy readers that take feature capabilities + * through render props rather than hooks of their own. + */ +export function ManualDiagnosticReportConsumer(props: { + readonly children: (copyManualReport: CopyManualDiagnosticReport) => ReactNode; +}): ReactNode { + return props.children(useDiagnosticsServices().copyManualReport); +} + +/** + * Hands the crash report command to the Error Boundary. Outside Desktop + * composition (Storybook, renderer tests) there is none, and the boundary + * copies its own browser report instead: the crash surface must not depend on + * a provider being mounted. + */ +export function RendererCrashReportConsumer(props: { + readonly children: (copyCrashReport: CopyRendererCrashReport | undefined) => ReactNode; +}): ReactNode { + return props.children(useOptionalDiagnosticsServices()?.copyRendererCrashReport); +} diff --git a/apps/desktop/src/renderer/features/overlays/README.md b/apps/desktop/src/renderer/features/overlays/README.md index 3627ff69e6..7efcd7cb41 100644 --- a/apps/desktop/src/renderer/features/overlays/README.md +++ b/apps/desktop/src/renderer/features/overlays/README.md @@ -39,6 +39,14 @@ here too. through `OverlaysConsumer` and mounts it. - The palette's command list stays a shell concern: the shell builds the rows from its own actions and passes them to `CommandPalette`. +- The Desktop operations behind the rows' own actions are this slice's + `palette` port: testing a connection or making it the default (by slug, the + bridge's slug form rather than Connection Settings' identity form), testing + the network proxy, opening the local memory file and saving the conversation + to a file. The projection hands them to the shell's row builder as + `paletteActions`; the rows keep their default-Host resolution and toasts. + The Desktop adapter is the only code that calls those bridge methods for the + palette. ## Destination diff --git a/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts b/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts index 3b26b867d3..a25b29124f 100644 --- a/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts +++ b/apps/desktop/src/renderer/features/overlays/controller/use-overlays-controller.ts @@ -117,5 +117,9 @@ export function useOverlaysController(): OverlaysController { [helpOpen, paletteOpen, searchOpen, settings, searchScrollTarget], ); - return useMemo(() => ({ commands, selectors }), [commands, selectors]); + const paletteActions = services.palette; + return useMemo( + () => ({ commands, selectors, paletteActions }), + [commands, selectors, paletteActions], + ); } diff --git a/apps/desktop/src/renderer/features/overlays/index.ts b/apps/desktop/src/renderer/features/overlays/index.ts index d0ca3716e7..b34608ed2b 100644 --- a/apps/desktop/src/renderer/features/overlays/index.ts +++ b/apps/desktop/src/renderer/features/overlays/index.ts @@ -23,6 +23,6 @@ export { OverlaysConsumer } from './ui/overlays-context.js'; export { KeyboardHelpModal } from './ui/keyboard-help-modal.js'; export { CommandPalette } from './ui/command-palette.js'; export { SearchModalHost } from './ui/search-modal-host.js'; -export type { OverlaysServices } from './ports.js'; +export type { OverlayPaletteActions, OverlaysServices } from './ports.js'; export type { Command } from './model/command.js'; export type { OverlaysShellProjection } from './model/overlays-projection.js'; diff --git a/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts b/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts index c9a179b703..64724fd0e7 100644 --- a/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts +++ b/apps/desktop/src/renderer/features/overlays/model/overlays-projection.ts @@ -19,7 +19,7 @@ import type { ProviderType } from '@maka/core/llm-connections'; import type { SettingsSection } from '@maka/core/settings'; -import type { OverlaySearchRecall } from '../ports.js'; +import type { OverlayPaletteActions, OverlaySearchRecall } from '../ports.js'; import type { SearchScrollTarget } from './search-scroll-target.js'; import type { SettingsModalState } from './settings-modal-state.js'; @@ -58,4 +58,10 @@ export interface OverlaysSelectors { export interface OverlaysShellProjection { readonly commands: OverlaysCommands; readonly selectors: OverlaysSelectors; + /** + * The Desktop operations behind the palette's own rows, for the shell's + * row builder (`app-shell-command-actions.ts`) while it stays outside this + * feature. Created once with the services, so its identity never moves. + */ + readonly paletteActions: OverlayPaletteActions; } diff --git a/apps/desktop/src/renderer/features/overlays/ports.ts b/apps/desktop/src/renderer/features/overlays/ports.ts index c9cfa445c5..2a3c5c4161 100644 --- a/apps/desktop/src/renderer/features/overlays/ports.ts +++ b/apps/desktop/src/renderer/features/overlays/ports.ts @@ -17,7 +17,8 @@ * under the License. */ -import type { SettingsSection } from '@maka/core/settings'; +import type { ConnectionTestResult } from '@maka/core/llm-connections'; +import type { SettingsSection, SettingsTestResult } from '@maka/core/settings'; import type { SearchModal } from '@maka/ui'; /** The recall search the Search modal runs; the type is the modal's own. */ @@ -44,8 +45,40 @@ export interface OverlayFocusService { blurActiveElement(): void; } +/** The Runtime Host a palette row runs on; the row builder resolves the default one. */ +export interface OverlayPaletteHost { + readonly profileId: string; + readonly hostId: string; +} + +export type OverlayConversationSaveResult = + | { readonly ok: true; readonly path: string } + | { readonly ok: false; readonly reason: 'canceled' | 'write_failed' | 'invalid_input' }; + +/** + * The Desktop operations behind the command palette's own rows. The rows keep + * their toasts and default-Host resolution; these only reach Desktop, with + * the arguments the rows always sent. + */ +export interface OverlayPaletteActions { + /** Tests a connection named by its slug, as the palette row lists it. */ + testConnection(slug: string, host: OverlayPaletteHost): Promise<ConnectionTestResult>; + setDefaultConnection(slug: string, host: OverlayPaletteHost): Promise<void>; + /** Tests the persisted network proxy settings. */ + testNetworkProxy(host: OverlayPaletteHost): Promise<SettingsTestResult>; + openLocalMemoryFile( + host: OverlayPaletteHost, + ): Promise<{ readonly ok: true } | { readonly ok: false; readonly code: string }>; + /** Asks where to save, then writes the rendered conversation. */ + saveConversationToFile(input: { + readonly markdown: string; + readonly defaultName: string; + }): Promise<OverlayConversationSaveResult>; +} + export interface OverlaysServices { search: OverlaySearchService; settingsSection: OverlaySettingsSectionStore; focus: OverlayFocusService; + palette: OverlayPaletteActions; } diff --git a/apps/desktop/src/renderer/features/overlays/testing.ts b/apps/desktop/src/renderer/features/overlays/testing.ts index 6a9e628210..55cdaa3738 100644 --- a/apps/desktop/src/renderer/features/overlays/testing.ts +++ b/apps/desktop/src/renderer/features/overlays/testing.ts @@ -48,6 +48,13 @@ export function createFakeOverlaysServices( }, settingsSection: { persist: () => undefined }, focus: { blurActiveElement: () => undefined }, + palette: { + testConnection: async () => ({ ok: true }), + setDefaultConnection: async () => undefined, + testNetworkProxy: async () => ({ ok: true, message: '' }), + openLocalMemoryFile: async () => ({ ok: true }), + saveConversationToFile: async () => ({ ok: false, reason: 'canceled' }), + }, ...overrides, }; } diff --git a/apps/desktop/src/renderer/features/task-entry/README.md b/apps/desktop/src/renderer/features/task-entry/README.md index 7895872f59..c11639973d 100644 --- a/apps/desktop/src/renderer/features/task-entry/README.md +++ b/apps/desktop/src/renderer/features/task-entry/README.md @@ -24,6 +24,13 @@ loads the Runtime Host/Project catalog, preserves a selection per Host, derives the new-task target and draft identity, projects the Workspace Picker, and owns add/relink plus remote-directory handoff lifecycles. +It also reveals folders in the system file manager: a task's project folder +through that task, otherwise the default Runtime Host's project or workspace +folder. The Desktop adapter resolves the default Host and names the task or +Host profile each failure belongs to; the controller reports it in the shell's +open-folder copy, and a task whose workspace is gone gets the +workspace-unavailable notice instead. + ## Dependency direction - Consumers import production APIs from `features/task-entry`. diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts index 8eabfedf32..33bf613ccb 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -45,9 +45,12 @@ import { type ReadyTaskEntryHost, } from '../model/task-entry-selection.js'; import { resolveWorkBoardStartTarget, type WorkBoardStartTargetResult } from '../model/work-board-target.js'; +import { folderOpenFailure } from '../model/folder-open-failure.js'; import type { TaskEntryCatalog, TaskEntryError, + TaskEntryFolder, + TaskEntryFolderOpenResult, TaskEntryHostRef, TaskEntryProjectMutationResult, TaskEntryTarget, @@ -110,6 +113,10 @@ export interface TaskEntryControllerCommands { }): Promise<boolean>; resolveWorkBoardTarget(item: WorkBoardItem): WorkBoardStartTargetResult; prepareWorkBoardDraft(target: TaskEntryTarget, draft: string): string | undefined; + /** Reveals the project folder of the task `sessionId`, or the default Host's without one. */ + openProjectFolder(sessionId?: string): Promise<void>; + /** Reveals the default Host's workspace folder. */ + openWorkspaceFolder(): Promise<void>; } export interface TaskEntryController { @@ -168,7 +175,7 @@ export function useTaskEntryController( const conversationCopy = getConversationCopy(locale).workspace; const reportError = input.reportError; const manageProjects = input.manageProjects; - const { catalog: service, sessions: sessionService } = useTaskEntryServices(); + const { catalog: service, sessions: sessionService, folders } = useTaskEntryServices(); const [catalog, setCatalog] = useState<TaskEntryCatalog>(EMPTY_CATALOG); const [selectedProfileId, setSelectedProfileId] = useState<string>(); const [projectSelections, setProjectSelections] = useState( @@ -731,6 +738,15 @@ export function useTaskEntryController( close: () => setNewProjectOpen(false), submit: (name: string) => addSelectedProject(name), } : undefined, [newProjectOpen, addSelectedProject]); + const openFolder = useCallback(async ( + folder: TaskEntryFolder, + open: () => Promise<TaskEntryFolderOpenResult>, + sessionId?: string, + ): Promise<void> => { + const failure = folderOpenFailure(folder, await open(), locale, sessionId); + if (failure) reportError(failure); + }, [locale, reportError]); + return useMemo(() => ({ host: { ...(directoryHost @@ -789,6 +805,10 @@ export function useTaskEntryController( addSessionWorkspace, resolveWorkBoardTarget, prepareWorkBoardDraft, + openProjectFolder: (sessionId) => + openFolder('project', () => folders.openProjectFolder(sessionId), sessionId), + openWorkspaceFolder: () => + openFolder('workspace', () => folders.openWorkspaceFolder()), }, selectors: { ...(target ? { target } : {}), @@ -822,6 +842,8 @@ export function useTaskEntryController( closeDirectoryPicker, closeSessionWorkspaceRecovery, directoryHost, + folders, + openFolder, projectPath, projectScopes, openSessionWorkspaceRecovery, diff --git a/apps/desktop/src/renderer/features/task-entry/model/folder-open-failure.ts b/apps/desktop/src/renderer/features/task-entry/model/folder-open-failure.ts new file mode 100644 index 0000000000..f8695fa937 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/model/folder-open-failure.ts @@ -0,0 +1,68 @@ +/* + * 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 { UiLocale } from '@maka/core/ui-locale'; +import { classifiedErrorFallback } from '../../../application/contracts/operation-diagnostics.js'; +import { isSessionWorkspaceUnavailableError } from '../../../application/contracts/session-workspace-errors.js'; +import { + getShellCopy, + openPathActionLabel, + openPathFailureCopy, +} from '../../../locales/shell-copy.js'; +import type { TaskEntryError, TaskEntryFolder, TaskEntryFolderOpenResult } from '../ports.js'; + +/** + * What to report for a folder that did not open, or undefined when it opened. + * The words are the shell's open-folder copy; a task whose workspace is gone + * gets the workspace-unavailable notice instead of an open failure. + */ +export function folderOpenFailure( + folder: TaskEntryFolder, + result: TaskEntryFolderOpenResult, + locale: UiLocale, + sessionId?: string, +): TaskEntryError | undefined { + if (result.kind === 'opened') return undefined; + const copy = getShellCopy(locale); + const title = copy.projectActions.openFailedTitle(openPathActionLabel(folder, locale)); + if (result.kind === 'refused') { + return { + title, + description: openPathFailureCopy(result.reason, locale), + ...result.diagnosticTarget, + }; + } + if (sessionId && isSessionWorkspaceUnavailableError(result.error)) { + return { + title: copy.errors.workspaceUnavailableTitle, + description: copy.errors.workspaceUnavailableDescription, + sessionId, + }; + } + return { + title, + description: classifiedErrorFallback( + result.error, + copy.errors.openPath(copy.paths[folder]), + locale, + `open-path:${folder}`, + ), + ...result.diagnosticTarget, + }; +} diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts index 85e84564c8..6a311d0e1b 100644 --- a/apps/desktop/src/renderer/features/task-entry/ports.ts +++ b/apps/desktop/src/renderer/features/task-entry/ports.ts @@ -31,7 +31,10 @@ export interface TaskEntryHostRef { export interface TaskEntryError { readonly title: string; readonly description?: string; - readonly profileId: string; + /** The Host profile the failed operation ran on, when one was resolved. */ + readonly profileId?: string; + /** The task whose folder failed to open; reported instead of a profile. */ + readonly sessionId?: string; } export interface TaskEntryTarget extends TaskEntryHostRef { @@ -136,7 +139,41 @@ export interface TaskEntrySessionService { ): Promise<TaskEntrySessionWorkspaceResult>; } +/** A folder Task Entry / Workspace reveals in the system file manager. */ +export type TaskEntryFolder = 'project' | 'workspace'; + +/** Where a folder failure is reported: the task it belonged to, or the Host profile it ran on. */ +export type TaskEntryFolderDiagnosticTarget = + | { readonly sessionId: string } + | { readonly profileId: string }; + +export type TaskEntryFolderOpenResult = + | { readonly kind: 'opened' } + /** Desktop refused the folder; `reason` is its open-path failure code. */ + | { + readonly kind: 'refused'; + readonly reason: string; + readonly diagnosticTarget: TaskEntryFolderDiagnosticTarget; + } + /** The request itself failed; no target when the default Host was never resolved. */ + | { + readonly kind: 'failed'; + readonly error: unknown; + readonly diagnosticTarget?: TaskEntryFolderDiagnosticTarget; + }; + +export interface TaskEntryFolderService { + /** + * Reveals the project folder of the task `sessionId`, or the default + * Runtime Host's project folder when no task is given. + */ + openProjectFolder(sessionId?: string): Promise<TaskEntryFolderOpenResult>; + /** Reveals the default Runtime Host's workspace folder. */ + openWorkspaceFolder(): Promise<TaskEntryFolderOpenResult>; +} + export interface TaskEntryServices { readonly catalog: TaskEntryCatalogService; readonly sessions: TaskEntrySessionService; + readonly folders: TaskEntryFolderService; } diff --git a/apps/desktop/src/renderer/features/task-entry/testing.ts b/apps/desktop/src/renderer/features/task-entry/testing.ts index 57d7be8f49..def18db03f 100644 --- a/apps/desktop/src/renderer/features/task-entry/testing.ts +++ b/apps/desktop/src/renderer/features/task-entry/testing.ts @@ -36,8 +36,10 @@ export { taskEntryDraftKey, } from './model/task-entry-selection.js'; export { resolveWorkBoardStartTarget } from './model/work-board-target.js'; +export { folderOpenFailure } from './model/folder-open-failure.js'; export type { TaskEntryCatalog, + TaskEntryFolderOpenResult, TaskEntryHost, TaskEntryServices, } from './ports.js'; @@ -60,6 +62,10 @@ export function createFakeTaskEntryServices( sessions: { relocateWorkspace: async () => ({ ok: false, reason: 'operation_unavailable' }), }, + folders: { + openProjectFolder: async () => ({ kind: 'opened' }), + openWorkspaceFolder: async () => ({ kind: 'opened' }), + }, ...overrides, }; } diff --git a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx index c004942e62..57c6ee6789 100644 --- a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx +++ b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx @@ -96,6 +96,8 @@ const EMPTY_CONTROLLER: TaskEntryController = { _target: Parameters<TaskEntryControllerCommands['prepareWorkBoardDraft']>[0], _draft: Parameters<TaskEntryControllerCommands['prepareWorkBoardDraft']>[1], ): ReturnType<TaskEntryControllerCommands['prepareWorkBoardDraft']> => undefined, + async openProjectFolder() {}, + async openWorkspaceFolder() {}, }, selectors: { draftKey: taskEntryDraftKey(undefined), @@ -160,6 +162,9 @@ function createTaskEntryOwner(): TaskEntryOwner & { draft: Parameters<TaskEntryControllerCommands['prepareWorkBoardDraft']>[1], ) => current.commands.prepareWorkBoardDraft(target, draft), + openProjectFolder: (sessionId?: string) => + current.commands.openProjectFolder(sessionId), + openWorkspaceFolder: () => current.commands.openWorkspaceFolder(), }, publish(controller: TaskEntryController): void { if (current === controller) return; @@ -313,8 +318,13 @@ export function TaskEntryRoot({ children }: TaskEntryRootProps) { const owner = ownership.owner as ReturnType<typeof createTaskEntryOwner>; const toastApi = useToast(); const reportError = useCallback( - ({ title, description, profileId }: TaskEntryError) => { - toastApi.error(title, description, undefined, { profileId }); + ({ title, description, profileId, sessionId }: TaskEntryError) => { + toastApi.error( + title, + description, + undefined, + sessionId ? { sessionId } : profileId !== undefined ? { profileId } : undefined, + ); }, [toastApi], ); diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index d5e1602521..bbecc4bc4d 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -2373,6 +2373,21 @@ export function getShellCopy(locale: UiLocale): ShellCopy { return SHELL_COPY_BY_LOCALE[locale]; } +/** + * An `app:openPath` failure reason in this locale. Desktop's open-path guard + * reports a closed set; any other value reads as `unknown`, so raw text never + * reaches the UI. + */ +export function openPathFailureCopy(reason: string, locale: UiLocale): string { + const copy = getShellCopy(locale).projectActions.openPathFailures; + return reason in copy ? copy[reason as keyof typeof copy] : copy.unknown; +} + +/** The folder an open-path action names, for titles such as "无法打开工作区目录". */ +export function openPathActionLabel(key: 'workspace' | 'memory' | 'project', locale: UiLocale): string { + return getShellCopy(locale).projectActions.openPathLabels[key]; +} + export function localizedShellErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { if (error instanceof AttachmentIngestBlockedError) return getShellCopy(locale).sessionSettingsActions.attachmentIngestBlocked[error.code]; diff --git a/apps/desktop/src/renderer/open-path.ts b/apps/desktop/src/renderer/open-path.ts index 890fbbcf73..2635b60bcb 100644 --- a/apps/desktop/src/renderer/open-path.ts +++ b/apps/desktop/src/renderer/open-path.ts @@ -21,29 +21,11 @@ * Renderer-side helpers for the structured `app:openPath` IPC contract. * * Backend (see `apps/desktop/src/main/open-path-guard.ts`) returns either - * `{ ok: true; opened: string }` or `{ ok: false; reason: OpenPathFailureReason }`. - * The reason is a closed enum — surfaces should not interpolate the raw value - * into UI; use {@link openPathFailureCopy} for human-facing strings. + * `{ ok: true; opened: string }` or `{ ok: false; reason }`, where the reason + * is a closed enum — surfaces should not interpolate the raw value into UI; + * use `openPathFailureCopy` for human-facing strings. Both helpers live in the + * shell catalog beside the copy they read, so feature code reaches the same + * implementation. */ -import type { UiLocale } from '@maka/core/ui-locale'; -import { getShellCopy } from './locales/shell-copy.js'; - -export type OpenPathKey = 'workspace' | 'memory' | 'project'; - -export type OpenPathFailureReason = 'unknown-key' | 'not-allowed' | 'missing' | 'not-a-directory' | 'open-failed'; - -/** Closed-form mapping from enum to renderer-localized copy. */ -export function openPathFailureCopy(reason: OpenPathFailureReason | string, locale: UiLocale): string { - const copy = getShellCopy(locale).projectActions.openPathFailures; - return reason in copy ? copy[reason as OpenPathFailureReason] : copy.unknown; -} - -/** - * Convenience that maps an `OpenPathKey` to the corresponding action label, - * used by toast titles so we can show "在 Finder 中打开工作区失败" instead of - * a generic "打开失败". - */ -export function openPathActionLabel(key: OpenPathKey, locale: UiLocale): string { - return getShellCopy(locale).projectActions.openPathLabels[key]; -} +export { openPathActionLabel, openPathFailureCopy } from './locales/shell-copy.js'; diff --git a/apps/desktop/src/renderer/platform/desktop/create-diagnostics-services.ts b/apps/desktop/src/renderer/platform/desktop/create-diagnostics-services.ts index cff6408da7..62e0492a7f 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-diagnostics-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-diagnostics-services.ts @@ -34,6 +34,15 @@ export function createDesktopDiagnosticsServices( ...(report.diagnosticDetails ? { details: report.diagnosticDetails } : {}), ...(report.diagnosticTarget ? { target: report.diagnosticTarget } : {}), }), + copyManualReport: (target) => bridge.diagnostics.copyReport({ + surface: 'manual', + ...(target ? { target } : {}), + }), + copyRendererCrashReport: (report) => bridge.diagnostics.copyReport({ + surface: 'renderer_crash', + title: report.title, + details: report.details, + }), takePreviousMainProcessInterruption: () => bridge.diagnostics.takePreviousMainProcessInterruption(), copyPreviousMainProcessInterruption: () => bridge.diagnostics.copyPreviousMainProcessInterruption(), }; diff --git a/apps/desktop/src/renderer/platform/desktop/create-overlays-services.ts b/apps/desktop/src/renderer/platform/desktop/create-overlays-services.ts index 0c687a95b0..3ef6c52cb3 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-overlays-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-overlays-services.ts @@ -20,7 +20,10 @@ import type { MakaBridge } from '../../../preload/bridge-contract.js'; import type { OverlaysServices } from '../../features/overlays/index.js'; -export type DesktopOverlaysBridge = Pick<MakaBridge, 'search'>; +export type DesktopOverlaysBridge = Pick< + MakaBridge, + 'connections' | 'memory' | 'search' | 'sessions' | 'settings' +>; /** The browser capabilities the overlays reach through this adapter. */ export interface DesktopOverlaysEnvironment { @@ -55,5 +58,14 @@ export function createDesktopOverlaysServices( } }, }, + // The palette rows name connections by slug, so these keep the bridge's + // slug form rather than Connection Settings' identity form. + palette: { + testConnection: (slug, host) => bridge.connections.test(slug, undefined, host), + setDefaultConnection: (slug, host) => bridge.connections.setDefault(slug, host), + testNetworkProxy: (host) => bridge.settings.testNetworkProxy(undefined, host), + openLocalMemoryFile: (host) => bridge.memory.openFile(host), + saveConversationToFile: (input) => bridge.sessions.saveConversationToFile(input), + }, }; } diff --git a/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts b/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts index f8d7d40f68..be5e60210e 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts @@ -19,8 +19,12 @@ import type { MakaBridge } from '../../../preload/bridge-contract.js'; import type { TaskEntryServices } from '../../features/task-entry'; +import { + defaultRuntimeHostDiagnosticTarget, + runOnDefaultRuntimeHost, +} from './default-runtime-host-operation.js'; -export type DesktopTaskEntryBridge = Pick<MakaBridge, 'newTasks' | 'projects' | 'sessions'>; +export type DesktopTaskEntryBridge = Pick<MakaBridge, 'app' | 'newTasks' | 'projects' | 'sessions'>; /** The only Desktop-to-Task Entry adapter. */ export function createDesktopTaskEntryServices( @@ -47,5 +51,42 @@ export function createDesktopTaskEntryServices( : { ok: false as const, reason: result.code }; }, }, + folders: { + // A task's folder opens through the task; anything else through the + // default Runtime Host, which also names the profile a failure reports. + async openProjectFolder(sessionId) { + try { + const { value: result, diagnosticTarget } = sessionId + ? { + value: await bridge.app.openPath('project', sessionId), + diagnosticTarget: { sessionId }, + } + : await runOnDefaultRuntimeHost((host) => + bridge.app.openPath('project', undefined, host), + ); + return result.ok + ? { kind: 'opened' as const } + : { kind: 'refused' as const, reason: result.reason, diagnosticTarget }; + } catch (error) { + const diagnosticTarget = sessionId + ? { sessionId } + : defaultRuntimeHostDiagnosticTarget(error); + return { kind: 'failed' as const, error, ...(diagnosticTarget ? { diagnosticTarget } : {}) }; + } + }, + async openWorkspaceFolder() { + try { + const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => + bridge.app.openPath('workspace', undefined, host), + ); + return result.ok + ? { kind: 'opened' as const } + : { kind: 'refused' as const, reason: result.reason, diagnosticTarget }; + } catch (error) { + const diagnosticTarget = defaultRuntimeHostDiagnosticTarget(error); + return { kind: 'failed' as const, error, ...(diagnosticTarget ? { diagnosticTarget } : {}) }; + } + }, + }, }; } diff --git a/apps/desktop/src/renderer/session-workspace-errors.ts b/apps/desktop/src/renderer/session-workspace-errors.ts deleted file mode 100644 index 5ef75b145e..0000000000 --- a/apps/desktop/src/renderer/session-workspace-errors.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { UiLocale } from '@maka/core/ui-locale'; -import type { ToastDiagnosticTarget } from '@maka/ui'; -import { getShellCopy } from './locales/shell-copy.js'; - -export { isSessionWorkspaceUnavailableError } from './application/contracts/session-workspace-errors.js'; - -export function showSessionWorkspaceUnavailableToast( - toastApi: { - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: ToastDiagnosticTarget, - ): void; - }, - locale: UiLocale, - diagnosticTarget?: ToastDiagnosticTarget, -): void { - const copy = getShellCopy(locale).errors; - toastApi.error( - copy.workspaceUnavailableTitle, - copy.workspaceUnavailableDescription, - undefined, - diagnosticTarget, - ); -} diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx index c3fdea6875..4c2e3832e3 100644 --- a/apps/desktop/src/renderer/settings/about-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx @@ -32,6 +32,10 @@ import { AppUpdateAboutProjectionConsumer, type AppUpdateAboutProjection, } from '../features/app-update/index.js'; +import { + ManualDiagnosticReportConsumer, + type CopyManualDiagnosticReport, +} from '../features/diagnostics/index.js'; import { SettingsPage, SettingsRow, SettingsSection } from './settings-section.js'; import { settingsActionErrorMessage } from './settings-error-copy.js'; import { SettingsSkeletonStack } from './settings-skeleton.js'; @@ -162,11 +166,11 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { }; }, [copy.loadFailed, locale, toast]); - async function copyDiagnostics() { + async function copyDiagnostics(copyManualReport: CopyManualDiagnosticReport) { if (!diagnosticCopyGuard.begin('copy')) return; setCopyingDiagnostics(true); try { - await window.maka.diagnostics.copyReport({ surface: 'manual' }); + await copyManualReport(); if (aboutPageMountedRef.current) toast.success(copy.copied, copy.pasteHint); } catch { if (aboutPageMountedRef.current) { @@ -255,13 +259,17 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { label={copy.copyDiagnostics} description={copy.copyHelp} end={( - <Button - variant="secondary" - isLoading={copyingDiagnostics} - onClick={() => void copyDiagnostics()} - aria-label={copy.copyDiagnostics} - label={copy.copyAction} - /> + <ManualDiagnosticReportConsumer> + {(copyManualReport) => ( + <Button + variant="secondary" + isLoading={copyingDiagnostics} + onClick={() => void copyDiagnostics(copyManualReport)} + aria-label={copy.copyDiagnostics} + label={copy.copyAction} + /> + )} + </ManualDiagnosticReportConsumer> )} /> <SettingsRow diff --git a/apps/desktop/src/renderer/use-project-context.ts b/apps/desktop/src/renderer/use-project-context.ts index 8e5a7fbfa0..2a63115f56 100644 --- a/apps/desktop/src/renderer/use-project-context.ts +++ b/apps/desktop/src/renderer/use-project-context.ts @@ -18,20 +18,12 @@ */ import { useEffect, useRef, useState } from 'react'; -import { useStableActions } from './use-stable-actions.js'; import type { ProjectRecord } from '@maka/core/project'; -import type { UiLocale } from '@maka/core/ui-locale'; import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; import type { DesktopProjectCapabilities, DesktopRuntimeHostRef, } from '../preload/bridge-contract.js'; -import { - createAppShellProjectActions, - type AppShellProjectActions, - type RendererAppInfo, - type SessionProjectInfoState, -} from './app-shell-project-actions'; import { runIfDefaultRuntimeHostCurrent, runOnDefaultRuntimeHost, @@ -39,10 +31,15 @@ import { type RefBox<T> = { current: T }; -type ToastApi = { - success(title: string, description?: string): void; - error(title: string, description?: string): void; -}; +interface RendererAppInfo { + projectId?: string | null; + projectPath: string; + projectGit: { isGitRepo: boolean; branch?: string }; +} + +interface SessionProjectInfoState extends RendererAppInfo { + sessionId: string; +} const NO_PROJECT_CAPABILITIES: DesktopProjectCapabilities = { chooseClientDirectory: false, @@ -53,27 +50,19 @@ const NO_PROJECT_CAPABILITIES: DesktopProjectCapabilities = { }; /** - * Owns the workspace / project-picker cluster: the new-task project, active - * session project projection, the persistent project catalog, and the - * project-picker pending state / dedup refs. Seeds appInfo from the persisted composer defaults so the home view - * is populated before the async `app:info` - * round-trip completes on mount. - * - * The picker refs are returned so AppShell can hand them to the bootstrap - * unmount cleanup (which cancels an in-flight pick), and the action helpers - * (createAppShellProjectActions) are created here so their setters never - * have to be threaded back out through AppShell. + * Owns the workspace projection AppShell reads: the default Host's project, + * the active session's project, and the persistent project catalog behind + * them. Project mutations and the open-folder commands belong to Task Entry. */ export function useAppShellProjectContext(options: { - uiLocale: UiLocale; rendererMountedRef: RefBox<boolean>; sessionId?: string; sessionCwd?: string; sessionProjectId?: string | null; sessionProfileKind?: RuntimeHostProfileKind; - onProjectSelected(ownerSessionId?: string): void; - toastApi: ToastApi; -}): AppShellProjectActions & { +}): { + /** Re-reads the default Host's project context; resolves to its projects. */ + refreshProjects(): Promise<ProjectRecord[]>; projectInfo: RendererAppInfo | null; projects: ProjectRecord[]; projectCapabilities: DesktopProjectCapabilities; @@ -82,19 +71,13 @@ export function useAppShellProjectContext(options: { selectedProjectId: string | null | undefined; currentProjectId: string | null | undefined; currentProject: ProjectRecord | undefined; - projectPickerPending: boolean; - projectPickerPendingRef: RefBox<boolean>; - projectPickerRequestRef: RefBox<number>; } { const { - uiLocale, rendererMountedRef, sessionId, sessionCwd, sessionProjectId, sessionProfileKind, - onProjectSelected, - toastApi, } = options; const [appInfo, setAppInfo] = useState<RendererAppInfo | null>(null); const [sessionProjectInfo, setSessionProjectInfo] = useState<SessionProjectInfoState | null>(null); @@ -108,9 +91,6 @@ export function useAppShellProjectContext(options: { capabilities: DesktopProjectCapabilities; } | null>(null); const [selectedProjectId, setSelectedProjectId] = useState<string | null | undefined>(undefined); - const [projectPickerPending, setProjectPickerPending] = useState(false); - const projectPickerPendingRef = useRef(false); - const projectPickerRequestRef = useRef(0); const defaultRefreshGenerationRef = useRef(0); const refreshDefaultProjectState = async ( @@ -252,26 +232,13 @@ export function useAppShellProjectContext(options: { (project) => project.id === currentProjectId || project.aliases?.includes(currentProjectId ?? ''), ); - // Stable identities, because the rail's Project rows are built from these: - // rebuilt per render they put the whole list back on every AppShell commit - // (#4109). Same facade the shell's other action factories already use - // (#1043); this one was the last bare call site. - const actions = useStableActions(createAppShellProjectActions, { - uiLocale, - projectPickerPendingRef, - projectPickerRequestRef, - rendererMountedRef, - setProjectPickerPending, - refreshDefaultProjectState, - selectedProjectId, - projects, - projectCapabilities, - sessionId, - onProjectSelected, - toastApi, - }); + // Read through an effect event by the bootstrap subscriptions, so a fresh + // identity per render costs nothing. + const refreshProjects = async (): Promise<ProjectRecord[]> => + (await runOnDefaultRuntimeHost((host) => refreshDefaultProjectState(host))).value; return { + refreshProjects, projectInfo, projects, projectCapabilities, @@ -280,9 +247,5 @@ export function useAppShellProjectContext(options: { selectedProjectId, currentProjectId, currentProject, - projectPickerPending, - projectPickerPendingRef, - projectPickerRequestRef, - ...actions, }; } diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index a936a81aff..48a9333386 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1721,7 +1721,7 @@ export const NewChatComposerEmptyLocalHost: Story = { }; // Real path: 新任务 → 切换项目 → 项目 picker 处于 pending(切换中)。 -// Production passes `pending: projectPickerPending` while a project switch is +// Task Entry marks the Workspace Picker `pending` while a project mutation is // in flight; the trigger locks with a spinner and every menu row disables, // matching the model switcher's mid-switch treatment. export const NewChatComposerProjectPending: Story = { diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 6bcbe4eec0..20d616a95e 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -55,6 +55,10 @@ import { AppUpdateServicesProvider, type AppUpdateServices, } from '../../src/renderer/features/app-update/index.js'; +import { + DiagnosticsServicesProvider, + createFakeDiagnosticsServices, +} from '../../src/renderer/features/diagnostics/testing.js'; import type { SessionSummary } from '@maka/core/session'; import { revisionFamilySessionIds } from '@maka/core/session-revisions'; import type { @@ -1098,6 +1102,9 @@ const settingsAppUpdateServices: AppUpdateServices = { }, }; +/** About's 复制诊断信息 resolves without a Desktop bridge to copy from. */ +const settingsDiagnosticsServices = createFakeDiagnosticsServices(); + /** * A PACKAGED install, which the shared fixture cannot be: it is a dev checkout, * and `buildMode` short-circuits the About lead before `updateChannel` is ever @@ -1989,11 +1996,13 @@ function fieldChrome(element: HTMLElement) { function SettingsStory(props: SettingsStoryProps) { return ( <ToastProvider> - <AppUpdateServicesProvider services={settingsAppUpdateServices}> - <AppUpdateProvider> - <SettingsStoryFrame {...props} /> - </AppUpdateProvider> - </AppUpdateServicesProvider> + <DiagnosticsServicesProvider services={settingsDiagnosticsServices}> + <AppUpdateServicesProvider services={settingsAppUpdateServices}> + <AppUpdateProvider> + <SettingsStoryFrame {...props} /> + </AppUpdateProvider> + </AppUpdateServicesProvider> + </DiagnosticsServicesProvider> </ToastProvider> ); } diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index f9ce4b3a30..549bbd73d6 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 327 files — blocker 0, reimplementation 0, polish 4, aligned 323. +**Totals:** 328 files — blocker 0, reimplementation 0, polish 4, aligned 324. ## Exclusions (explicit) @@ -80,6 +80,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/diagnostics/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/diagnostics/ui/diagnostic-report-toast-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/diagnostics/ui/previous-main-process-interruption-notice.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/diagnostics/ui/report-consumers.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/external-agent-settings/page.tsx` | other | Banner, Button, HStack, Link, StatusDot, Text | aligned — uses Astryx (Banner, Button, HStack, Link, StatusDot, Text) | aligned | | `apps/desktop/src/renderer/features/goals/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text, TextArea, TextInput, VStack | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index a16651af14..5c15ec9d17 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -50,6 +50,7 @@ apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx apps/desktop/src/renderer/features/diagnostics/services-context.tsx apps/desktop/src/renderer/features/diagnostics/ui/diagnostic-report-toast-provider.tsx apps/desktop/src/renderer/features/diagnostics/ui/previous-main-process-interruption-notice.tsx +apps/desktop/src/renderer/features/diagnostics/ui/report-consumers.tsx apps/desktop/src/renderer/features/external-agent-settings/page.tsx apps/desktop/src/renderer/features/goals/services-context.tsx apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx