Checkpoint of in-flight work so the orchestrator branch can rebase onto a clean main: ContextBar → ContextMeter, model-label helper, model/agent picker + provider-snapshot/registry changes, inference payload + message-columns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1034 lines
39 KiB
TypeScript
1034 lines
39 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 {
|
|
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<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;
|
|
/** Append a new BooCode tab to an existing coder pane (the 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;
|
|
});
|
|
}, []);
|
|
|
|
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],
|
|
);
|
|
|
|
// Add a new BooCode tab to an existing coder pane (the "+" in the coder pane
|
|
// header). Creates a fresh chat row (= a new agent context that shares the
|
|
// session worktree) and APPENDS it to the pane's chatIds, keeping the pane
|
|
// kind 'coder' and focusing the new tab. Mirrors createChat for chat panes;
|
|
// the per-pane "split into a new pane" action stays addSplitPane.
|
|
const createCoderTab = useCallback(
|
|
async (paneIdx: number) => {
|
|
const paneId = panes[paneIdx]?.id;
|
|
if (!paneId) return;
|
|
markPaneChatPending(paneId, true);
|
|
try {
|
|
const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind('coder') });
|
|
setPanes((prev) => {
|
|
const idx = prev.findIndex((p) => p.id === paneId);
|
|
if (idx < 0) return prev;
|
|
const pane = prev[idx]!;
|
|
const newIds = [...pane.chatIds, chat.id];
|
|
const next = [...prev];
|
|
next[idx] = {
|
|
...pane,
|
|
kind: 'coder',
|
|
chatId: chat.id,
|
|
chatIds: newIds,
|
|
activeChatIdx: newIds.length - 1,
|
|
};
|
|
return next;
|
|
});
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to create coder tab');
|
|
} finally {
|
|
markPaneChatPending(paneId, false);
|
|
}
|
|
},
|
|
[sessionId, panes, 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- 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] = { ...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;
|
|
// Preserve pane.kind (...pane) — a coder pane stays a coder pane.
|
|
next[paneIdx] = {
|
|
...pane,
|
|
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];
|
|
if (!pane) return prev;
|
|
const next = [...prev];
|
|
if (pane.kind === 'coder' || pane.kind === 'terminal') {
|
|
// Scoped panes don't host chat tabs. Leaving one for the session
|
|
// history closes it: drop the pane→chat binding, and for terminals
|
|
// kill the tmux session (terminals are ephemeral — closing = killing,
|
|
// mirroring removePane).
|
|
if (pane.kind === 'terminal') {
|
|
api.terminals.kill(sessionId, pane.id).catch(() => { /* non-fatal */ });
|
|
}
|
|
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
|
} else {
|
|
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
|
|
}
|
|
return next;
|
|
});
|
|
}, [sessionId]);
|
|
|
|
// 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[], 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<string>) => {
|
|
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<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,
|
|
createCoderTab,
|
|
toggleSettingsPane,
|
|
removePane,
|
|
reopenPane,
|
|
hasClosedPanes,
|
|
removeChatFromPanes,
|
|
initializeFirstChatIfEmpty,
|
|
validatePanes,
|
|
isPaneChatPending,
|
|
handlePaneDragStart,
|
|
handlePaneDragOver,
|
|
handlePaneDragLeave,
|
|
handlePaneDrop,
|
|
handlePaneDragEnd,
|
|
dragOverIdx,
|
|
draggingIdxRef,
|
|
};
|
|
}
|