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('