Skip to content

Commit b61737f

Browse files
authored
🤖 Hide old workspaces in sidebar with expandable section (#361)
Improves sidebar UX by hiding workspaces that haven't been used in more than 24 hours, while keeping them accessible via an expandable section per project. ## Changes **New utility:** - `workspaceFiltering.ts`: Partitions workspaces into recent (<24h) and old (≥24h) based on recency timestamps **Component updates:** - ProjectSidebar: Added collapsible old workspaces section with toggle button - LeftSidebar/App: Pass `workspaceRecency` through component tree **State management:** - Per-project expansion state persisted in localStorage (`expandedOldWorkspaces`) - Fully reversible toggle **Tests:** - Comprehensive unit tests for partitioning logic (5 tests, all passing) ## UX Flow 1. Recent workspaces (<24h activity) display normally 2. If old workspaces exist, shows: "Show N older workspaces" button 3. Click to expand and reveal old workspaces 4. Button changes to "Hide N older workspaces" when expanded 5. State persists across sessions _Generated with `cmux`_
1 parent e89a2d8 commit b61737f

File tree

5 files changed

+285
-5
lines changed

5 files changed

+285
-5
lines changed

src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,7 @@ function AppInner() {
820820
onGetSecrets={handleGetSecrets}
821821
onUpdateSecrets={handleUpdateSecrets}
822822
sortedWorkspacesByProject={sortedWorkspacesByProject}
823+
workspaceRecency={workspaceRecency}
823824
/>
824825
<MainContent>
825826
<ContentArea>

src/components/LeftSidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ interface LeftSidebarProps {
4242
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
4343
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
4444
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
45+
workspaceRecency: Record<string, number>;
4546
}
4647

4748
export function LeftSidebar(props: LeftSidebarProps) {

src/components/ProjectSidebar.tsx

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { useDrag, useDrop, useDragLayer } from "react-dnd";
1111
import { sortProjectsByOrder, reorderProjects, normalizeOrder } from "@/utils/projectOrdering";
1212
import { matchesKeybind, formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
1313
import { abbreviatePath } from "@/utils/ui/pathAbbreviation";
14+
import {
15+
partitionWorkspacesByAge,
16+
formatOldWorkspaceThreshold,
17+
} from "@/utils/ui/workspaceFiltering";
1418
import { TooltipWrapper, Tooltip } from "./Tooltip";
1519
import SecretsModal from "./SecretsModal";
1620
import type { Secret } from "@/types/secrets";
@@ -312,6 +316,49 @@ const AddWorkspaceBtn = styled.button`
312316
}
313317
`;
314318

319+
const OldWorkspacesSection = styled.button<{ expanded: boolean }>`
320+
width: 100%;
321+
padding: 8px 12px 8px 22px;
322+
background: transparent;
323+
color: #858585;
324+
border: none;
325+
border-top: 1px solid #2a2a2b;
326+
cursor: pointer;
327+
font-size: 12px;
328+
transition: all 0.15s;
329+
display: flex;
330+
align-items: center;
331+
justify-content: space-between;
332+
font-weight: 500;
333+
334+
&:hover {
335+
background: rgba(255, 255, 255, 0.03);
336+
color: #aaa;
337+
338+
.arrow {
339+
color: #aaa;
340+
}
341+
}
342+
343+
.label {
344+
display: flex;
345+
align-items: center;
346+
gap: 6px;
347+
}
348+
349+
.count {
350+
color: #666;
351+
font-weight: 400;
352+
}
353+
354+
.arrow {
355+
font-size: 11px;
356+
color: #666;
357+
transition: transform 0.2s ease;
358+
transform: ${(props) => (props.expanded ? "rotate(90deg)" : "rotate(0deg)")};
359+
}
360+
`;
361+
315362
const RemoveErrorToast = styled.div<{ top: number; left: number }>`
316363
position: fixed;
317364
top: ${(props) => props.top}px;
@@ -492,6 +539,7 @@ interface ProjectSidebarProps {
492539
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
493540
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
494541
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
542+
workspaceRecency: Record<string, number>;
495543
}
496544

497545
const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
@@ -510,6 +558,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
510558
onGetSecrets,
511559
onUpdateSecrets,
512560
sortedWorkspacesByProject,
561+
workspaceRecency,
513562
}) => {
514563
// Workspace-specific subscriptions moved to WorkspaceListItem component
515564

@@ -525,6 +574,11 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
525574
const setExpandedProjects = (projects: Set<string>) => {
526575
setExpandedProjectsArray(Array.from(projects));
527576
};
577+
578+
// Track which projects have old workspaces expanded (per-project)
579+
const [expandedOldWorkspaces, setExpandedOldWorkspaces] = usePersistedState<
580+
Record<string, boolean>
581+
>("expandedOldWorkspaces", {});
528582
const [removeError, setRemoveError] = useState<{
529583
workspaceId: string;
530584
error: string;
@@ -561,6 +615,13 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
561615
setExpandedProjects(newExpanded);
562616
};
563617

618+
const toggleOldWorkspaces = (projectPath: string) => {
619+
setExpandedOldWorkspaces((prev) => ({
620+
...prev,
621+
[projectPath]: !prev[projectPath],
622+
}));
623+
};
624+
564625
const showRemoveError = useCallback(
565626
(workspaceId: string, error: string, anchor?: { top: number; left: number }) => {
566627
if (removeErrorTimeoutRef.current) {
@@ -825,23 +886,56 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
825886
` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`}
826887
</AddWorkspaceBtn>
827888
</WorkspaceHeader>
828-
{sortedWorkspacesByProject.get(projectPath)?.map((metadata) => {
829-
const isSelected = selectedWorkspace?.workspaceId === metadata.id;
889+
{(() => {
890+
const allWorkspaces =
891+
sortedWorkspacesByProject.get(projectPath) ?? [];
892+
const { recent, old } = partitionWorkspacesByAge(
893+
allWorkspaces,
894+
workspaceRecency
895+
);
896+
const showOldWorkspaces = expandedOldWorkspaces[projectPath] ?? false;
830897

831-
return (
898+
const renderWorkspace = (metadata: FrontendWorkspaceMetadata) => (
832899
<WorkspaceListItem
833900
key={metadata.id}
834901
metadata={metadata}
835902
projectPath={projectPath}
836903
projectName={projectName}
837-
isSelected={isSelected}
904+
isSelected={selectedWorkspace?.workspaceId === metadata.id}
838905
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
839906
onSelectWorkspace={onSelectWorkspace}
840907
onRemoveWorkspace={handleRemoveWorkspace}
841908
onToggleUnread={_onToggleUnread}
842909
/>
843910
);
844-
})}
911+
912+
return (
913+
<>
914+
{recent.map(renderWorkspace)}
915+
{old.length > 0 && (
916+
<>
917+
<OldWorkspacesSection
918+
onClick={() => toggleOldWorkspaces(projectPath)}
919+
aria-label={
920+
showOldWorkspaces
921+
? `Collapse workspaces older than ${formatOldWorkspaceThreshold()}`
922+
: `Expand workspaces older than ${formatOldWorkspaceThreshold()}`
923+
}
924+
aria-expanded={showOldWorkspaces}
925+
expanded={showOldWorkspaces}
926+
>
927+
<div className="label">
928+
<span>Older than {formatOldWorkspaceThreshold()}</span>
929+
<span className="count">({old.length})</span>
930+
</div>
931+
<span className="arrow"></span>
932+
</OldWorkspacesSection>
933+
{showOldWorkspaces && old.map(renderWorkspace)}
934+
</>
935+
)}
936+
</>
937+
);
938+
})()}
845939
</WorkspacesContainer>
846940
)}
847941
</ProjectGroup>
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, it, expect } from "@jest/globals";
2+
import { partitionWorkspacesByAge, formatOldWorkspaceThreshold } from "./workspaceFiltering";
3+
import type { FrontendWorkspaceMetadata } from "@/types/workspace";
4+
5+
describe("partitionWorkspacesByAge", () => {
6+
const now = Date.now();
7+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
8+
9+
const createWorkspace = (id: string): FrontendWorkspaceMetadata => ({
10+
id,
11+
name: `workspace-${id}`,
12+
projectName: "test-project",
13+
projectPath: "/test/project",
14+
namedWorkspacePath: `/test/project/.worktrees/${id}`,
15+
});
16+
17+
it("should partition workspaces into recent and old based on 24-hour threshold", () => {
18+
const workspaces = [
19+
createWorkspace("recent1"),
20+
createWorkspace("old1"),
21+
createWorkspace("recent2"),
22+
createWorkspace("old2"),
23+
];
24+
25+
const workspaceRecency = {
26+
recent1: now - 1000, // 1 second ago
27+
old1: now - ONE_DAY_MS - 1000, // 24 hours and 1 second ago
28+
recent2: now - 12 * 60 * 60 * 1000, // 12 hours ago
29+
old2: now - 2 * ONE_DAY_MS, // 2 days ago
30+
};
31+
32+
const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
33+
34+
expect(recent).toHaveLength(2);
35+
expect(recent.map((w) => w.id)).toEqual(expect.arrayContaining(["recent1", "recent2"]));
36+
37+
expect(old).toHaveLength(2);
38+
expect(old.map((w) => w.id)).toEqual(expect.arrayContaining(["old1", "old2"]));
39+
});
40+
41+
it("should treat workspaces with no recency timestamp as old", () => {
42+
const workspaces = [createWorkspace("no-activity"), createWorkspace("recent")];
43+
44+
const workspaceRecency = {
45+
recent: now - 1000,
46+
// no-activity has no timestamp
47+
};
48+
49+
const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
50+
51+
expect(recent).toHaveLength(1);
52+
expect(recent[0].id).toBe("recent");
53+
54+
expect(old).toHaveLength(1);
55+
expect(old[0].id).toBe("no-activity");
56+
});
57+
58+
it("should handle empty workspace list", () => {
59+
const { recent, old } = partitionWorkspacesByAge([], {});
60+
61+
expect(recent).toHaveLength(0);
62+
expect(old).toHaveLength(0);
63+
});
64+
65+
it("should handle workspace at exactly 24 hours (should show as recent due to always-show-one rule)", () => {
66+
const workspaces = [createWorkspace("exactly-24h")];
67+
68+
const workspaceRecency = {
69+
"exactly-24h": now - ONE_DAY_MS,
70+
};
71+
72+
const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
73+
74+
// Even though it's exactly 24 hours old, it should show as recent (always show at least one)
75+
expect(recent).toHaveLength(1);
76+
expect(recent[0].id).toBe("exactly-24h");
77+
expect(old).toHaveLength(0);
78+
});
79+
80+
it("should preserve workspace order within partitions", () => {
81+
const workspaces = [
82+
createWorkspace("recent"),
83+
createWorkspace("old1"),
84+
createWorkspace("old2"),
85+
createWorkspace("old3"),
86+
];
87+
88+
const workspaceRecency = {
89+
recent: now - 1000,
90+
old1: now - 2 * ONE_DAY_MS,
91+
old2: now - 3 * ONE_DAY_MS,
92+
old3: now - 4 * ONE_DAY_MS,
93+
};
94+
95+
const { old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
96+
97+
expect(old.map((w) => w.id)).toEqual(["old1", "old2", "old3"]);
98+
});
99+
100+
it("should always show at least one workspace when all are old", () => {
101+
const workspaces = [createWorkspace("old1"), createWorkspace("old2"), createWorkspace("old3")];
102+
103+
const workspaceRecency = {
104+
old1: now - 2 * ONE_DAY_MS,
105+
old2: now - 3 * ONE_DAY_MS,
106+
old3: now - 4 * ONE_DAY_MS,
107+
};
108+
109+
const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency);
110+
111+
// Most recent should be moved to recent section
112+
expect(recent).toHaveLength(1);
113+
expect(recent[0].id).toBe("old1");
114+
115+
// Remaining should stay in old section
116+
expect(old).toHaveLength(2);
117+
expect(old.map((w) => w.id)).toEqual(["old2", "old3"]);
118+
});
119+
});
120+
121+
describe("formatOldWorkspaceThreshold", () => {
122+
it("should format the threshold as a human-readable string", () => {
123+
const result = formatOldWorkspaceThreshold();
124+
expect(result).toBe("1 day");
125+
});
126+
});

src/utils/ui/workspaceFiltering.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { FrontendWorkspaceMetadata } from "@/types/workspace";
2+
3+
/**
4+
* Time threshold for considering a workspace "old" (24 hours in milliseconds)
5+
*/
6+
const OLD_WORKSPACE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
7+
8+
/**
9+
* Format the old workspace threshold for display.
10+
* Returns a human-readable string like "1 day", "2 hours", etc.
11+
*/
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`;
19+
}
20+
21+
/**
22+
* Partition workspaces into recent and old based on recency timestamp.
23+
* Workspaces with no activity in the last 24 hours are considered "old".
24+
* Always shows at least one workspace in the recent section (the most recent one).
25+
*/
26+
export function partitionWorkspacesByAge(
27+
workspaces: FrontendWorkspaceMetadata[],
28+
workspaceRecency: Record<string, number>
29+
): {
30+
recent: FrontendWorkspaceMetadata[];
31+
old: FrontendWorkspaceMetadata[];
32+
} {
33+
if (workspaces.length === 0) {
34+
return { recent: [], old: [] };
35+
}
36+
37+
const now = Date.now();
38+
const recent: FrontendWorkspaceMetadata[] = [];
39+
const old: FrontendWorkspaceMetadata[] = [];
40+
41+
for (const workspace of workspaces) {
42+
const recencyTimestamp = workspaceRecency[workspace.id] ?? 0;
43+
const age = now - recencyTimestamp;
44+
45+
if (age >= OLD_WORKSPACE_THRESHOLD_MS) {
46+
old.push(workspace);
47+
} else {
48+
recent.push(workspace);
49+
}
50+
}
51+
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()!);
55+
}
56+
57+
return { recent, old };
58+
}

0 commit comments

Comments
 (0)