Skip to content

Commit 61b9c2d

Browse files
committed
feat(chat): the chat is an editor tab, and only an editor tab
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.
1 parent 4e6593b commit 61b9c2d

4 files changed

Lines changed: 169 additions & 223 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 62 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,6 @@ let ctx;
5353
* @type {vscode.Webview | undefined}
5454
*/
5555
let activeWebview;
56-
/** @type {vscode.WebviewView | undefined} */
57-
let sidebarChatView; // the contributed view, so the panel can hand the slot back when it closes
5856
/** @type {vscode.WebviewPanel | undefined} */
5957
let chatEditorPanel; // set only while the chat is open as an editor tab
6058
let chatProvider; // the single provider instance; both surfaces wire through it
@@ -431,10 +429,13 @@ function captureSelection() {
431429
* diagnose — a chat surface that silently never appears — so `why` names the caller in the log.
432430
*
433431
* NOT for a command handler whose whole job IS the reveal: `levelcode.ai.focus` returns the thenable
434-
* instead, so VS Code reports the failure to the user who asked for it. See moveChatToSidebar.
432+
* instead, so VS Code reports the failure to the user who asked for it.
433+
*
434+
* "The chat" is now the editor tab and nothing else — this used to reveal the contributed view in the
435+
* right-hand bar, which is why every one of these callers kept pulling a panel out on the right.
435436
*/
436437
function focusChatView(why) {
437-
return Promise.resolve(vscode.commands.executeCommand('levelcodeAi.chat.focus'))
438+
return Promise.resolve(openChatInEditor())
438439
.then(undefined, (e) => {
439440
const msg = String((e && e.message) || e);
440441
console.warn('[levelcode-ai] chat.focus.failed', { why, msg });
@@ -1082,10 +1083,33 @@ async function resumeSession(id) {
10821083
dbg('sessions.resumed', { id, tier: r.plan && r.plan.tier, restored: agentMessages.length, shown: turns.length });
10831084
}
10841085

1086+
/**
1087+
* Seal the live session and let memory learn from it.
1088+
*
1089+
* Extracted because closing the chat tab has to do exactly what New Chat does. A conversation that
1090+
* ends because the user shut the tab is not a lost one: it is a finished one, and it should land in
1091+
* History with its outcome recorded and its facts promoted, the same as any other. Two copies of this
1092+
* would drift, and the half that drifted would be the one nobody watches — the close path.
1093+
*
1094+
* Never throws: it runs from a dispose handler, where an exception has nowhere to go.
1095+
*/
1096+
function sealLiveSession(why) {
1097+
try {
1098+
const m = sessionsManager();
1099+
if (!m) { return; }
1100+
const sealedId = m.liveId();
1101+
m.seal('done');
1102+
if (sealedId) { enrichMemoryAsync(sealedId); } // outcome + fact promotion, off the critical path
1103+
dbg('sessions.sealed', { why, id: sealedId });
1104+
} catch (e) {
1105+
dbg('sessions.seal.error', { why, msg: String((e && e.message) || e) });
1106+
}
1107+
}
1108+
10851109
function newChat() {
10861110
// Seal the outgoing session (its terminal state + a final index row) BEFORE the transcript is cleared,
10871111
// so it lands in History as a finished session and the next turn opens a fresh one.
1088-
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) }); }
1112+
sealLiveSession('newChat');
10891113
conversation = [];
10901114
agentMessages = [];
10911115
checkpoints.length = 0; currentCheckpoint = null; // drop the per-turn restore stack
@@ -2177,39 +2201,34 @@ function sendConfigToWebview() {
21772201
post({ type: 'config', provider: providerId, proseSize, proseWidth, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity });
21782202
}
21792203

2204+
/**
2205+
* Owns the chat webview: one message handler, one live surface.
2206+
*
2207+
* It used to also be a WebviewViewProvider, because the chat could be hosted by a contributed view in
2208+
* the right-hand bar OR by an editor tab. That is gone: the chat is an editor tab and nothing else.
2209+
* Two hosts for one conversation bought a hand-over card, a detached-state document, a move command,
2210+
* a close-versus-move distinction, and a replay on every transition — all of it machinery for a
2211+
* choice nobody wanted. Sessions still live in the right-hand bar; they are a different thing and do
2212+
* not need to share a column with the conversation they index.
2213+
*/
21802214
class ChatViewProvider {
2181-
/** @param {vscode.WebviewView} view */
2182-
resolveWebviewView(view) {
2183-
sidebarChatView = view;
2184-
view.webview.options = { enableScripts: true, localResourceRoots: [ctx.extensionUri] };
2185-
this.wire(view.webview);
2186-
// If the chat is currently an editor tab, this slot shows a hand-off card rather than a second
2187-
// live copy. The view can resolve at any time (first reveal, a reload), so the check belongs
2188-
// here and not only at the moment the panel opens.
2189-
if (chatEditorPanel) { view.webview.html = detachedHtml(); return; }
2190-
this.makeLive(view.webview);
2191-
}
2192-
21932215
/** Point the conversation at `webview` and load the chat into it. Assumes it is already wired. */
21942216
makeLive(webview) {
21952217
activeWebview = webview;
21962218
webview.html = getHtml();
21972219
}
21982220

21992221
/**
2200-
* Register the ONE message handler on a webview. Separate from makeLive because the sidebar's html
2201-
* is swapped between the chat and the hand-off card, and a listener survives an html swap — wiring
2202-
* on every swap would stack duplicate handlers and double-send every message.
2222+
* Register the ONE message handler on a webview. Kept separate from makeLive because a listener
2223+
* lives on the WEBVIEW and survives an html swap: makeLive can reload the document (a new chat, a
2224+
* resumed session) without stacking a second handler and double-sending every message.
22032225
*/
22042226
wire(webview) {
22052227
webview.onDidReceiveMessage(async (msg) => {
22062228
switch (msg.type) {
22072229
// `ready` is the earliest a freshly-loaded webview can hear anything, so it is also where a
22082230
// surface that just took over replays the conversation it inherited (openChatInEditor).
22092231
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;
2210-
// The hand-off card's button. Disposing the panel runs its onDidDispose, which is the ONE
2211-
// place that restores the sidebar — so "bring it back" and closing the tab are one path.
2212-
case 'reattach': if (chatEditorPanel) { chatEditorPanel.dispose(); } break;
22132232
case 'setMode': agentMode = !!msg.agent; post({ type: 'mode', agent: agentMode }); break;
22142233
case 'setAutopilot': autopilot = !!msg.on; aiConfig().update('agent.autopilot', autopilot, vscode.ConfigurationTarget.Global); dbg('autopilot.set', { on: autopilot }); post({ type: 'autopilot', on: autopilot }); break;
22152234
case 'send': await handleSend(msg.text); break;
@@ -2280,8 +2299,9 @@ class ChatViewProvider {
22802299
* middle. Only EDITORS live in the middle, so the centre needs a WebviewPanel: a real tab that
22812300
* splits, moves between groups, and can be dragged to another window like any other editor.
22822301
*
2283-
* It is a MOVE. The sidebar hands over its slot and shows a card; the conversation continues in the
2284-
* tab with one live surface throughout.
2302+
* This is the ONLY surface. It used to be one of two — the chat could also be hosted by a contributed
2303+
* view in the right-hand bar, and opening here was a "move" that handed that slot over and left a card
2304+
* behind. Sessions still live over there; the conversation does not.
22852305
*/
22862306
async function openChatInEditor(opts) {
22872307
// `preserveFocus` exists for the STARTUP path only. Opening the chat centred is what the user asked
@@ -2307,45 +2327,24 @@ async function openChatInEditor(opts) {
23072327

23082328
// Hand the sidebar slot over. Its listener survives an html swap, so the card's button still
23092329
// reaches the same handler — see ChatViewProvider.wire.
2310-
if (sidebarChatView) { sidebarChatView.webview.html = detachedHtml(); }
23112330
dbg('chat.openInEditor', {});
23122331

23132332
panel.onDidDispose(() => {
2333+
// Closing the chat CLOSES it. There is no second surface to hand back to any more, and the
2334+
// previous behaviour — reveal the sidebar — turned ⌘W into "reopen on the right", with no way to
2335+
// put the chat away at all.
2336+
//
2337+
// The conversation is not discarded, though. Shutting the tab is an ending, so it gets the same
2338+
// ending New Chat gives: the session is sealed into History and memory learns from it. Doing
2339+
// this here rather than only in newChat is the difference between "I closed the tab" and "I lost
2340+
// the conversation".
23142341
chatEditorPanel = undefined;
2315-
if (sidebarChatView) {
2316-
pendingTranscriptReplay = 'Back in the sidebar';
2317-
chatProvider.makeLive(sidebarChatView.webview);
2318-
sidebarChatView.show?.(true);
2319-
} else {
2320-
// The view was never resolved (the container has not been opened this session). Reveal it —
2321-
// resolveWebviewView then makes it live, and without this the chat would have no surface at all.
2322-
activeWebview = undefined;
2323-
pendingTranscriptReplay = 'Back in the sidebar';
2324-
focusChatView('editorClosed');
2325-
}
2342+
activeWebview = undefined; // nothing may post into a disposed webview
2343+
sealLiveSession('chatClosed');
23262344
dbg('chat.closedEditor', {});
23272345
});
23282346
}
23292347

2330-
/**
2331-
* The other direction of the move: put the chat back in the right-hand bar.
2332-
*
2333-
* Disposing the panel IS the move — `onDidDispose` above already hands the slot back to the sidebar
2334-
* and replays the transcript. Going through it rather than duplicating that path is what makes this
2335-
* button and ⌘W behave identically; a second implementation would drift from it the first time the
2336-
* hand-over changed.
2337-
*/
2338-
function moveChatToSidebar() {
2339-
if (chatEditorPanel) { chatEditorPanel.dispose(); return undefined; }
2340-
// Already there (or never moved) — just reveal it, so the command is never a silent no-op.
2341-
//
2342-
// RETURNED, not fired and forgotten. `registerCommand` awaits whatever the handler returns, so a
2343-
// failure here reaches the user as a failed command instead of an unhandled rejection. That is the
2344-
// opposite of the startup path on purpose: this is an explicit click, and silence would leave the
2345-
// user pressing a button that does nothing.
2346-
return vscode.commands.executeCommand('levelcodeAi.chat.focus');
2347-
}
2348-
23492348
/**
23502349
* Where the chat opens when the window does.
23512350
*
@@ -2367,7 +2366,6 @@ async function revealChatAtStartup() {
23672366
const where = chatStartLocation();
23682367
dbg('chat.startLocation', { where });
23692368
if (where === 'none') { return; }
2370-
if (where === 'secondarySidebar') { await vscode.commands.executeCommand('levelcodeAi.chat.focus'); return; }
23712369
await openChatInEditor({ preserveFocus: true });
23722370
}
23732371

@@ -2391,34 +2389,6 @@ function replayLiveTranscript(tag) {
23912389
post({ type: 'sessionResumed', id, title: entry.title || 'Session', note: '', tag, icon: 'layout', turns });
23922390
}
23932391

2394-
/**
2395-
* The sidebar slot while the chat is an editor tab. Deliberately tiny — it is a signpost, not a UI.
2396-
*
2397-
* Small does not mean exempt: it enables scripts and carries an inline one, so it gets the same
2398-
* CSP + nonce as the chat and sessions documents. Anything less and this would be the one webview
2399-
* whose script surface is undescribed.
2400-
*/
2401-
function detachedHtml() {
2402-
const { nonce, csp } = webviewCsp();
2403-
const bg = 'var(--vscode-sideBar-background)', fg = 'var(--vscode-foreground)';
2404-
return '<!DOCTYPE html><html><head><meta charset="utf-8">'
2405-
+ '<meta http-equiv="Content-Security-Policy" content="' + csp + '">'
2406-
+ '<style>'
2407-
+ 'body{margin:0;padding:28px 22px;background:' + bg + ';color:' + fg + ';'
2408-
+ 'font-family:var(--vscode-font-family);font-size:var(--vscode-font-size);text-align:center}'
2409-
+ '.t{font-size:14px;font-weight:600;margin-bottom:6px}'
2410-
+ '.s{opacity:.7;line-height:1.55;margin-bottom:18px}'
2411-
+ 'button{width:100%;padding:7px 10px;border:1px solid var(--vscode-button-border,transparent);'
2412-
+ 'border-radius:4px;background:var(--vscode-button-background);color:var(--vscode-button-foreground);'
2413-
+ 'font:inherit;cursor:pointer}button:hover{background:var(--vscode-button-hoverBackground)}'
2414-
+ '</style></head><body>'
2415-
+ '<div class="t">Chat is open in the editor</div>'
2416-
+ '<div class="s">The conversation moved to a tab so it has room. Closing that tab brings it back here.</div>'
2417-
+ '<button id="b">Bring it back</button>'
2418-
+ '<script nonce="' + nonce + '">const v=acquireVsCodeApi();document.getElementById("b").onclick=()=>v.postMessage({type:"reattach"});</script>'
2419-
+ '</body></html>';
2420-
}
2421-
24222392
/**
24232393
* A webview Content-Security-Policy and the nonce it authorises.
24242394
*
@@ -2715,16 +2685,19 @@ async function openWorkspaceFile(rel) {
27152685

27162686
function activate(context) {
27172687
ctx = context;
2688+
// Constructed directly rather than by registerWebviewViewProvider: the chat is no longer a
2689+
// contributed view, but the panel still needs the one object that owns wire()/makeLive().
2690+
chatProvider = new ChatViewProvider();
27182691
context.subscriptions.push(
2719-
vscode.window.registerWebviewViewProvider('levelcodeAi.chat', (chatProvider = new ChatViewProvider()), {
2720-
webviewOptions: { retainContextWhenHidden: true }
2721-
}),
27222692
vscode.window.registerWebviewViewProvider('levelcodeAi.sessions', new SessionsViewProvider(), {
27232693
webviewOptions: { retainContextWhenHidden: true }
27242694
}),
27252695
vscode.commands.registerCommand('levelcode.ai.sessions', () => vscode.commands.executeCommand('levelcodeAi.sessions.focus')),
27262696
vscode.window.onDidChangeActiveTextEditor(() => postActiveFile()),
2727-
vscode.commands.registerCommand('levelcode.ai.focus', () => vscode.commands.executeCommand('levelcodeAi.chat.focus')),
2697+
// ⇧⌘I. Opens the chat where the chat lives — the editor tab. This pointed at the contributed
2698+
// view, which is why the shortcut kept pulling a panel out on the right after the conversation
2699+
// had stopped living there.
2700+
vscode.commands.registerCommand('levelcode.ai.focus', () => openChatInEditor()),
27282701
vscode.commands.registerCommand('levelcode.customize', () => openCustomize(context)),
27292702
// Agent Sketch: the visual multi-agent flow canvas. Lazy require — only loads when opened.
27302703
vscode.commands.registerCommand('levelcode.ai.sketch', () => {
@@ -2742,7 +2715,6 @@ function activate(context) {
27422715
// argument, and openChatInEditor now reads an options object there. Bound directly, a title-bar
27432716
// click would pass whatever VS Code supplies and could set preserveFocus by accident.
27442717
vscode.commands.registerCommand('levelcode.ai.openChatInEditor', () => openChatInEditor()),
2745-
vscode.commands.registerCommand('levelcode.ai.moveChatToSidebar', () => moveChatToSidebar()),
27462718
vscode.commands.registerCommand('levelcode.ai.addSelection', addSelection),
27472719
vscode.commands.registerCommand('levelcode.ai.addFileContext', addContext),
27482720
vscode.commands.registerCommand('levelcode.ai.setApiKey', () => promptForKey()),

extensions/levelcode-ai/package.json

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,6 @@
8585
},
8686
"views": {
8787
"levelcodeAi": [
88-
{
89-
"id": "levelcodeAi.chat",
90-
"name": "Chat",
91-
"type": "webview"
92-
},
9388
{
9489
"id": "levelcodeAi.sessions",
9590
"name": "Sessions",
@@ -135,12 +130,6 @@
135130
"category": "LevelCode",
136131
"icon": "$(link-external)"
137132
},
138-
{
139-
"command": "levelcode.ai.moveChatToSidebar",
140-
"title": "AI: Move Chat to Sidebar",
141-
"category": "LevelCode",
142-
"icon": "$(layout-sidebar-right)"
143-
},
144133
{
145134
"command": "levelcode.ai.sessions",
146135
"title": "AI: Sessions",
@@ -217,33 +206,21 @@
217206
}
218207
],
219208
"menus": {
220-
"view/title": [
209+
"editor/title": [
221210
{
222211
"command": "levelcode.ai.addFileContext",
223-
"when": "view == levelcodeAi.chat",
224-
"group": "navigation@1"
212+
"when": "activeWebviewPanelId == 'levelcode.ai.chat'",
213+
"group": "navigation@0"
225214
},
226215
{
227216
"command": "levelcode.ai.newChat",
228-
"when": "view == levelcodeAi.chat",
229-
"group": "navigation@2"
217+
"when": "activeWebviewPanelId == 'levelcode.ai.chat'",
218+
"group": "navigation@1"
230219
},
231220
{
232221
"command": "levelcode.ai.setApiKey",
233-
"when": "view == levelcodeAi.chat",
234-
"group": "navigation@3"
235-
},
236-
{
237-
"command": "levelcode.ai.openChatInEditor",
238-
"when": "view == levelcodeAi.chat",
239-
"group": "navigation@4"
240-
}
241-
],
242-
"editor/title": [
243-
{
244-
"command": "levelcode.ai.moveChatToSidebar",
245222
"when": "activeWebviewPanelId == 'levelcode.ai.chat'",
246-
"group": "navigation@0"
223+
"group": "navigation@2"
247224
},
248225
{
249226
"command": "levelcode.ai.review.keepActive",
@@ -390,12 +367,10 @@
390367
"type": "string",
391368
"enum": [
392369
"editor",
393-
"secondarySidebar",
394370
"none"
395371
],
396372
"enumDescriptions": [
397-
"Open the chat as a centred editor tab, like any other file.",
398-
"Reveal the chat in the right-hand sidebar.",
373+
"Open the chat as a centred editor tab.",
399374
"Do not open the chat automatically."
400375
],
401376
"default": "editor",

0 commit comments

Comments
 (0)