Files
paperclip/ui/src/pages/ProjectWorkspaceDetail.test.tsx
T
scotttong eaef47f4c7 Information Architecture + project/agent visual refresh (experimental) (#7543)
## 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>
2026-06-06 09:17:27 -05:00

343 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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 projects 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");
});
});