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; activePaneIdx: number; setActivePaneIdx: React.Dispatch>; activePaneIdxRef: React.MutableRefObject; 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; /** 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; // 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) => void; /** True while a coder/terminal pane is waiting for its scoped chat row. */ isPaneChatPending: (paneId: string) => boolean; handlePaneDragStart: (idx: number) => (e: DragEvent) => void; handlePaneDragOver: (idx: number) => (e: DragEvent) => void; handlePaneDragLeave: () => void; handlePaneDrop: (targetIdx: number) => (e: DragEvent) => void; handlePaneDragEnd: () => void; dragOverIdx: number | null; draggingIdxRef: React.MutableRefObject; } export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { const [panes, setPanes] = useState(() => [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>({}); const [nextTabNumber, setNextTabNumber] = useState(1); const [closedPaneStack, setClosedPaneStack] = useState([]); const draggingIdxRef = useRef(null); const [dragOverIdx, setDragOverIdx] = useState(null); const [historyPaneId, setHistoryPaneId] = useState(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('[]'); const pendingPaneChatRef = useRef>(new Set()); const [pendingPaneChatIds, setPendingPaneChatIds] = useState>(() => 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(); 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 = {}; 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 = {}; 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) => { 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) => { draggingIdxRef.current = idx; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(idx)); }, [] ); const handlePaneDragOver = useCallback( (idx: number) => (e: DragEvent) => { 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) => { 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, }; }