| author | |
| committer | |
| log | bc12421736fdf014953726b3a1e604652c45b088 |
| tree | 1d923160e566a8e961ec9be87bd759c6ff78ec3c |
| parent | 8f11ea44a75ba1a350d888daa8f773ad716c5cd7 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | hasUnseenCompletion, |
| 5 | resolveProjectStatusIndicator, | |
| 5 | 6 | resolveSidebarNewThreadEnvMode, |
| 6 | 7 | resolveThreadRowClassName, |
| 7 | 8 | resolveThreadStatusPill, |
| ... | ... | @@ -230,3 +231,53 @@ describe("resolveThreadRowClassName", () => { |
| 230 | 231 | expect(className).toContain("hover:bg-accent"); |
| 231 | 232 | }); |
| 232 | 233 | }); |
| 234 | ||
| 235 | describe("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 | 22 | pulse: boolean; |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | const 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 | ||
| 25 | 34 | type ThreadStatusInput = Pick< |
| 26 | 35 | Thread, |
| 27 | 36 | "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" |
| ... | ... | @@ -151,3 +160,21 @@ export function resolveThreadStatusPill(input: { |
| 151 | 160 | |
| 152 | 161 | return null; |
| 153 | 162 | } |
| 163 | ||
| 164 | export 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 | 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 85 | 85 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 86 | 86 | import { |
| 87 | resolveProjectStatusIndicator, | |
| 87 | 88 | resolveSidebarNewThreadEnvMode, |
| 88 | 89 | resolveThreadRowClassName, |
| 89 | 90 | resolveThreadStatusPill, |
| ... | ... | @@ -1340,13 +1341,22 @@ export default function Sidebar() { |
| 1340 | 1341 | if (byDate !== 0) return byDate; |
| 1341 | 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 | 1353 | const isThreadListExpanded = expandedThreadListsByProject.has(project.id); |
| 1344 | 1354 | const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; |
| 1345 | 1355 | const visibleThreads = |
| 1346 | 1356 | hasHiddenThreads && !isThreadListExpanded |
| 1347 | 1357 | ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) |
| 1348 | 1358 | : projectThreads; |
| 1349 | const orderedProjectThreadIds = projectThreads.map((t) => t.id); | |
| 1359 | const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); | |
| 1350 | 1360 | |
| 1351 | 1361 | return ( |
| 1352 | 1362 | <SortableProjectItem key={project.id} projectId={project.id}> |
| ... | ... | @@ -1369,11 +1379,28 @@ export default function Sidebar() { |
| 1369 | 1379 | }); |
| 1370 | 1380 | }} |
| 1371 | 1381 | > |
| 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 | )} | |
| 1377 | 1404 | <ProjectFavicon cwd={project.cwd} /> |
| 1378 | 1405 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> |
| 1379 | 1406 | {project.name} |