From 58571838236776948bb03eb622c5d5d3b08dc6e2 Mon Sep 17 00:00:00 2001 From: Sanjay Singh Date: Wed, 21 Oct 2026 21:34:04 +0200 Subject: [PATCH] fix: gate canvas event stream on the webview ready handshake The canvas host replays the latest progress and dataReady on /events as soon as a client connects. The bridge shim subscribed on DOMContentLoaded, which only preceded app.js while the bundle stayed a parser-blocking script; an async or code-split bundle could still miss the replay, leaving the dashboard stuck on its loading screen with no way to recover by navigating. The shim now opens the stream only when the webview posts `ready`, which initMessageListener sends after its listener is attached - the same handshake the VS Code panel uses. A jsdom regression test wires the served shim to the real listener and fails against both earlier subscription strategies. Resolves #269 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extensions/ai-engineer-coach/README.md | 2 +- CHANGELOG.md | 4 ++ src/canvas/bridge-shim.test.ts | 59 +++++++++++++++++++ src/canvas/host.test.ts | 17 ++++-- src/canvas/host.ts | 17 ++++-- 5 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 src/canvas/bridge-shim.test.ts 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('