Skip to content
Merged
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
115 changes: 77 additions & 38 deletions src/browser/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { matchesKeybind, formatKeybind, KEYBINDS } from "@/browser/utils/ui/keyb
import { PlatformPaths } from "@/common/utils/paths";
import {
partitionWorkspacesByAge,
formatOldWorkspaceThreshold,
formatDaysThreshold,
AGE_THRESHOLDS_DAYS,
} from "@/browser/utils/ui/workspaceFiltering";
import { TooltipWrapper, Tooltip } from "./Tooltip";
import SecretsModal from "./SecretsModal";
Expand Down Expand Up @@ -207,7 +208,8 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
setExpandedProjectsArray(Array.from(projects));
};

// Track which projects have old workspaces expanded (per-project)
// Track which projects have old workspaces expanded (per-project, per-tier)
// Key format: `${projectPath}:${tierIndex}` where tierIndex is 0, 1, 2 for 1/7/30 days
const [expandedOldWorkspaces, setExpandedOldWorkspaces] = usePersistedState<
Record<string, boolean>
>("expandedOldWorkspaces", {});
Expand Down Expand Up @@ -247,10 +249,11 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
setExpandedProjects(newExpanded);
};

const toggleOldWorkspaces = (projectPath: string) => {
const toggleOldWorkspaces = (projectPath: string, tierIndex: number) => {
const key = `${projectPath}:${tierIndex}`;
setExpandedOldWorkspaces((prev) => ({
...prev,
[projectPath]: !prev[projectPath],
[key]: !prev[key],
}));
};

Expand Down Expand Up @@ -559,11 +562,10 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
{(() => {
const allWorkspaces =
sortedWorkspacesByProject.get(projectPath) ?? [];
const { recent, old } = partitionWorkspacesByAge(
const { recent, buckets } = partitionWorkspacesByAge(
allWorkspaces,
workspaceRecency
);
const showOldWorkspaces = expandedOldWorkspaces[projectPath] ?? false;

const renderWorkspace = (metadata: FrontendWorkspaceMetadata) => (
<WorkspaceListItem
Expand All @@ -579,41 +581,78 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
/>
);

// Find the next tier with workspaces (skip empty tiers)
const findNextNonEmptyTier = (startIndex: number): number => {
for (let i = startIndex; i < buckets.length; i++) {
if (buckets[i].length > 0) return i;
}
return -1;
};

// Render a tier and all subsequent tiers recursively
// Each tier only shows if the previous tier is expanded
// Empty tiers are skipped automatically
const renderTier = (tierIndex: number): React.ReactNode => {
const bucket = buckets[tierIndex];
// Sum remaining workspaces from this tier onward
const remainingCount = buckets
.slice(tierIndex)
.reduce((sum, b) => sum + b.length, 0);

if (remainingCount === 0) return null;

const key = `${projectPath}:${tierIndex}`;
const isExpanded = expandedOldWorkspaces[key] ?? false;
const thresholdDays = AGE_THRESHOLDS_DAYS[tierIndex];
const thresholdLabel = formatDaysThreshold(thresholdDays);

return (
<>
<button
onClick={() => toggleOldWorkspaces(projectPath, tierIndex)}
aria-label={
isExpanded
? `Collapse workspaces older than ${thresholdLabel}`
: `Expand workspaces older than ${thresholdLabel}`
}
aria-expanded={isExpanded}
className="text-muted border-hover hover:text-label [&:hover_.arrow]:text-label flex w-full cursor-pointer items-center justify-between border-t border-none bg-transparent px-3 py-2 pl-[22px] text-xs font-medium transition-all duration-150 hover:bg-white/[0.03]"
>
<div className="flex items-center gap-1.5">
<span>Older than {thresholdLabel}</span>
<span className="text-dim font-normal">
({remainingCount})
</span>
</div>
<span
className="arrow text-dim text-[11px] transition-transform duration-200 ease-in-out"
style={{
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
}}
>
<ChevronRight size={12} />
</span>
</button>
{isExpanded && (
<>
{bucket.map(renderWorkspace)}
{(() => {
const nextTier = findNextNonEmptyTier(tierIndex + 1);
return nextTier !== -1 ? renderTier(nextTier) : null;
})()}
</>
)}
</>
);
};

// Find first non-empty tier to start rendering
const firstTier = findNextNonEmptyTier(0);

return (
<>
{recent.map(renderWorkspace)}
{old.length > 0 && (
<>
<button
onClick={() => toggleOldWorkspaces(projectPath)}
aria-label={
showOldWorkspaces
? `Collapse workspaces older than ${formatOldWorkspaceThreshold()}`
: `Expand workspaces older than ${formatOldWorkspaceThreshold()}`
}
aria-expanded={showOldWorkspaces}
className="text-muted border-hover hover:text-label [&:hover_.arrow]:text-label flex w-full cursor-pointer items-center justify-between border-t border-none bg-transparent px-3 py-2 pl-[22px] text-xs font-medium transition-all duration-150 hover:bg-white/[0.03]"
>
<div className="flex items-center gap-1.5">
<span>Older than {formatOldWorkspaceThreshold()}</span>
<span className="text-dim font-normal">
({old.length})
</span>
</div>
<span
className="arrow text-dim text-[11px] transition-transform duration-200 ease-in-out"
style={{
transform: showOldWorkspaces
? "rotate(90deg)"
: "rotate(0deg)",
}}
>
<ChevronRight size={12} />
</span>
</button>
{showOldWorkspaces && old.map(renderWorkspace)}
</>
)}
{firstTier !== -1 && renderTier(firstTier)}
</>
);
})()}
Expand Down
73 changes: 60 additions & 13 deletions src/browser/utils/ui/workspaceFiltering.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect } from "@jest/globals";
import { partitionWorkspacesByAge, formatOldWorkspaceThreshold } from "./workspaceFiltering";
import {
partitionWorkspacesByAge,
formatDaysThreshold,
AGE_THRESHOLDS_DAYS,
} from "./workspaceFiltering";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";

Expand All @@ -12,10 +16,13 @@ describe("partitionWorkspacesByAge", () => {
name: `workspace-${id}`,
projectName: "test-project",
projectPath: "/test/project",
namedWorkspacePath: `/test/project/workspace-${id}`, // Path is arbitrary for this test
namedWorkspacePath: `/test/project/workspace-${id}`,
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
});

// Helper to get all "old" workspaces (all buckets combined)
const getAllOld = (buckets: FrontendWorkspaceMetadata[][]) => buckets.flat();

it("should partition workspaces into recent and old based on 24-hour threshold", () => {
const workspaces = [
createWorkspace("recent1"),
Expand All @@ -31,7 +38,8 @@ describe("partitionWorkspacesByAge", () => {
old2: now - 2 * ONE_DAY_MS, // 2 days ago
};

const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
const old = getAllOld(buckets);

expect(recent).toHaveLength(2);
expect(recent.map((w) => w.id)).toEqual(expect.arrayContaining(["recent1", "recent2"]));
Expand All @@ -48,7 +56,8 @@ describe("partitionWorkspacesByAge", () => {
// no-activity has no timestamp
};

const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
const old = getAllOld(buckets);

expect(recent).toHaveLength(1);
expect(recent[0].id).toBe("recent");
Expand All @@ -58,10 +67,11 @@ describe("partitionWorkspacesByAge", () => {
});

it("should handle empty workspace list", () => {
const { recent, old } = partitionWorkspacesByAge([], {});
const { recent, buckets } = partitionWorkspacesByAge([], {});

expect(recent).toHaveLength(0);
expect(old).toHaveLength(0);
expect(buckets).toHaveLength(AGE_THRESHOLDS_DAYS.length);
expect(buckets.every((b) => b.length === 0)).toBe(true);
});

it("should handle workspace at exactly 24 hours (should show as recent due to always-show-one rule)", () => {
Expand All @@ -71,7 +81,8 @@ describe("partitionWorkspacesByAge", () => {
"exactly-24h": now - ONE_DAY_MS,
};

const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
const old = getAllOld(buckets);

// Even though it's exactly 24 hours old, it should show as recent (always show at least one)
expect(recent).toHaveLength(1);
Expand All @@ -94,7 +105,8 @@ describe("partitionWorkspacesByAge", () => {
old3: now - 4 * ONE_DAY_MS,
};

const { old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
const { buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
const old = getAllOld(buckets);

expect(old.map((w) => w.id)).toEqual(["old1", "old2", "old3"]);
});
Expand All @@ -108,7 +120,8 @@ describe("partitionWorkspacesByAge", () => {
old3: now - 4 * ONE_DAY_MS,
};

const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
const old = getAllOld(buckets);

// Most recent should be moved to recent section
expect(recent).toHaveLength(1);
Expand All @@ -118,11 +131,45 @@ describe("partitionWorkspacesByAge", () => {
expect(old).toHaveLength(2);
expect(old.map((w) => w.id)).toEqual(["old2", "old3"]);
});

it("should partition into correct age buckets", () => {
const workspaces = [
createWorkspace("recent"), // < 1 day
createWorkspace("bucket0"), // 1-7 days
createWorkspace("bucket1"), // 7-30 days
createWorkspace("bucket2"), // > 30 days
];

const workspaceRecency = {
recent: now - 12 * 60 * 60 * 1000, // 12 hours
bucket0: now - 3 * ONE_DAY_MS, // 3 days (1-7 day bucket)
bucket1: now - 15 * ONE_DAY_MS, // 15 days (7-30 day bucket)
bucket2: now - 60 * ONE_DAY_MS, // 60 days (>30 day bucket)
};

const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);

expect(recent).toHaveLength(1);
expect(recent[0].id).toBe("recent");

expect(buckets[0]).toHaveLength(1);
expect(buckets[0][0].id).toBe("bucket0");

expect(buckets[1]).toHaveLength(1);
expect(buckets[1][0].id).toBe("bucket1");

expect(buckets[2]).toHaveLength(1);
expect(buckets[2][0].id).toBe("bucket2");
});
});

describe("formatOldWorkspaceThreshold", () => {
it("should format the threshold as a human-readable string", () => {
const result = formatOldWorkspaceThreshold();
expect(result).toBe("1 day");
describe("formatDaysThreshold", () => {
it("should format singular day correctly", () => {
expect(formatDaysThreshold(1)).toBe("1 day");
});

it("should format plural days correctly", () => {
expect(formatDaysThreshold(7)).toBe("7 days");
expect(formatDaysThreshold(30)).toBe("30 days");
});
});
Loading