From 7b98f972f3f8dbc30a82fe7b77713be73067ed6a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 31 Dec 2025 00:06:59 +0200 Subject: [PATCH] feat: add OpenChamber sidebar command and icon for VS Code title bar --- packages/vscode/assets/icon-titlebar.svg | 42 +++++++ packages/vscode/package.json | 19 +++ packages/vscode/src/ChatViewProvider.ts | 6 +- packages/vscode/src/extension.ts | 145 ++++++++++++++++++++++- 4 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 packages/vscode/assets/icon-titlebar.svg diff --git a/packages/vscode/assets/icon-titlebar.svg b/packages/vscode/assets/icon-titlebar.svg new file mode 100644 index 00000000..3c8c1408 --- /dev/null +++ b/packages/vscode/assets/icon-titlebar.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 85087cc7..063ff4e3 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -54,6 +54,19 @@ ] }, "commands": [ + { + "command": "openchamber.openSidebar", + "title": "Open OpenChamber Sidebar", + "icon": { + "light": "assets/icon.svg", + "dark": "assets/icon-titlebar.svg" + } + }, + { + "command": "openchamber.focusChat", + "title": "OpenChamber: Focus Chat" + }, + { "command": "openchamber.restartApi", "title": "OpenChamber: Restart API Connection" @@ -88,6 +101,12 @@ "group": "navigation" } ], + "editor/title": [ + { + "command": "openchamber.openSidebar", + "group": "navigation@1" + } + ], "openchamber.submenu": [ { "command": "openchamber.explain" diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index f21b7dba..f9d7c799 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -8,7 +8,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'openchamber.chatView'; private _view?: vscode.WebviewView; - + + public isVisible() { + return this._view?.visible ?? false; + } + // Cache latest status/URL for when webview is resolved after connection is ready private _cachedStatus: ConnectionStatus = 'connecting'; private _cachedError?: string; diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 2808db7c..0cf9cecc 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -26,6 +26,143 @@ const formatDurationMs = (value: number | null | undefined) => { export async function activate(context: vscode.ExtensionContext) { outputChannel = vscode.window.createOutputChannel('OpenChamber'); + let moveToRightSidebarScheduled = false; + + const isCursorLikeHost = () => /\bcursor\b/i.test(vscode.env.appName); + + const findMoveToRightSidebarCommandId = async (): Promise => { + const commands = await vscode.commands.getCommands(true); + + const preferred = [ + // Newer VS Code naming + 'workbench.action.moveViewToSecondarySideBar', + 'workbench.action.moveViewToSecondarySidebar', + 'workbench.action.moveFocusedViewToSecondarySideBar', + 'workbench.action.moveFocusedViewToSecondarySidebar', + + // Some builds use "Auxiliary Bar" naming + 'workbench.action.moveViewToAuxiliaryBar', + 'workbench.action.moveFocusedViewToAuxiliaryBar', + ]; + + for (const commandId of preferred) { + if (commands.includes(commandId)) return commandId; + } + + const fuzzy = commands.find((commandId) => { + const id = commandId.toLowerCase(); + const looksLikeMoveView = id.includes('workbench.action') && id.includes('move') && id.includes('view'); + if (!looksLikeMoveView) return false; + + // Support both "secondary sidebar" and "auxiliary bar" naming. + return (id.includes('secondary') && id.includes('side') && id.includes('bar')) || (id.includes('auxiliary') && id.includes('bar')); + }); + + return fuzzy || null; + }; + + const attemptMoveChatToRightSidebar = async (): Promise<'moved' | 'unsupported' | 'failed'> => { + const moveCommandId = await findMoveToRightSidebarCommandId(); + if (!moveCommandId) return 'unsupported'; + + try { + await vscode.commands.executeCommand('openchamber.chatView.focus'); + await vscode.commands.executeCommand(moveCommandId); + return 'moved'; + } catch (error) { + outputChannel?.appendLine( + `[OpenChamber] Failed moving chat view to right sidebar (command=${moveCommandId}): ${error instanceof Error ? error.message : String(error)}` + ); + return 'failed'; + } + }; + + const maybeMoveChatToRightSidebarOnStartup = async () => { + if (isCursorLikeHost()) return; + + const attempted = context.globalState.get('openchamber.sidebarAutoMoveAttempted') || false; + if (attempted) return; + await context.globalState.update('openchamber.sidebarAutoMoveAttempted', true); + + if (moveToRightSidebarScheduled) return; + moveToRightSidebarScheduled = true; + + // Defer until after activation to avoid stealing focus during startup. + setTimeout(() => { + void (async () => { + try { + await attemptMoveChatToRightSidebar(); + } finally { + moveToRightSidebarScheduled = false; + } + })(); + }, 800); + }; + + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + const tryExecuteCommand = async (commandId: string) => { + try { + await vscode.commands.executeCommand(commandId); + return true; + } catch { + return false; + } + }; + + const togglePrimarySidebar = async () => { + return await tryExecuteCommand('workbench.action.toggleSidebarVisibility'); + }; + + const toggleSecondarySidebar = async () => { + const candidates = ['workbench.action.toggleSecondarySideBarVisibility', 'workbench.action.toggleAuxiliaryBar']; + for (const commandId of candidates) { + if (await tryExecuteCommand(commandId)) return true; + } + return false; + }; + + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.openSidebar', async () => { + const isOpen = chatViewProvider?.isVisible() ?? false; + + // Second click hides the sidebar that currently hosts the chat view. + if (isOpen) { + if (isCursorLikeHost()) { + await togglePrimarySidebar(); + await sleep(50); + if (chatViewProvider?.isVisible()) { + await toggleSecondarySidebar(); + } + return; + } + + await toggleSecondarySidebar(); + await sleep(50); + if (chatViewProvider?.isVisible()) { + await togglePrimarySidebar(); + } + return; + } + + // Best-effort: open the container (if available), then focus the chat view. + try { + await vscode.commands.executeCommand('workbench.view.extension.openchamber'); + } catch { + // Ignore: not all VS Code forks expose this command. + } + + await vscode.commands.executeCommand('openchamber.chatView.focus'); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('openchamber.focusChat', async () => { + await vscode.commands.executeCommand('openchamber.chatView.focus'); + }) + ); + + // Migration: clear legacy auto-set API URLs (ports 47680-47689 were auto-assigned by older extension versions) const config = vscode.workspace.getConfiguration('openchamber'); const legacyApiUrl = config.get('apiUrl') || ''; @@ -48,6 +185,8 @@ export async function activate(context: vscode.ExtensionContext) { ) ); + void maybeMoveChatToRightSidebarOnStartup(); + context.subscriptions.push( vscode.commands.registerCommand('openchamber.restartApi', async () => { try { @@ -91,7 +230,7 @@ export async function activate(context: vscode.ExtensionContext) { chatViewProvider?.addTextToInput(contextText); // Focus the chat panel - vscode.commands.executeCommand('openchamber.chatView.focus'); + vscode.commands.executeCommand('openchamber.focusChat'); }) ); @@ -123,7 +262,7 @@ export async function activate(context: vscode.ExtensionContext) { // Create new session and send the prompt chatViewProvider?.createNewSessionWithPrompt(prompt); - vscode.commands.executeCommand('openchamber.chatView.focus'); + vscode.commands.executeCommand('openchamber.focusChat'); }) ); @@ -153,7 +292,7 @@ export async function activate(context: vscode.ExtensionContext) { // Create new session and send the prompt chatViewProvider?.createNewSessionWithPrompt(prompt); - vscode.commands.executeCommand('openchamber.chatView.focus'); + vscode.commands.executeCommand('openchamber.focusChat'); }) );