mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Give tasks durable company-scoped workspace bindings independent of project assignment, channel defaults and authorized repository preparation. Reuse verified input seeds and sparse output recovery while preserving trust, privacy and controller provisioning boundaries.
Full CI and E2E, Superagent, legacy checks and Greptile Apex 5/5 passed on reviewed head 753680b61c. All review findings are resolved; the PR records focused verification and historical acceptance limits.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
import { Link } from "@/lib/router";
|
|
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CopyText } from "./CopyText";
|
|
import { IssuesQuicklook } from "./IssuesQuicklook";
|
|
import type { ProjectWorkspaceLinkedIssue, ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
|
|
import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly";
|
|
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { Copy, ExternalLink, FolderOpen, GitBranch, Loader2, Play, Square } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
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 | null;
|
|
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 { hideHostPaths } = useManagedSandboxOnly();
|
|
const visibleIssues = summary.issues.slice(0, 4);
|
|
const hiddenIssueCount = Math.max(summary.linkedIssueCount - visibleIssues.length, 0);
|
|
const workspaceHref =
|
|
summary.kind === "project_workspace" && projectRef
|
|
? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
|
|
: `/execution-workspaces/${summary.workspaceId}`;
|
|
const hasRunningServices = summary.runningServiceCount > 0;
|
|
const actionKey = `${summary.key}:${hasRunningServices ? "stop" : "start"}`;
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border bg-background p-4 shadow-sm sm:p-5">
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="min-w-0 space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant="outline" className="border-border bg-background px-2.5 py-1 text-(length:--text-micro) uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
|
|
{workspaceKindLabel(summary.kind)}
|
|
</Badge>
|
|
<Badge variant="outline" className="border-border/70 bg-background px-2.5 py-1 text-muted-foreground">
|
|
Updated {timeAgo(summary.lastUpdatedAt)}
|
|
</Badge>
|
|
{summary.serviceCount > 0 ? (
|
|
<Badge variant="outline"
|
|
className={cn(
|
|
"gap-1.5 px-2.5 py-1",
|
|
hasRunningServices
|
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
|
: "border-border/70 bg-background text-muted-foreground",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"h-1.5 w-1.5 rounded-full",
|
|
hasRunningServices ? "bg-emerald-500" : "bg-muted-foreground/40",
|
|
)}
|
|
/>
|
|
{summary.runningServiceCount}/{summary.serviceCount} services
|
|
</Badge>
|
|
) : null}
|
|
{summary.executionWorkspaceStatus ? (
|
|
<Badge variant="outline" className="border-border/70 bg-background px-2.5 py-1 text-muted-foreground">
|
|
{summary.executionWorkspaceStatus.replace(/_/g, " ")}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
<Link
|
|
to={workspaceHref}
|
|
className="block break-words text-base font-semibold leading-6 text-foreground hover:underline"
|
|
>
|
|
{summary.workspaceName}
|
|
</Link>
|
|
</div>
|
|
|
|
<div
|
|
className="flex flex-col gap-2 min-[420px]:flex-row lg:w-auto lg:justify-end"
|
|
data-testid="workspace-summary-actions"
|
|
>
|
|
{summary.hasRuntimeConfig ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 justify-center px-3 text-xs"
|
|
disabled={runtimeActionPending}
|
|
onClick={() =>
|
|
onRuntimeAction({
|
|
key: summary.key,
|
|
kind: summary.kind,
|
|
workspaceId: summary.workspaceId,
|
|
action: hasRunningServices ? "stop" : "start",
|
|
})
|
|
}
|
|
>
|
|
{runtimeActionKey === actionKey ? (
|
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
|
) : hasRunningServices ? (
|
|
<Square className="mr-2 h-3.5 w-3.5" />
|
|
) : (
|
|
<Play className="mr-2 h-3.5 w-3.5" />
|
|
)}
|
|
{hasRunningServices ? "Stop services" : "Start services"}
|
|
</Button>
|
|
) : null}
|
|
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-9 px-3 text-xs text-muted-foreground"
|
|
onClick={() => onCloseWorkspace({
|
|
id: summary.executionWorkspaceId!,
|
|
name: summary.workspaceName,
|
|
status: summary.executionWorkspaceStatus!,
|
|
})}
|
|
>
|
|
{summary.executionWorkspaceStatus === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-border/70 bg-background px-3 py-3">
|
|
<div className="space-y-2 text-sm">
|
|
{summary.branchName ? (
|
|
<div className="flex items-start gap-2">
|
|
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-(length:--text-micro) uppercase tracking-(--tracking-eyebrow) text-muted-foreground">Branch</div>
|
|
<div className="flex items-start gap-2">
|
|
<CopyText
|
|
text={summary.branchName}
|
|
containerClassName="min-w-0"
|
|
className="min-w-0 break-all text-left font-mono text-xs text-foreground"
|
|
copiedLabel="Branch copied"
|
|
>
|
|
{summary.branchName}
|
|
</CopyText>
|
|
<CopyText
|
|
text={summary.branchName}
|
|
ariaLabel="Copy branch"
|
|
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
|
|
copiedLabel="Branch copied"
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</CopyText>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{/*
|
|
The path is a host filesystem path, so it disappears under the
|
|
managed-sandbox-only policy, and stays hidden until that policy is
|
|
known. Branch, service, and issue rows stay.
|
|
*/}
|
|
{summary.cwd && !hideHostPaths ? (
|
|
<div className="flex items-start gap-2">
|
|
<FolderOpen className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-(length:--text-micro) uppercase tracking-(--tracking-eyebrow) text-muted-foreground">Path</div>
|
|
<div className="flex items-start gap-2">
|
|
<CopyText
|
|
text={summary.cwd}
|
|
title={summary.cwd}
|
|
containerClassName="min-w-0"
|
|
className="min-w-0 break-all text-left font-mono text-xs text-foreground"
|
|
copiedLabel="Path copied"
|
|
>
|
|
{truncatePath(summary.cwd)}
|
|
</CopyText>
|
|
<CopyText
|
|
text={summary.cwd}
|
|
ariaLabel="Copy path"
|
|
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
|
|
copiedLabel="Path copied"
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</CopyText>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{summary.primaryServiceUrl ? (
|
|
<div className="flex items-start gap-2">
|
|
<ExternalLink className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0">
|
|
<div className="text-(length:--text-micro) uppercase tracking-(--tracking-eyebrow) text-muted-foreground">Service</div>
|
|
<a
|
|
href={summary.primaryServiceUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
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}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{summary.issues.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<div className="text-(length:--text-micro) font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
|
|
Linked tasks
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{visibleIssues.map((issue) => (
|
|
<IssuePill key={issue.id} issue={issue} />
|
|
))}
|
|
{hiddenIssueCount > 0 ? (
|
|
<Link
|
|
to={workspaceHref}
|
|
className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground hover:underline"
|
|
>
|
|
+{hiddenIssueCount} more
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function IssuePill({ issue }: { issue: ProjectWorkspaceLinkedIssue }) {
|
|
return (
|
|
<IssuesQuicklook issue={issue}>
|
|
<Link
|
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
|
className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 font-mono text-xs text-foreground transition-colors hover:border-foreground/30 hover:text-foreground hover:underline"
|
|
>
|
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
</Link>
|
|
</IssuesQuicklook>
|
|
);
|
|
}
|