diff --git a/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx b/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx new file mode 100644 index 00000000000..dc4388f586e --- /dev/null +++ b/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import type { ExecutionWorkspace, Issue } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProjectWorkspaceSummary } from "../lib/project-workspaces-tab"; +import { ProjectWorkspaceSummaryCard } from "./ProjectWorkspaceSummaryCard"; + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => {children}, +})); + +vi.mock("./IssuesQuicklook", () => ({ + IssuesQuicklook: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("./CopyText", () => ({ + CopyText: ({ children }: { children: ReactNode }) => {children}, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createIssue(overrides: Partial = {}): Issue { + return { + id: overrides.id ?? "issue-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? null, + goalId: overrides.goalId ?? null, + parentId: overrides.parentId ?? null, + title: overrides.title ?? "Issue", + description: overrides.description ?? null, + status: overrides.status ?? "todo", + priority: overrides.priority ?? "medium", + assigneeAgentId: overrides.assigneeAgentId ?? null, + assigneeUserId: overrides.assigneeUserId ?? null, + checkoutRunId: overrides.checkoutRunId ?? null, + executionRunId: overrides.executionRunId ?? null, + executionAgentNameKey: overrides.executionAgentNameKey ?? null, + executionLockedAt: overrides.executionLockedAt ?? null, + createdByAgentId: overrides.createdByAgentId ?? null, + createdByUserId: overrides.createdByUserId ?? null, + issueNumber: overrides.issueNumber ?? 1, + identifier: overrides.identifier ?? "PAP-1", + requestDepth: overrides.requestDepth ?? 0, + billingCode: overrides.billingCode ?? null, + assigneeAdapterOverrides: overrides.assigneeAdapterOverrides ?? null, + executionWorkspaceId: overrides.executionWorkspaceId ?? null, + executionWorkspacePreference: overrides.executionWorkspacePreference ?? null, + executionWorkspaceSettings: overrides.executionWorkspaceSettings ?? null, + startedAt: overrides.startedAt ?? null, + completedAt: overrides.completedAt ?? null, + cancelledAt: overrides.cancelledAt ?? null, + hiddenAt: overrides.hiddenAt ?? null, + createdAt: overrides.createdAt ?? new Date("2026-04-12T00:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-04-12T00:00:00Z"), + } as Issue; +} + +function createSummary(overrides: Partial = {}): ProjectWorkspaceSummary { + return { + key: overrides.key ?? "execution:workspace-1", + kind: overrides.kind ?? "execution_workspace", + workspaceId: overrides.workspaceId ?? "workspace-1", + workspaceName: overrides.workspaceName ?? "PAP-989-multi-user-implementation", + cwd: overrides.cwd ?? "/worktrees/PAP-989-multi-user-implementation", + branchName: overrides.branchName ?? "PAP-989-multi-user-implementation", + lastUpdatedAt: overrides.lastUpdatedAt ?? new Date("2026-04-12T00:00:00Z"), + projectWorkspaceId: overrides.projectWorkspaceId ?? "project-workspace-1", + executionWorkspaceId: overrides.executionWorkspaceId ?? "workspace-1", + executionWorkspaceStatus: overrides.executionWorkspaceStatus ?? "active", + serviceCount: overrides.serviceCount ?? 2, + runningServiceCount: overrides.runningServiceCount ?? 0, + primaryServiceUrl: overrides.primaryServiceUrl ?? "http://127.0.0.1:62474", + hasRuntimeConfig: overrides.hasRuntimeConfig ?? true, + issues: overrides.issues ?? [ + createIssue({ id: "issue-1", identifier: "PAP-1364" }), + createIssue({ id: "issue-2", identifier: "PAP-1367" }), + createIssue({ id: "issue-3", identifier: "PAP-1362" }), + createIssue({ id: "issue-4", identifier: "PAP-1363" }), + createIssue({ id: "issue-5", identifier: "PAP-1340" }), + ], + }; +} + +describe("ProjectWorkspaceSummaryCard", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders a stacked mobile-friendly summary with metadata labels and compact issue pills", () => { + const root = createRoot(container); + act(() => { + root.render( + {}} + onCloseWorkspace={() => {}} + />, + ); + }); + + expect(container.textContent).toContain("Execution workspace"); + expect(container.textContent).toContain("Branch"); + expect(container.textContent).toContain("Path"); + expect(container.textContent).toContain("Service"); + expect(container.textContent).toContain("Linked issues"); + expect(container.textContent).toContain("Start services"); + expect(container.textContent).toContain("Close workspace"); + expect(container.textContent).toContain("+1 more"); + + const actions = container.querySelector('[data-testid="workspace-summary-actions"]'); + expect(actions?.className).toContain("flex-col"); + + act(() => { + root.unmount(); + }); + }); + + it("uses project workspace routes and omits close controls for project workspaces", () => { + const runtimeSpy = vi.fn(); + const closeSpy = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const titleLink = container.querySelector("a[href='/projects/paperclip-app/workspaces/workspace-1']"); + expect(titleLink).not.toBeNull(); + expect(container.textContent).not.toContain("Close workspace"); + expect(container.textContent).not.toContain("Start services"); + + act(() => { + root.unmount(); + }); + }); + + it("shows retry close for cleanup failures", () => { + const root = createRoot(container); + + act(() => { + root.render( + {}} + onCloseWorkspace={() => {}} + />, + ); + }); + + expect(container.textContent).toContain("Retry close"); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/ProjectWorkspaceSummaryCard.tsx b/ui/src/components/ProjectWorkspaceSummaryCard.tsx new file mode 100644 index 00000000000..2c34951f8a6 --- /dev/null +++ b/ui/src/components/ProjectWorkspaceSummaryCard.tsx @@ -0,0 +1,230 @@ +import { Link } from "@/lib/router"; +import type { ExecutionWorkspace, Issue } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { CopyText } from "./CopyText"; +import { IssuesQuicklook } from "./IssuesQuicklook"; +import type { ProjectWorkspaceSummary } from "../lib/project-workspaces-tab"; +import { cn, projectWorkspaceUrl } from "../lib/utils"; +import { timeAgo } from "../lib/timeAgo"; +import { Copy, ExternalLink, FolderOpen, GitBranch, Loader2, Play, Square } from "lucide-react"; + +function workspaceKindLabel(kind: ProjectWorkspaceSummary["kind"]) { + return kind === "execution_workspace" ? "Execution workspace" : "Project workspace"; +} + +function truncatePath(path: string) { + const parts = path.split("/").filter(Boolean); + if (parts.length <= 3) return path; + return `…/${parts.slice(-3).join("/")}`; +} + +interface ProjectWorkspaceSummaryCardProps { + projectRef: string; + summary: ProjectWorkspaceSummary; + runtimeActionKey: string | null; + runtimeActionPending: boolean; + onRuntimeAction: (input: { + key: string; + kind: "project_workspace" | "execution_workspace"; + workspaceId: string; + action: "start" | "stop" | "restart"; + }) => void; + onCloseWorkspace: (input: { + id: string; + name: string; + status: ExecutionWorkspace["status"]; + }) => void; +} + +export function ProjectWorkspaceSummaryCard({ + projectRef, + summary, + runtimeActionKey, + runtimeActionPending, + onRuntimeAction, + onCloseWorkspace, +}: ProjectWorkspaceSummaryCardProps) { + const visibleIssues = summary.issues.slice(0, 4); + const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0); + const workspaceHref = + summary.kind === "project_workspace" + ? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId) + : `/execution-workspaces/${summary.workspaceId}`; + const hasRunningServices = summary.runningServiceCount > 0; + const actionKey = `${summary.key}:${hasRunningServices ? "stop" : "start"}`; + + return ( +
+
+
+
+
+ + {workspaceKindLabel(summary.kind)} + + + Updated {timeAgo(summary.lastUpdatedAt)} + + {summary.serviceCount > 0 ? ( + + + {summary.runningServiceCount}/{summary.serviceCount} services + + ) : null} + {summary.executionWorkspaceStatus ? ( + + {summary.executionWorkspaceStatus.replace(/_/g, " ")} + + ) : null} +
+ + {summary.workspaceName} + +
+ +
+ {summary.hasRuntimeConfig ? ( + + ) : null} + {summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? ( + + ) : null} +
+
+ +
+
+ {summary.branchName ? ( +
+ +
+
Branch
+
{summary.branchName}
+
+
+ ) : null} + + {summary.cwd ? ( +
+ +
+
Path
+
+ + {truncatePath(summary.cwd)} + + + + +
+
+
+ ) : null} + + {summary.primaryServiceUrl ? ( + + ) : null} +
+
+ + {summary.issues.length > 0 ? ( +
+
+ Linked issues +
+
+ {visibleIssues.map((issue) => ( + + ))} + {hiddenIssueCount > 0 ? ( + + +{hiddenIssueCount} more + + ) : null} +
+
+ ) : null} +
+
+ ); +} + +function IssuePill({ issue }: { issue: Issue }) { + return ( + + + {issue.identifier ?? issue.id.slice(0, 8)} + + + ); +} diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 1b16e52f46f..4f607dab2bf 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -16,7 +16,6 @@ import { useToast } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties"; -import { CopyText } from "../components/CopyText"; import { InlineEditor } from "../components/InlineEditor"; import { StatusBadge } from "../components/StatusBadge"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; @@ -24,15 +23,14 @@ import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceC import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; +import { ProjectWorkspaceSummaryCard } from "../components/ProjectWorkspaceSummaryCard"; import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab"; -import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; -import { timeAgo } from "../lib/timeAgo"; +import { projectRouteRef } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; -import { Copy, FolderOpen, GitBranch, Loader2, Play, Square } from "lucide-react"; -import { IssuesQuicklook } from "../components/IssuesQuicklook"; +import { Loader2 } from "lucide-react"; /* ── Top-level tab types ── */ @@ -263,154 +261,21 @@ function ProjectWorkspacesContent({ const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed"); const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed"); - const renderSummaryRow = (summary: ReturnType[number]) => { - const visibleIssues = summary.issues.slice(0, 5); - const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0); - const workspaceHref = - summary.kind === "project_workspace" - ? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId) - : `/execution-workspaces/${summary.workspaceId}`; - const hasRunningServices = summary.runningServiceCount > 0; - - const truncatePath = (path: string) => { - const parts = path.split("/").filter(Boolean); - if (parts.length <= 3) return path; - return `…/${parts.slice(-2).join("/")}`; - }; - - return ( -
- {/* Header row: name + actions */} -
- - {summary.workspaceName} - - -
- {summary.serviceCount > 0 ? ( - - - {summary.runningServiceCount}/{summary.serviceCount} - - ) : null} - {summary.executionWorkspaceStatus && summary.executionWorkspaceStatus !== "active" ? ( - {summary.executionWorkspaceStatus} - ) : null} -
- -
- {timeAgo(summary.lastUpdatedAt)} - {summary.hasRuntimeConfig ? ( - - ) : null} - {summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? ( - - ) : null} -
-
- - {/* Metadata lines: branch, folder */} -
- {summary.branchName ? ( -
- - {summary.branchName} -
- ) : null} - {summary.cwd ? ( -
- - - {truncatePath(summary.cwd)} - - - - -
- ) : null} - {summary.primaryServiceUrl ? ( - - ) : null} -
- - {/* Issues */} - {summary.issues.length > 0 ? ( -
- Issues - {visibleIssues.map((issue) => ( - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - ))} - {hiddenIssueCount > 0 ? ( - - +{hiddenIssueCount} more - - ) : null} -
- ) : null} -
- ); - }; - return ( <>
- {activeSummaries.map(renderSummaryRow)} + {activeSummaries.map((summary) => ( + controlWorkspaceRuntime.mutate(input)} + onCloseWorkspace={(input) => setClosingWorkspace(input)} + /> + ))}
{cleanupFailedSummaries.length > 0 ? (
@@ -418,7 +283,17 @@ function ProjectWorkspacesContent({ Cleanup attention needed
- {cleanupFailedSummaries.map(renderSummaryRow)} + {cleanupFailedSummaries.map((summary) => ( + controlWorkspaceRuntime.mutate(input)} + onCloseWorkspace={(input) => setClosingWorkspace(input)} + /> + ))}
) : null}