From 55bdb9be1ec77ddd00d79f76bf98e44e236f4454 Mon Sep 17 00:00:00 2001 From: Timo Date: Fri, 21 Aug 2026 00:14:02 +0200 Subject: [PATCH] fix(web): stop dimming unread in-flight sidebar rows - Extract shared `sidebarRowNeedsAttention` helper so the receded surface and the in-flight opacity fade agree on which rows stay at full contrast - Unread and woken threads no longer get dimmed while a turn is in flight - Cover the helper with unit tests --- apps/web/src/components/Sidebar.logic.test.ts | 27 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 15 +++++++++++ apps/web/src/components/Sidebar.tsx | 15 ++++++----- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ba75f2eaaf54..3219c3136d96 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -27,6 +27,7 @@ import { formatWorkingDurationLabel, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, + sidebarRowNeedsAttention, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebar, pinOrderKeyBetween, @@ -318,6 +319,32 @@ describe("hasUnseenCompletion", () => { }); }); +describe("sidebarRowNeedsAttention", () => { + const quietRow = { + isUnread: false, + isWoke: false, + isActive: false, + isSelected: false, + }; + + it("lets a read row be dimmed", () => { + expect(sidebarRowNeedsAttention(quietRow)).toBe(false); + }); + + it("keeps an unread row at full contrast", () => { + expect(sidebarRowNeedsAttention({ ...quietRow, isUnread: true })).toBe(true); + }); + + it("keeps a woken row at full contrast", () => { + expect(sidebarRowNeedsAttention({ ...quietRow, isWoke: true })).toBe(true); + }); + + it("exempts the active and selected rows", () => { + expect(sidebarRowNeedsAttention({ ...quietRow, isActive: true })).toBe(true); + expect(sidebarRowNeedsAttention({ ...quietRow, isSelected: true })).toBe(true); + }); +}); + describe("createThreadJumpHintVisibilityController", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 747fc07d3daf..fd45ece671b1 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -267,6 +267,21 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { return completedAt > lastVisitedAt; } +/** + * Whether a sidebar row is exempt from every dimming treatment: it needs a + * human (unread completion, fresh wake) or the user is already on it. Both the + * receded surface and the in-flight fade read this, so the two can never + * disagree about which rows stay at full contrast. + */ +export function sidebarRowNeedsAttention(input: { + isUnread: boolean; + isWoke: boolean; + isActive: boolean; + isSelected: boolean; +}): boolean { + return input.isUnread || input.isWoke || input.isActive || input.isSelected; +} + export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { if (target === null) return true; return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0c30a5b6b55e..4082a82a752e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -135,6 +135,7 @@ import { resolveSidebarThreadStatus, searchSidebarThreadsByTitle, shouldCreateNewThreadInCurrentProject, + sidebarRowNeedsAttention, resolveWorkingStartedAt, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, @@ -833,8 +834,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // stands out. const isInFlight = status === "working" || status === "monitoring" || status === "approval" || status === "input"; - const shouldRecede = - (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; + const needsAttention = sidebarRowNeedsAttention({ + isUnread, + isWoke, + isActive: props.isActive, + isSelected, + }); + const shouldRecede = (status === "ready" || isInFlight) && !needsAttention; // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. @@ -1105,10 +1111,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : shouldRecede ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", - isInFlight && - !props.isActive && - !isSelected && - "opacity-70 transition-opacity hover:opacity-100", + isInFlight && !needsAttention && "opacity-70 transition-opacity hover:opacity-100", ); const title = isRenaming ? (