mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Isolated workspaces give each task a safe and reproducible checkout. > - The existing setup cloned the development database before an agent needed to run the app. > - This made worktree creation slower and heavier for tasks that never start a service. > - Runtime services already use one server start path for heartbeat, operator, and startup recovery flows. > - This pull request moves heavy setup to that start path and keeps worktree creation lean. > - The benefit is faster isolated workspace creation with the same reliable runtime setup when a service starts. ## Linked Issues or Issue Description Related pull request: #10652 covers the initial deferred database-seeding slice. This pull request supersedes it with end-to-end runtime provisioning and safe cleanup. **What existing behavior does this improve?** This improves isolated worktree creation, runtime service startup, and isolated instance cleanup. **Subsystem affected** Cross-cutting: CLI worktree setup, server runtime orchestration, shared workspace contracts, and development scripts. **Current behavior** Paperclip seeds an isolated development database during worktree creation. It can also leave an isolated instance directory after workspace teardown. This work happens even when no runtime service starts. **Proposed behavior** Paperclip creates the worktree with a lean eager setup. It runs an idempotent runtime provision command before the first managed service spawn. Concurrent starts share one provision attempt. Teardown removes the isolated instance safely. **Reason and benefit** Many agent tasks only edit and test code. They do not need a running Paperclip instance. Deferring the database seed reduces workspace startup cost while preserving automatic setup for tasks that start the app. **Breaking changes** None. The new runtime provision command is optional. Existing workspace behavior is unchanged when it is absent. ## What Changed - Split Paperclip worktree setup into a lean eager script and an idempotent runtime provision script. - Added `runtimeProvisionCommand` to project, issue, realized workspace, and persisted workspace contracts. - Added a per-workspace provision mutex before local service spawn for heartbeat, operator, and startup recovery flows. - Added a persisted `provisioning` service state and the `workspace_runtime_provision` operation phase. - Kept provision time outside the service readiness timeout and made failed attempts visible and retryable. - Reclaimed isolated instance data during safe workspace teardown. - Serialized deferred database seeding across processes and bound teardown to the instance root captured in persisted workspace metadata. - Added tests for config flow, concurrency, retry, no-op behavior, readiness timing, scripts, CLI commands, and cleanup. - Documented the eager and runtime provisioning contracts. ## Verification - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` (server: 3,201 passed; UI: 3,345 passed; the CLI phase exposed one environment-sensitive AWS doctor assertion because the agent runtime injects static AWS credentials) - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts -t 'passes AWS doctor checks when non-secret provider config is present'` - Focused runtime tests cover serialized provisioning, retry after stderr failure, absent-command no-op behavior, operation logging, persisted state order, and readiness timeout exclusion. - Focused CLI and cleanup tests cover concurrent seed serialization, stale-lock fail-closed behavior, persisted instance ownership, and rewritten sibling pointers. ## Risks - A faulty runtime provision script blocks service startup. Paperclip records stderr, marks the service failed, and retries on the next start. - Concurrent service requests share an in-process provision attempt, while the seed command uses an atomic filesystem lock across processes. A stale lock fails closed and requires an operator to verify no seed is running before removing it. - Isolated instance cleanup is destructive. The cleanup service validates ownership and path containment before removal. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, `gpt-5.6-sol`, with agentic reasoning, tool use, and code execution. The service does not expose the context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
536 lines
18 KiB
TypeScript
536 lines
18 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
Copy,
|
|
ExternalLink,
|
|
Loader2,
|
|
Play,
|
|
RotateCcw,
|
|
Square,
|
|
TriangleAlert,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type WorkspaceServiceControlState =
|
|
| "stopped"
|
|
| "provisioning"
|
|
| "starting"
|
|
| "running"
|
|
| "stopping"
|
|
| "restarting"
|
|
| "failed";
|
|
|
|
export type WorkspaceServiceControlAction = "start" | "stop" | "restart";
|
|
|
|
export type WorkspaceServiceControlEntry = {
|
|
key: string;
|
|
name: string;
|
|
state: WorkspaceServiceControlState;
|
|
healthStatus?: "unknown" | "healthy" | "unhealthy" | null;
|
|
url?: string | null;
|
|
port?: number | null;
|
|
/** Short human-readable failure summary, e.g. "dev exited with code 1, 12s ago". */
|
|
failureDetail?: string | null;
|
|
canStart?: boolean;
|
|
};
|
|
|
|
export type WorkspaceServiceControlBarProps = {
|
|
services: WorkspaceServiceControlEntry[];
|
|
/** serviceKey is null when the action targets all services (aggregate bar / popover footer). */
|
|
onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void;
|
|
onViewLogs?: () => void;
|
|
/** Optional link target for "Manage in Services tab" in the multi-service popover. */
|
|
onManageServices?: () => void;
|
|
/** Initial open state for the multi-service popover (used by Storybook/static captures). */
|
|
defaultServicesOpen?: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
const TRANSITIONAL_STATES: WorkspaceServiceControlState[] = ["provisioning", "starting", "stopping", "restarting"];
|
|
|
|
function isTransitional(state: WorkspaceServiceControlState) {
|
|
return TRANSITIONAL_STATES.includes(state);
|
|
}
|
|
|
|
function formatServiceUrl(url: string | null | undefined) {
|
|
if (!url) return null;
|
|
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
}
|
|
|
|
function statusMeta(entry: WorkspaceServiceControlEntry): { label: string; unhealthy: boolean } {
|
|
switch (entry.state) {
|
|
case "provisioning":
|
|
return { label: "Provisioning…", unhealthy: false };
|
|
case "starting":
|
|
return { label: "Starting…", unhealthy: false };
|
|
case "stopping":
|
|
return { label: "Stopping…", unhealthy: false };
|
|
case "restarting":
|
|
return { label: "Restarting…", unhealthy: false };
|
|
case "failed":
|
|
return { label: "Failed", unhealthy: false };
|
|
case "running":
|
|
return entry.healthStatus === "unhealthy"
|
|
? { label: "Unhealthy", unhealthy: true }
|
|
: { label: "Running", unhealthy: false };
|
|
default:
|
|
return { label: "Stopped", unhealthy: false };
|
|
}
|
|
}
|
|
|
|
function StatusIndicator({ entry, className }: { entry: WorkspaceServiceControlEntry; className?: string }) {
|
|
if (isTransitional(entry.state)) {
|
|
return <Loader2 className={cn("size-3 shrink-0 animate-spin text-muted-foreground", className)} />;
|
|
}
|
|
if (entry.state === "failed") {
|
|
return <TriangleAlert className={cn("size-3 shrink-0 text-destructive", className)} />;
|
|
}
|
|
const unhealthy = entry.state === "running" && entry.healthStatus === "unhealthy";
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"size-2 shrink-0 rounded-full",
|
|
entry.state === "running"
|
|
? unhealthy
|
|
? "bg-amber-500 ring-2 ring-amber-500/30"
|
|
: "bg-emerald-500"
|
|
: "border border-muted-foreground/60 bg-transparent",
|
|
className,
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function CopyUrlButton({ url, disabled }: { url: string; disabled?: boolean }) {
|
|
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => () => {
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
}, []);
|
|
const copyLabel = copyState === "copied" ? "URL copied" : copyState === "failed" ? "Copy failed" : "Copy URL";
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
disabled={disabled}
|
|
aria-label={copyLabel}
|
|
title={copyLabel}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
onClick={async () => {
|
|
try {
|
|
if (!navigator.clipboard) throw new Error("Clipboard API unavailable");
|
|
await navigator.clipboard.writeText(url);
|
|
setCopyState("copied");
|
|
} catch {
|
|
setCopyState("failed");
|
|
}
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = setTimeout(() => setCopyState("idle"), 1500);
|
|
}}
|
|
>
|
|
{copyState === "copied" ? (
|
|
<Check className="size-3" />
|
|
) : copyState === "failed" ? (
|
|
<TriangleAlert className="size-3 text-destructive" />
|
|
) : (
|
|
<Copy className="size-3" />
|
|
)}
|
|
<span className="sr-only" aria-live="polite">{copyLabel}</span>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function UrlSegment({ entry, compact }: { entry: WorkspaceServiceControlEntry; compact?: boolean }) {
|
|
const displayUrl = formatServiceUrl(entry.url) ?? (entry.port ? `:${entry.port}` : null);
|
|
const live = entry.state === "running" && Boolean(entry.url);
|
|
|
|
if (!displayUrl) {
|
|
return <span className="font-mono text-xs text-muted-foreground/70">no url</span>;
|
|
}
|
|
return (
|
|
<>
|
|
{live ? (
|
|
<a
|
|
href={entry.url ?? undefined}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title={entry.url ?? undefined}
|
|
className={cn("min-w-0 truncate font-mono text-xs text-foreground hover:underline", compact ? "max-w-44" : "max-w-56")}
|
|
>
|
|
{displayUrl}
|
|
</a>
|
|
) : (
|
|
<span
|
|
title={entry.url ?? undefined}
|
|
className={cn("min-w-0 truncate font-mono text-xs text-muted-foreground/70", compact ? "max-w-44" : "max-w-56")}
|
|
>
|
|
{displayUrl}
|
|
</span>
|
|
)}
|
|
<span className={cn("flex items-center", live ? null : "invisible")} aria-hidden={live ? undefined : true}>
|
|
<CopyUrlButton url={entry.url ?? ""} disabled={!live} />
|
|
<Button
|
|
asChild={live}
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
disabled={!live}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
title="Open in new tab"
|
|
>
|
|
{live ? (
|
|
<a href={entry.url ?? undefined} target="_blank" rel="noreferrer" aria-label="Open in new tab">
|
|
<ExternalLink className="size-3" />
|
|
</a>
|
|
) : (
|
|
<ExternalLink className="size-3" />
|
|
)}
|
|
</Button>
|
|
</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ActionSlots({
|
|
entry,
|
|
onAction,
|
|
}: {
|
|
entry: Pick<WorkspaceServiceControlEntry, "state" | "canStart">;
|
|
onAction: (action: WorkspaceServiceControlAction) => void;
|
|
}) {
|
|
const transitional = isTransitional(entry.state);
|
|
const canStart = entry.canStart ?? true;
|
|
|
|
if (entry.state === "stopped") {
|
|
return (
|
|
<Button
|
|
variant="cta"
|
|
size="xs"
|
|
className="w-13 justify-center"
|
|
disabled={!canStart}
|
|
onClick={() => onAction("start")}
|
|
aria-label="Start"
|
|
title="Start"
|
|
>
|
|
<Play className="size-3" />
|
|
Start
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
if (entry.state === "failed") {
|
|
return (
|
|
<>
|
|
<Button
|
|
variant="cta"
|
|
size="icon-xs"
|
|
disabled={!canStart}
|
|
onClick={() => onAction("start")}
|
|
aria-label="Start"
|
|
title="Start"
|
|
>
|
|
<Play className="size-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
disabled={!canStart}
|
|
onClick={() => onAction("restart")}
|
|
aria-label="Restart"
|
|
title="Restart"
|
|
className="border border-border text-foreground"
|
|
>
|
|
<RotateCcw className="size-3" />
|
|
</Button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
disabled={transitional}
|
|
onClick={() => onAction("stop")}
|
|
aria-label="Stop"
|
|
title="Stop"
|
|
className="border border-border text-foreground"
|
|
>
|
|
<Square className="size-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
disabled={transitional || !canStart}
|
|
onClick={() => onAction("restart")}
|
|
aria-label="Restart"
|
|
title="Restart"
|
|
className="border border-border text-foreground"
|
|
>
|
|
<RotateCcw className="size-3" />
|
|
</Button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function FailureDetail({
|
|
entry,
|
|
onViewLogs,
|
|
}: {
|
|
entry: WorkspaceServiceControlEntry;
|
|
onViewLogs?: () => void;
|
|
}) {
|
|
if (entry.state !== "failed" || !entry.failureDetail) return null;
|
|
return (
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<span>{entry.failureDetail}</span>
|
|
{onViewLogs ? (
|
|
<>
|
|
<span aria-hidden>·</span>
|
|
<button
|
|
type="button"
|
|
onClick={onViewLogs}
|
|
className="font-medium text-foreground underline underline-offset-2 hover:text-foreground/80"
|
|
>
|
|
View logs
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SingleServiceBar({
|
|
entry,
|
|
onAction,
|
|
onViewLogs,
|
|
className,
|
|
}: {
|
|
entry: WorkspaceServiceControlEntry;
|
|
onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void;
|
|
onViewLogs?: () => void;
|
|
className?: string;
|
|
}) {
|
|
const meta = statusMeta(entry);
|
|
return (
|
|
<div className={cn("flex w-full flex-col items-stretch gap-1 sm:w-auto sm:items-end", className)}>
|
|
<div className="rounded-lg border border-border bg-background">
|
|
<div className="flex h-9 items-center pl-3 pr-1.5">
|
|
<div className="flex items-center gap-2 sm:min-w-24">
|
|
<StatusIndicator entry={entry} />
|
|
<span className="whitespace-nowrap text-xs font-medium text-foreground">{meta.label}</span>
|
|
</div>
|
|
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
|
|
<div className="hidden w-56 min-w-0 shrink-0 items-center gap-0.5 sm:flex">
|
|
<UrlSegment entry={entry} />
|
|
</div>
|
|
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
|
|
<div className="ml-auto flex items-center gap-1 pl-3 sm:pl-0">
|
|
<ActionSlots
|
|
entry={entry}
|
|
onAction={(action) => onAction(action, entry.key)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex h-8 items-center justify-between gap-0.5 border-t border-border px-3 sm:hidden">
|
|
<UrlSegment entry={entry} compact />
|
|
</div>
|
|
</div>
|
|
<FailureDetail entry={entry} onViewLogs={onViewLogs} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ServicePopoverRow({
|
|
entry,
|
|
onAction,
|
|
}: {
|
|
entry: WorkspaceServiceControlEntry;
|
|
onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void;
|
|
}) {
|
|
const meta = statusMeta(entry);
|
|
const displayUrl = formatServiceUrl(entry.url);
|
|
const live = entry.state === "running" && Boolean(entry.url);
|
|
const secondary = live
|
|
? displayUrl
|
|
: entry.state === "starting" && entry.port
|
|
? `starting on :${entry.port}…`
|
|
: entry.state === "failed" && entry.failureDetail
|
|
? entry.failureDetail
|
|
: `${meta.label.toLowerCase().replace(/…$/, "")}${entry.port ? ` · :${entry.port}` : ""}`;
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 py-2.5">
|
|
<StatusIndicator entry={entry} className="mt-0.5" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-foreground">{entry.name}</div>
|
|
<div className="flex min-w-0 items-center gap-0.5">
|
|
{live && entry.url ? (
|
|
<>
|
|
<a
|
|
href={entry.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title={entry.url}
|
|
className="min-w-0 truncate font-mono text-xs text-muted-foreground hover:text-foreground hover:underline"
|
|
>
|
|
{displayUrl}
|
|
</a>
|
|
<CopyUrlButton url={entry.url} />
|
|
</>
|
|
) : (
|
|
<span className="min-w-0 truncate text-xs text-muted-foreground">{secondary}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<ActionSlots entry={entry} onAction={(action) => onAction(action, entry.key)} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MultiServiceBar({
|
|
services,
|
|
onAction,
|
|
onManageServices,
|
|
defaultServicesOpen,
|
|
className,
|
|
}: {
|
|
services: WorkspaceServiceControlEntry[];
|
|
onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void;
|
|
onManageServices?: () => void;
|
|
defaultServicesOpen?: boolean;
|
|
className?: string;
|
|
}) {
|
|
const [open, setOpen] = useState(defaultServicesOpen ?? false);
|
|
const runningCount = services.filter((entry) => entry.state === "running").length;
|
|
const anyTransitional = services.some((entry) => isTransitional(entry.state));
|
|
const anyFailed = services.some((entry) => entry.state === "failed");
|
|
const anyRunning = runningCount > 0;
|
|
const primary = services.find((entry) => entry.state === "running" && entry.url) ?? null;
|
|
|
|
const aggregateEntry: WorkspaceServiceControlEntry = {
|
|
key: "__all__",
|
|
name: "All services",
|
|
state: anyTransitional
|
|
? "starting"
|
|
: anyFailed
|
|
? "failed"
|
|
: anyRunning
|
|
? "running"
|
|
: "stopped",
|
|
healthStatus: services.some((entry) => entry.state === "running" && entry.healthStatus === "unhealthy")
|
|
? "unhealthy"
|
|
: "healthy",
|
|
};
|
|
|
|
return (
|
|
<div className={cn("flex w-full flex-col items-stretch gap-1 sm:w-auto sm:items-end", className)}>
|
|
<div className="rounded-lg border border-border bg-background">
|
|
<div className="flex h-9 items-center pl-3 pr-1.5">
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex h-full items-center gap-2 rounded-l-lg pr-1 text-xs font-medium text-foreground hover:bg-accent"
|
|
aria-label={`${runningCount} of ${services.length} services running — show services`}
|
|
>
|
|
<StatusIndicator entry={aggregateEntry} />
|
|
<span className="whitespace-nowrap">{runningCount}/{services.length} running</span>
|
|
<ChevronDown className="size-3 text-muted-foreground" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-96 p-0" onOpenAutoFocus={(event) => event.preventDefault()}>
|
|
<div className="px-4 pb-1 pt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
Services · {services.length}
|
|
</div>
|
|
<div className="divide-y divide-border px-4">
|
|
{services.map((entry) => (
|
|
<ServicePopoverRow key={entry.key} entry={entry} onAction={onAction} />
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-1 border-t border-border px-4 py-2">
|
|
<Button variant="ghost" size="xs" onClick={() => onAction("start", null)}>Start all</Button>
|
|
<Button variant="ghost" size="xs" onClick={() => onAction("stop", null)}>Stop all</Button>
|
|
<Button variant="ghost" size="xs" onClick={() => onAction("restart", null)}>Restart all</Button>
|
|
{onManageServices ? (
|
|
<Button
|
|
variant="link"
|
|
size="xs"
|
|
className="ml-auto text-muted-foreground"
|
|
onClick={onManageServices}
|
|
>
|
|
Manage in Services tab →
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
|
|
<div className="hidden min-w-0 items-center gap-0.5 sm:flex">
|
|
{primary ? (
|
|
<>
|
|
<span className="mr-1 shrink-0 text-xs text-muted-foreground">{primary.name}</span>
|
|
<UrlSegment entry={primary} />
|
|
</>
|
|
) : (
|
|
<span className="font-mono text-xs text-muted-foreground/70">no url</span>
|
|
)}
|
|
</div>
|
|
<div className="mx-3 hidden h-5 w-px bg-border sm:block" />
|
|
<div className="ml-auto flex items-center gap-1 pl-3 sm:pl-0">
|
|
<ActionSlots
|
|
entry={{ state: aggregateEntry.state, canStart: true }}
|
|
onAction={(action) => onAction(action, null)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{primary ? (
|
|
<div className="flex h-8 items-center justify-between gap-0.5 border-t border-border px-3 sm:hidden">
|
|
<UrlSegment entry={primary} compact />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Segmented control bar for execution-workspace services: status · URL · actions.
|
|
* Geometry is identical in every state — transitions are announced by the status
|
|
* segment (spinner + label) instead of buttons appearing and disappearing.
|
|
*/
|
|
export function WorkspaceServiceControlBar({
|
|
services,
|
|
onAction,
|
|
onViewLogs,
|
|
onManageServices,
|
|
defaultServicesOpen,
|
|
className,
|
|
}: WorkspaceServiceControlBarProps) {
|
|
if (services.length === 0) return null;
|
|
if (services.length === 1) {
|
|
return (
|
|
<SingleServiceBar
|
|
entry={services[0]}
|
|
onAction={onAction}
|
|
onViewLogs={onViewLogs}
|
|
className={className}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<MultiServiceBar
|
|
services={services}
|
|
onAction={onAction}
|
|
onManageServices={onManageServices}
|
|
defaultServicesOpen={defaultServicesOpen}
|
|
className={className}
|
|
/>
|
|
);
|
|
}
|