chore: snapshot main sync
This commit is contained in:
@@ -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:*",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
20
apps/web/src/api/__tests__/client.test.ts
Normal file
20
apps/web/src/api/__tests__/client.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
13
apps/web/src/api/__tests__/constants.test.ts
Normal file
13
apps/web/src/api/__tests__/constants.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
28
apps/web/src/api/analytics-types.ts
Normal file
28
apps/web/src/api/analytics-types.ts
Normal 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;
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
229
apps/web/src/api/coder-types.ts
Normal file
229
apps/web/src/api/coder-types.ts
Normal 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';
|
||||
6
apps/web/src/api/index.ts
Normal file
6
apps/web/src/api/index.ts
Normal 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';
|
||||
17
apps/web/src/api/memory-types.ts
Normal file
17
apps/web/src/api/memory-types.ts
Normal 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;
|
||||
}
|
||||
82
apps/web/src/api/project-types.ts
Normal file
82
apps/web/src/api/project-types.ts
Normal 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;
|
||||
}
|
||||
247
apps/web/src/api/session-types.ts
Normal file
247
apps/web/src/api/session-types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
38
apps/web/src/components/control/ControlErrorBoundary.tsx
Normal file
38
apps/web/src/components/control/ControlErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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">
|
||||
|
||||
80
apps/web/src/components/control/JobsTab.tsx
Normal file
80
apps/web/src/components/control/JobsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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>('');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
20
apps/web/src/components/control/__tests__/VramGauge.test.tsx
Normal file
20
apps/web/src/components/control/__tests__/VramGauge.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
29
apps/web/src/components/control/useThemeEpoch.ts
Normal file
29
apps/web/src/components/control/useThemeEpoch.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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())), []);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
221
apps/web/src/hooks/workspace-pane-ops.ts
Normal file
221
apps/web/src/hooks/workspace-pane-ops.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user