From 2fd7e5bf97f4f42559cfe0f91617856d4a0e0f52 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 31 May 2026 02:15:03 +0000 Subject: [PATCH] feat(web): workspace panes & tabs overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/api/client.ts | 9 +- apps/web/src/api/types.ts | 29 +- apps/web/src/api/ws-frames.ts | 7 +- apps/web/src/components/ChatTabBar.tsx | 56 +++- apps/web/src/components/MessageBubble.tsx | 82 +---- .../web/src/components/SessionLandingPage.tsx | 128 ++++++- apps/web/src/components/Workspace.tsx | 5 + .../src/components/panes/CoderMessageList.tsx | 2 +- apps/web/src/hooks/sessionEvents.ts | 15 +- apps/web/src/hooks/useSidebar.ts | 1 + apps/web/src/hooks/useWorkspacePanes.ts | 314 +++++++++++++++--- 11 files changed, 506 insertions(+), 142 deletions(-) diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 758c5d4..e17ca5f 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -22,6 +22,7 @@ import type { CoderTaskDetail, PermissionPrompt, AgentCommand, + WorkspaceState, } from './types'; export class ApiError extends Error { @@ -175,10 +176,10 @@ export const api = { ), openChatsCount: (id: string) => request<{ count: number }>(`/api/sessions/${id}/chats/open-count`), - updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) => + updateWorkspacePanes: (id: string, state: WorkspaceState) => request(`/api/sessions/${id}/workspace`, { method: 'PATCH', - body: JSON.stringify({ workspace_panes: panes }), + body: JSON.stringify({ workspace_panes: state }), }), }, @@ -354,6 +355,10 @@ export const api = { request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`), getTask: (taskId: string) => request(`/api/coder/tasks/${taskId}`), + // Cancel a pending/running coder task (cancels permission wait + inference; + // server sets state='cancelled'). Used by CoderPane's stop button. + cancelTask: (taskId: string) => + request<{ cancelled: boolean }>(`/api/coder/tasks/${taskId}/cancel`, { method: 'POST' }), listMessages: (sessionId: string, chatId?: string) => request( `/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`, diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 45c3b3f..d0b3ac5 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -60,7 +60,10 @@ export interface Session { // v1.9: null = inherit from project.default_web_search_enabled. web_search_enabled: boolean | null; // v1.12.1: server-authoritative pane layout, replaces localStorage. - workspace_panes: WorkspacePane[]; + // A value may be the legacy bare WorkspacePane[] (older rows) OR the new + // WorkspaceState envelope (panes + tab numbering + reopen stack). Normalize + // on read via useWorkspacePanes' toWorkspaceState. + workspace_panes: WorkspacePane[] | WorkspaceState; // v1.13.17: paths the agent has been granted read access to via the // request_read_access tool. Empty by default. Settings UI surfaces the // list with per-row revoke; the grant flow itself appends through the @@ -511,6 +514,30 @@ export interface WorkspacePane { html_artifact_state?: HtmlArtifactState; } +// Reopen LIFO stack entry. Shape unchanged from the prior module-level stack; +// now persisted inside the WorkspaceState envelope so the reopen-pane stack +// survives a reload / cross-device sync. +export interface ClosedPaneEntry { + kind: WorkspacePane['kind']; + chatIds: string[]; + activeChatIdx: number; +} + +// Envelope persisted to sessions.workspace_panes. Supersedes the bare +// WorkspacePane[] shape (still accepted on read for legacy rows — see the +// migration in useWorkspacePanes.toWorkspaceState). The server accepts either +// shape; the frontend always emits this envelope going forward. +export interface WorkspaceState { + panes: WorkspacePane[]; + // Stable, session-scoped tab number per chat id. Numbers only ever increase + // and are never reused (retired entries are pruned on tab close). + tabNumbers: { [chatId: string]: number }; + // Next number to hand out; starts at 1; ONLY increments. + nextTabNumber: number; + // Reopen LIFO stack, max 10, most-recent last. + closedPaneStack: ClosedPaneEntry[]; +} + export type WsFrame = | { type: 'snapshot'; messages: Message[] } | { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole } diff --git a/apps/web/src/api/ws-frames.ts b/apps/web/src/api/ws-frames.ts index b743309..63a4014 100644 --- a/apps/web/src/api/ws-frames.ts +++ b/apps/web/src/api/ws-frames.ts @@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({ export const SessionWorkspaceUpdatedFrame = z.object({ type: z.literal('session_workspace_updated'), session_id: Uuid, - workspace_panes: z.array(OpaqueObject), + // v2.6.x: widened from z.array — the payload is now either the legacy bare + // WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers + + // nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop + // every envelope frame at validation. MUST be mirrored in the server's + // byte-identical copy (parity test). + workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]), }); export const ChatCreatedFrame = z.object({ diff --git a/apps/web/src/components/ChatTabBar.tsx b/apps/web/src/components/ChatTabBar.tsx index 7c84a94..aa7aeb7 100644 --- a/apps/web/src/components/ChatTabBar.tsx +++ b/apps/web/src/components/ChatTabBar.tsx @@ -16,11 +16,15 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useLongPress } from '@/hooks/useLongPress'; +import { sessionEvents } from '@/hooks/sessionEvents'; import { cn } from '@/lib/utils'; interface Props { pane: WorkspacePane; tabs: Chat[]; + // v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by + // chat.id, NEVER by tab position. + tabNumbers: Record; onSwitchTab: (tabIdx: number) => void; onRemoveTab: (chatId: string) => void; onCloseOthers: (chatId: string) => void; @@ -37,6 +41,7 @@ interface Props { export function ChatTabBar({ pane, tabs, + tabNumbers, onSwitchTab, onRemoveTab, onCloseOthers, @@ -83,6 +88,9 @@ export function ChatTabBar({ const isLast = tabIdx === tabs.length - 1; const onlyTab = tabs.length === 1; const label = chat.name ?? 'New chat'; + // v2.6.x: stable tab number keyed by chat.id (NOT tab position). + // Omit gracefully when not yet assigned. + const tabNumber = tabNumbers[chat.id]; return ( @@ -117,8 +125,11 @@ export function ChatTabBar({ className="bg-transparent border-b border-border text-xs outline-none w-28" /> ) : ( - - {label} + + {tabNumber !== undefined ? `${tabNumber} · ${label}` : label} )} + + + + + + {/* New BooChat opens a tab in THIS pane; terminal/coder can't be + tabs, so they split into a new pane (matches the Split menu). */} + + New BooChat + + onSplitPane('terminal')}> + New BooTerm + + onSplitPane('coder')}> + New BooCode + + + )} - {isAssistant && !hiddenSet.has('openInPane') && ( - - )} {isAssistant && ( + ))} + + + )} + {archivedChats.length > 0 && ( + <> +

+ Archived +

+
+ {archivedChats.map((c) => ( + + ))} +
+ + )} + + )} + switchTab(idx, tabIdx)} onRemoveTab={(chatId) => removeTab(idx, chatId)} onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)} @@ -390,6 +392,9 @@ export function Workspace({ createChat={() => api.chats.create(sessionId)} onSend={(content) => void handleLandingSend(idx, content)} onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)} + chats={chats} + onOpenChat={(chatId) => openChatInPane(idx, chatId)} + onUnarchiveChat={unarchiveChat} /> )} diff --git a/apps/web/src/components/panes/CoderMessageList.tsx b/apps/web/src/components/panes/CoderMessageList.tsx index 56172f5..b783dc4 100644 --- a/apps/web/src/components/panes/CoderMessageList.tsx +++ b/apps/web/src/components/panes/CoderMessageList.tsx @@ -149,7 +149,7 @@ interface Props { actions?: MessageActions; } -const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane']; +const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork']; export function CoderMessageList({ messages, chatId, footer, actions }: Props) { const endRef = useRef(null); diff --git a/apps/web/src/hooks/sessionEvents.ts b/apps/web/src/hooks/sessionEvents.ts index 2601dcf..ed57f64 100644 --- a/apps/web/src/hooks/sessionEvents.ts +++ b/apps/web/src/hooks/sessionEvents.ts @@ -51,7 +51,11 @@ export interface SessionUpdatedEvent { export interface SessionWorkspaceUpdatedEvent { type: 'session_workspace_updated'; session_id: string; - workspace_panes: import('@/api/types').WorkspacePane[]; + // Legacy bare array OR the new envelope — useWorkspacePanes normalizes both + // via toWorkspaceState. + workspace_panes: + | import('@/api/types').WorkspacePane[] + | import('@/api/types').WorkspaceState; } export interface SessionLoadedEvent { @@ -75,6 +79,14 @@ export interface OpenChatInActivePaneEvent { chat_id: string; } +// Open a whole chat in a fresh split pane (vs the active pane). Emitted by the +// ChatTabBar tab context menu ("Open in new pane") and by MessageBubble.fork() +// so a fork lands beside the original. useWorkspacePanes subscribes. +export interface OpenChatInNewPaneEvent { + type: 'open_chat_in_new_pane'; + chat_id: string; +} + // v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of // these; useWorkspacePanes subscribes and inserts the corresponding artifact // pane (or focuses an existing one keyed by message_id). @@ -178,6 +190,7 @@ export type SessionEvent = | OpenFileInBrowserEvent | AttachChatFileEvent | OpenChatInActivePaneEvent + | OpenChatInNewPaneEvent | OpenMarkdownArtifactPaneEvent | OpenHtmlArtifactPaneEvent | OpenSettingsPaneEvent diff --git a/apps/web/src/hooks/useSidebar.ts b/apps/web/src/hooks/useSidebar.ts index d4f6652..d90ffe1 100644 --- a/apps/web/src/hooks/useSidebar.ts +++ b/apps/web/src/hooks/useSidebar.ts @@ -152,6 +152,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess case 'attach_chat_file': return prev; case 'open_chat_in_active_pane': + case 'open_chat_in_new_pane': // Consumed by Workspace; sidebar has no business with pane state. return prev; case 'open_markdown_artifact_pane': diff --git a/apps/web/src/hooks/useWorkspacePanes.ts b/apps/web/src/hooks/useWorkspacePanes.ts index ed1d12d..c21b78c 100644 --- a/apps/web/src/hooks/useWorkspacePanes.ts +++ b/apps/web/src/hooks/useWorkspacePanes.ts @@ -3,9 +3,11 @@ 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'; @@ -32,19 +34,35 @@ function chatPane(chatId: string): WorkspacePane { return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; } -interface ClosedPaneEntry { - kind: WorkspacePane['kind']; - chatIds: string[]; - activeChatIdx: number; -} +// 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; -const closedPaneStack: ClosedPaneEntry[] = []; -function pushClosed(pane: WorkspacePane): void { - if (pane.kind === 'empty' || pane.kind === 'settings') return; - if (pane.chatIds.length === 0) return; - closedPaneStack.push({ kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx }); - if (closedPaneStack.length > MAX_CLOSED) closedPaneStack.shift(); +// 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 { @@ -110,6 +128,26 @@ 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 { @@ -132,6 +170,9 @@ function readLegacyPanes(sessionId: string): WorkspacePane[] | null { export interface UseWorkspacePanesResult { panes: WorkspacePane[]; + // v2.6.x: stable session-scoped tab number per chat id (Batch 3a). Keyed by + // chat.id, NEVER by tab position. + tabNumbers: Record; activePaneIdx: number; setActivePaneIdx: React.Dispatch>; activePaneIdxRef: React.MutableRefObject; @@ -171,6 +212,12 @@ export interface UseWorkspacePanesResult { export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { const [panes, setPanes] = useState(() => [emptyPane()]); const [activePaneIdx, setActivePaneIdx] = useState(0); + // v2.6.x envelope state. Persisted alongside `panes` in the WorkspaceState + // envelope. `tabNumbers` is the stable session-scoped tab number per chat id; + // `nextTabNumber` only ever increments; `closedPaneStack` is the reopen LIFO. + const [tabNumbers, setTabNumbers] = useState>({}); + const [nextTabNumber, setNextTabNumber] = useState(1); + const [closedPaneStack, setClosedPaneStack] = useState([]); const draggingIdxRef = useRef(null); const [dragOverIdx, setDragOverIdx] = useState(null); // v1.12.1: skip PATCH while hydrating from the server. Without this, the @@ -237,27 +284,42 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { try { const session = await api.sessions.get(sessionId); if (cancelled) return; - let initial: WorkspacePane[] = Array.isArray(session.workspace_panes) - ? normalizePanes(session.workspace_panes) - : []; + 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 and delete the local key. + // 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 updated = await api.sessions.updateWorkspacePanes(sessionId, legacy); + const seedState: WorkspaceState = { + panes: persistablePanes(legacy), + tabNumbers: {}, + nextTabNumber: 1, + closedPaneStack: [], + }; + const updated = await api.sessions.updateWorkspacePanes(sessionId, seedState); if (cancelled) return; - initial = updated.workspace_panes; + env = toWorkspaceState(updated.workspace_panes); + initial = normalizePanes(env.panes); localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`); } catch { - initial = legacy; + env = { ...env, panes: legacy }; + initial = normalizePanes(legacy); } } } const next = initial.length > 0 ? initial : [emptyPane()]; - lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next)); + 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 { @@ -273,15 +335,25 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { return sessionEvents.subscribe((ev) => { if (ev.type !== 'session_workspace_updated') return; if (ev.session_id !== sessionId) return; - const incoming = normalizePanes( - Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [], - ); - const json = JSON.stringify(incoming); + 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; - setPanes(incoming.length > 0 ? incoming : [emptyPane()]); + 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(incoming.length > 0 ? incoming : [emptyPane()]); + seedEmptyScopedPanes(nextPanes); }); }, [sessionId, seedEmptyScopedPanes]); @@ -333,18 +405,75 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { // before saving (ephemeral per v1.9). useEffect(() => { if (!hydratedRef.current) return; - const payload = persistablePanes(panes); - const json = JSON.stringify(payload); + // 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, payload).catch(() => { + 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]); + }, [sessionId, panes, tabNumbers, nextTabNumber, closedPaneStack]); + + // v2.6.x (Batch 3a): maintain stable, session-scoped tab numbers. Collect the + // chat ids that appear in CHAT-kind panes in deterministic order (pane index, + // then tab index). Assign numbers to any without one (global per session, + // only ever increasing, never reused) and prune entries whose chat is no + // longer in any chat-kind pane. Guarded against render loops: only setState + // when something actually changed. + useEffect(() => { + const liveChatIds: string[] = []; + const liveSet = new Set(); + for (const pane of panes) { + if (pane.kind !== 'chat') continue; + for (const id of pane.chatIds) { + if (!liveSet.has(id)) { + liveSet.add(id); + liveChatIds.push(id); + } + } + } + + // Assign: walk live ids in deterministic order, handing out numbers. + let counter = nextTabNumber; + const additions: Record = {}; + for (const id of liveChatIds) { + if (tabNumbers[id] === undefined && additions[id] === undefined) { + additions[id] = counter; + counter += 1; + } + } + + // Prune: retire numbers for chats no longer in any chat-kind pane. + const removals: string[] = []; + for (const id of Object.keys(tabNumbers)) { + if (!liveSet.has(id)) removals.push(id); + } + + const hasAdditions = Object.keys(additions).length > 0; + const hasRemovals = removals.length > 0; + if (!hasAdditions && !hasRemovals) return; + + setTabNumbers((prev) => { + const next: Record = {}; + for (const [id, n] of Object.entries(prev)) { + if (!removals.includes(id)) next[id] = n; + } + Object.assign(next, additions); + return next; + }); + if (hasAdditions) setNextTabNumber(counter); + }, [panes, tabNumbers, nextTabNumber]); useEffect(() => { const active = panes[activePaneIdx]; @@ -391,6 +520,37 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { 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]; @@ -411,7 +571,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { if (next.length > 1) { // Last tab closed and other panes exist — remove the whole pane // instead of leaving an orphaned empty panel. - pushClosed(pane); setHasClosedPanes(true); + setClosedPaneStack((stack) => appendClosed(stack, pane)); const spliced = next.filter((_, i) => i !== paneIdx); setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1)); return spliced; @@ -547,7 +707,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { 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. + // 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()]; @@ -559,35 +720,101 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { // The endpoint is idempotent (404 on missing session) so a strict-mode // double-invoke of the updater is safe. const removed = prev[idx]; - if (removed) { pushClosed(removed); setHasClosedPanes(true); } + // 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 */ }); } - const next = prev.filter((_, i) => i !== idx); + + // 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, setHasClosedPanes] = useState(closedPaneStack.length > 0); + const hasClosedPanes = closedPaneStack.length > 0; const reopenPane = useCallback(() => { - const entry = closedPaneStack.pop(); - setHasClosedPanes(closedPaneStack.length > 0); - if (!entry) return; + // 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: entry.kind, - chatId: entry.chatIds[entry.activeChatIdx] ?? entry.chatIds[0], - chatIds: entry.chatIds, - activeChatIdx: Math.min(entry.activeChatIdx, entry.chatIds.length - 1), + 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 = [...prev, restored]; + 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. @@ -705,6 +932,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { return { panes, + tabNumbers, activePaneIdx, setActivePaneIdx, activePaneIdxRef,