@@ -53,8 +53,6 @@ let ctx;
5353 * @type {vscode.Webview | undefined }
5454 */
5555let 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 } */
5957let chatEditorPanel ; // set only while the chat is open as an editor tab
6058let 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 */
436437function 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+
10851109function 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+ */
21802214class 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 */
22862306async 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
27162686function 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 ( ) ) ,
0 commit comments