A cohesive batch of pane/tab UX + the persisted workspace-state model (grouped
because the changes interleave across useWorkspacePanes, ChatTabBar, Workspace,
sessionEvents and the api types/client):
- Open a whole chat in a fresh pane via a new open_chat_in_new_pane event:
ChatTabBar tab context menu "Open in new pane", and MessageBubble.fork() now
lands the fork beside the original instead of replacing the active pane.
openChatInNewPane detaches the chat from any pane already holding it
(one-chat-per-pane).
- The tab-bar "+" becomes a New BooChat/BooTerm/BooCode menu (chat as a tab,
term/coder as split panes); the split button is unchanged.
- Drop the per-message "Open in pane" button (it opened a single message's
artifact) and its dead code; the artifact-pane machinery is left orphaned for
a later teardown.
- Session history: the empty/landing pane lists the session's open chats plus
archived chats (fetched separately), click to open / restore-and-open.
- Relocate-on-close: closing a chat pane moves its tabs (in order) into the
oldest chat/empty pane instead of discarding them; terminal/coder panes close
as before. Reopen strips the restored chatIds from all live panes first, so a
relocated-then-reopened pane never duplicates a tab — no stack-shape change.
- Stable global tab numbering: tabNumbers/nextTabNumber assigned on chat-pane
open, retired on close (never reused), rendered map-keyed (not positional).
- workspace_panes is now a WorkspaceState envelope { panes, tabNumbers,
nextTabNumber, closedPaneStack }; the reopen stack moved from a module-level
array into the persisted envelope so it survives reload. Hydrate/persist
normalize the legacy bare-array shape. appendClosed dedupes a value-identical
top entry to neutralize the StrictMode double-invoke of the setPanes updater.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
964 lines
36 KiB
TypeScript
964 lines
36 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;
|
|
// 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<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);
|
|
// 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],
|
|
);
|
|
|
|
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<string>();
|
|
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<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;
|
|
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<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,
|
|
addSplitPane,
|
|
toggleSettingsPane,
|
|
removePane,
|
|
reopenPane,
|
|
hasClosedPanes,
|
|
removeChatFromPanes,
|
|
initializeFirstChatIfEmpty,
|
|
validatePanes,
|
|
isPaneChatPending,
|
|
handlePaneDragStart,
|
|
handlePaneDragOver,
|
|
handlePaneDragLeave,
|
|
handlePaneDrop,
|
|
handlePaneDragEnd,
|
|
dragOverIdx,
|
|
draggingIdxRef,
|
|
};
|
|
}
|