Files
paperclip/ui/src/pages/ExecutionWorkspaceDetail.test.tsx
T
Dotta 2e74d32871 PAP-10430: split Issue-to-Task copy migration (#7651)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI is the operator surface where users create, assign,
monitor, and review work items.
> - The product language is moving toward "tasks" for user-facing work
items while the internal API and database still use "issues".
> - PR #7543 bundled this copy migration with broader
information-architecture work, which made the branch too large for
Greptile review.
> - This pull request peels the Issue-to-Task copy migration into a
smaller, independently reviewable change.
> - The benefit is clearer user-facing terminology, less agent confusion
via the Paperclip skill note, and a smaller PR that Greptile can review.

## Linked Issues or Issue Description

Refs #7645
Refs #7543
Refs PAP-10430

This PR was split out of #7543 so the Issue-to-Task copy migration can
be reviewed separately and the remaining IA PR can fall under Greptile's
file limit.

## What Changed

- Preserves Scott Tong's original `PAP-57` copy-only commit, with author
and co-author credit intact, to rename user-facing "Issues" copy to
"Tasks" across the UI while keeping routes/API/internal symbols as
`issue`.
- Updates onboarding and release-smoke browser selectors from `Create &
Open Issue` to `Create & Open Task`.
- Adds a terminology note to `skills/paperclip/SKILL.md` clarifying that
task and issue refer to the same Paperclip work item.
- Resolves the only cherry-pick conflict by keeping current search
artifacts support and changing visible search copy to "tasks".

## Verification

- `pnpm --filter @paperclipai/ui build` passed.
- `NODE_ENV=test pnpm exec vitest run
ui/src/components/IssuesList.test.tsx ui/src/components/Sidebar.test.tsx
ui/src/components/NewIssueDialog.test.tsx
ui/src/pages/IssueDetail.test.tsx` passed: 4 files, 66 tests.
- `git diff --check origin/master...HEAD` passed.
- Diff is 80 files, below Greptile's 100-file limit.
- Before/after UI copy examples: "Issues" -> "Tasks", "New Issue" ->
"New Task", "Create & Open Issue" -> "Create & Open Task".

## Risks

- Medium copy-risk: this intentionally changes user-facing terminology
broadly while keeping internal issue identifiers and routes unchanged.
- Some docs and APIs still say `issue`; the skill note clarifies this so
agents do not treat task and issue as separate entities.
- Browser-level visual validation is expected from CI because this local
container is missing usable browser dependencies.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

Scott Tong authored the original `PAP-57` copy migration, assisted by
Claude Opus 4.8 and Paperclip agents per the preserved commit metadata.
Codex / GPT-5-class coding agent with shell, GitHub CLI, and repository
access performed the PR split, conflict resolution, skill note, and
verification.

## 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: scotttong <scott.tong@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-06 10:10:01 -05:00

323 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ExecutionWorkspace, 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 { ExecutionWorkspaceDetail } from "./ExecutionWorkspaceDetail";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
get: vi.fn(),
update: vi.fn(),
listWorkspaceOperations: vi.fn(),
controlRuntimeCommands: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({ get: vi.fn() }));
const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn() }));
const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn() }));
const mockRoutinesApi = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), run: vi.fn() }));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotOutlet = vi.hoisted(() => vi.fn());
const mockPluginSlotMount = vi.hoisted(() => vi.fn());
const mockPluginSlotState = vi.hoisted(() => ({
slots: [] as unknown[],
isLoading: false,
errorMessage: null as string | null,
}));
const mockRouteLocation = vi.hoisted(() => ({
pathname: "/execution-workspaces/workspace-1/issues",
search: "",
}));
vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi }));
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi }));
vi.mock("../api/routines", () => ({ routinesApi: mockRoutinesApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
useLocation: () => ({ ...mockRouteLocation, hash: "", state: null }),
useNavigate: () => mockNavigate,
useParams: () => ({ workspaceId: "workspace-1" }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
setSelectedCompanyId: vi.fn(),
}),
}));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: (props: unknown) => {
mockPluginSlotMount(props);
return <div data-testid="plugin-slot-mount" />;
},
PluginSlotOutlet: (props: unknown) => {
mockPluginSlotOutlet(props);
return <div data-testid="plugin-slot-outlet" />;
},
usePluginSlots: (filters: unknown) => {
mockUsePluginSlots(filters);
const entityType = (filters as { entityType?: string }).entityType;
return {
slots: entityType === "execution_workspace" ? mockPluginSlotState.slots : [],
isLoading: mockPluginSlotState.isLoading,
errorMessage: mockPluginSlotState.errorMessage,
};
},
}));
vi.mock("../components/IssuesList", () => ({
IssuesList: () => <div data-testid="issues-list" />,
}));
vi.mock("../components/ExecutionWorkspaceCloseDialog", () => ({
ExecutionWorkspaceCloseDialog: () => null,
}));
vi.mock("../components/RoutineRunVariablesDialog", () => ({
RoutineRunVariablesDialog: () => null,
}));
vi.mock("../components/WorkspaceRuntimeControls", () => ({
buildWorkspaceRuntimeControlSections: () => [],
WorkspaceRuntimeQuickControls: () => <div data-testid="runtime-quick-controls" />,
WorkspaceRuntimeControls: () => <div data-testid="runtime-controls" />,
}));
vi.mock("../components/PageTabBar", () => ({
PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => (
<div data-testid="page-tab-bar">
{items.map((item) => (
<button key={item.value} data-tab-value={item.value} type="button">{item.label}</button>
))}
</div>
),
}));
vi.mock("../components/CopyText", () => ({ CopyText: () => null }));
function workspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
sourceIssueId: null,
mode: "local",
strategyType: "local_worktree",
name: "Diff worktree",
status: "active",
cwd: "/tmp/workspace-1",
repoUrl: null,
baseRef: null,
branchName: null,
providerType: "local",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: now,
openedAt: now,
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
runtimeServices: [],
createdAt: now,
updatedAt: now,
...overrides,
} as ExecutionWorkspace;
}
function project(overrides: Partial<Project> = {}): 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: "Test Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
icon: null,
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,
managedByPlugin: null,
archivedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
function pluginSlot(overrides: Record<string, unknown> = {}) {
return {
id: "changes-tab",
type: "detailTab",
displayName: "Changes",
exportName: "ExecutionWorkspaceChangesTab",
entityTypes: ["execution_workspace"],
pluginId: "plugin-1",
pluginKey: "paperclip.workspace-diff",
pluginDisplayName: "Workspace Changes",
pluginVersion: "0.1.0",
...overrides,
};
}
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("ExecutionWorkspaceDetail plugin slots", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockExecutionWorkspacesApi.get.mockResolvedValue(workspace());
mockExecutionWorkspacesApi.listWorkspaceOperations.mockResolvedValue([]);
mockProjectsApi.get.mockResolvedValue(project());
mockIssuesApi.list.mockResolvedValue([]);
mockAgentsApi.list.mockResolvedValue([]);
mockRoutinesApi.list.mockResolvedValue([]);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
mockPluginSlotState.slots = [];
mockPluginSlotState.isLoading = false;
mockPluginSlotState.errorMessage = null;
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
mockRouteLocation.pathname = "/execution-workspaces/workspace-1/issues";
mockRouteLocation.search = "";
});
async function render() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<ExecutionWorkspaceDetail />
</QueryClientProvider>,
);
});
await act(async () => {
await flush();
});
}
it("scopes the plugin detail-tab discovery to execution_workspace and the workspace'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("execution_workspace");
expect(filters.companyId).toBe("company-1");
}
});
it("mounts a toolbar PluginSlotOutlet with execution_workspace context", async () => {
await render();
const outletCalls = mockPluginSlotOutlet.mock.calls.map(([props]) => props as {
slotTypes: string[];
entityType: string;
context: { entityId: string; entityType: string; companyId: string; projectId: string };
});
const toolbarOutlet = outletCalls.find((props) => props.slotTypes.includes("toolbarButton"));
expect(toolbarOutlet).toBeDefined();
expect(toolbarOutlet?.entityType).toBe("execution_workspace");
expect(toolbarOutlet?.context).toMatchObject({
entityId: "workspace-1",
entityType: "execution_workspace",
companyId: "company-1",
projectId: "project-1",
});
});
it("does not mount plugin slots scoped to other entity types", async () => {
await render();
const outletCalls = mockPluginSlotOutlet.mock.calls.map(([props]) => props as { entityType: string });
for (const props of outletCalls) {
expect(props.entityType).toBe("execution_workspace");
}
});
it("shows a missing plugin placeholder instead of routines for stale plugin tab URLs", async () => {
mockRouteLocation.pathname = "/execution-workspaces/workspace-1";
mockRouteLocation.search = "?tab=plugin%3Amissing%3Aslot";
await render();
expect(container.textContent).toContain("Workspace plugin tab is not available.");
expect(container.querySelector('a[href="/execution-workspaces/workspace-1/issues"]')?.textContent).toBe("Back to tasks");
expect(container.textContent).not.toContain("Workspace routines");
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
});
it("orders execution workspace plugin tabs against built-in tabs by slot order", async () => {
mockPluginSlotState.slots = [
pluginSlot({ id: "default-tab", displayName: "Default" }),
pluginSlot({ id: "changes-tab", displayName: "Changes", order: 25 }),
pluginSlot({ id: "inspect-tab", displayName: "Inspect", order: 50 }),
];
await render();
const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent);
expect(tabLabels).toEqual([
"Tasks",
"Services",
"Changes",
"Configuration",
"Runtime logs",
"Inspect",
"Routines",
"Default",
]);
});
});