Skip to content

Commit 45812f7

Browse files
authored
πŸ€– feat: expand sidebar timeframe filter to 1/7/30 day tiers (#820)
Extends the sidebar old workspace filter to show progressive tiers: - **Older than 1 day** β†’ reveals 7-day button when expanded - **Older than 7 days** β†’ reveals 30-day button when expanded - **Older than 30 days** β†’ final tier Each tier shows the count of all remaining workspaces from that tier onward. ### Implementation - `partitionWorkspacesByAge()` now returns `{ recent, buckets }` where buckets is an array per threshold - Recursive `renderTier()` function maximizes code reuse - Per-tier expansion state stored as `${projectPath}:${tierIndex}` _Generated with `mux`_
1 parent 284dbc7 commit 45812f7

File tree

3 files changed

+191
-77
lines changed

3 files changed

+191
-77
lines changed

β€Žsrc/browser/components/ProjectSidebar.tsxβ€Ž

Lines changed: 77 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import { matchesKeybind, formatKeybind, KEYBINDS } from "@/browser/utils/ui/keyb
1515
import { PlatformPaths } from "@/common/utils/paths";
1616
import {
1717
partitionWorkspacesByAge,
18-
formatOldWorkspaceThreshold,
18+
formatDaysThreshold,
19+
AGE_THRESHOLDS_DAYS,
1920
} from "@/browser/utils/ui/workspaceFiltering";
2021
import { TooltipWrapper, Tooltip } from "./Tooltip";
2122
import SecretsModal from "./SecretsModal";
@@ -207,7 +208,8 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
207208
setExpandedProjectsArray(Array.from(projects));
208209
};
209210

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

250-
const toggleOldWorkspaces = (projectPath: string) => {
252+
const toggleOldWorkspaces = (projectPath: string, tierIndex: number) => {
253+
const key = `${projectPath}:${tierIndex}`;
251254
setExpandedOldWorkspaces((prev) => ({
252255
...prev,
253-
[projectPath]: !prev[projectPath],
256+
[key]: !prev[key],
254257
}));
255258
};
256259

@@ -559,11 +562,10 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
559562
{(() => {
560563
const allWorkspaces =
561564
sortedWorkspacesByProject.get(projectPath) ?? [];
562-
const { recent, old } = partitionWorkspacesByAge(
565+
const { recent, buckets } = partitionWorkspacesByAge(
563566
allWorkspaces,
564567
workspaceRecency
565568
);
566-
const showOldWorkspaces = expandedOldWorkspaces[projectPath] ?? false;
567569

568570
const renderWorkspace = (metadata: FrontendWorkspaceMetadata) => (
569571
<WorkspaceListItem
@@ -579,41 +581,78 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
579581
/>
580582
);
581583

584+
// Find the next tier with workspaces (skip empty tiers)
585+
const findNextNonEmptyTier = (startIndex: number): number => {
586+
for (let i = startIndex; i < buckets.length; i++) {
587+
if (buckets[i].length > 0) return i;
588+
}
589+
return -1;
590+
};
591+
592+
// Render a tier and all subsequent tiers recursively
593+
// Each tier only shows if the previous tier is expanded
594+
// Empty tiers are skipped automatically
595+
const renderTier = (tierIndex: number): React.ReactNode => {
596+
const bucket = buckets[tierIndex];
597+
// Sum remaining workspaces from this tier onward
598+
const remainingCount = buckets
599+
.slice(tierIndex)
600+
.reduce((sum, b) => sum + b.length, 0);
601+
602+
if (remainingCount === 0) return null;
603+
604+
const key = `${projectPath}:${tierIndex}`;
605+
const isExpanded = expandedOldWorkspaces[key] ?? false;
606+
const thresholdDays = AGE_THRESHOLDS_DAYS[tierIndex];
607+
const thresholdLabel = formatDaysThreshold(thresholdDays);
608+
609+
return (
610+
<>
611+
<button
612+
onClick={() => toggleOldWorkspaces(projectPath, tierIndex)}
613+
aria-label={
614+
isExpanded
615+
? `Collapse workspaces older than ${thresholdLabel}`
616+
: `Expand workspaces older than ${thresholdLabel}`
617+
}
618+
aria-expanded={isExpanded}
619+
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]"
620+
>
621+
<div className="flex items-center gap-1.5">
622+
<span>Older than {thresholdLabel}</span>
623+
<span className="text-dim font-normal">
624+
({remainingCount})
625+
</span>
626+
</div>
627+
<span
628+
className="arrow text-dim text-[11px] transition-transform duration-200 ease-in-out"
629+
style={{
630+
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
631+
}}
632+
>
633+
<ChevronRight size={12} />
634+
</span>
635+
</button>
636+
{isExpanded && (
637+
<>
638+
{bucket.map(renderWorkspace)}
639+
{(() => {
640+
const nextTier = findNextNonEmptyTier(tierIndex + 1);
641+
return nextTier !== -1 ? renderTier(nextTier) : null;
642+
})()}
643+
</>
644+
)}
645+
</>
646+
);
647+
};
648+
649+
// Find first non-empty tier to start rendering
650+
const firstTier = findNextNonEmptyTier(0);
651+
582652
return (
583653
<>
584654
{recent.map(renderWorkspace)}
585-
{old.length > 0 && (
586-
<>
587-
<button
588-
onClick={() => toggleOldWorkspaces(projectPath)}
589-
aria-label={
590-
showOldWorkspaces
591-
? `Collapse workspaces older than ${formatOldWorkspaceThreshold()}`
592-
: `Expand workspaces older than ${formatOldWorkspaceThreshold()}`
593-
}
594-
aria-expanded={showOldWorkspaces}
595-
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]"
596-
>
597-
<div className="flex items-center gap-1.5">
598-
<span>Older than {formatOldWorkspaceThreshold()}</span>
599-
<span className="text-dim font-normal">
600-
({old.length})
601-
</span>
602-
</div>
603-
<span
604-
className="arrow text-dim text-[11px] transition-transform duration-200 ease-in-out"
605-
style={{
606-
transform: showOldWorkspaces
607-
? "rotate(90deg)"
608-
: "rotate(0deg)",
609-
}}
610-
>
611-
<ChevronRight size={12} />
612-
</span>
613-
</button>
614-
{showOldWorkspaces && old.map(renderWorkspace)}
615-
</>
616-
)}
655+
{firstTier !== -1 && renderTier(firstTier)}
617656
</>
618657
);
619658
})()}

