From 9027aff96a05958efc871badcced2e80ba83389c Mon Sep 17 00:00:00 2001 From: killersteps <34531389+killersteps@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:17:49 +0800 Subject: [PATCH] fix: show session delete button after the list shifts under the cursor Deleting a session unmounts that row so the next row slides up into the same place. The pointer often has not moved, so mouseenter/:hover do not fire and the rename/delete buttons intermittently never appear. Track the pointer against row geometry, ignore synthetic pointerleave from the removed row, and treat the row under the cursor as hovered after the list reflows. --- components/SessionSidebar.test.mjs | 10 ++++- components/SessionSidebar.tsx | 61 ++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 5 deletions(-) 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 && (