diff --git a/.gitignore b/.gitignore index 78f85fdef..912d309a1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ docs/phases/ docs/specs/ PROJECT-STATUS.md .worktrees/ +.spacebot-dev/ diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 6435b3e9c..b42440009 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1653,6 +1653,26 @@ export const api = { } return response.json() as Promise; }, + startCopilotOAuthBrowser: async (params: { model: string }) => { + const response = await fetch(`${getApiBase()}/providers/github-copilot/browser-oauth/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: params.model }), + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, + copilotOAuthBrowserStatus: async (state: string) => { + const response = await fetch( + `${getApiBase()}/providers/github-copilot/browser-oauth/status?state=${encodeURIComponent(state)}`, + ); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, removeProvider: async (provider: string) => { const response = await fetch(`${getApiBase()}/providers/${encodeURIComponent(provider)}`, { method: "DELETE", diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index 25289950b..db1faff34 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -1338,6 +1338,38 @@ export interface paths { patch?: never; trace?: never; }; + "/providers/github-copilot/browser-oauth/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["start_copilot_browser_oauth"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/providers/github-copilot/browser-oauth/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["copilot_browser_oauth_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/providers/openai/browser-oauth/start": { parameters: { query?: never; @@ -2253,6 +2285,22 @@ export interface components { /** Format: float */ emergency_threshold?: number | null; }; + CopilotOAuthBrowserStartRequest: { + model: string; + }; + CopilotOAuthBrowserStartResponse: { + message: string; + state?: string | null; + success: boolean; + user_code?: string | null; + verification_url?: string | null; + }; + CopilotOAuthBrowserStatusResponse: { + done: boolean; + found: boolean; + message?: string | null; + success: boolean; + }; CortexChatDeleteThreadRequest: { agent_id: string; thread_id: string; @@ -3097,6 +3145,7 @@ export interface components { fireworks: boolean; gemini: boolean; github_copilot: boolean; + github_copilot_oauth: boolean; groq: boolean; kilo: boolean; minimax: boolean; @@ -7094,6 +7143,65 @@ export interface operations { }; }; }; + start_copilot_browser_oauth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CopilotOAuthBrowserStartRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CopilotOAuthBrowserStartResponse"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + copilot_browser_oauth_status: { + parameters: { + query: { + /** @description OAuth state parameter */ + state: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CopilotOAuthBrowserStatusResponse"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; start_openai_browser_oauth: { parameters: { query?: never; diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index 261334d0e..f5ea6c209 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -336,6 +336,9 @@ export type OpenAiOAuthBrowserStartResponse = components["schemas"]["OpenAiOAuthBrowserStartResponse"]; export type OpenAiOAuthBrowserStatusResponse = components["schemas"]["OpenAiOAuthBrowserStatusResponse"]; +export type CopilotOAuthBrowserStartRequest = components["schemas"]["CopilotOAuthBrowserStartRequest"]; +export type CopilotOAuthBrowserStartResponse = components["schemas"]["CopilotOAuthBrowserStartResponse"]; +export type CopilotOAuthBrowserStatusResponse = components["schemas"]["CopilotOAuthBrowserStatusResponse"]; // Models export type ModelInfo = components["schemas"]["ModelInfo"]; diff --git a/interface/src/components/Markdown.tsx b/interface/src/components/Markdown.tsx index 6d432ccfa..d03846c26 100644 --- a/interface/src/components/Markdown.tsx +++ b/interface/src/components/Markdown.tsx @@ -1,8 +1,21 @@ +import { memo } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; -export function Markdown({ +// Stable module-level references so ReactMarkdown never re-renders due to +// new array/object identities on every call. +const remarkPlugins = [remarkGfm]; +const rehypePlugins = [rehypeRaw]; +const markdownComponents = { + a: ({ children, href, ...props }: React.ComponentPropsWithoutRef<"a">) => ( + + {children} + + ), +}; + +export const Markdown = memo(function Markdown({ children, className, }: { @@ -12,18 +25,12 @@ export function Markdown({ return (
( - - {children} - - ), - }} + remarkPlugins={remarkPlugins} + rehypePlugins={rehypePlugins} + components={markdownComponents} > {children}
); -} +}); diff --git a/interface/src/components/ModelSelect.tsx b/interface/src/components/ModelSelect.tsx index c44b5173c..66061bd7b 100644 --- a/interface/src/components/ModelSelect.tsx +++ b/interface/src/components/ModelSelect.tsx @@ -32,6 +32,7 @@ const PROVIDER_LABELS: Record = { minimax: "MiniMax", "minimax-cn": "MiniMax CN", "github-copilot": "GitHub Copilot", + "github-copilot-oauth": "GitHub Copilot (OAuth)", }; function formatContextWindow(tokens: number | null): string { @@ -136,6 +137,7 @@ export function ModelSelect({ "openai", "openai-chatgpt", "github-copilot", + "github-copilot-oauth", "ollama", "deepseek", "xai", diff --git a/interface/src/components/WebChatPanel.tsx b/interface/src/components/WebChatPanel.tsx index 15ce094b8..e979fe053 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -96,25 +96,28 @@ function ThinkingIndicator() { ); } +// Input owns its own state so keystrokes never trigger a re-render of the +// parent WebChatPanel (and therefore never re-render the message list). function FloatingChatInput({ - value, - onChange, - onSubmit, + onSend, disabled, agentId, }: { - value: string; - onChange: (value: string) => void; - onSubmit: () => void; + onSend: (message: string) => void; disabled: boolean; agentId: string; }) { const textareaRef = useRef(null); + const [input, setInput] = useState(""); + // Focus on mount. useEffect(() => { textareaRef.current?.focus({preventScroll: true}); }, []); + // Attach the height-adjustment listener once. Using the native "input" event + // avoids adding [value] to the dependency array, which previously caused a + // remove-add cycle (and a forced reflow) on every single keystroke. useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -130,12 +133,26 @@ function FloatingChatInput({ adjustHeight(); textarea.addEventListener("input", adjustHeight); return () => textarea.removeEventListener("input", adjustHeight); - }, [value]); + }, []); + + const handleSubmit = () => { + const trimmed = input.trim(); + if (!trimmed || disabled) return; + onSend(trimmed); + setInput(""); + // React's controlled value update doesn't fire a native "input" event, + // so reset the height directly after clearing. + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = "auto"; + textarea.style.overflowY = "hidden"; + } + }; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); - onSubmit(); + handleSubmit(); } }; @@ -146,8 +163,8 @@ function FloatingChatInput({