Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 16 additions & 38 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import "./styles/globals.css";
import { useWorkspaceContext } from "./contexts/WorkspaceContext";
import { useProjectContext } from "./contexts/ProjectContext";
import type { WorkspaceSelection } from "./components/ProjectSidebar";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import { LeftSidebar } from "./components/LeftSidebar";
import { ProjectCreateModal } from "./components/ProjectCreateModal";
import { AIView } from "./components/AIView";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering";
import { useResumeManager } from "./hooks/useResumeManager";
import { useUnreadTracking } from "./hooks/useUnreadTracking";
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
Expand Down Expand Up @@ -193,46 +193,24 @@ function AppInner() {
// NEW: Get workspace recency from store
const workspaceRecency = useWorkspaceRecency();

// Sort workspaces by recency (most recent first)
// Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
// Build sorted workspaces map including pending workspaces
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
const sortedWorkspacesByProject = useStableReference(
() => {
const result = new Map<string, FrontendWorkspaceMetadata[]>();
for (const [projectPath, config] of projects) {
// Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID
const metadataList = config.workspaces
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null);

// Sort by recency
metadataList.sort((a, b) => {
const aTimestamp = workspaceRecency[a.id] ?? 0;
const bTimestamp = workspaceRecency[b.id] ?? 0;
return bTimestamp - aTimestamp;
() => buildSortedWorkspacesByProject(projects, workspaceMetadata, workspaceRecency),
(prev, next) =>
compareMaps(prev, next, (a, b) => {
if (a.length !== b.length) return false;
// Check ID, name, and status to detect changes
return a.every((meta, i) => {
const other = b[i];
return (
other &&
meta.id === other.id &&
meta.name === other.name &&
meta.status === other.status
);
});

result.set(projectPath, metadataList);
}
return result;
},
(prev, next) => {
// Compare Maps: check if size, workspace order, and metadata content are the same
if (
!compareMaps(prev, next, (a, b) => {
if (a.length !== b.length) return false;
// Check both ID and name to detect renames
return a.every((metadata, i) => {
const bMeta = b[i];
if (!bMeta || !metadata) return false; // Null-safe
return metadata.id === bMeta.id && metadata.name === bMeta.name;
});
})
) {
return false;
}
return true;
},
}),
[projects, workspaceMetadata, workspaceRecency]
);

Expand Down
91 changes: 55 additions & 36 deletions src/browser/components/WorkspaceListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
onToggleUnread,
}) => {
// Destructure metadata for convenience
const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata;
const { id: workspaceId, name: workspaceName, namedWorkspacePath, status } = metadata;
const isCreating = status === "creating";
const gitStatus = useGitStatus(workspaceId);

// Get rename context
Expand Down Expand Up @@ -103,18 +104,23 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
<React.Fragment>
<div
className={cn(
"py-1.5 pl-4 pr-2 cursor-pointer border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative hover:bg-hover [&:hover_button]:opacity-100 flex gap-2",
isSelected && "bg-hover border-l-blue-400"
"py-1.5 pl-4 pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
isCreating
? "cursor-default opacity-70"
: "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100",
isSelected && !isCreating && "bg-hover border-l-blue-400"
)}
onClick={() =>
onClick={() => {
if (isCreating) return; // Disable click while creating
onSelectWorkspace({
projectPath,
projectName,
namedWorkspacePath,
workspaceId,
})
}
});
}}
onKeyDown={(e) => {
if (isCreating) return; // Disable keyboard while creating
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectWorkspace({
Expand All @@ -126,9 +132,12 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
}
}}
role="button"
tabIndex={0}
tabIndex={isCreating ? -1 : 0}
aria-current={isSelected ? "true" : undefined}
aria-label={`Select workspace ${displayName}`}
aria-label={
isCreating ? `Creating workspace ${displayName}` : `Select workspace ${displayName}`
}
aria-disabled={isCreating}
data-workspace-path={namedWorkspacePath}
data-workspace-id={workspaceId}
>
Expand Down Expand Up @@ -156,14 +165,18 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
/>
) : (
<span
className="text-foreground -mx-1 min-w-0 flex-1 cursor-pointer truncate rounded-sm px-1 text-left text-[14px] transition-colors duration-200 hover:bg-white/5"
className={cn(
"text-foreground -mx-1 min-w-0 flex-1 truncate rounded-sm px-1 text-left text-[14px] transition-colors duration-200",
!isCreating && "cursor-pointer hover:bg-white/5"
)}
onDoubleClick={(e) => {
if (isCreating) return; // Disable rename while creating
e.stopPropagation();
startRenaming();
}}
title="Double-click to rename"
title={isCreating ? "Creating workspace..." : "Double-click to rename"}
>
{canInterrupt ? (
{canInterrupt || isCreating ? (
<Shimmer className="w-full truncate" colorClass="var(--color-foreground)">
{displayName}
</Shimmer>
Expand All @@ -174,33 +187,39 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
)}

<div className="ml-auto flex items-center gap-1">
<GitStatusIndicator
gitStatus={gitStatus}
workspaceId={workspaceId}
tooltipPosition="right"
/>

<TooltipWrapper inline>
<button
className="text-muted hover:text-foreground col-start-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-base opacity-0 transition-all duration-200 hover:rounded-sm hover:bg-white/10"
onClick={(e) => {
e.stopPropagation();
void onRemoveWorkspace(workspaceId, e.currentTarget);
}}
aria-label={`Remove workspace ${displayName}`}
data-workspace-id={workspaceId}
>
×
</button>
<Tooltip className="tooltip" align="right">
Remove workspace
</Tooltip>
</TooltipWrapper>
{!isCreating && (
<>
<GitStatusIndicator
gitStatus={gitStatus}
workspaceId={workspaceId}
tooltipPosition="right"
/>

<TooltipWrapper inline>
<button
className="text-muted hover:text-foreground col-start-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-base opacity-0 transition-all duration-200 hover:rounded-sm hover:bg-white/10"
onClick={(e) => {
e.stopPropagation();
void onRemoveWorkspace(workspaceId, e.currentTarget);
}}
aria-label={`Remove workspace ${displayName}`}
data-workspace-id={workspaceId}
>
×
</button>
<Tooltip className="tooltip" align="right">
Remove workspace
</Tooltip>
</TooltipWrapper>
</>
)}
</div>
</div>
<div className="min-w-0">
<WorkspaceStatusIndicator workspaceId={workspaceId} />
</div>
{!isCreating && (
<div className="min-w-0">
<WorkspaceStatusIndicator workspaceId={workspaceId} />
</div>
)}
</div>
</div>
{renameError && isEditing && (
Expand Down
149 changes: 148 additions & 1 deletion src/browser/utils/ui/workspaceFiltering.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, it, expect } from "@jest/globals";
import { partitionWorkspacesByAge, formatOldWorkspaceThreshold } from "./workspaceFiltering";
import {
partitionWorkspacesByAge,
formatOldWorkspaceThreshold,
buildSortedWorkspacesByProject,
} from "./workspaceFiltering";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { ProjectConfig } from "@/common/types/project";
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";

describe("partitionWorkspacesByAge", () => {
Expand Down Expand Up @@ -126,3 +131,145 @@ describe("formatOldWorkspaceThreshold", () => {
expect(result).toBe("1 day");
});
});

describe("buildSortedWorkspacesByProject", () => {
const createWorkspace = (
id: string,
projectPath: string,
status?: "creating"
): FrontendWorkspaceMetadata => ({
id,
name: `workspace-${id}`,
projectName: projectPath.split("/").pop() ?? "unknown",
projectPath,
namedWorkspacePath: `${projectPath}/workspace-${id}`,
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
status,
});

it("should include workspaces from persisted config", () => {
const projects = new Map<string, ProjectConfig>([
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
]);
const metadata = new Map<string, FrontendWorkspaceMetadata>([
["ws1", createWorkspace("ws1", "/project/a")],
]);

const result = buildSortedWorkspacesByProject(projects, metadata, {});

expect(result.get("/project/a")).toHaveLength(1);
expect(result.get("/project/a")?.[0].id).toBe("ws1");
});

it("should include pending workspaces not yet in config", () => {
const projects = new Map<string, ProjectConfig>([
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
]);
const metadata = new Map<string, FrontendWorkspaceMetadata>([
["ws1", createWorkspace("ws1", "/project/a")],
["pending1", createWorkspace("pending1", "/project/a", "creating")],
]);

const result = buildSortedWorkspacesByProject(projects, metadata, {});

expect(result.get("/project/a")).toHaveLength(2);
expect(result.get("/project/a")?.map((w) => w.id)).toContain("ws1");
expect(result.get("/project/a")?.map((w) => w.id)).toContain("pending1");
});

it("should handle multiple concurrent pending workspaces", () => {
const projects = new Map<string, ProjectConfig>([["/project/a", { workspaces: [] }]]);
const metadata = new Map<string, FrontendWorkspaceMetadata>([
["pending1", createWorkspace("pending1", "/project/a", "creating")],
["pending2", createWorkspace("pending2", "/project/a", "creating")],
["pending3", createWorkspace("pending3", "/project/a", "creating")],
]);

const result = buildSortedWorkspacesByProject(projects, metadata, {});

expect(result.get("/project/a")).toHaveLength(3);
});

it("should add pending workspaces for projects not yet in config", () => {
const projects = new Map<string, ProjectConfig>();
const metadata = new Map<string, FrontendWorkspaceMetadata>([
["pending1", createWorkspace("pending1", "/new/project", "creating")],
]);

const result = buildSortedWorkspacesByProject(projects, metadata, {});

expect(result.get("/new/project")).toHaveLength(1);
expect(result.get("/new/project")?.[0].id).toBe("pending1");
});

it("should sort workspaces by recency (most recent first)", () => {
const now = Date.now();
const projects = new Map<string, ProjectConfig>([
[
"/project/a",
{
workspaces: [
{ path: "/a/ws1", id: "ws1" },
{ path: "/a/ws2", id: "ws2" },
{ path: "/a/ws3", id: "ws3" },
],
},
],
]);
const metadata = new Map<string, FrontendWorkspaceMetadata>([
["ws1", createWorkspace("ws1", "/project/a")],
["ws2", createWorkspace("ws2", "/project/a")],
["ws3", createWorkspace("ws3", "/project/a")],
]);
const recency = {
ws1: now - 3000, // oldest
ws2: now - 1000, // newest
ws3: now - 2000, // middle
};

const result = buildSortedWorkspacesByProject(projects, metadata, recency);

expect(result.get("/project/a")?.map((w) => w.id)).toEqual(["ws2", "ws3", "ws1"]);
});

it("should not duplicate workspaces that exist in both config and have creating status", () => {
// Edge case: workspace was saved to config but still has status: "creating"
// (this shouldn't happen in practice but tests defensive coding)
const projects = new Map<string, ProjectConfig>([
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
]);
const metadata = new Map<string, FrontendWorkspaceMetadata>([
["ws1", createWorkspace("ws1", "/project/a", "creating")],
]);

const result = buildSortedWorkspacesByProject(projects, metadata, {});

expect(result.get("/project/a")).toHaveLength(1);
expect(result.get("/project/a")?.[0].id).toBe("ws1");
});

it("should skip workspaces with no id in config", () => {
const projects = new Map<string, ProjectConfig>([
["/project/a", { workspaces: [{ path: "/a/legacy" }, { path: "/a/ws1", id: "ws1" }] }],
]);
const metadata = new Map<string, FrontendWorkspaceMetadata>([
["ws1", createWorkspace("ws1", "/project/a")],
]);

const result = buildSortedWorkspacesByProject(projects, metadata, {});

expect(result.get("/project/a")).toHaveLength(1);
expect(result.get("/project/a")?.[0].id).toBe("ws1");
});

it("should skip config workspaces with no matching metadata", () => {
const projects = new Map<string, ProjectConfig>([
["/project/a", { workspaces: [{ path: "/a/ws1", id: "ws1" }] }],
]);
const metadata = new Map<string, FrontendWorkspaceMetadata>(); // empty

const result = buildSortedWorkspacesByProject(projects, metadata, {});

expect(result.get("/project/a")).toHaveLength(0);
});
});
Loading