diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 8c3b47010..83a691ba7 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 { + collectSidebarNonIdleProjectIds, hasUnseenCompletion, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -83,6 +84,55 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("collectSidebarNonIdleProjectIds", () => { + const projectA = "project-a" as never; + const projectB = "project-b" as never; + const threadA = { id: "thread-a" as never, projectId: projectA }; + const threadB = { id: "thread-b" as never, projectId: projectB }; + const workingStatus = { + label: "Working" as const, + colorClass: "text-sky-600", + dotClass: "bg-sky-500", + pulse: true, + }; + + it("preserves a project when one of its threads has a running terminal", () => { + const ids = collectSidebarNonIdleProjectIds({ + activeProjectId: null, + threads: [threadA], + threadStatusById: new Map([[threadA.id, null]]), + runningTerminalThreadIds: new Set([threadA.id]), + }); + + expect(ids).toEqual(new Set([projectA])); + }); + + it("excludes projects that have neither thread status nor running terminals", () => { + const ids = collectSidebarNonIdleProjectIds({ + activeProjectId: null, + threads: [threadA, threadB], + threadStatusById: new Map([ + [threadA.id, null], + [threadB.id, workingStatus], + ]), + runningTerminalThreadIds: new Set(), + }); + + expect(ids).toEqual(new Set([projectB])); + }); + + it("preserves the active project even without thread status or terminal activity", () => { + const ids = collectSidebarNonIdleProjectIds({ + activeProjectId: projectA, + threads: [threadA], + threadStatusById: new Map([[threadA.id, null]]), + runningTerminalThreadIds: new Set(), + }); + + expect(ids).toEqual(new Set([projectA])); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { interactionMode: "plan" as const, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index d9b394e4d..32a4f0501 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,3 +1,4 @@ +import type { ProjectId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; import { @@ -50,6 +51,27 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +export function collectSidebarNonIdleProjectIds(input: { + activeProjectId: ProjectId | null; + threads: readonly Pick[]; + threadStatusById: ReadonlyMap; + runningTerminalThreadIds: ReadonlySet; +}): Set { + const ids = new Set(); + if (input.activeProjectId) { + ids.add(input.activeProjectId); + } + + for (const thread of input.threads) { + const threadStatus = input.threadStatusById.get(thread.id) ?? null; + if (threadStatus !== null || input.runningTerminalThreadIds.has(thread.id)) { + ids.add(thread.projectId); + } + } + + return ids; +} + export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index beacd1d94..f7dc6683f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { ArrowLeftIcon, ChevronRightIcon, + FoldVerticalIcon, FolderIcon, GitPullRequestIcon, PlusIcon, @@ -84,6 +85,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + collectSidebarNonIdleProjectIds, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -104,12 +106,6 @@ function formatRelativeTime(iso: string): string { return `${Math.floor(hours / 24)}d ago`; } -interface TerminalStatusIndicator { - label: "Terminal process running"; - colorClass: string; - pulse: boolean; -} - interface PrStatusIndicator { label: "PR open" | "PR closed" | "PR merged"; colorClass: string; @@ -119,19 +115,6 @@ interface PrStatusIndicator { type ThreadPr = GitStatusResult["pr"]; -function terminalStatusFromRunningIds( - runningTerminalIds: string[], -): TerminalStatusIndicator | null { - if (runningTerminalIds.length === 0) { - return null; - } - return { - label: "Terminal process running", - colorClass: "text-teal-600 dark:text-teal-300/90", - pulse: true, - }; -} - function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { if (!pr) return null; @@ -255,6 +238,7 @@ export default function Sidebar() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); const markThreadUnread = useStore((store) => store.markThreadUnread); + const setProjectExpanded = useStore((store) => store.setProjectExpanded); const toggleProject = useStore((store) => store.toggleProject); const reorderProjects = useStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); @@ -359,6 +343,51 @@ export default function Sidebar() { return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); + const threadStatusById = useMemo(() => { + const map = new Map>(); + for (const thread of threads) { + map.set( + thread.id, + resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }), + ); + } + return map; + }, [threads]); + + const runningTerminalThreadIds = useMemo(() => { + const ids = new Set(); + for (const thread of threads) { + if (selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds.length) { + ids.add(thread.id); + } + } + return ids; + }, [terminalStateByThreadId, threads]); + + const activeThread = routeThreadId + ? threads.find((thread) => thread.id === routeThreadId) + : undefined; + const activeDraftThread = useComposerDraftStore((store) => + routeThreadId ? store.draftThreadsByThreadId[routeThreadId] : undefined, + ); + const activeProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + + // Currently active project and projects with a thread status or running terminal + const nonIdleProjectIds = useMemo( + () => + collectSidebarNonIdleProjectIds({ + activeProjectId, + threads, + threadStatusById, + runningTerminalThreadIds, + }), + [activeProjectId, runningTerminalThreadIds, threadStatusById, threads], + ); + const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { event.preventDefault(); event.stopPropagation(); @@ -987,6 +1016,15 @@ export default function Sidebar() { [toggleProject], ); + const handleCollapseIdleProjects = useCallback(() => { + for (const project of projects) { + if (!project.expanded || nonIdleProjectIds.has(project.id)) { + continue; + } + setProjectExpanded(project.id, false); + } + }, [projects, nonIdleProjectIds, setProjectExpanded]); + useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { if (selectedThreadIds.size === 0) return; @@ -1230,26 +1268,43 @@ export default function Sidebar() { Projects - - + + + } + > + + + Collapse idle projects + + + + } + > + - } - > - - - Add project - + + Add project + + {shouldShowProjectPathEntry && ( @@ -1419,20 +1474,17 @@ export default function Sidebar() { const isActive = routeThreadId === thread.id; const isSelected = selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; - const threadStatus = resolveThreadStatusPill({ - thread, - hasPendingApprovals: - derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: - derivePendingUserInputs(thread.activities).length > 0, - }); + const threadStatus = threadStatusById.get(thread.id) ?? null; const prStatus = prStatusIndicator( prByThreadId.get(thread.id) ?? null, ); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id) - .runningTerminalIds, - ); + const terminalStatus = runningTerminalThreadIds.has(thread.id) + ? { + label: "Terminal process running", + colorClass: "text-teal-600 dark:text-teal-300/90", + pulse: true, + } + : null; return (