diff --git a/src/App.tsx b/src/App.tsx index 2370b60c8..e8c1f164a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -820,6 +820,7 @@ function AppInner() { onGetSecrets={handleGetSecrets} onUpdateSecrets={handleUpdateSecrets} sortedWorkspacesByProject={sortedWorkspacesByProject} + workspaceRecency={workspaceRecency} /> diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index 06a74c093..2daa34ead 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -42,6 +42,7 @@ interface LeftSidebarProps { onGetSecrets: (projectPath: string) => Promise; onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise; sortedWorkspacesByProject: Map; + workspaceRecency: Record; } export function LeftSidebar(props: LeftSidebarProps) { diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index f4ded754f..bcd79d578 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -11,6 +11,10 @@ import { useDrag, useDrop, useDragLayer } from "react-dnd"; import { sortProjectsByOrder, reorderProjects, normalizeOrder } from "@/utils/projectOrdering"; import { matchesKeybind, formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; import { abbreviatePath } from "@/utils/ui/pathAbbreviation"; +import { + partitionWorkspacesByAge, + formatOldWorkspaceThreshold, +} from "@/utils/ui/workspaceFiltering"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import SecretsModal from "./SecretsModal"; import type { Secret } from "@/types/secrets"; @@ -312,6 +316,49 @@ const AddWorkspaceBtn = styled.button` } `; +const OldWorkspacesSection = styled.button<{ expanded: boolean }>` + width: 100%; + padding: 8px 12px 8px 22px; + background: transparent; + color: #858585; + border: none; + border-top: 1px solid #2a2a2b; + cursor: pointer; + font-size: 12px; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 500; + + &:hover { + background: rgba(255, 255, 255, 0.03); + color: #aaa; + + .arrow { + color: #aaa; + } + } + + .label { + display: flex; + align-items: center; + gap: 6px; + } + + .count { + color: #666; + font-weight: 400; + } + + .arrow { + font-size: 11px; + color: #666; + transition: transform 0.2s ease; + transform: ${(props) => (props.expanded ? "rotate(90deg)" : "rotate(0deg)")}; + } +`; + const RemoveErrorToast = styled.div<{ top: number; left: number }>` position: fixed; top: ${(props) => props.top}px; @@ -492,6 +539,7 @@ interface ProjectSidebarProps { onGetSecrets: (projectPath: string) => Promise; onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise; sortedWorkspacesByProject: Map; + workspaceRecency: Record; } const ProjectSidebarInner: React.FC = ({ @@ -510,6 +558,7 @@ const ProjectSidebarInner: React.FC = ({ onGetSecrets, onUpdateSecrets, sortedWorkspacesByProject, + workspaceRecency, }) => { // Workspace-specific subscriptions moved to WorkspaceListItem component @@ -525,6 +574,11 @@ const ProjectSidebarInner: React.FC = ({ const setExpandedProjects = (projects: Set) => { setExpandedProjectsArray(Array.from(projects)); }; + + // Track which projects have old workspaces expanded (per-project) + const [expandedOldWorkspaces, setExpandedOldWorkspaces] = usePersistedState< + Record + >("expandedOldWorkspaces", {}); const [removeError, setRemoveError] = useState<{ workspaceId: string; error: string; @@ -561,6 +615,13 @@ const ProjectSidebarInner: React.FC = ({ setExpandedProjects(newExpanded); }; + const toggleOldWorkspaces = (projectPath: string) => { + setExpandedOldWorkspaces((prev) => ({ + ...prev, + [projectPath]: !prev[projectPath], + })); + }; + const showRemoveError = useCallback( (workspaceId: string, error: string, anchor?: { top: number; left: number }) => { if (removeErrorTimeoutRef.current) { @@ -825,23 +886,56 @@ const ProjectSidebarInner: React.FC = ({ ` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`} - {sortedWorkspacesByProject.get(projectPath)?.map((metadata) => { - const isSelected = selectedWorkspace?.workspaceId === metadata.id; + {(() => { + const allWorkspaces = + sortedWorkspacesByProject.get(projectPath) ?? []; + const { recent, old } = partitionWorkspacesByAge( + allWorkspaces, + workspaceRecency + ); + const showOldWorkspaces = expandedOldWorkspaces[projectPath] ?? false; - return ( + const renderWorkspace = (metadata: FrontendWorkspaceMetadata) => ( ); - })} + + return ( + <> + {recent.map(renderWorkspace)} + {old.length > 0 && ( + <> + toggleOldWorkspaces(projectPath)} + aria-label={ + showOldWorkspaces + ? `Collapse workspaces older than ${formatOldWorkspaceThreshold()}` + : `Expand workspaces older than ${formatOldWorkspaceThreshold()}` + } + aria-expanded={showOldWorkspaces} + expanded={showOldWorkspaces} + > +
+ Older than {formatOldWorkspaceThreshold()} + ({old.length}) +
+ +
+ {showOldWorkspaces && old.map(renderWorkspace)} + + )} + + ); + })()} )} diff --git a/src/utils/ui/workspaceFiltering.test.ts b/src/utils/ui/workspaceFiltering.test.ts new file mode 100644 index 000000000..f22052bac --- /dev/null +++ b/src/utils/ui/workspaceFiltering.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "@jest/globals"; +import { partitionWorkspacesByAge, formatOldWorkspaceThreshold } from "./workspaceFiltering"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; + +describe("partitionWorkspacesByAge", () => { + const now = Date.now(); + const ONE_DAY_MS = 24 * 60 * 60 * 1000; + + const createWorkspace = (id: string): FrontendWorkspaceMetadata => ({ + id, + name: `workspace-${id}`, + projectName: "test-project", + projectPath: "/test/project", + namedWorkspacePath: `/test/project/.worktrees/${id}`, + }); + + it("should partition workspaces into recent and old based on 24-hour threshold", () => { + const workspaces = [ + createWorkspace("recent1"), + createWorkspace("old1"), + createWorkspace("recent2"), + createWorkspace("old2"), + ]; + + const workspaceRecency = { + recent1: now - 1000, // 1 second ago + old1: now - ONE_DAY_MS - 1000, // 24 hours and 1 second ago + recent2: now - 12 * 60 * 60 * 1000, // 12 hours ago + old2: now - 2 * ONE_DAY_MS, // 2 days ago + }; + + const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency); + + expect(recent).toHaveLength(2); + expect(recent.map((w) => w.id)).toEqual(expect.arrayContaining(["recent1", "recent2"])); + + expect(old).toHaveLength(2); + expect(old.map((w) => w.id)).toEqual(expect.arrayContaining(["old1", "old2"])); + }); + + it("should treat workspaces with no recency timestamp as old", () => { + const workspaces = [createWorkspace("no-activity"), createWorkspace("recent")]; + + const workspaceRecency = { + recent: now - 1000, + // no-activity has no timestamp + }; + + const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency); + + expect(recent).toHaveLength(1); + expect(recent[0].id).toBe("recent"); + + expect(old).toHaveLength(1); + expect(old[0].id).toBe("no-activity"); + }); + + it("should handle empty workspace list", () => { + const { recent, old } = partitionWorkspacesByAge([], {}); + + expect(recent).toHaveLength(0); + expect(old).toHaveLength(0); + }); + + it("should handle workspace at exactly 24 hours (should show as recent due to always-show-one rule)", () => { + const workspaces = [createWorkspace("exactly-24h")]; + + const workspaceRecency = { + "exactly-24h": now - ONE_DAY_MS, + }; + + const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency); + + // Even though it's exactly 24 hours old, it should show as recent (always show at least one) + expect(recent).toHaveLength(1); + expect(recent[0].id).toBe("exactly-24h"); + expect(old).toHaveLength(0); + }); + + it("should preserve workspace order within partitions", () => { + const workspaces = [ + createWorkspace("recent"), + createWorkspace("old1"), + createWorkspace("old2"), + createWorkspace("old3"), + ]; + + const workspaceRecency = { + recent: now - 1000, + old1: now - 2 * ONE_DAY_MS, + old2: now - 3 * ONE_DAY_MS, + old3: now - 4 * ONE_DAY_MS, + }; + + const { old } = partitionWorkspacesByAge(workspaces, workspaceRecency); + + expect(old.map((w) => w.id)).toEqual(["old1", "old2", "old3"]); + }); + + it("should always show at least one workspace when all are old", () => { + const workspaces = [createWorkspace("old1"), createWorkspace("old2"), createWorkspace("old3")]; + + const workspaceRecency = { + old1: now - 2 * ONE_DAY_MS, + old2: now - 3 * ONE_DAY_MS, + old3: now - 4 * ONE_DAY_MS, + }; + + const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency); + + // Most recent should be moved to recent section + expect(recent).toHaveLength(1); + expect(recent[0].id).toBe("old1"); + + // Remaining should stay in old section + expect(old).toHaveLength(2); + expect(old.map((w) => w.id)).toEqual(["old2", "old3"]); + }); +}); + +describe("formatOldWorkspaceThreshold", () => { + it("should format the threshold as a human-readable string", () => { + const result = formatOldWorkspaceThreshold(); + expect(result).toBe("1 day"); + }); +}); diff --git a/src/utils/ui/workspaceFiltering.ts b/src/utils/ui/workspaceFiltering.ts new file mode 100644 index 000000000..a42733c7f --- /dev/null +++ b/src/utils/ui/workspaceFiltering.ts @@ -0,0 +1,58 @@ +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; + +/** + * Time threshold for considering a workspace "old" (24 hours in milliseconds) + */ +const OLD_WORKSPACE_THRESHOLD_MS = 24 * 60 * 60 * 1000; + +/** + * Format the old workspace threshold for display. + * Returns a human-readable string like "1 day", "2 hours", 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`; +} + +/** + * Partition workspaces into recent and old based on recency timestamp. + * Workspaces with no activity in the last 24 hours are considered "old". + * 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[]; +} { + if (workspaces.length === 0) { + return { recent: [], old: [] }; + } + + const now = Date.now(); + const recent: FrontendWorkspaceMetadata[] = []; + const old: FrontendWorkspaceMetadata[] = []; + + 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 { + recent.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()!); + } + + return { recent, old }; +}