From dbff5015a03ab794911c835e33ed8d5751b7768c Mon Sep 17 00:00:00 2001 From: Enis Date: Sat, 1 Aug 2026 23:43:45 +0200 Subject: [PATCH] feat(web): collapse same-worktree chats into one sidebar row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chats spawned into the same git worktree via the in-chat worktree tab strip no longer each claim their own sidebar row. A worktree now shows as a single row — the earliest-created chat — and its siblings are reachable only through the tab strip. Chats with no worktree are unchanged. Add collapseWorktreeSiblings to Sidebar.logic and apply it in both the v1 sidebar (per project) and the v2 sidebar (before active/snoozed/settled partitioning). When the active route is a collapsed sibling, its representative row highlights, pins when collapsed, gets pulled into view in the settled/snoozed shelves, and keyboard traversal steps relative to it. The v1 project status dot still reads every non-archived thread so a busy sibling is never hidden behind its row. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/Sidebar.logic.test.ts | 77 ++++++++ apps/web/src/components/Sidebar.logic.ts | 66 +++++++ apps/web/src/components/Sidebar.tsx | 65 +++++-- apps/web/src/components/SidebarV2.tsx | 167 ++++++++++-------- 4 files changed, 291 insertions(+), 84 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 11bad3afa3b1..0947552964b3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + collapseWorktreeSiblings, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, @@ -1041,6 +1042,82 @@ describe("getVisibleThreadsForProject", () => { }); }); +describe("collapseWorktreeSiblings", () => { + type CollapsibleThread = { + id: string; + environmentId: string; + worktreePath: string | null; + createdAt: string; + }; + const keyOf = (thread: CollapsibleThread) => `${thread.environmentId}:${thread.id}`; + const make = ( + id: string, + worktreePath: string | null, + createdAt: string, + environmentId = "env-1", + ): CollapsibleThread => ({ id, environmentId, worktreePath, createdAt }); + + it("keeps only the earliest-created chat per worktree, preserving input order", () => { + const threads = [ + make("newer", "/wt/a", "2026-03-09T12:00:00.000Z"), + make("standalone", null, "2026-03-09T11:00:00.000Z"), + make("older", "/wt/a", "2026-03-09T10:00:00.000Z"), + ]; + + const { threads: collapsed } = collapseWorktreeSiblings(threads, keyOf); + + // "older" survives (earliest in its worktree) and stays where it sat. + expect(collapsed.map((thread) => thread.id)).toEqual(["standalone", "older"]); + }); + + it("never collapses threads without a worktree", () => { + const threads = [ + make("a", null, "2026-03-09T10:00:00.000Z"), + make("b", null, "2026-03-09T10:00:00.000Z"), + ]; + + const { threads: collapsed } = collapseWorktreeSiblings(threads, keyOf); + + expect(collapsed.map((thread) => thread.id)).toEqual(["a", "b"]); + }); + + it("does not merge same-path worktrees across environments", () => { + const threads = [ + make("a", "/wt/shared", "2026-03-09T10:00:00.000Z", "env-1"), + make("b", "/wt/shared", "2026-03-09T11:00:00.000Z", "env-2"), + ]; + + const { threads: collapsed } = collapseWorktreeSiblings(threads, keyOf); + + expect(collapsed.map((thread) => thread.id)).toEqual(["a", "b"]); + }); + + it("maps every sibling and the representative to the representative's key", () => { + const threads = [ + make("older", "/wt/a", "2026-03-09T10:00:00.000Z"), + make("newer", "/wt/a", "2026-03-09T12:00:00.000Z"), + make("solo", null, "2026-03-09T11:00:00.000Z"), + ]; + + const { representativeKeyByThreadKey } = collapseWorktreeSiblings(threads, keyOf); + + expect(representativeKeyByThreadKey.get("env-1:newer")).toBe("env-1:older"); + expect(representativeKeyByThreadKey.get("env-1:older")).toBe("env-1:older"); + expect(representativeKeyByThreadKey.get("env-1:solo")).toBe("env-1:solo"); + }); + + it("breaks createdAt ties deterministically by id", () => { + const threads = [ + make("beta", "/wt/a", "2026-03-09T10:00:00.000Z"), + make("alpha", "/wt/a", "2026-03-09T10:00:00.000Z"), + ]; + + const { threads: collapsed } = collapseWorktreeSiblings(threads, keyOf); + + expect(collapsed.map((thread) => thread.id)).toEqual(["alpha"]); + }); +}); + function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7aee3100d0ea..1fcd25b8e1b8 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -671,6 +671,72 @@ export function getVisibleThreadsForProject>(input: }; } +type WorktreeCollapsibleThread = { + readonly id: string; + readonly environmentId: string; + readonly worktreePath: string | null; + readonly createdAt: string; +}; + +function isEarlierCreatedThread( + candidate: T, + incumbent: T, +): boolean { + const byCreatedAt = candidate.createdAt.localeCompare(incumbent.createdAt); + return byCreatedAt !== 0 ? byCreatedAt < 0 : candidate.id.localeCompare(incumbent.id) < 0; +} + +/** + * Collapse chats that share one on-disk git worktree into a single sidebar + * row. Several chats can run in the same worktree — the in-chat worktree tab + * strip spawns siblings that reuse it — and listing each as its own row + * duplicates the worktree down the sidebar. Only the earliest-created chat in + * a group survives as the representative row; its siblings stay reachable + * through the tab strip. Threads with no worktree (worktreePath === null) + * never collapse — each keeps its own row. + * + * Survivors keep their input order (each stays at its own position), so the + * caller's sort is preserved. `representativeKeyByThreadKey` maps every input + * thread's key to its representative's key so the caller can highlight the + * representative row when the active route is a collapsed sibling. + */ +export function collapseWorktreeSiblings( + threads: readonly T[], + keyOf: (thread: T) => string, +): { threads: T[]; representativeKeyByThreadKey: Map } { + const representativeByGroupKey = new Map(); + for (const thread of threads) { + if (thread.worktreePath === null) continue; + const groupKey = `${thread.environmentId}\0${thread.worktreePath}`; + const incumbent = representativeByGroupKey.get(groupKey); + if (incumbent === undefined || isEarlierCreatedThread(thread, incumbent)) { + representativeByGroupKey.set(groupKey, thread); + } + } + + const representativeKeyByGroupKey = new Map(); + for (const [groupKey, thread] of representativeByGroupKey) { + representativeKeyByGroupKey.set(groupKey, keyOf(thread)); + } + + const representativeKeyByThreadKey = new Map(); + const survivors: T[] = []; + for (const thread of threads) { + const key = keyOf(thread); + if (thread.worktreePath === null) { + representativeKeyByThreadKey.set(key, key); + survivors.push(thread); + continue; + } + const groupKey = `${thread.environmentId}\0${thread.worktreePath}`; + const representativeKey = representativeKeyByGroupKey.get(groupKey) ?? key; + representativeKeyByThreadKey.set(key, representativeKey); + if (representativeKey === key) survivors.push(thread); + } + + return { threads: survivors, representativeKeyByThreadKey }; +} + export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7b77eb0abee3..865280e2fe39 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -176,6 +176,7 @@ import { openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + collapseWorktreeSiblings, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, @@ -1239,7 +1240,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return counts; }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); - const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { + const { + projectStatus, + visibleProjectThreads, + orderedProjectThreadKeys, + representativeKeyByThreadKey, + } = useMemo(() => { const lastVisitedAtByThreadKey = new Map( projectThreads.map((thread, index) => [ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -1257,23 +1263,38 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const visibleProjectThreads = sortThreads( + const sortedThreads = sortThreads( projectThreads.filter((thread) => thread.archivedAt === null), threadSortOrder, ); + // Chats spawned into the same worktree (via the in-chat worktree tab + // strip) collapse to a single row; the siblings stay reachable only as + // tabs. The project status dot still reads from every non-archived + // thread so a busy sibling never goes unreported behind its row. + const { threads: visibleProjectThreads, representativeKeyByThreadKey } = + collapseWorktreeSiblings(sortedThreads, (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); const projectStatus = resolveProjectStatusIndicator( - visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)), + sortedThreads.map((thread) => resolveProjectThreadStatus(thread)), ); return { orderedProjectThreadKeys: visibleProjectThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), projectStatus, + representativeKeyByThreadKey, visibleProjectThreads, }; }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + // When the active route is a collapsed worktree sibling, highlight and pin + // the representative row that stands in for it. + const effectiveActiveRouteThreadKey = + activeRouteThreadKey === null + ? null + : (representativeKeyByThreadKey.get(activeRouteThreadKey) ?? activeRouteThreadKey); const pinnedCollapsedThread = useMemo(() => { - const activeThreadKey = activeRouteThreadKey ?? undefined; + const activeThreadKey = effectiveActiveRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { return null; } @@ -1283,7 +1304,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, ) ?? null ); - }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]); + }, [effectiveActiveRouteThreadKey, projectExpanded, visibleProjectThreads]); const { hasOverflowingThreads, @@ -2375,7 +2396,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec shouldShowThreadPanel={shouldShowThreadPanel} isThreadListExpanded={isThreadListExpanded} projectCwd={project.workspaceRoot} - activeRouteThreadKey={activeRouteThreadKey} + activeRouteThreadKey={effectiveActiveRouteThreadKey} threadJumpLabelByKey={threadJumpLabelByKey} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} renamingThreadKey={renamingThreadKey} @@ -3327,6 +3348,16 @@ export default function Sidebar() { () => sidebarThreads.filter((thread) => thread.archivedAt === null), [sidebarThreads], ); + // The active route may be a collapsed worktree sibling that has no row of + // its own; keyboard traversal and jump ordering run against the earliest + // chat that represents it. + const routeRepresentativeThreadKey = useMemo(() => { + if (routeThreadKey === null) return null; + const { representativeKeyByThreadKey } = collapseWorktreeSiblings(visibleThreads, (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + return representativeKeyByThreadKey.get(routeThreadKey) ?? routeThreadKey; + }, [routeThreadKey, visibleThreads]); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ ...project, @@ -3362,17 +3393,26 @@ export default function Sidebar() { const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( - (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, + // Mirror the row-level collapse so jump labels and prewarming line up + // with the rendered rows: worktree siblings fold into their earliest + // representative and never claim their own visible slot. + const { threads: projectThreads, representativeKeyByThreadKey } = collapseWorktreeSiblings( + sortThreads( + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, ), - sidebarThreadSortOrder, + (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); const projectExpanded = resolveProjectExpanded( projectExpandedById, projectExpansionPreferenceKeys(project), ); - const activeThreadKey = routeThreadKey ?? undefined; + const activeThreadKey = + routeThreadKey === null + ? undefined + : (representativeKeyByThreadKey.get(routeThreadKey) ?? routeThreadKey); const pinnedCollapsedThread = !projectExpanded && activeThreadKey ? (projectThreads.find( @@ -3482,7 +3522,7 @@ export default function Sidebar() { if (traversalDirection !== null) { const targetThreadKey = resolveAdjacentThreadId({ threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, + currentThreadId: routeRepresentativeThreadKey, direction: traversalDirection, }); if (!targetThreadKey) { @@ -3529,6 +3569,7 @@ export default function Sidebar() { navigateToThread, orderedSidebarThreadKeys, platform, + routeRepresentativeThreadKey, routeThreadKey, sidebarThreadByKey, threadJumpThreadKeys, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 9c594245bb95..3dcf5cff4ebf 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -117,6 +117,7 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat" import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { + collapseWorktreeSiblings, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, @@ -1607,68 +1608,84 @@ export default function SidebarV2() { // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; - // Snooze classification uses a REAL clock, not the quantized minute: - // wake times are second-precise and a woken thread must not linger on - // the shelf for the rest of the minute. snoozeWakeTick re-runs this - // memo exactly at the next wake boundary. - void snoozeWakeTick; - const preciseNow = new Date().toISOString(); - const visible = threads.filter( - (thread) => - thread.archivedAt === null && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), - ); - const active: EnvironmentThreadShell[] = []; - const snoozed: EnvironmentThreadShell[] = []; - const settled: EnvironmentThreadShell[] = []; - for (const thread of visible) { - // Threads on servers without the settlement capability (old server, - // or descriptor not loaded yet) never classify as settled: the user - // could neither un-settle nor pin them, so auto-settling them would - // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - // Snooze outranks settled classification: an explicitly snoozed thread - // belongs to the shelf even if it would also auto-settle (the shelf's - // wake time is a stronger statement about when it matters again). - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { - snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { - settled.push(thread); - } else { - active.push(thread); + const { activeThreads, snoozedThreads, settledThreads, snoozeNow, representativeKeyByThreadKey } = + useMemo(() => { + const now = `${nowMinute}:00.000Z`; + // Snooze classification uses a REAL clock, not the quantized minute: + // wake times are second-precise and a woken thread must not linger on + // the shelf for the rest of the minute. snoozeWakeTick re-runs this + // memo exactly at the next wake boundary. + void snoozeWakeTick; + const preciseNow = new Date().toISOString(); + const visibleBeforeCollapse = threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ); + // Chats sharing one worktree collapse to a single row (the earliest chat), + // classified and shown by that representative; the rest live only in the + // chat's worktree tab strip. + const { threads: visible, representativeKeyByThreadKey } = collapseWorktreeSiblings( + visibleBeforeCollapse, + (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + const active: EnvironmentThreadShell[] = []; + const snoozed: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + // Threads on servers without the settlement capability (old server, + // or descriptor not loaded yet) never classify as settled: the user + // could neither un-settle nor pin them, so auto-settling them would + // strand rows in a tail with no working affordances. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + // Snooze outranks settled classification: an explicitly snoozed thread + // belongs to the shelf even if it would also auto-settle (the shelf's + // wake time is a stronger statement about when it matters again). + if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + snoozed.push(thread); + } else if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } } - } - return { - activeThreads: sortThreadsForSidebarV2(active), - // Soonest wake first: "what comes back next" is the shelf's question. - snoozedThreads: snoozed.toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ), - settledThreads: sortSettledThreadsForSidebarV2(settled), - snoozeNow: preciseNow, - }; - }, [ - autoSettleAfterDays, - changeRequestStateByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + return { + activeThreads: sortThreadsForSidebarV2(active), + // Soonest wake first: "what comes back next" is the shelf's question. + snoozedThreads: snoozed.toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ), + settledThreads: sortSettledThreadsForSidebarV2(settled), + snoozeNow: preciseNow, + representativeKeyByThreadKey, + }; + }, [ + autoSettleAfterDays, + changeRequestStateByKey, + nowMinute, + scopedProjectKeys, + serverConfigs, + snoozeWakeTick, + threads, + ]); + // When the active route is a collapsed worktree sibling, its row is folded + // into the earliest chat's; highlight and keep that representative visible. + const effectiveRouteThreadKey = + routeThreadKey === null + ? null + : (representativeKeyByThreadKey.get(routeThreadKey) ?? routeThreadKey); // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted @@ -1705,17 +1722,18 @@ export default function SidebarV2() { // The open thread must never hide under "Show more": navigating into a // deep settled thread (search, deep link) pulls its row into the visible // tail so the highlight and the un-settle affordance stay reachable. - if (routeThreadKey !== null) { + if (effectiveRouteThreadKey !== null) { const routeThread = settledThreads .slice(settledVisibleCount) .find( (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + effectiveRouteThreadKey, ); if (routeThread !== undefined) visible.push(routeThread); } return visible; - }, [routeThreadKey, settledThreads, settledVisibleCount]); + }, [effectiveRouteThreadKey, settledThreads, settledVisibleCount]); const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; const showMoreSettled = useCallback( () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), @@ -1725,13 +1743,14 @@ export default function SidebarV2() { const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); const renderedSettledThreads = useMemo(() => { if (settledShelfExpanded) return visibleSettledThreads; - if (routeThreadKey === null) return []; + if (effectiveRouteThreadKey === null) return []; const routeThread = visibleSettledThreads.find( (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + effectiveRouteThreadKey, ); return routeThread === undefined ? [] : [routeThread]; - }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + }, [effectiveRouteThreadKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump @@ -1744,13 +1763,14 @@ export default function SidebarV2() { // snoozed thread reached by route (deep link, open before snoozing // elsewhere) keeps its row — with highlight and wake affordance — same // exception the settled tail's "Show more" makes. - if (routeThreadKey === null) return []; + if (effectiveRouteThreadKey === null) return []; const routeThread = snoozedThreads.find( (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + effectiveRouteThreadKey, ); return routeThread === undefined ? [] : [routeThread]; - }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); + }, [effectiveRouteThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], @@ -2394,7 +2414,9 @@ export default function SidebarV2() { navigateToThreadKey( resolveAdjacentThreadId({ threadIds: orderedThreadKeys, - currentThreadId: routeThreadKey, + // A collapsed sibling isn't in the ordered rows; step relative to + // the representative row that stands in for it. + currentThreadId: effectiveRouteThreadKey, direction: traversalDirection, }), ); @@ -2407,6 +2429,7 @@ export default function SidebarV2() { window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); }, [ + effectiveRouteThreadKey, keybindings, navigateToThread, orderedThreadKeys, @@ -2674,7 +2697,7 @@ export default function SidebarV2() { // the wake signal must survive the trip. Still-snoozed // rows resolve to null on their own. wokeAt={threadWokeAt(thread, { now: snoozeNow })} - isActive={routeThreadKey === threadKey} + isActive={effectiveRouteThreadKey === threadKey} jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} currentEnvironmentId={primaryEnvironmentId} environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}