import { useCallback, useEffect, useRef, useState } from 'react'; import type { DragEvent } from 'react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import type { ClosedPaneEntry, HtmlArtifactState, MarkdownArtifactState, WorkspacePane, WorkspaceState, } 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(); } // 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: [], activeChatIdx: -1 }; } function chatPane(chatId: string): WorkspacePane { return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], 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], 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'; } function scopedPane(id: string, kind: 'coder' | 'terminal', chatId: string): WorkspacePane { return { id, kind, chatId, chatIds: [chatId], activeChatIdx: 0 }; } /** 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, }; } // 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. if ((pane.kind as string) === 'agent') { return { ...pane, kind: 'coder' }; } return pane; } 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; // 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; // 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); // 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; }); }, []); const attachChatToPane = useCallback( (paneId: string, chatId: string, kind: 'coder' | 'terminal') => { setPanes((prev) => prev.map((p) => (p.id === paneId ? scopedPane(paneId, kind, chatId) : p)), ); }, [], ); const seedPaneChat = useCallback( async (paneId: string, kind: 'coder' | 'terminal') => { if (pendingPaneChatRef.current.has(paneId)) return; markPaneChatPending(paneId, true); try { const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind(kind) }); attachChatToPane(paneId, chat.id, kind); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to create pane chat'); } finally { markPaneChatPending(paneId, false); } }, [sessionId, attachChatToPane, markPaneChatPending], ); 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-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 chat-kind 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') 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] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing }; } else { const newIds = [...pane.chatIds, chatId]; next[paneIdx] = { ...pane, kind: 'chat', chatId, chatIds: newIds, activeChatIdx: newIds.length - 1, }; } 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]!; const chatId = pane.chatIds[tabIdx]; if (!chatId) return prev; next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx }; return next; }); }, []); const removeTab = useCallback((paneIdx: number, chatId: string) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const nextIds = pane.chatIds.filter((id) => id !== chatId); if (nextIds.length === 0) { if (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] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; } else { const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); next[paneIdx] = { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx], }; } return next; }); }, []); // 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; next[paneIdx] = { ...pane, kind: 'chat', chatId: keepChatId, chatIds: [keepChatId], activeChatIdx: 0, }; return next; }); }, []); // 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 nextIds = pane.chatIds.slice(0, pivotIdx + 1); const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); next[paneIdx] = { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx], }; return next; }); }, []); // Close every tab in this pane; land on landing page. const closeAllTabs = useCallback((paneIdx: number) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; return next; }); }, []); const showLandingPage = useCallback((paneIdx: number) => { setPanes((prev) => { const pane = prev[paneIdx]; // Coder/terminal panes are not chat hosts — history button is chat-only. if (!pane || pane.kind === 'coder' || pane.kind === 'terminal') return prev; const next = [...prev]; next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined }; return next; }); }, []); 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[], activeChatIdx: -1 } : kind === 'coder' ? { id: newPaneId, kind: 'coder' as const, chatIds: [] as string[], 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]); // 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)); if (removed?.kind === 'terminal') { api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ }); } // v2.6.x (Batch 1): relocate a closing CHAT pane's tabs to the oldest // remaining pane that can host chat tabs, so chats aren't lost on close. // Only chat panes relocate — terminal/coder panes own a scoped chat bound // to the pane, so those close exactly as before (no relocation). let working = prev; if (removed && removed.kind === 'chat' && removed.chatIds.length > 0) { // "Oldest remaining": lowest index, excluding `idx`, that is a chat or // empty pane (the only kinds that can host arbitrary chat tabs). Skip // terminal/coder/settings/artifact panes. 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) { working = prev.map((p, i) => { if (i !== targetIdx) return p; const mergedIds = [...p.chatIds, ...removed.chatIds]; // 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 { ...p, kind: 'chat' as const, chatIds: mergedIds, activeChatIdx: ai, chatId: mergedIds[ai], }; }); } } const next = working.filter((_, i) => i !== idx); setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); return next; }); }, [sessionId]); 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 idxs = p.chatIds.filter((id) => !e.chatIds.includes(id)); if (idxs.length === p.chatIds.length) { stripped.push(p); continue; } if (idxs.length === 0) { if (p.kind === 'chat') { // Drop the now-empty chat pane (we still have the restored pane plus // possibly others). If it would leave zero panes, turn it empty. continue; } stripped.push({ ...p, chatId: undefined, chatIds: [], activeChatIdx: -1 }); continue; } const ai = Math.min(p.activeChatIdx, idxs.length - 1); stripped.push({ ...p, chatIds: idxs, activeChatIdx: ai < 0 ? 0 : ai, chatId: idxs[ai < 0 ? 0 : ai] }); } const restored: WorkspacePane = { id: generateId(), kind: e.kind, chatId: e.chatIds[e.activeChatIdx] ?? e.chatIds[0], chatIds: e.chatIds, activeChatIdx: Math.min(e.activeChatIdx, e.chatIds.length - 1), }; 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) => { const usesChat = pane.kind === 'chat' || pane.kind === 'coder' || pane.kind === 'terminal'; if (!usesChat || pane.chatIds.length === 0) return pane; const nextIds = pane.chatIds.filter((id) => validChatIds.has(id)); if (nextIds.length === pane.chatIds.length) return pane; if (nextIds.length === 0) { if (pane.kind === 'chat') { return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 }; } return { ...pane, chatId: undefined, chatIds: [], activeChatIdx: -1 }; } const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); return { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx] }; }); const unchanged = cleaned.every((p, i) => p === prev[i]); const next = unchanged ? prev : cleaned; if (!unchanged) { for (const pane of next) { if (pane.kind === 'coder' && !activePaneChatId(pane)) { queueMicrotask(() => void seedPaneChat(pane.id, 'coder')); } else if (pane.kind === 'terminal' && !activePaneChatId(pane)) { queueMicrotask(() => void seedPaneChat(pane.id, 'terminal')); } } } return next; }); }, [seedPaneChat]); const isPaneChatPending = useCallback( (paneId: string) => pendingPaneChatIds.has(paneId), [pendingPaneChatIds], ); const removeChatFromPanes = useCallback((chatId: string) => { setPanes((prev) => prev.map((p) => { const idx = p.chatIds.indexOf(chatId); if (idx < 0) return p; const nextIds = p.chatIds.filter((id) => id !== chatId); if (nextIds.length === 0) { return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 }; } const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1); return { ...p, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx], }; })); }, []); 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, addSplitPane, toggleSettingsPane, removePane, reopenPane, hasClosedPanes, removeChatFromPanes, initializeFirstChatIfEmpty, validatePanes, isPaneChatPending, handlePaneDragStart, handlePaneDragOver, handlePaneDragLeave, handlePaneDrop, handlePaneDragEnd, dragOverIdx, draggingIdxRef, }; }