diff --git a/openspec/changes/open-html-preview-in-browser/design.md b/openspec/changes/open-html-preview-in-browser/design.md
index bda7a286..6b996e4a 100644
--- a/openspec/changes/open-html-preview-in-browser/design.md
+++ b/openspec/changes/open-html-preview-in-browser/design.md
@@ -12,6 +12,7 @@ Reuse preview path resolution, native browser ownership, toolbar styling and uns
- Pass the existing browser-opening callback through PreviewDetailsPanel to FilePreviewContent. Extend it with an optional URL and navigate the newly created page using the existing bridge. This preserves session ownership instead of bypassing ChatView from a leaf component.
- Reuse the globe icon and ace-code-action-btn styling before the wrap action. Hide the action in runtimes without native browser support.
- Reuse save/discard/cancel before opening. The browser reads the saved disk file, never an unsaved draft. Guard session changes across asynchronous work and prevent duplicate opens while pending.
+- Native page creation is asynchronous and may return `ready:false`. Wait for the native ready state before navigation, bounded to 15 seconds, preserving cancellation when the session changes or page closes. This was verified against real Windows WebView2, not inferred from an immediate-ready fixture.
## Risks / Trade-offs
diff --git a/openspec/changes/open-html-preview-in-browser/specs/side-preview-rendering/spec.md b/openspec/changes/open-html-preview-in-browser/specs/side-preview-rendering/spec.md
index 8da780cc..3f4006e0 100644
--- a/openspec/changes/open-html-preview-in-browser/specs/side-preview-rendering/spec.md
+++ b/openspec/changes/open-html-preview-in-browser/specs/side-preview-rendering/spec.md
@@ -21,3 +21,8 @@ The file source preview SHALL offer a keyboard-accessible browser icon before th
#### Scenario: Browser failure
- **WHEN** browser page creation or navigation fails
- **THEN** the application displays the failure and retains the source file tab
+
+#### Scenario: Native page is still initializing
+- **WHEN** the newly created native browser page is not yet ready
+- **THEN** the application waits for readiness before navigating to the file
+- **AND** a closed page, startup failure, timeout, or session switch stops that navigation
diff --git a/openspec/changes/open-html-preview-in-browser/tasks.md b/openspec/changes/open-html-preview-in-browser/tasks.md
index 209ecedc..265fd138 100644
--- a/openspec/changes/open-html-preview-in-browser/tasks.md
+++ b/openspec/changes/open-html-preview-in-browser/tasks.md
@@ -6,6 +6,7 @@
## 2. Validation
- [x] 2.1 Run Web tests/build, strict OpenSpec validation, diff checks and focused browser checks; all passed on 2026-10-08.
+- [x] 2.2 Handle asynchronous native page startup before immediate HTML navigation; readiness/failure/cancellation tests, delayed-ready App fixture, and native Windows file loading passed before publication.
## Validation evidence
@@ -15,3 +16,6 @@
- Real App/ChatView browser fixture passed toolbar ordering, keyboard activation, session ownership, encoded navigation, unsaved cancel/save, non-HTML exclusion, and creation/navigation error reporting. Evidence: `%TEMP%/ace-html-browser-pTOugM/`.
- Initial fixture attempts selected a non-button Files tab and a collapsed file panel; corrected the fixture to reveal the panel through the real shortcut. No product change was needed.
- Browser proof uses an isolated native bridge fixture. Native package and cross-platform release verification are tracked separately for v1.0.0.
+- Native Windows verification exposed a real startup race: page creation returned `ready:false` and immediate navigation was rejected. The action now waits up to 15 seconds for readiness, stops on close/session changes, and reports startup failures. Added deterministic readiness/timeout/closed/cancellation regressions; the real App fixture now rejects navigation during a 200 ms startup delay and passes.
+- A packaged v1.0.0 Desktop with the actual readiness helper loaded a real `file://` page containing Unicode, spaces, `#` and `%` in its filename and reported the expected title. Evidence is in the release's `native-html-smoke.json`; updated App captures are under `%TEMP%/ace-html-browser-pit2jD/`.
+- Full Web tests/build, i18n generation and strict OpenSpec validation passed again after the readiness fix. The initial native harness required a standard `USERPROFILE/AppData/{Local,Roaming}` hierarchy; this was a test-profile correction, not a production source change.
diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs
index 6f7e29a1..d150e592 100644
--- a/web/scripts/i18n-en-overrides.mjs
+++ b/web/scripts/i18n-en-overrides.mjs
@@ -3,6 +3,8 @@
export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({
'打开设置': 'Open settings',
'在内置浏览器中打开': 'Open in built-in browser',
+ '浏览器页面已关闭': 'The browser page has been closed',
+ '浏览器启动超时,请重试': 'Browser startup timed out. Please try again',
'发送消息': 'Send message',
'思考深度 ({{p0}} / {{p1}})': 'Reasoning depth ({{p0}} / {{p1}})',
'keyboard shortcuts hotkeys 快捷键 键盘 搜索 前进 后退 思维深度 Ctrl Alt Cmd': 'keyboard shortcuts hotkeys search forward back reasoning depth Ctrl Alt Cmd',
diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx
index ce952788..8720a488 100644
--- a/web/src/components/ChatView.jsx
+++ b/web/src/components/ChatView.jsx
@@ -287,6 +287,7 @@ import {
hasNativeAgentBrowser,
runAgentBrowserBridgeAction,
selectAgentBrowserPage,
+ waitForAgentBrowserPageReady,
} from '../lib/agentBrowser.js';
import {
agentBrowserPageStore,
@@ -5478,12 +5479,24 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses
await closeAgentBrowserPage(created.page_id);
return;
}
- showBrowserPage(
+ const shown = await showBrowserPage(
created.page_id,
created.title || defaultBrowserTabTitle(),
created.favicon,
);
+ if (!shown) {
+ await closeAgentBrowserPage(created.page_id);
+ return;
+ }
if (typeof url === 'string' && url) {
+ const ready = await waitForAgentBrowserPageReady(created.page_id, {
+ isCurrent: () => sidRef.current === sid,
+ });
+ if (ready.cancelled) {
+ await closeAgentBrowserPage(created.page_id);
+ return;
+ }
+ if (ready.ok === false) throw new Error(ready.error || '浏览器操作失败');
const navigated = await runAgentBrowserBridgeAction('aceDesktop_agentBrowserNavigate', {
page_id: created.page_id, url,
});
diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js
index b8cfaadb..5c14a447 100644
--- a/web/src/i18n/sourceCatalog.generated.js
+++ b/web/src/i18n/sourceCatalog.generated.js
@@ -1815,6 +1815,7 @@ export const sourceCatalogs = {
"s_ac7646d3e0e17e38": "待导入主题预览",
"s_ac77953a1e064ed6": "发送失败",
"s_ac8e7ffd9e3458ab": "本平台没有可用沙盒,未知命令需要确认",
+ "s_acae259f56e1fc3e": "浏览器页面已关闭",
"s_acb60e5e17fe5e0a": "当前安装的 ACECode 已是最新版本。",
"s_acfc4e74a650e7df": "下一步",
"s_acfc92e72a99cc1a": "危险模式下不能开启远程 Web 模式",
@@ -1939,6 +1940,7 @@ export const sourceCatalogs = {
"s_b9f07dc1e2f18bba": "新建专家",
"s_b9f738a8c427d576": "解除{{p0}}:{{p1}}",
"s_ba05867a7464cdeb": "当前运行方式不支持 macOS 系统通知",
+ "s_ba10a6c73bc64727": "浏览器启动超时,请重试",
"s_ba2e93e73037c71e": "恢复默认",
"s_ba40014ff496f64e": "类型",
"s_ba46deca5b144acf": "该文件不在当前工作区或 worktree 中,只能只读预览",
@@ -4504,6 +4506,7 @@ export const sourceCatalogs = {
"s_ac7646d3e0e17e38": "Theme import preview",
"s_ac77953a1e064ed6": "Sending failed",
"s_ac8e7ffd9e3458ab": "No sandbox is available; unknown commands require approval",
+ "s_acae259f56e1fc3e": "The browser page has been closed",
"s_acb60e5e17fe5e0a": "The currently installed ACECode is the latest version.",
"s_acfc4e74a650e7df": "Next step",
"s_acfc92e72a99cc1a": "Remote Web mode cannot be enabled in dangerous mode",
@@ -4628,6 +4631,7 @@ export const sourceCatalogs = {
"s_b9f07dc1e2f18bba": "New expert",
"s_b9f738a8c427d576": "Detach {{p0}}: {{p1}}",
"s_ba05867a7464cdeb": "The current running mode does not support macOS system notifications",
+ "s_ba10a6c73bc64727": "Browser startup timed out. Please try again",
"s_ba2e93e73037c71e": "Restore defaults",
"s_ba40014ff496f64e": "Type",
"s_ba46deca5b144acf": "This file is outside the current workspace or worktree and can only be previewed",
diff --git a/web/src/lib/agentBrowser.js b/web/src/lib/agentBrowser.js
index b0ac2959..bd26a828 100644
--- a/web/src/lib/agentBrowser.js
+++ b/web/src/lib/agentBrowser.js
@@ -138,6 +138,22 @@ export async function getAgentBrowserState(pageId = '', win = globalThis.window)
);
}
+export async function waitForAgentBrowserPageReady(
+ pageId, { isCurrent = () => true, timeoutMs = 15000, pollMs = 50 } = {}, win = globalThis.window,
+) {
+ const deadline = Date.now() + timeoutMs;
+ while (isCurrent()) {
+ const state = await getAgentBrowserState(pageId, win);
+ if (!isCurrent()) break;
+ if (state.ok === false) return state;
+ if (state.closed) return { ok: false, error: '浏览器页面已关闭' };
+ if (state.ready) return state;
+ if (Date.now() >= deadline) return { ok: false, error: '浏览器启动超时,请重试' };
+ await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, deadline - Date.now())));
+ }
+ return { ok: false, cancelled: true };
+}
+
// 页面归属参数:daemon 工具与 UI 自建页都用同一形态,session_id 是唯一联结键,
// workspace_hash 只作附带信息。
export function agentBrowserOwnerForSession(sessionRef) {
diff --git a/web/src/lib/agentBrowser.test.js b/web/src/lib/agentBrowser.test.js
index 9bf8034e..3c81d0c6 100644
--- a/web/src/lib/agentBrowser.test.js
+++ b/web/src/lib/agentBrowser.test.js
@@ -13,6 +13,7 @@ import {
setAgentBrowserShared,
toggleAgentBrowserDevTools,
toggleAgentBrowserElementSelection,
+ waitForAgentBrowserPageReady,
} from './agentBrowser.js';
async function run(name, fn) {
@@ -25,6 +26,31 @@ async function run(name, fn) {
}
}
+await run('Agent Browser waits for native page readiness before immediate navigation', async () => {
+ const states = [{ ok: true, ready: false }, { ok: true, ready: true }];
+ const calls = [];
+ const win = {
+ __ACECODE_DESKTOP_SHELL__: true,
+ __ACECODE_OS__: 'windows',
+ aceDesktop_agentBrowserCreatePage() {},
+ aceDesktop_agentBrowserSetLayout() {},
+ aceDesktop_agentBrowserGetState(pageId) { calls.push(pageId); return states.shift(); },
+ };
+ assert.equal((await waitForAgentBrowserPageReady('page-1', { pollMs: 0 }, win)).ready, true);
+ assert.deepEqual(calls, ['page-1', 'page-1']);
+ win.aceDesktop_agentBrowserGetState = () => ({ ok: true, ready: false });
+ assert.match((await waitForAgentBrowserPageReady('page-1', { timeoutMs: 0 }, win)).error, /超时/);
+ win.aceDesktop_agentBrowserGetState = () => ({ ok: false, error: 'native failed' });
+ assert.equal((await waitForAgentBrowserPageReady('page-1', {}, win)).error, 'native failed');
+ win.aceDesktop_agentBrowserGetState = () => ({ ok: true, closed: true });
+ assert.match((await waitForAgentBrowserPageReady('page-1', {}, win)).error, /已关闭/);
+ let current = true;
+ win.aceDesktop_agentBrowserGetState = () => { current = false; return { ok: true, ready: true }; };
+ assert.equal((await waitForAgentBrowserPageReady('page-1', { isCurrent: () => current }, win)).cancelled, true);
+ win.aceDesktop_agentBrowserGetState = () => { throw new Error('must not read a stale page'); };
+ assert.equal((await waitForAgentBrowserPageReady('page-1', { isCurrent: () => false }, win)).cancelled, true);
+});
+
await run('Agent Browser address normalization accepts web and absolute local paths', () => {
assert.equal(normalizeAgentBrowserAddress(' example.com/a '), 'https://example.com/a');
assert.equal(normalizeAgentBrowserAddress('HTTP://localhost:3000'), 'HTTP://localhost:3000');