diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index a6f7afb2..43ccce4c 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -36,10 +36,12 @@ import { findGroup, primaryGroup, allGroups, + allPanelIds, findGroupInDirection, updateSizes, findGroupContainingPanel, subsetInsertIndex, + mergeAllGroups, type DropZone, } from '../utils/panelLayout'; import { Download, Upload, GitMerge, GitPullRequestArrow, Terminal, ChevronDown, ChevronUp, RefreshCw, Archive, ArchiveRestore, GitCommitHorizontal, TerminalSquare, Undo2, X } from 'lucide-react'; @@ -263,10 +265,10 @@ export const SessionView = memo(() => { const pinned = loadedPanels.find(p => p.type === 'terminal'); const livePanels = pinned ? loadedPanels.filter(p => p.id !== pinned.id) : loadedPanels; - // Sort for initial layout creation (diff first, explorer second, then position) + // Sort for initial layout creation (explorer first, diff second, then position) const typeOrder = (type: string) => { - if (type === 'diff') return 0; - if (type === 'explorer') return 1; + if (type === 'explorer') return 0; + if (type === 'diff') return 1; return 2; }; const sortedLive = [...livePanels].sort((a, b) => { @@ -415,11 +417,11 @@ export const SessionView = memo(() => { [sessionPanels, defaultTerminalPanel] ); - // Sort tab bar panels same as PanelTabBar: diff first, explorer second, then by position + // Sort tab bar panels same as PanelTabBar: explorer first, diff second, then by position const sortedSessionPanels = useMemo(() => { const typeOrder = (type: string) => { - if (type === 'diff') return 0; - if (type === 'explorer') return 1; + if (type === 'explorer') return 0; + if (type === 'diff') return 1; return 2; }; return [...tabBarPanels].sort((a, b) => { @@ -464,13 +466,18 @@ export const SessionView = memo(() => { return primaryGroupNode.panelIds.map(id => panelMap.get(id)).filter((p): p is ToolPanel => !!p); }, [primaryGroupNode, tabBarPanels]); const isSplitLayout = sessionLayout?.root.type === 'split'; - /** What the top bar shows: everything when unsplit; only the primary - group's permanent tabs once split (working tabs live in group strips). */ + /** What the top bar shows: everything when unsplit; once split, the + permanent tool tabs hoisted from EVERY group (in reading order), so the + defaults stay pinned to the top bar no matter which group owns them. + Clicking one routes to its owning group via handlePanelSelect. */ const topBarPanels = useMemo(() => { if (!primaryGroupPanels) return undefined; - if (!isSplitLayout) return primaryGroupPanels; - return primaryGroupPanels.filter(p => p.metadata?.permanent === true); - }, [primaryGroupPanels, isSplitLayout]); + if (!isSplitLayout || !sessionLayout) return primaryGroupPanels; + const panelMap = new Map(tabBarPanels.map(p => [p.id, p])); + return allPanelIds(sessionLayout.root) + .map(id => panelMap.get(id)) + .filter((p): p is ToolPanel => !!p && p.metadata?.permanent === true); + }, [primaryGroupPanels, isSplitLayout, sessionLayout, tabBarPanels]); const getPanelTabPresentation = useCallback((panel) => { if (panel.type !== 'diff') return undefined; @@ -1101,17 +1108,33 @@ export const SessionView = memo(() => { setDropZones(new Map()); }, [activeSession, applyLayout, preferWorkingActive]); - // Stable identity for the primary strip's drop handler so memo(PanelTabBar) - // holds. When split, the top bar shows only the permanent subset, so its - // drop indexes must translate to the group's full panel order. + // Top-bar drops are the un-split gesture: merge every group back into the + // primary group and place the dropped tab at the indicated position. When + // the pane isn't split, the merge is the identity and this is a plain + // reorder. The top bar shows only the permanent subset while split, so its + // drop indexes translate to the group's full panel order first. const primaryGroupId = primaryGroupNode?.id; const handlePrimaryStripDrop = useCallback((panelId: string, insertIndex: number) => { - if (!primaryGroupId || !primaryGroupNode) return; + if (!primaryGroupId || !primaryGroupNode || !activeSession) return; + const sid = activeSession.id; + const currentLayout = usePanelStore.getState().layouts[sid]; + if (!currentLayout) return; + const merged = mergeAllGroups(currentLayout.root); + // The top bar shows the hoisted permanent subset while split; translate + // its drop index against the merged (post-un-split) panel order. const fullIndex = isSplitLayout && topBarPanels - ? subsetInsertIndex(primaryGroupNode.panelIds, topBarPanels.map(p => p.id), insertIndex) + ? subsetInsertIndex(merged.panelIds, topBarPanels.map(p => p.id), insertIndex) : insertIndex; - handleStripDrop(primaryGroupId, panelId, fullIndex); - }, [primaryGroupId, primaryGroupNode, isSplitLayout, topBarPanels, handleStripDrop]); + const newRoot = movePanelInLayout(merged, panelId, { groupId: merged.id, index: fullIndex }); + applyLayout(sid, { + ...currentLayout, + root: newRoot, + focusedGroupId: merged.id, + zoomedGroupId: null, + }); + setDraggedPanelId(null); + setDropZones(new Map()); + }, [primaryGroupId, primaryGroupNode, activeSession, isSplitLayout, topBarPanels, applyLayout]); // --- Editor stage element (shared by both layouts) --- const editorStageElement = useMemo(() => { @@ -1707,7 +1730,7 @@ export const SessionView = memo(() => { onToggleDetailPanel={handleToggleDetailPanel} detailPanelVisible={detailVisible} primaryGroupPanels={topBarPanels} - primaryGroupActivePanelId={primaryGroupNode?.activePanelId} + primaryGroupActivePanelId={isSplitLayout ? (currentActivePanel?.id ?? null) : primaryGroupNode?.activePanelId} primaryGroupFocused={!primaryGroupNode || primaryGroupNode.id === focusedGroupId} tabsInGroups={isSplitLayout} onDragStart={handleDragStart} diff --git a/frontend/src/components/panels/PanelGroupView.tsx b/frontend/src/components/panels/PanelGroupView.tsx index 944a1594..e39e7aa1 100644 --- a/frontend/src/components/panels/PanelGroupView.tsx +++ b/frontend/src/components/panels/PanelGroupView.tsx @@ -157,13 +157,13 @@ export const PanelGroupView: React.FC = React.memo(({ return group.panelIds.map(id => panelMap.get(id)).filter((p): p is ToolPanel => !!p); }, [group.panelIds, groupPanels]); - // The primary group's permanent tabs (Diff/Explorer/Browser) stay up in - // PanelTabBar; its strip carries only the working tabs. Secondary groups - // show their full membership (a permanent tab dragged into one lives there). - const stripPanels = useMemo(() => { - if (!isPrimary) return orderedPanels; - return orderedPanels.filter(p => p.metadata?.permanent !== true); - }, [isPrimary, orderedPanels]); + // Permanent tool tabs (Diff/Explorer/Browser) are hoisted to PanelTabBar + // from EVERY group while split, so strips carry only working tabs. Their + // content still renders inside whichever group owns them. + const stripPanels = useMemo( + () => orderedPanels.filter(p => p.metadata?.permanent !== true), + [orderedPanels], + ); // Strip drop indexes are relative to the displayed subset; translate to the // group's full panel order before moving. @@ -198,6 +198,7 @@ export const PanelGroupView: React.FC = React.memo(({ activePanelId={group.activePanelId} onPanelSelect={onPanelSelect} onPanelClose={onPanelClose} + isPrimary={isPrimary} isFocused={isFocusedGroup} variant="compact" onDragStart={onDragStart} diff --git a/frontend/src/components/panels/PanelTabBar.tsx b/frontend/src/components/panels/PanelTabBar.tsx index e76823e4..56590eec 100644 --- a/frontend/src/components/panels/PanelTabBar.tsx +++ b/frontend/src/components/panels/PanelTabBar.tsx @@ -458,11 +458,17 @@ export const PanelTabBar: React.FC = memo(({ } }; - // Sort panels: diff first, explorer second, then by position + // Whether a tab drag is hovering the bar (drives the un-split affordance) + const [dragOverBar, setDragOverBar] = useState(false); + useEffect(() => { + if (!isTabDragging) setDragOverBar(false); + }, [isTabDragging]); + + // Sort panels: explorer first, diff second, then by position const sortedPanels = useMemo(() => { const typeOrder = (type: string) => { - if (type === 'diff') return 0; - if (type === 'explorer') return 1; + if (type === 'explorer') return 0; + if (type === 'diff') return 1; if (type === 'browser') return 2; return 3; }; @@ -478,10 +484,28 @@ export const PanelTabBar: React.FC = memo(({
{/* Flex container */}
setDragOverBar(true) : undefined} + onDragLeave={tabsInGroups && isTabDragging ? () => setDragOverBar(false) : undefined} > + {/* Un-split affordance: dropping a tab on the top bar while split + merges every group back into the primary group. Advertise that + while a drag hovers the bar (pointer-events-none so drops pass + through to the strip). */} + {tabsInGroups && isTabDragging && dragOverBar && ( +
+ + + Drop to merge all tabs back here + +
+ )} {/* Scrollable tab area — delegated to PanelTabStrip. When the pane is split, SessionView passes only the primary group's permanent tabs here (working tabs live in the group strips); shortcut hints are diff --git a/frontend/src/components/panels/PanelTabStrip.tsx b/frontend/src/components/panels/PanelTabStrip.tsx index 23ecdfc4..2c9d3218 100644 --- a/frontend/src/components/panels/PanelTabStrip.tsx +++ b/frontend/src/components/panels/PanelTabStrip.tsx @@ -222,6 +222,18 @@ export const PanelTabStrip: React.FC = React.memo(({ if (el) el.focus(); }, []); + // The divider separates the default tool tabs (diff/explorer/browser) from + // the working tabs. It sits after the RIGHTMOST default in the strip, not + // after a hardcoded type, so reordering the defaults moves it correctly. + // Suppressed when the rightmost default is the strip's last tab. + const lastDefaultIndex = useMemo(() => { + let last = -1; + panels.forEach((p, idx) => { + if (p.type === 'diff' || p.type === 'explorer' || p.type === 'browser') last = idx; + }); + return last; + }, [panels]); + // Shortcut hints are only truthful when the hotkeys actually target this // strip's panels: Mod+Shift+1-9 act on the focused group, so hide hints // while a different group has focus. @@ -339,7 +351,9 @@ export const PanelTabStrip: React.FC = React.memo(({ )} /> )} {getPanelIcon(panel.type, panel, compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} - {displayTitle} + {/* Bold marks the primary group's strip: the group the top + bar's tool tabs and the un-split gesture belong to */} + {displayTitle} )} @@ -357,7 +371,7 @@ export const PanelTabStrip: React.FC = React.memo(({
); - const showDividerAfter = !compact && panel.type === 'browser'; + const showDividerAfter = !compact && index === lastDefaultIndex && index < panels.length - 1; const divider = showDividerAfter ? (