From d716343c2535874a0c6e10f2117271fd76d5bf5e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 18 Apr 2026 07:56:12 -0500 Subject: [PATCH] Polish project workspace cards Co-Authored-By: Paperclip --- .../ProjectWorkspaceSummaryCard.test.tsx | 32 +++++++ .../ProjectWorkspaceSummaryCard.tsx | 11 ++- ui/src/lib/project-workspaces-tab.test.ts | 94 ++++++++++++++++++- ui/src/lib/project-workspaces-tab.ts | 36 +++++-- ui/src/pages/ProjectDetail.tsx | 4 +- 5 files changed, 162 insertions(+), 15 deletions(-) diff --git a/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx b/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx index dc4388f586e..c8ed161214b 100644 --- a/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx +++ b/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx @@ -75,6 +75,7 @@ function createSummary(overrides: Partial = {}): Projec serviceCount: overrides.serviceCount ?? 2, runningServiceCount: overrides.runningServiceCount ?? 0, primaryServiceUrl: overrides.primaryServiceUrl ?? "http://127.0.0.1:62474", + primaryServiceUrlRunning: overrides.primaryServiceUrlRunning ?? false, hasRuntimeConfig: overrides.hasRuntimeConfig ?? true, issues: overrides.issues ?? [ createIssue({ id: "issue-1", identifier: "PAP-1364" }), @@ -124,6 +125,9 @@ describe("ProjectWorkspaceSummaryCard", () => { const actions = container.querySelector('[data-testid="workspace-summary-actions"]'); expect(actions?.className).toContain("flex-col"); + const card = container.firstElementChild; + expect(card?.className).toContain("rounded-lg"); + expect(card?.className).toContain("border"); act(() => { root.unmount(); @@ -189,4 +193,32 @@ describe("ProjectWorkspaceSummaryCard", () => { root.unmount(); }); }); + + it("colors live service urls green", () => { + const root = createRoot(container); + + act(() => { + root.render( + {}} + onCloseWorkspace={() => {}} + />, + ); + }); + + const serviceLink = container.querySelector("a[href='http://127.0.0.1:62475']"); + expect(serviceLink?.className).toContain("text-emerald"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/ProjectWorkspaceSummaryCard.tsx b/ui/src/components/ProjectWorkspaceSummaryCard.tsx index 2c34951f8a6..bb63ddab48a 100644 --- a/ui/src/components/ProjectWorkspaceSummaryCard.tsx +++ b/ui/src/components/ProjectWorkspaceSummaryCard.tsx @@ -54,7 +54,7 @@ export function ProjectWorkspaceSummaryCard({ const actionKey = `${summary.key}:${hasRunningServices ? "stop" : "start"}`; return ( -
+
@@ -143,7 +143,7 @@ export function ProjectWorkspaceSummaryCard({
-
+
{summary.branchName ? (
@@ -181,7 +181,12 @@ export function ProjectWorkspaceSummaryCard({ href={summary.primaryServiceUrl} target="_blank" rel="noreferrer" - className="break-all font-mono text-xs text-foreground hover:underline" + className={cn( + "break-all font-mono text-xs hover:underline", + summary.primaryServiceUrlRunning + ? "text-emerald-700 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200" + : "text-foreground", + )} > {summary.primaryServiceUrl} diff --git a/ui/src/lib/project-workspaces-tab.test.ts b/ui/src/lib/project-workspaces-tab.test.ts index 0dcb70c8770..d1013876c4d 100644 --- a/ui/src/lib/project-workspaces-tab.test.ts +++ b/ui/src/lib/project-workspaces-tab.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared"; +import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, WorkspaceRuntimeService } from "@paperclipai/shared"; import { buildProjectWorkspaceSummaries } from "./project-workspaces-tab"; function createProjectWorkspace(overrides: Partial): ProjectWorkspace { @@ -96,6 +96,39 @@ function createExecutionWorkspace(overrides: Partial): Execu }; } +function createRuntimeService(overrides: Partial = {}): WorkspaceRuntimeService { + return { + id: overrides.id ?? "service-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? null, + executionWorkspaceId: overrides.executionWorkspaceId ?? null, + issueId: overrides.issueId ?? null, + scopeType: overrides.scopeType ?? "execution_workspace", + scopeId: overrides.scopeId ?? null, + serviceName: overrides.serviceName ?? "preview", + status: overrides.status ?? "running", + lifecycle: overrides.lifecycle ?? "ephemeral", + reuseKey: overrides.reuseKey ?? null, + command: overrides.command ?? null, + cwd: overrides.cwd ?? null, + port: overrides.port ?? 3100, + url: overrides.url ?? "http://127.0.0.1:3100", + provider: overrides.provider ?? "local_process", + providerRef: overrides.providerRef ?? null, + ownerAgentId: overrides.ownerAgentId ?? null, + startedByRunId: overrides.startedByRunId ?? null, + lastUsedAt: overrides.lastUsedAt ?? new Date("2026-03-26T10:00:00Z"), + startedAt: overrides.startedAt ?? new Date("2026-03-26T09:00:00Z"), + stoppedAt: overrides.stoppedAt ?? null, + stopPolicy: overrides.stopPolicy ?? null, + healthStatus: overrides.healthStatus ?? "healthy", + configIndex: overrides.configIndex ?? null, + createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"), + }; +} + describe("buildProjectWorkspaceSummaries", () => { const primaryWorkspace = createProjectWorkspace({ id: "workspace-default", @@ -228,4 +261,63 @@ describe("buildProjectWorkspaceSummaries", () => { expect(summaries).toHaveLength(1); expect(summaries[0]?.key).toBe("project:workspace-default"); }); + + it("sorts workspaces with running services first and marks live service urls", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-stopped", + executionWorkspaceId: "exec-stopped", + updatedAt: new Date("2026-03-27T12:00:00Z"), + }), + createIssue({ + id: "issue-live", + executionWorkspaceId: "exec-live", + updatedAt: new Date("2026-03-25T12:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-stopped", + name: "newer stopped", + lastUsedAt: new Date("2026-03-27T12:00:00Z"), + runtimeServices: [ + createRuntimeService({ + id: "service-stopped", + executionWorkspaceId: "exec-stopped", + status: "stopped", + url: "http://127.0.0.1:4100", + }), + ], + }), + createExecutionWorkspace({ + id: "exec-live", + name: "older live", + lastUsedAt: new Date("2026-03-25T12:00:00Z"), + runtimeServices: [ + createRuntimeService({ + id: "service-live", + executionWorkspaceId: "exec-live", + status: "running", + url: "http://127.0.0.1:4200", + }), + ], + }), + ], + }); + + expect(summaries[0]).toMatchObject({ + key: "execution:exec-live", + primaryServiceUrl: "http://127.0.0.1:4200", + primaryServiceUrlRunning: true, + runningServiceCount: 1, + }); + expect(summaries[1]).toMatchObject({ + key: "execution:exec-stopped", + primaryServiceUrl: "http://127.0.0.1:4100", + primaryServiceUrlRunning: false, + runningServiceCount: 0, + }); + }); }); diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts index df0d9bb35d9..ed184b5700b 100644 --- a/ui/src/lib/project-workspaces-tab.ts +++ b/ui/src/lib/project-workspaces-tab.ts @@ -16,6 +16,7 @@ export interface ProjectWorkspaceSummary { serviceCount: number; runningServiceCount: number; primaryServiceUrl: string | null; + primaryServiceUrlRunning: boolean; hasRuntimeConfig: boolean; issues: Issue[]; } @@ -52,6 +53,24 @@ function isDefaultSharedExecutionWorkspace(input: { return input.executionWorkspace.mode === "shared_workspace" && linkedProjectWorkspaceId === input.primaryWorkspaceId; } +function runtimeServiceSummary( + services: NonNullable | undefined, +) { + const serviceCount = services?.length ?? 0; + const runningServiceCount = services?.filter((service) => service.status === "running").length ?? 0; + const primaryService = + services?.find((service) => service.status === "running" && service.url) + ?? services?.find((service) => service.url) + ?? null; + + return { + serviceCount, + runningServiceCount, + primaryServiceUrl: primaryService?.url ?? null, + primaryServiceUrlRunning: primaryService?.status === "running", + }; +} + export function buildProjectWorkspaceSummaries(input: { project: ProjectWorkspaceLike; issues: Issue[]; @@ -81,6 +100,7 @@ export function buildProjectWorkspaceSummaries(input: { const nextIssues = [...(existing?.issues ?? []), issue].sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), ); + const runtimeSummary = runtimeServiceSummary(executionWorkspace.runtimeServices); summaries.set(`execution:${executionWorkspace.id}`, { key: `execution:${executionWorkspace.id}`, @@ -98,9 +118,7 @@ export function buildProjectWorkspaceSummaries(input: { projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null, executionWorkspaceId: executionWorkspace.id, executionWorkspaceStatus: executionWorkspace.status, - serviceCount: executionWorkspace.runtimeServices?.length ?? 0, - runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, - primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + ...runtimeSummary, hasRuntimeConfig: Boolean( executionWorkspace.config?.workspaceRuntime ?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime, @@ -118,6 +136,7 @@ export function buildProjectWorkspaceSummaries(input: { const nextIssues = [...(existing?.issues ?? []), issue].sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), ); + const runtimeSummary = runtimeServiceSummary(projectWorkspace.runtimeServices); summaries.set(`project:${projectWorkspace.id}`, { key: `project:${projectWorkspace.id}`, @@ -130,9 +149,7 @@ export function buildProjectWorkspaceSummaries(input: { projectWorkspaceId: projectWorkspace.id, executionWorkspaceId: null, executionWorkspaceStatus: null, - serviceCount: projectWorkspace.runtimeServices?.length ?? 0, - runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, - primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + ...runtimeSummary, hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime), issues: nextIssues, }); @@ -146,6 +163,7 @@ export function buildProjectWorkspaceSummaries(input: { || Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime) || (projectWorkspace.runtimeServices?.length ?? 0) > 0; if (!shouldSurfaceWorkspace) continue; + const runtimeSummary = runtimeServiceSummary(projectWorkspace.runtimeServices); summaries.set(key, { key, kind: "project_workspace", @@ -157,15 +175,15 @@ export function buildProjectWorkspaceSummaries(input: { projectWorkspaceId: projectWorkspace.id, executionWorkspaceId: null, executionWorkspaceStatus: null, - serviceCount: projectWorkspace.runtimeServices?.length ?? 0, - runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, - primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + ...runtimeSummary, hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime), issues: [], }); } return [...summaries.values()].sort((a, b) => { + const liveDiff = Number(b.runningServiceCount > 0) - Number(a.runningServiceCount > 0); + if (liveDiff !== 0) return liveDiff; const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime(); return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName); }); diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index f5b0976bfb0..eaa65f0972c 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -264,7 +264,7 @@ function ProjectWorkspacesContent({ return ( <>
-
+
{activeSummaries.map((summary) => ( Cleanup attention needed
-
+
{cleanupFailedSummaries.map((summary) => (