Skip to content

Commit d6fe9df

Browse files
committed
feat(vscode): move chat to activitybar sidebar, add empty state layout, use favicon icon
1 parent 20ab6ad commit d6fe9df

4 files changed

Lines changed: 92 additions & 57 deletions

File tree

packages/vscode/package.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,23 @@
312312
"name": "Modelica Libraries",
313313
"visibility": "visible"
314314
}
315+
],
316+
"modelscript-ai": [
317+
{
318+
"type": "webview",
319+
"id": "modelscript.chat",
320+
"name": "Chat",
321+
"visibility": "visible"
322+
}
323+
]
324+
},
325+
"viewsContainers": {
326+
"activitybar": [
327+
{
328+
"id": "modelscript-ai",
329+
"title": "ModelScript AI",
330+
"icon": "$(comment-discussion)"
331+
}
315332
]
316333
},
317334
"viewsWelcome": [

packages/vscode/src/browserClientMain.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as vscode from "vscode";
22
import { Uri, commands, workspace } from "vscode";
33
import { LanguageClientOptions } from "vscode-languageclient";
44
import { LanguageClient } from "vscode-languageclient/browser";
5-
import { ChatPanel } from "./chatPanel";
5+
import { ChatViewProvider } from "./chatPanel";
66
import { DiagramEditorProvider } from "./diagramEditorProvider";
77
import { LibraryTreeProvider } from "./libraryTreeProvider";
88
import { registerLLMProvider } from "./llmProvider";
@@ -159,10 +159,20 @@ export async function activate(context: vscode.ExtensionContext) {
159159
/* proposed API not available */
160160
}
161161

162+
// Register chat view provider (secondary sidebar)
163+
if (client) {
164+
const chatProvider = new ChatViewProvider(context.extensionUri, client);
165+
context.subscriptions.push(
166+
vscode.window.registerWebviewViewProvider(ChatViewProvider.viewType, chatProvider, {
167+
webviewOptions: { retainContextWhenHidden: true },
168+
}),
169+
);
170+
}
171+
162172
// Custom chat panel (works without VS Code Chat API / Copilot)
163173
context.subscriptions.push(
164174
vscode.commands.registerCommand("modelscript.openChat", () => {
165-
if (client) ChatPanel.createOrShow(context.extensionUri, client);
175+
vscode.commands.executeCommand("modelscript.chat.focus");
166176
}),
167177
);
168178

packages/vscode/src/chatPanel.ts

Lines changed: 60 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,38 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22
//
3-
// Manages the AI Chat webview panel lifecycle.
4-
// The webview runs WebLLM (Qwen3.5) directly via WebGPU.
3+
// Provides the AI Chat as a WebviewViewProvider (secondary sidebar).
4+
// The webview runs WebLLM (Qwen3) directly via WebGPU.
55
// The extension host bridges tool calls to the LSP server.
66

77
import * as vscode from "vscode";
88
import { LanguageClient } from "vscode-languageclient/browser";
99

10-
export class ChatPanel {
11-
static currentPanel: ChatPanel | undefined;
10+
export class ChatViewProvider implements vscode.WebviewViewProvider {
1211
static readonly viewType = "modelscript.chat";
1312

14-
private readonly panel: vscode.WebviewPanel;
15-
private readonly extensionUri: vscode.Uri;
16-
private readonly client: LanguageClient;
13+
private view?: vscode.WebviewView;
1714
private disposables: vscode.Disposable[] = [];
1815

19-
static createOrShow(extensionUri: vscode.Uri, client: LanguageClient) {
20-
if (ChatPanel.currentPanel) {
21-
ChatPanel.currentPanel.panel.reveal(vscode.ViewColumn.Beside);
22-
return;
23-
}
16+
constructor(
17+
private readonly extensionUri: vscode.Uri,
18+
private readonly client: LanguageClient,
19+
) {}
2420

25-
const panel = vscode.window.createWebviewPanel(ChatPanel.viewType, "ModelScript Chat", vscode.ViewColumn.Beside, {
26-
enableScripts: true,
27-
retainContextWhenHidden: true,
28-
localResourceRoots: [vscode.Uri.joinPath(extensionUri, "dist")],
29-
});
30-
31-
ChatPanel.currentPanel = new ChatPanel(panel, extensionUri, client);
32-
}
21+
resolveWebviewView(webviewView: vscode.WebviewView): void {
22+
this.view = webviewView;
3323

34-
private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri, client: LanguageClient) {
35-
this.panel = panel;
36-
this.extensionUri = extensionUri;
37-
this.client = client;
24+
webviewView.webview.options = {
25+
enableScripts: true,
26+
localResourceRoots: [
27+
vscode.Uri.joinPath(this.extensionUri, "dist"),
28+
vscode.Uri.joinPath(this.extensionUri, "images"),
29+
],
30+
};
3831

39-
this.panel.iconPath = vscode.Uri.joinPath(extensionUri, "images", "icon.png");
40-
this.panel.webview.html = this.getHtmlForWebview();
32+
webviewView.webview.html = this.getHtmlForWebview(webviewView.webview);
4133

4234
// Handle messages from webview (tool calls bridged to LSP)
43-
this.panel.webview.onDidReceiveMessage(
35+
webviewView.webview.onDidReceiveMessage(
4436
async (msg) => {
4537
switch (msg.type) {
4638
case "toolCall":
@@ -58,7 +50,7 @@ export class ChatPanel {
5850
this.disposables,
5951
);
6052

61-
// Auto-send active file context when panel opens
53+
// Auto-send active file context when view opens
6254
this.sendActiveFileContext();
6355

6456
// Re-send active file context when user switches editors
@@ -68,10 +60,16 @@ export class ChatPanel {
6860
}),
6961
);
7062

71-
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
63+
webviewView.onDidDispose(() => {
64+
while (this.disposables.length) {
65+
const x = this.disposables.pop();
66+
if (x) x.dispose();
67+
}
68+
});
7269
}
7370

7471
private async handleToolCall(msg: { id: string; tool: string; input: Record<string, unknown> }) {
72+
if (!this.view) return;
7573
try {
7674
let result: unknown;
7775
switch (msg.tool) {
@@ -90,9 +88,9 @@ export class ChatPanel {
9088
default:
9189
result = { error: `Unknown tool: ${msg.tool}` };
9290
}
93-
this.panel.webview.postMessage({ type: "toolResult", id: msg.id, result });
91+
this.view.webview.postMessage({ type: "toolResult", id: msg.id, result });
9492
} catch (e) {
95-
this.panel.webview.postMessage({
93+
this.view.webview.postMessage({
9694
type: "toolResult",
9795
id: msg.id,
9896
result: { error: e instanceof Error ? e.message : String(e) },
@@ -101,13 +99,14 @@ export class ChatPanel {
10199
}
102100

103101
private async handleListClasses(msg: { id: string }) {
102+
if (!this.view) return;
104103
try {
105104
const result = (await this.client.sendRequest("modelscript/listClasses")) as {
106105
classes: { name: string; kind: string; uri: string }[];
107106
};
108-
this.panel.webview.postMessage({ type: "classListResult", id: msg.id, result });
107+
this.view.webview.postMessage({ type: "classListResult", id: msg.id, result });
109108
} catch (e) {
110-
this.panel.webview.postMessage({
109+
this.view.webview.postMessage({
111110
type: "classListResult",
112111
id: msg.id,
113112
result: { classes: [], error: e instanceof Error ? e.message : String(e) },
@@ -116,21 +115,20 @@ export class ChatPanel {
116115
}
117116

118117
private sendActiveFileContext() {
119-
// activeTextEditor is undefined when the webview panel has focus,
120-
// so fall back to visibleTextEditors to find any open Modelica file.
118+
if (!this.view) return;
121119
let editor = vscode.window.activeTextEditor;
122120
if (!editor || editor.document.languageId !== "modelica") {
123121
editor = vscode.window.visibleTextEditors.find((e) => e.document.languageId === "modelica");
124122
}
125123
if (editor && editor.document.languageId === "modelica") {
126-
this.panel.webview.postMessage({
124+
this.view.webview.postMessage({
127125
type: "activeFileContext",
128126
fileName: editor.document.fileName.split("/").pop(),
129127
content: editor.document.getText(),
130128
uri: editor.document.uri.toString(),
131129
});
132130
} else {
133-
this.panel.webview.postMessage({
131+
this.view.webview.postMessage({
134132
type: "activeFileContext",
135133
fileName: null,
136134
content: null,
@@ -139,22 +137,20 @@ export class ChatPanel {
139137
}
140138
}
141139

142-
private getHtmlForWebview(): string {
143-
const webview = this.panel.webview;
140+
private getHtmlForWebview(webview: vscode.Webview): string {
144141
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "chatWebview.js"));
145142
const nonce = getNonce();
146143

147-
// Model files are served by the IDE Express server at /api/models/.
148-
// We need the server origin (not the webview's virtual origin) for fetch.
149-
// The scriptUri gives us the server origin.
144+
const iconUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "images", "icon.png"));
145+
150146
const serverOrigin = new URL(scriptUri.toString()).origin;
151147
const modelBaseUrl = `${serverOrigin}/api/models`;
152148

153-
// CSP: WebLLM runs in the webview main thread, needs WASM + fetch to model server
154149
const csp = [
155150
"default-src 'none'",
156151
`style-src 'unsafe-inline'`,
157152
`script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`,
153+
`img-src ${webview.cspSource}`,
158154
`connect-src http: https:`,
159155
].join(";");
160156

@@ -330,7 +326,7 @@ export class ChatPanel {
330326
border-color: var(--vscode-focusBorder, #0078d4);
331327
}
332328
#send-btn {
333-
align-self: flex-end;
329+
align-self: center;
334330
background: var(--vscode-button-background, #0078d4);
335331
color: var(--vscode-button-foreground, #fff);
336332
border: none;
@@ -347,11 +343,29 @@ export class ChatPanel {
347343
opacity: 0.5;
348344
cursor: not-allowed;
349345
}
346+
347+
/* Empty state: center input vertically, stack send button below */
348+
body.empty #messages { display: none; }
349+
body.empty {
350+
justify-content: center;
351+
}
352+
body.empty #input-area {
353+
flex-direction: column;
354+
border-top: none;
355+
padding: 16px 24px;
356+
}
357+
body.empty #input {
358+
min-height: 60px;
359+
}
360+
body.empty #send-btn {
361+
align-self: stretch;
362+
padding: 10px;
363+
}
350364
</style>
351365
</head>
352-
<body>
366+
<body class="empty">
353367
<div id="header">
354-
<span>🤖 ModelScript AI</span>
368+
<span><img src="proxy.php?url=https%3A%2F%2Fgithub.com%2Fmodelscript%2Fmodelscript%2Fcommit%2F%3C%2Fspan%3E%3Cspan+class%3D"pl-s1">${iconUri}" width="16" height="16" style="vertical-align: middle; margin-right: 4px;">ModelScript AI</span>
355369
<span class="status" id="model-status">Ready</span>
356370
</div>
357371
<div id="progress-container">
@@ -370,15 +384,6 @@ export class ChatPanel {
370384
</body>
371385
</html>`;
372386
}
373-
374-
dispose() {
375-
ChatPanel.currentPanel = undefined;
376-
this.panel.dispose();
377-
while (this.disposables.length) {
378-
const x = this.disposables.pop();
379-
if (x) x.dispose();
380-
}
381-
}
382387
}
383388

384389
function getNonce() {

packages/vscode/src/webview/chatWebview.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ async function ensureEngine(): Promise<void> {
105105
// ── Message UI ──
106106

107107
function addMessage(role: "user" | "assistant" | "tool", content: string): HTMLElement {
108+
// Switch from centered empty state to normal chat layout
109+
document.body.classList.remove("empty");
110+
108111
const div = document.createElement("div");
109112
div.className = `msg ${role}`;
110113
if (role === "assistant" || role === "tool") {

0 commit comments

Comments
 (0)