Skip to content

Commit 217dc7e

Browse files
committed
add: collapse idle
1 parent 02989fe commit 217dc7e

File tree

3 files changed

+154
-35
lines changed

3 files changed

+154
-35
lines changed

apps/web/src/components/Sidebar.logic.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22

33
import {
4+
collectSidebarNonIdleProjectIds,
45
getFallbackThreadIdAfterDelete,
56
getVisibleThreadsForProject,
67
getProjectSortTimestamp,
@@ -96,6 +97,55 @@ describe("resolveSidebarNewThreadEnvMode", () => {
9697
});
9798
});
9899

100+
describe("collectSidebarNonIdleProjectIds", () => {
101+
const projectA = "project-a" as never;
102+
const projectB = "project-b" as never;
103+
const threadA = { id: "thread-a" as never, projectId: projectA };
104+
const threadB = { id: "thread-b" as never, projectId: projectB };
105+
const workingStatus = {
106+
label: "Working" as const,
107+
colorClass: "text-sky-600",
108+
dotClass: "bg-sky-500",
109+
pulse: true,
110+
};
111+
112+
it("preserves a project when one of its threads has a running terminal", () => {
113+
const ids = collectSidebarNonIdleProjectIds({
114+
activeProjectId: null,
115+
threads: [threadA],
116+
threadStatusById: new Map([[threadA.id, null]]),
117+
runningTerminalThreadIds: new Set([threadA.id]),
118+
});
119+
120+
expect(ids).toEqual(new Set([projectA]));
121+
});
122+
123+
it("excludes projects that have neither thread status nor running terminals", () => {
124+
const ids = collectSidebarNonIdleProjectIds({
125+
activeProjectId: null,
126+
threads: [threadA, threadB],
127+
threadStatusById: new Map([
128+
[threadA.id, null],
129+
[threadB.id, workingStatus],
130+
]),
131+
runningTerminalThreadIds: new Set(),
132+
});
133+
134+
expect(ids).toEqual(new Set([projectB]));
135+
});
136+
137+
it("preserves the active project even without thread status or terminal activity", () => {
138+
const ids = collectSidebarNonIdleProjectIds({
139+
activeProjectId: projectA,
140+
threads: [threadA],
141+
threadStatusById: new Map([[threadA.id, null]]),
142+
runningTerminalThreadIds: new Set(),
143+
});
144+
145+
expect(ids).toEqual(new Set([projectA]));
146+
});
147+
});
148+
99149
describe("resolveThreadStatusPill", () => {
100150
const baseThread = {
101151
interactionMode: "plan" as const,

apps/web/src/components/Sidebar.logic.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ProjectId, ThreadId } from "@t3tools/contracts";
12
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
23
import type { Thread } from "../types";
34
import { cn } from "../lib/utils";
@@ -67,6 +68,27 @@ export function resolveSidebarNewThreadEnvMode(input: {
6768
return input.requestedEnvMode ?? input.defaultEnvMode;
6869
}
6970

71+
export function collectSidebarNonIdleProjectIds(input: {
72+
activeProjectId: ProjectId | null;
73+
threads: readonly Pick<Thread, "id" | "projectId">[];
74+
threadStatusById: ReadonlyMap<ThreadId, ThreadStatusPill | null>;
75+
runningTerminalThreadIds: ReadonlySet<ThreadId>;
76+
}): Set<ProjectId> {
77+
const ids = new Set<ProjectId>();
78+
if (input.activeProjectId) {
79+
ids.add(input.activeProjectId);
80+
}
81+
82+
for (const thread of input.threads) {
83+
const threadStatus = input.threadStatusById.get(thread.id) ?? null;
84+
if (threadStatus !== null || input.runningTerminalThreadIds.has(thread.id)) {
85+
ids.add(thread.projectId);
86+
}
87+
}
88+
89+
return ids;
90+
}
91+
7092
export function resolveThreadRowClassName(input: {
7193
isActive: boolean;
7294
isSelected: boolean;

apps/web/src/components/Sidebar.tsx

Lines changed: 82 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
ArrowLeftIcon,
33
ArrowUpDownIcon,
44
ChevronRightIcon,
5+
FoldVerticalIcon,
56
FolderIcon,
67
GitPullRequestIcon,
78
PlusIcon,
@@ -93,6 +94,7 @@ import {
9394
getFallbackThreadIdAfterDelete,
9495
getVisibleThreadsForProject,
9596
resolveProjectStatusIndicator,
97+
collectSidebarNonIdleProjectIds,
9698
resolveSidebarNewThreadEnvMode,
9799
resolveThreadRowClassName,
98100
resolveThreadStatusPill,
@@ -130,12 +132,6 @@ function formatRelativeTime(iso: string): string {
130132
return `${Math.floor(hours / 24)}d ago`;
131133
}
132134

133-
interface TerminalStatusIndicator {
134-
label: "Terminal process running";
135-
colorClass: string;
136-
pulse: boolean;
137-
}
138-
139135
interface PrStatusIndicator {
140136
label: "PR open" | "PR closed" | "PR merged";
141137
colorClass: string;
@@ -145,19 +141,6 @@ interface PrStatusIndicator {
145141

146142
type ThreadPr = GitStatusResult["pr"];
147143

148-
function terminalStatusFromRunningIds(
149-
runningTerminalIds: string[],
150-
): TerminalStatusIndicator | null {
151-
if (runningTerminalIds.length === 0) {
152-
return null;
153-
}
154-
return {
155-
label: "Terminal process running",
156-
colorClass: "text-teal-600 dark:text-teal-300/90",
157-
pulse: true,
158-
};
159-
}
160-
161144
function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
162145
if (!pr) return null;
163146

@@ -365,6 +348,7 @@ export default function Sidebar() {
365348
const projects = useStore((store) => store.projects);
366349
const threads = useStore((store) => store.threads);
367350
const markThreadUnread = useStore((store) => store.markThreadUnread);
351+
const setProjectExpanded = useStore((store) => store.setProjectExpanded);
368352
const toggleProject = useStore((store) => store.toggleProject);
369353
const reorderProjects = useStore((store) => store.reorderProjects);
370354
const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
@@ -471,6 +455,51 @@ export default function Sidebar() {
471455
return map;
472456
}, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
473457

458+
const threadStatusById = useMemo(() => {
459+
const map = new Map<ThreadId, ReturnType<typeof resolveThreadStatusPill>>();
460+
for (const thread of threads) {
461+
map.set(
462+
thread.id,
463+
resolveThreadStatusPill({
464+
thread,
465+
hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
466+
hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
467+
}),
468+
);
469+
}
470+
return map;
471+
}, [threads]);
472+
473+
const runningTerminalThreadIds = useMemo(() => {
474+
const ids = new Set<ThreadId>();
475+
for (const thread of threads) {
476+
if (selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds.length) {
477+
ids.add(thread.id);
478+
}
479+
}
480+
return ids;
481+
}, [terminalStateByThreadId, threads]);
482+
483+
const activeThread = routeThreadId
484+
? threads.find((thread) => thread.id === routeThreadId)
485+
: undefined;
486+
const activeDraftThread = useComposerDraftStore((store) =>
487+
routeThreadId ? store.draftThreadsByThreadId[routeThreadId] : undefined,
488+
);
489+
const activeProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;
490+
491+
// Currently active project and projects with a thread status or running terminal
492+
const nonIdleProjectIds = useMemo(
493+
() =>
494+
collectSidebarNonIdleProjectIds({
495+
activeProjectId,
496+
threads,
497+
threadStatusById,
498+
runningTerminalThreadIds,
499+
}),
500+
[activeProjectId, runningTerminalThreadIds, threadStatusById, threads],
501+
);
502+
474503
const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
475504
event.preventDefault();
476505
event.stopPropagation();
@@ -1113,13 +1142,7 @@ export default function Sidebar() {
11131142
appSettings.sidebarThreadSortOrder,
11141143
);
11151144
const projectStatus = resolveProjectStatusIndicator(
1116-
projectThreads.map((thread) =>
1117-
resolveThreadStatusPill({
1118-
thread,
1119-
hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
1120-
hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
1121-
}),
1122-
),
1145+
projectThreads.map((thread) => threadStatusById.get(thread.id) ?? null),
11231146
);
11241147
const activeThreadId = routeThreadId ?? undefined;
11251148
const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
@@ -1140,15 +1163,15 @@ export default function Sidebar() {
11401163
const isActive = routeThreadId === thread.id;
11411164
const isSelected = selectedThreadIds.has(thread.id);
11421165
const isHighlighted = isActive || isSelected;
1143-
const threadStatus = resolveThreadStatusPill({
1144-
thread,
1145-
hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
1146-
hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
1147-
});
1166+
const threadStatus = threadStatusById.get(thread.id) ?? null;
11481167
const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
1149-
const terminalStatus = terminalStatusFromRunningIds(
1150-
selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds,
1151-
);
1168+
const terminalStatus = runningTerminalThreadIds.has(thread.id)
1169+
? {
1170+
label: "Terminal process running",
1171+
colorClass: "text-teal-600 dark:text-teal-300/90",
1172+
pulse: true,
1173+
}
1174+
: null;
11521175

11531176
return (
11541177
<SidebarMenuSubItem key={thread.id} className="w-full" data-thread-item>
@@ -1447,6 +1470,15 @@ export default function Sidebar() {
14471470
[toggleProject],
14481471
);
14491472

1473+
const handleCollapseIdleProjects = useCallback(() => {
1474+
for (const project of projects) {
1475+
if (!project.expanded || nonIdleProjectIds.has(project.id)) {
1476+
continue;
1477+
}
1478+
setProjectExpanded(project.id, false);
1479+
}
1480+
}, [projects, nonIdleProjectIds, setProjectExpanded]);
1481+
14501482
useEffect(() => {
14511483
const onMouseDown = (event: globalThis.MouseEvent) => {
14521484
if (selectedThreadIds.size === 0) return;
@@ -1687,7 +1719,7 @@ export default function Sidebar() {
16871719
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
16881720
Projects
16891721
</span>
1690-
<div className="flex items-center gap-1">
1722+
<div className="flex items-center gap-0.5">
16911723
<ProjectSortMenu
16921724
projectSortOrder={appSettings.sidebarProjectSortOrder}
16931725
threadSortOrder={appSettings.sidebarThreadSortOrder}
@@ -1698,6 +1730,21 @@ export default function Sidebar() {
16981730
updateSettings({ sidebarThreadSortOrder: sortOrder });
16991731
}}
17001732
/>
1733+
<Tooltip>
1734+
<TooltipTrigger
1735+
render={
1736+
<button
1737+
type="button"
1738+
aria-label="Collapse idle projects"
1739+
className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
1740+
onClick={handleCollapseIdleProjects}
1741+
/>
1742+
}
1743+
>
1744+
<FoldVerticalIcon className="size-3.5" />
1745+
</TooltipTrigger>
1746+
<TooltipPopup side="top">Collapse idle projects</TooltipPopup>
1747+
</Tooltip>
17011748
<Tooltip>
17021749
<TooltipTrigger
17031750
render={

0 commit comments

Comments
 (0)