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
206 changes: 139 additions & 67 deletions src/renderer/components/SplitPane/SurfaceTabBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { SurfaceRef, SurfaceId, PaneId, WorkspaceId, QuickLaunchProfile } from '../../../shared/types';
import { useStore } from '../../store';
import { IconAdd, IconSplit, IconSplitDown, IconClose, IconCaret } from './icons';

interface SurfaceTabBarProps {
paneId: PaneId;
Expand Down Expand Up @@ -82,9 +84,15 @@ export default function SurfaceTabBar({
const [insertIndex, setInsertIndex] = useState<number | null>(null);
const [renamingId, setRenamingId] = useState<SurfaceId | null>(null);
const [renameValue, setRenameValue] = useState('');
const [newMenuOpen, setNewMenuOpen] = useState(false);
// Which control-cluster dropdown is open, and where to anchor it (issue #34).
// Menus render through a portal to document.body so the tab bar's
// `overflow: hidden` can no longer clip them (the old caret-dropdown bug).
const [openMenu, setOpenMenu] = useState<'new' | 'layout' | null>(null);
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const newMenuRef = useRef<HTMLDivElement>(null);
const newCaretRef = useRef<HTMLButtonElement>(null);
const layoutCaretRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const agentMeta = useStore((state) => state.agentMeta);
const activeWorkspaceId = useStore((state) => state.activeWorkspaceId);
const renameSurface = useStore((state) => state.renameSurface);
Expand Down Expand Up @@ -129,32 +137,59 @@ export default function SurfaceTabBar({
}
}, [renamingId]);

// Close the "new surface" menu on outside click or Escape
// Toggle a portalled dropdown, anchoring it to the trigger caret button.
const toggleMenu = useCallback((menu: 'new' | 'layout', ref: React.RefObject<HTMLButtonElement | null>) => {
if (openMenu === menu) { setOpenMenu(null); return; }
const rect = ref.current?.getBoundingClientRect();
if (rect) {
setMenuPos({ top: rect.bottom + 2, right: Math.max(4, window.innerWidth - rect.right) });
}
setOpenMenu(menu);
}, [openMenu]);

// Close any open dropdown on outside click, Escape, or viewport change.
// Clicks inside the menu or on either caret are ignored (the caret's own
// onClick handles toggling).
useEffect(() => {
if (!newMenuOpen) return;
if (!openMenu) return;
const onDown = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
const t = e.target as Node;
if (menuRef.current?.contains(t)) return;
if (newCaretRef.current?.contains(t)) return;
if (layoutCaretRef.current?.contains(t)) return;
setOpenMenu(null);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setNewMenuOpen(false); };
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpenMenu(null); };
const onViewportChange = () => setOpenMenu(null);
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
window.addEventListener('resize', onViewportChange);
window.addEventListener('scroll', onViewportChange, true);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
};
}, [newMenuOpen]);
}, [openMenu]);

const pickNew = useCallback((type: 'terminal' | 'browser' | 'markdown') => {
setNewMenuOpen(false);
setOpenMenu(null);
if (onNewTyped) onNewTyped(type);
else onNew();
}, [onNewTyped, onNew]);

const pickProfile = useCallback((profile: QuickLaunchProfile) => {
setNewMenuOpen(false);
setOpenMenu(null);
onNewProfile?.(profile);
}, [onNewProfile]);

const pickSplit = useCallback((dir: 'right' | 'down') => {
setOpenMenu(null);
if (dir === 'right') onSplitRight?.();
else onSplitDown?.();
}, [onSplitRight, onSplitDown]);

// Always show tab bar (even for 1 surface — like browser tabs)
return (
<div
Expand Down Expand Up @@ -271,92 +306,129 @@ export default function SurfaceTabBar({
})}
</div>

