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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user