wip: pane/session + tab-bar checkpoint

Second checkpoint of in-flight work (sessions route, api types, ChatTabBar,
PaneHeaderActions, Workspace, useWorkspacePanes) so the Orchestrator branch
can rebase onto current main before merge.
This commit is contained in:
2026-06-03 15:15:47 +00:00
parent ef3b998826
commit 7ff99238c9
6 changed files with 404 additions and 317 deletions

View File

@@ -8,6 +8,7 @@ import type {
MarkdownArtifactState,
WorkspacePane,
WorkspaceState,
WorkspaceTabKind,
} from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -23,15 +24,84 @@ function generateId(): string {
return crypto.randomUUID();
}
// Mixed tabs: terminal tabs have no chats row, so their tab id is a generated
// `term_*` id (used to key the tmux session). chat/coder tab ids are chats-row
// ids.
const TERM_TAB_PREFIX = 'term_';
function generateTermTabId(): string {
return `${TERM_TAB_PREFIX}${generateId()}`;
}
// Per-tab kinds, with a legacy back-fill from pane.kind for pre-mixed-tabs rows.
function paneTabKinds(pane: WorkspacePane): WorkspaceTabKind[] {
if (pane.tabKinds && pane.tabKinds.length === pane.chatIds.length) return pane.tabKinds;
const fallback: WorkspaceTabKind =
pane.kind === 'coder' || pane.kind === 'terminal' ? pane.kind : 'chat';
return pane.chatIds.map(() => fallback);
}
// Rebuild a tabbed pane from (ids, kinds, desired active index). Keeps pane.kind
// in sync with the ACTIVE tab (so the render-by-pane.kind path renders the right
// tab) and collapses to an empty landing pane when no tabs remain.
function rebuildPane(
pane: WorkspacePane,
ids: string[],
kinds: WorkspaceTabKind[],
desiredActive: number,
): WorkspacePane {
if (ids.length === 0) {
return {
...pane,
kind: 'empty',
chatId: undefined,
chatIds: [],
tabKinds: [],
activeChatIdx: -1,
markdown_artifact_state: undefined,
html_artifact_state: undefined,
};
}
const idx = Math.max(0, Math.min(desiredActive, ids.length - 1));
return {
...pane,
kind: kinds[idx]!,
chatId: ids[idx],
chatIds: ids,
tabKinds: kinds,
activeChatIdx: idx,
};
}
// Filter a pane's tabs, keeping chatIds + tabKinds aligned and collecting the
// ids of any dropped terminal tabs (so callers can kill their tmux sessions).
function filterTabs(
pane: WorkspacePane,
keep: (id: string, idx: number) => boolean,
): { ids: string[]; kinds: WorkspaceTabKind[]; removedTermIds: string[] } {
const kinds = paneTabKinds(pane);
const ids: string[] = [];
const nextKinds: WorkspaceTabKind[] = [];
const removedTermIds: string[] = [];
pane.chatIds.forEach((id, i) => {
if (keep(id, i)) {
ids.push(id);
nextKinds.push(kinds[i]!);
} else if (kinds[i] === 'terminal') {
removedTermIds.push(id);
}
});
return { ids, kinds: nextKinds, removedTermIds };
}
// v1.10.3: optional id arg lets addSplitPane lift id generation out of the
// setPanes updater so the new pane's id can be returned synchronously to the
// caller (needed for mobile URL state).
function emptyPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'empty', chatIds: [], activeChatIdx: -1 };
return { id, kind: 'empty', chatIds: [], tabKinds: [], activeChatIdx: -1 };
}
function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], tabKinds: ['chat'], activeChatIdx: 0 };
}
// v2.6.x: reopen stack cap. The stack now lives in React state (persisted in
@@ -45,7 +115,7 @@ const MAX_CLOSED = 10;
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 };
const entry = { kind: pane.kind, chatIds: [...pane.chatIds], tabKinds: [...paneTabKinds(pane)], activeChatIdx: pane.activeChatIdx };
// Dedupe a value-identical top entry. This is called via setClosedPaneStack
// inside the setPanes updater in removePane; React StrictMode double-invokes
// that updater in dev, which would otherwise push two identical entries.
@@ -69,9 +139,6 @@ 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 {
@@ -114,10 +181,24 @@ function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
// 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' };
let p = pane;
if ((p.kind as string) === 'agent') p = { ...p, kind: 'coder' };
// Mixed-tabs migration: back-fill per-tab kinds for pre-mixed-tabs rows.
const tabbed = p.kind === 'chat' || p.kind === 'coder' || p.kind === 'terminal';
if (!tabbed) return p;
// Legacy terminal panes keyed their tmux session off the PANE id and stored a
// vestigial chats row in chatIds[0]. Re-seat the terminal as a tab whose id IS
// the pane id, so the existing tmux session keeps resolving after migration.
if (p.kind === 'terminal' && (!p.tabKinds || p.tabKinds.length === 0)) {
return { ...p, chatIds: [p.id], tabKinds: ['terminal'], chatId: p.id, activeChatIdx: 0 };
}
return pane;
if (!p.tabKinds || p.tabKinds.length !== p.chatIds.length) {
const k: WorkspaceTabKind = p.kind === 'coder' ? 'coder' : p.kind === 'terminal' ? 'terminal' : 'chat';
return { ...p, tabKinds: p.chatIds.map(() => k) };
}
return p;
}
function normalizePanes(panes: WorkspacePane[]): WorkspacePane[] {
@@ -194,7 +275,9 @@ export interface UseWorkspacePanesResult {
// 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 "+"). */
/** Mixed tabs: add a tab of any kind to a pane (the "+" menu). */
createTab: (paneIdx: number, kind: WorkspaceTabKind) => Promise<void>;
/** Back-compat alias for createTab(paneIdx, 'coder'). */
createCoderTab: (paneIdx: number) => Promise<void>;
// Open-on-first-click, close-on-second-click. Singleton — settings panes
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
@@ -249,10 +332,20 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, []);
const attachChatToPane = useCallback(
(paneId: string, chatId: string, kind: 'coder' | 'terminal') => {
// Fire-and-forget kill of terminal-tab tmux sessions (keyed by tab id). The
// endpoint is idempotent (404 on a missing session) so a StrictMode
// double-invoke of a setPanes updater that calls this is harmless.
const killTerms = useCallback(
(ids: string[]) => {
for (const id of ids) api.terminals.kill(sessionId, id).catch(() => { /* non-fatal */ });
},
[sessionId],
);
const attachTabToPane = useCallback(
(paneId: string, tabId: string, kind: WorkspaceTabKind) => {
setPanes((prev) =>
prev.map((p) => (p.id === paneId ? scopedPane(paneId, kind, chatId) : p)),
prev.map((p) => (p.id === paneId ? rebuildPane(p, [tabId], [kind], 0) : p)),
);
},
[],
@@ -263,46 +356,59 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
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);
if (kind === 'terminal') {
// Terminal tabs have no chats row — a generated id keys the tmux session.
attachTabToPane(paneId, generateTermTabId(), 'terminal');
} else {
const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind(kind) });
attachTabToPane(paneId, chat.id, kind);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create pane chat');
} finally {
markPaneChatPending(paneId, false);
}
},
[sessionId, attachChatToPane, markPaneChatPending],
[sessionId, attachTabToPane, 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) => {
// Mixed tabs: add a new tab of ANY kind to a pane (the "+" menu). chat/coder
// tabs create a fresh chats row; terminal tabs get a generated id (its own
// tmux session). The new tab is appended and focused, and pane.kind tracks it
// (rebuildPane). The "split into a new pane" action stays addSplitPane.
const createTab = useCallback(
async (paneIdx: number, kind: WorkspaceTabKind) => {
const paneId = panes[paneIdx]?.id;
if (!paneId) return;
markPaneChatPending(paneId, true);
try {
const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind('coder') });
const appendTab = (tabId: string) =>
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,
};
next[idx] = rebuildPane(
pane,
[...pane.chatIds, tabId],
[...paneTabKinds(pane), kind],
pane.chatIds.length,
);
return next;
});
if (kind === 'terminal') {
appendTab(generateTermTabId());
setActivePaneIdx(paneIdx);
return;
}
markPaneChatPending(paneId, true);
try {
const chat = await api.chats.create(
sessionId,
kind === 'coder' ? { name: chatNameForPaneKind('coder') } : undefined,
);
appendTab(chat.id);
setActivePaneIdx(paneIdx);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create coder tab');
toast.error(err instanceof Error ? err.message : 'Failed to create tab');
} finally {
markPaneChatPending(paneId, false);
}
@@ -310,6 +416,12 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
[sessionId, panes, markPaneChatPending],
);
// Back-compat wrapper: the desktop coder pane "+" used to call this directly.
const createCoderTab = useCallback(
(paneIdx: number) => createTab(paneIdx, 'coder'),
[createTab],
);
const seedEmptyScopedPanes = useCallback(
(paneList: WorkspacePane[]) => {
for (const pane of paneList) {
@@ -549,16 +661,15 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const pane = next[paneIdx]!;
const existing = pane.chatIds.indexOf(chatId);
if (existing >= 0) {
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
next[paneIdx] = rebuildPane(pane, pane.chatIds, paneTabKinds(pane), existing);
} else {
const newIds = [...pane.chatIds, chatId];
next[paneIdx] = {
...pane,
kind: 'chat',
chatId,
chatIds: newIds,
activeChatIdx: newIds.length - 1,
};
// Opening a stored conversation appends a chat tab (mixed tabs).
next[paneIdx] = rebuildPane(
pane,
[...pane.chatIds, chatId],
[...paneTabKinds(pane), 'chat'],
pane.chatIds.length,
);
}
return next;
});
@@ -600,9 +711,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const chatId = pane.chatIds[tabIdx];
if (!chatId) return prev;
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
if (tabIdx < 0 || tabIdx >= pane.chatIds.length) return prev;
next[paneIdx] = rebuildPane(pane, pane.chatIds, paneTabKinds(pane), tabIdx);
return next;
});
}, []);
@@ -611,29 +721,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
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],
};
if (!pane.chatIds.includes(chatId)) return prev;
const { ids, kinds, removedTermIds } = filterTabs(pane, (id) => id !== chatId);
killTerms(removedTermIds);
if (ids.length === 0 && next.length > 1) {
// Last tab closed and other panes exist — remove the whole pane
// instead of leaving an orphaned empty panel.
setClosedPaneStack((stack) => appendClosed(stack, pane));
const spliced = next.filter((_, i) => i !== paneIdx);
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
return spliced;
}
next[paneIdx] = rebuildPane(pane, ids, kinds, Math.min(pane.activeChatIdx, ids.length - 1));
return next;
});
}, []);
}, [killTerms]);
// Keep only the right-clicked tab open in this pane.
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
@@ -642,16 +744,12 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
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,
};
const { ids, kinds, removedTermIds } = filterTabs(pane, (id) => id === keepChatId);
killTerms(removedTermIds);
next[paneIdx] = rebuildPane(pane, ids, kinds, 0);
return next;
});
}, []);
}, [killTerms]);
// Close every tab to the right of the right-clicked one.
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
@@ -660,48 +758,38 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
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],
};
const { ids, kinds, removedTermIds } = filterTabs(pane, (_id, i) => i <= pivotIdx);
killTerms(removedTermIds);
next[paneIdx] = rebuildPane(pane, ids, kinds, Math.min(pane.activeChatIdx, ids.length - 1));
return next;
});
}, []);
}, [killTerms]);
// Close every tab in this pane; land on landing page.
const closeAllTabs = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
const { removedTermIds } = filterTabs(pane, () => false);
killTerms(removedTermIds);
next[paneIdx] = rebuildPane(pane, [], [], -1);
return next;
});
}, []);
}, [killTerms]);
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const pane = prev[paneIdx];
if (!pane) return prev;
const next = [...prev];
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 };
}
// Drop the pane's tabs and show the landing page. Terminal tabs are
// ephemeral — kill their tmux sessions (keyed by tab id) on close.
const { removedTermIds } = filterTabs(pane, () => false);
if (removedTermIds.length > 0) killTerms(removedTermIds);
next[paneIdx] = rebuildPane(pane, [], [], -1);
return next;
});
}, [sessionId]);
}, [killTerms]);
// Reveal the session-history list. Mirrors the desktop "Show history" action:
// convert the pane to its landing (showLandingPage) and flag it so the landing
@@ -728,9 +816,9 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
}
const newPane =
kind === 'terminal'
? { id: newPaneId, kind: 'terminal' as const, chatIds: [] as string[], activeChatIdx: -1 }
? { id: newPaneId, kind: 'terminal' as const, chatIds: [] as string[], tabKinds: [], activeChatIdx: -1 }
: kind === 'coder'
? { id: newPaneId, kind: 'coder' as const, chatIds: [] as string[], activeChatIdx: -1 }
? { id: newPaneId, kind: 'coder' as const, chatIds: [] as string[], tabKinds: [], activeChatIdx: -1 }
: emptyPane(newPaneId);
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
@@ -788,19 +876,14 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
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).
// v2.6.x (Batch 1) + mixed tabs: relocate a closing CHAT-active pane's
// tabs (any kind) to the oldest remaining pane that can host tabs, so
// conversations aren't lost on close. Terminal/coder-active panes close
// exactly as before (no relocation).
let working = prev;
let relocated = false;
if (removed && removed.kind === 'chat' && removed.chatIds.length > 0) {
// "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;
@@ -811,28 +894,30 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
}
}
if (targetIdx >= 0) {
relocated = true;
working = prev.map((p, i) => {
if (i !== targetIdx) return p;
const mergedIds = [...p.chatIds, ...removed.chatIds];
const mergedKinds = [...paneTabKinds(p), ...paneTabKinds(removed)];
// Preserve the target's existing focus — append, don't force-focus
// the moved tabs. Clamp only when the target had no active tab.
const ai = p.activeChatIdx >= 0 ? p.activeChatIdx : 0;
return {
...p,
kind: 'chat' as const,
chatIds: mergedIds,
activeChatIdx: ai,
chatId: mergedIds[ai],
};
return rebuildPane(p, mergedIds, mergedKinds, ai);
});
}
}
// Kill the tmux sessions of any terminal tabs that are NOT relocated
// (keyed by tab id, not pane id, since mixed panes hold many terminals).
if (removed && !relocated) {
killTerms(filterTabs(removed, () => false).removedTermIds);
}
const next = working.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, [sessionId]);
}, [killTerms]);
const hasClosedPanes = closedPaneStack.length > 0;
@@ -852,30 +937,28 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
// 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) {
const { ids, kinds } = filterTabs(p, (id) => !e.chatIds.includes(id));
if (ids.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 });
if (ids.length === 0 && p.kind === 'chat') {
// Drop the now-empty chat pane (the restored pane plus possibly others
// remain). rebuildPane would leave an empty landing — we'd rather drop.
continue;
}
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] });
stripped.push(rebuildPane(p, ids, kinds, Math.min(p.activeChatIdx, ids.length - 1)));
}
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 restoredKinds: WorkspaceTabKind[] =
e.tabKinds && e.tabKinds.length === e.chatIds.length
? e.tabKinds
: e.chatIds.map(() => (e.kind === 'coder' ? 'coder' : e.kind === 'terminal' ? 'terminal' : 'chat'));
const restored: WorkspacePane = rebuildPane(
{ id: generateId(), kind: e.kind, chatIds: [], activeChatIdx: -1 },
e.chatIds,
restoredKinds,
e.activeChatIdx,
);
const next = [...stripped, restored];
setActivePaneIdx(next.length - 1);
return next;
@@ -896,34 +979,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
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] };
if (pane.chatIds.length === 0) return pane;
const kinds = paneTabKinds(pane);
// Prune chat/coder tabs whose chats row was deleted. Terminal tabs have
// no chats row, so they're always kept.
const { ids, kinds: nextKinds } = filterTabs(
pane,
(id, i) => kinds[i] === 'terminal' || validChatIds.has(id),
);
if (ids.length === pane.chatIds.length) return pane;
return rebuildPane(pane, ids, nextKinds, Math.min(pane.activeChatIdx, ids.length - 1));
});
const unchanged = cleaned.every((p, i) => p === prev[i]);
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;
return unchanged ? prev : cleaned;
});
}, [seedPaneChat]);
}, []);
const isPaneChatPending = useCallback(
(paneId: string) => pendingPaneChatIds.has(paneId),
@@ -932,19 +1002,9 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
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],
};
if (!p.chatIds.includes(chatId)) return p;
const { ids, kinds } = filterTabs(p, (id) => id !== chatId);
return rebuildPane(p, ids, kinds, Math.min(p.activeChatIdx, ids.length - 1));
}));
}, []);
@@ -1013,6 +1073,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
openSessionHistory,
closeSessionHistory,
addSplitPane,
createTab,
createCoderTab,
toggleSettingsPane,
removePane,