<button
className="surface-tab-bar__new-btn"
onClick={onNew}
tabIndex={-1}
title="New terminal tab (Ctrl+T)"
>
+
</button>
{onNewTyped && (
<div className="surface-tab-bar__new-menu-wrap" ref={newMenuRef}>
<div className="surface-tab-bar__cluster">
{/* New (split-button): main click = default terminal, caret = type/profile menu */}
<div className="surface-tab-bar__group">
<button
className="surface-tab-bar__ctl surface-tab-bar__ctl--new"
onClick={onNew}
tabIndex={-1}
title="New terminal tab (Ctrl+T)"
>
<IconAdd />
</button>
{onNewTyped && (
<button
ref={newCaretRef}
className="surface-tab-bar__ctl surface-tab-bar__caret"
onClick={() => toggleMenu('new', newCaretRef)}
tabIndex={-1}
aria-haspopup="menu"
aria-expanded={openMenu === 'new'}
title="New tab type…"
>
<IconCaret />
</button>
)}
</div>

{/* Layout (split-button): main click = split right, caret = right/down menu */}
{onSplitRight && (
<div className="surface-tab-bar__group">
<button
className="surface-tab-bar__ctl surface-tab-bar__ctl--layout"
onClick={onSplitRight}
tabIndex={-1}
title="Split right (Ctrl+D)"
>
<IconSplit />
</button>
<button
ref={layoutCaretRef}
className="surface-tab-bar__ctl surface-tab-bar__caret"
onClick={() => toggleMenu('layout', layoutCaretRef)}
tabIndex={-1}
aria-haspopup="menu"
aria-expanded={openMenu === 'layout'}
title="Split layout…"
>
<IconCaret />
</button>
</div>
)}

{/* Close pane */}
{onClosePane && (
<button
className="surface-tab-bar__new-caret"
onClick={() => setNewMenuOpen((v) => !v)}
className="surface-tab-bar__ctl surface-tab-bar__ctl--close"
onClick={onClosePane}
tabIndex={-1}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
title="New tab type…"
title="Close pane"
>
<IconClose />
</button>
{newMenuOpen && (
<div className="surface-tab-bar__new-menu" role="menu">
)}
</div>

{openMenu && menuPos && createPortal(
<div
ref={menuRef}
className="surface-tab-menu"
role="menu"
style={{ position: 'fixed', top: menuPos.top, right: menuPos.right }}
>
{openMenu === 'new' ? (
<>
<button role="menuitem" onClick={() => pickNew('terminal')}>
<span className="surface-tab-bar__new-menu-icon">{surfaceIcon('terminal', false)}</span> Terminal
<span className="surface-tab-menu__icon">{surfaceIcon('terminal', false)}</span> Terminal
</button>
<button role="menuitem" onClick={() => pickNew('browser')}>
<span className="surface-tab-bar__new-menu-icon">{surfaceIcon('browser', false)}</span> Browser
<span className="surface-tab-menu__icon">{surfaceIcon('browser', false)}</span> Browser
</button>
<button role="menuitem" onClick={() => pickNew('markdown')}>
<span className="surface-tab-bar__new-menu-icon">{surfaceIcon('markdown', false)}</span> Markdown
<span className="surface-tab-menu__icon">{surfaceIcon('markdown', false)}</span> Markdown
</button>
{profiles && profiles.length > 0 && (
<>
<div className="surface-tab-bar__new-menu-sep" role="separator" />
<div className="surface-tab-menu__sep" role="separator" />
{profiles.map((profile) => (
<button
key={profile.id}
role="menuitem"
className="surface-tab-bar__new-menu-profile"
className="surface-tab-menu__profile"
onClick={() => pickProfile(profile)}
title={profile.source === 'project' ? 'Project profile (.wmux.json)' : 'Global profile'}
>
<span className="surface-tab-bar__new-menu-icon">
<span className="surface-tab-menu__icon">
{profile.icon || surfaceIcon(profile.type, false)}
</span>
<span className="surface-tab-bar__new-menu-profile-name">{profile.name}</span>
<span className="surface-tab-menu__profile-name">{profile.name}</span>
{profile.source === 'project' && (
<span className="surface-tab-bar__new-menu-badge">project</span>
<span className="surface-tab-menu__badge">project</span>
)}
</button>
))}
</>
)}
</div>
</>
) : (
<>
<button role="menuitem" onClick={() => pickSplit('right')}>
<span className="surface-tab-menu__icon"><IconSplit size={15} /></span>
Split right
<span className="surface-tab-menu__kbd">Ctrl+D</span>
</button>
{onSplitDown && (
<button role="menuitem" onClick={() => pickSplit('down')}>
<span className="surface-tab-menu__icon"><IconSplitDown size={15} /></span>
Split down
<span className="surface-tab-menu__kbd">Ctrl+Shift+D</span>
</button>
)}
</>
)}
</div>
)}
{onSplitRight && (
<button
className="surface-tab-bar__split-btn"
onClick={onSplitRight}
tabIndex={-1}
title="Split right (Ctrl+D)"
>
</button>
)}
{onSplitDown && (
<button
className="surface-tab-bar__split-btn"
onClick={onSplitDown}
tabIndex={-1}
title="Split down (Ctrl+Shift+D)"
>
</button>
)}
{onClosePane && (
<button
className="surface-tab-bar__close-pane-btn"
onClick={onClosePane}
tabIndex={-1}
title="Close pane"
>
×
</button>
</div>,
document.body,
)}
</div>
);
Expand Down
67 changes: 67 additions & 0 deletions src/renderer/components/SplitPane/icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Inline SVG icons for the terminal toolbar control cluster (issue #34).
// Stroke-based, inherit `currentColor` so hover accents come from CSS.
// NOTE: a future issue may migrate these to `lucide-react` once icon usage
// spreads across the UI — keeping them in one file makes that swap trivial.
import React from 'react';

interface IconProps {
className?: string;
size?: number;
}

const base = {
fill: 'none',
stroke: 'currentColor',
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
};

/** New tab — plus inside a rounded square ("add a tab"). */
export function IconAdd({ className, size = 16 }: IconProps) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" strokeWidth={2} {...base}>
<rect x="3" y="3" width="18" height="18" rx="3" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
);
}

/** Layout / split right — two side-by-side panes (vertical divider). */
export function IconSplit({ className, size = 16 }: IconProps) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" strokeWidth={2} {...base}>
<rect x="3" y="4" width="7" height="16" rx="1.5" />
<rect x="14" y="4" width="7" height="16" rx="1.5" />
</svg>
);
}

/** Split down — two stacked panes (horizontal divider). */
export function IconSplitDown({ className, size = 16 }: IconProps) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" strokeWidth={2} {...base}>
<rect x="4" y="3" width="16" height="7" rx="1.5" />
<rect x="4" y="14" width="16" height="7" rx="1.5" />
</svg>
);
}

/** Close — clean X. */
export function IconClose({ className, size = 16 }: IconProps) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" strokeWidth={2.2} {...base}>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}

/** Dropdown caret — chevron down. */
export function IconCaret({ className, size = 9 }: IconProps) {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" strokeWidth={2.5} {...base}>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
Loading