Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 42 additions & 19 deletions frontend/src/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<PanelTabPresentationResolver>((panel) => {
if (panel.type !== 'diff') return undefined;
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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}
Expand Down
15 changes: 8 additions & 7 deletions frontend/src/components/panels/PanelGroupView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,13 @@ export const PanelGroupView: React.FC<PanelGroupViewProps> = 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.
Expand Down Expand Up @@ -198,6 +198,7 @@ export const PanelGroupView: React.FC<PanelGroupViewProps> = React.memo(({
activePanelId={group.activePanelId}
onPanelSelect={onPanelSelect}
onPanelClose={onPanelClose}
isPrimary={isPrimary}
isFocused={isFocusedGroup}
variant="compact"
onDragStart={onDragStart}
Expand Down
32 changes: 28 additions & 4 deletions frontend/src/components/panels/PanelTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,11 +458,17 @@ export const PanelTabBar: React.FC<PanelTabBarProps> = 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;
};
Expand All @@ -478,10 +484,28 @@ export const PanelTabBar: React.FC<PanelTabBarProps> = memo(({
<div className="panel-tab-bar bg-bg-chrome flex-shrink-0">
{/* Flex container */}
<div
className="flex items-center min-h-[var(--panel-tab-height)] px-2"
className="relative flex items-center min-h-[var(--panel-tab-height)] px-2"
role="tablist"
aria-label="Panel Tabs"
onDragOver={tabsInGroups && isTabDragging ? () => 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 && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] bg-surface-primary border border-[color-mix(in_srgb,var(--color-interactive-primary)_40%,transparent)] text-text-secondary shadow-dropdown">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
<line x1="8" y1="2.5" x2="8" y2="13.5" strokeDasharray="2 2" opacity="0.5" />
<path d="M5.5 8h5M9 6.5 10.5 8 9 9.5" />
</svg>
Drop to merge all tabs back here
</span>
</div>
)}
{/* 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
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/components/panels/PanelTabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,18 @@ export const PanelTabStrip: React.FC<PanelTabStripProps> = 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.
Expand Down Expand Up @@ -339,7 +351,9 @@ export const PanelTabStrip: React.FC<PanelTabStripProps> = React.memo(({
)} />
)}
{getPanelIcon(panel.type, panel, compact ? 'w-3.5 h-3.5' : 'w-4 h-4')}
<span>{displayTitle}</span>
{/* Bold marks the primary group's strip: the group the top
bar's tool tabs and the un-split gesture belong to */}
<span className={cn(compact && isPrimary && "font-semibold")}>{displayTitle}</span>
</span>
)}

Expand All @@ -357,7 +371,7 @@ export const PanelTabStrip: React.FC<PanelTabStripProps> = React.memo(({
</div>
);

const showDividerAfter = !compact && panel.type === 'browser';
const showDividerAfter = !compact && index === lastDefaultIndex && index < panels.length - 1;
const divider = showDividerAfter ? (
<div className="h-4 w-px bg-border-primary mx-1 flex-shrink-0" aria-hidden="true" />
) : null;
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/utils/panelLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
findGroupInDirection,
dropZoneFor,
subsetInsertIndex,
mergeAllGroups,
} from './panelLayout';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -430,6 +431,34 @@ describe('directional focus geometry', () => {
});
});

// ---------------------------------------------------------------------------
// mergeAllGroups
// ---------------------------------------------------------------------------

describe('mergeAllGroups', () => {
it('returns a single group unchanged', () => {
const g = group('g1', ['a', 'b'], 'b');
expect(mergeAllGroups(g)).toBe(g);
});

it('collapses a nested tree into the primary group, preserving id, order, and active', () => {
const tree = split('s1', 'row', [
group('g1', ['a', 'b'], 'b'),
split('s2', 'column', [group('g2', ['c']), group('g3', ['d'])]),
]);
const merged = mergeAllGroups(tree);
expect(merged.id).toBe('g1');
expect(merged.panelIds).toEqual(['a', 'b', 'c', 'd']);
expect(merged.activePanelId).toBe('b');
});

it('dedupes panel ids, keeping the first occurrence', () => {
const tree = split('s1', 'row', [group('g1', ['a', 'b']), group('g2', ['a', 'c'])]);
const merged = mergeAllGroups(tree);
expect(merged.panelIds).toEqual(['a', 'b', 'c']);
});
});

// ---------------------------------------------------------------------------
// subsetInsertIndex
// ---------------------------------------------------------------------------
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/utils/panelLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,34 @@ export function reconcile(
};
}

// ---------------------------------------------------------------------------
// Merge all groups (the un-split gesture)
// ---------------------------------------------------------------------------

/**
* Collapse the entire tree into the primary group. Panel order: the primary
* group's panels first, then the remaining groups' panels in reading order.
* The primary group's id and active tab survive.
*/
export function mergeAllGroups(root: PanelLayoutNode): PanelGroupNode {
if (root.type === 'group') return root;
const primary = primaryGroup(root);
const seen = new Set<string>();
const ids: string[] = [];
for (const id of allPanelIds(root)) {
if (!seen.has(id)) {
seen.add(id);
ids.push(id);
}
}
return {
type: 'group',
id: primary.id,
panelIds: ids,
activePanelId: primary.activePanelId ?? ids[0] ?? null,
};
}

// ---------------------------------------------------------------------------
// Subset index translation
// ---------------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions main/src/services/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,14 @@ export class SessionManager extends EventEmitter {

await panelManager.ensureExplorerPanel(session.id);
await panelManager.ensureDiffPanel(session.id);
// Explorer is the intended initial tab (the diff creation above just
// stole active by being the last createPanel).
const explorerPanel = panelManager
.getPanelsForSession(session.id)
.find((p) => p.type === 'explorer');
if (explorerPanel) {
await panelManager.setActivePanel(session.id, explorerPanel.id);
}
return session;
});
}
Expand Down
10 changes: 10 additions & 0 deletions main/src/services/taskQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,16 @@ export class TaskQueue {
panelManager.ensureBrowserPanel(session.id),
]);

// Each createPanel marks the new panel active, so the parallel ensures
// leave a race winner focused. Explorer is the intended initial tab
// (renders instantly; diff can take a moment on big worktrees).
const explorerPanel = panelManager
.getPanelsForSession(session.id)
.find((p) => p.type === 'explorer');
if (explorerPanel) {
await panelManager.setActivePanel(session.id, explorerPanel.id);
}

// Emit the session-created event BEFORE running build script so UI shows immediately
sessionManager.emitSessionCreated(session);

Expand Down
Loading