diff --git a/src/renderer/components/thread/ThreadToolRail.tsx b/src/renderer/components/thread/ThreadToolRail.tsx index 1523c749..62ddaf78 100644 --- a/src/renderer/components/thread/ThreadToolRail.tsx +++ b/src/renderer/components/thread/ThreadToolRail.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { LucideIcon } from "lucide-react"; import { FileDiff, FolderOpen, NotebookPen, PanelRightOpen, TerminalSquare } from "lucide-react"; @@ -27,6 +27,7 @@ const railPillClass = "flex flex-col items-center gap-0.5 rounded-full p-1"; * each side — 920 + 2 × 44. */ const ALWAYS_OPEN_MIN_PANE_WIDTH = 1008; +const HEADER_MENU_CLOSE_DELAY_MS = 250; interface RailTool { id: string; @@ -84,14 +85,32 @@ export function ThreadToolRail(props: { const paneAnchorRef = useRef(null); const pillRef = useRef(null); + const headerMenuCloseTimerRef = useRef | null>(null); const [paneElement, setPaneElement] = useState(null); const [paneWidth, setPaneWidth] = useState(null); const [paneHeight, setPaneHeight] = useState(null); const [railHeight, setRailHeight] = useState(null); const [headerMenuPhase, setHeaderMenuPhase] = useState("ready"); + const [headerMenuPointerOpen, setHeaderMenuPointerOpen] = useState(false); + const [headerMenuFocusOpen, setHeaderMenuFocusOpen] = useState(false); const alwaysOpen = paneCount === 1 && paneWidth !== null && paneWidth >= ALWAYS_OPEN_MIN_PANE_WIDTH; const sideRailVisible = alwaysOpen && paneElement !== null && !sidePanelOpen; + const headerMenuOpen = + headerMenuPhase === "ready" && (headerMenuPointerOpen || headerMenuFocusOpen); + + const cancelHeaderMenuClose = () => { + if (headerMenuCloseTimerRef.current === null) return; + clearTimeout(headerMenuCloseTimerRef.current); + headerMenuCloseTimerRef.current = null; + }; + + useEffect( + () => () => { + cancelHeaderMenuClose(); + }, + [], + ); useLayoutEffect(() => { if (paneCount !== 1 || sidePanelOpen) return; @@ -188,6 +207,9 @@ export function ThreadToolRail(props: { if (!primaryTool) return null; const suppressHeaderMenu = () => { + cancelHeaderMenuClose(); + setHeaderMenuPointerOpen(false); + setHeaderMenuFocusOpen(false); setHeaderMenuPhase("suppressed"); }; @@ -247,14 +269,38 @@ export function ThreadToolRail(props: {
{ - setHeaderMenuPhase((phase) => (phase === "suppressed" ? "awaiting-reentry" : phase)); + if (headerMenuPhase === "suppressed") { + setHeaderMenuPhase("awaiting-reentry"); + return; + } + cancelHeaderMenuClose(); + headerMenuCloseTimerRef.current = setTimeout(() => { + headerMenuCloseTimerRef.current = null; + setHeaderMenuPointerOpen(false); + }, HEADER_MENU_CLOSE_DELAY_MS); }} onPointerEnter={() => { - setHeaderMenuPhase((phase) => (phase === "awaiting-reentry" ? "ready" : phase)); + cancelHeaderMenuClose(); + if (headerMenuPhase === "suppressed") return; + setHeaderMenuPointerOpen(true); + if (headerMenuPhase === "awaiting-reentry") setHeaderMenuPhase("ready"); + }} + onFocusCapture={() => { + cancelHeaderMenuClose(); + setHeaderMenuPhase("ready"); + setHeaderMenuFocusOpen(true); + }} + onBlurCapture={(event) => { + if ( + event.relatedTarget instanceof Node && + event.currentTarget.contains(event.relatedTarget) + ) { + return; + } + setHeaderMenuFocusOpen(false); }} - onFocusCapture={() => setHeaderMenuPhase("ready")} >