diff --git a/apps/desktop/src/main/__tests__/about-settings-page.test.ts b/apps/desktop/src/main/__tests__/about-settings-page.test.ts new file mode 100644 index 0000000000..2ebd3bd6fa --- /dev/null +++ b/apps/desktop/src/main/__tests__/about-settings-page.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { AboutSettingsPage } from '../../renderer/settings/about-settings-page.js'; + +test('keeps manual diagnostics available while About metadata is pending', () => { + 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 }), + ); + + assert.match(markup, />Copy diagnostics]*aria-busy="true"/); +}); diff --git a/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts new file mode 100644 index 0000000000..7692eb3e94 --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveManualDiagnosticTarget } from '../../renderer/app-shell-command-actions.js'; + +test('targets manual diagnostics to the current task or new-task Host profile', () => { + assert.deepEqual( + resolveManualDiagnosticTarget( + { navSection: 'sessions', sessionId: '["remote-host","session-1"]' }, + 'new-task-profile', + ), + { kind: 'session', sessionId: '["remote-host","session-1"]' }, + ); + assert.deepEqual( + resolveManualDiagnosticTarget( + { navSection: 'sessions', sessionId: undefined }, + 'new-task-profile', + ), + { kind: 'profile', profileId: 'new-task-profile' }, + ); + assert.equal( + resolveManualDiagnosticTarget( + { navSection: 'extensions', sessionId: undefined }, + 'new-task-profile', + ), + undefined, + ); + assert.deepEqual( + resolveManualDiagnosticTarget( + { navSection: 'sessions', sessionId: '["hidden-host","hidden-session"]' }, + 'hidden-new-task-profile', + true, + 'settings-profile', + ), + { kind: 'profile', profileId: 'settings-profile' }, + ); + assert.equal( + resolveManualDiagnosticTarget( + { navSection: 'sessions', sessionId: '["hidden-host","hidden-session"]' }, + 'hidden-new-task-profile', + true, + ), + undefined, + ); +}); 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 index dfb1f3c902..56a250091d 100644 --- a/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts @@ -61,6 +61,59 @@ test('remote Project capabilities do not dispatch Client-local actions', async ( } }); +test('Project errors preserve the Host authority of the failed operation', async () => { + const actionsModule = await importProjectActions(); + const previousWindow = globalThis.window; + const diagnosticTargets: unknown[] = []; + globalThis.window = { + maka: { + app: { + openPath: async () => { + throw new Error('unavailable'); + }, + }, + }, + } as unknown as Window & typeof globalThis; + + try { + const actions = actionsModule.createAppShellProjectActions({ + uiLocale: 'en', + projectPickerPendingRef: { current: false }, + projectPickerRequestRef: { current: 0 }, + rendererMountedRef: { current: true }, + setProjectPickerPending: () => {}, + refreshDefaultProjectState: async () => [], + selectedProjectId: null, + projects: [], + projectCapabilities: { + chooseClientDirectory: false, + chooseHostDirectory: false, + selectNoProject: false, + setLocalDefault: false, + viewClientPath: false, + }, + sessionId: 'session-key', + onProjectSelected: () => {}, + toastApi: { + success: () => {}, + error: (_title, _description, _details, target) => { + diagnosticTargets.push(target); + }, + }, + }); + + await actions.openWorkspaceFolder(); + await actions.openProjectFolder(); + + assert.deepEqual(diagnosticTargets, [ + undefined, + { sessionId: 'session-key' }, + ]); + } finally { + globalThis.window = previousWindow; + } +}); + async function importProjectActions(): Promise { const outdir = await mkdtemp(resolve(REPO_ROOT, 'apps/desktop/dist/main/__tests__/project-actions-')); const outfile = resolve(outdir, 'app-shell-project-actions.mjs'); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts index 352e01098a..6e5e42e15d 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts @@ -46,6 +46,7 @@ function installWindow( harness: SweepHarness, options: { rejectIds?: readonly string[]; + rejectWithUndefinedIds?: readonly string[]; surviving?: readonly SessionSummary[]; /** Runs after each accepted removal, to model what another client did meanwhile. */ onRemove?: (sessionId: string) => void; @@ -67,6 +68,9 @@ function installWindow( sessions: { remove: async (id: string, removeOptions?: { requireArchived?: boolean }) => { harness.removeOptions.push([id, removeOptions?.requireArchived === true]); + if (options.rejectWithUndefinedIds?.includes(id)) { + return Promise.reject(undefined); + } if (options.rejectIds?.includes(id)) throw new Error(`busy:${id}`); const target = options.catalog?.find((session) => session.id === id); if (removeOptions?.requireArchived && target && !target.isArchived) return 'restored'; @@ -151,7 +155,7 @@ describe('purgeSessions', () => { remaining: [], restored: [], verified: true, - firstError: undefined, + firstFailure: undefined, }); // Every delete in a sweep carries the archived premise the confirm named. assert.deepEqual(h.removeOptions, [ @@ -226,7 +230,7 @@ describe('purgeSessions', () => { const outcome = await actions.purgeSessions(['first', 'rescued']).finally(restore); assert.deepEqual(outcome.restored, ['rescued']); - assert.equal(outcome.firstError, undefined); + assert.equal(outcome.firstFailure, undefined); // A task that is still there keeps its renderer state, including being the // open one. assert.deepEqual(h.cleared, ['first']); @@ -296,7 +300,26 @@ describe('purgeSessions', () => { assert.equal(h.listCalls, 1); assert.deepEqual(outcome.remaining, ['survivor']); assert.equal(outcome.removed, 1); - assert.equal((outcome.firstError as Error).message, 'busy:committed'); + assert.ok(outcome.firstFailure); + assert.equal((outcome.firstFailure.error as Error).message, 'busy:committed'); + assert.equal(outcome.firstFailure.sessionId, 'committed'); + }); + + it('retains the first failing Session even when the rejection value is undefined', async () => { + const h = harness(); + const sessions = [summary('first'), summary('second')]; + const restore = installWindow(h, { + rejectWithUndefinedIds: ['first'], + rejectIds: ['second'], + surviving: sessions, + }); + const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined } }); + + const outcome = await actions.purgeSessions(['first', 'second']).finally(restore); + + assert.ok(outcome.firstFailure); + assert.equal(outcome.firstFailure.sessionId, 'first'); + assert.equal(outcome.firstFailure.error, undefined); }); it('claims nothing when the catalog cannot be read back', async () => { diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index 8398140d0b..20f9a9dfc2 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -50,6 +50,7 @@ function createHarness(options: { const permissionCalls: string[] = []; const thinkingCalls: string[] = []; const errors: string[] = []; + const errorTargets: Array<{ sessionId: string } | undefined> = []; const successes: Array<{ title: string; description?: string }> = []; const newTaskPermissionModes: string[] = []; const modelResult = deferred(); @@ -99,7 +100,10 @@ function createHarness(options: { }, toastApi: { success: (title, description) => successes.push({ title, description }), - error: (title) => errors.push(title), + error: (title, _description, _details, target) => { + errors.push(title); + errorTargets.push(target); + }, confirm: options.confirm ?? (async () => true), }, }); @@ -108,6 +112,7 @@ function createHarness(options: { actions, activeIdRef, errors, + errorTargets, modelCalls, modelResult, newTaskPermissionModes, @@ -275,6 +280,7 @@ describe('AppShell session settings actions', () => { assert.equal(harness.pending.has('session-a'), false); assert.equal(harness.pendingBySession['session-a'], undefined); assert.equal(harness.errors.length, 1); + assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); const modelChange = harness.actions.setSessionModel({ llmConnectionSlug: 'e2e', diff --git a/apps/desktop/src/main/__tests__/app-shell-toast-diagnostics.test.ts b/apps/desktop/src/main/__tests__/app-shell-toast-diagnostics.test.ts new file mode 100644 index 0000000000..358fda694d --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-shell-toast-diagnostics.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { diagnosticInputForErrorToast } from '../../renderer/app-shell-toast-diagnostics.js'; + +test('preserves stable Host targets independently from optional execution evidence', () => { + assert.deepEqual( + diagnosticInputForErrorToast({ + title: 'Client failure', + }), + { + surface: 'toast', + title: 'Client failure', + }, + ); + assert.deepEqual( + diagnosticInputForErrorToast({ + title: 'Task operation failed', + diagnosticTarget: { sessionId: '["remote-host","session-1"]' }, + }), + { + surface: 'toast', + title: 'Task operation failed', + target: { kind: 'session', sessionId: '["remote-host","session-1"]' }, + }, + ); + assert.deepEqual( + diagnosticInputForErrorToast({ + title: 'New task failed', + diagnosticTarget: { profileId: 'remote-profile' }, + }), + { + surface: 'toast', + title: 'New task failed', + target: { kind: 'profile', profileId: 'remote-profile' }, + }, + ); + assert.deepEqual( + diagnosticInputForErrorToast({ + title: 'Turn failed', + diagnosticTarget: { + sessionId: '["remote-host","session-1"]', + turnId: 'turn-1', + eventId: 'event-1', + }, + }), + { + surface: 'toast', + title: 'Turn failed', + execution: { + sessionId: '["remote-host","session-1"]', + turnId: 'turn-1', + eventId: 'event-1', + }, + }, + ); +}); diff --git a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts index 97b5435c24..027529c536 100644 --- a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts +++ b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts @@ -6,8 +6,8 @@ import { type DesktopDiagnosticsIpcDeps, } from '../desktop-diagnostics-ipc-main.js'; import { - formatDesktopErrorDiagnosticReport, - parseDesktopErrorDiagnosticInput, + formatDesktopDiagnosticReport, + parseDesktopDiagnosticInput, } from '../main-process-diagnostics.js'; const environment = { @@ -48,10 +48,11 @@ const runtimeHostDiagnostics = { }; test('formats one redacted Desktop and Runtime Host diagnostic report', () => { - const report = formatDesktopErrorDiagnosticReport( + const report = formatDesktopDiagnosticReport( { surface: 'toast', title: 'Connection failed', + hostTarget: 'default', description: 'api_key=sk-secretvalue123', rendererLocale: 'en-US', }, @@ -71,21 +72,102 @@ test('formats one redacted Desktop and Runtime Host diagnostic report', () => { }); test('bounds renderer diagnostic text and rejects unknown fields', () => { - const input = parseDesktopErrorDiagnosticInput({ + const input = parseDesktopDiagnosticInput({ surface: 'toast', title: '🚀'.repeat(513), + hostTarget: 'default', description: 'x'.repeat(30 * 1024), }); + assert.equal(input.surface, 'toast'); assert.ok(Buffer.byteLength(input.title) <= 512); assert.ok(Buffer.byteLength(input.description ?? '') <= 24 * 1024); assert.match(input.title, /$/); assert.throws( - () => parseDesktopErrorDiagnosticInput({ surface: 'toast', title: 'error', extra: true }), + () => parseDesktopDiagnosticInput({ + surface: 'toast', + title: 'error', + hostTarget: 'default', + extra: true, + }), /Invalid Desktop diagnostic input/, ); }); +test('accepts only a Runtime Host target and renderer context for capture', () => { + assert.deepEqual( + parseDesktopDiagnosticInput({ + surface: 'manual', + hostTarget: 'default', + rendererLocale: 'en-US', + }), + { + surface: 'manual', + hostTarget: 'default', + rendererLocale: 'en-US', + }, + ); + assert.deepEqual( + parseDesktopDiagnosticInput({ + surface: 'manual', + hostTarget: 'task', + }), + { + surface: 'manual', + hostTarget: 'task', + }, + ); + assert.throws( + () => parseDesktopDiagnosticInput({ + surface: 'manual', + hostTarget: 'none', + }), + /Manual Desktop diagnostics require Runtime Host authority/, + ); + assert.deepEqual( + parseDesktopDiagnosticInput({ + surface: 'toast', + title: 'Renderer failed', + hostTarget: 'none', + }), + { + surface: 'toast', + title: 'Renderer failed', + hostTarget: 'none', + }, + ); + assert.throws( + () => parseDesktopDiagnosticInput({ surface: 'manual', title: 'Not an error' }), + /Invalid Desktop diagnostic input/, + ); + assert.throws( + () => parseDesktopDiagnosticInput({ + surface: 'manual', + hostTarget: { kind: 'target', hostId: '' }, + }), + /Invalid Desktop diagnostic Runtime Host target/, + ); +}); + +test('formats a manual capture without inventing an error', () => { + const report = formatDesktopDiagnosticReport( + { + surface: 'manual', + hostTarget: 'default', + rendererLocale: 'en-US', + }, + environment, + ['main log'], + { ok: true, value: runtimeHostDiagnostics }, + undefined, + new Date('2026-08-09T00:00:00Z'), + ); + + assert.match(report, /Capture\nSurface: manual/); + assert.doesNotMatch(report, /\nError\n|\nTitle:/); + assert.match(report, /Recent Runtime Host logs \(1\)\nhost log/); +}); + test('copies Desktop diagnostics while Runtime Host is unavailable', async () => { type IpcHandler = Parameters['handle']>[1]; const handlers = new Map(); @@ -98,6 +180,7 @@ test('copies Desktop diagnostics while Runtime Host is unavailable', async () => }, environment: () => environment, mainLogs: () => ['main remained available'], + resolveActiveRuntimeHost: () => undefined, resolveRuntimeHost: () => ({ getDiagnostics: async () => { throw new Error('Runtime Host disconnected'); @@ -109,15 +192,14 @@ test('copies Desktop diagnostics while Runtime Host is unavailable', async () => }, }); - const handler = handlers.get('diagnostics:copyErrorReport'); + const handler = handlers.get('diagnostics:copyReport'); assert.ok(handler); - const result = await handler( + await handler( {} as never, { hostId: 'test-host', targetEpoch: 'test-target' }, - { surface: 'toast', title: 'Host failed' }, + { surface: 'toast', title: 'Host failed', hostTarget: 'task' }, ); - assert.deepEqual(result, { ok: true }); assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/); assert.match(clipboard, /Diagnostics unavailable: Runtime Host disconnected/); }); @@ -134,35 +216,116 @@ test('copies Desktop diagnostics while the scoped Host is reconnecting', async ( }, environment: () => environment, mainLogs: () => ['main remained available'], + resolveActiveRuntimeHost: () => undefined, resolveRuntimeHost: () => undefined, writeClipboard: (value) => { clipboard = value; }, }); - const handler = handlers.get('diagnostics:copyErrorReport'); + const handler = handlers.get('diagnostics:copyReport'); assert.ok(handler); - assert.deepEqual( - await handler( - {} as never, - { hostId: 'test-host', targetEpoch: 'test-target' }, - { surface: 'toast', title: 'Host reconnecting' }, - ), - { ok: true }, + await handler( + {} as never, + { hostId: 'test-host', targetEpoch: 'test-target' }, + { surface: 'toast', title: 'Host reconnecting', hostTarget: 'task' }, ); assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/); assert.match(clipboard, /Diagnostics unavailable: Runtime Host is reconnecting/); }); +test('keeps renderer-only error diagnostics Desktop-only', async () => { + type IpcHandler = Parameters['handle']>[1]; + const handlers = new Map(); + let clipboard = ''; + registerDesktopDiagnosticsIpc({ + ipcMain: { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + environment: () => environment, + mainLogs: () => ['main remained available'], + resolveActiveRuntimeHost: () => { + throw new Error('Desktop-only diagnostics must not resolve the default Host'); + }, + resolveRuntimeHost: () => { + throw new Error('Desktop-only diagnostics must not resolve a task Host'); + }, + writeClipboard: (value) => { + clipboard = value; + }, + }); + + const handler = handlers.get('diagnostics:copyReport'); + assert.ok(handler); + await handler( + {} as never, + undefined, + { surface: 'renderer_crash', title: 'Renderer failed', hostTarget: 'none' }, + ); + + assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/); + assert.match( + clipboard, + /Diagnostics unavailable: No Runtime Host authority was associated with this error/, + ); +}); + +test('copies task error diagnostics without Host evidence when its scope is unavailable', async () => { + type IpcHandler = Parameters['handle']>[1]; + const handlers = new Map(); + let clipboard = ''; + registerDesktopDiagnosticsIpc({ + ipcMain: { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + environment: () => environment, + mainLogs: () => ['main remained available'], + resolveActiveRuntimeHost: () => { + throw new Error('Task capture must not fall back to the default Host'); + }, + resolveRuntimeHost: () => { + throw new Error('An unavailable task must not resolve a Host'); + }, + writeClipboard: (value) => { + clipboard = value; + }, + }); + + const handler = handlers.get('diagnostics:copyReport'); + assert.ok(handler); + await handler( + {} as never, + undefined, + { + surface: 'toast', + title: 'Task failed', + hostTarget: 'task', + execution: { sessionId: 'session-1', turnId: 'turn-1', eventId: 'event-1' }, + }, + ); + + assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/); + assert.match( + clipboard, + /Diagnostics unavailable: Runtime Host for this task is unavailable/, + ); + assert.match(clipboard, /Execution evidence unavailable: not queried/); +}); + test('copies bounded evidence for the exact failed Turn', async () => { type IpcHandler = Parameters['handle']>[1]; const handlers = new Map(); let clipboard = ''; const runtime: ReturnType = { getDiagnostics: async () => runtimeHostDiagnostics, - getTurnTrace: async (sessionId: string, turnId: string) => { + getTurnTrace: async (sessionId: string, turnId: string, timeoutMs: number) => { assert.equal(sessionId, 'session-1'); assert.equal(turnId, 'turn-1'); + assert.equal(timeoutMs, 2_000); return { turnId, runId: 'run-1', @@ -231,25 +394,26 @@ test('copies bounded evidence for the exact failed Turn', async () => { }, environment: () => environment, mainLogs: () => [], + resolveActiveRuntimeHost: () => undefined, resolveRuntimeHost: () => runtime, writeClipboard: (value) => { clipboard = value; }, }); - const handler = handlers.get('diagnostics:copyErrorReport'); + const handler = handlers.get('diagnostics:copyReport'); assert.ok(handler); - const result = await handler( + await handler( {} as never, { hostId: 'test-host', targetEpoch: 'test-target' }, { surface: 'toast', title: 'Conversation error', + hostTarget: 'task', execution: { sessionId: 'session-1', turnId: 'turn-1', eventId: 'event-1' }, }, ); - assert.deepEqual(result, { ok: true }); assert.match(clipboard, /Runtime Host execution[\s\S]*Run: run-1/); assert.match(clipboard, /Failure message: No endpoints accepted the request/); }); @@ -271,6 +435,7 @@ test('keeps every diagnostic read bound to the scoped Host during a switch', asy }, environment: () => environment, mainLogs: () => [], + resolveActiveRuntimeHost: () => undefined, resolveRuntimeHost: (scope) => { assert.equal(scope.hostId, activeHostId); assert.equal(scope.targetEpoch, 'target-a'); @@ -289,7 +454,7 @@ test('keeps every diagnostic read bound to the scoped Host during a switch', asy writeClipboard() {}, }); - const handler = handlers.get('diagnostics:copyErrorReport'); + const handler = handlers.get('diagnostics:copyReport'); assert.ok(handler); const copying = handler( {} as never, @@ -297,6 +462,7 @@ test('keeps every diagnostic read bound to the scoped Host during a switch', asy { surface: 'toast', title: 'Conversation error', + hostTarget: 'task', execution: { sessionId: 'shared-session', turnId: 'shared-turn', @@ -307,6 +473,241 @@ test('keeps every diagnostic read bound to the scoped Host during a switch', asy activeHostId = 'host-b'; releaseDiagnostics(); - assert.deepEqual(await copying, { ok: true }); + await copying; assert.deepEqual(traceReads, ['host-a']); }); + +test('copies manual Desktop diagnostics without an active Runtime Host', async () => { + type IpcHandler = Parameters['handle']>[1]; + const handlers = new Map(); + let clipboard = ''; + registerDesktopDiagnosticsIpc({ + ipcMain: { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + environment: () => environment, + mainLogs: () => ['main remained available'], + resolveActiveRuntimeHost: () => undefined, + resolveRuntimeHost: () => { + throw new Error('Manual capture must not require a renderer-provided Host scope'); + }, + writeClipboard: (value) => { + clipboard = value; + }, + }); + + const handler = handlers.get('diagnostics:copyReport'); + assert.ok(handler); + await handler( + {} as never, + undefined, + { + surface: 'manual', + hostTarget: 'default', + rendererLocale: 'en-US', + }, + ); + assert.match(clipboard, /Capture\nSurface: manual/); + assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/); + assert.match(clipboard, /Diagnostics unavailable: Runtime Host is unavailable/); +}); + +test('copies diagnostics from the Runtime Host that owns the current task', async () => { + type IpcHandler = Parameters['handle']>[1]; + const handlers = new Map(); + let clipboard = ''; + registerDesktopDiagnosticsIpc({ + ipcMain: { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + environment: () => environment, + mainLogs: () => [], + resolveActiveRuntimeHost: () => { + throw new Error('Targeted capture must not fall back to the default Host'); + }, + resolveRuntimeHost: (scope) => { + assert.deepEqual(scope, { hostId: 'remote-host', targetEpoch: 'remote-target' }); + return { + getDiagnostics: async () => ({ + ...runtimeHostDiagnostics, + logs: ['remote task host log'], + }), + getTurnTrace: async () => undefined, + }; + }, + writeClipboard: (value) => { + clipboard = value; + }, + }); + + const handler = handlers.get('diagnostics:copyReport'); + assert.ok(handler); + await handler( + {} as never, + { hostId: 'remote-host', targetEpoch: 'remote-target' }, + { + surface: 'manual', + hostTarget: 'task', + }, + ); + assert.match(clipboard, /Recent Runtime Host logs \(1\)\nremote task host log/); +}); + +test('keeps targeted manual capture Desktop-only when the task Host is unavailable', async () => { + type IpcHandler = Parameters['handle']>[1]; + const handlers = new Map(); + let clipboard = ''; + registerDesktopDiagnosticsIpc({ + ipcMain: { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + environment: () => environment, + mainLogs: () => ['main remained available'], + resolveActiveRuntimeHost: () => { + throw new Error('Targeted capture must not fall back to the default Host'); + }, + resolveRuntimeHost: () => { + throw new Error('The task Host disappeared after scope resolution'); + }, + writeClipboard: (value) => { + clipboard = value; + }, + }); + + const handler = handlers.get('diagnostics:copyReport'); + assert.ok(handler); + await handler( + {} as never, + undefined, + { + surface: 'manual', + hostTarget: 'task', + }, + ); + assert.match( + clipboard, + /Diagnostics unavailable: Runtime Host for this task is unavailable/, + ); + await handler( + {} as never, + { hostId: 'remote-host', targetEpoch: 'stale-target' }, + { + surface: 'manual', + hostTarget: 'task', + }, + ); + assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/); + assert.match( + clipboard, + /Diagnostics unavailable: Runtime Host for this task is unavailable/, + ); +}); + +test('rejects a default diagnostic request that carries a task Host scope', async () => { + type IpcHandler = Parameters['handle']>[1]; + const handlers = new Map(); + registerDesktopDiagnosticsIpc({ + ipcMain: { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + environment: () => environment, + mainLogs: () => [], + resolveActiveRuntimeHost: () => undefined, + resolveRuntimeHost: () => { + throw new Error('Default capture must be rejected before Host resolution'); + }, + writeClipboard() { + throw new Error('Default capture must be rejected before clipboard output'); + }, + }); + + const handler = handlers.get('diagnostics:copyReport'); + assert.ok(handler); + await assert.rejects( + handler( + {} as never, + { hostId: 'default-host', targetEpoch: 'default-target' }, + { + surface: 'manual', + hostTarget: 'default', + }, + ), + /Default Desktop diagnostics must not carry a Host scope/, + ); +}); + +test('rejects Desktop-only diagnostics that carry a Host scope', async () => { + type IpcHandler = Parameters['handle']>[1]; + const handlers = new Map(); + registerDesktopDiagnosticsIpc({ + ipcMain: { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + environment: () => environment, + mainLogs: () => [], + resolveActiveRuntimeHost: () => { + throw new Error('Desktop-only capture must be rejected before Host resolution'); + }, + resolveRuntimeHost: () => { + throw new Error('Desktop-only capture must be rejected before Host resolution'); + }, + writeClipboard() { + throw new Error('Desktop-only capture must be rejected before clipboard output'); + }, + }); + + const handler = handlers.get('diagnostics:copyReport'); + assert.ok(handler); + await assert.rejects( + handler( + {} as never, + { hostId: 'unrelated-host', targetEpoch: 'unrelated-target' }, + { + surface: 'toast', + title: 'Renderer failed', + hostTarget: 'none', + }, + ), + /Desktop-only diagnostics must not carry a Host scope/, + ); +}); + +test('rejects the copy request when the main-process clipboard write fails', async () => { + type IpcHandler = Parameters['handle']>[1]; + const handlers = new Map(); + registerDesktopDiagnosticsIpc({ + ipcMain: { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + environment: () => environment, + mainLogs: () => [], + resolveActiveRuntimeHost: () => undefined, + resolveRuntimeHost: () => undefined, + writeClipboard() { + throw new Error('System clipboard unavailable'); + }, + }); + + const handler = handlers.get('diagnostics:copyReport'); + assert.ok(handler); + await assert.rejects( + handler( + {} as never, + undefined, + { surface: 'manual', hostTarget: 'default' }, + ), + /System clipboard unavailable/, + ); +}); diff --git a/apps/desktop/src/main/__tests__/use-shell-connections.test.ts b/apps/desktop/src/main/__tests__/use-shell-connections.test.ts index 700c21c656..ec612c322b 100644 --- a/apps/desktop/src/main/__tests__/use-shell-connections.test.ts +++ b/apps/desktop/src/main/__tests__/use-shell-connections.test.ts @@ -98,6 +98,54 @@ test('loads the default Host projection without waiting for the new-task catalog assert.equal(current.defaultConnection, 'default-connection'); }); +test('reports connection refresh failures against their owning Host', async () => { + const { root } = installReactRenderer(); + const sessionId = desktopSessionKey({ hostId: 'host-a', sessionId: 'session-a' }); + const diagnosticTargets: unknown[] = []; + (globalThis.window as unknown as { maka: unknown }).maka = { + connections: { + getSnapshot: async () => { + throw new Error('session unavailable'); + }, + }, + newTasks: { + getConnections: async () => { + throw new Error('profile unavailable'); + }, + }, + }; + + function Probe(props: { target: Parameters[0]['target'] }) { + useShellConnections({ + toastApi: { + error: (_title, _description, _details, target) => { + diagnosticTargets.push(target); + }, + }, + uiLocale: 'en', + target: props.target, + }); + return null; + } + + await act(async () => { + root.render(createElement(Probe, { target: { kind: 'session', sessionId } })); + }); + await act(async () => { + root.render(createElement(Probe, { + target: { + kind: 'new-task', + host: { profileId: 'profile-b', hostId: 'host-b' }, + }, + })); + }); + + assert.deepEqual(diagnosticTargets, [ + { sessionId }, + { profileId: 'profile-b' }, + ]); +}); + afterEach(() => { cleanupFakeDom(); delete (globalThis as { window?: unknown }).window; diff --git a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts index f0cbfdd655..7e38f1f0fa 100644 --- a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts +++ b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts @@ -3,45 +3,82 @@ import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { redactSecrets } from '@maka/core/redaction'; import type { TurnTrace } from '@maka/core/session-trace'; import type { HostDiagnosticsResult } from '@maka/runtime-host/protocol'; -import type { DesktopDiagnosticCopyResult } from '../preload/diagnostics-contract.js'; import { requireDesktopTargetScope, type DesktopTargetScope, } from '../shared/runtime-host-identity.js'; import { - formatDesktopErrorDiagnosticReport, - parseDesktopErrorDiagnosticInput, + formatDesktopDiagnosticReport, + parseDesktopDiagnosticInput, type DesktopDiagnosticEnvironment, type RuntimeHostDiagnosticRead, type RuntimeHostExecutionDiagnosticRead, } from './main-process-diagnostics.js'; +type RuntimeHostDiagnosticsClient = { + readonly getDiagnostics: () => Promise; + readonly getTurnTrace: ( + sessionId: string, + turnId: string, + timeoutMs: number, + ) => Promise; +}; + +const EXECUTION_DIAGNOSTIC_TIMEOUT_MS = 2_000; + export interface DesktopDiagnosticsIpcDeps { readonly ipcMain: Pick; readonly environment: () => DesktopDiagnosticEnvironment; readonly mainLogs: () => readonly string[]; - readonly resolveRuntimeHost: (scope: DesktopTargetScope) => - | { - readonly getDiagnostics: () => Promise; - readonly getTurnTrace: ( - sessionId: string, - turnId: string, - ) => Promise; - } - | undefined; + readonly resolveActiveRuntimeHost: () => RuntimeHostDiagnosticsClient | undefined; + readonly resolveRuntimeHost: (scope: DesktopTargetScope) => RuntimeHostDiagnosticsClient | undefined; readonly writeClipboard: (value: string) => void; } export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps): void { deps.ipcMain.handle( - 'diagnostics:copyErrorReport', - async (_event, scope: unknown, rawInput: unknown): Promise => { - const host = requireDesktopTargetScope(scope); - const runtime = deps.resolveRuntimeHost(host); - const input = parseDesktopErrorDiagnosticInput(rawInput); + 'diagnostics:copyReport', + async (_event, scope: unknown, rawInput: unknown): Promise => { + const input = parseDesktopDiagnosticInput(rawInput); + let runtime: RuntimeHostDiagnosticsClient | undefined; + if (input.hostTarget === 'none') { + if (scope !== undefined) { + throw new Error('Desktop-only diagnostics must not carry a Host scope'); + } + } else if (input.hostTarget === 'default') { + if (scope !== undefined) { + throw new Error('Default Desktop diagnostics must not carry a Host scope'); + } + try { + runtime = deps.resolveActiveRuntimeHost(); + } catch { + runtime = undefined; + } + } else if (scope !== undefined) { + const target = requireDesktopTargetScope(scope); + try { + runtime = deps.resolveRuntimeHost(target); + } catch { + // A task's Host may disappear between preload scope resolution and + // this handler. Manual capture still returns Desktop diagnostics. + runtime = undefined; + } + } let runtimeHost: RuntimeHostDiagnosticRead; if (!runtime) { - runtimeHost = { ok: false, error: 'Runtime Host is reconnecting' }; + let error: string; + if (input.hostTarget === 'none') { + error = 'No Runtime Host authority was associated with this error'; + } else if (input.hostTarget === 'default') { + error = input.surface === 'manual' + ? 'Runtime Host is unavailable' + : 'Runtime Host is reconnecting'; + } else { + error = input.surface !== 'manual' && scope !== undefined + ? 'Runtime Host is reconnecting' + : 'Runtime Host for this task is unavailable'; + } + runtimeHost = { ok: false, error }; } else { try { runtimeHost = { ok: true, value: await runtime.getDiagnostics() }; @@ -56,11 +93,13 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps): } } let runtimeExecution: RuntimeHostExecutionDiagnosticRead | undefined; - if (input.execution && runtime) { + const execution = input.surface === 'manual' ? undefined : input.execution; + if (execution && runtime) { try { const turn = await runtime.getTurnTrace( - input.execution.sessionId, - input.execution.turnId, + execution.sessionId, + execution.turnId, + EXECUTION_DIAGNOSTIC_TIMEOUT_MS, ); runtimeExecution = turn ? { ok: true, value: turn } @@ -75,19 +114,14 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps): }; } } - const report = formatDesktopErrorDiagnosticReport( + const report = formatDesktopDiagnosticReport( input, deps.environment(), deps.mainLogs(), runtimeHost, runtimeExecution, ); - try { - deps.writeClipboard(report); - return { ok: true }; - } catch { - return { ok: false, reason: 'clipboard_unavailable' }; - } + deps.writeClipboard(report); }, ); } diff --git a/apps/desktop/src/main/main-process-diagnostics.ts b/apps/desktop/src/main/main-process-diagnostics.ts index d11c4114a3..f3aedceade 100644 --- a/apps/desktop/src/main/main-process-diagnostics.ts +++ b/apps/desktop/src/main/main-process-diagnostics.ts @@ -4,7 +4,8 @@ import { redactSecrets } from '@maka/core/redaction'; import type { TurnTrace } from '@maka/core/session-trace'; import type { HostDiagnosticsResult } from '@maka/runtime-host/protocol'; import type { - DesktopErrorDiagnosticInput, + DesktopDiagnosticHostTarget, + DesktopDiagnosticWireInput, DesktopExecutionDiagnosticTarget, } from '../preload/diagnostics-contract.js'; @@ -51,19 +52,34 @@ export function installMainProcessLogCapture(buffer: DiagnosticLogBuffer = mainP installConsoleDiagnosticLogCapture(buffer); } -export function parseDesktopErrorDiagnosticInput(input: unknown): DesktopErrorDiagnosticInput { +export function parseDesktopDiagnosticInput(input: unknown): DesktopDiagnosticWireInput { if (!input || typeof input !== 'object' || Array.isArray(input)) { throw new TypeError('Invalid Desktop diagnostic input'); } const record = input as Record; - const allowedKeys = new Set([ + const sharedKeys = new Set([ 'surface', + 'hostTarget', + 'rendererUserAgent', + 'rendererLocale', + ]); + if (record.surface === 'manual') { + if (Object.keys(record).some((key) => !sharedKeys.has(key))) { + throw new TypeError('Invalid Desktop diagnostic input'); + } + return { + surface: 'manual', + hostTarget: parseManualDiagnosticHostTarget(record.hostTarget), + ...optionalBoundedString(record, 'rendererUserAgent', INPUT_LIMITS.rendererUserAgent), + ...optionalBoundedString(record, 'rendererLocale', INPUT_LIMITS.rendererLocale), + }; + } + const allowedKeys = new Set([ + ...sharedKeys, 'title', 'description', 'details', 'execution', - 'rendererUserAgent', - 'rendererLocale', ]); if (Object.keys(record).some((key) => !allowedKeys.has(key))) { throw new TypeError('Invalid Desktop diagnostic input'); @@ -75,6 +91,7 @@ export function parseDesktopErrorDiagnosticInput(input: unknown): DesktopErrorDi return { surface: record.surface, title, + hostTarget: parseDiagnosticHostTarget(record.hostTarget), ...optionalBoundedString(record, 'description', INPUT_LIMITS.description), ...optionalBoundedString(record, 'details', INPUT_LIMITS.details), ...(record.execution !== undefined @@ -85,24 +102,22 @@ export function parseDesktopErrorDiagnosticInput(input: unknown): DesktopErrorDi }; } -export function formatDesktopErrorDiagnosticReport( - input: DesktopErrorDiagnosticInput, +export function formatDesktopDiagnosticReport( + input: DesktopDiagnosticWireInput, environment: DesktopDiagnosticEnvironment, mainLogs: readonly string[], runtimeHost: RuntimeHostDiagnosticRead, runtimeExecution: RuntimeHostExecutionDiagnosticRead | undefined = undefined, capturedAt = new Date(), ): string { - const lines = [ - 'Maka Desktop diagnostic report', - `Captured at: ${capturedAt.toISOString()}`, - '', - 'Error', - `Surface: ${input.surface}`, - `Title: ${input.title}`, - ]; - if (input.description) lines.push(`Description: ${input.description}`); - if (input.details) lines.push('', 'Details:', input.details); + const lines = ['Maka Desktop diagnostic report', `Captured at: ${capturedAt.toISOString()}`]; + if (input.surface === 'manual') { + lines.push('', 'Capture', 'Surface: manual'); + } else { + lines.push('', 'Error', `Surface: ${input.surface}`, `Title: ${input.title}`); + if (input.description) lines.push(`Description: ${input.description}`); + if (input.details) lines.push('', 'Details:', input.details); + } lines.push( '', @@ -140,12 +155,13 @@ export function formatDesktopErrorDiagnosticReport( lines.push(`Diagnostics unavailable: ${runtimeHost.error}`); } - if (input.execution) { + const execution = input.surface === 'manual' ? undefined : input.execution; + if (execution) { lines.push('', 'Runtime Host execution'); if (!runtimeExecution?.ok) { lines.push(`Execution evidence unavailable: ${runtimeExecution?.error ?? 'not queried'}`); } else { - appendTurnTrace(lines, input.execution, runtimeExecution.value); + appendTurnTrace(lines, execution, runtimeExecution.value); } } @@ -153,6 +169,21 @@ export function formatDesktopErrorDiagnosticReport( return collapseHomePath(redacted, environment.homePath, environment.platform); } +function parseDiagnosticHostTarget(value: unknown): DesktopDiagnosticHostTarget { + if (value === 'none' || value === 'default' || value === 'task') return value; + throw new TypeError('Invalid Desktop diagnostic Runtime Host target'); +} + +function parseManualDiagnosticHostTarget( + value: unknown, +): Exclude { + const target = parseDiagnosticHostTarget(value); + if (target === 'none') { + throw new TypeError('Manual Desktop diagnostics require Runtime Host authority'); + } + return target; +} + function parseExecutionDiagnosticTarget(value: unknown): DesktopExecutionDiagnosticTarget { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new TypeError('Invalid Desktop execution diagnostic target'); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 76932aa736..1e75dbbb87 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1235,6 +1235,10 @@ function registerPersistentClientIpc(): void { processUptimeSeconds: process.uptime(), }), mainLogs: () => mainProcessLogBuffer.snapshot(), + resolveActiveRuntimeHost: () => { + const scope = activeRuntimeHostRef(); + return scope ? resolveRuntimeHostDiagnostics(scope) : undefined; + }, resolveRuntimeHost: resolveRuntimeHostDiagnostics, writeClipboard: (report) => clipboard.writeText(report), }); @@ -1283,12 +1287,12 @@ function resolveRuntimeHostDiagnostics(scope: DesktopTargetScope) { const client = current.client; return { getDiagnostics: () => client.queryHostDiagnostics(), - getTurnTrace: async (sessionId: string, turnId: string) => { + getTurnTrace: async (sessionId: string, turnId: string, timeoutMs: number) => { const result = await client.request('execution.inspect.query', { kind: "turn_trace", sessionId, turnId, - }); + }, timeoutMs); return result.kind === "turn_trace" ? result.turn : undefined; }, }; diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ae66a200b3..c5248220a2 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1561,9 +1561,10 @@ export class DesktopRuntimeHostClient { request( operation: K, input: OperationInput, + timeoutMs?: number, ): Promise> { this.#assertOpen(); - return this.connection.request(operation, input); + return this.connection.request(operation, input, timeoutMs); } #assertOpen(): void { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 70bdff7262..cde7ca716f 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -98,10 +98,7 @@ import type { DesktopSessionSummary } from '../shared/desktop-session-projection export type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js'; import type { DesktopExternalSessionCatalogItem } from './external-session-catalog.js'; -import type { - DesktopDiagnosticCopyResult, - DesktopErrorDiagnosticInput, -} from './diagnostics-contract.js'; +import type { DesktopDiagnosticInput } from './diagnostics-contract.js'; import type { Result } from '@maka/core/result'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import type { @@ -1121,7 +1118,7 @@ export interface MakaBridge { saveArtifactAs(sessionId: string, artifactId: string): Promise; }; diagnostics: { - copyErrorReport(input: DesktopErrorDiagnosticInput): Promise; + copyReport(input: DesktopDiagnosticInput): Promise; }; workspace: { searchFiles( diff --git a/apps/desktop/src/preload/diagnostics-contract.ts b/apps/desktop/src/preload/diagnostics-contract.ts index 7e0bc92b64..f6ac6672b9 100644 --- a/apps/desktop/src/preload/diagnostics-contract.ts +++ b/apps/desktop/src/preload/diagnostics-contract.ts @@ -4,16 +4,56 @@ export interface DesktopExecutionDiagnosticTarget { readonly eventId: string; } +interface DesktopDiagnosticRendererContext { + readonly rendererUserAgent?: string; + readonly rendererLocale?: string; +} + +export type DesktopManualDiagnosticTarget = + | { + readonly kind: 'session'; + readonly sessionId: string; + } + | { + readonly kind: 'profile'; + readonly profileId: string; + }; + +export interface DesktopManualDiagnosticInput { + readonly surface: 'manual'; + readonly target?: DesktopManualDiagnosticTarget; +} + export interface DesktopErrorDiagnosticInput { readonly surface: 'toast' | 'renderer_crash'; readonly title: string; readonly description?: string; readonly details?: string; + readonly target?: DesktopManualDiagnosticTarget; readonly execution?: DesktopExecutionDiagnosticTarget; - readonly rendererUserAgent?: string; - readonly rendererLocale?: string; } -export type DesktopDiagnosticCopyResult = - | { readonly ok: true } - | { readonly ok: false; readonly reason: 'clipboard_unavailable' }; +export type DesktopDiagnosticInput = DesktopManualDiagnosticInput | DesktopErrorDiagnosticInput; + +/** + * Runtime Host authority attached to one diagnostic request. + * + * `none` is intentionally distinct from `default`: renderer-local failures + * have no Runtime Host whose logs can be attributed to them, while a manual + * capture with no explicit task asks for the current default Host. + */ +export type DesktopDiagnosticHostTarget = 'none' | 'default' | 'task'; + +export type DesktopManualDiagnosticWireInput = Omit & + DesktopDiagnosticRendererContext & { + readonly hostTarget: Exclude; + }; + +export type DesktopErrorDiagnosticWireInput = Omit & + DesktopDiagnosticRendererContext & { + readonly hostTarget: DesktopDiagnosticHostTarget; + }; + +export type DesktopDiagnosticWireInput = + | DesktopManualDiagnosticWireInput + | DesktopErrorDiagnosticWireInput; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e6520dd530..e6beac576f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -48,8 +48,10 @@ import { type DesktopTranscriptOpenResult, } from './transcript-contract.js'; import type { - DesktopDiagnosticCopyResult, - DesktopErrorDiagnosticInput, + DesktopDiagnosticInput, + DesktopErrorDiagnosticWireInput, + DesktopManualDiagnosticTarget, + DesktopManualDiagnosticWireInput, } from './diagnostics-contract.js'; import type { ConnectionEvent } from '@maka/core/connections'; import type { @@ -302,6 +304,69 @@ async function runtimeHostSessionRef(sessionId: string): Promise<{ return { scope, sessionId: ref.sessionId }; } +type DiagnosticRuntimeHostResolution = { + readonly hostTarget: TTarget; + readonly scope?: DesktopTargetScope; +}; + +type TaskDiagnosticRuntimeHostResolution = DiagnosticRuntimeHostResolution<'task'>; +type ManualDiagnosticRuntimeHostResolution = DiagnosticRuntimeHostResolution<'default' | 'task'>; + +type ManualDiagnosticHostSelector = + | { readonly kind: 'host'; readonly hostId: string } + | { readonly kind: 'profile'; readonly profileId: string }; + +async function resolveManualDiagnosticRuntimeHost( + value: DesktopManualDiagnosticTarget | undefined, +): Promise { + if (value === undefined) return { hostTarget: 'default' }; + const selector = parseManualDiagnosticTarget(value); + return resolveTaskDiagnosticRuntimeHost(selector); +} + +async function resolveTaskDiagnosticRuntimeHost( + selector: ManualDiagnosticHostSelector, +): Promise { + try { + await runtimeHostScopeList(); + } catch { + return { hostTarget: 'task' }; + } + const hostId = selector.kind === 'host' + ? selector.hostId + : runtimeHostProfiles.get(selector.profileId); + const scope = hostId ? runtimeHostScopes.get(hostId) : undefined; + return { hostTarget: 'task', ...(scope ? { scope } : {}) }; +} + +function parseManualDiagnosticTarget(value: unknown): ManualDiagnosticHostSelector { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('Invalid Desktop manual diagnostic target'); + } + const record = value as Record; + if ( + record.kind === 'session' && + Object.keys(record).length === 2 && + Object.hasOwn(record, 'sessionId') && + typeof record.sessionId === 'string' && + Buffer.byteLength(record.sessionId, 'utf8') <= 512 + ) { + return { kind: 'host', hostId: parseDesktopSessionKey(record.sessionId).hostId }; + } + if ( + record.kind === 'profile' && + Object.keys(record).length === 2 && + Object.hasOwn(record, 'profileId') && + typeof record.profileId === 'string' && + record.profileId.length > 0 && + !/[\u0000-\u001f\u007f]/.test(record.profileId) && + Buffer.byteLength(record.profileId, 'utf8') <= 512 + ) { + return { kind: 'profile', profileId: record.profileId }; + } + throw new TypeError('Invalid Desktop manual diagnostic target'); +} + async function activeRuntimeHostRef(): Promise { while (!activeRuntimeHost) { const generation = activeRuntimeHostGeneration; @@ -2603,15 +2668,51 @@ const makaBridge = { }, }, diagnostics: { - async copyErrorReport(input: DesktopErrorDiagnosticInput): Promise { - if (!input.execution) { - return invokeActiveRuntimeHost('diagnostics:copyErrorReport', input); + async copyReport(input: DesktopDiagnosticInput): Promise { + const rendererContext = { + rendererUserAgent: navigator.userAgent, + rendererLocale: navigator.language, + }; + if (input.surface === 'manual') { + const { target, ...manualInput } = input; + const resolution = await resolveManualDiagnosticRuntimeHost(target); + const wireInput: DesktopManualDiagnosticWireInput = { + ...manualInput, + hostTarget: resolution.hostTarget, + ...rendererContext, + }; + await ipcRenderer.invoke( + 'diagnostics:copyReport', + resolution.scope, + wireInput, + ); + return; } - const session = await runtimeHostSessionRef(input.execution.sessionId); - return ipcRenderer.invoke('diagnostics:copyErrorReport', session.scope, { - ...input, - execution: { ...input.execution, sessionId: session.sessionId }, + const { execution, target, ...errorInput } = input; + if (!execution) { + const resolution: DiagnosticRuntimeHostResolution<'none' | 'default' | 'task'> = target + ? await resolveManualDiagnosticRuntimeHost(target) + : { hostTarget: 'none' }; + const wireInput: DesktopErrorDiagnosticWireInput = { + ...errorInput, + hostTarget: resolution.hostTarget, + ...rendererContext, + }; + await ipcRenderer.invoke('diagnostics:copyReport', resolution.scope, wireInput); + return; + } + const session = parseDesktopSessionKey(execution.sessionId); + const resolution = await resolveTaskDiagnosticRuntimeHost({ + kind: 'host', + hostId: session.hostId, }); + const wireInput: DesktopErrorDiagnosticWireInput = { + ...errorInput, + hostTarget: resolution.hostTarget, + ...rendererContext, + execution: { ...execution, sessionId: session.sessionId }, + }; + await ipcRenderer.invoke('diagnostics:copyReport', resolution.scope, wireInput); }, }, workspace: { diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 73b5cd32b9..f2f4b19cce 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -92,7 +92,12 @@ type PendingNewChatModel = { type PendingNewChatThinkingLevel = ThinkingLevel | null; type ToastApi = { - error(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string } | { profileId: string }, + ): void; info(title: string, description?: string): void; }; @@ -164,7 +169,11 @@ export function createAppShellChatActions(deps: { onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ onExecutionBoundaryChanged?: (sessionId: string) => void; - showModelSetupToast: (description: string, reason?: string) => void; + showModelSetupToast: ( + description: string, + reason?: string, + diagnosticTarget?: { sessionId: string } | { profileId: string }, + ) => void; toastApi: ToastApi; upsertSessionSummary: (session: DesktopSessionSummary) => void; newChatModel: PendingNewChatModel; @@ -415,7 +424,12 @@ export function createAppShellChatActions(deps: { }); if (!sendResult.ok) { if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { - showSkillInvocationFeedback(uiLocale, toastApi, sendResult.skillInvocation); + showSkillInvocationFeedback( + uiLocale, + toastApi, + sendResult.skillInvocation, + session.id, + ); } disarmTurnActive(session.id, turnId); await discardUnsentSession(); @@ -426,7 +440,12 @@ export function createAppShellChatActions(deps: { if (settledTurnId !== undefined) optimisticTurnId = settledTurnId; options.onSessionResolved?.(session.id); if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { - showSkillInvocationFeedback(uiLocale, toastApi, sendResult.skillInvocation); + showSkillInvocationFeedback( + uiLocale, + toastApi, + sendResult.skillInvocation, + session.id, + ); } if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { setNavSelection({ section: 'sessions' }); @@ -488,7 +507,12 @@ export function createAppShellChatActions(deps: { }); if (!sendResult.ok) { if (activeIdRef.current === sessionId) { - showSkillInvocationFeedback(uiLocale, toastApi, sendResult.skillInvocation); + showSkillInvocationFeedback( + uiLocale, + toastApi, + sendResult.skillInvocation, + sessionId, + ); } disarmTurnActive(sessionId, turnId); return false; @@ -498,7 +522,12 @@ export function createAppShellChatActions(deps: { if (startedTurnId === undefined) return true; optimisticTurnId = startedTurnId; if (activeIdRef.current === sessionId) { - showSkillInvocationFeedback(uiLocale, toastApi, sendResult.skillInvocation); + showSkillInvocationFeedback( + uiLocale, + toastApi, + sendResult.skillInvocation, + sessionId, + ); } showOptimisticUserMessage( sessionId, @@ -535,6 +564,11 @@ export function createAppShellChatActions(deps: { // surface and the app is now on the session it just made, so the id is // taken from the flight and only the section comes from the capture. const feedbackSessionId = optimisticSessionId ?? initialSessionId; + const diagnosticTarget = feedbackSessionId + ? { sessionId: feedbackSessionId } + : initialNewTaskTarget + ? { profileId: initialNewTaskTarget.profileId } + : undefined; const sendStillOwnsCurrentSurface = (feedbackSessionId !== undefined && isShellSurfaceOwnerActive({ @@ -545,11 +579,20 @@ export function createAppShellChatActions(deps: { if (!sendStillOwnsCurrentSurface) return false; if (isNoRealConnectionError(error)) { const reason = noRealConnectionReasonFromError(error); - showModelSetupToast(noRealConnectionSetupDescription(reason, uiLocale), reason); + showModelSetupToast( + noRealConnectionSetupDescription(reason, uiLocale), + reason, + diagnosticTarget, + ); } else if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale); + showSessionWorkspaceUnavailableToast(toastApi, uiLocale, diagnosticTarget); } else { - toastApi.error(copy.sendFailedTitle, localizedShellErrorMessage(error, copy.sendFailedFallback, uiLocale)); + toastApi.error( + copy.sendFailedTitle, + localizedShellErrorMessage(error, copy.sendFailedFallback, uiLocale), + undefined, + diagnosticTarget, + ); } return false; } @@ -575,11 +618,13 @@ export function createAppShellChatActions(deps: { // surfaces instead of dying as UnhandledPromiseRejection. if (activeIdRef.current !== sessionId) return; if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale); + showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); } else { toastApi.error( copy.responseFailedTitle, localizedShellErrorMessage(error, copy.responseFailedFallback, uiLocale), + undefined, + { sessionId }, ); } } @@ -595,11 +640,13 @@ export function createAppShellChatActions(deps: { } catch (error) { if (activeIdRef.current !== sessionId) return; if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale); + showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); } else { toastApi.error( copy.responseFailedTitle, localizedShellErrorMessage(error, copy.responseFailedFallback, uiLocale), + undefined, + { sessionId }, ); } } @@ -643,7 +690,7 @@ export function createAppShellChatActions(deps: { ...current, [sessionId]: message, })); - toastApi.error(copy.refreshFailedTitle, message); + toastApi.error(copy.refreshFailedTitle, message, undefined, { sessionId }); } return false; } @@ -660,7 +707,7 @@ export function createAppShellChatActions(deps: { ...current, [sessionId]: message, })); - toastApi.error(copy.refreshFailedTitle, message); + toastApi.error(copy.refreshFailedTitle, message, undefined, { sessionId }); } finally { clearPendingSessionAction(sessionId, messageRetryPendingRef, setMessageRetryPendingBySession); } diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 78269742b7..264bdf8de3 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -8,6 +8,7 @@ import type { SettingsSection, ThemePreference } from '@maka/core/settings'; import type { UiLocale } from '@maka/core/ui-locale'; import { formatDailyReviewMarkdown } from "@maka/ui"; import type { DailyReviewMarkdownActionInput, NavSelection } from "@maka/ui"; +import type { DesktopManualDiagnosticTarget } from '../preload/diagnostics-contract.js'; import { buildCommandList, buildSessionCommands, @@ -25,7 +26,12 @@ import { settingsTestResultMessage } from "./locales/settings-test-result-copy.j type ToastApi = { success(title: string, description?: string): void; info(title: string, description?: string): void; - error(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string } | { profileId: string }, + ): void; }; type ComposerImportOwner = { @@ -54,6 +60,9 @@ export interface AppShellCommandListOptions { defaultConnection: string | null; dailyReviewBridge: DailyReviewBridge; messages: StoredMessage[]; + newTaskProfileId: string | undefined; + settingsOpen: boolean; + settingsProfileId: string | undefined; sessions: SessionSummary[]; themePref: ThemePreference; visibleSessions: SessionSummary[]; @@ -81,6 +90,24 @@ export interface AppShellCommandListOptions { toastApi: ToastApi; } +export function resolveManualDiagnosticTarget( + owner: Pick, + newTaskProfileId: string | undefined, + settingsOpen = false, + settingsProfileId?: string, +): DesktopManualDiagnosticTarget | undefined { + if (settingsOpen) { + return settingsProfileId + ? { kind: 'profile', profileId: settingsProfileId } + : undefined; + } + if (owner.navSection !== 'sessions') return undefined; + if (owner.sessionId) return { kind: 'session', sessionId: owner.sessionId }; + return newTaskProfileId + ? { kind: 'profile', profileId: newTaskProfileId } + : undefined; +} + export function buildAppShellCommandList( optionsRef: RefBox, ): ReturnType { @@ -363,38 +390,33 @@ export function buildAppShellCommandList( ); } }, - onCopyEnvSummary: async () => { - const { toastApi } = optionsRef.current; + onCopyDiagnostics: async () => { + const { + captureComposerImportOwner, + newTaskProfileId, + settingsOpen, + settingsProfileId, + toastApi, + } = optionsRef.current; + const owner = captureComposerImportOwner(); + const target = resolveManualDiagnosticTarget( + owner, + newTaskProfileId, + settingsOpen, + settingsProfileId, + ); try { - const info = await window.maka.app.info(); - const platformPretty = - info.platform === "darwin" - ? "macOS" - : info.platform === "win32" - ? "Windows" - : info.platform === "linux" - ? "Linux" - : info.platform; - const buildLine = - info.buildMode === "dev" - ? `- Build: dev${info.buildCommit ? ` @ ${info.buildCommit}` : ""}` - : "- Build: packaged"; - const summary = [ - `**Maka** v${info.appVersion}`, - ``, - `- Electron: ${info.electronVersion}`, - `- Node: ${info.nodeVersion}`, - `- Chrome: ${info.chromeVersion}`, - `- Platform: ${platformPretty} ${info.osRelease}`, - `- Arch: ${info.arch}`, - buildLine, - ].join("\n"); - await navigator.clipboard.writeText(summary); - toastApi.success( - copy.environmentCopiedTitle, - `Maka v${info.appVersion} · ${platformPretty} · ${info.arch}`, - ); + await window.maka.diagnostics.copyReport({ + surface: "manual", + ...(target ? { target } : {}), + }); + toastApi.success(copy.diagnosticsCopiedTitle, copy.diagnosticsCopiedDescription); } catch (err) { + const diagnosticTarget = target?.kind === 'session' + ? { sessionId: target.sessionId } + : target?.kind === 'profile' + ? { profileId: target.profileId } + : undefined; toastApi.error( copy.copyFailedTitle, commandPaletteActionErrorMessage( @@ -402,6 +424,8 @@ export function buildAppShellCommandList( copy.clipboardDenied, options.uiLocale, ), + undefined, + diagnosticTarget, ); } }, diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 9397766ff6..c4a760b5b2 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -53,7 +53,12 @@ type SessionEventHealthUpdater = ( ) => void; type ToastApi = { - error(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string }, + ): void; info(title: string, description?: string): void; toast(options: { title: string; @@ -478,7 +483,12 @@ export function useActiveSessionEvents(options: { [sessionId]: message, })); options.setMessageLoadPending(false); - options.toastApi.error(getDesktopConversationCopy(options.uiLocale).actions.messageReadFailedTitle, message); + options.toastApi.error( + getDesktopConversationCopy(options.uiLocale).actions.messageReadFailedTitle, + message, + undefined, + { sessionId }, + ); } }); const handleSessionEvent = useEffectEvent((sessionId: string, event: SessionEvent) => { diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx index a92d5ef524..dbd17ddfa4 100644 --- a/apps/desktop/src/renderer/app-shell-overlays.tsx +++ b/apps/desktop/src/renderer/app-shell-overlays.tsx @@ -63,6 +63,7 @@ export function AppShellOverlays(props: { commandOptions: AppShellCommandListOptions; onExternalSessionImported(session: DesktopSessionSummary): void; onRemoteHostAdded(profileId: string): void; + onSelectedRuntimeHostProfileIdChange(profileId: string | undefined): void; }) { const { closeHelp, @@ -118,6 +119,7 @@ export function AppShellOverlays(props: { archivedTasks={props.archivedTasks} onTaskImported={onExternalSessionImported} onRemoteHostAdded={props.onRemoteHostAdded} + onSelectedRuntimeHostProfileIdChange={props.onSelectedRuntimeHostProfileIdChange} /> )} diff --git a/apps/desktop/src/renderer/app-shell-project-actions.ts b/apps/desktop/src/renderer/app-shell-project-actions.ts index e7394e1f08..3cc37730a0 100644 --- a/apps/desktop/src/renderer/app-shell-project-actions.ts +++ b/apps/desktop/src/renderer/app-shell-project-actions.ts @@ -21,7 +21,12 @@ type RefBox = { current: T }; type ToastApi = { success(title: string, description?: string): void; - error(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string } | { profileId: string }, + ): void; }; export interface AppShellProjectActions { @@ -69,6 +74,17 @@ export function createAppShellProjectActions(deps: { toastApi, } = deps; const copy = getShellCopy(uiLocale).projectActions; + const sessionDiagnosticTarget = sessionId ? { sessionId } : undefined; + const showDefaultProjectError = (title: string, description?: string) => { + // These operations use the default Host implicitly. Without an explicit + // Host ref captured by the operation, a profile sampled elsewhere could + // identify a different Host after a default switch. Desktop-only evidence + // is preferable to a confidently wrong Runtime Host report. + toastApi.error(title, description); + }; + const showSessionProjectError = (title: string, description?: string) => { + toastApi.error(title, description, undefined, sessionDiagnosticTarget); + }; async function refreshProjects(): Promise { return refreshDefaultProjectState(); @@ -115,7 +131,7 @@ export function createAppShellProjectActions(deps: { return result.project; } catch (error) { if (isCurrentProjectPickerRequest()) { - toastApi.error( + showDefaultProjectError( copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale), ); @@ -135,7 +151,7 @@ export function createAppShellProjectActions(deps: { if (!project) return false; return await selectProjectRecord(project, true); } catch (error) { - toastApi.error(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale)); + showDefaultProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale)); return false; } } @@ -147,7 +163,7 @@ export function createAppShellProjectActions(deps: { await refreshProjects(); onProjectSelected(sessionId); } catch (error) { - toastApi.error( + showDefaultProjectError( copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale), ); @@ -159,7 +175,7 @@ export function createAppShellProjectActions(deps: { const project = projects.find((candidate) => candidate.id === projectId); return project ? await selectProjectRecord(project, false) : false; } catch (error) { - toastApi.error(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale)); + showDefaultProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale)); return false; } } @@ -178,7 +194,7 @@ export function createAppShellProjectActions(deps: { } return await selectProjectRecord(project, false); } catch (error) { - toastApi.error( + showDefaultProjectError( copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale), ); @@ -195,7 +211,7 @@ export function createAppShellProjectActions(deps: { else await refreshProjects(); return result.project; } catch (error) { - toastApi.error(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale)); + showDefaultProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale)); return null; } } @@ -205,7 +221,7 @@ export function createAppShellProjectActions(deps: { await window.maka.projects.rename(projectId, name); await refreshProjects(); } catch (error) { - toastApi.error( + showDefaultProjectError( copy.projectUpdateFailedTitle, localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale), ); @@ -217,7 +233,7 @@ export function createAppShellProjectActions(deps: { await window.maka.projects.archive(projectId); await refreshProjects(); } catch (error) { - toastApi.error( + showDefaultProjectError( copy.projectUpdateFailedTitle, localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale), ); @@ -229,7 +245,7 @@ export function createAppShellProjectActions(deps: { await window.maka.projects.restore(projectId); await refreshProjects(); } catch (error) { - toastApi.error( + showDefaultProjectError( copy.projectUpdateFailedTitle, localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale), ); @@ -240,13 +256,13 @@ export function createAppShellProjectActions(deps: { try { const result = await window.maka.app.openPath('skills'); if (!result.ok) { - toastApi.error( + showDefaultProjectError( copy.openFailedTitle(openPathActionLabel('skills', uiLocale)), openPathFailureCopy(result.reason, uiLocale), ); } } catch (error) { - toastApi.error( + showDefaultProjectError( copy.openFailedTitle(openPathActionLabel('skills', uiLocale)), openPathActionErrorMessage(error, 'skills', uiLocale), ); @@ -257,16 +273,16 @@ export function createAppShellProjectActions(deps: { try { const result = await window.maka.app.openPath('project', sessionId); if (!result.ok) { - toastApi.error( + showSessionProjectError( copy.openFailedTitle(openPathActionLabel('project', uiLocale)), openPathFailureCopy(result.reason, uiLocale), ); } } catch (error) { if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale); + showSessionWorkspaceUnavailableToast(toastApi, uiLocale, sessionDiagnosticTarget); } else { - toastApi.error( + showSessionProjectError( copy.openFailedTitle(openPathActionLabel('project', uiLocale)), openPathActionErrorMessage(error, 'project', uiLocale), ); @@ -278,13 +294,13 @@ export function createAppShellProjectActions(deps: { try { const result = await window.maka.app.openPath('workspace'); if (!result.ok) { - toastApi.error( + showDefaultProjectError( copy.openFailedTitle(openPathActionLabel('workspace', uiLocale)), openPathFailureCopy(result.reason, uiLocale), ); } } catch (error) { - toastApi.error( + showDefaultProjectError( copy.openFailedTitle(openPathActionLabel('workspace', uiLocale)), openPathActionErrorMessage(error, 'workspace', uiLocale), ); diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index 39ae7022ce..b80069fec1 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -26,7 +26,12 @@ type MessageListUpdater = ( type ToastApi = { info(title: string, description?: string): void; - error(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string }, + ): void; }; /** Active edit-and-resend draft owned by the desktop shell. */ @@ -124,7 +129,12 @@ export function createAppShellRevisionActions(deps: { message.type === 'user' && message.turnId === turnId, ); if (!userMessage) { - toastApi.error(copy.operationFailedTitle, copy.operationFailedFallback); + toastApi.error( + copy.operationFailedTitle, + copy.operationFailedFallback, + undefined, + { sessionId }, + ); return; } @@ -335,11 +345,15 @@ export function createAppShellRevisionActions(deps: { } if (activeIdRef.current !== sourceSessionId) return false; if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale); + showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { + sessionId: sourceSessionId, + }); } else { toastApi.error( copy.operationFailedTitle, localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), + undefined, + { sessionId: sourceSessionId }, ); } return false; diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index e71203708c..48df174c52 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -66,7 +66,11 @@ export function createAppShellSessionEventHandlers(options: { onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ onExecutionBoundaryChanged?: (sessionId: string) => void; - showModelSetupToast: (description: string, reason?: string) => void; + showModelSetupToast: ( + description: string, + reason?: string, + diagnosticTarget?: { sessionId: string }, + ) => void; toastApi: ToastApi; notifyRunEnded?: (payload: { kind: 'completed' | 'errored'; sessionId: string; body?: string }) => void; scheduleFrame?: (callback: () => void) => void; @@ -288,7 +292,11 @@ export function createAppShellSessionEventHandlers(options: { if (activeIdRef.current === sessionId) { if (isNoRealConnectionEvent(event)) { const reason = noRealConnectionReasonFromEvent(event); - showModelSetupToast(noRealConnectionSetupDescription(reason, uiLocale), reason); + showModelSetupToast( + noRealConnectionSetupDescription(reason, uiLocale), + reason, + { sessionId }, + ); } else { const copy = getDesktopConversationCopy(uiLocale).actions; toastApi.error( diff --git a/apps/desktop/src/renderer/app-shell-session-row-actions.ts b/apps/desktop/src/renderer/app-shell-session-row-actions.ts index ba51998638..b1b399a36a 100644 --- a/apps/desktop/src/renderer/app-shell-session-row-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-row-actions.ts @@ -10,7 +10,12 @@ type SessionRemoveDisposition = 'removed' | 'restored'; type ToastApi = { success(title: string, description?: string): void; - error(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string }, + ): void; confirm(options: { title: string; description: string; @@ -35,8 +40,11 @@ export interface SessionPurgeOutcome { */ restored: string[]; verified: boolean; - /** First rejection, so the caller can show a reason rather than a count. */ - firstError: unknown; + /** First rejection and the Session whose Host produced it. */ + firstFailure?: { + error: unknown; + sessionId: string; + }; } export interface AppShellSessionRowActions { @@ -85,7 +93,12 @@ export function createAppShellSessionRowActions(deps: { try { await action(); } catch (error) { - toastApi.error(errorTitle, localizedShellErrorMessage(error, copy.actionFallback, uiLocale)); + toastApi.error( + errorTitle, + localizedShellErrorMessage(error, copy.actionFallback, uiLocale), + undefined, + { sessionId }, + ); } finally { pendingSessionRowActionsRef.current.delete(key); } @@ -202,7 +215,7 @@ export function createAppShellSessionRowActions(deps: { async function purgeSessions(sessionIds: readonly string[]): Promise { const unsettled: string[] = []; const restored: string[] = []; - let firstError: unknown; + let firstFailure: SessionPurgeOutcome['firstFailure']; let removed = 0; for (const sessionId of sessionIds) { const key = `${sessionId}:delete`; @@ -221,14 +234,20 @@ export function createAppShellSessionRowActions(deps: { else removed += 1; } catch (error) { unsettled.push(sessionId); - firstError ??= error; + firstFailure ??= { error, sessionId }; } finally { pendingSessionRowActionsRef.current.delete(key); } } if (unsettled.length === 0) { await refreshSessions(); - return { removed, remaining: [], restored, verified: true, firstError }; + return { + removed, + remaining: [], + restored, + verified: true, + firstFailure, + }; } let listed: SessionSummary[] | undefined; try { @@ -237,7 +256,15 @@ export function createAppShellSessionRowActions(deps: { listed = undefined; } await refreshSessions(); - if (!listed) return { removed, remaining: [], restored, verified: false, firstError }; + if (!listed) { + return { + removed, + remaining: [], + restored, + verified: false, + firstFailure, + }; + } const present = new Set(listed.map((session) => session.id)); const remaining = unsettled.filter((sessionId) => present.has(sessionId)); return { @@ -245,7 +272,7 @@ export function createAppShellSessionRowActions(deps: { remaining, restored, verified: true, - firstError, + firstFailure, }; } diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 0fb3cdb033..f0e74290ae 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -15,7 +15,12 @@ type BooleanRecordUpdater = (updater: (current: Record) => Reco type ToastApi = { success(title: string, description?: string): void; - error(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string }, + ): void; confirm(input: { title: string; description?: string; @@ -130,7 +135,12 @@ export function createAppShellSessionSettingsActions(deps: { ); if (sessionId) await refreshSessions(); } catch (error) { - toastApi.error(copy.permissionFailedTitle, localizedShellErrorMessage(error, copy.permissionFallback, uiLocale)); + toastApi.error( + copy.permissionFailedTitle, + localizedShellErrorMessage(error, copy.permissionFallback, uiLocale), + undefined, + sessionId ? { sessionId } : undefined, + ); } finally { pendingPermissionModeChangesRef.current.delete(pendingKey); if (sessionId) setPendingPermissionModeBySession((current) => omitSessionKey(current, sessionId)); @@ -173,7 +183,12 @@ export function createAppShellSessionSettingsActions(deps: { await refreshSessions(); } catch (error) { if (activeIdRef.current === sessionId) { - toastApi.error(copy.modelFailedTitle, localizedShellErrorMessage(error, copy.modelFallback, uiLocale)); + toastApi.error( + copy.modelFailedTitle, + localizedShellErrorMessage(error, copy.modelFallback, uiLocale), + undefined, + { sessionId }, + ); } } finally { pendingSessionModelChangesRef.current.delete(sessionId); @@ -201,7 +216,12 @@ export function createAppShellSessionSettingsActions(deps: { await refreshSessions(); } catch (error) { if (activeIdRef.current === sessionId) { - toastApi.error(copy.thinkingFailedTitle, localizedShellErrorMessage(error, copy.thinkingFallback, uiLocale)); + toastApi.error( + copy.thinkingFailedTitle, + localizedShellErrorMessage(error, copy.thinkingFallback, uiLocale), + undefined, + { sessionId }, + ); } } finally { pendingSessionModelChangesRef.current.delete(sessionId); diff --git a/apps/desktop/src/renderer/app-shell-session-start-actions.ts b/apps/desktop/src/renderer/app-shell-session-start-actions.ts index 5f708d56e9..83c7e23555 100644 --- a/apps/desktop/src/renderer/app-shell-session-start-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-start-actions.ts @@ -26,7 +26,12 @@ type ComposerFocusHandle = { }; type ToastApi = { - error(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { profileId: string }, + ): void; }; export interface AppShellSessionStartActions { @@ -55,7 +60,11 @@ export function createAppShellSessionStartActions(deps: { * shared with the send path and the session-event stream. It carries the * 打开模型设置 action, which is the only thing that resolves this state. */ - showModelSetupToast: (description: string, reason?: string) => void; + showModelSetupToast: ( + description: string, + reason?: string, + diagnosticTarget?: { profileId: string }, + ) => void; toastApi: ToastApi; }): AppShellSessionStartActions { const { @@ -109,7 +118,9 @@ export function createAppShellSessionStartActions(deps: { // not be silently relabelled as "your setup is incomplete". if (isSessionWorkspaceUnavailableError(error)) { if (isShellSurfaceOwnerActive(owner)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale); + showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { + profileId: newTaskTarget.profileId, + }); } return false; } @@ -131,7 +142,11 @@ export function createAppShellSessionStartActions(deps: { refreshOnboarding(); if (isShellSurfaceOwnerActive(owner)) { const reason = noRealConnectionReasonFromError(error); - showModelSetupToast(noRealConnectionSetupDescription(reason, uiLocale), reason); + showModelSetupToast( + noRealConnectionSetupDescription(reason, uiLocale), + reason, + { profileId: newTaskTarget.profileId }, + ); } return false; } @@ -139,6 +154,8 @@ export function createAppShellSessionStartActions(deps: { toastApi.error( copy.sessionStartFailedTitle, localizedShellErrorMessage(error, copy.sessionStartFailedFallback, uiLocale), + undefined, + { profileId: newTaskTarget.profileId }, ); } return false; diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index 4119f97ec1..58f24bcbd3 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -6,7 +6,12 @@ type RefBox = { current: T }; type BooleanRecordUpdater = (updater: (current: Record) => Record) => void; type ToastApi = { - error(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string }, + ): void; }; export function createAppShellStopAction(deps: { @@ -50,7 +55,12 @@ export function createAppShellStopAction(deps: { // actually interrupted and can retry. if (activeIdRef.current === sessionId) { const copy = getDesktopConversationCopy(uiLocale).actions; - toastApi.error(copy.stopFailedTitle, localizedShellErrorMessage(error, copy.stopFailedFallback, uiLocale)); + toastApi.error( + copy.stopFailedTitle, + localizedShellErrorMessage(error, copy.stopFailedFallback, uiLocale), + undefined, + { sessionId }, + ); } } finally { clearPendingSessionAction(sessionId, stopPendingRef, setStopPendingBySession); diff --git a/apps/desktop/src/renderer/app-shell-toast-diagnostics.ts b/apps/desktop/src/renderer/app-shell-toast-diagnostics.ts new file mode 100644 index 0000000000..03b13e3286 --- /dev/null +++ b/apps/desktop/src/renderer/app-shell-toast-diagnostics.ts @@ -0,0 +1,23 @@ +import type { ToastErrorAction } from '@maka/ui'; +import type { DesktopErrorDiagnosticInput } from '../preload/diagnostics-contract.js'; + +type ErrorToastInput = Parameters[0]; + +export function diagnosticInputForErrorToast( + input: ErrorToastInput, +): DesktopErrorDiagnosticInput { + const target = input.diagnosticTarget; + return { + surface: 'toast', + title: input.title, + ...(input.description ? { description: input.description } : {}), + ...(input.diagnosticDetails ? { details: input.diagnosticDetails } : {}), + ...(target && 'profileId' in target + ? { target: { kind: 'profile', profileId: target.profileId } } + : target && 'turnId' in target + ? { execution: target } + : target + ? { target: { kind: 'session', sessionId: target.sessionId } } + : {}), + }; +} diff --git a/apps/desktop/src/renderer/app-shell-turn-actions.ts b/apps/desktop/src/renderer/app-shell-turn-actions.ts index 407fbf8789..d8d5900556 100644 --- a/apps/desktop/src/renderer/app-shell-turn-actions.ts +++ b/apps/desktop/src/renderer/app-shell-turn-actions.ts @@ -16,7 +16,12 @@ type MessageListUpdater = (next: StoredMessage[] | ((current: StoredMessage[]) = type ToastApi = { info(title: string, description?: string): void; success(title: string, description?: string): void; - error(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string }, + ): void; }; export interface AppShellTurnActions { @@ -94,11 +99,13 @@ export function createAppShellTurnActions(deps: { } catch (error) { if (activeIdRef.current !== sessionId) return; if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale); + showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); } else { toastApi.error( copy.operationFailedTitle, localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), + undefined, + { sessionId }, ); } } finally { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 0708c2996e..78c1f32360 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -35,6 +35,7 @@ import { LocaleProvider, ModuleHubSelector, ToastProvider, + type ToastDiagnosticTarget, type ToastErrorAction, type NavSelection, SessionListPanel, @@ -176,6 +177,7 @@ import { createAppShellDailyReviewActions } from './app-shell-daily-review-actio import { createAppShellSessionRowActions } from './app-shell-session-row-actions'; import { createAppShellSessionSettingsActions } from './app-shell-session-settings-actions'; import { createAppShellStopAction } from './app-shell-stop-action'; +import { diagnosticInputForErrorToast } from './app-shell-toast-diagnostics'; import { useStableActions } from './use-stable-actions'; import { useActiveSessionEvents, @@ -278,18 +280,9 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { const errorToastAction = useMemo( () => ({ label: getShellCopy(uiLocale).errorBoundary.copyReport, - onClick: (input) => { - void window.maka.diagnostics.copyErrorReport({ - surface: 'toast', - title: input.title, - ...(input.description ? { description: input.description } : {}), - ...(input.diagnosticDetails ? { details: input.diagnosticDetails } : {}), - ...(input.diagnosticTarget ? { execution: input.diagnosticTarget } : {}), - rendererUserAgent: navigator.userAgent, - rendererLocale: navigator.language, - }) - .catch(() => undefined); - }, + onClick: (input) => window.maka.diagnostics.copyReport( + diagnosticInputForErrorToast(input), + ), }), [uiLocale], ); @@ -543,6 +536,8 @@ function AppShellContent({ openConnectionDetail, openProviderCreate, } = useSettingsModal(); + const [settingsDiagnosticProfileId, setSettingsDiagnosticProfileId] = + useState(); const { themePref, setThemePref, @@ -1047,7 +1042,8 @@ function AppShellContent({ try { const planState = await window.maka.sessions.getPlanState(sessionId); if (active && planState.activeExecutionId) { - toastApi.error( + showSessionError( + sessionId, shellCopy.planModeExecutionActiveTitle, shellCopy.planModeExecutionActiveDescription, ); @@ -1082,7 +1078,8 @@ function AppShellContent({ return true; } catch (error) { if (activeIdRef.current === sessionId) { - toastApi.error( + showSessionError( + sessionId, shellCopy.planModeFailedTitle, localizedShellErrorMessage(error, shellCopy.planModeFallback, uiLocale), ); @@ -1121,7 +1118,8 @@ function AppShellContent({ return true; } catch (error) { if (activeIdRef.current === sessionId) { - toastApi.error( + showSessionError( + sessionId, shellCopy.orchestrationModeFailedTitle, localizedShellErrorMessage(error, shellCopy.orchestrationModeFallback, uiLocale), ); @@ -1680,7 +1678,8 @@ function AppShellContent({ else setBottomPanelOpen(true); }) .catch((error) => { - toastApi.error( + showSessionError( + ownerSessionId, terminalPanelCopy.startFailed, localizedShellErrorMessage( error, @@ -2217,9 +2216,10 @@ function AppShellContent({ } catch (error) { if (activeIdRef.current !== sessionId) return false; if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale); + showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { sessionId }); } else { - toastApi.error( + showSessionError( + sessionId, shellCopy.compactErrorTitle, localizedShellErrorMessage(error, shellCopy.compactErrorFallback, uiLocale), ); @@ -2370,7 +2370,8 @@ function AppShellContent({ } catch (error) { if (activeIdRef.current === sessionId) { const copy = getDesktopConversationCopy(uiLocale).actions; - toastApi.error( + showSessionError( + sessionId, copy.operationFailedTitle, localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), ); @@ -2779,7 +2780,11 @@ function AppShellContent({ void refreshShellSettings(); } - function showModelSetupToast(description: string, reason?: string) { + function showModelSetupToast( + description: string, + reason?: string, + diagnosticTarget?: ToastDiagnosticTarget, + ) { const copy = modelSetupToastCopy(reason, description, uiLocale); toastApi.toast({ title: copy.title, @@ -2788,6 +2793,7 @@ function AppShellContent({ : copy.description, variant: 'error', duration: 8000, + ...(diagnosticTarget ? { diagnosticTarget } : {}), ...(modelSettingsOwnsComposerHost ? { action: { @@ -2800,6 +2806,14 @@ function AppShellContent({ if (modelSettingsOwnsComposerHost) openSettingsSection('models'); } + function showSessionError( + sessionId: string, + title: string, + description?: string, + ) { + toastApi.error(title, description, undefined, { sessionId }); + } + const canStageComposerContext = activeId !== undefined || newTask.target !== undefined; const activeMessageLoadError = activeId ? messageLoadErrorBySession[activeId] : undefined; @@ -2829,7 +2843,8 @@ function AppShellContent({ ) { return; } - toastApi.error( + showSessionError( + sessionId, desktopConversationCopy.actions.messageReadFailedTitle, localizedShellErrorMessage( error, @@ -2859,6 +2874,9 @@ function AppShellContent({ defaultConnection: defaultHostConnections.snapshot.defaultConnection, dailyReviewBridge, messages, + newTaskProfileId: newTask.selectedProfileId, + settingsOpen, + settingsProfileId: settingsDiagnosticProfileId, sessions, themePref, visibleSessions, @@ -3343,7 +3361,8 @@ function AppShellContent({ : {}), onClear: () => { void window.maka.goal.clear(activeGoal.sessionId).catch((error) => { - toastApi.error( + showSessionError( + activeGoal.sessionId, shellCopy.goalClearFailedTitle, localizedShellErrorMessage( error, @@ -3364,7 +3383,8 @@ function AppShellContent({ activeGoal.sessionId, () => window.maka.goal.resume(activeGoal.sessionId), (error) => { - toastApi.error( + showSessionError( + activeGoal.sessionId, shellCopy.goalResumeFailedTitle, localizedShellErrorMessage( error, @@ -3385,7 +3405,8 @@ function AppShellContent({ activeGoal.sessionId, () => window.maka.goal.pause(activeGoal.sessionId), (error) => { - toastApi.error( + showSessionError( + activeGoal.sessionId, shellCopy.goalPauseFailedTitle, localizedShellErrorMessage( error, @@ -3636,6 +3657,8 @@ function AppShellContent({ projectActionsCopy.projectUpdateFailedFallback, uiLocale, ), + undefined, + { profileId: host.profileId }, ); }); }} @@ -3690,6 +3713,7 @@ function AppShellContent({ openNewTaskSurface(); void newTask.chooseProjectForProfile(profileId).catch(() => undefined); }} + onSelectedRuntimeHostProfileIdChange={setSettingsDiagnosticProfileId} /> ); diff --git a/apps/desktop/src/renderer/artifact-pane.tsx b/apps/desktop/src/renderer/artifact-pane.tsx index c216376d27..2cfc4a597f 100644 --- a/apps/desktop/src/renderer/artifact-pane.tsx +++ b/apps/desktop/src/renderer/artifact-pane.tsx @@ -141,7 +141,7 @@ export function ArtifactPane(props: { setRecordsSessionId(undefined); setRecords([]); } else { - toast.error(copy.pane.refreshFailed, message); + toast.error(copy.pane.refreshFailed, message, undefined, { sessionId }); } } } @@ -247,11 +247,21 @@ export function ArtifactPane(props: { const result = await window.maka.app.openArtifactPath(sessionId, artifactId); if (!isArtifactActionSurfaceActive(actionSessionId)) return; if (!result.ok) { - toast.error(copy.pane.openFailed, openPathFailureCopy(result.reason, locale)); + toast.error( + copy.pane.openFailed, + openPathFailureCopy(result.reason, locale), + undefined, + { sessionId: actionSessionId }, + ); } } catch (error) { if (!isArtifactActionSurfaceActive(actionSessionId)) return; - toast.error(copy.pane.openFailed, artifactActionErrorMessage(error, locale, copy)); + toast.error( + copy.pane.openFailed, + artifactActionErrorMessage(error, locale, copy), + undefined, + { sessionId: actionSessionId }, + ); } } @@ -262,13 +272,30 @@ export function ArtifactPane(props: { const record = activeRecords.find((entry) => entry.id === artifactId); if (!record || !isTextKind(record.kind)) return; const actionSessionId = sessionId; + let result: Awaited>; try { - const result = await window.maka.artifacts.readText(sessionId, artifactId); + result = await window.maka.artifacts.readText(sessionId, artifactId); + } catch (error) { if (!isArtifactActionSurfaceActive(actionSessionId)) return; - if (!result.ok) { - toast.error(copy.pane.copyFailed, copy.pane.readTextFailed); - return; - } + toast.error( + copy.pane.copyFailed, + artifactActionErrorMessage(error, locale, copy), + undefined, + { sessionId: actionSessionId }, + ); + return; + } + if (!isArtifactActionSurfaceActive(actionSessionId)) return; + if (!result.ok) { + toast.error( + copy.pane.copyFailed, + copy.pane.readTextFailed, + undefined, + { sessionId: actionSessionId }, + ); + return; + } + try { await navigator.clipboard.writeText(result.text); if (!isArtifactActionSurfaceActive(actionSessionId)) return; toast.success(copy.pane.copied, `${record.name} · ${formatBytes(record.sizeBytes)}`); @@ -289,10 +316,20 @@ export function ArtifactPane(props: { return; } if (result.reason === 'canceled') return; - toast.error(copy.pane.saveFailed, saveArtifactFailureCopy(result.reason, copy)); + toast.error( + copy.pane.saveFailed, + saveArtifactFailureCopy(result.reason, copy), + undefined, + { sessionId: actionSessionId }, + ); } catch (error) { if (!isArtifactActionSurfaceActive(actionSessionId)) return; - toast.error(copy.pane.saveFailed, artifactActionErrorMessage(error, locale, copy)); + toast.error( + copy.pane.saveFailed, + artifactActionErrorMessage(error, locale, copy), + undefined, + { sessionId: actionSessionId }, + ); } } @@ -316,7 +353,12 @@ export function ArtifactPane(props: { toast.success(copy.pane.deleted(name)); } catch (error) { if (!isArtifactActionSurfaceActive(actionSessionId)) return; - toast.error(copy.pane.deleteFailed(name), artifactActionErrorMessage(error, locale, copy)); + toast.error( + copy.pane.deleteFailed(name), + artifactActionErrorMessage(error, locale, copy), + undefined, + { sessionId: actionSessionId }, + ); } } diff --git a/apps/desktop/src/renderer/browser-panel.tsx b/apps/desktop/src/renderer/browser-panel.tsx index bf28bfe1e1..0846418cb4 100644 --- a/apps/desktop/src/renderer/browser-panel.tsx +++ b/apps/desktop/src/renderer/browser-panel.tsx @@ -141,7 +141,12 @@ export function BrowserPanel(props: { sessionId: string; hidden: boolean }) { const ownerSessionId = sessionId; void window.maka.browser.navigate(ownerSessionId, result.url).catch(() => { if (isBrowserPanelSessionCurrent(ownerSessionId)) { - toast.error(copy.navigationFailed, copy.navigationFailedDetail); + toast.error( + copy.navigationFailed, + copy.navigationFailedDetail, + undefined, + { sessionId: ownerSessionId }, + ); } }); }, [address, copy, isBrowserPanelSessionCurrent, sessionId, toast]); diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index 31c23de0d1..48a896db61 100644 --- a/apps/desktop/src/renderer/command-palette-commands.ts +++ b/apps/desktop/src/renderer/command-palette-commands.ts @@ -8,7 +8,7 @@ import { Blocks, CalendarDays, Clock, - Database, + Clipboard, Download, FolderOpen, Keyboard, @@ -109,13 +109,8 @@ export function buildCommandList(args: { * round-tripping the clipboard. */ onSaveTodayDailyReviewToFile?(): Promise | void; - /** - * PR-CMD-PALETTE-COPY-ENV-SUMMARY-0: copy the Settings → 关于 - * environment summary (Maka version + Electron / Node / Chrome - * versions + platform + arch + build mode/sha) as Markdown, - * without having to open Settings. Useful for bug reports. - */ - onCopyEnvSummary?(): Promise | void; + /** Copy redacted Desktop and active Runtime Host diagnostics for issue reports. */ + onCopyDiagnostics?(): Promise | void; /** * PR-CMD-PALETTE-NETWORK-PROXY-TEST-0: ⌘K → 测试当前网络代理. Fires * `window.maka.settings.testNetworkProxy()` and surfaces the result @@ -369,14 +364,14 @@ export function buildCommandList(args: { run: () => args.onSaveTodayDailyReviewToFile!(), }); } - if (args.onCopyEnvSummary) { + if (args.onCopyDiagnostics) { cmds.push({ - id: 'diag:copy-env-summary', + id: 'diag:copy-diagnostics', kind: 'action', - ...staticCopy('diag:copy-env-summary'), - Icon: Database, - keywords: [...copy.staticKeywords['diag:copy-env-summary']], - run: () => args.onCopyEnvSummary!(), + ...staticCopy('diag:copy-diagnostics'), + Icon: Clipboard, + keywords: [...copy.staticKeywords['diag:copy-diagnostics']], + run: () => args.onCopyDiagnostics!(), }); } if (args.onTestNetworkProxy) { diff --git a/apps/desktop/src/renderer/error-boundary.tsx b/apps/desktop/src/renderer/error-boundary.tsx index ae6ed59f64..22073243e6 100644 --- a/apps/desktop/src/renderer/error-boundary.tsx +++ b/apps/desktop/src/renderer/error-boundary.tsx @@ -82,14 +82,11 @@ export class ErrorBoundary extends Component<{ children: ReactNode; locale: UiLo try { const diagnostics = window.maka?.diagnostics; if (diagnostics) { - const result = await diagnostics.copyErrorReport({ + await diagnostics.copyReport({ surface: 'renderer_crash', title: `${error.name}: ${error.message}`, details: formatRendererErrorDetails(error, errorInfo), - rendererUserAgent: navigator.userAgent, - rendererLocale: navigator.language, }); - if (!result.ok) throw new Error('Desktop diagnostic clipboard write failed'); } else { await navigator.clipboard.writeText(formatRendererErrorReport(error, errorInfo)); } diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index 603aec2027..7ea0033e8c 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -165,7 +165,7 @@ export type SettingsPreferencesCopy = { privacyTitle: string; privacyPoints: readonly string[]; copying: string; - copyEnvironment: string; + copyDiagnostics: string; copyHelp: string; keyboardShortcuts: string; keyboardShortcutsHelp: string; @@ -239,7 +239,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { proxy: '代理服务器', proxyHelp: '为 AI 模型请求配置网络代理', enableProxy: '启用代理服务器', saveNetworkFailed: '保存网络设置失败', proxyProtocol: '代理协议', serverAddress: '服务器地址', port: '端口', proxyAuth: '代理认证', proxyAuthHelp: '需要用户名和密码时开启。', enableProxyAuth: '启用代理认证', username: '用户名', password: '密码', bypassList: '代理白名单', bypassHelp: '这些域名将绕过代理直连,多个用逗号分隔。', autoBypass: (count) => `已自动添加 ${count} 个域名。代理仅作用于 AI 模型请求。`, testing: '测试中…', testCurrent: '测试当前配置', proxyReachable: '代理可达', proxyTestFailed: '代理测试失败', proxyTestError: '代理测试出错', }, about: { - loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制环境信息', pasteHint: '可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', subtitle: '本地优先的 AI 助手 · 桌面端运行环境', privacyLabel: '隐私与安全', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['所有任务、设置、凭据和 Skill 指令文件都保留在本机工作区。', '模型密钥保存在本机凭据文件内;订阅账号令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权。', '每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'], copying: '复制中…', copyEnvironment: '复制环境信息', copyHelp: '复制当前版本与平台信息以便定位问题;内容不包含工作区路径。', keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看', reportIssueLabel: '报告问题', + loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', subtitle: '本地优先的 AI 助手 · 桌面端运行环境', privacyLabel: '隐私与安全', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['所有任务、设置、凭据和 Skill 指令文件都保留在本机工作区。', '模型密钥保存在本机凭据文件内;订阅账号令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权。', '每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'], copying: '复制中…', copyDiagnostics: '复制诊断信息', copyHelp: '复制版本、平台、隐藏主目录后的工作区路径,以及近期脱敏的 Desktop 与 Runtime Host 日志;仅写入剪贴板,不会自动上传。', keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看', reportIssueLabel: '报告问题', updatesTitle: '软件更新', checkForUpdates: '检查更新', checkingForUpdates: '检查中…', @@ -289,7 +289,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { shellPreference: 'Bash tool shell', shellPreferenceHelp: 'Automatic keeps the PowerShell-first Windows default. Git Bash is an explicit override for the current Runtime Host.', shellAuto: 'Automatic (recommended)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash executable', shellExecutableHelp: 'Enter the absolute path to bash.exe on the Windows machine running the Runtime Host. The legacy System32 WSL Bash shim is also recognized; Maka verifies GNU Bash before saving.', saveShell: 'Save shell setting', savingShell: 'Saving…', shellSaved: 'Saved', saveShellFailed: 'Could not save shell setting', shellExecutableRejected: 'The current Runtime Host could not run that path as GNU Bash. Check that the Host runs Windows, the path exists, and the file is named bash.exe.', }, about: { - loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Environment info copied', pasteHint: 'Paste it directly into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', subtitle: 'A local-first AI assistant · Desktop runtime', privacyLabel: 'Privacy and security', privacyTitle: 'Local first · Private by default', privacyPoints: ['Tasks, settings, credentials, and Skill instructions stay in the local workspace.', 'Model keys stay in a local credential file; subscription tokens use secure system storage.', 'Maka sends no usage telemetry and contacts a model provider only when you enable it.', 'High-risk tool operations require explicit permission in the task.', 'Messages, tool calls, permission decisions, and mode changes are retained locally for each task.'], copying: 'Copying…', copyEnvironment: 'Copy environment info', copyHelp: 'Copy version and platform details to help diagnose an issue. The workspace path is excluded.', keyboardShortcuts: 'Keyboard shortcuts', keyboardShortcutsHelp: 'Every shortcut Maka responds to.', keyboardShortcutsOpen: 'View', reportIssueLabel: 'Report an issue', + loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', subtitle: 'A local-first AI assistant · Desktop runtime', privacyLabel: 'Privacy and security', privacyTitle: 'Local first · Private by default', privacyPoints: ['Tasks, settings, credentials, and Skill instructions stay in the local workspace.', 'Model keys stay in a local credential file; subscription tokens use secure system storage.', 'Maka sends no usage telemetry and contacts a model provider only when you enable it.', 'High-risk tool operations require explicit permission in the task.', 'Messages, tool calls, permission decisions, and mode changes are retained locally for each task.'], copying: 'Copying…', copyDiagnostics: 'Copy diagnostics', copyHelp: 'Copy version, platform, a home-redacted workspace path, and recent redacted Desktop and Runtime Host logs. The report is written only to the clipboard and is never uploaded automatically.', keyboardShortcuts: 'Keyboard shortcuts', keyboardShortcutsHelp: 'Every shortcut Maka responds to.', keyboardShortcutsOpen: 'View', reportIssueLabel: 'Report an issue', updatesTitle: 'Software updates', checkForUpdates: 'Check for updates', checkingForUpdates: 'Checking…', diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 342143bd67..2bc80bdd5d 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -33,7 +33,7 @@ export const STATIC_COMMAND_IDS = [ 'diag:copy-today-daily-review', 'diag:paste-today-daily-review', 'diag:save-today-daily-review', - 'diag:copy-env-summary', + 'diag:copy-diagnostics', 'diag:test-network-proxy', 'diag:open-local-memory', ] as const; @@ -102,15 +102,18 @@ const STATIC_COMMAND_KEYWORDS: Record = { '文件', '导出', ], - 'diag:copy-env-summary': [ + 'diag:copy-diagnostics': [ 'env', 'environment', 'version', + 'diagnostics', + 'logs', 'about', 'bug', 'report', '环境', '版本', + '日志', '关于', '诊断', '汇报', @@ -221,7 +224,8 @@ type ShellCopy = { reviewSaveFallback: string; pasteFailedTitle: string; reviewUnavailable: string; - environmentCopiedTitle: string; + diagnosticsCopiedTitle: string; + diagnosticsCopiedDescription: string; clipboardDenied: string; networkPassedTitle: string; networkFailedTitle: string; @@ -559,9 +563,9 @@ const ZH_STATIC_COMMANDS: Record = { hint: '用系统保存对话框', group: '诊断', }, - 'diag:copy-env-summary': { - label: '复制环境信息', - hint: 'Markdown · bug report 友好', + 'diag:copy-diagnostics': { + label: '复制诊断信息', + hint: '脱敏日志 · 仅写入剪贴板', group: '诊断', }, 'diag:test-network-proxy': { @@ -655,9 +659,9 @@ const EN_STATIC_COMMANDS: Record = { hint: 'Use the system save dialog', group: 'Diagnostics', }, - 'diag:copy-env-summary': { - label: 'Copy environment information', - hint: 'Markdown · ready for bug reports', + 'diag:copy-diagnostics': { + label: 'Copy diagnostics', + hint: 'Redacted logs · clipboard only', group: 'Diagnostics', }, 'diag:test-network-proxy': { @@ -833,7 +837,8 @@ const SHELL_COPY_BY_LOCALE = { reviewSaveFallback: '保存每日回顾失败,请稍后重试。', pasteFailedTitle: '粘贴失败', reviewUnavailable: '今日回顾暂时不可用,请稍后重试。', - environmentCopiedTitle: '已复制环境信息', + diagnosticsCopiedTitle: '已复制诊断信息', + diagnosticsCopiedDescription: '检查内容后,可直接粘贴到问题报告', clipboardDenied: '剪贴板不可用或被系统拒绝', networkPassedTitle: '网络代理测试通过', networkFailedTitle: '网络代理测试失败', @@ -1333,7 +1338,8 @@ const SHELL_COPY_BY_LOCALE = { reviewSaveFallback: 'The Daily Review could not be saved. Try again later.', pasteFailedTitle: 'Paste failed', reviewUnavailable: "Today's review is temporarily unavailable. Try again later.", - environmentCopiedTitle: 'Environment information copied', + diagnosticsCopiedTitle: 'Diagnostics copied', + diagnosticsCopiedDescription: 'Review the contents, then paste them into the issue report', clipboardDenied: 'The clipboard is unavailable or was denied', networkPassedTitle: 'Network proxy test passed', networkFailedTitle: 'Network proxy test failed', diff --git a/apps/desktop/src/renderer/session-workspace-errors.ts b/apps/desktop/src/renderer/session-workspace-errors.ts index e442264a6e..c5a60fce46 100644 --- a/apps/desktop/src/renderer/session-workspace-errors.ts +++ b/apps/desktop/src/renderer/session-workspace-errors.ts @@ -1,14 +1,28 @@ import type { UiLocale } from '@maka/core/ui-locale'; +import type { ToastDiagnosticTarget } from '@maka/ui'; import { getShellCopy } from './locales/shell-copy.js'; const SESSION_WORKSPACE_UNAVAILABLE_CODE = 'SESSION_WORKSPACE_UNAVAILABLE'; export function showSessionWorkspaceUnavailableToast( - toastApi: { error(title: string, description?: string): void }, + 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); + toastApi.error( + copy.workspaceUnavailableTitle, + copy.workspaceUnavailableDescription, + undefined, + diagnosticTarget, + ); } export function isSessionWorkspaceUnavailableError(error: unknown): boolean { diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx index 75a7d58dfd..0c61666504 100644 --- a/apps/desktop/src/renderer/settings/about-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useState } from 'react'; +import { useEffect, useId, useState, type ReactNode } from 'react'; import { Badge, Link, List, ListItem } from '@astryxdesign/core'; import { Sparkles } from '@maka/ui/icons'; import { @@ -10,25 +10,18 @@ import { useUiLocale, } from '@maka/ui'; import type { AppUpdateStatus } from '../../preload/bridge-contract.js'; -import { SettingsActions, SettingsPage, SettingsSection } from './settings-section'; -import { SettingRow } from './settings-rows'; -import { settingsActionErrorMessage } from './settings-error-copy'; -import { SettingsSkeletonStack } from './settings-skeleton'; -import { useActionGuard } from './use-action-guard'; +import { SettingsActions, SettingsPage, SettingsSection } from './settings-section.js'; +import { SettingRow } from './settings-rows.js'; +import { settingsActionErrorMessage } from './settings-error-copy.js'; +import { SettingsSkeletonStack } from './settings-skeleton.js'; +import { useActionGuard } from './use-action-guard.js'; import { aboutUpdateStatusDetail } from './about-update-status.js'; import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js'; import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js'; type AppInfo = Awaited>; -const PLATFORM_LABEL: Record = { - darwin: 'macOS', - win32: 'Windows', - linux: 'Linux', -}; - -/** Where 复制环境信息 is meant to be pasted (owner msg `36501869`). */ -const ISSUE_TRACKER_URL = 'https://github.com/maka-agent/maka-agent/issues'; +const ISSUE_TRACKER_URL = 'https://github.com/apache/maka/issues'; export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { const locale = useUiLocale(); @@ -36,14 +29,14 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { const sharedCopy = getSettingsSharedCopy(locale); const [info, setInfo] = useState(null); const [infoError, setInfoError] = useState(null); - const [copyingEnvSummary, setCopyingEnvSummary] = useState(false); + const [copyingDiagnostics, setCopyingDiagnostics] = useState(false); const [updateStatus, setUpdateStatus] = useState(null); const [checkingUpdate, setCheckingUpdate] = useState(false); - const envSummaryCopyGuard = useActionGuard<'copy'>(); + const diagnosticCopyGuard = useActionGuard<'copy'>(); const checkUpdateGuard = useActionGuard<'check'>(); const aboutPageMountedRef = useMountedRef(); const toast = useToast(); - const envSummaryHelpId = useId(); + const diagnosticsHelpId = useId(); const updateHelpId = useId(); useEffect(() => { @@ -84,33 +77,22 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { }; }, []); - if (!info && !infoError) { - return ( - - ); - } - - if (!info) { - return ( - - - - ); + async function copyDiagnostics() { + if (!diagnosticCopyGuard.begin('copy')) return; + setCopyingDiagnostics(true); + try { + await window.maka.diagnostics.copyReport({ surface: 'manual' }); + if (aboutPageMountedRef.current) toast.success(copy.copied, copy.pasteHint); + } catch { + if (aboutPageMountedRef.current) { + toast.error(copy.copyFailed, copy.clipboardUnavailable); + } + } finally { + diagnosticCopyGuard.finish(); + if (aboutPageMountedRef.current) setCopyingDiagnostics(false); + } } - const platformPretty = PLATFORM_LABEL[info.platform] ?? info.platform; - async function checkForUpdates() { if (!checkUpdateGuard.begin('check')) return; setCheckingUpdate(true); @@ -130,144 +112,123 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { } } - async function copyEnvSummary() { - if (!info) return; - if (!envSummaryCopyGuard.begin('copy')) return; - setCopyingEnvSummary(true); - // Markdown block ready to paste into a problem report. Deliberately excludes - // workspacePath since that can leak the OS username; user can still copy - // it from the Data page if needed. - const buildLine = - info.buildMode === 'dev' - ? `- Build: dev${info.buildCommit ? ` @ ${info.buildCommit}` : ''}` - : '- Build: packaged'; - const summary = [ - `**Maka** v${info.appVersion}`, - ``, - `- Electron: ${info.electronVersion}`, - `- Node: ${info.nodeVersion}`, - `- Chrome: ${info.chromeVersion}`, - `- Platform: ${platformPretty} ${info.osRelease}`, - `- Arch: ${info.arch}`, - buildLine, - ].join('\n'); - try { - await navigator.clipboard.writeText(summary); - if (aboutPageMountedRef.current) { - toast.success(copy.copied, copy.pasteHint); - } - } catch { - if (aboutPageMountedRef.current) { - toast.error(copy.copyFailed, copy.clipboardUnavailable); - } - } finally { - envSummaryCopyGuard.finish(); - if (aboutPageMountedRef.current) { - setCopyingEnvSummary(false); - } - } - } - - return ( - - /* 64% of the 48px plate, matching .providerLogo's fill */} - iconClassName="settingsAboutLogo" - headingRowClassName="settingsAboutHeading" - title="Maka" - badge={ - <> - - - - } - subtitle={copy.subtitle} - subtitleClassName="settingsAboutTagline" + let aboutContent: ReactNode; + if (!info && !infoError) { + aboutContent = ( + - {/* Detail audit: the five privacy commitments rendered inside an info - Banner — five lines of bold status-blue body copy, the exact blue - flood DESIGN.md's Signal-Not-Texture rule forbids. They are ordinary - statements, so they read as a quiet marker list in a labeled group. */} - - - {/* Fragment-wrapped: ListItem single-line-truncates STRING labels, - and a privacy commitment must wrap, not ellipsize. */} - {copy.privacyPoints.map((point) => {point}} />)} - - - {/* UX audit (owner msg `30f736ed`): this group used to print Electron / - Node / Chrome, OS + arch, the workspace path, and "storage: local" as - four readout rows. The only task any of it serves is "send my - environment to a developer", and the 复制环境信息 button already does - that task completely — the rows were the button's payload, spread out - for the user to read and then not act on. - - The workspace path also had a second home on the 数据 page, which is - the one that can actually open and copy it, and "storage: local" only - repeated a line the privacy list above already makes. - - What is left is the version itself (in the hero above) and the one - action. */} - {/* The keyboard sheet's home. It used to be reachable only from the - titlebar's `…` drawer and from two shortcuts — which made the panel - that lists the shortcuts openable only by shortcut. It is reference - material about the app, so it belongs on 关于, and this is the entry - a mouse can find. */} - {props.onOpenKeyboardHelp && ( - + ); + } else if (!info) { + aboutContent = ( + + ); + } else { + aboutContent = ( + <> + /* 64% of the 48px plate, matching .providerLogo's fill */} + iconClassName="settingsAboutLogo" + headingRowClassName="settingsAboutHeading" + title="Maka" + badge={ + <> + + + + } + subtitle={copy.subtitle} + subtitleClassName="settingsAboutTagline" + /> + {/* Detail audit: the five privacy commitments rendered inside an info + Banner — five lines of bold status-blue body copy, the exact blue + flood DESIGN.md's Signal-Not-Texture rule forbids. They are ordinary + statements, so they read as a quiet marker list in a labeled group. */} + + + {/* Fragment-wrapped: ListItem single-line-truncates STRING labels, + and a privacy commitment must wrap, not ellipsize. */} + {copy.privacyPoints.map((point) => {point}} />)} + + + {/* The keyboard sheet's home. It used to be reachable only from the + titlebar's `…` drawer and from two shortcuts — which made the panel + that lists the shortcuts openable only by shortcut. It is reference + material about the app, so it belongs on 关于, and this is the entry + a mouse can find. */} + {props.onOpenKeyboardHelp && ( + + + )} + /> + + )} + +