feat(web,coder): arena pane — compare 2-6 AI competitors on same prompt
Arena is a new pane kind for competitive AI evaluation. A Battle runs the same prompt against 2-6 Contestants across two concurrent lanes: local lane (llama-swap models, serial) and cloud lane (parallel). Added to all three registries: @boocode/contracts WsFrameSchema, server InferenceFrame, and web WsFrame. Backend (apps/coder): - arena-runner: battle scheduler, lane classifier, benchmark, results writer, resume, user winner override - arena-analyzer: two-stage digest→judge analysis on DEFAULT_MODEL - arena-decisions: status transitions and resume logic (unit-tested) - arena-analyzer-helpers: pure helper functions (unit-tested) - arena-model-call: model call utility for analysis - arena routes: create/get/list/stop/analyze/cross-examine/winner/diff - schema: battles, contestants, cross_examinations tables (idempotent) - remove old /api/arena* routes and tasks.arena_id column Frontend (apps/web): - ArenaLauncherDialog: battle type, prompt, contestant selection - ArenaPane: live roster, streaming output, analysis, cross-exam - DiffView: unified diff with line-by-line color for coding contests - Winner override per-row dropdown (Trophy icon) - battle_updated WS handler for live winner/analysis updates - arena pane kind in Workspace, ChatTabBar, useSidebar Cross-app: - ArenaState and ArenaContestantShape/WsFrame types (contracts) - battle_* frames in WsFrameSchema, InferenceFrame, and web WsFrame - manifest.json written per battle results folder - /Arena added to .gitignore Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRai
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { ThemeFx } from '@/components/fx/ThemeFx';
|
||||
import { FlowLauncherDialog } from '@/components/FlowLauncherDialog';
|
||||
import { ArenaLauncherDialog } from '@/components/ArenaLauncherDialog';
|
||||
|
||||
function SessionRightRail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -102,6 +103,7 @@ function AppShell() {
|
||||
</Routes>
|
||||
<Toaster position="bottom-right" />
|
||||
<FlowLauncherDialog />
|
||||
<ArenaLauncherDialog />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,9 @@ import type {
|
||||
WorkspaceState,
|
||||
FlowRunRow,
|
||||
FlowStepRow,
|
||||
BattleShape,
|
||||
ContestantShape,
|
||||
CrossExaminationShape,
|
||||
} from './types';
|
||||
|
||||
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
|
||||
@@ -518,6 +521,63 @@ export const api = {
|
||||
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
|
||||
},
|
||||
|
||||
// Arena battle API — proxied to boocoder at /api/coder/battles/*.
|
||||
battles: {
|
||||
create: (body: {
|
||||
project_id: string;
|
||||
battle_type: 'coding' | 'qa';
|
||||
prompt: string;
|
||||
contestants: Array<{ identity: string; model: string }>;
|
||||
}) =>
|
||||
request<{ battle_id: string }>('/api/coder/battles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
list: (projectId: string) =>
|
||||
request<{ battles: BattleShape[] }>(
|
||||
`/api/coder/battles?project_id=${encodeURIComponent(projectId)}`,
|
||||
),
|
||||
get: (battleId: string) =>
|
||||
request<{
|
||||
battle: BattleShape;
|
||||
contestants: ContestantShape[];
|
||||
cross_examinations: CrossExaminationShape[];
|
||||
}>(`/api/coder/battles/${encodeURIComponent(battleId)}`),
|
||||
stop: (battleId: string) =>
|
||||
request<{ cancelled: boolean }>(
|
||||
`/api/coder/battles/${encodeURIComponent(battleId)}/stop`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
analyze: (battleId: string) =>
|
||||
request<{ triggered: boolean }>(
|
||||
`/api/coder/battles/${encodeURIComponent(battleId)}/analyze`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
crossExamine: (battleId: string, body: { identity: string; model: string }) =>
|
||||
request<{ cross_exam_id: string }>(
|
||||
`/api/coder/battles/${encodeURIComponent(battleId)}/cross-examine`,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
),
|
||||
getAnalysis: (battleId: string) =>
|
||||
request<{ text: string }>(
|
||||
`/api/coder/battles/${encodeURIComponent(battleId)}/analysis`,
|
||||
),
|
||||
generatePrompt: (description: string) =>
|
||||
request<{ prompt: string }>('/api/coder/battles/generate-prompt', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ description }),
|
||||
}),
|
||||
setWinner: (battleId: string, body: { winner_contestant_id: string | null }) =>
|
||||
request<{ ok: boolean }>(
|
||||
`/api/coder/battles/${encodeURIComponent(battleId)}/winner`,
|
||||
{ method: 'PATCH', body: JSON.stringify(body) },
|
||||
),
|
||||
getDiff: (battleId: string, contestantId: string) =>
|
||||
request<{ diff: string }>(
|
||||
`/api/coder/battles/${encodeURIComponent(battleId)}/contestants/${encodeURIComponent(contestantId)}/diff`,
|
||||
),
|
||||
},
|
||||
|
||||
skills: {
|
||||
list: () => request<{ skills: Skill[] }>('/api/skills'),
|
||||
},
|
||||
|
||||
@@ -391,7 +391,8 @@ export type WorkspacePaneKind =
|
||||
| 'settings'
|
||||
| 'markdown_artifact'
|
||||
| 'html_artifact'
|
||||
| 'orchestrator';
|
||||
| '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
|
||||
@@ -424,6 +425,10 @@ export interface OrchestratorState {
|
||||
band: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
// Arena pane state — 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;
|
||||
@@ -475,6 +480,8 @@ export interface WorkspacePane {
|
||||
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;
|
||||
@@ -592,4 +599,31 @@ export type WsFrame =
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled';
|
||||
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
report?: string;
|
||||
}
|
||||
// arena frames: battle lifecycle + per-contestant streaming
|
||||
| {
|
||||
type: 'battle_started';
|
||||
battle_id: string;
|
||||
battle_type: 'coding' | 'qa';
|
||||
prompt: string;
|
||||
contestants: Array<{ id: string; identity: string; model: string; lane: 'local' | 'cloud' }>;
|
||||
}
|
||||
| {
|
||||
type: 'contestant_updated';
|
||||
battle_id: string;
|
||||
contestant_id: string;
|
||||
status?: 'queued' | 'running' | 'done' | 'error';
|
||||
duration_ms?: number;
|
||||
tokens_per_sec?: number;
|
||||
battle_status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
delta?: string;
|
||||
error?: string;
|
||||
}
|
||||
| {
|
||||
type: 'battle_updated';
|
||||
battle_id: string;
|
||||
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
winner_contestant_id?: string | null;
|
||||
analysis_ready?: boolean;
|
||||
cross_exam_id?: string;
|
||||
};
|
||||
|
||||
410
apps/web/src/components/ArenaLauncherDialog.tsx
Normal file
410
apps/web/src/components/ArenaLauncherDialog.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
// ArenaLauncherDialog — mirrors FlowLauncherDialog.
|
||||
// Opens via sessionEvents 'open_arena_launcher'.
|
||||
// Flow: pick Battle Type → write/generate prompt → add 2–6 contestants → Start.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Minus, Plus, Swords, TriangleAlert, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/api/client';
|
||||
import type { Agent, ProviderSnapshotEntry } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type BattleType = 'coding' | 'qa';
|
||||
|
||||
interface Contestant {
|
||||
key: string; // local unique key for React
|
||||
identity: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function newContestant(): Contestant {
|
||||
return { key: crypto.randomUUID(), identity: '', model: '' };
|
||||
}
|
||||
|
||||
function isDuplicate(contestants: Contestant[], c: Contestant): boolean {
|
||||
const dups = contestants.filter(
|
||||
(x) => x.key !== c.key && x.identity === c.identity && x.model === c.model && x.identity !== '',
|
||||
);
|
||||
return dups.length > 0;
|
||||
}
|
||||
|
||||
function hasDuplicatePair(contestants: Contestant[]): boolean {
|
||||
return contestants.some((c) => isDuplicate(contestants, c));
|
||||
}
|
||||
|
||||
function localCount(battleType: BattleType, contestants: Contestant[], snapshot: ProviderSnapshotEntry[] | null): number {
|
||||
if (battleType === 'qa') return contestants.filter((c) => c.identity !== '').length;
|
||||
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,
|
||||
snapshot,
|
||||
agents,
|
||||
allContestants,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
removable,
|
||||
}: {
|
||||
contestant: Contestant;
|
||||
battleType: BattleType;
|
||||
snapshot: ProviderSnapshotEntry[] | null;
|
||||
agents: Agent[];
|
||||
allContestants: Contestant[];
|
||||
onUpdate: (patch: Partial<Contestant>) => void;
|
||||
onRemove: () => void;
|
||||
removable: boolean;
|
||||
}) {
|
||||
const dup = isDuplicate(allContestants, contestant);
|
||||
|
||||
// Identity options for Coding: installed provider names.
|
||||
// Identity options for Q&A: agents by id.
|
||||
const identityOptions =
|
||||
battleType === 'coding'
|
||||
? (snapshot ?? [])
|
||||
.filter((e) => e.installed && e.enabled)
|
||||
.map((e) => ({ value: e.name, label: e.label }))
|
||||
: agents.map((a) => ({ value: a.id, label: a.name }));
|
||||
|
||||
// Model options: for Coding use the selected provider's models; for Q&A use boocode models.
|
||||
const modelOptions: { value: string; label: string }[] = (() => {
|
||||
if (battleType === 'coding') {
|
||||
const provider = (snapshot ?? []).find((e) => e.name === contestant.identity);
|
||||
return (provider?.models ?? []).map((m) => ({ value: m.id, label: m.label }));
|
||||
}
|
||||
// Q&A: native backend only — use boocode models
|
||||
const boocode = (snapshot ?? []).find((e) => e.name === 'boocode');
|
||||
return (boocode?.models ?? []).map((m) => ({ value: m.id, label: m.label }));
|
||||
})();
|
||||
|
||||
function handleIdentityChange(value: string) {
|
||||
// Reset model when identity changes so stale model doesn't persist.
|
||||
onUpdate({ identity: value, model: '' });
|
||||
}
|
||||
|
||||
function handleModelChange(value: string) {
|
||||
onUpdate({ model: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', dup && 'opacity-60')}>
|
||||
<select
|
||||
value={contestant.identity}
|
||||
onChange={(e) => handleIdentityChange(e.target.value)}
|
||||
className="flex-1 min-w-0 text-xs border border-border rounded bg-background px-2 py-1.5 text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
aria-label={battleType === 'coding' ? 'Backend' : 'Persona'}
|
||||
>
|
||||
<option value="">{battleType === 'coding' ? 'Backend…' : 'Persona…'}</option>
|
||||
{identityOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={contestant.model}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
disabled={!contestant.identity}
|
||||
className="flex-1 min-w-0 text-xs border border-border rounded bg-background px-2 py-1.5 text-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
aria-label="Model"
|
||||
>
|
||||
<option value="">Model…</option>
|
||||
{modelOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{dup && (
|
||||
<span title="Duplicate contestant" className="shrink-0 text-destructive">
|
||||
<TriangleAlert size={12} />
|
||||
</span>
|
||||
)}
|
||||
{removable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="shrink-0 inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Remove contestant"
|
||||
>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ArenaLauncherDialog ──────────────────────────────────────────────────────
|
||||
|
||||
export function ArenaLauncherDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [placement, setPlacement] = useState<'new' | 'split'>('new');
|
||||
const [battleType, setBattleType] = useState<BattleType>('coding');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [contestants, setContestants] = useState<Contestant[]>(() => [
|
||||
newContestant(),
|
||||
newContestant(),
|
||||
]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const snapshot = useProviderSnapshot();
|
||||
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((ev) => {
|
||||
if (ev.type !== 'open_arena_launcher') return;
|
||||
setProjectId(ev.project_id);
|
||||
setPlacement(ev.placement ?? 'new');
|
||||
setBattleType('coding');
|
||||
setPrompt('');
|
||||
setContestants([newContestant(), newContestant()]);
|
||||
setGenerating(false);
|
||||
setStarting(false);
|
||||
setOpen(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load agents list when dialog opens (for Q&A mode).
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) return;
|
||||
api.agents.list(projectId)
|
||||
.then((r) => setAgents(r.agents))
|
||||
.catch(() => {});
|
||||
}, [open, projectId]);
|
||||
|
||||
const handleGeneratePrompt = useCallback(async () => {
|
||||
const description = prompt.trim();
|
||||
if (!description || generating) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
const { prompt: generated } = await api.battles.generatePrompt(description);
|
||||
setPrompt(generated);
|
||||
promptRef.current?.focus();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Generate failed');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [prompt, generating]);
|
||||
|
||||
function updateContestant(key: string, patch: Partial<Contestant>) {
|
||||
setContestants((prev) => prev.map((c) => (c.key === key ? { ...c, ...patch } : c)));
|
||||
}
|
||||
|
||||
function removeContestant(key: string) {
|
||||
setContestants((prev) => prev.filter((c) => c.key !== key));
|
||||
}
|
||||
|
||||
function addContestant() {
|
||||
if (contestants.length >= 6) return;
|
||||
setContestants((prev) => [...prev, newContestant()]);
|
||||
}
|
||||
|
||||
const canStart =
|
||||
!starting &&
|
||||
prompt.trim().length > 0 &&
|
||||
contestants.length >= 2 &&
|
||||
contestants.every((c) => c.identity !== '' && c.model !== '') &&
|
||||
!hasDuplicatePair(contestants);
|
||||
|
||||
const localLaneCount = localCount(battleType, contestants, snapshot);
|
||||
const showLocalWarning = localLaneCount >= 3;
|
||||
|
||||
async function handleStart() {
|
||||
if (!canStart) return;
|
||||
setStarting(true);
|
||||
try {
|
||||
const { battle_id } = await api.battles.create({
|
||||
project_id: projectId,
|
||||
battle_type: battleType,
|
||||
prompt: prompt.trim(),
|
||||
contestants: contestants.map((c) => ({ identity: c.identity, model: c.model })),
|
||||
});
|
||||
sessionEvents.emit({
|
||||
type: 'open_arena_pane',
|
||||
state: { battle_id, battle_type: battleType, prompt: prompt.trim() },
|
||||
placement,
|
||||
});
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to start battle');
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="flex flex-col gap-0 p-0 max-h-[85vh] sm:max-w-lg overflow-hidden"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader className="gap-1.5 px-4 pt-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Swords size={14} className="text-muted-foreground shrink-0" />
|
||||
<DialogTitle className="text-sm font-medium">New Arena Battle</DialogTitle>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run the same prompt against multiple AI competitors and pick the best result.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 overflow-y-auto overscroll-contain px-4 py-3">
|
||||
{/* Battle type */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Battle type</Label>
|
||||
<div className="flex gap-1.5">
|
||||
{(['coding', 'qa'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => { setBattleType(t); setContestants([newContestant(), newContestant()]); }}
|
||||
aria-pressed={battleType === t}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg border py-1.5 text-xs transition-colors capitalize',
|
||||
battleType === t
|
||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||||
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t === 'coding' ? 'Coding' : 'Q&A'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{battleType === 'coding'
|
||||
? 'Each contestant works in its own isolated worktree. Results include a diff.'
|
||||
: 'Contestants answer the prompt as text. No code changes.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="arena-prompt" className="text-xs text-muted-foreground">
|
||||
Prompt
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleGeneratePrompt()}
|
||||
disabled={generating || prompt.trim().length === 0}
|
||||
className="text-xs text-primary hover:text-primary/80 disabled:opacity-40 disabled:cursor-default flex items-center gap-1"
|
||||
title="Expand your description into a fuller battle prompt"
|
||||
>
|
||||
{generating && <Loader2 size={10} className="animate-spin" />}
|
||||
Generate prompt
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="arena-prompt"
|
||||
ref={promptRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={
|
||||
battleType === 'coding'
|
||||
? 'Describe a coding task, or enter a short description and click Generate prompt…'
|
||||
: 'Ask a question or describe a topic, or enter a short description and click Generate prompt…'
|
||||
}
|
||||
rows={4}
|
||||
className="w-full text-sm border border-border rounded bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contestants */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Contestants ({contestants.length}/6)
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{battleType === 'coding' ? 'Backend + Model' : 'Persona + Model'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{contestants.map((c) => (
|
||||
<ContestantRow
|
||||
key={c.key}
|
||||
contestant={c}
|
||||
battleType={battleType}
|
||||
snapshot={snapshot}
|
||||
agents={agents}
|
||||
allContestants={contestants}
|
||||
onUpdate={(patch) => updateContestant(c.key, patch)}
|
||||
onRemove={() => removeContestant(c.key)}
|
||||
removable={contestants.length > 2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{contestants.length < 6 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addContestant}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground py-1"
|
||||
>
|
||||
<Plus size={12} /> Add contestant
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasDuplicatePair(contestants) && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<TriangleAlert size={12} />
|
||||
Duplicate contestants (same identity + model) are not allowed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLocalWarning && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<TriangleAlert size={12} />
|
||||
{localLaneCount} local contestants will run serially (one GPU load at a time). This battle will take a while.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t shrink-0 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={12} /> Cancel
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleStart()}
|
||||
disabled={!canStart}
|
||||
>
|
||||
{starting ? <Loader2 className="animate-spin" /> : <Swords size={14} />}
|
||||
Start battle
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ interface Props {
|
||||
onNewTab: (kind: WorkspaceTabKind) => void;
|
||||
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||
onNewOrchestrator?: () => void;
|
||||
onNewArena?: () => void;
|
||||
onReopenPane?: () => void;
|
||||
onShowHistory: () => void;
|
||||
onRename: (chatId: string, name: string) => Promise<void>;
|
||||
@@ -69,6 +70,7 @@ export function ChatTabBar({
|
||||
onNewTab,
|
||||
onSplitPane,
|
||||
onNewOrchestrator,
|
||||
onNewArena,
|
||||
onReopenPane,
|
||||
onShowHistory,
|
||||
onRename,
|
||||
@@ -230,6 +232,7 @@ export function ChatTabBar({
|
||||
onNewTab={onNewTab}
|
||||
onSplitPane={onSplitPane}
|
||||
onNewOrchestrator={onNewOrchestrator}
|
||||
onNewArena={onNewArena}
|
||||
onReopenPane={onReopenPane}
|
||||
onShowHistory={onShowHistory}
|
||||
onRemovePane={onRemovePane}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, Workflow, X } from 'lucide-react';
|
||||
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Swords, Terminal, Workflow, X } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -19,6 +19,8 @@ interface Props {
|
||||
// When provided, shows a "New Orchestrator" item that opens the flow launcher.
|
||||
// Orchestrators are always split (run-bound; can't live as a tab in another pane).
|
||||
onNewOrchestrator?: () => void;
|
||||
// When provided, shows a "New Arena" item that opens the arena launcher.
|
||||
onNewArena?: () => void;
|
||||
onReopenPane?: () => void;
|
||||
onShowHistory: () => void;
|
||||
onRemovePane?: () => void;
|
||||
@@ -35,6 +37,7 @@ export function PaneHeaderActions({
|
||||
onNewTab,
|
||||
onSplitPane,
|
||||
onNewOrchestrator,
|
||||
onNewArena,
|
||||
onReopenPane,
|
||||
onShowHistory,
|
||||
onRemovePane,
|
||||
@@ -71,6 +74,11 @@ export function PaneHeaderActions({
|
||||
<Workflow size={14} /> New Orchestrator
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onNewArena && (
|
||||
<DropdownMenuItem onSelect={onNewArena}>
|
||||
<Swords size={14} /> New Arena
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -101,6 +109,11 @@ export function PaneHeaderActions({
|
||||
<Workflow size={14} /> New Orchestrator
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onNewArena && (
|
||||
<DropdownMenuItem onSelect={onNewArena}>
|
||||
<Swords size={14} /> New Arena
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CoderPane } from '@/components/panes/CoderPane';
|
||||
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
|
||||
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
|
||||
import { OrchestratorPane } from '@/components/panes/OrchestratorPane';
|
||||
import { ArenaPane } from '@/components/panes/ArenaPane';
|
||||
import { ChatTabBar, type TabDescriptor } from '@/components/ChatTabBar';
|
||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -134,6 +135,14 @@ export function Workspace({
|
||||
});
|
||||
}
|
||||
|
||||
function handleNewArena() {
|
||||
sessionEvents.emit({
|
||||
type: 'open_arena_launcher',
|
||||
project_id: projectId,
|
||||
placement: 'split',
|
||||
});
|
||||
}
|
||||
|
||||
// v1.10 booterm + mixed tabs: per-terminal-TAB label, keyed by the terminal
|
||||
// tab id (which keys its tmux session). Numbered across the workspace.
|
||||
const terminalLabels = useMemo(() => {
|
||||
@@ -180,6 +189,7 @@ export function Workspace({
|
||||
const isTerminal = pane.kind === 'terminal';
|
||||
const isCoder = pane.kind === 'coder';
|
||||
const isOrchestrator = pane.kind === 'orchestrator';
|
||||
const isArena = pane.kind === 'arena';
|
||||
const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact';
|
||||
// v1.9: when maximized, hide every pane except the settings one.
|
||||
// display:none keeps the React tree mounted so streams / drafts
|
||||
@@ -192,8 +202,8 @@ export function Workspace({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Terminal + coder + orchestrator panes own their tab strip (no chats, no ChatTabBar).
|
||||
const isChromeless = isSettings || isTerminal || isCoder || isArtifact || isOrchestrator;
|
||||
// Terminal + coder + orchestrator + arena panes own their tab strip (no chats, no ChatTabBar).
|
||||
const isChromeless = isSettings || isTerminal || isCoder || isArtifact || isOrchestrator || isArena;
|
||||
return (
|
||||
<div
|
||||
key={pane.id}
|
||||
@@ -218,7 +228,7 @@ export function Workspace({
|
||||
(chat / coder / terminal / empty-landing). The "+" adds a tab
|
||||
of any kind; Split adds a pane. Settings/artifact panes own
|
||||
their own headers. Hidden on mobile (mobile uses pane panes). */}
|
||||
{!isMobile && !isSettings && !isArtifact && !isOrchestrator && (
|
||||
{!isMobile && !isSettings && !isArtifact && !isOrchestrator && !isArena && (
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={paneTabs(pane)}
|
||||
@@ -231,6 +241,7 @@ export function Workspace({
|
||||
onNewTab={(kind) => void createTab(idx, kind)}
|
||||
onSplitPane={(kind) => onAddPane(kind)}
|
||||
onNewOrchestrator={handleNewOrchestrator}
|
||||
onNewArena={handleNewArena}
|
||||
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||
onShowHistory={() => openSessionHistory(idx)}
|
||||
onRename={renameChat}
|
||||
@@ -277,6 +288,12 @@ export function Workspace({
|
||||
state={pane.orchestrator_state}
|
||||
onClose={() => removePane(idx)}
|
||||
/>
|
||||
) : pane.kind === 'arena' && pane.arena_state ? (
|
||||
<ArenaPane
|
||||
state={pane.arena_state}
|
||||
projectId={projectId}
|
||||
onClose={() => removePane(idx)}
|
||||
/>
|
||||
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
|
||||
<MarkdownArtifactPane
|
||||
chatId={pane.markdown_artifact_state.chat_id}
|
||||
|
||||
664
apps/web/src/components/panes/ArenaPane.tsx
Normal file
664
apps/web/src/components/panes/ArenaPane.tsx
Normal file
@@ -0,0 +1,664 @@
|
||||
// ArenaPane — live view for an Arena battle.
|
||||
// Mirrors OrchestratorPane: header with status/winner, contestant roster
|
||||
// (collapsed rows, expand-one), analysis panel, cross-examination control.
|
||||
//
|
||||
// Subscribes to the coder user channel (via useCoderUserEvents → sessionEvents)
|
||||
// for battle_started / contestant_updated / battle_updated frames.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Loader2, MoreHorizontal, RotateCcw, Swords, Trophy, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { ArenaState, BattleShape, ContestantShape, CrossExaminationShape, ProviderSnapshotEntry } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Status dot (mirrors FlowStepStatusDot) ───────────────────────────────────
|
||||
|
||||
function ContestantStatusDot({ status }: { status: ContestantShape['status'] }) {
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<span
|
||||
aria-label="running"
|
||||
className="inline-block w-3 h-3 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const cls =
|
||||
status === 'done'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'error'
|
||||
? 'bg-destructive'
|
||||
: 'bg-muted-foreground/40'; // queued
|
||||
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
|
||||
className={cn(
|
||||
'text-[10px] px-1 py-0.5 rounded shrink-0',
|
||||
lane === 'local'
|
||||
? 'bg-sky-500/10 text-sky-600 dark:text-sky-400'
|
||||
: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
)}
|
||||
>
|
||||
{lane}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Duration formatter ───────────────────────────────────────────────────────
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms == null) return '';
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
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(() => {
|
||||
const id = setInterval(() => setElapsed(Date.now() - startedAt), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [startedAt]);
|
||||
return <span>{formatDuration(elapsed)}</span>;
|
||||
}
|
||||
|
||||
// ─── DiffView ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function DiffView({ diff }: { diff: string }) {
|
||||
const lines = diff.split('\n');
|
||||
return (
|
||||
<div className="border-t border-border/50">
|
||||
<div className="px-3 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Diff
|
||||
</div>
|
||||
<pre className="px-3 pb-3 text-xs font-mono whitespace-pre leading-relaxed overflow-x-auto">
|
||||
{lines.map((line, i) => {
|
||||
const cls =
|
||||
line.startsWith('+') && !line.startsWith('+++')
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: line.startsWith('-') && !line.startsWith('---')
|
||||
? 'text-destructive'
|
||||
: line.startsWith('@@')
|
||||
? 'text-violet-500 dark:text-violet-400'
|
||||
: 'text-muted-foreground';
|
||||
return (
|
||||
<span key={i} className={cn('block', cls)}>
|
||||
{line || ' '}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ContestantRow ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ContestantRowState {
|
||||
data: ContestantShape;
|
||||
output: string;
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
function ContestantRow({
|
||||
row,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isWinner,
|
||||
battleId,
|
||||
battleType,
|
||||
}: {
|
||||
row: ContestantRowState;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
isWinner: boolean;
|
||||
battleId: string;
|
||||
battleType: 'coding' | 'qa';
|
||||
}) {
|
||||
const { data, output, startedAt } = row;
|
||||
const label = `${data.identity} / ${data.model}`;
|
||||
|
||||
// Lazy-fetch diff for coding contestants once they are done and expanded.
|
||||
const [diff, setDiff] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isExpanded || battleType !== 'coding' || data.status !== 'done') return;
|
||||
if (diff !== null) return;
|
||||
api.battles.getDiff(battleId, data.id)
|
||||
.then(({ diff: d }) => setDiff(d))
|
||||
.catch(() => setDiff(''));
|
||||
}, [isExpanded, battleType, data.status, data.id, battleId, diff]);
|
||||
|
||||
async function handleSetWinner(contestantId: string | null) {
|
||||
try {
|
||||
await api.battles.setWinner(battleId, { winner_contestant_id: contestantId });
|
||||
} catch {
|
||||
// WS frame updates the badge; a failed call just leaves it unchanged
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<ContestantStatusDot status={data.status} />
|
||||
<span className="text-sm flex-1 truncate min-w-0">{label}</span>
|
||||
{isWinner && (
|
||||
<Trophy size={11} className="shrink-0 text-emerald-500" aria-label="winner" />
|
||||
)}
|
||||
<LaneBadge lane={data.lane} />
|
||||
{data.status === 'running' && startedAt != null ? (
|
||||
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||
<LiveDuration startedAt={startedAt} />
|
||||
</span>
|
||||
) : data.duration_ms != null ? (
|
||||
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatDuration(data.duration_ms)}
|
||||
</span>
|
||||
) : null}
|
||||
{data.tokens_per_sec != null && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 hidden sm:block tabular-nums">
|
||||
{data.tokens_per_sec.toFixed(1)} tok/s
|
||||
</span>
|
||||
)}
|
||||
{data.status === 'error' && (
|
||||
<span className="text-xs text-destructive shrink-0 hidden sm:block truncate max-w-[100px]" title={data.error ?? ''}>
|
||||
{data.error ?? 'error'}
|
||||
</span>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} className="shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{/* Row menu: winner override. Stop propagation so the row toggle isn't triggered. */}
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
aria-label="Contestant options"
|
||||
>
|
||||
<MoreHorizontal size={12} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!isWinner && (
|
||||
<DropdownMenuItem onSelect={() => void handleSetWinner(data.id)}>
|
||||
<Trophy size={12} /> Set as winner
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isWinner && (
|
||||
<DropdownMenuItem onSelect={() => void handleSetWinner(null)}>
|
||||
Clear winner
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/50 bg-muted/10 max-h-[55vh] overflow-y-auto">
|
||||
{output.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
||||
{data.status === 'queued'
|
||||
? 'Waiting to start…'
|
||||
: data.status === 'error'
|
||||
? data.error ?? 'Error'
|
||||
: 'Connecting…'}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap leading-relaxed break-all text-foreground">
|
||||
{output}
|
||||
</pre>
|
||||
)}
|
||||
{battleType === 'coding' && data.status === 'done' && diff && (
|
||||
<DiffView diff={diff} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CrossExaminationPanel ────────────────────────────────────────────────────
|
||||
|
||||
function CrossExaminationPanel({
|
||||
battleId,
|
||||
crossExams,
|
||||
snapshot,
|
||||
}: {
|
||||
battleId: string;
|
||||
crossExams: CrossExaminationShape[];
|
||||
snapshot: ProviderSnapshotEntry[] | null;
|
||||
}) {
|
||||
const [identity, setIdentity] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const identityOptions = (snapshot ?? [])
|
||||
.filter((e) => e.installed && e.enabled)
|
||||
.map((e) => ({ value: e.name, label: e.label }));
|
||||
|
||||
const modelOptions = (() => {
|
||||
const provider = (snapshot ?? []).find((e) => e.name === identity);
|
||||
return (provider?.models ?? []).map((m) => ({ value: m.id, label: m.label }));
|
||||
})();
|
||||
|
||||
async function handleRun() {
|
||||
if (!identity || !model || running) return;
|
||||
setRunning(true);
|
||||
try {
|
||||
await api.battles.crossExamine(battleId, { identity, model });
|
||||
// The verdict arrives via battle_updated frame; ArenaPane will refetch.
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Cross-examination failed');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border p-4 flex flex-col gap-3">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Cross-examination
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Challenge the results with any model. The verdict is advisory and never changes the recorded winner.
|
||||
</p>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<select
|
||||
value={identity}
|
||||
onChange={(e) => { setIdentity(e.target.value); setModel(''); }}
|
||||
className="flex-1 min-w-[120px] text-xs border border-border rounded bg-background px-2 py-1.5 text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
aria-label="Backend"
|
||||
>
|
||||
<option value="">Backend…</option>
|
||||
{identityOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
disabled={!identity}
|
||||
className="flex-1 min-w-[120px] text-xs border border-border rounded bg-background px-2 py-1.5 text-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
aria-label="Model"
|
||||
>
|
||||
<option value="">Model…</option>
|
||||
{modelOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRun()}
|
||||
disabled={!identity || !model || running}
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-1.5 rounded border border-border text-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{running && <Loader2 size={10} className="animate-spin" />}
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{crossExams.length > 0 && (
|
||||
<div className="flex flex-col gap-3 mt-1">
|
||||
{crossExams.map((xe) => (
|
||||
<div key={xe.id} className="rounded border border-border/50 bg-muted/20 p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{xe.identity} / {xe.model}
|
||||
</div>
|
||||
{xe.verdict ? (
|
||||
<div className="text-sm whitespace-pre-wrap leading-relaxed">{xe.verdict}</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Loader2 size={10} className="animate-spin" /> Running…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ArenaPane ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
state: ArenaState;
|
||||
projectId: string; // available for future use (e.g. file browser affordance)
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ArenaPane({ state, onClose }: Props) {
|
||||
const [battle, setBattle] = useState<BattleShape | null>(null);
|
||||
const [contestantRows, setContestantRows] = useState<ContestantRowState[]>([]);
|
||||
const [crossExams, setCrossExams] = useState<CrossExaminationShape[]>([]);
|
||||
const [analysis, setAnalysis] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [reanalyzing, setReanalyzing] = useState(false);
|
||||
const startTimesRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const snapshot = useProviderSnapshot();
|
||||
|
||||
// Fetch current battle state on mount / battle_id change.
|
||||
useEffect(() => {
|
||||
setBattle(null);
|
||||
setContestantRows([]);
|
||||
setCrossExams([]);
|
||||
setAnalysis(null);
|
||||
setExpandedId(null);
|
||||
|
||||
api.battles.get(state.battle_id)
|
||||
.then(({ battle: b, contestants, cross_examinations }) => {
|
||||
setBattle(b);
|
||||
setContestantRows(
|
||||
contestants.map((c) => ({
|
||||
data: c,
|
||||
output: '',
|
||||
startedAt: c.status === 'running' ? Date.now() : null,
|
||||
})),
|
||||
);
|
||||
setCrossExams(cross_examinations);
|
||||
// Fetch analysis text if battle is already completed.
|
||||
if (b.status === 'completed') {
|
||||
api.battles.getAnalysis(state.battle_id)
|
||||
.then(({ text }) => setAnalysis(text))
|
||||
.catch(() => {});
|
||||
}
|
||||
// Auto-expand first running contestant.
|
||||
const firstRunning = contestants.find((c) => c.status === 'running');
|
||||
if (firstRunning) setExpandedId(firstRunning.id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [state.battle_id]);
|
||||
|
||||
// Subscribe to live battle/contestant frames.
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((ev) => {
|
||||
if (ev.type === 'battle_started' && ev.battle_id === state.battle_id) {
|
||||
setContestantRows((prev) => {
|
||||
if (prev.length > 0) return prev;
|
||||
return ev.contestants.map((c) => ({
|
||||
data: {
|
||||
id: c.id,
|
||||
battle_id: ev.battle_id,
|
||||
identity: c.identity,
|
||||
model: c.model,
|
||||
lane: c.lane,
|
||||
task_id: null,
|
||||
worktree_id: null,
|
||||
status: 'queued' as const,
|
||||
duration_ms: null,
|
||||
tokens_per_sec: null,
|
||||
cost_tokens: null,
|
||||
result_path: null,
|
||||
error: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
output: '',
|
||||
startedAt: null,
|
||||
}));
|
||||
});
|
||||
} else if (ev.type === 'contestant_updated' && ev.battle_id === state.battle_id) {
|
||||
setContestantRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.data.id !== ev.contestant_id) return row;
|
||||
const updatedData: ContestantShape = {
|
||||
...row.data,
|
||||
...(ev.status != null ? { status: ev.status } : {}),
|
||||
...(ev.duration_ms != null ? { duration_ms: ev.duration_ms } : {}),
|
||||
...(ev.tokens_per_sec != null ? { tokens_per_sec: ev.tokens_per_sec } : {}),
|
||||
...(ev.error != null ? { error: ev.error } : {}),
|
||||
};
|
||||
const newStartedAt =
|
||||
ev.status === 'running' && row.startedAt == null
|
||||
? Date.now()
|
||||
: ev.status === 'done' || ev.status === 'error'
|
||||
? null
|
||||
: row.startedAt;
|
||||
if (ev.status === 'running') {
|
||||
startTimesRef.current.set(ev.contestant_id, newStartedAt ?? Date.now());
|
||||
setExpandedId(ev.contestant_id);
|
||||
}
|
||||
return {
|
||||
data: updatedData,
|
||||
output: ev.delta ? row.output + ev.delta : row.output,
|
||||
startedAt: newStartedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (ev.battle_status) {
|
||||
setBattle((prev) => prev ? { ...prev, status: ev.battle_status! } : prev);
|
||||
}
|
||||
} else if (ev.type === 'battle_updated' && ev.battle_id === state.battle_id) {
|
||||
setBattle((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
...(ev.status != null ? { status: ev.status } : {}),
|
||||
...(ev.winner_contestant_id !== undefined ? { winner_contestant_id: ev.winner_contestant_id } : {}),
|
||||
};
|
||||
});
|
||||
if (ev.analysis_ready) {
|
||||
api.battles.getAnalysis(state.battle_id)
|
||||
.then(({ text }) => setAnalysis(text))
|
||||
.catch(() => setAnalysis('Analysis ready — failed to load text.'));
|
||||
}
|
||||
if (ev.cross_exam_id) {
|
||||
// Refetch cross-exams to get the latest verdict.
|
||||
api.battles.get(state.battle_id)
|
||||
.then(({ cross_examinations }) => setCrossExams(cross_examinations))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [state.battle_id]);
|
||||
|
||||
const toggleExpand = useCallback((id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
}, []);
|
||||
|
||||
async function handleStop() {
|
||||
if (stopping) return;
|
||||
setStopping(true);
|
||||
try {
|
||||
await api.battles.stop(state.battle_id);
|
||||
} catch {
|
||||
// non-fatal
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReanalyze() {
|
||||
if (reanalyzing) return;
|
||||
setReanalyzing(true);
|
||||
try {
|
||||
await api.battles.analyze(state.battle_id);
|
||||
toast.success('Re-analysis triggered');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Re-analysis failed');
|
||||
} finally {
|
||||
setReanalyzing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenResults() {
|
||||
if (!battle?.results_path) return;
|
||||
sessionEvents.emit({ type: 'open_file_in_browser', path: battle.results_path });
|
||||
}
|
||||
|
||||
function handleCopyAnalysis() {
|
||||
if (!analysis) return;
|
||||
navigator.clipboard.writeText(analysis).catch(() => toast.error('Clipboard write failed'));
|
||||
}
|
||||
|
||||
const battleStatus = battle?.status ?? 'running';
|
||||
const isRunning = battleStatus === 'running' || battleStatus === 'pending';
|
||||
const isCompleted = battleStatus === 'completed';
|
||||
const winnerId = battle?.winner_contestant_id;
|
||||
const winnerRow = winnerId ? contestantRows.find((r) => r.data.id === winnerId) : null;
|
||||
const winnerLabel = winnerRow ? `${winnerRow.data.identity} / ${winnerRow.data.model}` : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-2 shrink-0">
|
||||
<Swords size={13} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate min-w-0 flex-1" title={state.prompt}>
|
||||
{state.prompt.length > 60 ? state.prompt.slice(0, 60) + '…' : state.prompt}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 capitalize">{state.battle_type}</span>
|
||||
|
||||
{winnerLabel && (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 shrink-0 hidden sm:block truncate max-w-[130px]"
|
||||
title={`Winner: ${winnerLabel}`}
|
||||
>
|
||||
✓ {winnerLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||
{isRunning ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStop()}
|
||||
disabled={stopping}
|
||||
className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50"
|
||||
title="Stop battle"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded',
|
||||
isCompleted
|
||||
? 'text-emerald-600 bg-emerald-500/10'
|
||||
: battleStatus === 'failed' || battleStatus === 'cancelled'
|
||||
? 'text-destructive bg-destructive/10'
|
||||
: 'text-muted-foreground bg-muted/40',
|
||||
)}
|
||||
>
|
||||
{battleStatus}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isCompleted && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Battle options"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => void handleReanalyze()} disabled={reanalyzing}>
|
||||
<RotateCcw size={14} /> Re-analyze
|
||||
</DropdownMenuItem>
|
||||
{battle?.results_path && (
|
||||
<DropdownMenuItem onSelect={handleOpenResults}>
|
||||
Open results folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{analysis && (
|
||||
<DropdownMenuItem onSelect={handleCopyAnalysis}>
|
||||
Copy analysis
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Close pane"
|
||||
title="Close pane"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Analysis panel */}
|
||||
{analysis && (
|
||||
<div className="border-b border-border p-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2 pb-1 border-b border-border/50">
|
||||
Analysis
|
||||
</div>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{analysis}
|
||||
</div>
|
||||
{winnerLabel && (
|
||||
<div className="mt-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
Winner: {winnerLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{contestantRows.length === 0 && !analysis && (
|
||||
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
||||
Starting battle…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contestant roster */}
|
||||
<div className="divide-y divide-border">
|
||||
{contestantRows.map((row) => (
|
||||
<ContestantRow
|
||||
key={row.data.id}
|
||||
row={row}
|
||||
isExpanded={expandedId === row.data.id}
|
||||
onToggle={() => toggleExpand(row.data.id)}
|
||||
isWinner={winnerId === row.data.id}
|
||||
battleId={state.battle_id}
|
||||
battleType={battle?.battle_type ?? state.battle_type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cross-examination panel — available after battle finishes */}
|
||||
{!isRunning && (
|
||||
<CrossExaminationPanel
|
||||
battleId={state.battle_id}
|
||||
crossExams={crossExams}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
// also refresh the sidebar's session list).
|
||||
|
||||
import type {
|
||||
ArenaState,
|
||||
BattleShape,
|
||||
Chat,
|
||||
ContestantShape,
|
||||
CrossExaminationShape,
|
||||
ErrorReason,
|
||||
HtmlArtifactState,
|
||||
MarkdownArtifactState,
|
||||
@@ -231,6 +235,53 @@ export interface FlowRunStepUpdatedEvent {
|
||||
report?: string;
|
||||
}
|
||||
|
||||
// Arena: emitted by "New Arena" menu items to request the launcher dialog.
|
||||
export interface OpenArenaLauncherEvent {
|
||||
type: 'open_arena_launcher';
|
||||
project_id: string;
|
||||
placement?: 'new' | 'split';
|
||||
}
|
||||
|
||||
// Arena: emitted after a battle is created to open/focus the arena pane.
|
||||
export interface OpenArenaPaneEvent {
|
||||
type: 'open_arena_pane';
|
||||
state: ArenaState;
|
||||
placement?: 'new' | 'split';
|
||||
}
|
||||
|
||||
// Arena: battle lifecycle frames forwarded from the coder user channel.
|
||||
export interface BattleStartedEvent {
|
||||
type: 'battle_started';
|
||||
battle_id: string;
|
||||
battle_type: 'coding' | 'qa';
|
||||
prompt: string;
|
||||
contestants: Array<{ id: string; identity: string; model: string; lane: 'local' | 'cloud' }>;
|
||||
}
|
||||
|
||||
export interface ContestantUpdatedEvent {
|
||||
type: 'contestant_updated';
|
||||
battle_id: string;
|
||||
contestant_id: string;
|
||||
status?: 'queued' | 'running' | 'done' | 'error';
|
||||
duration_ms?: number;
|
||||
tokens_per_sec?: number;
|
||||
battle_status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
delta?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BattleUpdatedEvent {
|
||||
type: 'battle_updated';
|
||||
battle_id: string;
|
||||
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
winner_contestant_id?: string | null;
|
||||
analysis_ready?: boolean;
|
||||
cross_exam_id?: string;
|
||||
}
|
||||
|
||||
// Re-export arena API shapes for consumers that need the full battle data.
|
||||
export type { BattleShape, ContestantShape, CrossExaminationShape };
|
||||
|
||||
export type SessionEvent =
|
||||
| SessionRenamedEvent
|
||||
| ProjectCreatedEvent
|
||||
@@ -262,7 +313,12 @@ export type SessionEvent =
|
||||
| OpenOrchestratorPaneEvent
|
||||
| FlowRunStartedEvent
|
||||
| FlowRunStepUpdatedEvent
|
||||
| OpenFlowLauncherEvent;
|
||||
| OpenFlowLauncherEvent
|
||||
| OpenArenaLauncherEvent
|
||||
| OpenArenaPaneEvent
|
||||
| BattleStartedEvent
|
||||
| ContestantUpdatedEvent
|
||||
| BattleUpdatedEvent;
|
||||
type Listener = (event: SessionEvent) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import type { FlowRunStartedEvent, FlowRunStepUpdatedEvent } from './sessionEvents';
|
||||
import type {
|
||||
BattleStartedEvent,
|
||||
BattleUpdatedEvent,
|
||||
ContestantUpdatedEvent,
|
||||
FlowRunStartedEvent,
|
||||
FlowRunStepUpdatedEvent,
|
||||
} from './sessionEvents';
|
||||
|
||||
const RECONNECT_INITIAL_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
@@ -49,6 +55,12 @@ export function useCoderUserEvents(): void {
|
||||
sessionEvents.emit(frame as unknown as FlowRunStartedEvent);
|
||||
} else if (frame.type === 'flow_run_step_updated') {
|
||||
sessionEvents.emit(frame as unknown as FlowRunStepUpdatedEvent);
|
||||
} else if (frame.type === 'battle_started') {
|
||||
sessionEvents.emit(frame as unknown as BattleStartedEvent);
|
||||
} else if (frame.type === 'contestant_updated') {
|
||||
sessionEvents.emit(frame as unknown as ContestantUpdatedEvent);
|
||||
} else if (frame.type === 'battle_updated') {
|
||||
sessionEvents.emit(frame as unknown as BattleUpdatedEvent);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -204,6 +204,13 @@ function applyFrame(state: State, frame: WsFrame): State {
|
||||
// No-op here to keep TS exhaustiveness satisfied.
|
||||
return state;
|
||||
}
|
||||
case 'battle_started':
|
||||
case 'contestant_updated':
|
||||
case 'battle_updated': {
|
||||
// Arena frames consumed by ArenaPane's own subscription.
|
||||
// No-op here to keep TS exhaustiveness satisfied.
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,13 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
||||
case 'flow_run_step_updated':
|
||||
// Consumed by useWorkspacePanes / OrchestratorPane / FlowLauncherDialog; sidebar has no stake.
|
||||
return prev;
|
||||
case 'open_arena_launcher':
|
||||
case 'open_arena_pane':
|
||||
case 'battle_started':
|
||||
case 'contestant_updated':
|
||||
case 'battle_updated':
|
||||
// Consumed by useWorkspacePanes / ArenaPane / ArenaLauncherDialog; sidebar has no stake.
|
||||
return prev;
|
||||
case 'project_archived': {
|
||||
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
||||
if (next.length === prev.projects.length) return prev;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DragEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type {
|
||||
ArenaState,
|
||||
ClosedPaneEntry,
|
||||
HtmlArtifactState,
|
||||
MarkdownArtifactState,
|
||||
@@ -187,6 +188,16 @@ function orchestratorPane(state: OrchestratorState): WorkspacePane {
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -290,6 +301,8 @@ export interface UseWorkspacePanesResult {
|
||||
createTab: (paneIdx: number, kind: WorkspaceTabKind) => Promise<void>;
|
||||
/** Open an orchestrator run pane (or focus an existing one for the same run_id). */
|
||||
addOrchestratorPane: (state: OrchestratorState) => string | null;
|
||||
/** Open an arena battle pane (or focus an existing one for the same battle_id). */
|
||||
addArenaPane: (state: ArenaState) => string | null;
|
||||
/** Back-compat alias for createTab(paneIdx, 'coder'). */
|
||||
createCoderTab: (paneIdx: number) => Promise<void>;
|
||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||
@@ -877,6 +890,38 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
});
|
||||
}, [addOrchestratorPane]);
|
||||
|
||||
const addArenaPane = useCallback((state: ArenaState): string | null => {
|
||||
let openedId: string | null = null;
|
||||
setPanes((prev) => {
|
||||
const existingIdx = prev.findIndex(
|
||||
(p) => p.kind === 'arena' && p.arena_state?.battle_id === state.battle_id,
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
setActivePaneIdx(existingIdx);
|
||||
openedId = prev[existingIdx]!.id;
|
||||
return prev;
|
||||
}
|
||||
if (nonSettingsCount(prev) >= MAX_PANES) {
|
||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||
return prev;
|
||||
}
|
||||
const newPane = arenaPane(state);
|
||||
openedId = newPane.id;
|
||||
const next = [...prev, newPane];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
return openedId;
|
||||
}, []);
|
||||
|
||||
// Arena pane: open via sessionEvents (fired by the launcher).
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((ev) => {
|
||||
if (ev.type !== 'open_arena_pane') return;
|
||||
addArenaPane(ev.state);
|
||||
});
|
||||
}, [addArenaPane]);
|
||||
|
||||
// Returns the new settings pane id when one is OPENED (so mobile callers can
|
||||
// push ?pane= atomically — see addPaneAndSwitch), or null when it was closed.
|
||||
// Id generated outside the updater so a strict-mode double-invoke agrees.
|
||||
@@ -1121,6 +1166,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
addSplitPane,
|
||||
createTab,
|
||||
addOrchestratorPane,
|
||||
addArenaPane,
|
||||
createCoderTab,
|
||||
toggleSettingsPane,
|
||||
removePane,
|
||||
|
||||
Reference in New Issue
Block a user