Skip to content
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,7 @@ function AppInner() {
onGetSecrets={handleGetSecrets}
onUpdateSecrets={handleUpdateSecrets}
sortedWorkspacesByProject={sortedWorkspacesByProject}
workspaceRecency={workspaceRecency}
/>
<MainContent>
<ContentArea>
Expand Down
1 change: 1 addition & 0 deletions src/components/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface LeftSidebarProps {
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
workspaceRecency: Record<string, number>;
}

export function LeftSidebar(props: LeftSidebarProps) {
Expand Down
104 changes: 99 additions & 5 deletions src/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -492,6 +539,7 @@ interface ProjectSidebarProps {
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
workspaceRecency: Record<string, number>;
}

const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
Expand All @@ -510,6 +558,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
onGetSecrets,
onUpdateSecrets,
sortedWorkspacesByProject,
workspaceRecency,
}) => {
// Workspace-specific subscriptions moved to WorkspaceListItem component

Expand All @@ -525,6 +574,11 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
const setExpandedProjects = (projects: Set<string>) => {
setExpandedProjectsArray(Array.from(projects));
};

// Track which projects have old workspaces expanded (per-project)
const [expandedOldWorkspaces, setExpandedOldWorkspaces] = usePersistedState<
Record<string, boolean>
>("expandedOldWorkspaces", {});
const [removeError, setRemoveError] = useState<{
workspaceId: string;
error: string;
Expand Down Expand Up @@ -561,6 +615,13 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
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) {
Expand Down Expand Up @@ -825,23 +886,56 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`}
</AddWorkspaceBtn>
</WorkspaceHeader>
{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) => (
<WorkspaceListItem
key={metadata.id}
metadata={metadata}
projectPath={projectPath}
projectName={projectName}
isSelected={isSelected}
isSelected={selectedWorkspace?.workspaceId === metadata.id}
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
onSelectWorkspace={onSelectWorkspace}
onRemoveWorkspace={handleRemoveWorkspace}
onToggleUnread={_onToggleUnread}
/>
);
})}

return (
<>
{recent.map(renderWorkspace)}
{old.length > 0 && (
<>
<OldWorkspacesSection
onClick={() => toggleOldWorkspaces(projectPath)}
aria-label={
showOldWorkspaces
? `Collapse workspaces older than ${formatOldWorkspaceThreshold()}`
: `Expand workspaces older than ${formatOldWorkspaceThreshold()}`
}
aria-expanded={showOldWorkspaces}
expanded={showOldWorkspaces}
>
<div className="label">
<span>Older than {formatOldWorkspaceThreshold()}</span>
<span className="count">({old.length})</span>
</div>
<span className="arrow">â–¶</span>
</OldWorkspacesSection>
{showOldWorkspaces && old.map(renderWorkspace)}
</>
)}
</>
);
})()}
</WorkspacesContainer>
)}
</ProjectGroup>
Expand Down
126 changes: 126 additions & 0 deletions src/utils/ui/workspaceFiltering.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
58 changes: 58 additions & 0 deletions src/utils/ui/workspaceFiltering.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>
): {
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 };
}