β€Žsrc/browser/utils/ui/workspaceFiltering.test.tsβ€Ž

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, it, expect } from "@jest/globals";
2-
import { partitionWorkspacesByAge, formatOldWorkspaceThreshold } from "./workspaceFiltering";
2+
import {
3+
partitionWorkspacesByAge,
4+
formatDaysThreshold,
5+
AGE_THRESHOLDS_DAYS,
6+
} from "./workspaceFiltering";
37
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
48
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
59

@@ -12,10 +16,13 @@ describe("partitionWorkspacesByAge", () => {
1216
name: `workspace-${id}`,
1317
projectName: "test-project",
1418
projectPath: "/test/project",
15-
namedWorkspacePath: `/test/project/workspace-${id}`, // Path is arbitrary for this test
19+
namedWorkspacePath: `/test/project/workspace-${id}`,
1620
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
1721
});
1822

23+
// Helper to get all "old" workspaces (all buckets combined)
24+
const getAllOld = (buckets: FrontendWorkspaceMetadata[][]) => buckets.flat();
25+
1926
it("should partition workspaces into recent and old based on 24-hour threshold", () => {
2027
const workspaces = [
2128
createWorkspace("recent1"),
@@ -31,7 +38,8 @@ describe("partitionWorkspacesByAge", () => {
3138
old2: now - 2 * ONE_DAY_MS, // 2 days ago
3239
};
3340

34-
const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
41+
const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
42+
const old = getAllOld(buckets);
3543

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

51-
const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
59+
const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
60+
const old = getAllOld(buckets);
5261

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

6069
it("should handle empty workspace list", () => {
61-
const { recent, old } = partitionWorkspacesByAge([], {});
70+
const { recent, buckets } = partitionWorkspacesByAge([], {});
6271

6372
expect(recent).toHaveLength(0);
64-
expect(old).toHaveLength(0);
73+
expect(buckets).toHaveLength(AGE_THRESHOLDS_DAYS.length);
74+
expect(buckets.every((b) => b.length === 0)).toBe(true);
6575
});
6676

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

74-
const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
84+
const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
85+
const old = getAllOld(buckets);
7586

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

97-
const { old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
108+
const { buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
109+
const old = getAllOld(buckets);
98110

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

111-
const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
123+
const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
124+
const old = getAllOld(buckets);
112125

113126
// Most recent should be moved to recent section
114127
expect(recent).toHaveLength(1);
@@ -118,11 +131,45 @@ describe("partitionWorkspacesByAge", () => {
118131
expect(old).toHaveLength(2);
119132
expect(old.map((w) => w.id)).toEqual(["old2", "old3"]);
120133
});
134+
135+
it("should partition into correct age buckets", () => {
136+
const workspaces = [
137+
createWorkspace("recent"), // < 1 day
138+
createWorkspace("bucket0"), // 1-7 days
139+
createWorkspace("bucket1"), // 7-30 days
140+
createWorkspace("bucket2"), // > 30 days
141+
];
142+
143+
const workspaceRecency = {
144+
recent: now - 12 * 60 * 60 * 1000, // 12 hours
145+
bucket0: now - 3 * ONE_DAY_MS, // 3 days (1-7 day bucket)
146+
bucket1: now - 15 * ONE_DAY_MS, // 15 days (7-30 day bucket)
147+
bucket2: now - 60 * ONE_DAY_MS, // 60 days (>30 day bucket)
148+
};
149+
150+
const { recent, buckets } = partitionWorkspacesByAge(workspaces, workspaceRecency);
151+
152+
expect(recent).toHaveLength(1);
153+
expect(recent[0].id).toBe("recent");
154+
155+
expect(buckets[0]).toHaveLength(1);
156+
expect(buckets[0][0].id).toBe("bucket0");
157+
158+
expect(buckets[1]).toHaveLength(1);
159+
expect(buckets[1][0].id).toBe("bucket1");
160+
161+
expect(buckets[2]).toHaveLength(1);
162+
expect(buckets[2][0].id).toBe("bucket2");
163+
});
121164
});
122165

123-
describe("formatOldWorkspaceThreshold", () => {
124-
it("should format the threshold as a human-readable string", () => {
125-
const result = formatOldWorkspaceThreshold();
126-
expect(result).toBe("1 day");
166+
describe("formatDaysThreshold", () => {
167+
it("should format singular day correctly", () => {
168+
expect(formatDaysThreshold(1)).toBe("1 day");
169+
});
170+
171+
it("should format plural days correctly", () => {
172+
expect(formatDaysThreshold(7)).toBe("7 days");
173+
expect(formatDaysThreshold(30)).toBe("30 days");
127174
});
128175
});

0 commit comments

Comments
Β (0)