feat(chat): open the chat in an editor tab - #70
Conversation
Answers "can the LevelCode AI panel move to the centre?" — by dragging, no, and that is not our choice: `ViewContainerLocation` is Sidebar | Panel | AuxiliaryBar and nothing else, so a VIEW can go left or to the bottom but never into the editor grid. Explorer and Terminal cannot either. Only EDITORS live in the middle. So the centre needs a WebviewPanel rather than a WebviewView: a real tab that splits, moves between groups, and drags to another window. `AI: Open Chat in Editor`. It is a MOVE, not a mirror. Two live surfaces would mean fanning out every post(), making every handler idempotent, and shipping a UI that can disagree with itself; one live surface keeps a single source of truth and is what "open in editor" means to a user. The sidebar hands its slot to a small card offering "Bring it back". Three things carried the design: **The listener survives an html swap.** onDidReceiveMessage binds to the WEBVIEW, not the document, and the sidebar's html is swapped between the chat and the hand-off card. So wire() (register the handler, once) is split from makeLive() (become the live surface, load the chat). Wiring on every swap would stack handlers and double-send every message. **The transcript is DOM state.** A hand-over would land you in an empty chat holding a conversation the model still remembers. Every transition arms a replay of the live session's turns, consumed on `ready` — the earliest a fresh webview can receive anything — and cleared in the same step so it cannot repeat on a later reload. **One restore path.** "Bring it back" disposes the panel rather than restoring the sidebar itself, so closing the tab and clicking the button run identical code. A sidebar that was never resolved is revealed instead of written to. The replay reuses the sessionResumed renderer rather than a second one, now labelled by the caller: a move says "Moved to the editor", not "Resumed" — which would claim the session had been reloaded from disk when nothing of the sort happened. A real resume passes no tag and still reads "Resumed". Tests: 12 in test/chatSurface.test.js, read out of the shipped extension.js the way mcpManage does, because every failure mode here is state and invisible in a diff. Verified non-vacuous — each bypass is a bug someone could plausibly write: wire inside makeLive (duplicate handlers) 2/12 drop the already-open guard (two panels) 1/12 replay not cleared (stacking transcripts) 5/12 reattach restores directly (two paths) 7/12 hard-code "Resumed" (a move mislabelled) 10/12 drop the empty-transcript guard 6/12 33 suites green.
There was a problem hiding this comment.
Pull request overview
This PR adds an “open chat in the center” workflow for the levelcode-ai extension by moving the chat UI from a contributed sidebar WebviewView into an editor-tab WebviewPanel, preserving a single live chat surface and replaying the transcript across moves.
Changes:
- Introduces
AI: Open Chat in Editorcommand that hosts the chat in an editor tab (move, not mirror) and provides a sidebar hand-off card with “Bring it back”. - Refactors chat webview wiring so message handlers are registered once per webview (
wire()) and “becoming live” is separate (makeLive()), avoiding duplicate listeners during HTML swaps. - Adds targeted unit tests asserting the intended state/wiring invariants for sidebar ⇄ editor-tab transitions.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| extensions/levelcode-ai/extension.js | Adds editor-tab chat panel support, single-surface handoff logic, transcript replay, and sidebar hand-off card. |
| extensions/levelcode-ai/media/chat.html | Updates the “session resumed” banner renderer to accept caller-provided tag/icon (move vs resume). |
| extensions/levelcode-ai/package.json | Registers the new levelcode.ai.openChatInEditor command for discoverability. |
| extensions/levelcode-ai/test/chatSurface.test.js | Adds Node-based tests that parse shipped extension.js/chat.html to pin wiring/state behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| function detachedHtml() { | ||
| const bg = 'var(--vscode-sideBar-background)', fg = 'var(--vscode-foreground)'; | ||
| return '<!DOCTYPE html><html><head><meta charset="utf-8">' | ||
| + '<style>' | ||
| + 'body{margin:0;padding:28px 22px;background:' + bg + ';color:' + fg + ';' | ||
| + 'font-family:var(--vscode-font-family);font-size:var(--vscode-font-size);text-align:center}' | ||
| + '.t{font-size:14px;font-weight:600;margin-bottom:6px}' | ||
| + '.s{opacity:.7;line-height:1.55;margin-bottom:18px}' | ||
| + 'button{width:100%;padding:7px 10px;border:1px solid var(--vscode-button-border,transparent);' | ||
| + 'border-radius:4px;background:var(--vscode-button-background);color:var(--vscode-button-foreground);' | ||
| + 'font:inherit;cursor:pointer}button:hover{background:var(--vscode-button-hoverBackground)}' | ||
| + '</style></head><body>' | ||
| + '<div class="t">Chat is open in the editor</div>' | ||
| + '<div class="s">The conversation moved to a tab so it has room. Closing that tab brings it back here.</div>' | ||
| + '<button id="b">Bring it back</button>' | ||
| + '<script>const v=acquireVsCodeApi();document.getElementById("b").onclick=()=>v.postMessage({type:"reattach"});</script>' | ||
| + '</body></html>'; | ||
| } |
The command shipped palette-only, which is the wrong affordance for it: nobody searches the Command Palette for a capability they do not know exists, and "the chat can move to the centre" is exactly that kind of capability. The icon was already declared and doing nothing, because no menu contributed it. Now a fourth navigation action on the chat view title, beside Add context, New Chat and Set API key. `navigation@4` keeps it inline and lets VS Code overflow it into the … menu when the sidebar is too narrow for four — no crowding, and the sidebar is narrow by definition until you use this. Scoped `view == levelcodeAi.chat`; without the when-clause a view/title action appears on every view title in the window. One more test, verified non-vacuous three ways — remove the menu entry, drop the when-scope, remove the icon: 12/13 each. 33 suites green.
Nothing constrained the transcript's line length. The twelve max-width rules in chat.html are all cards, dialogs and the empty state; the message column had none. That went unnoticed for as long as the chat only lived in a ~380px sidebar, where the container did the bounding — opening it as an editor tab (#70) put the same CSS at 900px and produced ~154-character lines. Now: every direct child of #log shares one 680px column, centred, behind a --prose-max custom property so T5 can hand it to a setting. Applied to every child rather than just .msg, or the cards and the activity timeline drift wider than the prose they sit between. MEASURED, not estimated — and the measurement corrected the plan. The doc's first draft said "~72ch, near 640-700px". Both halves were wrong: "0" (the ch unit) 8.13px real average prose char 5.86px `ch` is 39% wider than actual text, so a 72ch cap yields ~100 characters, not 72; and 72 REAL characters is a 422px column, narrower than the sidebar. The print range of 45-75 does not transfer to a chat carrying identifiers and file paths. 680px is ~116 characters, down from 154, and still wide enough that a fenced block does not wrap constantly. The doc now records the correction; a test asserts the cap stays an absolute length so nobody reaches for `ch` again. Rhythm (D3) is em-based and GATED to >=760px. T1's exit criterion is that a narrow panel renders exactly as before, and that is verified rather than assumed: computed styles at 520px against develop are identical across padding, gap, paragraph and heading margins, line-height and font-size. Rendered and measured at three widths: 520px column 496 · 81 chars · padding 12px (identical to develop) 900px column 680 · 115 chars · padding 24px 1400px column 680 · 115 chars · does not grow Two guards added to webviewCss, because this regression is invisible in a sidebar: whoever refactors the log container will not see it break, a user with the chat in an editor tab will. Verified non-vacuous five ways — remove the cap, scope it to .msg only, switch to ch, drop the width gate, drop the centring: 14-15/17 each. 33 suites green.
Review, and correct: detachedHtml() served a webview document with an inline <script> and no Content-Security-Policy, while getHtml() and getSessionsHtml() both inject CSP + nonce. Small is not exempt — the card enables scripts and carries one, so it was the single document in the extension whose script surface was undescribed. Worse than untidy: a later tightening elsewhere would have silently stopped its button from working, and "Bring it back" is the only way out of the detached state short of closing the tab. Rather than a third copy of the policy, the construction moves into webviewCsp() and all three documents call it. It had already been duplicated twice; a third would have made "tighten the CSP" a three-file change with one easy to miss. Verified by rendering the card and reading it back: CSP default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-…' script nonce matches the policy no remote origins allowed Two guards, verified non-vacuous four ways — drop the meta (12/15), unnonce the script (12/15), hand-roll a third copy of the policy (13/15), bypass the helper in getHtml (13/15). One of them asserts the policy string exists exactly ONCE in the file, so the next document cannot quietly hand-roll its own. 33 suites green.
|
Correct, and fixed in dc91502.
It's also worse than untidy: a later CSP tightening elsewhere would have silently stopped its button from working, and Bring it back is the only way out of the detached state short of closing the tab. Extracted rather than copiedThe construction was already duplicated in Verified by rendering the card and reading it back: GuardsTwo new tests. One of them asserts the policy string appears exactly once in the file, so the next document can't quietly hand-roll its own.
33 suites green. |
Review found CHAT-TYPOGRAPHY.md contradicting itself in three places. One cause: D1 was corrected after measuring, and the correction was never propagated. intro "~130-character lines" → ~154, the figure actually measured T1 exit "~72 characters" → ~116, the 680px cap D1 settled on risks "no test guards this" → closed by the guards in this PR The 130 and the 72 were both written BEFORE the measurement that produced 680px; the risk bullet was true when written and stopped being true in the same commit. Rather than proofreading it again, the number is now pinned to the code: a test reads `--prose-max` out of the stylesheet and asserts the doc names that exact cap, and that no stray reference to the disproved 72-character target survives. Prose drifts from code silently — this is the only claim in the doc that a test can hold, so it holds it. Caught both directions: change the CSS without the doc (15/18), the doc without the CSS (15/18), reintroduce the old figure (15/18). The fourth comment (detachedHtml has no CSP) was written against this branch before it was rebased, when it still carried #70's commits. extension.js is no longer in this PR's diff, and the CSP + nonce landed in #70 as dc91502. 33 suites green.
Answers "can the LevelCode AI panel move to the centre?"
By dragging: no — and that isn't our choice:
Three locations, and the editor grid isn't one of them. A view can go left or to the bottom, never the middle — Explorer and Terminal can't either. Only editors live in the middle.
So the centre needs a
WebviewPanelrather than aWebviewView: a real tab that splits, moves between groups, and drags to another window.AI: Open Chat in Editor.It's a move, not a mirror
Two live surfaces would mean fanning out every
post(), making every handler idempotent, and shipping a UI that can disagree with itself. One live surface keeps a single source of truth — and "open in editor" means move to a user anyway. The sidebar hands its slot to a small card offering Bring it back.Three things shaped the design
The listener survives an html swap.
onDidReceiveMessagebinds to the webview, not the document, and the sidebar's html swaps between the chat and the hand-off card. Sowire()(register the handler, once) is split frommakeLive()(become the live surface, load the chat). Wiring on every swap would stack handlers and double-send every message — a double-resolved approval, a message sent twice.The transcript is DOM state. A naive hand-over lands you in an empty chat holding a conversation the model still remembers — the worst of both. Every transition arms a replay of the live session's turns, consumed on
ready(the earliest a fresh webview can receive anything) and cleared in the same step so it can't repeat on a later reload.One restore path. Bring it back disposes the panel rather than restoring the sidebar itself, so closing the tab and clicking the button run identical code. A sidebar that was never resolved is revealed rather than written to.
A move isn't a resume
The replay reuses the
sessionResumedrenderer rather than adding a second one — but the caller now names it. A move reads "Moved to the editor", not "Resumed", which would claim the session had been reloaded from disk when nothing of the sort happened. A real resume passes no tag and still says Resumed.Tests
12 in
test/chatSurface.test.js, read out of the shippedextension.jsthe waymcpManagedoes — every failure mode here is state, and invisible in a diff.Each bypass is a bug someone could plausibly write:
33 suites green.
Side benefit
At editor width the session cards get their labels back for free — the container query from #65/#67 already handles it, so the six-button action row stops fighting for space.