authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-03-20 13:54:52-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-20 09:54:52-07:00
logbc12421736fdf014953726b3a1e604652c45b088
tree1d923160e566a8e961ec9be87bd759c6ff78ec3c
parent8f11ea44a75ba1a350d888daa8f773ad716c5cd7
signature Signed by PGP key B5690EEEBB952194

Show project status dots when collapsed in the sidebar (#1097)


3 files changed, 111 insertions(+), 6 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+51
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
33import {
44 hasUnseenCompletion,
5 resolveProjectStatusIndicator,
56 resolveSidebarNewThreadEnvMode,
67 resolveThreadRowClassName,
78 resolveThreadStatusPill,
......@@ -230,3 +231,53 @@ describe("resolveThreadRowClassName", () => {
230231 expect(className).toContain("hover:bg-accent");
231232 });
232233});
234
235describe("resolveProjectStatusIndicator", () => {
236 it("returns null when no threads have a notable status", () => {
237 expect(resolveProjectStatusIndicator([null, null])).toBeNull();
238 });
239
240 it("surfaces the highest-priority actionable state across project threads", () => {
241 expect(
242 resolveProjectStatusIndicator([
243 {
244 label: "Completed",
245 colorClass: "text-emerald-600",
246 dotClass: "bg-emerald-500",
247 pulse: false,
248 },
249 {
250 label: "Pending Approval",
251 colorClass: "text-amber-600",
252 dotClass: "bg-amber-500",
253 pulse: false,
254 },
255 {
256 label: "Working",
257 colorClass: "text-sky-600",
258 dotClass: "bg-sky-500",
259 pulse: true,
260 },
261 ]),
262 ).toMatchObject({ label: "Pending Approval", dotClass: "bg-amber-500" });
263 });
264
265 it("prefers plan-ready over completed when no stronger action is needed", () => {
266 expect(
267 resolveProjectStatusIndicator([
268 {
269 label: "Completed",
270 colorClass: "text-emerald-600",
271 dotClass: "bg-emerald-500",
272 pulse: false,
273 },
274 {
275 label: "Plan Ready",
276 colorClass: "text-violet-600",
277 dotClass: "bg-violet-500",
278 pulse: false,
279 },
280 ]),
281 ).toMatchObject({ label: "Plan Ready", dotClass: "bg-violet-500" });
282 });
283});
apps/web/src/components/Sidebar.logic.ts+27
......@@ -22,6 +22,15 @@ export interface ThreadStatusPill {
2222 pulse: boolean;
2323}
2424
25const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
26 "Pending Approval": 5,
27 "Awaiting Input": 4,
28 Working: 3,
29 Connecting: 3,
30 "Plan Ready": 2,
31 Completed: 1,
32};
33
2534type ThreadStatusInput = Pick<
2635 Thread,
2736 "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
......@@ -151,3 +160,21 @@ export function resolveThreadStatusPill(input: {
151160
152161 return null;
153162}
163
164export function resolveProjectStatusIndicator(
165 statuses: ReadonlyArray<ThreadStatusPill | null>,
166): ThreadStatusPill | null {
167 let highestPriorityStatus: ThreadStatusPill | null = null;
168
169 for (const status of statuses) {
170 if (status === null) continue;
171 if (
172 highestPriorityStatus === null ||
173 THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label]
174 ) {
175 highestPriorityStatus = status;
176 }
177 }
178
179 return highestPriorityStatus;
180}
apps/web/src/components/Sidebar.tsx+33-6
......@@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
8484import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
8585import { isNonEmpty as isNonEmptyString } from "effect/String";
8686import {
87 resolveProjectStatusIndicator,
8788 resolveSidebarNewThreadEnvMode,
8889 resolveThreadRowClassName,
8990 resolveThreadStatusPill,
......@@ -1340,13 +1341,22 @@ export default function Sidebar() {
13401341 if (byDate !== 0) return byDate;
13411342 return b.id.localeCompare(a.id);
13421343 });
1344 const projectStatus = resolveProjectStatusIndicator(
1345 projectThreads.map((thread) =>
1346 resolveThreadStatusPill({
1347 thread,
1348 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
1349 hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
1350 }),
1351 ),
1352 );
13431353 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
13441354 const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
13451355 const visibleThreads =
13461356 hasHiddenThreads && !isThreadListExpanded
13471357 ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT)
13481358 : projectThreads;
1349 const orderedProjectThreadIds = projectThreads.map((t) => t.id);
1359 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
13501360
13511361 return (
13521362 <SortableProjectItem key={project.id} projectId={project.id}>
......@@ -1369,11 +1379,28 @@ export default function Sidebar() {
13691379 });
13701380 }}
13711381 >
1372 <ChevronRightIcon
1373 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1374 project.expanded ? "rotate-90" : ""
1375 }`}
1376 />
1382 {!project.expanded && projectStatus ? (
1383 <span
1384 aria-hidden="true"
1385 title={projectStatus.label}
1386 className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
1387 >
1388 <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
1389 <span
1390 className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
1391 projectStatus.pulse ? "animate-pulse" : ""
1392 }`}
1393 />
1394 </span>
1395 <ChevronRightIcon className="absolute inset-0 m-auto size-3.5 text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover/project-header:opacity-100" />
1396 </span>
1397 ) : (
1398 <ChevronRightIcon
1399 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
1400 project.expanded ? "rotate-90" : ""
1401 }`}
1402 />
1403 )}
13771404 <ProjectFavicon cwd={project.cwd} />
13781405 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
13791406 {project.name}