feat(startup): show a dialog when startup fails

A failed startup removed the status hint and left a frozen window, with the error only in the log. Module loading in init.js and firstLoadInit now show one modal dialog with the error, stage and stack, a native clipboard copy and reload, and still throw the original error. In init.js the dialog module loads off the error path, since a module-loading failure can take the dialog's own imports down with it.

The dialog renders in a shadow root, and its host resets inherited styles inline because page rules outrank :host. It reopens when closed instead of cancelling the close request, because Chromium ignores preventDefault() on a close request without user activation.

Refs #332
This commit is contained in:
Darkatse 2026-10-07 18:29:30 -04:00
parent 9db588538c
commit 2eecdefe51
7 changed files with 104 additions and 4 deletions

View file

@ -105,6 +105,8 @@
设置与角色是启动必需数据,失败会阻止启动;群组、头像或密钥状态失败时保留可见错误并仅禁用对应能力,不阻止 `APP_READY`。
启动失败只有两个出口:`src/init.js` 加载应用模块失败,或 `firstLoadInit()` 抛错。两处都调用 `showStartupFailure(error)` 显示不可关闭的模态失败对话框(错误、阶段与调用栈,可复制或重新加载),并继续抛出原错误,日志照常记录。展示不能替换原错误:`init.js` 的失败可能来自模块加载本身,所以对话框模块在错误路径之外加载,加载失败时作为第二个错误上报。对话框在 Shadow DOM 中渲染,不受主题与自定义 CSS 影响。
后端采样入口见 [bootstrap_commands.rs](../../src-tauri/crates/tauritavern/src/presentation/commands/bootstrap_commands.rs)。
---
@ -176,8 +178,8 @@ Panel Runtime 会在 `APP_READY` 后安装,用于在抽屉关闭时把部分
- `src/tauri/main/bootstrap.js`:`tt:tauri:*`
- `src/script.js`:`tt:startup:shell/core/full` + `tt:startup:ready`
- 运行时提示:
- `src/scripts/tauri/startup/startup-status-overlay.js`:右下角非阻塞启动状态 overlay(`APP_READY` 后移除)
- `pnpm run test:browser` 构建 vendor bundle 并运行 `tests/browser/*.test.mjs`。其中 `startup-order.test.mjs` 验证 Host-ready 模块先加载时主应用仍能正常初始化、早到的事件等待应用就绪后呈现。该测试组包含在 `pnpm test` 和 `pnpm run check` 中。
- `src/scripts/tauri/startup/startup-status-overlay.js`:右下角非阻塞启动状态 overlay(`APP_READY` 后移除)与启动失败对话框(见 §4)
- `pnpm run test:browser` 构建 vendor bundle 并运行 `tests/browser/*.test.mjs`。其中 `startup-order.test.mjs` 验证 Host-ready 模块先加载时主应用仍能正常初始化、早到的事件等待应用就绪后呈现,以及启动失败时对话框显示错误且错误继续上报。该测试组包含在 `pnpm test` 和 `pnpm run check` 中。
---

View file

@ -304,7 +304,10 @@ async function initializeApplication() {
safePerfMeasure('tt:init:import:app', 'tt:init:import:app:start', 'tt:init:import:app:end');
}
} catch (error) {
console.error('TauriTavern: Failed to initialize application:', error);
// Loading the dialog must not delay or replace the startup error.
void importWithRetry('./scripts/tauri/startup/startup-status-overlay.js')
.then(({ showStartupFailure }) => showStartupFailure(error));
throw error;
} finally {
if (PERF_ENABLED) {
safePerfMark('tt:init:end');

View file

@ -4684,6 +4684,9 @@
"Starting: connecting to the backend…": "启动中:连接后端…",
"Starting: loading core data…": "启动中:加载核心数据…",
"Starting: loading extensions and optional modules…": "启动中:加载扩展与可选模块…",
"TauriTavern could not start": "TauriTavern 无法启动",
"Reload to try again. If this keeps happening, copy the details below into a bug report.": "请重新加载后再试。如果问题反复出现,请复制下方详情并提交问题报告。",
"Copy details": "复制详情",
"Guide the Agent?": "是否引导Agent行为?",
"This text will be inserted as user guidance into the next Agent model request. It will not be saved as a regular chat message.": "这条内容会作为用户指引插入下一次 Agent 模型请求,不会作为普通聊天消息保存。",
"Could not save API key": "无法保存 API 密钥",

View file

@ -5201,6 +5201,9 @@
"Starting: connecting to the backend…": "啟動中:連接後端…",
"Starting: loading core data…": "啟動中:載入核心資料…",
"Starting: loading extensions and optional modules…": "啟動中:載入擴充功能與選用模組…",
"TauriTavern could not start": "TauriTavern 無法啟動",
"Reload to try again. If this keeps happening, copy the details below into a bug report.": "請重新載入後再試。如果問題反覆出現,請複製下方詳情並提交問題回報。",
"Copy details": "複製詳情",
"Guide the Agent?": "是否引導Agent行為?",
"This text will be inserted as user guidance into the next Agent model request. It will not be saved as a regular chat message.": "這則內容會作為使用者指引插入下一次 Agent 模型請求,不會作為一般聊天訊息儲存。",
"Could not save API key": "無法儲存 API 金鑰",

View file

@ -406,7 +406,7 @@ import { MacroEnvBuilder } from './scripts/macros/engine/MacroEnvBuilder.js';
import { MessageFormatter } from './scripts/message-formatter.js';
import { addChatBackupsBrowser } from './scripts/chat-backups.js';
import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';
import { createStartupStatusOverlay } from './scripts/tauri/startup/startup-status-overlay.js';
import { createStartupStatusOverlay, showStartupFailure } from './scripts/tauri/startup/startup-status-overlay.js';
import { canJumpToSwipeForMessage, canOpenSwipePickerForMessage, initSwipePicker } from './scripts/swipe-picker.js';
// API OBJECT FOR EXTERNAL WIRING
@ -1310,6 +1310,9 @@ async function firstLoadInit() {
}
})();
} catch (error) {
showStartupFailure(error);
throw error;
} finally {
startupStatus.remove();
}

