From f40c08ac05314a2d84b938fb91fbd547c714883c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 14 Mar 2026 22:21:31 -0300 Subject: [PATCH 1/2] Show collapsed project status dots in sidebar --- apps/web/src/components/Sidebar.logic.test.ts | 51 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 27 ++++++++++ apps/web/src/components/Sidebar.tsx | 36 ++++++++++--- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a8f84d564a..9023b0c904 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasUnseenCompletion, + resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -199,3 +200,53 @@ describe("resolveThreadRowClassName", () => { expect(className).toContain("hover:bg-accent"); }); }); + +describe("resolveProjectStatusIndicator", () => { + it("returns null when no threads have a notable status", () => { + expect(resolveProjectStatusIndicator([null, null])).toBeNull(); + }); + + it("surfaces the highest-priority actionable state across project threads", () => { + expect( + resolveProjectStatusIndicator([ + { + label: "Completed", + colorClass: "text-emerald-600", + dotClass: "bg-emerald-500", + pulse: false, + }, + { + label: "Pending Approval", + colorClass: "text-amber-600", + dotClass: "bg-amber-500", + pulse: false, + }, + { + label: "Working", + colorClass: "text-sky-600", + dotClass: "bg-sky-500", + pulse: true, + }, + ]), + ).toMatchObject({ label: "Pending Approval", dotClass: "bg-amber-500" }); + }); + + it("prefers plan-ready over completed when no stronger action is needed", () => { + expect( + resolveProjectStatusIndicator([ + { + label: "Completed", + colorClass: "text-emerald-600", + dotClass: "bg-emerald-500", + pulse: false, + }, + { + label: "Plan Ready", + colorClass: "text-violet-600", + dotClass: "bg-violet-500", + pulse: false, + }, + ]), + ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" }); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f08ed212a1..1a40e698f1 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -18,6 +18,15 @@ export interface ThreadStatusPill { pulse: boolean; } +const THREAD_STATUS_PRIORITY: Record = { + "Pending Approval": 5, + "Awaiting Input": 4, + Working: 3, + Connecting: 3, + "Plan Ready": 2, + Completed: 1, +}; + type ThreadStatusInput = Pick< Thread, "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" @@ -145,3 +154,21 @@ export function resolveThreadStatusPill(input: { return null; } + +export function resolveProjectStatusIndicator( + statuses: ReadonlyArray, +): ThreadStatusPill | null { + let highestPriorityStatus: ThreadStatusPill | null = null; + + for (const status of statuses) { + if (status === null) continue; + if ( + highestPriorityStatus === null || + THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label] + ) { + highestPriorityStatus = status; + } + } + + return highestPriorityStatus; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b43eb4c18..f1f5a87261 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -1301,13 +1302,22 @@ export default function Sidebar() { if (byDate !== 0) return byDate; return b.id.localeCompare(a.id); }); + const projectStatus = resolveProjectStatusIndicator( + projectThreads.map((thread) => + resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }), + ), + ); const isThreadListExpanded = expandedThreadListsByProject.has(project.id); const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; const visibleThreads = hasHiddenThreads && !isThreadListExpanded ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) : projectThreads; - const orderedProjectThreadIds = projectThreads.map((t) => t.id); + const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); return ( @@ -1330,11 +1340,25 @@ export default function Sidebar() { }); }} > - + {!project.expanded && projectStatus ? ( +