Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/extensions/ai-engineer-coach/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions src/canvas/bridge-shim.test.ts
Original file line number Diff line number Diff line change
@@ -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('<script>') + '<script>'.length;
return html.slice(start, html.indexOf('</script>', 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']);
});
17 changes: 12 additions & 5 deletions src/canvas/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
}

it('launches the canvas parser with Node and keeps child output off the protocol stream', async () => {
vi.mocked(parseAllLogsViaWorker).mockImplementationOnce(async (_dirs, _progress, deps) => {

Check warning on line 54 in src/canvas/host.test.ts

View workflow job for this annotation

GitHub Actions / verify

Async arrow function has no 'await' expression
deps?.fork?.('/fixture/parse-worker.js', [], { execArgv: ['--expose-gc'] });
return { sessions: [], workspaces: new Map(), editLocIndex: new Map(), sessionSourceIndex: new Map() };
});
Expand Down Expand Up @@ -105,27 +105,34 @@
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('<script>', `<${scriptTag}>`)
.replaceAll('</script>', `</${scriptTag}>`);
const script = /<script>([\s\S]*?)<\/script>/i.exec(html)![1];
const listeners = new Map<string, () => void>();
const dispatchEvent = vi.fn();
const streams: { onmessage?: (event: { data: string }) => void }[] = [];
const streams: { url: string; onmessage?: (event: { data: string }) => void }[] = [];
const sandboxWindow: { dispatchEvent: typeof dispatchEvent; acquireVsCodeApi?: () => { postMessage(msg: unknown): void } } = { dispatchEvent };
runInNewContext(script, {
window: { dispatchEvent },
window: sandboxWindow,
document: { addEventListener: (name: string, callback: () => void) => listeners.set(name, callback) },
MessageEvent,
EventSource: class {
onmessage?: (event: { data: string }) => void;
constructor() { streams.push(this); }
constructor(readonly url: string) { streams.push(this); }
},
});
// Page lifecycle events are no readiness signal: the bundle may not be listening yet (#269).
for (const callback of listeners.values()) callback();
const api = sandboxWindow.acquireVsCodeApi!();
api.postMessage({ type: 'progress' });
expect(streams).toHaveLength(0);
listeners.get('DOMContentLoaded')!();
api.postMessage({ type: 'ready' });
api.postMessage({ type: 'ready' });
expect(streams).toHaveLength(1);
expect(streams[0].url).toBe('/events');
streams[0].onmessage!({ data: '{"type":"dataReady"}' });
expect(dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({ data: { type: 'dataReady' } }));
host.dispose();
Expand Down
17 changes: 12 additions & 5 deletions src/canvas/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
'.css': 'text/css; charset=utf-8',
};

export function createCanvasHost(options: CanvasHostOptions): CanvasHost {

Check warning on line 77 in src/canvas/host.ts

View workflow job for this annotation

GitHub Actions / verify

Function 'createCanvasHost' has too many lines (164). Maximum allowed is 50
const webviewDir = path.join(options.distDir, 'webview');
const sseClients = new Set<ServerResponse>();

Expand Down Expand Up @@ -310,12 +310,23 @@
/* Stands in for the VS Code webview bridge. `acquireVsCodeApi` must exist before
* app.js runs. RPC requests become POSTs to /rpc; responses, progress, and
* dataReady arrive via SSE and are re-dispatched as window message events so the
* unchanged webview message listener handles them. */
* unchanged webview message listener handles them. /events replays the latest
* progress and dataReady on connect, so the stream opens only once the webview
* posts `ready` (sent after its message listener is attached), the same handshake
* the VS Code panel uses. Subscribing earlier can drop that replay before app.js
* listens, leaving the dashboard stuck on its loading screen (#269). */
const BRIDGE_SHIM = `(function(){
var STATE_KEY='ai-engineer-coach-canvas-state';
var events=null;
function dispatch(data){window.dispatchEvent(new MessageEvent('message',{data:data}));}
function subscribe(){
if(events)return;
events=new EventSource('/events');
events.onmessage=function(e){try{dispatch(JSON.parse(e.data));}catch(err){}};
}
var api={
postMessage:function(msg){
if(msg&&msg.type==='ready'){subscribe();return;}
if(!msg||msg.type!=='request')return;
if(msg.method==='openExternal'){
var url=msg.params&&msg.params.url;
Expand All @@ -339,8 +350,4 @@
setState:function(s){try{localStorage.setItem(STATE_KEY,JSON.stringify(s));}catch(e){}return s;}
};
window.acquireVsCodeApi=function(){return api;};
document.addEventListener('DOMContentLoaded',function(){
var es=new EventSource('/events');
es.onmessage=function(e){try{dispatch(JSON.parse(e.data));}catch(err){}};
},{once:true});
})();`;
Loading