mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
Refine execution workspace detail layout
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2a932a6db0
commit
3638036230
2 files changed
+402
-401
No files matched your search
@@ -162,6 +162,7 @@ function boardRoutes() {
|
||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="goals" element={<Goals />} />
|
||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||
@@ -352,6 +353,7 @@ export function App() {
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
||||
|
||||
@@ -34,7 +34,7 @@ type WorkspaceFormState = {
|
||||
workspaceRuntime: string;
|
||||
};
|
||||
|
||||
type ExecutionWorkspaceTab = "configuration" | "issues";
|
||||
type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues";
|
||||
|
||||
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
@@ -42,10 +42,16 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex
|
||||
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
|
||||
const tab = segments[executionWorkspacesIndex + 2];
|
||||
if (tab === "issues") return "issues";
|
||||
if (tab === "runtime-logs") return "runtime_logs";
|
||||
if (tab === "configuration") return "configuration";
|
||||
return null;
|
||||
}
|
||||
|
||||
function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceTab) {
|
||||
const segment = tab === "runtime_logs" ? "runtime-logs" : tab;
|
||||
return `/execution-workspaces/${workspaceId}/${segment}`;
|
||||
}
|
||||
|
||||
function isSafeExternalUrl(value: string | null | undefined) {
|
||||
if (!value) return false;
|
||||
try {
|
||||
@@ -450,18 +456,18 @@ export function ExecutionWorkspaceDetail() {
|
||||
let cachedTab: ExecutionWorkspaceTab = "configuration";
|
||||
try {
|
||||
const storedTab = localStorage.getItem(`paperclip:execution-workspace-tab:${workspaceId}`);
|
||||
if (storedTab === "issues" || storedTab === "configuration") {
|
||||
if (storedTab === "issues" || storedTab === "configuration" || storedTab === "runtime_logs") {
|
||||
cachedTab = storedTab;
|
||||
}
|
||||
} catch {}
|
||||
return <Navigate to={`/execution-workspaces/${workspaceId}/${cachedTab}`} replace />;
|
||||
return <Navigate to={executionWorkspaceTabPath(workspaceId, cachedTab)} replace />;
|
||||
}
|
||||
|
||||
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
|
||||
try {
|
||||
localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab);
|
||||
} catch {}
|
||||
navigate(`/execution-workspaces/${workspace.id}/${tab}`);
|
||||
navigate(executionWorkspaceTabPath(workspace.id, tab));
|
||||
};
|
||||
|
||||
const saveChanges = () => {
|
||||
@@ -485,7 +491,7 @@ export function ExecutionWorkspaceDetail() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-5xl space-y-4 overflow-hidden sm:space-y-6">
|
||||
<div className="space-y-4 overflow-hidden sm:space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
|
||||
@@ -511,10 +517,91 @@ export function ExecutionWorkspaceDetail() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
|
||||
<h2 className="text-lg font-semibold">Attached services</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Source: {runtimeConfigSource === "execution_workspace"
|
||||
? "execution workspace override"
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "project workspace default"
|
||||
: "none"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("start")}
|
||||
>
|
||||
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("restart")}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
|
||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspace.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{service.serviceName}</div>
|
||||
<div className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{service.url ? (
|
||||
<a href={service.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||||
{service.url}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
{service.port ? <div>Port {service.port}</div> : null}
|
||||
{service.command ? <MonoValue value={service.command} copy /> : null}
|
||||
{service.cwd ? <MonoValue value={service.cwd} copy /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill className="self-start">{service.healthStatus}</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{effectiveRuntimeConfig
|
||||
? "No runtime services are currently running for this execution workspace."
|
||||
: "No runtime config is defined for this execution workspace yet."}
|
||||
</p>
|
||||
)}
|
||||
{!errorMessage && runtimeActionMessage ? <p className="mt-4 text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab ?? "configuration"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "runtime_logs", label: "Runtime logs" },
|
||||
{ value: "issues", label: "Issues" },
|
||||
]}
|
||||
align="start"
|
||||
@@ -524,412 +611,324 @@ export function ExecutionWorkspaceDetail() {
|
||||
</Tabs>
|
||||
|
||||
{activeTab === "configuration" ? (
|
||||
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
||||
<div className="min-w-0 space-y-4 sm:space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Configuration
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">Workspace settings</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setCloseDialogOpen(true)}
|
||||
disabled={workspace.status === "archived"}
|
||||
>
|
||||
{workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-5" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Workspace name">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
||||
placeholder="Execution workspace name"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Branch name" hint="Useful for isolated worktrees">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.branchName}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
|
||||
placeholder="PAP-946-workspace"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Working directory">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.cwd}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
|
||||
placeholder="/absolute/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Provider path / ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.providerRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
|
||||
placeholder="/path/to/worktree or provider ref"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Repo URL">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.repoUrl}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
|
||||
placeholder="https://github.com/org/repo"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Base ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.baseRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
||||
value={form.provisionCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/provision-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
||||
value={form.teardownCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
||||
<textarea
|
||||
className="min-h-16 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-24"
|
||||
value={form.cleanupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||
placeholder="pkill -f vite || true"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Runtime config source
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{runtimeConfigSource === "execution_workspace"
|
||||
? "This execution workspace currently overrides the project workspace runtime config."
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "This execution workspace is inheriting the project workspace runtime config."
|
||||
: "No runtime config is currently defined on this execution workspace or its project workspace."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
size="sm"
|
||||
disabled={!linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime}
|
||||
onClick={() =>
|
||||
setForm((current) => current ? {
|
||||
...current,
|
||||
inheritRuntime: true,
|
||||
workspaceRuntime: "",
|
||||
} : current)
|
||||
}
|
||||
>
|
||||
Reset to inherit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Runtime services JSON" hint="Concrete workspace runtime settings for this execution workspace. Leave this inheriting unless you need a one-off override. If you are missing the right commands, ask your CEO to set them up for you.">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
id="inherit-runtime-config"
|
||||
type="checkbox"
|
||||
checked={form.inheritRuntime}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
setForm((current) => {
|
||||
if (!current) return current;
|
||||
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
|
||||
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
|
||||
}
|
||||
return { ...current, inheritRuntime: checked };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="min-h-32 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-48"
|
||||
value={form.workspaceRuntime}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
||||
disabled={form.inheritRuntime}
|
||||
placeholder={'{\n "services": [\n {\n "name": "web",\n "command": "pnpm dev",\n "port": 3100\n }\n ]\n}'}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
|
||||
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={!isDirty || updateWorkspace.isPending}
|
||||
onClick={() => {
|
||||
setForm(initialState);
|
||||
setErrorMessage(null);
|
||||
setRuntimeActionMessage(null);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
|
||||
{!errorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
||||
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-4 sm:space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
|
||||
<h2 className="text-lg font-semibold">Workspace context</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<DetailRow label="Project">
|
||||
{project ? <Link to={`/projects/${projectRef}`} className="hover:underline">{project.name}</Link> : <MonoValue value={workspace.projectId} />}
|
||||
</DetailRow>
|
||||
<DetailRow label="Project workspace">
|
||||
{project && linkedProjectWorkspace ? (
|
||||
<WorkspaceLink project={project} workspace={linkedProjectWorkspace} />
|
||||
) : workspace.projectWorkspaceId ? (
|
||||
<MonoValue value={workspace.projectWorkspaceId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Source issue">
|
||||
{sourceIssue ? (
|
||||
<Link to={issueUrl(sourceIssue)} className="hover:underline">
|
||||
{sourceIssue.identifier ?? sourceIssue.id} · {sourceIssue.title}
|
||||
</Link>
|
||||
) : workspace.sourceIssueId ? (
|
||||
<MonoValue value={workspace.sourceIssueId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Derived from">
|
||||
{derivedWorkspace ? (
|
||||
<Link to={`/execution-workspaces/${derivedWorkspace.id}/configuration`} className="hover:underline">
|
||||
{derivedWorkspace.name}
|
||||
</Link>
|
||||
) : workspace.derivedFromExecutionWorkspaceId ? (
|
||||
<MonoValue value={workspace.derivedFromExecutionWorkspaceId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Workspace ID">
|
||||
<MonoValue value={workspace.id} />
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
|
||||
<h2 className="text-lg font-semibold">Concrete location</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<DetailRow label="Working dir">
|
||||
{workspace.cwd ? <MonoValue value={workspace.cwd} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Provider ref">
|
||||
{workspace.providerRef ? <MonoValue value={workspace.providerRef} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Repo URL">
|
||||
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
|
||||
<div className="inline-flex max-w-full items-start gap-2">
|
||||
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex min-w-0 items-center gap-1 break-all hover:underline">
|
||||
{workspace.repoUrl}
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||
</a>
|
||||
<CopyText text={workspace.repoUrl} className="shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Copied">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</CopyText>
|
||||
</div>
|
||||
) : workspace.repoUrl ? (
|
||||
<MonoValue value={workspace.repoUrl} copy />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Base ref">
|
||||
{workspace.baseRef ? <MonoValue value={workspace.baseRef} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Branch">
|
||||
{workspace.branchName ? <MonoValue value={workspace.branchName} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Opened">{formatDateTime(workspace.openedAt)}</DetailRow>
|
||||
<DetailRow label="Last used">{formatDateTime(workspace.lastUsedAt)}</DetailRow>
|
||||
<DetailRow label="Cleanup">
|
||||
{workspace.cleanupEligibleAt
|
||||
? `${formatDateTime(workspace.cleanupEligibleAt)}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}`
|
||||
: "Not scheduled"}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
|
||||
<h2 className="text-lg font-semibold">Attached services</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Source: {runtimeConfigSource === "execution_workspace"
|
||||
? "execution workspace override"
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "project workspace default"
|
||||
: "none"}
|
||||
</p>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Configuration
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("start")}
|
||||
>
|
||||
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("restart")}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
|
||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspace.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{service.serviceName}</div>
|
||||
<div className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{service.url ? (
|
||||
<a href={service.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||||
{service.url}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
{service.port ? <div>Port {service.port}</div> : null}
|
||||
{service.command ? <MonoValue value={service.command} copy /> : null}
|
||||
{service.cwd ? <MonoValue value={service.cwd} copy /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill className="self-start">{service.healthStatus}</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<h2 className="text-lg font-semibold">Workspace settings</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{effectiveRuntimeConfig
|
||||
? "No runtime services are currently running for this execution workspace."
|
||||
: "No runtime config is defined for this execution workspace yet."}
|
||||
Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setCloseDialogOpen(true)}
|
||||
disabled={workspace.status === "archived"}
|
||||
>
|
||||
{workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
|
||||
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{workspaceOperationsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading workspace operations…</p>
|
||||
) : workspaceOperationsQuery.error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{workspaceOperationsQuery.error instanceof Error
|
||||
? workspaceOperationsQuery.error.message
|
||||
: "Failed to load workspace operations."}
|
||||
</p>
|
||||
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspaceOperationsQuery.data.slice(0, 6).map((operation) => (
|
||||
<div key={operation.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDateTime(operation.startedAt)}
|
||||
{operation.finishedAt ? ` → ${formatDateTime(operation.finishedAt)}` : ""}
|
||||
</div>
|
||||
{operation.stderrExcerpt ? (
|
||||
<div className="whitespace-pre-wrap break-words text-xs text-destructive">{operation.stderrExcerpt}</div>
|
||||
) : operation.stdoutExcerpt ? (
|
||||
<div className="whitespace-pre-wrap break-words text-xs text-muted-foreground">{operation.stdoutExcerpt}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusPill className="self-start">{operation.status}</StatusPill>
|
||||
</div>
|
||||
<Separator className="my-5" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Workspace name">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
||||
placeholder="Execution workspace name"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Branch name" hint="Useful for isolated worktrees">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.branchName}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
|
||||
placeholder="PAP-946-workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Working directory">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.cwd}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
|
||||
placeholder="/absolute/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Provider path / ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.providerRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
|
||||
placeholder="/path/to/worktree or provider ref"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Repo URL">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.repoUrl}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
|
||||
placeholder="https://github.com/org/repo"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Base ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.baseRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
||||
value={form.provisionCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/provision-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
||||
value={form.teardownCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
||||
<textarea
|
||||
className="min-h-16 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-24"
|
||||
value={form.cleanupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||
placeholder="pkill -f vite || true"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Runtime config source
|
||||
</div>
|
||||
))}
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{runtimeConfigSource === "execution_workspace"
|
||||
? "This execution workspace currently overrides the project workspace runtime config."
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "This execution workspace is inheriting the project workspace runtime config."
|
||||
: "No runtime config is currently defined on this execution workspace or its project workspace."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
size="sm"
|
||||
disabled={!linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime}
|
||||
onClick={() =>
|
||||
setForm((current) => current ? {
|
||||
...current,
|
||||
inheritRuntime: true,
|
||||
workspaceRuntime: "",
|
||||
} : current)
|
||||
}
|
||||
>
|
||||
Reset to inherit
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No workspace operations have been recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="Runtime services JSON" hint="Concrete workspace runtime settings for this execution workspace. Leave this inheriting unless you need a one-off override. If you are missing the right commands, ask your CEO to set them up for you.">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
id="inherit-runtime-config"
|
||||
type="checkbox"
|
||||
checked={form.inheritRuntime}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
setForm((current) => {
|
||||
if (!current) return current;
|
||||
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
|
||||
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
|
||||
}
|
||||
return { ...current, inheritRuntime: checked };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="min-h-32 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-48"
|
||||
value={form.workspaceRuntime}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
||||
disabled={form.inheritRuntime}
|
||||
placeholder={'{\n "services": [\n {\n "name": "web",\n "command": "pnpm dev",\n "port": 3100\n }\n ]\n}'}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
|
||||
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={!isDirty || updateWorkspace.isPending}
|
||||
onClick={() => {
|
||||
setForm(initialState);
|
||||
setErrorMessage(null);
|
||||
setRuntimeActionMessage(null);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
|
||||
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
|
||||
<h2 className="text-lg font-semibold">Workspace context</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<DetailRow label="Project">
|
||||
{project ? <Link to={`/projects/${projectRef}`} className="hover:underline">{project.name}</Link> : <MonoValue value={workspace.projectId} />}
|
||||
</DetailRow>
|
||||
<DetailRow label="Project workspace">
|
||||
{project && linkedProjectWorkspace ? (
|
||||
<WorkspaceLink project={project} workspace={linkedProjectWorkspace} />
|
||||
) : workspace.projectWorkspaceId ? (
|
||||
<MonoValue value={workspace.projectWorkspaceId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Source issue">
|
||||
{sourceIssue ? (
|
||||
<Link to={issueUrl(sourceIssue)} className="hover:underline">
|
||||
{sourceIssue.identifier ?? sourceIssue.id} · {sourceIssue.title}
|
||||
</Link>
|
||||
) : workspace.sourceIssueId ? (
|
||||
<MonoValue value={workspace.sourceIssueId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Derived from">
|
||||
{derivedWorkspace ? (
|
||||
<Link to={executionWorkspaceTabPath(derivedWorkspace.id, "configuration")} className="hover:underline">
|
||||
{derivedWorkspace.name}
|
||||
</Link>
|
||||
) : workspace.derivedFromExecutionWorkspaceId ? (
|
||||
<MonoValue value={workspace.derivedFromExecutionWorkspaceId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Workspace ID">
|
||||
<MonoValue value={workspace.id} />
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
|
||||
<h2 className="text-lg font-semibold">Concrete location</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<DetailRow label="Working dir">
|
||||
{workspace.cwd ? <MonoValue value={workspace.cwd} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Provider ref">
|
||||
{workspace.providerRef ? <MonoValue value={workspace.providerRef} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Repo URL">
|
||||
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
|
||||
<div className="inline-flex max-w-full items-start gap-2">
|
||||
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex min-w-0 items-center gap-1 break-all hover:underline">
|
||||
{workspace.repoUrl}
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||
</a>
|
||||
<CopyText text={workspace.repoUrl} className="shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Copied">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</CopyText>
|
||||
</div>
|
||||
) : workspace.repoUrl ? (
|
||||
<MonoValue value={workspace.repoUrl} copy />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Base ref">
|
||||
{workspace.baseRef ? <MonoValue value={workspace.baseRef} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Branch">
|
||||
{workspace.branchName ? <MonoValue value={workspace.branchName} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Opened">{formatDateTime(workspace.openedAt)}</DetailRow>
|
||||
<DetailRow label="Last used">{formatDateTime(workspace.lastUsedAt)}</DetailRow>
|
||||
<DetailRow label="Cleanup">
|
||||
{workspace.cleanupEligibleAt
|
||||
? `${formatDateTime(workspace.cleanupEligibleAt)}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}`
|
||||
: "Not scheduled"}
|
||||
</DetailRow>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "runtime_logs" ? (
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
|
||||
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{workspaceOperationsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading workspace operations…</p>
|
||||
) : workspaceOperationsQuery.error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{workspaceOperationsQuery.error instanceof Error
|
||||
? workspaceOperationsQuery.error.message
|
||||
: "Failed to load workspace operations."}
|
||||
</p>
|
||||
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspaceOperationsQuery.data.map((operation) => (
|
||||
<div key={operation.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDateTime(operation.startedAt)}
|
||||
{operation.finishedAt ? ` → ${formatDateTime(operation.finishedAt)}` : ""}
|
||||
</div>
|
||||
{operation.stderrExcerpt ? (
|
||||
<div className="whitespace-pre-wrap break-words text-xs text-destructive">{operation.stderrExcerpt}</div>
|
||||
) : operation.stdoutExcerpt ? (
|
||||
<div className="whitespace-pre-wrap break-words text-xs text-muted-foreground">{operation.stdoutExcerpt}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusPill className="self-start">{operation.status}</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No workspace operations have been recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ExecutionWorkspaceIssuesList
|
||||
|
||||
Reference in new issue
Block a user