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";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
3import {3import {
4 hasUnseenCompletion,4 hasUnseenCompletion,
5 resolveProjectStatusIndicator,
5 resolveSidebarNewThreadEnvMode,6 resolveSidebarNewThreadEnvMode,
6 resolveThreadRowClassName,7 resolveThreadRowClassName,
7 resolveThreadStatusPill,8 resolveThreadStatusPill,
...@@ -230,3 +231,53 @@ describe("resolveThreadRowClassName", () => {...@@ -230,3 +231,53 @@ describe("resolveThreadRowClassName", () => {
230 expect(className).toContain("hover:bg-accent");231 expect(className).toContain("hover:bg-accent");
231 });232 });
232});233});
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 {...@@ -22,6 +22,15 @@ export interface ThreadStatusPill {
22 pulse: boolean;22 pulse: boolean;
23}23}
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
25type ThreadStatusInput = Pick<34type ThreadStatusInput = Pick<
26 Thread,35 Thread,
27 "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"36 "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
...@@ -151,3 +160,21 @@ export function resolveThreadStatusPill(input: {...@@ -151,3 +160,21 @@ export function resolveThreadStatusPill(input: {
151160
152 return null;161 return null;
153}162}
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";...@@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
84import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";84import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
85import { isNonEmpty as isNonEmptyString } from "effect/String";85import { isNonEmpty as isNonEmptyString } from "effect/String";
86import {86import {
87 resolveProjectStatusIndicator,
87 resolveSidebarNewThreadEnvMode,88 resolveSidebarNewThreadEnvMode,
88 resolveThreadRowClassName,89 resolveThreadRowClassName,
89 resolveThreadStatusPill,90 resolveThreadStatusPill,
...@@ -1340,13 +1341,22 @@ export default function Sidebar() {...@@ -1340,13 +1341,22 @@ export default function Sidebar() {
1340 if (byDate !== 0) return byDate;1341 if (byDate !== 0) return byDate;
1341 return b.id.localeCompare(a.id);1342 return b.id.localeCompare(a.id);
1342 });1343 });
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 );
1343 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);1353 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
1344 const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;1354 const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
1345 const visibleThreads =1355 const visibleThreads =
1346 hasHiddenThreads && !isThreadListExpanded1356 hasHiddenThreads && !isThreadListExpanded
1347 ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT)1357 ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT)
1348 : projectThreads;1358 : projectThreads;
1349 const orderedProjectThreadIds = projectThreads.map((t) => t.id);1359 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
13501360
1351 return (1361 return (
1352 <SortableProjectItem key={project.id} projectId={project.id}>1362 <SortableProjectItem key={project.id} projectId={project.id}>
...@@ -1369,11 +1379,28 @@ export default function Sidebar() {...@@ -1369,11 +1379,28 @@ export default function Sidebar() {
1369 });1379 });
1370 }}1380 }}
1371 >1381 >
1372 <ChevronRightIcon1382 {!project.expanded && projectStatus ? (
1373 className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${1383 <span
1374 project.expanded ? "rotate-90" : ""1384 aria-hidden="true"
1375 }`}1385 title={projectStatus.label}
1376 />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 )}
1377 <ProjectFavicon cwd={project.cwd} />1404 <ProjectFavicon cwd={project.cwd} />
1378 <span className="flex-1 truncate text-xs font-medium text-foreground/90">1405 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
1379 {project.name}1406 {project.name}