diff --git a/components/SessionSidebar.test.mjs b/components/SessionSidebar.test.mjs index a4379c9ef..26c7420fe 100644 --- a/components/SessionSidebar.test.mjs +++ b/components/SessionSidebar.test.mjs @@ -119,7 +119,15 @@ test("lifecycle refreshes bypass the cache while cross-window polling reuses it" test("does not expose disk-backed actions for transient sessions", () => { assert.match(sessionItemSource, /if \(session\.transient\) return;/); - assert.match(sessionItemSource, /\{hovered && !session\.transient && \(/); + assert.match(sessionItemSource, /\{showHover && !session\.transient && \(/); +}); + +test("re-probes the session under a stationary pointer after the list reflows", () => { + assert.match(source, /data-session-id=\{family\.root\.id\}/); + assert.match(source, /list\.querySelectorAll\("\[data-session-id\]"\)/); + assert.match(source, /Removing the hovered row fires pointerleave/); + assert.match(source, /pointerActive=\{pointerSessionId === family\.root\.id\}/); + assert.match(sessionItemSource, /const showHover = hovered \|\| pointerActive/); }); test("hides subagent rows and aggregates their state into the main session row", () => { diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx index 60bc47ada..770ed193e 100644 --- a/components/SessionSidebar.tsx +++ b/components/SessionSidebar.tsx @@ -426,6 +426,45 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio const [listScrollTop, setListScrollTop] = useState(0); const [focusedSessionId, setFocusedSessionId] = useState(null); const listScrollRafRef = useRef(null); + const listPointerRef = useRef<{ x: number; y: number } | null>(null); + const [pointerSessionId, setPointerSessionId] = useState(null); + const syncPointerSession = useCallback(() => { + const pos = listPointerRef.current; + const list = listScrollRef.current; + if (!pos || !list) { + setPointerSessionId((current) => (current === null ? current : null)); + return; + } + let id: string | null = null; + for (const candidate of list.querySelectorAll("[data-session-id]")) { + if (!(candidate instanceof HTMLElement)) continue; + const rect = candidate.getBoundingClientRect(); + if (pos.x >= rect.left && pos.x <= rect.right && pos.y >= rect.top && pos.y <= rect.bottom) { + id = candidate.dataset.sessionId ?? null; + break; + } + } + setPointerSessionId((current) => (current === id ? current : id)); + }, []); + const handleListPointerMove = useCallback((event: React.PointerEvent | React.MouseEvent) => { + listPointerRef.current = { x: event.clientX, y: event.clientY }; + syncPointerSession(); + }, [syncPointerSession]); + const handleListPointerLeave = useCallback((event: React.PointerEvent | React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const inside = event.clientX >= rect.left && event.clientX <= rect.right + && event.clientY >= rect.top && event.clientY <= rect.bottom; + // Removing the hovered row fires pointerleave even though the cursor is + // still inside the list. Keep the last point so the row that slides under + // the pointer can show its actions. + if (inside) { + listPointerRef.current = { x: event.clientX, y: event.clientY }; + syncPointerSession(); + return; + } + listPointerRef.current = null; + setPointerSessionId(null); + }, [syncPointerSession]); const handleListScroll = useCallback((e: React.UIEvent) => { const top = e.currentTarget.scrollTop; if (listScrollRafRef.current != null) return; @@ -445,6 +484,11 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio setListScrollTop(el.scrollTop); return () => ro.disconnect(); }, [sessionSearchActive]); + useLayoutEffect(() => { + syncPointerSession(); + const id = requestAnimationFrame(() => syncPointerSession()); + return () => cancelAnimationFrame(id); + }, [allSessions, syncPointerSession]); const loadSessions = useCallback(async (showLoading = false, force = false) => { const loadId = ++sessionLoadIdRef.current; @@ -1677,6 +1721,10 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio
{loading && ( @@ -1711,15 +1759,17 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio return (
setFocusedSessionId(family.root.id)} onBlur={() => setFocusedSessionId(null)} - style={{ position: "absolute", top: index * SESSION_LIST_ITEM_HEIGHT, left: 0, right: 0 }} + style={{ position: "absolute", top: index * SESSION_LIST_ITEM_HEIGHT, left: 0, right: 0, height: SESSION_LIST_ITEM_HEIGHT }} > session.id === selectedSessionId)} isRunning={familySessions.some((session) => runningSessionIds.has(session.id))} isUnread={familySessions.some((session) => unreadSessionIds.has(session.id))} + pointerActive={pointerSessionId === family.root.id} onClick={() => handleSelectSessionFromList(family.root)} onRenamed={loadSessions} onDeleted={(id) => { @@ -1996,6 +2046,7 @@ function SessionItem({ isSelected, isRunning, isUnread, + pointerActive = false, onClick, onRenamed, onDeleted, @@ -2008,6 +2059,7 @@ function SessionItem({ isSelected: boolean; isRunning?: boolean; isUnread?: boolean; + pointerActive?: boolean; onClick: () => void; onRenamed?: () => void; onDeleted?: (id: string) => void; @@ -2018,6 +2070,7 @@ function SessionItem({ }) { const { locale, t } = useI18n(); const [hovered, setHovered] = useState(false); + const showHover = hovered || pointerActive; const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); const [confirmDelete, setConfirmDelete] = useState(false); @@ -2128,7 +2181,7 @@ function SessionItem({ cursor: confirmDelete || renaming ? "default" : "pointer", background: confirmDelete ? "rgba(239,68,68,0.06)" - : isSelected ? "var(--bg-selected)" : hovered ? "var(--bg-hover)" : "transparent", + : isSelected ? "var(--bg-selected)" : showHover ? "var(--bg-hover)" : "transparent", borderLeft: confirmDelete ? "2px solid #ef4444" : isSelected ? "2px solid var(--accent)" : "2px solid transparent", @@ -2277,8 +2330,8 @@ function SessionItem({ )} - {/* Action buttons — shown on hover */} - {hovered && !session.transient && ( + {/* Action buttons — shown on hover, including when a row slides under a stationary pointer after delete */} + {showHover && !session.transient && (