authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-04 15:20:20-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 15:20:20-04:00
log45bd3b631bf1e84520c90ebdfdd1b135871ced14
treeef764e92c361bdb10ce775fa7aaab4fdf0f52151
parentd115a96763b76d00f06b06272688cf68eebe8206
signature Signed by PGP key B5690EEEBB952194

fix(sidebar): mute background working threads (#9759)


3 files changed, 85 insertions(+), 20 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+46
...@@ -29,6 +29,7 @@ import {...@@ -29,6 +29,7 @@ import {
29 formatWorkingDurationLabel,29 formatWorkingDurationLabel,
30 shouldNavigateAfterProjectRemoval,30 shouldNavigateAfterProjectRemoval,
31 shouldClearThreadSelectionOnMouseDown,31 shouldClearThreadSelectionOnMouseDown,
32 shouldRecedeSidebarThread,
32 sortLogicalProjectsForSidebar,33 sortLogicalProjectsForSidebar,
33 sortSettledThreadsForSidebar,34 sortSettledThreadsForSidebar,
34 pinOrderKeyBetween,35 pinOrderKeyBetween,
...@@ -295,6 +296,51 @@ describe("hasUnseenCompletion", () => {...@@ -295,6 +296,51 @@ describe("hasUnseenCompletion", () => {
295 });296 });
296});297});
297298
299describe("shouldRecedeSidebarThread", () => {
300 it.each(["working", "monitoring"] as const)(
301 "recedes an inactive %s thread even when it is unread and woke",
302 (status) => {
303 expect(
304 shouldRecedeSidebarThread({
305 status,
306 isUnread: true,
307 isWoke: true,
308 isActive: false,
309 isSelected: false,
310 }),
311 ).toBe(true);
312 },
313 );
314
315 it.each(["ready", "approval", "input"] as const)(
316 "keeps an unread %s thread prominent",
317 (status) => {
318 expect(
319 shouldRecedeSidebarThread({
320 status,
321 isUnread: true,
322 isWoke: false,
323 isActive: false,
324 isSelected: false,
325 }),
326 ).toBe(false);
327 },
328 );
329
330 it("keeps active and selected working threads prominent", () => {
331 const input = {
332 status: "working" as const,
333 isUnread: true,
334 isWoke: true,
335 isActive: false,
336 isSelected: false,
337 };
338
339 expect(shouldRecedeSidebarThread({ ...input, isActive: true })).toBe(false);
340 expect(shouldRecedeSidebarThread({ ...input, isSelected: true })).toBe(false);
341 });
342});
343
298describe("createThreadJumpHintVisibilityController", () => {344describe("createThreadJumpHintVisibilityController", () => {
299 beforeEach(() => {345 beforeEach(() => {
300 vi.useFakeTimers();346 vi.useFakeTimers();
apps/web/src/components/Sidebar.logic.ts+15
...@@ -532,6 +532,21 @@ export type SidebarThreadStatus =...@@ -532,6 +532,21 @@ export type SidebarThreadStatus =
532 | "failed"532 | "failed"
533 | "ready";533 | "ready";
534534
535export function shouldRecedeSidebarThread(input: {
536 status: SidebarThreadStatus;
537 isUnread: boolean;
538 isWoke: boolean;
539 isActive: boolean;
540 isSelected: boolean;
541}): boolean {
542 if (input.isActive || input.isSelected) return false;
543 if (input.status === "working" || input.status === "monitoring") return true;
544 if (input.status === "ready" || input.status === "approval" || input.status === "input") {
545 return !input.isUnread && !input.isWoke;
546 }
547 return false;
548}
549
535type SidebarThreadStatusInput = Pick<550type SidebarThreadStatusInput = Pick<
536 SidebarThreadSummary,551 SidebarThreadSummary,
537 "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness"552 "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness"
apps/web/src/components/Sidebar.tsx+24-20
...@@ -144,6 +144,7 @@ import {...@@ -144,6 +144,7 @@ import {
144 resolveSidebarThreadStatus,144 resolveSidebarThreadStatus,
145 searchSidebarThreadsByTitle,145 searchSidebarThreadsByTitle,
146 shouldCreateNewThreadInCurrentProject,146 shouldCreateNewThreadInCurrentProject,
147 shouldRecedeSidebarThread,
147 resolveWorkingStartedAt,148 resolveWorkingStartedAt,
148 sortLogicalProjectsForSidebar,149 sortLogicalProjectsForSidebar,
149 sortPinnedThreadsForSidebar,150 sortPinnedThreadsForSidebar,
...@@ -874,6 +875,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -874,6 +875,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
874 // switching sidebars must not light up every historical thread as unread.875 // switching sidebars must not light up every historical thread as unread.
875 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });876 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
876 const status = resolveSidebarThreadStatus(thread);877 const status = resolveSidebarThreadStatus(thread);
878 const isInFlight =
879 status === "working" || status === "monitoring" || status === "approval" || status === "input";
877 // A woken thread reappears at its original position (the sort is880 // A woken thread reappears at its original position (the sort is
878 // deliberately static), so the pill has to carry the weight. Snoozing is881 // deliberately static), so the pill has to carry the weight. Snoozing is
879 // an explicit act, so the pill clears only when the user re-engages:882 // an explicit act, so the pill clears only when the user re-engages:
...@@ -887,17 +890,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -887,17 +890,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
887 wokeAtDate !== null &&890 wokeAtDate !== null &&
888 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&891 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
889 thread.settledOverride !== "settled";892 thread.settledOverride !== "settled";
890 // In-flight rows (working, or waiting on approval/input) fade as a whole:893 // Background work always recedes when it is not selected: an unread parent
891 // there is nothing for the user to do yet, so prominence is reserved for894 // completion must not pull a still-working thread back into the foreground.
892 // rows that need a human — done (unread), read-but-unsettled, failed, and895 // Ready and action-required rows keep their unread and wake prominence.
893 // freshly woken. The status label keeps its hue, so waiting rows stay896 const shouldRecede = shouldRecedeSidebarThread({
894 // findable. In-flight rows recede the same as read-ready ones (inbox-zero:897 status,
895 // working threads aren't your problem yet) — only the colored status label898 isUnread,
896 // stands out.899 isWoke,
897 const isInFlight =900 isActive: props.isActive,
898 status === "working" || status === "monitoring" || status === "approval" || status === "input";901 isSelected,
899 const shouldRecede =902 });
900 (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected;
901 // Status hues follow the system-wide convention set by sidebar v1 and the903 // Status hues follow the system-wide convention set by sidebar v1 and the
902 // mobile Live Activity/widgets (amber approval, indigo input, sky working)904 // mobile Live Activity/widgets (amber approval, indigo input, sky working)
903 // so a thread reads the same color everywhere it surfaces.905 // so a thread reads the same color everywhere it surfaces.
...@@ -1210,21 +1212,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1210,21 +1212,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1210 variant === "card"1212 variant === "card"
1211 ? cn(1213 ? cn(
1212 "truncate",1214 "truncate",
1213 isUnread || isWoke1215 shouldRecede
1214 ? "text-foreground"1216 ? "text-secondary-label"
1215 : shouldRecede1217 : isUnread || isWoke
1216 ? "text-secondary-label"1218 ? "text-foreground"
1217 : status === "failed"1219 : status === "failed"
1218 ? "text-foreground/95"1220 ? "text-foreground/95"
1219 : "text-foreground/90",1221 : "text-foreground/90",
1220 )1222 )
1221 : cn(1223 : cn(
1222 "truncate group-hover/sidebar-row:text-foreground",1224 "truncate group-hover/sidebar-row:text-foreground",
1223 props.isActive || isWoke1225 shouldRecede
1224 ? "text-foreground"1226 ? "text-secondary-label/70"
1225 : isUnread1227 : props.isActive || isWoke
1226 ? "text-muted-foreground"1228 ? "text-foreground"
1227 : "text-secondary-label/70",1229 : isUnread
1230 ? "text-muted-foreground"
1231 : "text-secondary-label/70",
1228 ),1232 ),
1229 isRegeneratingTitle && "opacity-[0.55]",1233 isRegeneratingTitle && "opacity-[0.55]",
1230 )}1234 )}