From 32d2c62393271ec19e304c14502a7391aa0a6274 Mon Sep 17 00:00:00 2001 From: Dmitriy Nekrasov Date: Fri, 13 Mar 2026 19:19:37 +0100 Subject: [PATCH 1/7] fix: Windows compatibility for native messaging host - Replace hardcoded macOS copilot CLI path with cross-platform detection - Add host-wrapper.bat for Windows native messaging (Chrome requires .bat/.exe, not .mjs) - Fix register-host.bat to reference host-wrapper.bat instead of host.mjs directly - Use published @github/copilot-sdk (^0.1.32) instead of local file: reference - Add postinstall script to patch vscode-jsonrpc ESM exports map - Add approveAll permission handler for tool execution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 177 +++++++++++++++++++++++++------ package.json | 5 +- scripts/patch-vscode-jsonrpc.mjs | 25 +++++ scripts/register-host.bat | 4 +- src/host/host-wrapper.bat | 2 + src/host/host.mjs | 40 ++++++- 6 files changed, 211 insertions(+), 42 deletions(-) create mode 100644 scripts/patch-vscode-jsonrpc.mjs create mode 100644 src/host/host-wrapper.bat diff --git a/package-lock.json b/package-lock.json index 131bbed..a71d989 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "github-copilot-browser", "version": "0.1.0", "dependencies": { - "@github/copilot-sdk": "file:../copilot-sdk/nodejs", + "@github/copilot-sdk": "^0.1.32", "highlight.js": "^11.11.1", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -28,36 +28,6 @@ "vite": "^6.1.0" } }, - "../copilot-sdk/nodejs": { - "name": "@github/copilot-sdk", - "version": "0.1.8", - "license": "MIT", - "dependencies": { - "@github/copilot": "^0.0.405", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" - }, - "devDependencies": { - "@types/node": "^25.2.0", - "@typescript-eslint/eslint-plugin": "^8.54.0", - "@typescript-eslint/parser": "^8.54.0", - "esbuild": "^0.27.2", - "eslint": "^9.0.0", - "glob": "^13.0.1", - "json-schema": "^0.4.0", - "json-schema-to-typescript": "^15.0.4", - "prettier": "^3.8.1", - "quicktype-core": "^23.2.6", - "rimraf": "^6.1.2", - "semver": "^7.7.3", - "tsx": "^4.20.6", - "typescript": "^5.0.0", - "vitest": "^4.0.18" - }, - "engines": { - "node": ">=24.0.0" - } - }, "node_modules/@crxjs/vite-plugin": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.3.0.tgz", @@ -525,9 +495,132 @@ "node": ">=18" } }, + "node_modules/@github/copilot": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.5.tgz", + "integrity": "sha512-lQGN1/qw7gJRT+lSW1U79Ltrf9rkF6UP8FcEb0hGEf9hq0K8/MaulzK+iDtH/gwXYweFXID29E3QlwSqbdsHqQ==", + "license": "SEE LICENSE IN LICENSE.md", + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.5", + "@github/copilot-darwin-x64": "1.0.5", + "@github/copilot-linux-arm64": "1.0.5", + "@github/copilot-linux-x64": "1.0.5", + "@github/copilot-win32-arm64": "1.0.5", + "@github/copilot-win32-x64": "1.0.5" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.5.tgz", + "integrity": "sha512-XBwo8t5higPXzCvXVYkADImixt9k8P2XsflWup2b86x9KtcssYTcfEWWIg42AOCe8J/OJRJN2MMTQuWt5aeK9w==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.5.tgz", + "integrity": "sha512-zUlMEKct5oPk/ImnYKz+fUjI9xfIwRE2/WI8BrpuDDe16aFDW2Co/6WFFr5rgYcXoGX2Jm8HT563UUxaFbnnOA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.5.tgz", + "integrity": "sha512-Rp5Key6IBcm00K3+yc8rga3IXaJKN7mwYtP/mpkCKaJJp7izpJK7Z7Dr1slb63Z3yCAyPwMeYlE+adFCwlnYUA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.5.tgz", + "integrity": "sha512-ZEKOi57SUo3Ds2ZeYkIkHJ9MJA0Im1i04i0vdAPKH5Xibb2AC6I2EHO2dU/MWwqIeXoK5QDRh0r0Gs+BkHA/dg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, "node_modules/@github/copilot-sdk": { - "resolved": "../copilot-sdk/nodejs", - "link": true + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.32.tgz", + "integrity": "sha512-mPWM0fw1Gqc/SW8nl45K8abrFH+92fO7y6tRtRl5imjS5hGapLf/dkX5WDrgPtlsflD0c41lFXVUri5NVJwtoA==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.2", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.5.tgz", + "integrity": "sha512-pkhuKJZ1AcRAkVS2OO4BEBfMovGSuGWem4isBq+cgRDtuXRfRiZuc88Z9WcrtDCCwpdLx9rSYPVSWQG5fvupPQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.5.tgz", + "integrity": "sha512-x6PWG80uCuCI+IgCLD1fnBJtfuf9nMBzJwOcMlFwjRtHduV/V9OOW3c89ooGwh/lRhCatAP5GxZGTyC7AJR3kQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", @@ -4337,6 +4430,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -4361,6 +4463,15 @@ "node": ">=18" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 433dfae..51e3e9e 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,11 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "postinstall": "node scripts/patch-vscode-jsonrpc.mjs" }, "dependencies": { - "@github/copilot-sdk": "file:../copilot-sdk/nodejs", + "@github/copilot-sdk": "^0.1.32", "highlight.js": "^11.11.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/scripts/patch-vscode-jsonrpc.mjs b/scripts/patch-vscode-jsonrpc.mjs new file mode 100644 index 0000000..f524c77 --- /dev/null +++ b/scripts/patch-vscode-jsonrpc.mjs @@ -0,0 +1,25 @@ +// Patch vscode-jsonrpc package.json to add ESM-compatible exports map. +// vscode-jsonrpc@8.x doesn't define "exports", so ESM imports like +// "vscode-jsonrpc/node" (used by @github/copilot-sdk) fail. +import { readFileSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkgPath = join(__dirname, '..', 'node_modules', 'vscode-jsonrpc', 'package.json'); + +try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + if (!pkg.exports) { + pkg.exports = { + '.': { types: './lib/common/api.d.ts', default: './lib/common/api.js' }, + './node': { node: './lib/node/main.js', types: './lib/node/main.d.ts', default: './lib/node/main.js' }, + './node.js': { node: './lib/node/main.js', types: './lib/node/main.d.ts', default: './lib/node/main.js' }, + './browser': { types: './lib/browser/main.d.ts', browser: './lib/browser/main.js' }, + }; + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + console.log('[postinstall] Patched vscode-jsonrpc exports for ESM compatibility'); + } +} catch (e) { + // Silently ignore if vscode-jsonrpc isn't installed yet +} diff --git a/scripts/register-host.bat b/scripts/register-host.bat index 447340c..92e542b 100644 --- a/scripts/register-host.bat +++ b/scripts/register-host.bat @@ -4,7 +4,7 @@ setlocal set HOST_NAME=com.github.copilot.browser set SCRIPT_DIR=%~dp0 set PROJECT_DIR=%SCRIPT_DIR%.. -set HOST_PATH=%PROJECT_DIR%\src\host\host.mjs +set WRAPPER_PATH=%PROJECT_DIR%\src\host\host-wrapper.bat set EXTENSION_ID=%1 if "%EXTENSION_ID%"=="" ( @@ -24,7 +24,7 @@ if not exist "%CHROME_DIR%" mkdir "%CHROME_DIR%" echo {> "%CHROME_DIR%\%HOST_NAME%.json" echo "name": "%HOST_NAME%",>> "%CHROME_DIR%\%HOST_NAME%.json" echo "description": "GitHub Copilot Browser Extension Native Messaging Host",>> "%CHROME_DIR%\%HOST_NAME%.json" -echo "path": "node %HOST_PATH:\=\\%",>> "%CHROME_DIR%\%HOST_NAME%.json" +echo "path": "%WRAPPER_PATH:\=\\%",>> "%CHROME_DIR%\%HOST_NAME%.json" echo "type": "stdio",>> "%CHROME_DIR%\%HOST_NAME%.json" echo "allowed_origins": [>> "%CHROME_DIR%\%HOST_NAME%.json" echo "chrome-extension://%EXTENSION_ID%/">> "%CHROME_DIR%\%HOST_NAME%.json" diff --git a/src/host/host-wrapper.bat b/src/host/host-wrapper.bat new file mode 100644 index 0000000..431285b --- /dev/null +++ b/src/host/host-wrapper.bat @@ -0,0 +1,2 @@ +@echo off +node "%~dp0host.mjs" diff --git a/src/host/host.mjs b/src/host/host.mjs index 411e70c..c749554 100755 --- a/src/host/host.mjs +++ b/src/host/host.mjs @@ -1,5 +1,6 @@ #!/opt/homebrew/bin/node -import { CopilotClient } from '@github/copilot-sdk'; +import { CopilotClient, approveAll } from '@github/copilot-sdk'; +import { execSync } from 'child_process'; // ── Chrome Native Messaging Protocol ──────────────────────────────── // 4-byte little-endian length prefix + JSON payload on stdin/stdout. @@ -125,14 +126,43 @@ const browserTools = [ async function initialize() { try { - client = new CopilotClient({ - cliPath: '/opt/homebrew/bin/copilot', + // Resolve copilot CLI path. The SDK can use either an explicit path + // or its bundled @github/copilot package (the default when cliPath is omitted). + const isWindows = process.platform === 'win32'; + let cliPath; + + if (isWindows) { + try { + const output = execSync('where copilot', { timeout: 5000 }).toString().trim(); + const lines = output.split(/[\r\n]+/); + const exePath = lines.find(p => p.toLowerCase().endsWith('.exe')); + cliPath = exePath || lines[0]; + } catch { + // `where` failed — Chrome may launch us with limited PATH. + // Fall through: cliPath stays undefined → SDK uses bundled CLI. + } + } else { + // macOS/Linux: check well-known Homebrew path first + const { existsSync } = await import('fs'); + const candidates = ['/opt/homebrew/bin/copilot', '/usr/local/bin/copilot']; + cliPath = candidates.find(p => existsSync(p)); + } + + const envPath = isWindows + ? process.env.PATH + : `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || '/usr/bin:/bin'}`; + + const clientOpts = { logLevel: 'error', - env: { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || '/usr/bin:/bin'}` }, - }); + env: { ...process.env, PATH: envPath }, + }; + if (cliPath) clientOpts.cliPath = cliPath; + + client = new CopilotClient(clientOpts); session = await client.createSession({ tools: browserTools, + onPermissionRequest: approveAll, systemMessage: { mode: 'append', content: [ From 8cf544cbd80186c00d84c7d2796b43bc75d36f1b Mon Sep 17 00:00:00 2001 From: Dmitriy Nekrasov Date: Mon, 16 Mar 2026 09:32:30 +0100 Subject: [PATCH 2/7] fix: portable shebang and explicit copilot.exe lookup - Change shebang to #!/usr/bin/env node for Linux/macOS portability - Use 'where copilot.exe' instead of 'where copilot' to avoid picking up .bat wrappers on Windows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/host/host.mjs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/host/host.mjs b/src/host/host.mjs index c749554..7b24e48 100755 --- a/src/host/host.mjs +++ b/src/host/host.mjs @@ -1,4 +1,4 @@ -#!/opt/homebrew/bin/node +#!/usr/bin/env node import { CopilotClient, approveAll } from '@github/copilot-sdk'; import { execSync } from 'child_process'; @@ -133,10 +133,8 @@ async function initialize() { if (isWindows) { try { - const output = execSync('where copilot', { timeout: 5000 }).toString().trim(); - const lines = output.split(/[\r\n]+/); - const exePath = lines.find(p => p.toLowerCase().endsWith('.exe')); - cliPath = exePath || lines[0]; + const output = execSync('where copilot.exe', { timeout: 5000 }).toString().trim(); + cliPath = output.split(/[\r\n]+/)[0]; } catch { // `where` failed — Chrome may launch us with limited PATH. // Fall through: cliPath stays undefined → SDK uses bundled CLI. From 80d86d54109f3b08f9f4856928f7a68616331608 Mon Sep 17 00:00:00 2001 From: Dmitriy Nekrasov Date: Mon, 16 Mar 2026 09:53:08 +0100 Subject: [PATCH 3/7] feat: add model selector to browser extension Add a dropdown in the panel header that lets users choose which AI model powers the Copilot browser assistant. - Fetch available models from SDK via client.listModels() on init - New ModelSelector component with dropdown UI - Model selection persisted in localStorage across sessions - SET_MODEL message type for mid-session model switching - Model passed with each SEND_CHAT_MESSAGE for per-request selection - Full message flow: Panel -> ServiceWorker -> NativeHost -> SDK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/background/service-worker.ts | 24 ++++++- src/host/host.mjs | 26 +++++++ src/panel/App.tsx | 30 +++++++- src/panel/components/HeaderBar.tsx | 15 +++- src/panel/components/ModelSelector.tsx | 95 ++++++++++++++++++++++++++ src/panel/lib/copilot-client.ts | 9 ++- src/shared/messages.ts | 11 ++- src/shared/types.ts | 6 ++ 8 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 src/panel/components/ModelSelector.tsx diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index d6865aa..83962dc 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -58,11 +58,11 @@ async function handlePanelMessage(message: PanelMessage, port: chrome.runtime.Po } case 'SEND_CHAT_MESSAGE': { - const { content, sessionId } = message.payload; + const { content, sessionId, model } = message.payload; try { nativeMessaging.send({ type: 'SEND_CHAT_MESSAGE', - payload: { content }, + payload: { content, ...(model && { model }) }, }); } catch (error) { sendToPanel(port, { @@ -76,6 +76,18 @@ async function handlePanelMessage(message: PanelMessage, port: chrome.runtime.Po break; } + case 'SET_MODEL': { + try { + nativeMessaging.send({ + type: 'SET_MODEL', + payload: { model: message.payload.model }, + }); + } catch (error) { + console.error('[Background] Failed to send SET_MODEL:', error); + } + break; + } + case 'EXECUTE_TOOL': break; } @@ -152,6 +164,14 @@ nativeMessaging.onMessage((message: any) => { case 'TOOL_EXECUTION_COMPLETE': // Already handled via TOOL_CALL_REQUEST flow break; + + // Available models from the Copilot SDK + case 'AVAILABLE_MODELS': + sendToPanels({ + type: 'AVAILABLE_MODELS', + payload: { models: message.payload.models }, + }); + break; } }); diff --git a/src/host/host.mjs b/src/host/host.mjs index 411e70c..8e0bfa7 100755 --- a/src/host/host.mjs +++ b/src/host/host.mjs @@ -131,6 +131,15 @@ async function initialize() { env: { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || '/usr/bin:/bin'}` }, }); + // Fetch and send available models to the extension + try { + const models = await client.listModels(); + const modelList = models.map(m => ({ id: m.id, name: m.name || m.id })); + sendMessage({ type: 'AVAILABLE_MODELS', payload: { models: modelList } }); + } catch { + // Model listing failed — non-fatal, extension works without selector + } + session = await client.createSession({ tools: browserTools, systemMessage: { @@ -189,6 +198,10 @@ async function handleMessage(message) { return; } try { + // Apply model if provided with the message + if (message.payload.model) { + await session.setModel(message.payload.model); + } await session.send({ prompt: message.payload.content }); } catch (error) { sendMessage({ type: 'CHAT_RESPONSE_ERROR', payload: { error: error.message } }); @@ -196,6 +209,19 @@ async function handleMessage(message) { break; } + case 'SET_MODEL': { + if (!session) { + sendMessage({ type: 'CHAT_RESPONSE_ERROR', payload: { error: 'Session not initialized' } }); + return; + } + try { + await session.setModel(message.payload.model); + } catch (error) { + sendMessage({ type: 'CHAT_RESPONSE_ERROR', payload: { error: `Failed to set model: ${error.message}` } }); + } + break; + } + case 'TOOL_CALL_RESULT': { const { toolCallId, result } = message.payload; const pending = pendingToolCalls.get(toolCallId); diff --git a/src/panel/App.tsx b/src/panel/App.tsx index 2875599..dd16402 100644 --- a/src/panel/App.tsx +++ b/src/panel/App.tsx @@ -5,7 +5,7 @@ import ChatInput from './components/ChatInput'; import SessionHistory from './components/SessionHistory'; import { copilotClient } from './lib/copilot-client'; import * as sessionStorage from './lib/session-storage'; -import type { ChatMessage, ConnectionStatus, Session } from '../shared/types'; +import type { ChatMessage, ConnectionStatus, Session, ModelInfo } from '../shared/types'; import type { BackgroundMessage } from '../shared/messages'; export default function App() { @@ -15,6 +15,10 @@ export default function App() { const [isLoading, setIsLoading] = useState(false); const [showHistory, setShowHistory] = useState(false); const [sessionId, setSessionId] = useState(''); + const [availableModels, setAvailableModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(() => { + try { return localStorage.getItem('copilot-browser-model') || ''; } catch { return ''; } + }); const initialized = useRef(false); // Initialize on mount @@ -152,6 +156,17 @@ export default function App() { ); break; } + + case 'AVAILABLE_MODELS': { + const models = message.payload.models; + setAvailableModels(models); + // Auto-select first model if none selected yet + setSelectedModel((prev) => { + if (prev && models.some((m: ModelInfo) => m.id === prev)) return prev; + return models.length > 0 ? models[0].id : ''; + }); + break; + } } }); @@ -175,7 +190,7 @@ export default function App() { } if (connectionStatus === 'connected') { - copilotClient.sendChatMessage(content, sessionId); + copilotClient.sendChatMessage(content, sessionId, selectedModel || undefined); } else { // Fallback when not connected setTimeout(() => { @@ -194,7 +209,7 @@ export default function App() { }, 500); } }, - [connectionStatus, sessionId], + [connectionStatus, sessionId, selectedModel], ); const handleConnect = useCallback(() => { @@ -228,6 +243,12 @@ export default function App() { } }, []); + const handleModelChange = useCallback((modelId: string) => { + setSelectedModel(modelId); + try { localStorage.setItem('copilot-browser-model', modelId); } catch {} + copilotClient.setModel(modelId); + }, []); + return (
setShowHistory(true)} + availableModels={availableModels} + selectedModel={selectedModel} + onModelChange={handleModelChange} /> diff --git a/src/panel/components/HeaderBar.tsx b/src/panel/components/HeaderBar.tsx index ab80a1e..6000032 100644 --- a/src/panel/components/HeaderBar.tsx +++ b/src/panel/components/HeaderBar.tsx @@ -1,5 +1,7 @@ import React from 'react'; import type { ConnectionStatus } from '../../shared/types'; +import type { ModelInfo } from '../../shared/types'; +import ModelSelector from './ModelSelector'; interface HeaderBarProps { connectionStatus: ConnectionStatus; @@ -7,6 +9,9 @@ interface HeaderBarProps { onConnect: () => void; onNewSession: () => void; onShowHistory: () => void; + availableModels: ModelInfo[]; + selectedModel: string; + onModelChange: (modelId: string) => void; } const statusColors: Record = { @@ -23,7 +28,7 @@ const statusLabels: Record = { error: 'Error', }; -export default function HeaderBar({ connectionStatus, connectionError, onConnect, onNewSession, onShowHistory }: HeaderBarProps) { +export default function HeaderBar({ connectionStatus, connectionError, onConnect, onNewSession, onShowHistory, availableModels, selectedModel, onModelChange }: HeaderBarProps) { const tooltip = connectionError ? `${statusLabels[connectionStatus]}: ${connectionError}` : statusLabels[connectionStatus]; @@ -35,7 +40,7 @@ export default function HeaderBar({ connectionStatus, connectionError, onConnect Copilot Browser
-
+
+ + + {isOpen && ( +
+ {models.map((model) => ( + + ))} +
+ )} +
+ ); +} + +function formatModelName(model: ModelInfo): string { + // Use display name if different from ID, otherwise prettify the ID + if (model.name && model.name !== model.id) return model.name; + return model.id; +} diff --git a/src/panel/lib/copilot-client.ts b/src/panel/lib/copilot-client.ts index e3d7be8..3a38c6c 100644 --- a/src/panel/lib/copilot-client.ts +++ b/src/panel/lib/copilot-client.ts @@ -39,8 +39,13 @@ class CopilotClient { } // Send a chat message - sendChatMessage(content: string, sessionId: string): void { - this.send({ type: 'SEND_CHAT_MESSAGE', payload: { content, sessionId } }); + sendChatMessage(content: string, sessionId: string, model?: string): void { + this.send({ type: 'SEND_CHAT_MESSAGE', payload: { content, sessionId, ...(model && { model }) } }); + } + + // Set the active model + setModel(model: string): void { + this.send({ type: 'SET_MODEL', payload: { model } }); } // Cancel current request diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 7c6102a..ae14715 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -1,8 +1,9 @@ -import type { ChatMessage, ToolCall, ToolResult, ConnectionStatus, TabInfo } from './types'; +import type { ChatMessage, ToolCall, ToolResult, ConnectionStatus, TabInfo, ModelInfo } from './types'; // Direction: Panel -> Background export type PanelMessage = - | { type: 'SEND_CHAT_MESSAGE'; payload: { content: string; sessionId: string } } + | { type: 'SEND_CHAT_MESSAGE'; payload: { content: string; sessionId: string; model?: string } } + | { type: 'SET_MODEL'; payload: { model: string } } | { type: 'CANCEL_REQUEST'; payload: { sessionId: string } } | { type: 'GET_CONNECTION_STATUS' } | { type: 'CONNECT_TO_HOST' } @@ -18,6 +19,7 @@ export type BackgroundMessage = | { type: 'TOOL_CALL_START'; payload: { toolCall: ToolCall; sessionId: string } } | { type: 'TOOL_CALL_RESULT'; payload: { toolCallId: string; result: ToolResult; sessionId: string } } | { type: 'CONNECTION_STATUS_CHANGED'; payload: { status: ConnectionStatus; error?: string | null } } + | { type: 'AVAILABLE_MODELS'; payload: { models: ModelInfo[] } } | { type: 'OPEN_TABS'; payload: { tabs: TabInfo[] } }; // Direction: Background -> Content Script @@ -54,4 +56,7 @@ export type NativeMessage = | { type: 'COPILOT_REQUEST'; payload: { method: string; params: unknown } } | { type: 'COPILOT_RESPONSE'; payload: { id: string; result?: unknown; error?: unknown } } | { type: 'COPILOT_STREAM'; payload: { id: string; chunk: string } } - | { type: 'HOST_STATUS'; payload: { connected: boolean; error?: string } }; + | { type: 'HOST_STATUS'; payload: { connected: boolean; error?: string } } + | { type: 'AVAILABLE_MODELS'; payload: { models: ModelInfo[] } } + | { type: 'SET_MODEL'; payload: { model: string } } + | { type: 'SEND_CHAT_MESSAGE'; payload: { content: string; model?: string } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index f055121..2f3c788 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -49,6 +49,12 @@ export interface Session { updatedAt: number; } +// Model info (from Copilot SDK) +export interface ModelInfo { + id: string; + name: string; +} + // Connection status export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; From 26ca4379e60e3f833e30dc85991cf6ec4264db28 Mon Sep 17 00:00:00 2001 From: Dmitriy Nekrasov Date: Mon, 16 Mar 2026 09:59:19 +0100 Subject: [PATCH 4/7] fix: address multi-model code review findings - Cache model list in service worker so late-connecting panels get it - Send AVAILABLE_MODELS after session init (not before) to prevent race - Serialize message handling in host with promise queue - Make setModel best-effort: failure doesn't block the user's message - Remove redundant SET_MODEL path; model sent per-message only Reviewed by: Claude Opus 4.6, GPT-5.2, Gemini 3 Pro Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/background/service-worker.ts | 21 ++++++--------- src/host/host.mjs | 45 ++++++++++++++------------------ src/panel/App.tsx | 1 - src/panel/lib/copilot-client.ts | 5 ---- src/shared/messages.ts | 2 -- 5 files changed, 28 insertions(+), 46 deletions(-) diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 83962dc..67acd48 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -8,6 +8,7 @@ console.log('[Background] Service worker loaded'); // Track panel connections const panelPorts: chrome.runtime.Port[] = []; +let cachedModels: { models: Array<{ id: string; name: string }> } | null = null; // Listen for connections from the side panel chrome.runtime.onConnect.addListener((port) => { @@ -30,6 +31,11 @@ chrome.runtime.onConnect.addListener((port) => { type: 'CONNECTION_STATUS_CHANGED', payload: { status: nativeMessaging.status, error: nativeMessaging.lastError }, }); + + // Send cached model list if available + if (cachedModels) { + sendToPanel(port, { type: 'AVAILABLE_MODELS', payload: cachedModels }); + } } }); @@ -76,18 +82,6 @@ async function handlePanelMessage(message: PanelMessage, port: chrome.runtime.Po break; } - case 'SET_MODEL': { - try { - nativeMessaging.send({ - type: 'SET_MODEL', - payload: { model: message.payload.model }, - }); - } catch (error) { - console.error('[Background] Failed to send SET_MODEL:', error); - } - break; - } - case 'EXECUTE_TOOL': break; } @@ -167,9 +161,10 @@ nativeMessaging.onMessage((message: any) => { // Available models from the Copilot SDK case 'AVAILABLE_MODELS': + cachedModels = { models: message.payload.models }; sendToPanels({ type: 'AVAILABLE_MODELS', - payload: { models: message.payload.models }, + payload: cachedModels, }); break; } diff --git a/src/host/host.mjs b/src/host/host.mjs index 8e0bfa7..037714c 100755 --- a/src/host/host.mjs +++ b/src/host/host.mjs @@ -40,6 +40,9 @@ function readMessages(callback) { let client = null; let session = null; +// Serialize message handling to prevent race conditions (e.g., setModel + send interleaving) +let messageQueue = Promise.resolve(); + // Pending tool call requests from the extension (browser tools) // When the LLM calls a browser tool, we forward it to the extension // and wait for the result. @@ -131,15 +134,6 @@ async function initialize() { env: { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || '/usr/bin:/bin'}` }, }); - // Fetch and send available models to the extension - try { - const models = await client.listModels(); - const modelList = models.map(m => ({ id: m.id, name: m.name || m.id })); - sendMessage({ type: 'AVAILABLE_MODELS', payload: { models: modelList } }); - } catch { - // Model listing failed — non-fatal, extension works without selector - } - session = await client.createSession({ tools: browserTools, systemMessage: { @@ -185,6 +179,15 @@ async function initialize() { }); sendMessage({ type: 'HOST_STATUS', payload: { connected: true } }); + + // Fetch and send available models after session is ready + try { + const models = await client.listModels(); + const modelList = models.map(m => ({ id: m.id, name: m.name || m.id })); + sendMessage({ type: 'AVAILABLE_MODELS', payload: { models: modelList } }); + } catch { + // Model listing failed — non-fatal, extension works without selector + } } catch (error) { sendMessage({ type: 'HOST_STATUS', payload: { connected: false, error: error.message } }); } @@ -198,9 +201,13 @@ async function handleMessage(message) { return; } try { - // Apply model if provided with the message + // Apply model best-effort — don't let failure prevent the message from sending if (message.payload.model) { - await session.setModel(message.payload.model); + try { + await session.setModel(message.payload.model); + } catch { + // Model change failed — proceed with current model + } } await session.send({ prompt: message.payload.content }); } catch (error) { @@ -209,19 +216,6 @@ async function handleMessage(message) { break; } - case 'SET_MODEL': { - if (!session) { - sendMessage({ type: 'CHAT_RESPONSE_ERROR', payload: { error: 'Session not initialized' } }); - return; - } - try { - await session.setModel(message.payload.model); - } catch (error) { - sendMessage({ type: 'CHAT_RESPONSE_ERROR', payload: { error: `Failed to set model: ${error.message}` } }); - } - break; - } - case 'TOOL_CALL_RESULT': { const { toolCallId, result } = message.payload; const pending = pendingToolCalls.get(toolCallId); @@ -266,7 +260,8 @@ sendMessage({ type: 'HOST_STATUS', payload: { connected: false, status: 'initial await initialize(); readMessages((message) => { - handleMessage(message).catch((error) => { + // Serialize all message handling to prevent race conditions + messageQueue = messageQueue.then(() => handleMessage(message)).catch((error) => { sendMessage({ type: 'HOST_STATUS', payload: { connected: false, error: error.message } }); }); }); diff --git a/src/panel/App.tsx b/src/panel/App.tsx index dd16402..424c101 100644 --- a/src/panel/App.tsx +++ b/src/panel/App.tsx @@ -246,7 +246,6 @@ export default function App() { const handleModelChange = useCallback((modelId: string) => { setSelectedModel(modelId); try { localStorage.setItem('copilot-browser-model', modelId); } catch {} - copilotClient.setModel(modelId); }, []); return ( diff --git a/src/panel/lib/copilot-client.ts b/src/panel/lib/copilot-client.ts index 3a38c6c..10d42c4 100644 --- a/src/panel/lib/copilot-client.ts +++ b/src/panel/lib/copilot-client.ts @@ -43,11 +43,6 @@ class CopilotClient { this.send({ type: 'SEND_CHAT_MESSAGE', payload: { content, sessionId, ...(model && { model }) } }); } - // Set the active model - setModel(model: string): void { - this.send({ type: 'SET_MODEL', payload: { model } }); - } - // Cancel current request cancelRequest(sessionId: string): void { this.send({ type: 'CANCEL_REQUEST', payload: { sessionId } }); diff --git a/src/shared/messages.ts b/src/shared/messages.ts index ae14715..5334a31 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -3,7 +3,6 @@ import type { ChatMessage, ToolCall, ToolResult, ConnectionStatus, TabInfo, Mode // Direction: Panel -> Background export type PanelMessage = | { type: 'SEND_CHAT_MESSAGE'; payload: { content: string; sessionId: string; model?: string } } - | { type: 'SET_MODEL'; payload: { model: string } } | { type: 'CANCEL_REQUEST'; payload: { sessionId: string } } | { type: 'GET_CONNECTION_STATUS' } | { type: 'CONNECT_TO_HOST' } @@ -58,5 +57,4 @@ export type NativeMessage = | { type: 'COPILOT_STREAM'; payload: { id: string; chunk: string } } | { type: 'HOST_STATUS'; payload: { connected: boolean; error?: string } } | { type: 'AVAILABLE_MODELS'; payload: { models: ModelInfo[] } } - | { type: 'SET_MODEL'; payload: { model: string } } | { type: 'SEND_CHAT_MESSAGE'; payload: { content: string; model?: string } }; From 943527ec69f347557d8946c16e93e1b30f8ea3d6 Mon Sep 17 00:00:00 2001 From: Dmitriy Nekrasov Date: Mon, 16 Mar 2026 11:58:52 +0100 Subject: [PATCH 5/7] chore: bump version to 0.2.0 and merge Windows fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- manifest.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index 3740181..6a8426d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "GitHub Copilot Browser", - "version": "0.1.0", + "version": "0.2.0", "description": "Your AI copilot for the web", "permissions": [ "sidePanel", diff --git a/package.json b/package.json index 51e3e9e..890309d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "github-copilot-browser", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", From d4f5613cd1a0ff3bde8910920d47bd51f25b4ab3 Mon Sep 17 00:00:00 2001 From: Dmitriy Nekrasov Date: Mon, 16 Mar 2026 16:55:09 +0100 Subject: [PATCH 6/7] feat: stop button + model recommendation tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add stop button (red square) that replaces send button during streaming - Stop finalizes streaming message and sends CANCEL_REQUEST through the stack - CANCEL_REQUEST handled in service-worker → native host → session.cancel() - New ModelRecommendation component analyzes prompt text (debounced) - Classifies prompts as code/complex/simple and recommends optimal model - Tip banner appears above chat input with Switch/Dismiss buttons - Accepting switches model; dismissing hides for current input session - Bump version to 0.3.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- manifest.json | 2 +- package-lock.json | 5 +- package.json | 2 +- src/background/service-worker.ts | 8 + src/host/host.mjs | 13 ++ src/panel/App.tsx | 67 ++++++- src/panel/components/ChatInput.tsx | 50 ++++-- src/panel/components/ModelRecommendation.tsx | 173 +++++++++++++++++++ src/shared/messages.ts | 3 +- 9 files changed, 303 insertions(+), 20 deletions(-) create mode 100644 src/panel/components/ModelRecommendation.tsx diff --git a/manifest.json b/manifest.json index 6a8426d..2abf78a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "GitHub Copilot Browser", - "version": "0.2.0", + "version": "0.3.0", "description": "Your AI copilot for the web", "permissions": [ "sidePanel", diff --git a/package-lock.json b/package-lock.json index a71d989..61f2025 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "github-copilot-browser", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-copilot-browser", - "version": "0.1.0", + "version": "0.2.0", + "hasInstallScript": true, "dependencies": { "@github/copilot-sdk": "^0.1.32", "highlight.js": "^11.11.1", diff --git a/package.json b/package.json index 890309d..ab3af49 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "github-copilot-browser", "private": true, - "version": "0.2.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 67acd48..a90f7dc 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -82,6 +82,14 @@ async function handlePanelMessage(message: PanelMessage, port: chrome.runtime.Po break; } + case 'CANCEL_REQUEST': + try { + nativeMessaging.send({ type: 'CANCEL_REQUEST' }); + } catch { + // Host may not support cancel — ignore + } + break; + case 'EXECUTE_TOOL': break; } diff --git a/src/host/host.mjs b/src/host/host.mjs index faf599e..63805f2 100755 --- a/src/host/host.mjs +++ b/src/host/host.mjs @@ -244,6 +244,19 @@ async function handleMessage(message) { break; } + case 'CANCEL_REQUEST': { + // Best-effort cancel: destroy current session and recreate + if (session) { + try { + await session.cancel?.(); + } catch { + // cancel() may not exist — fall through + } + } + sendMessage({ type: 'CHAT_RESPONSE_COMPLETE', payload: { content: '', done: true } }); + break; + } + case 'TOOL_CALL_RESULT': { const { toolCallId, result } = message.payload; const pending = pendingToolCalls.get(toolCallId); diff --git a/src/panel/App.tsx b/src/panel/App.tsx index 424c101..37c6f4b 100644 --- a/src/panel/App.tsx +++ b/src/panel/App.tsx @@ -3,6 +3,8 @@ import HeaderBar from './components/HeaderBar'; import MessageList from './components/MessageList'; import ChatInput from './components/ChatInput'; import SessionHistory from './components/SessionHistory'; +import ModelRecommendationBanner, { getModelRecommendation } from './components/ModelRecommendation'; +import type { ModelRecommendation } from './components/ModelRecommendation'; import { copilotClient } from './lib/copilot-client'; import * as sessionStorage from './lib/session-storage'; import type { ChatMessage, ConnectionStatus, Session, ModelInfo } from '../shared/types'; @@ -19,6 +21,9 @@ export default function App() { const [selectedModel, setSelectedModel] = useState(() => { try { return localStorage.getItem('copilot-browser-model') || ''; } catch { return ''; } }); + const [modelRecommendation, setModelRecommendation] = useState(null); + const [recommendationDismissed, setRecommendationDismissed] = useState(false); + const recommendationTimer = useRef | null>(null); const initialized = useRef(false); // Initialize on mount @@ -183,6 +188,8 @@ export default function App() { }; setMessages((prev) => [...prev, userMessage]); setIsLoading(true); + setModelRecommendation(null); + setRecommendationDismissed(false); // Persist message if (sessionId) { @@ -245,9 +252,56 @@ export default function App() { const handleModelChange = useCallback((modelId: string) => { setSelectedModel(modelId); + setModelRecommendation(null); + setRecommendationDismissed(false); try { localStorage.setItem('copilot-browser-model', modelId); } catch {} }, []); + const handleStop = useCallback(() => { + setIsLoading(false); + // Finalize any streaming message + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant' && (last as ChatMessage & { isStreaming?: boolean }).isStreaming) { + return prev.map((m, i) => + i === prev.length - 1 ? { ...m, isStreaming: undefined } : m + ); + } + return prev; + }); + // Send cancel to backend + if (sessionId) { + copilotClient.cancelRequest(sessionId); + } + }, [sessionId]); + + const handleInputChange = useCallback((value: string) => { + // Clear previous timer + if (recommendationTimer.current) { + clearTimeout(recommendationTimer.current); + } + // Debounce recommendation analysis (500ms after user stops typing) + if (value.trim().length < 15) { + setModelRecommendation(null); + return; + } + recommendationTimer.current = setTimeout(() => { + if (recommendationDismissed) return; + const rec = getModelRecommendation(value, selectedModel, availableModels); + setModelRecommendation(rec); + }, 600); + }, [selectedModel, availableModels, recommendationDismissed]); + + const handleAcceptRecommendation = useCallback((modelId: string) => { + handleModelChange(modelId); + setModelRecommendation(null); + }, [handleModelChange]); + + const handleDismissRecommendation = useCallback(() => { + setModelRecommendation(null); + setRecommendationDismissed(true); + }, []); + return (
- + + setShowHistory(false)} diff --git a/src/panel/components/ChatInput.tsx b/src/panel/components/ChatInput.tsx index b8a10cd..479f50b 100644 --- a/src/panel/components/ChatInput.tsx +++ b/src/panel/components/ChatInput.tsx @@ -2,10 +2,13 @@ import React, { useState, useRef, useEffect } from 'react'; interface ChatInputProps { onSend: (message: string) => void; + onStop?: () => void; + isLoading?: boolean; disabled?: boolean; + onInputChange?: (value: string) => void; } -export default function ChatInput({ onSend, disabled }: ChatInputProps) { +export default function ChatInput({ onSend, onStop, isLoading, disabled, onInputChange }: ChatInputProps) { const [input, setInput] = useState(''); const textareaRef = useRef(null); @@ -18,9 +21,15 @@ export default function ChatInput({ onSend, disabled }: ChatInputProps) { const handleSubmit = () => { const trimmed = input.trim(); - if (!trimmed || disabled) return; + if (!trimmed || isLoading) return; onSend(trimmed); setInput(''); + onInputChange?.(''); + }; + + const handleChange = (e: React.ChangeEvent) => { + setInput(e.target.value); + onInputChange?.(e.target.value); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -36,7 +45,7 @@ export default function ChatInput({ onSend, disabled }: ChatInputProps) {