feat: improve VS Code dev flow and stabilize sidebar/chat behavior (#754)

* fix: improve session sidebar tooltip and truncation behavior

- Keep new-draft tooltip anchored to its trigger button
- Fix minimal-mode worktree/group header text truncation
- Tune minimal-mode right padding to reduce early label clipping

* fix: render reasoning through markdown pipeline

- Use Streamdown rendering for reasoning in live chat mode
- Remove italic styling from reasoning text
- Render expanded reasoning content with MarkdownRenderer

* chore: remove legacy electron dependencies

- Removed unused Electron packages from root and UI manifests
- Deleted obsolete Electron context menu type declaration
- Regenerated lockfile after dependency cleanup

* fix: handle non-repository folders in git status API

- Prevent 500 errors when status is requested outside a valid Git repo
- Improve repository detection using `git rev-parse --git-dir`
- Reduce noisy server logs for expected non-repo status checks

* fix unloaded session chat layout flicker

* fix: reduce noisy TTS status polling

Cache and dedupe TTS status requests, and only check provider availability when the related voice features are enabled so disabled voice setups stay quiet.

* perf: throttle background PR git status refreshes

* fix: improve VS Code Explorer file drop mentions in chat

- Add Explorer context action to insert selected files as @mentions.
- Handle Explorer drag-and-drop to prefill @file mentions instead of attachments.
- Prevent duplicate plain-path text when dropping multiple files.

* fix: deduplicate recent sessions in VS Code sidebar

- Hide sessions from main list when already shown in recent
- Apply dedup only in VS Code runtime
- Keep session search behavior unchanged

* feat: add true HMR dev flow for VS Code extension

- Load VS Code webview from Vite dev server with React refresh preamble
- Add `vscode:dev` runner that starts watchers and opens Extension Development Host
- Update VS Code dev docs and scripts to use the new HMR startup flow

* feat: polish VS Code session sidebar and attachment UX

- Add resizable sessions sidebar in VS Code layout
- Tighten session list spacing and hover behavior in VS Code
- Remove bulk file/image attach success toasts while keeping error toasts
This commit is contained in:
Bohdan Triapitsyn
2026-03-23 23:51:55 +02:00
committed by GitHub
parent ea6d4c4d43
commit 1231fd773e
39 changed files with 1441 additions and 791 deletions
+14
View File
@@ -62,6 +62,20 @@ Select code in the editor, right-click, and find the **OpenChamber** submenu:
```bash
bun install
bun run vscode:dev
```
`bun run vscode:dev` now starts watchers + opens an Extension Development Host automatically. Webview UI changes use Vite HMR automatically.
Optional overrides:
- `OPENCHAMBER_VSCODE_BIN=cursor bun run vscode:dev`
- `OPENCHAMBER_VSCODE_DEV_WORKSPACE=/path/to/workspace bun run vscode:dev`
- `bun run vscode:dev /path/to/workspace`
To package manually:
```bash
bun run --cwd packages/vscode build
cd packages/vscode && bunx vsce package --no-dependencies
```
+16 -2
View File
@@ -35,6 +35,7 @@
"main": "./dist/extension.js",
"activationEvents": [
"onCommand:openchamber.openSidebar",
"onCommand:openchamber.attachExplorerToChat",
"onView:openchamber.chatView"
],
"contributes": {
@@ -134,6 +135,11 @@
"category": "OpenChamber",
"title": "Settings",
"icon": "$(settings-gear)"
},
{
"command": "openchamber.attachExplorerToChat",
"category": "OpenChamber",
"title": "Attach to OpenChamber Chat"
}
],
"submenus": [
@@ -149,6 +155,13 @@
"group": "navigation"
}
],
"explorer/context": [
{
"command": "openchamber.attachExplorerToChat",
"when": "resourceScheme == file && explorerResourceIsFolder == false",
"group": "navigation@50"
}
],
"editor/title": [
{
"command": "openchamber.openNewSessionInEditor",
@@ -210,7 +223,8 @@
"build": "bun run build:extension && bun run build:webview",
"build:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --minify --main-fields=module,main",
"build:webview": "VITE_OPENCODE_URL=/api vite build",
"dev": "concurrently -n \"ext,web\" -c \"cyan,magenta\" \"bun run watch:extension\" \"bun run watch:webview\"",
"dev": "concurrently -n \"ext,web\" -c \"cyan,magenta\" \"bun run watch:extension\" \"bun run dev:webview\"",
"dev:webview": "vite --host localhost --port 5173 --strictPort",
"watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch --sourcemap --main-fields=module,main",
"watch:webview": "vite build --watch",
"type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json",
@@ -229,7 +243,7 @@
},
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.2.27",
"@opencode-ai/sdk": "^1.3.0",
"adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
@@ -5,6 +5,7 @@ import type { OpenCodeManager, ConnectionStatus } from './opencode';
import { getWebviewShikiThemes } from './shikiThemes';
import { getWebviewHtml } from './webviewHtml';
import { openSseProxy } from './sseProxy';
import { resolveWebviewDevServerUrl } from './webviewDevServer';
export class AgentManagerPanelProvider {
public static readonly viewType = 'openchamber.agentManager';
@@ -16,12 +17,15 @@ export class AgentManagerPanelProvider {
private _cachedError?: string;
private _sseCounter = 0;
private _sseStreams = new Map<string, AbortController>();
private readonly _webviewDevServerUrl: string | null;
constructor(
private readonly _context: vscode.ExtensionContext,
private readonly _extensionUri: vscode.Uri,
private readonly _openCodeManager?: OpenCodeManager
) {}
) {
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
}
public createOrShow(): void {
// If panel exists, reveal it
@@ -227,6 +231,7 @@ export class AgentManagerPanelProvider {
initialStatus: this._cachedStatus,
cliAvailable,
panelType: 'agentManager',
devServerUrl: this._webviewDevServerUrl,
});
}
}
+27 -1
View File
@@ -5,6 +5,7 @@ import type { OpenCodeManager, ConnectionStatus } from './opencode';
import { getWebviewShikiThemes } from './shikiThemes';
import { getWebviewHtml } from './webviewHtml';
import { openSseProxy } from './sseProxy';
import { resolveWebviewDevServerUrl } from './webviewDevServer';
export class ChatViewProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'openchamber.chatView';
@@ -20,12 +21,15 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
private _cachedError?: string;
private _sseCounter = 0;
private _sseStreams = new Map<string, AbortController>();
private readonly _webviewDevServerUrl: string | null;
constructor(
private readonly _context: vscode.ExtensionContext,
private readonly _extensionUri: vscode.Uri,
private readonly _openCodeManager?: OpenCodeManager
) {}
) {
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
}
public resolveWebviewView(
webviewView: vscode.WebviewView
@@ -110,6 +114,27 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
}
}
public addFileMentions(paths: string[]) {
if (!this._view) {
return;
}
const cleanedPaths = paths
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (cleanedPaths.length === 0) {
return;
}
this._view.show(true);
this._view.webview.postMessage({
type: 'command',
command: 'addFileMentions',
payload: { paths: cleanedPaths },
});
}
public createNewSessionWithPrompt(prompt: string) {
if (this._view) {
// Reveal the webview panel
@@ -277,6 +302,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
workspaceFolder,
initialStatus,
cliAvailable,
devServerUrl: this._webviewDevServerUrl,
});
}
}
@@ -5,6 +5,7 @@ import type { OpenCodeManager, ConnectionStatus } from './opencode';
import { getWebviewShikiThemes } from './shikiThemes';
import { getWebviewHtml } from './webviewHtml';
import { openSseProxy } from './sseProxy';
import { resolveWebviewDevServerUrl } from './webviewDevServer';
type SessionPanelState = {
panel: vscode.WebviewPanel;
@@ -18,12 +19,15 @@ export class SessionEditorPanelProvider {
private _cachedError?: string;
private _sseCounter = 0;
private _panels = new Map<string, SessionPanelState>();
private readonly _webviewDevServerUrl: string | null;
constructor(
private readonly _context: vscode.ExtensionContext,
private readonly _extensionUri: vscode.Uri,
private readonly _openCodeManager?: OpenCodeManager
) {}
) {
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
}
public createOrShowNewSession(): void {
// Generate unique panel ID for new session drafts
@@ -266,6 +270,7 @@ export class SessionEditorPanelProvider {
panelType: 'chat',
initialSessionId: sessionId ?? undefined,
viewMode: 'editor',
devServerUrl: this._webviewDevServerUrl,
});
}
}
+48 -15
View File
@@ -183,6 +183,50 @@ const guessMimeTypeFromExtension = (ext: string) => {
}
};
const hasUriScheme = (value: string): boolean => /^[A-Za-z][A-Za-z\d+.-]*:/.test(value);
const parseDroppedFileReference = (rawReference: string):
| { uri: vscode.Uri }
| { skipped: { name: string; reason: string } } => {
const trimmed = rawReference.trim().replace(/^['"]+|['"]+$/g, '');
if (!trimmed) {
return { skipped: { name: rawReference, reason: 'Empty drop reference' } };
}
if (hasUriScheme(trimmed)) {
try {
const parsed = vscode.Uri.parse(trimmed, true);
if (parsed.scheme !== 'file') {
return {
skipped: {
name: trimmed,
reason: `Unsupported URI scheme: ${parsed.scheme || 'unknown'}`,
},
};
}
return { uri: parsed };
} catch (error) {
return {
skipped: {
name: trimmed,
reason: error instanceof Error ? error.message : 'Invalid URI',
},
};
}
}
if (!path.isAbsolute(trimmed)) {
return {
skipped: {
name: trimmed,
reason: 'Drop reference is not an absolute file path',
},
};
}
return { uri: vscode.Uri.file(trimmed) };
};
const readUriAsAttachment = async (
uri: vscode.Uri,
fallbackName?: string,
@@ -1934,24 +1978,13 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
const dedupedUris = Array.from(new Set(uris.map((value) => value.trim())));
for (const rawUri of dedupedUris) {
let uri: vscode.Uri;
try {
uri = vscode.Uri.parse(rawUri, true);
} catch (error) {
skipped.push({
name: rawUri,
reason: error instanceof Error ? error.message : 'Invalid URI',
});
const parsed = parseDroppedFileReference(rawUri);
if ('skipped' in parsed) {
skipped.push(parsed.skipped);
continue;
}
if (uri.scheme !== 'file') {
skipped.push({
name: rawUri,
reason: `Unsupported URI scheme: ${uri.scheme}`,
});
continue;
}
const uri = parsed.uri;
const name = path.basename(uri.fsPath || uri.path || rawUri);
+60
View File
@@ -269,6 +269,66 @@ export async function activate(context: vscode.ExtensionContext) {
})
);
context.subscriptions.push(
vscode.commands.registerCommand('openchamber.attachExplorerToChat', async (resource?: vscode.Uri, resources?: vscode.Uri[]) => {
const uriCandidates: vscode.Uri[] = [];
if (Array.isArray(resources)) {
uriCandidates.push(...resources.filter((entry): entry is vscode.Uri => entry instanceof vscode.Uri));
}
if (resource instanceof vscode.Uri) {
uriCandidates.push(resource);
}
if (uriCandidates.length === 0) {
const activeEditorUri = vscode.window.activeTextEditor?.document.uri;
if (activeEditorUri) {
uriCandidates.push(activeEditorUri);
}
}
const uniqueUris = Array.from(new Map(uriCandidates.map((uri) => [uri.toString(), uri])).values());
const mentionPaths: string[] = [];
const skippedEntries: string[] = [];
for (const uri of uniqueUris) {
if (uri.scheme !== 'file') {
skippedEntries.push(uri.toString());
continue;
}
try {
const stat = await vscode.workspace.fs.stat(uri);
if ((stat.type & vscode.FileType.Directory) !== 0) {
skippedEntries.push(vscode.workspace.asRelativePath(uri, false));
continue;
}
} catch {
skippedEntries.push(vscode.workspace.asRelativePath(uri, false));
continue;
}
const relativePath = vscode.workspace.asRelativePath(uri, false).replace(/\\/g, '/').trim();
if (!relativePath) {
skippedEntries.push(uri.fsPath || uri.toString());
continue;
}
mentionPaths.push(relativePath);
}
if (mentionPaths.length === 0) {
vscode.window.showWarningMessage('OpenChamber: No file selected to mention');
return;
}
await vscode.commands.executeCommand('openchamber.openSidebar');
await new Promise((resolve) => setTimeout(resolve, 80));
chatViewProvider?.addFileMentions(mentionPaths);
if (skippedEntries.length > 0) {
vscode.window.showInformationMessage('OpenChamber: Some selected entries were skipped (folders or unsupported resources)');
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('openchamber.explain', async () => {
const editor = vscode.window.activeTextEditor;
+36
View File
@@ -0,0 +1,36 @@
import * as vscode from 'vscode';
const DEFAULT_WEBVIEW_DEV_SERVER_URL = 'http://localhost:5173';
const normalizeUrl = (value: string): string | null => {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString().replace(/\/$/, '');
} catch {
return null;
}
};
export const resolveWebviewDevServerUrl = (context: vscode.ExtensionContext): string | null => {
if (context.extensionMode !== vscode.ExtensionMode.Development) {
return null;
}
if (process.env.OPENCHAMBER_DISABLE_WEBVIEW_HMR === '1') {
return null;
}
const configured = normalizeUrl(process.env.OPENCHAMBER_VSCODE_WEBVIEW_URL ?? '');
if (configured) {
return configured;
}
return DEFAULT_WEBVIEW_DEV_SERVER_URL;
};
+132 -2
View File
@@ -13,8 +13,32 @@ export interface WebviewHtmlOptions {
panelType?: PanelType;
initialSessionId?: string;
viewMode?: 'sidebar' | 'editor';
devServerUrl?: string | null;
}
const asCspToken = (value: string | null | undefined): string | null => {
if (!value) {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const toOrigin = (value: string | null | undefined): string | null => {
if (!value) {
return null;
}
try {
return new URL(value).origin;
} catch {
return null;
}
};
const uniqueTokens = (values: Array<string | null | undefined>): string => {
return Array.from(new Set(values.map(asCspToken).filter((value): value is string => Boolean(value)))).join(' ');
};
export function getWebviewHtml(options: WebviewHtmlOptions): string {
const {
webview,
@@ -25,10 +49,18 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
panelType = 'chat',
initialSessionId,
viewMode = 'sidebar',
devServerUrl,
} = options;
const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js');
const scriptUri = webview.asWebviewUri(scriptPath);
const normalizedDevServerUrl = asCspToken(devServerUrl)?.replace(/\/$/, '') ?? null;
const devServerOrigin = toOrigin(normalizedDevServerUrl);
const styleSrc = uniqueTokens([webview.cspSource, "'unsafe-inline'", devServerOrigin]);
const scriptSrc = uniqueTokens([webview.cspSource, "'unsafe-inline'", "'unsafe-eval'", devServerOrigin]);
const connectSrc = uniqueTokens(['*', 'ws:', 'wss:', 'http:', 'https:', devServerOrigin]);
const imgSrc = uniqueTokens([webview.cspSource, 'data:', 'https:', devServerOrigin]);
const fontSrc = uniqueTokens([webview.cspSource, 'data:', devServerOrigin]);
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
@@ -45,7 +77,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src ${webview.cspSource} 'unsafe-inline' 'unsafe-eval'; connect-src * ws: wss: http: https:; img-src ${webview.cspSource} data: https:; font-src ${webview.cspSource} data:;">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${styleSrc}; script-src ${scriptSrc}; connect-src ${connectSrc}; img-src ${imgSrc}; font-src ${fontSrc};">
<style>
html, body, #root { height: 100%; width: 100%; margin: 0; padding: 0; }
body {
@@ -171,7 +203,105 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
}
});
</script>
<script type="module" src="${scriptUri}"></script>
<script type="module">
const prodEntryUrl = ${JSON.stringify(scriptUri.toString())};
const devServerUrl = ${normalizedDevServerUrl ? JSON.stringify(normalizedDevServerUrl) : 'null'};
const loadProductionBundle = () => {
const script = document.createElement('script');
script.type = 'module';
script.src = prodEntryUrl;
document.body.appendChild(script);
};
if (!devServerUrl) {
loadProductionBundle();
} else {
const baseUrl = devServerUrl;
const statusEl = document.getElementById('loading-status');
const setStatus = (text) => {
if (statusEl) {
statusEl.textContent = text;
}
};
const retryDelayMs = 500;
let attempt = 0;
const waitForRootMount = (timeoutMs) => {
const root = document.getElementById('root');
if (!root) {
return Promise.resolve(false);
}
if (root.childNodes.length > 0) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
if (root.childNodes.length > 0) {
observer.disconnect();
clearTimeout(timer);
resolve(true);
}
});
observer.observe(root, { childList: true, subtree: true });
const timer = window.setTimeout(() => {
observer.disconnect();
resolve(root.childNodes.length > 0);
}, timeoutMs);
});
};
const tryLoadDevBundle = () => {
const viteClientUrl = baseUrl + '/@vite/client';
const reactRefreshUrl = baseUrl + '/@react-refresh';
const devEntryUrl = baseUrl + '/main.tsx';
const hostLabel = (() => {
try {
return new URL(baseUrl).host;
} catch {
return baseUrl;
}
})();
setStatus('Starting webview dev server (' + hostLabel + ')...');
Promise.resolve()
.then(() => import(viteClientUrl))
.then(() => import(reactRefreshUrl))
.then((mod) => {
const runtime = mod && mod.default ? mod.default : null;
if (runtime && typeof runtime.injectIntoGlobalHook === 'function') {
runtime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
}
})
.then(() => import(devEntryUrl))
.then(() => waitForRootMount(4000))
.then((mounted) => {
if (!mounted) {
throw new Error('Dev bundle loaded but app did not mount');
}
})
.catch((error) => {
attempt += 1;
console.warn('[OpenChamber] VS Code webview dev bundle unavailable, retrying...', error);
setStatus('Waiting for webview dev server (' + hostLabel + ')... attempt ' + attempt);
window.setTimeout(() => {
tryLoadDevBundle();
}, retryDelayMs);
});
};
tryLoadDevBundle();
}
</script>
</body>
</html>`;
}
+17 -3
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
export default defineConfig(({ mode }) => ({
root: path.resolve(__dirname, 'webview'),
base: './', // Use relative paths for VS Code webview
plugins: [
@@ -27,11 +27,25 @@ export default defineConfig({
format: 'es',
},
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'),
'global': 'globalThis',
'__OPENCHAMBER_WEBVIEW_BUILD_TIME__': JSON.stringify(new Date().toISOString()),
},
envPrefix: ['VITE_'],
server: {
host: 'localhost',
port: 5173,
strictPort: true,
cors: true,
headers: {
'Access-Control-Allow-Origin': '*',
},
hmr: {
host: 'localhost',
protocol: 'ws',
port: 5173,
},
},
optimizeDeps: {
include: ['@opencode-ai/sdk/v2'],
},
@@ -48,4 +62,4 @@ export default defineConfig({
},
},
},
});
}));
+21
View File
@@ -982,6 +982,27 @@ onCommand('addToContext', (payload) => {
});
});
onCommand('addFileMentions', (payload) => {
const rawPaths = Array.isArray((payload as { paths?: unknown[] })?.paths)
? (payload as { paths: unknown[] }).paths
: [];
const paths = rawPaths
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (paths.length === 0) {
return;
}
const mentionText = paths.map((relativePath) => `@${relativePath}`).join(' ');
import('@/stores/useSessionStore').then(({ useSessionStore }) => {
const store = useSessionStore.getState();
store.setPendingInputText(mentionText, 'append-inline');
});
});
// Listen for createSessionWithPrompt command from extension (Explain, Improve Code)
onCommand('createSessionWithPrompt', (payload) => {
const { prompt } = payload as { prompt: string };