Skip to content

Commit 61af200

Browse files
committed
🤖 feat: expand sidebar timeframe filter to 1/7/30 day tiers
- Add progressive expansion: 7-day and 30-day buttons only appear after previous tier is expanded - Change partitionWorkspacesByAge to return buckets array instead of flat old - Use recursive renderTier() for maximum code reuse - State key format: ${projectPath}:${tierIndex} for per-tier tracking _Generated with mux_
1 parent 27c9cd0 commit 61af200

File tree

3 files changed

+177
-77
lines changed

3 files changed

+177
-77
lines changed

src/browser/components/ProjectSidebar.tsx

Lines changed: 63 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,64 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
579581
/>
580582
);
581583

584+
// Render a tier and all subsequent tiers recursively
585+
// Each tier only shows if the previous tier is expanded
586+
const renderTier = (tierIndex: number): React.ReactNode => {
587+
const bucket = buckets[tierIndex];
588+
// Sum remaining workspaces from this tier onward
589+
const remainingCount = buckets
590+
.slice(tierIndex)
591+
.reduce((sum, b) => sum + b.length, 0);
592+
593+
if (remainingCount === 0) return null;
594+
595+
const key = `${projectPath}:${tierIndex}`;
596+
const isExpanded = expandedOldWorkspaces[key] ?? false;
597+
const thresholdDays = AGE_THRESHOLDS_DAYS[tierIndex];
598+
const thresholdLabel = formatDaysThreshold(thresholdDays);
599+
600+
return (
601+
<>
602+
<button
603+
onClick={() => toggleOldWorkspaces(projectPath, tierIndex)}
604+
aria-label={
605+
isExpanded
606+
? `Collapse workspaces older than ${thresholdLabel}`
607+
: `Expand workspaces older than ${thresholdLabel}`
608+
}
609+
aria-expanded={isExpanded}
610+
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]"
611+
>
612+
<div className="flex items-center gap-1.5">
613+
<span>Older than {thresholdLabel}</span>
614+
<span className="text-dim font-normal">
615+
({remainingCount})
616+
</span>
617+
</div>
618+
<span
619+
className="arrow text-dim text-[11px] transition-transform duration-200 ease-in-out"
620+
style={{
621+
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
622+
}}
623+
>
624+
<ChevronRight size={12} />
625+
</span>
626+
</button>
627+
{isExpanded && (
628+
<>
629+
{bucket.map(renderWorkspace)}
630+
{tierIndex + 1 < buckets.length &&
631+
renderTier(tierIndex + 1)}
632+
</>
633+
)}
634+
</>
635+
);
636+
};
637+
582638
return (
583639
<>
584640
{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-
)}
641+
{renderTier(0)}
617642
</>
618643
);
619644
})()}

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
});
Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,86 @@
11
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
22

33
/**
4-
* Time threshold for considering a workspace "old" (24 hours in milliseconds)
4+
* Age thresholds for workspace filtering, in ascending order.
5+
* Each tier hides workspaces older than the specified duration.
56
*/
6-
const OLD_WORKSPACE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
7+
export const AGE_THRESHOLDS_DAYS = [1, 7, 30] as const;
8+
export type AgeThresholdDays = (typeof AGE_THRESHOLDS_DAYS)[number];
9+
10+
const DAY_MS = 24 * 60 * 60 * 1000;
711

812
/**
9-
* Format the old workspace threshold for display.
10-
* Returns a human-readable string like "1 day", "2 hours", etc.
13+
* Format a day count for display.
14+
* Returns a human-readable string like "1 day", "7 days", etc.
1115
*/
12-
export function formatOldWorkspaceThreshold(): string {
13-
const hours = OLD_WORKSPACE_THRESHOLD_MS / (60 * 60 * 1000);
14-
if (hours >= 24) {
15-
const days = hours / 24;
16-
return days === 1 ? "1 day" : `${days} days`;
17-
}
18-
return hours === 1 ? "1 hour" : `${hours} hours`;
16+
export function formatDaysThreshold(days: number): string {
17+
return days === 1 ? "1 day" : `${days} days`;
18+
}
19+
20+
/**
21+
* Result of partitioning workspaces by age thresholds.
22+
* - recent: workspaces newer than the first threshold (1 day)
23+
* - buckets: array of workspaces for each threshold tier
24+
* - buckets[0]: older than 1 day but newer than 7 days
25+
* - buckets[1]: older than 7 days but newer than 30 days
26+
* - buckets[2]: older than 30 days
27+
*/
28+
export interface AgePartitionResult {
29+
recent: FrontendWorkspaceMetadata[];
30+
buckets: FrontendWorkspaceMetadata[][];
1931
}
2032

2133
/**
22-
* Partition workspaces into recent and old based on recency timestamp.
23-
* Workspaces with no activity in the last 24 hours are considered "old".
34+
* Partition workspaces into age-based buckets.
2435
* Always shows at least one workspace in the recent section (the most recent one).
2536
*/
2637
export function partitionWorkspacesByAge(
2738
workspaces: FrontendWorkspaceMetadata[],
2839
workspaceRecency: Record<string, number>
29-
): {
30-
recent: FrontendWorkspaceMetadata[];
31-
old: FrontendWorkspaceMetadata[];
32-
} {
40+
): AgePartitionResult {
3341
if (workspaces.length === 0) {
34-
return { recent: [], old: [] };
42+
return { recent: [], buckets: AGE_THRESHOLDS_DAYS.map(() => []) };
3543
}
3644

3745
const now = Date.now();
46+
const thresholdMs = AGE_THRESHOLDS_DAYS.map((d) => d * DAY_MS);
47+
3848
const recent: FrontendWorkspaceMetadata[] = [];
39-
const old: FrontendWorkspaceMetadata[] = [];
49+
const buckets: FrontendWorkspaceMetadata[][] = AGE_THRESHOLDS_DAYS.map(() => []);
4050

4151
for (const workspace of workspaces) {
4252
const recencyTimestamp = workspaceRecency[workspace.id] ?? 0;
4353
const age = now - recencyTimestamp;
4454

45-
if (age >= OLD_WORKSPACE_THRESHOLD_MS) {
46-
old.push(workspace);
47-
} else {
55+
if (age < thresholdMs[0]) {
4856
recent.push(workspace);
57+
} else {
58+
// Find which bucket this workspace belongs to
59+
// buckets[i] contains workspaces older than threshold[i] but newer than threshold[i+1]
60+
let placed = false;
61+
for (let i = 0; i < thresholdMs.length - 1; i++) {
62+
if (age >= thresholdMs[i] && age < thresholdMs[i + 1]) {
63+
buckets[i].push(workspace);
64+
placed = true;
65+
break;
66+
}
67+
}
68+
// Older than the last threshold
69+
if (!placed) {
70+
buckets[buckets.length - 1].push(workspace);
71+
}
4972
}
5073
}
5174

52-
// Always show at least one workspace - move the most recent from old to recent
53-
if (recent.length === 0 && old.length > 0) {
54-
recent.push(old.shift()!);
75+
// Always show at least one workspace - move the most recent from first non-empty bucket
76+
if (recent.length === 0) {
77+
for (const bucket of buckets) {
78+
if (bucket.length > 0) {
79+
recent.push(bucket.shift()!);
80+
break;
81+
}
82+
}
5583
}
5684

57-
return { recent, old };
85+
return { recent, buckets };
5886
}

0 commit comments

Comments
 (0)