Skip to content

Commit 041c369

Browse files
committed
fix(chat): closing the chat tab closes it, instead of reopening on the right
Regression from #76, reported from a real build. A MOVE and a CLOSE both end in panel.dispose(), and onDidDispose treated them identically — revealing the sidebar every time. That was correct while the sidebar was home: surrendering the tab meant going home. With chat.startLocation defaulting to `editor` it inverts into a trap. Closing the tab pops the panel open on the right, and there is no way to put the chat away at all: close the tab and the panel appears, close the panel and it returns with the next window. The `else` branch was the sharp end — with no resolved sidebar view (the normal case now, since the chat opens centred and nobody opens the secondary sidebar) it called focusChatView() unconditionally, which is precisely what revealed the panel. Dispose cannot see the difference on its own, so moveChatToSidebar announces itself with a flag that dispose reads once and clears. A plain close now closes. WHAT STILL HAPPENS ON A CLOSE: if a sidebar view is already resolved it still becomes live, because otherwise the conversation would be live nowhere while a resolved view sat there showing a stale hand-over card. Only the REVEAL is conditional — making a hidden view live costs nothing and is correct the moment the user opens it. Two existing tests asserted the old behaviour and were updated deliberately, not mechanically: - "a sidebar that was never resolved is revealed rather than assumed" asserted the reveal on EVERY close. That was the bug, stated as a requirement. It now covers the move. - the activeWebview invariant pinned an exact list of assignments, so adding a second RESET read as a regression. It now says what it means: exactly one place points it at a live surface, and at least one releases it. Guards, each bypass-verified by reverting the fix: - the resolved view force-opened on a plain close - the no-view reveal escaping the `moving` branch (the reported bug) - the flag never cleared, so the NEXT close reveals too - dispose unable to see a move at all - the flag set AFTER dispose, so the move reads it stale - the resolved view never becoming live, which would leave the chat live nowhere NOT in this change, both reported alongside it: - The untitled tab at startup is not ours — LevelCode has no `workbench.startupEditor` override anywhere in its source. That is the editor default or a user setting. - The "ResizeObserver loop" console spam is INFO-level noise from VS Code's own webview harness; we register no ResizeObserver. I could not reproduce a loop: a harness running the real stylesheet at 280/320/340/360px, with the ASCII logo's clamp(5px, 3.6cqi, 13px) genuinely in its variable range, reported 1 resize callback and 0 loop errors. Fixing this bug removes the reported scenario anyway, since the sidebar view is no longer force-opened. Not shipping a speculative CSS change for something I have not reproduced. 25 tests in chatSurface, 34 suites green.
1 parent 4e6593b commit 041c369

2 files changed

Lines changed: 91 additions & 15 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ let sidebarChatView; // the contributed view, so the panel can hand the slot
5858
/** @type {vscode.WebviewPanel | undefined} */
5959
let chatEditorPanel; // set only while the chat is open as an editor tab
6060
let chatProvider; // the single provider instance; both surfaces wire through it
61+
// Closing the tab and MOVING the chat both end in panel.dispose(), and they must not mean the same
62+
// thing. Set only by moveChatToSidebar, read once by onDidDispose, cleared immediately.
63+
let movingChatToSidebar = false;
6164
// The visible transcript lives in the webview's DOM, so swapping surfaces would blank it. Set before
6265
// handing over; the freshly-loaded surface replays on its `ready`, which is the first moment it can
6366
// receive anything at all.
@@ -2311,19 +2314,37 @@ async function openChatInEditor(opts) {
23112314
dbg('chat.openInEditor', {});
23122315

23132316
panel.onDidDispose(() => {
2317+
// A MOVE and a CLOSE both land here. Until the chat opened centred by default they were the same
2318+
// thing — the sidebar was home, so surrendering the tab meant going home — and this handler
2319+
// revealed the sidebar unconditionally. With `chat.startLocation: editor` that turns ⌘W into
2320+
// "reopen on the right", and there is no way to put the chat away at all: close the tab, the
2321+
// panel appears; close the panel, it is still bound to come back next time.
2322+
//
2323+
// So the reveal now happens ONLY for a deliberate move. A plain close closes.
2324+
const moving = movingChatToSidebar;
2325+
movingChatToSidebar = false;
23142326
chatEditorPanel = undefined;
2327+
23152328
if (sidebarChatView) {
2329+
// The view exists, so hand the conversation back to it either way — otherwise the chat would
2330+
// be live nowhere while a resolved view sits there showing a stale hand-over card. Only the
2331+
// REVEAL is conditional: making a hidden view live costs nothing and is correct the moment
2332+
// the user opens it.
23162333
pendingTranscriptReplay = 'Back in the sidebar';
23172334
chatProvider.makeLive(sidebarChatView.webview);
2318-
sidebarChatView.show?.(true);
2319-
} else {
2320-
// The view was never resolved (the container has not been opened this session). Reveal it —
2321-
// resolveWebviewView then makes it live, and without this the chat would have no surface at all.
2335+
if (moving) { sidebarChatView.show?.(true); }
2336+
} else if (moving) {
2337+
// Moving with no resolved view: reveal it, and resolveWebviewView makes it live on arrival.
23222338
activeWebview = undefined;
23232339
pendingTranscriptReplay = 'Back in the sidebar';
2324-
focusChatView('editorClosed');
2340+
focusChatView('movedToSidebar');
2341+
} else {
2342+
// Plain close, nothing resolved: the chat has no surface, which is exactly what was asked
2343+
// for. Drop the reference so nothing posts into a disposed webview; reopening from the
2344+
// command, the sidebar, or the next window restores it.
2345+
activeWebview = undefined;
23252346
}
2326-
dbg('chat.closedEditor', {});
2347+
dbg('chat.closedEditor', { moving });
23272348
});
23282349
}
23292350

