diff --git a/.github/extensions/ai-engineer-coach/README.md b/.github/extensions/ai-engineer-coach/README.md
index 1f849a8f..8cf26517 100644
--- a/.github/extensions/ai-engineer-coach/README.md
+++ b/.github/extensions/ai-engineer-coach/README.md
@@ -37,6 +37,6 @@ Open with `{ "page": "curiosity" }` to start on the Curiosity page. Omit input t
- `extension.mjs` owns build detection and a single `127.0.0.1` HTTP server, and declares the canvas via `createCanvas` / `joinSession`.
- `dist/canvas-host.cjs` (built from `src/canvas/host.ts`) provides the request handler: the dashboard shell, the asset routes, an SSE channel for parse progress, and the `/rpc` endpoint.
-- The dashboard talks to the host through an injected `acquireVsCodeApi` shim, so the webview code is unchanged between VS Code and canvas.
+- The dashboard talks to the host through an injected `acquireVsCodeApi` shim, so the webview code is unchanged between VS Code and canvas. The shim opens the SSE stream only after the dashboard posts `ready` (the same handshake the VS Code panel uses), so the progress and `dataReady` events replayed on connect always reach a listener.
The harness breakdown distinguishes **GitHub Copilot App** (desktop) from **GitHub Copilot CLI** (terminal) using the `remoteSteerable` marker present in app session logs.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19be66f0..95d5ea54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+- Fix canvas mode intermittently loading with an empty content pane or a stuck
+ loading screen (#269): the canvas bridge now subscribes to host events only
+ after the dashboard signals it is listening, so the replayed `dataReady` is
+ never dropped
- Fix Coding Moments image counting and loading across VS Code, Copilot CLI/App,
Claude (including subagents), Codex, OpenCode JSON/SQLite, and Xcode (#292);
extract images off-thread with bounded local reads and session-scoped caching
diff --git a/src/canvas/bridge-shim.test.ts b/src/canvas/bridge-shim.test.ts
new file mode 100644
index 00000000..8f5524db
--- /dev/null
+++ b/src/canvas/bridge-shim.test.ts
@@ -0,0 +1,59 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+/**
+ * @vitest-environment jsdom
+ *
+ * Regression test for #269. The canvas host replays `dataReady` on /events the moment a client
+ * connects. If the bridge shim subscribes before app.js has attached its message listener (at
+ * shim evaluation, or on DOMContentLoaded when the bundle has not run yet), the replay reaches
+ * no listener and the dashboard never leaves its loading screen. This wires the shim the host
+ * actually serves to the real webview listener and replays as soon as the stream opens.
+ */
+
+import { IncomingMessage, ServerResponse } from 'http';
+import { Socket } from 'net';
+import { runInThisContext } from 'vm';
+import { afterEach, expect, it, vi } from 'vitest';
+import { createCanvasHost } from './host';
+
+afterEach(() => vi.unstubAllGlobals());
+
+function servedBridgeShim(): string {
+ const host = createCanvasHost({ distDir: '/fixture/dist' });
+ const req = new IncomingMessage(new Socket());
+ req.url = '/';
+ req.method = 'GET';
+ const res = new ServerResponse(req);
+ const end = vi.spyOn(res, 'end').mockImplementation(() => res);
+ host.handle(req, res);
+ host.dispose();
+ const html = String(end.mock.calls[0][0]);
+ const start = html.indexOf('', start));
+}
+
+it('delivers the replayed dataReady even when the page finishes loading before the dashboard listens', async () => {
+ const streams: string[] = [];
+ vi.stubGlobal('EventSource', class {
+ onmessage?: (event: { data: string }) => void;
+ constructor(url: string) {
+ streams.push(url);
+ setTimeout(() => this.onmessage?.({ data: JSON.stringify({ type: 'dataReady', currentWorkspace: 'my-repo' }) }));
+ }
+ });
+ runInThisContext(servedBridgeShim());
+
+ // An async or code-split bundle can attach its listener after DOMContentLoaded has fired.
+ document.dispatchEvent(new Event('DOMContentLoaded'));
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ const { initMessageListener } = await import('../webview/shared');
+ const onDataReady = vi.fn();
+ initMessageListener(() => { /* progress is not under test */ }, onDataReady);
+
+ await vi.waitFor(() => expect(onDataReady).toHaveBeenCalledWith('my-repo', { skippedFiles: 0, skippedLines: 0 }));
+ expect(streams).toEqual(['/events']);
+});
diff --git a/src/canvas/host.test.ts b/src/canvas/host.test.ts
index c3733e40..0190f799 100644
--- a/src/canvas/host.test.ts
+++ b/src/canvas/host.test.ts
@@ -105,7 +105,7 @@ it('retains startup errors for RPC calls and clients that connect later', async
host.dispose();
});
-it.each(['script', 'SCRIPT', 'Script'])('waits for app.js to load before subscribing to replayed dataReady events (%s tags)', (scriptTag) => {
+it.each(['script', 'SCRIPT', 'Script'])('subscribes to replayed dataReady events only after the webview announces it is listening (%s tags)', (scriptTag) => {
const host = createCanvasHost({ distDir: '/fixture/dist' });
const html = String(request(host, '/').end.mock.calls[0][0])
.replaceAll('