wip: pane/session + tab-bar checkpoint
Second checkpoint of in-flight work (sessions route, api types, ChatTabBar, PaneHeaderActions, Workspace, useWorkspacePanes) so the Orchestrator branch can rebase onto current main before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,11 +40,15 @@ const PaneKindZ = z.enum([
|
|||||||
'html_artifact',
|
'html_artifact',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Mixed tabs: each tab carries its own kind (parallel to chatIds).
|
||||||
|
const TabKindZ = z.enum(['chat', 'coder', 'terminal']);
|
||||||
|
|
||||||
const WorkspacePaneZ = z.object({
|
const WorkspacePaneZ = z.object({
|
||||||
id: z.string().min(1).max(200),
|
id: z.string().min(1).max(200),
|
||||||
kind: PaneKindZ,
|
kind: PaneKindZ,
|
||||||
chatId: z.string().min(1).max(200).optional(),
|
chatId: z.string().min(1).max(200).optional(),
|
||||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
|
tabKinds: z.array(TabKindZ).max(50).optional(),
|
||||||
activeChatIdx: z.number().int(),
|
activeChatIdx: z.number().int(),
|
||||||
markdown_artifact_state: MarkdownArtifactStateZ.optional(),
|
markdown_artifact_state: MarkdownArtifactStateZ.optional(),
|
||||||
html_artifact_state: HtmlArtifactStateZ.optional(),
|
html_artifact_state: HtmlArtifactStateZ.optional(),
|
||||||
@@ -57,6 +61,7 @@ const WorkspacePaneZ = z.object({
|
|||||||
const ClosedPaneEntryZ = z.object({
|
const ClosedPaneEntryZ = z.object({
|
||||||
kind: PaneKindZ,
|
kind: PaneKindZ,
|
||||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
|
tabKinds: z.array(TabKindZ).max(50).optional(),
|
||||||
activeChatIdx: z.number().int(),
|
activeChatIdx: z.number().int(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -392,6 +392,11 @@ export type WorkspacePaneKind =
|
|||||||
| 'markdown_artifact'
|
| 'markdown_artifact'
|
||||||
| 'html_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
|
// v1.14.x: per-pane artifact payloads. Optional + namespaced so older saved
|
||||||
// pane rows (without these fields) deserialize unchanged.
|
// pane rows (without these fields) deserialize unchanged.
|
||||||
// v1.14.x: pane state is a reference only — the pane component fetches the
|
// v1.14.x: pane state is a reference only — the pane component fetches the
|
||||||
@@ -413,9 +418,17 @@ export interface HtmlArtifactState {
|
|||||||
|
|
||||||
export interface WorkspacePane {
|
export interface WorkspacePane {
|
||||||
id: string;
|
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;
|
kind: WorkspacePaneKind;
|
||||||
chatId?: string;
|
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[];
|
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;
|
activeChatIdx: number;
|
||||||
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
|
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
|
||||||
markdown_artifact_state?: MarkdownArtifactState;
|
markdown_artifact_state?: MarkdownArtifactState;
|
||||||
@@ -428,6 +441,7 @@ export interface WorkspacePane {
|
|||||||
export interface ClosedPaneEntry {
|
export interface ClosedPaneEntry {
|
||||||
kind: WorkspacePane['kind'];
|
kind: WorkspacePane['kind'];
|
||||||
chatIds: string[];
|
chatIds: string[];
|
||||||
|
tabKinds?: WorkspaceTabKind[];
|
||||||
activeChatIdx: number;
|
activeChatIdx: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Code, History, MessageSquare, X } from 'lucide-react';
|
import { Clipboard, Code, History, MessageSquare, Terminal, X } from 'lucide-react';
|
||||||
import type { Chat, WorkspacePane } from '@/api/types';
|
import type { WorkspacePane, WorkspaceTabKind } from '@/api/types';
|
||||||
import { StatusDot } from '@/components/StatusDot';
|
import { StatusDot } from '@/components/StatusDot';
|
||||||
import { PaneHeaderActions } from '@/components/PaneHeaderActions';
|
import { PaneHeaderActions } from '@/components/PaneHeaderActions';
|
||||||
import {
|
import {
|
||||||
@@ -14,32 +14,51 @@ import { useLongPress } from '@/hooks/useLongPress';
|
|||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
interface Props {
|
||||||
pane: WorkspacePane;
|
pane: WorkspacePane;
|
||||||
tabs: Chat[];
|
tabs: TabDescriptor[];
|
||||||
// Host pane kind — 'coder' shows the Code glyph + routes the "+" to a new
|
// v2.6.x (Batch 3a): stable session-scoped tab number per id.
|
||||||
// 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.
|
|
||||||
tabNumbers: Record<string, number>;
|
tabNumbers: Record<string, number>;
|
||||||
onSwitchTab: (tabIdx: number) => void;
|
onSwitchTab: (tabIdx: number) => void;
|
||||||
onRemoveTab: (chatId: string) => void;
|
onRemoveTab: (id: string) => void;
|
||||||
onCloseOthers: (chatId: string) => void;
|
onCloseOthers: (id: string) => void;
|
||||||
onCloseToRight: (chatId: string) => void;
|
onCloseToRight: (id: string) => void;
|
||||||
onCloseAll: () => 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;
|
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||||
onReopenPane?: () => void;
|
onReopenPane?: () => void;
|
||||||
onShowHistory: () => void;
|
onShowHistory: () => void;
|
||||||
onRename: (chatId: string, name: string) => Promise<void>;
|
onRename: (chatId: string, name: string) => Promise<void>;
|
||||||
onRemovePane?: () => void;
|
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({
|
export function ChatTabBar({
|
||||||
pane,
|
pane,
|
||||||
tabs,
|
tabs,
|
||||||
tabKind = 'chat',
|
|
||||||
tabNumbers,
|
tabNumbers,
|
||||||
onSwitchTab,
|
onSwitchTab,
|
||||||
onRemoveTab,
|
onRemoveTab,
|
||||||
@@ -52,15 +71,13 @@ export function ChatTabBar({
|
|||||||
onShowHistory,
|
onShowHistory,
|
||||||
onRename,
|
onRename,
|
||||||
onRemovePane,
|
onRemovePane,
|
||||||
|
onTerminalPaste,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
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
|
// Long-press: dispatch a synthetic contextmenu event on the tab so the
|
||||||
// existing Radix ContextMenuTrigger opens at the touch coordinates. Works
|
// existing Radix ContextMenuTrigger opens at the touch coordinates.
|
||||||
// because asChild composition makes the tab div the trigger element.
|
|
||||||
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
||||||
if (!target || !(target instanceof Element)) return;
|
if (!target || !(target instanceof Element)) return;
|
||||||
const tab = target.closest('[data-tab-id]') as HTMLElement | null;
|
const tab = target.closest('[data-tab-id]') as HTMLElement | null;
|
||||||
@@ -70,8 +87,8 @@ export function ChatTabBar({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function startRename(chatId: string, currentName: string | null) {
|
function startRename(id: string, currentName: string | null) {
|
||||||
setRenamingId(chatId);
|
setRenamingId(id);
|
||||||
setRenameValue(currentName ?? '');
|
setRenameValue(currentName ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,19 +101,19 @@ export function ChatTabBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto max-md:hidden">
|
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto max-md:hidden">
|
||||||
{tabs.map((chat, tabIdx) => {
|
{tabs.map((tab, tabIdx) => {
|
||||||
const isActive = tabIdx === pane.activeChatIdx;
|
const isActive = tabIdx === pane.activeChatIdx;
|
||||||
const isLast = tabIdx === tabs.length - 1;
|
const isLast = tabIdx === tabs.length - 1;
|
||||||
const onlyTab = tabs.length === 1;
|
const onlyTab = tabs.length === 1;
|
||||||
const label = chat.name ?? 'New chat';
|
const TabIcon = iconForKind(tab.kind);
|
||||||
// v2.6.x: stable tab number keyed by chat.id (NOT tab position).
|
const label = tab.name ?? defaultName(tab.kind);
|
||||||
// Omit gracefully when not yet assigned.
|
const canRename = tab.kind !== 'terminal';
|
||||||
const tabNumber = tabNumbers[chat.id];
|
const tabNumber = tabNumbers[tab.id];
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={chat.id}>
|
<ContextMenu key={tab.id}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
data-tab-id={chat.id}
|
data-tab-id={tab.id}
|
||||||
onClick={() => onSwitchTab(tabIdx)}
|
onClick={() => onSwitchTab(tabIdx)}
|
||||||
onTouchStart={longPress.onTouchStart}
|
onTouchStart={longPress.onTouchStart}
|
||||||
onTouchMove={longPress.onTouchMove}
|
onTouchMove={longPress.onTouchMove}
|
||||||
@@ -111,8 +128,8 @@ export function ChatTabBar({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TabIcon size={12} className="shrink-0" />
|
<TabIcon size={12} className="shrink-0" />
|
||||||
<StatusDot chatId={chat.id} />
|
{tab.kind !== 'terminal' && <StatusDot chatId={tab.id} />}
|
||||||
{renamingId === chat.id ? (
|
{renamingId === tab.id ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={renameValue}
|
value={renameValue}
|
||||||
@@ -137,7 +154,7 @@ export function ChatTabBar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
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"
|
aria-label="Close tab"
|
||||||
@@ -147,34 +164,34 @@ export function ChatTabBar({
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem onSelect={onNewTab}>
|
<ContextMenuItem onSelect={() => onNewTab(tab.kind)}>
|
||||||
{newLabel}
|
New {defaultName(tab.kind)}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
{tab.kind !== 'terminal' && (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id })
|
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: tab.id })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Open in new pane
|
Open in new pane
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
{canRename && (
|
||||||
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
<ContextMenuItem onSelect={() => startRename(tab.id, tab.name)}>
|
||||||
Rename
|
Rename
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onSelect={() => onRemoveTab(chat.id)}>
|
<ContextMenuItem onSelect={() => onRemoveTab(tab.id)}>
|
||||||
Close
|
Close
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem disabled={onlyTab} onSelect={() => onCloseOthers(tab.id)}>
|
||||||
disabled={onlyTab}
|
|
||||||
onSelect={() => onCloseOthers(chat.id)}
|
|
||||||
>
|
|
||||||
Close others
|
Close others
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem disabled={isLast} onSelect={() => onCloseToRight(tab.id)}>
|
||||||
disabled={isLast}
|
|
||||||
onSelect={() => onCloseToRight(chat.id)}
|
|
||||||
>
|
|
||||||
Close to right
|
Close to right
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onSelect={() => onCloseAll()}>
|
<ContextMenuItem onSelect={() => onCloseAll()}>
|
||||||
@@ -192,10 +209,23 @@ export function ChatTabBar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center px-1 shrink-0">
|
||||||
|
{onTerminalPaste && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTerminalPaste();
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="Paste from clipboard"
|
||||||
|
title="Paste from clipboard"
|
||||||
|
>
|
||||||
|
<Clipboard size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<PaneHeaderActions
|
<PaneHeaderActions
|
||||||
className="ml-auto px-1"
|
|
||||||
onNewTab={onNewTab}
|
onNewTab={onNewTab}
|
||||||
tabKind={tabKind}
|
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
onReopenPane={onReopenPane}
|
onReopenPane={onReopenPane}
|
||||||
onShowHistory={onShowHistory}
|
onShowHistory={onShowHistory}
|
||||||
@@ -203,5 +233,6 @@ export function ChatTabBar({
|
|||||||
historyActive={pane.kind === 'empty'}
|
historyActive={pane.kind === 'empty'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,9 @@ import { cn } from '@/lib/utils';
|
|||||||
// desktop coder + terminal pane headers (Workspace) so all pane kinds share one
|
// desktop coder + terminal pane headers (Workspace) so all pane kinds share one
|
||||||
// control set. Extracted to avoid a divergent copy per header.
|
// control set. Extracted to avoid a divergent copy per header.
|
||||||
interface Props {
|
interface Props {
|
||||||
// When provided, the "+" menu item matching `tabKind` opens an in-pane tab
|
// Mixed tabs: the "+" menu adds a tab of the chosen kind to THIS pane. Split
|
||||||
// (e.g. chat panes: New BooChat → tab; coder panes: New BooCode → tab). Every
|
// (the second control) adds a new pane.
|
||||||
// OTHER kind splits into a new pane. When onNewTab is omitted (terminal
|
onNewTab: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||||
// 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';
|
|
||||||
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||||
onReopenPane?: () => void;
|
onReopenPane?: () => void;
|
||||||
onShowHistory: () => void;
|
onShowHistory: () => void;
|
||||||
@@ -35,7 +30,6 @@ const BTN =
|
|||||||
|
|
||||||
export function PaneHeaderActions({
|
export function PaneHeaderActions({
|
||||||
onNewTab,
|
onNewTab,
|
||||||
tabKind = 'chat',
|
|
||||||
onSplitPane,
|
onSplitPane,
|
||||||
onReopenPane,
|
onReopenPane,
|
||||||
onShowHistory,
|
onShowHistory,
|
||||||
@@ -43,10 +37,6 @@ export function PaneHeaderActions({
|
|||||||
historyActive,
|
historyActive,
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: 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 (
|
return (
|
||||||
<div className={cn('flex items-center gap-0.5 shrink-0', className)}>
|
<div className={cn('flex items-center gap-0.5 shrink-0', className)}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -55,22 +45,21 @@ export function PaneHeaderActions({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className={BTN}
|
className={BTN}
|
||||||
aria-label="New chat, terminal, or coder"
|
aria-label="New tab"
|
||||||
title="New chat / terminal / coder"
|
title="New tab (chat / terminal / coder)"
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-fit">
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
{/* The item matching the host pane's kind opens an in-pane tab; the
|
{/* Mixed tabs: every item adds a tab of that kind to THIS pane. */}
|
||||||
others split into a new pane. (tabKind defaults to 'chat'.) */}
|
<DropdownMenuItem onSelect={() => onNewTab('chat')}>
|
||||||
<DropdownMenuItem onSelect={newOrSplit('chat')}>
|
|
||||||
<MessageSquare size={14} /> New BooChat
|
<MessageSquare size={14} /> New BooChat
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={newOrSplit('terminal')}>
|
<DropdownMenuItem onSelect={() => onNewTab('terminal')}>
|
||||||
<Terminal size={14} /> New BooTerm
|
<Terminal size={14} /> New BooTerm
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={newOrSplit('coder')}>
|
<DropdownMenuItem onSelect={() => onNewTab('coder')}>
|
||||||
<Code size={14} /> New BooCode
|
<Code size={14} /> New BooCode
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Terminal, Clipboard } from 'lucide-react';
|
|
||||||
import { api } from '@/api/client';
|
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 { activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
@@ -12,11 +11,17 @@ import { TerminalPane } from '@/components/panes/TerminalPane';
|
|||||||
import { CoderPane } from '@/components/panes/CoderPane';
|
import { CoderPane } from '@/components/panes/CoderPane';
|
||||||
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
|
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
|
||||||
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
|
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar, type TabDescriptor } from '@/components/ChatTabBar';
|
||||||
import { PaneHeaderActions } from '@/components/PaneHeaderActions';
|
|
||||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -59,8 +64,7 @@ export function Workspace({
|
|||||||
historyPaneId,
|
historyPaneId,
|
||||||
openSessionHistory,
|
openSessionHistory,
|
||||||
closeSessionHistory,
|
closeSessionHistory,
|
||||||
addSplitPane,
|
createTab,
|
||||||
createCoderTab,
|
|
||||||
removePane,
|
removePane,
|
||||||
reopenPane,
|
reopenPane,
|
||||||
hasClosedPanes,
|
hasClosedPanes,
|
||||||
@@ -75,7 +79,6 @@ export function Workspace({
|
|||||||
} = panesHook;
|
} = panesHook;
|
||||||
const {
|
const {
|
||||||
chats,
|
chats,
|
||||||
createChat,
|
|
||||||
archiveChat,
|
archiveChat,
|
||||||
unarchiveChat,
|
unarchiveChat,
|
||||||
deleteChat,
|
deleteChat,
|
||||||
@@ -121,26 +124,35 @@ export function Workspace({
|
|||||||
if (maximized && settingsIdx < 0) setMaximized(false);
|
if (maximized && settingsIdx < 0) setMaximized(false);
|
||||||
}, [maximized, settingsIdx]);
|
}, [maximized, settingsIdx]);
|
||||||
|
|
||||||
function chatsForPane(pane: WorkspacePane): Chat[] {
|
// v1.10 booterm + mixed tabs: per-terminal-TAB label, keyed by the terminal
|
||||||
return pane.chatIds
|
// tab id (which keys its tmux session). Numbered across the workspace.
|
||||||
.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.
|
|
||||||
const terminalLabels = useMemo(() => {
|
const terminalLabels = useMemo(() => {
|
||||||
const out = new Map<string, string>();
|
const out = new Map<string, string>();
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const p of panes) {
|
for (const p of panes) {
|
||||||
if (p.kind === 'terminal') {
|
p.chatIds.forEach((id, i) => {
|
||||||
|
if (tabKindAt(p, i) === 'terminal') {
|
||||||
n += 1;
|
n += 1;
|
||||||
out.set(p.id, `Terminal ${n}`);
|
out.set(id, `Terminal ${n}`);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [panes]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div
|
<div
|
||||||
@@ -191,61 +203,35 @@ export function Workspace({
|
|||||||
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||||
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
|
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||||
>
|
>
|
||||||
{/* Hidden on mobile; settings/terminal/artifact panes own their
|
{/* Mixed tabs: one unified strip for every tabbed pane
|
||||||
own header. Chat and coder panes share this strip — tabKind
|
(chat / coder / terminal / empty-landing). The "+" adds a tab
|
||||||
and onNewTab differ between the two. */}
|
of any kind; Split adds a pane. Settings/artifact panes own
|
||||||
{!isMobile && (isCoder || !isChromeless) && (
|
their own headers. Hidden on mobile (mobile uses pane panes). */}
|
||||||
|
{!isMobile && !isSettings && !isArtifact && (
|
||||||
<ChatTabBar
|
<ChatTabBar
|
||||||
pane={pane}
|
pane={pane}
|
||||||
tabs={chatsForPane(pane)}
|
tabs={paneTabs(pane)}
|
||||||
tabKind={isCoder ? 'coder' : undefined}
|
|
||||||
tabNumbers={tabNumbers}
|
tabNumbers={tabNumbers}
|
||||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||||
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
||||||
onCloseAll={() => closeAllTabs(idx)}
|
onCloseAll={() => closeAllTabs(idx)}
|
||||||
onNewTab={isCoder ? () => void createCoderTab(idx) : () => void createChat(idx)}
|
onNewTab={(kind) => void createTab(idx, kind)}
|
||||||
onSplitPane={(kind) => onAddPane(kind)}
|
onSplitPane={(kind) => onAddPane(kind)}
|
||||||
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||||
onShowHistory={() => openSessionHistory(idx)}
|
onShowHistory={() => openSessionHistory(idx)}
|
||||||
onRename={renameChat}
|
onRename={renameChat}
|
||||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
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 && (
|
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
|
||||||
<Terminal size={12} className="text-muted-foreground" />
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
|
||||||
</span>
|
|
||||||
<div className="ml-auto flex items-center gap-0.5">
|
|
||||||
{/* 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. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
terminalsRegistry.get(pane.id)?.paste();
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
||||||
aria-label="Paste from clipboard"
|
|
||||||
title="Paste from clipboard"
|
|
||||||
>
|
|
||||||
<Clipboard size={12} />
|
|
||||||
</button>
|
|
||||||
<PaneHeaderActions
|
|
||||||
onSplitPane={onAddPane}
|
|
||||||
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
|
||||||
onShowHistory={() => openSessionHistory(idx)}
|
|
||||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
@@ -260,9 +246,10 @@ export function Workspace({
|
|||||||
/>
|
/>
|
||||||
) : isTerminal ? (
|
) : isTerminal ? (
|
||||||
<TerminalPane
|
<TerminalPane
|
||||||
|
key={activePaneChatId(pane) ?? pane.id}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
paneId={pane.id}
|
paneId={activePaneChatId(pane) ?? pane.id}
|
||||||
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
label={terminalLabels.get(activePaneChatId(pane) ?? pane.id) ?? 'Terminal'}
|
||||||
active={idx === activePaneIdx}
|
active={idx === activePaneIdx}
|
||||||
/>
|
/>
|
||||||
) : pane.kind === 'coder' ? (
|
) : pane.kind === 'coder' ? (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
MarkdownArtifactState,
|
MarkdownArtifactState,
|
||||||
WorkspacePane,
|
WorkspacePane,
|
||||||
WorkspaceState,
|
WorkspaceState,
|
||||||
|
WorkspaceTabKind,
|
||||||
} from '@/api/types';
|
} from '@/api/types';
|
||||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -23,15 +24,84 @@ function generateId(): string {
|
|||||||
return crypto.randomUUID();
|
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
|
// 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
|
// setPanes updater so the new pane's id can be returned synchronously to the
|
||||||
// caller (needed for mobile URL state).
|
// caller (needed for mobile URL state).
|
||||||
function emptyPane(id: string = generateId()): WorkspacePane {
|
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 {
|
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
|
// 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[] {
|
function appendClosed(stack: ClosedPaneEntry[], pane: WorkspacePane): ClosedPaneEntry[] {
|
||||||
if (pane.kind === 'empty' || pane.kind === 'settings') return stack;
|
if (pane.kind === 'empty' || pane.kind === 'settings') return stack;
|
||||||
if (pane.chatIds.length === 0) 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
|
// Dedupe a value-identical top entry. This is called via setClosedPaneStack
|
||||||
// inside the setPanes updater in removePane; React StrictMode double-invokes
|
// inside the setPanes updater in removePane; React StrictMode double-invokes
|
||||||
// that updater in dev, which would otherwise push two identical entries.
|
// 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';
|
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). */
|
/** Active chat id for a pane row (chat / coder / terminal). */
|
||||||
export function activePaneChatId(pane: WorkspacePane): string | undefined {
|
export function activePaneChatId(pane: WorkspacePane): string | undefined {
|
||||||
@@ -114,10 +181,24 @@ function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
|
|||||||
// sidebar Settings button when needed.
|
// sidebar Settings button when needed.
|
||||||
function normalizePaneKind(pane: WorkspacePane): WorkspacePane {
|
function normalizePaneKind(pane: WorkspacePane): WorkspacePane {
|
||||||
// v2.3: server once accepted legacy 'agent' before 'coder' landed in the schema.
|
// v2.3: server once accepted legacy 'agent' before 'coder' landed in the schema.
|
||||||
if ((pane.kind as string) === 'agent') {
|
let p = pane;
|
||||||
return { ...pane, kind: 'coder' };
|
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[] {
|
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
|
// id to update mobile URL state so the URL-sync effect doesn't fight the
|
||||||
// freshly-set activePaneIdx.
|
// freshly-set activePaneIdx.
|
||||||
addSplitPane: (kind: 'chat' | 'terminal' | 'coder') => string | null;
|
addSplitPane: (kind: 'chat' | 'terminal' | 'coder') => string | null;
|
||||||
/** Append a new BooCode tab to an existing coder pane (the coder "+"). */
|
/** Mixed tabs: add a tab of any kind to a pane (the "+" menu). */
|
||||||
|
createTab: (paneIdx: number, kind: WorkspaceTabKind) => Promise<void>;
|
||||||
|
/** Back-compat alias for createTab(paneIdx, 'coder'). */
|
||||||
createCoderTab: (paneIdx: number) => Promise<void>;
|
createCoderTab: (paneIdx: number) => Promise<void>;
|
||||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
// 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(
|
// Fire-and-forget kill of terminal-tab tmux sessions (keyed by tab id). The
|
||||||
(paneId: string, chatId: string, kind: 'coder' | 'terminal') => {
|
// 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) =>
|
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;
|
if (pendingPaneChatRef.current.has(paneId)) return;
|
||||||
markPaneChatPending(paneId, true);
|
markPaneChatPending(paneId, true);
|
||||||
try {
|
try {
|
||||||
|
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) });
|
const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind(kind) });
|
||||||
attachChatToPane(paneId, chat.id, kind);
|
attachTabToPane(paneId, chat.id, kind);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to create pane chat');
|
toast.error(err instanceof Error ? err.message : 'Failed to create pane chat');
|
||||||
} finally {
|
} finally {
|
||||||
markPaneChatPending(paneId, false);
|
markPaneChatPending(paneId, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sessionId, attachChatToPane, markPaneChatPending],
|
[sessionId, attachTabToPane, markPaneChatPending],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add a new BooCode tab to an existing coder pane (the "+" in the coder pane
|
// Mixed tabs: add a new tab of ANY kind to a pane (the "+" menu). chat/coder
|
||||||
// header). Creates a fresh chat row (= a new agent context that shares the
|
// tabs create a fresh chats row; terminal tabs get a generated id (its own
|
||||||
// session worktree) and APPENDS it to the pane's chatIds, keeping the pane
|
// tmux session). The new tab is appended and focused, and pane.kind tracks it
|
||||||
// kind 'coder' and focusing the new tab. Mirrors createChat for chat panes;
|
// (rebuildPane). The "split into a new pane" action stays addSplitPane.
|
||||||
// the per-pane "split into a new pane" action stays addSplitPane.
|
const createTab = useCallback(
|
||||||
const createCoderTab = useCallback(
|
async (paneIdx: number, kind: WorkspaceTabKind) => {
|
||||||
async (paneIdx: number) => {
|
|
||||||
const paneId = panes[paneIdx]?.id;
|
const paneId = panes[paneIdx]?.id;
|
||||||
if (!paneId) return;
|
if (!paneId) return;
|
||||||
markPaneChatPending(paneId, true);
|
const appendTab = (tabId: string) =>
|
||||||
try {
|
|
||||||
const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind('coder') });
|
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const idx = prev.findIndex((p) => p.id === paneId);
|
const idx = prev.findIndex((p) => p.id === paneId);
|
||||||
if (idx < 0) return prev;
|
if (idx < 0) return prev;
|
||||||
const pane = prev[idx]!;
|
const pane = prev[idx]!;
|
||||||
const newIds = [...pane.chatIds, chat.id];
|
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[idx] = {
|
next[idx] = rebuildPane(
|
||||||
...pane,
|
pane,
|
||||||
kind: 'coder',
|
[...pane.chatIds, tabId],
|
||||||
chatId: chat.id,
|
[...paneTabKinds(pane), kind],
|
||||||
chatIds: newIds,
|
pane.chatIds.length,
|
||||||
activeChatIdx: newIds.length - 1,
|
);
|
||||||
};
|
|
||||||
return next;
|
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) {
|
} 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 {
|
} finally {
|
||||||
markPaneChatPending(paneId, false);
|
markPaneChatPending(paneId, false);
|
||||||
}
|
}
|
||||||
@@ -310,6 +416,12 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
[sessionId, panes, markPaneChatPending],
|
[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(
|
const seedEmptyScopedPanes = useCallback(
|
||||||
(paneList: WorkspacePane[]) => {
|
(paneList: WorkspacePane[]) => {
|
||||||
for (const pane of paneList) {
|
for (const pane of paneList) {
|
||||||
@@ -549,16 +661,15 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
const pane = next[paneIdx]!;
|
const pane = next[paneIdx]!;
|
||||||
const existing = pane.chatIds.indexOf(chatId);
|
const existing = pane.chatIds.indexOf(chatId);
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
|
next[paneIdx] = rebuildPane(pane, pane.chatIds, paneTabKinds(pane), existing);
|
||||||
} else {
|
} else {
|
||||||
const newIds = [...pane.chatIds, chatId];
|
// Opening a stored conversation appends a chat tab (mixed tabs).
|
||||||
next[paneIdx] = {
|
next[paneIdx] = rebuildPane(
|
||||||
...pane,
|
pane,
|
||||||
kind: 'chat',
|
[...pane.chatIds, chatId],
|
||||||
chatId,
|
[...paneTabKinds(pane), 'chat'],
|
||||||
chatIds: newIds,
|
pane.chatIds.length,
|
||||||
activeChatIdx: newIds.length - 1,
|
);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -600,9 +711,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
const pane = next[paneIdx]!;
|
const pane = next[paneIdx]!;
|
||||||
const chatId = pane.chatIds[tabIdx];
|
if (tabIdx < 0 || tabIdx >= pane.chatIds.length) return prev;
|
||||||
if (!chatId) return prev;
|
next[paneIdx] = rebuildPane(pane, pane.chatIds, paneTabKinds(pane), tabIdx);
|
||||||
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -611,9 +721,10 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
const pane = next[paneIdx]!;
|
const pane = next[paneIdx]!;
|
||||||
const nextIds = pane.chatIds.filter((id) => id !== chatId);
|
if (!pane.chatIds.includes(chatId)) return prev;
|
||||||
if (nextIds.length === 0) {
|
const { ids, kinds, removedTermIds } = filterTabs(pane, (id) => id !== chatId);
|
||||||
if (next.length > 1) {
|
killTerms(removedTermIds);
|
||||||
|
if (ids.length === 0 && next.length > 1) {
|
||||||
// Last tab closed and other panes exist — remove the whole pane
|
// Last tab closed and other panes exist — remove the whole pane
|
||||||
// instead of leaving an orphaned empty panel.
|
// instead of leaving an orphaned empty panel.
|
||||||
setClosedPaneStack((stack) => appendClosed(stack, pane));
|
setClosedPaneStack((stack) => appendClosed(stack, pane));
|
||||||
@@ -621,19 +732,10 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
|
||||||
return spliced;
|
return spliced;
|
||||||
}
|
}
|
||||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
next[paneIdx] = rebuildPane(pane, ids, kinds, Math.min(pane.activeChatIdx, ids.length - 1));
|
||||||
} else {
|
|
||||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
|
||||||
next[paneIdx] = {
|
|
||||||
...pane,
|
|
||||||
chatIds: nextIds,
|
|
||||||
activeChatIdx: nextActiveIdx,
|
|
||||||
chatId: nextIds[nextActiveIdx],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [killTerms]);
|
||||||
|
|
||||||
// Keep only the right-clicked tab open in this pane.
|
// Keep only the right-clicked tab open in this pane.
|
||||||
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
|
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
|
||||||
@@ -642,16 +744,12 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
const pane = next[paneIdx]!;
|
const pane = next[paneIdx]!;
|
||||||
const keepIdx = pane.chatIds.indexOf(keepChatId);
|
const keepIdx = pane.chatIds.indexOf(keepChatId);
|
||||||
if (keepIdx < 0) return prev;
|
if (keepIdx < 0) return prev;
|
||||||
// Preserve pane.kind (...pane) — a coder pane stays a coder pane.
|
const { ids, kinds, removedTermIds } = filterTabs(pane, (id) => id === keepChatId);
|
||||||
next[paneIdx] = {
|
killTerms(removedTermIds);
|
||||||
...pane,
|
next[paneIdx] = rebuildPane(pane, ids, kinds, 0);
|
||||||
chatId: keepChatId,
|
|
||||||
chatIds: [keepChatId],
|
|
||||||
activeChatIdx: 0,
|
|
||||||
};
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [killTerms]);
|
||||||
|
|
||||||
// Close every tab to the right of the right-clicked one.
|
// Close every tab to the right of the right-clicked one.
|
||||||
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
|
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
|
||||||
@@ -660,48 +758,38 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
const pane = next[paneIdx]!;
|
const pane = next[paneIdx]!;
|
||||||
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
|
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
|
||||||
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
|
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
|
||||||
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
|
const { ids, kinds, removedTermIds } = filterTabs(pane, (_id, i) => i <= pivotIdx);
|
||||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
killTerms(removedTermIds);
|
||||||
next[paneIdx] = {
|
next[paneIdx] = rebuildPane(pane, ids, kinds, Math.min(pane.activeChatIdx, ids.length - 1));
|
||||||
...pane,
|
|
||||||
chatIds: nextIds,
|
|
||||||
activeChatIdx: nextActiveIdx,
|
|
||||||
chatId: nextIds[nextActiveIdx],
|
|
||||||
};
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [killTerms]);
|
||||||
|
|
||||||
// Close every tab in this pane; land on landing page.
|
// Close every tab in this pane; land on landing page.
|
||||||
const closeAllTabs = useCallback((paneIdx: number) => {
|
const closeAllTabs = useCallback((paneIdx: number) => {
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
const pane = next[paneIdx]!;
|
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;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [killTerms]);
|
||||||
|
|
||||||
const showLandingPage = useCallback((paneIdx: number) => {
|
const showLandingPage = useCallback((paneIdx: number) => {
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const pane = prev[paneIdx];
|
const pane = prev[paneIdx];
|
||||||
if (!pane) return prev;
|
if (!pane) return prev;
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
if (pane.kind === 'coder' || pane.kind === 'terminal') {
|
// Drop the pane's tabs and show the landing page. Terminal tabs are
|
||||||
// Scoped panes don't host chat tabs. Leaving one for the session
|
// ephemeral — kill their tmux sessions (keyed by tab id) on close.
|
||||||
// history closes it: drop the pane→chat binding, and for terminals
|
const { removedTermIds } = filterTabs(pane, () => false);
|
||||||
// kill the tmux session (terminals are ephemeral — closing = killing,
|
if (removedTermIds.length > 0) killTerms(removedTermIds);
|
||||||
// mirroring removePane).
|
next[paneIdx] = rebuildPane(pane, [], [], -1);
|
||||||
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 };
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [sessionId]);
|
}, [killTerms]);
|
||||||
|
|
||||||
// Reveal the session-history list. Mirrors the desktop "Show history" action:
|
// Reveal the session-history list. Mirrors the desktop "Show history" action:
|
||||||
// convert the pane to its landing (showLandingPage) and flag it so the landing
|
// 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 =
|
const newPane =
|
||||||
kind === 'terminal'
|
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'
|
: 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);
|
: emptyPane(newPaneId);
|
||||||
const next = [...prev, newPane];
|
const next = [...prev, newPane];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
@@ -788,19 +876,14 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
const removed = prev[idx];
|
const removed = prev[idx];
|
||||||
// Push the original pane (with its chatIds intact) to the reopen stack.
|
// Push the original pane (with its chatIds intact) to the reopen stack.
|
||||||
if (removed) setClosedPaneStack((stack) => appendClosed(stack, removed));
|
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
|
// v2.6.x (Batch 1) + mixed tabs: relocate a closing CHAT-active pane's
|
||||||
// remaining pane that can host chat tabs, so chats aren't lost on close.
|
// tabs (any kind) to the oldest remaining pane that can host tabs, so
|
||||||
// Only chat panes relocate — terminal/coder panes own a scoped chat bound
|
// conversations aren't lost on close. Terminal/coder-active panes close
|
||||||
// to the pane, so those close exactly as before (no relocation).
|
// exactly as before (no relocation).
|
||||||
let working = prev;
|
let working = prev;
|
||||||
|
let relocated = false;
|
||||||
if (removed && removed.kind === 'chat' && removed.chatIds.length > 0) {
|
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;
|
let targetIdx = -1;
|
||||||
for (let i = 0; i < prev.length; i += 1) {
|
for (let i = 0; i < prev.length; i += 1) {
|
||||||
if (i === idx) continue;
|
if (i === idx) continue;
|
||||||
@@ -811,28 +894,30 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (targetIdx >= 0) {
|
if (targetIdx >= 0) {
|
||||||
|
relocated = true;
|
||||||
working = prev.map((p, i) => {
|
working = prev.map((p, i) => {
|
||||||
if (i !== targetIdx) return p;
|
if (i !== targetIdx) return p;
|
||||||
const mergedIds = [...p.chatIds, ...removed.chatIds];
|
const mergedIds = [...p.chatIds, ...removed.chatIds];
|
||||||
|
const mergedKinds = [...paneTabKinds(p), ...paneTabKinds(removed)];
|
||||||
// Preserve the target's existing focus — append, don't force-focus
|
// Preserve the target's existing focus — append, don't force-focus
|
||||||
// the moved tabs. Clamp only when the target had no active tab.
|
// the moved tabs. Clamp only when the target had no active tab.
|
||||||
const ai = p.activeChatIdx >= 0 ? p.activeChatIdx : 0;
|
const ai = p.activeChatIdx >= 0 ? p.activeChatIdx : 0;
|
||||||
return {
|
return rebuildPane(p, mergedIds, mergedKinds, ai);
|
||||||
...p,
|
|
||||||
kind: 'chat' as const,
|
|
||||||
chatIds: mergedIds,
|
|
||||||
activeChatIdx: ai,
|
|
||||||
chatId: mergedIds[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);
|
const next = working.filter((_, i) => i !== idx);
|
||||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [sessionId]);
|
}, [killTerms]);
|
||||||
|
|
||||||
const hasClosedPanes = closedPaneStack.length > 0;
|
const hasClosedPanes = closedPaneStack.length > 0;
|
||||||
|
|
||||||
@@ -852,30 +937,28 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
// dropped when other panes remain, else turned empty.
|
// dropped when other panes remain, else turned empty.
|
||||||
const stripped: WorkspacePane[] = [];
|
const stripped: WorkspacePane[] = [];
|
||||||
for (const p of prev) {
|
for (const p of prev) {
|
||||||
const idxs = p.chatIds.filter((id) => !e.chatIds.includes(id));
|
const { ids, kinds } = filterTabs(p, (id) => !e.chatIds.includes(id));
|
||||||
if (idxs.length === p.chatIds.length) {
|
if (ids.length === p.chatIds.length) {
|
||||||
stripped.push(p);
|
stripped.push(p);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (idxs.length === 0) {
|
if (ids.length === 0 && p.kind === 'chat') {
|
||||||
if (p.kind === 'chat') {
|
// Drop the now-empty chat pane (the restored pane plus possibly others
|
||||||
// Drop the now-empty chat pane (we still have the restored pane plus
|
// remain). rebuildPane would leave an empty landing — we'd rather drop.
|
||||||
// possibly others). If it would leave zero panes, turn it empty.
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
stripped.push({ ...p, chatId: undefined, chatIds: [], activeChatIdx: -1 });
|
stripped.push(rebuildPane(p, ids, kinds, Math.min(p.activeChatIdx, ids.length - 1)));
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
const ai = Math.min(p.activeChatIdx, idxs.length - 1);
|
const restoredKinds: WorkspaceTabKind[] =
|
||||||
stripped.push({ ...p, chatIds: idxs, activeChatIdx: ai < 0 ? 0 : ai, chatId: idxs[ai < 0 ? 0 : ai] });
|
e.tabKinds && e.tabKinds.length === e.chatIds.length
|
||||||
}
|
? e.tabKinds
|
||||||
const restored: WorkspacePane = {
|
: e.chatIds.map(() => (e.kind === 'coder' ? 'coder' : e.kind === 'terminal' ? 'terminal' : 'chat'));
|
||||||
id: generateId(),
|
const restored: WorkspacePane = rebuildPane(
|
||||||
kind: e.kind,
|
{ id: generateId(), kind: e.kind, chatIds: [], activeChatIdx: -1 },
|
||||||
chatId: e.chatIds[e.activeChatIdx] ?? e.chatIds[0],
|
e.chatIds,
|
||||||
chatIds: e.chatIds,
|
restoredKinds,
|
||||||
activeChatIdx: Math.min(e.activeChatIdx, e.chatIds.length - 1),
|
e.activeChatIdx,
|
||||||
};
|
);
|
||||||
const next = [...stripped, restored];
|
const next = [...stripped, restored];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
return next;
|
return next;
|
||||||
@@ -896,34 +979,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
const validatePanes = useCallback((validChatIds: Set<string>) => {
|
const validatePanes = useCallback((validChatIds: Set<string>) => {
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const cleaned = prev.map((pane) => {
|
const cleaned = prev.map((pane) => {
|
||||||
const usesChat =
|
if (pane.chatIds.length === 0) return pane;
|
||||||
pane.kind === 'chat' || pane.kind === 'coder' || pane.kind === 'terminal';
|
const kinds = paneTabKinds(pane);
|
||||||
if (!usesChat || pane.chatIds.length === 0) return pane;
|
// Prune chat/coder tabs whose chats row was deleted. Terminal tabs have
|
||||||
const nextIds = pane.chatIds.filter((id) => validChatIds.has(id));
|
// no chats row, so they're always kept.
|
||||||
if (nextIds.length === pane.chatIds.length) return pane;
|
const { ids, kinds: nextKinds } = filterTabs(
|
||||||
if (nextIds.length === 0) {
|
pane,
|
||||||
if (pane.kind === 'chat') {
|
(id, i) => kinds[i] === 'terminal' || validChatIds.has(id),
|
||||||
return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
);
|
||||||
}
|
if (ids.length === pane.chatIds.length) return pane;
|
||||||
return { ...pane, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
return rebuildPane(pane, ids, nextKinds, Math.min(pane.activeChatIdx, ids.length - 1));
|
||||||
}
|
|
||||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
|
||||||
return { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx] };
|
|
||||||
});
|
});
|
||||||
const unchanged = cleaned.every((p, i) => p === prev[i]);
|
const unchanged = cleaned.every((p, i) => p === prev[i]);
|
||||||
const next = unchanged ? prev : cleaned;
|
return 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;
|
|
||||||
});
|
});
|
||||||
}, [seedPaneChat]);
|
}, []);
|
||||||
|
|
||||||
const isPaneChatPending = useCallback(
|
const isPaneChatPending = useCallback(
|
||||||
(paneId: string) => pendingPaneChatIds.has(paneId),
|
(paneId: string) => pendingPaneChatIds.has(paneId),
|
||||||
@@ -932,19 +1002,9 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
|
|
||||||
const removeChatFromPanes = useCallback((chatId: string) => {
|
const removeChatFromPanes = useCallback((chatId: string) => {
|
||||||
setPanes((prev) => prev.map((p) => {
|
setPanes((prev) => prev.map((p) => {
|
||||||
const idx = p.chatIds.indexOf(chatId);
|
if (!p.chatIds.includes(chatId)) return p;
|
||||||
if (idx < 0) return p;
|
const { ids, kinds } = filterTabs(p, (id) => id !== chatId);
|
||||||
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
return rebuildPane(p, ids, kinds, Math.min(p.activeChatIdx, ids.length - 1));
|
||||||
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],
|
|
||||||
};
|
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1013,6 +1073,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
openSessionHistory,
|
openSessionHistory,
|
||||||
closeSessionHistory,
|
closeSessionHistory,
|
||||||
addSplitPane,
|
addSplitPane,
|
||||||
|
createTab,
|
||||||
createCoderTab,
|
createCoderTab,
|
||||||
toggleSettingsPane,
|
toggleSettingsPane,
|
||||||
removePane,
|
removePane,
|
||||||
|
|||||||
Reference in New Issue
Block a user