chore: snapshot main sync

This commit is contained in:
2026-06-17 20:08:31 +00:00
parent b18de2a331
commit 8bd32537cf
354 changed files with 10208 additions and 9230 deletions

View File

@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
"typecheck": "tsc -b --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@boocode/contracts": "workspace:*",

View File

@@ -11,6 +11,8 @@ import { Analytics } from '@/pages/Analytics';
import { Results } from '@/pages/Results';
import { Memory } from '@/pages/Memory';
import { Control } from '@/pages/Control';
import { ControlProvider } from '@/hooks/useControlStream';
import { ControlErrorBoundary } from '@/components/control/ControlErrorBoundary';
import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner';
import { useUserEvents } from '@/hooks/useUserEvents';
@@ -136,7 +138,7 @@ function AppShell() {
<Route path="/analytics" element={<Analytics />} />
<Route path="/results" element={<Results />} />
<Route path="/memory" element={<Memory />} />
<Route path="/control" element={<Control />} />
<Route path="/control" element={<ControlErrorBoundary><ControlProvider><Control /></ControlProvider></ControlErrorBoundary>} />
</Routes>
</main>
<MobileRightRailBackdrop />

View File

@@ -0,0 +1,20 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { api } from '@/api/client';
describe('api client', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('health fetches from /api/health', async () => {
const mock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 'ok', db: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const result = await api.health();
expect(mock).toHaveBeenCalledWith('/api/health', expect.any(Object));
expect(result).toEqual({ status: 'ok', db: true });
});
});

View File

@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { PROJECT_STATUSES } from '@/api/project-types';
import { CHAT_STATUSES } from '@/api/session-types';
describe('api constants', () => {
it('PROJECT_STATUSES has expected values', () => {
expect(PROJECT_STATUSES).toEqual(['open', 'archived']);
});
it('CHAT_STATUSES has expected values', () => {
expect(CHAT_STATUSES).toEqual(['open', 'archived']);
});
});

View File

@@ -0,0 +1,28 @@
// token-analyzer-ui: aggregate token/cost analytics types.
export interface AnalyticsSummary {
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
session_count: number;
}
export interface SessionAnalyticsRow {
session_id: string;
session_name: string;
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
last_active_at: string | null;
}
export interface ContextWindowStats {
avg_ctx_used: number | null;
avg_ctx_max: number | null;
avg_utilization_pct: number | null;
message_count: number;
}
export interface TokenBreakdownAgg {
category: string;
total_tokens: number;
}

View File

