Skip to content
Open
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
10 changes: 9 additions & 1 deletion components/SessionSidebar.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
61 changes: 57 additions & 4 deletions components/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,45 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio
const [listScrollTop, setListScrollTop] = useState(0);
const [focusedSessionId, setFocusedSessionId] = useState<string | null>(null);
const listScrollRafRef = useRef<number | null>(null);
const listPointerRef = useRef<{ x: number; y: number } | null>(null);
const [pointerSessionId, setPointerSessionId] = useState<string | null>(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<HTMLDivElement> | React.MouseEvent<HTMLDivElement>) => {
listPointerRef.current = { x: event.clientX, y: event.clientY };
syncPointerSession();
}, [syncPointerSession]);
const handleListPointerLeave = useCallback((event: React.PointerEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
const top = e.currentTarget.scrollTop;
if (listScrollRafRef.current != null) return;
Expand All @@ -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;
Expand Down Expand Up @@ -1677,6 +1721,10 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio
<div
ref={listScrollRef}
onScroll={handleListScroll}
onPointerMove={handleListPointerMove}
onPointerLeave={handleListPointerLeave}
onMouseMove={handleListPointerMove}
onMouseLeave={handleListPointerLeave}
style={{ flex: explorerOpen && (selectedCwdProp || selectedCwd) ? "1 1 0" : "1 1 auto", overflowY: "auto", padding: "0", minHeight: 80 }}
>
{loading && (
Expand Down Expand Up @@ -1711,15 +1759,17 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio
return (
<div
key={family.root.id}
data-session-id={family.root.id}
onFocus={() => 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 }}
>
<SessionItem
session={displaySession}
isSelected={familySessions.some((session) => 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) => {
Expand Down Expand Up @@ -1996,6 +2046,7 @@ function SessionItem({
isSelected,
isRunning,
isUnread,
pointerActive = false,
onClick,
onRenamed,
onDeleted,
Expand All @@ -2008,6 +2059,7 @@ function SessionItem({
isSelected: boolean;
isRunning?: boolean;
isUnread?: boolean;
pointerActive?: boolean;
onClick: () => void;
onRenamed?: () => void;
onDeleted?: (id: string) => void;
Expand All @@ -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);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -2277,8 +2330,8 @@ function SessionItem({
</button>
)}

{/* 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 && (
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
<button
onClick={startRename}
Expand Down
Loading