diff --git a/src/renderer/components/SplitPane/SurfaceTabBar.tsx b/src/renderer/components/SplitPane/SurfaceTabBar.tsx index cfd3fdd..a51b4f8 100644 --- a/src/renderer/components/SplitPane/SurfaceTabBar.tsx +++ b/src/renderer/components/SplitPane/SurfaceTabBar.tsx @@ -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; @@ -82,9 +84,15 @@ export default function SurfaceTabBar({ const [insertIndex, setInsertIndex] = useState(null); const [renamingId, setRenamingId] = useState(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(null); - const newMenuRef = useRef(null); + const newCaretRef = useRef(null); + const layoutCaretRef = useRef(null); + const menuRef = useRef(null); const agentMeta = useStore((state) => state.agentMeta); const activeWorkspaceId = useStore((state) => state.activeWorkspaceId); const renameSurface = useStore((state) => state.renameSurface); @@ -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) => { + 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 (
- - {onNewTyped && ( -
+
+ {/* New (split-button): main click = default terminal, caret = type/profile menu */} +
+ + {onNewTyped && ( + + )} +
+ + {/* Layout (split-button): main click = split right, caret = right/down menu */} + {onSplitRight && ( +
+ + +
+ )} + + {/* Close pane */} + {onClosePane && ( - {newMenuOpen && ( -
+ )} +
+ + {openMenu && menuPos && createPortal( +
+ {openMenu === 'new' ? ( + <> {profiles && profiles.length > 0 && ( <> -
+
{profiles.map((profile) => ( ))} )} -
+ + ) : ( + <> + + {onSplitDown && ( + + )} + )} -
- )} - {onSplitRight && ( - - )} - {onSplitDown && ( - - )} - {onClosePane && ( - +
, + document.body, )}
); diff --git a/src/renderer/components/SplitPane/icons.tsx b/src/renderer/components/SplitPane/icons.tsx new file mode 100644 index 0000000..8fe8272 --- /dev/null +++ b/src/renderer/components/SplitPane/icons.tsx @@ -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 ( + + + + + + ); +} + +/** Layout / split right — two side-by-side panes (vertical divider). */ +export function IconSplit({ className, size = 16 }: IconProps) { + return ( + + + + + ); +} + +/** Split down — two stacked panes (horizontal divider). */ +export function IconSplitDown({ className, size = 16 }: IconProps) { + return ( + + + + + ); +} + +/** Close — clean X. */ +export function IconClose({ className, size = 16 }: IconProps) { + return ( + + + + + ); +} + +/** Dropdown caret — chevron down. */ +export function IconCaret({ className, size = 9 }: IconProps) { + return ( + + + + ); +} diff --git a/src/renderer/styles/splitpane.css b/src/renderer/styles/splitpane.css index 8702526..d267ae8 100644 --- a/src/renderer/styles/splitpane.css +++ b/src/renderer/styles/splitpane.css @@ -115,79 +115,91 @@ .surface-tab:hover .surface-tab__close, .surface-tab--active .surface-tab__close { display: flex; } .surface-tab__close:hover { background: rgba(255,255,255,0.15); color: #fff; } -.surface-tab-bar__new-btn { +/* ─── Control cluster (New / Layout / Close) — issue #34 ─────────────────── */ +.surface-tab-bar__cluster { display: flex; align-items: center; - justify-content: center; - width: 28px; - height: 28px; + gap: 10px; flex-shrink: 0; - border: none; - background: transparent; - color: #888; - font-size: 18px; - cursor: pointer; - transition: color 0.1s ease, background 0.1s ease; + padding: 0 8px; +} +/* split-button: main icon + attached caret share one rounded hover pill */ +.surface-tab-bar__group { + display: flex; + align-items: center; + gap: 1px; + border-radius: 5px; } -.surface-tab-bar__new-btn:hover { color: #fff; background: rgba(255,255,255,0.08); } -.surface-tab-bar__new-menu-wrap { position: relative; flex-shrink: 0; } -.surface-tab-bar__new-caret { +.surface-tab-bar__ctl { display: flex; align-items: center; justify-content: center; - width: 16px; - height: 28px; + height: 24px; + min-width: 26px; flex-shrink: 0; border: none; background: transparent; - color: #888; - font-size: 10px; + color: #a8a8a8; cursor: pointer; + border-radius: 5px; transition: color 0.1s ease, background 0.1s ease; } -.surface-tab-bar__new-caret:hover { color: #fff; background: rgba(255,255,255,0.08); } -.surface-tab-bar__new-menu { - position: absolute; - top: 30px; - left: 0; - z-index: 50; - min-width: 150px; - padding: 4px; +.surface-tab-bar__caret { min-width: 16px; } +.surface-tab-bar__ctl:hover { background: rgba(255,255,255,0.10); color: #fff; } +/* per-control accent colors */ +.surface-tab-bar__ctl--new:hover { color: #5fb85f; background: rgba(95,184,95,0.12); } +.surface-tab-bar__ctl--layout:hover { color: #0091ff; background: rgba(0,145,255,0.12); } +.surface-tab-bar__ctl--close:hover { color: #ff6b6b; background: rgba(255,100,100,0.14); } + +/* ─── Control-cluster dropdowns (portalled to document.body) ─────────────── */ +.surface-tab-menu { + z-index: 200; + min-width: 180px; + padding: 5px; background: #1c1c1c; - border: 1px solid rgba(255,255,255,0.12); - border-radius: 6px; - box-shadow: 0 6px 20px rgba(0,0,0,0.5); + border: 1px solid rgba(255,255,255,0.14); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.6); display: flex; flex-direction: column; } -.surface-tab-bar__new-menu button { +.surface-tab-menu button { display: flex; align-items: center; - gap: 8px; + gap: 10px; width: 100%; - padding: 6px 10px; + padding: 7px 10px; border: none; background: transparent; color: #ccc; font-size: 13px; text-align: left; - border-radius: 4px; + border-radius: 5px; cursor: pointer; } -.surface-tab-bar__new-menu button:hover { background: rgba(0,145,255,0.18); color: #fff; } -.surface-tab-bar__new-menu-icon { display: inline-block; width: 14px; text-align: center; color: #888; } -.surface-tab-bar__new-menu-sep { +.surface-tab-menu button:hover { background: rgba(0,145,255,0.18); color: #fff; } +.surface-tab-menu__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + text-align: center; + color: #888; +} +.surface-tab-menu button:hover .surface-tab-menu__icon { color: #fff; } +.surface-tab-menu__kbd { margin-left: auto; font-size: 10px; color: #666; } +.surface-tab-menu__sep { height: 1px; margin: 4px 2px; background: rgba(255,255,255,0.10); } -.surface-tab-bar__new-menu-profile-name { +.surface-tab-menu__profile-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.surface-tab-bar__new-menu-badge { +.surface-tab-menu__badge { font-size: 9px; text-transform: uppercase; letter-spacing: 0.04em; @@ -197,36 +209,6 @@ padding: 0 4px; line-height: 14px; } -.surface-tab-bar__split-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 28px; - flex-shrink: 0; - border: none; - background: transparent; - color: #666; - font-size: 13px; - cursor: pointer; - transition: color 0.1s ease, background 0.1s ease; -} -.surface-tab-bar__split-btn:hover { color: #0091ff; background: rgba(0,145,255,0.1); } -.surface-tab-bar__close-pane-btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - flex-shrink: 0; - border: none; - background: transparent; - color: #666; - font-size: 16px; - cursor: pointer; - transition: color 0.1s ease, background 0.1s ease; -} -.surface-tab-bar__close-pane-btn:hover { color: #ff6b6b; background: rgba(255,100,100,0.12); } .surface-tab--agent .surface-tab__icon { color: #0091FF;