@@ -455,8 +455,6 @@ export const api = {
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
getTask: (taskId: string) =>
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
// Cancel a pending/running coder task (cancels permission wait + inference;
// server sets state='cancelled'). Used by CoderPane's stop button.
cancelTask: (taskId: string) =>
request<{ cancelled: boolean }>(`/api/coder/tasks/${taskId}/cancel`, { method: 'POST' }),
listMessages: (sessionId: string, chatId?: string) =>

View File

@@ -0,0 +1,229 @@
import type { WorkspacePaneKind, WorkspaceTabKind, WorkspacePane, WorkspaceState, ClosedPaneEntry, MarkdownArtifactState, HtmlArtifactState, OrchestratorState } from './session-types.js';
// 'global' = /data/AGENTS.md (always-on), 'project' = per-project override at
// <root>/AGENTS.md. In-code builtins were retired; the seed file lives at
// /data/AGENTS.md.
export type AgentSource = 'global' | 'project';
export interface Agent {
id: string;
name: string;
description: string;
system_prompt: string;
temperature: number;
tools: string[];
model: string | null;
source: AgentSource;
// per-agent tool-loop budget. null means resolve at runtime from the agent's
// toolset (30 for all read-only, 10 otherwise) or 15 for raw chat with no
// agent.
max_tool_calls: number | null;
// per-agent step cap for the outer inference loop. null means bounded only by
// MAX_STEPS (200). 0 means "no tool calls allowed."
steps: number | null;
}
export interface AgentParseError {
agent_name: string;
reason: string;
}
export interface AgentsResponse {
agents: Agent[];
errors: AgentParseError[];
}
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
export interface PermissionPrompt {
taskId: string;
kind?: PermissionKind;
toolTitle?: string;
input?: Record<string, unknown>;
options: Array<{ optionId: string; label: string }>;
}
export interface CoderSendMessageBody {
content: string;
pane_id: string;
chat_id?: string;
provider?: string;
model?: string;
mode_id?: string;
thinking_option_id?: string;
}
export interface CoderSendMessageResponse {
user_message_id?: string;
assistant_message_id?: string;
task_id?: string;
dispatched?: boolean;
}
export interface CoderMessageWire {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
status?: 'streaming' | 'complete' | 'failed';
// model-attribution: which model produced this coder assistant message.
model?: string | null;
reasoning_text?: string;
// Context-window fill for the ContextBar (claude SDK turn sets these from the
// SDK's reported window; other agents omit them). Read via the Message cast.
ctx_used?: number | null;
ctx_max?: number | null;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
}
export interface CoderTaskDetail {
id: string;
state: 'pending' | 'running' | 'completed' | 'failed' | 'blocked' | 'cancelled';
input: string;
output_summary: string | null;
agent: string | null;
model: string | null;
session_id: string | null;
}
export interface SidebarSession {
id: string;
name: string;
model: string;
updated_at: string;
project_id: string;
}
export interface SidebarProject {
id: string;
name: string;
path: string;
gitea_remote: string | null;
recent_sessions: SidebarSession[];
total_sessions: number;
}
export interface SidebarResponse {
projects: SidebarProject[];
}
// skill catalog row. Returned by GET /api/skills and consumed by the
// slash-command dropdown. `path` and `mtime` are exposed for debug surface
// (/api/skills) but the dropdown only renders name + description.
export interface Skill {
name: string;
description: string;
path: string;
mtime: number;
}
// ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] }
// in the same order. AskUserInputCard renders questions and POSTs answers.
export type AskUserQuestionType = 'single_select' | 'multi_select';
export interface AskUserQuestion {
question: string;
type: AskUserQuestionType;
options: string[];
}
export interface AskUserAnswer {
question: string;
selected_options: string[];
free_text: string | null;
}
export interface AskUserAnswerSet {
answers: AskUserAnswer[];
}
// tool traces: per-tool-call record returned by GET /api/chats/:id/traces.
export interface ToolTrace {
id: string;
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: Record<string, unknown>;
tool_output: string | null;
started_at: string;
finished_at: string | null;
latency_ms: number | null;
tokens_used: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
error: string | null;
outcome: string | null;
created_at: string;
}
export interface ToolTraceResponse {
data: ToolTrace[];
total: number;
limit: number;
offset: number;
}
// Orchestrator run API types (returned by GET /api/coder/runs/:id).
export interface FlowRunRow {
id: string;
project_id: string;
flow_name: string;
band: 'small' | 'medium' | 'large';
model: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
input: { question: string; band?: string; [key: string]: unknown };
report: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
export interface FlowStepRow {
id: string;
run_id: string;
step_id: string;
kind: 'agent' | 'code';
agent: string | null;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled';
task_id: string | null;
chat_id: string | null;
session_id: string | null;
input: string | null;
output: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
// Re-export workspace types from session-types for backward compat.
export type {
WorkspacePaneKind,
WorkspaceTabKind,
WorkspacePane,
WorkspaceState,
ClosedPaneEntry,
MarkdownArtifactState,
HtmlArtifactState,
OrchestratorState,
};
// Re-export contract types belonging to the coder domain.
export type { ErrorReason, MessageMetadata, AgentSessionConfig } from '@boocode/contracts/message-metadata';
export type {
ProviderModel,
ProviderMode,
ThinkingOption,
ProviderSnapshotStatus,
AgentCommand,
ProviderSnapshotEntry,
} from '@boocode/contracts/provider-snapshot';
export type {
ProviderOverride,
CoderProvidersFile,
ProviderConfigPatch,
} from '@boocode/contracts/provider-config';

View File

@@ -0,0 +1,6 @@
export type * from './session-types.js';
export type * from './project-types.js';
export type * from './coder-types.js';
export type * from './analytics-types.js';
export type * from './memory-types.js';
export type { WsFrame } from './types.js';

View File

@@ -0,0 +1,17 @@
// Memory browser types
export interface MemoryEntry {
id: string;
topic: string;
title: string;
content: string;
tags: string[];
}
export interface DailyMemoryEntry extends MemoryEntry {
date: string;
}
export interface DreamEntry {
date: string;
content: string;
}

View File

@@ -0,0 +1,82 @@
export const PROJECT_STATUSES = ['open', 'archived'] as const;
export type ProjectStatus = typeof PROJECT_STATUSES[number];
export interface Project {
id: string;
name: string;
path: string;
added_at: string;
last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
// per-project defaults. Empty string on default_system_prompt means
// "no override" — inference falls through to the base system prompt.
default_system_prompt: string;
default_web_search_enabled: boolean;
}
export interface AvailableProject {
path: string;
name: string;
}
// shape returned by GET /api/projects/:id/git. Mirrors services/git_meta.ts
// on the server. branch=null means "not a git repo".
export interface GitMeta {
branch: string | null;
is_dirty: boolean;
ahead: number;
behind: number;
}
// git-diff-panel Phase 1: shapes returned by GET /api/projects/:id/git/diff.
export type GitDiffMode = 'uncommitted' | 'committed';
export type GitDiffChangeType = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked';
export interface GitDiffFile {
path: string;
old_path: string | null;
change_type: GitDiffChangeType;
added_lines: number;
removed_lines: number;
staged: boolean;
diff_body: string | null;
is_binary: boolean;
is_too_large: boolean;
}
export interface GitDiffResult {
git_repo: boolean;
mode: GitDiffMode;
/** Server-computed mode based on dirty state — used for auto-select (FIX 1) and mode suggestion (FIX 4). */
auto_mode?: GitDiffMode;
base_label: string | null;
in_progress_op: string | null;
files: GitDiffFile[];
}
// git-diff-panel Phase 2: per-file info for the discard endpoint.
export interface GitDiscardFileInfo {
path: string;
change_type: GitDiffChangeType;
staged: boolean;
}
export interface FileEntry {
name: string;
kind: 'file' | 'dir';
size?: number;
}
export interface ListDirResult {
entries: FileEntry[];
truncated: boolean;
total: number;
}
export interface ViewFileResult {
content: string;
truncated: boolean;
total_bytes: number;
bytes_returned: number;
}

View File

@@ -0,0 +1,247 @@
import type { MessageMetadata } from '@boocode/contracts/message-metadata';
import type { ArenaState } from '@boocode/contracts/arena';
export const CHAT_STATUSES = ['open', 'archived'] as const;
export type ChatStatus = typeof CHAT_STATUSES[number];
export type SessionStatus = 'open' | 'archived';
export type MessageRole = 'user' | 'assistant' | 'tool' | 'system';
export type MessageStatus = 'streaming' | 'complete' | 'failed' | 'cancelled';
export type MessageKind = 'message' | 'compact';
// per-tool cost rolling-window stat. Returned by
// GET /api/tools/cost_stats — one entry per tool with mean prompt/completion
// tokens over the last 100 invocations. AgentPicker sums across an agent's
// whitelisted tools for per-agent cost hints.
export interface ToolCostStat {
tool_name: string;
mean_prompt_tokens: number;
mean_completion_tokens: number;
n_calls: number;
updated_at: string;
}
export interface Session {
id: string;
project_id: string;
name: string;
model: string;
system_prompt: string;
status: SessionStatus;
created_at: string;
updated_at: string;
agent_id: string | null;
// null = inherit from project.default_web_search_enabled.
web_search_enabled: boolean | null;
// server-authoritative pane layout, replaces localStorage.
// A value may be the legacy bare WorkspacePane[] (older rows) OR the new
// WorkspaceState envelope (panes + tab numbering + reopen stack). Normalize
// on read via useWorkspacePanes' toWorkspaceState.
workspace_panes: WorkspacePane[] | WorkspaceState;
// paths the agent has been granted read access to via the request_read_access
// tool. Empty by default. Settings UI surfaces the list with per-row revoke;
// the grant flow itself appends through the dedicated
// POST /api/chats/:id/grant_read_access endpoint (not PATCH).
allowed_read_paths: string[];
}
export interface Chat {
id: string;
session_id: string;
name: string | null;
model: string | null;
status: ChatStatus;
created_at: string;
updated_at: string;
// Populated by GET /api/sessions/:id/chats only.
message_count?: number;
last_message_preview?: string | null;
effective_context_tokens?: number | null;
// model's full context window from llama-swap /props. Used by
// ContextBar to render the zero-state + auto-compaction threshold tooltip
// before any assistant message exists in the chat. null when upstream
// lookup failed (model unknown, llama-swap unreachable) — UI degrades
// to a "model context unknown" placeholder.
model_context_limit?: number | null;
}
export interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
export interface ToolResult {
tool_call_id: string;
output: unknown;
truncated: boolean;
error?: string;
// unified diff snippet for write-tool results. Present when the tool
// modified files (edit_file, create_file, etc.) and the backend computed a
// diff. Rendered inline by DiffSnippet.
diff?: string;
}
export interface Message {
id: string;
session_id: string;
chat_id: string;
role: MessageRole;
content: string;
kind: MessageKind;
tool_calls: ToolCall[] | null;
tool_results: ToolResult | null;
status: MessageStatus;
last_seq: number;
tokens_used: number | null;
ctx_used: number | null;
ctx_max: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
// model-attribution: which model produced this assistant message (null for
// user/system rows + pre-attribution messages). Rendered as a chip.
model: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
// per-message metadata; see MessageMetadata. null for the vast majority of
// messages.
metadata: MessageMetadata | null;
// reasoning content captured from models that stream reasoning tokens
// separately (qwen3.6 etc.) and from external agents over ACP
// (agent_thought_chunk). Backend populates from message_parts; rendered by
// MessageBubble as a collapsible "Thinking" block.
reasoning_parts?: Array<{ text: string }> | null;
// Coder wire shape pre-joins reasoning_parts into a single string
// (CoderPane/CoderMessageList) and streams it live via reasoning_delta
// frames. MessageBubble reads whichever of the two is present.
reasoning_text?: string | null;
// compare group id. Set when the message is part of a multi-model compare
// response. All assistant messages in the same compare group share this id,
// keyed to the user message that triggered the compare.
compare_group_id?: string;
// anchored rolling compaction fields. Optional on the wire so that older API
// responses (or test fixtures) parse without explicit nulls.
// summary — true on the assistant row that holds the active
// anchored summary. Render via SummaryCard.
// tail_start_id — first preserved tail message the summary covers up to
// (exclusive). Diagnostic only on the client.
// compacted_at — set on rows that are "behind the curtain" of the
// current summary. Returned by the GET endpoint so the
// UI can show history, but the server-side inference
// assembly filters these out.
summary?: boolean;
tail_start_id?: string | null;
compacted_at?: string | null;
}
export interface ModelInfo {
id: string;
[key: string]: unknown;
}
// provider-grouped model catalog (W2, D-4).
export interface ModelCatalogProvider {
id: string;
label: string;
models: ModelInfo[];
}
export interface ModelCatalogResponse {
providers: ModelCatalogProvider[];
}
// Mixed tabs: a pane can hold tabs of different kinds (a BooChat tab next to a
// BooCode tab next to a Terminal tab). Each tab carries its own kind; the active
// tab's kind drives what the pane renders. `tabKinds` is parallel to `chatIds`.
export type WorkspaceTabKind = 'chat' | 'coder' | 'terminal';
// 'settings' is an ephemeral pane kind — never persisted, always singleton per
// workspace. The pane hook filters it out before writing to localStorage and
// dedupes on insertion via toggleSettingsPane().
// 'markdown_artifact' + 'html_artifact' carry payload state on the WorkspacePane
// row itself so useWorkspacePanes's JSON-string dedup + persisted jsonb stay
// self-contained — no extra fetch on rehydrate.
export type WorkspacePaneKind =
| 'chat'
| 'terminal'
| 'coder'
| 'empty'
| 'settings'
| 'markdown_artifact'
| 'html_artifact'
| 'orchestrator'
| 'arena';
// per-pane artifact payloads. Optional + namespaced so older saved pane rows
// (without these fields) deserialize unchanged.
// pane state is a reference only — the pane component fetches the actual content
// on mount. This keeps sessions.workspace_panes jsonb small and makes the
// message body / html_artifact part the single source of truth.
export interface MarkdownArtifactState {
chat_id: string;
message_id: string;
title: string;
}
export interface HtmlArtifactState {
chat_id: string;
message_id: string;
title: string;
}
// Orchestrator pane state — carries run identity for fetch-on-mount + reopen.
export interface OrchestratorState {
run_id: string;
flow_name: string;
band: 'small' | 'medium' | 'large';
}
export interface WorkspacePane {
id: string;
// For a tabbed pane (chat/coder/terminal) this mirrors the ACTIVE tab's kind,
// so the existing render-by-pane.kind path renders the active tab. Special
// panes (empty/settings/artifact) keep their own kind.
kind: WorkspacePaneKind;
chatId?: string;
// Tab ids. For chat/coder tabs this is the chats-row id; for terminal tabs
// it's a generated id used to key the tmux session. Parallel to tabKinds.
chatIds: string[];
// Per-tab kind, parallel to chatIds. Optional for legacy rows (back-filled on
// load from pane.kind via normalizePaneKind).
tabKinds?: WorkspaceTabKind[];
activeChatIdx: number;
// populated only when kind === 'markdown_artifact' / 'html_artifact'.
markdown_artifact_state?: MarkdownArtifactState;
html_artifact_state?: HtmlArtifactState;
// orchestrator pane: populated only when kind === 'orchestrator'.
orchestrator_state?: OrchestratorState;
// arena pane: populated only when kind === 'arena'.
arena_state?: ArenaState;
}
// Reopen LIFO stack entry. Shape unchanged from the prior module-level stack;
// now persisted inside the WorkspaceState envelope so the reopen-pane stack
// survives a reload / cross-device sync.
export interface ClosedPaneEntry {
kind: WorkspacePane['kind'];
chatIds: string[];
tabKinds?: WorkspaceTabKind[];
activeChatIdx: number;
}
// Envelope persisted to sessions.workspace_panes. Supersedes the bare
// WorkspacePane[] shape (still accepted on read for legacy rows — see the
// migration in useWorkspacePanes.toWorkspaceState). The server accepts either
// shape; the frontend always emits this envelope going forward.
export interface WorkspaceState {
panes: WorkspacePane[];
// Stable, session-scoped tab number per chat id. Numbers only ever increase
// and are never reused (retired entries are pruned on tab close).
tabNumbers: { [chatId: string]: number };
// Next number to hand out; starts at 1; ONLY increments.
nextTabNumber: number;
// Reopen LIFO stack, max 10, most-recent last.
closedPaneStack: ClosedPaneEntry[];
}

View File

@@ -1,601 +1,39 @@
export const PROJECT_STATUSES = ['open', 'archived'] as const;
export type ProjectStatus = typeof PROJECT_STATUSES[number];
// v1.13.10: per-tool cost rolling-window stat. Returned by
// GET /api/tools/cost_stats — one entry per tool with mean prompt/completion
// tokens over the last 100 invocations. AgentPicker sums across an agent's
// whitelisted tools for per-agent cost hints.
export interface ToolCostStat {
tool_name: string;
mean_prompt_tokens: number;
mean_completion_tokens: number;
n_calls: number;
updated_at: string;
}
export interface Project {
id: string;
name: string;
path: string;
added_at: string;
last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
// v1.9: per-project defaults. Empty string on default_system_prompt means
// "no override" — inference falls through to the base system prompt.
default_system_prompt: string;
default_web_search_enabled: boolean;
}
export interface AvailableProject {
path: string;
name: string;
}
export type SessionStatus = 'open' | 'archived';
// ── Backward-compat re-exports (all types moved to domain files) ─────────────
export type * from './session-types.js';
export type * from './project-types.js';
export type * from './coder-types.js';
export type * from './analytics-types.js';
export type * from './memory-types.js';
// ── end backward-compat re-exports ──────────────────────────────────────────
// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here.
export type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk';
export interface Session {
id: string;
project_id: string;
name: string;
model: string;
system_prompt: string;
status: SessionStatus;
created_at: string;
updated_at: string;
agent_id: string | null;
// v1.9: null = inherit from project.default_web_search_enabled.
web_search_enabled: boolean | null;
// v1.12.1: server-authoritative pane layout, replaces localStorage.
// A value may be the legacy bare WorkspacePane[] (older rows) OR the new
// WorkspaceState envelope (panes + tab numbering + reopen stack). Normalize
// on read via useWorkspacePanes' toWorkspaceState.
workspace_panes: WorkspacePane[] | WorkspaceState;
// v1.13.17: paths the agent has been granted read access to via the
// request_read_access tool. Empty by default. Settings UI surfaces the
// list with per-row revoke; the grant flow itself appends through the
// dedicated POST /api/chats/:id/grant_read_access endpoint (not PATCH).
allowed_read_paths: string[];
}
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
// override at <root>/AGENTS.md. In-code builtins were retired; the seed file
// lives at /data/AGENTS.md.
export type AgentSource = 'global' | 'project';
export interface Agent {
id: string;
name: string;
description: string;
system_prompt: string;
temperature: number;
tools: string[];
model: string | null;
source: AgentSource;
// v1.8.2: per-agent tool-loop budget. null means resolve at runtime from
// the agent's toolset (30 for all read-only, 10 otherwise) or 15 for raw
// chat with no agent.
max_tool_calls: number | null;
// v1.14.0: per-agent step cap for the outer inference loop. null means
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
steps: number | null;
}
export interface AgentParseError {
agent_name: string;
reason: string;
}
export interface AgentsResponse {
agents: Agent[];
errors: AgentParseError[];
}
export const CHAT_STATUSES = ['open', 'archived'] as const;
export type ChatStatus = typeof CHAT_STATUSES[number];
export interface Chat {
id: string;
session_id: string;
name: string | null;
model: string | null;
status: ChatStatus;
created_at: string;
updated_at: string;
// Populated by GET /api/sessions/:id/chats only.
message_count?: number;
last_message_preview?: string | null;
effective_context_tokens?: number | null;
// v1.11.5: model's full context window from llama-swap /props. Used by
// ContextBar to render the zero-state + auto-compaction threshold tooltip
// before any assistant message exists in the chat. null when upstream
// lookup failed (model unknown, llama-swap unreachable) — UI degrades
// to a "model context unknown" placeholder.
model_context_limit?: number | null;
}
export type MessageRole = 'user' | 'assistant' | 'tool' | 'system';
export type MessageStatus = 'streaming' | 'complete' | 'failed' | 'cancelled';
export type MessageKind = 'message' | 'compact';
export interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
export interface ToolResult {
tool_call_id: string;
output: unknown;
truncated: boolean;
error?: string;
// v2.8: unified diff snippet for write-tool results. Present when the tool
// modified files (edit_file, create_file, etc.) and the backend computed a
// diff. Rendered inline by DiffSnippet.
diff?: string;
}
// v1.8.2 / v1.11.6: ErrorReason + MessageMetadata single-sourced in
// @boocode/contracts — edit the package, not here.
import type { ErrorReason, MessageMetadata } from '@boocode/contracts/message-metadata';
export type { ErrorReason, MessageMetadata };
export interface Message {
id: string;
session_id: string;
chat_id: string;
role: MessageRole;
content: string;
kind: MessageKind;
tool_calls: ToolCall[] | null;
tool_results: ToolResult | null;
status: MessageStatus;
last_seq: number;
tokens_used: number | null;
ctx_used: number | null;
ctx_max: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
// model-attribution: which model produced this assistant message (null for
// user/system rows + pre-attribution messages). Rendered as a chip.
model: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
// v1.8.2: per-message metadata; see MessageMetadata. null for the vast
// majority of messages.
metadata: MessageMetadata | null;
// v1.13.1-C: reasoning content captured from models that stream reasoning
// tokens separately (qwen3.6 etc.) and from external agents over ACP
// (agent_thought_chunk). Backend populates from message_parts; rendered by
// MessageBubble as a collapsible "Thinking" block.
reasoning_parts?: Array<{ text: string }> | null;
// Coder wire shape pre-joins reasoning_parts into a single string
// (CoderPane/CoderMessageList) and streams it live via reasoning_delta
// frames. MessageBubble reads whichever of the two is present.
reasoning_text?: string | null;
// v2.8-compare: compare group id. Set when the message is part of a
// multi-model compare response. All assistant messages in the same compare
// group share this id, keyed to the user message that triggered the compare.
compare_group_id?: string;
// v1.11: anchored rolling compaction fields. Optional on the wire so that
// older API responses (or test fixtures) parse without explicit nulls.
// summary — true on the assistant row that holds the active
// anchored summary. Render via SummaryCard.
// tail_start_id — first preserved tail message the summary covers up to
// (exclusive). Diagnostic only on the client.
// compacted_at — set on rows that are "behind the curtain" of the
// current summary. Returned by the GET endpoint so the
// UI can show history, but the server-side inference
// assembly filters these out.
summary?: boolean;
tail_start_id?: string | null;
compacted_at?: string | null;
}
export interface ModelInfo {
id: string;
[key: string]: unknown;
}
// v2.x: provider-grouped model catalog (W2, D-4).
export interface ModelCatalogProvider {
id: string;
label: string;
models: ModelInfo[];
}
export interface ModelCatalogResponse {
providers: ModelCatalogProvider[];
}
export type {
ProviderModel,
ProviderMode,
ThinkingOption,
ProviderSnapshotStatus,
AgentCommand,
ProviderSnapshotEntry,
} from '@boocode/contracts/provider-snapshot';
export type {
ProviderOverride,
CoderProvidersFile,
ProviderConfigPatch,
} from '@boocode/contracts/provider-config';
// AgentSessionConfig single-sourced in @boocode/contracts — edit the package, not here.
export type { AgentSessionConfig } from '@boocode/contracts/message-metadata';
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
export interface PermissionPrompt {
taskId: string;
kind?: PermissionKind;
toolTitle?: string;
input?: Record<string, unknown>;
options: Array<{ optionId: string; label: string }>;
}
export interface CoderSendMessageBody {
content: string;
pane_id: string;
chat_id?: string;
provider?: string;
model?: string;
mode_id?: string;
thinking_option_id?: string;
}
export interface CoderSendMessageResponse {
user_message_id?: string;
assistant_message_id?: string;
task_id?: string;
dispatched?: boolean;
}
export interface CoderMessageWire {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
status?: 'streaming' | 'complete' | 'failed';
// model-attribution: which model produced this coder assistant message.
model?: string | null;
reasoning_text?: string;
// Context-window fill for the ContextBar (claude SDK turns set these from the
// SDK's reported window; other agents omit them). Read via the Message cast.
ctx_used?: number | null;
ctx_max?: number | null;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
}
export interface CoderTaskDetail {
id: string;
state: 'pending' | 'running' | 'completed' | 'failed' | 'blocked' | 'cancelled';
input: string;
output_summary: string | null;
agent: string | null;
model: string | null;
session_id: string | null;
}
export interface SidebarSession {
id: string;
name: string;
model: string;
updated_at: string;
project_id: string;
}
export interface SidebarProject {
id: string;
name: string;
path: string;
gitea_remote: string | null;
recent_sessions: SidebarSession[];
total_sessions: number;
}
export interface SidebarResponse {
projects: SidebarProject[];
}
export interface FileEntry {
name: string;
kind: 'file' | 'dir';
size?: number;
}
export interface ListDirResult {
entries: FileEntry[];
truncated: boolean;
total: number;
}
export interface ViewFileResult {
content: string;
truncated: boolean;
total_bytes: number;
bytes_returned: number;
}
// v1.8 mobile-tabs: shape returned by GET /api/projects/:id/git. Mirrors
// services/git_meta.ts on the server. branch=null means "not a git repo".
export interface GitMeta {
branch: string | null;
is_dirty: boolean;
ahead: number;
behind: number;
}
// git-diff-panel Phase 1: shapes returned by GET /api/projects/:id/git/diff.
export type GitDiffMode = 'uncommitted' | 'committed';
export type GitDiffChangeType = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked';
export interface GitDiffFile {
path: string;
old_path: string | null;
change_type: GitDiffChangeType;
added_lines: number;
removed_lines: number;
staged: boolean;
diff_body: string | null;
is_binary: boolean;
is_too_large: boolean;
}
export interface GitDiffResult {
git_repo: boolean;
mode: GitDiffMode;
/** Server-computed mode based on dirty state — used for auto-select (FIX 1) and mode suggestion (FIX 4). */
auto_mode?: GitDiffMode;
base_label: string | null;
in_progress_op: string | null;
files: GitDiffFile[];
}
// git-diff-panel Phase 2: per-file info for the discard endpoint.
export interface GitDiscardFileInfo {
path: string;
change_type: GitDiffChangeType;
staged: boolean;
}
// Batch 9.6: skill catalog row. Returned by GET /api/skills and consumed by
// the slash-command dropdown. `path` and `mtime` are exposed for debug surface
// (/api/skills) but the dropdown only renders name + description.
export interface Skill {
name: string;
description: string;
path: string;
mtime: number;
}
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
// same order. AskUserInputCard renders questions and POSTs answers.
export type AskUserQuestionType = 'single_select' | 'multi_select';
export interface AskUserQuestion {
question: string;
type: AskUserQuestionType;
options: string[];
}
export interface AskUserAnswer {
question: string;
selected_options: string[];
free_text: string | null;
}
export interface AskUserAnswerSet {
answers: AskUserAnswer[];
}
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
// singleton per workspace. The pane hook filters it out before writing to
// localStorage and dedupes on insertion via toggleSettingsPane().
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
// Both carry payload state on the WorkspacePane row itself so
// useWorkspacePanes's JSON-string dedup + persisted jsonb stay self-contained
// — no extra fetch on rehydrate.
export type WorkspacePaneKind =
| 'chat'
| 'terminal'
| 'coder'
| 'empty'
| 'settings'
| 'markdown_artifact'
| 'html_artifact'
| 'orchestrator'
| 'arena';
// Mixed tabs: a pane can hold tabs of different kinds (a BooChat tab next to a
// BooCode tab next to a Terminal tab). Each tab carries its own kind; the active
// tab's kind drives what the pane renders. `tabKinds` is parallel to `chatIds`.
export type WorkspaceTabKind = 'chat' | 'coder' | 'terminal';
// v1.14.x: per-pane artifact payloads. Optional + namespaced so older saved
// pane rows (without these fields) deserialize unchanged.
// v1.14.x: pane state is a reference only — the pane component fetches the
// actual content on mount. This keeps sessions.workspace_panes jsonb small and
// makes the message body / html_artifact part the single source of truth.
export interface MarkdownArtifactState {
// chat_id is needed for the download endpoint
// (POST /api/chats/:chat_id/messages/:msg_id/artifacts/download).
chat_id: string;
message_id: string;
title: string;
}
export interface HtmlArtifactState {
chat_id: string;
message_id: string;
title: string;
}
// Orchestrator pane state — carries run identity for fetch-on-mount + reopen.
export interface OrchestratorState {
run_id: string;
flow_name: string;
band: 'small' | 'medium' | 'large';
}
// Arena pane state — single-sourced in @boocode/contracts; edit the package, not here.
// Arena types single-sourced in @boocode/contracts; edit the package, not here.
import type { ArenaState, BattleShape, ContestantShape, CrossExaminationShape, BattleType, BattleStatus, ContestantStatus, ContestantLane } from '@boocode/contracts/arena';
export type { ArenaState, BattleShape, ContestantShape, CrossExaminationShape, BattleType, BattleStatus, ContestantStatus, ContestantLane };
// Orchestrator run API types (returned by GET /api/coder/runs/:id).
export interface FlowRunRow {
id: string;
project_id: string;
flow_name: string;
band: 'small' | 'medium' | 'large';
model: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
input: { question: string; band?: string; [key: string]: unknown };
report: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
export interface FlowStepRow {
id: string;
run_id: string;
step_id: string;
kind: 'agent' | 'code';
agent: string | null;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled';
task_id: string | null;
chat_id: string | null;
session_id: string | null;
input: string | null;
output: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
export interface WorkspacePane {
id: string;
// For a tabbed pane (chat/coder/terminal) this mirrors the ACTIVE tab's kind,
// so the existing render-by-pane.kind path renders the active tab. Special
// panes (empty/settings/artifact) keep their own kind.
kind: WorkspacePaneKind;
chatId?: string;
// Tab ids. For chat/coder tabs this is the chats-row id; for terminal tabs
// it's a generated id used to key the tmux session. Parallel to tabKinds.
chatIds: string[];
// Per-tab kind, parallel to chatIds. Optional for legacy rows (back-filled on
// load from pane.kind via normalizePaneKind).
tabKinds?: WorkspaceTabKind[];
activeChatIdx: number;
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
markdown_artifact_state?: MarkdownArtifactState;
html_artifact_state?: HtmlArtifactState;
// orchestrator pane: populated only when kind === 'orchestrator'.
orchestrator_state?: OrchestratorState;
// arena pane: populated only when kind === 'arena'.
arena_state?: ArenaState;
}
// Reopen LIFO stack entry. Shape unchanged from the prior module-level stack;
// now persisted inside the WorkspaceState envelope so the reopen-pane stack
// survives a reload / cross-device sync.
export interface ClosedPaneEntry {
kind: WorkspacePane['kind'];
chatIds: string[];
tabKinds?: WorkspaceTabKind[];
activeChatIdx: number;
}
// Envelope persisted to sessions.workspace_panes. Supersedes the bare
// WorkspacePane[] shape (still accepted on read for legacy rows — see the
// migration in useWorkspacePanes.toWorkspaceState). The server accepts either
// shape; the frontend always emits this envelope going forward.
export interface WorkspaceState {
panes: WorkspacePane[];
// Stable, session-scoped tab number per chat id. Numbers only ever increase
// and are never reused (retired entries are pruned on tab close).
tabNumbers: { [chatId: string]: number };
// Next number to hand out; starts at 1; ONLY increments.
nextTabNumber: number;
// Reopen LIFO stack, max 10, most-recent last.
closedPaneStack: ClosedPaneEntry[];
}
// ── BooControl fleet frames ─────────────────────────────────────────────────
//
// 2-location sync: contracts (WsFrameSchema + KNOWN_FRAME_TYPES) + web strict
// union only. They skip the server's broker entirely.
export type ControlFleetFrame = {
type: 'control_fleet';
seq: number;
hosts: Array<{
providerId: string;
liveness: 'connected' | 'reconnecting' | 'down';
lastSeenAt: string | null;
seq: number;
models: Array<{
model: string;
state: string;
ts: string;
ttlDeadline: string | null;
inflight: number;
}>;
}>;
};
import type {
ControlFleetFrameType,
ControlActivityFrameType,
ControlPerfFrameType,
ControlLogFrameType,
ControlJobFrameType,
} from '@boocode/contracts/ws-frames';
export type ControlActivityFrame = {
type: 'control_activity';
seq: number;
providerId: string;
entry: {
id: number;
ts: string;
model: string | null;
reqPath: string | null;
statusCode: number | null;
durationMs: number | null;
};
};
export type ControlPerfFrame = {
type: 'control_perf';
seq: number;
providerId: string;
ts: string;
gpu: unknown;
sys: unknown;
};
export type ControlLogFrame = {
type: 'control_log';
seq: number;
providerId: string;
source: 'proxy' | 'upstream' | 'model';
line: string;
};
export type ControlJobFrame = {
type: 'control_job';
seq: number;
jobType: 'bench' | 'eval' | 'action';
jobId: string;
status: 'queued' | 'running' | 'completed' | 'failed';
detail?: Record<string, unknown>;
};
export type ControlFleetFrame = ControlFleetFrameType;
export type ControlActivityFrame = ControlActivityFrameType;
export type ControlPerfFrame = ControlPerfFrameType;
export type ControlLogFrame = ControlLogFrameType;
export type ControlJobFrame = ControlJobFrameType;
// ── end BooControl fleet frames ─────────────────────────────────────────────
import type { Message, ToolCall, MessageRole } from './session-types.js';
import type { ErrorReason, MessageMetadata } from '@boocode/contracts/message-metadata';
export type WsFrame =
| { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole; compare_group_id?: string }
@@ -625,20 +63,20 @@ export type WsFrame =
finished_at?: string | null;
// model-attribution: the model that produced this assistant message.
model?: string | null;
// v1.8.2: piggybacks the persisted metadata onto the terminal frame so
// cap-hit sentinels (and any future stamped-on-complete metadata) flow
// to the client without a refetch.
// piggybacks the persisted metadata onto the terminal frame so cap-hit
// sentinels (and any future stamped-on-complete metadata) flow to the
// client without a refetch.
metadata?: MessageMetadata | null;
// F1 (D-8): terminal status of the assistant message. Absent on the normal
// path (reducer defaults to 'complete'); the BooCoder dispatcher stamps it
// 'cancelled' on a user Stop / stall and 'failed' on a thrown error so the
// reducer renders a muted "Stopped" / failed state — no new frame type.
// terminal status of the assistant message. Absent on the normal path
// (reducer defaults to 'complete'); the BooCoder dispatcher stamps it
// 'cancelled' on a user Stop / stall and 'failed' on a thrown error so
// the reducer renders a muted "Stopped" / failed state — no new frame type.
status?: 'complete' | 'cancelled' | 'failed';
compare_group_id?: string;
}
// v1.12.2: live throughput frame, published mid-stream every ~500ms with
// the latest token + ctx counts so ChatThroughput can render tok/s and
// ctx_used while the model is still generating.
// live throughput frame, published mid-stream every ~500ms with the latest
// token + ctx counts so ChatThroughput can render tok/s and ctx_used while
// the model is still generating.
| {
type: 'usage';
message_id: string;
@@ -657,13 +95,13 @@ export type WsFrame =
mode?: string | null;
turn_number: number;
}
// v1.11: published by services/compaction.ts after the new anchored
// summary row lands. Carries the new summary row id for diagnostics; the
// session-stream handler ignores the id and re-fetches the full message
// list (the cohort of compacted_at-stamped rows changed too).
// published by services/compaction.ts after the new anchored summary row
// lands. Carries the new summary row id for diagnostics; the session-stream
// handler ignores the id and re-fetches the full message list (the cohort of
// compacted_at-stamped rows changed too).
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
// over `error` text when present).
// `reason` discriminates structured failures (the UI prefers it over `error`
// text when present).
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason; compare_group_id?: string }
// agent-status-normalize (#10): BooCoder publishes a normalized per-(chat,agent)
// lifecycle status for external coding agents on the per-session channel. The
@@ -803,78 +241,3 @@ export type WsFrame =
| ControlPerfFrame
| ControlLogFrame
| ControlJobFrame;
// tool traces: per-tool-call record returned by GET /api/chats/:id/traces.
export interface ToolTrace {
id: string;
session_id: string;
chat_id: string;
message_id: string | null;
turn_number: number;
tool_name: string;
tool_input: Record<string, unknown>;
tool_output: string | null;
started_at: string;
finished_at: string | null;
latency_ms: number | null;
tokens_used: number | null;
cache_tokens: number | null;
reasoning_tokens: number | null;
error: string | null;
outcome: string | null;
created_at: string;
}
export interface ToolTraceResponse {
data: ToolTrace[];
total: number;
limit: number;
offset: number;
}
// token-analyzer-ui: aggregate token/cost analytics types.
export interface AnalyticsSummary {
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
session_count: number;
}
export interface SessionAnalyticsRow {
session_id: string;
session_name: string;
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
last_active_at: string | null;
}
export interface ContextWindowStats {
avg_ctx_used: number | null;
avg_ctx_max: number | null;
avg_utilization_pct: number | null;
message_count: number;
}
export interface TokenBreakdownAgg {
category: string;
total_tokens: number;
}
// ── Memory browser types ────────────────────────────────────────────
export interface MemoryEntry {
id: string;
topic: string;
title: string;
content: string;
tags: string[];
}
export interface DailyMemoryEntry extends MemoryEntry {
date: string;
}
export interface DreamEntry {
date: string;
content: string;
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, ShieldAlert, Eye, Brain, Bot, Star } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
@@ -118,6 +119,10 @@ interface PickerProps {
/** Grouped rendering: renders sections with labels (Favorites-first, then
* per-provider). When provided, `options` is ignored. */
groups?: ModelGroup[];
/** When set, each row shows a star toggle; `favorites` marks the filled ones.
* Used by the Model picker to add/remove models from the Favorites section. */
favorites?: Set<string>;
onToggleFavorite?: (id: string) => void;
}
interface ModelGroup {
@@ -125,51 +130,78 @@ interface ModelGroup {
options: Array<{ id: string; label: string }>;
}
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly, flexible, groups }: PickerProps) {
// Star toggle rendered inside a picker row. Stops pointer/click propagation so
// hitting the star favorites the model without selecting it / closing the menu.
function FavoriteStar({ id, isFav, onToggle }: { id: string; isFav: boolean; onToggle: (id: string) => void }) {
return (
<button
type="button"
tabIndex={-1}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle(id);
}}
className="shrink-0 -mr-0.5 p-0.5 rounded hover:bg-foreground/10"
>
<Star className={cn('size-3', isFav ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40')} />
</button>
);
}
// One selectable row in the mobile (BottomSheet) list. Shared by the flat and
// grouped renderers so the two stay identical.
function PickerRow({ o, selected, isFav, onSelect, onToggleFavorite }: {
o: { id: string; label: string };
selected: boolean;
isFav: boolean;
onSelect: (id: string) => void;
onToggleFavorite?: (id: string) => void;
}) {
return (
<button
type="button"
onClick={() => onSelect(o.id)}
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
>
<Check className={cn('size-3 shrink-0', selected ? 'opacity-100' : 'opacity-0')} />
<span className="truncate flex-1">{o.label}</span>
{onToggleFavorite && <FavoriteStar id={o.id} isFav={isFav} onToggle={onToggleFavorite} />}
</button>
);
}
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly, flexible, groups, favorites, onToggleFavorite }: PickerProps) {
const { isMobile } = useViewport();
const [open, setOpen] = useState(false);
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
const isFav = (id: string) => favorites?.has(id) ?? false;
const select = (id: string) => {
onPick(id);
setOpen(false);
};
const flatList = (
<div className="py-1">
{options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => {
onPick(o.id);
setOpen(false);
}}
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
>
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{o.label}</span>
</button>
<PickerRow key={o.id} o={o} selected={o.id === value} isFav={isFav(o.id)} onSelect={select} onToggleFavorite={onToggleFavorite} />
))}
</div>
);
const groupedList = (
<div className="py-1">
{groups!.map((g, gi) => {
{(groups ?? []).map((g, gi) => {
if (g.options.length === 0) return null;
return (
<div key={g.label}>
{gi > 0 && <div className="h-px bg-border mx-2 my-1" />}
<div className="text-[10px] font-medium text-muted-foreground px-2 py-0.5 uppercase tracking-wider">{g.label}</div>
{g.options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => {
onPick(o.id);
setOpen(false);
}}
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
>
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{o.label}</span>
</button>
<PickerRow key={o.id} o={o} selected={o.id === value} isFav={isFav(o.id)} onSelect={select} onToggleFavorite={onToggleFavorite} />
))}
</div>
);
@@ -179,6 +211,15 @@ function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly
const list = groups ? groupedList : flatList;
// Desktop (DropdownMenu) row. Shared by the flat and grouped renderers.
const renderDesktopItem = (o: { id: string; label: string }) => (
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="text-xs gap-2">
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
<span className="truncate flex-1">{o.label}</span>
{onToggleFavorite && <FavoriteStar id={o.id} isFav={isFav(o.id)} onToggle={onToggleFavorite} />}
</DropdownMenuItem>
);
if (isMobile) {
return (
<>
@@ -223,13 +264,20 @@ function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
{options.map((o) => (
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="text-xs">
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
{o.label}
</DropdownMenuItem>
))}
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[180px]">
{groups
? groups.map((g, gi) =>
g.options.length === 0 ? null : (
<Fragment key={g.label}>
{gi > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground py-0.5">
{g.label}
</DropdownMenuLabel>
{g.options.map(renderDesktopItem)}
</Fragment>
),
)
: options.map(renderDesktopItem)}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -310,6 +358,20 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
}).catch(() => { /* settings fetch is best-effort */ });
}, []);
const favoriteSet = useMemo(() => new Set(favoriteModels), [favoriteModels]);
// Toggle a model in/out of the persisted favorites list: optimistic local
// update + best-effort settings PATCH. The Favorites section re-sorts live.
const toggleFavorite = useCallback((id: string) => {
const next = favoriteModels.includes(id)
? favoriteModels.filter((m) => m !== id)
: [...favoriteModels, id];
setFavoriteModels(next);
void api.settings.patch({ [FAVORITE_MODELS_KEY]: next }).catch(() => {
toast.error('Failed to save favorite');
});
}, [favoriteModels]);
useEffect(() => {
hydratedRef.current = false;
}, [projectPath]);
@@ -380,8 +442,6 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
if (models.length === 0) return [];
const favSet = new Set(favoriteModels);
// Build a model map for quick lookup
const modelMap = new Map(models.map((m) => [m.id, m]));
// Group models by provider prefix (the part before the first slash)
@@ -526,6 +586,8 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
disabled={modelGroups ? modelGroups.every((g) => g.options.length === 0) : modelOptions.length === 0}
options={modelOptions}
groups={modelGroups ?? undefined}
favorites={favoriteSet}
onToggleFavorite={modelGroups ? toggleFavorite : undefined}
onPick={pickModel}
icon={<Bot size={13} className="shrink-0" />}
flexible

View File

@@ -20,8 +20,6 @@ import { sessionEvents } from '@/hooks/sessionEvents';
import { useProviderSnapshot } from '@/hooks/useProviderSnapshot';
import { cn } from '@/lib/utils';
// ─── types ────────────────────────────────────────────────────────────────────
type BattleType = 'coding' | 'qa';
interface Contestant {
@@ -30,8 +28,6 @@ interface Contestant {
model: string;
}
// ─── helpers ─────────────────────────────────────────────────────────────────
function newContestant(): Contestant {
return { key: crypto.randomUUID(), identity: '', model: '' };
}
@@ -52,14 +48,10 @@ function localCount(battleType: BattleType, contestants: Contestant[], snapshot:
const boocode = snapshot?.find((e) => e.name === 'boocode');
const localModelIds = new Set(boocode?.models.map((m) => m.id) ?? []);
return contestants.filter((c) => {
// Match bare IDs (boocode/native) and llama-swap/-prefixed IDs used by
// opencode and other external agents pointing at the local llama-swap server.
return localModelIds.has(c.model) || localModelIds.has(c.model.replace(/^llama-swap\//, ''));
}).length;
}
// ─── ContestantRow ────────────────────────────────────────────────────────────
function ContestantRow({
contestant,
battleType,
@@ -154,8 +146,6 @@ function ContestantRow({
);
}
// ─── ArenaLauncherDialog ──────────────────────────────────────────────────────
export function ArenaLauncherDialog() {
const [open, setOpen] = useState(false);
const [projectId, setProjectId] = useState('');

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent, type ReactNode } from 'react';
import { Globe, ListPlus, Paperclip, Send, Square, SquareSlash, Workflow } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
@@ -95,9 +95,13 @@ interface Props {
// yet). Both are optional so older call sites still compile.
messages?: Message[];
modelContextLimit?: number | null;
// Extra controls rendered in the bottom bar, right after the Web button
// (e.g. BooChat's chat-actions ⋯ menu). Optional so CoderPane and other
// call sites that don't supply it render nothing.
composerActions?: ReactNode;
}
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, stopDisabled, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, stopDisabled, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit, composerActions }: Props) {
const { isMobile } = useViewport();
const [value, setValue] = useState('');
const { draft, setDraft, clearDraft } = useDraftPersistence(chatId);
@@ -209,7 +213,6 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
});
}, [chatId]);
// Initialize textarea from saved draft on mount.
useEffect(() => {
if (draft) setValue(draft);
}, [draft]);
@@ -387,7 +390,6 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
}
if (slashState) setSlashState(null);
// Check for @ trigger
if (pos > 0 && newValue[pos - 1] === '@') {
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
if (charBefore === null || charBefore === ' ' || charBefore === '\n') {
@@ -445,11 +447,6 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
const closeMention = useCallback(() => setMentionState(null), []);
// ---- Drag & drop (F1 + F3 + F4) ----------------------------------------
// The drop zone is the outer ChatInput container (ref'd as dropRootRef).
// onDragLeave only clears the highlight when the cursor leaves the
// container, not when it crosses into a child element.
async function processDroppedFile(file: File) {
// Size gate
if (file.size > MAX_FILE_SIZE_BYTES) {
@@ -563,12 +560,6 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
if (disabled || busy) return;
void handleDroppedItems(e.dataTransfer);
}
// ---- end Drag & drop -----------------------------------------------------
// ---- Paste-as-attachment (F2) -------------------------------------------
// Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than
// inline content. Image pastes are rejected with a toast. If both text and
// image are present (e.g. screenshot tool that sets both), prefer text.
function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
const cd = e.clipboardData;
@@ -599,7 +590,6 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
toast.error('Image paste is not supported. Drop a file or paste text.');
}
}
// ---- end Paste-as-attachment --------------------------------------------
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) return;
@@ -762,6 +752,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
<span className="max-md:hidden">Web</span>
</button>
)}
{composerActions}
<div className="flex-1" />
{messages !== undefined && (
<ContextMeter messages={messages} modelContextLimit={modelContextLimit} />

View File

@@ -20,7 +20,6 @@ export function ComparePane({ models, responses, onClose }: Props) {
const panelsRef = useRef<(HTMLDivElement | null)[]>([]);
const isSyncingRef = useRef(false);
// Build a map for quick lookup
const responseMap = new Map<string, CompareResponse>();
for (const r of responses) {
responseMap.set(r.model, r);

View File

@@ -2,11 +2,6 @@ import { useEffect, useRef, useState } from 'react';
import type { Message } from '@/api/types';
import { cn } from '@/lib/utils';
// Circular context-window meter — a small SVG ring (Paseo-style) that lives in
// the composer footer beside the send button. Tap/click toggles a popover with
// the full detail (% used, used/max tokens, optional session cost). Replaces the
// old inline ContextBar (a horizontal bar in the toolbar row above the box).
interface Props {
messages: Message[];
// Zero-state fallback: the model's full context window from

View File

@@ -4,7 +4,6 @@ import { codeToHtml } from 'shiki';
import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
import { cn } from '@/lib/utils';
import { DiffSplitView } from './DiffSplitView';
import { InlineReviewGutterCell } from './InlineReviewGutterCell';
import { InlineReviewEditor } from './InlineReviewEditor';
import { InlineReviewThread } from './InlineReviewThread';
import { useDiffComments } from '@/stores/useDiffCommentStore';

View File

@@ -2,10 +2,8 @@
// in-chat bubble renderer and the MarkdownArtifactPane share the same Shiki +
// remark-gfm + path-linkifier pipeline. Behavior preserved byte-for-byte from
// the original MessageBubble.MarkdownBody helper (and its linkify helpers).
import { memo, Children, cloneElement, isValidElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import type { Components } from 'react-markdown';
import { memo, Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
import Markdown, { type Components } from "react-markdown";
import remarkGfm from 'remark-gfm';
import { CodeBlock } from './CodeBlock';
import { MessageBoundary } from './MessageBoundary';

View File

@@ -76,7 +76,6 @@ function parseRichContent(output: string): ContentSegment[] {
// Inline URLs in text — detect and wrap them
const inlineUrls = trimmed.match(URL_REGEX);
if (inlineUrls) {
// Render as text with linkified URLs
segments.push({ type: 'text', content: trimmed });
} else {
segments.push({ type: 'text', content: line });

View File

@@ -1,5 +1,4 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertCircle, RefreshCw } from 'lucide-react';
interface Props {

View File

@@ -1,5 +1,4 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertCircle } from 'lucide-react';
interface Props {

View File

@@ -27,6 +27,8 @@ interface PickerState {
badges: Record<string, string[]>;
/** P6.1: badge kind -> human label. */
badgeLabels: Record<string, string>;
/** compositeIds of models currently loaded (from llama-swap /running). */
loadedModels: Set<string>;
error: string | null;
}
@@ -52,11 +54,31 @@ async function fetchRoutingBadges(): Promise<{ badges: Record<string, string[]>;
}
}
async function fetchProviderStatus(): Promise<Set<string>> {
try {
const res = await fetch('/api/providers/status');
if (!res.ok) return new Set();
const data = await res.json() as {
providers?: Array<{ running?: Array<{ compositeId: string }> }>;
};
const ids = new Set<string>();
for (const p of data.providers ?? []) {
for (const m of p.running ?? []) {
if (m.compositeId) ids.add(m.compositeId);
}
}
return ids;
} catch {
return new Set();
}
}
async function fetchPickerData(): Promise<PickerState> {
const [catalog, settings, routing] = await Promise.all([
const [catalog, settings, routing, loadedModels] = await Promise.all([
api.models(),
api.settings.get(),
fetchRoutingBadges(),
fetchProviderStatus(),
]);
const raw = settings[FAVORITE_MODELS_KEY];
const favoriteModels = Array.isArray(raw)
@@ -67,6 +89,7 @@ async function fetchPickerData(): Promise<PickerState> {
favoriteModels,
badges: routing.badges,
badgeLabels: routing.badgeLabels,
loadedModels,
error: null,
};
}
@@ -104,6 +127,7 @@ function ModelRow({
id,
isSelected,
isFavorite,
isLoaded,
badges,
badgeLabels,
onPick,
@@ -112,6 +136,7 @@ function ModelRow({
id: string;
isSelected: boolean;
isFavorite: boolean;
isLoaded?: boolean;
badges?: string[];
badgeLabels: Record<string, string>;
onPick: (id: string) => void;
@@ -139,6 +164,11 @@ function ModelRow({
>
<Check className={`size-3 shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
<span className="truncate">{formatModelLabel(id)}</span>
{isLoaded && (
<span className="px-1 py-px text-[10px] leading-none rounded bg-green-500/15 text-green-400 border border-green-500/30 shrink-0">
loaded
</span>
)}
<ModelBadges ids={badges} labels={badgeLabels} />
</button>
</div>
@@ -149,6 +179,7 @@ function ModelSections({
providers,
favoriteModels,
selectedModel,
loadedModels,
badges,
badgeLabels,
onPick,
@@ -157,6 +188,7 @@ function ModelSections({
providers: ModelCatalogProvider[];
favoriteModels: string[];
selectedModel: string | null;
loadedModels: Set<string>;
badges: Record<string, string[]>;
badgeLabels: Record<string, string>;
onPick: (id: string) => void;
@@ -164,7 +196,6 @@ function ModelSections({
}) {
const favSet = useMemo(() => new Set(favoriteModels), [favoriteModels]);
// Build model map for quick lookup
const modelMap = useMemo(() => {
const map = new Map<string, ModelInfo>();
for (const p of providers) {
@@ -185,7 +216,7 @@ function ModelSections({
// The dropdown version uses the primitives directly.
return (
<>
{favoriteModelsInInventory.length > 0 && (
{favoriteModelsInInventory.length > 0 && (
<>
<DropdownMenuLabel>Favorites</DropdownMenuLabel>
{favoriteModelsInInventory.map((id) => (
@@ -200,6 +231,7 @@ function ModelSections({
id={id}
isSelected={selectedModel === id}
isFavorite={favSet.has(id)}
isLoaded={loadedModels.has(id)}
badges={badges[id]}
badgeLabels={badgeLabels}
onPick={onPick}
@@ -228,6 +260,7 @@ function ModelSections({
id={m.id}
isSelected={selectedModel === m.id}
isFavorite={favSet.has(m.id)}
isLoaded={loadedModels.has(m.id)}
badges={badges[m.id]}
badgeLabels={badgeLabels}
onPick={onPick}
@@ -248,6 +281,7 @@ function MobileModelList({
providers,
favoriteModels,
selectedModel,
loadedModels,
badges,
badgeLabels,
onPick,
@@ -256,6 +290,7 @@ function MobileModelList({
providers: ModelCatalogProvider[];
favoriteModels: string[];
selectedModel: string | null;
loadedModels: Set<string>;
badges: Record<string, string[]>;
badgeLabels: Record<string, string>;
onPick: (id: string) => void;
@@ -289,6 +324,7 @@ function MobileModelList({
id={id}
isSelected={selectedModel === id}
isFavorite={favSet.has(id)}
isLoaded={loadedModels.has(id)}
badges={badges[id]}
badgeLabels={badgeLabels}
onPick={onPick}
@@ -310,6 +346,7 @@ function MobileModelList({
id={m.id}
isSelected={selectedModel === m.id}
isFavorite={favSet.has(m.id)}
isLoaded={loadedModels.has(m.id)}
badges={badges[m.id]}
badgeLabels={badgeLabels}
onPick={onPick}
@@ -329,6 +366,8 @@ export function ModelPicker({ value, onChange }: Props) {
const [state, setState] = useState<PickerState | null>(null);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
// D2: live "currently routes to X" for the selected auto:* model.
const [routesTo, setRoutesTo] = useState<string | null>(null);
useEffect(() => {
if (!open || state !== null) return;
fetchPickerData()
@@ -338,6 +377,23 @@ export function ModelPicker({ value, onChange }: Props) {
);
}, [open, state]);
// D2: when an auto:* model is selected, show where the gateway last routed it.
useEffect(() => {
if (!open || !value) { setRoutesTo(null); return; }
const tail = value.includes('/') ? value.slice(value.indexOf('/') + 1) : value;
if (tail !== 'auto' && !tail.startsWith('auto:')) { setRoutesTo(null); return; }
let alive = true;
fetch(`/api/control/policies/dispatch-log?virtualModel=${encodeURIComponent(tail)}`)
.then((r) => (r.ok ? r.json() : null))
.then((d: { dispatches?: Array<{ chosenProviderId: string | null; chosenModel: string | null; status: string }> } | null) => {
if (!alive) return;
const last = d?.dispatches?.find((x) => x.chosenProviderId);
setRoutesTo(last ? `${last.chosenProviderId}/${last.chosenModel}` : null);
})
.catch(() => { if (alive) setRoutesTo(null); });
return () => { alive = false; };
}, [open, value]);
// Reset state when dropdown closes so we re-fetch fresh data next open.
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v);
@@ -403,11 +459,17 @@ export function ModelPicker({ value, onChange }: Props) {
Routing gateway offline this session's <span className="font-mono">{value}</span> model can't route. Pick a concrete model.
</div>
)}
{state && routesTo && (
<div className="px-2 py-1 mb-1 text-[11px] text-emerald-400">
Auto routes to <span className="font-mono">{routesTo}</span> (last dispatch)
</div>
)}
{state && (
<MobileModelList
providers={state.providers}
favoriteModels={state.favoriteModels}
selectedModel={value}
loadedModels={state.loadedModels}
badges={state.badges}
badgeLabels={state.badgeLabels}
onPick={handlePick}
@@ -443,11 +505,17 @@ export function ModelPicker({ value, onChange }: Props) {
Routing gateway offline this session's <span className="font-mono">{value}</span> model can't route. Pick a concrete model.
</div>
)}
{state && routesTo && (
<div className="px-2 py-1 mb-1 text-[11px] text-emerald-400">
Auto routes to <span className="font-mono">{routesTo}</span> (last dispatch)
</div>
)}
{state && (
<ModelSections
providers={state.providers}
favoriteModels={state.favoriteModels}
selectedModel={value}
loadedModels={state.loadedModels}
badges={state.badges}
badgeLabels={state.badgeLabels}
onPick={handlePick}

View File

@@ -11,11 +11,6 @@ interface Props {
busy?: boolean;
}
// ---------------------------------------------------------------------------
// Question detection — ACP's RequestPermissionRequest carries the tool input
// in `input`. Claude Code's AskUserQuestion puts { questions: [...] } there.
// ---------------------------------------------------------------------------
interface Question {
question: string;
header?: string;
@@ -45,11 +40,6 @@ function parseQuestions(input: Record<string, unknown> | undefined): Question[]
return out.length > 0 ? out : null;
}
// ---------------------------------------------------------------------------
// Elicitation detection — ACP's createElicitation carries a JSON Schema in
// `input.requestedSchema`. For now, render each property as a text input.
// ---------------------------------------------------------------------------
interface ElicitationField {
key: string;
title: string;
@@ -139,10 +129,6 @@ export function PermissionCard({ prompt, onRespond, busy }: Props) {
);
}
// ---------------------------------------------------------------------------
// QuestionView — renders Claude's AskUserQuestion as interactive radio/checkbox
// ---------------------------------------------------------------------------
function QuestionView({
questions,
prompt,
@@ -315,10 +301,6 @@ function QuestionView({
);
}
// ---------------------------------------------------------------------------
// ElicitationView — renders ACP elicitation forms (JSON Schema-driven)
// ---------------------------------------------------------------------------
function ElicitationView({
elicitation,
prompt,

View File

@@ -129,7 +129,7 @@ export function SessionLandingPage({
}
}, [onUnarchiveChat, onOpenChat]);
const openChats = [...chats.filter((c) => c.status === 'open')].sort(byRecent);
const openChats = chats.filter((c) => c.status === 'open').sort(byRecent);
const openIds = new Set(openChats.map((c) => c.id));
const archivedChats = archived.filter((c) => !openIds.has(c.id)).sort(byRecent);
const isEmpty = openChats.length === 0 && archivedChats.length === 0;

View File

@@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, ReactNode, RefObject } from 'react';
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { createPortal } from 'react-dom';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';

View File

@@ -7,7 +7,7 @@
import type { ReactNode } from 'react';
import { Bird, Dog, Terminal as TermIcon } from 'lucide-react';
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
import { ClaudeIcon, OpenCodeIcon, ReasonixIcon } from '@/components/icons/ProviderIcons';
import mascot from '@/assets/brand/banner-mascot.png';
/**
@@ -32,6 +32,8 @@ export function providerIcon(name: string | null, size = 13): ReactNode {
return <ClaudeIcon size={size} className="shrink-0" />;
case 'opencode':
return <OpenCodeIcon size={size} className="shrink-0" />;
case 'reasonix':
return <ReasonixIcon size={size} className="shrink-0" />;
case 'goose':
return <Bird size={size} className="shrink-0" />;
case 'qwen':
@@ -60,6 +62,8 @@ export function providerLabel(name: string | null): string {
return 'goose';
case 'qwen':
return 'Qwen';
case 'reasonix':
return 'Reasonix';
default:
return name;
}

View File

@@ -27,11 +27,13 @@ function formatTime(iso: string): string {
}
export function ActivityTab({ requests, providerIds, onOpenCapture }: ActivityTabProps) {
const [paused, setPaused] = useState(false);
const [manualPaused, setManualPaused] = useState(false);
const [hoverPaused, setHoverPaused] = useState(false);
const [scrollPaused, setScrollPaused] = useState(false);
const paused = manualPaused || hoverPaused || scrollPaused;
const [modelFilter, setModelFilter] = useState<string | null>(null);
const [hostFilter, setHostFilter] = useState<string | null>(null);
// Extract unique models from requests
const models = useMemo(() => {
const set = new Set<string>();
for (const r of requests) {
@@ -49,12 +51,8 @@ export function ActivityTab({ requests, providerIds, onOpenCapture }: ActivityTa
}, [requests, modelFilter, hostFilter]);
const handleScroll = useCallback((isAtBottom: boolean) => {
if (!isAtBottom && !paused) {
setPaused(true);
} else if (isAtBottom) {
setPaused(false);
}
}, [paused]);
setScrollPaused(!isAtBottom);
}, []);
const itemContent = useCallback(
(_index: number, entry: ControlRequestEntry) => {
@@ -158,7 +156,7 @@ export function ActivityTab({ requests, providerIds, onOpenCapture }: ActivityTa
{/* Pause toggle */}
<button
type="button"
onClick={() => setPaused((p) => !p)}
onClick={() => setManualPaused((p) => !p)}
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium',
'border border-border/40 transition-colors',
@@ -166,10 +164,10 @@ export function ActivityTab({ requests, providerIds, onOpenCapture }: ActivityTa
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
: 'bg-muted/30 text-muted-foreground hover:text-foreground',
)}
aria-label={paused ? 'Resume follow' : 'Pause follow'}
title={paused ? 'Resume follow' : 'Pause follow'}
aria-label={manualPaused ? 'Resume follow' : 'Pause follow'}
title={manualPaused ? 'Resume follow' : 'Pause follow'}
>
{paused ? <Play className="size-3" /> : <Pause className="size-3" />}
{manualPaused ? <Play className="size-3" /> : <Pause className="size-3" />}
{paused ? 'Paused' : 'Follow'}
</button>
</div>
@@ -181,19 +179,15 @@ export function ActivityTab({ requests, providerIds, onOpenCapture }: ActivityTa
itemContent={itemContent}
followOutput={paused ? undefined : 'bottom' as FollowOutput}
overscan={400}
atBottomStateChange={handleScroll}
components={{
Footer: () => (
<div className="h-2" />
),
}}
className="h-full"
onMouseEnter={() => {
// pause on hover for readability
if (!paused) setPaused(true);
}}
onMouseLeave={() => {
if (paused) setPaused(false);
}}
onMouseEnter={() => setHoverPaused(true)}
onMouseLeave={() => setHoverPaused(false)}
/>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { LineChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components';
import { buildEChartsTheme } from './buildEChartsTheme';
import { useThemeEpoch } from './useThemeEpoch';
import {
Play,
Loader2,
@@ -64,6 +65,13 @@ interface BenchSample {
error: string | null;
}
function parsePositiveIntegerList(value: string): number[] {
return value
.split(',')
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0);
}
export function BenchTab({ providerIds }: BenchTabProps) {
const [view, setView] = useState<'launcher' | 'history' | 'results'>('launcher');
const [suites, setSuites] = useState<BenchSuite[]>([]);
@@ -76,6 +84,7 @@ export function BenchTab({ providerIds }: BenchTabProps) {
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const chartRef = useRef<HTMLDivElement>(null);
const historyChartRef = useRef<HTMLDivElement>(null);
const themeEpoch = useThemeEpoch();
// Suite form state
const [suiteName, setSuiteName] = useState('');
@@ -104,13 +113,13 @@ export function BenchTab({ providerIds }: BenchTabProps) {
if (view === 'history' && historyChartRef.current && runs.length > 0) {
renderHistoryChart();
}
}, [view, runs]);
}, [view, runs, themeEpoch]);
useEffect(() => {
if (view === 'results' && chartRef.current && selectedRun && samples.length > 0) {
renderResultsChart();
}
}, [view, selectedRun, samples]);
}, [view, selectedRun, samples, themeEpoch]);
const loadSuites = useCallback(async () => {
try {
@@ -123,14 +132,16 @@ export function BenchTab({ providerIds }: BenchTabProps) {
}
}, []);
const loadRuns = useCallback(async () => {
const loadRuns = useCallback(async (): Promise<BenchRun[] | null> => {
try {
const res = await fetch('/api/control/bench/runs');
if (!res.ok) return;
if (!res.ok) return null;
const data = await res.json() as { runs: BenchRun[] };
setRuns(data.runs);
return data.runs;
} catch {
// silent
return null;
}
}, []);
@@ -147,23 +158,40 @@ export function BenchTab({ providerIds }: BenchTabProps) {
}
}, []);
const createSuite = async () => {
const promptTokens = suitePromptTokens.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n));
const genTokens = suiteGenTokens.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n));
const concurrency = suiteConcurrency.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n));
const repetitions = parseInt(suiteRepetitions) || 1;
const suiteValidation = useMemo(() => {
const promptTokens = parsePositiveIntegerList(suitePromptTokens);
const genTokens = parsePositiveIntegerList(suiteGenTokens);
const concurrency = parsePositiveIntegerList(suiteConcurrency);
const repetitions = Number.parseInt(suiteRepetitions, 10);
if (!suiteName || !suiteProvider || !suiteModel) return;
if (!promptTokens.length || !genTokens.length || !concurrency.length) return;
if (!suiteName.trim()) return { reason: 'Name is required', promptTokens, genTokens, concurrency, repetitions: 1 };
if (!suiteProvider) return { reason: 'Provider is required', promptTokens, genTokens, concurrency, repetitions: 1 };
if (!suiteModel.trim()) return { reason: 'Model is required', promptTokens, genTokens, concurrency, repetitions: 1 };
if (!promptTokens.length) return { reason: 'Prompt tokens must include at least one positive number', promptTokens, genTokens, concurrency, repetitions: 1 };
if (!genTokens.length) return { reason: 'Gen tokens must include at least one positive number', promptTokens, genTokens, concurrency, repetitions: 1 };
if (!concurrency.length) return { reason: 'Concurrency must include at least one positive number', promptTokens, genTokens, concurrency, repetitions: 1 };
if (!Number.isFinite(repetitions) || repetitions < 1) return { reason: 'Repetitions must be 1 or greater', promptTokens, genTokens, concurrency, repetitions: 1 };
return { reason: null, promptTokens, genTokens, concurrency, repetitions };
}, [suiteConcurrency, suiteGenTokens, suiteModel, suiteName, suitePromptTokens, suiteProvider, suiteRepetitions]);
const activeRun = useMemo(() => {
return runs.find((run) => run.status !== 'completed' && run.status !== 'failed') ?? null;
}, [runs]);
const createSuite = async () => {
if (suiteValidation.reason) return;
const { promptTokens, genTokens, concurrency, repetitions } = suiteValidation;
try {
const res = await fetch('/api/control/bench/suite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: suiteName,
name: suiteName.trim(),
providerId: suiteProvider,
model: suiteModel,
model: suiteModel.trim(),
promptTokens,
genTokens,
concurrency,
@@ -200,8 +228,8 @@ export function BenchTab({ providerIds }: BenchTabProps) {
// Poll for completion
pollRef.current = setInterval(async () => {
await loadRuns();
const latestRun = runs[0];
const freshRuns = await loadRuns();
const latestRun = freshRuns?.[0];
if (latestRun && (latestRun.status === 'completed' || latestRun.status === 'failed')) {
if (pollRef.current) {
clearInterval(pollRef.current);
@@ -232,7 +260,7 @@ export function BenchTab({ providerIds }: BenchTabProps) {
}
}, []);
const [baselines, setBaselines] = useState<Array<{ providerId: string; model: string; aggregate: Record<string, unknown> | null }>>([]);
const [_baselines, setBaselines] = useState<Array<{ providerId: string; model: string; aggregate: Record<string, unknown> | null }>>([]);
useEffect(() => {
loadBaselines().then((d) => setBaselines(d?.baselines ?? []));
@@ -497,16 +525,30 @@ export function BenchTab({ providerIds }: BenchTabProps) {
/>
</div>
</div>
<div className="mt-2 text-[11px] text-muted-foreground">
Use comma-separated positive numbers, for example 256,512,1024.
</div>
<button
type="button"
onClick={createSuite}
disabled={!suiteName || !suiteProvider || !suiteModel}
className="mt-3 px-3 py-1.5 bg-accent/20 text-accent rounded text-sm hover:bg-accent/30 disabled:opacity-50 transition-colors"
disabled={suiteValidation.reason != null}
className="mt-2 px-3 py-1.5 bg-accent/20 text-accent rounded text-sm hover:bg-accent/30 disabled:opacity-50 transition-colors"
title={suiteValidation.reason ?? 'Create suite'}
>
Create Suite
</button>
{suiteValidation.reason && (
<div className="mt-1 text-[11px] text-amber-400">{suiteValidation.reason}</div>
)}
</div>
{activeRun && (
<div className="mb-3 flex items-center justify-between rounded-md border border-accent/20 bg-accent/10 px-3 py-2 text-xs text-accent">
<span>Bench run in progress</span>
<span className="font-mono">{activeRun.completedSamples}/{activeRun.totalSamples} samples</span>
</div>
)}
{/* Existing suites */}
<div className="space-y-2">
{suites.map((suite) => (

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { X, ExternalLink, Copy } from 'lucide-react';
import { X, Copy } from 'lucide-react';
import { codeToHtml } from 'shiki';
interface CaptureDrawerProps {
@@ -30,6 +30,14 @@ export function CaptureDrawer({ requestId, providerId, onClose }: CaptureDrawerP
const [highlightedReq, setHighlightedReq] = useState('');
const [highlightedResp, setHighlightedResp] = useState('');
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
let cancelled = false;
async function fetchCapture() {
@@ -91,11 +99,16 @@ export function CaptureDrawer({ requestId, providerId, onClose }: CaptureDrawerP
if (loading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background border border-border rounded-lg p-6 w-[80vw] max-w-4xl max-h-[80vh]">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-background border border-border rounded-lg p-6 w-[80vw] max-w-4xl max-h-[80vh]"
role="dialog"
aria-modal="true"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Loading capture...</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close capture">
<X className="size-4" />
</button>
</div>
@@ -109,11 +122,16 @@ export function CaptureDrawer({ requestId, providerId, onClose }: CaptureDrawerP
if (error) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background border border-border rounded-lg p-6 w-[80vw] max-w-4xl max-h-[80vh]">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-background border border-border rounded-lg p-6 w-[80vw] max-w-4xl max-h-[80vh]"
role="dialog"
aria-modal="true"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-red-400">Capture Error</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close capture">
<X className="size-4" />
</button>
</div>
@@ -126,8 +144,13 @@ export function CaptureDrawer({ requestId, providerId, onClose }: CaptureDrawerP
if (!capture) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background border border-border rounded-lg w-[80vw] max-w-4xl max-h-[80vh] flex flex-col">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-background border border-border rounded-lg w-[80vw] max-w-4xl max-h-[80vh] flex flex-col"
role="dialog"
aria-modal="true"
onClick={(event) => event.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div>
@@ -137,15 +160,8 @@ export function CaptureDrawer({ requestId, providerId, onClose }: CaptureDrawerP
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs border border-border/40 text-muted-foreground hover:text-foreground transition-colors"
title="Open in Playground (P3)"
>
<ExternalLink className="size-3" />
Open in Playground
</button>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
{/* C3: "Open in Playground" was a dead button (no handler); hidden until wired. */}
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close capture">
<X className="size-4" />
</button>
</div>

View File

@@ -0,0 +1,38 @@
import { Component, type ReactNode } from 'react';
/**
* Route-level error boundary for /control. A render-time throw anywhere in the
* cockpit subtree (a bad WS frame shape, a chart blowing up) degrades to a
* message instead of unwinding to the root and blank-screening the whole app
* (the failure class the project's CLAUDE.md repeatedly warns about).
*/
interface State {
error: Error | null;
}
export class ControlErrorBoundary extends Component<{ children: ReactNode }, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
override render(): ReactNode {
if (this.state.error) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-8 text-center">
<p className="text-sm font-medium text-foreground">The control cockpit hit a render error.</p>
<p className="text-xs text-muted-foreground max-w-md break-words">{this.state.error.message}</p>
<button
type="button"
onClick={() => this.setState({ error: null })}
className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90"
>
Retry
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import * as echarts from 'echarts/core';
import { ScatterChart, BarChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
@@ -92,11 +93,17 @@ async function runEval(suiteId: string, providerId: string, model: string): Prom
export function EvalsTab({ providerIds }: EvalsTabProps) {
const [suites, setSuites] = useState<EvalSuite[]>([]);
const [runs, setRuns] = useState<EvalRun[]>([]);
// AUD6: O(1) suite lookup per run row instead of suites.find() in the render map.
const suiteMap = useMemo(() => new Map(suites.map((s) => [s.id, s])), [suites]);
// AUD5: controlled launcher inputs (was document.getElementById).
const [launchSuite, setLaunchSuite] = useState('');
const [launchProvider, setLaunchProvider] = useState('');
const [launchModel, setLaunchModel] = useState('');
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'leaderboard' | 'runs' | 'scatter'>('leaderboard');
const [suiteFilter, setSuiteFilter] = useState<string>('all');
const [_suiteFilter, _setSuiteFilter] = useState<string>('all');
const [kindFilter, setKindFilter] = useState<string>('all');
const scatterRef = useRef<HTMLDivElement>(null);
const barRef = useRef<HTMLDivElement>(null);
@@ -257,8 +264,10 @@ export function EvalsTab({ providerIds }: EvalsTabProps) {
setRunning(key);
try {
await runEval(suiteId, providerId, model);
toast.success(`Eval queued — watch the Jobs tab`);
} catch (err) {
console.error('eval: run failed', err);
// C2: surface the failure instead of swallowing it to the console.
toast.error(err instanceof Error ? err.message : 'eval run failed');
} finally {
setRunning(null);
}
@@ -355,7 +364,8 @@ export function EvalsTab({ providerIds }: EvalsTabProps) {
<h3 className="text-sm font-medium mb-2">Launch Eval</h3>
<div className="flex flex-wrap gap-2">
<select
id="eval-suite"
value={launchSuite || (suites[0]?.id ?? '')}
onChange={(e) => setLaunchSuite(e.target.value)}
className="text-xs bg-background border border-border rounded-md px-2 py-1"
>
{suites.map((s) => (
@@ -363,7 +373,8 @@ export function EvalsTab({ providerIds }: EvalsTabProps) {
))}
</select>
<select
id="eval-provider"
value={launchProvider || (providerIds[0] ?? '')}
onChange={(e) => setLaunchProvider(e.target.value)}
className="text-xs bg-background border border-border rounded-md px-2 py-1"
>
{providerIds.map((pid) => (
@@ -371,22 +382,26 @@ export function EvalsTab({ providerIds }: EvalsTabProps) {
))}
</select>
<input
id="eval-model"
value={launchModel}
onChange={(e) => setLaunchModel(e.target.value)}
placeholder="Model ID"
className="text-xs bg-background border border-border rounded-md px-2 py-1 flex-1 min-w-[120px]"
/>
<button
disabled={running !== null}
onClick={async () => {
const suiteId = (document.getElementById('eval-suite') as HTMLSelectElement).value;
const providerId = (document.getElementById('eval-provider') as HTMLSelectElement).value;
const model = (document.getElementById('eval-model') as HTMLInputElement).value;
if (suiteId && providerId && model) {
await handleRunEval(suiteId, providerId, model);
const suiteId = launchSuite || suites[0]?.id || '';
const providerId = launchProvider || providerIds[0] || '';
const model = launchModel.trim();
if (!suiteId || !providerId || !model) {
toast.error('Pick a suite, provider, and model first');
return;
}
await handleRunEval(suiteId, providerId, model);
}}
className="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
className="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
<Play className="size-3" />
{running !== null ? <Loader2 className="size-3 animate-spin" /> : <Play className="size-3" />}
Run
</button>
</div>
@@ -409,7 +424,7 @@ export function EvalsTab({ providerIds }: EvalsTabProps) {
</thead>
<tbody>
{runs.map((run) => {
const suite = suites.find((s) => s.id === run.suiteId);
const suite = suiteMap.get(run.suiteId);
return (
<tr key={run.id} className="border-b border-border/20 hover:bg-muted/20">
<td className="py-2 px-3 font-mono">{run.id.slice(0, 16)}</td>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { AnimatePresence } from 'framer-motion';
import { Settings2 } from 'lucide-react';
import { ControlFleetHost } from '@/hooks/useControlStream';
import { ControlFleetHost, ControlPerfSample, ControlConnection } from '@/hooks/useControlStream';
import { HostCard } from './HostCard';
import { HostConfigEditor } from './HostConfigEditor';
@@ -15,15 +15,23 @@ export interface GpuData {
interface FleetTabProps {
hosts: ControlFleetHost[];
gpuMap: Map<string, GpuData>;
perfSamples?: ControlPerfSample[];
connection?: ControlConnection;
}
export function FleetTab({ hosts, gpuMap }: FleetTabProps) {
export function FleetTab({ hosts, gpuMap, perfSamples = [], connection = 'connecting' }: FleetTabProps) {
const [editing, setEditing] = useState<string | null>(null);
if (hosts.length === 0) {
// B3: distinguish "not connected" from a genuinely empty fleet.
const msg = connection === 'live'
? 'No hosts connected'
: connection === 'down'
? 'Control service unreachable — retrying…'
: 'Connecting to control service…';
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">No hosts connected</p>
<p className="text-sm text-muted-foreground">{msg}</p>
</div>
);
}
@@ -41,7 +49,7 @@ export function FleetTab({ hosts, gpuMap }: FleetTabProps) {
>
<Settings2 className="size-4" />
</button>
<HostCard host={host} gpuData={gpuMap.get(host.providerId) ?? null} />
<HostCard host={host} gpuData={gpuMap.get(host.providerId) ?? null} perfSamples={perfSamples} />
</div>
))}
</AnimatePresence>

View File

@@ -1,18 +1,30 @@
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
import { ControlFleetHost } from '@/hooks/useControlStream';
import { useState, useMemo } from 'react';
import { ControlFleetHost, ControlPerfSample } from '@/hooks/useControlStream';
import { useReducedMotion } from '@/hooks/useReducedMotion';
import { VramGauge } from './VramGauge';
import { TtlRing } from './TtlRing';
import { PerfChart } from './PerfChart';
import { cn } from '@/lib/utils';
import type { GpuData } from './FleetTab';
import { Play, Eraser } from 'lucide-react';
import { Play, Eraser, Check, Loader2, AlertTriangle, Circle, ChevronDown, ChevronRight } from 'lucide-react';
interface HostCardProps {
host: ControlFleetHost;
gpuData: GpuData | null;
perfSamples?: ControlPerfSample[];
}
// C1: redundant (non-color) signifier per model state for color-blind users.
const STATE_ICON: Record<string, typeof Check> = {
ready: Check,
starting: Loader2,
stopping: Loader2,
error: AlertTriangle,
stopped: Circle,
down: Circle,
};
const STATE_COLORS: Record<string, { bg: string; glowVar: string; animate: boolean }> = {
starting: { bg: 'bg-amber-500', glowVar: '--glow-amber', animate: true },
ready: { bg: 'bg-green-500', glowVar: '--glow-green', animate: false },
@@ -40,7 +52,6 @@ function relTime(iso: string | null): string {
function livenessLabel(state: string): string {
switch (state) {
case 'connected': return 'connected';
case 'reconnecting': return 'reconnecting';
case 'down': return 'down';
default: return state;
}
@@ -50,11 +61,33 @@ function getGlowColor(glowVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(glowVar).trim();
}
export function HostCard({ host, gpuData }: HostCardProps) {
export function HostCard({ host, gpuData, perfSamples = [] }: HostCardProps) {
const reducedMotion = useReducedMotion();
const livenessKey = host.liveness === 'connected' ? 'ready' : host.liveness === 'reconnecting' ? 'starting' : host.liveness;
const [showPerf, setShowPerf] = useState(false);
// B2: build perf history series for this host from buffered samples.
const perf = useMemo(() => {
const mine = perfSamples.filter((s) => s.providerId === host.providerId).slice(-120);
const timestamps = mine.map((s) => s.ts);
const num = (g: unknown, k: string): number => {
const v = (g as Record<string, unknown> | null)?.[k];
return typeof v === 'number' ? v : 0;
};
return {
timestamps,
hasData: mine.length > 1,
series: [
{ name: 'VRAM MB', data: mine.map((s) => num(s.gpu, 'vram_used')), color: '#60a5fa' },
{ name: 'Temp C', data: mine.map((s) => num(s.gpu, 'temperature')), color: '#f87171' },
{ name: 'Power W', data: mine.map((s) => num(s.gpu, 'power')), color: '#fbbf24' },
],
};
}, [perfSamples, host.providerId]);
const livenessKey = host.liveness === 'connected' ? 'ready' : host.liveness;
const stateConfig = STATE_COLORS[livenessKey] ?? FALLBACK_STATE;
const glowColor = getGlowColor(stateConfig.glowVar);
// AUD1: getComputedStyle is a forced style read; memoize so it doesn't fire on
// every WS-delta re-render (only the theme token changes it, which is rare).
const glowColor = useMemo(() => getGlowColor(stateConfig.glowVar), [stateConfig.glowVar]);
const vramUsed = gpuData?.vram_used ?? 0;
const vramTotal = gpuData?.vram_total ?? 0;
@@ -110,9 +143,16 @@ export function HostCard({ host, gpuData }: HostCardProps) {
</span>
)}
<span className="text-[10px] text-muted-foreground ml-auto font-mono">
seq {host.seq}
</span>
{perf.hasData && (
<button
type="button"
onClick={() => setShowPerf((v) => !v)}
className="text-[10px] text-muted-foreground ml-auto inline-flex items-center gap-0.5 hover:text-foreground"
>
{showPerf ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
perf
</button>
)}
</div>
<div className="flex flex-col lg:flex-row gap-4">
@@ -146,7 +186,7 @@ export function HostCard({ host, gpuData }: HostCardProps) {
<div className="flex flex-wrap gap-2">
<AnimatePresence mode="popLayout">
{host.models.map((m) => (
<ModelChip key={`${m.model}-${m.state}`} model={m} />
<ModelChip key={`${m.model}-${m.state}`} model={m} providerId={host.providerId} />
))}
</AnimatePresence>
</div>
@@ -171,6 +211,13 @@ export function HostCard({ host, gpuData }: HostCardProps) {
)}
</div>
</div>
{/* B2: perf history (collapsible) */}
{showPerf && perf.hasData && (
<div className="mt-4 border-t border-border/30 pt-3">
<PerfChart series={perf.series} timestamps={perf.timestamps} height={180} />
</div>
)}
</motion.div>
);
}
@@ -196,11 +243,13 @@ interface ModelChipProps {
ttlDeadline: string | null;
inflight: number;
};
providerId: string;
}
function ModelChip({ model }: ModelChipProps) {
function ModelChip({ model, providerId }: ModelChipProps) {
const reducedMotion = useReducedMotion();
const stateConfig = STATE_COLORS[model.state] ?? FALLBACK_STATE;
const StateIcon = STATE_ICON[model.state] ?? Circle;
const spin = model.state === 'starting' || model.state === 'stopping';
const [actionError, setActionError] = useState<string | null>(null);
const [confirmUnload, setConfirmUnload] = useState(false);
@@ -211,7 +260,7 @@ function ModelChip({ model }: ModelChipProps) {
const res = await fetch('/api/control/action/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'warm', providerId: model.model.split(':')[0], model: model.model }),
body: JSON.stringify({ type: 'warm', providerId, model: model.model }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -231,7 +280,7 @@ function ModelChip({ model }: ModelChipProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'unload',
providerId: model.model.split(':')[0],
providerId,
model: model.model,
confirmed,
}),
@@ -266,18 +315,23 @@ function ModelChip({ model }: ModelChipProps) {
exit={reducedMotion ? undefined : { scale: 0.8, opacity: 0 }}
transition={reducedMotion ? undefined : { type: 'spring', stiffness: 400, damping: 20 }}
className={cn(
'inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
'relative inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
'border border-border/40 bg-muted/30',
'font-medium',
)}
>
<span
<StateIcon
aria-label={model.state}
className={cn(
'w-1.5 h-1.5 rounded-full shrink-0',
stateConfig.bg,
'size-3 shrink-0',
spin && 'animate-spin',
model.state === 'ready' && 'text-green-400',
model.state === 'error' && 'text-red-400',
(model.state === 'starting' || model.state === 'stopping') && 'text-amber-400',
(model.state === 'stopped' || model.state === 'down') && 'text-gray-400',
)}
/>
<span className="truncate max-w-[160px]">{model.model}</span>
<span className="truncate max-w-[160px]" title={`${model.model} (${model.state})`}>{model.model}</span>
{model.inflight > 0 && (
<span className="text-[10px] text-muted-foreground ml-0.5">
({model.inflight})

View File

@@ -37,6 +37,14 @@ export function HostConfigEditor({ providerId, onClose }: { providerId: string;
const [pullRepo, setPullRepo] = useState('');
const [pullMsg, setPullMsg] = useState<string | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const loadHost = useCallback(async () => {
const res = await fetch('/api/control/hosts');
const data = await res.json() as { hosts: HostInfo[] };
@@ -116,7 +124,7 @@ export function HostConfigEditor({ providerId, onClose }: { providerId: string;
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo }),
});
const data = await res.json() as { jobId?: string; error?: string };
setPullMsg(res.ok ? `queued (job ${data.jobId}) — watch Reports/Logs for progress` : (data.error ?? `failed: ${res.status}`));
setPullMsg(res.ok ? `queued (job ${data.jobId}) - track it in the Jobs tab` : (data.error ?? `failed: ${res.status}`));
} finally { setBusy(null); }
};
@@ -124,11 +132,13 @@ export function HostConfigEditor({ providerId, onClose }: { providerId: string;
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-background border border-border rounded-lg w-[min(900px,92vw)] max-h-[88vh] flex flex-col"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-2 border-b border-border/40">
<h2 className="text-sm font-medium">SSH config {providerId}</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground" aria-label="Close config editor"><X className="size-4" /></button>
</div>
<div className="flex-1 overflow-auto p-4 space-y-4 min-h-0">

View File

@@ -0,0 +1,80 @@
import { Loader2, CheckCircle2, XCircle, Clock, Gauge, Brain, Download, Wrench } from 'lucide-react';
import type { ControlJob } from '@/hooks/useControlStream';
/**
* B1: Unified Job Center. Surfaces every control_job (bench / eval / pull /
* action) the operator initiated, with live status — so pull/bench/eval are no
* longer black holes. Fed by the already-buffered fleet.jobs (deduped by jobId).
*/
export function JobsTab({ jobs }: { jobs: ControlJob[] }) {
// Newest first.
const ordered = [...jobs].sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
if (ordered.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">No jobs yet. Bench, eval, and model-pull runs appear here live.</p>
</div>
);
}
return (
<div className="flex-1 overflow-auto p-4">
<div className="space-y-2">
{ordered.map((job) => (
<JobRow key={job.jobId} job={job} />
))}
</div>
</div>
);
}
function kindIcon(job: ControlJob) {
const detailKind = (job.detail as { kind?: string } | undefined)?.kind;
if (detailKind === 'pull') return Download;
if (job.jobType === 'bench') return Gauge;
if (job.jobType === 'eval') return Brain;
return Wrench;
}
function statusBits(status: ControlJob['status']) {
switch (status) {
case 'completed': return { Icon: CheckCircle2, cls: 'text-green-400', label: 'completed' };
case 'failed': return { Icon: XCircle, cls: 'text-red-400', label: 'failed' };
case 'running': return { Icon: Loader2, cls: 'text-blue-400 animate-spin', label: 'running' };
default: return { Icon: Clock, cls: 'text-amber-400', label: 'queued' };
}
}
function JobRow({ job }: { job: ControlJob }) {
const KindIcon = kindIcon(job);
const { Icon, cls, label } = statusBits(job.status);
const detail = (job.detail ?? {}) as Record<string, unknown>;
const kind = (detail.kind as string) ?? job.jobType;
const repo = detail.repo as string | undefined;
const model = detail.model as string | undefined;
const pct = detail.percent as number | undefined;
const err = detail.error as string | undefined;
const summary = repo ?? model ?? job.jobId;
return (
<div className="border border-border/40 rounded-lg p-3 bg-card/50 flex items-center gap-3">
<KindIcon className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium capitalize">{kind}</span>
<span className="text-xs font-mono text-muted-foreground truncate">{summary}</span>
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{new Date(job.ts).toLocaleTimeString()}
{typeof pct === 'number' && job.status === 'running' && <span> · {pct}%</span>}
{err && <span className="text-red-400"> · {String(err).slice(0, 120)}</span>}
</div>
</div>
<span className={`inline-flex items-center gap-1 text-xs ${cls}`}>
<Icon className="size-3.5" />
{label}
</span>
</div>
);
}

View File

@@ -21,7 +21,9 @@ const SOURCE_COLORS: Record<string, string> = {
};
export function LogsTab({ logs, providerIds }: LogsTabProps) {
const [paused, setPaused] = useState(false);
const [manualPaused, setManualPaused] = useState(false);
const [hoverPaused, setHoverPaused] = useState(false);
const paused = manualPaused || hoverPaused;
const [sourceFilter, setSourceFilter] = useState<string | null>(null);
const [hostFilter, setHostFilter] = useState<string | null>(null);
@@ -46,7 +48,7 @@ export function LogsTab({ logs, providerIds }: LogsTabProps) {
return (
<div className="flex items-start gap-2 px-3 py-0.5 text-[11px] font-mono border-b border-border/10">
<span className="text-muted-foreground shrink-0 w-20">
{formatTime(new Date().toISOString())}
{formatTime(entry.ts)}
</span>
<span
className={cn(
@@ -112,7 +114,7 @@ export function LogsTab({ logs, providerIds }: LogsTabProps) {
<button
type="button"
onClick={() => setPaused((p) => !p)}
onClick={() => setManualPaused((p) => !p)}
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium',
'border border-border/40 transition-colors',
@@ -120,9 +122,10 @@ export function LogsTab({ logs, providerIds }: LogsTabProps) {
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
: 'bg-muted/30 text-muted-foreground hover:text-foreground',
)}
aria-label={paused ? 'Resume follow' : 'Pause follow'}
aria-label={manualPaused ? 'Resume follow' : 'Pause follow'}
title={manualPaused ? 'Resume follow' : 'Pause follow'}
>
{paused ? <Play className="size-3" /> : <Pause className="size-3" />}
{manualPaused ? <Play className="size-3" /> : <Pause className="size-3" />}
{paused ? 'Paused' : 'Follow'}
</button>
</div>
@@ -135,6 +138,8 @@ export function LogsTab({ logs, providerIds }: LogsTabProps) {
followOutput={paused ? undefined : 'bottom' as FollowOutput}
overscan={400}
className="h-full"
onMouseEnter={() => setHoverPaused(true)}
onMouseLeave={() => setHoverPaused(false)}
/>
</div>
</div>

View File

@@ -10,6 +10,7 @@ import {
} from 'echarts/components';
import type { EChartsType } from 'echarts/core';
import { buildEChartsTheme } from './buildEChartsTheme';
import { useThemeEpoch } from './useThemeEpoch';
echarts.use([LineChart, CanvasRenderer, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent]);
@@ -28,6 +29,7 @@ interface PerfChartProps {
export function PerfChart({ series, timestamps, height = 200 }: PerfChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<EChartsType | null>(null);
const themeEpoch = useThemeEpoch();
useEffect(() => {
if (!containerRef.current) return;
@@ -102,7 +104,7 @@ export function PerfChart({ series, timestamps, height = 200 }: PerfChartProps)
chart.dispose();
chartRef.current = null;
};
}, [series, timestamps]);
}, [series, timestamps, themeEpoch]);
return (
<div ref={containerRef} className="w-full" style={{ height }} />

View File

@@ -16,7 +16,7 @@ interface ChatMessage {
content: string;
}
export function PlaygroundTab({ providerIds }: PlaygroundTabProps) {
export function PlaygroundTab({ providerIds: _providerIds }: PlaygroundTabProps) {
const [models, setModels] = useState<ModelEntry[]>([]);
const [selectedModel, setSelectedModel] = useState<string>('');
const [selectedProvider, setSelectedProvider] = useState<string>('');

View File

@@ -40,34 +40,37 @@ interface Dispatch {
type View = 'reports' | 'policies' | 'dispatch';
export function ReportsTab() {
const [view, setView] = useState<View>('reports');
// D1: 'reports' tab shows digests; 'routing' tab shows policies + dispatch log.
// Default to all sub-views for backward compatibility.
export function ReportsTab({ mode = 'all' }: { mode?: 'reports' | 'routing' | 'all' }) {
const subTabs: View[] = mode === 'reports' ? ['reports'] : mode === 'routing' ? ['policies', 'dispatch'] : ['reports', 'policies', 'dispatch'];
const [view, setView] = useState<View>(subTabs[0]!);
const LABEL: Record<View, { icon: typeof FileText; text: string }> = {
reports: { icon: FileText, text: 'Reports' },
policies: { icon: Route, text: 'Policies' },
dispatch: { icon: ListOrdered, text: 'Dispatch Log' },
};
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/40">
<button
onClick={() => setView('reports')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${view === 'reports' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<FileText className="size-3 inline mr-1" />
Reports
</button>
<button
onClick={() => setView('policies')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${view === 'policies' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<Route className="size-3 inline mr-1" />
Policies
</button>
<button
onClick={() => setView('dispatch')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${view === 'dispatch' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<ListOrdered className="size-3 inline mr-1" />
Dispatch Log
</button>
</div>
{subTabs.length > 1 && (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/40">
{subTabs.map((t) => {
const { icon: Icon, text } = LABEL[t];
return (
<button
key={t}
onClick={() => setView(t)}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${view === t ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<Icon className="size-3 inline mr-1" />
{text}
</button>
);
})}
</div>
)}
<div className="flex-1 overflow-auto">
{view === 'reports' && <ReportsView />}
@@ -78,8 +81,6 @@ export function ReportsTab() {
);
}
// ─── Reports ──────────────────────────────────────────────────────────────
function ReportsView() {
const [reports, setReports] = useState<ReportSummary[]>([]);
const [selected, setSelected] = useState<ReportDetail | null>(null);
@@ -232,8 +233,6 @@ function ReportsView() {
);
}
// ─── Policies ─────────────────────────────────────────────────────────────
function PoliciesView() {
const [policies, setPolicies] = useState<Policy[]>([]);
const [virtualModels, setVirtualModels] = useState<string[]>([]);
@@ -363,8 +362,6 @@ function PoliciesView() {
);
}
// ─── Dispatch log ───────────────────────────────────────────────────────────
function DispatchView() {
const [dispatches, setDispatches] = useState<Dispatch[]>([]);
const [loading, setLoading] = useState(true);

View File

@@ -4,6 +4,7 @@ import { GaugeChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import type { EChartsType } from 'echarts/core';
import { buildEChartsTheme } from './buildEChartsTheme';
import { useThemeEpoch } from './useThemeEpoch';
echarts.use([GaugeChart, CanvasRenderer]);
@@ -16,6 +17,7 @@ export function TtlRing({ deadline, size = 80 }: TtlRingProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<EChartsType | null>(null);
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
const themeEpoch = useThemeEpoch();
useEffect(() => {
if (!containerRef.current || !deadline) return;
@@ -31,66 +33,81 @@ export function TtlRing({ deadline, size = 80 }: TtlRingProps) {
const maxMs = 3600_000; // 1h max ring
const update = () => {
const compute = () => {
const remaining = new Date(deadline).getTime() - Date.now();
const value = Math.max(0, remaining);
const pct = Math.min(1, value / maxMs);
const pct = Math.min(1, Math.max(0, remaining) / maxMs);
// Derive gauge progress color from CSS custom properties
let color = get('--glow-green');
if (pct < 0.3) color = get('--glow-red');
else if (pct < 0.6) color = get('--glow-amber');
const minutes = Math.floor(remaining / 60_000);
const seconds = Math.floor((remaining % 60_000) / 1000);
const label = remaining > 0 ? `${minutes}m ${seconds}s` : 'expired';
return { pct, color, label };
};
// Full structural option once. The static styling (axis, geometry, fonts)
// never changes after this; ticks only merge the dynamic series fields.
const initial = compute();
chart.setOption({
backgroundColor: 'transparent',
series: [
{
type: 'gauge',
startAngle: 220,
endAngle: -40,
min: 0,
max: 1,
radius: '90%',
center: ['50%', '55%'],
pointer: { show: false },
progress: {
show: true,
overlap: false,
roundCap: true,
clip: false,
itemStyle: { color: initial.color },
width: 4,
},
axisLine: {
lineStyle: {
width: 4,
color: [[1, get('--border')]],
},
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
title: { show: false },
detail: {
show: true,
offsetCenter: ['0%', '5%'],
fontSize: 11,
fontWeight: 'bold',
color: get('--foreground'),
fontFamily: 'Orbitron',
formatter: () => initial.label,
},
data: [{ value: initial.pct, name: 'TTL' }],
},
],
});
// AUD2: per-tick merge update — only the changing series fields, not the
// whole option tree, so the 1s timer stops rebuilding static geometry.
const tick = () => {
const { pct, color, label } = compute();
chart.setOption({
backgroundColor: 'transparent',
series: [
{
type: 'gauge',
startAngle: 220,
endAngle: -40,
min: 0,
max: 1,
radius: '90%',
center: ['50%', '55%'],
pointer: { show: false },
progress: {
show: true,
overlap: false,
roundCap: true,
clip: false,
itemStyle: { color },
width: 4,
},
axisLine: {
lineStyle: {
width: 4,
color: [[1, get('--border')]],
},
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
title: { show: false },
detail: {
show: true,
offsetCenter: ['0%', '5%'],
fontSize: 11,
fontWeight: 'bold',
color: get('--foreground'),
fontFamily: 'Orbitron',
formatter: () => remaining > 0 ? `${minutes}m ${seconds}s` : 'expired',
},
progress: { itemStyle: { color } },
detail: { formatter: () => label },
data: [{ value: pct, name: 'TTL' }],
},
],
});
};
update();
tickRef.current = setInterval(update, 1000);
tickRef.current = setInterval(tick, 1000);
const observer = new ResizeObserver(() => chart.resize());
observer.observe(containerRef.current);
@@ -101,7 +118,7 @@ export function TtlRing({ deadline, size = 80 }: TtlRingProps) {
chart.dispose();
chartRef.current = null;
};
}, [deadline]);
}, [deadline, themeEpoch]);
if (!deadline) return null;

View File

@@ -4,6 +4,7 @@ import { GaugeChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import type { EChartsType } from 'echarts/core';
import { buildEChartsTheme } from './buildEChartsTheme';
import { useThemeEpoch } from './useThemeEpoch';
echarts.use([GaugeChart, CanvasRenderer]);
@@ -16,6 +17,7 @@ interface VramGaugeProps {
export function VramGauge({ used, total, size = 120 }: VramGaugeProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<EChartsType | null>(null);
const themeEpoch = useThemeEpoch();
useEffect(() => {
if (!containerRef.current) return;
@@ -95,7 +97,7 @@ export function VramGauge({ used, total, size = 120 }: VramGaugeProps) {
chart.dispose();
chartRef.current = null;
};
}, [used, total]);
}, [used, total, themeEpoch]);
return (
<div

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { VramGauge } from '../VramGauge';
describe('VramGauge', () => {
it('renders without crashing', () => {
const div = document.createElement('div');
const root = createRoot(div);
root.render(React.createElement(VramGauge, { used: 2048, total: 8192 }));
root.unmount();
});
it('renders with zero values', () => {
const div = document.createElement('div');
const root = createRoot(div);
root.render(React.createElement(VramGauge, { used: 0, total: 0 }));
root.unmount();
});
});

View File

@@ -1,5 +1,3 @@
import * as echarts from 'echarts/core';
/**
* Build an ECharts theme object from the active CSS custom properties.
* Reads from document.documentElement so it always reflects the current theme.

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
/**
* AUD7: returns a counter that increments whenever the active theme changes.
*
* Themes are applied by swapping `document.documentElement.className`
* (`lib/theme.ts`). ECharts reads its colors from CSS custom properties at
* init time only, so a theme switch leaves charts stranded on the old palette.
* Charts include this epoch in their render effect's dependency array; the
* effect re-runs (dispose + re-init with a fresh theme) on every switch.
*/
export function useThemeEpoch(): number {
const [epoch, setEpoch] = useState(0);
useEffect(() => {
const target = document.documentElement;
let last = target.className;
const observer = new MutationObserver(() => {
if (target.className !== last) {
last = target.className;
setEpoch((n) => n + 1);
}
});
observer.observe(target, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return epoch;
}

View File

@@ -19,3 +19,13 @@ export function OpenCodeIcon({ size = 14, className }: IconProps) {
</svg>
);
}
export function ReasonixIcon({ size = 14, className }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 3L4 7v6c0 4 3.4 7.2 8 8 4.6-.8 8-4 8-8V7l-8-4z" />
<path d="M9 10h6" />
<path d="M9 14h3.5" />
</svg>
);
}

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { useState, type ReactNode } from "react";
import { toast } from 'sonner';
import { sendToTerminal } from '@/lib/events';
import { useTerminals } from '@/hooks/useTerminals';

View File

@@ -20,8 +20,6 @@ import {
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
// ─── Status dot (mirrors FlowStepStatusDot) ───────────────────────────────────
function ContestantStatusDot({ status }: { status: ContestantShape['status'] }) {
if (status === 'running') {
return (
@@ -40,8 +38,6 @@ function ContestantStatusDot({ status }: { status: ContestantShape['status'] })
return <span aria-label={status} className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', cls)} />;
}
// ─── Lane badge ───────────────────────────────────────────────────────────────
function LaneBadge({ lane }: { lane: ContestantShape['lane'] }) {
return (
<span
@@ -57,8 +53,6 @@ function LaneBadge({ lane }: { lane: ContestantShape['lane'] }) {
);
}
// ─── Duration formatter ───────────────────────────────────────────────────────
function formatDuration(ms: number | null): string {
if (ms == null) return '';
const s = Math.round(ms / 1000);
@@ -66,8 +60,6 @@ function formatDuration(ms: number | null): string {
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
}
// ─── Live ticker for running contestants ─────────────────────────────────────
function LiveDuration({ startedAt }: { startedAt: number }) {
const [elapsed, setElapsed] = useState(() => Date.now() - startedAt);
useEffect(() => {
@@ -77,8 +69,6 @@ function LiveDuration({ startedAt }: { startedAt: number }) {
return <span>{formatDuration(elapsed)}</span>;
}
// ─── DiffView ─────────────────────────────────────────────────────────────────
function DiffView({ diff }: { diff: string }) {
const lines = diff.split('\n');
return (
@@ -107,8 +97,6 @@ function DiffView({ diff }: { diff: string }) {
);
}
// ─── ContestantRow ────────────────────────────────────────────────────────────
interface ContestantRowState {
data: ContestantShape;
output: string;
@@ -250,8 +238,6 @@ function ContestantRow({
);
}
// ─── CrossExaminationPanel ────────────────────────────────────────────────────
function CrossExaminationPanel({
battleId,
crossExams,
@@ -352,8 +338,6 @@ function CrossExaminationPanel({
);
}
// ─── ArenaPane ────────────────────────────────────────────────────────────────
interface Props {
state: ArenaState;
projectId: string; // available for future use (e.g. file browser affordance)
@@ -372,7 +356,6 @@ export function ArenaPane({ state, onClose }: Props) {
const snapshot = useProviderSnapshot();
// Fetch current battle state on mount / battle_id change.
useEffect(() => {
setBattle(null);
setContestantRows([]);

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Columns, Download, History, Pencil, Send, X } from 'lucide-react';
import { Columns, Download, History, MoreHorizontal, Pencil, Send, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import {
@@ -7,11 +7,11 @@ import {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import { ModelPicker } from '@/components/ModelPicker';
import { StaleStreamBanner } from '@/components/StaleStreamBanner';
import { SessionTimeline } from '@/components/SessionTimeline';
import { TraceViewer } from '@/components/TraceViewer';
@@ -349,74 +349,6 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
return (
<div className="flex flex-col h-full min-h-0 relative">
{chatMessages.length > 0 && (
<div className="absolute top-2 right-2 z-10 flex items-center gap-2">
<ModelPicker
value={sessionChats?.find((c) => c.id === chatId)?.model ?? null}
onChange={async (model) => {
try {
await api.chats.update(chatId, { model });
toast.success(`Model set to ${model}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update model');
}
}}
/>
<button
type="button"
onClick={() => setShowCompareSelector(true)}
disabled={streaming}
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
transition-colors border
bg-background text-muted-foreground border-border hover:bg-muted hover:text-foreground
disabled:opacity-40 disabled:cursor-not-allowed
`}
aria-label="Compare models"
title="Compare models"
>
<Columns size={12} />
Compare
</button>
<button
type="button"
onClick={() => setShowTimeline((v) => !v)}
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
transition-colors border
${showTimeline
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted hover:text-foreground'
}
`}
aria-label={showTimeline ? 'Close timeline' : 'Open timeline'}
>
<History size={12} />
Timeline
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Export chat"
title="Export chat"
>
<Download className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => handleExport('json')}>
Export as JSON
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleExport('markdown')}>
Export as Markdown
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */}
{compareActive ? (
<ComparePane
@@ -496,6 +428,39 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
// drives latest-pair walk; modelContextLimit powers the zero-state.
messages={chatMessages}
modelContextLimit={modelContextLimit}
composerActions={chatMessages.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] max-md:min-h-[36px] max-md:min-w-[36px]"
aria-label="Chat actions"
title="Chat actions"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top">
<DropdownMenuItem disabled={streaming} onSelect={() => setShowCompareSelector(true)}>
<Columns size={14} className="mr-2" />
Compare models
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShowTimeline((v) => !v)}>
<History size={14} className="mr-2" />
{showTimeline ? 'Close timeline' : 'Timeline'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => handleExport('json')}>
<Download size={14} className="mr-2" />
Export as JSON
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleExport('markdown')}>
<Download size={14} className="mr-2" />
Export as Markdown
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : undefined}
/>
{/* Timeline overlay panel */}
@@ -511,64 +476,66 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
{/* Compare model selector dialog */}
{showCompareSelector && (
<Dialog open={showCompareSelector} onOpenChange={(open) => { if (!open) setShowCompareSelector(false); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogContent className="sm:max-w-md max-h-[85vh] flex flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle>Compare Models</DialogTitle>
<DialogDescription>
Select 2-3 models to compare. Each model receives the same message and you see responses side by side.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-4">
<div className="flex flex-col gap-3 py-4 min-h-0 flex-1">
<textarea
value={compareInput}
onChange={(e) => setCompareInput(e.target.value)}
placeholder="Type your message to compare across models…"
rows={3}
className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
className="w-full shrink-0 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div className="text-xs text-muted-foreground mb-1">Select 2-3 models:</div>
{availableModels.length === 0 && (
<div className="text-xs text-muted-foreground px-1">Loading models</div>
)}
{availableModels.map((model) => {
const isSelected = selectedCompareModels.includes(model);
return (
<label
key={model}
className={`
flex items-center gap-3 px-3 py-2 rounded-md border text-sm cursor-pointer transition-colors
${isSelected
? 'border-primary bg-primary/5 text-foreground'
: 'border-border hover:bg-muted/50 text-muted-foreground'
}
${selectedCompareModels.length >= 3 && !isSelected ? 'opacity-40 pointer-events-none' : ''}
`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {
setSelectedCompareModels((prev) =>
isSelected
? prev.filter((m) => m !== model)
: prev.length < 3
? [...prev, model]
: prev,
);
}}
className="size-4 accent-primary"
/>
<span className="flex-1">{model}</span>
{isSelected && (
<span className="text-[10px] text-muted-foreground shrink-0">
{selectedCompareModels.indexOf(model) + 1}
</span>
)}
</label>
);
})}
<div className="shrink-0 text-xs text-muted-foreground mb-1">Select 2-3 models:</div>
<div className="flex flex-col gap-2 min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
{availableModels.length === 0 && (
<div className="text-xs text-muted-foreground px-1">Loading models</div>
)}
{availableModels.map((model) => {
const isSelected = selectedCompareModels.includes(model);
return (
<label
key={model}
className={`
flex items-center gap-3 px-3 py-2 rounded-md border text-sm cursor-pointer transition-colors
${isSelected
? 'border-primary bg-primary/5 text-foreground'
: 'border-border hover:bg-muted/50 text-muted-foreground'
}
${selectedCompareModels.length >= 3 && !isSelected ? 'opacity-40 pointer-events-none' : ''}
`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {
setSelectedCompareModels((prev) =>
isSelected
? prev.filter((m) => m !== model)
: prev.length < 3
? [...prev, model]
: prev,
);
}}
className="size-4 accent-primary"
/>
<span className="flex-1">{model}</span>
{isSelected && (
<span className="text-[10px] text-muted-foreground shrink-0">
{selectedCompareModels.indexOf(model) + 1}
</span>
)}
</label>
);
})}
</div>
</div>
<DialogFooter>
<DialogFooter className="shrink-0">
<Button
variant="default"
disabled={selectedCompareModels.length < 2 || sendingCompare || !compareInput.trim()}

View File

@@ -14,7 +14,7 @@ import { api } from '@/api/client';
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
import { useSkills } from '@/hooks/useSkills';
import { toast } from 'sonner';
import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command';
import { mergeCommandsByName, parseSlashInput } from "@/lib/slash-command";
import { mergeWireToolCall } from '@/lib/coder-tools';
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
@@ -23,10 +23,6 @@ import { useAgentStatus, type AgentStatus, type AgentStatusEntry } from '@/hooks
import { cn } from '@/lib/utils';
import { sessionEvents } from '@/hooks/sessionEvents';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CoderMessage {
id: string;
role: 'user' | 'assistant' | 'system';
@@ -479,10 +475,6 @@ function useCheckpoints(sessionId: string, chatId: string | undefined) {
return { checkpointMessageIds: messageIds, refreshCheckpoints: refresh };
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function DiffPanel({
changes,
loading,
@@ -626,10 +618,6 @@ function DiffPanel({
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function CoderPane({
sessionId,
paneId,
@@ -685,7 +673,7 @@ export function CoderPane({
const [providerCommands, setProviderCommands] = useState<AgentCommand[]>([]);
const [liveTaskCommands, setLiveTaskCommands] = useState<AgentCommand[]>([]);
const { skills } = useSkills();
const [slashState, setSlashState] = useState<{ query: string } | null>(null);
const [_slashState, setSlashState] = useState<{ query: string } | null>(null);
const displayedCommands = useMemo(() => {
const base =

View File

@@ -19,8 +19,7 @@ import { api } from '@/api/client';
import type { FlowRunRow, FlowStepRow, OrchestratorState } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { chatInputsRegistry, sendToChat } from '@/lib/events';
import { CoderMessageList } from '@/components/panes/CoderMessageList';
import type { CoderTimelineWire } from '@/components/panes/CoderMessageList';
import { CoderMessageList, type CoderTimelineWire } from "@/components/panes/CoderMessageList";
import { mergeWireToolCall } from '@/lib/coder-tools';
import {
DropdownMenu,
@@ -52,11 +51,6 @@ function FlowStepStatusDot({ status }: { status: FlowStepRow['status'] }) {
return <span aria-label={status} className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', cls)} />;
}
// ---- per-step stream hook ---------------------------------------------------
// Connects to the synthetic session WS for the expanded step. Returns messages
// suitable for CoderMessageList. Disconnects and clears when sessionId/chatId
// are null (collapsed step). Reuses the same frame-handling logic as CoderPane.
type RawFrame = Record<string, unknown>;
function useStepStream(sessionId: string | null, chatId: string | null): CoderTimelineWire[] {
@@ -172,14 +166,10 @@ function useStepStream(sessionId: string | null, chatId: string | null): CoderTi
return messages;
}
// ---- helpers ---------------------------------------------------------------
function humanize(slug: string): string {
return slug.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// ---- StepRow ---------------------------------------------------------------
function StepRow({
step,
isExpanded,
@@ -229,8 +219,6 @@ function StepRow({
);
}
// ---- OrchestratorPane ------------------------------------------------------
interface Props {
state: OrchestratorState;
onClose: () => void;

View File

@@ -2,9 +2,6 @@ import { useEffect, useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { type ChatInputRegistration } from '@/lib/events';
// ============================================================
// FloatingMenu — kept from v1.10.4 (mobile long-press + desktop right-click)
// ============================================================
interface FloatingMenuProps {
x: number;
y: number;

View File

@@ -3,9 +3,6 @@ import type { SearchAddon } from '@xterm/addon-search';
import { ChevronDown, ChevronUp, X } from 'lucide-react';
import { type TermTheme } from './theme';
// ============================================================
// SearchBar — kept from v1.10.4
// ============================================================
interface SearchBarProps {
searchRef: React.MutableRefObject<SearchAddon | null>;
theme: TermTheme;

View File

@@ -2,12 +2,6 @@ import { useCallback } from 'react';
import { Maximize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
// ============================================================
// TerminalHotkeyBar — v1.10.8d port of boolab's TerminalHotkeyBar.jsx +
// terminalHotkeysStore.js DEFAULT_BAR. The catalog is hardcoded inline (no
// zustand store, no settings UI) — single-user homelab doesn't need either.
// Add new entries by extending HOTKEY_BAR below.
// ============================================================
type Hotkey =
| { id: string; label: string; bytes: string; sticky?: undefined }
| { id: string; label: string; sticky: 'ctrl'; bytes?: undefined };

View File

@@ -99,12 +99,6 @@ export function useTerminalSelection({
});
}, [send]);
// ============================================================
// v1.10.4 features (long-press menu, right-click, custom keys)
// Kept verbatim — independent of the WS/fit path that v1.10.8c fixes.
// Re-bound on session/pane change so the gesture closures reference the
// recreated terminal.
// ============================================================
useEffect(() => {
const termInit = termRef.current;
const ctrInit = containerRef.current;

View File

@@ -83,9 +83,6 @@ export function useTerminalSocket({
setCtrlArmedSync(!ctrlArmedRef.current);
}, [setCtrlArmedSync]);
// sendInput: write to the WS as a binary frame (server-side discriminator
// routes binary to PTY, text to JSON control). Used by the hotkey bar and
// the selection paste path.
const send = useCallback((text: string) => {
if (!text) return;
const ws = wsRef.current;

View File

@@ -9,11 +9,9 @@
import { createContext, useContext, useRef, useCallback, useEffect, useState } from 'react';
// ─── types ──────────────────────────────────────────────────────────────────
export interface ControlFleetHost {
providerId: string;
liveness: 'connected' | 'reconnecting' | 'down';
liveness: 'connected' | 'down';
lastSeenAt: string | null;
seq: number;
models: Array<{
@@ -46,9 +44,19 @@ export interface ControlLogEntry {
providerId: string;
source: 'proxy' | 'upstream' | 'model';
line: string;
/** Stamped at WS-ingest time (the frame carries no ts); far better than render-time now(). */
ts: string;
}
// ─── frame types ────────────────────────────────────────────────────────────
export type ControlConnection = 'connecting' | 'live' | 'reconnecting' | 'down';
export interface ControlJob {
jobType: 'bench' | 'eval' | 'action';
jobId: string;
status: 'queued' | 'running' | 'completed' | 'failed';
detail?: Record<string, unknown>;
ts: string;
}
export type ControlFleetDelta = {
type: 'control_fleet';
@@ -78,6 +86,7 @@ export type ControlLogFrame = {
providerId: string;
source: 'proxy' | 'upstream' | 'model';
line: string;
ts?: string;
};
export type ControlJobFrame = {
@@ -96,15 +105,12 @@ export type ControlFrame =
| ControlLogFrame
| ControlJobFrame;
// ─── A3: type-guards for incoming WS frames ─────────────────────────────────
// Replace 'as unknown as' casts with runtime validation.
function isValidHost(h: unknown): h is ControlFleetHost {
if (!h || typeof h !== 'object') return false;
const obj = h as Record<string, unknown>;
return (
typeof obj.providerId === 'string' &&
['connected', 'reconnecting', 'down'].includes(obj.liveness as string) &&
['connected', 'down'].includes(obj.liveness as string) &&
(obj.lastSeenAt === null || typeof obj.lastSeenAt === 'string') &&
typeof obj.seq === 'number' &&
Array.isArray(obj.models)
@@ -169,24 +175,18 @@ function isControlJobFrame(data: unknown): data is ControlJobFrame {
);
}
// ─── context ────────────────────────────────────────────────────────────────
export interface ControlStreamState {
hosts: ControlFleetHost[];
requests: ControlRequestEntry[];
perfSamples: ControlPerfSample[];
logs: ControlLogEntry[];
jobs: Array<{
jobType: 'bench' | 'eval' | 'action';
jobId: string;
status: 'queued' | 'running' | 'completed' | 'failed';
}>;
jobs: ControlJob[];
/** Live control-WS connection state, for the cockpit status pill. */
connection: ControlConnection;
}
const ControlContext = createContext<ControlStreamState | null>(null);
// ─── hook ───────────────────────────────────────────────────────────────────
export function useControlStream(): ControlStreamState {
const state = useContext(ControlContext);
if (!state) throw new Error('useControlStream must be used within ControlProvider');
@@ -200,6 +200,7 @@ export function ControlProvider({ children }: { children: React.ReactNode }) {
perfSamples: [],
logs: [],
jobs: [],
connection: 'connecting',
});
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -216,6 +217,7 @@ export function ControlProvider({ children }: { children: React.ReactNode }) {
snapshotSeqRef.current = 0;
hasSnapshotRef.current = false;
backoffRef.current = 5_000;
setState((prev) => ({ ...prev, connection: 'live' }));
};
ws.onmessage = (event) => {
@@ -259,15 +261,23 @@ export function ControlProvider({ children }: { children: React.ReactNode }) {
perfSamples: [...prev.perfSamples, { providerId: data.providerId, ts: data.ts, gpu: data.gpu, sys: data.sys }].slice(-500),
}));
} else if (isControlLogFrame(data)) {
// Prefer the server-emit ts; fall back to ingest time for old frames.
const ts = (data as ControlLogFrame).ts ?? new Date().toISOString();
setState((prev) => ({
...prev,
logs: [...prev.logs, { providerId: data.providerId, source: data.source, line: data.line }].slice(-1000),
logs: [...prev.logs, { providerId: data.providerId, source: data.source, line: data.line, ts }].slice(-1000),
}));
} else if (isControlJobFrame(data)) {
setState((prev) => ({
...prev,
jobs: [...prev.jobs, { jobType: data.jobType, jobId: data.jobId, status: data.status }].slice(-200),
}));
setState((prev) => {
// Dedupe by jobId: keep one entry per job, updated to its latest status.
// Preserve the original insertion ts so the job doesn't jump to the top
// of the Jobs list on every status tick (ts = when first seen).
const existing = prev.jobs.find((j) => j.jobId === data.jobId);
const ts = existing?.ts ?? new Date().toISOString();
const others = prev.jobs.filter((j) => j.jobId !== data.jobId);
const job: ControlJob = { jobType: data.jobType, jobId: data.jobId, status: data.status, detail: data.detail, ts };
return { ...prev, jobs: [...others, job].slice(-200) };
});
}
// Unknown frame types are silently dropped (fail-closed)
} catch {
@@ -279,6 +289,8 @@ export function ControlProvider({ children }: { children: React.ReactNode }) {
wsRef.current = null;
// A6 fix: exponential backoff instead of fixed 5s delay.
const delay = backoffRef.current;
// Once backoff has grown past the first couple of retries, call it 'down'.
setState((prev) => ({ ...prev, connection: backoffRef.current >= 20_000 ? 'down' : 'reconnecting' }));
backoffRef.current = Math.min(30_000, backoffRef.current * 2);
reconnectTimerRef.current = setTimeout(connect, delay);
};

View File

@@ -56,7 +56,6 @@ export function useDraftPersistence(chatId: string | undefined): DraftPersistenc
const keyRef = useRef(key);
keyRef.current = key;
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (timerRef.current !== null) {

View File

@@ -1,5 +1,4 @@
import { useCallback, useRef } from 'react';
import type { TouchEvent } from 'react';
import { useCallback, useRef, type TouchEvent } from "react";
interface LongPressHandlers {
onTouchStart: (e: TouchEvent) => void;

View File

@@ -1,5 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import type { TouchEvent } from 'react';
import { useCallback, useRef, useState, type TouchEvent } from "react";
interface Options {
threshold?: number;

View File

@@ -1,5 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { useLocation } from 'react-router-dom';
interface RightRailDrawerState {

View File

@@ -18,9 +18,6 @@ interface State {
type Channel = 'text' | 'tool_call' | 'tool_result' | 'status' | 'error';
// Per-channel out-of-order frame buffer with contiguous-seq flush logic.
// Stores incoming channel_delta frames and releases them only when seq
// becomes contiguous with the expected next value.
class ChannelBuffer {
private expectedSeq = 0;
private buffer = new Map<number, ChannelDeltaWsFrame>();

View File

@@ -1,5 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { useLocation } from 'react-router-dom';
import { useViewport } from './useViewport';

View File

@@ -1,9 +1,6 @@
import { useEffect, useState } from 'react';
import { terminalsRegistry, type TerminalRegistration } from '@/lib/events';
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
// Used by the right-click "Send to terminal" submenu so it always reflects
// currently-open terminal panes without prop drilling from Workspace.
export function useTerminals(): TerminalRegistration[] {
const [list, setList] = useState(() => terminalsRegistry.list());
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);

View File

@@ -1,12 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import { toast } from 'sonner';
import { api } from '@/api/client';
import type {
ArenaState,
ClosedPaneEntry,
HtmlArtifactState,
MarkdownArtifactState,
OrchestratorState,
WorkspacePane,
WorkspaceState,
@@ -15,261 +12,33 @@ import type {
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
import { sessionEvents } from '@/hooks/sessionEvents';
import {
activePaneChatId,
appendClosed,
arenaPane,
chatNameForPaneKind,
chatPane,
emptyPane,
filterTabs,
generateId,
generateTermTabId,
htmlArtifactPane,
LEGACY_STORAGE_KEY,
markdownArtifactPane,
nonSettingsCount,
normalizePanes,
orchestratorPane,
paneTabKinds,
persistablePanes,
readLegacyPanes,
rebuildPane,
SAVE_DEBOUNCE_MS,
settingsPane,
toWorkspaceState,
} from './workspace-pane-ops.js';
export { activePaneChatId };
export const MAX_PANES = 5;
// v1.12.1: legacy localStorage key. Read once on mount to seed the server
// for sessions still on per-device state, then deleted. Server is now
// authoritative via sessions.workspace_panes.
const LEGACY_STORAGE_KEY = 'boocode.workspace.panes';
const SAVE_DEBOUNCE_MS = 300;
function generateId(): string {
return crypto.randomUUID();
}
// Mixed tabs: terminal tabs have no chats row, so their tab id is a generated
// `term_*` id (used to key the tmux session). chat/coder tab ids are chats-row
// ids.
const TERM_TAB_PREFIX = 'term_';
function generateTermTabId(): string {
return `${TERM_TAB_PREFIX}${generateId()}`;
}
// Per-tab kinds, with a legacy back-fill from pane.kind for pre-mixed-tabs rows.
function paneTabKinds(pane: WorkspacePane): WorkspaceTabKind[] {
if (pane.tabKinds && pane.tabKinds.length === pane.chatIds.length) return pane.tabKinds;
const fallback: WorkspaceTabKind =
pane.kind === 'coder' || pane.kind === 'terminal' ? pane.kind : 'chat';
return pane.chatIds.map(() => fallback);
}
// Rebuild a tabbed pane from (ids, kinds, desired active index). Keeps pane.kind
// in sync with the ACTIVE tab (so the render-by-pane.kind path renders the right
// tab) and collapses to an empty landing pane when no tabs remain.
function rebuildPane(
pane: WorkspacePane,
ids: string[],
kinds: WorkspaceTabKind[],
desiredActive: number,
): WorkspacePane {
if (ids.length === 0) {
return {
...pane,
kind: 'empty',
chatId: undefined,
chatIds: [],
tabKinds: [],
activeChatIdx: -1,
markdown_artifact_state: undefined,
html_artifact_state: undefined,
};
}
const idx = Math.max(0, Math.min(desiredActive, ids.length - 1));
return {
...pane,
kind: kinds[idx]!,
chatId: ids[idx],
chatIds: ids,
tabKinds: kinds,
activeChatIdx: idx,
};
}
// Filter a pane's tabs, keeping chatIds + tabKinds aligned and collecting the
// ids of any dropped terminal tabs (so callers can kill their tmux sessions).
function filterTabs(
pane: WorkspacePane,
keep: (id: string, idx: number) => boolean,
): { ids: string[]; kinds: WorkspaceTabKind[]; removedTermIds: string[] } {
const kinds = paneTabKinds(pane);
const ids: string[] = [];
const nextKinds: WorkspaceTabKind[] = [];
const removedTermIds: string[] = [];
pane.chatIds.forEach((id, i) => {
if (keep(id, i)) {
ids.push(id);
nextKinds.push(kinds[i]!);
} else if (kinds[i] === 'terminal') {
removedTermIds.push(id);
}
});
return { ids, kinds: nextKinds, removedTermIds };
}
// v1.10.3: optional id arg lets addSplitPane lift id generation out of the
// setPanes updater so the new pane's id can be returned synchronously to the
// caller (needed for mobile URL state).
function emptyPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'empty', chatIds: [], tabKinds: [], activeChatIdx: -1 };
}
function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], tabKinds: ['chat'], activeChatIdx: 0 };
}
// v2.6.x: reopen stack cap. The stack now lives in React state (persisted in
// the WorkspaceState envelope), not a module-level array. `appendClosed` is the
// pure state-updater helper.
const MAX_CLOSED = 10;
// Pure helper: append a closed-pane entry derived from `pane` to `stack`,
// capped at MAX_CLOSED (most-recent last). Returns the SAME reference when the
// pane is not eligible (empty/settings/no chats) so callers can skip setState.
function appendClosed(stack: ClosedPaneEntry[], pane: WorkspacePane): ClosedPaneEntry[] {
if (pane.kind === 'empty' || pane.kind === 'settings') return stack;
if (pane.chatIds.length === 0) return stack;
const entry = { kind: pane.kind, chatIds: [...pane.chatIds], tabKinds: [...paneTabKinds(pane)], activeChatIdx: pane.activeChatIdx };
// Dedupe a value-identical top entry. This is called via setClosedPaneStack
// inside the setPanes updater in removePane; React StrictMode double-invokes
// that updater in dev, which would otherwise push two identical entries.
// Real closes never collide (one chat lives in at most one pane).
const top = stack[stack.length - 1];
if (
top &&
top.kind === entry.kind &&
top.activeChatIdx === entry.activeChatIdx &&
top.chatIds.length === entry.chatIds.length &&
top.chatIds.every((id, i) => id === entry.chatIds[i])
) {
return stack;
}
const next = [...stack, entry];
if (next.length > MAX_CLOSED) next.splice(0, next.length - MAX_CLOSED);
return next;
}
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
return kind === 'coder' ? 'BooCoder' : 'Terminal';
}
/** Active chat id for a pane row (chat / coder / terminal). */
export function activePaneChatId(pane: WorkspacePane): string | undefined {
const idx = pane.activeChatIdx ?? 0;
if (idx >= 0 && pane.chatIds?.[idx]) return pane.chatIds[idx];
return pane.chatId;
}
// v1.9: settings pane factory. No chats, no state beyond identity — the
// SettingsPane component renders Session/Project sections from the
// surrounding session/project.
function settingsPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 };
}
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
// the pane row so the sessions.workspace_panes jsonb survives reload.
function markdownArtifactPane(state: MarkdownArtifactState): WorkspacePane {
return {
id: generateId(),
kind: 'markdown_artifact',
chatIds: [],
activeChatIdx: -1,
markdown_artifact_state: state,
};
}
function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
return {
id: generateId(),
kind: 'html_artifact',
chatIds: [],
activeChatIdx: -1,
html_artifact_state: state,
};
}
function orchestratorPane(state: OrchestratorState): WorkspacePane {
return {
id: generateId(),
kind: 'orchestrator',
chatIds: [],
activeChatIdx: -1,
orchestrator_state: state,
};
}
function arenaPane(state: ArenaState): WorkspacePane {
return {
id: generateId(),
kind: 'arena',
chatIds: [],
activeChatIdx: -1,
arena_state: state,
};
}
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
// page reload always returns to a clean workspace; the user re-opens via the
// sidebar Settings button when needed.
function normalizePaneKind(pane: WorkspacePane): WorkspacePane {
// v2.3: server once accepted legacy 'agent' before 'coder' landed in the schema.
let p = pane;
if ((p.kind as string) === 'agent') p = { ...p, kind: 'coder' };
// Mixed-tabs migration: back-fill per-tab kinds for pre-mixed-tabs rows.
const tabbed = p.kind === 'chat' || p.kind === 'coder' || p.kind === 'terminal';
if (!tabbed) return p;
// Legacy terminal panes keyed their tmux session off the PANE id and stored a
// vestigial chats row in chatIds[0]. Re-seat the terminal as a tab whose id IS
// the pane id, so the existing tmux session keeps resolving after migration.
if (p.kind === 'terminal' && (!p.tabKinds || p.tabKinds.length === 0)) {
return { ...p, chatIds: [p.id], tabKinds: ['terminal'], chatId: p.id, activeChatIdx: 0 };
}
if (!p.tabKinds || p.tabKinds.length !== p.chatIds.length) {
const k: WorkspaceTabKind = p.kind === 'coder' ? 'coder' : p.kind === 'terminal' ? 'terminal' : 'chat';
return { ...p, tabKinds: p.chatIds.map(() => k) };
}
return p;
}
function normalizePanes(panes: WorkspacePane[]): WorkspacePane[] {
return panes.map(normalizePaneKind);
}
function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
}
// v2.6.x: LOCKED migration — a value read from session.workspace_panes (or the
// session_workspace_updated frame) may be EITHER the legacy bare
// WorkspacePane[] OR the new WorkspaceState envelope. Normalize to the
// envelope. Must match the server's normalization byte-for-byte.
function toWorkspaceState(raw: unknown): WorkspaceState {
if (Array.isArray(raw)) {
return { panes: raw as WorkspacePane[], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
}
if (raw && typeof raw === 'object' && Array.isArray((raw as WorkspaceState).panes)) {
const env = raw as WorkspaceState;
return {
panes: env.panes,
tabNumbers: env.tabNumbers ?? {},
nextTabNumber: env.nextTabNumber ?? 1,
closedPaneStack: env.closedPaneStack ?? [],
};
}
return { panes: [], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
}
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
// Helper used at every pane-insertion site so the rule lives in one place.
function nonSettingsCount(panes: WorkspacePane[]): number {
return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0);
}
// v1.12.1: read legacy per-device localStorage. If present, the caller seeds
// the server then deletes the key. One-time migration per session.
function readLegacyPanes(sessionId: string): WorkspacePane[] | null {
try {
const raw = localStorage.getItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as WorkspacePane[];
if (!Array.isArray(parsed) || parsed.length === 0) return null;
return parsed;
} catch {
return null;
}
}
export interface UseWorkspacePanesResult {
panes: WorkspacePane[];
@@ -1056,8 +825,6 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, [closedPaneStack]);
// Replaces a single empty default pane with a chat pane. Used by the initial
// chat fetch to land on the most-recent open chat if no saved pane state.
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
setPanes((prev) => {
if (prev.length === 1 && prev[0]!.kind === 'empty') {

View File

@@ -0,0 +1,221 @@
import type {
ArenaState,
ClosedPaneEntry,
HtmlArtifactState,
MarkdownArtifactState,
OrchestratorState,
WorkspacePane,
WorkspaceState,
WorkspaceTabKind,
} from '@/api/types';
export const TERM_TAB_PREFIX = 'term_';
export const MAX_CLOSED = 10;
export const SAVE_DEBOUNCE_MS = 300;
export const LEGACY_STORAGE_KEY = 'boocode.workspace.panes';
export function generateId(): string {
return crypto.randomUUID();
}
export function generateTermTabId(): string {
return `${TERM_TAB_PREFIX}${generateId()}`;
}
export function paneTabKinds(pane: WorkspacePane): WorkspaceTabKind[] {
if (pane.tabKinds && pane.tabKinds.length === pane.chatIds.length) return pane.tabKinds;
const fallback: WorkspaceTabKind =
pane.kind === 'coder' || pane.kind === 'terminal' ? pane.kind : 'chat';
return pane.chatIds.map(() => fallback);
}
export function rebuildPane(
pane: WorkspacePane,
ids: string[],
kinds: WorkspaceTabKind[],
desiredActive: number,
): WorkspacePane {
if (ids.length === 0) {
return {
...pane,
kind: 'empty',
chatId: undefined,
chatIds: [],
tabKinds: [],
activeChatIdx: -1,
markdown_artifact_state: undefined,
html_artifact_state: undefined,
};
}
const idx = Math.max(0, Math.min(desiredActive, ids.length - 1));
return {
...pane,
kind: kinds[idx]!,
chatId: ids[idx],
chatIds: ids,
tabKinds: kinds,
activeChatIdx: idx,
};
}
export function filterTabs(
pane: WorkspacePane,
keep: (id: string, idx: number) => boolean,
): { ids: string[]; kinds: WorkspaceTabKind[]; removedTermIds: string[] } {
const kinds = paneTabKinds(pane);
const ids: string[] = [];
const nextKinds: WorkspaceTabKind[] = [];
const removedTermIds: string[] = [];
pane.chatIds.forEach((id, i) => {
if (keep(id, i)) {
ids.push(id);
nextKinds.push(kinds[i]!);
} else if (kinds[i] === 'terminal') {
removedTermIds.push(id);
}
});
return { ids, kinds: nextKinds, removedTermIds };
}
export function emptyPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'empty', chatIds: [], tabKinds: [], activeChatIdx: -1 };
}
export function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], tabKinds: ['chat'], activeChatIdx: 0 };
}
export function appendClosed(stack: ClosedPaneEntry[], pane: WorkspacePane): ClosedPaneEntry[] {
if (pane.kind === 'empty' || pane.kind === 'settings') return stack;
if (pane.chatIds.length === 0) return stack;
const entry = { kind: pane.kind, chatIds: [...pane.chatIds], tabKinds: [...paneTabKinds(pane)], activeChatIdx: pane.activeChatIdx };
const top = stack[stack.length - 1];
if (
top &&
top.kind === entry.kind &&
top.activeChatIdx === entry.activeChatIdx &&
top.chatIds.length === entry.chatIds.length &&
top.chatIds.every((id, i) => id === entry.chatIds[i])
) {
return stack;
}
const next = [...stack, entry];
if (next.length > MAX_CLOSED) next.splice(0, next.length - MAX_CLOSED);
return next;
}
export function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
return kind === 'coder' ? 'BooCoder' : 'Terminal';
}
export function activePaneChatId(pane: WorkspacePane): string | undefined {
const idx = pane.activeChatIdx ?? 0;
if (idx >= 0 && pane.chatIds?.[idx]) return pane.chatIds[idx];
return pane.chatId;
}
function settingsPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 };
}
export { settingsPane };
export function markdownArtifactPane(state: MarkdownArtifactState): WorkspacePane {
return {
id: generateId(),
kind: 'markdown_artifact',
chatIds: [],
activeChatIdx: -1,
markdown_artifact_state: state,
};
}
export function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
return {
id: generateId(),
kind: 'html_artifact',
chatIds: [],
activeChatIdx: -1,
html_artifact_state: state,
};
}
export function orchestratorPane(state: OrchestratorState): WorkspacePane {
return {
id: generateId(),
kind: 'orchestrator',
chatIds: [],
activeChatIdx: -1,
orchestrator_state: state,
};
}
export function arenaPane(state: ArenaState): WorkspacePane {
return {
id: generateId(),
kind: 'arena',
chatIds: [],
activeChatIdx: -1,
arena_state: state,
};
}
function normalizePaneKind(pane: WorkspacePane): WorkspacePane {
let p = pane;
if ((p.kind as string) === 'agent') p = { ...p, kind: 'coder' };
const tabbed = p.kind === 'chat' || p.kind === 'coder' || p.kind === 'terminal';
if (!tabbed) return p;
if (p.kind === 'terminal' && (!p.tabKinds || p.tabKinds.length === 0)) {
return { ...p, chatIds: [p.id], tabKinds: ['terminal'], chatId: p.id, activeChatIdx: 0 };
}
if (!p.tabKinds || p.tabKinds.length !== p.chatIds.length) {
const k: WorkspaceTabKind = p.kind === 'coder' ? 'coder' : p.kind === 'terminal' ? 'terminal' : 'chat';
return { ...p, tabKinds: p.chatIds.map(() => k) };
}
return p;
}
export function normalizePanes(panes: WorkspacePane[]): WorkspacePane[] {
return panes.map(normalizePaneKind);
}
export { normalizePaneKind };
export function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
}
export function toWorkspaceState(raw: unknown): WorkspaceState {
if (Array.isArray(raw)) {
return { panes: raw as WorkspacePane[], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
}
if (raw && typeof raw === 'object' && Array.isArray((raw as WorkspaceState).panes)) {
const env = raw as WorkspaceState;
return {
panes: env.panes,
tabNumbers: env.tabNumbers ?? {},
nextTabNumber: env.nextTabNumber ?? 1,
closedPaneStack: env.closedPaneStack ?? [],
};
}
return { panes: [], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
}
export function nonSettingsCount(panes: WorkspacePane[]): number {
return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0);
}
export function readLegacyPanes(sessionId: string): WorkspacePane[] | null {
try {
const raw = localStorage.getItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as WorkspacePane[];
if (!Array.isArray(parsed) || parsed.length === 0) return null;
return parsed;
} catch {
return null;
}
}

View File

@@ -50,8 +50,6 @@ export function createWsReconnectToast(opts: Options): WsReconnectToast {
failureCount += 1;
const elapsed = Date.now() - firstFailureAt;
// Escalate to red error + Retry button after PERSISTENT_AFTER_MS. Replaces
// the gray toast if it's still showing.
if (persistentId === null && elapsed >= PERSISTENT_AFTER_MS) {
dismissReconnecting();
persistentId = toast.error(`${opts.label}: connection lost`, {

View File

@@ -2,16 +2,20 @@
// pickers (BooChat ModelPicker + BooCode AgentComposerBar). The actual model id
// sent to the backend is never changed — this only affects what's rendered.
//
// qwen3.6-35b-a3b-mxfp4 -> Qwen3.6 35B
// qwopus3.5-9b-coder-mtp -> Qwopus3.5 9B Coder
// qwen3.5-9b-deepseek-v4-mtp -> Qwen3.5 9B Deepseek
// OpenCode Zen/Big Pickle -> Big Pickle
// llama-swap/Qwen 3.6 27B MTP -> Qwen 3.6 27B MTP
// qwen3.6-35b-a3b-mxfp4 -> Qwen3.6 35B
// qwopus3.5-9b-coder-mtp -> Qwopus3.5 9B Coder
// qwen3.5-9b-deepseek-v4-mtp -> Qwen3.5 9B Deepseek
// nemotron-cascade-2-30b-a3b -> Nemotron Cascade 2 30B
// negentropy-4.7-9b -> Negentropy 4.7 9B
// glm-4.7-flash -> GLM 4.7 Flash
// north-mini-code -> North Mini Code
// gemma-3-270m -> Gemma 3 270M
// OpenCode Zen/Big Pickle -> Big Pickle
// llama-swap/Qwen 3.6 27B MTP -> Qwen 3.6 27B MTP
//
// OpenCode surfaces models as "Provider Group/Model Name"; we drop the group
// prefix and show just the model name. Conservative otherwise: ids that don't
// look like the `<family><ver>-<size>-…` shape (e.g. "Opus (latest)",
// "nemotron-nano-4b") are returned unchanged, so friendly labels aren't mangled.
// prefix and show just the model name. Ids that already contain whitespace are
// treated as friendly labels and returned unchanged.
// Quant / format / speculative-decoding tags that carry no meaning for a human
// scanning the picker. Dropped from the label.
@@ -21,6 +25,17 @@ const DROP_TOKENS = new Set([
'awq', 'gptq', 'gguf',
]);
// Family/qualifier tokens that read better fully uppercased than title-cased.
const ACRONYMS = new Set(['glm', 'lfm', 'gpt', 'vl', 'oss', 'rnj', 'ibm']);
// Title-case a token, but uppercase its leading-letter run when it's a known
// acronym so "glm" -> "GLM" and "lfm2.5" -> "LFM2.5".
function titleToken(t: string): string {
const m = /^([a-z]+)(.*)$/.exec(t);
if (m && ACRONYMS.has(m[1])) return m[1].toUpperCase() + m[2];
return t.charAt(0).toUpperCase() + t.slice(1);
}
export function formatModelLabel(raw: string): string {
if (!raw) return raw;
// OpenCode-style "Provider Group/Model Name" → keep just the model name.
@@ -28,23 +43,14 @@ export function formatModelLabel(raw: string): string {
if (slash >= 0) raw = raw.slice(slash + 1).trim();
if (/\s/.test(raw)) return raw; // already a friendly (spaced) label
const tokens = raw.split('-');
const head = tokens[0] ?? '';
// First token must look like a family+version (letters then a digit), e.g.
// qwen3.6 / qwopus3.5. Otherwise leave the id alone.
if (!/^[a-z]+\d/.test(head)) return raw;
const kept: string[] = [];
tokens.forEach((t, i) => {
if (i === 0) {
kept.push(t.charAt(0).toUpperCase() + t.slice(1)); // qwen3.6 -> Qwen3.6
return;
}
if (/^\d+(\.\d+)?b$/.test(t)) { kept.push(t.toUpperCase()); return; } // size: 9B, 27B, 35B
if (/^v\d+$/.test(t)) return; // variant tag: v1, v2, v4
if (/^a\d+b$/.test(t)) return; // MoE active-params tag: a3b
if (DROP_TOKENS.has(t)) return; // quant / format / decoding tags
kept.push(t.charAt(0).toUpperCase() + t.slice(1)); // descriptive: coder, deepseek
raw.split('-').forEach((t, i) => {
if (DROP_TOKENS.has(t)) return; // quant / format / decoding tags
if (/^v\d+$/.test(t)) return; // version-variant tag: v1, v2, v4
if (/^a\d+b$/.test(t)) return; // MoE active-params tag: a3b
if (i > 0 && /^\d+(\.\d+)?[bm]$/.test(t)) { kept.push(t.toUpperCase()); return; } // size: 9B, 27B, 270M
kept.push(titleToken(t)); // family, version, descriptor
});
return kept.join(' ');
return kept.length ? kept.join(' ') : raw;
}

View File

@@ -1,13 +1,3 @@
// Terminal WebSocket wire protocol (centralized; v2 Phase 9 extraction).
//
// The booterm WS multiplexes two directions on one socket with a binary/text
// discriminator (mirrored server-side in apps/booterm):
// - PTY input (keystrokes, paste, hotkey bytes) is sent as a BINARY frame.
// - Control frames are JSON text: outbound {type:'resize',cols,rows};
// inbound {type:'init'} and {type:'exit',code}.
// This module is the single source of that encoding so a server-side protocol
// change is mirrored in one place. Behavior is byte-identical to the prior
// inline encoding scattered across TerminalPane.
// TextEncoder is stateless; a single shared instance is equivalent to the
// per-call `new TextEncoder()` the inline sites used.

View File

@@ -13,10 +13,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
// --- Independent section data fetcher ---
// Each section manages its own loading/error/data state so one failure doesn't
// block the rest of the page.
function useFetch<T>(fetcher: () => Promise<T>): {
data: T | null;
loading: boolean;
@@ -43,12 +39,10 @@ function useFetch<T>(fetcher: () => Promise<T>): {
return { data, loading, error, retry: load };
}
// --- Skeleton pulse placeholder ---
function SkeletonBar({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
}
// --- Number formatting ---
function formatNumber(n: number | null | undefined): string {
if (n == null) return '—';
return n.toLocaleString();
@@ -76,7 +70,6 @@ function formatDate(iso: string | null | undefined): string {
});
}
// --- Summary Cards ---
function SummaryCards({ summary }: { summary: AnalyticsSummary }) {
const cards = [
{
@@ -137,7 +130,6 @@ function SummaryCardsSkeleton() {
);
}
// --- Section wrappers ---
function SectionCard({
title,
loading,
@@ -182,7 +174,6 @@ function EmptyState({ message }: { message: string }) {
return <p className="text-sm text-muted-foreground py-2">{message}</p>;
}
// --- Per-Session Token Table ---
function SessionTable({ sessions }: { sessions: SessionAnalyticsRow[] }) {
if (sessions.length === 0) {
return <EmptyState message="No session token data available yet. Token data is collected as agent sessions run." />;
@@ -218,7 +209,6 @@ function SessionTable({ sessions }: { sessions: SessionAnalyticsRow[] }) {
);
}
// --- Per-Tool Cost Table ---
function ToolTable({ stats }: { stats: ToolCostStat[] }) {
if (stats.length === 0) {
return <EmptyState message="No tool cost data available yet. Stats accumulate after tool calls are made." />;
@@ -255,7 +245,6 @@ function ToolTable({ stats }: { stats: ToolCostStat[] }) {
);
}
// --- Context Window Utilization ---
function ContextSection({ stats }: { stats: ContextWindowStats }) {
if (stats.message_count === 0) {
return <EmptyState message="No context window data available yet. Data is captured during inference." />;
@@ -292,7 +281,6 @@ function ContextSection({ stats }: { stats: ContextWindowStats }) {
);
}
// --- Token Category Breakdown (CSS stacked bar) ---
const CATEGORY_COLORS: Record<string, string> = {
system: 'bg-blue-500',
user: 'bg-green-500',
@@ -356,7 +344,6 @@ function TokenBreakdownSection({ categories }: { categories: TokenBreakdownAgg[]
);
}
// --- Main Page ---
export function Analytics() {
const navigate = useNavigate();

View File

@@ -8,15 +8,25 @@ import { PlaygroundTab } from '@/components/control/PlaygroundTab';
import { BenchTab } from '@/components/control/BenchTab';
import { EvalsTab } from '@/components/control/EvalsTab';
import { ReportsTab } from '@/components/control/ReportsTab';
import { JobsTab } from '@/components/control/JobsTab';
import { cn } from '@/lib/utils';
import { Radio, Activity, ScrollText, Gamepad2, Gauge, Brain, FileText } from 'lucide-react';
import { Radio, Activity, ScrollText, Gamepad2, Gauge, Brain, FileText, ListChecks, Route } from 'lucide-react';
type Tab = 'fleet' | 'activity' | 'logs' | 'playground' | 'bench' | 'evals' | 'reports';
type Tab = 'fleet' | 'activity' | 'logs' | 'playground' | 'bench' | 'evals' | 'jobs' | 'routing' | 'reports';
const CONNECTION_STYLE: Record<'live' | 'connecting' | 'reconnecting' | 'down', { dot: string; label: string }> = {
live: { dot: 'bg-green-500', label: 'live' },
connecting: { dot: 'bg-amber-500 animate-pulse', label: 'connecting' },
reconnecting: { dot: 'bg-amber-500 animate-pulse', label: 'reconnecting' },
down: { dot: 'bg-red-500', label: 'disconnected' },
};
export function Control() {
const [activeTab, setActiveTab] = useState<Tab>('fleet');
const fleet = useControlStream();
const providerIds = fleet.hosts.map((h) => h.providerId);
const conn = CONNECTION_STYLE[fleet.connection] ?? CONNECTION_STYLE.connecting;
const activeJobs = fleet.jobs.filter((j) => j.status === 'running' || j.status === 'queued').length;
// P2.4: Capture drawer state
const [captureDrawer, setCaptureDrawer] = useState<{ requestId: number; providerId: string } | null>(null);
@@ -38,21 +48,23 @@ export function Control() {
return map;
}, [fleet.perfSamples]);
const tabs: Array<{ id: Tab; label: string; icon: typeof Radio; badge?: number }> = [
{ id: 'fleet', label: 'Fleet', icon: Radio },
{ id: 'activity', label: 'Activity', icon: Activity },
{ id: 'logs', label: 'Logs', icon: ScrollText },
{ id: 'playground', label: 'Playground', icon: Gamepad2 },
{ id: 'bench', label: 'Bench', icon: Gauge },
{ id: 'evals', label: 'Evals', icon: Brain },
{ id: 'jobs', label: 'Jobs', icon: ListChecks, badge: activeJobs },
{ id: 'routing', label: 'Routing', icon: Route },
{ id: 'reports', label: 'Reports', icon: FileText },
];
return (
<div className="flex-1 flex flex-col bg-background text-foreground">
{/* Tab bar */}
<div className="flex gap-1 border-b border-border/40 px-4 shrink-0">
{(
[
{ id: 'fleet' as Tab, label: 'Fleet', icon: Radio },
{ id: 'activity' as Tab, label: 'Activity', icon: Activity },
{ id: 'logs' as Tab, label: 'Logs', icon: ScrollText },
{ id: 'playground' as Tab, label: 'Playground', icon: Gamepad2 },
{ id: 'bench' as Tab, label: 'Bench', icon: Gauge },
{ id: 'evals' as Tab, label: 'Evals', icon: Brain },
{ id: 'reports' as Tab, label: 'Reports', icon: FileText },
]
).map((tab) => (
<div className="flex items-center gap-1 border-b border-border/40 px-4 shrink-0">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
@@ -66,14 +78,26 @@ export function Control() {
>
<tab.icon className="size-3.5" />
<span>{tab.label}</span>
{tab.badge != null && tab.badge > 0 && (
<span className="ml-0.5 px-1.5 py-px text-[10px] leading-none rounded-full bg-primary/20 text-primary">{tab.badge}</span>
)}
</button>
))}
{/* B3: live connection status pill */}
<div
className="ml-auto inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] text-muted-foreground"
title={`control stream: ${conn.label}`}
>
<span className={cn('w-1.5 h-1.5 rounded-full', conn.dot)} />
<span>{conn.label}</span>
</div>
</div>
{/* Tab content */}
<div className="flex-1 flex flex-col min-h-0">
{activeTab === 'fleet' && (
<FleetTab hosts={fleet.hosts} gpuMap={gpuMap} />
<FleetTab hosts={fleet.hosts} gpuMap={gpuMap} perfSamples={fleet.perfSamples} connection={fleet.connection} />
)}
{activeTab === 'activity' && (
<ActivityTab
@@ -94,8 +118,14 @@ export function Control() {
{activeTab === 'evals' && (
<EvalsTab providerIds={providerIds} />
)}
{activeTab === 'jobs' && (
<JobsTab jobs={fleet.jobs} />
)}
{activeTab === 'routing' && (
<ReportsTab mode="routing" />
)}
{activeTab === 'reports' && (
<ReportsTab />
<ReportsTab mode="reports" />
)}
</div>

View File

@@ -8,8 +8,6 @@ import { Button } from '@/components/ui/button';
import { useSidebar } from '@/hooks/useSidebar';
import { cn } from '@/lib/utils';
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
function useFetch<T>(fetcher: () => Promise<T>): {
data: T | null;
loading: boolean;
@@ -36,14 +34,10 @@ function useFetch<T>(fetcher: () => Promise<T>): {
return { data, loading, error, retry: load };
}
// ─── Skeleton pulse placeholder ─────────────────────────────────────────────
function SkeletonBar({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
}
// ─── Formatters ─────────────────────────────────────────────────────────────
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, {
@@ -68,28 +62,11 @@ function truncate(str: string, max: number): string {
return str.slice(0, max) + '…';
}
function relTime(iso: string | null | undefined): string {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return formatDate(iso);
}
// ─── Empty state ────────────────────────────────────────────────────────────
function EmptyState({ message }: { message: string }) {
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
}
// ─── Tab bar (same pattern as Results.tsx) ──────────────────────────────────
type TabId = 'all' | 'daily' | 'dreams';
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
@@ -119,8 +96,6 @@ function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => v
);
}
// ─── All Memory Tab ─────────────────────────────────────────────────────────
function AllMemoryTab({ projectId }: { projectId: string }) {
const { data, loading, error, retry } = useFetch(() => api.memory.list(projectId).then((r) => r.entries));
const [expanded, setExpanded] = useState<string | null>(null);
@@ -203,8 +178,6 @@ function AllMemoryTab({ projectId }: { projectId: string }) {
);
}
// ─── Daily Log Tab ──────────────────────────────────────────────────────────
function DailyLogTab({ projectId }: { projectId: string }) {
const { data, loading, error, retry } = useFetch(() => api.memory.daily(projectId).then((r) => r.entries));
const [expanded, setExpanded] = useState<string | null>(null);
@@ -298,8 +271,6 @@ function DailyLogTab({ projectId }: { projectId: string }) {
);
}
// ─── Dreams Tab ─────────────────────────────────────────────────────────────
function DreamsTab({ projectId }: { projectId: string }) {
const { data, loading, error, retry } = useFetch(() => api.memory.dreams(projectId).then((r) => r.entries));
@@ -351,8 +322,6 @@ function DreamsTab({ projectId }: { projectId: string }) {
);
}
// ─── Main Page ──────────────────────────────────────────────────────────────
export function Memory() {
const navigate = useNavigate();
const { data: sidebar, activeSession } = useSidebar();

View File

@@ -8,8 +8,6 @@ import { Button } from '@/components/ui/button';
import { useSidebar } from '@/hooks/useSidebar';
import { cn } from '@/lib/utils';
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
function useFetch<T>(fetcher: () => Promise<T>): {
data: T | null;
loading: boolean;
@@ -36,14 +34,10 @@ function useFetch<T>(fetcher: () => Promise<T>): {
return { data, loading, error, retry: load };
}
// ─── Skeleton ────────────────────────────────────────────────────────────────
function SkeletonBar({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
}
// ─── Formatters ──────────────────────────────────────────────────────────────
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, {
@@ -70,8 +64,6 @@ function truncate(str: string, max: number): string {
return str.slice(0, max) + '…';
}
// ─── Status dot (shared visual language with OrchestratorPane/ArenaPane) ──────
type DotStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'pending';
function StatusDot({ status }: { status: DotStatus }) {
@@ -94,8 +86,6 @@ function StatusDot({ status }: { status: DotStatus }) {
return <span aria-label={status} className={cn('inline-block w-2 h-2 rounded-full shrink-0', cls)} />;
}
// ─── Tab bar ─────────────────────────────────────────────────────────────────
type TabId = 'runs' | 'battles';
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
@@ -124,14 +114,10 @@ function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => v
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyState({ message }: { message: string }) {
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
}
// ─── Project selector ────────────────────────────────────────────────────────
function ProjectSelector({
projects,
value,
@@ -156,8 +142,6 @@ function ProjectSelector({
);
}
// ─── Analysis Runs tab ───────────────────────────────────────────────────────
function AnalysisRunsTab({ projectId }: { projectId: string }) {
const { data, loading, error, retry } = useFetch(() => api.runs.list(projectId).then((r) => r.runs));
@@ -238,8 +222,6 @@ function AnalysisRunsTab({ projectId }: { projectId: string }) {
);
}
// ─── Arena Battles tab ───────────────────────────────────────────────────────
function ArenaBattlesTab({ projectId }: { projectId: string }) {
const { data, loading, error, retry } = useFetch(() => api.battles.list(projectId).then((r) => r.battles));
@@ -328,8 +310,6 @@ function ArenaBattlesTab({ projectId }: { projectId: string }) {
);
}
// ─── Battle analysis preview (fetches analysis.md on expand) ─────────────────
function AnalysisPreview({ battleId }: { battleId: string }) {
const { data, loading, error, retry } = useFetch(() => api.battles.getAnalysis(battleId).then((r) => r.text));
@@ -360,8 +340,6 @@ function AnalysisPreview({ battleId }: { battleId: string }) {
);
}
// ─── Summary strip ───────────────────────────────────────────────────────────
function SummaryCards({
runs,
battles,
@@ -413,8 +391,6 @@ function SummaryCardsSkeleton() {
);
}
// ─── Main Page ───────────────────────────────────────────────────────────────
export function Results() {
const navigate = useNavigate();
const { data: sidebar, activeSession } = useSidebar();

View File

@@ -3,10 +3,6 @@
* for both unified and side-by-side (split) diff views.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type DiffLineType = 'add' | 'remove' | 'context' | 'header';
export interface DiffLine {
@@ -39,10 +35,6 @@ export type SplitRow =
| { kind: 'header'; content: string }
| { kind: 'pair'; left: SplitDisplayLine | null; right: SplitDisplayLine | null };
// ---------------------------------------------------------------------------
// parseDiff
// ---------------------------------------------------------------------------
/**
* Parse unified diff text into an array of ParsedDiffFile objects.
*
@@ -67,10 +59,6 @@ export function parseDiff(diffBody: string): ParsedDiffFile[] {
return files;
}
// ---------------------------------------------------------------------------
// buildSplitRows
// ---------------------------------------------------------------------------
/**
* Build side-by-side (split) display rows from a parsed diff file.
*
@@ -154,10 +142,6 @@ export function buildSplitRows(file: ParsedDiffFile): SplitRow[] {
return rows;
}
// ---------------------------------------------------------------------------
// reconstructNewContent
// ---------------------------------------------------------------------------
/**
* Reconstruct the "new" file content from diff hunks by concatenating
* addition and context lines. Useful for syntax-highlighting the split
@@ -177,10 +161,6 @@ export function reconstructNewContent(hunks: DiffHunk[]): string {
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Extract file path from `+++ b/<path>` or `--- a/<path>` metadata lines. */
function extractPath(lines: string[]): string {
// Try +++ b/<path> first (most reliable for the "new" side)