Skip to content

Commit df1046d

Browse files
authored
Merge pull request #81 from levelcodeai/feat/chat-editor-only
feat(chat): the chat is an editor tab, and only an editor tab
2 parents 4e6593b + 61b9c2d commit df1046d

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)