paperclip/ui/src/pages/ProjectWorkspaceDetail.tsx
Dotta e89076148a
[codex] Improve workspace runtime and navigation ergonomics (#3680)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - That operator experience depends not just on issue chat, but also on
how workspaces, inbox groups, and navigation state behave over
long-running sessions
> - The current branch included a separate cluster of workspace-runtime
controls, inbox grouping, sidebar ordering, and worktree lifecycle fixes
> - Those changes cross server, shared contracts, database state, and UI
navigation, but they still form one coherent operator workflow area
> - This pull request isolates the workspace/runtime and navigation
ergonomics work into one standalone branch
> - The benefit is better workspace recovery and navigation persistence
without forcing reviewers through the unrelated issue-detail/chat work

## What Changed

- Improved execution workspace and project workspace controls, request
wiring, layout, and JSON editor ergonomics
- Hardened linked worktree reuse/startup behavior and documented the
`worktree repair` flow for recovering linked worktrees safely
- Added inbox workspace grouping, mobile collapse, archive undo,
keyboard navigation, shared group-header styling, and persisted
collapsed-group behavior
- Added persistent sidebar order preferences with the supporting DB
migration, shared/server contracts, routes, services, hooks, and UI
integration
- Scoped issue-list preferences by context and added targeted UI/server
tests for workspace controls, inbox behavior, sidebar preferences, and
worktree validation

## Verification

- `pnpm vitest run
server/src/__tests__/sidebar-preferences-routes.test.ts
ui/src/pages/Inbox.test.tsx
ui/src/components/ProjectWorkspaceSummaryCard.test.tsx
ui/src/components/WorkspaceRuntimeControls.test.tsx
ui/src/api/workspace-runtime-control.test.ts`
- `server/src/__tests__/workspace-runtime.test.ts` was attempted, but
the embedded Postgres suite self-skipped/hung on this host after
reporting an init-script issue, so it is not counted as a local pass
here

## Risks

- Medium: this branch includes migration-backed preference storage plus
worktree/runtime behavior, so merge review should pay attention to state
persistence and worktree recovery semantics
- The sidebar preference migration is standalone, but it should still be
watched for conflicts if another migration lands first

## Model Used

- OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact
deployed model ID is not exposed in this environment), reasoning
enabled, tool use and local code execution enabled

## 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)
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-14 12:57:11 -05:00

