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, /