View file

@ -1,4 +1,41 @@
import { writeClipboardText } from '../../../tauri-bridge.js';
import { t } from '../../i18n.js';
import { extractErrorText } from '../../util/command-error-utils.js';
const OVERLAY_ID = 'tt-startup-status-overlay';
const FAILURE_ID = 'tt-startup-failure';
const FAILURE_STYLE = `
dialog {
box-sizing: border-box;
width: min(92vw, 560px);
padding: 20px;
border: 1px solid GrayText;
border-radius: 12px;
color-scheme: light dark;
background: Canvas;
color: CanvasText;
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
dialog::backdrop { background: rgb(0 0 0 / 0.55); }
h2 { margin: 0 0 8px; font-size: 18px; }
p { margin: 0 0 12px; }
pre {
max-height: 40vh;
margin: 0 0 16px;
padding: 8px;
overflow: auto;
border-radius: 6px;
background: rgb(127 127 127 / 0.15);
font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
-webkit-user-select: text;
user-select: text;
}
.actions { display: flex; justify-content: flex-end; gap: 8px; }
button { font: inherit; padding: 6px 14px; }
`;
function ensureOverlayElement() {
const existing = document.getElementById(OVERLAY_ID);
@ -38,3 +75,43 @@ export function createStartupStatusOverlay() {
},
};
}
/**
* Callers must still throw the error; this only presents it.
* @param {unknown} error
*/
export function showStartupFailure(error) {
const stage = globalThis.__TAURITAVERN_STARTUP_STAGE__;
const details = [extractErrorText(error), stage && `Stage: ${stage}`, /** @type {any} */ (error)?.stack]
.filter(Boolean).join('\n');
const host = document.createElement('div');
host.id = FAILURE_ID;
// Theme styles, inherited ones included, must not reach the dialog; keep this reset inline.
host.style.all = 'initial';
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = `<style>${FAILURE_STYLE}</style>
<dialog role="alertdialog" aria-labelledby="title" aria-describedby="description">
<h2 id="title">${t`TauriTavern could not start`}</h2>
<p id="description">${t`Reload to try again. If this keeps happening, copy the details below into a bug report.`}</p>
<pre></pre>
<div class="actions">
<button type="button" name="copy">${t`Copy details`}</button>
<button type="button" name="reload" autofocus>${t`Reload`}</button>
</div>
</dialog>`;
root.querySelector('pre').textContent = details;
const copy = root.querySelector('button[name="copy"]');
copy.addEventListener('click', async () => {
await writeClipboardText(details);
copy.textContent = t`Copied!`;
});
root.querySelector('button[name="reload"]').addEventListener('click', () => location.reload());
const dialog = root.querySelector('dialog');
// The dialog must stay open; nothing usable remains behind it.
dialog.addEventListener('close', () => dialog.showModal());
document.body.append(host);
dialog.showModal();
}

View file

@ -19,6 +19,15 @@ try {
await earlyToast;
assert.deepEqual(toasts, ['Early sync event'], 'An event received before APP_READY must still be presented');
console.log('PASS: main application initializes and early host events wait for APP_READY');
// The runtime rejects every network request, so startup stops while loading locales.
const failure = new Promise(resolve => window.addEventListener('error', resolve, { once: true }));
window.jQuery.holdReady(false);
const { error } = await failure;
const dialog = window.document.getElementById('tt-startup-failure').shadowRoot.querySelector('dialog');
assert.equal(dialog.open, true, 'A failed startup must stay visible');
assert.ok(dialog.querySelector('pre').textContent.includes(error.message), 'The dialog must show the startup error');
console.log('PASS: a failed startup shows its error and still reports it');
} finally {
await window.happyDOM.close();
}