@@ -2336,7 +2357,13 @@ async function openChatInEditor(opts) {
23362357
* hand-over changed.
23372358
*/
23382359
function moveChatToSidebar() {
2339-
if (chatEditorPanel) { chatEditorPanel.dispose(); return undefined; }
2360+
if (chatEditorPanel) {
2361+
// Tell onDidDispose this is a MOVE. Disposing is how the move is performed, so without this flag
2362+
// the hand-over cannot tell it apart from the user simply closing the tab.
2363+
movingChatToSidebar = true;
2364+
chatEditorPanel.dispose();
2365+
return undefined;
2366+
}
23402367
// Already there (or never moved) — just reveal it, so the command is never a silent no-op.
23412368
//
23422369
// RETURNED, not fired and forgotten. `registerCommand` awaits whatever the handler returns, so a

extensions/levelcode-ai/test/chatSurface.test.js

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,14 @@ function fnBody(src, name) {
6161
test('SURFACE: only makeLive() ever moves the conversation, so two surfaces cannot both be live', () => {
6262
// `post()` writes to activeWebview. If anything else assigned it, a hand-over could leave the
6363
// pointer on a webview the user is no longer looking at — messages vanish into a hidden DOM.
64+
// Stated as an invariant rather than an exact list: the number of RESETS is allowed to grow (the
65+
// dispose path now has one per close route), but the number of places that point it at a live
66+
// surface is not. Pinning the whole list meant adding a reset looked like a regression.
6467
const writes = [...ext.matchAll(/activeWebview\s*=\s*([^;]+);/g)].map((m) => m[1].trim());
65-
assert.deepStrictEqual(writes.sort(), ['undefined', 'webview'],
66-
'activeWebview is assigned somewhere other than makeLive()/the dispose reset: ' + writes.join(' | '));
68+
const live = writes.filter((w) => w !== 'undefined');
69+
assert.deepStrictEqual(live, ['webview'],
70+
'activeWebview is pointed at a surface somewhere other than makeLive(): ' + writes.join(' | '));
71+
assert.ok(writes.length > live.length, 'nothing ever releases activeWebview — a disposed webview stays addressable');
6772
assert.match(fnBody(ext, 'openChatInEditor'), /chatProvider\.makeLive\(panel\.webview\)/,
6873
'the panel never becomes the live surface');
6974
});
@@ -140,14 +145,18 @@ test('RESTORE: closing the tab and "Bring it back" are the SAME path', () => {
140145
assert.match(open, /chatEditorPanel = undefined/, 'the panel ref outlives the panel');
141146
});
142147

143-
test('RESTORE: a sidebar that was never resolved is revealed rather than assumed', () => {
148+
test('RESTORE: a MOVE to a sidebar that was never resolved reveals it rather than assuming it', () => {
144149
// If the container has not been opened this session, sidebarChatView is undefined — restoring by
145-
// writing to it would throw, and doing nothing would leave the chat with no surface at all.
150+
// writing to it would throw, and for a MOVE, doing nothing would leave the chat with no surface at
151+
// all after the user explicitly asked for it on the right.
152+
//
153+
// This used to assert the reveal happened on EVERY close, which was right while the sidebar was
154+
// home and became a bug the moment the editor became the default: it turned closing the tab into
155+
// reopening the panel. The reveal is now scoped to the move, and the plain-close half is pinned by
156+
// the CLOSE tests below.
146157
const open = fnBody(ext, 'openChatInEditor');
147-
// The reveal now goes through focusChatView() so its rejection cannot go unhandled; what this test
148-
// cares about is unchanged — the else-branch must still reveal the view rather than assume it.
149-
assert.match(open, /if \(sidebarChatView\) \{[\s\S]*\} else \{[\s\S]*focusChatView\(/,
150-
'the never-resolved sidebar case is unhandled');
158+
assert.match(open, /if \(sidebarChatView\) \{[\s\S]*\} else if \(moving\) \{[\s\S]*focusChatView\(/,
159+
'a move with no resolved sidebar view no longer reveals it — the chat would land nowhere');
151160
});
152161

153162
test('RESTORE: while detached, the sidebar shows the hand-off card, not a second chat', () => {
@@ -354,4 +363,44 @@ test('FOCUS: the shared helper logs the failure and names who caused it', () =>
354363
assert.ok(callers >= 6, 'expected the background reveals to route through the helper, found ' + callers);
355364
});
356365

366+
test('CLOSE: closing the tab closes the chat — it does not reopen on the right', () => {
367+
// The regression this pins, reported from a real build: with chat.startLocation defaulting to the
368+
// editor, onDidDispose revealed the sidebar unconditionally. Closing the tab therefore POPPED THE
369+
// PANEL OPEN on the right, and there was no way to put the chat away at all — close the tab, the
370+
// panel appears; close the panel, it comes back with the next window.
371+
//
372+
// A move and a close both end in panel.dispose(), so they are told apart by an explicit flag rather
373+
// than by anything dispose itself can see.
374+
const dispose = ext.slice(ext.indexOf('panel.onDidDispose'), ext.indexOf('panel.onDidDispose') + 1800);
375+
assert.match(dispose, /const moving = movingChatToSidebar;/, 'dispose cannot tell a move from a close');
376+
assert.match(dispose, /movingChatToSidebar = false;/, 'the flag must be cleared, or the NEXT close reveals too');
377+
378+
// The two reveals are the whole bug. Both must now be conditional.
379+
assert.match(dispose, /if \(moving\) \{ sidebarChatView\.show\?\.\(true\); \}/,
380+
'a plain close still forces the resolved sidebar view open');
381+
// The no-view path is the one that actually bit: with nothing resolved, dispose used to call
382+
// focusChatView() unconditionally, which is what POPPED THE PANEL OPEN. It must now sit inside the
383+
// `moving` branch — checked positionally, because that is the property, and a regex trying to
384+
// describe the surrounding block shape is how the first version of this assertion broke.
385+
const revealIdx = dispose.indexOf('focusChatView(');
386+
const movingBranchIdx = dispose.indexOf('else if (moving)');
387+
assert.ok(movingBranchIdx > 0, 'the no-view close path is no longer split on `moving`');
388+
assert.ok(revealIdx > movingBranchIdx,
389+
'focusChatView is reachable on a plain close — closing the tab reopens the chat on the right');
390+
391+
// …and a close must still release the surface, or messages post into a disposed webview.
392+
assert.match(dispose, /activeWebview = undefined;/, 'the disposed webview is still referenced');
393+
});
394+
395+
test('CLOSE: a deliberate move still hands the conversation over', () => {
396+
// The other half — it would be easy to fix the close by making the move stop working.
397+
assert.match(fnBody(ext, 'moveChatToSidebar'), /movingChatToSidebar = true;[\s\S]{0,120}chatEditorPanel\.dispose\(\)/,
398+
'the move must announce itself BEFORE disposing, or dispose reads a stale flag');
399+
const dispose = ext.slice(ext.indexOf('panel.onDidDispose'), ext.indexOf('panel.onDidDispose') + 1800);
400+
assert.match(dispose, /chatProvider\.makeLive\(sidebarChatView\.webview\)/,
401+
'a resolved sidebar view must still become live, or the chat is live nowhere');
402+
assert.match(dispose, /pendingTranscriptReplay = 'Back in the sidebar'/,
403+
'the transcript must still replay into whichever surface takes over');
404+
});
405+
357406
console.log('\nchatSurface: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)