From 93f95e39539c63fef41ee839d8a3fb926bc76f16 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 18:16:17 -0500 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=A4=96=20Add=20collapsible=20section?= =?UTF-8?q?=20for=20old=20workspaces=20in=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hide workspaces inactive for >24 hours by default per project - Add 'Show N older workspaces' toggle button with count - Persist expansion state in localStorage - Fully reversible expansion/collapse - Includes comprehensive unit tests Improves sidebar UX by reducing clutter from stale workspaces while keeping them accessible when needed. --- src/App.tsx | 1 + src/components/LeftSidebar.tsx | 1 + src/components/ProjectSidebar.tsx | 143 ++++++++++++++++++------ src/utils/ui/workspaceFiltering.test.ts | 97 ++++++++++++++++ src/utils/ui/workspaceFiltering.ts | 36 ++++++ 5 files changed, 246 insertions(+), 32 deletions(-) create mode 100644 src/utils/ui/workspaceFiltering.test.ts create mode 100644 src/utils/ui/workspaceFiltering.ts 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..23e539fa2 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -11,6 +11,7 @@ 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 } from "@/utils/ui/workspaceFiltering"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import SecretsModal from "./SecretsModal"; import type { Secret } from "@/types/secrets"; @@ -312,6 +313,26 @@ const AddWorkspaceBtn = styled.button` } `; +const ShowOldWorkspacesBtn = styled.button` + width: 100%; + padding: 6px 12px 6px 22px; + background: transparent; + color: #666; + border: none; + cursor: pointer; + font-size: 12px; + transition: all 0.2s; + text-align: left; + display: flex; + align-items: center; + gap: 6px; + + &:hover { + background: #2a2a2b; + color: #888; + } +`; + const RemoveErrorToast = styled.div<{ top: number; left: number }>` position: fixed; top: ${(props) => props.top}px; @@ -492,6 +513,7 @@ interface ProjectSidebarProps { onGetSecrets: (projectPath: string) => Promise; onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise; sortedWorkspacesByProject: Map; + workspaceRecency: Record; } const ProjectSidebarInner: React.FC = ({ @@ -510,6 +532,7 @@ const ProjectSidebarInner: React.FC = ({ onGetSecrets, onUpdateSecrets, sortedWorkspacesByProject, + workspaceRecency, }) => { // Workspace-specific subscriptions moved to WorkspaceListItem component @@ -525,6 +548,18 @@ const ProjectSidebarInner: React.FC = ({ const setExpandedProjects = (projects: Set) => { setExpandedProjectsArray(Array.from(projects)); }; + + // Track which projects have old workspaces expanded + const [expandedOldWorkspacesArray, setExpandedOldWorkspacesArray] = usePersistedState( + "expandedOldWorkspaces", + [] + ); + const expandedOldWorkspaces = new Set( + Array.isArray(expandedOldWorkspacesArray) ? expandedOldWorkspacesArray : [] + ); + const setExpandedOldWorkspaces = (projects: Set) => { + setExpandedOldWorkspacesArray(Array.from(projects)); + }; const [removeError, setRemoveError] = useState<{ workspaceId: string; error: string; @@ -561,6 +596,16 @@ const ProjectSidebarInner: React.FC = ({ setExpandedProjects(newExpanded); }; + const toggleOldWorkspaces = (projectPath: string) => { + const newExpanded = new Set(expandedOldWorkspaces); + if (newExpanded.has(projectPath)) { + newExpanded.delete(projectPath); + } else { + newExpanded.add(projectPath); + } + setExpandedOldWorkspaces(newExpanded); + }; + const showRemoveError = useCallback( (workspaceId: string, error: string, anchor?: { top: number; left: number }) => { if (removeErrorTimeoutRef.current) { @@ -812,38 +857,72 @@ const ProjectSidebarInner: React.FC = ({ - {isExpanded && ( - - - onAddWorkspace(projectPath)} - data-project-path={projectPath} - aria-label={`Add workspace to ${projectName}`} - > - + New Workspace - {selectedWorkspace?.projectPath === projectPath && - ` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`} - - - {sortedWorkspacesByProject.get(projectPath)?.map((metadata) => { - const isSelected = selectedWorkspace?.workspaceId === metadata.id; - - return ( - - ); - })} - - )} + {isExpanded && (() => { + const allWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; + const { recent, old } = partitionWorkspacesByAge(allWorkspaces, workspaceRecency); + const showOldWorkspaces = expandedOldWorkspaces.has(projectPath); + + return ( + + + onAddWorkspace(projectPath)} + data-project-path={projectPath} + aria-label={`Add workspace to ${projectName}`} + > + + New Workspace + {selectedWorkspace?.projectPath === projectPath && + ` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`} + + + {recent.map((metadata) => { + const isSelected = selectedWorkspace?.workspaceId === metadata.id; + + return ( + + ); + })} + {old.length > 0 && ( + <> + toggleOldWorkspaces(projectPath)} + aria-label={showOldWorkspaces ? `Hide ${old.length} older workspaces` : `Show ${old.length} older workspaces`} + > + {showOldWorkspaces ? "▼" : "▶"} + {showOldWorkspaces ? "Hide" : "Show"} {old.length} older workspace{old.length !== 1 ? "s" : ""} + + {showOldWorkspaces && old.map((metadata) => { + const isSelected = selectedWorkspace?.workspaceId === metadata.id; + + return ( + + ); + })} + + )} + + ); + })()} ); }) diff --git a/src/utils/ui/workspaceFiltering.test.ts b/src/utils/ui/workspaceFiltering.test.ts new file mode 100644 index 000000000..37bd00f70 --- /dev/null +++ b/src/utils/ui/workspaceFiltering.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "@jest/globals"; +import { partitionWorkspacesByAge } 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 be old)", () => { + const workspaces = [createWorkspace("exactly-24h")]; + + const workspaceRecency = { + "exactly-24h": now - ONE_DAY_MS, + }; + + const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency); + + expect(recent).toHaveLength(0); + expect(old).toHaveLength(1); + expect(old[0].id).toBe("exactly-24h"); + }); + + it("should preserve workspace order within partitions", () => { + 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 { old } = partitionWorkspacesByAge(workspaces, workspaceRecency); + + expect(old.map((w) => w.id)).toEqual(["old1", "old2", "old3"]); + }); +}); + diff --git a/src/utils/ui/workspaceFiltering.ts b/src/utils/ui/workspaceFiltering.ts new file mode 100644 index 000000000..46f306835 --- /dev/null +++ b/src/utils/ui/workspaceFiltering.ts @@ -0,0 +1,36 @@ +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; + +/** + * Partition workspaces into recent and old based on recency timestamp. + * Workspaces with no activity in the last 24 hours are considered "old". + */ +export function partitionWorkspacesByAge( + workspaces: FrontendWorkspaceMetadata[], + workspaceRecency: Record +): { + recent: FrontendWorkspaceMetadata[]; + old: FrontendWorkspaceMetadata[]; +} { + 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); + } + } + + return { recent, old }; +} + From 625033e9f5a5784efd52183bab4d8d4c97296279 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 18:19:17 -0500 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=A4=96=20Improve=20old=20workspaces?= =?UTF-8?q?=20toggle=20design=20and=20messaging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change text from 'Show N older workspaces' to 'Show workspaces older than 1 day (N)' - Add formatOldWorkspaceThreshold() for human-readable cutoff display - Enhance button styling with better colors, border-top separator, and spacing - Use CSS classes for arrow and count styling - Fix boundary condition: workspaces at exactly 24 hours are now correctly marked as old Design improvements: - Clearer visual separation with border-top - Better hover states with subtle background change - Count displayed in parentheses as secondary info - More professional, less cluttered appearance --- src/components/ProjectSidebar.tsx | 36 ++++++++++++++++++------- src/utils/ui/workspaceFiltering.test.ts | 9 ++++++- src/utils/ui/workspaceFiltering.ts | 15 ++++++++++- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 23e539fa2..8f649ec4b 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -11,7 +11,7 @@ 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 } from "@/utils/ui/workspaceFiltering"; +import { partitionWorkspacesByAge, formatOldWorkspaceThreshold } from "@/utils/ui/workspaceFiltering"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import SecretsModal from "./SecretsModal"; import type { Secret } from "@/types/secrets"; @@ -315,21 +315,34 @@ const AddWorkspaceBtn = styled.button` const ShowOldWorkspacesBtn = styled.button` width: 100%; - padding: 6px 12px 6px 22px; + padding: 8px 12px 8px 22px; background: transparent; - color: #666; + color: #858585; border: none; + border-top: 1px solid #2a2a2b; cursor: pointer; font-size: 12px; - transition: all 0.2s; + transition: all 0.15s; text-align: left; display: flex; align-items: center; - gap: 6px; + gap: 8px; + font-weight: 500; &:hover { - background: #2a2a2b; - color: #888; + background: rgba(255, 255, 255, 0.03); + color: #aaa; + } + + .arrow { + font-size: 10px; + color: #666; + transition: transform 0.15s; + } + + .count { + color: #666; + font-weight: 400; } `; @@ -896,10 +909,13 @@ const ProjectSidebarInner: React.FC = ({ <> toggleOldWorkspaces(projectPath)} - aria-label={showOldWorkspaces ? `Hide ${old.length} older workspaces` : `Show ${old.length} older workspaces`} + aria-label={showOldWorkspaces ? `Hide workspaces older than ${formatOldWorkspaceThreshold()}` : `Show workspaces older than ${formatOldWorkspaceThreshold()}`} > - {showOldWorkspaces ? "▼" : "▶"} - {showOldWorkspaces ? "Hide" : "Show"} {old.length} older workspace{old.length !== 1 ? "s" : ""} + {showOldWorkspaces ? "▼" : "▶"} + + {showOldWorkspaces ? "Hide" : "Show"} workspaces older than {formatOldWorkspaceThreshold()} + + ({old.length}) {showOldWorkspaces && old.map((metadata) => { const isSelected = selectedWorkspace?.workspaceId === metadata.id; diff --git a/src/utils/ui/workspaceFiltering.test.ts b/src/utils/ui/workspaceFiltering.test.ts index 37bd00f70..bbf76f65a 100644 --- a/src/utils/ui/workspaceFiltering.test.ts +++ b/src/utils/ui/workspaceFiltering.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "@jest/globals"; -import { partitionWorkspacesByAge } from "./workspaceFiltering"; +import { partitionWorkspacesByAge, formatOldWorkspaceThreshold } from "./workspaceFiltering"; import type { FrontendWorkspaceMetadata } from "@/types/workspace"; describe("partitionWorkspacesByAge", () => { @@ -95,3 +95,10 @@ describe("partitionWorkspacesByAge", () => { }); }); +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 index 46f306835..43b0c6425 100644 --- a/src/utils/ui/workspaceFiltering.ts +++ b/src/utils/ui/workspaceFiltering.ts @@ -5,6 +5,19 @@ import type { FrontendWorkspaceMetadata } from "@/types/workspace"; */ 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". @@ -24,7 +37,7 @@ export function partitionWorkspacesByAge( const recencyTimestamp = workspaceRecency[workspace.id] ?? 0; const age = now - recencyTimestamp; - if (age > OLD_WORKSPACE_THRESHOLD_MS) { + if (age >= OLD_WORKSPACE_THRESHOLD_MS) { old.push(workspace); } else { recent.push(workspace); From 06f4090869ca870b82d0b0a0c247c2966c2ca7f7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 18:20:12 -0500 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=A4=96=20Make=20old=20workspaces=20to?= =?UTF-8?q?ggle=20symmetric?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Text stays constant: 'OLDER THAN 1 DAY (N)' - Only arrow changes: ▶ when collapsed, ▼ when expanded - Styled as section header with uppercase text - Better visual hierarchy with consistent spacing - Added aria-expanded for accessibility --- src/components/ProjectSidebar.tsx | 51 +++++++++++++++++++------------ 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 8f649ec4b..44a3cc2cc 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -313,36 +313,46 @@ const AddWorkspaceBtn = styled.button` } `; -const ShowOldWorkspacesBtn = styled.button` +const OldWorkspacesSection = styled.button` width: 100%; padding: 8px 12px 8px 22px; background: transparent; - color: #858585; + color: #787878; border: none; border-top: 1px solid #2a2a2b; cursor: pointer; - font-size: 12px; + font-size: 11px; transition: all 0.15s; - text-align: left; display: flex; align-items: center; - gap: 8px; - font-weight: 500; + justify-content: space-between; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; &:hover { background: rgba(255, 255, 255, 0.03); - color: #aaa; + color: #999; + + .arrow { + color: #999; + } } - .arrow { - font-size: 10px; - color: #666; - transition: transform 0.15s; + .label { + display: flex; + align-items: center; + gap: 6px; } .count { + color: #5a5a5a; + } + + .arrow { + font-size: 10px; color: #666; - font-weight: 400; + transition: transform 0.15s ease; } `; @@ -907,16 +917,17 @@ const ProjectSidebarInner: React.FC = ({ })} {old.length > 0 && ( <> - toggleOldWorkspaces(projectPath)} - aria-label={showOldWorkspaces ? `Hide workspaces older than ${formatOldWorkspaceThreshold()}` : `Show workspaces older than ${formatOldWorkspaceThreshold()}`} + aria-label={showOldWorkspaces ? `Collapse workspaces older than ${formatOldWorkspaceThreshold()}` : `Expand workspaces older than ${formatOldWorkspaceThreshold()}`} + aria-expanded={showOldWorkspaces} > - {showOldWorkspaces ? "▼" : "▶"} - - {showOldWorkspaces ? "Hide" : "Show"} workspaces older than {formatOldWorkspaceThreshold()} - - ({old.length}) - +
+ {showOldWorkspaces ? "▼" : "▶"} + Older than {formatOldWorkspaceThreshold()} + ({old.length}) +
+ {showOldWorkspaces && old.map((metadata) => { const isSelected = selectedWorkspace?.workspaceId === metadata.id; From 1579c5d70f5887679162422de88a0826602119bc Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 18:21:08 -0500 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=A4=96=20Make=20old=20workspaces=20se?= =?UTF-8?q?ction=20perfectly=20symmetric?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Arrow moved to right side and rotates in place - Text 'Older than 1 day (N)' stays constant on left - Arrow rotates 90° from ▶ to ▼ when expanded - Restored normal font weight (not uppercase) - Perfect symmetry: all elements stay in same position --- src/components/ProjectSidebar.tsx | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 44a3cc2cc..dbd0d0477 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -313,29 +313,27 @@ const AddWorkspaceBtn = styled.button` } `; -const OldWorkspacesSection = styled.button` +const OldWorkspacesSection = styled.button<{ expanded: boolean }>` width: 100%; padding: 8px 12px 8px 22px; background: transparent; - color: #787878; + color: #858585; border: none; border-top: 1px solid #2a2a2b; cursor: pointer; - font-size: 11px; + font-size: 12px; transition: all 0.15s; display: flex; align-items: center; justify-content: space-between; - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 600; + font-weight: 500; &:hover { background: rgba(255, 255, 255, 0.03); - color: #999; + color: #aaa; .arrow { - color: #999; + color: #aaa; } } @@ -346,13 +344,15 @@ const OldWorkspacesSection = styled.button` } .count { - color: #5a5a5a; + color: #666; + font-weight: 400; } .arrow { - font-size: 10px; + font-size: 11px; color: #666; - transition: transform 0.15s ease; + transition: transform 0.2s ease; + transform: ${(props) => (props.expanded ? "rotate(90deg)" : "rotate(0deg)")}; } `; @@ -921,12 +921,13 @@ const ProjectSidebarInner: React.FC = ({ onClick={() => toggleOldWorkspaces(projectPath)} aria-label={showOldWorkspaces ? `Collapse workspaces older than ${formatOldWorkspaceThreshold()}` : `Expand workspaces older than ${formatOldWorkspaceThreshold()}`} aria-expanded={showOldWorkspaces} + expanded={showOldWorkspaces} >
- {showOldWorkspaces ? "▼" : "▶"} Older than {formatOldWorkspaceThreshold()} ({old.length})
+ {showOldWorkspaces && old.map((metadata) => { const isSelected = selectedWorkspace?.workspaceId === metadata.id; From 02f9f5fe068d8ea8cac2eb1751257a8e94675a97 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 18:22:30 -0500 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=A4=96=20Always=20show=20at=20least?= =?UTF-8?q?=20one=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When all workspaces are old, show the most recent one - Prevents empty workspace list in sidebar - Updated tests to cover this behavior - Improves UX by ensuring projects always show their most recent workspace --- src/utils/ui/workspaceFiltering.test.ts | 35 ++++++++++++++++++++++--- src/utils/ui/workspaceFiltering.ts | 10 +++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/utils/ui/workspaceFiltering.test.ts b/src/utils/ui/workspaceFiltering.test.ts index bbf76f65a..76f58742d 100644 --- a/src/utils/ui/workspaceFiltering.test.ts +++ b/src/utils/ui/workspaceFiltering.test.ts @@ -62,7 +62,7 @@ describe("partitionWorkspacesByAge", () => { expect(old).toHaveLength(0); }); - it("should handle workspace at exactly 24 hours (should be old)", () => { + 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 = { @@ -71,19 +71,22 @@ describe("partitionWorkspacesByAge", () => { const { recent, old } = partitionWorkspacesByAge(workspaces, workspaceRecency); - expect(recent).toHaveLength(0); - expect(old).toHaveLength(1); - expect(old[0].id).toBe("exactly-24h"); + // 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, @@ -93,6 +96,30 @@ describe("partitionWorkspacesByAge", () => { 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", () => { diff --git a/src/utils/ui/workspaceFiltering.ts b/src/utils/ui/workspaceFiltering.ts index 43b0c6425..639fe5565 100644 --- a/src/utils/ui/workspaceFiltering.ts +++ b/src/utils/ui/workspaceFiltering.ts @@ -21,6 +21,7 @@ export function formatOldWorkspaceThreshold(): string { /** * 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[], @@ -29,6 +30,10 @@ export function partitionWorkspacesByAge( recent: FrontendWorkspaceMetadata[]; old: FrontendWorkspaceMetadata[]; } { + if (workspaces.length === 0) { + return { recent: [], old: [] }; + } + const now = Date.now(); const recent: FrontendWorkspaceMetadata[] = []; const old: FrontendWorkspaceMetadata[] = []; @@ -44,6 +49,11 @@ export function partitionWorkspacesByAge( } } + // 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 }; } From 5f155fba2fc37670de1787094e9cbbf98c346121 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 18:24:13 -0500 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=A4=96=20Fix=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProjectSidebar.tsx | 155 +++++++++++++----------- src/utils/ui/workspaceFiltering.test.ts | 7 +- src/utils/ui/workspaceFiltering.ts | 1 - 3 files changed, 85 insertions(+), 78 deletions(-) diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index dbd0d0477..c2a8a90fd 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -11,7 +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 { + partitionWorkspacesByAge, + formatOldWorkspaceThreshold, +} from "@/utils/ui/workspaceFiltering"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import SecretsModal from "./SecretsModal"; import type { Secret } from "@/types/secrets"; @@ -880,77 +883,87 @@ const ProjectSidebarInner: React.FC = ({ - {isExpanded && (() => { - const allWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; - const { recent, old } = partitionWorkspacesByAge(allWorkspaces, workspaceRecency); - const showOldWorkspaces = expandedOldWorkspaces.has(projectPath); - - return ( - - - onAddWorkspace(projectPath)} - data-project-path={projectPath} - aria-label={`Add workspace to ${projectName}`} - > - + New Workspace - {selectedWorkspace?.projectPath === projectPath && - ` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`} - - - {recent.map((metadata) => { - const isSelected = selectedWorkspace?.workspaceId === metadata.id; - - return ( - - ); - })} - {old.length > 0 && ( - <> - toggleOldWorkspaces(projectPath)} - aria-label={showOldWorkspaces ? `Collapse workspaces older than ${formatOldWorkspaceThreshold()}` : `Expand workspaces older than ${formatOldWorkspaceThreshold()}`} - aria-expanded={showOldWorkspaces} - expanded={showOldWorkspaces} + {isExpanded && + (() => { + const allWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; + const { recent, old } = partitionWorkspacesByAge( + allWorkspaces, + workspaceRecency + ); + const showOldWorkspaces = expandedOldWorkspaces.has(projectPath); + + return ( + + + onAddWorkspace(projectPath)} + data-project-path={projectPath} + aria-label={`Add workspace to ${projectName}`} > -
- Older than {formatOldWorkspaceThreshold()} - ({old.length}) -
- -
- {showOldWorkspaces && old.map((metadata) => { - const isSelected = selectedWorkspace?.workspaceId === metadata.id; - - return ( - - ); - })} - - )} -
- ); - })()} + + New Workspace + {selectedWorkspace?.projectPath === projectPath && + ` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`} + + + {recent.map((metadata) => { + const isSelected = selectedWorkspace?.workspaceId === metadata.id; + + return ( + + ); + })} + {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((metadata) => { + const isSelected = + selectedWorkspace?.workspaceId === metadata.id; + + return ( + + ); + })} + + )} + + ); + })()} ); }) diff --git a/src/utils/ui/workspaceFiltering.test.ts b/src/utils/ui/workspaceFiltering.test.ts index 76f58742d..f22052bac 100644 --- a/src/utils/ui/workspaceFiltering.test.ts +++ b/src/utils/ui/workspaceFiltering.test.ts @@ -98,11 +98,7 @@ describe("partitionWorkspacesByAge", () => { }); it("should always show at least one workspace when all are old", () => { - const workspaces = [ - createWorkspace("old1"), - createWorkspace("old2"), - createWorkspace("old3"), - ]; + const workspaces = [createWorkspace("old1"), createWorkspace("old2"), createWorkspace("old3")]; const workspaceRecency = { old1: now - 2 * ONE_DAY_MS, @@ -128,4 +124,3 @@ describe("formatOldWorkspaceThreshold", () => { expect(result).toBe("1 day"); }); }); - diff --git a/src/utils/ui/workspaceFiltering.ts b/src/utils/ui/workspaceFiltering.ts index 639fe5565..a42733c7f 100644 --- a/src/utils/ui/workspaceFiltering.ts +++ b/src/utils/ui/workspaceFiltering.ts @@ -56,4 +56,3 @@ export function partitionWorkspacesByAge( return { recent, old }; } - From 9bd5bb8a5c7e367f4f247d3dc2983c2a88be8abc Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 18:34:06 -0500 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=A4=96=20Fix:=20Scope=20expandedOldWo?= =?UTF-8?q?rkspaces=20per-project?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed from Set to Record for clearer per-project tracking - Now properly scoped: each project path has its own expansion state - Simplified toggle logic --- src/components/ProjectSidebar.tsx | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index c2a8a90fd..3f3873679 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -575,17 +575,10 @@ const ProjectSidebarInner: React.FC = ({ setExpandedProjectsArray(Array.from(projects)); }; - // Track which projects have old workspaces expanded - const [expandedOldWorkspacesArray, setExpandedOldWorkspacesArray] = usePersistedState( - "expandedOldWorkspaces", - [] - ); - const expandedOldWorkspaces = new Set( - Array.isArray(expandedOldWorkspacesArray) ? expandedOldWorkspacesArray : [] - ); - const setExpandedOldWorkspaces = (projects: Set) => { - setExpandedOldWorkspacesArray(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; @@ -623,13 +616,10 @@ const ProjectSidebarInner: React.FC = ({ }; const toggleOldWorkspaces = (projectPath: string) => { - const newExpanded = new Set(expandedOldWorkspaces); - if (newExpanded.has(projectPath)) { - newExpanded.delete(projectPath); - } else { - newExpanded.add(projectPath); - } - setExpandedOldWorkspaces(newExpanded); + setExpandedOldWorkspaces((prev) => ({ + ...prev, + [projectPath]: !prev[projectPath], + })); }; const showRemoveError = useCallback( @@ -890,7 +880,7 @@ const ProjectSidebarInner: React.FC = ({ allWorkspaces, workspaceRecency ); - const showOldWorkspaces = expandedOldWorkspaces.has(projectPath); + const showOldWorkspaces = expandedOldWorkspaces[projectPath] ?? false; return ( From c30c510568900dc54dd69447525b1db27ef6fef0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 18:35:39 -0500 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=A4=96=20Refactor=20workspace=20rende?= =?UTF-8?q?ring=20to=20reduce=20nesting=20and=20duplication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract workspace rendering into renderWorkspace helper - Reduce nesting depth by restructuring JSX - Eliminate duplicate WorkspaceListItem code - Move WorkspacesContainer outside IIFE - Clearer separation of concerns --- src/components/ProjectSidebar.tsx | 145 +++++++++++++----------------- 1 file changed, 64 insertions(+), 81 deletions(-) diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 3f3873679..111ed28f2 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -873,87 +873,70 @@ const ProjectSidebarInner: React.FC = ({ - {isExpanded && - (() => { - const allWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; - const { recent, old } = partitionWorkspacesByAge( - allWorkspaces, - workspaceRecency - ); - const showOldWorkspaces = expandedOldWorkspaces[projectPath] ?? false; - - return ( - - - onAddWorkspace(projectPath)} - data-project-path={projectPath} - aria-label={`Add workspace to ${projectName}`} - > - + New Workspace - {selectedWorkspace?.projectPath === projectPath && - ` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`} - - - {recent.map((metadata) => { - const isSelected = selectedWorkspace?.workspaceId === metadata.id; - - return ( - - ); - })} - {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((metadata) => { - const isSelected = - selectedWorkspace?.workspaceId === metadata.id; - - return ( - - ); - })} - - )} -
- ); - })()} + {isExpanded && ( + + + onAddWorkspace(projectPath)} + data-project-path={projectPath} + aria-label={`Add workspace to ${projectName}`} + > + + New Workspace + {selectedWorkspace?.projectPath === projectPath && + ` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`} + + + {(() => { + const allWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; + const { recent, old } = partitionWorkspacesByAge( + allWorkspaces, + workspaceRecency + ); + const showOldWorkspaces = expandedOldWorkspaces[projectPath] ?? false; + + 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)} + + )} + + ); + })()} +
+ )} ); }) From 335f6a504914b1e3a5e5eea08fa24fb356cabf70 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 18:49:38 -0500 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=A4=96=20Fix=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProjectSidebar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 111ed28f2..bcd79d578 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -887,7 +887,8 @@ const ProjectSidebarInner: React.FC = ({ {(() => { - const allWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; + const allWorkspaces = + sortedWorkspacesByProject.get(projectPath) ?? []; const { recent, old } = partitionWorkspacesByAge( allWorkspaces, workspaceRecency