Skip to content

Commit 742442e

Browse files
committed
feat(chat): open the chat in an editor tab
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.
1 parent 0810a4c commit 742442e

4 files changed

Lines changed: 321 additions & 7 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,25 @@ const SYSTEM_PROMPT =
4343

4444
/** @type {vscode.ExtensionContext} */
4545
let ctx;
46-
/** @type {vscode.Webview | undefined} */
46+
/**
47+
* THE chat surface — whichever webview is currently hosting the conversation. `post()` writes here.
48+
*
49+
* The chat can live in two places: the sidebar view it is contributed as, or an editor tab
50+
* (openChatInEditor). Only ONE is ever live — a "move", not a mirror. Two live surfaces would mean
51+
* fanning out every post() and making every handler idempotent, for a UI that can then disagree with
52+
* itself; moving keeps one source of truth and is what "open in editor" means to a user anyway.
53+
* @type {vscode.Webview | undefined}
54+
*/
4755
let activeWebview;
56+
/** @type {vscode.WebviewView | undefined} */
57+
let sidebarChatView; // the contributed view, so the panel can hand the slot back when it closes
58+
/** @type {vscode.WebviewPanel | undefined} */
59+
let chatEditorPanel; // set only while the chat is open as an editor tab
60+
let chatProvider; // the single provider instance; both surfaces wire through it
61+
// The visible transcript lives in the webview's DOM, so swapping surfaces would blank it. Set before
62+
// handing over; the freshly-loaded surface replays on its `ready`, which is the first moment it can
63+
// receive anything at all.
64+
let pendingTranscriptReplay = '';
4865
let sessionsWebview; // the Sessions sidebar webview (for pushing list refreshes after a History action)
4966
/** @type {{role:string,content:string}[]} */
5067
let conversation = [];
@@ -2109,12 +2126,36 @@ function sendConfigToWebview() {
21092126
class ChatViewProvider {
21102127
/** @param {vscode.WebviewView} view */
21112128
resolveWebviewView(view) {
2112-
activeWebview = view.webview;
2129+
sidebarChatView = view;
21132130
view.webview.options = { enableScripts: true, localResourceRoots: [ctx.extensionUri] };
2114-
view.webview.html = getHtml();
2115-
view.webview.onDidReceiveMessage(async (msg) => {
2131+
this.wire(view.webview);
2132+
// If the chat is currently an editor tab, this slot shows a hand-off card rather than a second
2133+
// live copy. The view can resolve at any time (first reveal, a reload), so the check belongs
2134+
// here and not only at the moment the panel opens.
2135+
if (chatEditorPanel) { view.webview.html = detachedHtml(); return; }
2136+
this.makeLive(view.webview);
2137+
}
2138+
2139+
/** Point the conversation at `webview` and load the chat into it. Assumes it is already wired. */
2140+
makeLive(webview) {
2141+
activeWebview = webview;
2142+
webview.html = getHtml();
2143+
}
2144+
2145+
/**
2146+
* Register the ONE message handler on a webview. Separate from makeLive because the sidebar's html
2147+
* is swapped between the chat and the hand-off card, and a listener survives an html swap — wiring
2148+
* on every swap would stack duplicate handlers and double-send every message.
2149+
*/
2150+
wire(webview) {
2151+
webview.onDidReceiveMessage(async (msg) => {
21162152
switch (msg.type) {
2117-
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(); break;
2153+
// `ready` is the earliest a freshly-loaded webview can hear anything, so it is also where a
2154+
// surface that just took over replays the conversation it inherited (openChatInEditor).
2155+
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;
2156+
// The hand-off card's button. Disposing the panel runs its onDidDispose, which is the ONE
2157+
// place that restores the sidebar — so "bring it back" and closing the tab are one path.
2158+
case 'reattach': if (chatEditorPanel) { chatEditorPanel.dispose(); } break;
21182159
case 'setMode': agentMode = !!msg.agent; post({ type: 'mode', agent: agentMode }); break;
21192160
case 'setAutopilot': autopilot = !!msg.on; aiConfig().update('agent.autopilot', autopilot, vscode.ConfigurationTarget.Global); dbg('autopilot.set', { on: autopilot }); post({ type: 'autopilot', on: autopilot }); break;
21202161
case 'send': await handleSend(msg.text); break;
@@ -2177,6 +2218,95 @@ class ChatViewProvider {
21772218
}
21782219
}
21792220

2221+
/**
2222+
* Move the chat into an editor tab.
2223+
*
2224+
* A view cannot live in the editor grid — `ViewContainerLocation` is Sidebar | Panel | AuxiliaryBar
2225+
* and nothing else, which is why the panel can be dragged left or to the bottom but never to the
2226+
* middle. Only EDITORS live in the middle, so the centre needs a WebviewPanel: a real tab that
2227+
* splits, moves between groups, and can be dragged to another window like any other editor.
2228+
*
2229+
* It is a MOVE. The sidebar hands over its slot and shows a card; the conversation continues in the
2230+
* tab with one live surface throughout.
2231+
*/
2232+
async function openChatInEditor() {
2233+
if (chatEditorPanel) { chatEditorPanel.reveal(); return; }
2234+
2235+
const panel = vscode.window.createWebviewPanel(
2236+
'levelcode.ai.chat', 'LevelCode AI', vscode.ViewColumn.Active,
2237+
// retainContextWhenHidden: the transcript lives in this DOM, so switching to another tab and
2238+
// back must not wipe it — the same reason the contributed views set it.
2239+
{ enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [ctx.extensionUri] }
2240+
);
2241+
panel.iconPath = vscode.Uri.joinPath(ctx.extensionUri, 'media', 'levelcode-ai.svg');
2242+
chatEditorPanel = panel;
2243+
2244+
chatProvider.wire(panel.webview);
2245+
pendingTranscriptReplay = 'Moved to the editor';
2246+
chatProvider.makeLive(panel.webview);
2247+
2248+
// Hand the sidebar slot over. Its listener survives an html swap, so the card's button still
2249+
// reaches the same handler — see ChatViewProvider.wire.
2250+
if (sidebarChatView) { sidebarChatView.webview.html = detachedHtml(); }
2251+
dbg('chat.openInEditor', {});
2252+
2253+
panel.onDidDispose(() => {
2254+
chatEditorPanel = undefined;
2255+
if (sidebarChatView) {
2256+
pendingTranscriptReplay = 'Back in the sidebar';
2257+
chatProvider.makeLive(sidebarChatView.webview);
2258+
sidebarChatView.show?.(true);
2259+
} else {
2260+
// The view was never resolved (the container has not been opened this session). Reveal it —
2261+
// resolveWebviewView then makes it live, and without this the chat would have no surface at all.
2262+
activeWebview = undefined;
2263+
pendingTranscriptReplay = 'Back in the sidebar';
2264+
vscode.commands.executeCommand('levelcodeAi.chat.focus');
2265+
}
2266+
dbg('chat.closedEditor', {});
2267+
});
2268+
}
2269+
2270+
/**
2271+
* Replay the live session's visible turns into whichever surface just took over.
2272+
*
2273+
* The transcript is DOM state, so a hand-over would otherwise land you in an empty chat holding a
2274+
* conversation the model still remembers — the worst of both. This reuses the `sessionResumed`
2275+
* renderer rather than a second one, tagged so a move does not read as a resume.
2276+
*/
2277+
function replayLiveTranscript(tag) {
2278+
const m = sessionsManager();
2279+
if (!m) { return; }
2280+
const id = m.liveId();
2281+
if (!id) { return; } // nothing said yet — an empty chat is the honest state
2282+
let turns = [];
2283+
try { turns = sessionEvents.toDisplayTurns(m.transcript(id)); }
2284+
catch (e) { dbg('chat.replay.failed', { msg: String((e && e.message) || e) }); return; }
2285+
if (!turns.length) { return; }
2286+
const entry = m.list().find((e) => e.id === id) || {};
2287+
post({ type: 'sessionResumed', id, title: entry.title || 'Session', note: '', tag, icon: 'layout', turns });
2288+
}
2289+
2290+
/** The sidebar slot while the chat is an editor tab. Deliberately tiny — it is a signpost, not a UI. */
2291+
function detachedHtml() {
2292+
const bg = 'var(--vscode-sideBar-background)', fg = 'var(--vscode-foreground)';
2293+
return '<!DOCTYPE html><html><head><meta charset="utf-8">'
2294+
+ '<style>'
2295+
+ 'body{margin:0;padding:28px 22px;background:' + bg + ';color:' + fg + ';'
2296+
+ 'font-family:var(--vscode-font-family);font-size:var(--vscode-font-size);text-align:center}'
2297+
+ '.t{font-size:14px;font-weight:600;margin-bottom:6px}'
2298+
+ '.s{opacity:.7;line-height:1.55;margin-bottom:18px}'
2299+
+ 'button{width:100%;padding:7px 10px;border:1px solid var(--vscode-button-border,transparent);'
2300+
+ 'border-radius:4px;background:var(--vscode-button-background);color:var(--vscode-button-foreground);'
2301+
+ 'font:inherit;cursor:pointer}button:hover{background:var(--vscode-button-hoverBackground)}'
2302+
+ '</style></head><body>'
2303+
+ '<div class="t">Chat is open in the editor</div>'
2304+
+ '<div class="s">The conversation moved to a tab so it has room. Closing that tab brings it back here.</div>'
2305+
+ '<button id="b">Bring it back</button>'
2306+
+ '<script>const v=acquireVsCodeApi();document.getElementById("b").onclick=()=>v.postMessage({type:"reattach"});</script>'
2307+
+ '</body></html>';
2308+
}
2309+
21802310
function getHtml() {
21812311
const nonce = String(Math.random()).slice(2) + String(Date.now());
21822312
const csp = [
@@ -2466,7 +2596,7 @@ async function openWorkspaceFile(rel) {
24662596
function activate(context) {
24672597
ctx = context;
24682598
context.subscriptions.push(
2469-
vscode.window.registerWebviewViewProvider('levelcodeAi.chat', new ChatViewProvider(), {
2599+
vscode.window.registerWebviewViewProvider('levelcodeAi.chat', (chatProvider = new ChatViewProvider()), {
24702600
webviewOptions: { retainContextWhenHidden: true }
24712601
}),
24722602
vscode.window.registerWebviewViewProvider('levelcodeAi.sessions', new SessionsViewProvider(), {
@@ -2488,6 +2618,7 @@ function activate(context) {
24882618
vscode.commands.registerCommand('levelcode.ai.newChat', newChat),
24892619
vscode.commands.registerCommand('levelcode.ai.pickModel', pickModel),
24902620
vscode.commands.registerCommand('levelcode.ai.manageMcp', manageMcpServers),
2621+
vscode.commands.registerCommand('levelcode.ai.openChatInEditor', openChatInEditor),
24912622
vscode.commands.registerCommand('levelcode.ai.addSelection', addSelection),
24922623
vscode.commands.registerCommand('levelcode.ai.addFileContext', addContext),
24932624
vscode.commands.registerCommand('levelcode.ai.setApiKey', () => promptForKey()),

extensions/levelcode-ai/media/chat.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3438,7 +3438,11 @@
34383438
clearEmpty();
34393439
const banner = document.createElement('div');
34403440
banner.className = 'sessresumed';
3441-
banner.innerHTML = '<div class="rrow">' + codicon('sync') + '<span class="rtag">Resumed</span>'
3441+
// The same renderer serves two events: resuming a past session, and the chat MOVING between the
3442+
// sidebar and an editor tab. They look identical (a replayed transcript) but mean different
3443+
// things, so the caller names it — a move labelled "Resumed" would tell the user their session
3444+
// had been reloaded from disk when nothing of the sort happened.
3445+
banner.innerHTML = '<div class="rrow">' + codicon(m.icon || 'sync') + '<span class="rtag">' + esc(m.tag || 'Resumed') + '</span>'
34423446
+ '<span class="rtitle" title="' + escAttr(m.title || '') + '">' + esc(m.title || 'Session') + '</span></div>'
34433447
+ (m.note ? '<div class="rnote">' + esc(m.note) + '</div>' : '');
34443448
log.appendChild(banner);

extensions/levelcode-ai/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@
129129
"title": "AI: Manage MCP Servers…",
130130
"category": "LevelCode"
131131
},
132+
{
133+
"command": "levelcode.ai.openChatInEditor",
134+
"title": "AI: Open Chat in Editor",
135+
"category": "LevelCode",
136+
"icon": "$(link-external)"
137+
},
132138
{
133139
"command": "levelcode.ai.sessions",
134140
"title": "AI: Sessions",

0 commit comments

Comments
 (0)