From 3e7fc28bfdb867a2034436771e0ddb7b7f119b9b Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 20 Mar 2026 13:54:52 -0300 Subject: [PATCH 1/9] Show project status dots when collapsed in the sidebar (#1097) --- apps/web/src/components/Sidebar.logic.test.ts | 51 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 27 ++++++++++ apps/web/src/components/Sidebar.tsx | 37 ++++++++++++-- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 8c3b470105..19d6f30609 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, @@ -230,3 +231,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 d9b394e4dd..ef338dab67 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -22,6 +22,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" @@ -151,3 +160,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 f16cafc1a6..e0ea9584a7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -100,6 +100,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -1847,6 +1848,15 @@ 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 filteredProjectThreads = hasActiveThreadSearch ? projectThreads.filter((thread) => threadTitleMatchesSearch(thread, normalizedThreadSearchQuery), @@ -1889,11 +1899,28 @@ export default function Sidebar() { }); }} > - + {!isProjectOpen && projectStatus ? ( +