diff --git a/ui/src/components/EntityRow.tsx b/ui/src/components/EntityRow.tsx
index 4c375fbd070..de1c69e6048 100644
--- a/ui/src/components/EntityRow.tsx
+++ b/ui/src/components/EntityRow.tsx
@@ -12,6 +12,7 @@ interface EntityRowProps {
to?: string;
onClick?: () => void;
className?: string;
+ reserveSubtitleSpace?: boolean;
}
export function EntityRow({
@@ -24,6 +25,7 @@ export function EntityRow({
to,
onClick,
className,
+ reserveSubtitleSpace,
}: EntityRowProps) {
const isClickable = !!(to || onClick);
const classes = cn(
@@ -45,8 +47,13 @@ export function EntityRow({
)}
{title}
- {subtitle && (
-
{subtitle}
+ {(subtitle || reserveSubtitleSpace) && (
+
+ {subtitle}
+
)}
{trailing && {trailing}
}
diff --git a/ui/src/pages/Projects.test.tsx b/ui/src/pages/Projects.test.tsx
new file mode 100644
index 00000000000..185db7663bc
--- /dev/null
+++ b/ui/src/pages/Projects.test.tsx
@@ -0,0 +1,164 @@
+// @vitest-environment jsdom
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { Project } from "@paperclipai/shared";
+import { act, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Projects } from "./Projects";
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const mockProjectsApi = vi.hoisted(() => ({
+ list: vi.fn(),
+}));
+const mockOpenNewProject = vi.hoisted(() => vi.fn());
+const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
+
+vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
+vi.mock("@/lib/router", () => ({
+ Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
+}));
+vi.mock("../context/CompanyContext", () => ({
+ useCompany: () => ({ selectedCompanyId: "company-1" }),
+}));
+vi.mock("../context/DialogContext", () => ({
+ useDialogActions: () => ({ openNewProject: mockOpenNewProject }),
+}));
+vi.mock("../context/BreadcrumbContext", () => ({
+ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
+}));
+
+function project(overrides: Partial): Project {
+ const now = new Date("2026-05-01T00:00:00Z");
+ return {
+ id: "project-1",
+ companyId: "company-1",
+ urlKey: "project-1",
+ goalId: null,
+ goalIds: [],
+ goals: [],
+ name: "Project",
+ description: null,
+ status: "in_progress",
+ leadAgentId: null,
+ targetDate: null,
+ color: "#14b8a6",
+ env: null,
+ pauseReason: null,
+ pausedAt: null,
+ executionWorkspacePolicy: null,
+ codebase: {
+ workspaceId: null,
+ repoUrl: null,
+ repoRef: null,
+ defaultRef: null,
+ repoName: null,
+ localFolder: null,
+ managedFolder: "/tmp/project-1",
+ effectiveLocalFolder: "/tmp/project-1",
+ origin: "managed_checkout",
+ },
+ workspaces: [],
+ primaryWorkspace: null,
+ archivedAt: null,
+ createdAt: now,
+ updatedAt: now,
+ ...overrides,
+ };
+}
+
+async function renderProjects(container: HTMLElement) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ let root: Root | null = null;
+
+ await act(async () => {
+ root = createRoot(container);
+ root.render(
+
+
+ ,
+ );
+ });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ return root;
+}
+
+function projectLinkNames(container: HTMLElement): string[] {
+ return Array.from(container.querySelectorAll("a[href^='/projects/']")).map((link) => {
+ const title = link.querySelector("span.truncate");
+ return title?.textContent ?? "";
+ });
+}
+
+describe("Projects", () => {
+ let root: Root | null = null;
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ mockProjectsApi.list.mockResolvedValue([
+ project({
+ id: "project-bravo",
+ urlKey: "bravo",
+ name: "Bravo",
+ description: null,
+ updatedAt: new Date("2026-05-02T00:00:00Z"),
+ }),
+ project({
+ id: "project-alpha",
+ urlKey: "alpha",
+ name: "Alpha",
+ description: "First project",
+ updatedAt: new Date("2026-05-01T00:00:00Z"),
+ }),
+ project({
+ id: "project-charlie",
+ urlKey: "charlie",
+ name: "Charlie",
+ description: null,
+ updatedAt: new Date("2026-05-03T00:00:00Z"),
+ }),
+ ]);
+ });
+
+ afterEach(() => {
+ act(() => root?.unmount());
+ root = null;
+ container.remove();
+ vi.clearAllMocks();
+ });
+
+ it("sorts projects by name by default and can switch sort mode", async () => {
+ root = await renderProjects(container);
+
+ expect(projectLinkNames(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
+
+ const select = container.querySelector("select");
+ expect(select).not.toBeNull();
+
+ await act(async () => {
+ select!.value = "updated";
+ select!.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+
+ expect(projectLinkNames(container)).toEqual(["Charlie", "Bravo", "Alpha"]);
+ });
+
+ it("reserves description line height for projects without descriptions", async () => {
+ root = await renderProjects(container);
+
+ const bravoLink = Array.from(container.querySelectorAll("a")).find((link) =>
+ link.textContent?.includes("Bravo"),
+ );
+ const hiddenDescriptionLine = bravoLink?.querySelector("p[aria-hidden='true']");
+
+ expect(hiddenDescriptionLine).not.toBeNull();
+ expect(hiddenDescriptionLine?.className).toContain("min-h-4");
+ });
+});
diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx
index 08d246a8562..a26c17dd628 100644
--- a/ui/src/pages/Projects.tsx
+++ b/ui/src/pages/Projects.tsx
@@ -1,5 +1,6 @@
-import { useEffect, useMemo } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
+import type { Project } from "@paperclipai/shared";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
@@ -11,12 +12,67 @@ import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate, projectUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
-import { Hexagon, Plus } from "lucide-react";
+import { ArrowUpDown, Hexagon, Plus } from "lucide-react";
+
+type ProjectSortMode = "name" | "updated" | "targetDate" | "status";
+
+const PROJECT_SORT_LABELS: Record = {
+ name: "Name",
+ updated: "Recently updated",
+ targetDate: "Target date",
+ status: "Status",
+};
+
+const PROJECT_STATUS_RANK: Record = {
+ in_progress: 0,
+ planned: 1,
+ backlog: 2,
+ completed: 3,
+ cancelled: 4,
+};
+
+function projectTime(project: Project, field: "createdAt" | "updatedAt"): number {
+ const value = project[field];
+ const time = value instanceof Date ? value.getTime() : new Date(value).getTime();
+ return Number.isFinite(time) ? time : 0;
+}
+
+function compareProjectNames(left: Project, right: Project): number {
+ const nameDiff = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
+ return nameDiff !== 0 ? nameDiff : left.id.localeCompare(right.id);
+}
+
+function compareTargetDates(left: Project, right: Project): number {
+ if (!left.targetDate && !right.targetDate) return compareProjectNames(left, right);
+ if (!left.targetDate) return 1;
+ if (!right.targetDate) return -1;
+
+ const dateDiff = left.targetDate.localeCompare(right.targetDate);
+ return dateDiff !== 0 ? dateDiff : compareProjectNames(left, right);
+}
+
+function sortProjects(projects: Project[], sortMode: ProjectSortMode): Project[] {
+ return [...projects].sort((left, right) => {
+ if (sortMode === "updated") {
+ const updatedDiff = projectTime(right, "updatedAt") - projectTime(left, "updatedAt");
+ return updatedDiff !== 0 ? updatedDiff : compareProjectNames(left, right);
+ }
+ if (sortMode === "targetDate") {
+ return compareTargetDates(left, right);
+ }
+ if (sortMode === "status") {
+ const statusDiff = PROJECT_STATUS_RANK[left.status] - PROJECT_STATUS_RANK[right.status];
+ return statusDiff !== 0 ? statusDiff : compareProjectNames(left, right);
+ }
+ return compareProjectNames(left, right);
+ });
+}
export function Projects() {
const { selectedCompanyId } = useCompany();
const { openNewProject } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
+ const [sortMode, setSortMode] = useState("name");
useEffect(() => {
setBreadcrumbs([{ label: "Projects" }]);
@@ -31,6 +87,10 @@ export function Projects() {
() => (allProjects ?? []).filter((p) => !p.archivedAt),
[allProjects],
);
+ const sortedProjects = useMemo(
+ () => sortProjects(projects, sortMode),
+ [projects, sortMode],
+ );
if (!selectedCompanyId) {
return ;
@@ -42,7 +102,22 @@ export function Projects() {
return (
-
+
+