Skip to content

feat(chat): open the chat in an editor tab - #70

Merged
ndemianc merged 3 commits into
developfrom
feat/chat-in-editor
Aug 15, 2026
Merged

feat(chat): open the chat in an editor tab#70
ndemianc merged 3 commits into
developfrom
feat/chat-in-editor

Conversation

@ndemianc

Copy link
Copy Markdown
Contributor

Answers "can the LevelCode AI panel move to the centre?"

By dragging: no — and that isn't our choice:

export const enum ViewContainerLocation { Sidebar, Panel, AuxiliaryBar }

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 WebviewPanel rather than a WebviewView: 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. onDidReceiveMessage binds to the webview, not the document, and the sidebar's html swaps 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 — 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 sessionResumed renderer 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 shipped extension.js the way mcpManage does — every failure mode here is state, and invisible in a diff.

Each bypass is a bug someone could plausibly write:

bypass caught at
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.

One note: I first edited package.json via a JSON round-trip, which escaped every em-dash to across the file. Reverted and inserted as text — the diff is now the 6 lines it should be.

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.

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.
Copilot AI lite review requested due to automatic review settings August 15, 2026 18:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Editor command 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.

Comment on lines +2291 to +2308
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.
ndemianc added a commit that referenced this pull request Aug 15, 2026
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.
@ndemianc

Copy link
Copy Markdown
Contributor Author

Correct, and fixed in dc91502.

detachedHtml() served a webview document with an inline <script> and no CSP, while getHtml() and getSessionsHtml() both inject policy + nonce. Small isn't exempt — the card enables scripts and carries one, so it was the single document in the extension whose script surface was undescribed.

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 copied

The construction was already duplicated in getHtml and getSessionsHtml. A third copy would have made "tighten the CSP" a three-file change with one of them easy to miss — so it moved into webviewCsp() and all three call it.

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
remote origins none

Guards

Two 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.

bypass caught at
drop the CSP 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

33 suites green.

@ndemianc
ndemianc merged commit d4afa67 into develop Aug 15, 2026
1 check passed
@ndemianc
ndemianc deleted the feat/chat-in-editor branch August 15, 2026 21:14
ndemianc added a commit that referenced this pull request Aug 15, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants