diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js
index 4da3933..9ebd1e8 100644
--- a/extensions/levelcode-ai/extension.js
+++ b/extensions/levelcode-ai/extension.js
@@ -53,8 +53,6 @@ let ctx;
* @type {vscode.Webview | undefined}
*/
let activeWebview;
-/** @type {vscode.WebviewView | undefined} */
-let sidebarChatView; // the contributed view, so the panel can hand the slot back when it closes
/** @type {vscode.WebviewPanel | undefined} */
let chatEditorPanel; // set only while the chat is open as an editor tab
let chatProvider; // the single provider instance; both surfaces wire through it
@@ -431,10 +429,13 @@ function captureSelection() {
* diagnose — a chat surface that silently never appears — so `why` names the caller in the log.
*
* NOT for a command handler whose whole job IS the reveal: `levelcode.ai.focus` returns the thenable
- * instead, so VS Code reports the failure to the user who asked for it. See moveChatToSidebar.
+ * instead, so VS Code reports the failure to the user who asked for it.
+ *
+ * "The chat" is now the editor tab and nothing else — this used to reveal the contributed view in the
+ * right-hand bar, which is why every one of these callers kept pulling a panel out on the right.
*/
function focusChatView(why) {
- return Promise.resolve(vscode.commands.executeCommand('levelcodeAi.chat.focus'))
+ return Promise.resolve(openChatInEditor())
.then(undefined, (e) => {
const msg = String((e && e.message) || e);
console.warn('[levelcode-ai] chat.focus.failed', { why, msg });
@@ -1082,10 +1083,33 @@ async function resumeSession(id) {
dbg('sessions.resumed', { id, tier: r.plan && r.plan.tier, restored: agentMessages.length, shown: turns.length });
}
+/**
+ * Seal the live session and let memory learn from it.
+ *
+ * Extracted because closing the chat tab has to do exactly what New Chat does. A conversation that
+ * ends because the user shut the tab is not a lost one: it is a finished one, and it should land in
+ * History with its outcome recorded and its facts promoted, the same as any other. Two copies of this
+ * would drift, and the half that drifted would be the one nobody watches — the close path.
+ *
+ * Never throws: it runs from a dispose handler, where an exception has nowhere to go.
+ */
+function sealLiveSession(why) {
+ try {
+ const m = sessionsManager();
+ if (!m) { return; }
+ const sealedId = m.liveId();
+ m.seal('done');
+ if (sealedId) { enrichMemoryAsync(sealedId); } // outcome + fact promotion, off the critical path
+ dbg('sessions.sealed', { why, id: sealedId });
+ } catch (e) {
+ dbg('sessions.seal.error', { why, msg: String((e && e.message) || e) });
+ }
+}
+
function newChat() {
// Seal the outgoing session (its terminal state + a final index row) BEFORE the transcript is cleared,
// so it lands in History as a finished session and the next turn opens a fresh one.
- try { const m = sessionsManager(); if (m) { const sealedId = m.liveId(); m.seal('done'); if (sealedId) { enrichMemoryAsync(sealedId); } } } catch (e) { dbg('sessions.seal.error', { msg: String((e && e.message) || e) }); }
+ sealLiveSession('newChat');
conversation = [];
agentMessages = [];
checkpoints.length = 0; currentCheckpoint = null; // drop the per-turn restore stack
@@ -2177,19 +2201,17 @@ function sendConfigToWebview() {
post({ type: 'config', provider: providerId, proseSize, proseWidth, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity });
}
+/**
+ * Owns the chat webview: one message handler, one live surface.
+ *
+ * It used to also be a WebviewViewProvider, because the chat could be hosted by a contributed view in
+ * the right-hand bar OR by an editor tab. That is gone: the chat is an editor tab and nothing else.
+ * Two hosts for one conversation bought a hand-over card, a detached-state document, a move command,
+ * a close-versus-move distinction, and a replay on every transition — all of it machinery for a
+ * choice nobody wanted. Sessions still live in the right-hand bar; they are a different thing and do
+ * not need to share a column with the conversation they index.
+ */
class ChatViewProvider {
- /** @param {vscode.WebviewView} view */
- resolveWebviewView(view) {
- sidebarChatView = view;
- view.webview.options = { enableScripts: true, localResourceRoots: [ctx.extensionUri] };
- this.wire(view.webview);
- // If the chat is currently an editor tab, this slot shows a hand-off card rather than a second
- // live copy. The view can resolve at any time (first reveal, a reload), so the check belongs
- // here and not only at the moment the panel opens.
- if (chatEditorPanel) { view.webview.html = detachedHtml(); return; }
- this.makeLive(view.webview);
- }
-
/** Point the conversation at `webview` and load the chat into it. Assumes it is already wired. */
makeLive(webview) {
activeWebview = webview;
@@ -2197,9 +2219,9 @@ class ChatViewProvider {
}
/**
- * Register the ONE message handler on a webview. Separate from makeLive because the sidebar's html
- * is swapped between the chat and the hand-off card, and a listener survives an html swap — wiring
- * on every swap would stack duplicate handlers and double-send every message.
+ * Register the ONE message handler on a webview. Kept separate from makeLive because a listener
+ * lives on the WEBVIEW and survives an html swap: makeLive can reload the document (a new chat, a
+ * resumed session) without stacking a second handler and double-sending every message.
*/
wire(webview) {
webview.onDidReceiveMessage(async (msg) => {
@@ -2207,9 +2229,6 @@ class ChatViewProvider {
// `ready` is the earliest a freshly-loaded webview can hear anything, so it is also where a
// surface that just took over replays the conversation it inherited (openChatInEditor).
case 'ready': cloudSignedIn = !!(ctx && await ctx.secrets.get(ACCOUNT_TOKEN_KEY)); autopilot = aiConfig().get('agent.autopilot', false); sendConfigToWebview(); postActiveFile(); postContextFiles(); post({ type: 'mode', agent: agentMode }); post({ type: 'autopilot', on: autopilot }); postAccount(); buildFileIndex(); post({ type: 'contextUsage', input: 0, limit: currentContextLimit() }); if (review) { review.resync(); } postMemoryDigest(); if (pendingTranscriptReplay) { const t = pendingTranscriptReplay; pendingTranscriptReplay = ''; replayLiveTranscript(t); } break;
- // The hand-off card's button. Disposing the panel runs its onDidDispose, which is the ONE
- // place that restores the sidebar — so "bring it back" and closing the tab are one path.
- case 'reattach': if (chatEditorPanel) { chatEditorPanel.dispose(); } break;
case 'setMode': agentMode = !!msg.agent; post({ type: 'mode', agent: agentMode }); break;
case 'setAutopilot': autopilot = !!msg.on; aiConfig().update('agent.autopilot', autopilot, vscode.ConfigurationTarget.Global); dbg('autopilot.set', { on: autopilot }); post({ type: 'autopilot', on: autopilot }); break;
case 'send': await handleSend(msg.text); break;
@@ -2280,8 +2299,9 @@ class ChatViewProvider {
* middle. Only EDITORS live in the middle, so the centre needs a WebviewPanel: a real tab that
* splits, moves between groups, and can be dragged to another window like any other editor.
*
- * It is a MOVE. The sidebar hands over its slot and shows a card; the conversation continues in the
- * tab with one live surface throughout.
+ * This is the ONLY surface. It used to be one of two — the chat could also be hosted by a contributed
+ * view in the right-hand bar, and opening here was a "move" that handed that slot over and left a card
+ * behind. Sessions still live over there; the conversation does not.
*/
async function openChatInEditor(opts) {
// `preserveFocus` exists for the STARTUP path only. Opening the chat centred is what the user asked
@@ -2307,45 +2327,24 @@ async function openChatInEditor(opts) {
// Hand the sidebar slot over. Its listener survives an html swap, so the card's button still
// reaches the same handler — see ChatViewProvider.wire.
- if (sidebarChatView) { sidebarChatView.webview.html = detachedHtml(); }
dbg('chat.openInEditor', {});
panel.onDidDispose(() => {
+ // Closing the chat CLOSES it. There is no second surface to hand back to any more, and the
+ // previous behaviour — reveal the sidebar — turned ⌘W into "reopen on the right", with no way to
+ // put the chat away at all.
+ //
+ // The conversation is not discarded, though. Shutting the tab is an ending, so it gets the same
+ // ending New Chat gives: the session is sealed into History and memory learns from it. Doing
+ // this here rather than only in newChat is the difference between "I closed the tab" and "I lost
+ // the conversation".
chatEditorPanel = undefined;
- if (sidebarChatView) {
- pendingTranscriptReplay = 'Back in the sidebar';
- chatProvider.makeLive(sidebarChatView.webview);
- sidebarChatView.show?.(true);
- } else {
- // The view was never resolved (the container has not been opened this session). Reveal it —
- // resolveWebviewView then makes it live, and without this the chat would have no surface at all.
- activeWebview = undefined;
- pendingTranscriptReplay = 'Back in the sidebar';
- focusChatView('editorClosed');
- }
+ activeWebview = undefined; // nothing may post into a disposed webview
+ sealLiveSession('chatClosed');
dbg('chat.closedEditor', {});
});
}
-/**
- * The other direction of the move: put the chat back in the right-hand bar.
- *
- * Disposing the panel IS the move — `onDidDispose` above already hands the slot back to the sidebar
- * and replays the transcript. Going through it rather than duplicating that path is what makes this
- * button and ⌘W behave identically; a second implementation would drift from it the first time the
- * hand-over changed.
- */
-function moveChatToSidebar() {
- if (chatEditorPanel) { chatEditorPanel.dispose(); return undefined; }
- // Already there (or never moved) — just reveal it, so the command is never a silent no-op.
- //
- // RETURNED, not fired and forgotten. `registerCommand` awaits whatever the handler returns, so a
- // failure here reaches the user as a failed command instead of an unhandled rejection. That is the
- // opposite of the startup path on purpose: this is an explicit click, and silence would leave the
- // user pressing a button that does nothing.
- return vscode.commands.executeCommand('levelcodeAi.chat.focus');
-}
-
/**
* Where the chat opens when the window does.
*
@@ -2367,7 +2366,6 @@ async function revealChatAtStartup() {
const where = chatStartLocation();
dbg('chat.startLocation', { where });
if (where === 'none') { return; }
- if (where === 'secondarySidebar') { await vscode.commands.executeCommand('levelcodeAi.chat.focus'); return; }
await openChatInEditor({ preserveFocus: true });
}
@@ -2391,34 +2389,6 @@ function replayLiveTranscript(tag) {
post({ type: 'sessionResumed', id, title: entry.title || 'Session', note: '', tag, icon: 'layout', turns });
}
-/**
- * The sidebar slot while the chat is an editor tab. Deliberately tiny — it is a signpost, not a UI.
- *
- * Small does not mean exempt: it enables scripts and carries an inline one, so it gets the same
- * CSP + nonce as the chat and sessions documents. Anything less and this would be the one webview
- * whose script surface is undescribed.
- */
-function detachedHtml() {
- const { nonce, csp } = webviewCsp();
- const bg = 'var(--vscode-sideBar-background)', fg = 'var(--vscode-foreground)';
- return '
'
- + ''
- + ''
- + '
Chat is open in the editor
'
- + '
The conversation moved to a tab so it has room. Closing that tab brings it back here.
'
- + ''
- + ''
- + '';
-}
-
/**
* A webview Content-Security-Policy and the nonce it authorises.
*
@@ -2715,16 +2685,19 @@ async function openWorkspaceFile(rel) {
function activate(context) {
ctx = context;
+ // Constructed directly rather than by registerWebviewViewProvider: the chat is no longer a
+ // contributed view, but the panel still needs the one object that owns wire()/makeLive().
+ chatProvider = new ChatViewProvider();
context.subscriptions.push(
- vscode.window.registerWebviewViewProvider('levelcodeAi.chat', (chatProvider = new ChatViewProvider()), {
- webviewOptions: { retainContextWhenHidden: true }
- }),
vscode.window.registerWebviewViewProvider('levelcodeAi.sessions', new SessionsViewProvider(), {
webviewOptions: { retainContextWhenHidden: true }
}),
vscode.commands.registerCommand('levelcode.ai.sessions', () => vscode.commands.executeCommand('levelcodeAi.sessions.focus')),
vscode.window.onDidChangeActiveTextEditor(() => postActiveFile()),
- vscode.commands.registerCommand('levelcode.ai.focus', () => vscode.commands.executeCommand('levelcodeAi.chat.focus')),
+ // ⇧⌘I. Opens the chat where the chat lives — the editor tab. This pointed at the contributed
+ // view, which is why the shortcut kept pulling a panel out on the right after the conversation
+ // had stopped living there.
+ vscode.commands.registerCommand('levelcode.ai.focus', () => openChatInEditor()),
vscode.commands.registerCommand('levelcode.customize', () => openCustomize(context)),
// Agent Sketch: the visual multi-agent flow canvas. Lazy require — only loads when opened.
vscode.commands.registerCommand('levelcode.ai.sketch', () => {
@@ -2742,7 +2715,6 @@ function activate(context) {
// argument, and openChatInEditor now reads an options object there. Bound directly, a title-bar
// click would pass whatever VS Code supplies and could set preserveFocus by accident.
vscode.commands.registerCommand('levelcode.ai.openChatInEditor', () => openChatInEditor()),
- vscode.commands.registerCommand('levelcode.ai.moveChatToSidebar', () => moveChatToSidebar()),
vscode.commands.registerCommand('levelcode.ai.addSelection', addSelection),
vscode.commands.registerCommand('levelcode.ai.addFileContext', addContext),
vscode.commands.registerCommand('levelcode.ai.setApiKey', () => promptForKey()),
diff --git a/extensions/levelcode-ai/package.json b/extensions/levelcode-ai/package.json
index 8011ca5..2ba77e9 100644
--- a/extensions/levelcode-ai/package.json
+++ b/extensions/levelcode-ai/package.json
@@ -85,11 +85,6 @@
},
"views": {
"levelcodeAi": [
- {
- "id": "levelcodeAi.chat",
- "name": "Chat",
- "type": "webview"
- },
{
"id": "levelcodeAi.sessions",
"name": "Sessions",
@@ -135,12 +130,6 @@
"category": "LevelCode",
"icon": "$(link-external)"
},
- {
- "command": "levelcode.ai.moveChatToSidebar",
- "title": "AI: Move Chat to Sidebar",
- "category": "LevelCode",
- "icon": "$(layout-sidebar-right)"
- },
{
"command": "levelcode.ai.sessions",
"title": "AI: Sessions",
@@ -217,33 +206,21 @@
}
],
"menus": {
- "view/title": [
+ "editor/title": [
{
"command": "levelcode.ai.addFileContext",
- "when": "view == levelcodeAi.chat",
- "group": "navigation@1"
+ "when": "activeWebviewPanelId == 'levelcode.ai.chat'",
+ "group": "navigation@0"
},
{
"command": "levelcode.ai.newChat",
- "when": "view == levelcodeAi.chat",
- "group": "navigation@2"
+ "when": "activeWebviewPanelId == 'levelcode.ai.chat'",
+ "group": "navigation@1"
},
{
"command": "levelcode.ai.setApiKey",
- "when": "view == levelcodeAi.chat",
- "group": "navigation@3"
- },
- {
- "command": "levelcode.ai.openChatInEditor",
- "when": "view == levelcodeAi.chat",
- "group": "navigation@4"
- }
- ],
- "editor/title": [
- {
- "command": "levelcode.ai.moveChatToSidebar",
"when": "activeWebviewPanelId == 'levelcode.ai.chat'",
- "group": "navigation@0"
+ "group": "navigation@2"
},
{
"command": "levelcode.ai.review.keepActive",
@@ -390,12 +367,10 @@
"type": "string",
"enum": [
"editor",
- "secondarySidebar",
"none"
],
"enumDescriptions": [
- "Open the chat as a centred editor tab, like any other file.",
- "Reveal the chat in the right-hand sidebar.",
+ "Open the chat as a centred editor tab.",
"Do not open the chat automatically."
],
"default": "editor",
diff --git a/extensions/levelcode-ai/test/chatSurface.test.js b/extensions/levelcode-ai/test/chatSurface.test.js
index 90e9af6..a5a16a2 100644
--- a/extensions/levelcode-ai/test/chatSurface.test.js
+++ b/extensions/levelcode-ai/test/chatSurface.test.js
@@ -1,16 +1,18 @@
/*---------------------------------------------------------------------------------------------
* Chat surfaces — sidebar ⇄ editor tab — run: node test/chatSurface.test.js
*
- * WHY THIS EXISTS. The chat can be hosted by a contributed WebviewView (sidebar) or a WebviewPanel
- * (an editor tab). A view can never live in the editor grid — `ViewContainerLocation` is
- * Sidebar | Panel | AuxiliaryBar and nothing else — so the centre needs a genuinely different
- * object, and now two objects can host one conversation.
+ * WHY THIS EXISTS. The chat is a WebviewPanel — an editor tab — and nothing else. It used to ALSO be
+ * hostable by a contributed WebviewView in the right-hand bar, and one conversation with two possible
+ * hosts needed a hand-over card, a detached document, a move command, a close-versus-move
+ * distinction, and a transcript replay on every transition. All of that is gone; Sessions keeps the
+ * right-hand container, because an index of past conversations is a different thing from the
+ * conversation and does not need to share a column with it.
*
* Everything that can go wrong here is STATE, not layout, and none of it is visible in a diff:
- * · two live surfaces, so a post() reaches one and the user is looking at the other
* · a listener registered twice, so every click is handled twice
- * · a hand-over that blanks the transcript, because it lives in the DOM
- * · a restore path that only runs for one of the two ways a tab can close
+ * · a second panel, so a post() reaches one DOM and the user is looking at the other
+ * · a close that silently discards the conversation instead of sealing it
+ * · a reveal that resurrects the surface the user just closed
*
* So these assertions are about the SHAPE of the wiring, read out of the shipped extension.js the
* way mcpManage/ctxSegments read theirs. extension.js requires `vscode`, which does not exist
@@ -61,9 +63,13 @@ function fnBody(src, name) {
test('SURFACE: only makeLive() ever moves the conversation, so two surfaces cannot both be live', () => {
// `post()` writes to activeWebview. If anything else assigned it, a hand-over could leave the
// pointer on a webview the user is no longer looking at — messages vanish into a hidden DOM.
+ // Stated as an invariant rather than an exact list: RESETS may multiply, but the places that point
+ // it at a live surface may not.
const writes = [...ext.matchAll(/activeWebview\s*=\s*([^;]+);/g)].map((m) => m[1].trim());
- assert.deepStrictEqual(writes.sort(), ['undefined', 'webview'],
- 'activeWebview is assigned somewhere other than makeLive()/the dispose reset: ' + writes.join(' | '));
+ const live = writes.filter((w) => w !== 'undefined');
+ assert.deepStrictEqual(live, ['webview'],
+ 'activeWebview is pointed at a surface somewhere other than makeLive(): ' + writes.join(' | '));
+ assert.ok(writes.length > live.length, 'nothing releases activeWebview — a disposed webview stays addressable');
assert.match(fnBody(ext, 'openChatInEditor'), /chatProvider\.makeLive\(panel\.webview\)/,
'the panel never becomes the live surface');
});
@@ -108,8 +114,8 @@ test('REPLAY: every hand-over arms a replay — the transcript is DOM state and
// Three transitions exist: to the tab, back to a resolved sidebar, and back to one that was never
// resolved. Miss any and the user lands in an empty chat holding a conversation the model still
// remembers — the worst of both worlds.
- assert.strictEqual((ext.match(/pendingTranscriptReplay = '[^']+'/g) || []).length, 3,
- 'a hand-over path does not arm the replay');
+ assert.strictEqual((ext.match(/pendingTranscriptReplay = '[^']+'/g) || []).length, 1,
+ 'the tab-open replay is gone, or a second hand-over path crept back in');
assert.match(fnBody(ext, 'openChatInEditor'), /pendingTranscriptReplay = 'Moved to the editor'[\s\S]*makeLive\(panel\.webview\)/,
'the replay must be armed BEFORE the surface loads, or `ready` fires with nothing pending');
});
@@ -130,33 +136,8 @@ test('REPLAY: nothing is invented when there is nothing to replay', () => {
// ---- 4. The restore path ------------------------------------------------------------------------
-test('RESTORE: closing the tab and "Bring it back" are the SAME path', () => {
- // Two restore paths is two chances to leave the chat with no surface. The card disposes the panel
- // and lets onDidDispose do the work, rather than restoring the sidebar itself.
- assert.match(ext, /case 'reattach': if \(chatEditorPanel\) \{ chatEditorPanel\.dispose\(\); \} break;/,
- 'reattach restores the sidebar directly instead of disposing the panel');
- const open = fnBody(ext, 'openChatInEditor');
- assert.match(open, /onDidDispose\(\(\) => \{/, 'no disposal handler — closing the tab would strand the chat');
- assert.match(open, /chatEditorPanel = undefined/, 'the panel ref outlives the panel');
-});
-test('RESTORE: a sidebar that was never resolved is revealed rather than assumed', () => {
- // If the container has not been opened this session, sidebarChatView is undefined — restoring by
- // writing to it would throw, and doing nothing would leave the chat with no surface at all.
- const open = fnBody(ext, 'openChatInEditor');
- // The reveal now goes through focusChatView() so its rejection cannot go unhandled; what this test
- // cares about is unchanged — the else-branch must still reveal the view rather than assume it.
- assert.match(open, /if \(sidebarChatView\) \{[\s\S]*\} else \{[\s\S]*focusChatView\(/,
- 'the never-resolved sidebar case is unhandled');
-});
-test('RESTORE: while detached, the sidebar shows the hand-off card, not a second chat', () => {
- assert.match(ext, /if \(chatEditorPanel\) \{ view\.webview\.html = detachedHtml\(\); return; \}/,
- 'a sidebar resolving while the tab is open would load a second live chat');
- const card = fnBody(ext, 'detachedHtml');
- assert.match(card, /postMessage\(\{type:"reattach"\}\)/, 'the card offers no way back');
- assert.ok(!/getHtml\(\)/.test(card), 'the card must not be the full chat');
-});
// ---- 5. It reads as a move, not a resume --------------------------------------------------------
@@ -181,17 +162,6 @@ test('COMMAND: it is registered and discoverable in the palette', () => {
// ---- 6. Every webview document describes its own script surface --------------------------------
-test('CSP: the hand-off card carries a policy and a nonced script, like the other documents', () => {
- // It shipped without one. Small is not exempt: the card enables scripts and carries an inline one,
- // so without a CSP it was the single webview in the extension whose script surface was undescribed
- // — and a later tightening elsewhere would have silently stopped its button from working.
- const card = fnBody(ext, 'detachedHtml');
- assert.match(card, /Content-Security-Policy/, 'no CSP meta — the card is unlike every other document here');
- assert.match(card, /