From ebd0c14e1aa472331dad7dba053d164f5a2caafa Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 10:40:07 -0600 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20feat:=20expand=20sidebar=20t?= =?UTF-8?q?imeframe=20filter=20to=201/7/30=20day=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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_ --- src/browser/components/ProjectSidebar.tsx | 101 +++++++++++------- .../utils/ui/workspaceFiltering.test.ts | 73 ++++++++++--- src/browser/utils/ui/workspaceFiltering.ts | 80 +++++++++----- 3 files changed, 177 insertions(+), 77 deletions(-) diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index 938ff073f8..48246b9969 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -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"; @@ -207,7 +208,8 @@ const ProjectSidebarInner: React.FC = ({ 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 >("expandedOldWorkspaces", {}); @@ -247,10 +249,11 @@ const ProjectSidebarInner: React.FC = ({ 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], })); }; @@ -559,11 +562,10 @@ const ProjectSidebarInner: React.FC = ({ {(() => { const allWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; - const { recent, old } = partitionWorkspacesByAge( + const { recent, buckets } = partitionWorkspacesByAge( allWorkspaces, workspaceRecency ); - const showOldWorkspaces = expandedOldWorkspaces[projectPath] ?? false; const renderWorkspace = (metadata: FrontendWorkspaceMetadata) => ( = ({ /> ); + // Render a tier and all subsequent tiers recursively + // Each tier only shows if the previous tier is expanded + 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 ( + <> + + {isExpanded && ( + <> + {bucket.map(renderWorkspace)} + {tierIndex + 1 < buckets.length && + renderTier(tierIndex + 1)} + + )} + + ); + }; + return ( <> {recent.map(renderWorkspace)} - {old.length > 0 && ( - <> - - {showOldWorkspaces && old.map(renderWorkspace)} - - )} + {renderTier(0)} ); })()} diff --git a/src/browser/utils/ui/workspaceFiltering.test.ts b/src/browser/utils/ui/workspaceFiltering.test.ts index 81efe22474..4981b278c8 100644 --- a/src/browser/utils/ui/workspaceFiltering.test.ts +++ b/src/browser/utils/ui/workspaceFiltering.test.ts @@ -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"; @@ -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"), @@ -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"])); @@ -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"); @@ -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)", () => { @@ -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); @@ -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"]); }); @@ -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); @@ -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"); }); }); diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts index 55dec9f2d5..a64dcb7932 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -1,58 +1,86 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; /** - * Time threshold for considering a workspace "old" (24 hours in milliseconds) + * Age thresholds for workspace filtering, in ascending order. + * Each tier hides workspaces older than the specified duration. */ -const OLD_WORKSPACE_THRESHOLD_MS = 24 * 60 * 60 * 1000; +export const AGE_THRESHOLDS_DAYS = [1, 7, 30] as const; +export type AgeThresholdDays = (typeof AGE_THRESHOLDS_DAYS)[number]; + +const DAY_MS = 24 * 60 * 60 * 1000; /** - * Format the old workspace threshold for display. - * Returns a human-readable string like "1 day", "2 hours", etc. + * Format a day count for display. + * Returns a human-readable string like "1 day", "7 days", etc. */ -export function formatOldWorkspaceThreshold(): string { - const hours = OLD_WORKSPACE_THRESHOLD_MS / (60 * 60 * 1000); - if (hours >= 24) { - const days = hours / 24; - return days === 1 ? "1 day" : `${days} days`; - } - return hours === 1 ? "1 hour" : `${hours} hours`; +export function formatDaysThreshold(days: number): string { + return days === 1 ? "1 day" : `${days} days`; +} + +/** + * Result of partitioning workspaces by age thresholds. + * - recent: workspaces newer than the first threshold (1 day) + * - buckets: array of workspaces for each threshold tier + * - buckets[0]: older than 1 day but newer than 7 days + * - buckets[1]: older than 7 days but newer than 30 days + * - buckets[2]: older than 30 days + */ +export interface AgePartitionResult { + recent: FrontendWorkspaceMetadata[]; + buckets: FrontendWorkspaceMetadata[][]; } /** - * Partition workspaces into recent and old based on recency timestamp. - * Workspaces with no activity in the last 24 hours are considered "old". + * Partition workspaces into age-based buckets. * Always shows at least one workspace in the recent section (the most recent one). */ export function partitionWorkspacesByAge( workspaces: FrontendWorkspaceMetadata[], workspaceRecency: Record -): { - recent: FrontendWorkspaceMetadata[]; - old: FrontendWorkspaceMetadata[]; -} { +): AgePartitionResult { if (workspaces.length === 0) { - return { recent: [], old: [] }; + return { recent: [], buckets: AGE_THRESHOLDS_DAYS.map(() => []) }; } const now = Date.now(); + const thresholdMs = AGE_THRESHOLDS_DAYS.map((d) => d * DAY_MS); + const recent: FrontendWorkspaceMetadata[] = []; - const old: FrontendWorkspaceMetadata[] = []; + const buckets: FrontendWorkspaceMetadata[][] = AGE_THRESHOLDS_DAYS.map(() => []); for (const workspace of workspaces) { const recencyTimestamp = workspaceRecency[workspace.id] ?? 0; const age = now - recencyTimestamp; - if (age >= OLD_WORKSPACE_THRESHOLD_MS) { - old.push(workspace); - } else { + if (age < thresholdMs[0]) { recent.push(workspace); + } else { + // Find which bucket this workspace belongs to + // buckets[i] contains workspaces older than threshold[i] but newer than threshold[i+1] + let placed = false; + for (let i = 0; i < thresholdMs.length - 1; i++) { + if (age >= thresholdMs[i] && age < thresholdMs[i + 1]) { + buckets[i].push(workspace); + placed = true; + break; + } + } + // Older than the last threshold + if (!placed) { + buckets[buckets.length - 1].push(workspace); + } } } - // Always show at least one workspace - move the most recent from old to recent - if (recent.length === 0 && old.length > 0) { - recent.push(old.shift()!); + // Always show at least one workspace - move the most recent from first non-empty bucket + if (recent.length === 0) { + for (const bucket of buckets) { + if (bucket.length > 0) { + recent.push(bucket.shift()!); + break; + } + } } - return { recent, old }; + return { recent, buckets }; } From 0ade411adfbafbb56296cab82ae6ef4c658604c8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 10:52:14 -0600 Subject: [PATCH 2/2] fix: skip empty tiers when expanding old workspaces If all workspaces are older than 30 days, show 'Older than 30 days' directly instead of requiring clicks through empty 1-day and 7-day tiers. --- src/browser/components/ProjectSidebar.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index 48246b9969..c9eb77c093 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -581,8 +581,17 @@ const ProjectSidebarInner: React.FC = ({ /> ); + // 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 @@ -627,18 +636,23 @@ const ProjectSidebarInner: React.FC = ({ {isExpanded && ( <> {bucket.map(renderWorkspace)} - {tierIndex + 1 < buckets.length && - renderTier(tierIndex + 1)} + {(() => { + 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)} - {renderTier(0)} + {firstTier !== -1 && renderTier(firstTier)} ); })()}