Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";

import {
hasUnseenCompletion,
resolveProjectStatusIndicator,
resolveSidebarNewThreadEnvMode,
resolveThreadRowClassName,
resolveThreadStatusPill,
Expand Down Expand Up @@ -199,3 +200,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" });
});
});
27 changes: 27 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ export interface ThreadStatusPill {
pulse: boolean;
}

const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
"Pending Approval": 5,
"Awaiting Input": 4,
Working: 3,
Connecting: 3,
"Plan Ready": 2,
Completed: 1,
};

type ThreadStatusInput = Pick<
Thread,
"interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
Expand Down Expand Up @@ -145,3 +154,21 @@ export function resolveThreadStatusPill(input: {

return null;
}

export function resolveProjectStatusIndicator(
statuses: ReadonlyArray<ThreadStatusPill | null>,
): 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;
}
39 changes: 33 additions & 6 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
import { isNonEmpty as isNonEmptyString } from "effect/String";
import {
resolveProjectStatusIndicator,
resolveSidebarNewThreadEnvMode,
resolveThreadRowClassName,
resolveThreadStatusPill,
Expand Down Expand Up @@ -1301,13 +1302,22 @@ 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 isThreadListExpanded = expandedThreadListsByProject.has(project.id);
const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
const visibleThreads =
hasHiddenThreads && !isThreadListExpanded
? projectThreads.slice(0, THREAD_PREVIEW_LIMIT)
: projectThreads;
const orderedProjectThreadIds = projectThreads.map((t) => t.id);
const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);

return (
<SortableProjectItem key={project.id} projectId={project.id}>
Expand All @@ -1330,11 +1340,28 @@ export default function Sidebar() {
});
}}
>
<ChevronRightIcon
className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
project.expanded ? "rotate-90" : ""
}`}
/>
{!project.expanded && projectStatus ? (
<span
aria-hidden="true"
title={projectStatus.label}
className={`-ml-0.5 relative inline-flex size-3.5 shrink-0 items-center justify-center ${projectStatus.colorClass}`}
>
<span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0">
<span
className={`size-[9px] rounded-full ${projectStatus.dotClass} ${
projectStatus.pulse ? "animate-pulse" : ""
}`}
/>
</span>
<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" />
</span>
) : (
<ChevronRightIcon
className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${
project.expanded ? "rotate-90" : ""
}`}
/>
)}
<ProjectFavicon cwd={project.cwd} />
<span className="flex-1 truncate text-xs font-medium text-foreground/90">
{project.name}
Expand Down