Implement execution workspaces and work products

This commit is contained in:
Dotta
2026-03-13 17:12:25 -05:00
parent 9da5358bb3
commit 920bc4c70f
45 changed files with 9157 additions and 140 deletions

View File

@@ -0,0 +1,70 @@
import { Link, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { ExternalLink } from "lucide-react";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { queryKeys } from "../lib/queryKeys";
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 py-1.5">
<div className="w-28 shrink-0 text-xs text-muted-foreground">{label}</div>
<div className="min-w-0 flex-1 text-sm">{children}</div>
</div>
);
}
export function ExecutionWorkspaceDetail() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const { data: workspace, isLoading, error } = useQuery({
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
queryFn: () => executionWorkspacesApi.get(workspaceId!),
enabled: Boolean(workspaceId),
});
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error instanceof Error ? error.message : "Failed to load workspace"}</p>;
if (!workspace) return null;
return (
<div className="max-w-2xl space-y-4">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Execution workspace</div>
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
<div className="text-sm text-muted-foreground">
{workspace.status} · {workspace.mode} · {workspace.providerType}
</div>
</div>
<div className="rounded-lg border border-border p-4">
<DetailRow label="Project">
{workspace.projectId ? <Link to={`/projects/${workspace.projectId}`} className="hover:underline">{workspace.projectId}</Link> : "None"}
</DetailRow>
<DetailRow label="Source issue">
{workspace.sourceIssueId ? <Link to={`/issues/${workspace.sourceIssueId}`} className="hover:underline">{workspace.sourceIssueId}</Link> : "None"}
</DetailRow>
<DetailRow label="Branch">{workspace.branchName ?? "None"}</DetailRow>
<DetailRow label="Base ref">{workspace.baseRef ?? "None"}</DetailRow>
<DetailRow label="Working dir">
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
</DetailRow>
<DetailRow label="Provider ref">
<span className="break-all font-mono text-xs">{workspace.providerRef ?? "None"}</span>
</DetailRow>
<DetailRow label="Repo URL">
{workspace.repoUrl ? (
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
{workspace.repoUrl}
<ExternalLink className="h-3 w-3" />
</a>
) : "None"}
</DetailRow>
<DetailRow label="Opened">{new Date(workspace.openedAt).toLocaleString()}</DetailRow>
<DetailRow label="Last used">{new Date(workspace.lastUsedAt).toLocaleString()}</DetailRow>
<DetailRow label="Cleanup">
{workspace.cleanupEligibleAt ? `${new Date(workspace.cleanupEligibleAt).toLocaleString()}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}` : "Not scheduled"}
</DetailRow>
</div>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { queryKeys } from "../lib/queryKeys";
import { formatDateTime, relativeTime } from "../lib/utils";
import { useExperimentalWorkspacesEnabled } from "../lib/experimentalSettings";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
@@ -30,6 +31,7 @@ export function InstanceSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const { enabled: workspacesEnabled, setEnabled: setWorkspacesEnabled } = useExperimentalWorkspacesEnabled();
useEffect(() => {
setBreadcrumbs([
@@ -110,6 +112,34 @@ export function InstanceSettings() {
return (
<div className="max-w-5xl space-y-6">
<div className="space-y-3 rounded-lg border border-border bg-card p-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Experimental</h2>
</div>
<p className="text-sm text-muted-foreground">
UI-only feature flags for in-progress product surfaces.
</p>
</div>
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<div className="space-y-0.5">
<div className="text-sm font-medium">Workspaces</div>
<div className="text-xs text-muted-foreground">
Show workspace, execution workspace, and work product controls in project and issue UI.
</div>
</div>
<Button
type="button"
variant={workspacesEnabled ? "default" : "outline"}
size="sm"
onClick={() => setWorkspacesEnabled(!workspacesEnabled)}
>
{workspacesEnabled ? "Enabled" : "Disabled"}
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />

View File

@@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { useExperimentalWorkspacesEnabled } from "../lib/experimentalSettings";
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens } from "../lib/utils";
@@ -36,15 +37,21 @@ import {
ChevronDown,
ChevronRight,
EyeOff,
ExternalLink,
FileText,
GitBranch,
GitPullRequest,
Hexagon,
ListTree,
MessageSquare,
MoreHorizontal,
Package,
Paperclip,
Rocket,
SlidersHorizontal,
Trash2,
} from "lucide-react";
import type { ActivityEvent } from "@paperclipai/shared";
import type { ActivityEvent, IssueWorkProduct } from "@paperclipai/shared";
import type { Agent, IssueAttachment } from "@paperclipai/shared";
type CommentReassignment = {
@@ -133,6 +140,24 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
}
function workProductIcon(product: IssueWorkProduct) {
switch (product.type) {
case "pull_request":
return <GitPullRequest className="h-3.5 w-3.5" />;
case "branch":
case "commit":
return <GitBranch className="h-3.5 w-3.5" />;
case "artifact":
return <Package className="h-3.5 w-3.5" />;
case "document":
return <FileText className="h-3.5 w-3.5" />;
case "runtime_service":
return <Rocket className="h-3.5 w-3.5" />;
default:
return <ExternalLink className="h-3.5 w-3.5" />;
}
}
function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<string, Agent> }) {
const id = evt.actorId;
if (evt.actorType === "agent") {
@@ -147,6 +172,7 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany();
const { enabled: experimentalWorkspacesEnabled } = useExperimentalWorkspacesEnabled();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
@@ -160,6 +186,13 @@ export function IssueDetail() {
cost: false,
});
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [newWorkProductType, setNewWorkProductType] = useState<IssueWorkProduct["type"]>("preview_url");
const [newWorkProductProvider, setNewWorkProductProvider] = useState("paperclip");
const [newWorkProductTitle, setNewWorkProductTitle] = useState("");
const [newWorkProductUrl, setNewWorkProductUrl] = useState("");
const [newWorkProductStatus, setNewWorkProductStatus] = useState<IssueWorkProduct["status"]>("active");
const [newWorkProductReviewState, setNewWorkProductReviewState] = useState<IssueWorkProduct["reviewState"]>("none");
const [newWorkProductSummary, setNewWorkProductSummary] = useState("");
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
@@ -387,6 +420,7 @@ export function IssueDetail() {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(issueId!) });
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
@@ -471,6 +505,42 @@ export function IssueDetail() {
},
});
const createWorkProduct = useMutation({
mutationFn: () =>
issuesApi.createWorkProduct(issueId!, {
type: newWorkProductType,
provider: newWorkProductProvider,
title: newWorkProductTitle.trim(),
url: newWorkProductUrl.trim() || null,
status: newWorkProductStatus,
reviewState: newWorkProductReviewState,
summary: newWorkProductSummary.trim() || null,
projectId: issue?.projectId ?? null,
executionWorkspaceId: issue?.currentExecutionWorkspace?.id ?? issue?.executionWorkspaceId ?? null,
}),
onSuccess: () => {
setNewWorkProductTitle("");
setNewWorkProductUrl("");
setNewWorkProductSummary("");
setNewWorkProductType("preview_url");
setNewWorkProductProvider("paperclip");
setNewWorkProductStatus("active");
setNewWorkProductReviewState("none");
invalidateIssue();
},
});
const updateWorkProduct = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.updateWorkProduct(id, data),
onSuccess: () => invalidateIssue(),
});
const deleteWorkProduct = useMutation({
mutationFn: (id: string) => issuesApi.deleteWorkProduct(id),
onSuccess: () => invalidateIssue(),
});
useEffect(() => {
const titleLabel = issue?.title ?? issueId ?? "Issue";
setBreadcrumbs([
@@ -508,6 +578,11 @@ export function IssueDetail() {
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
const ancestors = issue.ancestors ?? [];
const workProducts = issue.workProducts ?? [];
const showOutputsTab =
experimentalWorkspacesEnabled ||
Boolean(issue.currentExecutionWorkspace) ||
workProducts.length > 0;
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
const file = evt.target.files?.[0];
@@ -759,6 +834,12 @@ export function IssueDetail() {
<MessageSquare className="h-3.5 w-3.5" />
Comments
</TabsTrigger>
{showOutputsTab && (
<TabsTrigger value="outputs" className="gap-1.5">
<Rocket className="h-3.5 w-3.5" />
Outputs
</TabsTrigger>
)}
<TabsTrigger value="subissues" className="gap-1.5">
<ListTree className="h-3.5 w-3.5" />
Sub-issues
@@ -798,6 +879,199 @@ export function IssueDetail() {
/>
</TabsContent>
{showOutputsTab && (
<TabsContent value="outputs" className="space-y-4">
{issue.currentExecutionWorkspace && (
<div className="rounded-lg border border-border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-medium">Execution workspace</div>
<div className="text-xs text-muted-foreground">
{issue.currentExecutionWorkspace.status} · {issue.currentExecutionWorkspace.mode}
</div>
</div>
<Link
to={`/execution-workspaces/${issue.currentExecutionWorkspace.id}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
Open
<ExternalLink className="h-3 w-3" />
</Link>
</div>
<div className="text-xs text-muted-foreground">
{issue.currentExecutionWorkspace.branchName ?? issue.currentExecutionWorkspace.cwd ?? "No workspace path recorded."}
</div>
</div>
)}
<div className="rounded-lg border border-border p-3 space-y-3">
<div className="text-sm font-medium">Work product</div>
<div className="grid gap-2 sm:grid-cols-2">
<select
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={newWorkProductType}
onChange={(e) => setNewWorkProductType(e.target.value as IssueWorkProduct["type"])}
>
<option value="preview_url">Preview URL</option>
<option value="runtime_service">Runtime service</option>
<option value="pull_request">Pull request</option>
<option value="branch">Branch</option>
<option value="commit">Commit</option>
<option value="artifact">Artifact</option>
<option value="document">Document</option>
</select>
<input
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={newWorkProductProvider}
onChange={(e) => setNewWorkProductProvider(e.target.value)}
placeholder="Provider"
/>
<input
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none sm:col-span-2"
value={newWorkProductTitle}
onChange={(e) => setNewWorkProductTitle(e.target.value)}
placeholder="Title"
/>
<input
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none sm:col-span-2"
value={newWorkProductUrl}
onChange={(e) => setNewWorkProductUrl(e.target.value)}
placeholder="URL"
/>
<select
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={newWorkProductStatus}
onChange={(e) => setNewWorkProductStatus(e.target.value as IssueWorkProduct["status"])}
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="ready_for_review">Ready for review</option>
<option value="approved">Approved</option>
<option value="changes_requested">Changes requested</option>
<option value="merged">Merged</option>
<option value="closed">Closed</option>
<option value="failed">Failed</option>
<option value="archived">Archived</option>
</select>
<select
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={newWorkProductReviewState}
onChange={(e) => setNewWorkProductReviewState(e.target.value as IssueWorkProduct["reviewState"])}
>
<option value="none">No review state</option>
<option value="needs_board_review">Needs board review</option>
<option value="approved">Approved</option>
<option value="changes_requested">Changes requested</option>
</select>
<textarea
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none sm:col-span-2 min-h-20"
value={newWorkProductSummary}
onChange={(e) => setNewWorkProductSummary(e.target.value)}
placeholder="Summary"
/>
</div>
<div className="flex justify-end">
<Button
size="sm"
disabled={!newWorkProductTitle.trim() || createWorkProduct.isPending}
onClick={() => createWorkProduct.mutate()}
>
{createWorkProduct.isPending ? "Adding..." : "Add output"}
</Button>
</div>
</div>
{workProducts.length === 0 ? (
<p className="text-xs text-muted-foreground">No work product yet.</p>
) : (
<div className="space-y-2">
{workProducts.map((product) => (
<div key={product.id} className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-medium">
{workProductIcon(product)}
<span className="truncate">{product.title}</span>
{product.isPrimary && (
<span className="rounded-full border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
Primary
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{product.type.replace(/_/g, " ")} · {product.provider}
</div>
</div>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => {
if (!window.confirm(`Delete "${product.title}"?`)) return;
deleteWorkProduct.mutate(product.id);
}}
disabled={deleteWorkProduct.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{product.url && (
<a
href={product.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{product.url}
<ExternalLink className="h-3 w-3" />
</a>
)}
{product.summary && (
<div className="text-xs text-muted-foreground">{product.summary}</div>
)}
<div className="grid gap-2 sm:grid-cols-3">
<select
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={product.status}
onChange={(e) =>
updateWorkProduct.mutate({ id: product.id, data: { status: e.target.value } })}
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="ready_for_review">Ready for review</option>
<option value="approved">Approved</option>
<option value="changes_requested">Changes requested</option>
<option value="merged">Merged</option>
<option value="closed">Closed</option>
<option value="failed">Failed</option>
<option value="archived">Archived</option>
</select>
<select
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={product.reviewState}
onChange={(e) =>
updateWorkProduct.mutate({ id: product.id, data: { reviewState: e.target.value } })}
>
<option value="none">No review state</option>
<option value="needs_board_review">Needs board review</option>
<option value="approved">Approved</option>
<option value="changes_requested">Changes requested</option>
</select>
<Button
variant={product.isPrimary ? "secondary" : "outline"}
size="sm"
onClick={() => updateWorkProduct.mutate({ id: product.id, data: { isPrimary: true } })}
disabled={product.isPrimary || updateWorkProduct.isPending}
>
{product.isPrimary ? "Primary" : "Make primary"}
</Button>
</div>
</div>
))}
</div>
)}
</TabsContent>
)}
<TabsContent value="subissues">
{childIssues.length === 0 ? (
<p className="text-xs text-muted-foreground">No sub-issues.</p>