@@ -43,8 +43,25 @@ const SYSTEM_PROMPT =
4343
4444/** @type {vscode.ExtensionContext } */
4545let 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+ */
4755let 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 = '' ;
4865let sessionsWebview ; // the Sessions sidebar webview (for pushing list refreshes after a History action)
4966/** @type {{role:string,content:string}[] } */
5067let conversation = [ ] ;
@@ -2109,12 +2126,36 @@ function sendConfigToWebview() {
21092126class 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,13 +2218,123 @@ class ChatViewProvider {
21772218 }
21782219}
21792220
2180- function getHtml ( ) {
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+ /**
2291+ * The sidebar slot while the chat is an editor tab. Deliberately tiny — it is a signpost, not a UI.
2292+ *
2293+ * Small does not mean exempt: it enables scripts and carries an inline one, so it gets the same
2294+ * CSP + nonce as the chat and sessions documents. Anything less and this would be the one webview
2295+ * whose script surface is undescribed.
2296+ */
2297+ function detachedHtml ( ) {
2298+ const { nonce, csp } = webviewCsp ( ) ;
2299+ const bg = 'var(--vscode-sideBar-background)' , fg = 'var(--vscode-foreground)' ;
2300+ return '<!DOCTYPE html><html><head><meta charset="utf-8">'
2301+ + '<meta http-equiv="Content-Security-Policy" content="' + csp + '">'
2302+ + '<style>'
2303+ + 'body{margin:0;padding:28px 22px;background:' + bg + ';color:' + fg + ';'
2304+ + 'font-family:var(--vscode-font-family);font-size:var(--vscode-font-size);text-align:center}'
2305+ + '.t{font-size:14px;font-weight:600;margin-bottom:6px}'
2306+ + '.s{opacity:.7;line-height:1.55;margin-bottom:18px}'
2307+ + 'button{width:100%;padding:7px 10px;border:1px solid var(--vscode-button-border,transparent);'
2308+ + 'border-radius:4px;background:var(--vscode-button-background);color:var(--vscode-button-foreground);'
2309+ + 'font:inherit;cursor:pointer}button:hover{background:var(--vscode-button-hoverBackground)}'
2310+ + '</style></head><body>'
2311+ + '<div class="t">Chat is open in the editor</div>'
2312+ + '<div class="s">The conversation moved to a tab so it has room. Closing that tab brings it back here.</div>'
2313+ + '<button id="b">Bring it back</button>'
2314+ + '<script nonce="' + nonce + '">const v=acquireVsCodeApi();document.getElementById("b").onclick=()=>v.postMessage({type:"reattach"});</script>'
2315+ + '</body></html>' ;
2316+ }
2317+
2318+ /**
2319+ * A webview Content-Security-Policy and the nonce it authorises.
2320+ *
2321+ * Every document this extension serves goes through here, so the script surface is described in ONE
2322+ * place: no remote anything (`default-src 'none'`), inline styles allowed because the documents are
2323+ * self-contained, and inline script allowed ONLY for the exact nonce minted per render. A document
2324+ * that forgets this is not merely inconsistent — a later CSP tightening elsewhere would silently
2325+ * stop its script from running.
2326+ */
2327+ function webviewCsp ( ) {
21812328 const nonce = String ( Math . random ( ) ) . slice ( 2 ) + String ( Date . now ( ) ) ;
2182- const csp = [
2329+ return { nonce , csp : [
21832330 "default-src 'none'" ,
21842331 "style-src 'unsafe-inline'" ,
21852332 "script-src 'nonce-" + nonce + "'"
2186- ] . join ( '; ' ) ;
2333+ ] . join ( '; ' ) } ;
2334+ }
2335+
2336+ function getHtml ( ) {
2337+ const { nonce, csp } = webviewCsp ( ) ;
21872338 const html = fs . readFileSync ( path . join ( ctx . extensionPath , 'media' , 'chat.html' ) , 'utf8' ) ;
21882339 return html . replace ( / _ _ C S P _ _ / g, csp ) . replace ( / _ _ N O N C E _ _ / g, nonce ) ;
21892340}
@@ -2216,12 +2367,7 @@ class SessionsViewProvider {
22162367}
22172368
22182369function getSessionsHtml ( ) {
2219- const nonce = String ( Math . random ( ) ) . slice ( 2 ) + String ( Date . now ( ) ) ;
2220- const csp = [
2221- "default-src 'none'" ,
2222- "style-src 'unsafe-inline'" ,
2223- "script-src 'nonce-" + nonce + "'"
2224- ] . join ( '; ' ) ;
2370+ const { nonce, csp } = webviewCsp ( ) ;
22252371 const html = fs . readFileSync ( path . join ( ctx . extensionPath , 'media' , 'sessionsView.html' ) , 'utf8' ) ;
22262372 return html . replace ( / _ _ C S P _ _ / g, csp ) . replace ( / _ _ N O N C E _ _ / g, nonce ) ;
22272373}
@@ -2466,7 +2612,7 @@ async function openWorkspaceFile(rel) {
24662612function activate ( context ) {
24672613 ctx = context ;
24682614 context . subscriptions . push (
2469- vscode . window . registerWebviewViewProvider ( 'levelcodeAi.chat' , new ChatViewProvider ( ) , {
2615+ vscode . window . registerWebviewViewProvider ( 'levelcodeAi.chat' , ( chatProvider = new ChatViewProvider ( ) ) , {
24702616 webviewOptions : { retainContextWhenHidden : true }
24712617 } ) ,
24722618 vscode . window . registerWebviewViewProvider ( 'levelcodeAi.sessions' , new SessionsViewProvider ( ) , {
@@ -2488,6 +2634,7 @@ function activate(context) {
24882634 vscode . commands . registerCommand ( 'levelcode.ai.newChat' , newChat ) ,
24892635 vscode . commands . registerCommand ( 'levelcode.ai.pickModel' , pickModel ) ,
24902636 vscode . commands . registerCommand ( 'levelcode.ai.manageMcp' , manageMcpServers ) ,
2637+ vscode . commands . registerCommand ( 'levelcode.ai.openChatInEditor' , openChatInEditor ) ,
24912638 vscode . commands . registerCommand ( 'levelcode.ai.addSelection' , addSelection ) ,
24922639 vscode . commands . registerCommand ( 'levelcode.ai.addFileContext' , addContext ) ,
24932640 vscode . commands . registerCommand ( 'levelcode.ai.setApiKey' , ( ) => promptForKey ( ) ) ,
0 commit comments