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,