diff --git a/apps/app/package.json b/apps/app/package.json index 93a455ff8..f0d1b8b19 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -57,6 +57,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cronstrue": "^3.14.0", + "dockview-react": "^6.6.1", "jotai": "^2.19.0", "jotai-family": "^1.0.1", "mermaid": "^11.15.0", diff --git a/apps/app/src/app.css b/apps/app/src/app.css index 56abe3678..33c5ef604 100644 --- a/apps/app/src/app.css +++ b/apps/app/src/app.css @@ -1,4 +1,5 @@ @import "@fontsource-variable/inter"; +@import "dockview-react/dist/styles/dockview.css"; @import "@/components/ui/theme.css"; @layer base { @@ -171,3 +172,168 @@ display: none; } } + +.bb-right-panel-dock { + --dv-tabs-and-actions-container-height: 3rem; + --dv-tabs-and-actions-container-font-size: inherit; + --dv-group-view-background-color: var(--background); + --dv-tabs-and-actions-container-background-color: var(--background); + --dv-activegroup-visiblepanel-tab-background-color: var(--background); + --dv-activegroup-hiddenpanel-tab-background-color: var(--background); + --dv-inactivegroup-visiblepanel-tab-background-color: var(--background); + --dv-inactivegroup-hiddenpanel-tab-background-color: var(--background); + --dv-activegroup-visiblepanel-tab-color: var(--foreground); + --dv-activegroup-hiddenpanel-tab-color: var(--muted-foreground); + --dv-inactivegroup-visiblepanel-tab-color: var(--foreground); + --dv-inactivegroup-hiddenpanel-tab-color: var(--muted-foreground); + --dv-tab-divider-color: transparent; + --dv-separator-border: var(--border); + --dv-paneview-active-outline-color: var(--ring); + --dv-sash-color: transparent; + --dv-active-sash-color: color-mix( + in oklch, + var(--foreground) 28%, + transparent + ); + --dv-drag-over-background-color: color-mix( + in oklch, + var(--accent) 24%, + transparent + ); + --dv-drag-over-border-color: var(--ring); + --dv-overlay-z-index: 80; + --dv-tab-border-radius: 0; + --dv-border-radius: 0; + --dv-tab-margin: 0; + color: var(--foreground); + font: inherit; +} + +.bb-right-panel-dock, +.bb-right-panel-dock .dv-groupview, +.bb-right-panel-dock .dv-content-container, +.bb-right-panel-dock .dv-split-view-container, +.bb-right-panel-dock .dv-view-container, +.bb-right-panel-dock .dv-view { + background-color: var(--background); +} + +.bb-right-panel-dock .dv-tabs-and-actions-container { + height: 3rem; + min-height: 3rem; + align-items: center; + gap: 0.5rem; + border: 0; + background-color: var(--background); + padding: 0 1rem; +} + +.bb-right-panel-dock .dv-tabs-and-actions-container > .dv-scrollable { + display: flex; + min-width: 0; + align-items: center; +} + +.bb-right-panel-dock + .dv-tabs-and-actions-container.dv-single-tab.dv-full-width-single-tab + .dv-scrollable, +.bb-right-panel-dock + .dv-tabs-and-actions-container.dv-single-tab.dv-full-width-single-tab + .dv-tabs-container, +.bb-right-panel-dock + .dv-tabs-and-actions-container.dv-single-tab.dv-full-width-single-tab + .dv-tabs-container + .dv-tab { + flex-grow: 0; +} + +.bb-right-panel-dock + .dv-tabs-and-actions-container.dv-single-tab.dv-full-width-single-tab + .dv-void-container { + flex-grow: 1; +} + +.bb-right-panel-dock .dv-groupview { + min-width: 0; + min-height: 0; +} + +.bb-right-panel-dock .dv-tabs-container { + height: auto; + min-height: 1.75rem; + align-items: center; + gap: 0.25rem; + overflow-x: auto; + overflow-y: hidden; + padding-inline: 0; + scrollbar-width: none; +} + +.bb-right-panel-dock .dv-tabs-container::-webkit-scrollbar { + display: none; +} + +.bb-right-panel-dock + .dv-tabs-container.dv-horizontal + .dv-tab:not(:first-child)::before { + display: none; +} + +.bb-right-panel-dock .dv-tab { + display: flex; + height: 1.75rem; + align-items: center; + margin: 0; + padding: 0; + background: transparent; + color: inherit; +} + +.bb-right-panel-dock .dv-tab:not(.dv-active-tab) { + opacity: 1; +} + +.bb-right-panel-dock .dv-tab-content { + display: flex; + min-width: 0; + align-items: center; +} + +.bb-right-panel-dock .dv-right-actions-container { + align-items: center; +} + +.bb-right-panel-dock .dv-void-container { + min-width: 0; +} + +.bb-right-panel-dock-tab { + display: flex; + min-width: 0; + height: 1.75rem; + align-items: center; + gap: 0.25rem; + border-radius: var(--radius-md); + padding-inline: 0.5rem 0.25rem; + transition: + color 150ms, + background-color 150ms; +} + +.bb-right-panel-dock-pill { + display: inline-flex; + min-width: 0; +} + +.bb-right-panel-dock-actions { + margin-left: 0.25rem; +} + +.bb-right-panel-dock-macos .dv-tabs-and-actions-container { + app-region: drag; + -webkit-app-region: drag; +} + +.bb-right-panel-dock-reserve-left .dv-tabs-and-actions-container { + padding-left: 5rem; +} diff --git a/apps/app/src/components/secondary-panel/RightPanelDockLayout.tsx b/apps/app/src/components/secondary-panel/RightPanelDockLayout.tsx new file mode 100644 index 000000000..119daab6e --- /dev/null +++ b/apps/app/src/components/secondary-panel/RightPanelDockLayout.tsx @@ -0,0 +1,734 @@ +import { + createContext, + type ReactNode, + useContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + DockviewReact, + type DockviewApi, + type IDockviewHeaderActionsProps, + type DockviewIDisposable, + type DockviewReadyEvent, + type IDockviewPanel, + type IDockviewPanelHeaderProps, + type IDockviewPanelProps, + type SerializedDockview, +} from "dockview-react"; +import { Button } from "@/components/ui/button.js"; +import { Icon } from "@/components/ui/icon.js"; +import { EmptyStatePanel } from "@/components/ui/empty-state.js"; +import { + COARSE_POINTER_COMPACT_ICON_BUTTON_CLASS, + COARSE_POINTER_COMPACT_ICON_SIZE_CLASS, +} from "@/components/ui/coarse-pointer-sizing.js"; +import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@/components/ui/chromeStyleTokens"; +import { TabPill } from "@/components/ui/tab-pill"; +import { MACOS_WINDOW_NO_DRAG_CLASS } from "@/lib/bb-desktop"; +import { cn } from "@/lib/utils"; +import type { + FixedPanelTab, + SecondaryFixedPanelTab, +} from "@/lib/fixed-panel-tabs-state"; +import type { ThreadSecondaryPanel as ThreadSecondaryPanelTab } from "@/lib/thread-secondary-panel"; +import type { SecondaryPanelFileTab } from "./secondaryPanelFileTab"; + +const RIGHT_PANEL_DOCK_COMPONENT = "right-panel-tab"; +const RIGHT_PANEL_DOCK_STORAGE_PREFIX = "bb.thread.rightPanelDockLayout"; +const RIGHT_PANEL_DOCK_MAX_GROUPS = 4; +const RIGHT_PANEL_DOCK_PERSIST_DELAY_MS = 200; +const RIGHT_PANEL_DOCK_SCROLL_SLOT_CLASS = + "min-h-0 flex-1 overflow-x-auto overflow-y-auto"; + +interface RightPanelDockLayoutProps { + activeTab: SecondaryFixedPanelTab | null; + browserDeck?: ReactNode; + fileTabContent?: ReactNode; + fileTabs?: readonly SecondaryPanelFileTab[]; + gitDiffContent: ReactNode; + gitDiffToolbar?: ReactNode; + headerActions: ReactNode; + isBrowserTabActive: boolean; + metadataContent: ReactNode; + onPanelChange: (panel: ThreadSecondaryPanelTab) => void; + renderTabContent?: (tab: FixedPanelTab) => ReactNode; + reserveLeftForDesktopTrafficLights: boolean; + tabs: readonly FixedPanelTab[]; + threadId?: string | null; + usesDesktopChrome: boolean; +} + +interface RightPanelDockTabParams { + closable: boolean; + tabId: string; +} + +interface SyncDockviewPanelsArgs { + activeTabId: string | null; + api: DockviewApi; + tabs: readonly DockPanelModel[]; +} + +interface DockPanelModel { + closable: boolean; + id: string; + title: string; +} + +interface RightPanelDockRenderContextValue { + activeTabId: string | null; + activateDockTab: (tabId: string) => void; + browserDeck?: ReactNode; + fileTabContent?: ReactNode; + gitDiffContent: ReactNode; + gitDiffToolbar?: ReactNode; + headerActions: ReactNode; + isBrowserTabActive: boolean; + metadataContent: ReactNode; + renderTabContent?: (tab: FixedPanelTab) => ReactNode; + tabsById: ReadonlyMap; + fileTabsById: ReadonlyMap; + usesDesktopChrome: boolean; +} + +const RightPanelDockRenderContext = + createContext(null); + +const RIGHT_PANEL_DOCK_COMPONENTS = { + [RIGHT_PANEL_DOCK_COMPONENT]: RightPanelDockPanel, +}; + +function useRightPanelDockRenderContext(): RightPanelDockRenderContextValue { + const context = useContext(RightPanelDockRenderContext); + if (context === null) { + throw new Error("RightPanelDockLayout context is missing."); + } + return context; +} + +const RIGHT_PANEL_DOCK_HEADER_ACTIONS = RightPanelDockHeaderActions; + +function getDockStorageKey(threadId: string | null | undefined): string | null { + return threadId ? `${RIGHT_PANEL_DOCK_STORAGE_PREFIX}.${threadId}` : null; +} + +function readStoredDockviewLayout( + threadId: string | null | undefined, +): SerializedDockview | null { + const storageKey = getDockStorageKey(threadId); + if (storageKey === null) { + return null; + } + + const storedValue = window.localStorage.getItem(storageKey); + if (storedValue === null) { + return null; + } + + try { + const parsedValue: unknown = JSON.parse(storedValue); + if ( + typeof parsedValue !== "object" || + parsedValue === null || + !("grid" in parsedValue) || + !("panels" in parsedValue) + ) { + return null; + } + return parsedValue as SerializedDockview; + } catch { + return null; + } +} + +function writeStoredDockviewLayout({ + layout, + threadId, +}: { + layout: SerializedDockview; + threadId: string | null | undefined; +}): void { + const storageKey = getDockStorageKey(threadId); + if (storageKey === null) { + return; + } + window.localStorage.setItem(storageKey, JSON.stringify(layout)); +} + +function isDockClosableTab(tab: FixedPanelTab): boolean { + switch (tab.kind) { + case "thread-info": + case "git-diff": + return false; + case "workspace-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + case "browser": + case "new-tab": + case "terminal": + return true; + } +} + +function findFileTab( + fileTabs: readonly SecondaryPanelFileTab[] | undefined, + tabId: string, +): SecondaryPanelFileTab | null { + return fileTabs?.find((tab) => tab.id === tabId) ?? null; +} + +function getFilenameFromPath(path: string): string { + return path.split("/").at(-1) ?? path; +} + +function getDockTabTitle({ + fileTabs, + tab, +}: { + fileTabs: readonly SecondaryPanelFileTab[] | undefined; + tab: FixedPanelTab; +}): string { + const fileTab = findFileTab(fileTabs, tab.id); + if (fileTab) { + return fileTab.filename; + } + + switch (tab.kind) { + case "thread-info": + return "Info"; + case "git-diff": + return "Diff"; + case "workspace-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + return getFilenameFromPath(tab.path); + case "browser": + return tab.title ?? "Browser"; + case "new-tab": + return "New tab"; + case "terminal": + return "Terminal"; + } +} + +function buildDockPanelModels({ + fileTabs, + tabs, +}: { + fileTabs: readonly SecondaryPanelFileTab[] | undefined; + tabs: readonly FixedPanelTab[]; +}): readonly DockPanelModel[] { + return tabs.map((tab) => ({ + closable: isDockClosableTab(tab), + id: tab.id, + title: getDockTabTitle({ fileTabs, tab }), + })); +} + +function updateDockPanel(panel: IDockviewPanel, model: DockPanelModel): void { + if (panel.title !== model.title) { + panel.setTitle(model.title); + } + panel.update({ + params: { + closable: model.closable, + tabId: model.id, + } satisfies RightPanelDockTabParams, + }); +} + +function syncDockviewPanels({ + activeTabId, + api, + tabs, +}: SyncDockviewPanelsArgs): void { + const wantedIds = new Set(tabs.map((tab) => tab.id)); + for (const panel of api.panels) { + if (!wantedIds.has(panel.id)) { + api.removePanel(panel); + } + } + + for (const tab of tabs) { + const existingPanel = api.getPanel(tab.id); + if (existingPanel) { + updateDockPanel(existingPanel, tab); + continue; + } + + api.addPanel({ + id: tab.id, + component: RIGHT_PANEL_DOCK_COMPONENT, + inactive: tab.id !== activeTabId, + params: { + closable: tab.closable, + tabId: tab.id, + }, + title: tab.title, + }); + } + + if (activeTabId) { + const activePanel = api.getPanel(activeTabId); + if (activePanel) { + activePanel.group.model.openPanel(activePanel); + } + } +} + +function RightPanelDockTab({ + api, + params, +}: IDockviewPanelHeaderProps) { + const { activateDockTab, fileTabsById, tabsById, usesDesktopChrome } = + useRightPanelDockRenderContext(); + const tab = tabsById.get(params.tabId); + const fileTab = fileTabsById.get(params.tabId) ?? null; + const isActive = api.group.activePanel?.id === params.tabId; + const handleClose = useCallback(() => { + api.close(); + }, [api]); + + if (tab?.kind === "thread-info" || tab?.kind === "git-diff") { + return ( +
+ +
+ ); + } + + if (fileTab) { + return ( +
+ activateDockTab(params.tabId)} + labelMaxWidthClass="max-w-[160px]" + closeAction={ + fileTab.isPinned + ? null + : { + onClose: handleClose, + closeLabel: `Close ${fileTab.filename}`, + closeTooltip: "Close tab", + } + } + /> +
+ ); + } + + return ( +
+ {api.title ?? "Tab"} + {params.closable ? ( + + ) : null} +
+ ); +} + +function RightPanelDockHeaderActions({ + containerApi, + group, +}: IDockviewHeaderActionsProps) { + const { headerActions, usesDesktopChrome } = useRightPanelDockRenderContext(); + if (containerApi.groups[0]?.id !== group.id) { + return null; + } + return ( +
+ {headerActions} +
+ ); +} + +function RightPanelDockPanel({ + params, +}: IDockviewPanelProps) { + const { + activeTabId, + activateDockTab, + browserDeck, + fileTabContent, + gitDiffContent, + gitDiffToolbar, + isBrowserTabActive, + metadataContent, + renderTabContent, + tabsById, + } = useRightPanelDockRenderContext(); + const tab = tabsById.get(params.tabId); + if (!tab) { + return null; + } + + switch (tab.kind) { + case "thread-info": + return {metadataContent}; + case "git-diff": + return ( +
+ {gitDiffToolbar} + {gitDiffContent} +
+ ); + case "browser": + return activeTabId === tab.id && isBrowserTabActive ? ( +
{browserDeck}
+ ) : ( + + ); + case "workspace-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + case "new-tab": + return ( +
+ {renderTabContent?.(tab) ?? + (activeTabId === tab.id ? ( + fileTabContent + ) : ( + + No preview content. + + ))} +
+ ); + case "terminal": + return activeTabId === tab.id ? ( +
+ {fileTabContent ?? ( + + No preview content. + + )} +
+ ) : ( + + ); + } +} + +export function RightPanelDockLayout({ + activeTab, + browserDeck, + fileTabContent, + fileTabs, + gitDiffContent, + gitDiffToolbar, + headerActions, + isBrowserTabActive, + metadataContent, + onPanelChange, + renderTabContent, + reserveLeftForDesktopTrafficLights, + tabs, + threadId, + usesDesktopChrome, +}: RightPanelDockLayoutProps) { + const [api, setApi] = useState(null); + const isSyncingRef = useRef(false); + const disposablesRef = useRef([]); + const persistTimeoutRef = useRef(null); + const dockPanelModels = useMemo( + () => buildDockPanelModels({ fileTabs, tabs }), + [fileTabs, tabs], + ); + const tabsById = useMemo( + () => new Map(tabs.map((tab) => [tab.id, tab])), + [tabs], + ); + const fileTabsById = useMemo( + () => new Map((fileTabs ?? []).map((tab) => [tab.id, tab])), + [fileTabs], + ); + const activeTabId = activeTab?.id ?? null; + + const activateDockTab = useCallback( + (tabId: string) => { + const tab = tabsById.get(tabId); + if (!tab) { + return; + } + switch (tab.kind) { + case "thread-info": + onPanelChange("thread-info"); + return; + case "git-diff": + onPanelChange("git-diff"); + return; + case "workspace-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + case "browser": + case "new-tab": + case "terminal": + fileTabsById.get(tab.id)?.onSelect(); + return; + } + }, + [fileTabsById, onPanelChange, tabsById], + ); + const activateDockTabRef = useRef(activateDockTab); + useEffect(() => { + activateDockTabRef.current = activateDockTab; + }, [activateDockTab]); + + const closeDockTab = useCallback( + (tabId: string) => { + const tab = tabsById.get(tabId); + if (!tab || !isDockClosableTab(tab)) { + return; + } + fileTabsById.get(tab.id)?.onClose(); + }, + [fileTabsById, tabsById], + ); + const closeDockTabRef = useRef(closeDockTab); + useEffect(() => { + closeDockTabRef.current = closeDockTab; + }, [closeDockTab]); + + const renderContextValue = useMemo( + () => ({ + activeTabId, + activateDockTab, + browserDeck, + fileTabsById, + fileTabContent, + gitDiffContent, + gitDiffToolbar, + headerActions, + isBrowserTabActive, + metadataContent, + renderTabContent, + tabsById, + usesDesktopChrome, + }), + [ + activateDockTab, + activeTabId, + browserDeck, + fileTabsById, + fileTabContent, + gitDiffContent, + gitDiffToolbar, + headerActions, + isBrowserTabActive, + metadataContent, + renderTabContent, + tabsById, + usesDesktopChrome, + ], + ); + + const handleReady = useCallback( + (event: DockviewReadyEvent) => { + const dockApi = event.api; + for (const disposable of disposablesRef.current) { + disposable.dispose(); + } + disposablesRef.current = []; + setApi(dockApi); + + const storedLayout = readStoredDockviewLayout(threadId); + if (storedLayout) { + try { + dockApi.fromJSON(storedLayout, { reuseExistingPanels: true }); + } catch { + // Fall back to rebuilding from the app-owned tab list below. + } + } + + isSyncingRef.current = true; + syncDockviewPanels({ + activeTabId, + api: dockApi, + tabs: dockPanelModels, + }); + isSyncingRef.current = false; + + disposablesRef.current = [ + dockApi.onDidActivePanelChange((panel) => { + if (!panel || isSyncingRef.current) { + return; + } + activateDockTabRef.current(panel.id); + }), + dockApi.onDidRemovePanel((panel) => { + if (isSyncingRef.current) { + return; + } + closeDockTabRef.current(panel.id); + }), + dockApi.onDidLayoutChange(() => { + if (isSyncingRef.current) { + return; + } + if (persistTimeoutRef.current !== null) { + window.clearTimeout(persistTimeoutRef.current); + } + persistTimeoutRef.current = window.setTimeout(() => { + writeStoredDockviewLayout({ + layout: dockApi.toJSON(), + threadId, + }); + persistTimeoutRef.current = null; + }, RIGHT_PANEL_DOCK_PERSIST_DELAY_MS); + }), + dockApi.onWillShowOverlay((event) => { + if ( + event.position !== "center" && + event.api.groups.length >= RIGHT_PANEL_DOCK_MAX_GROUPS + ) { + event.preventDefault(); + } + }), + ]; + }, + [activeTabId, dockPanelModels, threadId], + ); + + useEffect(() => { + if (!api) { + return; + } + isSyncingRef.current = true; + syncDockviewPanels({ + activeTabId, + api, + tabs: dockPanelModels, + }); + isSyncingRef.current = false; + }, [activeTabId, api, dockPanelModels]); + + useEffect(() => { + return () => { + for (const disposable of disposablesRef.current) { + disposable.dispose(); + } + disposablesRef.current = []; + if (persistTimeoutRef.current !== null) { + window.clearTimeout(persistTimeoutRef.current); + } + }; + }, []); + + return ( + +
+ { + if ( + event.position !== "center" && + event.api.groups.length >= RIGHT_PANEL_DOCK_MAX_GROUPS + ) { + event.preventDefault(); + } + }} + /> +
+
+ ); +} + +function ThreadInfoDockContent({ children }: { children: ReactNode }) { + return
{children}
; +} + +function InactiveDockTab({ + onSelect, + tabId, +}: { + onSelect: (tabId: string) => void; + tabId: string; +}) { + return ( +
+
+ ); +} diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx index 6973fe62a..75ef36f5b 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx @@ -60,7 +60,13 @@ import { shouldUseMacosDesktopChrome, } from "@/lib/bb-desktop"; import { IframeDragGuardOverlay } from "@/lib/iframe-drag-guard"; -import type { SecondaryFixedPanelTab } from "@/lib/fixed-panel-tabs-state"; +import { + createGitDiffFixedPanelTab, + createThreadInfoFixedPanelTab, + type FixedPanelTab, + type SecondaryFixedPanelTab, +} from "@/lib/fixed-panel-tabs-state"; +import { RightPanelDockLayout } from "./RightPanelDockLayout"; export type { GitDiffDisplayMode, GitDiffSelectionOption, @@ -114,6 +120,9 @@ export interface ThreadSecondaryPanelProps { onCollapse: () => void; onClose: () => void; onOpenNewTab: () => void; + renderTabContent?: (tab: FixedPanelTab) => ReactNode; + tabs?: readonly FixedPanelTab[]; + threadId?: string | null; workspaceRootPath?: string | null; onOpenFileInEditor?: (path: string) => void; onOpenFilePreview?: (path: string) => void; @@ -189,6 +198,9 @@ export function ThreadSecondaryPanel({ onCollapse, onClose, onOpenNewTab, + renderTabContent, + tabs, + threadId, workspaceRootPath, onOpenFileInEditor, onOpenFilePreview, @@ -240,6 +252,26 @@ export function ThreadSecondaryPanel({ resolveActiveFixedPanel({ activeTab, canUseGitUi }) ?? "thread-info"; const isDiffPanelActive = activeFixedPanel === "git-diff"; const shouldShowGitDiffTab = canUseGitUi && showGitDiffTab !== false; + const shouldUseDockLayout = !renderAsDrawer && tabs !== undefined; + const dockTabs = useMemo(() => { + if (tabs === undefined) { + return undefined; + } + const contentTabs = tabs.filter( + (tab) => tab.kind !== "thread-info" && tab.kind !== "git-diff", + ); + return shouldShowGitDiffTab + ? [ + createThreadInfoFixedPanelTab(), + createGitDiffFixedPanelTab(), + ...contentTabs, + ] + : [createThreadInfoFixedPanelTab(), ...contentTabs]; + }, [shouldShowGitDiffTab, tabs]); + const dockActiveTab = + activeTab?.kind === "git-diff" && !shouldShowGitDiffTab + ? createThreadInfoFixedPanelTab() + : activeTab; const shouldRenderFileTabContent = isOpen; const { gitDiffTarget, @@ -317,6 +349,74 @@ export function ThreadSecondaryPanel({ } onPanelFocus(); }; + const gitDiffToolbar = ( + + ); + const gitDiffContent = ( + + ); + const dockHeaderActions = ( + <> + + {conversationCollapseControl ? ( + + ) : null} + + + ); const asideMarkup = ( diff --git a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx index 7810c8256..2aeb15972 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx @@ -138,11 +138,14 @@ const areThreadSecondaryPanelPropsEqual: ThreadSecondaryPanelPropsEqual = ( previous.isBrowserTabActive === next.isBrowserTabActive && previous.isOpen === next.isOpen && previous.showGitDiffTab === next.showGitDiffTab && + previous.tabs === next.tabs && + previous.threadId === next.threadId && previous.onPanelFocus === next.onPanelFocus && previous.onPanelChange === next.onPanelChange && previous.onCollapse === next.onCollapse && previous.onClose === next.onClose && previous.onOpenNewTab === next.onOpenNewTab && + previous.renderTabContent === next.renderTabContent && previous.onFileTabReorder === next.onFileTabReorder && previous.onOpenFileInEditor === next.onOpenFileInEditor && previous.onOpenFilePreview === next.onOpenFilePreview; diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 0c3e8de2b..e94206ca4 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -156,7 +156,10 @@ import { useTouchFixedPanelTabsState, useUpdateFixedPanelTabsState, } from "@/lib/fixed-panel-tabs"; -import { createNewTabFixedPanelTab } from "@/lib/fixed-panel-tabs-state"; +import { + createNewTabFixedPanelTab, + type FixedPanelTab, +} from "@/lib/fixed-panel-tabs-state"; import { buildParentSelectorOptions, isRootThread, @@ -633,10 +636,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { [terminalSessions], ); const terminalsById = useMemo( - () => - new Map( - terminalSessions.map((session) => [session.id, session]), - ), + () => new Map(terminalSessions.map((session) => [session.id, session])), [terminalSessions], ); const syncedOrderedSecondaryFileTabs = useMemo( @@ -1616,11 +1616,10 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { thread={thread} /> ); - const activeTerminalId = - findActiveTerminalIdInSecondaryFileTabs({ - activeTabId: activeFixedSecondaryTabId, - tabs: syncedOrderedSecondaryFileTabs, - }); + const activeTerminalId = findActiveTerminalIdInSecondaryFileTabs({ + activeTabId: activeFixedSecondaryTabId, + tabs: syncedOrderedSecondaryFileTabs, + }); const fileTabContent = activeTerminalId ? ( ) : undefined; + const renderDockTabContent = useCallback( + (tab: FixedPanelTab) => { + switch (tab.kind) { + case "new-tab": + return ( + + ); + case "workspace-file-preview": { + const copyPath = resolveAbsoluteFilePath({ + path: tab.path, + rootPath: workspacePreviewRootPath, + }); + const baseDir = + copyPath === null + ? undefined + : getAbsoluteDirname({ path: copyPath }); + return ( + + ); + } + case "host-file-preview": { + const baseDir = getAbsoluteDirname({ path: tab.path }); + const rootPath = resolveHostFilePreviewLinkRootPath({ + baseDir, + threadStorageRootPath, + workspaceRootPath: workspacePreviewRootPath, + }); + const onOpenHostTabInEditor: OpenInEditorHandler | undefined = + threadEnvironmentIsLocal && canOpenPreferredFileTarget + ? (path) => { + void openPathInPreferredFileTarget({ + lineNumber: getFilePreviewLineRangeStart({ + lineRange: tab.lineRange, + }), + path, + }); + } + : undefined; + return ( + + ); + } + case "thread-storage-file-preview": { + const copyPath = resolveAbsoluteFilePath({ + path: tab.path, + rootPath: threadStorageRootPath, + }); + const baseDir = + copyPath === null + ? undefined + : getAbsoluteDirname({ path: copyPath }); + return ( + + ); + } + case "thread-info": + case "git-diff": + case "browser": + case "terminal": + return null; + } + }, + [ + canCreateTerminal, + canOpenPreferredFileTarget, + handleOpenBrowser, + handleOpenFileInEditor, + handleOpenStorageFileInEditor, + handleOpenTimelineLink, + handleOpenTimelineLocalFileLink, + handleStartTerminal, + newTabFocusRequest, + openPathInPreferredFileTarget, + projectId, + selectFileSearchResult, + thread.environmentId, + thread.id, + threadEnvironmentIsLocal, + threadStorageRootPath, + workspacePreviewRootPath, + ], + ); // Browser tabs are not rendered through the single `fileTabContent` slot: // each one keeps a live native view that must persist across tab switches, so // the deck stays mounted independently of which tab is active. @@ -1742,7 +1873,10 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { onOpenFilePreview: handleOpenFilePreview, onPanelFocus: handleSecondaryPanelFocus, onPanelChange: handleSecondaryPanelChange, + renderTabContent: renderDockTabContent, showGitDiffTab: canUseGitUi, + tabs: fixedPanelTabsState.secondary.tabs, + threadId: thread.id, }} timeline={{ activeThinking, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa4d145fc..0b35d1df7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,9 @@ importers: cronstrue: specifier: ^3.14.0 version: 3.14.0 + dockview-react: + specifier: ^6.6.1 + version: 6.6.1(react@19.2.4) jotai: specifier: ^2.19.0 version: 2.19.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.13)(react@19.2.4) @@ -5912,6 +5915,17 @@ packages: os: [darwin] hasBin: true + dockview-core@6.6.1: + resolution: {integrity: sha512-WuNoO2wl3rGI8MaTuvmfgViek4WafHLD9Kf//Hw69g8TgAHSpAWr6RW15kU6U9vwtQxIi/YBxQNcIA5RzQ46Fw==} + + dockview-react@6.6.1: + resolution: {integrity: sha512-zF/CQzAxjA2mlSTnDt2rbBe49uG33mvg1DPitNCPrIhhDulyiiBG1A1pRyjVx4MJbVh1xx5gAYbNRwu+mCR7CQ==} + + dockview@6.6.1: + resolution: {integrity: sha512-vBnxWVk0f665z1Zyx598l/BMUjHD3SK5V4XorxpYGjaSREM8lLc8O6ylgKTtw+ZnCOMYOhcwov9x0ubfFazvMw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -14029,6 +14043,19 @@ snapshots: verror: 1.10.1 optional: true + dockview-core@6.6.1: {} + + dockview-react@6.6.1(react@19.2.4): + dependencies: + dockview: 6.6.1(react@19.2.4) + transitivePeerDependencies: + - react + + dockview@6.6.1(react@19.2.4): + dependencies: + dockview-core: 6.6.1 + react: 19.2.4 + dom-accessibility-api@0.5.16: {} dompurify@3.4.9: