diff --git a/manifest.json b/manifest.json index 3740181..2abf78a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "GitHub Copilot Browser", - "version": "0.1.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 131bbed..61f2025 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "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": "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 +29,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 +496,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 +4431,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 +4464,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..ab3af49 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "github-copilot-browser", "private": true, - "version": "0.1.0", + "version": "0.3.0", "type": "module", "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/background/service-worker.ts b/src/background/service-worker.ts index d6865aa..a90f7dc 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 }); + } } }); @@ -58,11 +64,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 +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; } @@ -152,6 +166,15 @@ 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': + cachedModels = { models: message.payload.models }; + sendToPanels({ + type: 'AVAILABLE_MODELS', + payload: cachedModels, + }); + break; } }); 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..63805f2 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'; +#!/usr/bin/env node +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. @@ -40,6 +41,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. @@ -125,14 +129,41 @@ 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.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. + } + } 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: [ @@ -176,6 +207,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 } }); } @@ -189,6 +229,14 @@ async function handleMessage(message) { return; } try { + // Apply model best-effort — don't let failure prevent the message from sending + if (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) { sendMessage({ type: 'CHAT_RESPONSE_ERROR', payload: { error: error.message } }); @@ -196,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); @@ -240,7 +301,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 2875599..e501214 100644 --- a/src/panel/App.tsx +++ b/src/panel/App.tsx @@ -3,9 +3,11 @@ 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 } from '../shared/types'; +import type { ChatMessage, ConnectionStatus, Session, ModelInfo } from '../shared/types'; import type { BackgroundMessage } from '../shared/messages'; export default function App() { @@ -15,6 +17,14 @@ 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 [modelRecommendation, setModelRecommendation] = useState(null); + const [recommendationDismissed, setRecommendationDismissed] = useState(false); + const recommendationTimer = useRef | null>(null); + const loadingOffTimer = useRef | null>(null); const initialized = useRef(false); // Initialize on mount @@ -58,6 +68,9 @@ export default function App() { break; case 'CHAT_RESPONSE_CHUNK': { + // Cancel any pending loading-off timer — LLM is still streaming + if (loadingOffTimer.current) clearTimeout(loadingOffTimer.current); + setIsLoading(true); // Accumulate streaming deltas into a message const chunk = message.payload.chunk; setMessages((prev) => { @@ -99,11 +112,16 @@ export default function App() { // No streaming message found, add directly return [...prev, message.payload.message]; }); - setIsLoading(false); + // Defer isLoading=false: if a tool call starts within 500ms, stay loading + if (loadingOffTimer.current) clearTimeout(loadingOffTimer.current); + loadingOffTimer.current = setTimeout(() => { + setIsLoading(false); + }, 500); break; } case 'CHAT_RESPONSE_ERROR': + if (loadingOffTimer.current) clearTimeout(loadingOffTimer.current); setMessages((prev) => [ ...prev, { @@ -117,6 +135,9 @@ export default function App() { break; case 'TOOL_CALL_START': { + // Cancel any pending loading-off timer — workflow is still active + if (loadingOffTimer.current) clearTimeout(loadingOffTimer.current); + setIsLoading(true); // Add tool call as a message with tool call data const { toolCall } = message.payload; setMessages((prev) => [ @@ -152,6 +173,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; + } } }); @@ -168,6 +200,8 @@ export default function App() { }; setMessages((prev) => [...prev, userMessage]); setIsLoading(true); + setModelRecommendation(null); + setRecommendationDismissed(false); // Persist message if (sessionId) { @@ -175,7 +209,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 +228,7 @@ export default function App() { }, 500); } }, - [connectionStatus, sessionId], + [connectionStatus, sessionId, selectedModel], ); const handleConnect = useCallback(() => { @@ -228,6 +262,60 @@ 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(() => { + // Clear any pending loading-off timer + if (loadingOffTimer.current) clearTimeout(loadingOffTimer.current); + 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(true)} + availableModels={availableModels} + selectedModel={selectedModel} + onModelChange={handleModelChange} /> - + + 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) {