Files
boocode/apps/web/src/hooks/useWorkspacePanes.ts
indifferentketchup 3474be4865 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
2026-06-06 23:25:29 +00:00

1188 lines
44 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type {
ArenaState,
ClosedPaneEntry,
HtmlArtifactState,
MarkdownArtifactState,
OrchestratorState,
WorkspacePane,
WorkspaceState,
WorkspaceTabKind,
} from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
import { sessionEvents } from '@/hooks/sessionEvents';
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[];
// v2.6.x: stable session-scoped tab number per chat id (Batch 3a). Keyed by
// chat.id, NEVER by tab position.
tabNumbers: Record<string, number>;
activePaneIdx: number;
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
activePaneIdxRef: React.MutableRefObject<number>;
openChatInPane: (paneIdx: number, chatId: string) => void;
switchTab: (paneIdx: number, tabIdx: number) => void;
removeTab: (paneIdx: number, chatId: string) => void;
closeOtherTabs: (paneIdx: number, keepChatId: string) => void;
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void;
// Session-history view: which pane (by id) should render its landing in the
// history list instead of the new-chat hero. Shared so the mobile header
// button and the desktop pane-header menu drive the same controlled view.
historyPaneId: string | null;
openSessionHistory: (paneIdx: number) => void;
closeSessionHistory: () => void;
// v1.10.3: returns the new pane's id (or null if the operation was a no-op:
// max panes reached). Callers can use the
// id to update mobile URL state so the URL-sync effect doesn't fight the
// freshly-set activePaneIdx.
addSplitPane: (kind: 'chat' | 'terminal' | 'coder') => string | null;
/** Mixed tabs: add a tab of any kind to a pane (the "+" menu). */
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
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
// falls back to an empty pane to preserve the "always one pane" invariant.
toggleSettingsPane: () => string | null;
removePane: (idx: number) => void;
reopenPane: () => void;
hasClosedPanes: boolean;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
validatePanes: (validChatIds: Set<string>) => void;
/** True while a coder/terminal pane is waiting for its scoped chat row. */
isPaneChatPending: (paneId: string) => boolean;
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragLeave: () => void;
handlePaneDrop: (targetIdx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragEnd: () => void;
dragOverIdx: number | null;
draggingIdxRef: React.MutableRefObject<number | null>;
}
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
const [activePaneIdx, setActivePaneIdx] = useState(0);
// v2.6.x envelope state. Persisted alongside `panes` in the WorkspaceState
// envelope. `tabNumbers` is the stable session-scoped tab number per chat id;
// `nextTabNumber` only ever increments; `closedPaneStack` is the reopen LIFO.
const [tabNumbers, setTabNumbers] = useState<Record<string, number>>({});
const [nextTabNumber, setNextTabNumber] = useState(1);
const [closedPaneStack, setClosedPaneStack] = useState<ClosedPaneEntry[]>([]);
const draggingIdxRef = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const [historyPaneId, setHistoryPaneId] = useState<string | null>(null);
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
// initial [emptyPane()] would be saved over the server's real state before
// the GET resolves.
const hydratedRef = useRef(false);
// Tracks the last value broadcast by another device (or this one's own
// round-trip). If a PATCH would echo this exact payload, we skip the call.
const lastRemoteJsonRef = useRef<string>('[]');
const pendingPaneChatRef = useRef<Set<string>>(new Set());
const [pendingPaneChatIds, setPendingPaneChatIds] = useState<Set<string>>(() => new Set());
const markPaneChatPending = useCallback((paneId: string, pending: boolean) => {
setPendingPaneChatIds((prev) => {
const next = new Set(prev);
if (pending) next.add(paneId);
else next.delete(paneId);
pendingPaneChatRef.current = next;
return next;
});
}, []);
// Fire-and-forget kill of terminal-tab tmux sessions (keyed by tab id). The
// endpoint is idempotent (404 on a missing session) so a StrictMode
// double-invoke of a setPanes updater that calls this is harmless.
const killTerms = useCallback(
(ids: string[]) => {
for (const id of ids) api.terminals.kill(sessionId, id).catch(() => { /* non-fatal */ });
},
[sessionId],
);
const attachTabToPane = useCallback(
(paneId: string, tabId: string, kind: WorkspaceTabKind) => {
setPanes((prev) =>
prev.map((p) => (p.id === paneId ? rebuildPane(p, [tabId], [kind], 0) : p)),
);
},
[],
);
const seedPaneChat = useCallback(
async (paneId: string, kind: 'coder' | 'terminal') => {
if (pendingPaneChatRef.current.has(paneId)) return;
markPaneChatPending(paneId, true);
try {
if (kind === 'terminal') {
// Terminal tabs have no chats row — a generated id keys the tmux session.
attachTabToPane(paneId, generateTermTabId(), 'terminal');
} else {
const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind(kind) });
attachTabToPane(paneId, chat.id, kind);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create pane chat');
} finally {
markPaneChatPending(paneId, false);
}
},
[sessionId, attachTabToPane, markPaneChatPending],
);
// Mixed tabs: add a new tab of ANY kind to a pane (the "+" menu). chat/coder
// tabs create a fresh chats row; terminal tabs get a generated id (its own
// tmux session). The new tab is appended and focused, and pane.kind tracks it
// (rebuildPane). The "split into a new pane" action stays addSplitPane.
const createTab = useCallback(
async (paneIdx: number, kind: WorkspaceTabKind) => {
const paneId = panes[paneIdx]?.id;
if (!paneId) return;
const appendTab = (tabId: string) =>
setPanes((prev) => {
const idx = prev.findIndex((p) => p.id === paneId);
if (idx < 0) return prev;
const pane = prev[idx]!;
const next = [...prev];
next[idx] = rebuildPane(
pane,
[...pane.chatIds, tabId],
[...paneTabKinds(pane), kind],
pane.chatIds.length,
);
return next;
});
if (kind === 'terminal') {
appendTab(generateTermTabId());
setActivePaneIdx(paneIdx);
return;
}
markPaneChatPending(paneId, true);
try {
const chat = await api.chats.create(
sessionId,
kind === 'coder' ? { name: chatNameForPaneKind('coder') } : undefined,
);
appendTab(chat.id);
setActivePaneIdx(paneIdx);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create tab');
} finally {
markPaneChatPending(paneId, false);
}
},
[sessionId, panes, markPaneChatPending],
);
// Back-compat wrapper: the desktop coder pane "+" used to call this directly.
const createCoderTab = useCallback(
(paneIdx: number) => createTab(paneIdx, 'coder'),
[createTab],
);
const seedEmptyScopedPanes = useCallback(
(paneList: WorkspacePane[]) => {
for (const pane of paneList) {
if (pane.kind !== 'coder' && pane.kind !== 'terminal') continue;
if ((pane.chatIds?.length ?? 0) > 0 || pane.chatId) continue;
void seedPaneChat(pane.id, pane.kind);
}
},
[seedPaneChat],
);
// v1.12.1: hydrate from server on mount, then subscribe to remote updates.
useEffect(() => {
hydratedRef.current = false;
let cancelled = false;
void (async () => {
try {
const session = await api.sessions.get(sessionId);
if (cancelled) return;
let env = toWorkspaceState(session.workspace_panes);
let initial: WorkspacePane[] = normalizePanes(env.panes);
// One-time migration: if server is empty but legacy localStorage has
// a layout, seed the server (as an envelope) and delete the local key.
if (initial.length === 0) {
const legacy = readLegacyPanes(sessionId);
if (legacy && legacy.length > 0) {
try {
const seedState: WorkspaceState = {
panes: persistablePanes(legacy),
tabNumbers: {},
nextTabNumber: 1,
closedPaneStack: [],
};
const updated = await api.sessions.updateWorkspacePanes(sessionId, seedState);
if (cancelled) return;
env = toWorkspaceState(updated.workspace_panes);
initial = normalizePanes(env.panes);
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
} catch {
env = { ...env, panes: legacy };
initial = normalizePanes(legacy);
}
}
}
const next = initial.length > 0 ? initial : [emptyPane()];
lastRemoteJsonRef.current = JSON.stringify({
panes: persistablePanes(next),
tabNumbers: env.tabNumbers,
nextTabNumber: env.nextTabNumber,
closedPaneStack: env.closedPaneStack,
});
setPanes(next);
setTabNumbers(env.tabNumbers);
setNextTabNumber(env.nextTabNumber);
setClosedPaneStack(env.closedPaneStack);
setActivePaneIdx(0);
seedEmptyScopedPanes(next);
} finally {
if (!cancelled) hydratedRef.current = true;
}
})();
return () => { cancelled = true; };
}, [sessionId, seedEmptyScopedPanes]);
// v1.12.1: live cross-device sync. Replace local state when another device
// (or our own write echo) lands a session_workspace_updated frame.
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (ev.type !== 'session_workspace_updated') return;
if (ev.session_id !== sessionId) return;
const env = toWorkspaceState(ev.workspace_panes);
const incoming = normalizePanes(env.panes);
// Echo-dedup on the FULL envelope so tabNumber / stack-only changes are
// not mistaken for our own write echo.
const json = JSON.stringify({
panes: persistablePanes(incoming),
tabNumbers: env.tabNumbers,
nextTabNumber: env.nextTabNumber,
closedPaneStack: env.closedPaneStack,
});
if (json === lastRemoteJsonRef.current) return;
lastRemoteJsonRef.current = json;
const nextPanes = incoming.length > 0 ? incoming : [emptyPane()];
setPanes(nextPanes);
setTabNumbers(env.tabNumbers);
setNextTabNumber(env.nextTabNumber);
setClosedPaneStack(env.closedPaneStack);
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
seedEmptyScopedPanes(nextPanes);
});
}, [sessionId, seedEmptyScopedPanes]);
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of
// these per click. If a pane already exists for the same message_id, focus
// it instead of stacking a duplicate. Otherwise append (capped at MAX_PANES;
// settings panes don't count, matching addSplitPane's rule).
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (
ev.type !== 'open_markdown_artifact_pane' &&
ev.type !== 'open_html_artifact_pane'
) {
return;
}
setPanes((prev) => {
const targetKind: WorkspacePane['kind'] =
ev.type === 'open_html_artifact_pane' ? 'html_artifact' : 'markdown_artifact';
const messageId = ev.state.message_id;
const existingIdx = prev.findIndex((p) =>
p.kind === 'markdown_artifact'
? p.markdown_artifact_state?.message_id === messageId
: p.kind === 'html_artifact'
? p.html_artifact_state?.message_id === messageId
: false,
);
if (existingIdx >= 0) {
setActivePaneIdx(existingIdx);
return prev;
}
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const newPane =
ev.type === 'open_html_artifact_pane'
? htmlArtifactPane(ev.state)
: markdownArtifactPane(ev.state);
// Defensive: assert kind matches for the discriminated union.
if (newPane.kind !== targetKind) return prev;
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
return next;
});
});
}, []);
// v1.12.1: debounced PATCH on every change. Settings panes are stripped
// before saving (ephemeral per v1.9).
useEffect(() => {
if (!hydratedRef.current) return;
// v2.6.x: persist the full WorkspaceState envelope. The dedup ref compares
// the whole envelope so tabNumber / reopen-stack changes also persist.
const envelope: WorkspaceState = {
panes: persistablePanes(panes),
tabNumbers,
nextTabNumber,
closedPaneStack,
};
const json = JSON.stringify(envelope);
if (json === lastRemoteJsonRef.current) return;
const timer = setTimeout(() => {
lastRemoteJsonRef.current = json;
api.sessions.updateWorkspacePanes(sessionId, envelope).catch(() => {
// Non-fatal: next change retries. Persistent failures surface via
// the network layer's existing reconnect toast.
});
}, SAVE_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [sessionId, panes, tabNumbers, nextTabNumber, closedPaneStack]);
// v2.6.x (Batch 3a): maintain stable, session-scoped tab numbers. Collect the
// chat ids that appear in CHAT- or CODER-kind panes in deterministic order
// (pane index, then tab index). Assign numbers to any without one (global per
// session, only ever increasing, never reused) and prune entries whose chat
// is no longer in any tab-hosting pane. Guarded against render loops: only
// setState when something actually changed.
useEffect(() => {
const liveChatIds: string[] = [];
const liveSet = new Set<string>();
for (const pane of panes) {
if (pane.kind !== 'chat' && pane.kind !== 'coder') continue;
for (const id of pane.chatIds) {
if (!liveSet.has(id)) {
liveSet.add(id);
liveChatIds.push(id);
}
}
}
// Assign: walk live ids in deterministic order, handing out numbers.
let counter = nextTabNumber;
const additions: Record<string, number> = {};
for (const id of liveChatIds) {
if (tabNumbers[id] === undefined && additions[id] === undefined) {
additions[id] = counter;
counter += 1;
}
}
// Prune: retire numbers for chats no longer in any chat-kind pane.
const removals: string[] = [];
for (const id of Object.keys(tabNumbers)) {
if (!liveSet.has(id)) removals.push(id);
}
const hasAdditions = Object.keys(additions).length > 0;
const hasRemovals = removals.length > 0;
if (!hasAdditions && !hasRemovals) return;
setTabNumbers((prev) => {
const next: Record<string, number> = {};
for (const [id, n] of Object.entries(prev)) {
if (!removals.includes(id)) next[id] = n;
}
Object.assign(next, additions);
return next;
});
if (hasAdditions) setNextTabNumber(counter);
}, [panes, tabNumbers, nextTabNumber]);
useEffect(() => {
const active = panes[activePaneIdx];
if (!active) {
clearActivePane();
return;
}
setActivePaneInfo({
sessionId,
paneId: active.id,
kind: active.kind,
activeFile: null,
});
}, [sessionId, panes, activePaneIdx]);
useEffect(() => {
return () => {
clearActivePane();
};
}, []);
const activePaneIdxRef = useRef(activePaneIdx);
activePaneIdxRef.current = activePaneIdx;
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const existing = pane.chatIds.indexOf(chatId);
if (existing >= 0) {
next[paneIdx] = rebuildPane(pane, pane.chatIds, paneTabKinds(pane), existing);
} else {
// Opening a stored conversation appends a chat tab (mixed tabs).
next[paneIdx] = rebuildPane(
pane,
[...pane.chatIds, chatId],
[...paneTabKinds(pane), 'chat'],
pane.chatIds.length,
);
}
return next;
});
setActivePaneIdx(paneIdx);
}, []);
// Open a whole chat in its own fresh pane (focused). Detaches the chat from
// any pane currently showing it so it lives in exactly one pane (preserves
// the one-chat-per-pane model), dropping a source pane left with no tabs. For
// fork the chat isn't in any pane yet, so the detach is a no-op (pure append).
const openChatInNewPane = useCallback((chatId: string) => {
setPanes((prev) => {
const detached = prev.flatMap((p) => {
if (!p.chatIds.includes(chatId)) return [p];
const nextIds = p.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) return [];
const ai = Math.min(p.activeChatIdx, nextIds.length - 1);
return [{ ...p, kind: 'chat' as const, chatId: nextIds[ai], chatIds: nextIds, activeChatIdx: ai }];
});
if (nonSettingsCount(detached) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...detached, chatPane(chatId)];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
// ChatTabBar's "Open in new pane" + MessageBubble.fork() emit this.
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (ev.type !== 'open_chat_in_new_pane') return;
openChatInNewPane(ev.chat_id);
});
}, [openChatInNewPane]);
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
if (tabIdx < 0 || tabIdx >= pane.chatIds.length) return prev;
next[paneIdx] = rebuildPane(pane, pane.chatIds, paneTabKinds(pane), tabIdx);
return next;
});
}, []);
const removeTab = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
if (!pane.chatIds.includes(chatId)) return prev;
const { ids, kinds, removedTermIds } = filterTabs(pane, (id) => id !== chatId);
killTerms(removedTermIds);
if (ids.length === 0 && next.length > 1) {
// Last tab closed and other panes exist — remove the whole pane
// instead of leaving an orphaned empty panel.
setClosedPaneStack((stack) => appendClosed(stack, pane));
const spliced = next.filter((_, i) => i !== paneIdx);
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
return spliced;
}
next[paneIdx] = rebuildPane(pane, ids, kinds, Math.min(pane.activeChatIdx, ids.length - 1));
return next;
});
}, [killTerms]);
// Keep only the right-clicked tab open in this pane.
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const keepIdx = pane.chatIds.indexOf(keepChatId);
if (keepIdx < 0) return prev;
const { ids, kinds, removedTermIds } = filterTabs(pane, (id) => id === keepChatId);
killTerms(removedTermIds);
next[paneIdx] = rebuildPane(pane, ids, kinds, 0);
return next;
});
}, [killTerms]);
// Close every tab to the right of the right-clicked one.
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
const { ids, kinds, removedTermIds } = filterTabs(pane, (_id, i) => i <= pivotIdx);
killTerms(removedTermIds);
next[paneIdx] = rebuildPane(pane, ids, kinds, Math.min(pane.activeChatIdx, ids.length - 1));
return next;
});
}, [killTerms]);
// Close every tab in this pane; land on landing page.
const closeAllTabs = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const { removedTermIds } = filterTabs(pane, () => false);
killTerms(removedTermIds);
next[paneIdx] = rebuildPane(pane, [], [], -1);
return next;
});
}, [killTerms]);
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const pane = prev[paneIdx];
if (!pane) return prev;
const next = [...prev];
// Drop the pane's tabs and show the landing page. Terminal tabs are
// ephemeral — kill their tmux sessions (keyed by tab id) on close.
const { removedTermIds } = filterTabs(pane, () => false);
if (removedTermIds.length > 0) killTerms(removedTermIds);
next[paneIdx] = rebuildPane(pane, [], [], -1);
return next;
});
}, [killTerms]);
// Reveal the session-history list. Mirrors the desktop "Show history" action:
// convert the pane to its landing (showLandingPage) and flag it so the landing
// opens on the history list rather than the new-chat hero.
const openSessionHistory = useCallback((paneIdx: number) => {
const id = panes[paneIdx]?.id ?? null;
showLandingPage(paneIdx);
setHistoryPaneId(id);
}, [panes, showLandingPage]);
const closeSessionHistory = useCallback(() => setHistoryPaneId(null), []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'coder'): string | null => {
// Generate the id outside the updater so we can return it deterministically.
// setPanes's updater can be invoked twice in strict mode; using a fixed id
// ensures both invocations agree and the returned id matches what landed.
const newPaneId = generateId();
let success = false;
setPanes((prev) => {
// v1.9: settings panes are excluded from the MAX cap (decision c).
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const newPane =
kind === 'terminal'
? { id: newPaneId, kind: 'terminal' as const, chatIds: [] as string[], tabKinds: [], activeChatIdx: -1 }
: kind === 'coder'
? { id: newPaneId, kind: 'coder' as const, chatIds: [] as string[], tabKinds: [], activeChatIdx: -1 }
: emptyPane(newPaneId);
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
success = true;
if (kind === 'terminal' || kind === 'coder') {
queueMicrotask(() => void seedPaneChat(newPaneId, kind));
}
return next;
});
return success ? newPaneId : null;
}, [seedPaneChat]);
const addOrchestratorPane = useCallback((state: OrchestratorState): string | null => {
let openedId: string | null = null;
setPanes((prev) => {
// Dedup: focus an existing pane for the same run.
const existingIdx = prev.findIndex(
(p) => p.kind === 'orchestrator' && p.orchestrator_state?.run_id === state.run_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 = orchestratorPane(state);
openedId = newPane.id;
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
return next;
});
return openedId;
}, []);
// Orchestrator pane: open via sessionEvents (fired by ChatInput slash/button).
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (ev.type !== 'open_orchestrator_pane') return;
addOrchestratorPane(ev.state);
});
}, [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.
const toggleSettingsPane = useCallback((): string | null => {
const newPaneId = generateId();
let openedId: string | null = null;
setPanes((prev) => {
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
if (existingIdx < 0) {
const next = [...prev, settingsPane(newPaneId)];
setActivePaneIdx(next.length - 1);
openedId = newPaneId;
return next;
}
openedId = null;
if (prev.length <= 1) {
setActivePaneIdx(0);
return [emptyPane()];
}
const next = prev.filter((_, i) => i !== existingIdx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
return openedId;
}, []);
const removePane = useCallback((idx: number) => {
setPanes((prev) => {
if (prev.length <= 1) {
// Settings is the only kind that can be the last pane and still need
// closing (X / Esc / sidebar toggle). Fall back to empty. One-pane
// edge: no relocation — there is no other pane.
if (prev[idx]?.kind === 'settings') {
setActivePaneIdx(0);
return [emptyPane()];
}
return prev;
}
// v1.10.8c: with per-pane tmux sessions, an unkilled session leaks until
// the next `tmux kill-server`. Fire-and-forget /kill on terminal removal.
// The endpoint is idempotent (404 on missing session) so a strict-mode
// double-invoke of the updater is safe.
const removed = prev[idx];
// Push the original pane (with its chatIds intact) to the reopen stack.
if (removed) setClosedPaneStack((stack) => appendClosed(stack, removed));
// v2.6.x (Batch 1) + mixed tabs: relocate a closing CHAT-active pane's
// tabs (any kind) to the oldest remaining pane that can host tabs, so
// conversations aren't lost on close. Terminal/coder-active panes close
// exactly as before (no relocation).
let working = prev;
let relocated = false;
if (removed && removed.kind === 'chat' && removed.chatIds.length > 0) {
let targetIdx = -1;
for (let i = 0; i < prev.length; i += 1) {
if (i === idx) continue;
const p = prev[i]!;
if (p.kind === 'chat' || p.kind === 'empty') {
targetIdx = i;
break;
}
}
if (targetIdx >= 0) {
relocated = true;
working = prev.map((p, i) => {
if (i !== targetIdx) return p;
const mergedIds = [...p.chatIds, ...removed.chatIds];
const mergedKinds = [...paneTabKinds(p), ...paneTabKinds(removed)];
// Preserve the target's existing focus — append, don't force-focus
// the moved tabs. Clamp only when the target had no active tab.
const ai = p.activeChatIdx >= 0 ? p.activeChatIdx : 0;
return rebuildPane(p, mergedIds, mergedKinds, ai);
});
}
}
// Kill the tmux sessions of any terminal tabs that are NOT relocated
// (keyed by tab id, not pane id, since mixed panes hold many terminals).
if (removed && !relocated) {
killTerms(filterTabs(removed, () => false).removedTermIds);
}
const next = working.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, [killTerms]);
const hasClosedPanes = closedPaneStack.length > 0;
const reopenPane = useCallback(() => {
// Read the top entry from the current render's stack (not inside the
// updater) so a StrictMode double-invoke can't pop two entries. The pop
// setState is idempotent: filtering by reference removes exactly this entry.
const e = closedPaneStack[closedPaneStack.length - 1];
if (!e) return;
setClosedPaneStack((stack) => (stack[stack.length - 1] === e ? stack.slice(0, -1) : stack));
setPanes((prev) => {
// v2.6.x (Batch 4): reversible reopen. The closed tabs may have been
// relocated into another pane on close (Batch 1). Strip e.chatIds from
// every existing pane first so reopening never duplicates a tab —
// whether or not it was relocated (a no-op strip when it wasn't). Mirror
// removeTab's emptiness handling: a chat pane emptied by the strip is
// dropped when other panes remain, else turned empty.
const stripped: WorkspacePane[] = [];
for (const p of prev) {
const { ids, kinds } = filterTabs(p, (id) => !e.chatIds.includes(id));
if (ids.length === p.chatIds.length) {
stripped.push(p);
continue;
}
if (ids.length === 0 && p.kind === 'chat') {
// Drop the now-empty chat pane (the restored pane plus possibly others
// remain). rebuildPane would leave an empty landing — we'd rather drop.
continue;
}
stripped.push(rebuildPane(p, ids, kinds, Math.min(p.activeChatIdx, ids.length - 1)));
}
const restoredKinds: WorkspaceTabKind[] =
e.tabKinds && e.tabKinds.length === e.chatIds.length
? e.tabKinds
: e.chatIds.map(() => (e.kind === 'coder' ? 'coder' : e.kind === 'terminal' ? 'terminal' : 'chat'));
const restored: WorkspacePane = rebuildPane(
{ id: generateId(), kind: e.kind, chatIds: [], activeChatIdx: -1 },
e.chatIds,
restoredKinds,
e.activeChatIdx,
);
const next = [...stripped, restored];
setActivePaneIdx(next.length - 1);
return next;
});
}, [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') {
return [chatPane(chatId)];
}
return prev;
});
}, []);
const validatePanes = useCallback((validChatIds: Set<string>) => {
setPanes((prev) => {
const cleaned = prev.map((pane) => {
if (pane.chatIds.length === 0) return pane;
const kinds = paneTabKinds(pane);
// Prune chat/coder tabs whose chats row was deleted. Terminal tabs have
// no chats row, so they're always kept.
const { ids, kinds: nextKinds } = filterTabs(
pane,
(id, i) => kinds[i] === 'terminal' || validChatIds.has(id),
);
if (ids.length === pane.chatIds.length) return pane;
return rebuildPane(pane, ids, nextKinds, Math.min(pane.activeChatIdx, ids.length - 1));
});
const unchanged = cleaned.every((p, i) => p === prev[i]);
return unchanged ? prev : cleaned;
});
}, []);
const isPaneChatPending = useCallback(
(paneId: string) => pendingPaneChatIds.has(paneId),
[pendingPaneChatIds],
);
const removeChatFromPanes = useCallback((chatId: string) => {
setPanes((prev) => prev.map((p) => {
if (!p.chatIds.includes(chatId)) return p;
const { ids, kinds } = filterTabs(p, (id) => id !== chatId);
return rebuildPane(p, ids, kinds, Math.min(p.activeChatIdx, ids.length - 1));
}));
}, []);
const handlePaneDragStart = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
draggingIdxRef.current = idx;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
},
[]
);
const handlePaneDragOver = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
if (draggingIdxRef.current === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverIdx !== idx) setDragOverIdx(idx);
},
[dragOverIdx]
);
const handlePaneDragLeave = useCallback(() => {
setDragOverIdx(null);
}, []);
const handlePaneDrop = useCallback(
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const fromIdx = draggingIdxRef.current;
draggingIdxRef.current = null;
setDragOverIdx(null);
if (fromIdx === null || fromIdx === targetIdx) return;
setPanes((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(targetIdx, 0, moved);
// Keep active selection on the same logical pane (the one being dragged).
setActivePaneIdx(targetIdx);
return next;
});
},
[]
);
const handlePaneDragEnd = useCallback(() => {
draggingIdxRef.current = null;
setDragOverIdx(null);
}, []);
return {
panes,
tabNumbers,
activePaneIdx,
setActivePaneIdx,
activePaneIdxRef,
openChatInPane,
switchTab,
removeTab,
closeOtherTabs,
closeTabsToRight,
closeAllTabs,
showLandingPage,
historyPaneId,
openSessionHistory,
closeSessionHistory,
addSplitPane,
createTab,
addOrchestratorPane,
addArenaPane,
createCoderTab,
toggleSettingsPane,
removePane,
reopenPane,
hasClosedPanes,
removeChatFromPanes,
initializeFirstChatIfEmpty,
validatePanes,
isPaneChatPending,
handlePaneDragStart,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handlePaneDragEnd,
dragOverIdx,
draggingIdxRef,
};
}