Files
paperclip/ui/src/components/IssueProperties.test.tsx
T
Dotta fee514efcb [codex] Improve workspace navigation and runtime UI (#4089)
## Thinking Path

> - Paperclip agents do real work in project and execution workspaces.
> - Operators need workspace state to be visible, navigable, and
copyable without digging through raw run logs.
> - The branch included related workspace cards, navigation, runtime
controls, stale-service handling, and issue-property visibility.
> - These changes share the workspace UI and runtime-control surfaces
and can stand alone from unrelated access/profile work.
> - This pull request groups the workspace experience changes into one
standalone branch.
> - The benefit is a clearer workspace overview, better metadata copy
flows, and more accurate runtime service controls.

## What Changed

- Polished project workspace summary cards and made workspace metadata
copyable.
- Added a workspace navigation overview and extracted reusable project
workspace content.
- Squared and polished the execution workspace configuration page.
- Fixed stale workspace command matching and hid stopped stale services
in runtime controls.
- Showed live workspace service context in issue properties.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
ui/src/components/ProjectWorkspaceSummaryCard.test.tsx
ui/src/lib/project-workspaces-tab.test.ts
ui/src/components/Sidebar.test.tsx
ui/src/components/WorkspaceRuntimeControls.test.tsx
ui/src/components/IssueProperties.test.tsx`
- `pnpm exec vitest run packages/shared/src/workspace-commands.test.ts
--config /dev/null` because the root Vitest project config does not
currently include `packages/shared` tests.
- Split integration check: merged after runtime/governance,
dev-infra/backups, and access/profiles with no merge conflicts.
- Confirmed this branch does not include `pnpm-lock.yaml`.

## Risks

- Medium risk: touches workspace navigation, runtime controls, and issue
property rendering.
- Visual layout changes may need browser QA, especially around smaller
screens and dense workspace metadata.
- No database migrations are included.

> 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

- OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic
code-editing/runtime with local shell and GitHub CLI access; exact
context window and reasoning mode are not exposed by the Paperclip
harness.

## 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 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] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 06:14:32 -05:00

648 lines
19 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type {
ExecutionWorkspace,
IssueExecutionPolicy,
IssueExecutionState,
Project,
WorkspaceRuntimeService,
} from "@paperclipai/shared";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueProperties } from "./IssueProperties";
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
listLabels: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../hooks/useProjectOrder", () => ({
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
orderedProjects: projects,
}),
}));
vi.mock("../lib/recent-assignees", () => ({
getRecentAssigneeIds: () => [],
sortAgentsByRecency: (agents: unknown[]) => agents,
trackRecentAssignee: vi.fn(),
}));
vi.mock("../lib/assignees", () => ({
formatAssigneeUserLabel: () => "Me",
}));
vi.mock("./StatusIcon", () => ({
StatusIcon: ({ status }: { status: string }) => <span>{status}</span>,
}));
vi.mock("./PriorityIcon", () => ({
PriorityIcon: ({ priority }: { priority: string }) => <span>{priority}</span>,
}));
vi.mock("./Identity", () => ({
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
vi.mock("./AgentIconPicker", () => ({
AgentIcon: () => null,
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => <a href={to} {...props}>{children}</a>,
}));
vi.mock("@/components/ui/separator", () => ({
Separator: () => <hr />,
}));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Parent issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labels: [],
labelIds: [],
blockedBy: [],
blocks: [],
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:05:00.000Z"),
...overrides,
};
}
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
return {
id: "service-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
issueId: "issue-1",
scopeType: "execution_workspace",
scopeId: "workspace-1",
serviceName: "web",
status: "running",
lifecycle: "shared",
reuseKey: null,
command: "pnpm dev",
cwd: "/tmp/paperclip",
port: 62475,
url: "http://127.0.0.1:62475",
provider: "local_process",
providerRef: null,
ownerAgentId: null,
startedByRunId: null,
lastUsedAt: new Date("2026-04-06T12:03:00.000Z"),
startedAt: new Date("2026-04-06T12:02:00.000Z"),
stoppedAt: null,
stopPolicy: null,
healthStatus: "healthy",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
...overrides,
};
}
function createExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-main",
sourceIssueId: "issue-1",
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "PAP-1 workspace",
status: "active",
cwd: "/tmp/paperclip/PAP-1",
repoUrl: null,
baseRef: "master",
branchName: "pap-1-workspace",
providerType: "git_worktree",
providerRef: "/tmp/paperclip/PAP-1",
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date("2026-04-06T12:04:00.000Z"),
openedAt: new Date("2026-04-06T12:01:00.000Z"),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
runtimeServices: [createRuntimeService()],
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:04:00.000Z"),
...overrides,
};
}
function createProject(overrides: Partial<Project> = {}): Project {
const primaryWorkspace = {
id: "workspace-main",
companyId: "company-1",
projectId: "project-1",
name: "Main",
sourceType: "local_path" as const,
cwd: "/tmp/paperclip",
repoUrl: null,
repoRef: null,
defaultRef: "master",
visibility: "default" as const,
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: true,
runtimeServices: [],
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
};
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: "#6366f1",
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: "workspace-main",
repoUrl: null,
repoRef: null,
defaultRef: "master",
repoName: null,
localFolder: "/tmp/paperclip",
managedFolder: "/tmp/paperclip",
effectiveLocalFolder: "/tmp/paperclip",
origin: "local_folder",
},
workspaces: [primaryWorkspace],
primaryWorkspace,
archivedAt: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
}
function createExecutionPolicy(overrides: Partial<IssueExecutionPolicy> = {}): IssueExecutionPolicy {
return {
mode: "normal",
commentRequired: true,
stages: [],
...overrides,
};
}
function createExecutionState(overrides: Partial<IssueExecutionState> = {}): IssueExecutionState {
return {
status: "changes_requested",
currentStageId: "stage-1",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "agent-1", userId: null },
returnAssignee: { type: "agent", agentId: "agent-2", userId: null },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: "changes_requested",
...overrides,
};
}
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties {...props} />
</QueryClientProvider>,
);
});
return root;
}
describe("IssueProperties", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockAgentsApi.list.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([]);
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
});
afterEach(() => {
document.body.innerHTML = "";
});
it("always exposes the add sub-issue action", async () => {
const onAddSubIssue = vi.fn();
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onAddSubIssue,
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Sub-issues");
expect(container.textContent).toContain("Add sub-issue");
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add sub-issue"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAddSubIssue).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
mode: "isolated_workspace",
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running" })],
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const serviceLink = container.querySelector(`a[href="${serviceUrl}"]`);
expect(serviceLink).not.toBeNull();
expect(serviceLink?.getAttribute("target")).toBe("_blank");
expect(serviceLink?.className).toContain("text-emerald");
expect((container.textContent ?? "").indexOf("Service")).toBeLessThan(
(container.textContent ?? "").indexOf("Workspace"),
);
act(() => root.unmount());
});
it("does not show a service link for the main shared workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
mode: "shared_workspace",
projectWorkspaceId: "workspace-main",
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running" })],
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.querySelector(`a[href="${serviceUrl}"]`)).toBeNull();
act(() => root.unmount());
});
it("shows an add-label button when labels already exist and opens the picker", async () => {
const root = renderProperties(container, {
issue: createIssue({
labels: [{ id: "label-1", companyId: "company-1", name: "Bug", color: "#ef4444", createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:00:00.000Z") }],
labelIds: ["label-1"],
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
const addLabelButton = container.querySelector('button[aria-label="Add label"]');
expect(addLabelButton).not.toBeNull();
expect(container.querySelector('input[placeholder="Search labels..."]')).toBeNull();
await act(async () => {
addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.querySelector('input[placeholder="Search labels..."]')).not.toBeNull();
expect(container.querySelector('button[title="Delete Bug"]')).toBeNull();
act(() => root.unmount());
});
it("allows setting and clearing a parent issue from the properties pane", async () => {
const onUpdate = vi.fn();
mockIssuesApi.list.mockResolvedValue([
createIssue({ id: "issue-2", identifier: "PAP-2", title: "Candidate parent", status: "in_progress" }),
]);
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const parentTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("No parent"));
expect(parentTrigger).not.toBeUndefined();
await act(async () => {
parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const candidateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
expect(candidateButton).not.toBeUndefined();
await act(async () => {
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-2" });
onUpdate.mockClear();
const rerenderedIssue = createIssue({
parentId: "issue-2",
ancestors: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Candidate parent",
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
projectId: null,
goalId: null,
project: null,
goal: null,
},
],
});
act(() => root.unmount());
const rerenderedRoot = renderProperties(container, {
issue: rerenderedIssue,
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
expect(selectedParentTrigger).not.toBeUndefined();
const parentLink = container.querySelector('a[href="/issues/PAP-2"]');
expect(parentLink).not.toBeNull();
expect(selectedParentTrigger!.contains(parentLink)).toBe(false);
await act(async () => {
selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const clearParentButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("No parent"));
expect(clearParentButton).not.toBeUndefined();
await act(async () => {
clearParentButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ parentId: null });
act(() => rerenderedRoot.unmount());
});
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
}),
childIssues: [],
onUpdate,
});
await flush();
const runReviewButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Run review now"));
expect(runReviewButton).not.toBeUndefined();
await act(async () => {
runReviewButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ status: "in_review" });
act(() => root.unmount());
});
it("shows a run approval action when approval is the next runnable stage", async () => {
const root = renderProperties(container, {
issue: createIssue({
executionPolicy: createExecutionPolicy({
stages: [
{
id: "approval-stage",
type: "approval",
approvalsNeeded: 1,
participants: [{ id: "participant-2", type: "user", agentId: null, userId: "user-1" }],
},
],
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Run approval now");
expect(container.textContent).not.toContain("Run review now");
act(() => root.unmount());
});
it("keeps the run review action available after changes are requested", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "in_progress",
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
executionState: createExecutionState(),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Run review now");
act(() => root.unmount());
});
it("hides the run action while an execution stage is already pending", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "in_review",
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
executionState: createExecutionState({
status: "pending",
currentStageType: "review",
lastDecisionOutcome: null,
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Run review now");
expect(container.textContent).not.toContain("Run approval now");
act(() => root.unmount());
});
});