diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index 1077aba06..802bff969 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -63,7 +63,10 @@ import { getThreadRoutePath, isProjectlessProjectId, PLUGIN_PANEL_ROUTE_PATH, + PROJECTLESS_THREAD_DIFF_ROUTE_PATH, SETTINGS_ROUTE_PATH, + TERMINAL_ROUTE_PATH, + THREAD_DIFF_ROUTE_PATH, } from "@/lib/route-paths"; import { useQuickCreateProjectController } from "@/hooks/useQuickCreateProject"; import { IframeDragGuardOverlay } from "@/lib/iframe-drag-guard"; @@ -78,8 +81,11 @@ import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { useMobileVisualViewportHeight } from "./useMobileVisualViewportHeight"; import { wsManager } from "@/lib/ws"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; -import { findPaneByThread } from "@/lib/split-layout"; -import { applyThreadOpenToLayout } from "@/views/thread-detail/splitThreadNavigation"; +import { findContentTab } from "@/lib/split-layout"; +import { + applyThreadOpenToLayout, + threadPaneContent, +} from "@/views/thread-detail/splitThreadNavigation"; import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled"; import { useAppSettingsRouteMemory } from "@/hooks/useAppSettingsRouteMemory"; @@ -443,19 +449,24 @@ export function AppLayout({ children }: AppLayoutProps) { return; } const current = store.get(splitLayoutAtom); + const thread = { + projectId: signal.projectId, + threadId: signal.threadId, + }; + const content = threadPaneContent(thread); const alreadyOpen = - current !== null && - findPaneByThread(current.root, signal.projectId, signal.threadId) !== - null; + current !== null && findContentTab(current.root, content) !== null; const next = applyThreadOpenToLayout( current, - { projectId: signal.projectId, threadId: signal.threadId }, + thread, isCompactViewport ? "replace" : signal.split, ); if (next !== current) { store.set(splitLayoutAtom, next); } - void navigate(route, alreadyOpen ? { replace: true } : undefined); + if (findContentTab(next.root, content) !== null) { + void navigate(route, alreadyOpen ? { replace: true } : undefined); + } }), [isCompactViewport, navigate, store, threadSplitsEnabled], ); @@ -554,7 +565,17 @@ export function AppLayout({ children }: AppLayoutProps) { const showHeader = !isThreadView && !isRootView && - !(threadSplitsEnabled && pluginPanelMatch !== null); + // Splittable workspace routes render inside SplitThreadArea, where each + // pane's tab strip is the top chrome; the shared AppHeader would sit + // above it as an empty 48px band. + !( + threadSplitsEnabled && + (pluginPanelMatch !== null || + matchPath(TERMINAL_ROUTE_PATH, location.pathname) !== null || + matchPath(PROJECTLESS_THREAD_DIFF_ROUTE_PATH, location.pathname) !== + null || + matchPath(THREAD_DIFF_ROUTE_PATH, location.pathname) !== null) + ); const [desktopInfo] = useState(getBbDesktopInfo); const desktopWindowState = useDesktopWindowState(); const usesDesktopChrome = shouldUseMacosDesktopChrome(desktopInfo); diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx index b7f908517..34500af00 100644 --- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx +++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx @@ -56,6 +56,17 @@ import { } from "./PluginPanelActions"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import type { PromptDraftState } from "@/lib/prompt-draft"; +import type { PaneContent } from "@/lib/split-layout"; + +function pane(paneId: string, content: PaneContent) { + const tabId = `${paneId}-t1`; + return { + type: "pane" as const, + paneId, + tabs: [{ tabId, content, preview: false }], + activeTabId: tabId, + }; +} function registrationSet( overrides: Partial, @@ -1379,25 +1390,17 @@ describe("PluginNavSidebarItems + PluginPanelView", () => { dir: "row", sizes: [0.5, 0.5], children: [ - { - type: "pane", - paneId: "pane-plugin", - content: { - kind: "plugin-panel", - pluginId: "demo", - panelPath: "board", - subPath: "card/1", - }, - }, - { - type: "pane", - paneId: "pane-thread", - content: { - kind: "thread", - projectId: "proj_test", - threadId: "thr_test", - }, - }, + pane("pane-plugin", { + kind: "plugin-panel", + pluginId: "demo", + panelPath: "board", + subPath: "card/1", + }), + pane("pane-thread", { + kind: "thread", + projectId: "proj_test", + threadId: "thr_test", + }), ], }, }); diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx index d5c1a5d2b..4c9c33469 100644 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx @@ -17,6 +17,7 @@ import { isThreadSearchKeyboardEventTarget } from "./AppSidebar"; import { ProjectListActionButtons } from "./ProjectList"; import { SidebarThreadSearchPanel } from "./SidebarThreadSearchPanel"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import type { PaneContent } from "@/lib/split-layout"; import { getSidebarThreadSearchOptionId, haveSameSidebarThreadSearchNavigationItems, @@ -31,6 +32,16 @@ vi.mock("@/hooks/queries/thread-queries", () => ({ const mockUseThreadSearch = vi.mocked(useThreadSearch); +function pane(paneId: string, content: PaneContent) { + const tabId = `${paneId}-t1`; + return { + type: "pane" as const, + paneId, + tabs: [{ tabId, content, preview: false }], + activeTabId: tabId, + }; +} + function createThreadListEntry({ sectionId = null, id, @@ -424,20 +435,12 @@ describe("ProjectListActionButtons", () => { dir: "row", sizes: [0.5, 0.5], children: [ - { - type: "pane", - paneId: "pane-compose", - content: { kind: "new-thread" }, - }, - { - type: "pane", - paneId: "pane-thread", - content: { - kind: "thread", - projectId: "proj_test", - threadId: "thr_test", - }, - }, + pane("pane-compose", { kind: "new-thread" }), + pane("pane-thread", { + kind: "thread", + projectId: "proj_test", + threadId: "thr_test", + }), ], }, }); diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index 48759e65b..97f0a6195 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -464,12 +464,15 @@ function ThreadRowComponent({ { kind: "thread", projectId, threadId: thread.id }, threadSplitsEnabled, ); - const { onPointerDown: onSplitDragPointerDown, openInSplit } = - useThreadRowSplitDrag({ - projectId, - threadId: thread.id, - title: labelTitle, - }); + const { + onPointerDown: onSplitDragPointerDown, + openInSplit, + commitThreadTab, + } = useThreadRowSplitDrag({ + projectId, + threadId: thread.id, + title: labelTitle, + }); // Splits are disabled on compact viewports; the drag hook signals that by // withholding its pointer handler, so gate the click/menu entry points on it. const splitAvailable = onSplitDragPointerDown !== undefined; @@ -571,6 +574,7 @@ function ThreadRowComponent({ to={getThreadRoutePath({ projectId, threadId: thread.id })} data-sidebar-thread-shortcut-target="" data-sidebar-thread-id={thread.id} + onDoubleClick={commitThreadTab} onClick={(event) => { // Selecting a thread/agent row restores its conversation without // disturbing any other thread's collapsed conversation state. diff --git a/apps/app/src/components/sidebar/paneContentSplitIndicator.ts b/apps/app/src/components/sidebar/paneContentSplitIndicator.ts index 0c414e177..f53c9009a 100644 --- a/apps/app/src/components/sidebar/paneContentSplitIndicator.ts +++ b/apps/app/src/components/sidebar/paneContentSplitIndicator.ts @@ -4,8 +4,8 @@ import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { computePaneRects, + contentMatches, countPanes, - findPaneByContent, listPanes, type PaneContent, type PaneRect, @@ -53,12 +53,16 @@ export function usePaneContentSplitIndicator( ) { return NO_INDICATOR; } - const pane = findPaneByContent(layout.root, content); - if (pane === null) { + const panes = listPanes(layout.root); + if ( + !panes.some((pane) => + pane.tabs.some((tab) => contentMatches(tab.content, content)), + ) + ) { return NO_INDICATOR; } const rects = computePaneRects(layout.root); - const miniMap: MiniMapSlot[] = listPanes(layout.root).flatMap((entry) => { + const miniMap: MiniMapSlot[] = panes.flatMap((entry) => { const rect = rects.get(entry.paneId); return rect === undefined ? [] @@ -66,7 +70,9 @@ export function usePaneContentSplitIndicator( { paneId: entry.paneId, rect, - isMe: entry.paneId === pane.paneId, + isMe: entry.tabs.some((tab) => + contentMatches(tab.content, content), + ), isFocused: entry.paneId === layout.focusedPaneId, }, ]; diff --git a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts index 9d78e939a..9a86adfe9 100644 --- a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts +++ b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts @@ -2,18 +2,15 @@ import { useCallback, type PointerEvent as ReactPointerEvent } from "react"; import { useStore } from "jotai"; import { useNavigate } from "react-router-dom"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; -import { - getPluginPanelRoutePath, - getRootComposeRoutePath, - getThreadRoutePath, -} from "@/lib/route-paths"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { + activateTab, + commitTab, countPanes, - findPaneByContent, + findContentTab, listPanes, MAX_PANES, - replacePaneContent, + openTab, setFocus, splitPane, type PaneContent, @@ -25,20 +22,11 @@ import { shouldEngageSidebarSplitDrag, type SplitDragFallbackTarget, } from "@/lib/split-drag"; +import { paneContentRoute } from "@/views/thread-detail/splitThreadNavigation"; const SIDEBAR_SELECTOR = '[data-sidebar="sidebar"]'; const MAIN_CONTENT_SELECTOR = "main"; -function routeForContent(content: PaneContent): string { - if (content.kind === "thread") return getThreadRoutePath(content); - if (content.kind === "new-thread") return getRootComposeRoutePath(); - return getPluginPanelRoutePath({ - pluginId: content.pluginId, - path: content.panelPath, - subPath: content.subPath, - }); -} - /** Prototype drag/cmd-click source for non-thread pages. */ export function usePaneContentSplitDrag({ content, @@ -54,21 +42,27 @@ export function usePaneContentSplitDrag({ const isCompact = useIsCompactViewport(); const openInSplit = useCallback(() => { - const route = routeForContent(content); + const route = paneContentRoute(content); const layout = store.get(splitLayoutAtom); if (!enabled || isCompact || layout === null) { navigate(route); return; } - const existing = findPaneByContent(layout.root, content); - const next = + const existing = findContentTab(layout.root, content); + let next = existing !== null - ? setFocus(layout, existing.paneId) + ? activateTab(layout, existing.pane.paneId, existing.tab.tabId) : countPanes(layout.root) >= MAX_PANES - ? replacePaneContent(layout, layout.focusedPaneId, content) + ? openTab(layout, layout.focusedPaneId, content) : splitPane(layout, layout.focusedPaneId, "right", content); + if (existing !== null) { + next = commitTab(next, existing.pane.paneId, existing.tab.tabId); + next = setFocus(next, existing.pane.paneId); + } if (next !== layout) store.set(splitLayoutAtom, next); - navigate(route, existing !== null ? { replace: true } : undefined); + if (findContentTab(next.root, content) !== null) { + navigate(route, existing !== null ? { replace: true } : undefined); + } }, [content, enabled, isCompact, navigate, store]); const onPointerDown = useCallback( @@ -100,23 +94,28 @@ export function usePaneContentSplitDrag({ if (layout === null) return null; return decideThreadDrop({ zone, - threadAlreadyOpen: findPaneByContent(layout.root, content) !== null, + threadAlreadyOpen: findContentTab(layout.root, content) !== null, atMaxPanes: countPanes(layout.root) >= MAX_PANES, }); }, onDrop: (target) => { const layout = store.get(splitLayoutAtom); if (layout === null) return; - const existing = findPaneByContent(layout.root, content); - const next = + const existing = findContentTab(layout.root, content); + let next = existing !== null - ? setFocus(layout, existing.paneId) + ? activateTab(layout, existing.pane.paneId, existing.tab.tabId) : target.zone === "center" - ? replacePaneContent(layout, target.paneId, content) + ? openTab(layout, target.paneId, content) : splitPane(layout, target.paneId, target.zone, content); + if (existing !== null) { + next = commitTab(next, existing.pane.paneId, existing.tab.tabId); + next = setFocus(next, existing.pane.paneId); + } if (next !== layout) store.set(splitLayoutAtom, next); + if (findContentTab(next.root, content) === null) return; navigate( - routeForContent(content), + paneContentRoute(content), existing !== null ? { replace: true } : undefined, ); }, diff --git a/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx b/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx index 1086fb734..ec02fbe12 100644 --- a/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx +++ b/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx @@ -3,17 +3,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; import { createStore, Provider } from "jotai"; -import type { ReactNode } from "react"; +import type { PointerEvent as ReactPointerEvent, ReactNode } from "react"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; -import { countPanes, findPaneByThread, listPanes } from "@/lib/split-layout"; +import { + activePaneContent, + countPanes, + findPane, + findPaneByThread, + listPanes, + openTab, +} from "@/lib/split-layout"; import type { LayoutNode, PaneContent, SplitLayout } from "@/lib/split-layout"; +import type { SplitDragConfig } from "@/lib/split-drag"; import { useThreadRowSplitDrag } from "./useThreadRowSplitDrag"; -const { navigateSpy, compactState, experimentState } = vi.hoisted(() => ({ - navigateSpy: vi.fn(), - compactState: { value: false }, - experimentState: { enabled: true }, -})); +const { navigateSpy, compactState, experimentState, splitDragState } = + vi.hoisted(() => ({ + navigateSpy: vi.fn(), + compactState: { value: false }, + experimentState: { enabled: true }, + splitDragState: { config: null as SplitDragConfig | null }, + })); vi.mock("react-router-dom", async (importOriginal) => ({ ...(await importOriginal()), @@ -28,12 +38,27 @@ vi.mock("@/hooks/useThreadSplitsEnabled", () => ({ useThreadSplitsEnabled: () => experimentState.enabled, })); +vi.mock("@/lib/split-drag", async (importOriginal) => ({ + ...(await importOriginal()), + beginSplitDrag: vi.fn( + (_startX: number, _startY: number, config: SplitDragConfig) => { + splitDragState.config = config; + }, + ), +})); + function content(threadId: string): PaneContent { return { kind: "thread", projectId: "p1", threadId }; } function pane(paneId: string, threadId: string): LayoutNode { - return { type: "pane", paneId, content: content(threadId) }; + const tabId = `${paneId}-t1`; + return { + type: "pane", + paneId, + tabs: [{ tabId, content: content(threadId), preview: false }], + activeTabId: tabId, + }; } function singlePane(): SplitLayout { @@ -66,6 +91,14 @@ function eightPanes(): SplitLayout { }; } +function fullSinglePane(): SplitLayout { + let layout = singlePane(); + for (let index = 2; index <= 16; index += 1) { + layout = openTab(layout, "pane-1", content(`t${index}`)); + } + return layout; +} + function renderOpenInSplit(threadId: string, layout: SplitLayout | null) { const store = createStore(); store.set(splitLayoutAtom, layout); @@ -80,14 +113,30 @@ function renderOpenInSplit(threadId: string, layout: SplitLayout | null) { store, getOnPointerDown: () => result.current.onPointerDown, openInSplit: () => act(() => result.current.openInSplit()), + startDrag: () => { + const row = document.createElement("div"); + const pointerEvent = new PointerEvent("pointerdown", { + button: 0, + clientX: 0, + clientY: 0, + }); + Object.defineProperty(pointerEvent, "currentTarget", { value: row }); + act(() => + result.current.onPointerDown?.( + pointerEvent as unknown as ReactPointerEvent, + ), + ); + return splitDragState.config; + }, }; } -describe("useThreadRowSplitDrag — openInSplit (cmd-click / context-menu entry)", () => { +describe("useThreadRowSplitDrag", () => { beforeEach(() => { navigateSpy.mockClear(); compactState.value = false; experimentState.enabled = true; + splitDragState.config = null; }); it("splits the focused pane to the right by default", () => { @@ -105,28 +154,55 @@ describe("useThreadRowSplitDrag — openInSplit (cmd-click / context-menu entry) expect(navigateSpy).toHaveBeenCalledWith("/projects/p1/threads/t9"); }); - it("focuses the existing pane instead of duplicating an already-open thread", () => { - const { store, openInSplit } = renderOpenInSplit("t2", twoPanes()); + it("reveals an existing inactive tab without growing the layout", () => { + let seeded = openTab(twoPanes(), "pane-2", content("t3")); + const beforeTabCount = findPane(seeded.root, "pane-2")!.tabs.length; + const { store, openInSplit } = renderOpenInSplit("t2", seeded); openInSplit(); const layout = store.get(splitLayoutAtom); - expect(countPanes(layout!.root)).toBe(2); // no new pane + const paneTwo = findPane(layout!.root, "pane-2")!; + expect(countPanes(layout!.root)).toBe(2); + expect(paneTwo.tabs).toHaveLength(beforeTabCount); expect(layout!.focusedPaneId).toBe("pane-2"); + expect(activePaneContent(paneTwo)).toEqual(content("t2")); + expect( + paneTwo.tabs.find((tab) => tab.tabId === paneTwo.activeTabId)?.preview, + ).toBe(false); expect(navigateSpy).toHaveBeenCalledWith("/projects/p1/threads/t2", { replace: true, }); }); - it("coerces to a replace of the focused pane at the eight-pane cap", () => { + it("coerces to a committed center tab at the eight-pane cap", () => { const { store, openInSplit } = renderOpenInSplit("t9", eightPanes()); openInSplit(); const layout = store.get(splitLayoutAtom); - expect(countPanes(layout!.root)).toBe(8); // never exceeds the cap + expect(countPanes(layout!.root)).toBe(8); const opened = findPaneByThread(layout!.root, "p1", "t9"); - expect(opened?.paneId).toBe("pane-1"); // replaced the focused pane - expect(findPaneByThread(layout!.root, "p1", "t1")).toBeNull(); + const focused = findPane(layout!.root, "pane-1")!; + expect(opened?.paneId).toBe("pane-1"); + expect(focused.tabs).toHaveLength(2); + expect(activePaneContent(focused)).toEqual(content("t9")); + expect( + focused.tabs.find((tab) => tab.tabId === focused.activeTabId)?.preview, + ).toBe(false); + expect(findPaneByThread(layout!.root, "p1", "t1")?.paneId).toBe("pane-1"); expect(navigateSpy).toHaveBeenCalledWith("/projects/p1/threads/t9"); }); + it("does not navigate when a center drop cannot open into a full sixteen-tab group", () => { + const seeded = fullSinglePane(); + const { store, startDrag } = renderOpenInSplit("t17", seeded); + const config = startDrag(); + expect(config).not.toBeNull(); + + act(() => config?.onDrop({ paneId: "pane-1", zone: "center" })); + + expect(store.get(splitLayoutAtom)).toBe(seeded); + expect(findPaneByThread(seeded.root, "p1", "t17")).toBeNull(); + expect(navigateSpy).not.toHaveBeenCalled(); + }); + it("plain-navigates without touching the layout on compact viewports", () => { compactState.value = true; const seeded = singlePane(); diff --git a/apps/app/src/components/sidebar/useThreadRowSplitDrag.ts b/apps/app/src/components/sidebar/useThreadRowSplitDrag.ts index 626e68a33..f9f98d556 100644 --- a/apps/app/src/components/sidebar/useThreadRowSplitDrag.ts +++ b/apps/app/src/components/sidebar/useThreadRowSplitDrag.ts @@ -6,11 +6,13 @@ import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled"; import { getThreadRoutePath } from "@/lib/route-paths"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { + activateTab, + commitTab, countPanes, - findPaneByThread, + findContentTab, listPanes, MAX_PANES, - replacePaneContent, + openTab, setFocus, splitPane, type SplitLayout, @@ -39,9 +41,10 @@ const MAIN_CONTENT_SELECTOR = "main"; * pointer-driven layer. It engages only once the pointer leaves the sidebar * toward the main area (so the existing dnd-kit vertical reorder always wins * inside the sidebar — plan §3), then hit-tests panes: an edge splits, the - * center replaces, and a thread already open focuses its pane instead of - * duplicating. The layout ops enforce the pane cap and no-duplicate invariants; - * this only picks targets. Disabled on compact viewports, where splits are off. + * center opens a committed tab, and a thread already open reveals and commits + * its tab instead of duplicating. The layout ops enforce the pane cap and + * no-duplicate invariants; this only picks targets. Disabled on compact + * viewports, where splits are off. */ export function useThreadRowSplitDrag({ projectId, @@ -52,11 +55,14 @@ export function useThreadRowSplitDrag({ /** * Opens this thread in the split via the second entry point (cmd/ctrl-click, * context-menu "Open in split"), using the SAME placement rules as drag: - * default to a right split, focus the pane if already open, and coerce to - * replace at the pane cap. Falls back to plain navigation on compact - * viewports (splits disabled) and non-thread routes (no layout to split). + * default to a right split, reveal the tab if already open, and coerce to + * opening a tab in the focused pane at the pane cap. Falls back to plain + * navigation on compact viewports (splits disabled) and non-thread routes + * (no layout to split). */ openInSplit: () => void; + /** Commits this thread's preview tab on a sidebar-row double click. */ + commitThreadTab: () => void; } { const store = useStore(); const navigate = useNavigate(); @@ -98,8 +104,7 @@ export function useThreadRowSplitDrag({ } return decideThreadDrop({ zone, - threadAlreadyOpen: - findPaneByThread(layout.root, projectId, threadId) !== null, + threadAlreadyOpen: findContentTab(layout.root, content) !== null, atMaxPanes: countPanes(layout.root) >= MAX_PANES, }); }, @@ -108,19 +113,26 @@ export function useThreadRowSplitDrag({ if (layout === null) { return; } - const existing = findPaneByThread(layout.root, projectId, threadId); - const next = + const existing = findContentTab(layout.root, content); + let next = existing !== null - ? setFocus(layout, existing.paneId) + ? activateTab(layout, existing.pane.paneId, existing.tab.tabId) : target.zone === "center" - ? replacePaneContent(layout, target.paneId, content) + ? openTab(layout, target.paneId, content) : splitPane(layout, target.paneId, target.zone, content); + if (existing !== null) { + next = commitTab(next, existing.pane.paneId, existing.tab.tabId); + next = setFocus(next, existing.pane.paneId); + } if (next !== layout) { store.set(splitLayoutAtom, next); } + if (findContentTab(next.root, content) === null) { + return; + } // The dropped thread now owns the focused pane, so the URL follows it. - // An already-open focus is a replace (no history entry); a split or - // replace pushes like a sidebar click. + // An already-open reveal is a replace (no history entry); a split or + // new tab pushes like a sidebar click. navigate( getThreadRoutePath({ projectId, threadId }), existing !== null ? { replace: true } : undefined, @@ -140,13 +152,18 @@ export function useThreadRowSplitDrag({ navigate(route); return; } - const existing = findPaneByThread(layout.root, projectId, threadId); + const content: PaneContent = { kind: "thread", projectId, threadId }; + const existing = findContentTab(layout.root, content); if (existing !== null) { - const next = setFocus(layout, existing.paneId); + let next = activateTab(layout, existing.pane.paneId, existing.tab.tabId); + next = commitTab(next, existing.pane.paneId, existing.tab.tabId); + next = setFocus(next, existing.pane.paneId); if (next !== layout) { store.set(splitLayoutAtom, next); } - navigate(route, { replace: true }); + if (findContentTab(next.root, content) !== null) { + navigate(route, { replace: true }); + } return; } // Same decision as a drag with a default right-edge target. @@ -155,21 +172,41 @@ export function useThreadRowSplitDrag({ threadAlreadyOpen: false, atMaxPanes: countPanes(layout.root) >= MAX_PANES, }); - const content: PaneContent = { kind: "thread", projectId, threadId }; const next = decision.zone === "center" - ? replacePaneContent(layout, layout.focusedPaneId, content) + ? openTab(layout, layout.focusedPaneId, content) : splitPane(layout, layout.focusedPaneId, "right", content); if (next !== layout) { store.set(splitLayoutAtom, next); } - navigate(route); + if (findContentTab(next.root, content) !== null) { + navigate(route); + } }, [isCompact, navigate, projectId, store, threadId, threadSplitsEnabled]); + const commitThreadTab = useCallback(() => { + const layout = store.get(splitLayoutAtom); + if (layout === null) { + return; + } + const content: PaneContent = { kind: "thread", projectId, threadId }; + const existing = findContentTab(layout.root, content); + if (existing === null) { + return; + } + let next = activateTab(layout, existing.pane.paneId, existing.tab.tabId); + next = commitTab(next, existing.pane.paneId, existing.tab.tabId); + next = setFocus(next, existing.pane.paneId); + if (next !== layout) { + store.set(splitLayoutAtom, next); + } + }, [projectId, store, threadId]); + return { onPointerDown: threadSplitsEnabled && !isCompact ? onPointerDown : undefined, openInSplit, + commitThreadTab, }; } diff --git a/apps/app/src/components/workspace-panes/DiffPaneContent.tsx b/apps/app/src/components/workspace-panes/DiffPaneContent.tsx new file mode 100644 index 000000000..4edf75b12 --- /dev/null +++ b/apps/app/src/components/workspace-panes/DiffPaneContent.tsx @@ -0,0 +1,90 @@ +import { useMemo } from "react"; +import { resolveEnvironmentMergeBaseBranch } from "@bb/domain"; +import { GIT_DIFF_VIEW_BASE_OPTIONS } from "@/components/git-diff/GitDiffCard"; +import { useGitDiffPanelState } from "@/components/secondary-panel/git-diff/useGitDiffPanelState"; +import { GitDiffTabContent } from "@/components/secondary-panel/ThreadSecondaryPanelTabContent"; +import { usePreferredTheme } from "@/hooks/useTheme"; +import { useEnvironment } from "@/hooks/queries/environment-queries"; +import { useThread } from "@/hooks/queries/thread-queries"; + +function DiffPlaceholder({ children }: { children: string }) { + return ( +
+ {children} +
+ ); +} + +export function DiffPaneContent({ + projectId, + threadId, +}: { + projectId: string; + threadId: string; +}) { + const threadQuery = useThread(threadId); + const environmentId = threadQuery.data?.environmentId; + const environmentQuery = useEnvironment(environmentId); + const preferredTheme = usePreferredTheme(); + const defaultMergeBaseBranch = resolveEnvironmentMergeBaseBranch( + environmentQuery.data, + ); + const { gitDiffTarget } = useGitDiffPanelState({ + environmentId: environmentId ?? undefined, + isDiffPanelActive: true, + defaultMergeBaseBranch, + }); + const gitDiffViewOptions = useMemo( + () => ({ + ...GIT_DIFF_VIEW_BASE_OPTIONS, + diffStyle: "unified", + themeType: preferredTheme, + }), + [preferredTheme], + ); + + if ( + threadQuery.data === undefined && + (threadQuery.isLoading || threadQuery.isFetching) + ) { + return Loading diff…; + } + + if ( + threadQuery.data === undefined || + threadQuery.data.projectId !== projectId + ) { + return Diff unavailable; + } + + if (environmentId === null) { + return ( + + Diff unavailable: thread has no environment + + ); + } + + if ( + environmentQuery.data === undefined && + (environmentQuery.isLoading || environmentQuery.isFetching) + ) { + return Loading diff…; + } + + if (environmentQuery.data === undefined) { + return Diff unavailable; + } + + return ( +
+ +
+ ); +} diff --git a/apps/app/src/components/workspace-panes/TerminalPaneContent.tsx b/apps/app/src/components/workspace-panes/TerminalPaneContent.tsx new file mode 100644 index 000000000..adbabd249 --- /dev/null +++ b/apps/app/src/components/workspace-panes/TerminalPaneContent.tsx @@ -0,0 +1,72 @@ +import { Pill } from "@bb/shared-ui/pill"; +import { ThreadTerminalView } from "@/components/thread/terminal/ThreadTerminalView"; +import { useTerminals } from "@/hooks/queries/thread-terminal-queries"; +import type { TerminalPaneTarget } from "@/lib/split-layout"; + +function TerminalPlaceholder({ children }: { children: string }) { + return ( +
+ {children} +
+ ); +} + +function terminalTargetLabel(target: TerminalPaneTarget): string { + switch (target.kind) { + case "thread": + return `Thread ${target.threadId.slice(0, 8)}`; + case "environment": + return target.environmentId; + case "host_path": + return target.cwd; + } +} + +export function TerminalPaneContent({ + terminalId, + target, +}: { + terminalId: string; + target?: TerminalPaneTarget; +}) { + const terminalsQuery = useTerminals(target, { + enabled: target !== undefined, + }); + + if (target === undefined) { + return ( + + Terminal unavailable on this surface + + ); + } + + if ( + terminalsQuery.data === undefined && + (terminalsQuery.isLoading || terminalsQuery.isFetching) + ) { + return null; + } + + const session = terminalsQuery.data?.sessions.find( + (candidate) => candidate.id === terminalId, + ); + if (session === undefined || session.status === "exited") { + return Terminal ended; + } + + return ( +
+
+ + + {terminalTargetLabel(target)} + + +
+
+ +
+
+ ); +} diff --git a/apps/app/src/lib/route-paths.ts b/apps/app/src/lib/route-paths.ts index 6a7984ab2..0164a034d 100644 --- a/apps/app/src/lib/route-paths.ts +++ b/apps/app/src/lib/route-paths.ts @@ -20,6 +20,10 @@ export const THREAD_DETAIL_ROUTE_PATH = "/projects/:projectId/threads/:threadId"; // Trailing splat: the remainder is the panel's `subPath` (empty at the root). export const PLUGIN_PANEL_ROUTE_PATH = "/plugins/:pluginId/:panelPath/*"; +export const TERMINAL_ROUTE_PATH = "/terminals/:terminalId"; +export const PROJECTLESS_THREAD_DIFF_ROUTE_PATH = "/threads/:threadId/diff"; +export const THREAD_DIFF_ROUTE_PATH = + "/projects/:projectId/threads/:threadId/diff"; export interface ThreadRoutePathArgs { projectId: string; @@ -110,6 +114,14 @@ export function getThreadRoutePath(args: ThreadRoutePathArgs): string { : `/projects/${args.projectId}/threads/${args.threadId}`; } +export function getTerminalRoutePath(terminalId: string): string { + return `/terminals/${terminalId}`; +} + +export function getThreadDiffRoutePath(args: ThreadRoutePathArgs): string { + return `${getThreadRoutePath(args)}/diff`; +} + const baseRoutePatterns: readonly string[] = [ APP_ROOT_ROUTE_PATH, AUTH_CALLBACK_ROUTE_PATH, @@ -124,6 +136,9 @@ const baseRoutePatterns: readonly string[] = [ PROJECTLESS_THREAD_DETAIL_ROUTE_PATH, THREAD_DETAIL_ROUTE_PATH, PLUGIN_PANEL_ROUTE_PATH, + TERMINAL_ROUTE_PATH, + PROJECTLESS_THREAD_DIFF_ROUTE_PATH, + THREAD_DIFF_ROUTE_PATH, ]; export const ROUTE_PATTERNS = baseRoutePatterns; diff --git a/apps/app/src/lib/split-layout/atoms.test.ts b/apps/app/src/lib/split-layout/atoms.test.ts index 6412bafbd..f2682ec8d 100644 --- a/apps/app/src/lib/split-layout/atoms.test.ts +++ b/apps/app/src/lib/split-layout/atoms.test.ts @@ -8,16 +8,34 @@ import { MAXIMIZED_PANE_STORAGE_KEY, splitLayoutAtom, } from "./atoms"; -import { countPanes, findPaneByThread, splitPane } from "./ops"; -import type { SplitLayout } from "./types"; +import { + activateTab, + countPanes, + findContentTab, + findPane, + findPaneByThread, + openTab, + splitPane, +} from "./ops"; +import type { PaneContent, PaneNode, SplitLayout } from "./types"; + +function pane(paneId: string, content: PaneContent): PaneNode { + const tabId = `${paneId}-t1`; + return { + type: "pane", + paneId, + tabs: [{ tabId, content, preview: false }], + activeTabId: tabId, + }; +} function singlePane(threadId: string): SplitLayout { return { - root: { - type: "pane", - paneId: "pane-1", - content: { kind: "thread", projectId: "project-1", threadId }, - }, + root: pane("pane-1", { + kind: "thread", + projectId: "project-1", + threadId, + }), focusedPaneId: "pane-1", }; } @@ -112,6 +130,100 @@ describe("closePanesForThreadsAtom", () => { expect(store.get(splitLayoutAtom)).toEqual(layout); }); + it("closes every targeted thread and diff tab while preserving terminals", () => { + const store = createStore(); + let layout = openTab(singlePane("thread-1"), "pane-1", { + kind: "diff", + projectId: "project-1", + threadId: "thread-1", + }); + layout = openTab(layout, "pane-1", { + kind: "terminal", + terminalId: "term-1", + target: { kind: "thread", threadId: "thread-1" }, + }); + layout = openTab(layout, "pane-1", { + kind: "thread", + projectId: "project-1", + threadId: "thread-2", + }); + store.set(splitLayoutAtom, layout); + + const result = store.set(closePanesForThreadsAtom, ["thread-1"]); + const next = store.get(splitLayoutAtom)!; + + expect(result).toEqual({ + removedAny: true, + focusedRoute: { projectId: "project-1", threadId: "thread-2" }, + }); + expect( + findContentTab(next.root, { + kind: "thread", + projectId: "project-1", + threadId: "thread-1", + }), + ).toBeNull(); + expect( + findContentTab(next.root, { + kind: "diff", + projectId: "project-1", + threadId: "thread-1", + }), + ).toBeNull(); + expect( + findContentTab(next.root, { kind: "terminal", terminalId: "term-1" }), + ).not.toBeNull(); + }); + + it("keeps a spared terminal when archiving its thread and reports no focused thread route", () => { + const store = createStore(); + const terminal: PaneContent = { + kind: "terminal", + terminalId: "term-1", + target: { kind: "thread", threadId: "thread-1" }, + }; + const layout = openTab(singlePane("thread-1"), "pane-1", terminal); + store.set(splitLayoutAtom, layout); + + const result = store.set(closePanesForThreadsAtom, ["thread-1"]); + const next = store.get(splitLayoutAtom); + + expect(result).toEqual({ removedAny: true, focusedRoute: null }); + expect(next).not.toBeNull(); + expect(findContentTab(next!.root, terminal)).not.toBeNull(); + expect(findPaneByThread(next!.root, "project-1", "thread-1")).toBeNull(); + }); + + it("collapses groups emptied by tab closure and derives the route from the focused active tab", () => { + const store = createStore(); + let layout = twoPanes(); + layout = openTab(layout, "pane-2", { + kind: "thread", + projectId: "project-2", + threadId: "thread-3", + }); + layout = activateTab( + layout, + "pane-2", + findContentTab(layout.root, { + kind: "thread", + projectId: "project-2", + threadId: "thread-3", + })!.tab.tabId, + ); + store.set(splitLayoutAtom, layout); + + const result = store.set(closePanesForThreadsAtom, ["thread-1"]); + const next = store.get(splitLayoutAtom)!; + + expect(countPanes(next.root)).toBe(1); + expect(findPane(next.root, "pane-1")).toBeNull(); + expect(result.focusedRoute).toEqual({ + projectId: "project-2", + threadId: "thread-3", + }); + }); + it("does nothing when there is no layout or no target threads", () => { const store = createStore(); expect(store.set(closePanesForThreadsAtom, ["thread-1"])).toEqual({ diff --git a/apps/app/src/lib/split-layout/atoms.ts b/apps/app/src/lib/split-layout/atoms.ts index b11c761c3..aebd5bfc2 100644 --- a/apps/app/src/lib/split-layout/atoms.ts +++ b/apps/app/src/lib/split-layout/atoms.ts @@ -6,7 +6,13 @@ import { type SyncStorage, } from "@/lib/browser-storage"; import type { ThreadRoutePathArgs } from "@/lib/route-paths"; -import { findPane, listPanes, removePane } from "./ops"; +import { + activePaneContent, + closeTab, + findPane, + listPanes, + listTabs, +} from "./ops"; import { deserializeSplitLayout, serializeSplitLayout, @@ -88,20 +94,24 @@ export const closePanesForThreadsAtom = atom( return { removedAny: false, focusedRoute: null }; } const targets = new Set(threadIds); + // A tab belongs to a targeted thread when it IS the thread view or a view + // bound to it (its diff). Terminal tabs survive: the PTY resource outlives + // the thread view and closing it is a separate decision. + const isTargetedTab = (content: ReturnType) => + (content.kind === "thread" || content.kind === "diff") && + targets.has(content.threadId); let layout = current; let removedAny = false; for (;;) { - const pane = listPanes(layout.root).find( - (candidate) => - candidate.content.kind === "thread" && - targets.has(candidate.content.threadId), + const location = listTabs(layout.root).find(({ tab }) => + isTargetedTab(tab.content), ); - if (pane === undefined) { + if (location === undefined) { break; } - const next = removePane(layout, pane.paneId); + const next = closeTab(layout, location.pane.paneId, location.tab.tabId); if (next === layout) { - // removePane refuses to remove the last pane. + // closeTab refuses to remove the last pane's last tab. break; } layout = next; @@ -118,26 +128,31 @@ export const closePanesForThreadsAtom = atom( ) { set(maximizedPaneIdAtom, null); } - // The surviving focused pane may still be a targeted thread (recursive - // archive covering every pane). Treat that as "no valid survivor": clear the - // layout so a stale pane isn't left behind, and signal the caller to fall - // back to navigating the window away. - const focused = findPane(layout.root, layout.focusedPaneId); - const survivorRoute = - focused !== null && - focused.content.kind === "thread" && - !targets.has(focused.content.threadId) - ? { - projectId: focused.content.projectId, - threadId: focused.content.threadId, - } - : null; - if (survivorRoute === null) { + // Clear the layout only when NOTHING valid survives (a recursive archive + // covering every view). Non-targeted tabs — a spared terminal, another + // thread — keep the layout alive even when the focused view is still a + // targeted thread; the null route then tells the caller to run its + // pre-split navigate-away while the arrangement persists. + const hasValidSurvivor = listTabs(layout.root).some( + ({ tab }) => !isTargetedTab(tab.content), + ); + if (!hasValidSurvivor) { set(splitLayoutAtom, null); set(maximizedPaneIdAtom, null); return { removedAny: true, focusedRoute: null }; } set(splitLayoutAtom, layout); + const focused = findPane(layout.root, layout.focusedPaneId); + const focusedContent = focused === null ? null : activePaneContent(focused); + const survivorRoute = + focusedContent !== null && + focusedContent.kind === "thread" && + !targets.has(focusedContent.threadId) + ? { + projectId: focusedContent.projectId, + threadId: focusedContent.threadId, + } + : null; return { removedAny: true, focusedRoute: survivorRoute }; }, ); diff --git a/apps/app/src/lib/split-layout/computePaneRects.test.ts b/apps/app/src/lib/split-layout/computePaneRects.test.ts index 4ea271385..3f8f57ae7 100644 --- a/apps/app/src/lib/split-layout/computePaneRects.test.ts +++ b/apps/app/src/lib/split-layout/computePaneRects.test.ts @@ -7,7 +7,13 @@ function content(threadId: string): PaneContent { } function pane(paneId: string): LayoutNode { - return { type: "pane", paneId, content: content(paneId) }; + const tabId = `${paneId}-t1`; + return { + type: "pane", + paneId, + tabs: [{ tabId, content: content(paneId), preview: false }], + activeTabId: tabId, + }; } function expectRect(actual: PaneRect | undefined, expected: PaneRect): void { diff --git a/apps/app/src/lib/split-layout/ops.test.ts b/apps/app/src/lib/split-layout/ops.test.ts index 607948d9b..21a611125 100644 --- a/apps/app/src/lib/split-layout/ops.test.ts +++ b/apps/app/src/lib/split-layout/ops.test.ts @@ -1,13 +1,23 @@ import { describe, expect, it } from "vitest"; import { + MAX_TABS_PER_PANE, MAX_PANES, + activateTab, + activePaneContent, + closeTab, + commitTab, + contentMatches, countPanes, + findContentTab, findPane, findPaneByThread, listPanes, + moveTab, movePane, normalize, + openTab, removePane, + reorderTab, replacePaneContent, resizeSplit, setFocus, @@ -21,13 +31,36 @@ function threadContent(threadId: string, projectId = "project-1"): PaneContent { } function pane(paneId: string, threadId = paneId): PaneNode { - return { type: "pane", paneId, content: threadContent(threadId) }; + const tabId = `${paneId}-t1`; + return { + type: "pane", + paneId, + tabs: [{ tabId, content: threadContent(threadId), preview: false }], + activeTabId: tabId, + }; } function singlePaneLayout(): SplitLayout { return { root: pane("pane-1"), focusedPaneId: "pane-1" }; } +function fourTabLayout(): SplitLayout { + let layout = singlePaneLayout(); + for (let index = 2; index <= 4; index += 1) { + layout = openTab(layout, "pane-1", threadContent(`thread-${index}`)); + } + return layout; +} + +function paneThreadOrder(layout: SplitLayout): string[] { + return findPane(layout.root, "pane-1")!.tabs.map((tab) => { + if (tab.content.kind !== "thread") { + throw new Error("Expected thread-only fixture"); + } + return tab.content.threadId; + }); +} + function layoutAtPaneCount(count: number): SplitLayout { let layout = singlePaneLayout(); for (let index = 2; index <= count; index += 1) { @@ -143,8 +176,10 @@ describe("split layout operations", () => { const swapped = swapPanes(replaced, "pane-1", "pane-2"); expect(replaced.focusedPaneId).toBe("pane-1"); - expect(findPane(swapped.root, "pane-2")?.content).toBe(replacement); - expect(findPane(swapped.root, "pane-1")?.content).toEqual( + expect(activePaneContent(findPane(swapped.root, "pane-2")!)).toBe( + replacement, + ); + expect(activePaneContent(findPane(swapped.root, "pane-1")!)).toEqual( threadContent("thread-2"), ); expect(swapped.focusedPaneId).toBe("pane-2"); @@ -186,7 +221,7 @@ describe("split layout operations", () => { expect(countPanes(moved.root)).toBe(MAX_PANES); expect(after).toBe(before); - expect(after?.content).toBe(before?.content); + expect(after?.tabs).toBe(before?.tabs); expect(moved.focusedPaneId).toBe("pane-8"); expectValidFocus(moved); expect(movePane(moved, "pane-8", "pane-8", "right")).toBe(moved); @@ -281,4 +316,422 @@ describe("split layout operations", () => { expectNormalizedSizes(normalized); expectValidFocus(normalized); }); + + it("opens committed tabs after the active tab and reveals an existing tab", () => { + const first = openTab( + singlePaneLayout(), + "pane-1", + threadContent("thread-2"), + ); + const second = openTab(first, "pane-1", threadContent("thread-3")); + const activated = activateTab(second, "pane-1", "pane-1-t1"); + const inserted = openTab(activated, "pane-1", threadContent("thread-4")); + const revealed = openTab(inserted, "pane-1", threadContent("thread-3")); + const openedPane = findPane(inserted.root, "pane-1")!; + + expect(openedPane.tabs.map((tab) => tab.content)).toEqual([ + threadContent("pane-1"), + threadContent("thread-4"), + threadContent("thread-2"), + threadContent("thread-3"), + ]); + expect(openedPane.tabs.every((tab) => !tab.preview)).toBe(true); + expect(findPane(revealed.root, "pane-1")?.tabs).toHaveLength(4); + expect(activePaneContent(findPane(revealed.root, "pane-1")!)).toEqual( + threadContent("thread-3"), + ); + }); + + it("replaces a preview in place, and commits it when reopened non-preview", () => { + const preview = openTab( + singlePaneLayout(), + "pane-1", + threadContent("preview-1"), + { preview: true }, + ); + const previewPane = findPane(preview.root, "pane-1")!; + const previewId = previewPane.activeTabId; + const replaced = openTab(preview, "pane-1", threadContent("preview-2"), { + preview: true, + }); + const replacedPane = findPane(replaced.root, "pane-1")!; + const committed = openTab(replaced, "pane-1", threadContent("preview-2")); + + expect(replacedPane.tabs).toHaveLength(2); + expect(replacedPane.tabs[1]).toEqual({ + tabId: previewId, + content: threadContent("preview-2"), + preview: true, + }); + expect(findPane(committed.root, "pane-1")?.tabs[1]?.preview).toBe(false); + }); + + it("caps a pane at sixteen tabs while still allowing reveal-existing", () => { + let layout = singlePaneLayout(); + for (let index = 2; index <= MAX_TABS_PER_PANE; index += 1) { + layout = openTab(layout, "pane-1", threadContent(`thread-${index}`)); + } + const capped = openTab( + layout, + "pane-1", + threadContent(`thread-${MAX_TABS_PER_PANE + 1}`), + ); + const revealed = openTab(layout, "pane-1", threadContent("thread-2")); + + expect(capped).toBe(layout); + expect(findPane(revealed.root, "pane-1")?.tabs).toHaveLength( + MAX_TABS_PER_PANE, + ); + expect(activePaneContent(findPane(revealed.root, "pane-1")!)).toEqual( + threadContent("thread-2"), + ); + }); + + it("forces terminal opens committed even when preview is requested", () => { + const terminal: PaneContent = { + kind: "terminal", + terminalId: "term-1", + target: { kind: "thread", threadId: "thread-1" }, + }; + const opened = openTab(singlePaneLayout(), "pane-1", terminal, { + preview: true, + }); + const terminalTab = findContentTab(opened.root, terminal)?.tab; + + expect(terminalTab).toMatchObject({ content: terminal, preview: false }); + }); + + it("activates and commits tabs without changing unrelated tab state", () => { + const preview = openTab( + singlePaneLayout(), + "pane-1", + threadContent("thread-2"), + { preview: true }, + ); + const previewId = findPane(preview.root, "pane-1")!.activeTabId; + const activated = activateTab(preview, "pane-1", "pane-1-t1"); + const committed = commitTab(activated, "pane-1", previewId); + + expect(findPane(activated.root, "pane-1")?.activeTabId).toBe("pane-1-t1"); + expect( + findPane(committed.root, "pane-1")?.tabs.find( + (tab) => tab.tabId === previewId, + )?.preview, + ).toBe(false); + expect(committed.focusedPaneId).toBe("pane-1"); + }); + + it("reorders tabs forward and backward while preserving the active tab", () => { + const layout = fourTabLayout(); + const paneBefore = findPane(layout.root, "pane-1")!; + const activeTabId = paneBefore.activeTabId; + const firstTabId = paneBefore.tabs[0]!.tabId; + const lastTabId = paneBefore.tabs[3]!.tabId; + + const forward = reorderTab(layout, "pane-1", firstTabId, 2); + const backward = reorderTab(layout, "pane-1", lastTabId, 1); + + expect(paneThreadOrder(forward)).toEqual([ + "thread-2", + "thread-3", + "pane-1", + "thread-4", + ]); + expect(paneThreadOrder(backward)).toEqual([ + "pane-1", + "thread-4", + "thread-2", + "thread-3", + ]); + expect(findPane(forward.root, "pane-1")?.activeTabId).toBe(activeTabId); + expect(findPane(backward.root, "pane-1")?.activeTabId).toBe(activeTabId); + }); + + it("clamps reorder destinations beyond both ends", () => { + const layout = fourTabLayout(); + const secondTabId = findPane(layout.root, "pane-1")!.tabs[1]!.tabId; + const thirdTabId = findPane(layout.root, "pane-1")!.tabs[2]!.tabId; + + expect( + paneThreadOrder(reorderTab(layout, "pane-1", secondTabId, 999)), + ).toEqual(["pane-1", "thread-3", "thread-4", "thread-2"]); + expect( + paneThreadOrder(reorderTab(layout, "pane-1", thirdTabId, -999)), + ).toEqual(["thread-3", "pane-1", "thread-2", "thread-4"]); + }); + + it("commits a reordered preview and preserves no-op identity", () => { + const preview = openTab( + singlePaneLayout(), + "pane-1", + threadContent("preview"), + { preview: true }, + ); + const previewTab = findContentTab( + preview.root, + threadContent("preview"), + )!.tab; + const reordered = reorderTab(preview, "pane-1", previewTab.tabId, 0); + + expect(paneThreadOrder(reordered)).toEqual(["preview", "pane-1"]); + expect( + findContentTab(reordered.root, threadContent("preview"))?.tab.preview, + ).toBe(false); + expect(findPane(reordered.root, "pane-1")?.activeTabId).toBe( + previewTab.tabId, + ); + + const committed = fourTabLayout(); + const sameIndexTab = findPane(committed.root, "pane-1")!.tabs[1]!; + expect(reorderTab(committed, "pane-1", sameIndexTab.tabId, 1)).toBe( + committed, + ); + expect(reorderTab(committed, "missing", sameIndexTab.tabId, 0)).toBe( + committed, + ); + expect(reorderTab(committed, "pane-1", "missing", 0)).toBe(committed); + }); + + it("closes tabs with index-neighbor fallback, collapses empty panes, and protects the final tab", () => { + const withThree = openTab( + openTab(singlePaneLayout(), "pane-1", threadContent("thread-2")), + "pane-1", + threadContent("thread-3"), + ); + const middleId = findPane(withThree.root, "pane-1")!.tabs[1]!.tabId; + const activated = activateTab(withThree, "pane-1", middleId); + const closedMiddle = closeTab(activated, "pane-1", middleId); + const paneAfterClose = findPane(closedMiddle.root, "pane-1")!; + + expect(activePaneContent(paneAfterClose)).toEqual( + threadContent("thread-3"), + ); + + const split = splitPane( + closedMiddle, + "pane-1", + "right", + threadContent("thread-4"), + ); + const collapsed = closeTab( + split, + "pane-2", + findPane(split.root, "pane-2")!.activeTabId, + ); + expect(countPanes(collapsed.root)).toBe(1); + + const only = singlePaneLayout(); + expect(closeTab(only, "pane-1", "pane-1-t1")).toBe(only); + }); + + it("moves tabs into groups and side splits with commit and collapse semantics", () => { + const split = splitPane( + openTab(singlePaneLayout(), "pane-1", threadContent("preview"), { + preview: true, + }), + "pane-1", + "right", + threadContent("target"), + ); + const previewTab = findContentTab(split.root, threadContent("preview"))!; + const centered = moveTab(split, "pane-1", previewTab.tab.tabId, { + type: "center", + targetPaneId: "pane-2", + }); + const targetPane = findPane(centered.root, "pane-2")!; + + expect(targetPane.tabs.at(-1)).toMatchObject({ + tabId: previewTab.tab.tabId, + preview: false, + }); + expect(targetPane.activeTabId).toBe(previewTab.tab.tabId); + expect(countPanes(centered.root)).toBe(2); + + const sourceLastTabId = findPane(centered.root, "pane-1")!.activeTabId; + const dissolved = moveTab(centered, "pane-1", sourceLastTabId, { + type: "center", + targetPaneId: "pane-2", + }); + expect(countPanes(dissolved.root)).toBe(1); + expect(findPane(dissolved.root, "pane-1")).toBeNull(); + + const movedSide = moveTab(dissolved, "pane-2", previewTab.tab.tabId, { + type: "side", + targetPaneId: "pane-2", + side: "left", + }); + expect(countPanes(movedSide.root)).toBe(2); + expect( + findContentTab(movedSide.root, threadContent("preview"))?.tab.preview, + ).toBe(false); + }); + + it("handles self drops and pane caps according to whether the source survives", () => { + const preview = openTab( + singlePaneLayout(), + "pane-1", + threadContent("preview"), + { preview: true }, + ); + const previewId = findPane(preview.root, "pane-1")!.activeTabId; + const selfCenter = moveTab(preview, "pane-1", previewId, { + type: "center", + targetPaneId: "pane-1", + }); + expect(findPane(selfCenter.root, "pane-1")?.tabs[1]?.preview).toBe(false); + + const only = singlePaneLayout(); + expect( + moveTab(only, "pane-1", "pane-1-t1", { + type: "side", + targetPaneId: "pane-1", + side: "right", + }), + ).toBe(only); + + const atCap = layoutAtPaneCount(MAX_PANES); + const survivingSource = openTab(atCap, "pane-1", threadContent("extra")); + const extraId = findContentTab( + survivingSource.root, + threadContent("extra"), + )!.tab.tabId; + expect( + moveTab(survivingSource, "pane-1", extraId, { + type: "side", + targetPaneId: "pane-2", + side: "left", + }), + ).toBe(survivingSource); + + const lastTabId = findPane(atCap.root, "pane-8")!.activeTabId; + const paneReplacement = moveTab(atCap, "pane-8", lastTabId, { + type: "side", + targetPaneId: "pane-7", + side: "left", + }); + expect(countPanes(paneReplacement.root)).toBe(MAX_PANES); + expect(findPane(paneReplacement.root, "pane-8")).toBeNull(); + }); + + it("normalizes empty panes, invalid active tabs, and duplicate previews", () => { + const malformed: SplitLayout = { + root: { + type: "split", + dir: "row", + sizes: [0.5, 0.5], + children: [ + { ...pane("pane-1"), tabs: [], activeTabId: "missing" }, + { + ...pane("pane-2"), + activeTabId: "missing", + tabs: [ + { tabId: "a", content: threadContent("a"), preview: true }, + { tabId: "b", content: threadContent("b"), preview: true }, + ], + }, + ], + }, + focusedPaneId: "pane-1", + }; + + const normalized = normalize(malformed); + const survivor = findPane(normalized.root, "pane-2")!; + expect(normalized.root.type).toBe("pane"); + expect(normalized.focusedPaneId).toBe("pane-2"); + expect(survivor.activeTabId).toBe("a"); + expect(survivor.tabs.map((tab) => tab.preview)).toEqual([true, false]); + }); + + it("normalizes terminal tabs to committed state", () => { + const terminal: PaneContent = { + kind: "terminal", + terminalId: "term-1", + target: { kind: "thread", threadId: "thread-1" }, + }; + const malformed: SplitLayout = { + root: { + ...pane("pane-1"), + tabs: [ + { + tabId: "pane-1-t1", + content: terminal, + preview: true, + }, + ], + }, + focusedPaneId: "pane-1", + }; + + const normalized = normalize(malformed); + expect(findContentTab(normalized.root, terminal)?.tab.preview).toBe(false); + }); + + it("trims excess tabs to sixteen without dropping the active tab", () => { + const activeTabId = `tab-${MAX_TABS_PER_PANE + 2}`; + const malformed: SplitLayout = { + root: { + type: "pane", + paneId: "pane-1", + tabs: Array.from({ length: MAX_TABS_PER_PANE + 2 }, (_, index) => ({ + tabId: `tab-${index + 1}`, + content: threadContent(`thread-${index + 1}`), + preview: false, + })), + activeTabId, + }, + focusedPaneId: "pane-1", + }; + + const normalized = normalize(malformed); + const normalizedPane = findPane(normalized.root, "pane-1")!; + expect(normalizedPane.tabs).toHaveLength(MAX_TABS_PER_PANE); + expect(normalizedPane.activeTabId).toBe(activeTabId); + expect(normalizedPane.tabs.at(-1)?.tabId).toBe(activeTabId); + }); + + it("matches view identity by kind, including terminals independent of target", () => { + const terminal = { + kind: "terminal", + terminalId: "term-1", + target: { kind: "thread", threadId: "thread-1" }, + } as const; + const layout = openTab(singlePaneLayout(), "pane-1", terminal); + + expect( + contentMatches(terminal, { + kind: "terminal", + terminalId: "term-1", + target: { kind: "environment", environmentId: "env-1" }, + }), + ).toBe(true); + expect( + findContentTab(layout.root, { kind: "terminal", terminalId: "term-1" }) + ?.tab.content, + ).toEqual(terminal); + expect( + contentMatches(threadContent("same", "p1"), threadContent("same", "p2")), + ).toBe(false); + expect( + contentMatches( + { kind: "diff", projectId: "p1", threadId: "same" }, + { kind: "diff", projectId: "p2", threadId: "same" }, + ), + ).toBe(false); + expect( + contentMatches( + { + kind: "plugin-panel", + pluginId: "notes", + panelPath: "files", + subPath: "a", + }, + { + kind: "plugin-panel", + pluginId: "notes", + panelPath: "files", + subPath: "b", + }, + ), + ).toBe(true); + }); }); diff --git a/apps/app/src/lib/split-layout/ops.ts b/apps/app/src/lib/split-layout/ops.ts index d0492ffac..9a538e138 100644 --- a/apps/app/src/lib/split-layout/ops.ts +++ b/apps/app/src/lib/split-layout/ops.ts @@ -2,6 +2,7 @@ import type { LayoutNode, PaneContent, PaneNode, + PaneTab, SplitLayout, SplitNode, SplitPath, @@ -9,6 +10,10 @@ import type { } from "./types"; export const MAX_PANES = 8; +// A sanity bound, not a UX budget: callers must still handle the no-op (and +// not navigate as if the open succeeded), but the bound is high enough that +// real usage never hits it. +export const MAX_TABS_PER_PANE = 16; const MIN_SIZE = 0.15; const MAX_SIZE = 0.85; @@ -45,48 +50,99 @@ export function findPane(root: LayoutNode, paneId: string): PaneNode | null { return null; } +/** The tab a pane currently renders. Normalized layouts always have a valid + * active tab; the first-tab fallback covers transiently inconsistent input. */ +export function activeTab(pane: PaneNode): PaneTab { + const active = pane.tabs.find((tab) => tab.tabId === pane.activeTabId); + const fallback = pane.tabs[0]; + if (active !== undefined) { + return active; + } + if (fallback === undefined) { + throw new Error("A pane must hold at least one tab"); + } + return fallback; +} + +export function activePaneContent(pane: PaneNode): PaneContent { + return activeTab(pane).content; +} + +export interface TabLocation { + pane: PaneNode; + tab: PaneTab; +} + +export function listTabs(root: LayoutNode): TabLocation[] { + return listPanes(root).flatMap((pane) => + pane.tabs.map((tab) => ({ pane, tab })), + ); +} + +/** + * View identity per content kind: two contents match when opening the second + * should reveal the first instead of creating another view. Threads and diffs + * are one-view-per-thread, terminals one-view-per-terminal, plugin panels one + * view per panel (subpaths are navigation within it), and the compose page is + * a singleton. + */ +export function contentMatches(a: PaneContent, b: PaneContent): boolean { + if (a.kind !== b.kind) { + return false; + } + switch (a.kind) { + case "new-thread": + return true; + case "thread": + return ( + b.kind === "thread" && + a.projectId === b.projectId && + a.threadId === b.threadId + ); + case "plugin-panel": + return ( + b.kind === "plugin-panel" && + a.pluginId === b.pluginId && + a.panelPath === b.panelPath + ); + case "terminal": + return b.kind === "terminal" && a.terminalId === b.terminalId; + case "diff": + return ( + b.kind === "diff" && + a.projectId === b.projectId && + a.threadId === b.threadId + ); + } +} + +/** Finds the tab whose content is the same view as `content`, anywhere in the + * tree, for reveal-existing semantics. */ +export function findContentTab( + root: LayoutNode, + content: PaneContent, +): TabLocation | null { + return ( + listTabs(root).find(({ tab }) => contentMatches(tab.content, content)) ?? + null + ); +} + export function findPaneByThread( root: LayoutNode, projectId: string, threadId: string, ): PaneNode | null { return ( - listPanes(root).find( - (pane) => - pane.content.kind === "thread" && - pane.content.projectId === projectId && - pane.content.threadId === threadId, - ) ?? null + findContentTab(root, { kind: "thread", projectId, threadId })?.pane ?? null ); } -/** Finds the pane representing the same routable page as `content`. Plugin - * subpaths belong to one panel identity, so navigating within a panel updates - * that pane instead of opening duplicates. The compose page is a singleton in - * this prototype, matching its existing shared draft/project state. */ export function findPaneByContent( root: LayoutNode, content: PaneContent, ): PaneNode | null { - return ( - listPanes(root).find((pane) => { - const candidate = pane.content; - if (candidate.kind !== content.kind) return false; - if (content.kind === "new-thread") return true; - if (content.kind === "thread") { - return ( - candidate.kind === "thread" && - candidate.projectId === content.projectId && - candidate.threadId === content.threadId - ); - } - return ( - candidate.kind === "plugin-panel" && - candidate.pluginId === content.pluginId && - candidate.panelPath === content.panelPath - ); - }) ?? null - ); + return findContentTab(root, content)?.pane ?? null; } function replacePaneNode( @@ -105,13 +161,40 @@ function replacePaneNode( }; } -function nextPaneId(root: LayoutNode): string { - const existingIds = new Set(listPanes(root).map((pane) => pane.paneId)); +function nextSequenceId(existingIds: ReadonlySet, prefix: string) { let sequence = 1; - while (existingIds.has(`pane-${sequence}`)) { + while (existingIds.has(`${prefix}-${sequence}`)) { sequence += 1; } - return `pane-${sequence}`; + return `${prefix}-${sequence}`; +} + +function nextPaneId(root: LayoutNode): string { + return nextSequenceId( + new Set(listPanes(root).map((pane) => pane.paneId)), + "pane", + ); +} + +function nextTabId(root: LayoutNode): string { + return nextSequenceId( + new Set(listTabs(root).map(({ tab }) => tab.tabId)), + "tab", + ); +} + +export function createPaneNode( + paneId: string, + tabId: string, + content: PaneContent, + options?: { preview?: boolean }, +): PaneNode { + return { + type: "pane", + paneId, + tabs: [{ tabId, content, preview: options?.preview === true }], + activeTabId: tabId, + }; } function splitDirection(side: SplitSide): SplitNode["dir"] { @@ -179,17 +262,19 @@ export function splitPane( ) { return layout; } - const pane: PaneNode = { - type: "pane", - paneId: nextPaneId(layout.root), + const pane = createPaneNode( + nextPaneId(layout.root), + nextTabId(layout.root), content, - }; + ); return { root: insertPane(layout.root, targetPaneId, side, pane), focusedPaneId: pane.paneId, }; } +/** Replaces the pane's ACTIVE tab content in place: navigation within a view + * (e.g. a thread pane navigating to another thread) rather than a new tab. */ export function replacePaneContent( layout: SplitLayout, paneId: string, @@ -199,12 +284,311 @@ export function replacePaneContent( if (pane === null) { return layout; } + const active = activeTab(pane); + const nextPane: PaneNode = { + ...pane, + tabs: pane.tabs.map((tab) => + tab.tabId === active.tabId ? { ...tab, content } : tab, + ), + activeTabId: active.tabId, + }; + return { + root: replacePaneNode(layout.root, paneId, nextPane), + focusedPaneId: paneId, + }; +} + +export interface OpenTabOptions { + /** Open as the group's preview tab (replacing its current preview). */ + preview?: boolean; +} + +/** + * Opens `content` as a tab in `paneId`. If the same view already exists in the + * target pane it is revealed (activated) instead of duplicated — callers + * wanting tree-wide reveal check {@link findContentTab} first. A preview open + * replaces the group's existing preview tab in its slot; a committed open + * appends after the active tab. + */ +export function openTab( + layout: SplitLayout, + paneId: string, + content: PaneContent, + options?: OpenTabOptions, +): SplitLayout { + const pane = findPane(layout.root, paneId); + if (pane === null) { + return layout; + } + // Stateful views never preview: a preview slot is silently destroyed by the + // next single-click, which must not tear down a PTY view. + const preview = options?.preview === true && content.kind !== "terminal"; + + const existing = pane.tabs.find((tab) => contentMatches(tab.content, content)); + if (existing !== undefined) { + const nextPane: PaneNode = { + ...pane, + // Re-opening a preview tab's view as committed commits it in place. + tabs: pane.tabs.map((tab) => + tab.tabId === existing.tabId && !preview && tab.preview + ? { ...tab, preview: false } + : tab, + ), + activeTabId: existing.tabId, + }; + return { + root: replacePaneNode(layout.root, paneId, nextPane), + focusedPaneId: paneId, + }; + } + + if (preview) { + const previewIndex = pane.tabs.findIndex((tab) => tab.preview); + if (previewIndex !== -1) { + const previewTab = pane.tabs[previewIndex]; + if (previewTab === undefined) { + return layout; + } + const nextPane: PaneNode = { + ...pane, + tabs: pane.tabs.map((tab, index) => + index === previewIndex ? { ...tab, content } : tab, + ), + activeTabId: previewTab.tabId, + }; + return { + root: replacePaneNode(layout.root, paneId, nextPane), + focusedPaneId: paneId, + }; + } + } + + if (pane.tabs.length >= MAX_TABS_PER_PANE) { + return layout; + } + const tabId = nextTabId(layout.root); + const activeIndex = pane.tabs.findIndex( + (tab) => tab.tabId === pane.activeTabId, + ); + const insertionIndex = + activeIndex === -1 ? pane.tabs.length : activeIndex + 1; + const tabs = [...pane.tabs]; + tabs.splice(insertionIndex, 0, { tabId, content, preview }); + const nextPane: PaneNode = { ...pane, tabs, activeTabId: tabId }; + return { + root: replacePaneNode(layout.root, paneId, nextPane), + focusedPaneId: paneId, + }; +} + +export function activateTab( + layout: SplitLayout, + paneId: string, + tabId: string, +): SplitLayout { + const pane = findPane(layout.root, paneId); + if (pane === null || !pane.tabs.some((tab) => tab.tabId === tabId)) { + return layout; + } return { - root: replacePaneNode(layout.root, paneId, { ...pane, content }), + root: replacePaneNode(layout.root, paneId, { ...pane, activeTabId: tabId }), focusedPaneId: paneId, }; } +/** Commits a preview tab (double-click / drag gestures). */ +export function commitTab( + layout: SplitLayout, + paneId: string, + tabId: string, +): SplitLayout { + const pane = findPane(layout.root, paneId); + if (pane === null) { + return layout; + } + const target = pane.tabs.find((tab) => tab.tabId === tabId); + if (target === undefined || !target.preview) { + return layout; + } + const nextPane: PaneNode = { + ...pane, + tabs: pane.tabs.map((tab) => + tab.tabId === tabId ? { ...tab, preview: false } : tab, + ), + }; + return { ...layout, root: replacePaneNode(layout.root, paneId, nextPane) }; +} + +/** + * Closes a tab. Closing a pane's last tab removes the pane (unless it is the + * layout's only pane, which is refused like {@link removePane}). The active + * tab falls to the neighbor that took the closed tab's index. + */ +export function closeTab( + layout: SplitLayout, + paneId: string, + tabId: string, +): SplitLayout { + const pane = findPane(layout.root, paneId); + if (pane === null) { + return layout; + } + const tabIndex = pane.tabs.findIndex((tab) => tab.tabId === tabId); + if (tabIndex === -1) { + return layout; + } + if (pane.tabs.length === 1) { + return removePane(layout, paneId); + } + const tabs = pane.tabs.filter((tab) => tab.tabId !== tabId); + const fallbackTab = tabs[Math.min(tabIndex, tabs.length - 1)]; + const nextPane: PaneNode = { + ...pane, + tabs, + activeTabId: + pane.activeTabId === tabId && fallbackTab !== undefined + ? fallbackTab.tabId + : pane.activeTabId, + }; + return { ...layout, root: replacePaneNode(layout.root, paneId, nextPane) }; +} + +/** + * Reorders a tab within its own group. Dragging is a commit gesture, so the + * moved tab loses any preview state. `toIndex` is clamped to the tab list. + */ +export function reorderTab( + layout: SplitLayout, + paneId: string, + tabId: string, + toIndex: number, +): SplitLayout { + const pane = findPane(layout.root, paneId); + if (pane === null || !Number.isInteger(toIndex)) { + return layout; + } + const fromIndex = pane.tabs.findIndex((tab) => tab.tabId === tabId); + if (fromIndex === -1) { + return layout; + } + const clampedIndex = Math.min(Math.max(toIndex, 0), pane.tabs.length - 1); + const moved = pane.tabs[fromIndex]; + if (moved === undefined) { + return layout; + } + if (clampedIndex === fromIndex && !moved.preview) { + return layout; + } + const tabs = pane.tabs.filter((tab) => tab.tabId !== tabId); + tabs.splice(clampedIndex, 0, { ...moved, preview: false }); + return { + ...layout, + root: replacePaneNode(layout.root, paneId, { ...pane, tabs }), + }; +} + +export type TabDropTarget = + | { type: "center"; targetPaneId: string } + | { type: "side"; targetPaneId: string; side: SplitSide }; + +/** + * Moves a tab between groups (center drop) or out into a new split (side + * drop). Dragging always commits a preview tab. Moving a pane's last tab to a + * side of itself is a no-op; moving it elsewhere dissolves the source pane. + */ +export function moveTab( + layout: SplitLayout, + sourcePaneId: string, + tabId: string, + target: TabDropTarget, +): SplitLayout { + const sourcePane = findPane(layout.root, sourcePaneId); + const tab = sourcePane?.tabs.find((candidate) => candidate.tabId === tabId); + if (sourcePane === undefined || sourcePane === null || tab === undefined) { + return layout; + } + const movedTab: PaneTab = { ...tab, preview: false }; + const sourceEmptiesOut = sourcePane.tabs.length === 1; + + if (target.type === "center") { + if (target.targetPaneId === sourcePaneId) { + // Same-group center drop just commits (reorder is out of scope for v1). + return commitTab(layout, sourcePaneId, tabId); + } + const targetPane = findPane(layout.root, target.targetPaneId); + if ( + targetPane === null || + targetPane.tabs.length >= MAX_TABS_PER_PANE + ) { + return layout; + } + let root = replacePaneNode(layout.root, target.targetPaneId, { + ...targetPane, + tabs: [...targetPane.tabs, movedTab], + activeTabId: movedTab.tabId, + }); + root = withTabRemoved(root, sourcePaneId, tabId); + const collapsed = sourceEmptiesOut + ? dropEmptyPane(root, sourcePaneId) + : root; + if (collapsed === null) { + return layout; + } + return { root: collapsed, focusedPaneId: target.targetPaneId }; + } + + if (target.targetPaneId === sourcePaneId && sourceEmptiesOut) { + return layout; + } + if (!sourceEmptiesOut && countPanes(layout.root) >= MAX_PANES) { + return layout; + } + const newPane: PaneNode = { + type: "pane", + paneId: nextPaneId(layout.root), + tabs: [movedTab], + activeTabId: movedTab.tabId, + }; + let root = withTabRemoved(layout.root, sourcePaneId, tabId); + const collapsed = sourceEmptiesOut ? dropEmptyPane(root, sourcePaneId) : root; + if (collapsed === null) { + return layout; + } + root = insertPane(collapsed, target.targetPaneId, target.side, newPane); + return { root, focusedPaneId: newPane.paneId }; +} + +function withTabRemoved( + root: LayoutNode, + paneId: string, + tabId: string, +): LayoutNode { + const pane = findPane(root, paneId); + if (pane === null) { + return root; + } + const tabIndex = pane.tabs.findIndex((tab) => tab.tabId === tabId); + if (tabIndex === -1) { + return root; + } + const tabs = pane.tabs.filter((tab) => tab.tabId !== tabId); + const fallbackTab = tabs[Math.min(tabIndex, tabs.length - 1)]; + return replacePaneNode(root, paneId, { + ...pane, + tabs, + activeTabId: + pane.activeTabId === tabId && fallbackTab !== undefined + ? fallbackTab.tabId + : pane.activeTabId, + }); +} + +/** Detaches a now-empty pane and collapses the tree; null when it can't. */ +function dropEmptyPane(root: LayoutNode, paneId: string): LayoutNode | null { + const result = detachPane(root, paneId); + return result.detached === null ? root : result.node; +} + interface DetachResult { node: LayoutNode | null; detached: PaneNode | null; @@ -315,12 +699,14 @@ export function swapPanes( } const withFirstSwap = replacePaneNode(layout.root, paneId, { ...pane, - content: targetPane.content, + tabs: targetPane.tabs, + activeTabId: targetPane.activeTabId, }); return { root: replacePaneNode(withFirstSwap, targetPaneId, { ...targetPane, - content: pane.content, + tabs: pane.tabs, + activeTabId: pane.activeTabId, }), focusedPaneId: targetPaneId, }; @@ -464,18 +850,61 @@ export function setFocus(layout: SplitLayout, paneId: string): SplitLayout { return { ...layout, focusedPaneId: paneId }; } -function normalizeNode(node: LayoutNode): LayoutNode { +function normalizePane(pane: PaneNode): PaneNode | null { + if (pane.tabs.length === 0) { + return null; + } + // Terminals are never previews; otherwise at most one preview per group + // (later duplicates commit). + let sawPreview = false; + let tabs = pane.tabs.map((tab) => { + if (!tab.preview) { + return tab; + } + if (tab.content.kind === "terminal" || sawPreview) { + return { ...tab, preview: false }; + } + sawPreview = true; + return tab; + }); + if (tabs.length > MAX_TABS_PER_PANE) { + // Trim from the end, but never drop the active tab. + const activeIndex = tabs.findIndex( + (tab) => tab.tabId === pane.activeTabId, + ); + const kept = tabs.slice(0, MAX_TABS_PER_PANE); + const active = activeIndex >= MAX_TABS_PER_PANE ? tabs[activeIndex] : null; + if (active != null) { + kept[MAX_TABS_PER_PANE - 1] = active; + } + tabs = kept; + } + const activeTabId = tabs.some((tab) => tab.tabId === pane.activeTabId) + ? pane.activeTabId + : (tabs[0]?.tabId ?? pane.activeTabId); + return { ...pane, tabs, activeTabId }; +} + +function normalizeNode(node: LayoutNode): LayoutNode | null { if (node.type === "pane") { - return { ...node }; + return normalizePane(node); + } + const children = node.children + .map(normalizeNode) + .filter((child): child is LayoutNode => child !== null); + if (children.length === 0) { + return null; } - const children = node.children.map(normalizeNode); if (children.length === 1) { - return children[0] ?? node; + return children[0] ?? null; } return { ...node, children, - sizes: normalizeSizes(node.sizes, children.length), + sizes: + children.length === node.children.length + ? normalizeSizes(node.sizes, children.length) + : equalSizes(children.length), }; } @@ -496,7 +925,11 @@ function trimToPaneLimit(root: LayoutNode): LayoutNode { } export function normalize(layout: SplitLayout): SplitLayout { - const root = normalizeNode(trimToPaneLimit(layout.root)); + const normalizedRoot = normalizeNode(layout.root); + if (normalizedRoot === null) { + return layout; + } + const root = trimToPaneLimit(normalizedRoot); const panes = listPanes(root); const focusedPaneId = panes.some( (pane) => pane.paneId === layout.focusedPaneId, diff --git a/apps/app/src/lib/split-layout/persistence.test.ts b/apps/app/src/lib/split-layout/persistence.test.ts index bd796215d..26dcddf39 100644 --- a/apps/app/src/lib/split-layout/persistence.test.ts +++ b/apps/app/src/lib/split-layout/persistence.test.ts @@ -4,7 +4,18 @@ import { serializeSplitLayout, SPLIT_LAYOUT_SCHEMA_VERSION, } from "./persistence"; -import type { SplitLayout } from "./types"; +import { MAX_TABS_PER_PANE } from "./ops"; +import type { PaneContent, PaneNode, SplitLayout } from "./types"; + +function pane(paneId: string, content: PaneContent): PaneNode { + const tabId = `${paneId}-t1`; + return { + type: "pane", + paneId, + tabs: [{ tabId, content, preview: false }], + activeTabId: tabId, + }; +} function layoutWithPaneCount(count: number): SplitLayout { return { @@ -12,15 +23,13 @@ function layoutWithPaneCount(count: number): SplitLayout { type: "split", dir: "row", sizes: Array.from({ length: count }, () => 1 / count), - children: Array.from({ length: count }, (_, index) => ({ - type: "pane" as const, - paneId: `pane-${index + 1}`, - content: { + children: Array.from({ length: count }, (_, index) => + pane(`pane-${index + 1}`, { kind: "thread" as const, projectId: "project-1", threadId: `thread-${index + 1}`, - }, - })), + }), + ), }, focusedPaneId: `pane-${count}`, }; @@ -32,24 +41,16 @@ const layout: SplitLayout = { dir: "row", sizes: [0.4, 0.6], children: [ - { - type: "pane", - paneId: "pane-1", - content: { - kind: "thread", - projectId: "project-1", - threadId: "thread-1", - }, - }, - { - type: "pane", - paneId: "pane-2", - content: { - kind: "thread", - projectId: "project-2", - threadId: "thread-2", - }, - }, + pane("pane-1", { + kind: "thread", + projectId: "project-1", + threadId: "thread-1", + }), + pane("pane-2", { + kind: "thread", + projectId: "project-2", + threadId: "thread-2", + }), ], }, focusedPaneId: "pane-2", @@ -65,7 +66,7 @@ describe("split layout persistence", () => { expect(deserializeSplitLayout(serialized)).toEqual(layout); }); - it("round-trips mixed new-thread and plugin panel content", () => { + it("round-trips tabs, preview state, terminal targets, and mixed content", () => { const mixed: SplitLayout = { root: { type: "split", @@ -73,20 +74,40 @@ describe("split layout persistence", () => { sizes: [0.5, 0.5], children: [ { - type: "pane", - paneId: "pane-1", - content: { kind: "new-thread" }, - }, - { - type: "pane", - paneId: "pane-2", - content: { - kind: "plugin-panel", - pluginId: "notes", - panelPath: "notes", - subPath: "work/today.md", - }, + ...pane("pane-1", { kind: "new-thread" }), + tabs: [ + { + tabId: "pane-1-t1", + content: { kind: "new-thread" }, + preview: false, + }, + { + tabId: "pane-1-t2", + content: { + kind: "terminal", + terminalId: "term-1", + target: { kind: "thread", threadId: "thread-1" }, + }, + preview: false, + }, + { + tabId: "pane-1-t3", + content: { + kind: "diff", + projectId: "project-1", + threadId: "thread-1", + }, + preview: true, + }, + ], + activeTabId: "pane-1-t3", }, + pane("pane-2", { + kind: "plugin-panel", + pluginId: "notes", + panelPath: "notes", + subPath: "work/today.md", + }), ], }, focusedPaneId: "pane-2", @@ -132,4 +153,148 @@ describe("split layout persistence", () => { deserializeSplitLayout(serializeSplitLayout(layoutWithPaneCount(9))), ).toBeNull(); }); + + it("deliberately rejects version-one single-content pane payloads", () => { + expect( + deserializeSplitLayout( + JSON.stringify({ + version: 1, + layout: { + root: { + type: "pane", + paneId: "pane-1", + content: { + kind: "thread", + projectId: "project-1", + threadId: "thread-1", + }, + }, + focusedPaneId: "pane-1", + }, + }), + ), + ).toBeNull(); + }); + + it("rejects invalid tab-group invariants and terminals without targets", () => { + const single = pane("pane-1", { + kind: "thread", + projectId: "project-1", + threadId: "thread-1", + }); + const stored = (root: unknown) => + JSON.stringify({ + version: SPLIT_LAYOUT_SCHEMA_VERSION, + layout: { root, focusedPaneId: "pane-1" }, + }); + + expect( + deserializeSplitLayout(stored({ ...single, activeTabId: "missing" })), + ).toBeNull(); + expect( + deserializeSplitLayout( + stored({ + ...single, + tabs: [ + { ...single.tabs[0], preview: true }, + { + tabId: "pane-1-t2", + content: { kind: "new-thread" }, + preview: true, + }, + ], + }), + ), + ).toBeNull(); + expect(deserializeSplitLayout(stored({ ...single, tabs: [] }))).toBeNull(); + expect( + deserializeSplitLayout( + stored({ + ...single, + tabs: Array.from({ length: MAX_TABS_PER_PANE + 1 }, (_, index) => ({ + tabId: `tab-${index}`, + content: { kind: "new-thread" }, + preview: false, + })), + activeTabId: "tab-0", + }), + ), + ).toBeNull(); + expect( + deserializeSplitLayout( + stored({ + ...single, + tabs: [ + { + tabId: "pane-1-t1", + content: { kind: "terminal", terminalId: "term-1" }, + preview: false, + }, + ], + }), + ), + ).toBeNull(); + expect( + deserializeSplitLayout( + stored({ + ...single, + tabs: [ + { + tabId: "pane-1-t1", + content: { + kind: "terminal", + terminalId: "term-1", + target: { kind: "thread", threadId: "thread-1" }, + }, + preview: true, + }, + ], + }), + ), + ).toBeNull(); + expect( + deserializeSplitLayout( + JSON.stringify({ + version: SPLIT_LAYOUT_SCHEMA_VERSION, + layout: { + root: { + type: "split", + dir: "row", + sizes: [0.5, 0.5], + children: [single, { ...single, paneId: "pane-2" }], + }, + focusedPaneId: "pane-1", + }, + }), + ), + ).toBeNull(); + expect( + deserializeSplitLayout( + JSON.stringify({ + version: SPLIT_LAYOUT_SCHEMA_VERSION, + layout: { + root: { + type: "split", + dir: "row", + sizes: [0.5, 0.5], + children: [ + single, + { + ...single, + tabs: [ + { + ...single.tabs[0], + tabId: "pane-2-t1", + }, + ], + activeTabId: "pane-2-t1", + }, + ], + }, + focusedPaneId: "pane-1", + }, + }), + ), + ).toBeNull(); + }); }); diff --git a/apps/app/src/lib/split-layout/persistence.ts b/apps/app/src/lib/split-layout/persistence.ts index 841c23f9c..3c990c428 100644 --- a/apps/app/src/lib/split-layout/persistence.ts +++ b/apps/app/src/lib/split-layout/persistence.ts @@ -1,8 +1,10 @@ import { z } from "zod"; -import { MAX_PANES, countPanes, listPanes } from "./ops"; +import { MAX_PANES, MAX_TABS_PER_PANE, countPanes, listPanes } from "./ops"; import type { LayoutNode, PaneNode, SplitLayout, SplitNode } from "./types"; -export const SPLIT_LAYOUT_SCHEMA_VERSION = 1; +/** v2: leaves are tab groups. v1 (single-content panes) deserializes to null — + * a deliberate fresh start, matching the reader's "seed from the route". */ +export const SPLIT_LAYOUT_SCHEMA_VERSION = 2; export const SPLIT_LAYOUT_STORAGE_KEY = "bb.splitLayout"; const paneContentSchema = z.discriminatedUnion("kind", [ @@ -22,15 +24,80 @@ const paneContentSchema = z.discriminatedUnion("kind", [ subPath: z.string(), }) .strict(), + z + .object({ + kind: z.literal("terminal"), + terminalId: z.string().min(1), + target: z.discriminatedUnion("kind", [ + z + .object({ kind: z.literal("thread"), threadId: z.string().min(1) }) + .strict(), + z + .object({ + kind: z.literal("environment"), + environmentId: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("host_path"), + hostId: z.string().min(1), + cwd: z.string().min(1), + }) + .strict(), + ]), + }) + .strict(), + z + .object({ + kind: z.literal("diff"), + projectId: z.string().min(1), + threadId: z.string().min(1), + }) + .strict(), ]); +const paneTabSchema = z + .object({ + tabId: z.string().min(1), + content: paneContentSchema, + preview: z.boolean(), + }) + .strict() + .superRefine((tab, context) => { + if (tab.preview && tab.content.kind === "terminal") { + context.addIssue({ + code: "custom", + message: "Terminal tabs are never previews", + path: ["preview"], + }); + } + }); + const paneNodeSchema: z.ZodType = z .object({ type: z.literal("pane"), paneId: z.string().min(1), - content: paneContentSchema, + tabs: z.array(paneTabSchema).min(1).max(MAX_TABS_PER_PANE), + activeTabId: z.string().min(1), }) - .strict(); + .strict() + .superRefine((pane, context) => { + if (!pane.tabs.some((tab) => tab.tabId === pane.activeTabId)) { + context.addIssue({ + code: "custom", + message: "The active tab must exist in the pane", + path: ["activeTabId"], + }); + } + if (pane.tabs.filter((tab) => tab.preview).length > 1) { + context.addIssue({ + code: "custom", + message: "A pane holds at most one preview tab", + path: ["tabs"], + }); + } + }); const layoutNodeSchema: z.ZodType = z.lazy(() => z.union([paneNodeSchema, splitNodeSchema]), @@ -91,6 +158,14 @@ const splitLayoutSchema: z.ZodType = z path: ["root"], }); } + const tabIds = panes.flatMap((pane) => pane.tabs.map((tab) => tab.tabId)); + if (new Set(tabIds).size !== tabIds.length) { + context.addIssue({ + code: "custom", + message: "Tab IDs must be unique", + path: ["root"], + }); + } }); const storedSplitLayoutSchema = z diff --git a/apps/app/src/lib/split-layout/types.ts b/apps/app/src/lib/split-layout/types.ts index 36923f5f1..a3cda0089 100644 --- a/apps/app/src/lib/split-layout/types.ts +++ b/apps/app/src/lib/split-layout/types.ts @@ -1,3 +1,9 @@ +/** Mirrors the terminal-session target scopes in `@bb/server-contract`. */ +export type TerminalPaneTarget = + | { kind: "thread"; threadId: string } + | { kind: "environment"; environmentId: string } + | { kind: "host_path"; hostId: string; cwd: string }; + export type PaneContent = | { kind: "thread"; @@ -12,12 +18,45 @@ export type PaneContent = pluginId: string; panelPath: string; subPath: string; + } + | { + kind: "terminal"; + terminalId: string; + /** Explicit context binding, fixed at creation: which scope owns the + * PTY. The pane needs it to look up its session (sessions list by + * scope, not id) and to badge its chrome. Absent only on URL-derived + * content (`/terminals/:id` can't carry it) — reveal intents that never + * open a new tab; persisted tabs always carry it. */ + target?: TerminalPaneTarget; + } + | { + kind: "diff"; + projectId: string; + threadId: string; }; +/** + * One view in a tab group. `preview` marks the group's at-most-one preview tab + * (VS Code-style): the next preview open replaces it in place, and commit + * gestures (double-click, drag) clear the flag. Stateful kinds (terminal) + * never carry it. + */ +export interface PaneTab { + tabId: string; + content: PaneContent; + preview: boolean; +} + +/** + * A leaf of the layout tree: a tab group holding one or more views. The pane + * renders its active tab; `paneId` is the stable identity drag/focus/commands + * address, `tabId` addresses a view within it. + */ export interface PaneNode { type: "pane"; paneId: string; - content: PaneContent; + tabs: PaneTab[]; + activeTabId: string; } export interface SplitNode { diff --git a/apps/app/src/views/SplitWorkspaceRoute.tsx b/apps/app/src/views/SplitWorkspaceRoute.tsx index 806101cc9..0194269b6 100644 --- a/apps/app/src/views/SplitWorkspaceRoute.tsx +++ b/apps/app/src/views/SplitWorkspaceRoute.tsx @@ -1,9 +1,13 @@ import { useMemo } from "react"; import { matchPath, Navigate, useLocation } from "react-router-dom"; +import { PERSONAL_PROJECT_ID } from "@bb/domain"; import { APP_ROOT_ROUTE_PATH, LEGACY_PROJECT_COMPOSE_ROUTE_PATH, PLUGIN_PANEL_ROUTE_PATH, + PROJECTLESS_THREAD_DIFF_ROUTE_PATH, + TERMINAL_ROUTE_PATH, + THREAD_DIFF_ROUTE_PATH, } from "@/lib/route-paths"; import type { PaneContent } from "@/lib/split-layout"; import { useRouteState } from "@/hooks/useRouteState"; @@ -27,14 +31,31 @@ export default function SplitWorkspaceRoute() { LEGACY_PROJECT_COMPOSE_ROUTE_PATH, location.pathname, ); + const terminalMatch = matchPath(TERMINAL_ROUTE_PATH, location.pathname); + const projectDiffMatch = matchPath(THREAD_DIFF_ROUTE_PATH, location.pathname); + const projectlessDiffMatch = matchPath( + PROJECTLESS_THREAD_DIFF_ROUTE_PATH, + location.pathname, + ); const pluginId = pluginMatch?.params.pluginId; const panelPath = pluginMatch?.params.panelPath; const pluginSubPath = pluginMatch?.params["*"] ?? ""; + const terminalId = terminalMatch?.params.terminalId; + const diffThreadId = + projectDiffMatch?.params.threadId ?? projectlessDiffMatch?.params.threadId; + const diffProjectId = + projectDiffMatch?.params.projectId ?? PERSONAL_PROJECT_ID; const routeContent = useMemo(() => { if (location.pathname === APP_ROOT_ROUTE_PATH) { return ROOT_COMPOSE_CONTENT; } + if (terminalId) { + return { kind: "terminal", terminalId }; + } + if (diffThreadId) { + return { kind: "diff", projectId: diffProjectId, threadId: diffThreadId }; + } if (isThreadView && projectId && threadId) { return { kind: "thread", projectId, threadId }; } @@ -48,12 +69,15 @@ export default function SplitWorkspaceRoute() { } return null; }, [ + diffProjectId, + diffThreadId, isThreadView, location.pathname, panelPath, pluginId, pluginSubPath, projectId, + terminalId, threadId, ]); diff --git a/apps/app/src/views/thread-detail/PaneTabStrip.tsx b/apps/app/src/views/thread-detail/PaneTabStrip.tsx new file mode 100644 index 000000000..18ace0b86 --- /dev/null +++ b/apps/app/src/views/thread-detail/PaneTabStrip.tsx @@ -0,0 +1,574 @@ +import { cn } from "@bb/shared-ui/lib/utils"; +import { Icon, type IconName } from "@bb/shared-ui/icon"; +import { + Fragment, + useContext, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { useThread } from "@/hooks/queries/thread-queries"; +import { usePluginSlots } from "@/lib/plugin-slots"; +import { activeTab } from "@/lib/split-layout"; +import type { PaneContent, PaneNode, PaneTab } from "@/lib/split-layout"; +import { HEADER_ICON_BUTTON_CLASS } from "@/components/layout/AppPageHeader"; +import { PluginIcon } from "@/components/plugin/PluginIcon"; +import { SecondaryPanelHostLayoutContext } from "@/components/secondary-panel/SecondaryPanelHostLayoutContext"; +import { + BROWSER_COLLAPSED_HEADER_RESERVE_CLASS, + CHROME_ROW_CLASS, + getBbDesktopInfo, + MACOS_APP_REGION_NO_DRAG_CLASS, + MACOS_COLLAPSED_HEADER_RESERVE_CLASS, + MACOS_WINDOW_DRAG_CLASS, + shouldReserveMacosTrafficLights, + shouldUseMacosDesktopChrome, +} from "@/lib/bb-desktop"; +import { useIsSidebarShowing } from "@/components/ui/sidebar.js"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { useDesktopWindowState } from "@/hooks/useDesktopWindowState"; + +// Movement before a tab pointerdown becomes a reorder drag (vs a click). +const TAB_DRAG_ENGAGE_DISTANCE_PX = 5; +// How far outside the strip's box the pointer may wander while still +// reordering; beyond it the gesture hands off to the tear-out/split flow. +const STRIP_TEAR_OUT_MARGIN_PX = 16; + +/** Where a mid-gesture tear-out hand-off starts the split-drag session. */ +export interface TabDragStart { + clientX: number; + clientY: number; + /** The dragged tab element, dimmed by the session while it lives. */ + sourceEl: HTMLElement | null; +} + +export type BeginTabTearOut = ( + tabId: string, + start: TabDragStart, + label: string, +) => void; + +export interface PaneTabStripProps { + pane: PaneNode; + isPaneFocused: boolean; + /** Whether this strip sits on the workspace's top edge — it then doubles as + * the macOS window-drag row, exactly like the pane header row it replaced. */ + isTopRow: boolean; + /** + * True for the workspace's top-right pane: the window panel-toggle overlay + * floats over this strip's right corner, so reserve its footprint while the + * window panel is closed (open, the toggle overlays the panel's own chrome). + */ + reservesWindowPanelToggle?: boolean; + /** + * True for the workspace's structural top-left pane. The strip is the + * titlebar row there, so it — not the in-pane headers below it — clears the + * pinned sidebar trigger and macOS traffic lights, exactly like the page + * header row it replaced. Several top-row strips can be drag regions, but + * only one leaf may own this reserve. + */ + ownsWindowTopLeft?: boolean; + onActivateTab: (tabId: string) => void; + onCommitTab: (tabId: string) => void; + onCloseTab: (tabId: string) => void; + onReorderTab: (tabId: string, toIndex: number) => void; + onBeginTabTearOut: BeginTabTearOut; + /** + * Starts a WHOLE-group move/swap drag from the strip's empty background — + * the only group-drag handle left when the active view suppresses the pane + * header (terminal/diff). Absent on the single-pane surface, where a lone + * group has nothing to reorder. + */ + onBeginGroupDrag?: (event: ReactPointerEvent, label: string) => void; +} + +interface TabDragState { + tabId: string; + /** Insertion index among the OTHER tabs (reorderTab's filtered-list index). */ + insertIndex: number; +} + +/** + * The chrome row above a pane's content: one tab per view in the group, sized + * to the shared 48px chrome-row axis so it carries the old pane header's + * responsibilities (window drag, the reserved panel-toggle corner). The + * "Synthesis" treatment from the BB-39 design board: soft elevated container + * on the active tab, per-kind leading icons, dotted-underline preview state. + * Click activates (and the URL follows), double-click commits a preview tab, + * middle-click or the X closes. Dragging a tab within the strip shows a + * pin-style insertion indicator and reorders on drop; leaving the strip hands + * off to the tear-out/split flow. + */ +export function PaneTabStrip({ + pane, + isPaneFocused, + isTopRow, + reservesWindowPanelToggle = false, + ownsWindowTopLeft = false, + onActivateTab, + onCommitTab, + onCloseTab, + onReorderTab, + onBeginTabTearOut, + onBeginGroupDrag, +}: PaneTabStripProps) { + const stripRef = useRef(null); + const scrollRef = useRef(null); + const { navPanels } = usePluginSlots(); + const isWindowPanelOpen = + useContext(SecondaryPanelHostLayoutContext)?.isOpen === true; + const [desktopInfo] = useState(getBbDesktopInfo); + const isWindowDragRegion = + shouldUseMacosDesktopChrome(desktopInfo) && isTopRow; + const isSidebarShowing = useIsSidebarShowing(); + const isCompactViewport = useIsCompactViewport(); + const desktopWindowState = useDesktopWindowState(); + const reserveMacosTrafficLights = shouldReserveMacosTrafficLights({ + desktopInfo, + windowState: desktopWindowState, + }); + const shouldReserveSidebarTrigger = + ownsWindowTopLeft && (isCompactViewport || !isSidebarShowing); + const [drag, setDrag] = useState(null); + + // "+N" chip: how many tabs sit (mostly) outside the scrollport. + const [overflowCount, setOverflowCount] = useState(0); + useEffect(() => { + const scroller = scrollRef.current; + if (scroller === null) { + return; + } + const update = () => { + const viewport = scroller.getBoundingClientRect(); + let hidden = 0; + for (const el of scroller.querySelectorAll( + "[data-pane-tab-id]", + )) { + const rect = el.getBoundingClientRect(); + const mid = rect.left + rect.width / 2; + if (mid < viewport.left || mid > viewport.right) { + hidden += 1; + } + } + setOverflowCount(hidden); + }; + update(); + scroller.addEventListener("scroll", update); + const observer = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update); + observer?.observe(scroller); + return () => { + scroller.removeEventListener("scroll", update); + observer?.disconnect(); + }; + }, [pane.tabs.length]); + + const handleStripPointerDown = (event: ReactPointerEvent) => { + // Only the strip's empty background is the group handle: pointerdowns on + // tabs (and their close buttons) target the tab elements instead. + if ( + event.button !== 0 || + !(event.target instanceof HTMLElement) || + event.target.dataset.stripBg === undefined + ) { + return; + } + onBeginGroupDrag?.( + event, + staticTabLabel(activeTab(pane).content, navPanels), + ); + }; + + // One pointer session per tab drag: within the strip's (slightly inflated) + // box the gesture tracks a pin-style insertion indicator and reorders on + // drop; crossing out hands the same gesture to the split-drag layer already + // engaged. + const beginTabGesture = ( + tab: PaneTab, + label: string, + event: ReactPointerEvent, + ) => { + const strip = stripRef.current; + const tabEl = event.currentTarget; + if (strip === null || !(tabEl instanceof HTMLElement)) { + return; + } + const startX = event.clientX; + const startY = event.clientY; + let engaged = false; + // A drag that never crosses another tab's midpoint drops in place: the + // dragged tab's own index doubles as its filtered-list insertion index. + let lastIndex = Math.max( + 0, + pane.tabs.findIndex((candidate) => candidate.tabId === tab.tabId), + ); + + function cleanup(): void { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + window.removeEventListener("pointercancel", handleCancel); + setDrag(null); + } + + function handleUp(): void { + const wasEngaged = engaged; + const toIndex = lastIndex; + cleanup(); + if (wasEngaged) { + onReorderTab(tab.tabId, toIndex); + } + } + + function handleCancel(): void { + cleanup(); + } + + function handleMove(move: globalThis.PointerEvent): void { + if (strip === null || !(tabEl instanceof HTMLElement)) { + return; + } + if (!engaged) { + if ( + Math.hypot(move.clientX - startX, move.clientY - startY) <= + TAB_DRAG_ENGAGE_DISTANCE_PX + ) { + return; + } + engaged = true; + } + move.preventDefault(); + const rect = strip.getBoundingClientRect(); + const margin = STRIP_TEAR_OUT_MARGIN_PX; + const withinStrip = + move.clientX >= rect.left - margin && + move.clientX <= rect.right + margin && + move.clientY >= rect.top - margin && + move.clientY <= rect.bottom + margin; + if (!withinStrip) { + cleanup(); + onBeginTabTearOut( + tab.tabId, + { clientX: move.clientX, clientY: move.clientY, sourceEl: tabEl }, + label, + ); + return; + } + // Insertion index = how many OTHER tabs the pointer has passed + // (midpoint rule), which is exactly reorderTab's filtered-list index. + let toIndex = 0; + for (const el of strip.querySelectorAll( + "[data-pane-tab-id]", + )) { + if (el.dataset.paneTabId === tab.tabId) { + continue; + } + const tabRect = el.getBoundingClientRect(); + if (tabRect.left + tabRect.width / 2 < move.clientX) { + toIndex += 1; + } + } + lastIndex = toIndex; + setDrag({ tabId: tab.tabId, insertIndex: toIndex }); + } + + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + window.addEventListener("pointercancel", handleCancel); + }; + + // Interleave the insertion indicator at the drop gap: it renders before the + // (insertIndex + 1)-th non-dragged tab, or trailing when past them all. + const items: { tab: PaneTab; indicatorBefore: boolean }[] = []; + let othersSeen = 0; + let indicatorPlaced = false; + for (const tab of pane.tabs) { + const isDragged = drag !== null && drag.tabId === tab.tabId; + let indicatorBefore = false; + if (drag !== null && !isDragged) { + if (othersSeen === drag.insertIndex) { + indicatorBefore = true; + indicatorPlaced = true; + } + othersSeen += 1; + } + items.push({ tab, indicatorBefore }); + } + const indicatorAtEnd = drag !== null && !indicatorPlaced; + + const scrollToEnd = () => { + scrollRef.current?.scrollTo({ + left: scrollRef.current.scrollWidth, + behavior: "smooth", + }); + }; + + return ( +
+
+ {items.map(({ tab, indicatorBefore }) => ( + + {indicatorBefore ? : null} + onActivateTab(tab.tabId)} + onCommit={() => onCommitTab(tab.tabId)} + onClose={() => onCloseTab(tab.tabId)} + onBeginGesture={(event, label) => + beginTabGesture(tab, label, event) + } + /> + + ))} + {indicatorAtEnd ? : null} +
+ {overflowCount > 0 ? ( + + ) : null} + {/* Remaining width is the group-drag / window-drag background. */} +
+ {reservesWindowPanelToggle && !isWindowPanelOpen ? ( + // The floating window panel toggle overlays this corner; reserve its + // stable 28px footprint so overflowing tabs never slide under it. + + ) : null} +
+ ); +} + +/** The mock's pin-style insertion mark: a small dot capping a vertical bar. */ +function DropIndicator() { + return ( + + + + ); +} + +interface PaneTabItemProps { + tab: PaneTab; + isActive: boolean; + isPaneFocused: boolean; + isWindowDragRegion: boolean; + isDragSource: boolean; + onActivate: () => void; + onCommit: () => void; + onClose: () => void; + onBeginGesture: (event: ReactPointerEvent, label: string) => void; +} + +function PaneTabItem(props: PaneTabItemProps) { + if (props.tab.content.kind === "thread") { + return ; + } + return ; +} + +function ThreadTabItem({ + threadId, + ...props +}: PaneTabItemProps & { threadId: string }) { + const { data: thread } = useThread(threadId); + const label = + thread?.title?.trim() || thread?.titleFallback?.trim() || "Thread"; + return ; +} + +function StaticTabItem(props: PaneTabItemProps) { + const { navPanels } = usePluginSlots(); + return ( + + ); +} + +function staticTabLabel( + content: PaneContent, + navPanels: ReturnType["navPanels"], +): string { + switch (content.kind) { + // Thread tabs render through ThreadTabItem; this is only the query-less + // fallback name. + case "thread": + return "Thread"; + case "new-thread": + return "New thread"; + case "terminal": + return "Terminal"; + case "diff": + return "Diff"; + case "plugin-panel": + return ( + navPanels.find( + (candidate) => + candidate.pluginId === content.pluginId && + candidate.path === content.panelPath, + )?.title ?? content.panelPath + ); + } +} + +const KIND_ICONS: Record, IconName> = + { + thread: "MessageSquare", + "new-thread": "Plus", + terminal: "Terminal", + diff: "FileDiff", + }; + +/** The tab's leading 14px kind icon; inherits the tab's text color. */ +function TabKindIcon({ content }: { content: PaneContent }) { + if (content.kind === "plugin-panel") { + // PluginIcon resolves the plugin's branding/contribution icon hint and + // falls back to a generic mark on its own. + return ( + + ); + } + return ( +
@@ -685,7 +858,11 @@ interface SplitTreeProps { ) => void; onNavigateInPane: NavigateInPane; onBeginPaneDrag: BeginPaneDrag; - onPruneStalePane: (paneId: string) => void; + onActivateTab: (paneId: string, tabId: string) => void; + onCommitTab: (paneId: string, tabId: string) => void; + onCloseTab: (paneId: string, tabId: string) => void; + onReorderTab: (paneId: string, tabId: string, toIndex: number) => void; + onBeginTabDrag: BeginPaneTabDrag; } function SplitTree(props: SplitTreeProps) { @@ -711,7 +888,7 @@ function SplitTree(props: SplitTreeProps) { isHiddenByMaximize ? { contentVisibility: "hidden" } : undefined } className={cn( - "relative flex min-h-0 min-w-0 flex-1 overflow-hidden", + "relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden", isHiddenByMaximize && "invisible pointer-events-none", isMaximized && "absolute inset-0 z-30", )} @@ -719,33 +896,58 @@ function SplitTree(props: SplitTreeProps) { data-maximized={isMaximized ? "true" : undefined} > {/* Only mounted in split mode, so single panes never pay for the extra - thread subscription (and never prune the last pane). */} - {node.content.kind === "thread" ? ( - props.onPruneStalePane(node.paneId)} - /> - ) : null} - props.onClosePane(node.paneId)} - isMaximized={isMaximized} - onToggleMaximize={() => props.onToggleMaximizePane(node.paneId)} - isBoundedPane + thread subscriptions. One watcher per thread TAB: a stale thread + closes its tab, not the whole group (closeTab still refuses the + layout's last view). */} + {node.tabs.map((tab) => + tab.content.kind === "thread" ? ( + props.onCloseTab(node.paneId, tab.tabId)} + /> + ) : null, + )} + props.onActivateTab(node.paneId, tabId)} + onCommitTab={(tabId) => props.onCommitTab(node.paneId, tabId)} + onCloseTab={(tabId) => props.onCloseTab(node.paneId, tabId)} + onReorderTab={(tabId, toIndex) => + props.onReorderTab(node.paneId, tabId, toIndex) + } + onBeginTabTearOut={(tabId, start, label) => + props.onBeginTabDrag(node.paneId, tabId, start, label) + } + onBeginGroupDrag={(event, label) => + props.onBeginPaneDrag(node.paneId, event, label) + } /> +
+ props.onClosePane(node.paneId)} + isMaximized={isMaximized} + onToggleMaximize={() => props.onToggleMaximizePane(node.paneId)} + isBoundedPane + isTopRow={isMaximized || isTopRow} + ownsWindowTopLeft={false} + onNavigateInPane={props.onNavigateInPane} + onBeginPaneDrag={props.onBeginPaneDrag} + /> +
{/* The inactive-pane scrim lives on an overlay ABOVE the pane's content because styles painted on the pane element itself get covered by children with opaque backgrounds (header scrim, composer). */} @@ -809,7 +1011,6 @@ interface WorkspacePaneContentProps { isFocused: boolean; isSplitPane: boolean; secondaryPanelRegistry: PaneSecondaryPanelRegistry | null; - reservesWindowPanelToggle: boolean; onRequestClose: (() => void) | null; isMaximized: boolean; onToggleMaximize: (() => void) | null; @@ -829,7 +1030,6 @@ function WorkspacePaneContent({ isFocused, isSplitPane, secondaryPanelRegistry, - reservesWindowPanelToggle, onRequestClose, isMaximized, onToggleMaximize, @@ -867,7 +1067,9 @@ function WorkspacePaneContent({ isFocused, isSplitPane, secondaryPanelHost, - reservesWindowPanelToggle, + // The pane's tab strip owns the top-right panel-toggle reservation now; + // in-pane headers below it never share a row with the floating toggle. + reservesWindowPanelToggle: false, onRequestClose, isMaximized, onToggleMaximize, @@ -889,7 +1091,6 @@ function WorkspacePaneContent({ isMaximized, onToggleMaximize, paneId, - reservesWindowPanelToggle, secondaryPanelHost, ], ); @@ -899,7 +1100,6 @@ function WorkspacePaneContent({ ; - } - if (content.kind === "new-thread") { - return ; + switch (content.kind) { + case "thread": + return ; + case "new-thread": + return ; + case "terminal": + return ( + + ); + case "diff": + return ( + + ); + case "plugin-panel": + return ( + + ); } - return ( - - ); } function NonThreadPaneContent({ content, - onRequestClose, beginPaneDrag, isBoundedPane, isTopRow, ownsWindowTopLeft, }: { content: Exclude; - onRequestClose: (() => void) | null; beginPaneDrag?: (event: ReactPointerEvent, label: string) => void; isBoundedPane: boolean; isTopRow: boolean; ownsWindowTopLeft: boolean; }) { const { navPanels } = usePluginSlots(); - const { reservesWindowPanelToggle } = useOptionalPaneContext() ?? { - reservesWindowPanelToggle: false, - }; - const isWindowPanelOpen = - useContext(SecondaryPanelHostLayoutContext)?.isOpen === true; const [desktopInfo] = useState(getBbDesktopInfo); const usesDesktopChrome = shouldUseMacosDesktopChrome(desktopInfo); const panel = @@ -967,40 +1175,29 @@ function NonThreadPaneContent({ candidate.path === content.panelPath, ) : undefined; - const label = panel?.title ?? "New thread"; + const label = + content.kind === "terminal" + ? "Terminal" + : content.kind === "diff" + ? "Diff" + : (panel?.title ?? "New thread"); + // The tab strip is the pane chrome now: the tab owns the title and the + // close/reserve affordances, so bounded panes get no generic title header. + // Only a resolved plugin panel keeps its header row — it carries real + // affordances (panel title/icon and the panel's own header actions). + const showsHeader = panel !== undefined; const handlePointerDown = (event: ReactPointerEvent) => { if (event.button === 0) beginPaneDrag?.(event, label); }; - const actions = ( + const actions = panel ? ( <> - {panel ? ( - - ) : null} + - {onRequestClose ? ( - - ) : null} - {reservesWindowPanelToggle && !isWindowPanelOpen ? ( - // The host's shortcut hint drops below the chrome row; reserve only - // its stable 28px corner button beside these pane actions. With the - // window panel open, the toggle overlays the panel's own chrome - // instead, so the pane actions sit flush at the pane edge. - - ) : null} - ); + ) : null; return (
- {isBoundedPane || panel ? ( + {showsHeader ? ( - {panel ? ( - - ) : ( -

New thread

- )} + {panel ? : null}
} actions={actions} /> ) : null} -
+
{content.kind === "new-thread" ? ( + ) : content.kind === "terminal" ? ( + + ) : content.kind === "diff" ? ( + ) : ( ({ useIsCompactViewport: () => false, })); +// The diff/terminal entry points pull in navigation and mutation machinery +// that this title-rendering test doesn't exercise. +vi.mock("./useThreadPaneEntryPoints", () => ({ + useThreadPaneEntryPoints: () => ({ + openDiff: vi.fn(), + openTerminal: vi.fn(), + newTerminal: vi.fn(), + openTerminalSession: vi.fn(), + runningTerminals: [], + isBusy: false, + }), +})); + const PANE_CONTEXT: PaneContextValue = { paneId: "main", isFocused: true, @@ -53,6 +66,8 @@ describe("ThreadDetailHeader", () => { isSecondaryPanelOpen={false} onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} + projectId="proj-1" + threadId="thr-1" threadHeaderGitActions={[]} threadTitle="Review @docs/foo.test.ts with @thread:thr_worker" /> diff --git a/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx b/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx index d0a2b7623..538e981b8 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx @@ -27,6 +27,15 @@ import { ThreadTitleMentions } from "@/components/thread/ThreadTitleMentions"; import { SecondaryPanelHostLayoutContext } from "@/components/secondary-panel/SecondaryPanelHostLayoutContext"; import { usePaneContext } from "./PaneContext"; import { PaneMaximizeButton } from "./PaneMaximizeButton"; +import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled"; +import { useThreadPaneEntryPoints } from "./useThreadPaneEntryPoints"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@bb/shared-ui/context-menu"; const THREAD_HEADER_ACTION_BUTTON_CLASS = COARSE_POINTER_TOOLBAR_ACTION_BUTTON_CLASS; @@ -47,7 +56,9 @@ interface ThreadDetailHeaderProps { onToggleSecondaryPanel: () => void; /** Plugin-contributed thread action buttons (design §4.9); optional. */ pluginActions?: ReactNode; + projectId: string; threadHeaderGitActions: ThreadHeaderGitAction[]; + threadId: string; threadTitle: string; workspaceOpenButton?: ReactNode; } @@ -60,12 +71,15 @@ export function ThreadDetailHeader({ onOpenThreadGitAction, onToggleSecondaryPanel, pluginActions, + projectId, threadHeaderGitActions, + threadId, threadTitle, workspaceOpenButton, }: ThreadDetailHeaderProps) { const [primaryAction, ...secondaryActions] = threadHeaderGitActions; const renderAsDrawer = useIsCompactViewport(); + const threadSplitsEnabled = useThreadSplitsEnabled(); const [desktopInfo] = useState(getBbDesktopInfo); const panelShortcut = useAppCommandShortcut("panel.toggle"); const usesDesktopChrome = shouldUseMacosDesktopChrome(desktopInfo); @@ -177,6 +191,12 @@ export function ThreadDetailHeader({ {primaryAction.label} ) : null} + {threadSplitsEnabled && !renderAsDrawer ? ( + + ) : null} {showRightPanelToggle ? ( @@ -238,3 +258,73 @@ export function ThreadDetailHeader({ /> ); } + +function ThreadPaneEntryPointButtons({ + projectId, + threadId, +}: { + projectId: string; + threadId: string; +}) { + const { + openDiff, + openTerminal, + newTerminal, + openTerminalSession, + runningTerminals, + isBusy, + } = useThreadPaneEntryPoints({ + projectId, + threadId, + }); + + return ( + <> + + {/* Click reveals the thread's terminal (creating the first); threads can + hold any number, so alt-click and the context menu start more. */} + + + + + + + + New terminal + + {runningTerminals.length > 0 ? : null} + {runningTerminals.map((terminal) => ( + openTerminalSession(terminal.id)} + > + + {terminal.title} + + ))} + + + + ); +} diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 3240fa333..0e4e39ad6 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -2352,7 +2352,9 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { onOpenThreadGitAction={gitActions.threadGitActionDialog.onOpen} onToggleSecondaryPanel={toggleSecondaryPanel} pluginActions={} + projectId={projectId} threadHeaderGitActions={gitActions.threadHeaderGitActions} + threadId={threadId} threadTitle={threadTitle} workspaceOpenButton={workspaceOpenButton} /> diff --git a/apps/app/src/views/thread-detail/splitPaneCommands.test.ts b/apps/app/src/views/thread-detail/splitPaneCommands.test.ts index d22253a76..dc6dea024 100644 --- a/apps/app/src/views/thread-detail/splitPaneCommands.test.ts +++ b/apps/app/src/views/thread-detail/splitPaneCommands.test.ts @@ -6,10 +6,18 @@ import { } from "./splitPaneCommands"; function pane(paneId: string): PaneNode { + const tabId = `${paneId}-t1`; return { type: "pane", paneId, - content: { kind: "thread", projectId: "project-1", threadId: paneId }, + tabs: [ + { + tabId, + content: { kind: "thread", projectId: "project-1", threadId: paneId }, + preview: false, + }, + ], + activeTabId: tabId, }; } diff --git a/apps/app/src/views/thread-detail/splitThreadNavigation.test.ts b/apps/app/src/views/thread-detail/splitThreadNavigation.test.ts index f2033e7e0..916fe8a32 100644 --- a/apps/app/src/views/thread-detail/splitThreadNavigation.test.ts +++ b/apps/app/src/views/thread-detail/splitThreadNavigation.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { + activePaneContent, + findContentTab, + findPane, findPaneByContent, findPaneByThread, listPanes, MAX_PANES, + setFocus, splitPane, } from "@/lib/split-layout"; import type { SplitLayout } from "@/lib/split-layout"; @@ -32,6 +36,14 @@ function twoPaneLayout(): SplitLayout { ); } +function expectLayout(layout: SplitLayout | null): SplitLayout { + expect(layout).not.toBeNull(); + if (layout === null) { + throw new Error("Expected a split layout"); + } + return layout; +} + function eightPaneLayout(): SplitLayout { let layout = twoPaneLayout(); for (let index = 3; index <= MAX_PANES; index += 1) { @@ -58,7 +70,7 @@ describe("reconcileLayoutForRoute", () => { ); }); - it("replaces only the focused pane's content, preserving the rest of the split", () => { + it("adds a preview tab to the focused group, preserving committed tabs", () => { const before = twoPaneLayout(); const after = reconcileLayoutForRoute(before, { @@ -66,18 +78,45 @@ describe("reconcileLayoutForRoute", () => { threadId: "thread-3", }); - // Layout shape preserved (still two panes); focused pane now shows thread-3. expect(listPanes(after.root)).toHaveLength(2); expect(after.focusedPaneId).toBe("pane-2"); expect(findPaneByThread(after.root, "p1", "thread-3")?.paneId).toBe( "pane-2", ); - // The other pane is untouched. expect(findPaneByThread(after.root, "p1", "thread-1")?.paneId).toBe( "pane-1", ); - // thread-2 was displaced from the focused pane. - expect(findPaneByThread(after.root, "p1", "thread-2")).toBeNull(); + const focused = findPane(after.root, "pane-2")!; + expect(focused.tabs).toHaveLength(2); + expect(focused.tabs[0]?.content).toEqual({ + kind: "thread", + projectId: "p1", + threadId: "thread-2", + }); + expect(focused.tabs[0]?.preview).toBe(false); + expect(focused.tabs[1]?.preview).toBe(true); + }); + + it("replaces the focused group's existing preview in place", () => { + const first = reconcileLayoutForRoute(twoPaneLayout(), { + projectId: "p1", + threadId: "thread-3", + }); + const previewId = findPane(first.root, "pane-2")!.activeTabId; + const second = reconcileLayoutForRoute(first, { + projectId: "p1", + threadId: "thread-4", + }); + const focused = findPane(second.root, "pane-2")!; + + expect(focused.tabs).toHaveLength(2); + expect(focused.activeTabId).toBe(previewId); + expect(activePaneContent(focused)).toEqual({ + kind: "thread", + projectId: "p1", + threadId: "thread-4", + }); + expect(findPaneByThread(second.root, "p1", "thread-3")).toBeNull(); }); it("focuses an existing pane instead of duplicating an already-open thread", () => { @@ -92,6 +131,30 @@ describe("reconcileLayoutForRoute", () => { expect(after.focusedPaneId).toBe("pane-1"); }); + it("activates an existing inactive tab and focuses its group", () => { + const withTab = applyThreadOpenToLayout( + twoPaneLayout(), + { projectId: "p1", threadId: "thread-3" }, + "replace", + ); + const movedFocus = reconcileLayoutForRoute(withTab, { + projectId: "p1", + threadId: "thread-1", + }); + + const after = reconcileLayoutForRoute(movedFocus, { + projectId: "p1", + threadId: "thread-3", + }); + expect(after.focusedPaneId).toBe("pane-2"); + expect(activePaneContent(findPane(after.root, "pane-2")!)).toEqual({ + kind: "thread", + projectId: "p1", + threadId: "thread-3", + }); + expect(findPane(after.root, "pane-2")?.tabs).toHaveLength(2); + }); + it("is a no-op when the route already matches the focused pane", () => { const before = twoPaneLayout(); @@ -110,9 +173,9 @@ describe("mixed page navigation", () => { kind: "new-thread", }); - const after = reconcileLayoutForContent(withCompose, { - kind: "new-thread", - }); + const after = expectLayout( + reconcileLayoutForContent(withCompose, { kind: "new-thread" }), + ); expect(listPanes(after.root)).toHaveLength(3); expect(after.focusedPaneId).toBe( @@ -130,18 +193,62 @@ describe("mixed page navigation", () => { } as const; const before = splitPane(twoPaneLayout(), "pane-1", "bottom", plugin); - const after = reconcileLayoutForContent(before, { - ...plugin, - subPath: "work/today.md", - }); + const after = expectLayout( + reconcileLayoutForContent(before, { + ...plugin, + subPath: "work/today.md", + }), + ); expect(listPanes(after.root)).toHaveLength(3); - expect(findPaneByContent(after.root, plugin)?.content).toEqual({ + expect(findContentTab(after.root, plugin)?.tab.content).toEqual({ ...plugin, subPath: "work/today.md", }); expect(focusedPaneRoute(after)).toBe("/plugins/notes/notes/work/today.md"); }); + + it("leaves a null layout null for an unopened URL-derived terminal", () => { + expect( + reconcileLayoutForContent(null, { + kind: "terminal", + terminalId: "term-1", + }), + ).toBeNull(); + }); + + it("treats an unopened URL-derived terminal as a reveal-only no-op for an existing layout", () => { + const before = twoPaneLayout(); + const after = reconcileLayoutForContent(before, { + kind: "terminal", + terminalId: "term-1", + }); + + expect(after).toBe(before); + }); + + it("reveals an existing terminal by id regardless of target", () => { + const terminal = { + kind: "terminal", + terminalId: "term-1", + target: { kind: "thread", threadId: "thread-1" }, + } as const; + const before = setFocus( + splitPane(twoPaneLayout(), "pane-1", "bottom", terminal), + "pane-2", + ); + const after = expectLayout( + reconcileLayoutForContent(before, { + kind: "terminal", + terminalId: "term-1", + }), + ); + + expect(after.focusedPaneId).toBe( + findPaneByContent(after.root, terminal)?.paneId, + ); + expect(findContentTab(after.root, terminal)?.tab.preview).toBe(false); + }); }); describe("focusedThreadRoute", () => { @@ -166,6 +273,27 @@ describe("focusedThreadRoute", () => { }); describe("applyThreadOpenToLayout", () => { + it("adds a committed tab for a replace intent", () => { + const before = twoPaneLayout(); + const after = applyThreadOpenToLayout( + before, + { projectId: "p2", threadId: "thread-replace" }, + "replace", + ); + const focused = findPane(after.root, "pane-2")!; + + expect(listPanes(after.root)).toHaveLength(2); + expect(focused.tabs).toHaveLength(2); + expect(activePaneContent(focused)).toEqual({ + kind: "thread", + projectId: "p2", + threadId: "thread-replace", + }); + expect( + focused.tabs.find((tab) => tab.tabId === focused.activeTabId)?.preview, + ).toBe(false); + }); + it("splits from the focused pane and focuses the opened thread", () => { const before = twoPaneLayout(); const after = applyThreadOpenToLayout( @@ -193,7 +321,7 @@ describe("applyThreadOpenToLayout", () => { expect(after.focusedPaneId).toBe("pane-1"); }); - it("creates panes five through eight, then replaces the focused pane for a ninth open", () => { + it("creates panes through eight, then coerces a ninth edge open to a committed center tab", () => { const eight = eightPaneLayout(); const focusedPaneId = eight.focusedPaneId; @@ -220,7 +348,14 @@ describe("applyThreadOpenToLayout", () => { expect(findPaneByThread(after.root, "p2", "thread-9")?.paneId).toBe( focusedPaneId, ); - expect(findPaneByThread(after.root, "p1", "thread-8")).toBeNull(); + expect(findPaneByThread(after.root, "p1", "thread-8")?.paneId).toBe( + focusedPaneId, + ); + const focused = findPane(after.root, focusedPaneId)!; + expect(focused.tabs).toHaveLength(2); + expect( + focused.tabs.find((tab) => tab.tabId === focused.activeTabId)?.preview, + ).toBe(false); }); }); diff --git a/apps/app/src/views/thread-detail/splitThreadNavigation.ts b/apps/app/src/views/thread-detail/splitThreadNavigation.ts index ec69b2afb..1df55f2ba 100644 --- a/apps/app/src/views/thread-detail/splitThreadNavigation.ts +++ b/apps/app/src/views/thread-detail/splitThreadNavigation.ts @@ -1,11 +1,15 @@ import type { ThreadRoutePathArgs } from "@/lib/route-paths"; import type { ThreadOpenSplit, ThreadPaneAction } from "@bb/server-contract"; import { + activateTab, + activePaneContent, countPanes, + createPaneNode, + findContentTab, findPane, - findPaneByContent, findPaneByThread, MAX_PANES, + openTab, replacePaneContent, setFocus, splitPane, @@ -15,10 +19,13 @@ import type { PaneContent, SplitLayout } from "@/lib/split-layout"; import { getPluginPanelRoutePath, getRootComposeRoutePath, + getTerminalRoutePath, + getThreadDiffRoutePath, getThreadRoutePath, } from "@/lib/route-paths"; const FIRST_PANE_ID = "pane-1"; +const FIRST_TAB_ID = "tab-1"; export function threadPaneContent(thread: ThreadRoutePathArgs): PaneContent { return { @@ -31,108 +38,115 @@ export function threadPaneContent(thread: ThreadRoutePathArgs): PaneContent { export function createSinglePaneLayout( thread: ThreadRoutePathArgs, ): SplitLayout { - return { - root: { - type: "pane", - paneId: FIRST_PANE_ID, - content: threadPaneContent(thread), - }, - focusedPaneId: FIRST_PANE_ID, - }; + return createSinglePaneContentLayout(threadPaneContent(thread)); } export function createSinglePaneContentLayout( content: PaneContent, ): SplitLayout { return { - root: { type: "pane", paneId: FIRST_PANE_ID, content }, + root: createPaneNode(FIRST_PANE_ID, FIRST_TAB_ID, content), focusedPaneId: FIRST_PANE_ID, }; } export function paneContentRoute(content: PaneContent): string { - if (content.kind === "thread") { - return getThreadRoutePath(content); - } - if (content.kind === "new-thread") { - return getRootComposeRoutePath(); + switch (content.kind) { + case "thread": + return getThreadRoutePath(content); + case "new-thread": + return getRootComposeRoutePath(); + case "terminal": + return getTerminalRoutePath(content.terminalId); + case "diff": + return getThreadDiffRoutePath(content); + case "plugin-panel": + return getPluginPanelRoutePath({ + pluginId: content.pluginId, + path: content.panelPath, + subPath: content.subPath, + }); } - return getPluginPanelRoutePath({ - pluginId: content.pluginId, - path: content.panelPath, - subPath: content.subPath, - }); } -/** Reconciles any splittable page route into the focused pane. */ +/** Stateful views never open as previews: a preview slot gets silently + * replaced by the next single-click, which must not destroy a PTY view. */ +function contentPreviewable(content: PaneContent): boolean { + return content.kind !== "terminal"; +} + +/** + * Reconciles any splittable page route into the layout, per the settled + * docking policy: + * - no layout yet => a single pane holding the route's view; + * - the view already exists as a tab anywhere => reveal it (focus its pane, + * activate its tab), never duplicate; + * - otherwise => open it as the focused group's PREVIEW tab (VS Code-style: + * single-click navigation replaces the group's preview slot in place), + * except stateful kinds which open committed. + * Plugin-panel subpath changes update the existing tab's content (navigation + * within the view). Returns the same reference when nothing changes so the + * driving effect stays idempotent (no history spam, no render loop). + */ export function reconcileLayoutForContent( layout: SplitLayout | null, content: PaneContent, -): SplitLayout { +): SplitLayout | null { + const isRevealOnlyTerminal = + content.kind === "terminal" && content.target === undefined; if (layout === null) { - return createSinglePaneContentLayout(content); + // URL-derived terminal content can't seed a layout either — a targetless + // tab could never locate its session and must not be persisted. + return isRevealOnlyTerminal ? null : createSinglePaneContentLayout(content); } - const existing = findPaneByContent(layout.root, content); + const existing = findContentTab(layout.root, content); if (existing !== null) { - const withRouteState = - existing.content.kind === "plugin-panel" && + const needsSubPathRefresh = + existing.tab.content.kind === "plugin-panel" && content.kind === "plugin-panel" && - existing.content.subPath !== content.subPath - ? replacePaneContent(layout, existing.paneId, content) - : layout; - return withRouteState.focusedPaneId === existing.paneId - ? withRouteState - : setFocus(withRouteState, existing.paneId); + existing.tab.content.subPath !== content.subPath; + let next = layout; + if (existing.pane.activeTabId !== existing.tab.tabId) { + next = activateTab(next, existing.pane.paneId, existing.tab.tabId); + } + if (needsSubPathRefresh) { + next = replacePaneContent(next, existing.pane.paneId, content); + } + return next.focusedPaneId === existing.pane.paneId + ? next + : setFocus(next, existing.pane.paneId); } - return replacePaneContent(layout, layout.focusedPaneId, content); + if (isRevealOnlyTerminal) { + // URL-derived terminal content is a reveal intent only: without its + // context binding the pane couldn't locate its session, so an unopened + // terminal deep link leaves the layout untouched. + return layout; + } + return openTab(layout, layout.focusedPaneId, content, { + preview: contentPreviewable(content), + }); } export function focusedPaneRoute(layout: SplitLayout): string | null { const focused = findPane(layout.root, layout.focusedPaneId); - return focused === null ? null : paneContentRoute(focused.content); + return focused === null ? null : paneContentRoute(activePaneContent(focused)); } /** - * Folds an external route (initial load, sidebar click, deep link) into the - * layout, per the binding navigation policy: - * - no layout yet => a single pane from the route; - * - the route thread is already open in a pane => focus that pane, never - * duplicate it; - * - otherwise => replace the focused pane's content, never dismantling the - * rest of the layout. - * Returns the same reference when nothing changes so the driving effect stays - * idempotent (no history spam, no render loop). + * Folds an external thread route (initial load, sidebar click, deep link) into + * the layout. Threads are reveal-existing (one view per thread); an unopened + * thread opens as the focused group's preview tab. + * Returns the same reference when nothing changes. */ export function reconcileLayoutForRoute( layout: SplitLayout | null, thread: ThreadRoutePathArgs, ): SplitLayout { - if (layout === null) { - return createSinglePaneLayout(thread); - } - const existing = findPaneByThread( - layout.root, - thread.projectId, - thread.threadId, - ); - if (existing !== null) { - return layout.focusedPaneId === existing.paneId - ? layout - : setFocus(layout, existing.paneId); - } - const focused = findPane(layout.root, layout.focusedPaneId); - if ( - focused !== null && - focused.content.kind === "thread" && - focused.content.projectId === thread.projectId && - focused.content.threadId === thread.threadId - ) { - return layout; - } - return replacePaneContent( - layout, - layout.focusedPaneId, - threadPaneContent(thread), + // Thread content never hits the reveal-only terminal guard, so the null + // branch always seeds a layout; the fallback only satisfies the types. + return ( + reconcileLayoutForContent(layout, threadPaneContent(thread)) ?? + createSinglePaneLayout(thread) ); } @@ -143,7 +157,9 @@ function threadOpenSplitZone(split: ThreadOpenSplit): SplitZone { /** * Applies a CLI/SDK thread-open intent to the current layout. This deliberately * routes through the same drop decision as sidebar dragging so already-open - * threads focus, and edge splits at the pane cap coerce to replacement. + * threads focus, and edge splits at the pane cap coerce to replacement. An + * explicit open intent is a commit gesture: the tab opens committed, and a + * center intent adds a tab to the focused group rather than replacing content. */ export function applyThreadOpenToLayout( layout: SplitLayout | null, @@ -164,13 +180,19 @@ export function applyThreadOpenToLayout( atMaxPanes: countPanes(layout.root) >= MAX_PANES, }); if (existing !== null) { - return layout.focusedPaneId === existing.paneId - ? layout - : setFocus(layout, existing.paneId); + const content = threadPaneContent(thread); + const location = findContentTab(layout.root, content); + let next = layout; + if (location !== null && location.pane.activeTabId !== location.tab.tabId) { + next = activateTab(next, location.pane.paneId, location.tab.tabId); + } + return next.focusedPaneId === existing.paneId + ? next + : setFocus(next, existing.paneId); } const content = threadPaneContent(thread); return decision.zone === "center" - ? replacePaneContent(layout, layout.focusedPaneId, content) + ? openTab(layout, layout.focusedPaneId, content) : splitPane(layout, layout.focusedPaneId, decision.zone, content); } @@ -209,18 +231,20 @@ export function applyThreadPaneActionToLayout( } /** - * The focused pane's thread as route args, or null when it isn't a thread pane. - * Drives URL sync: the focused pane owns the address bar. + * The focused pane's thread as route args, or null when its active view isn't + * a thread. Drives URL sync: the focused pane's active tab owns the address + * bar. */ export function focusedThreadRoute( layout: SplitLayout, ): ThreadRoutePathArgs | null { const focused = findPane(layout.root, layout.focusedPaneId); - if (focused === null || focused.content.kind !== "thread") { + const content = focused === null ? null : activePaneContent(focused); + if (content === null || content.kind !== "thread") { return null; } return { - projectId: focused.content.projectId, - threadId: focused.content.threadId, + projectId: content.projectId, + threadId: content.threadId, }; } diff --git a/apps/app/src/views/thread-detail/useThreadPaneEntryPoints.ts b/apps/app/src/views/thread-detail/useThreadPaneEntryPoints.ts new file mode 100644 index 000000000..064d9175e --- /dev/null +++ b/apps/app/src/views/thread-detail/useThreadPaneEntryPoints.ts @@ -0,0 +1,211 @@ +import { useCallback, useMemo, useRef } from "react"; +import { useStore } from "jotai"; +import { useNavigate } from "react-router-dom"; +import { + useCreateThreadTerminal, + useThreadTerminals, +} from "@/hooks/queries/thread-terminal-queries"; +import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { + activateTab, + findContentTab, + listTabs, + openTab, + setFocus, + type PaneContent, + type SplitLayout, +} from "@/lib/split-layout"; +import { paneContentRoute } from "./splitThreadNavigation"; + +const TERMINAL_COLS = 100; +const TERMINAL_ROWS = 30; + +interface UseThreadPaneEntryPointsArgs { + projectId: string; + threadId: string; +} + +interface ThreadPaneEntryPoints { + openDiff(): void; + /** Reveals the thread's terminal (open tab, else running session), creating + * the first one when none exists. */ + openTerminal(): void; + /** Always starts another terminal session for the thread and opens its tab — + * threads can hold any number of terminals. */ + newTerminal(): void; + /** Reveals or opens the tab for one specific running session. */ + openTerminalSession(terminalId: string): void; + /** Running sessions for this thread, for pickers over `openTerminalSession`. */ + runningTerminals: readonly TerminalSessionSummary[]; + isBusy?: boolean; +} + +export interface TerminalSessionSummary { + id: string; + title: string; +} + +function revealTab( + layout: SplitLayout, + location: NonNullable>, +): SplitLayout { + return setFocus( + activateTab(layout, location.pane.paneId, location.tab.tabId), + location.pane.paneId, + ); +} + +export function useThreadPaneEntryPoints({ + projectId, + threadId, +}: UseThreadPaneEntryPointsArgs): ThreadPaneEntryPoints { + const store = useStore(); + const navigate = useNavigate(); + const terminalsQuery = useThreadTerminals(threadId); + const createTerminal = useCreateThreadTerminal(); + const isCreatingTerminalRef = useRef(false); + const isLoadingTerminals = + terminalsQuery.data === undefined && + (terminalsQuery.isLoading || terminalsQuery.isFetching); + + const applyContent = useCallback( + (content: PaneContent) => { + const layout = store.get(splitLayoutAtom); + if (layout === null) { + return; + } + const threadLocation = findContentTab(layout.root, { + kind: "thread", + projectId, + threadId, + }); + if (threadLocation === null) { + return; + } + + const existing = findContentTab(layout.root, content); + const nextLayout = + existing === null + ? openTab(layout, threadLocation.pane.paneId, content) + : revealTab(layout, existing); + if (nextLayout === layout) { + return; + } + store.set(splitLayoutAtom, nextLayout); + void navigate(paneContentRoute(content), { replace: true }); + }, + [navigate, projectId, store, threadId], + ); + + const openDiff = useCallback(() => { + applyContent({ kind: "diff", projectId, threadId }); + }, [applyContent, projectId, threadId]); + + const openTerminalSession = useCallback( + (terminalId: string) => { + applyContent({ + kind: "terminal", + terminalId, + target: { kind: "thread", threadId }, + }); + }, + [applyContent, threadId], + ); + + const newTerminal = useCallback(() => { + if (createTerminal.isPending || isCreatingTerminalRef.current) { + return; + } + isCreatingTerminalRef.current = true; + createTerminal.mutate( + { + threadId, + cols: TERMINAL_COLS, + rows: TERMINAL_ROWS, + }, + { + onSuccess: (session) => { + openTerminalSession(session.id); + }, + onSettled: () => { + isCreatingTerminalRef.current = false; + }, + }, + ); + }, [createTerminal, openTerminalSession, threadId]); + + const openTerminal = useCallback(() => { + const layout = store.get(splitLayoutAtom); + if (layout === null) { + return; + } + const threadLocation = findContentTab(layout.root, { + kind: "thread", + projectId, + threadId, + }); + if (threadLocation === null) { + return; + } + + const existingTerminal = listTabs(layout.root).find( + ({ tab }) => + tab.content.kind === "terminal" && + tab.content.target?.kind === "thread" && + tab.content.target.threadId === threadId, + ); + if (existingTerminal !== undefined) { + const content = existingTerminal.tab.content; + const nextLayout = setFocus( + activateTab( + layout, + existingTerminal.pane.paneId, + existingTerminal.tab.tabId, + ), + existingTerminal.pane.paneId, + ); + store.set(splitLayoutAtom, nextLayout); + void navigate(paneContentRoute(content), { replace: true }); + return; + } + + const runningSession = terminalsQuery.data?.sessions.find( + (session) => session.status === "running", + ); + if (runningSession !== undefined) { + openTerminalSession(runningSession.id); + return; + } + + if (isLoadingTerminals) { + return; + } + newTerminal(); + }, [ + isLoadingTerminals, + navigate, + newTerminal, + openTerminalSession, + projectId, + store, + terminalsQuery.data?.sessions, + threadId, + ]); + + const runningTerminals = useMemo( + () => + (terminalsQuery.data?.sessions ?? []) + .filter((session) => session.status === "running") + .map((session) => ({ id: session.id, title: session.title })), + [terminalsQuery.data?.sessions], + ); + + return { + openDiff, + openTerminal, + newTerminal, + openTerminalSession, + runningTerminals, + isBusy: isLoadingTerminals || createTerminal.isPending, + }; +} diff --git a/bb39-report/bb39-dockable-panes-review.html b/bb39-report/bb39-dockable-panes-review.html new file mode 100644 index 000000000..69d9245ae --- /dev/null +++ b/bb39-report/bb39-dockable-panes-review.html @@ -0,0 +1,269 @@ + + + + + + BB-39 · Dockable Panes Experiment — Product Review + + + +
+
+
Product review · BB-39 / BB-25
+

Dockable panes: terminals, diffs, and threads in one tab-group workspace

+

First working slice of the unified docking model: the split tree's leaves became VS Code-style tab groups, navigation opens preview tabs, and terminals and diffs are first-class draggable panes with thread-header entry points. Built by a four-worker model-routed fan-out, gated by a GPT-5.6 review, and verified live in the dev app for this report.

+
+ Verdict — works end-to-end; 1 P2 found in live QA + Branch bb/dockable-terminals-and-views-thr_9nsm45dtne (uncommitted, base 6dbeb8a20) + Build dev app @ localhost:12325 · threadSplits flag ON + Tests 241 files / 1,624 passing · typecheck clean + Scope experiment behind flag — phases 2+3 of 6 +
+
+ +
+ +
+ +
+

What was built and why

+

BB previously ran two separate pane systems: the recursive split tree (threads and plugin panels only) and the per-thread "secondary panel" tab strip (where terminals, diffs, and files actually lived). BB-25's decision record unifies them: one layout tree whose leaves are tab groups, every surface a tab, resources (threads, PTYs) distinct from views. This experiment ships the first two implementation phases behind the existing experiments.threadSplits flag:

+
+
Tab-group layout engine

Leaves hold ordered tabs with one preview slot; open/close/activate/commit/move ops; localStorage schema v2 (v1 = deliberate fresh start); 8-pane cap, 16-tab sanity bound.

+
Preview tabs

Navigation opens an italic preview that the next single-click replaces in place. Commit by double-click (tab or sidebar row) or by dragging. Stateful views (terminals) never preview.

+
Terminal & diff panes

New descriptors with canonical routes (/terminals/:id, /threads/:id/diff), explicit context binding, xterm mounted straight off the server-relayed session, diff via the production git-diff panel.

+
Thread-header entry points

Diff and Terminal buttons on the thread pane: reveal an open view, else reopen the thread's running terminal, else create one — never a duplicate, never an accidental PTY.

+
+

Deferred by design (later phases): browser panes (BB-40), plugin views API (BB-41), server-side per-project persistence + bb panes agent CLI (BB-42), the registry sidebar and quick switcher (BB-43).

+
+ +
+

The core workflow: click around, commit what matters

+

The defining loop of the design: single-click exploration is free — it never mints permanent tabs — and an explicit gesture locks in what you want to keep.

+
+ QA screenshot: qa-001b-direct-thread.jpg +
Navigation opens a preview. Visiting QA Alpha put it in the tab strip in italics — a preview tab sharing the group with the committed "New thread" view. The thread header keeps its slim chrome; the tab owns the title.
+
+
+ QA screenshot: qa-002-beta-replaces-preview.jpg +
The next click replaces the preview in place. Single-clicking QA Beta in the sidebar swapped the preview slot — still exactly two tabs, no pane spam, URL following along.
+
+
+ QA screenshot: qa-004-gamma-third-preview.jpg +
Commit + preview coexist. Double-clicking QA Beta's sidebar row committed it (upright); QA Gamma then opened as a fresh preview beside it. Three tabs: committed, committed, preview.
+
+
+ +
+

Terminals as first-class panes

+

The load-bearing claim of the whole unification — stateful, server-relayed PTYs living in the layout tree — verified live:

+
+ QA screenshot: qa-005-terminal-pane.jpg +
One click on the header's Terminal button created a PTY bound to the thread, opened it as a committed tab (terminals never preview), and the URL became /terminals/term_cr9va6c5vt. The slim context strip shows the binding: Thread thr_5yi7.
+
+
+ QA screenshot: qa-006-terminal-interactive.jpg +
Fully interactive over the pane's websocket: echo dockable-panes-live-$((6*7))dockable-panes-live-42, executed in the environment's worktree.
+
+
+ QA screenshot: qa-008-terminal-reopened-same-pty.jpg +
The resource outlives its view. After closing the Terminal tab and clicking the entry point again, the same terminal id returned with scrollback intact — the PTY survived the close; the button revealed the existing resource instead of spawning a new one.
+
+
+ +
+

Diff panes

+
+ QA screenshot: qa-009-diff-pane.jpg +
The Diff entry point opened a committed tab at /threads/thr_5yi7…/diff, reusing the production git-diff panel. This environment isn't a git repository, so the empty state is the correct render; populated diff content was not exercised in this pass (the component is the same one the secondary panel ships today).
+
+
+ +
+

Drag, tear-out, and splits

+
+ QA screenshot: qa-011-terminal-split-right.jpg +
Tearing the Terminal tab out to the right edge created a second group — live PTY on the right with scrollback intact, the left group keeping its four tabs (Gamma still a preview). Note the sidebar: thread rows now carry split-indicator minimaps showing where they're docked. Dragging always commits.
+
+
+ +
+

Persistence and lifecycle

+

Reload: after a full page reload, both groups restored from localStorage schema v2, the URL stayed on the focused terminal pane, and the terminal reconnected by id with scrollback (corroborated by DOM query: the earlier command output re-rendered).

+
+ QA screenshot: qa-013-archive-spares-terminal.jpg +
Archiving the thread (via CLI, out-of-band) closed its thread tab but the layout and Terminal tab survived — the exact High-severity review fix, observed live. The PTY itself was ended by server policy (thread archive closes thread-scoped sessions), and the pane degrades honestly to "Terminal ended". The lingering Diff tab in this screenshot is the one defect found — see below.
+
+
+ +
+

Flag-off parity

+
+ QA screenshot: qa-014-flag-off-parity.jpg +
With threadSplits off the thread page renders exactly as production today: no tab strip, no header entry points (verified by accessibility query: zero "Open diff/terminal pane" buttons). The single-pane single-tab path is also DOM-parity-guarded by test.
+
+
+ +
+

Architecture at review depth

+
    +
  • Resources vs views (BB-25 decision record): a tab is a view; threads and terminals are resources that exist unviewed. Per-kind multiplicity: threads reveal-existing (1 view in v1), terminals exactly-one-view (PTY geometry), data views (tasks/diff) cheap multi.
  • +
  • URL-first descriptors: every pane kind has a canonical route and two-way route↔descriptor conversion; the focused pane's active tab owns the address bar. Bare /terminals/:id URLs are reveal-only (they can't carry the context binding) — verified they cannot seed an invalid tab.
  • +
  • Preview is view-layer only, orthogonal to the resource model; one preview slot per group, enforced at open, normalize, and persisted-schema levels for terminals.
  • +
  • Explicit context binding: terminal descriptors carry their target (thread/environment/host-path) fixed at creation, shown in pane chrome.
  • +
  • Fresh start on storage: schema v2; v1 layouts deserialize to null by design (experiment behind flag; old tab state had a 14-day expiry anyway).
  • +
+
+ +
+

How it was built and verified

+

Manager (Fable) implemented the core model and navigation policy; three parallel workers with non-overlapping file ownership built the renderer (Fable), pane components + entry points (GPT-5.6 Sol high), and sidebar adaptations (Sol medium); a fourth migrated tests (Sol high). A GPT-5.6 Sol review gate returned REQUEST CHANGES with 6 findings: 5 fixed in one round (archive sparing terminals, cap-guarded navigation, URL-terminal guard, invariant enforcement, group-drag restoration), 1 declined deliberately (tab insertion stays after-active so a thread's diff/terminal lands beside it). Every fix carries a regression test.

+
    +
  • Typecheck: clean across the app after integration (verified independently by the manager).
  • +
  • Test suite: 241 files / 1,624 tests passing, including migrated split-layout coverage and new regression tests.
  • +
  • Live QA (this report): 13 workflows exercised in the dev app via browser automation with console-error capture — zero console errors observed in any captured run.
  • +
+
+ +
+

Defects, limitations, and environment notes

+

P2 — Archived thread's diff tab lingers on out-of-band archive

+
    +
  1. Open a thread, its Diff pane, and a terminal (any layout).
  2. +
  3. Archive the thread from outside the app (CLI: bb thread archive <id>).
  4. +
  5. Expected: the thread tab and its diff tab close (matching the in-app archive path, which routes through closePanesForThreadsAtom and does close both). Actual: only the thread tab is pruned; the stale-thread watcher doesn't cover thread-bound diff tabs, so an orphaned Diff tab remains (evidence: the lifecycle screenshot above).
  6. +
+

Suggested fix: extend the per-tab stale watcher to thread-bound descriptor kinds, or have DiffPaneContent render an archived-thread state. Not fixed during QA to keep the finding reproducible.

+

Product-policy observation (not a defect)

+

Server policy ends thread-scoped PTYs when their thread is archived, so the spared terminal tab shows "Terminal ended". If terminals should survive archive, that's a server-side lifecycle decision (relevant to the BB-43 registry, where running terminals are sidebar resources).

+

Not exercised in this pass

+
    +
  • Populated diff rendering (QA env isn't a git repo; component is production code reused).
  • +
  • Compact-viewport fallback, dark/custom themes, center-drop tab merge via pointer drag, and strip-background group drag (all unit-tested; drag decide-tables covered by the suite).
  • +
  • CLI thread-open-into-split (applyThreadOpenToLayout is unit-tested; the full bb panes agent surface is BB-42).
  • +
+

Environment / QA residue

+
    +
  • Dev instance data dir: ~/.bb-dev/bb-worktrees-env_rp87eqfyeb-bb-c3596b6d3569; fixtures QA Alpha/Beta remain, QA Gamma archived, one ended terminal session, qa-diff-fixture.txt in its env worktree. Left in place for reproduction.
  • +
  • threadSplits flag left ON in the dev instance for hands-on evaluation; dev server left running.
  • +
  • One tooling quirk (not product): first automation click from the homepage timed out under Playwright's stability wait; all subsequent targeted clicks behaved.
  • +
+
+ +
+

Decisions requested

+
    +
  • Ship gate: is this slice good enough to merge behind the flag (with the P2 filed as a fast-follow), or should the diff-tab pruning land first?
  • +
  • Terminal-on-archive policy: keep server-side PTY teardown on thread archive, or let terminals survive as unbound resources (matters for the BB-43 registry design)?
  • +
  • Insertion order: confirm the declined review finding — committed opens insert after the active tab (keeps a thread's diff/terminal adjacent) rather than appending to the end.
  • +
+
+ +
+

Try it

+
# dev server is already running on this branch
+scripts/bb-dev-app status        # URLs; app at http://localhost:12325
+# flag is already ON; to toggle:
+sqlite3 ~/.bb-dev/bb-worktrees-env_rp87eqfyeb-bb-c3596b6d3569/bb.db \
+  "UPDATE system_experiments SET thread_splits=1 WHERE id='current';"
+

Things to feel: single-click threads (preview, italics) vs double-click (commit); the Diff/Terminal buttons in the thread header; drag a tab to any edge; reload mid-layout; close a terminal tab and reopen it from the button.

+
+ +
+

Coverage summary

+ + + + + + + + + + + + + + + + + + +
WorkflowStatusEvidence
App boot, fixtures visible, console cleanpassqa-000
Navigation opens preview tab (italic) in focused grouppassqa-001b
Preview replaced in place by next single-clickpassqa-002
Sidebar double-click commits; next click adds new previewpassqa-003/004
Terminal entry point creates PTY, committed tab, canonical URLpassqa-005
Terminal interactive I/O through pane websocketpassqa-006
Close tab → PTY survives → reopen reveals same terminal + scrollbackpassqa-007/008 + URL logs
Diff entry point → committed tab at /diff, empty state correctpassqa-009
Tab tear-out drag → edge split, two groups, PTY intactpassqa-010/011
Sidebar split-indicator minimaps reflect docked tabspassqa-011
Reload restores layout (schema v2) + terminal reconnectpassqa-012 + DOM query
External archive prunes thread tab, layout + terminal tab survivepassqa-013
Archived thread's diff tab closes (out-of-band path)fail — P2qa-013
Flag-off parity (no strip, no buttons)passqa-014
Browser panes, plugin views API, registry sidebar, ⌘K, bb panes CLIN/A — later phases (BB-40/41/42/43)
Populated diff render, compact viewport, themes, center-drop mergenot exercised live; unit-tested where applicable
+

Full evidence archive (14 raw screenshots + review artifacts): thread storage bb39/qa/ and bb39/review-report.md.

+
+ +
+
+
BB-39 dockable-panes experiment · reviewed 2026-07-18 · branch bb/dockable-terminals-and-views-thr_9nsm45dtne (base 6dbeb8a20, uncommitted) · dev app localhost:12325
+
+ + diff --git a/bb39-report/qa-001b-direct-thread.jpg b/bb39-report/qa-001b-direct-thread.jpg new file mode 100644 index 000000000..d596afd6f Binary files /dev/null and b/bb39-report/qa-001b-direct-thread.jpg differ diff --git a/bb39-report/qa-002-beta-replaces-preview.jpg b/bb39-report/qa-002-beta-replaces-preview.jpg new file mode 100644 index 000000000..f57d9b4f2 Binary files /dev/null and b/bb39-report/qa-002-beta-replaces-preview.jpg differ diff --git a/bb39-report/qa-004-gamma-third-preview.jpg b/bb39-report/qa-004-gamma-third-preview.jpg new file mode 100644 index 000000000..256d0097e Binary files /dev/null and b/bb39-report/qa-004-gamma-third-preview.jpg differ diff --git a/bb39-report/qa-005-terminal-pane.jpg b/bb39-report/qa-005-terminal-pane.jpg new file mode 100644 index 000000000..a0df9fd51 Binary files /dev/null and b/bb39-report/qa-005-terminal-pane.jpg differ diff --git a/bb39-report/qa-006-terminal-interactive.jpg b/bb39-report/qa-006-terminal-interactive.jpg new file mode 100644 index 000000000..1c1c48e0c Binary files /dev/null and b/bb39-report/qa-006-terminal-interactive.jpg differ diff --git a/bb39-report/qa-008-terminal-reopened-same-pty.jpg b/bb39-report/qa-008-terminal-reopened-same-pty.jpg new file mode 100644 index 000000000..1c1c48e0c Binary files /dev/null and b/bb39-report/qa-008-terminal-reopened-same-pty.jpg differ diff --git a/bb39-report/qa-009-diff-pane.jpg b/bb39-report/qa-009-diff-pane.jpg new file mode 100644 index 000000000..bcaf55f7c Binary files /dev/null and b/bb39-report/qa-009-diff-pane.jpg differ diff --git a/bb39-report/qa-011-terminal-split-right.jpg b/bb39-report/qa-011-terminal-split-right.jpg new file mode 100644 index 000000000..57d4e4e5f Binary files /dev/null and b/bb39-report/qa-011-terminal-split-right.jpg differ diff --git a/bb39-report/qa-013-archive-spares-terminal.jpg b/bb39-report/qa-013-archive-spares-terminal.jpg new file mode 100644 index 000000000..8334565ed Binary files /dev/null and b/bb39-report/qa-013-archive-spares-terminal.jpg differ diff --git a/bb39-report/qa-014-flag-off-parity.jpg b/bb39-report/qa-014-flag-off-parity.jpg new file mode 100644 index 000000000..9139e5c66 Binary files /dev/null and b/bb39-report/qa-014-flag-off-parity.jpg differ diff --git a/bb39-report/report-draft.html b/bb39-report/report-draft.html new file mode 100644 index 000000000..2c67e1c64 --- /dev/null +++ b/bb39-report/report-draft.html @@ -0,0 +1,269 @@ + + + + + + BB-39 · Dockable Panes Experiment — Product Review + + + +
+
+
Product review · BB-39 / BB-25
+

Dockable panes: terminals, diffs, and threads in one tab-group workspace

+

First working slice of the unified docking model: the split tree's leaves became VS Code-style tab groups, navigation opens preview tabs, and terminals and diffs are first-class draggable panes with thread-header entry points. Built by a four-worker model-routed fan-out, gated by a GPT-5.6 review, and verified live in the dev app for this report.

+
+ Verdict — works end-to-end; 1 P2 found in live QA + Branch bb/dockable-terminals-and-views-thr_9nsm45dtne (uncommitted, base 6dbeb8a20) + Build dev app @ localhost:12325 · threadSplits flag ON + Tests 241 files / 1,624 passing · typecheck clean + Scope experiment behind flag — phases 2+3 of 6 +
+
+ +
+ +
+ +
+

What was built and why

+

BB previously ran two separate pane systems: the recursive split tree (threads and plugin panels only) and the per-thread "secondary panel" tab strip (where terminals, diffs, and files actually lived). BB-25's decision record unifies them: one layout tree whose leaves are tab groups, every surface a tab, resources (threads, PTYs) distinct from views. This experiment ships the first two implementation phases behind the existing experiments.threadSplits flag:

+
+
Tab-group layout engine

Leaves hold ordered tabs with one preview slot; open/close/activate/commit/move ops; localStorage schema v2 (v1 = deliberate fresh start); 8-pane cap, 16-tab sanity bound.

+
Preview tabs

Navigation opens an italic preview that the next single-click replaces in place. Commit by double-click (tab or sidebar row) or by dragging. Stateful views (terminals) never preview.

+
Terminal & diff panes

New descriptors with canonical routes (/terminals/:id, /threads/:id/diff), explicit context binding, xterm mounted straight off the server-relayed session, diff via the production git-diff panel.

+
Thread-header entry points

Diff and Terminal buttons on the thread pane: reveal an open view, else reopen the thread's running terminal, else create one — never a duplicate, never an accidental PTY.

+
+

Deferred by design (later phases): browser panes (BB-40), plugin views API (BB-41), server-side per-project persistence + bb panes agent CLI (BB-42), the registry sidebar and quick switcher (BB-43).

+
+ +
+

The core workflow: click around, commit what matters

+

The defining loop of the design: single-click exploration is free — it never mints permanent tabs — and an explicit gesture locks in what you want to keep.

+
+ QA screenshot: qa-001b-direct-thread.jpg +
Navigation opens a preview. Visiting QA Alpha put it in the tab strip in italics — a preview tab sharing the group with the committed "New thread" view. The thread header keeps its slim chrome; the tab owns the title.
+
+
+ QA screenshot: qa-002-beta-replaces-preview.jpg +
The next click replaces the preview in place. Single-clicking QA Beta in the sidebar swapped the preview slot — still exactly two tabs, no pane spam, URL following along.
+
+
+ QA screenshot: qa-004-gamma-third-preview.jpg +
Commit + preview coexist. Double-clicking QA Beta's sidebar row committed it (upright); QA Gamma then opened as a fresh preview beside it. Three tabs: committed, committed, preview.
+
+
+ +
+

Terminals as first-class panes

+

The load-bearing claim of the whole unification — stateful, server-relayed PTYs living in the layout tree — verified live:

+
+ QA screenshot: qa-005-terminal-pane.jpg +
One click on the header's Terminal button created a PTY bound to the thread, opened it as a committed tab (terminals never preview), and the URL became /terminals/term_cr9va6c5vt. The slim context strip shows the binding: Thread thr_5yi7.
+
+
+ QA screenshot: qa-006-terminal-interactive.jpg +
Fully interactive over the pane's websocket: echo dockable-panes-live-$((6*7))dockable-panes-live-42, executed in the environment's worktree.
+
+
+ QA screenshot: qa-008-terminal-reopened-same-pty.jpg +
The resource outlives its view. After closing the Terminal tab and clicking the entry point again, the same terminal id returned with scrollback intact — the PTY survived the close; the button revealed the existing resource instead of spawning a new one.
+
+
+ +
+

Diff panes

+
+ QA screenshot: qa-009-diff-pane.jpg +
The Diff entry point opened a committed tab at /threads/thr_5yi7…/diff, reusing the production git-diff panel. This environment isn't a git repository, so the empty state is the correct render; populated diff content was not exercised in this pass (the component is the same one the secondary panel ships today).
+
+
+ +
+

Drag, tear-out, and splits

+
+ QA screenshot: qa-011-terminal-split-right.jpg +
Tearing the Terminal tab out to the right edge created a second group — live PTY on the right with scrollback intact, the left group keeping its four tabs (Gamma still a preview). Note the sidebar: thread rows now carry split-indicator minimaps showing where they're docked. Dragging always commits.
+
+
+ +
+

Persistence and lifecycle

+

Reload: after a full page reload, both groups restored from localStorage schema v2, the URL stayed on the focused terminal pane, and the terminal reconnected by id with scrollback (corroborated by DOM query: the earlier command output re-rendered).

+
+ QA screenshot: qa-013-archive-spares-terminal.jpg +
Archiving the thread (via CLI, out-of-band) closed its thread tab but the layout and Terminal tab survived — the exact High-severity review fix, observed live. The PTY itself was ended by server policy (thread archive closes thread-scoped sessions), and the pane degrades honestly to "Terminal ended". The lingering Diff tab in this screenshot is the one defect found — see below.
+
+
+ +
+

Flag-off parity

+
+ QA screenshot: qa-014-flag-off-parity.jpg +
With threadSplits off the thread page renders exactly as production today: no tab strip, no header entry points (verified by accessibility query: zero "Open diff/terminal pane" buttons). The single-pane single-tab path is also DOM-parity-guarded by test.
+
+
+ +
+

Architecture at review depth

+
    +
  • Resources vs views (BB-25 decision record): a tab is a view; threads and terminals are resources that exist unviewed. Per-kind multiplicity: threads reveal-existing (1 view in v1), terminals exactly-one-view (PTY geometry), data views (tasks/diff) cheap multi.
  • +
  • URL-first descriptors: every pane kind has a canonical route and two-way route↔descriptor conversion; the focused pane's active tab owns the address bar. Bare /terminals/:id URLs are reveal-only (they can't carry the context binding) — verified they cannot seed an invalid tab.
  • +
  • Preview is view-layer only, orthogonal to the resource model; one preview slot per group, enforced at open, normalize, and persisted-schema levels for terminals.
  • +
  • Explicit context binding: terminal descriptors carry their target (thread/environment/host-path) fixed at creation, shown in pane chrome.
  • +
  • Fresh start on storage: schema v2; v1 layouts deserialize to null by design (experiment behind flag; old tab state had a 14-day expiry anyway).
  • +
+
+ +
+

How it was built and verified

+

Manager (Fable) implemented the core model and navigation policy; three parallel workers with non-overlapping file ownership built the renderer (Fable), pane components + entry points (GPT-5.6 Sol high), and sidebar adaptations (Sol medium); a fourth migrated tests (Sol high). A GPT-5.6 Sol review gate returned REQUEST CHANGES with 6 findings: 5 fixed in one round (archive sparing terminals, cap-guarded navigation, URL-terminal guard, invariant enforcement, group-drag restoration), 1 declined deliberately (tab insertion stays after-active so a thread's diff/terminal lands beside it). Every fix carries a regression test.

+
    +
  • Typecheck: clean across the app after integration (verified independently by the manager).
  • +
  • Test suite: 241 files / 1,624 tests passing, including migrated split-layout coverage and new regression tests.
  • +
  • Live QA (this report): 13 workflows exercised in the dev app via browser automation with console-error capture — zero console errors observed in any captured run.
  • +
+
+ +
+

Defects, limitations, and environment notes

+

P2 — Archived thread's diff tab lingers on out-of-band archive

+
    +
  1. Open a thread, its Diff pane, and a terminal (any layout).
  2. +
  3. Archive the thread from outside the app (CLI: bb thread archive <id>).
  4. +
  5. Expected: the thread tab and its diff tab close (matching the in-app archive path, which routes through closePanesForThreadsAtom and does close both). Actual: only the thread tab is pruned; the stale-thread watcher doesn't cover thread-bound diff tabs, so an orphaned Diff tab remains (evidence: the lifecycle screenshot above).
  6. +
+

Suggested fix: extend the per-tab stale watcher to thread-bound descriptor kinds, or have DiffPaneContent render an archived-thread state. Not fixed during QA to keep the finding reproducible.

+

Product-policy observation (not a defect)

+

Server policy ends thread-scoped PTYs when their thread is archived, so the spared terminal tab shows "Terminal ended". If terminals should survive archive, that's a server-side lifecycle decision (relevant to the BB-43 registry, where running terminals are sidebar resources).

+

Not exercised in this pass

+
    +
  • Populated diff rendering (QA env isn't a git repo; component is production code reused).
  • +
  • Compact-viewport fallback, dark/custom themes, center-drop tab merge via pointer drag, and strip-background group drag (all unit-tested; drag decide-tables covered by the suite).
  • +
  • CLI thread-open-into-split (applyThreadOpenToLayout is unit-tested; the full bb panes agent surface is BB-42).
  • +
+

Environment / QA residue

+
    +
  • Dev instance data dir: ~/.bb-dev/bb-worktrees-env_rp87eqfyeb-bb-c3596b6d3569; fixtures QA Alpha/Beta remain, QA Gamma archived, one ended terminal session, qa-diff-fixture.txt in its env worktree. Left in place for reproduction.
  • +
  • threadSplits flag left ON in the dev instance for hands-on evaluation; dev server left running.
  • +
  • One tooling quirk (not product): first automation click from the homepage timed out under Playwright's stability wait; all subsequent targeted clicks behaved.
  • +
+
+ +
+

Decisions requested

+
    +
  • Ship gate: is this slice good enough to merge behind the flag (with the P2 filed as a fast-follow), or should the diff-tab pruning land first?
  • +
  • Terminal-on-archive policy: keep server-side PTY teardown on thread archive, or let terminals survive as unbound resources (matters for the BB-43 registry design)?
  • +
  • Insertion order: confirm the declined review finding — committed opens insert after the active tab (keeps a thread's diff/terminal adjacent) rather than appending to the end.
  • +
+
+ +
+

Try it

+
# dev server is already running on this branch
+scripts/bb-dev-app status        # URLs; app at http://localhost:12325
+# flag is already ON; to toggle:
+sqlite3 ~/.bb-dev/bb-worktrees-env_rp87eqfyeb-bb-c3596b6d3569/bb.db \
+  "UPDATE system_experiments SET thread_splits=1 WHERE id='current';"
+

Things to feel: single-click threads (preview, italics) vs double-click (commit); the Diff/Terminal buttons in the thread header; drag a tab to any edge; reload mid-layout; close a terminal tab and reopen it from the button.

+
+ +
+

Coverage summary

+ + + + + + + + + + + + + + + + + + +
WorkflowStatusEvidence
App boot, fixtures visible, console cleanpassqa-000
Navigation opens preview tab (italic) in focused grouppassqa-001b
Preview replaced in place by next single-clickpassqa-002
Sidebar double-click commits; next click adds new previewpassqa-003/004
Terminal entry point creates PTY, committed tab, canonical URLpassqa-005
Terminal interactive I/O through pane websocketpassqa-006
Close tab → PTY survives → reopen reveals same terminal + scrollbackpassqa-007/008 + URL logs
Diff entry point → committed tab at /diff, empty state correctpassqa-009
Tab tear-out drag → edge split, two groups, PTY intactpassqa-010/011
Sidebar split-indicator minimaps reflect docked tabspassqa-011
Reload restores layout (schema v2) + terminal reconnectpassqa-012 + DOM query
External archive prunes thread tab, layout + terminal tab survivepassqa-013
Archived thread's diff tab closes (out-of-band path)fail — P2qa-013
Flag-off parity (no strip, no buttons)passqa-014
Browser panes, plugin views API, registry sidebar, ⌘K, bb panes CLIN/A — later phases (BB-40/41/42/43)
Populated diff render, compact viewport, themes, center-drop mergenot exercised live; unit-tested where applicable
+

Full evidence archive (14 raw screenshots + review artifacts): thread storage bb39/qa/ and bb39/review-report.md.

+
+ +
+
+
BB-39 dockable-panes experiment · reviewed 2026-07-18 · branch bb/dockable-terminals-and-views-thr_9nsm45dtne (base 6dbeb8a20, uncommitted) · dev app localhost:12325
+
+ + diff --git a/bb39-report/tab-bar-mocks.html b/bb39-report/tab-bar-mocks.html new file mode 100644 index 000000000..f12b96ad3 --- /dev/null +++ b/bb39-report/tab-bar-mocks.html @@ -0,0 +1,910 @@ + + + + + +BB · Dockable Workspace — Tab Bar Treatments + + + + +
+

Tab bar treatments — dockable workspace

+

Five treatments of the 48px titlebar tab strip, each rendered light + dark with the same tab set: + active thread, terminal, diff, preview thread, truncated plugin tab. Every variant also shows + hover close-reveal, a drag-insertion indicator, and an 8-tab overflow. All grays are mixed from + --canvas/--ink anchors only.

+
BB-39 · design review · 2026-07-18
+
+ + + + +
+

1Quiet underline

+

Linear-style: no container at all. Active earns a 2px underline and full-strength text; preview is italic with a dotted underline. Most faithful to BB's current airiness.

+
+
+
Light
+
+
Default
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unified…
+
+
+
+
Hover on “Terminal” (close reveals; no layout shift)
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
+
+
+
Dragging “Diff” — insertion point
+
+
+
+
Fix flaky test retries
+
+
Terminalthr_a1
+
QA Beta
+
+
+
+
Overflow (8 tabs → right fade + count chip)
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unif…
+
Retry queue notes
+
Terminalthr_b7
+
Release checklist
+
+
+3
+
+
+
+
+
+
Dark
+
+
Default
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unified…
+
+
+
+
Hover on “Terminal”
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
QA Beta
+
+
+
+
Drag insertion
+
+
+
+
Fix flaky test retries
+
+
Terminalthr_a1
+
QA Beta
+
+
+
+
Overflow
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unif…
+
Retry queue notes
+
Terminalthr_b7
+
Release checklist
+
+
+3
+
+
+
+
+
+

Strengths: quietest option by far — zero chrome at rest, perfect fit for the titlebar axis. Risks: tab boundaries are invisible, so drag targets and the "this is one object" grouping are weak; italic preview carries the whole state burden and reads poorly with truncated labels. Optimizes for calm over manipulability.

+
+ + +
+

2Soft contained

+

Active tab gets a subtle elevated container (rounded-md, hairline border); inactive is borderless text; preview swaps italics for a dashed hairline border — testing whether shape alone can signal transience.

+
+
+
Light
+
+
Default (note: preview = dashed border, upright text)
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unified…
+
+
+
+
Hover on “Diff”
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
+
+
+
Drag insertion
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
+
QA Beta
+
+
+
+
Overflow
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unif…
+
Retry queue notes
+
Terminalthr_b7
+
Release checklist
+
+
+3
+
+
+
+
+
+
Dark
+
+
Default
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unified…
+
+
+
+
Hover on “Diff”
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
+
+
+
Drag insertion
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
+
QA Beta
+
+
+
+
Overflow
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unif…
+
Retry queue notes
+
Terminalthr_b7
+
Release checklist
+
+
+3
+
+
+
+
+
+

Strengths: the active container is a strong, calm affordance and matches BB's existing pill language; the dashed preview border successfully replaces italics (labels stay legible when truncated). Risks: dashed borders can read as "drop target" in a drag-heavy UI — the two vocabularies collide. Optimizes for active-tab clarity with minimal added chrome.

+
+ + +
+

3Browser cell

+

Chrome/Arc-style contiguous cells: hairline separators between inactive tabs, and the active cell merges with the content surface below (no bottom border). Strongest "these are siblings you can reorder" semantics.

+
+
+
Light
+
+
Default (active cell fuses with the pane below)
+
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unified…
+
+
+
+
+
+
+
Hover on “Diff”
+
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
+
+
+
+
+
Drag insertion
+
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
+
QA Beta
+
+
+
+
+
+
Overflow
+
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unif…
+
Retry queue notes
+
Terminalthr_b7
+
Release checklist
+
+
+3
+
+
+
+
+
+
+
+
Dark
+
+
Default
+
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unified…
+
+
+
+
+
+
+
Hover on “Diff”
+
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
+
+
+
+
+
Drag insertion
+
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
+
QA Beta
+
+
+
+
+
+
Overflow
+
+
+
+
+
Fix flaky test retries
+
Terminalthr_a1
+
Diff
+
QA Beta
+
BB-26 · Design the unif…
+
Retry queue notes
+
Terminalthr_b7
+
Release checklist
+
+
+3
+
+
+
+
+
+
+
+

Strengths: unbeatable grouping and drag semantics — cells make reorder targets obvious, and the merged active cell states "you are here" without any accent color. This variant also shows the reserved 28px corner slot (panel toggle). Risks: it's the heaviest chrome of the set — the tinted strip and separators fight BB's airy canvas, and the merge trick requires the strip background to differ from the content background, a real cost across custom palettes. Optimizes for manipulability over quietness.

+
+ + +
+

4Icon-forward

+

Every tab leads with a 14px per-kind glyph; the icon does kind identification, and icon dimming (not italics) marks preview. Terminal and Diff drop their labels entirely when unambiguous — icon-only with a tooltip.

+
+
+
Light
+
+
Default (Terminal & Diff icon-only; badge dropped to the pane context strip)
+
+
+
+
Fix flaky test retries
+
+
+
QA Beta
+
BB-26 · Design the unified…
+
+
+
+
Hover on icon-only Terminal (close replaces nothing — slot reserved)
+
+
+
+
Fix flaky test retries
+
+
QA Beta
+
+
+
+
Drag insertion
+
+
+
+
Fix flaky test retries
+
+
+
QA Beta
+
+
+
+
Overflow (icon-only tabs buy real density)
+
+
+
+
Fix flaky test retries
+
+
+
QA Beta
+
BB-26 · Design the unif…
+
Retry queue notes
+
+
Release checklist
+
+
+3
+
+
+
+
+
+
Dark
+
+
Default
+
+
+
+
Fix flaky test retries
+
+
+
QA Beta
+
BB-26 · Design the unified…
+
+
+
+
Hover on icon-only Terminal
+
+
+
+
Fix flaky test retries
+
+
QA Beta
+
+
+
+
Drag insertion
+
+
+
+
Fix flaky test retries
+
+
+
QA Beta
+
+
+
+
Overflow
+
+
+
+
Fix flaky test retries
+
+
+
QA Beta
+
BB-26 · Design the unif…
+
Retry queue notes
+
+
Release checklist
+
+
+3
+
+
+
+
+
+

Strengths: kind identification becomes instant and pre-attentive; icon-only terminal/diff tabs recover ~100px each and make overflow far less likely. The thr_a1 badge is dropped from the tab here — the pane's context strip is the right home for it; with two terminals open, the tooltip disambiguates. Risks: muted-icon preview is the weakest preview signal of the set (dimmed vs. muted is a two-step gray distinction), and icon-only tabs lose their identity mid-drag. Optimizes for density and scanability.

+
+ + +
+

5Synthesis (recommended)

+

Soft-contained active (from 2) + per-kind icons (from 4) + dotted-underline preview (from 1). Inactive tabs stay bare text+icon so the strip is quiet at rest; the badge moves to the pane context strip.

+
+
+
Light
+
+
Default — preview “QA Beta”: upright text, faint ink, dotted underline
+
+
+
+
Fix flaky test retries
+
Terminal
+
Diff
+
QA Beta
+
BB-26 · Design the unified…
+
+
+
+
+
Hover on “Terminal”
+
+
+
+
Fix flaky test retries
+
Terminal
+
QA Beta
+
+
+
+
Drag insertion
+
+
+
+
Fix flaky test retries
+
Terminal
+
+
QA Beta
+
+
+
+
Overflow
+
+
+
+
Fix flaky test retries
+
Terminal
+
Diff
+
QA Beta
+
BB-26 · Design the unif…
+
Retry queue notes
+
Terminal
+
+
+3
+
+
+
+
+
+
Dark
+
+
Default
+
+
+
+
Fix flaky test retries
+
Terminal
+
Diff
+
QA Beta
+
BB-26 · Design the unified…
+
+
+
+
+
Hover on “Terminal”
+
+
+
+
Fix flaky test retries
+
Terminal
+
QA Beta
+
+
+
+
Drag insertion
+
+
+
+
Fix flaky test retries
+
Terminal
+
+
QA Beta
+
+
+
+
Overflow
+
+
+
+
Fix flaky test retries
+
Terminal
+
Diff
+
QA Beta
+
BB-26 · Design the unif…
+
Retry queue notes
+
Terminal
+
+
+3
+
+
+
+
+
+

Why this synthesis: the elevated container is the one affordance the current bare-text strip most lacks, and it doubles as the drag handle silhouette when a tab tears out. Icons carry kind identification so labels can shorten (or drop, later, for terminal/diff) without ambiguity — but every tab keeps a label at v1 to stay learnable. Preview = faint ink + dotted underline: distinct at a glance, upright and legible when truncated, and it never collides with drag vocabulary the way dashed borders do. Badges (thr_a1) move to the pane context strip — the tab names the surface, the pane names the context. Corner keeps the reserved 28px panel-toggle slot; everything else stays drag region.

+
+ + +
+

ΣComparison

+ + + + + + + + + + + +
VariantKind identificationPreview clarityDensityDrag affordanceQuietness
1 · Quiet underline○○○●○○●●○●○○●●●
2 · Soft contained○○○●●○●●○●●○●●○
3 · Browser cell○○○●○○●○○●●●●○○
4 · Icon-forward●●●●○○●●●●●○●●○
5 · Synthesis ★●●●●●●●●○●●○●●○
+
+ + + diff --git a/dockable-workspace-prototype.html b/dockable-workspace-prototype.html new file mode 100644 index 000000000..931d5e149 --- /dev/null +++ b/dockable-workspace-prototype.html @@ -0,0 +1,1942 @@ + + + + + +BB — Dockable Panes Prototype + + + + +
+
BB — Dockable Panes Prototype
+
/
+
+ +
+ Surface + + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+
+ +
+
Click a live pane to reveal · click a view to preview, double-click to keep · Esc to close
+
+
+ + +
+

Things to try

+
    +
  • The sidebar is a registry of live resources — pulsing dots are running threads/PTYs, hollow dots are idle
  • +
  • Click a thread that is already open — its view is revealed with a flash, never duplicated
  • +
  • Unopened thread: single-click = preview (italic tab, one per group, replaced in place); double-click the row or tab label, or drag the tab, to keep it
  • +
  • Close a Terminal tab — it stays in the registry (PTY keeps running); click its row to reopen the same view. “+” / ⌘K create a new terminal resource
  • +
  • Right-click a task row → open in new tab or split right; or drag a task row into the workspace (five drop zones)
  • +
  • Watch tab titles follow navigation — a Tasks pane retitles to “BB-26 · …” on a task, back restores “Tasks”
  • +
  • Open views via ⌘K Panes or each group's +; drag tabs between groups; edges split; splitters resize
  • +
  • Use the thread header's diff / terminal buttons — they open thread-bound views as tabs in the same group, revealing an existing one instead of duplicating
  • +
  • Flip Desktop / Web — browser panes swap to a web placeholder
  • +
+
+ + + +