648 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared";
import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ChoosePathButton } from "../components/PathInstructionsModal";
import { projectsApi } from "../api/projects";
import {
buildWorkspaceRuntimeControlSections,
WorkspaceRuntimeControls,
type WorkspaceRuntimeControlRequest,
} from "../components/WorkspaceRuntimeControls";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
type WorkspaceFormState = {
name: string;
sourceType: ProjectWorkspaceSourceType;
cwd: string;
repoUrl: string;
repoRef: string;
defaultRef: string;
visibility: ProjectWorkspaceVisibility;
setupCommand: string;
cleanupCommand: string;
remoteProvider: string;
remoteWorkspaceRef: string;
sharedWorkspaceKey: string;
runtimeConfig: string;
};
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"];
const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [
{ value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." },
{ value: "non_git_path", label: "Local non-git path", description: "A local folder without git semantics." },
{ value: "git_repo", label: "Remote git repo", description: "A repo URL with optional refs and local checkout." },
{ value: "remote_managed", label: "Remote-managed workspace", description: "A hosted workspace tracked by external reference." },
];
const VISIBILITY_OPTIONS: Array<{ value: ProjectWorkspaceVisibility; label: string }> = [
{ value: "default", label: "Default" },
{ value: "advanced", label: "Advanced" },
];
function isSafeExternalUrl(value: string | null | undefined) {
if (!value) return false;
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function isAbsolutePath(value: string) {
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
}
function readText(value: string | null | undefined) {
return value ?? "";
}
function formatJson(value: Record<string, unknown> | null | undefined) {
if (!value || Object.keys(value).length === 0) return "";
return JSON.stringify(value, null, 2);
}
function formStateFromWorkspace(workspace: ProjectWorkspace): WorkspaceFormState {
return {
name: workspace.name,
sourceType: workspace.sourceType,
cwd: readText(workspace.cwd),
repoUrl: readText(workspace.repoUrl),
repoRef: readText(workspace.repoRef),
defaultRef: readText(workspace.defaultRef),
visibility: workspace.visibility,
setupCommand: readText(workspace.setupCommand),
cleanupCommand: readText(workspace.cleanupCommand),
remoteProvider: readText(workspace.remoteProvider),
remoteWorkspaceRef: readText(workspace.remoteWorkspaceRef),
sharedWorkspaceKey: readText(workspace.sharedWorkspaceKey),
runtimeConfig: formatJson(workspace.runtimeConfig?.workspaceRuntime),
};
}
function normalizeText(value: string) {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function parseRuntimeConfigJson(value: string) {
const trimmed = value.trim();
if (!trimmed) return { ok: true as const, value: null as Record<string, unknown> | null };
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
ok: false as const,
error: "Workspace commands JSON must be a JSON object.",
};
}
return { ok: true as const, value: parsed as Record<string, unknown> };
} catch (error) {
return {
ok: false as const,
error: error instanceof Error ? error.message : "Invalid JSON.",
};
}
}
function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) {
const patch: Record<string, unknown> = {};
const maybeAssign = (key: keyof WorkspaceFormState, transform?: (value: string) => unknown) => {
const initialValue = initialState[key];
const nextValue = nextState[key];
if (initialValue === nextValue) return;
patch[key] = transform ? transform(nextValue) : nextValue;
};
maybeAssign("name", normalizeText);
maybeAssign("sourceType");
maybeAssign("cwd", normalizeText);
maybeAssign("repoUrl", normalizeText);
maybeAssign("repoRef", normalizeText);
maybeAssign("defaultRef", normalizeText);
maybeAssign("visibility");
maybeAssign("setupCommand", normalizeText);
maybeAssign("cleanupCommand", normalizeText);
maybeAssign("remoteProvider", normalizeText);
maybeAssign("remoteWorkspaceRef", normalizeText);
maybeAssign("sharedWorkspaceKey", normalizeText);
if (initialState.runtimeConfig !== nextState.runtimeConfig) {
const parsed = parseRuntimeConfigJson(nextState.runtimeConfig);
if (!parsed.ok) throw new Error(parsed.error);
patch.runtimeConfig = {
workspaceRuntime: parsed.value,
};
}
return patch;
}
function validateWorkspaceForm(form: WorkspaceFormState) {
const cwd = normalizeText(form.cwd);
const repoUrl = normalizeText(form.repoUrl);
const remoteWorkspaceRef = normalizeText(form.remoteWorkspaceRef);
if (form.sourceType === "remote_managed") {
if (!remoteWorkspaceRef && !repoUrl) {
return "Remote-managed workspaces require a remote workspace ref or repo URL.";
}
} else if (!cwd && !repoUrl) {
return "Workspace requires at least one local path or repo URL.";
}
if (cwd && (form.sourceType === "local_path" || form.sourceType === "non_git_path") && !isAbsolutePath(cwd)) {
return "Local workspace path must be absolute.";
}
if (repoUrl) {
try {
new URL(repoUrl);
} catch {
return "Repo URL must be a valid URL.";
}
}
const runtimeConfig = parseRuntimeConfigJson(form.runtimeConfig);
if (!runtimeConfig.ok) {
return runtimeConfig.error;
}
return null;
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="space-y-1.5">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
{hint ? <span className="text-[11px] leading-relaxed text-muted-foreground sm:text-right">{hint}</span> : null}
</div>
{children}
</label>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
<div className="shrink-0 text-xs text-muted-foreground sm:w-28">{label}</div>
<div className="min-w-0 flex-1 text-sm">{children}</div>
</div>
);
}
export function ProjectWorkspaceDetail() {
const { companyPrefix, projectId, workspaceId } = useParams<{
companyPrefix?: string;
projectId: string;
workspaceId: string;
}>();
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form, setForm] = useState<WorkspaceFormState | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const routeProjectRef = projectId ?? "";
const routeWorkspaceId = workspaceId ?? "";
const routeCompanyId = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
}, [companies, companyPrefix]);
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
const projectQuery = useQuery({
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
enabled: canFetchProject,
});
const project = projectQuery.data ?? null;
const workspace = useMemo(
() => project?.workspaces.find((item) => item.id === routeWorkspaceId) ?? null,
[project, routeWorkspaceId],
);
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
useEffect(() => {
if (!project?.companyId || project.companyId === selectedCompanyId) return;
setSelectedCompanyId(project.companyId, { source: "route_sync" });
}, [project?.companyId, selectedCompanyId, setSelectedCompanyId]);
useEffect(() => {
if (!workspace) return;
setForm(formStateFromWorkspace(workspace));
setErrorMessage(null);
}, [workspace]);
useEffect(() => {
if (!project) return;
setBreadcrumbs([
{ label: "Projects", href: "/projects" },
{ label: project.name, href: `/projects/${canonicalProjectRef}` },
{ label: "Workspaces", href: `/projects/${canonicalProjectRef}/workspaces` },
{ label: workspace?.name ?? routeWorkspaceId },
]);
}, [setBreadcrumbs, project, canonicalProjectRef, workspace?.name, routeWorkspaceId]);
useEffect(() => {
if (!project) return;
if (routeProjectRef === canonicalProjectRef) return;
navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true });
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]);
const invalidateProject = () => {
if (!project) return;
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) });
if (lookupCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(lookupCompanyId) });
}
};
const updateWorkspace = useMutation({
mutationFn: (patch: Record<string, unknown>) =>
projectsApi.updateWorkspace(project!.id, routeWorkspaceId, patch, lookupCompanyId),
onSuccess: () => {
invalidateProject();
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error instanceof Error ? error.message : "Failed to save workspace.");
},
});
const setPrimaryWorkspace = useMutation({
mutationFn: () => projectsApi.updateWorkspace(project!.id, routeWorkspaceId, { isPrimary: true }, lookupCompanyId),
onSuccess: () => {
invalidateProject();
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error instanceof Error ? error.message : "Failed to update workspace.");
},
});
const controlRuntimeServices = useMutation({
mutationFn: (request: WorkspaceRuntimeControlRequest) =>
projectsApi.controlWorkspaceCommands(project!.id, routeWorkspaceId, request.action, lookupCompanyId, request),
onSuccess: (result, request) => {
invalidateProject();
setErrorMessage(null);
setRuntimeActionMessage(
request.action === "run"
? "Workspace job completed."
: request.action === "stop"
? "Workspace service stopped."
: request.action === "restart"
? "Workspace service restarted."
: "Workspace service started.",
);
},
onError: (error) => {
setRuntimeActionMessage(null);
setErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands.");
},
});
if (projectQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace</p>;
if (projectQuery.error) {
return (
<p className="text-sm text-destructive">
{projectQuery.error instanceof Error ? projectQuery.error.message : "Failed to load workspace"}
</p>
);
}
if (!project || !workspace || !form || !initialState) {
return <p className="text-sm text-muted-foreground">Workspace not found for this project.</p>;
}
const canRunWorkspaceCommands = Boolean(workspace.cwd);
const canStartRuntimeServices = Boolean(workspace.runtimeConfig?.workspaceRuntime) && canRunWorkspaceCommands;
const runtimeControlSections = buildWorkspaceRuntimeControlSections({
runtimeConfig: workspace.runtimeConfig?.workspaceRuntime ?? null,
runtimeServices: workspace.runtimeServices ?? [],
canStartServices: canStartRuntimeServices,
canRunJobs: canRunWorkspaceCommands,
});
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
const saveChanges = () => {
const validationError = validateWorkspaceForm(form);
if (validationError) {
setErrorMessage(validationError);
return;
}
const patch = buildWorkspacePatch(initialState, form);
if (Object.keys(patch).length === 0) return;
updateWorkspace.mutate(patch);
};
const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null;
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to={`/projects/${canonicalProjectRef}/workspaces`}>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to workspaces
</Link>
</Button>
<div className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card 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-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Project workspace
</div>
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace
checkout behavior, default runtime services for child execution workspaces, and let you override setup
or cleanup commands when one workspace needs special handling.
</p>
</div>
{!workspace.isPrimary ? (
<Button
variant="outline"
className="w-full sm:w-auto"
disabled={setPrimaryWorkspace.isPending}
onClick={() => setPrimaryWorkspace.mutate()}
>
{setPrimaryWorkspace.isPending
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <Check className="mr-2 h-4 w-4" />}
Make primary
</Button>
) : (
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
<Sparkles className="h-4 w-4" />
This is the projects primary codebase workspace.
</div>
)}
</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="Workspace name"
/>
</Field>
<Field label="Visibility">
<select
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.visibility}
onChange={(event) =>
setForm((current) => current ? { ...current, visibility: event.target.value as ProjectWorkspaceVisibility } : current)
}
>
{VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field>
</div>
<div className="mt-4 grid gap-4">
<Field label="Source type" hint={sourceTypeDescription ?? undefined}>
<select
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.sourceType}
onChange={(event) =>
setForm((current) => current ? { ...current, sourceType: event.target.value as ProjectWorkspaceSourceType } : current)
}
>
{SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
<Field label="Local path">
<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>
<div className="flex items-end">
<ChoosePathButton />
</div>
</div>
<div className="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="Repo ref">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.repoRef}
onChange={(event) => setForm((current) => current ? { ...current, repoRef: event.target.value } : current)}
placeholder="origin/main"
/>
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Default ref">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.defaultRef}
onChange={(event) => setForm((current) => current ? { ...current, defaultRef: event.target.value } : current)}
placeholder="origin/main"
/>
</Field>
<Field label="Shared workspace key">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.sharedWorkspaceKey}
onChange={(event) => setForm((current) => current ? { ...current, sharedWorkspaceKey: event.target.value } : current)}
placeholder="frontend"
/>
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Remote provider">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.remoteProvider}
onChange={(event) => setForm((current) => current ? { ...current, remoteProvider: event.target.value } : current)}
placeholder="codespaces"
/>
</Field>
<Field label="Remote workspace ref">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.remoteWorkspaceRef}
onChange={(event) => setForm((current) => current ? { ...current, remoteWorkspaceRef: event.target.value } : current)}
placeholder="workspace-123"
/>
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Setup command" hint="Runs when this workspace needs custom bootstrap">
<textarea
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.setupCommand}
onChange={(event) => setForm((current) => current ? { ...current, setupCommand: event.target.value } : current)}
placeholder="pnpm install && pnpm dev"
/>
</Field>
<Field label="Cleanup command" hint="Runs before project-level execution workspace teardown">
<textarea
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.cleanupCommand}
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
placeholder="pkill -f vite || true"
/>
</Field>
</div>
<details className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
<p className="mt-2 text-sm text-muted-foreground">
Paperclip derives Services and Jobs from this JSON. Prefer editing named commands first; use raw JSON for advanced lifecycle, port, readiness, or environment settings.
</p>
<div className="mt-3">
<Field label="Workspace commands JSON" hint="Execution workspaces inherit this config unless they override it. Legacy `services` arrays still work, but `commands` supports both services and jobs.">
<textarea
className="min-h-96 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.runtimeConfig}
onChange={(event) => setForm((current) => current ? { ...current, runtimeConfig: event.target.value } : current)}
placeholder={"{\n \"commands\": [\n {\n \"id\": \"web\",\n \"name\": \"web\",\n \"kind\": \"service\",\n \"command\": \"pnpm dev\",\n \"cwd\": \".\",\n \"port\": { \"type\": \"auto\" },\n \"readiness\": {\n \"type\": \"http\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"expose\": {\n \"type\": \"url\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"lifecycle\": \"shared\",\n \"reuseScope\": \"project_workspace\"\n },\n {\n \"id\": \"db-migrate\",\n \"name\": \"db:migrate\",\n \"kind\": \"job\",\n \"command\": \"pnpm db:migrate\",\n \"cwd\": \".\"\n }\n ]\n}"}
/>
</Field>
</div>
</details>
</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);
}}
>
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="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace facts</div>
<h2 className="text-lg font-semibold">Current state</h2>
</div>
<Separator className="my-4" />
<DetailRow label="Project">
<Link to={`/projects/${canonicalProjectRef}`} className="hover:underline">{project.name}</Link>
</DetailRow>
<DetailRow label="Workspace ID">
<span className="break-all font-mono text-xs">{workspace.id}</span>
</DetailRow>
<DetailRow label="Local path">
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
</DetailRow>
<DetailRow label="Repo">
{workspace.repoUrl && isSafeExternalUrl(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>
) : workspace.repoUrl ? (
<span className="break-all font-mono text-xs">{workspace.repoUrl}</span>
) : "None"}
</DetailRow>
<DetailRow label="Default ref">{workspace.defaultRef ?? "None"}</DetailRow>
<DetailRow label="Updated">{new Date(workspace.updatedAt).toLocaleString()}</DetailRow>
</div>
<div className="rounded-2xl border border-border bg-card 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">Workspace commands</div>
<h2 className="text-lg font-semibold">Services and jobs</h2>
<p className="text-sm text-muted-foreground">
Long-running services stay supervised here, while one-shot jobs run on demand against this workspace. Execution workspaces inherit this config unless they override it.
</p>
</div>
</div>
<WorkspaceRuntimeControls
className="mt-4"
sections={runtimeControlSections}
isPending={controlRuntimeServices.isPending}
pendingRequest={pendingRuntimeAction}
serviceEmptyMessage={
workspace.runtimeConfig?.workspaceRuntime
? "No services have been started for this workspace yet."
: "No workspace command config is defined for this workspace yet."
}
jobEmptyMessage="No one-shot jobs are configured for this workspace yet."
disabledHint="Project workspaces need a working directory before local commands can run, and services also need runtime config."
onAction={(request) => controlRuntimeServices.mutate(request)}
/>
</div>
</div>
</div>
</div>
);
}