From f1cb162fc2b17217fea95bef14e651b375b0bc05 Mon Sep 17 00:00:00 2001
From: M4n5ter
Date: Fri, 21 Aug 2026 21:07:54 +0800
Subject: [PATCH 1/8] feat(desktop): copy diagnostics on demand
Promote the environment-summary action into a manual diagnostic capture that reuses the bounded, redacted Desktop and Runtime Host report pipeline. Manual capture remains available when no Runtime Host or About metadata is available and writes only to the system clipboard.
Generated-by: Codex
---
.../main-process-diagnostics.test.ts | 85 ++++++++++--
.../src/main/desktop-diagnostics-ipc-main.ts | 48 ++++---
.../src/main/main-process-diagnostics.ts | 46 ++++---
apps/desktop/src/main/runtime-host-boot.ts | 4 +
apps/desktop/src/preload/bridge-contract.d.ts | 4 +-
.../src/preload/diagnostics-contract.ts | 15 ++-
apps/desktop/src/preload/preload.ts | 11 +-
.../src/renderer/app-shell-command-actions.ts | 37 ++----
apps/desktop/src/renderer/app-shell.tsx | 2 +-
.../src/renderer/command-palette-commands.ts | 23 ++--
apps/desktop/src/renderer/error-boundary.tsx | 2 +-
.../locales/settings-preferences-copy.ts | 6 +-
.../src/renderer/locales/shell-copy.ts | 28 ++--
.../renderer/settings/about-settings-page.tsx | 122 +++++++-----------
14 files changed, 240 insertions(+), 193 deletions(-)
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..ece4a9f94e 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,7 +48,7 @@ const runtimeHostDiagnostics = {
};
test('formats one redacted Desktop and Runtime Host diagnostic report', () => {
- const report = formatDesktopErrorDiagnosticReport(
+ const report = formatDesktopDiagnosticReport(
{
surface: 'toast',
title: 'Connection failed',
@@ -71,21 +71,48 @@ 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),
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', extra: true }),
/Invalid Desktop diagnostic input/,
);
});
+test('accepts only renderer context for a manual diagnostic capture', () => {
+ assert.deepEqual(
+ parseDesktopDiagnosticInput({ surface: 'manual', rendererLocale: 'en-US' }),
+ { surface: 'manual', rendererLocale: 'en-US' },
+ );
+ assert.throws(
+ () => parseDesktopDiagnosticInput({ surface: 'manual', title: 'Not an error' }),
+ /Invalid Desktop diagnostic input/,
+ );
+});
+
+test('formats a manual capture without inventing an error', () => {
+ const report = formatDesktopDiagnosticReport(
+ { surface: 'manual', 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 +125,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,7 +137,7 @@ 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(
{} as never,
@@ -134,13 +162,14 @@ 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(
@@ -231,13 +260,14 @@ 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(
{} as never,
@@ -271,6 +301,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 +320,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,
@@ -310,3 +341,39 @@ test('keeps every diagnostic read bound to the scoped Host during a switch', asy
assert.deepEqual(await copying, { ok: true });
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);
+ assert.deepEqual(
+ await handler(
+ {} as never,
+ undefined,
+ { surface: 'manual', rendererLocale: 'en-US' },
+ ),
+ { ok: true },
+ );
+ 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/);
+});
diff --git a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
index f0cbfdd655..f6f8c55f61 100644
--- a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
+++ b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
@@ -9,39 +9,46 @@ import {
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,
+ ) => Promise;
+};
+
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',
+ 'diagnostics:copyReport',
async (_event, scope: unknown, rawInput: unknown): Promise => {
- const host = requireDesktopTargetScope(scope);
- const runtime = deps.resolveRuntimeHost(host);
- const input = parseDesktopErrorDiagnosticInput(rawInput);
+ const input = parseDesktopDiagnosticInput(rawInput);
+ const runtime = input.surface === 'manual'
+ ? deps.resolveActiveRuntimeHost()
+ : deps.resolveRuntimeHost(requireDesktopTargetScope(scope));
let runtimeHost: RuntimeHostDiagnosticRead;
if (!runtime) {
- runtimeHost = { ok: false, error: 'Runtime Host is reconnecting' };
+ runtimeHost = {
+ ok: false,
+ error: input.surface === 'manual'
+ ? 'Runtime Host is unavailable'
+ : 'Runtime Host is reconnecting',
+ };
} else {
try {
runtimeHost = { ok: true, value: await runtime.getDiagnostics() };
@@ -56,11 +63,12 @@ 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,
);
runtimeExecution = turn
? { ok: true, value: turn }
@@ -75,7 +83,7 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps):
};
}
}
- const report = formatDesktopErrorDiagnosticReport(
+ const report = formatDesktopDiagnosticReport(
input,
deps.environment(),
deps.mainLogs(),
diff --git a/apps/desktop/src/main/main-process-diagnostics.ts b/apps/desktop/src/main/main-process-diagnostics.ts
index d11c4114a3..19dc9ea949 100644
--- a/apps/desktop/src/main/main-process-diagnostics.ts
+++ b/apps/desktop/src/main/main-process-diagnostics.ts
@@ -4,7 +4,7 @@ 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,
+ DesktopDiagnosticInput,
DesktopExecutionDiagnosticTarget,
} from '../preload/diagnostics-contract.js';
@@ -51,19 +51,28 @@ export function installMainProcessLogCapture(buffer: DiagnosticLogBuffer = mainP
installConsoleDiagnosticLogCapture(buffer);
}
-export function parseDesktopErrorDiagnosticInput(input: unknown): DesktopErrorDiagnosticInput {
+export function parseDesktopDiagnosticInput(input: unknown): DesktopDiagnosticInput {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new TypeError('Invalid Desktop diagnostic input');
}
const record = input as Record;
+ const rendererKeys = new Set(['surface', 'rendererUserAgent', 'rendererLocale']);
+ if (record.surface === 'manual') {
+ if (Object.keys(record).some((key) => !rendererKeys.has(key))) {
+ throw new TypeError('Invalid Desktop diagnostic input');
+ }
+ return {
+ surface: 'manual',
+ ...optionalBoundedString(record, 'rendererUserAgent', INPUT_LIMITS.rendererUserAgent),
+ ...optionalBoundedString(record, 'rendererLocale', INPUT_LIMITS.rendererLocale),
+ };
+ }
const allowedKeys = new Set([
- 'surface',
+ ...rendererKeys,
'title',
'description',
'details',
'execution',
- 'rendererUserAgent',
- 'rendererLocale',
]);
if (Object.keys(record).some((key) => !allowedKeys.has(key))) {
throw new TypeError('Invalid Desktop diagnostic input');
@@ -85,24 +94,22 @@ export function parseDesktopErrorDiagnosticInput(input: unknown): DesktopErrorDi
};
}
-export function formatDesktopErrorDiagnosticReport(
- input: DesktopErrorDiagnosticInput,
+export function formatDesktopDiagnosticReport(
+ input: DesktopDiagnosticInput,
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 +147,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);
}
}
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 76932aa736..b6ff53e80e 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),
});
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 70bdff7262..a19f3fc5c9 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -100,7 +100,7 @@ import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-sna
import type { DesktopExternalSessionCatalogItem } from './external-session-catalog.js';
import type {
DesktopDiagnosticCopyResult,
- DesktopErrorDiagnosticInput,
+ DesktopDiagnosticInput,
} from './diagnostics-contract.js';
import type { Result } from '@maka/core/result';
import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs';
@@ -1121,7 +1121,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..887a581624 100644
--- a/apps/desktop/src/preload/diagnostics-contract.ts
+++ b/apps/desktop/src/preload/diagnostics-contract.ts
@@ -4,16 +4,25 @@ export interface DesktopExecutionDiagnosticTarget {
readonly eventId: string;
}
-export interface DesktopErrorDiagnosticInput {
+interface DesktopDiagnosticRendererContext {
+ readonly rendererUserAgent?: string;
+ readonly rendererLocale?: string;
+}
+
+export interface DesktopManualDiagnosticInput extends DesktopDiagnosticRendererContext {
+ readonly surface: 'manual';
+}
+
+export interface DesktopErrorDiagnosticInput extends DesktopDiagnosticRendererContext {
readonly surface: 'toast' | 'renderer_crash';
readonly title: string;
readonly description?: string;
readonly details?: string;
readonly execution?: DesktopExecutionDiagnosticTarget;
- readonly rendererUserAgent?: string;
- readonly rendererLocale?: string;
}
+export type DesktopDiagnosticInput = DesktopManualDiagnosticInput | DesktopErrorDiagnosticInput;
+
export type DesktopDiagnosticCopyResult =
| { readonly ok: true }
| { readonly ok: false; readonly reason: 'clipboard_unavailable' };
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index e6520dd530..ee1827878b 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -49,7 +49,7 @@ import {
} from './transcript-contract.js';
import type {
DesktopDiagnosticCopyResult,
- DesktopErrorDiagnosticInput,
+ DesktopDiagnosticInput,
} from './diagnostics-contract.js';
import type { ConnectionEvent } from '@maka/core/connections';
import type {
@@ -2603,12 +2603,15 @@ const makaBridge = {
},
},
diagnostics: {
- async copyErrorReport(input: DesktopErrorDiagnosticInput): Promise {
+ async copyReport(input: DesktopDiagnosticInput): Promise {
+ if (input.surface === 'manual') {
+ return ipcRenderer.invoke('diagnostics:copyReport', undefined, input);
+ }
if (!input.execution) {
- return invokeActiveRuntimeHost('diagnostics:copyErrorReport', input);
+ return invokeActiveRuntimeHost('diagnostics:copyReport', input);
}
const session = await runtimeHostSessionRef(input.execution.sessionId);
- return ipcRenderer.invoke('diagnostics:copyErrorReport', session.scope, {
+ return ipcRenderer.invoke('diagnostics:copyReport', session.scope, {
...input,
execution: { ...input.execution, sessionId: session.sessionId },
});
diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts
index 78269742b7..8d2cc5ac83 100644
--- a/apps/desktop/src/renderer/app-shell-command-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-command-actions.ts
@@ -363,37 +363,16 @@ export function buildAppShellCommandList(
);
}
},
- onCopyEnvSummary: async () => {
+ onCopyDiagnostics: async () => {
const { toastApi } = optionsRef.current;
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}`,
- );
+ const result = await window.maka.diagnostics.copyReport({
+ surface: "manual",
+ rendererUserAgent: navigator.userAgent,
+ rendererLocale: navigator.language,
+ });
+ if (!result.ok) throw new Error(copy.clipboardDenied);
+ toastApi.success(copy.diagnosticsCopiedTitle, copy.diagnosticsCopiedDescription);
} catch (err) {
toastApi.error(
copy.copyFailedTitle,
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 0708c2996e..20a258435f 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -279,7 +279,7 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {
() => ({
label: getShellCopy(uiLocale).errorBoundary.copyReport,
onClick: (input) => {
- void window.maka.diagnostics.copyErrorReport({
+ void window.maka.diagnostics.copyReport({
surface: 'toast',
title: input.title,
...(input.description ? { description: input.description } : {}),
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..c66800c14f 100644
--- a/apps/desktop/src/renderer/error-boundary.tsx
+++ b/apps/desktop/src/renderer/error-boundary.tsx
@@ -82,7 +82,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode; locale: UiLo
try {
const diagnostics = window.maka?.diagnostics;
if (diagnostics) {
- const result = await diagnostics.copyErrorReport({
+ const result = await diagnostics.copyReport({
surface: 'renderer_crash',
title: `${error.name}: ${error.message}`,
details: formatRendererErrorDetails(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/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx
index 75a7d58dfd..d48021a29f 100644
--- a/apps/desktop/src/renderer/settings/about-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx
@@ -21,14 +21,7 @@ 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,6 +77,45 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
};
}, []);
+ async function copyDiagnostics() {
+ if (!diagnosticCopyGuard.begin('copy')) return;
+ setCopyingDiagnostics(true);
+ try {
+ const result = await window.maka.diagnostics.copyReport({
+ surface: 'manual',
+ rendererUserAgent: navigator.userAgent,
+ rendererLocale: navigator.language,
+ });
+ if (!result.ok) throw new Error('Desktop diagnostic clipboard write failed');
+ 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 diagnosticActions = (
+
+
+
+
+ );
+
if (!info && !infoError) {
return (
+ {diagnosticActions}
);
}
- const platformPretty = PLATFORM_LABEL[info.platform] ?? info.platform;
-
async function checkForUpdates() {
if (!checkUpdateGuard.begin('check')) return;
setCheckingUpdate(true);
@@ -130,44 +161,6 @@ 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 (
{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
@@ -257,19 +237,7 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
{info.buildMode === 'dev' ? copy.updateDevBuildHelp : copy.updateHelp}
-
-
-
-
+ {diagnosticActions}
);
}
From b5d00e050464c2591d34530b15f56622329438e4 Mon Sep 17 00:00:00 2001
From: M4n5ter
Date: Fri, 21 Aug 2026 21:21:32 +0800
Subject: [PATCH 2/8] fix(desktop): keep diagnostics visible while About loads
About metadata reads can remain pending while the Runtime Host reconnects. Render the Host-independent diagnostic action alongside the loading skeleton and cover that state with a server-rendered behavior test.
Generated-by: Codex
---
.../__tests__/about-settings-page.test.ts | 18 ++++++++++++
.../renderer/settings/about-settings-page.tsx | 29 ++++++++++---------
.../src/renderer/settings/settings-rows.tsx | 2 +-
.../src/renderer/settings/use-action-guard.ts | 4 +--
4 files changed, 37 insertions(+), 16 deletions(-)
create mode 100644 apps/desktop/src/main/__tests__/about-settings-page.test.ts
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);
+ assert.match(markup, /role="status"[^>]*aria-busy="true"/);
+});
diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx
index d48021a29f..f3af26fa7d 100644
--- a/apps/desktop/src/renderer/settings/about-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx
@@ -10,11 +10,11 @@ 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';
@@ -118,14 +118,17 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
if (!info && !infoError) {
return (
-
+
+
+ {diagnosticActions}
+
);
}
diff --git a/apps/desktop/src/renderer/settings/settings-rows.tsx b/apps/desktop/src/renderer/settings/settings-rows.tsx
index 7ede104f42..de9f33a5ac 100644
--- a/apps/desktop/src/renderer/settings/settings-rows.tsx
+++ b/apps/desktop/src/renderer/settings/settings-rows.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
-import { SettingsRow } from './settings-section';
+import { SettingsRow } from './settings-section.js';
/**
* `value` is optional: a row may exist for what it explains and what it lets
diff --git a/apps/desktop/src/renderer/settings/use-action-guard.ts b/apps/desktop/src/renderer/settings/use-action-guard.ts
index 4c24443c08..ab82f3bc99 100644
--- a/apps/desktop/src/renderer/settings/use-action-guard.ts
+++ b/apps/desktop/src/renderer/settings/use-action-guard.ts
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
-import { createKeyedActionGuard, type KeyedActionGuard } from './action-guard';
-import { createOneShotActionGuard, type OneShotActionGuard } from './oauth-login-flow-guard';
+import { createKeyedActionGuard, type KeyedActionGuard } from './action-guard.js';
+import { createOneShotActionGuard, type OneShotActionGuard } from './oauth-login-flow-guard.js';
/**
* Shared one-shot action guards for Settings async actions.
From 405706c6109c49f1a8b8106367baf8aeda9cd4c8 Mon Sep 17 00:00:00 2001
From: M4n5ter
Date: Fri, 21 Aug 2026 21:46:47 +0800
Subject: [PATCH 3/8] fix(desktop): bind manual diagnostics to task host
Command-palette captures now use the Runtime Host that owns the visible task, without falling back to the default Host when that target is unavailable. The About page also keeps its diagnostic action outside metadata-state branching so every state exposes the same recovery path.
Generated-by: Codex
---
.../main-process-diagnostics.test.ts | 153 ++++++++++++
.../src/main/desktop-diagnostics-ipc-main.ts | 26 +-
.../src/main/main-process-diagnostics.ts | 19 +-
.../src/preload/diagnostics-contract.ts | 2 +
apps/desktop/src/preload/preload.ts | 15 +-
.../src/renderer/app-shell-command-actions.ts | 6 +-
.../renderer/settings/about-settings-page.tsx | 231 +++++++++---------
7 files changed, 327 insertions(+), 125 deletions(-)
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 ece4a9f94e..e4a22aa7fc 100644
--- a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
+++ b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
@@ -92,10 +92,24 @@ test('accepts only renderer context for a manual diagnostic capture', () => {
parseDesktopDiagnosticInput({ surface: 'manual', rendererLocale: 'en-US' }),
{ surface: 'manual', rendererLocale: 'en-US' },
);
+ assert.deepEqual(
+ parseDesktopDiagnosticInput({
+ surface: 'manual',
+ targetSessionId: '["remote-host","session-1"]',
+ }),
+ {
+ surface: 'manual',
+ targetSessionId: '["remote-host","session-1"]',
+ },
+ );
assert.throws(
() => parseDesktopDiagnosticInput({ surface: 'manual', title: 'Not an error' }),
/Invalid Desktop diagnostic input/,
);
+ assert.throws(
+ () => parseDesktopDiagnosticInput({ surface: 'manual', targetSessionId: 'session-1' }),
+ /Invalid Desktop diagnostic targetSessionId/,
+ );
});
test('formats a manual capture without inventing an error', () => {
@@ -377,3 +391,142 @@ test('copies manual Desktop diagnostics without an active Runtime Host', async (
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);
+ assert.deepEqual(
+ await handler(
+ {} as never,
+ { hostId: 'remote-host', targetEpoch: 'remote-target' },
+ {
+ surface: 'manual',
+ targetSessionId: '["remote-host","session-1"]',
+ },
+ ),
+ { ok: true },
+ );
+ 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);
+ assert.deepEqual(
+ await handler(
+ {} as never,
+ undefined,
+ {
+ surface: 'manual',
+ targetSessionId: '["remote-host","session-1"]',
+ },
+ ),
+ { ok: true },
+ );
+ assert.match(
+ clipboard,
+ /Diagnostics unavailable: Runtime Host for this task is unavailable/,
+ );
+ assert.deepEqual(
+ await handler(
+ {} as never,
+ { hostId: 'remote-host', targetEpoch: 'stale-target' },
+ {
+ surface: 'manual',
+ targetSessionId: '["remote-host","session-1"]',
+ },
+ ),
+ { ok: true },
+ );
+ 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 manual diagnostic scope from a different task Host', 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('Mismatched scope must be rejected before Host resolution');
+ },
+ writeClipboard() {
+ throw new Error('Mismatched scope 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',
+ targetSessionId: '["remote-host","session-1"]',
+ },
+ ),
+ /Desktop diagnostic target belongs to a different Runtime Host/,
+ );
+});
diff --git a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
index f6f8c55f61..ba5d79e4ce 100644
--- a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
+++ b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
@@ -5,6 +5,7 @@ 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 {
+ parseDesktopSessionKey,
requireDesktopTargetScope,
type DesktopTargetScope,
} from '../shared/runtime-host-identity.js';
@@ -38,15 +39,32 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps):
'diagnostics:copyReport',
async (_event, scope: unknown, rawInput: unknown): Promise => {
const input = parseDesktopDiagnosticInput(rawInput);
- const runtime = input.surface === 'manual'
- ? deps.resolveActiveRuntimeHost()
- : deps.resolveRuntimeHost(requireDesktopTargetScope(scope));
+ let runtime: RuntimeHostDiagnosticsClient | undefined;
+ if (input.surface !== 'manual') {
+ runtime = deps.resolveRuntimeHost(requireDesktopTargetScope(scope));
+ } else if (input.targetSessionId === undefined) {
+ runtime = deps.resolveActiveRuntimeHost();
+ } else if (scope !== undefined) {
+ const target = requireDesktopTargetScope(scope);
+ if (target.hostId !== parseDesktopSessionKey(input.targetSessionId).hostId) {
+ throw new Error('Desktop diagnostic target belongs to a different Runtime Host');
+ }
+ 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: input.surface === 'manual'
- ? 'Runtime Host is unavailable'
+ ? input.targetSessionId === undefined
+ ? 'Runtime Host is unavailable'
+ : 'Runtime Host for this task is unavailable'
: 'Runtime Host is reconnecting',
};
} else {
diff --git a/apps/desktop/src/main/main-process-diagnostics.ts b/apps/desktop/src/main/main-process-diagnostics.ts
index 19dc9ea949..27b20aec07 100644
--- a/apps/desktop/src/main/main-process-diagnostics.ts
+++ b/apps/desktop/src/main/main-process-diagnostics.ts
@@ -7,6 +7,7 @@ import type {
DesktopDiagnosticInput,
DesktopExecutionDiagnosticTarget,
} from '../preload/diagnostics-contract.js';
+import { parseDesktopSessionKey } from '../shared/runtime-host-identity.js';
const INPUT_LIMITS = {
title: 512,
@@ -58,11 +59,15 @@ export function parseDesktopDiagnosticInput(input: unknown): DesktopDiagnosticIn
const record = input as Record;
const rendererKeys = new Set(['surface', 'rendererUserAgent', 'rendererLocale']);
if (record.surface === 'manual') {
- if (Object.keys(record).some((key) => !rendererKeys.has(key))) {
+ const manualKeys = new Set([...rendererKeys, 'targetSessionId']);
+ if (Object.keys(record).some((key) => !manualKeys.has(key))) {
throw new TypeError('Invalid Desktop diagnostic input');
}
return {
surface: 'manual',
+ ...(record.targetSessionId !== undefined
+ ? { targetSessionId: requireDesktopDiagnosticSessionKey(record.targetSessionId) }
+ : {}),
...optionalBoundedString(record, 'rendererUserAgent', INPUT_LIMITS.rendererUserAgent),
...optionalBoundedString(record, 'rendererLocale', INPUT_LIMITS.rendererLocale),
};
@@ -161,6 +166,18 @@ export function formatDesktopDiagnosticReport(
return collapseHomePath(redacted, environment.homePath, environment.platform);
}
+function requireDesktopDiagnosticSessionKey(value: unknown): string {
+ if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 512) {
+ throw new TypeError('Invalid Desktop diagnostic targetSessionId');
+ }
+ try {
+ parseDesktopSessionKey(value);
+ } catch {
+ throw new TypeError('Invalid Desktop diagnostic targetSessionId');
+ }
+ return value;
+}
+
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/preload/diagnostics-contract.ts b/apps/desktop/src/preload/diagnostics-contract.ts
index 887a581624..68ceb45519 100644
--- a/apps/desktop/src/preload/diagnostics-contract.ts
+++ b/apps/desktop/src/preload/diagnostics-contract.ts
@@ -11,6 +11,8 @@ interface DesktopDiagnosticRendererContext {
export interface DesktopManualDiagnosticInput extends DesktopDiagnosticRendererContext {
readonly surface: 'manual';
+ /** Desktop session key whose Runtime Host should contribute diagnostics. */
+ readonly targetSessionId?: string;
}
export interface DesktopErrorDiagnosticInput extends DesktopDiagnosticRendererContext {
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index ee1827878b..ca1ba252d0 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -2605,7 +2605,20 @@ const makaBridge = {
diagnostics: {
async copyReport(input: DesktopDiagnosticInput): Promise {
if (input.surface === 'manual') {
- return ipcRenderer.invoke('diagnostics:copyReport', undefined, input);
+ if (!input.targetSessionId) {
+ return ipcRenderer.invoke('diagnostics:copyReport', undefined, input);
+ }
+ const ref = parseDesktopSessionKey(input.targetSessionId);
+ try {
+ await runtimeHostScopeList();
+ } catch {
+ return ipcRenderer.invoke('diagnostics:copyReport', undefined, input);
+ }
+ return ipcRenderer.invoke(
+ 'diagnostics:copyReport',
+ runtimeHostScopes.get(ref.hostId),
+ input,
+ );
}
if (!input.execution) {
return invokeActiveRuntimeHost('diagnostics:copyReport', input);
diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts
index 8d2cc5ac83..2af3981d22 100644
--- a/apps/desktop/src/renderer/app-shell-command-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-command-actions.ts
@@ -364,10 +364,14 @@ export function buildAppShellCommandList(
}
},
onCopyDiagnostics: async () => {
- const { toastApi } = optionsRef.current;
+ const { captureComposerImportOwner, toastApi } = optionsRef.current;
+ const owner = captureComposerImportOwner();
try {
const result = await window.maka.diagnostics.copyReport({
surface: "manual",
+ ...(owner.navSection === "sessions" && owner.sessionId
+ ? { targetSessionId: owner.sessionId }
+ : {}),
rendererUserAgent: navigator.userAgent,
rendererLocale: navigator.language,
});
diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx
index f3af26fa7d..f0fed97b08 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 {
@@ -98,53 +98,6 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
}
}
- const diagnosticActions = (
-
-
-
-
- );
-
- if (!info && !infoError) {
- return (
-
-
- {diagnosticActions}
-
- );
- }
-
- if (!info) {
- return (
-
-
- {diagnosticActions}
-
- );
- }
-
async function checkForUpdates() {
if (!checkUpdateGuard.begin('check')) return;
setCheckingUpdate(true);
@@ -164,83 +117,125 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
}
}
- 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}>} />)}
-
-
- {/* 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 && (
+
+
+ )}
+ />
+
+ )}
+
+
- )}
-
- void checkForUpdates()}
- label={checkingUpdate || updateStatus?.state === 'checking'
- ? copy.checkingForUpdates
- : copy.checkForUpdates}
- />
- )}
- />
-
- {info.buildMode === 'dev' ? copy.updateDevBuildHelp : copy.updateHelp}
-
+ >
+ );
+ }
+
+ return (
+
+ {aboutContent}
+
+
+
- {diagnosticActions}
);
}
From 47689272a80fecf02c2b18a2a540615b5afbfd77 Mon Sep 17 00:00:00 2001
From: M4n5ter
Date: Fri, 21 Aug 2026 22:07:50 +0800
Subject: [PATCH 4/8] fix(desktop): preserve diagnostic host context
Resolve manual diagnostic targets at the preload authority boundary so existing tasks and new-task profiles cannot silently fall back to an unrelated Runtime Host. Let IPC rejection remain the single clipboard failure channel and keep renderer environment metadata owned by preload.
Generated-by: Codex
---
.../app-shell-command-actions.test.ts | 27 ++++
.../main-process-diagnostics.test.ts | 148 +++++++++++-------
.../src/main/desktop-diagnostics-ipc-main.ts | 26 +--
.../src/main/main-process-diagnostics.ts | 42 +++--
apps/desktop/src/preload/bridge-contract.d.ts | 7 +-
.../src/preload/diagnostics-contract.ts | 36 ++++-
apps/desktop/src/preload/preload.ts | 98 ++++++++++--
.../src/renderer/app-shell-command-actions.ts | 29 +++-
apps/desktop/src/renderer/app-shell.tsx | 3 +-
apps/desktop/src/renderer/error-boundary.tsx | 5 +-
.../renderer/settings/about-settings-page.tsx | 7 +-
11 files changed, 292 insertions(+), 136 deletions(-)
create mode 100644 apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts
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..bb0cd7b783
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts
@@ -0,0 +1,27 @@
+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,
+ );
+});
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 e4a22aa7fc..02f274540d 100644
--- a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
+++ b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
@@ -87,19 +87,27 @@ test('bounds renderer diagnostic text and rejects unknown fields', () => {
);
});
-test('accepts only renderer context for a manual diagnostic capture', () => {
+test('accepts only a Runtime Host policy and renderer context for manual capture', () => {
assert.deepEqual(
- parseDesktopDiagnosticInput({ surface: 'manual', rendererLocale: 'en-US' }),
- { surface: 'manual', rendererLocale: 'en-US' },
+ parseDesktopDiagnosticInput({
+ surface: 'manual',
+ runtimeHost: { kind: 'default' },
+ rendererLocale: 'en-US',
+ }),
+ {
+ surface: 'manual',
+ runtimeHost: { kind: 'default' },
+ rendererLocale: 'en-US',
+ },
);
assert.deepEqual(
parseDesktopDiagnosticInput({
surface: 'manual',
- targetSessionId: '["remote-host","session-1"]',
+ runtimeHost: { kind: 'target', hostId: 'remote-host' },
}),
{
surface: 'manual',
- targetSessionId: '["remote-host","session-1"]',
+ runtimeHost: { kind: 'target', hostId: 'remote-host' },
},
);
assert.throws(
@@ -107,14 +115,21 @@ test('accepts only renderer context for a manual diagnostic capture', () => {
/Invalid Desktop diagnostic input/,
);
assert.throws(
- () => parseDesktopDiagnosticInput({ surface: 'manual', targetSessionId: 'session-1' }),
- /Invalid Desktop diagnostic targetSessionId/,
+ () => parseDesktopDiagnosticInput({
+ surface: 'manual',
+ runtimeHost: { kind: 'target', hostId: '' },
+ }),
+ /Invalid Desktop diagnostic hostId/,
);
});
test('formats a manual capture without inventing an error', () => {
const report = formatDesktopDiagnosticReport(
- { surface: 'manual', rendererLocale: 'en-US' },
+ {
+ surface: 'manual',
+ runtimeHost: { kind: 'default' },
+ rendererLocale: 'en-US',
+ },
environment,
['main log'],
{ ok: true, value: runtimeHostDiagnostics },
@@ -153,13 +168,12 @@ test('copies Desktop diagnostics while Runtime Host is unavailable', async () =>
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' },
);
- assert.deepEqual(result, { ok: true });
assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/);
assert.match(clipboard, /Diagnostics unavailable: Runtime Host disconnected/);
});
@@ -185,13 +199,10 @@ test('copies Desktop diagnostics while the scoped Host is reconnecting', async (
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' },
);
assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/);
assert.match(clipboard, /Diagnostics unavailable: Runtime Host is reconnecting/);
@@ -283,7 +294,7 @@ test('copies bounded evidence for the exact failed Turn', async () => {
const handler = handlers.get('diagnostics:copyReport');
assert.ok(handler);
- const result = await handler(
+ await handler(
{} as never,
{ hostId: 'test-host', targetEpoch: 'test-target' },
{
@@ -293,7 +304,6 @@ test('copies bounded evidence for the exact failed Turn', async () => {
},
);
- 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/);
});
@@ -352,7 +362,7 @@ 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']);
});
@@ -379,13 +389,14 @@ test('copies manual Desktop diagnostics without an active Runtime Host', async (
const handler = handlers.get('diagnostics:copyReport');
assert.ok(handler);
- assert.deepEqual(
- await handler(
- {} as never,
- undefined,
- { surface: 'manual', rendererLocale: 'en-US' },
- ),
- { ok: true },
+ await handler(
+ {} as never,
+ undefined,
+ {
+ surface: 'manual',
+ runtimeHost: { kind: 'default' },
+ rendererLocale: 'en-US',
+ },
);
assert.match(clipboard, /Capture\nSurface: manual/);
assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/);
@@ -424,16 +435,13 @@ test('copies diagnostics from the Runtime Host that owns the current task', asyn
const handler = handlers.get('diagnostics:copyReport');
assert.ok(handler);
- assert.deepEqual(
- await handler(
- {} as never,
- { hostId: 'remote-host', targetEpoch: 'remote-target' },
- {
- surface: 'manual',
- targetSessionId: '["remote-host","session-1"]',
- },
- ),
- { ok: true },
+ await handler(
+ {} as never,
+ { hostId: 'remote-host', targetEpoch: 'remote-target' },
+ {
+ surface: 'manual',
+ runtimeHost: { kind: 'target', hostId: 'remote-host' },
+ },
);
assert.match(clipboard, /Recent Runtime Host logs \(1\)\nremote task host log/);
});
@@ -463,31 +471,25 @@ test('keeps targeted manual capture Desktop-only when the task Host is unavailab
const handler = handlers.get('diagnostics:copyReport');
assert.ok(handler);
- assert.deepEqual(
- await handler(
- {} as never,
- undefined,
- {
- surface: 'manual',
- targetSessionId: '["remote-host","session-1"]',
- },
- ),
- { ok: true },
+ await handler(
+ {} as never,
+ undefined,
+ {
+ surface: 'manual',
+ runtimeHost: { kind: 'unavailable' },
+ },
);
assert.match(
clipboard,
/Diagnostics unavailable: Runtime Host for this task is unavailable/,
);
- assert.deepEqual(
- await handler(
- {} as never,
- { hostId: 'remote-host', targetEpoch: 'stale-target' },
- {
- surface: 'manual',
- targetSessionId: '["remote-host","session-1"]',
- },
- ),
- { ok: true },
+ await handler(
+ {} as never,
+ { hostId: 'remote-host', targetEpoch: 'stale-target' },
+ {
+ surface: 'manual',
+ runtimeHost: { kind: 'target', hostId: 'remote-host' },
+ },
);
assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/);
assert.match(
@@ -524,9 +526,39 @@ test('rejects a manual diagnostic scope from a different task Host', async () =>
{ hostId: 'default-host', targetEpoch: 'default-target' },
{
surface: 'manual',
- targetSessionId: '["remote-host","session-1"]',
+ runtimeHost: { kind: 'target', hostId: 'remote-host' },
},
),
/Desktop diagnostic target belongs to a different Runtime Host/,
);
});
+
+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', runtimeHost: { kind: 'default' } },
+ ),
+ /System clipboard unavailable/,
+ );
+});
diff --git a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
index ba5d79e4ce..3ee9c3e15d 100644
--- a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
+++ b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
@@ -3,9 +3,7 @@ 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 {
- parseDesktopSessionKey,
requireDesktopTargetScope,
type DesktopTargetScope,
} from '../shared/runtime-host-identity.js';
@@ -37,16 +35,23 @@ export interface DesktopDiagnosticsIpcDeps {
export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps): void {
deps.ipcMain.handle(
'diagnostics:copyReport',
- async (_event, scope: unknown, rawInput: unknown): Promise => {
+ async (_event, scope: unknown, rawInput: unknown): Promise => {
const input = parseDesktopDiagnosticInput(rawInput);
let runtime: RuntimeHostDiagnosticsClient | undefined;
if (input.surface !== 'manual') {
runtime = deps.resolveRuntimeHost(requireDesktopTargetScope(scope));
- } else if (input.targetSessionId === undefined) {
+ } else if (input.runtimeHost.kind === 'default') {
+ if (scope !== undefined) {
+ throw new Error('Default Desktop diagnostics must not carry a Host scope');
+ }
runtime = deps.resolveActiveRuntimeHost();
- } else if (scope !== undefined) {
+ } else if (input.runtimeHost.kind === 'unavailable') {
+ if (scope !== undefined) {
+ throw new Error('Unavailable Desktop diagnostics must not carry a Host scope');
+ }
+ } else {
const target = requireDesktopTargetScope(scope);
- if (target.hostId !== parseDesktopSessionKey(input.targetSessionId).hostId) {
+ if (target.hostId !== input.runtimeHost.hostId) {
throw new Error('Desktop diagnostic target belongs to a different Runtime Host');
}
try {
@@ -62,7 +67,7 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps):
runtimeHost = {
ok: false,
error: input.surface === 'manual'
- ? input.targetSessionId === undefined
+ ? input.runtimeHost.kind === 'default'
? 'Runtime Host is unavailable'
: 'Runtime Host for this task is unavailable'
: 'Runtime Host is reconnecting',
@@ -108,12 +113,7 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps):
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 27b20aec07..abc4cec520 100644
--- a/apps/desktop/src/main/main-process-diagnostics.ts
+++ b/apps/desktop/src/main/main-process-diagnostics.ts
@@ -4,10 +4,10 @@ import { redactSecrets } from '@maka/core/redaction';
import type { TurnTrace } from '@maka/core/session-trace';
import type { HostDiagnosticsResult } from '@maka/runtime-host/protocol';
import type {
- DesktopDiagnosticInput,
+ DesktopDiagnosticWireInput,
DesktopExecutionDiagnosticTarget,
+ DesktopManualDiagnosticRuntimeHost,
} from '../preload/diagnostics-contract.js';
-import { parseDesktopSessionKey } from '../shared/runtime-host-identity.js';
const INPUT_LIMITS = {
title: 512,
@@ -52,22 +52,20 @@ export function installMainProcessLogCapture(buffer: DiagnosticLogBuffer = mainP
installConsoleDiagnosticLogCapture(buffer);
}
-export function parseDesktopDiagnosticInput(input: unknown): DesktopDiagnosticInput {
+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 rendererKeys = new Set(['surface', 'rendererUserAgent', 'rendererLocale']);
if (record.surface === 'manual') {
- const manualKeys = new Set([...rendererKeys, 'targetSessionId']);
+ const manualKeys = new Set([...rendererKeys, 'runtimeHost']);
if (Object.keys(record).some((key) => !manualKeys.has(key))) {
throw new TypeError('Invalid Desktop diagnostic input');
}
return {
surface: 'manual',
- ...(record.targetSessionId !== undefined
- ? { targetSessionId: requireDesktopDiagnosticSessionKey(record.targetSessionId) }
- : {}),
+ runtimeHost: parseManualDiagnosticRuntimeHost(record.runtimeHost),
...optionalBoundedString(record, 'rendererUserAgent', INPUT_LIMITS.rendererUserAgent),
...optionalBoundedString(record, 'rendererLocale', INPUT_LIMITS.rendererLocale),
};
@@ -100,7 +98,7 @@ export function parseDesktopDiagnosticInput(input: unknown): DesktopDiagnosticIn
}
export function formatDesktopDiagnosticReport(
- input: DesktopDiagnosticInput,
+ input: DesktopDiagnosticWireInput,
environment: DesktopDiagnosticEnvironment,
mainLogs: readonly string[],
runtimeHost: RuntimeHostDiagnosticRead,
@@ -166,16 +164,28 @@ export function formatDesktopDiagnosticReport(
return collapseHomePath(redacted, environment.homePath, environment.platform);
}
-function requireDesktopDiagnosticSessionKey(value: unknown): string {
- if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 512) {
- throw new TypeError('Invalid Desktop diagnostic targetSessionId');
+function parseManualDiagnosticRuntimeHost(value: unknown): DesktopManualDiagnosticRuntimeHost {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ throw new TypeError('Invalid Desktop manual diagnostic Runtime Host');
}
- try {
- parseDesktopSessionKey(value);
- } catch {
- throw new TypeError('Invalid Desktop diagnostic targetSessionId');
+ const record = value as Record;
+ if (
+ (record.kind === 'default' || record.kind === 'unavailable') &&
+ Object.keys(record).length === 1
+ ) {
+ return { kind: record.kind };
}
- return value;
+ if (
+ record.kind === 'target' &&
+ Object.keys(record).length === 2 &&
+ Object.hasOwn(record, 'hostId')
+ ) {
+ return {
+ kind: 'target',
+ hostId: requireDiagnosticId(record.hostId, 'hostId'),
+ };
+ }
+ throw new TypeError('Invalid Desktop manual diagnostic Runtime Host');
}
function parseExecutionDiagnosticTarget(value: unknown): DesktopExecutionDiagnosticTarget {
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index a19f3fc5c9..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,
- DesktopDiagnosticInput,
-} 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: {
- copyReport(input: DesktopDiagnosticInput): 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 68ceb45519..f6b3459ee3 100644
--- a/apps/desktop/src/preload/diagnostics-contract.ts
+++ b/apps/desktop/src/preload/diagnostics-contract.ts
@@ -9,13 +9,22 @@ interface DesktopDiagnosticRendererContext {
readonly rendererLocale?: string;
}
-export interface DesktopManualDiagnosticInput extends DesktopDiagnosticRendererContext {
+export type DesktopManualDiagnosticTarget =
+ | {
+ readonly kind: 'session';
+ readonly sessionId: string;
+ }
+ | {
+ readonly kind: 'profile';
+ readonly profileId: string;
+ };
+
+export interface DesktopManualDiagnosticInput {
readonly surface: 'manual';
- /** Desktop session key whose Runtime Host should contribute diagnostics. */
- readonly targetSessionId?: string;
+ readonly target?: DesktopManualDiagnosticTarget;
}
-export interface DesktopErrorDiagnosticInput extends DesktopDiagnosticRendererContext {
+export interface DesktopErrorDiagnosticInput {
readonly surface: 'toast' | 'renderer_crash';
readonly title: string;
readonly description?: string;
@@ -25,6 +34,19 @@ export interface DesktopErrorDiagnosticInput extends DesktopDiagnosticRendererCo
export type DesktopDiagnosticInput = DesktopManualDiagnosticInput | DesktopErrorDiagnosticInput;
-export type DesktopDiagnosticCopyResult =
- | { readonly ok: true }
- | { readonly ok: false; readonly reason: 'clipboard_unavailable' };
+export type DesktopManualDiagnosticRuntimeHost =
+ | { readonly kind: 'default' }
+ | { readonly kind: 'target'; readonly hostId: string }
+ | { readonly kind: 'unavailable' };
+
+export type DesktopManualDiagnosticWireInput = Omit &
+ DesktopDiagnosticRendererContext & {
+ readonly runtimeHost: DesktopManualDiagnosticRuntimeHost;
+ };
+
+export type DesktopErrorDiagnosticWireInput = DesktopErrorDiagnosticInput &
+ DesktopDiagnosticRendererContext;
+
+export type DesktopDiagnosticWireInput =
+ | DesktopManualDiagnosticWireInput
+ | DesktopErrorDiagnosticWireInput;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index ca1ba252d0..dae754b63c 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -48,8 +48,11 @@ import {
type DesktopTranscriptOpenResult,
} from './transcript-contract.js';
import type {
- DesktopDiagnosticCopyResult,
DesktopDiagnosticInput,
+ DesktopErrorDiagnosticWireInput,
+ DesktopManualDiagnosticRuntimeHost,
+ DesktopManualDiagnosticTarget,
+ DesktopManualDiagnosticWireInput,
} from './diagnostics-contract.js';
import type { ConnectionEvent } from '@maka/core/connections';
import type {
@@ -302,6 +305,62 @@ async function runtimeHostSessionRef(sessionId: string): Promise<{
return { scope, sessionId: ref.sessionId };
}
+type ManualDiagnosticRuntimeHostResolution = {
+ readonly runtimeHost: DesktopManualDiagnosticRuntimeHost;
+ readonly scope?: DesktopTargetScope;
+};
+
+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 { runtimeHost: { kind: 'default' } };
+ const selector = parseManualDiagnosticTarget(value);
+ try {
+ await runtimeHostScopeList();
+ } catch {
+ return { runtimeHost: { kind: 'unavailable' } };
+ }
+ const hostId = selector.kind === 'host'
+ ? selector.hostId
+ : runtimeHostProfiles.get(selector.profileId);
+ const scope = hostId ? runtimeHostScopes.get(hostId) : undefined;
+ return scope
+ ? { runtimeHost: { kind: 'target', hostId: scope.hostId }, scope }
+ : { runtimeHost: { kind: 'unavailable' } };
+}
+
+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,29 +2662,34 @@ const makaBridge = {
},
},
diagnostics: {
- async copyReport(input: DesktopDiagnosticInput): Promise {
+ async copyReport(input: DesktopDiagnosticInput): Promise {
+ const rendererContext = {
+ rendererUserAgent: navigator.userAgent,
+ rendererLocale: navigator.language,
+ };
if (input.surface === 'manual') {
- if (!input.targetSessionId) {
- return ipcRenderer.invoke('diagnostics:copyReport', undefined, input);
- }
- const ref = parseDesktopSessionKey(input.targetSessionId);
- try {
- await runtimeHostScopeList();
- } catch {
- return ipcRenderer.invoke('diagnostics:copyReport', undefined, input);
- }
- return ipcRenderer.invoke(
+ const { target, ...manualInput } = input;
+ const resolution = await resolveManualDiagnosticRuntimeHost(target);
+ const wireInput: DesktopManualDiagnosticWireInput = {
+ ...manualInput,
+ runtimeHost: resolution.runtimeHost,
+ ...rendererContext,
+ };
+ await ipcRenderer.invoke(
'diagnostics:copyReport',
- runtimeHostScopes.get(ref.hostId),
- input,
+ resolution.scope,
+ wireInput,
);
+ return;
}
+ const wireInput: DesktopErrorDiagnosticWireInput = { ...input, ...rendererContext };
if (!input.execution) {
- return invokeActiveRuntimeHost('diagnostics:copyReport', input);
+ await invokeActiveRuntimeHost('diagnostics:copyReport', wireInput);
+ return;
}
const session = await runtimeHostSessionRef(input.execution.sessionId);
- return ipcRenderer.invoke('diagnostics:copyReport', session.scope, {
- ...input,
+ await ipcRenderer.invoke('diagnostics:copyReport', session.scope, {
+ ...wireInput,
execution: { ...input.execution, sessionId: session.sessionId },
});
},
diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts
index 2af3981d22..e68fa89915 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,
@@ -54,6 +55,7 @@ export interface AppShellCommandListOptions {
defaultConnection: string | null;
dailyReviewBridge: DailyReviewBridge;
messages: StoredMessage[];
+ newTaskProfileId: string | undefined;
sessions: SessionSummary[];
themePref: ThemePreference;
visibleSessions: SessionSummary[];
@@ -81,6 +83,17 @@ export interface AppShellCommandListOptions {
toastApi: ToastApi;
}
+export function resolveManualDiagnosticTarget(
+ owner: Pick,
+ newTaskProfileId: string | undefined,
+): DesktopManualDiagnosticTarget | 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 {
@@ -364,18 +377,18 @@ export function buildAppShellCommandList(
}
},
onCopyDiagnostics: async () => {
- const { captureComposerImportOwner, toastApi } = optionsRef.current;
+ const {
+ captureComposerImportOwner,
+ newTaskProfileId,
+ toastApi,
+ } = optionsRef.current;
const owner = captureComposerImportOwner();
+ const target = resolveManualDiagnosticTarget(owner, newTaskProfileId);
try {
- const result = await window.maka.diagnostics.copyReport({
+ await window.maka.diagnostics.copyReport({
surface: "manual",
- ...(owner.navSection === "sessions" && owner.sessionId
- ? { targetSessionId: owner.sessionId }
- : {}),
- rendererUserAgent: navigator.userAgent,
- rendererLocale: navigator.language,
+ ...(target ? { target } : {}),
});
- if (!result.ok) throw new Error(copy.clipboardDenied);
toastApi.success(copy.diagnosticsCopiedTitle, copy.diagnosticsCopiedDescription);
} catch (err) {
toastApi.error(
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 20a258435f..4429e7658c 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -285,8 +285,6 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {
...(input.description ? { description: input.description } : {}),
...(input.diagnosticDetails ? { details: input.diagnosticDetails } : {}),
...(input.diagnosticTarget ? { execution: input.diagnosticTarget } : {}),
- rendererUserAgent: navigator.userAgent,
- rendererLocale: navigator.language,
})
.catch(() => undefined);
},
@@ -2859,6 +2857,7 @@ function AppShellContent({
defaultConnection: defaultHostConnections.snapshot.defaultConnection,
dailyReviewBridge,
messages,
+ newTaskProfileId: newTask.selectedProfileId,
sessions,
themePref,
visibleSessions,
diff --git a/apps/desktop/src/renderer/error-boundary.tsx b/apps/desktop/src/renderer/error-boundary.tsx
index c66800c14f..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.copyReport({
+ 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/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx
index f0fed97b08..0c61666504 100644
--- a/apps/desktop/src/renderer/settings/about-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx
@@ -81,12 +81,7 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
if (!diagnosticCopyGuard.begin('copy')) return;
setCopyingDiagnostics(true);
try {
- const result = await window.maka.diagnostics.copyReport({
- surface: 'manual',
- rendererUserAgent: navigator.userAgent,
- rendererLocale: navigator.language,
- });
- if (!result.ok) throw new Error('Desktop diagnostic clipboard write failed');
+ await window.maka.diagnostics.copyReport({ surface: 'manual' });
if (aboutPageMountedRef.current) toast.success(copy.copied, copy.pasteHint);
} catch {
if (aboutPageMountedRef.current) {
From f37892228c22d3a652db299edae27a57ae3b7301 Mon Sep 17 00:00:00 2001
From: M4n5ter
Date: Fri, 21 Aug 2026 22:26:01 +0800
Subject: [PATCH 5/8] fix(desktop): keep error diagnostics available
Route default and task diagnostic intent independently from concrete Host scope so Host outages still yield Desktop evidence without falling back to another Host. Keep async toast actions visible when copying fails, and make scope the only concrete Host authority.
Generated-by: Codex
---
.../main-process-diagnostics.test.ts | 130 +++++++++++++++---
.../src/main/desktop-diagnostics-ipc-main.ts | 27 ++--
.../src/main/main-process-diagnostics.ts | 42 ++----
.../src/preload/diagnostics-contract.ts | 11 +-
apps/desktop/src/preload/preload.ts | 50 ++++---
apps/desktop/src/renderer/app-shell.tsx | 17 +--
packages/ui/src/toast.tsx | 19 ++-
7 files changed, 195 insertions(+), 101 deletions(-)
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 02f274540d..283ed2578c 100644
--- a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
+++ b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
@@ -52,6 +52,7 @@ test('formats one redacted Desktop and Runtime Host diagnostic report', () => {
{
surface: 'toast',
title: 'Connection failed',
+ hostTarget: 'default',
description: 'api_key=sk-secretvalue123',
rendererLocale: 'en-US',
},
@@ -74,6 +75,7 @@ test('bounds renderer diagnostic text and rejects unknown fields', () => {
const input = parseDesktopDiagnosticInput({
surface: 'toast',
title: '🚀'.repeat(513),
+ hostTarget: 'default',
description: 'x'.repeat(30 * 1024),
});
@@ -82,32 +84,37 @@ test('bounds renderer diagnostic text and rejects unknown fields', () => {
assert.ok(Buffer.byteLength(input.description ?? '') <= 24 * 1024);
assert.match(input.title, /$/);
assert.throws(
- () => parseDesktopDiagnosticInput({ 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 policy and renderer context for manual capture', () => {
+test('accepts only a Runtime Host target and renderer context for capture', () => {
assert.deepEqual(
parseDesktopDiagnosticInput({
surface: 'manual',
- runtimeHost: { kind: 'default' },
+ hostTarget: 'default',
rendererLocale: 'en-US',
}),
{
surface: 'manual',
- runtimeHost: { kind: 'default' },
+ hostTarget: 'default',
rendererLocale: 'en-US',
},
);
assert.deepEqual(
parseDesktopDiagnosticInput({
surface: 'manual',
- runtimeHost: { kind: 'target', hostId: 'remote-host' },
+ hostTarget: 'task',
}),
{
surface: 'manual',
- runtimeHost: { kind: 'target', hostId: 'remote-host' },
+ hostTarget: 'task',
},
);
assert.throws(
@@ -117,9 +124,9 @@ test('accepts only a Runtime Host policy and renderer context for manual capture
assert.throws(
() => parseDesktopDiagnosticInput({
surface: 'manual',
- runtimeHost: { kind: 'target', hostId: '' },
+ hostTarget: { kind: 'target', hostId: '' },
}),
- /Invalid Desktop diagnostic hostId/,
+ /Invalid Desktop diagnostic Runtime Host target/,
);
});
@@ -127,7 +134,7 @@ test('formats a manual capture without inventing an error', () => {
const report = formatDesktopDiagnosticReport(
{
surface: 'manual',
- runtimeHost: { kind: 'default' },
+ hostTarget: 'default',
rendererLocale: 'en-US',
},
environment,
@@ -171,7 +178,7 @@ test('copies Desktop diagnostics while Runtime Host is unavailable', async () =>
await handler(
{} as never,
{ hostId: 'test-host', targetEpoch: 'test-target' },
- { surface: 'toast', title: 'Host failed' },
+ { surface: 'toast', title: 'Host failed', hostTarget: 'task' },
);
assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/);
@@ -202,12 +209,91 @@ test('copies Desktop diagnostics while the scoped Host is reconnecting', async (
await handler(
{} as never,
{ hostId: 'test-host', targetEpoch: 'test-target' },
- { surface: 'toast', title: 'Host reconnecting' },
+ { 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('copies error diagnostics when the default Runtime Host cannot be resolved', 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('The default Runtime Host is unavailable');
+ },
+ resolveRuntimeHost: () => {
+ throw new Error('Default capture 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: 'default' },
);
+
assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/);
assert.match(clipboard, /Diagnostics unavailable: Runtime Host is reconnecting/);
});
+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();
@@ -300,6 +386,7 @@ test('copies bounded evidence for the exact failed Turn', async () => {
{
surface: 'toast',
title: 'Conversation error',
+ hostTarget: 'task',
execution: { sessionId: 'session-1', turnId: 'turn-1', eventId: 'event-1' },
},
);
@@ -352,6 +439,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',
@@ -394,7 +482,7 @@ test('copies manual Desktop diagnostics without an active Runtime Host', async (
undefined,
{
surface: 'manual',
- runtimeHost: { kind: 'default' },
+ hostTarget: 'default',
rendererLocale: 'en-US',
},
);
@@ -440,7 +528,7 @@ test('copies diagnostics from the Runtime Host that owns the current task', asyn
{ hostId: 'remote-host', targetEpoch: 'remote-target' },
{
surface: 'manual',
- runtimeHost: { kind: 'target', hostId: 'remote-host' },
+ hostTarget: 'task',
},
);
assert.match(clipboard, /Recent Runtime Host logs \(1\)\nremote task host log/);
@@ -476,7 +564,7 @@ test('keeps targeted manual capture Desktop-only when the task Host is unavailab
undefined,
{
surface: 'manual',
- runtimeHost: { kind: 'unavailable' },
+ hostTarget: 'task',
},
);
assert.match(
@@ -488,7 +576,7 @@ test('keeps targeted manual capture Desktop-only when the task Host is unavailab
{ hostId: 'remote-host', targetEpoch: 'stale-target' },
{
surface: 'manual',
- runtimeHost: { kind: 'target', hostId: 'remote-host' },
+ hostTarget: 'task',
},
);
assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/);
@@ -498,7 +586,7 @@ test('keeps targeted manual capture Desktop-only when the task Host is unavailab
);
});
-test('rejects a manual diagnostic scope from a different task Host', async () => {
+test('rejects a default diagnostic request that carries a task Host scope', async () => {
type IpcHandler = Parameters['handle']>[1];
const handlers = new Map();
registerDesktopDiagnosticsIpc({
@@ -511,10 +599,10 @@ test('rejects a manual diagnostic scope from a different task Host', async () =>
mainLogs: () => [],
resolveActiveRuntimeHost: () => undefined,
resolveRuntimeHost: () => {
- throw new Error('Mismatched scope must be rejected before Host resolution');
+ throw new Error('Default capture must be rejected before Host resolution');
},
writeClipboard() {
- throw new Error('Mismatched scope must be rejected before clipboard output');
+ throw new Error('Default capture must be rejected before clipboard output');
},
});
@@ -526,10 +614,10 @@ test('rejects a manual diagnostic scope from a different task Host', async () =>
{ hostId: 'default-host', targetEpoch: 'default-target' },
{
surface: 'manual',
- runtimeHost: { kind: 'target', hostId: 'remote-host' },
+ hostTarget: 'default',
},
),
- /Desktop diagnostic target belongs to a different Runtime Host/,
+ /Default Desktop diagnostics must not carry a Host scope/,
);
});
@@ -557,7 +645,7 @@ test('rejects the copy request when the main-process clipboard write fails', asy
handler(
{} as never,
undefined,
- { surface: 'manual', runtimeHost: { kind: 'default' } },
+ { surface: 'manual', hostTarget: 'default' },
),
/System clipboard unavailable/,
);
diff --git a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
index 3ee9c3e15d..310478c0f5 100644
--- a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
+++ b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
@@ -38,22 +38,17 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps):
async (_event, scope: unknown, rawInput: unknown): Promise => {
const input = parseDesktopDiagnosticInput(rawInput);
let runtime: RuntimeHostDiagnosticsClient | undefined;
- if (input.surface !== 'manual') {
- runtime = deps.resolveRuntimeHost(requireDesktopTargetScope(scope));
- } else if (input.runtimeHost.kind === 'default') {
+ if (input.hostTarget === 'default') {
if (scope !== undefined) {
throw new Error('Default Desktop diagnostics must not carry a Host scope');
}
- runtime = deps.resolveActiveRuntimeHost();
- } else if (input.runtimeHost.kind === 'unavailable') {
- if (scope !== undefined) {
- throw new Error('Unavailable Desktop diagnostics must not carry a Host scope');
+ try {
+ runtime = deps.resolveActiveRuntimeHost();
+ } catch {
+ runtime = undefined;
}
- } else {
+ } else if (scope !== undefined) {
const target = requireDesktopTargetScope(scope);
- if (target.hostId !== input.runtimeHost.hostId) {
- throw new Error('Desktop diagnostic target belongs to a different Runtime Host');
- }
try {
runtime = deps.resolveRuntimeHost(target);
} catch {
@@ -66,11 +61,13 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps):
if (!runtime) {
runtimeHost = {
ok: false,
- error: input.surface === 'manual'
- ? input.runtimeHost.kind === 'default'
+ error: input.hostTarget === 'default'
+ ? input.surface === 'manual'
? 'Runtime Host is unavailable'
- : 'Runtime Host for this task is unavailable'
- : 'Runtime Host is reconnecting',
+ : 'Runtime Host is reconnecting'
+ : input.surface !== 'manual' && scope !== undefined
+ ? 'Runtime Host is reconnecting'
+ : 'Runtime Host for this task is unavailable',
};
} else {
try {
diff --git a/apps/desktop/src/main/main-process-diagnostics.ts b/apps/desktop/src/main/main-process-diagnostics.ts
index abc4cec520..c193542732 100644
--- a/apps/desktop/src/main/main-process-diagnostics.ts
+++ b/apps/desktop/src/main/main-process-diagnostics.ts
@@ -4,9 +4,9 @@ import { redactSecrets } from '@maka/core/redaction';
import type { TurnTrace } from '@maka/core/session-trace';
import type { HostDiagnosticsResult } from '@maka/runtime-host/protocol';
import type {
+ DesktopDiagnosticHostTarget,
DesktopDiagnosticWireInput,
DesktopExecutionDiagnosticTarget,
- DesktopManualDiagnosticRuntimeHost,
} from '../preload/diagnostics-contract.js';
const INPUT_LIMITS = {
@@ -57,21 +57,25 @@ export function parseDesktopDiagnosticInput(input: unknown): DesktopDiagnosticWi
throw new TypeError('Invalid Desktop diagnostic input');
}
const record = input as Record;
- const rendererKeys = new Set(['surface', 'rendererUserAgent', 'rendererLocale']);
+ const sharedKeys = new Set([
+ 'surface',
+ 'hostTarget',
+ 'rendererUserAgent',
+ 'rendererLocale',
+ ]);
if (record.surface === 'manual') {
- const manualKeys = new Set([...rendererKeys, 'runtimeHost']);
- if (Object.keys(record).some((key) => !manualKeys.has(key))) {
+ if (Object.keys(record).some((key) => !sharedKeys.has(key))) {
throw new TypeError('Invalid Desktop diagnostic input');
}
return {
surface: 'manual',
- runtimeHost: parseManualDiagnosticRuntimeHost(record.runtimeHost),
+ hostTarget: parseDiagnosticHostTarget(record.hostTarget),
...optionalBoundedString(record, 'rendererUserAgent', INPUT_LIMITS.rendererUserAgent),
...optionalBoundedString(record, 'rendererLocale', INPUT_LIMITS.rendererLocale),
};
}
const allowedKeys = new Set([
- ...rendererKeys,
+ ...sharedKeys,
'title',
'description',
'details',
@@ -87,6 +91,7 @@ export function parseDesktopDiagnosticInput(input: unknown): DesktopDiagnosticWi
return {
surface: record.surface,
title,
+ hostTarget: parseDiagnosticHostTarget(record.hostTarget),
...optionalBoundedString(record, 'description', INPUT_LIMITS.description),
...optionalBoundedString(record, 'details', INPUT_LIMITS.details),
...(record.execution !== undefined
@@ -164,28 +169,9 @@ export function formatDesktopDiagnosticReport(
return collapseHomePath(redacted, environment.homePath, environment.platform);
}
-function parseManualDiagnosticRuntimeHost(value: unknown): DesktopManualDiagnosticRuntimeHost {
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
- throw new TypeError('Invalid Desktop manual diagnostic Runtime Host');
- }
- const record = value as Record;
- if (
- (record.kind === 'default' || record.kind === 'unavailable') &&
- Object.keys(record).length === 1
- ) {
- return { kind: record.kind };
- }
- if (
- record.kind === 'target' &&
- Object.keys(record).length === 2 &&
- Object.hasOwn(record, 'hostId')
- ) {
- return {
- kind: 'target',
- hostId: requireDiagnosticId(record.hostId, 'hostId'),
- };
- }
- throw new TypeError('Invalid Desktop manual diagnostic Runtime Host');
+function parseDiagnosticHostTarget(value: unknown): DesktopDiagnosticHostTarget {
+ if (value === 'default' || value === 'task') return value;
+ throw new TypeError('Invalid Desktop diagnostic Runtime Host target');
}
function parseExecutionDiagnosticTarget(value: unknown): DesktopExecutionDiagnosticTarget {
diff --git a/apps/desktop/src/preload/diagnostics-contract.ts b/apps/desktop/src/preload/diagnostics-contract.ts
index f6b3459ee3..dc77306e3e 100644
--- a/apps/desktop/src/preload/diagnostics-contract.ts
+++ b/apps/desktop/src/preload/diagnostics-contract.ts
@@ -34,18 +34,17 @@ export interface DesktopErrorDiagnosticInput {
export type DesktopDiagnosticInput = DesktopManualDiagnosticInput | DesktopErrorDiagnosticInput;
-export type DesktopManualDiagnosticRuntimeHost =
- | { readonly kind: 'default' }
- | { readonly kind: 'target'; readonly hostId: string }
- | { readonly kind: 'unavailable' };
+export type DesktopDiagnosticHostTarget = 'default' | 'task';
export type DesktopManualDiagnosticWireInput = Omit &
DesktopDiagnosticRendererContext & {
- readonly runtimeHost: DesktopManualDiagnosticRuntimeHost;
+ readonly hostTarget: DesktopDiagnosticHostTarget;
};
export type DesktopErrorDiagnosticWireInput = DesktopErrorDiagnosticInput &
- DesktopDiagnosticRendererContext;
+ DesktopDiagnosticRendererContext & {
+ readonly hostTarget: DesktopDiagnosticHostTarget;
+ };
export type DesktopDiagnosticWireInput =
| DesktopManualDiagnosticWireInput
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index dae754b63c..bfa02e1fe2 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -49,8 +49,8 @@ import {
} from './transcript-contract.js';
import type {
DesktopDiagnosticInput,
+ DesktopDiagnosticHostTarget,
DesktopErrorDiagnosticWireInput,
- DesktopManualDiagnosticRuntimeHost,
DesktopManualDiagnosticTarget,
DesktopManualDiagnosticWireInput,
} from './diagnostics-contract.js';
@@ -305,8 +305,8 @@ async function runtimeHostSessionRef(sessionId: string): Promise<{
return { scope, sessionId: ref.sessionId };
}
-type ManualDiagnosticRuntimeHostResolution = {
- readonly runtimeHost: DesktopManualDiagnosticRuntimeHost;
+type DiagnosticRuntimeHostResolution = {
+ readonly hostTarget: DesktopDiagnosticHostTarget;
readonly scope?: DesktopTargetScope;
};
@@ -316,21 +316,25 @@ type ManualDiagnosticHostSelector =
async function resolveManualDiagnosticRuntimeHost(
value: DesktopManualDiagnosticTarget | undefined,
-): Promise {
- if (value === undefined) return { runtimeHost: { kind: 'default' } };
+): 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 { runtimeHost: { kind: 'unavailable' } };
+ return { hostTarget: 'task' };
}
const hostId = selector.kind === 'host'
? selector.hostId
: runtimeHostProfiles.get(selector.profileId);
const scope = hostId ? runtimeHostScopes.get(hostId) : undefined;
- return scope
- ? { runtimeHost: { kind: 'target', hostId: scope.hostId }, scope }
- : { runtimeHost: { kind: 'unavailable' } };
+ return { hostTarget: 'task', ...(scope ? { scope } : {}) };
}
function parseManualDiagnosticTarget(value: unknown): ManualDiagnosticHostSelector {
@@ -2672,7 +2676,7 @@ const makaBridge = {
const resolution = await resolveManualDiagnosticRuntimeHost(target);
const wireInput: DesktopManualDiagnosticWireInput = {
...manualInput,
- runtimeHost: resolution.runtimeHost,
+ hostTarget: resolution.hostTarget,
...rendererContext,
};
await ipcRenderer.invoke(
@@ -2682,16 +2686,28 @@ const makaBridge = {
);
return;
}
- const wireInput: DesktopErrorDiagnosticWireInput = { ...input, ...rendererContext };
- if (!input.execution) {
- await invokeActiveRuntimeHost('diagnostics:copyReport', wireInput);
+ const { execution, ...errorInput } = input;
+ if (!execution) {
+ const wireInput: DesktopErrorDiagnosticWireInput = {
+ ...errorInput,
+ hostTarget: 'default',
+ ...rendererContext,
+ };
+ await ipcRenderer.invoke('diagnostics:copyReport', undefined, wireInput);
return;
}
- const session = await runtimeHostSessionRef(input.execution.sessionId);
- await ipcRenderer.invoke('diagnostics:copyReport', session.scope, {
- ...wireInput,
- execution: { ...input.execution, sessionId: session.sessionId },
+ 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.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 4429e7658c..069a05a273 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -278,16 +278,13 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {
const errorToastAction = useMemo(
() => ({
label: getShellCopy(uiLocale).errorBoundary.copyReport,
- onClick: (input) => {
- void window.maka.diagnostics.copyReport({
- surface: 'toast',
- title: input.title,
- ...(input.description ? { description: input.description } : {}),
- ...(input.diagnosticDetails ? { details: input.diagnosticDetails } : {}),
- ...(input.diagnosticTarget ? { execution: input.diagnosticTarget } : {}),
- })
- .catch(() => undefined);
- },
+ onClick: (input) => window.maka.diagnostics.copyReport({
+ surface: 'toast',
+ title: input.title,
+ ...(input.description ? { description: input.description } : {}),
+ ...(input.diagnosticDetails ? { details: input.diagnosticDetails } : {}),
+ ...(input.diagnosticTarget ? { execution: input.diagnosticTarget } : {}),
+ }),
}),
[uiLocale],
);
diff --git a/packages/ui/src/toast.tsx b/packages/ui/src/toast.tsx
index eae5d5f91a..5f0c21639f 100644
--- a/packages/ui/src/toast.tsx
+++ b/packages/ui/src/toast.tsx
@@ -39,14 +39,14 @@ export type ToastVariant = 'info' | 'success' | 'warning' | 'error';
export interface ToastAction {
label: string;
- onClick(): void;
+ onClick(): void | Promise;
}
export interface ToastErrorAction {
label: string;
onClick(
input: Pick,
- ): void;
+ ): Promise;
}
export interface ToastDiagnosticTarget {
@@ -149,8 +149,19 @@ function ToastController(props: { children: ReactNode; errorAction?: ToastErrorA
size="sm"
label={action.label}
onClick={() => {
- action.onClick();
- dismissCurrent?.();
+ try {
+ const pending = action.onClick();
+ if (!pending) {
+ dismissCurrent?.();
+ return;
+ }
+ void pending.then(
+ () => dismissCurrent?.(),
+ () => undefined,
+ );
+ } catch {
+ // Keep the toast visible so the user can retry the action.
+ }
}}
/>
))}
From ed7f26515c3285bd4992ce641a67bf8fcaaf24d2 Mon Sep 17 00:00:00 2001
From: M4n5ter
Date: Fri, 21 Aug 2026 22:48:43 +0800
Subject: [PATCH 6/8] fix(desktop): preserve diagnostic toast host targets
Attach stable session or profile context when task-scoped errors are surfaced, so copying diagnostics still selects the owning Host before an execution event exists. Keep turn metadata as optional evidence instead of using it as Host authority.
Generated-by: Codex
---
...app-shell-session-settings-actions.test.ts | 8 ++-
.../app-shell-toast-diagnostics.test.ts | 47 ++++++++++++++
.../src/preload/diagnostics-contract.ts | 3 +-
apps/desktop/src/preload/preload.ts | 7 ++-
.../src/renderer/app-shell-chat-actions.ts | 45 +++++++++++---
.../src/renderer/app-shell-command-actions.ts | 14 ++++-
.../desktop/src/renderer/app-shell-effects.ts | 14 ++++-
.../src/renderer/app-shell-project-actions.ts | 43 +++++++------
.../renderer/app-shell-revision-actions.ts | 20 +++++-
.../src/renderer/app-shell-session-events.ts | 12 +++-
.../renderer/app-shell-session-row-actions.ts | 14 ++++-
.../app-shell-session-settings-actions.ts | 28 +++++++--
.../app-shell-session-start-actions.ts | 25 ++++++--
.../src/renderer/app-shell-stop-action.ts | 14 ++++-
.../renderer/app-shell-toast-diagnostics.ts | 23 +++++++
.../src/renderer/app-shell-turn-actions.ts | 11 +++-
apps/desktop/src/renderer/app-shell.tsx | 61 +++++++++++++------
.../src/renderer/session-workspace-errors.ts | 18 +++++-
packages/ui/src/toast.tsx | 9 ++-
19 files changed, 337 insertions(+), 79 deletions(-)
create mode 100644 apps/desktop/src/main/__tests__/app-shell-toast-diagnostics.test.ts
create mode 100644 apps/desktop/src/renderer/app-shell-toast-diagnostics.ts
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..2d87a7e2af
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/app-shell-toast-diagnostics.test.ts
@@ -0,0 +1,47 @@
+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: '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/preload/diagnostics-contract.ts b/apps/desktop/src/preload/diagnostics-contract.ts
index dc77306e3e..c0202be52a 100644
--- a/apps/desktop/src/preload/diagnostics-contract.ts
+++ b/apps/desktop/src/preload/diagnostics-contract.ts
@@ -29,6 +29,7 @@ export interface DesktopErrorDiagnosticInput {
readonly title: string;
readonly description?: string;
readonly details?: string;
+ readonly target?: DesktopManualDiagnosticTarget;
readonly execution?: DesktopExecutionDiagnosticTarget;
}
@@ -41,7 +42,7 @@ export type DesktopManualDiagnosticWireInput = Omit &
DesktopDiagnosticRendererContext & {
readonly hostTarget: DesktopDiagnosticHostTarget;
};
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index bfa02e1fe2..a3cbacf246 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -2686,14 +2686,15 @@ const makaBridge = {
);
return;
}
- const { execution, ...errorInput } = input;
+ const { execution, target, ...errorInput } = input;
if (!execution) {
+ const resolution = await resolveManualDiagnosticRuntimeHost(target);
const wireInput: DesktopErrorDiagnosticWireInput = {
...errorInput,
- hostTarget: 'default',
+ hostTarget: resolution.hostTarget,
...rendererContext,
};
- await ipcRenderer.invoke('diagnostics:copyReport', undefined, wireInput);
+ await ipcRenderer.invoke('diagnostics:copyReport', resolution.scope, wireInput);
return;
}
const session = parseDesktopSessionKey(execution.sessionId);
diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts
index 73b5cd32b9..d04fdd334b 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;
@@ -535,6 +544,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 +559,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 +598,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 +620,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 +670,7 @@ export function createAppShellChatActions(deps: {
...current,
[sessionId]: message,
}));
- toastApi.error(copy.refreshFailedTitle, message);
+ toastApi.error(copy.refreshFailedTitle, message, undefined, { sessionId });
}
return false;
}
@@ -660,7 +687,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 e68fa89915..fe29850bc0 100644
--- a/apps/desktop/src/renderer/app-shell-command-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-command-actions.ts
@@ -26,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 = {
@@ -391,6 +396,11 @@ export function buildAppShellCommandList(
});
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(
@@ -398,6 +408,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-project-actions.ts b/apps/desktop/src/renderer/app-shell-project-actions.ts
index e7394e1f08..151d14c24d 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 },
+ ): void;
};
export interface AppShellProjectActions {
@@ -69,6 +74,10 @@ export function createAppShellProjectActions(deps: {
toastApi,
} = deps;
const copy = getShellCopy(uiLocale).projectActions;
+ const diagnosticTarget = sessionId ? { sessionId } : undefined;
+ const showProjectError = (title: string, description?: string) => {
+ toastApi.error(title, description, undefined, diagnosticTarget);
+ };
async function refreshProjects(): Promise {
return refreshDefaultProjectState();
@@ -115,7 +124,7 @@ export function createAppShellProjectActions(deps: {
return result.project;
} catch (error) {
if (isCurrentProjectPickerRequest()) {
- toastApi.error(
+ showProjectError(
copy.selectDirectoryFailedTitle,
localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale),
);
@@ -135,7 +144,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));
+ showProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale));
return false;
}
}
@@ -147,7 +156,7 @@ export function createAppShellProjectActions(deps: {
await refreshProjects();
onProjectSelected(sessionId);
} catch (error) {
- toastApi.error(
+ showProjectError(
copy.selectDirectoryFailedTitle,
localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale),
);
@@ -159,7 +168,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));
+ showProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale));
return false;
}
}
@@ -178,7 +187,7 @@ export function createAppShellProjectActions(deps: {
}
return await selectProjectRecord(project, false);
} catch (error) {
- toastApi.error(
+ showProjectError(
copy.selectDirectoryFailedTitle,
localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale),
);
@@ -195,7 +204,7 @@ export function createAppShellProjectActions(deps: {
else await refreshProjects();
return result.project;
} catch (error) {
- toastApi.error(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale));
+ showProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale));
return null;
}
}
@@ -205,7 +214,7 @@ export function createAppShellProjectActions(deps: {
await window.maka.projects.rename(projectId, name);
await refreshProjects();
} catch (error) {
- toastApi.error(
+ showProjectError(
copy.projectUpdateFailedTitle,
localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale),
);
@@ -217,7 +226,7 @@ export function createAppShellProjectActions(deps: {
await window.maka.projects.archive(projectId);
await refreshProjects();
} catch (error) {
- toastApi.error(
+ showProjectError(
copy.projectUpdateFailedTitle,
localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale),
);
@@ -229,7 +238,7 @@ export function createAppShellProjectActions(deps: {
await window.maka.projects.restore(projectId);
await refreshProjects();
} catch (error) {
- toastApi.error(
+ showProjectError(
copy.projectUpdateFailedTitle,
localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale),
);
@@ -240,13 +249,13 @@ export function createAppShellProjectActions(deps: {
try {
const result = await window.maka.app.openPath('skills');
if (!result.ok) {
- toastApi.error(
+ showProjectError(
copy.openFailedTitle(openPathActionLabel('skills', uiLocale)),
openPathFailureCopy(result.reason, uiLocale),
);
}
} catch (error) {
- toastApi.error(
+ showProjectError(
copy.openFailedTitle(openPathActionLabel('skills', uiLocale)),
openPathActionErrorMessage(error, 'skills', uiLocale),
);
@@ -257,16 +266,16 @@ export function createAppShellProjectActions(deps: {
try {
const result = await window.maka.app.openPath('project', sessionId);
if (!result.ok) {
- toastApi.error(
+ showProjectError(
copy.openFailedTitle(openPathActionLabel('project', uiLocale)),
openPathFailureCopy(result.reason, uiLocale),
);
}
} catch (error) {
if (isSessionWorkspaceUnavailableError(error)) {
- showSessionWorkspaceUnavailableToast(toastApi, uiLocale);
+ showSessionWorkspaceUnavailableToast(toastApi, uiLocale, diagnosticTarget);
} else {
- toastApi.error(
+ showProjectError(
copy.openFailedTitle(openPathActionLabel('project', uiLocale)),
openPathActionErrorMessage(error, 'project', uiLocale),
);
@@ -278,13 +287,13 @@ export function createAppShellProjectActions(deps: {
try {
const result = await window.maka.app.openPath('workspace');
if (!result.ok) {
- toastApi.error(
+ showProjectError(
copy.openFailedTitle(openPathActionLabel('workspace', uiLocale)),
openPathFailureCopy(result.reason, uiLocale),
);
}
} catch (error) {
- toastApi.error(
+ showProjectError(
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..16dde94e55 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;
@@ -85,7 +90,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);
}
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 069a05a273..0ce911658f 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,13 +280,9 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {
const errorToastAction = useMemo(
() => ({
label: getShellCopy(uiLocale).errorBoundary.copyReport,
- onClick: (input) => window.maka.diagnostics.copyReport({
- surface: 'toast',
- title: input.title,
- ...(input.description ? { description: input.description } : {}),
- ...(input.diagnosticDetails ? { details: input.diagnosticDetails } : {}),
- ...(input.diagnosticTarget ? { execution: input.diagnosticTarget } : {}),
- }),
+ onClick: (input) => window.maka.diagnostics.copyReport(
+ diagnosticInputForErrorToast(input),
+ ),
}),
[uiLocale],
);
@@ -1042,7 +1040,8 @@ function AppShellContent({
try {
const planState = await window.maka.sessions.getPlanState(sessionId);
if (active && planState.activeExecutionId) {
- toastApi.error(
+ showSessionError(
+ sessionId,
shellCopy.planModeExecutionActiveTitle,
shellCopy.planModeExecutionActiveDescription,
);
@@ -1077,7 +1076,8 @@ function AppShellContent({
return true;
} catch (error) {
if (activeIdRef.current === sessionId) {
- toastApi.error(
+ showSessionError(
+ sessionId,
shellCopy.planModeFailedTitle,
localizedShellErrorMessage(error, shellCopy.planModeFallback, uiLocale),
);
@@ -1116,7 +1116,8 @@ function AppShellContent({
return true;
} catch (error) {
if (activeIdRef.current === sessionId) {
- toastApi.error(
+ showSessionError(
+ sessionId,
shellCopy.orchestrationModeFailedTitle,
localizedShellErrorMessage(error, shellCopy.orchestrationModeFallback, uiLocale),
);
@@ -1675,7 +1676,8 @@ function AppShellContent({
else setBottomPanelOpen(true);
})
.catch((error) => {
- toastApi.error(
+ showSessionError(
+ ownerSessionId,
terminalPanelCopy.startFailed,
localizedShellErrorMessage(
error,
@@ -2212,9 +2214,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),
);
@@ -2365,7 +2368,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),
);
@@ -2774,7 +2778,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,
@@ -2783,6 +2791,7 @@ function AppShellContent({
: copy.description,
variant: 'error',
duration: 8000,
+ ...(diagnosticTarget ? { diagnosticTarget } : {}),
...(modelSettingsOwnsComposerHost
? {
action: {
@@ -2795,6 +2804,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;
@@ -2824,7 +2841,8 @@ function AppShellContent({
) {
return;
}
- toastApi.error(
+ showSessionError(
+ sessionId,
desktopConversationCopy.actions.messageReadFailedTitle,
localizedShellErrorMessage(
error,
@@ -3339,7 +3357,8 @@ function AppShellContent({
: {}),
onClear: () => {
void window.maka.goal.clear(activeGoal.sessionId).catch((error) => {
- toastApi.error(
+ showSessionError(
+ activeGoal.sessionId,
shellCopy.goalClearFailedTitle,
localizedShellErrorMessage(
error,
@@ -3360,7 +3379,8 @@ function AppShellContent({
activeGoal.sessionId,
() => window.maka.goal.resume(activeGoal.sessionId),
(error) => {
- toastApi.error(
+ showSessionError(
+ activeGoal.sessionId,
shellCopy.goalResumeFailedTitle,
localizedShellErrorMessage(
error,
@@ -3381,7 +3401,8 @@ function AppShellContent({
activeGoal.sessionId,
() => window.maka.goal.pause(activeGoal.sessionId),
(error) => {
- toastApi.error(
+ showSessionError(
+ activeGoal.sessionId,
shellCopy.goalPauseFailedTitle,
localizedShellErrorMessage(
error,
@@ -3632,6 +3653,8 @@ function AppShellContent({
projectActionsCopy.projectUpdateFailedFallback,
uiLocale,
),
+ undefined,
+ { profileId: host.profileId },
);
});
}}
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/packages/ui/src/toast.tsx b/packages/ui/src/toast.tsx
index 5f0c21639f..99ad58d68e 100644
--- a/packages/ui/src/toast.tsx
+++ b/packages/ui/src/toast.tsx
@@ -49,11 +49,10 @@ export interface ToastErrorAction {
): Promise;
}
-export interface ToastDiagnosticTarget {
- sessionId: string;
- turnId: string;
- eventId: string;
-}
+export type ToastDiagnosticTarget =
+ | { sessionId: string }
+ | { sessionId: string; turnId: string; eventId: string }
+ | { profileId: string };
export interface ToastInput {
title: string;
From 453e58aa533bc7cccbf105830359054eac457344 Mon Sep 17 00:00:00 2001
From: M4n5ter
Date: Fri, 21 Aug 2026 23:24:20 +0800
Subject: [PATCH 7/8] fix(desktop): align diagnostic targets with host
operations
Propagate the session or Runtime Host profile that actually owned each failed operation into manual diagnostics. Keep client-only validation, clipboard failures, and aggregate errors free of misleading Host authority, and preserve the first failing session when reporting multi-session purges.\n\nGenerated-by: Codex
---
.../app-shell-project-actions.test.ts | 54 +++++++++++
.../__tests__/app-shell-session-purge.test.ts | 2 +
.../__tests__/use-shell-connections.test.ts | 48 ++++++++++
.../src/renderer/app-shell-chat-actions.ts | 28 +++++-
.../src/renderer/app-shell-project-actions.ts | 46 ++++++----
.../renderer/app-shell-session-row-actions.ts | 29 +++++-
apps/desktop/src/renderer/app-shell.tsx | 3 +
apps/desktop/src/renderer/artifact-pane.tsx | 64 ++++++++++---
apps/desktop/src/renderer/browser-panel.tsx | 7 +-
.../settings/daily-review-settings-page.tsx | 7 +-
.../renderer/settings/data-settings-page.tsx | 35 +++++--
.../settings/general-settings-page.tsx | 33 ++++++-
.../settings/permission-center-page.tsx | 16 +++-
.../personalization-settings-section.tsx | 11 ++-
.../settings/projects-settings-page.tsx | 53 +++++++++--
.../settings/provider-connection-detail.tsx | 43 +++++++--
.../settings/provider-oauth-section.tsx | 2 +
.../src/renderer/settings/providers-panel.tsx | 18 +++-
.../runtime-host-profiles-section.tsx | 43 +++++++--
.../settings/subagent-settings-page.tsx | 16 +++-
.../renderer/settings/tasks-settings-page.tsx | 9 +-
.../settings/use-connection-detail.ts | 47 ++++++++--
.../use-memory-settings-controller.ts | 91 ++++++++++++++-----
.../renderer/settings/use-oauth-login-flow.ts | 60 +++++++++---
.../settings/web-search-settings-page.tsx | 21 ++++-
.../src/renderer/skill-invocation-feedback.ts | 10 +-
.../src/renderer/use-new-task-target.ts | 18 +++-
.../src/renderer/use-project-context.ts | 3 +
.../src/renderer/use-shell-connections.ts | 19 +++-
.../src/renderer/use-shell-memory-pill.ts | 9 +-
apps/desktop/src/renderer/use-shell-resume.ts | 11 ++-
31 files changed, 729 insertions(+), 127 deletions(-)
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..7fe347f91b 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,60 @@ 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',
+ defaultProfileId: 'default-profile',
+ onProjectSelected: () => {},
+ toastApi: {
+ success: () => {},
+ error: (_title, _description, _details, target) => {
+ diagnosticTargets.push(target);
+ },
+ },
+ });
+
+ await actions.openWorkspaceFolder();
+ await actions.openProjectFolder();
+
+ assert.deepEqual(diagnosticTargets, [
+ { profileId: 'default-profile' },
+ { 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..12b59a7fd3 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
@@ -152,6 +152,7 @@ describe('purgeSessions', () => {
restored: [],
verified: true,
firstError: undefined,
+ firstErrorSessionId: undefined,
});
// Every delete in a sweep carries the archived premise the confirm named.
assert.deepEqual(h.removeOptions, [
@@ -297,6 +298,7 @@ describe('purgeSessions', () => {
assert.deepEqual(outcome.remaining, ['survivor']);
assert.equal(outcome.removed, 1);
assert.equal((outcome.firstError as Error).message, 'busy:committed');
+ assert.equal(outcome.firstErrorSessionId, 'committed');
});
it('claims nothing when the catalog cannot be read back', async () => {
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/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts
index d04fdd334b..f2f4b19cce 100644
--- a/apps/desktop/src/renderer/app-shell-chat-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts
@@ -424,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();
@@ -435,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' });
@@ -497,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;
@@ -507,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,
diff --git a/apps/desktop/src/renderer/app-shell-project-actions.ts b/apps/desktop/src/renderer/app-shell-project-actions.ts
index 151d14c24d..36e228b44f 100644
--- a/apps/desktop/src/renderer/app-shell-project-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-project-actions.ts
@@ -25,7 +25,7 @@ type ToastApi = {
title: string,
description?: string,
diagnosticDetails?: string,
- diagnosticTarget?: { sessionId: string },
+ diagnosticTarget?: { sessionId: string } | { profileId: string },
): void;
};
@@ -56,6 +56,7 @@ export function createAppShellProjectActions(deps: {
projects: readonly ProjectRecord[];
projectCapabilities: DesktopProjectCapabilities;
sessionId?: string;
+ defaultProfileId?: string;
onProjectSelected(ownerSessionId?: string): void;
toastApi: ToastApi;
}): AppShellProjectActions {
@@ -70,13 +71,18 @@ export function createAppShellProjectActions(deps: {
projects,
projectCapabilities,
sessionId,
+ defaultProfileId,
onProjectSelected,
toastApi,
} = deps;
const copy = getShellCopy(uiLocale).projectActions;
- const diagnosticTarget = sessionId ? { sessionId } : undefined;
- const showProjectError = (title: string, description?: string) => {
- toastApi.error(title, description, undefined, diagnosticTarget);
+ const defaultDiagnosticTarget = defaultProfileId ? { profileId: defaultProfileId } : undefined;
+ const sessionDiagnosticTarget = sessionId ? { sessionId } : undefined;
+ const showDefaultProjectError = (title: string, description?: string) => {
+ toastApi.error(title, description, undefined, defaultDiagnosticTarget);
+ };
+ const showSessionProjectError = (title: string, description?: string) => {
+ toastApi.error(title, description, undefined, sessionDiagnosticTarget);
};
async function refreshProjects(): Promise {
@@ -124,7 +130,7 @@ export function createAppShellProjectActions(deps: {
return result.project;
} catch (error) {
if (isCurrentProjectPickerRequest()) {
- showProjectError(
+ showDefaultProjectError(
copy.selectDirectoryFailedTitle,
localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale),
);
@@ -144,7 +150,7 @@ export function createAppShellProjectActions(deps: {
if (!project) return false;
return await selectProjectRecord(project, true);
} catch (error) {
- showProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale));
+ showDefaultProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale));
return false;
}
}
@@ -156,7 +162,7 @@ export function createAppShellProjectActions(deps: {
await refreshProjects();
onProjectSelected(sessionId);
} catch (error) {
- showProjectError(
+ showDefaultProjectError(
copy.selectDirectoryFailedTitle,
localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale),
);
@@ -168,7 +174,7 @@ export function createAppShellProjectActions(deps: {
const project = projects.find((candidate) => candidate.id === projectId);
return project ? await selectProjectRecord(project, false) : false;
} catch (error) {
- showProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale));
+ showDefaultProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale));
return false;
}
}
@@ -187,7 +193,7 @@ export function createAppShellProjectActions(deps: {
}
return await selectProjectRecord(project, false);
} catch (error) {
- showProjectError(
+ showDefaultProjectError(
copy.selectDirectoryFailedTitle,
localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale),
);
@@ -204,7 +210,7 @@ export function createAppShellProjectActions(deps: {
else await refreshProjects();
return result.project;
} catch (error) {
- showProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale));
+ showDefaultProjectError(copy.selectDirectoryFailedTitle, localizedShellErrorMessage(error, copy.readPathFailedFallback, uiLocale));
return null;
}
}
@@ -214,7 +220,7 @@ export function createAppShellProjectActions(deps: {
await window.maka.projects.rename(projectId, name);
await refreshProjects();
} catch (error) {
- showProjectError(
+ showDefaultProjectError(
copy.projectUpdateFailedTitle,
localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale),
);
@@ -226,7 +232,7 @@ export function createAppShellProjectActions(deps: {
await window.maka.projects.archive(projectId);
await refreshProjects();
} catch (error) {
- showProjectError(
+ showDefaultProjectError(
copy.projectUpdateFailedTitle,
localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale),
);
@@ -238,7 +244,7 @@ export function createAppShellProjectActions(deps: {
await window.maka.projects.restore(projectId);
await refreshProjects();
} catch (error) {
- showProjectError(
+ showDefaultProjectError(
copy.projectUpdateFailedTitle,
localizedShellErrorMessage(error, copy.projectUpdateFailedFallback, uiLocale),
);
@@ -249,13 +255,13 @@ export function createAppShellProjectActions(deps: {
try {
const result = await window.maka.app.openPath('skills');
if (!result.ok) {
- showProjectError(
+ showDefaultProjectError(
copy.openFailedTitle(openPathActionLabel('skills', uiLocale)),
openPathFailureCopy(result.reason, uiLocale),
);
}
} catch (error) {
- showProjectError(
+ showDefaultProjectError(
copy.openFailedTitle(openPathActionLabel('skills', uiLocale)),
openPathActionErrorMessage(error, 'skills', uiLocale),
);
@@ -266,16 +272,16 @@ export function createAppShellProjectActions(deps: {
try {
const result = await window.maka.app.openPath('project', sessionId);
if (!result.ok) {
- showProjectError(
+ showSessionProjectError(
copy.openFailedTitle(openPathActionLabel('project', uiLocale)),
openPathFailureCopy(result.reason, uiLocale),
);
}
} catch (error) {
if (isSessionWorkspaceUnavailableError(error)) {
- showSessionWorkspaceUnavailableToast(toastApi, uiLocale, diagnosticTarget);
+ showSessionWorkspaceUnavailableToast(toastApi, uiLocale, sessionDiagnosticTarget);
} else {
- showProjectError(
+ showSessionProjectError(
copy.openFailedTitle(openPathActionLabel('project', uiLocale)),
openPathActionErrorMessage(error, 'project', uiLocale),
);
@@ -287,13 +293,13 @@ export function createAppShellProjectActions(deps: {
try {
const result = await window.maka.app.openPath('workspace');
if (!result.ok) {
- showProjectError(
+ showDefaultProjectError(
copy.openFailedTitle(openPathActionLabel('workspace', uiLocale)),
openPathFailureCopy(result.reason, uiLocale),
);
}
} catch (error) {
- showProjectError(
+ showDefaultProjectError(
copy.openFailedTitle(openPathActionLabel('workspace', uiLocale)),
openPathActionErrorMessage(error, 'workspace', uiLocale),
);
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 16dde94e55..9093e3e977 100644
--- a/apps/desktop/src/renderer/app-shell-session-row-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-session-row-actions.ts
@@ -42,6 +42,8 @@ export interface SessionPurgeOutcome {
verified: boolean;
/** First rejection, so the caller can show a reason rather than a count. */
firstError: unknown;
+ /** Session whose Host produced `firstError`. */
+ firstErrorSessionId?: string;
}
export interface AppShellSessionRowActions {
@@ -213,6 +215,7 @@ export function createAppShellSessionRowActions(deps: {
const unsettled: string[] = [];
const restored: string[] = [];
let firstError: unknown;
+ let firstErrorSessionId: string | undefined;
let removed = 0;
for (const sessionId of sessionIds) {
const key = `${sessionId}:delete`;
@@ -231,14 +234,24 @@ export function createAppShellSessionRowActions(deps: {
else removed += 1;
} catch (error) {
unsettled.push(sessionId);
- firstError ??= error;
+ if (firstError === undefined) {
+ firstError = error;
+ firstErrorSessionId = 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,
+ firstError,
+ firstErrorSessionId,
+ };
}
let listed: SessionSummary[] | undefined;
try {
@@ -247,7 +260,16 @@ 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,
+ firstError,
+ firstErrorSessionId,
+ };
+ }
const present = new Set(listed.map((session) => session.id));
const remaining = unsettled.filter((sessionId) => present.has(sessionId));
return {
@@ -256,6 +278,7 @@ export function createAppShellSessionRowActions(deps: {
restored,
verified: true,
firstError,
+ firstErrorSessionId,
};
}
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 0ce911658f..987db4e611 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -1901,6 +1901,9 @@ function AppShellContent({
sessionCwd: activeSession?.cwd,
sessionProjectId: activeSession?.projectId,
sessionProfileKind: activeDesktopSession?.profileKind,
+ defaultProfileId: newTask.catalog.hosts.length > 0
+ ? newTask.catalog.defaultProfileId
+ : undefined,
onProjectSelected: (ownerSessionId) => {
void refreshSkills();
void refreshManagedSkillSources();
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/settings/daily-review-settings-page.tsx b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx
index 08863114a4..f7ed951c07 100644
--- a/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/daily-review-settings-page.tsx
@@ -80,7 +80,12 @@ export function DailyReviewSettingsPage(props: { connections: readonly LlmConnec
if (mountedRef.current && saveConfigGuard.current === key) setConfig(next);
} catch (error) {
if (mountedRef.current && saveConfigGuard.current === key) {
- toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.saveFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
}
} finally {
if (saveConfigGuard.current === key) saveConfigGuard.finish();
diff --git a/apps/desktop/src/renderer/settings/data-settings-page.tsx b/apps/desktop/src/renderer/settings/data-settings-page.tsx
index 6610bea3df..63f05b81df 100644
--- a/apps/desktop/src/renderer/settings/data-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/data-settings-page.tsx
@@ -56,6 +56,7 @@ export function DataSettingsPage(props: {
const [importStrategy, setImportStrategy] = useState<'skip' | 'overwrite'>('skip');
const [configBusy, setConfigBusy] = useState(null);
const runtimeHostAvailable = host !== undefined && props.runtimeHostStatus === 'ready';
+ const diagnosticTarget = host ? { profileId: host.profileId } : undefined;
useEffect(() => {
if (!host) {
@@ -74,7 +75,7 @@ export function DataSettingsPage(props: {
const message = settingsActionErrorMessage(error, locale);
setInfo(null);
setInfoError(message);
- toast.error(copy.loadFailed, message);
+ toast.error(copy.loadFailed, message, undefined, diagnosticTarget);
});
return () => {
cancelled = true;
@@ -107,11 +108,18 @@ export function DataSettingsPage(props: {
toast.error(
copy.openFailed(openPathActionLabel('workspace', locale)),
openPathFailureCopy(result.reason, locale),
+ undefined,
+ diagnosticTarget,
);
}
} catch (error) {
if (dataPageMountedRef.current) {
- toast.error(copy.openFailed(openPathActionLabel('workspace', locale)), settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.openFailed(openPathActionLabel('workspace', locale)),
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ diagnosticTarget,
+ );
}
}
});
@@ -164,10 +172,20 @@ export function DataSettingsPage(props: {
if (res.ok) {
toast.success(copy.exported, copy.exportedDetail(res.includedData));
} else if (res.reason !== 'canceled') {
- toast.error(copy.exportFailed, res.reason === 'no_categories' ? copy.noCategories : copy.tryAgain);
+ toast.error(
+ copy.exportFailed,
+ res.reason === 'no_categories' ? copy.noCategories : copy.tryAgain,
+ undefined,
+ diagnosticTarget,
+ );
}
} catch (error) {
- toast.error(copy.exportFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.exportFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ diagnosticTarget,
+ );
} finally {
setConfigBusy(null);
}
@@ -184,10 +202,15 @@ export function DataSettingsPage(props: {
const detail = res.message && (locale === 'zh' || !/[\u3400-\u9fff]/u.test(res.message))
? res.message
: copy.invalidFile;
- toast.error(copy.importFailed, detail);
+ toast.error(copy.importFailed, detail, undefined, diagnosticTarget);
}
} catch (error) {
- toast.error(copy.importFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.importFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ diagnosticTarget,
+ );
} finally {
setConfigBusy(null);
}
diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx
index b4f9045f0b..bcbc232d6c 100644
--- a/apps/desktop/src/renderer/settings/general-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx
@@ -46,6 +46,7 @@ import { settingsTestResultMessage } from "../locales/settings-test-result-copy.
import { getShellCopy } from "../locales/shell-copy.js";
import type { RuntimeHostSettingsConnectionsBridge } from './runtime-host-settings-bridge.js';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js';
+import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js';
export function GeneralSettingsPage(props: {
settings: AppSettings;
@@ -60,6 +61,7 @@ export function GeneralSettingsPage(props: {
onRefreshConnections(): Promise;
onRetryRuntimeHost(): Promise;
}) {
+ const host = useRuntimeHostSettingsTarget();
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).general;
const sections = getSettingsPreferencesCopy(locale).sections;
@@ -114,6 +116,8 @@ export function GeneralSettingsPage(props: {
toast.error(
copy.incognitoFailed,
settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
);
}
}}
@@ -156,6 +160,8 @@ export function GeneralSettingsPage(props: {
toast.error(
copy.workspaceInstructionsFailed,
settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
);
}
}}
@@ -199,6 +205,7 @@ function ShellSettingsSection(props: {
patch: Parameters[0],
): Promise;
}) {
+ const host = useRuntimeHostSettingsTarget();
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).general;
const sections = getSettingsPreferencesCopy(locale).sections;
@@ -237,6 +244,8 @@ function ShellSettingsSection(props: {
isRejectedShellPreference(error)
? copy.shellExecutableRejected
: settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
);
}
} finally {
@@ -337,6 +346,7 @@ function GeneralDefaultsCard(props: {
patch: Parameters[0],
): Promise;
}) {
+ const host = useRuntimeHostSettingsTarget();
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).general;
// Level names come from the composer's own map — one vocabulary for the
@@ -396,6 +406,8 @@ function GeneralDefaultsCard(props: {
toast.error(
copy.saveDefaultModelFailed,
settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
);
}
} finally {
@@ -444,6 +456,8 @@ function GeneralDefaultsCard(props: {
toast.error(
copy.saveDefaultPermissionFailed,
settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
);
}
} finally {
@@ -460,7 +474,12 @@ function GeneralDefaultsCard(props: {
await props.onUpdate({ chatDefaults: { thinkingLevel: next } });
} catch (error) {
if (mountedRef.current) {
- toast.error(copy.saveDefaultThinkingFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.saveDefaultThinkingFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
}
} finally {
releaseSave();
@@ -544,6 +563,7 @@ function NetworkProxySection(props: {
patch: Parameters[0],
): Promise;
}) {
+ const host = useRuntimeHostSettingsTarget();
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).general;
const persistedProxy = props.settings.network.proxy;
@@ -566,6 +586,8 @@ function NetworkProxySection(props: {
toast.error(
copy.saveNetworkFailed,
settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
),
},
);
@@ -585,13 +607,20 @@ function NetworkProxySection(props: {
if (result.ok && networkPageMountedRef.current) {
toast.success(copy.proxyReachable, `${message}${latency}`);
} else if (networkPageMountedRef.current) {
- toast.error(copy.proxyTestFailed, message);
+ toast.error(
+ copy.proxyTestFailed,
+ message,
+ undefined,
+ { profileId: host.profileId },
+ );
}
} catch (error) {
if (networkPageMountedRef.current) {
toast.error(
copy.proxyTestError,
settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
);
}
} finally {
diff --git a/apps/desktop/src/renderer/settings/permission-center-page.tsx b/apps/desktop/src/renderer/settings/permission-center-page.tsx
index 637b4d4dc6..3045d8b9d2 100644
--- a/apps/desktop/src/renderer/settings/permission-center-page.tsx
+++ b/apps/desktop/src/renderer/settings/permission-center-page.tsx
@@ -140,10 +140,22 @@ export function PermissionCenterPage() {
setRefreshTick((tick) => tick + 1);
}
} else if (mountedRef.current) {
- toast.error(copy.actionFailed, permissionActionFailureCopy(result.reason, result.message, copy));
+ toast.error(
+ copy.actionFailed,
+ permissionActionFailureCopy(result.reason, result.message, copy),
+ undefined,
+ { profileId: host.profileId },
+ );
}
} catch (err) {
- if (mountedRef.current) toast.error(copy.actionFailed, settingsActionErrorMessage(err, locale));
+ if (mountedRef.current) {
+ toast.error(
+ copy.actionFailed,
+ settingsActionErrorMessage(err, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
+ }
} finally {
if (permissionActionGuard.current === actionKey) {
permissionActionGuard.finish();
diff --git a/apps/desktop/src/renderer/settings/personalization-settings-section.tsx b/apps/desktop/src/renderer/settings/personalization-settings-section.tsx
index 8a77fddc7b..e8bc5e23c2 100644
--- a/apps/desktop/src/renderer/settings/personalization-settings-section.tsx
+++ b/apps/desktop/src/renderer/settings/personalization-settings-section.tsx
@@ -18,6 +18,7 @@ import type { UiLocalePreference } from '@maka/core/ui-locale';
import { TextArea, TextInput, useMountedRef, useToast, useUiLocale } from '@maka/ui';
import { settingsActionErrorMessage } from './settings-error-copy';
import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js';
+import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js';
// PR-TONE-AUTOSAVE-0: the personalization block used to be the page's ONLY
// control with an explicit 保存 button + helper line — every neighboring row
@@ -33,6 +34,7 @@ export function PersonalizationSettingsSection(props: {
runtimeHostAvailable: boolean;
onUpdate(patch: Parameters[0]): Promise;
}) {
+ const host = useRuntimeHostSettingsTarget();
const locale = useUiLocale();
const copy = getSettingsPreferencesCopy(locale).personalization;
const sections = getSettingsPreferencesCopy(locale).sections;
@@ -112,7 +114,14 @@ export function PersonalizationSettingsSection(props: {
setUiLocale(value.uiLocale);
}
if (ticket === persistTicketRef.current) {
- toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
+ const targetsRuntimeHost =
+ patch.displayName !== undefined || patch.assistantTone !== undefined;
+ toast.error(
+ copy.saveFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ targetsRuntimeHost ? { profileId: host.profileId } : undefined,
+ );
}
return false;
} finally {
diff --git a/apps/desktop/src/renderer/settings/projects-settings-page.tsx b/apps/desktop/src/renderer/settings/projects-settings-page.tsx
index d1b7228fcb..24c1181a52 100644
--- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx
@@ -126,14 +126,40 @@ export function ProjectsSettingsPage(props: {
defaultProjectId !== undefined &&
listed.some((project) => project.id === defaultProjectId && project.available);
- async function runRowAction(key: string, action: () => Promise, failure: string) {
+ async function runRowAction(
+ key: string,
+ action: () => Promise,
+ failure: string,
+ diagnosticTarget?: { profileId: string },
+ ) {
const release = actionGuard.begin(key);
if (!release) return;
try {
- await action();
- await reload();
- } catch (error) {
- if (mountedRef.current) toast.error(failure, settingsActionErrorMessage(error, locale));
+ try {
+ await action();
+ } catch (error) {
+ if (mountedRef.current) {
+ toast.error(
+ failure,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ diagnosticTarget,
+ );
+ }
+ return;
+ }
+ try {
+ await reload();
+ } catch (error) {
+ if (mountedRef.current) {
+ toast.error(
+ failure,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ host ? { profileId: host.profileId } : undefined,
+ );
+ }
+ }
} finally {
release();
}
@@ -164,6 +190,7 @@ export function ProjectsSettingsPage(props: {
);
}
+ const selectedHost = host;
return (
@@ -273,6 +300,7 @@ export function ProjectsSettingsPage(props: {
if (!result.ok) throw new Error(result.reason);
},
copy.openFolderFailed,
+ { profileId: host.profileId },
),
},
]
@@ -295,9 +323,21 @@ export function ProjectsSettingsPage(props: {
// Removing the default leaves the preference
// pointing at nothing; clear it in the same
// action rather than leaving a dangling id.
- if (isDefault) await setDefault(undefined);
+ if (isDefault) {
+ try {
+ await setDefault(undefined);
+ } catch (error) {
+ if (mountedRef.current) {
+ toast.error(
+ copy.setDefaultFailed,
+ settingsActionErrorMessage(error, locale),
+ );
+ }
+ }
+ }
},
copy.actionFailed,
+ { profileId: host.profileId },
),
},
]}
@@ -319,6 +359,7 @@ export function ProjectsSettingsPage(props: {
setRenamingId(null);
},
copy.renameFailed,
+ { profileId: selectedHost.profileId },
);
}
diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
index b6c632abc7..19ff543191 100644
--- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
+++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
@@ -70,6 +70,7 @@ export function ConnectionDetail(props: ConnectionDetailProps) {
}
function UnknownConnectionDetail({ props }: { props: ConnectionDetailProps }) {
+ const host = useRuntimeHostSettingsTarget();
const locale = useUiLocale();
const copy = getProviderSettingsCopy(locale).detail;
const { connection } = props;
@@ -94,7 +95,12 @@ function UnknownConnectionDetail({ props }: { props: ConnectionDetailProps }) {
await props.onDeleted();
} catch (error) {
if (!mounted.current) return;
- toast.error(copy.deleteFailed, providerPanelActionErrorMessage(error, locale));
+ toast.error(
+ copy.deleteFailed,
+ providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
} finally {
if (mounted.current) setDeleting(false);
}
@@ -116,6 +122,7 @@ function UnknownConnectionDetail({ props }: { props: ConnectionDetailProps }) {
}
function ConnectionDetailInner(props: ConnectionDetailProps) {
+ const host = useRuntimeHostSettingsTarget();
const locale = useUiLocale();
const copy = getProviderSettingsCopy(locale).detail;
const { connection } = props;
@@ -223,7 +230,12 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
})
.catch((error) => {
if (!current) return;
- toast.error(copy.requestCustomizationInvalid, providerPanelActionErrorMessage(error, locale));
+ toast.error(
+ copy.requestCustomizationInvalid,
+ providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
});
return () => {
current = false;
@@ -256,7 +268,12 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
return true;
} catch (error) {
if (mounted.current) {
- toast.error(copy.saveFailed, providerPanelActionErrorMessage(error, locale));
+ toast.error(
+ copy.saveFailed,
+ providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
}
return false;
} finally {
@@ -279,7 +296,12 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
return true;
} catch (error) {
if (mounted.current) {
- toast.error(copy.saveFailed, providerPanelActionErrorMessage(error, locale));
+ toast.error(
+ copy.saveFailed,
+ providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
}
return false;
} finally {
@@ -852,13 +874,20 @@ function GitHubCopilotReloginNotice(props: {
try {
const result = await window.maka.githubCopilotSubscription.connectExistingLogin(host);
if (!result.ok) {
- toast.error(copy.copilotImportFailed, result.message);
+ toast.error(copy.copilotImportFailed, result.message, undefined, {
+ profileId: host.profileId,
+ });
return;
}
await props.onRelogin();
} catch (error) {
if (mountedRef.current) {
- toast.error(copy.copilotImportFailed, providerPanelActionErrorMessage(error, locale));
+ toast.error(
+ copy.copilotImportFailed,
+ providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
}
} finally {
connectGuard.finish();
@@ -887,10 +916,12 @@ function OAuthReloginNotice(props: {
hasSecret: CredentialPresenceStatus;
onRelogin(): Promise;
}) {
+ const host = useRuntimeHostSettingsTarget();
const copy = getProviderSettingsCopy(useUiLocale()).detail;
const flow = useOAuthLoginFlow({
bridge: props.service.bridge,
display: props.service.display,
+ diagnosticTarget: { profileId: host.profileId },
onLoginSuccess: props.onRelogin,
});
const { hasSecret } = props;
diff --git a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
index aee1ba659f..31a0053e67 100644
--- a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
+++ b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
@@ -194,6 +194,7 @@ function SubscriptionLoginPanel(props: {
host,
),
display: { name: display.name, shortName: display.shortName },
+ diagnosticTarget: { profileId: host.profileId },
onLoginSuccess: props.onLoginSuccess,
});
@@ -243,6 +244,7 @@ function GitHubCopilotLoginPanel(props: { onLoginSuccess(): void | Promise
logout: () => window.maka.githubCopilotSubscription.logout(host),
} as OAuthLoginFlowBridge,
display: { name: 'GitHub Copilot', shortName: 'GitHub Copilot' },
+ diagnosticTarget: { profileId: host.profileId },
onLoginSuccess: props.onLoginSuccess,
direct: {
login: () => window.maka.githubCopilotSubscription.connectExistingLogin(host),
diff --git a/apps/desktop/src/renderer/settings/providers-panel.tsx b/apps/desktop/src/renderer/settings/providers-panel.tsx
index 2e1d0661ca..faa7eafd6c 100644
--- a/apps/desktop/src/renderer/settings/providers-panel.tsx
+++ b/apps/desktop/src/renderer/settings/providers-panel.tsx
@@ -36,6 +36,7 @@ import { ProviderLogo, providerDisplay } from './provider-display';
import { oauthPanelSubtitle } from './provider-oauth-section';
import { providerPanelActionErrorMessage, type ConnectionsBridge } from './provider-panel-shared';
import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
+import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js';
export type { ConnectionsBridge } from './provider-panel-shared';
@@ -83,6 +84,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
/** Called once the setup level has been entered. */
onInitialCreateProviderConsumed?: () => void;
}) {
+ const host = useRuntimeHostSettingsTarget();
const [connections, setConnections] = useState([]);
const [defaultSlug, setDefaultSlug] = useState(null);
const [route, setRoute] = useState({ kind: 'list' });
@@ -118,7 +120,12 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const message = providerPanelActionErrorMessage(error, locale);
setLoadError(message);
setLoading(false);
- toast.error(copy.loadFailed, message);
+ toast.error(
+ copy.loadFailed,
+ message,
+ undefined,
+ { profileId: host.profileId },
+ );
return false;
}
}
@@ -288,7 +295,12 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
} catch (error) {
// The state is unchanged on failure, so the Badge stays
// where it was and the button remains the way to retry.
- toast.error(copy.setDefaultFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.setDefaultFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
}
}}
/>
@@ -355,6 +367,8 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
providerPanelActionErrorMessage(modelDiscoveryError, locale),
providerCopy.detail.endpointTroubleshooting,
),
+ undefined,
+ { profileId: host.profileId },
);
}
}}
diff --git a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
index bbb51c1b18..ecbc583d4e 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
@@ -81,7 +81,12 @@ export function RuntimeHostProfilesSection(props: {
} catch (error) {
if (mountedRef.current) {
await reload().catch(() => undefined);
- toast.error(copy.selectFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.selectFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId },
+ );
}
} finally {
if (mountedRef.current) setSwitching(false);
@@ -95,6 +100,7 @@ export function RuntimeHostProfilesSection(props: {
async function saveAndEnable() {
setSwitching(true);
+ const profileId = draft.id;
try {
const transport = createTransport(draft);
const result = await window.maka.runtimeHostProfiles.addAndEnable({
@@ -110,7 +116,12 @@ export function RuntimeHostProfilesSection(props: {
if (!mountedRef.current) return;
setSnapshot(result.snapshot);
if (result.kind === "unavailable") {
- toast.error(copy.selectFailed, result.message);
+ toast.error(
+ copy.selectFailed,
+ result.message,
+ undefined,
+ { profileId },
+ );
return;
}
setShowAdd(false);
@@ -118,7 +129,12 @@ export function RuntimeHostProfilesSection(props: {
} catch (error) {
if (mountedRef.current) {
await reload().catch(() => undefined);
- toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.saveFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId },
+ );
}
} finally {
if (mountedRef.current) setSwitching(false);
@@ -131,7 +147,12 @@ export function RuntimeHostProfilesSection(props: {
if (mountedRef.current) setSnapshot(next);
} catch (error) {
if (mountedRef.current) {
- toast.error(copy.removeFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.removeFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId },
+ );
}
}
}
@@ -144,12 +165,22 @@ export function RuntimeHostProfilesSection(props: {
setSnapshot(next);
const entry = next.entries.find((candidate) => candidate.profile.id === profileId);
if (entry?.readiness === "unavailable" && entry.message) {
- toast.error(copy.selectFailed, entry.message);
+ toast.error(
+ copy.selectFailed,
+ entry.message,
+ undefined,
+ { profileId },
+ );
}
} catch (error) {
if (mountedRef.current) {
await reload().catch(() => undefined);
- toast.error(copy.selectFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.selectFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId },
+ );
}
} finally {
if (mountedRef.current) setSwitching(false);
diff --git a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx
index 0de1bf9d6d..0359c86c8c 100644
--- a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx
@@ -61,6 +61,7 @@ import {
type SubagentPageRoute,
} from './subagent-preset-presentation.js';
import { statusBadgeVariant } from './settings-status-badge.js';
+import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js';
/** How many characters a value spends on leading whitespace the store trims. */
function leadingSpace(value: string): number {
@@ -79,6 +80,7 @@ export function SubagentSettingsPage(props: {
patch: Parameters[0],
): Promise;
}) {
+ const host = useRuntimeHostSettingsTarget();
const locale = useUiLocale();
const copy = getSubagentSettingsCopy(locale);
const toast = useToast();
@@ -140,12 +142,22 @@ export function SubagentSettingsPage(props: {
expectPresent !== undefined &&
!result.settings.subagents.presets.some((candidate) => candidate.id === expectPresent)
) {
- toast.error(copy.toast.saveFailed, copy.toast.rejected);
+ toast.error(
+ copy.toast.saveFailed,
+ copy.toast.rejected,
+ undefined,
+ { profileId: host.profileId },
+ );
return false;
}
return true;
} catch (error) {
- toast.error(copy.toast.saveFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.toast.saveFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
return false;
} finally {
setSaving(false);
diff --git a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx
index 9b2880237b..6a83f76fc5 100644
--- a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx
@@ -128,7 +128,14 @@ export function TasksSettingsPage(props: ArchivedTasksBridge) {
: outcome.firstError
? settingsActionErrorMessage(outcome.firstError, locale)
: copy.purgeFailedBody(outcome.remaining.length);
- toast.error(copy.purgeFailedTitle, kept ? `${reason} ${kept}` : reason);
+ toast.error(
+ copy.purgeFailedTitle,
+ kept ? `${reason} ${kept}` : reason,
+ undefined,
+ outcome.firstErrorSessionId
+ ? { sessionId: outcome.firstErrorSessionId }
+ : undefined,
+ );
} else {
toast.success(copy.purgedToast(outcome.removed), kept);
}
diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts
index b3016aee1f..753de21c73 100644
--- a/apps/desktop/src/renderer/settings/use-connection-detail.ts
+++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts
@@ -189,9 +189,14 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
.catch((error) => {
if (!isConnectionDetailCurrent(lifecycle)) return;
setHasSecret('error');
- toast.error(copy.credentialReadFailed, providerPanelActionErrorMessage(error, locale));
+ toast.error(
+ copy.credentialReadFailed,
+ providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
});
- }, [props.bridge, connection.slug, probesCredential, toast]);
+ }, [props.bridge, connection.slug, host.profileId, probesCredential, toast]);
useEffect(() => {
const nextSnapshot = connectionDetailSnapshot(connection, defaults.baseUrl);
@@ -302,6 +307,8 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
toast.error(
saved ? copy.refreshFailed : copy.saveFailed,
providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
);
return saved;
} finally {
@@ -336,6 +343,8 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
toast.error(
saved ? copy.refreshFailed : copy.saveModelsFailed,
providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
);
} finally {
releaseSaveModels();
@@ -477,7 +486,12 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
return true;
} catch (error) {
if (!isConnectionDetailCurrent(lifecycle)) return false;
- toast.error(copy.saveFailed, providerPanelActionErrorMessage(error, locale));
+ toast.error(
+ copy.saveFailed,
+ providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
return false;
} finally {
releaseSave();
@@ -576,12 +590,19 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
auth: copy.authTroubleshooting(credentialTroubleshootingCopy),
recheck: copy.recheckTroubleshooting(credentialTroubleshootingCopy),
}, locale),
+ undefined,
+ { profileId: host.profileId },
);
}
} catch (error) {
if (!isConnectionDetailCurrent(lifecycle)) return;
const message = providerPanelActionErrorMessage(error, locale);
- toast.error(copy.connectionTestError(connection.name), message);
+ toast.error(
+ copy.connectionTestError(connection.name),
+ message,
+ undefined,
+ { profileId: host.profileId },
+ );
} finally {
releaseTest();
if (isConnectionDetailCurrent(lifecycle)) setTesting(false);
@@ -621,11 +642,18 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
// means whatever's on screen is not from the latest probe.
if (!fetched && models.length === 0) setModelSource('fallback');
if (fetched) {
- toast.error(copy.refreshFailed, message);
+ toast.error(
+ copy.refreshFailed,
+ message,
+ undefined,
+ { profileId: host.profileId },
+ );
} else {
toast.error(
copy.modelsFetchFailed(connection.name),
copy.modelsFetchFailedDetail(message, credentialTroubleshootingCopy),
+ undefined,
+ { profileId: host.profileId },
);
}
} finally {
@@ -664,6 +692,8 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
toast.error(
deleted ? copy.refreshFailed : copy.deleteFailed,
providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
);
} finally {
releaseDelete();
@@ -683,7 +713,12 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
} catch (error) {
if (!isConnectionDetailCurrent(lifecycle)) return;
setHasSecret('error');
- toast.error(copy.credentialReadFailed, providerPanelActionErrorMessage(error, locale));
+ toast.error(
+ copy.credentialReadFailed,
+ providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
}
await props.onChanged();
}
diff --git a/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts b/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts
index be9c03442d..1a615645a1 100644
--- a/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts
+++ b/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts
@@ -33,6 +33,7 @@ export interface MemoryDocumentControllerProps {
/** Owns the MEMORY.md document lifecycle. */
export function useMemoryDocumentController(props: MemoryDocumentControllerProps) {
const host = useRuntimeHostSettingsTarget();
+ const diagnosticTarget = { profileId: host.profileId } as const;
const locale = useUiLocale();
const copy = getMemorySettingsCopy(locale);
type MemoryWriteAction = 'reload' | 'enable' | 'agent-read' | 'save' | 'reset' | 'restore' | 'entry-status';
@@ -61,6 +62,8 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
const memoryPageLifecycleRef = useRef(0);
const memoryReloadTicketRef = useRef(0);
const toast = useToast();
+ const reportHostError = (title: string, description?: string) =>
+ toast.error(title, description, undefined, diagnosticTarget);
useEffect(() => {
memoryPageLifecycleRef.current += 1;
@@ -137,7 +140,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
return true;
} catch (error) {
if (isMemoryPageCurrent(lifecycle) && ticket === memoryReloadTicketRef.current) {
- toast.error(copy.text.loadFailed, settingsActionErrorMessage(error, locale));
+ reportHostError(copy.text.loadFailed, settingsActionErrorMessage(error, locale));
}
return false;
} finally {
@@ -168,7 +171,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
setDraft(next.content);
});
} catch (error) {
- toast.error(copy.text.toggleFailed, settingsActionErrorMessage(error, locale));
+ reportHostError(copy.text.toggleFailed, settingsActionErrorMessage(error, locale));
}
}
@@ -182,7 +185,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
setDraft(next.content);
});
} catch (error) {
- toast.error(copy.text.agentReadFailed, settingsActionErrorMessage(error, locale));
+ reportHostError(copy.text.agentReadFailed, settingsActionErrorMessage(error, locale));
}
}
@@ -196,7 +199,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
setDraft(next.content);
if (next.status === 'safe_mode') {
setLastSaveSummary(null);
- toast.error(copy.text.saveBlocked, copy.text.safeMode);
+ reportHostError(copy.text.saveBlocked, copy.text.safeMode);
} else if (redacted) {
const detail = copy.redactedDetail(formatLocalMemorySaveSummary(next, copy));
setLastSaveSummary({
@@ -216,7 +219,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
}
});
} catch (error) {
- toast.error(copy.text.saveFailed, settingsActionErrorMessage(error, locale));
+ reportHostError(copy.text.saveFailed, settingsActionErrorMessage(error, locale));
}
}
@@ -231,7 +234,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
toast.success(copy.text.resetDone, copy.text.resetDoneDetail);
});
} catch (error) {
- toast.error(copy.text.resetFailed, settingsActionErrorMessage(error, locale));
+ reportHostError(copy.text.resetFailed, settingsActionErrorMessage(error, locale));
}
}
@@ -262,11 +265,17 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
if (result.ok) {
toast.success(copy.text.restoredLatest, `${backupLabel} · ${copy.text.restoredDetail}`);
} else {
- toast.error(copy.text.restoreFailed, memoryResultMessage(result.message, locale, copy.text.restoreFailed));
+ reportHostError(
+ copy.text.restoreFailed,
+ memoryResultMessage(result.message, locale, copy.text.restoreFailed),
+ );
}
});
} catch (error) {
- toast.error(copy.text.restoreLatestFailed, settingsActionErrorMessage(error, locale));
+ reportHostError(
+ copy.text.restoreLatestFailed,
+ settingsActionErrorMessage(error, locale),
+ );
}
});
}
@@ -293,11 +302,17 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
if (result.ok) {
toast.success(copy.text.restoredCandidate, `${backupLabel} · ${copy.text.restoredDetail}`);
} else {
- toast.error(copy.text.restoreFailed, memoryResultMessage(result.message, locale, copy.text.restoreFailed));
+ reportHostError(
+ copy.text.restoreFailed,
+ memoryResultMessage(result.message, locale, copy.text.restoreFailed),
+ );
}
});
} catch (error) {
- toast.error(copy.text.restoreCandidateFailed, settingsActionErrorMessage(error, locale));
+ reportHostError(
+ copy.text.restoreCandidateFailed,
+ settingsActionErrorMessage(error, locale),
+ );
}
});
}
@@ -307,9 +322,16 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
try {
const result = await window.maka.memory.openFile(host);
if (!isCurrent()) return;
- if (!result.ok) toast.error(copy.text.openFailed, memoryResultMessage(result.message, locale, copy.text.openFailed));
+ if (!result.ok) {
+ reportHostError(
+ copy.text.openFailed,
+ memoryResultMessage(result.message, locale, copy.text.openFailed),
+ );
+ }
} catch (error) {
- if (isCurrent()) toast.error(copy.text.openFailed, settingsActionErrorMessage(error, locale));
+ if (isCurrent()) {
+ reportHostError(copy.text.openFailed, settingsActionErrorMessage(error, locale));
+ }
}
});
}
@@ -319,9 +341,19 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
try {
const result = await window.maka.memory.openLatestBackup(host);
if (!isCurrent()) return;
- if (!result.ok) toast.error(copy.text.openPreviousFailed, memoryResultMessage(result.message, locale, copy.text.openPreviousFailed));
+ if (!result.ok) {
+ reportHostError(
+ copy.text.openPreviousFailed,
+ memoryResultMessage(result.message, locale, copy.text.openPreviousFailed),
+ );
+ }
} catch (error) {
- if (isCurrent()) toast.error(copy.text.openPreviousFailed, settingsActionErrorMessage(error, locale));
+ if (isCurrent()) {
+ reportHostError(
+ copy.text.openPreviousFailed,
+ settingsActionErrorMessage(error, locale),
+ );
+ }
}
});
}
@@ -332,11 +364,17 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
const result = await window.maka.memory.openBackup(backup.kind, host);
if (!isCurrent()) return;
if (!result.ok) {
- toast.error(copy.openBackupFailed(localMemoryBackupKindLabel(backup.kind, copy)), memoryResultMessage(result.message, locale, copy.text.openFailed));
+ reportHostError(
+ copy.openBackupFailed(localMemoryBackupKindLabel(backup.kind, copy)),
+ memoryResultMessage(result.message, locale, copy.text.openFailed),
+ );
}
} catch (error) {
if (isCurrent())
- toast.error(copy.openBackupFailed(localMemoryBackupKindLabel(backup.kind, copy)), settingsActionErrorMessage(error, locale));
+ reportHostError(
+ copy.openBackupFailed(localMemoryBackupKindLabel(backup.kind, copy)),
+ settingsActionErrorMessage(error, locale),
+ );
}
});
}
@@ -347,11 +385,17 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
const result = await window.maka.app.openPath('memory', undefined, host);
if (!isCurrent()) return;
if (!result.ok) {
- toast.error(copy.openBackupFailed(openPathActionLabel('memory', locale)), openPathFailureCopy(result.reason, locale));
+ reportHostError(
+ copy.openBackupFailed(openPathActionLabel('memory', locale)),
+ openPathFailureCopy(result.reason, locale),
+ );
}
} catch (error) {
if (isCurrent())
- toast.error(copy.openBackupFailed(openPathActionLabel('memory', locale)), settingsActionErrorMessage(error, locale));
+ reportHostError(
+ copy.openBackupFailed(openPathActionLabel('memory', locale)),
+ settingsActionErrorMessage(error, locale),
+ );
}
});
}
@@ -476,7 +520,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
setState(next);
setDraft(next.content);
if (next.status === 'safe_mode') {
- toast.error(copy.text.saveBlocked, copy.text.safeMode);
+ reportHostError(copy.text.saveBlocked, copy.text.safeMode);
return;
}
setNewMemoryTitle('');
@@ -485,7 +529,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
toast.success(copy.text.addedDraft, title.trim());
});
} catch (error) {
- toast.error(copy.text.saveFailed, settingsActionErrorMessage(error, locale));
+ reportHostError(copy.text.saveFailed, settingsActionErrorMessage(error, locale));
}
}
@@ -518,13 +562,16 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps
setState(next);
setDraft(next.content);
if (next.status === 'safe_mode') {
- toast.error(copy.text.updateBlocked, copy.text.safeMode);
+ reportHostError(copy.text.updateBlocked, copy.text.safeMode);
} else {
toast.success(status === 'archived' ? copy.text.archived : copy.text.restored, entry.title);
}
});
} catch (error) {
- toast.error(status === 'archived' ? copy.text.archiveFailed : copy.text.entryRestoreFailed, settingsActionErrorMessage(error, locale));
+ reportHostError(
+ status === 'archived' ? copy.text.archiveFailed : copy.text.entryRestoreFailed,
+ settingsActionErrorMessage(error, locale),
+ );
}
}
diff --git a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
index 3e3a3ce175..b30d9192a7 100644
--- a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
+++ b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
@@ -1,7 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { generalizedErrorMessage, generalizedErrorMessageChinese, redactSecrets } from '@maka/core/redaction';
import { type UiLocale } from '@maka/core/ui-locale';
-import { useMountedRef, useToast, useUiLocale } from '@maka/ui';
+import {
+ useMountedRef,
+ useToast,
+ useUiLocale,
+ type ToastDiagnosticTarget,
+} from '@maka/ui';
import { createOneShotActionGuard, teardownPendingAuthorization } from './oauth-login-flow-guard';
import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
@@ -90,6 +95,7 @@ export interface OAuthLoginFlowController {
export function useOAuthLoginFlow(params: {
bridge: OAuthLoginFlowBridge;
display: OAuthLoginFlowDisplay;
+ diagnosticTarget: ToastDiagnosticTarget;
// Fired after a successful completeAuthorization (browser handoff done).
// The detail sheet uses it to re-probe hasSecret + reload connection status;
// catalog modals use it to refresh both their account card and the shared
@@ -105,6 +111,8 @@ export function useOAuthLoginFlow(params: {
const copy = getProviderSettingsCopy(locale).oauthFlow;
const direct = params.direct;
const toast = useToast();
+ const reportHostError = (title: string, description?: string) =>
+ toast.error(title, description, undefined, params.diagnosticTarget);
const [state, setState] = useState(null);
const [authRequestId, setAuthRequestId] = useState(null);
const [stateHint, setStateHint] = useState(null);
@@ -123,7 +131,7 @@ export function useOAuthLoginFlow(params: {
} catch (error) {
if (!oauthLoginFlowMountedRef.current) return false;
const message = subscriptionActionErrorMessage(error, locale);
- toast.error(copy.refreshFailed, message);
+ reportHostError(copy.refreshFailed, message);
setErrorMessage(message);
return false;
}
@@ -160,14 +168,20 @@ export function useOAuthLoginFlow(params: {
const result = await direct.login();
if (!oauthLoginFlowMountedRef.current) return;
if (!result.ok) {
- toast.error(copy.accountActionFailed(display.name), subscriptionResultMessage(result.message, copy.loginFailedRetry, locale));
+ reportHostError(
+ copy.accountActionFailed(display.name),
+ subscriptionResultMessage(result.message, copy.loginFailedRetry, locale),
+ );
}
await refresh();
if (!oauthLoginFlowMountedRef.current) return;
if (result.ok && params.onLoginSuccess) await params.onLoginSuccess();
} catch (error) {
if (!oauthLoginFlowMountedRef.current) return;
- toast.error(copy.accountActionFailed(display.name), subscriptionActionErrorMessage(error, locale));
+ reportHostError(
+ copy.accountActionFailed(display.name),
+ subscriptionActionErrorMessage(error, locale),
+ );
} finally {
finishPendingAction();
}
@@ -178,7 +192,7 @@ export function useOAuthLoginFlow(params: {
if ('ok' in payload) {
if (!oauthLoginFlowMountedRef.current) return;
const failureMessage = payload.ok ? copy.retry : subscriptionResultMessage(payload.message, copy.startFailedRetry, locale);
- toast.error(copy.startFailed, failureMessage);
+ reportHostError(copy.startFailed, failureMessage);
setErrorMessage(failureMessage);
return;
}
@@ -194,7 +208,7 @@ export function useOAuthLoginFlow(params: {
if (!oauthLoginFlowMountedRef.current) return;
if (!opened.ok) {
const message = subscriptionResultMessage(opened.message, copy.openFailedRetry, locale);
- toast.error(copy.openFailed, message);
+ reportHostError(copy.openFailed, message);
setErrorMessage(message);
void bridge.cancelAuthorization(payload.authRequestId);
authRequestIdRef.current = null;
@@ -217,7 +231,7 @@ export function useOAuthLoginFlow(params: {
if (params.onLoginSuccess) await params.onLoginSuccess();
} else {
const message = subscriptionResultMessage(result.message, copy.incompleteRetry, locale);
- toast.error(copy.incomplete, message);
+ reportHostError(copy.incomplete, message);
setErrorMessage(message);
}
} catch (error) {
@@ -228,7 +242,7 @@ export function useOAuthLoginFlow(params: {
setAuthRequestId(null);
setStateHint(null);
const message = subscriptionActionErrorMessage(error, locale);
- toast.error(copy.loginFailed, message);
+ reportHostError(copy.loginFailed, message);
setErrorMessage(message);
} finally {
finishPendingAction();
@@ -259,16 +273,28 @@ export function useOAuthLoginFlow(params: {
}
await refresh();
} else if (direct) {
- toast.error(copy.accountActionFailed(display.name), subscriptionResultMessage(result.message, copy.logoutFailedRetry, locale));
+ reportHostError(
+ copy.accountActionFailed(display.name),
+ subscriptionResultMessage(result.message, copy.logoutFailedRetry, locale),
+ );
} else {
- toast.error(copy.logoutFailed, subscriptionResultMessage(result.message, copy.logoutFailedRetry, locale));
+ reportHostError(
+ copy.logoutFailed,
+ subscriptionResultMessage(result.message, copy.logoutFailedRetry, locale),
+ );
}
} catch (error) {
if (!oauthLoginFlowMountedRef.current) return;
if (direct) {
- toast.error(copy.accountActionFailed(display.name), subscriptionActionErrorMessage(error, locale));
+ reportHostError(
+ copy.accountActionFailed(display.name),
+ subscriptionActionErrorMessage(error, locale),
+ );
} else {
- toast.error(copy.logoutFailed, subscriptionActionErrorMessage(error, locale));
+ reportHostError(
+ copy.logoutFailed,
+ subscriptionActionErrorMessage(error, locale),
+ );
}
} finally {
finishPendingAction();
@@ -282,12 +308,18 @@ export function useOAuthLoginFlow(params: {
const result = await direct.refreshTokens();
if (!oauthLoginFlowMountedRef.current) return;
if (!result.ok) {
- toast.error(copy.accountActionFailed(display.name), subscriptionResultMessage(result.message, copy.reverifyFailedRetry, locale));
+ reportHostError(
+ copy.accountActionFailed(display.name),
+ subscriptionResultMessage(result.message, copy.reverifyFailedRetry, locale),
+ );
}
await refresh();
} catch (error) {
if (!oauthLoginFlowMountedRef.current) return;
- toast.error(copy.accountActionFailed(display.name), subscriptionActionErrorMessage(error, locale));
+ reportHostError(
+ copy.accountActionFailed(display.name),
+ subscriptionActionErrorMessage(error, locale),
+ );
} finally {
finishPendingAction();
}
diff --git a/apps/desktop/src/renderer/settings/web-search-settings-page.tsx b/apps/desktop/src/renderer/settings/web-search-settings-page.tsx
index df5b6e20ca..7e6a1fa178 100644
--- a/apps/desktop/src/renderer/settings/web-search-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/web-search-settings-page.tsx
@@ -89,7 +89,12 @@ export function WebSearchSettingsPage(props: {
return true;
} catch (error) {
if (webSearchMountedRef.current) {
- toast.error(failureTitle, settingsActionErrorMessage(error, locale));
+ toast.error(
+ failureTitle,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
}
return false;
}
@@ -164,11 +169,21 @@ export function WebSearchSettingsPage(props: {
if (result.ok) {
toast.success(copy.credentialValid, copy.resultCount(result.results.length));
} else {
- toast.error(copy.testFailed, copy.errors[result.reason]);
+ toast.error(
+ copy.testFailed,
+ copy.errors[result.reason],
+ undefined,
+ { profileId: host.profileId },
+ );
}
} catch (err) {
if (webSearchMountedRef.current) {
- toast.error(copy.testError, settingsActionErrorMessage(err, locale));
+ toast.error(
+ copy.testError,
+ settingsActionErrorMessage(err, locale),
+ undefined,
+ { profileId: host.profileId },
+ );
}
} finally {
releaseTest();
diff --git a/apps/desktop/src/renderer/skill-invocation-feedback.ts b/apps/desktop/src/renderer/skill-invocation-feedback.ts
index a71f4a0d8e..5421209fb1 100644
--- a/apps/desktop/src/renderer/skill-invocation-feedback.ts
+++ b/apps/desktop/src/renderer/skill-invocation-feedback.ts
@@ -3,7 +3,12 @@ import type { SkillInvocationResult } from '@maka/runtime/skill-invocation';
import { getShellCopy } from './locales/shell-copy.js';
type SkillInvocationToastApi = {
- error(title: string, description?: string): void;
+ error(
+ title: string,
+ description?: string,
+ diagnosticDetails?: string,
+ diagnosticTarget?: { sessionId: string },
+ ): void;
info(title: string, description?: string): void;
};
@@ -21,6 +26,7 @@ export function showSkillInvocationFeedback(
uiLocale: UiLocale,
toastApi: SkillInvocationToastApi,
skillInvocation: SkillInvocationResult,
+ sessionId: string,
): void {
const failures = skillInvocation.failed;
if (failures.length === 0) return;
@@ -34,6 +40,8 @@ export function showSkillInvocationFeedback(
toastApi.error(
copy.skillInvocationBlockedTitle,
copy.skillInvocationBlockedDescription(items),
+ undefined,
+ { sessionId },
);
return;
}
diff --git a/apps/desktop/src/renderer/use-new-task-target.ts b/apps/desktop/src/renderer/use-new-task-target.ts
index a029b986e6..7f5267dca7 100644
--- a/apps/desktop/src/renderer/use-new-task-target.ts
+++ b/apps/desktop/src/renderer/use-new-task-target.ts
@@ -14,7 +14,12 @@ type ReadyHost = Extract<
>;
type ToastApi = {
- error(title: string, description?: string): void;
+ error(
+ title: string,
+ description?: string,
+ diagnosticDetails?: string,
+ diagnosticTarget?: { profileId: string },
+ ): void;
};
export function useNewTaskTarget(options: {
@@ -140,6 +145,8 @@ export function useNewTaskTarget(options: {
options.toastApi.error(
copy.selectDirectoryFailedTitle,
localizedShellErrorMessage(error, copy.readPathFailedFallback, options.uiLocale),
+ undefined,
+ { profileId: host.profile.id },
);
} finally {
setPending(false);
@@ -155,7 +162,12 @@ export function useNewTaskTarget(options: {
candidate.state === 'available',
);
if (!host) {
- options.toastApi.error(copy.catalogUnavailable);
+ options.toastApi.error(
+ copy.catalogUnavailable,
+ undefined,
+ undefined,
+ { profileId },
+ );
return;
}
setSelectedProfileId(profileId);
@@ -196,6 +208,8 @@ export function useNewTaskTarget(options: {
options.toastApi.error(
copy.selectDirectoryFailedTitle,
localizedShellErrorMessage(error, copy.readPathFailedFallback, options.uiLocale),
+ undefined,
+ { profileId: host.profile.id },
);
} finally {
setPending(false);
diff --git a/apps/desktop/src/renderer/use-project-context.ts b/apps/desktop/src/renderer/use-project-context.ts
index ec8b0ba3a0..17afec92a5 100644
--- a/apps/desktop/src/renderer/use-project-context.ts
+++ b/apps/desktop/src/renderer/use-project-context.ts
@@ -43,6 +43,7 @@ export function useAppShellProjectContext(options: {
sessionCwd?: string;
sessionProjectId?: string | null;
sessionProfileKind?: 'local' | 'remote';
+ defaultProfileId?: string;
onProjectSelected(ownerSessionId?: string): void;
toastApi: ToastApi;
}): AppShellProjectActions & {
@@ -65,6 +66,7 @@ export function useAppShellProjectContext(options: {
sessionCwd,
sessionProjectId,
sessionProfileKind,
+ defaultProfileId,
onProjectSelected,
toastApi,
} = options;
@@ -222,6 +224,7 @@ export function useAppShellProjectContext(options: {
projects,
projectCapabilities,
sessionId,
+ defaultProfileId,
onProjectSelected,
toastApi,
});
diff --git a/apps/desktop/src/renderer/use-shell-connections.ts b/apps/desktop/src/renderer/use-shell-connections.ts
index 0e98d8bf4a..013f08c20b 100644
--- a/apps/desktop/src/renderer/use-shell-connections.ts
+++ b/apps/desktop/src/renderer/use-shell-connections.ts
@@ -8,7 +8,12 @@ import { getShellRemainingCopy } from './locales/shell-remaining-copy.js';
import { localizedShellErrorMessage } from './locales/shell-copy.js';
type ToastApi = {
- error(title: string, description?: string): void;
+ error(
+ title: string,
+ description?: string,
+ diagnosticDetails?: string,
+ diagnosticTarget?: { sessionId: string } | { profileId: string },
+ ): void;
};
const EMPTY_SNAPSHOT: DesktopConnectionSnapshot = {
@@ -83,7 +88,17 @@ export function useShellConnections(options: {
refreshSequence.current.get(key) !== sequence ||
currentKey.current !== key
) return;
- toastApi.error(copy.refreshFailed, localizedShellErrorMessage(error, copy.refreshFallback, uiLocale));
+ const diagnosticTarget = target.kind === 'session' && target.sessionId
+ ? { sessionId: target.sessionId }
+ : target.kind === 'new-task' && target.host
+ ? { profileId: target.host.profileId }
+ : undefined;
+ toastApi.error(
+ copy.refreshFailed,
+ localizedShellErrorMessage(error, copy.refreshFallback, uiLocale),
+ undefined,
+ diagnosticTarget,
+ );
}
}
diff --git a/apps/desktop/src/renderer/use-shell-memory-pill.ts b/apps/desktop/src/renderer/use-shell-memory-pill.ts
index 3672f15937..0895feac32 100644
--- a/apps/desktop/src/renderer/use-shell-memory-pill.ts
+++ b/apps/desktop/src/renderer/use-shell-memory-pill.ts
@@ -3,7 +3,12 @@ import type { UiLocale } from '@maka/core/ui-locale';
import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js';
type ToastApi = {
- error(title: string, description?: string): void;
+ error(
+ title: string,
+ description?: string,
+ diagnosticDetails?: string,
+ diagnosticTarget?: { sessionId: string },
+ ): void;
};
/**
@@ -41,6 +46,8 @@ export function useShellMemoryPill({
toastApi.error(
failureContext === 'load' ? copy.memoryLoadErrorTitle : copy.memoryRefreshErrorTitle,
localizedShellErrorMessage(error, copy.memoryErrorFallback, uiLocale),
+ undefined,
+ sessionId ? { sessionId } : undefined,
);
}
}
diff --git a/apps/desktop/src/renderer/use-shell-resume.ts b/apps/desktop/src/renderer/use-shell-resume.ts
index c707f7f652..eef8f60321 100644
--- a/apps/desktop/src/renderer/use-shell-resume.ts
+++ b/apps/desktop/src/renderer/use-shell-resume.ts
@@ -5,7 +5,12 @@ import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.j
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;
};
/**
@@ -45,7 +50,7 @@ export function useShellResume(options: {
...current,
[sessionId]: parkCopy.description,
}));
- toastApi.error(parkCopy.title, parkCopy.description);
+ toastApi.error(parkCopy.title, parkCopy.description, undefined, { sessionId });
} else {
setResumeParkDescriptionBySession((current) => {
const { [sessionId]: _removed, ...remaining } = current;
@@ -62,6 +67,8 @@ export function useShellResume(options: {
shellCopy.resumeFailedFallback,
uiLocale,
),
+ undefined,
+ { sessionId },
);
} finally {
setResumePendingSessionId((current) => current === sessionId ? null : current);
From 8a291c5a50c1e8ab6a9deb1ca305fab1c570fb21 Mon Sep 17 00:00:00 2001
From: M4n5ter
Date: Fri, 21 Aug 2026 23:51:37 +0800
Subject: [PATCH 8/8] fix(desktop): preserve diagnostic Host authority
Distinguish renderer-only failures from default-Host captures, carry the selected Settings Host into diagnostic actions, and bound execution evidence reads so copying always settles. Keep purge failure details and their owning Session in one value to prevent mismatched reports.
Generated-by: Codex
---
.../app-shell-command-actions.test.ts | 17 +++++
.../app-shell-project-actions.test.ts | 3 +-
.../__tests__/app-shell-session-purge.test.ts | 31 ++++++--
.../app-shell-toast-diagnostics.test.ts | 9 +++
.../main-process-diagnostics.test.ts | 73 +++++++++++++++++--
.../src/main/desktop-diagnostics-ipc-main.ts | 33 ++++++---
.../src/main/main-process-diagnostics.ts | 14 +++-
apps/desktop/src/main/runtime-host-boot.ts | 4 +-
apps/desktop/src/main/runtime-host-client.ts | 3 +-
.../src/preload/diagnostics-contract.ts | 11 ++-
apps/desktop/src/preload/preload.ts | 16 ++--
.../src/renderer/app-shell-command-actions.ts | 18 ++++-
.../src/renderer/app-shell-overlays.tsx | 2 +
.../src/renderer/app-shell-project-actions.ts | 9 ++-
.../renderer/app-shell-session-row-actions.ts | 26 +++----
apps/desktop/src/renderer/app-shell.tsx | 8 +-
.../src/renderer/settings/settings-modal.tsx | 2 +
.../renderer/settings/settings-surface.tsx | 16 +++-
.../renderer/settings/tasks-settings-page.tsx | 8 +-
.../settings/use-connection-detail.ts | 2 +
.../src/renderer/use-project-context.ts | 3 -
.../settings/settings-pages.stories.tsx | 1 +
22 files changed, 240 insertions(+), 69 deletions(-)
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
index bb0cd7b783..7692eb3e94 100644
--- a/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts
@@ -24,4 +24,21 @@ test('targets manual diagnostics to the current task or new-task Host 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 7fe347f91b..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
@@ -93,7 +93,6 @@ test('Project errors preserve the Host authority of the failed operation', async
viewClientPath: false,
},
sessionId: 'session-key',
- defaultProfileId: 'default-profile',
onProjectSelected: () => {},
toastApi: {
success: () => {},
@@ -107,7 +106,7 @@ test('Project errors preserve the Host authority of the failed operation', async
await actions.openProjectFolder();
assert.deepEqual(diagnosticTargets, [
- { profileId: 'default-profile' },
+ undefined,
{ sessionId: 'session-key' },
]);
} finally {
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 12b59a7fd3..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,8 +155,7 @@ describe('purgeSessions', () => {
remaining: [],
restored: [],
verified: true,
- firstError: undefined,
- firstErrorSessionId: undefined,
+ firstFailure: undefined,
});
// Every delete in a sweep carries the archived premise the confirm named.
assert.deepEqual(h.removeOptions, [
@@ -227,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']);
@@ -297,8 +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.equal(outcome.firstErrorSessionId, '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-toast-diagnostics.test.ts b/apps/desktop/src/main/__tests__/app-shell-toast-diagnostics.test.ts
index 2d87a7e2af..358fda694d 100644
--- a/apps/desktop/src/main/__tests__/app-shell-toast-diagnostics.test.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-toast-diagnostics.test.ts
@@ -3,6 +3,15 @@ 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',
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 283ed2578c..027529c536 100644
--- a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
+++ b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
@@ -117,6 +117,25 @@ test('accepts only a Runtime Host target and renderer context for capture', () =
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/,
@@ -215,7 +234,7 @@ test('copies Desktop diagnostics while the scoped Host is reconnecting', async (
assert.match(clipboard, /Diagnostics unavailable: Runtime Host is reconnecting/);
});
-test('copies error diagnostics when the default Runtime Host cannot be resolved', async () => {
+test('keeps renderer-only error diagnostics Desktop-only', async () => {
type IpcHandler = Parameters['handle']>[1];
const handlers = new Map();
let clipboard = '';
@@ -228,10 +247,10 @@ test('copies error diagnostics when the default Runtime Host cannot be resolved'
environment: () => environment,
mainLogs: () => ['main remained available'],
resolveActiveRuntimeHost: () => {
- throw new Error('The default Runtime Host is unavailable');
+ throw new Error('Desktop-only diagnostics must not resolve the default Host');
},
resolveRuntimeHost: () => {
- throw new Error('Default capture must not resolve a task Host');
+ throw new Error('Desktop-only diagnostics must not resolve a task Host');
},
writeClipboard: (value) => {
clipboard = value;
@@ -243,11 +262,14 @@ test('copies error diagnostics when the default Runtime Host cannot be resolved'
await handler(
{} as never,
undefined,
- { surface: 'renderer_crash', title: 'Renderer failed', hostTarget: 'default' },
+ { surface: 'renderer_crash', title: 'Renderer failed', hostTarget: 'none' },
);
assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/);
- assert.match(clipboard, /Diagnostics unavailable: Runtime Host is reconnecting/);
+ 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 () => {
@@ -300,9 +322,10 @@ test('copies bounded evidence for the exact failed Turn', async () => {
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',
@@ -621,6 +644,44 @@ test('rejects a default diagnostic request that carries a task Host scope', asyn
);
});
+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();
diff --git a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
index 310478c0f5..7e38f1f0fa 100644
--- a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
+++ b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts
@@ -20,9 +20,12 @@ type RuntimeHostDiagnosticsClient = {
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;
@@ -38,7 +41,11 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps):
async (_event, scope: unknown, rawInput: unknown): Promise => {
const input = parseDesktopDiagnosticInput(rawInput);
let runtime: RuntimeHostDiagnosticsClient | undefined;
- if (input.hostTarget === 'default') {
+ 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');
}
@@ -59,16 +66,19 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps):
}
let runtimeHost: RuntimeHostDiagnosticRead;
if (!runtime) {
- runtimeHost = {
- ok: false,
- error: input.hostTarget === 'default'
- ? input.surface === 'manual'
- ? 'Runtime Host is unavailable'
- : 'Runtime Host is reconnecting'
- : input.surface !== 'manual' && scope !== undefined
- ? 'Runtime Host is reconnecting'
- : 'Runtime Host for this task is unavailable',
- };
+ 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() };
@@ -89,6 +99,7 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps):
const turn = await runtime.getTurnTrace(
execution.sessionId,
execution.turnId,
+ EXECUTION_DIAGNOSTIC_TIMEOUT_MS,
);
runtimeExecution = turn
? { ok: true, value: turn }
diff --git a/apps/desktop/src/main/main-process-diagnostics.ts b/apps/desktop/src/main/main-process-diagnostics.ts
index c193542732..f3aedceade 100644
--- a/apps/desktop/src/main/main-process-diagnostics.ts
+++ b/apps/desktop/src/main/main-process-diagnostics.ts
@@ -69,7 +69,7 @@ export function parseDesktopDiagnosticInput(input: unknown): DesktopDiagnosticWi
}
return {
surface: 'manual',
- hostTarget: parseDiagnosticHostTarget(record.hostTarget),
+ hostTarget: parseManualDiagnosticHostTarget(record.hostTarget),
...optionalBoundedString(record, 'rendererUserAgent', INPUT_LIMITS.rendererUserAgent),
...optionalBoundedString(record, 'rendererLocale', INPUT_LIMITS.rendererLocale),
};
@@ -170,10 +170,20 @@ export function formatDesktopDiagnosticReport(
}
function parseDiagnosticHostTarget(value: unknown): DesktopDiagnosticHostTarget {
- if (value === 'default' || value === 'task') return value;
+ 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 b6ff53e80e..1e75dbbb87 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -1287,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/diagnostics-contract.ts b/apps/desktop/src/preload/diagnostics-contract.ts
index c0202be52a..f6ac6672b9 100644
--- a/apps/desktop/src/preload/diagnostics-contract.ts
+++ b/apps/desktop/src/preload/diagnostics-contract.ts
@@ -35,11 +35,18 @@ export interface DesktopErrorDiagnosticInput {
export type DesktopDiagnosticInput = DesktopManualDiagnosticInput | DesktopErrorDiagnosticInput;
-export type DesktopDiagnosticHostTarget = 'default' | 'task';
+/**
+ * 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: DesktopDiagnosticHostTarget;
+ readonly hostTarget: Exclude;
};
export type DesktopErrorDiagnosticWireInput = Omit &
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index a3cbacf246..e6beac576f 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -49,7 +49,6 @@ import {
} from './transcript-contract.js';
import type {
DesktopDiagnosticInput,
- DesktopDiagnosticHostTarget,
DesktopErrorDiagnosticWireInput,
DesktopManualDiagnosticTarget,
DesktopManualDiagnosticWireInput,
@@ -305,18 +304,21 @@ async function runtimeHostSessionRef(sessionId: string): Promise<{
return { scope, sessionId: ref.sessionId };
}
-type DiagnosticRuntimeHostResolution = {
- readonly hostTarget: DesktopDiagnosticHostTarget;
+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 {
+): Promise {
if (value === undefined) return { hostTarget: 'default' };
const selector = parseManualDiagnosticTarget(value);
return resolveTaskDiagnosticRuntimeHost(selector);
@@ -324,7 +326,7 @@ async function resolveManualDiagnosticRuntimeHost(
async function resolveTaskDiagnosticRuntimeHost(
selector: ManualDiagnosticHostSelector,
-): Promise {
+): Promise {
try {
await runtimeHostScopeList();
} catch {
@@ -2688,7 +2690,9 @@ const makaBridge = {
}
const { execution, target, ...errorInput } = input;
if (!execution) {
- const resolution = await resolveManualDiagnosticRuntimeHost(target);
+ const resolution: DiagnosticRuntimeHostResolution<'none' | 'default' | 'task'> = target
+ ? await resolveManualDiagnosticRuntimeHost(target)
+ : { hostTarget: 'none' };
const wireInput: DesktopErrorDiagnosticWireInput = {
...errorInput,
hostTarget: resolution.hostTarget,
diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts
index fe29850bc0..264bdf8de3 100644
--- a/apps/desktop/src/renderer/app-shell-command-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-command-actions.ts
@@ -61,6 +61,8 @@ export interface AppShellCommandListOptions {
dailyReviewBridge: DailyReviewBridge;
messages: StoredMessage[];
newTaskProfileId: string | undefined;
+ settingsOpen: boolean;
+ settingsProfileId: string | undefined;
sessions: SessionSummary[];
themePref: ThemePreference;
visibleSessions: SessionSummary[];
@@ -91,7 +93,14 @@ export interface AppShellCommandListOptions {
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
@@ -385,10 +394,17 @@ export function buildAppShellCommandList(
const {
captureComposerImportOwner,
newTaskProfileId,
+ settingsOpen,
+ settingsProfileId,
toastApi,
} = optionsRef.current;
const owner = captureComposerImportOwner();
- const target = resolveManualDiagnosticTarget(owner, newTaskProfileId);
+ const target = resolveManualDiagnosticTarget(
+ owner,
+ newTaskProfileId,
+ settingsOpen,
+ settingsProfileId,
+ );
try {
await window.maka.diagnostics.copyReport({
surface: "manual",
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 36e228b44f..3cc37730a0 100644
--- a/apps/desktop/src/renderer/app-shell-project-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-project-actions.ts
@@ -56,7 +56,6 @@ export function createAppShellProjectActions(deps: {
projects: readonly ProjectRecord[];
projectCapabilities: DesktopProjectCapabilities;
sessionId?: string;
- defaultProfileId?: string;
onProjectSelected(ownerSessionId?: string): void;
toastApi: ToastApi;
}): AppShellProjectActions {
@@ -71,15 +70,17 @@ export function createAppShellProjectActions(deps: {
projects,
projectCapabilities,
sessionId,
- defaultProfileId,
onProjectSelected,
toastApi,
} = deps;
const copy = getShellCopy(uiLocale).projectActions;
- const defaultDiagnosticTarget = defaultProfileId ? { profileId: defaultProfileId } : undefined;
const sessionDiagnosticTarget = sessionId ? { sessionId } : undefined;
const showDefaultProjectError = (title: string, description?: string) => {
- toastApi.error(title, description, undefined, defaultDiagnosticTarget);
+ // 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);
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 9093e3e977..b1b399a36a 100644
--- a/apps/desktop/src/renderer/app-shell-session-row-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-session-row-actions.ts
@@ -40,10 +40,11 @@ export interface SessionPurgeOutcome {
*/
restored: string[];
verified: boolean;
- /** First rejection, so the caller can show a reason rather than a count. */
- firstError: unknown;
- /** Session whose Host produced `firstError`. */
- firstErrorSessionId?: string;
+ /** First rejection and the Session whose Host produced it. */
+ firstFailure?: {
+ error: unknown;
+ sessionId: string;
+ };
}
export interface AppShellSessionRowActions {
@@ -214,8 +215,7 @@ export function createAppShellSessionRowActions(deps: {
async function purgeSessions(sessionIds: readonly string[]): Promise {
const unsettled: string[] = [];
const restored: string[] = [];
- let firstError: unknown;
- let firstErrorSessionId: string | undefined;
+ let firstFailure: SessionPurgeOutcome['firstFailure'];
let removed = 0;
for (const sessionId of sessionIds) {
const key = `${sessionId}:delete`;
@@ -234,10 +234,7 @@ export function createAppShellSessionRowActions(deps: {
else removed += 1;
} catch (error) {
unsettled.push(sessionId);
- if (firstError === undefined) {
- firstError = error;
- firstErrorSessionId = sessionId;
- }
+ firstFailure ??= { error, sessionId };
} finally {
pendingSessionRowActionsRef.current.delete(key);
}
@@ -249,8 +246,7 @@ export function createAppShellSessionRowActions(deps: {
remaining: [],
restored,
verified: true,
- firstError,
- firstErrorSessionId,
+ firstFailure,
};
}
let listed: SessionSummary[] | undefined;
@@ -266,8 +262,7 @@ export function createAppShellSessionRowActions(deps: {
remaining: [],
restored,
verified: false,
- firstError,
- firstErrorSessionId,
+ firstFailure,
};
}
const present = new Set(listed.map((session) => session.id));
@@ -277,8 +272,7 @@ export function createAppShellSessionRowActions(deps: {
remaining,
restored,
verified: true,
- firstError,
- firstErrorSessionId,
+ firstFailure,
};
}
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 987db4e611..78c1f32360 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -536,6 +536,8 @@ function AppShellContent({
openConnectionDetail,
openProviderCreate,
} = useSettingsModal();
+ const [settingsDiagnosticProfileId, setSettingsDiagnosticProfileId] =
+ useState();
const {
themePref,
setThemePref,
@@ -1901,9 +1903,6 @@ function AppShellContent({
sessionCwd: activeSession?.cwd,
sessionProjectId: activeSession?.projectId,
sessionProfileKind: activeDesktopSession?.profileKind,
- defaultProfileId: newTask.catalog.hosts.length > 0
- ? newTask.catalog.defaultProfileId
- : undefined,
onProjectSelected: (ownerSessionId) => {
void refreshSkills();
void refreshManagedSkillSources();
@@ -2876,6 +2875,8 @@ function AppShellContent({
dailyReviewBridge,
messages,
newTaskProfileId: newTask.selectedProfileId,
+ settingsOpen,
+ settingsProfileId: settingsDiagnosticProfileId,
sessions,
themePref,
visibleSessions,
@@ -3712,6 +3713,7 @@ function AppShellContent({
openNewTaskSurface();
void newTask.chooseProjectForProfile(profileId).catch(() => undefined);
}}
+ onSelectedRuntimeHostProfileIdChange={setSettingsDiagnosticProfileId}
/>
);
diff --git a/apps/desktop/src/renderer/settings/settings-modal.tsx b/apps/desktop/src/renderer/settings/settings-modal.tsx
index 46dd6837e3..9a21f7bdb5 100644
--- a/apps/desktop/src/renderer/settings/settings-modal.tsx
+++ b/apps/desktop/src/renderer/settings/settings-modal.tsx
@@ -57,6 +57,7 @@ export function SettingsModal(props: {
/** Receives the task 导入任务 just created, and opens it. */
onTaskImported(session: DesktopSessionSummary): void;
onRemoteHostAdded(profileId: string): void;
+ onSelectedRuntimeHostProfileIdChange(profileId: string | undefined): void;
}) {
const locale = useUiLocale();
const copy = getSettingsSharedCopy(locale);
@@ -111,6 +112,7 @@ export function SettingsModal(props: {
archivedTasks={props.archivedTasks}
onTaskImported={props.onTaskImported}
onRemoteHostAdded={props.onRemoteHostAdded}
+ onSelectedRuntimeHostProfileIdChange={props.onSelectedRuntimeHostProfileIdChange}
/>
);
diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx
index a87e20fb62..232f04a065 100644
--- a/apps/desktop/src/renderer/settings/settings-surface.tsx
+++ b/apps/desktop/src/renderer/settings/settings-surface.tsx
@@ -127,6 +127,7 @@ export function SettingsSurface(props: {
archivedTasks: ArchivedTasksBridge;
onTaskImported(session: DesktopSessionSummary): void;
onRemoteHostAdded(profileId: string): void;
+ onSelectedRuntimeHostProfileIdChange(profileId: string | undefined): void;
}) {
const locale = useUiLocale();
const copy = getSettingsSharedCopy(locale);
@@ -277,6 +278,12 @@ export function SettingsSurface(props: {
const sectionScope = settingsSectionScope(section);
const showsRuntimeHost = sectionScope !== 'client';
const requiresRuntimeHost = sectionScope === 'runtime-host';
+ useEffect(() => {
+ props.onSelectedRuntimeHostProfileIdChange(
+ showsRuntimeHost ? selectedProfileId : undefined,
+ );
+ return () => props.onSelectedRuntimeHostProfileIdChange(undefined);
+ }, [props.onSelectedRuntimeHostProfileIdChange, selectedProfileId, showsRuntimeHost]);
const runtimeHostCatalogFailed = runtimeHostCatalog.status === 'error';
const runtimeHostSettingsLoading = Boolean(
selectedRuntimeHostKey &&
@@ -588,19 +595,26 @@ export function SettingsSurface(props: {
}));
async function retryRuntimeHostContent(): Promise {
+ let diagnosticTarget: { profileId: string } | undefined;
try {
if (runtimeHostCatalog.status === 'error') {
await reloadRuntimeHosts();
return;
}
if (!selectedRuntimeHost || !connectionsBridge) return;
+ diagnosticTarget = { profileId: selectedRuntimeHost.profileId };
await Promise.all([
reloadRuntimeHostSettings(selectedRuntimeHost),
reloadConnections(connectionsBridge, selectedRuntimeHost),
]);
} catch (error) {
if (settingsModalMountedRef.current) {
- toast.error(copy.settingsLoadFailed, settingsActionErrorMessage(error, locale));
+ toast.error(
+ copy.settingsLoadFailed,
+ settingsActionErrorMessage(error, locale),
+ undefined,
+ diagnosticTarget,
+ );
}
}
}
diff --git a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx
index 6a83f76fc5..674317caea 100644
--- a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx
@@ -125,15 +125,15 @@ export function TasksSettingsPage(props: ArchivedTasksBridge) {
// still running, and "N still there" gives the reader nothing to do.
const reason = !outcome.verified
? copy.purgeUnverified
- : outcome.firstError
- ? settingsActionErrorMessage(outcome.firstError, locale)
+ : outcome.firstFailure
+ ? settingsActionErrorMessage(outcome.firstFailure.error, locale)
: copy.purgeFailedBody(outcome.remaining.length);
toast.error(
copy.purgeFailedTitle,
kept ? `${reason} ${kept}` : reason,
undefined,
- outcome.firstErrorSessionId
- ? { sessionId: outcome.firstErrorSessionId }
+ outcome.firstFailure
+ ? { sessionId: outcome.firstFailure.sessionId }
: undefined,
);
} else {
diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts
index 753de21c73..982b6aee81 100644
--- a/apps/desktop/src/renderer/settings/use-connection-detail.ts
+++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts
@@ -552,6 +552,8 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
toast.error(
saved ? copy.refreshFailed : copy.saveModelsFailed,
providerPanelActionErrorMessage(error, locale),
+ undefined,
+ { profileId: host.profileId },
);
} finally {
releaseSaveModels();
diff --git a/apps/desktop/src/renderer/use-project-context.ts b/apps/desktop/src/renderer/use-project-context.ts
index 17afec92a5..ec8b0ba3a0 100644
--- a/apps/desktop/src/renderer/use-project-context.ts
+++ b/apps/desktop/src/renderer/use-project-context.ts
@@ -43,7 +43,6 @@ export function useAppShellProjectContext(options: {
sessionCwd?: string;
sessionProjectId?: string | null;
sessionProfileKind?: 'local' | 'remote';
- defaultProfileId?: string;
onProjectSelected(ownerSessionId?: string): void;
toastApi: ToastApi;
}): AppShellProjectActions & {
@@ -66,7 +65,6 @@ export function useAppShellProjectContext(options: {
sessionCwd,
sessionProjectId,
sessionProfileKind,
- defaultProfileId,
onProjectSelected,
toastApi,
} = options;
@@ -224,7 +222,6 @@ export function useAppShellProjectContext(options: {
projects,
projectCapabilities,
sessionId,
- defaultProfileId,
onProjectSelected,
toastApi,
});
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index 55fa0cafa2..b82ebb28f3 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -1179,6 +1179,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) {
archivedTasks={archivedTasks}
onTaskImported={noop}
onRemoteHostAdded={noop}
+ onSelectedRuntimeHostProfileIdChange={noop}
/>
>