Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
collectSidebarNonIdleProjectIds,
hasUnseenCompletion,
resolveSidebarNewThreadEnvMode,
resolveThreadRowClassName,
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ProjectId, ThreadId } from "@t3tools/contracts";
import type { Thread } from "../types";
import { cn } from "../lib/utils";
import {
Expand Down Expand Up @@ -50,6 +51,27 @@ export function resolveSidebarNewThreadEnvMode(input: {
return input.requestedEnvMode ?? input.defaultEnvMode;
}

export function collectSidebarNonIdleProjectIds(input: {
activeProjectId: ProjectId | null;
threads: readonly Pick<Thread, "id" | "projectId">[];
threadStatusById: ReadonlyMap<ThreadId, ThreadStatusPill | null>;
runningTerminalThreadIds: ReadonlySet<ThreadId>;
}): Set<ProjectId> {
const ids = new Set<ProjectId>();
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;
Expand Down
150 changes: 101 additions & 49 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ArrowLeftIcon,
ChevronRightIcon,
FoldVerticalIcon,
FolderIcon,
GitPullRequestIcon,
PlusIcon,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -359,6 +343,51 @@ export default function Sidebar() {
return map;
}, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);

const threadStatusById = useMemo(() => {
const map = new Map<ThreadId, ReturnType<typeof resolveThreadStatusPill>>();
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<ThreadId>();
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<HTMLElement>, prUrl: string) => {
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1230,26 +1268,43 @@ export default function Sidebar() {
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
Projects
</span>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label="Add project"
aria-pressed={shouldShowProjectPathEntry}
className="inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
onClick={handleStartAddProject}
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label="Collapse idle projects"
className="inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
onClick={handleCollapseIdleProjects}
/>
}
>
<FoldVerticalIcon className="size-3.5" />
</TooltipTrigger>
<TooltipPopup side="right">Collapse idle projects</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label="Add project"
aria-pressed={shouldShowProjectPathEntry}
className="inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
onClick={handleStartAddProject}
/>
}
>
<PlusIcon
className={`size-3.5 transition-transform duration-150 ${
shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
}`}
/>
}
>
<PlusIcon
className={`size-3.5 transition-transform duration-150 ${
shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
}`}
/>
</TooltipTrigger>
<TooltipPopup side="right">Add project</TooltipPopup>
</Tooltip>
</TooltipTrigger>
<TooltipPopup side="right">Add project</TooltipPopup>
</Tooltip>
</div>
</div>

{shouldShowProjectPathEntry && (
Expand Down Expand Up @@ -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 (
<SidebarMenuSubItem
Expand Down