Skip to content

Commit b3123eb

Browse files
committed
also count threads with a running terminal as non-idle
1 parent e5a1c68 commit b3123eb

3 files changed

Lines changed: 104 additions & 39 deletions

File tree

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
hasUnseenCompletion,
56
resolveProjectStatusIndicator,
67
resolveSidebarNewThreadEnvMode,
@@ -84,6 +85,55 @@ describe("resolveSidebarNewThreadEnvMode", () => {
8485
});
8586
});
8687

88+
describe("collectSidebarNonIdleProjectIds", () => {
89+
const projectA = "project-a" as never;
90+
const projectB = "project-b" as never;
91+
const threadA = { id: "thread-a" as never, projectId: projectA };
92+
const threadB = { id: "thread-b" as never, projectId: projectB };
93+
const workingStatus = {
94+
label: "Working" as const,
95+
colorClass: "text-sky-600",
96+
dotClass: "bg-sky-500",
97+
pulse: true,
98+
};
99+
100+
it("preserves a project when one of its threads has a running terminal", () => {
101+
const ids = collectSidebarNonIdleProjectIds({
102+
activeProjectId: null,
103+
threads: [threadA],
104+
threadStatusById: new Map([[threadA.id, null]]),
105+
runningTerminalThreadIds: new Set([threadA.id]),
106+
});
107+
108+
expect(ids).toEqual(new Set([projectA]));
109+
});
110+
111+
it("excludes projects that have neither thread status nor running terminals", () => {
112+
const ids = collectSidebarNonIdleProjectIds({
113+
activeProjectId: null,
114+
threads: [threadA, threadB],
115+
threadStatusById: new Map([
116+
[threadA.id, null],
117+
[threadB.id, workingStatus],
118+
]),
119+
runningTerminalThreadIds: new Set(),
120+
});
121+
122+
expect(ids).toEqual(new Set([projectB]));
123+
});
124+
125+
it("preserves the active project even without thread status or terminal activity", () => {
126+
const ids = collectSidebarNonIdleProjectIds({
127+
activeProjectId: projectA,
128+
threads: [threadA],
129+
threadStatusById: new Map([[threadA.id, null]]),
130+
runningTerminalThreadIds: new Set(),
131+
});
132+
133+
expect(ids).toEqual(new Set([projectA]));
134+
});
135+
});
136+
87137
describe("resolveThreadStatusPill", () => {
88138
const baseThread = {
89139
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 { Thread } from "../types";
23
import { cn } from "../lib/utils";
34
import {
@@ -59,6 +60,27 @@ export function resolveSidebarNewThreadEnvMode(input: {
5960
return input.requestedEnvMode ?? input.defaultEnvMode;
6061
}
6162

63+
export function collectSidebarNonIdleProjectIds(input: {
64+
activeProjectId: ProjectId | null;
65+
threads: readonly Pick<Thread, "id" | "projectId">[];
66+
threadStatusById: ReadonlyMap<ThreadId, ThreadStatusPill | null>;
67+
runningTerminalThreadIds: ReadonlySet<ThreadId>;
68+
}): Set<ProjectId> {
69+
const ids = new Set<ProjectId>();
70+
if (input.activeProjectId) {
71+
ids.add(input.activeProjectId);
72+
}
73+
74+
for (const thread of input.threads) {
75+
const threadStatus = input.threadStatusById.get(thread.id) ?? null;
76+
if (threadStatus !== null || input.runningTerminalThreadIds.has(thread.id)) {
77+
ids.add(thread.projectId);
78+
}
79+
}
80+
81+
return ids;
82+
}
83+
6284
export function resolveThreadRowClassName(input: {
6385
isActive: boolean;
6486
isSelected: boolean;

apps/web/src/components/Sidebar.tsx

Lines changed: 32 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "
8686
import { isNonEmpty as isNonEmptyString } from "effect/String";
8787
import {
8888
resolveProjectStatusIndicator,
89+
collectSidebarNonIdleProjectIds,
8990
resolveSidebarNewThreadEnvMode,
9091
resolveThreadRowClassName,
9192
resolveThreadStatusPill,
@@ -106,12 +107,6 @@ function formatRelativeTime(iso: string): string {
106107
return `${Math.floor(hours / 24)}d ago`;
107108
}
108109

109-
interface TerminalStatusIndicator {
110-
label: "Terminal process running";
111-
colorClass: string;
112-
pulse: boolean;
113-
}
114-
115110
interface PrStatusIndicator {
116111
label: "PR open" | "PR closed" | "PR merged";
117112
colorClass: string;
@@ -121,19 +116,6 @@ interface PrStatusIndicator {
121116

122117
type ThreadPr = GitStatusResult["pr"];
123118

124-
function terminalStatusFromRunningIds(
125-
runningTerminalIds: string[],
126-
): TerminalStatusIndicator | null {
127-
if (runningTerminalIds.length === 0) {
128-
return null;
129-
}
130-
return {
131-
label: "Terminal process running",
132-
colorClass: "text-teal-600 dark:text-teal-300/90",
133-
pulse: true,
134-
};
135-
}
136-
137119
function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
138120
if (!pr) return null;
139121

@@ -378,27 +360,35 @@ export default function Sidebar() {
378360
return map;
379361
}, [threads]);
380362

363+
const runningTerminalThreadIds = useMemo(() => {
364+
const ids = new Set<ThreadId>();
365+
for (const thread of threads) {
366+
if (selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds.length) {
367+
ids.add(thread.id);
368+
}
369+
}
370+
return ids;
371+
}, [terminalStateByThreadId, threads]);
372+
381373
const activeThread = routeThreadId
382374
? threads.find((thread) => thread.id === routeThreadId)
383375
: undefined;
384376
const activeDraftThread = useComposerDraftStore((store) =>
385377
routeThreadId ? store.draftThreadsByThreadId[routeThreadId] : undefined,
386378
);
387-
const activeProjectId = activeThread?.projectId ?? activeDraftThread?.projectId;
379+
const activeProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;
388380

389-
// Currently active project and projects with a thread status
390-
const activeProjectIds = useMemo(() => {
391-
const ids = new Set<ProjectId>();
392-
if (activeProjectId) {
393-
ids.add(activeProjectId);
394-
}
395-
for (const thread of threads) {
396-
if (threadStatusById.get(thread.id) !== null) {
397-
ids.add(thread.projectId);
398-
}
399-
}
400-
return ids;
401-
}, [activeProjectId, threadStatusById, threads]);
381+
// Currently active project and projects with a thread status or running terminal
382+
const nonIdleProjectIds = useMemo(
383+
() =>
384+
collectSidebarNonIdleProjectIds({
385+
activeProjectId,
386+
threads,
387+
threadStatusById,
388+
runningTerminalThreadIds,
389+
}),
390+
[activeProjectId, runningTerminalThreadIds, threadStatusById, threads],
391+
);
402392

403393
const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
404394
event.preventDefault();
@@ -1030,12 +1020,12 @@ export default function Sidebar() {
10301020

10311021
const handleCollapseIdleProjects = useCallback(() => {
10321022
for (const project of projects) {
1033-
if (!project.expanded || activeProjectIds.has(project.id)) {
1023+
if (!project.expanded || nonIdleProjectIds.has(project.id)) {
10341024
continue;
10351025
}
10361026
setProjectExpanded(project.id, false);
10371027
}
1038-
}, [projects, activeProjectIds, setProjectExpanded]);
1028+
}, [projects, nonIdleProjectIds, setProjectExpanded]);
10391029

10401030
useEffect(() => {
10411031
const onMouseDown = (event: globalThis.MouseEvent) => {
@@ -1518,10 +1508,13 @@ export default function Sidebar() {
15181508
const prStatus = prStatusIndicator(
15191509
prByThreadId.get(thread.id) ?? null,
15201510
);
1521-
const terminalStatus = terminalStatusFromRunningIds(
1522-
selectThreadTerminalState(terminalStateByThreadId, thread.id)
1523-
.runningTerminalIds,
1524-
);
1511+
const terminalStatus = runningTerminalThreadIds.has(thread.id)
1512+
? {
1513+
label: "Terminal process running",
1514+
colorClass: "text-teal-600 dark:text-teal-300/90",
1515+
pulse: true,
1516+
}
1517+
: null;
15251518

15261519
return (
15271520
<SidebarMenuSubItem

0 commit comments

Comments
 (0)