From 61b9c2d056b84ab91786866bbd06d78309eaba3e Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sun, 16 Aug 2026 13:54:50 -0400 Subject: [PATCH] feat(chat): the chat is an editor tab, and only an editor tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the contributed chat view in the right-hand bar entirely. Sessions keeps that container — an index of past conversations is a different thing from the conversation, and does not need to split a narrow column with it. WHY THE VIEW HAD TO GO RATHER THAN BE DEPRIORITISED. Two possible hosts for one conversation is what produced every bug reported against it: closing the tab reopened the chat on the right, ⇧⌘I opened it on the right, and the ResizeObserver console spam came from `purpose=webviewView` — the sidebar copy. Each was fixable in isolation; the shape that kept generating them was not. Gone with it: the hand-over card (detachedHtml), the `reattach` message, the move command and its button, the close-versus-move distinction, and two of the three transcript-replay paths. `levelcode.ai.focus` (⇧⌘I) and every background reveal now open the tab. CLOSING THE TAB IS AN ENDING, NOT A DISCARD. It seals the live session into History and lets memory learn from it — the same `m.seal('done')` + `enrichMemoryAsync(sealedId)` that New Chat has always done, now extracted into sealLiveSession() and shared. Two copies would drift, and the half that drifted would be the close path, because that is the half nobody watches. It cannot throw: it runs from a dispose handler, where an exception has nowhere to go. The three actions that lived on the sidebar view's title bar — New Chat, Add Files, Set API Key — move to the chat TAB's title bar, gated on `activeWebviewPanelId == 'levelcode.ai.chat'`. Deleting the view without moving them would have deleted the only place they were reachable outside the palette, which is no place at all for a capability nobody knows exists. `chat.startLocation` drops `secondarySidebar`; `editor` and `none` are the only honest values left. An existing `secondarySidebar` in settings.json falls back to the default through the validation that was already there. Guards, each bypass-verified by reverting the fix: - closing no longer sealing, so the conversation is silently dropped - memory never learning from the sealed session - newChat growing its own copy of the sealing logic again - ⇧⌘I pointing back at the removed view - nothing constructing the chat provider (which still owns wire()/makeLive()) - the chat returning as a contributed view (caught in sessionsUi, where that guard lives) Two test corrections worth naming: - the fire-and-forget scan looked for executeCommand('levelcodeAi.chat.focus'), a string that no longer appears anywhere — it would have kept passing while checking nothing. Repointed at openChatInEditor(), which is what opens the chat now, and taught to skip the function declaration. - sessionsUi asserted "both Chat and Sessions views present". It now asserts Sessions is there and Chat is NOT, which is the property that matters. Supersedes #80: a close-versus-move distinction is meaningless when there is nothing to move to. 19 tests in chatSurface, 34 suites green. --- extensions/levelcode-ai/extension.js | 152 ++++++-------- extensions/levelcode-ai/package.json | 39 +--- .../levelcode-ai/test/chatSurface.test.js | 192 +++++++++--------- .../levelcode-ai/test/sessionsUi.test.js | 9 +- 4 files changed, 169 insertions(+), 223 deletions(-) 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, /