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..a8f01c046 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -125,6 +125,15 @@ export interface OutboundMessageDeltaEvent { aggregated_text: string; } +export interface ContextUsageEvent { + type: "context_usage"; + agent_id: string; + channel_id: string; + estimated_tokens: number; + context_window: number; + usage_ratio: number; +} + export interface TypingStateEvent { type: "typing_state"; agent_id: string; @@ -251,6 +260,7 @@ export type ApiEvent = | InboundMessageEvent | OutboundMessageEvent | OutboundMessageDeltaEvent + | ContextUsageEvent | TypingStateEvent | WorkerStartedEvent | WorkerStatusEvent @@ -336,6 +346,9 @@ export interface StatusBlockSnapshot { active_workers: WorkerStatusInfo[]; active_branches: BranchStatusInfo[]; completed_items: CompletedItemInfo[]; + estimated_tokens?: number; + context_window?: number; + usage_ratio?: number; } export interface PromptInspectResponse { @@ -1653,6 +1666,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/AgentHeaderContent.tsx b/interface/src/components/AgentHeaderContent.tsx new file mode 100644 index 000000000..5b6e752e0 --- /dev/null +++ b/interface/src/components/AgentHeaderContent.tsx @@ -0,0 +1,36 @@ +import {formatTokens, getTokenUsageColor} from "@/utils/tokens"; + +interface AgentHeaderContentProps { + agentId: string; + displayName?: string | null; + contextUsage?: { + estimatedTokens: number; + contextWindow: number; + usageRatio: number; + } | null; +} + +export function AgentHeaderContent({agentId, displayName, contextUsage}: AgentHeaderContentProps) { + return ( +
+

+ {displayName ? ( + <> + {displayName} + {agentId} + + ) : ( + agentId + )} +

+ {contextUsage && contextUsage.estimatedTokens > 0 && ( +
+ {formatTokens(contextUsage.estimatedTokens)} / {formatTokens(contextUsage.contextWindow)} +
+ )} +
+ ); +} diff --git a/interface/src/components/CortexChatPanel.tsx b/interface/src/components/CortexChatPanel.tsx index 5ec03b031..6a3d07b97 100644 --- a/interface/src/components/CortexChatPanel.tsx +++ b/interface/src/components/CortexChatPanel.tsx @@ -182,10 +182,9 @@ function CortexChatInput({ }, []); useEffect(() => { - const textarea = textareaRef.current; - if (!textarea) return; - const adjustHeight = () => { + const textarea = textareaRef.current; + if (!textarea) return; textarea.style.height = "auto"; const scrollHeight = textarea.scrollHeight; const maxHeight = 160; @@ -194,12 +193,14 @@ function CortexChatInput({ }; adjustHeight(); + const textarea = textareaRef.current; + if (!textarea) return; textarea.addEventListener("input", adjustHeight); return () => textarea.removeEventListener("input", adjustHeight); - }, [value]); + }, []); const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey) { + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); onSubmit(); } 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..d1ba40c73 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -4,8 +4,10 @@ import {usePortal, getPortalSessionId} from "@/hooks/usePortal"; import {isOpenCodeWorker, type ActiveWorker} from "@/hooks/useChannelLiveState"; import {useLiveContext} from "@/hooks/useLiveContext"; import {Markdown} from "@/components/Markdown"; +import {AgentHeaderContent} from "@/components/AgentHeaderContent"; import {ConversationSettingsPanel} from "@/components/ConversationSettingsPanel"; import {ConversationsSidebar} from "@/components/ConversationsSidebar"; +import {useSetTopBar} from "@/components/TopBar"; import {Button} from "@/ui/Button"; import {Popover, PopoverTrigger, PopoverContent} from "@/ui/Popover"; import {api, type ConversationDefaultsResponse, type ConversationSettings} from "@/api/client"; @@ -96,25 +98,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 +135,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) { + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); - onSubmit(); + handleSubmit(); } }; @@ -146,8 +165,8 @@ function FloatingChatInput({