eaef47f4c7
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board UI is the control surface for issues, projects, agents, goals, workspaces, and operator settings. > - The existing navigation and list surfaces make several high-frequency workflows feel harder to scan than they should, especially around projects and agents. > - The product direction is to improve those surfaces without breaking the existing route model or forcing a new IA on every operator at once. > - This pull request now keeps the dependent IA, project identity, and agent-list visual refresh work together while the Issue-to-Task copy migration is split into #7651. > - The benefit is a clearer left nav, better project identity, denser agent/project list rows, and brand-aligned status treatment while preserving the classic default experience behind a flag. ## Linked Issues or Issue Description Refs #7645 Refs #7651 Internal planning/work references: PAP-53, PAP-56, PAP-58, PAP-59, PAP-60, PAP-61, PAP-68, PAP-69, PAP-70, PAP-71, PAP-72, PAP-75, PAP-76, PAP-80, PAP-85, PAP-86, PAP-87, PAP-88, PAP-89. ## What Changed - Adds `enableStreamlinedLeftNavigation`, defaulting off, and gates sidebar presentation so classic navigation remains the default. - Adds project icon persistence, validation, portability, picker UI, and `ProjectTile` rendering while defaulting new projects to neutral gray. - Adds projects-list task-count and budget summary data with focused server/shared/UI coverage. - Refreshes agent list rows, row actions, active/recent sidebar behavior, and status capsule/chip styling for the approved brand state system. - Removes the placeholder Conference room and Artifacts nav/routes from the finalized experimental nav direction. - Removes `pnpm-lock.yaml` and the Issue-to-Task copy migration from this PR diff; the copy migration now lives in #7651. ## Verification - Existing branch verification from the authored commits: UI typecheck, targeted unit tests, and light/dark visual checks for `/agents`, agent detail, and design-guide status states. - Maintainer cleanup verification on `75e34e5`: `git diff --check origin/master...HEAD` passed, the `design/` diff is empty, and the PR diff is 61 files, below Greptile's 100-file review limit. - `pnpm --filter @paperclipai/ui build` passed. - `NODE_ENV=test pnpm exec vitest run ui/src/components/Sidebar.test.tsx` passed: 1 file, 8 tests. - CI and Greptile should rerun on the latest push. ## Risks - Broad UI surface area: the experimental flag keeps the classic nav default, but changed shared components such as `EntityRow`, `ProjectTile`, and agent status badges could affect multiple pages. - Database migration: `projects.icon` is additive and nullable, but migration ordering and portability import/export must stay aligned. - The Issue-to-Task copy migration is now separated into #7651, so reviewers should evaluate this PR as IA/project/agent presentation work only. - Visual regressions are possible across smaller widths because the PR intentionally changes dense list-row layouts. ## Model Used Claude Opus 4.8 assisted the original feature commits. Paperclip-Paperclip agents assisted some planning/design commits. Codex / GPT-5-class coding agent with shell, GitHub CLI, and repository access performed this PR-readiness cleanup and split. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Dotta <bippadotta@protonmail.com>
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import type { Project, ProjectWorkspace } 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 { ProjectWorkspaceDetail } from "./ProjectWorkspaceDetail";
|
||
|
||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||
|
||
const mockProjectsApi = vi.hoisted(() => ({
|
||
get: vi.fn(),
|
||
updateWorkspace: vi.fn(),
|
||
controlWorkspaceCommands: vi.fn(),
|
||
}));
|
||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||
const mockPluginSlotMount = vi.hoisted(() => vi.fn());
|
||
const mockRouteSearch = vi.hoisted(() => ({ value: "" }));
|
||
const mockPluginSlotState = vi.hoisted(() => ({
|
||
slots: [] as unknown[],
|
||
isLoading: false,
|
||
errorMessage: null as string | null,
|
||
}));
|
||
|
||
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
|
||
|
||
vi.mock("@/lib/router", () => ({
|
||
Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => (
|
||
<a href={to} className={className}>{children}</a>
|
||
),
|
||
useLocation: () => ({
|
||
pathname: "/PAP/projects/paperclip-app/workspaces/workspace-1",
|
||
search: mockRouteSearch.value,
|
||
hash: "",
|
||
state: null,
|
||
}),
|
||
useNavigate: () => mockNavigate,
|
||
useParams: () => ({ companyPrefix: "PAP", projectId: "paperclip-app", workspaceId: "workspace-1" }),
|
||
}));
|
||
|
||
vi.mock("../context/CompanyContext", () => ({
|
||
useCompany: () => ({
|
||
companies: [{ id: "company-1", issuePrefix: "PAP" }],
|
||
selectedCompanyId: "company-1",
|
||
setSelectedCompanyId: mockSetSelectedCompanyId,
|
||
}),
|
||
}));
|
||
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
|
||
vi.mock("../components/PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
|
||
vi.mock("../components/WorkspaceRuntimeControls", () => ({
|
||
buildWorkspaceRuntimeControlSections: () => [],
|
||
WorkspaceRuntimeControls: () => <div data-testid="runtime-controls" />,
|
||
}));
|
||
vi.mock("@/plugins/slots", () => ({
|
||
PluginSlotMount: (props: unknown) => {
|
||
mockPluginSlotMount(props);
|
||
return <div data-testid="plugin-slot-mount" />;
|
||
},
|
||
usePluginSlots: (filters: unknown) => {
|
||
mockUsePluginSlots(filters);
|
||
const entityType = (filters as { entityType?: string }).entityType;
|
||
return {
|
||
slots: entityType === "project_workspace" ? mockPluginSlotState.slots : [],
|
||
isLoading: mockPluginSlotState.isLoading,
|
||
errorMessage: mockPluginSlotState.errorMessage,
|
||
};
|
||
},
|
||
}));
|
||
vi.mock("../components/PageTabBar", () => ({
|
||
PageTabBar: ({
|
||
items,
|
||
onValueChange,
|
||
}: {
|
||
items: Array<{ value: string; label: string }>;
|
||
onValueChange?: (value: string) => void;
|
||
}) => (
|
||
<div data-testid="page-tab-bar">
|
||
{items.map((item) => (
|
||
<button
|
||
key={item.value}
|
||
data-tab-value={item.value}
|
||
type="button"
|
||
onClick={() => onValueChange?.(item.value)}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
function projectWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWorkspace {
|
||
const now = new Date("2026-05-01T00:00:00Z");
|
||
return {
|
||
id: "workspace-1",
|
||
companyId: "company-1",
|
||
projectId: "project-1",
|
||
name: "Primary checkout",
|
||
sourceType: "local_path",
|
||
cwd: "/tmp/paperclip",
|
||
repoUrl: "https://github.com/paperclipai/paperclip",
|
||
repoRef: "master",
|
||
defaultRef: "origin/main",
|
||
visibility: "default",
|
||
setupCommand: null,
|
||
cleanupCommand: null,
|
||
remoteProvider: null,
|
||
remoteWorkspaceRef: null,
|
||
sharedWorkspaceKey: null,
|
||
metadata: null,
|
||
runtimeConfig: null,
|
||
runtimeServices: [],
|
||
isPrimary: true,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function project(overrides: Partial<Project> = {}): Project {
|
||
const now = new Date("2026-05-01T00:00:00Z");
|
||
const workspace = projectWorkspace();
|
||
return {
|
||
id: "project-1",
|
||
companyId: "company-1",
|
||
urlKey: "paperclip-app",
|
||
goalId: null,
|
||
goalIds: [],
|
||
goals: [],
|
||
name: "Paperclip App",
|
||
description: null,
|
||
status: "in_progress",
|
||
leadAgentId: null,
|
||
targetDate: null,
|
||
color: "#14b8a6",
|
||
icon: null,
|
||
env: null,
|
||
pauseReason: null,
|
||
pausedAt: null,
|
||
executionWorkspacePolicy: null,
|
||
codebase: {
|
||
workspaceId: workspace.id,
|
||
repoUrl: workspace.repoUrl,
|
||
repoRef: workspace.repoRef,
|
||
defaultRef: workspace.defaultRef,
|
||
repoName: "paperclip",
|
||
localFolder: workspace.cwd,
|
||
managedFolder: workspace.cwd ?? "/tmp/paperclip",
|
||
effectiveLocalFolder: workspace.cwd ?? "/tmp/paperclip",
|
||
origin: "local_folder",
|
||
},
|
||
workspaces: [workspace],
|
||
primaryWorkspace: workspace,
|
||
managedByPlugin: null,
|
||
archivedAt: null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
async function flush() {
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
}
|
||
|
||
function pluginSlot(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
id: "quality-tab",
|
||
type: "detailTab",
|
||
displayName: "Quality",
|
||
exportName: "ProjectWorkspaceQualityTab",
|
||
entityTypes: ["project_workspace"],
|
||
pluginId: "plugin-1",
|
||
pluginKey: "paperclip.quality",
|
||
pluginDisplayName: "Quality Plugin",
|
||
pluginVersion: "0.1.0",
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe("ProjectWorkspaceDetail plugin tabs", () => {
|
||
let root: Root | null = null;
|
||
let container: HTMLDivElement;
|
||
|
||
beforeEach(() => {
|
||
container = document.createElement("div");
|
||
document.body.appendChild(container);
|
||
mockProjectsApi.get.mockResolvedValue(project());
|
||
mockPluginSlotState.slots = [];
|
||
mockPluginSlotState.isLoading = false;
|
||
mockPluginSlotState.errorMessage = null;
|
||
});
|
||
|
||
afterEach(() => {
|
||
act(() => root?.unmount());
|
||
root = null;
|
||
container.remove();
|
||
vi.clearAllMocks();
|
||
mockRouteSearch.value = "";
|
||
});
|
||
|
||
async function render() {
|
||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
await act(async () => {
|
||
root = createRoot(container);
|
||
root.render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<ProjectWorkspaceDetail />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
await act(async () => {
|
||
await flush();
|
||
});
|
||
}
|
||
|
||
it("scopes plugin detail-tab discovery to project_workspace and the project's company", async () => {
|
||
await render();
|
||
|
||
const enabledDetailTabFilters = mockUsePluginSlots.mock.calls
|
||
.map(([filters]) => filters as { slotTypes: string[]; entityType: string; companyId: string | null; enabled?: boolean })
|
||
.filter((filters) => filters.slotTypes.includes("detailTab") && filters.enabled !== false);
|
||
|
||
expect(enabledDetailTabFilters.length).toBeGreaterThan(0);
|
||
for (const filters of enabledDetailTabFilters) {
|
||
expect(filters.entityType).toBe("project_workspace");
|
||
expect(filters.companyId).toBe("company-1");
|
||
}
|
||
});
|
||
|
||
it("renders an arbitrary project_workspace plugin detail tab from the generic URL value", async () => {
|
||
mockPluginSlotState.slots = [pluginSlot()];
|
||
mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab&diffView=head&baseRef=origin%2Fmaster";
|
||
|
||
await render();
|
||
|
||
expect(container.querySelector('[data-tab-value="configuration"]')?.textContent).toBe("Configuration");
|
||
expect(container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]')?.textContent).toBe("Quality");
|
||
expect(container.querySelector('[data-tab-value="changes"]')).toBeNull();
|
||
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
|
||
expect(mockPluginSlotMount).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
slot: expect.objectContaining({ pluginKey: "paperclip.quality", id: "quality-tab" }),
|
||
context: expect.objectContaining({ entityType: "project_workspace", entityId: "workspace-1" }),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("keeps the project workspace heading visible on plugin tabs", async () => {
|
||
mockPluginSlotState.slots = [pluginSlot({ displayName: "Changes" })];
|
||
mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab";
|
||
|
||
await render();
|
||
|
||
expect(container.querySelector("h1")?.textContent).toBe("Primary checkout");
|
||
expect(container.textContent).toContain("Project workspace");
|
||
expect(container.textContent).toContain("This is the project’s primary codebase workspace.");
|
||
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
|
||
expect(container.textContent).not.toContain("Configure the concrete workspace");
|
||
expect(container.textContent).not.toContain("Workspace name");
|
||
});
|
||
|
||
it("orders project workspace plugin tabs against built-in tabs by slot order", async () => {
|
||
mockPluginSlotState.slots = [
|
||
pluginSlot({ id: "late-tab", displayName: "Late", order: 40 }),
|
||
pluginSlot({ id: "early-tab", displayName: "Early", order: 20 }),
|
||
pluginSlot({ id: "default-tab", displayName: "Default" }),
|
||
];
|
||
|
||
await render();
|
||
|
||
const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent);
|
||
expect(tabLabels).toEqual(["Early", "Configuration", "Late", "Default"]);
|
||
});
|
||
|
||
it("navigates plugin tabs with only the generic plugin tab parameter", async () => {
|
||
mockPluginSlotState.slots = [pluginSlot()];
|
||
|
||
await render();
|
||
|
||
await act(async () => {
|
||
(container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]') as HTMLButtonElement).click();
|
||
});
|
||
|
||
expect(mockNavigate).toHaveBeenCalledWith(
|
||
"/projects/paperclip-app/workspaces/workspace-1?tab=plugin%3Apaperclip.quality%3Aquality-tab",
|
||
);
|
||
expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("diffView"));
|
||
expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("baseRef"));
|
||
});
|
||
|
||
it("does not treat the old changes tab query as a core plugin tab", async () => {
|
||
mockPluginSlotState.slots = [pluginSlot()];
|
||
mockRouteSearch.value = "?tab=changes&diffView=head&baseRef=origin%2Fmain";
|
||
|
||
await render();
|
||
|
||
expect(container.querySelector('[data-tab-value="changes"]')).toBeNull();
|
||
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
|
||
expect(container.textContent).toContain("Project workspace");
|
||
});
|
||
|
||
it("shows a missing plugin placeholder instead of configuration for stale plugin tab URLs", async () => {
|
||
mockRouteSearch.value = "?tab=plugin%3Amissing%3Aslot";
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).toContain("Workspace plugin tab is not available.");
|
||
expect(container.querySelector('a[href="/projects/paperclip-app/workspaces/workspace-1?tab=configuration"]')?.textContent).toBe(
|
||
"Back to configuration",
|
||
);
|
||
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
|
||
expect(container.textContent).not.toContain("Configure the concrete workspace");
|
||
expect(container.textContent).not.toContain("Workspace name");
|
||
});
|
||
|
||
it("shows loading and error states for plugin tab manifests", async () => {
|
||
mockPluginSlotState.isLoading = true;
|
||
mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab";
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).toContain("Loading workspace plugin...");
|
||
|
||
act(() => root?.unmount());
|
||
root = null;
|
||
container.innerHTML = "";
|
||
vi.clearAllMocks();
|
||
mockProjectsApi.get.mockResolvedValue(project());
|
||
mockPluginSlotState.isLoading = false;
|
||
mockPluginSlotState.errorMessage = "Plugin manifest failed";
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).toContain("Plugin manifest failed");
|
||
});
|
||
});
|