From 1831722e98e042e89349a7028ea0866c83735382 Mon Sep 17 00:00:00 2001 From: ponbac Date: Tue, 17 Mar 2026 17:17:00 +0100 Subject: [PATCH 1/2] add: collapse idle --- apps/web/src/components/Sidebar.tsx | 111 +++++++++++++++++++++------- 1 file changed, 85 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index beacd1d94..438fee1bd 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, @@ -255,6 +256,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 +361,43 @@ 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 activeThread = routeThreadId + ? threads.find((thread) => thread.id === routeThreadId) + : undefined; + const activeDraftThread = useComposerDraftStore((store) => + routeThreadId ? store.draftThreadsByThreadId[routeThreadId] : undefined, + ); + const activeProjectId = activeThread?.projectId ?? activeDraftThread?.projectId; + + // Currently active project and projects with a thread status + const activeProjectIds = useMemo(() => { + const ids = new Set(); + if (activeProjectId) { + ids.add(activeProjectId); + } + for (const thread of threads) { + if (threadStatusById.get(thread.id) !== null) { + ids.add(thread.projectId); + } + } + return ids; + }, [activeProjectId, threadStatusById, threads]); + const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { event.preventDefault(); event.stopPropagation(); @@ -987,6 +1026,15 @@ export default function Sidebar() { [toggleProject], ); + const handleCollapseIdleProjects = useCallback(() => { + for (const project of projects) { + if (!project.expanded || activeProjectIds.has(project.id)) { + continue; + } + setProjectExpanded(project.id, false); + } + }, [projects, activeProjectIds, setProjectExpanded]); + useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { if (selectedThreadIds.size === 0) return; @@ -1230,26 +1278,43 @@ export default function Sidebar() { Projects - - + + + } + > + + + Collapse idle projects + + + + } + > + - } - > - - - Add project - + + Add project + + {shouldShowProjectPathEntry && ( @@ -1419,13 +1484,7 @@ 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, ); From 5d7f5ec104928579c2245f5f9e32aeef6f9fd762 Mon Sep 17 00:00:00 2001 From: ponbac Date: Thu, 19 Mar 2026 17:23:16 +0100 Subject: [PATCH 2/2] also count threads with a running terminal as non-idle --- apps/web/src/components/Sidebar.logic.test.ts | 50 +++++++++++++ apps/web/src/components/Sidebar.logic.ts | 22 ++++++ apps/web/src/components/Sidebar.tsx | 71 +++++++++---------- 3 files changed, 104 insertions(+), 39 deletions(-) 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 438fee1bd..f7dc6683f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -85,6 +85,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + collectSidebarNonIdleProjectIds, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -105,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; @@ -120,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; @@ -376,27 +358,35 @@ export default function Sidebar() { 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; + const activeProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; - // Currently active project and projects with a thread status - const activeProjectIds = useMemo(() => { - const ids = new Set(); - if (activeProjectId) { - ids.add(activeProjectId); - } - for (const thread of threads) { - if (threadStatusById.get(thread.id) !== null) { - ids.add(thread.projectId); - } - } - return ids; - }, [activeProjectId, threadStatusById, threads]); + // 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(); @@ -1028,12 +1018,12 @@ export default function Sidebar() { const handleCollapseIdleProjects = useCallback(() => { for (const project of projects) { - if (!project.expanded || activeProjectIds.has(project.id)) { + if (!project.expanded || nonIdleProjectIds.has(project.id)) { continue; } setProjectExpanded(project.id, false); } - }, [projects, activeProjectIds, setProjectExpanded]); + }, [projects, nonIdleProjectIds, setProjectExpanded]); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { @@ -1488,10 +1478,13 @@ export default function Sidebar() { 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 (