diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 634d91e9d..9412d64eb 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + getProjectIdsWithRunningThreads, hasUnseenCompletion, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -154,3 +155,59 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Completed", pulse: false }); }); }); + +describe("getProjectIdsWithRunningThreads", () => { + it("collects the project ids that have a running thread", () => { + expect( + getProjectIdsWithRunningThreads([ + { + projectId: "project-1" as never, + session: { + provider: "codex" as const, + status: "running" as const, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + orchestrationStatus: "running" as const, + }, + }, + { + projectId: "project-2" as never, + session: { + provider: "codex" as const, + status: "ready" as const, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + orchestrationStatus: "ready" as const, + }, + }, + ]), + ).toEqual(new Set(["project-1"])); + }); + + it("ignores non-running sessions", () => { + expect( + getProjectIdsWithRunningThreads([ + { + projectId: "project-2" as never, + session: { + provider: "codex" as const, + status: "running" as const, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + orchestrationStatus: "running" as const, + }, + }, + { + projectId: "project-3" as never, + session: { + provider: "codex" as const, + status: "ready" as const, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + orchestrationStatus: "ready" as const, + }, + }, + ]), + ).toEqual(new Set(["project-2"])); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f1afa0f64..a07741b69 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -21,6 +21,20 @@ type ThreadStatusInput = Pick< "interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session" >; +type ProjectActivityThread = Pick; + +export function getProjectIdsWithRunningThreads( + threads: readonly ProjectActivityThread[], +): ReadonlySet { + const projectIds = new Set(); + for (const thread of threads) { + if (thread.session?.status === "running") { + projectIds.add(thread.projectId); + } + } + return projectIds; +} + export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b80..4d630669a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -83,7 +83,11 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; -import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; +import { + getProjectIdsWithRunningThreads, + resolveThreadStatusPill, + shouldClearThreadSelectionOnMouseDown, +} from "./Sidebar.logic"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -324,6 +328,10 @@ export default function Sidebar() { } return map; }, [threads]); + const projectIdsWithRunningThreads = useMemo( + () => getProjectIdsWithRunningThreads(threads), + [threads], + ); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -1422,6 +1430,7 @@ export default function Sidebar() { strategy={verticalListSortingStrategy} > {projects.map((project) => { + const projectHasActiveRun = projectIdsWithRunningThreads.has(project.id); const projectThreads = threads .filter((thread) => thread.projectId === project.id) .toSorted((a, b) => { @@ -1465,7 +1474,13 @@ export default function Sidebar() { }`} /> - + {project.name} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 228179161..9faa311e5 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -4,6 +4,7 @@ @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; + --animate-project-title-shimmer: project-title-shimmer 2.6s ease-in-out infinite; --color-warning-foreground: var(--warning-foreground); --color-warning: var(--warning); --color-success-foreground: var(--success-foreground); @@ -41,6 +42,15 @@ background-position: -200% 0; } } + + @keyframes project-title-shimmer { + 0% { + background-position: 200% 50%; + } + 100% { + background-position: -20% 50%; + } + } } @layer base { @@ -212,6 +222,43 @@ input { display: none; } +.project-title-shimmer { + animation: var(--animate-project-title-shimmer); +} + +@media (prefers-reduced-motion: reduce) { + .project-title-shimmer { + animation: none; + } +} + +@supports ((-webkit-background-clip: text) or (background-clip: text)) { + .project-title-shimmer { + background-image: linear-gradient( + 112deg, + var(--foreground) 0%, + var(--foreground) 43%, + var(--color-sky-300) 48%, + var(--color-sky-500) 50%, + var(--color-sky-300) 52%, + var(--foreground) 57%, + var(--foreground) 100% + ); + background-size: 220% 100%; + background-position: 200% 50%; + color: transparent; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + } + + @media (prefers-reduced-motion: reduce) { + .project-title-shimmer { + background-position: 50% 50%; + } + } +} + /* Terminal drawer scrollbar parity with chat */ .thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical { width: 6px !important;