| 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,6 +2,7 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | |||
| 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,6 +22,15 @@ export interface ThreadStatusPill { |
| 22 | pulse: boolean; | 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 | type ThreadStatusInput = Pick< | 34 | type 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: { |
| 151 | 160 | ||
| 152 | return null; | 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,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 85 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 85 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 86 | import { | 86 | import { |
| 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 && !isThreadListExpanded | 1356 | 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); |
| 1350 | 1360 | ||
| 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 | <ChevronRightIcon | 1382 | {!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} |