diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index d7a2e4b..0d28839 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -40,11 +40,15 @@ const PaneKindZ = z.enum([ 'html_artifact', ]); +// Mixed tabs: each tab carries its own kind (parallel to chatIds). +const TabKindZ = z.enum(['chat', 'coder', 'terminal']); + const WorkspacePaneZ = z.object({ id: z.string().min(1).max(200), kind: PaneKindZ, chatId: z.string().min(1).max(200).optional(), chatIds: z.array(z.string().min(1).max(200)).max(50), + tabKinds: z.array(TabKindZ).max(50).optional(), activeChatIdx: z.number().int(), markdown_artifact_state: MarkdownArtifactStateZ.optional(), html_artifact_state: HtmlArtifactStateZ.optional(), @@ -57,6 +61,7 @@ const WorkspacePaneZ = z.object({ const ClosedPaneEntryZ = z.object({ kind: PaneKindZ, chatIds: z.array(z.string().min(1).max(200)).max(50), + tabKinds: z.array(TabKindZ).max(50).optional(), activeChatIdx: z.number().int(), }); diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index ef41b3e..871d9fb 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -392,6 +392,11 @@ export type WorkspacePaneKind = | 'markdown_artifact' | 'html_artifact'; +// Mixed tabs: a pane can hold tabs of different kinds (a BooChat tab next to a +// BooCode tab next to a Terminal tab). Each tab carries its own kind; the active +// tab's kind drives what the pane renders. `tabKinds` is parallel to `chatIds`. +export type WorkspaceTabKind = 'chat' | 'coder' | 'terminal'; + // v1.14.x: per-pane artifact payloads. Optional + namespaced so older saved // pane rows (without these fields) deserialize unchanged. // v1.14.x: pane state is a reference only — the pane component fetches the @@ -413,9 +418,17 @@ export interface HtmlArtifactState { export interface WorkspacePane { id: string; + // For a tabbed pane (chat/coder/terminal) this mirrors the ACTIVE tab's kind, + // so the existing render-by-pane.kind path renders the active tab. Special + // panes (empty/settings/artifact) keep their own kind. kind: WorkspacePaneKind; chatId?: string; + // Tab ids. For chat/coder tabs this is the chats-row id; for terminal tabs + // it's a generated id used to key the tmux session. Parallel to tabKinds. chatIds: string[]; + // Per-tab kind, parallel to chatIds. Optional for legacy rows (back-filled on + // load from pane.kind via normalizePaneKind). + tabKinds?: WorkspaceTabKind[]; activeChatIdx: number; // v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'. markdown_artifact_state?: MarkdownArtifactState; @@ -428,6 +441,7 @@ export interface WorkspacePane { export interface ClosedPaneEntry { kind: WorkspacePane['kind']; chatIds: string[]; + tabKinds?: WorkspaceTabKind[]; activeChatIdx: number; } diff --git a/apps/web/src/components/ChatTabBar.tsx b/apps/web/src/components/ChatTabBar.tsx index b95fdf1..43277e4 100644 --- a/apps/web/src/components/ChatTabBar.tsx +++ b/apps/web/src/components/ChatTabBar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { Code, History, MessageSquare, X } from 'lucide-react'; -import type { Chat, WorkspacePane } from '@/api/types'; +import { Clipboard, Code, History, MessageSquare, Terminal, X } from 'lucide-react'; +import type { WorkspacePane, WorkspaceTabKind } from '@/api/types'; import { StatusDot } from '@/components/StatusDot'; import { PaneHeaderActions } from '@/components/PaneHeaderActions'; import { @@ -14,32 +14,51 @@ import { useLongPress } from '@/hooks/useLongPress'; import { sessionEvents } from '@/hooks/sessionEvents'; import { cn } from '@/lib/utils'; +// Mixed tabs: a pane can hold tabs of different kinds. Each tab is described by +// its id, kind, and label (terminal tabs have no chats row, so their label is +// supplied by the caller). +export interface TabDescriptor { + id: string; + kind: WorkspaceTabKind; + name: string | null; +} + interface Props { pane: WorkspacePane; - tabs: Chat[]; - // Host pane kind — 'coder' shows the Code glyph + routes the "+" to a new - // BooCode tab. Defaults to 'chat' (the BooChat tab bar). - tabKind?: 'chat' | 'coder'; - // v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by - // chat.id, NEVER by tab position. + tabs: TabDescriptor[]; + // v2.6.x (Batch 3a): stable session-scoped tab number per id. tabNumbers: Record; onSwitchTab: (tabIdx: number) => void; - onRemoveTab: (chatId: string) => void; - onCloseOthers: (chatId: string) => void; - onCloseToRight: (chatId: string) => void; + onRemoveTab: (id: string) => void; + onCloseOthers: (id: string) => void; + onCloseToRight: (id: string) => void; onCloseAll: () => void; - onNewTab: () => void; + // Mixed tabs: the "+" adds a tab of the chosen kind to THIS pane. + onNewTab: (kind: WorkspaceTabKind) => void; onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void; onReopenPane?: () => void; onShowHistory: () => void; onRename: (chatId: string, name: string) => Promise; onRemovePane?: () => void; + // iOS-safe terminal paste, shown only when the active tab is a terminal. + onTerminalPaste?: () => void; +} + +function iconForKind(kind: WorkspaceTabKind) { + if (kind === 'coder') return Code; + if (kind === 'terminal') return Terminal; + return MessageSquare; +} + +function defaultName(kind: WorkspaceTabKind): string { + if (kind === 'coder') return 'BooCoder'; + if (kind === 'terminal') return 'Terminal'; + return 'New chat'; } export function ChatTabBar({ pane, tabs, - tabKind = 'chat', tabNumbers, onSwitchTab, onRemoveTab, @@ -52,15 +71,13 @@ export function ChatTabBar({ onShowHistory, onRename, onRemovePane, + onTerminalPaste, }: Props) { const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); - const TabIcon = tabKind === 'coder' ? Code : MessageSquare; - const newLabel = tabKind === 'coder' ? 'New BooCode' : 'New chat'; // Long-press: dispatch a synthetic contextmenu event on the tab so the - // existing Radix ContextMenuTrigger opens at the touch coordinates. Works - // because asChild composition makes the tab div the trigger element. + // existing Radix ContextMenuTrigger opens at the touch coordinates. const longPress = useLongPress(({ clientX, clientY, target }) => { if (!target || !(target instanceof Element)) return; const tab = target.closest('[data-tab-id]') as HTMLElement | null; @@ -70,8 +87,8 @@ export function ChatTabBar({ ); }); - function startRename(chatId: string, currentName: string | null) { - setRenamingId(chatId); + function startRename(id: string, currentName: string | null) { + setRenamingId(id); setRenameValue(currentName ?? ''); } @@ -84,19 +101,19 @@ export function ChatTabBar({ return (
- {tabs.map((chat, tabIdx) => { + {tabs.map((tab, tabIdx) => { const isActive = tabIdx === pane.activeChatIdx; 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]; + const TabIcon = iconForKind(tab.kind); + const label = tab.name ?? defaultName(tab.kind); + const canRename = tab.kind !== 'terminal'; + const tabNumber = tabNumbers[tab.id]; return ( - +
onSwitchTab(tabIdx)} onTouchStart={longPress.onTouchStart} onTouchMove={longPress.onTouchMove} @@ -111,8 +128,8 @@ export function ChatTabBar({ )} > - - {renamingId === chat.id ? ( + {tab.kind !== 'terminal' && } + {renamingId === tab.id ? ( { e.stopPropagation(); - onRemoveTab(chat.id); + onRemoveTab(tab.id); }} className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0 max-md:min-h-[44px] max-md:min-w-[44px] max-md:opacity-100" aria-label="Close tab" @@ -147,34 +164,34 @@ export function ChatTabBar({
- - {newLabel} - - - sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id }) - } - > - Open in new pane + onNewTab(tab.kind)}> + New {defaultName(tab.kind)} + {tab.kind !== 'terminal' && ( + + sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: tab.id }) + } + > + Open in new pane + + )} + {canRename && ( + <> + + startRename(tab.id, tab.name)}> + Rename + + + )} - startRename(chat.id, chat.name)}> - Rename - - - onRemoveTab(chat.id)}> + onRemoveTab(tab.id)}> Close - onCloseOthers(chat.id)} - > + onCloseOthers(tab.id)}> Close others - onCloseToRight(chat.id)} - > + onCloseToRight(tab.id)}> Close to right onCloseAll()}> @@ -192,16 +209,30 @@ export function ChatTabBar({
)} - +
+ {onTerminalPaste && ( + + )} + +
); } diff --git a/apps/web/src/components/PaneHeaderActions.tsx b/apps/web/src/components/PaneHeaderActions.tsx index 9c3da0a..3257014 100644 --- a/apps/web/src/components/PaneHeaderActions.tsx +++ b/apps/web/src/components/PaneHeaderActions.tsx @@ -12,14 +12,9 @@ import { cn } from '@/lib/utils'; // desktop coder + terminal pane headers (Workspace) so all pane kinds share one // control set. Extracted to avoid a divergent copy per header. interface Props { - // When provided, the "+" menu item matching `tabKind` opens an in-pane tab - // (e.g. chat panes: New BooChat → tab; coder panes: New BooCode → tab). Every - // OTHER kind splits into a new pane. When onNewTab is omitted (terminal - // panes, which can't host tabs) all three items split. - onNewTab?: () => void; - // The host pane's own kind — the "+" item of this kind becomes "new tab". - // Defaults to 'chat' for back-compat with the chat tab bar. - tabKind?: 'chat' | 'terminal' | 'coder'; + // Mixed tabs: the "+" menu adds a tab of the chosen kind to THIS pane. Split + // (the second control) adds a new pane. + onNewTab: (kind: 'chat' | 'terminal' | 'coder') => void; onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void; onReopenPane?: () => void; onShowHistory: () => void; @@ -35,7 +30,6 @@ const BTN = export function PaneHeaderActions({ onNewTab, - tabKind = 'chat', onSplitPane, onReopenPane, onShowHistory, @@ -43,10 +37,6 @@ export function PaneHeaderActions({ historyActive, className, }: Props) { - // The "+" item of the host pane's own kind adds a tab; every other kind - // splits into a new pane. Falls back to split when onNewTab is absent. - const newOrSplit = (kind: 'chat' | 'terminal' | 'coder') => - onNewTab && tabKind === kind ? onNewTab : () => onSplitPane(kind); return (
@@ -55,22 +45,21 @@ export function PaneHeaderActions({ type="button" onClick={(e) => e.stopPropagation()} className={BTN} - aria-label="New chat, terminal, or coder" - title="New chat / terminal / coder" + aria-label="New tab" + title="New tab (chat / terminal / coder)" > - {/* The item matching the host pane's kind opens an in-pane tab; the - others split into a new pane. (tabKind defaults to 'chat'.) */} - + {/* Mixed tabs: every item adds a tab of that kind to THIS pane. */} + onNewTab('chat')}> New BooChat - + onNewTab('terminal')}> New BooTerm - + onNewTab('coder')}> New BooCode diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index b1a2f96..fb69fee 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import { Terminal, Clipboard } from 'lucide-react'; import { api } from '@/api/client'; -import type { Chat, Project, Session, WorkspacePane } from '@/api/types'; +import type { Project, Session, WorkspacePane, WorkspaceTabKind } from '@/api/types'; import { activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; import type { UseSessionChatsResult } from '@/hooks/useSessionChats'; import { useViewport } from '@/hooks/useViewport'; @@ -12,11 +11,17 @@ import { TerminalPane } from '@/components/panes/TerminalPane'; import { CoderPane } from '@/components/panes/CoderPane'; import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane'; import { HtmlArtifactPane } from '@/components/HtmlArtifactPane'; -import { ChatTabBar } from '@/components/ChatTabBar'; -import { PaneHeaderActions } from '@/components/PaneHeaderActions'; +import { ChatTabBar, type TabDescriptor } from '@/components/ChatTabBar'; import { SessionLandingPage } from '@/components/SessionLandingPage'; import { cn } from '@/lib/utils'; +// Mixed tabs: the kind of tab `i` in a pane, with a legacy fallback to pane.kind. +function tabKindAt(pane: WorkspacePane, i: number): WorkspaceTabKind { + const k = pane.tabKinds?.[i]; + if (k) return k; + return pane.kind === 'coder' ? 'coder' : pane.kind === 'terminal' ? 'terminal' : 'chat'; +} + interface Props { sessionId: string; projectId: string; @@ -59,8 +64,7 @@ export function Workspace({ historyPaneId, openSessionHistory, closeSessionHistory, - addSplitPane, - createCoderTab, + createTab, removePane, reopenPane, hasClosedPanes, @@ -75,7 +79,6 @@ export function Workspace({ } = panesHook; const { chats, - createChat, archiveChat, unarchiveChat, deleteChat, @@ -121,26 +124,35 @@ export function Workspace({ if (maximized && settingsIdx < 0) setMaximized(false); }, [maximized, settingsIdx]); - function chatsForPane(pane: WorkspacePane): Chat[] { - return pane.chatIds - .map((id) => chats.find((c) => c.id === id)) - .filter((c): c is Chat => c !== undefined); - } - - // v1.10 booterm: per-terminal label used by the registry that powers the - // MessageBubble "Send to terminal" submenu. Numbered in workspace order. + // v1.10 booterm + mixed tabs: per-terminal-TAB label, keyed by the terminal + // tab id (which keys its tmux session). Numbered across the workspace. const terminalLabels = useMemo(() => { const out = new Map(); let n = 0; for (const p of panes) { - if (p.kind === 'terminal') { - n += 1; - out.set(p.id, `Terminal ${n}`); - } + p.chatIds.forEach((id, i) => { + if (tabKindAt(p, i) === 'terminal') { + n += 1; + out.set(id, `Terminal ${n}`); + } + }); } return out; }, [panes]); + // Mixed tabs: descriptors for the tab strip — each tab's id, kind, and label. + // Terminal tabs have no chats row, so their label comes from terminalLabels. + function paneTabs(pane: WorkspacePane): TabDescriptor[] { + return pane.chatIds.map((id, i) => { + const kind = tabKindAt(pane, i); + const name = + kind === 'terminal' + ? terminalLabels.get(id) ?? 'Terminal' + : chats.find((c) => c.id === id)?.name ?? null; + return { id, kind, name }; + }); + } + return (
1 ? handlePaneDragStart(idx) : undefined} onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined} > - {/* Hidden on mobile; settings/terminal/artifact panes own their - own header. Chat and coder panes share this strip — tabKind - and onNewTab differ between the two. */} - {!isMobile && (isCoder || !isChromeless) && ( + {/* Mixed tabs: one unified strip for every tabbed pane + (chat / coder / terminal / empty-landing). The "+" adds a tab + of any kind; Split adds a pane. Settings/artifact panes own + their own headers. Hidden on mobile (mobile uses pane panes). */} + {!isMobile && !isSettings && !isArtifact && ( switchTab(idx, tabIdx)} onRemoveTab={(chatId) => removeTab(idx, chatId)} onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)} onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)} onCloseAll={() => closeAllTabs(idx)} - onNewTab={isCoder ? () => void createCoderTab(idx) : () => void createChat(idx)} + onNewTab={(kind) => void createTab(idx, kind)} onSplitPane={(kind) => onAddPane(kind)} onReopenPane={hasClosedPanes ? reopenPane : undefined} onShowHistory={() => openSessionHistory(idx)} onRename={renameChat} onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} + // iOS-safe terminal paste (real click is a user gesture), shown + // only while the active tab is a terminal. + onTerminalPaste={ + isTerminal + ? () => terminalsRegistry.get(activePaneChatId(pane) ?? pane.id)?.paste() + : undefined + } /> )} - {isTerminal && ( -
- - - {terminalLabels.get(pane.id) ?? 'Terminal'} - -
- {/* v1.10.4: iOS Safari restricts navigator.clipboard.readText - outside direct user gestures. A real button click IS a - gesture, so this works where keystroke-driven paste may - not on iOS. The action lives in TerminalPane behind the - registry's paste() callback. */} - - openSessionHistory(idx)} - onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} - /> -
-
- )}
@@ -260,9 +246,10 @@ export function Workspace({ /> ) : isTerminal ? ( ) : pane.kind === 'coder' ? ( diff --git a/apps/web/src/hooks/useWorkspacePanes.ts b/apps/web/src/hooks/useWorkspacePanes.ts index 0abf824..0340149 100644 --- a/apps/web/src/hooks/useWorkspacePanes.ts +++ b/apps/web/src/hooks/useWorkspacePanes.ts @@ -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; + /** Back-compat alias for createTab(paneIdx, 'coder'). */ createCoderTab: (paneIdx: number) => Promise; // 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) => { 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,