Skip to content
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ docs/phases/
docs/specs/
PROJECT-STATUS.md
.worktrees/
.spacebot-dev/
33 changes: 33 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -251,6 +260,7 @@ export type ApiEvent =
| InboundMessageEvent
| OutboundMessageEvent
| OutboundMessageDeltaEvent
| ContextUsageEvent
| TypingStateEvent
| WorkerStartedEvent
| WorkerStatusEvent
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1653,6 +1666,26 @@ export const api = {
}
return response.json() as Promise<Types.OpenAiOAuthBrowserStatusResponse>;
},
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<Types.CopilotOAuthBrowserStartResponse>;
},
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<Types.CopilotOAuthBrowserStatusResponse>;
},
removeProvider: async (provider: string) => {
const response = await fetch(`${getApiBase()}/providers/${encodeURIComponent(provider)}`, {
method: "DELETE",
Expand Down
108 changes: 108 additions & 0 deletions interface/src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3097,6 +3145,7 @@ export interface components {
fireworks: boolean;
gemini: boolean;
github_copilot: boolean;
github_copilot_oauth: boolean;
groq: boolean;
kilo: boolean;
minimax: boolean;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions interface/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
36 changes: 36 additions & 0 deletions interface/src/components/AgentHeaderContent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid h-full w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-6">
<h1 className="min-w-0 truncate font-plex text-sm font-medium text-ink">
{displayName ? (
<>
{displayName}
<span className="ml-2 text-ink-faint">{agentId}</span>
</>
) : (
agentId
)}
</h1>
{contextUsage && contextUsage.estimatedTokens > 0 && (
<div
className={`justify-self-end whitespace-nowrap text-tiny font-mono ${getTokenUsageColor(contextUsage.usageRatio)}`}
title={`${contextUsage.estimatedTokens.toLocaleString()} / ${contextUsage.contextWindow.toLocaleString()} tokens (${(contextUsage.usageRatio * 100).toFixed(1)}%)`}
>
{formatTokens(contextUsage.estimatedTokens)} / {formatTokens(contextUsage.contextWindow)}
</div>
)}
</div>
);
}
11 changes: 6 additions & 5 deletions interface/src/components/CortexChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
onSubmit();
}
Expand Down
29 changes: 18 additions & 11 deletions interface/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -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">) => (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
),
};

export const Markdown = memo(function Markdown({
children,
className,
}: {
Expand All @@ -12,18 +25,12 @@ export function Markdown({
return (
<div className={className ? `markdown ${className}` : "markdown"}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
a: ({ children, href, ...props }) => (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
),
}}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{children}
</ReactMarkdown>
</div>
);
}
});
2 changes: 2 additions & 0 deletions interface/src/components/ModelSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const PROVIDER_LABELS: Record<string, string> = {
minimax: "MiniMax",
"minimax-cn": "MiniMax CN",
"github-copilot": "GitHub Copilot",
"github-copilot-oauth": "GitHub Copilot (OAuth)",
};

function formatContextWindow(tokens: number | null): string {
Expand Down Expand Up @@ -136,6 +137,7 @@ export function ModelSelect({
"openai",
"openai-chatgpt",
"github-copilot",
"github-copilot-oauth",
"ollama",
"deepseek",
"xai",
Expand Down
Loading