Files
PaperClipAI/ui/src/components/ProjectProperties.tsx
T
842efe0181 fix(ui): stack project field save indicator below its label (#14765)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The web UI has a project detail page. A properties panel on that
page lets a user edit the project name, description, and other fields.
> - Each field shows a "Saving...", "Saved", or "Failed" indicator while
an edit is in progress.
> - The indicator was rendered next to the label text in a fixed 80px
label column. The "Description" label almost fills that column, so the
indicator spilled into the value column and covered the description
text.
> - A user cannot read the description while the indicator is visible.
This looks broken and it hides content.
> - This pull request stacks the indicator directly below the label
text, so it stays inside the label column.
> - The benefit is that the indicator never covers the field value, for
the description and for every other labelled field.

## Linked Issues or Issue Description

No public GitHub issue exists for this bug. The issue is described here.

**What happened?**

When a user edits a project description in the project properties panel,
the "Saving..." and "Saved" indicator appears next to the "Description"
label. The label column is 80px wide. The indicator does not fit, so it
overflows into the value column and overlaps the description text.

**Expected behavior**

The "Saving..." / "Saved" / "Failed" indicator must appear directly
below the "Description" label. It must not overlap the description text
or any other field value.

**Steps to reproduce**

1. Open a project in the Paperclip web UI.
2. Click the Description field in the properties panel and change the
text.
3. Click outside the field to save.
4. Look at the "Description" label while the "Saving..." and then
"Saved" indicator is visible. The indicator overlaps the description
text.

**Paperclip version or commit**

master at `5edf55d7350c7f08c9dd132c7e0f1421fa0bf2fb`.

**Deployment mode**

Local development (`pnpm dev`). The bug is in the UI layout, so it
applies to every deployment mode.

## What Changed

- `ui/src/components/ProjectProperties.tsx`: `FieldLabel` now renders
the label text and the `SaveIndicator` in a vertical flex column
(`flex-col`) instead of a horizontal row. The indicator sits directly
below the label and stays inside the 80px label column. This applies to
every labelled property row (Name, Description, Env, and so on), so no
label can overflow.
- `ui/src/components/ProjectProperties.save-indicator.test.tsx`: new
regression test. It asserts that the indicator is a stacked sibling
under the Description label for the `saving` and `saved` states, and
that no indicator renders for the `idle` state.

## Verification

- Run `pnpm --filter @paperclipai/ui exec vitest run
src/components/ProjectProperties` from the repo root. All
ProjectProperties tests pass, including the new save-indicator test.
- The new test fails against the previous inline layout (2 of 3 cases
fail) and passes with this change (3 of 3 cases pass).
- The existing `ProjectProperties.concurrency`,
`ProjectProperties.managed-sandbox`, and `ProjectDetail` tests pass (18
tests).
- `tsc -b` in `ui/` reports no errors in the changed files.
- Manual check: open a project, edit the description, and save. The
"Saving..." and "Saved" indicator now appears below the "Description"
label and does not cover the description text.

## Risks

- Low risk. The change is a single flex-direction swap on the label
wrapper in one component.
- Every labelled row in the project properties panel gets a slightly
taller label cell while an indicator is visible. This is intentional and
it matches the requested layout.
- No data, API, or migration changes.

## Model Used

- Claude Fable 5.1 (Anthropic), model id `claude-fable-5-1`, with
extended thinking and tool use, run through Claude Code inside a
Paperclip agent session. A human reviewed the change and the pull
request text.

## 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)
- [ ] 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Bender (Fable) <bender@paperclip.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 18:34:41 -07:00

1022 lines
44 KiB
TypeScript

import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { useState, type ReactNode } from "react";
import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } from "@/lib/managed-sandbox-environment";
import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared";
import { ProjectRepositories } from "./ProjectRepositories";
import { cn, formatDate } from "../lib/utils";
import { environmentsApi } from "../api/environments";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { secretsApi } from "../api/secrets";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Loader2, Trash2 } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
import { Badge } from "@/components/ui/badge";
interface ProjectPropertiesProps {
project: Project;
repositories?: ReactNode;
onUpdate?: (data: Record<string, unknown>) => void;
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
onArchive?: (archived: boolean) => void;
archivePending?: boolean;
}
export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error";
export type ProjectConfigFieldKey =
| "name"
| "description"
| "status"
| "goals"
| "env"
| "execution_workspace_enabled"
| "execution_workspace_default_mode"
| "execution_workspace_shared_concurrency"
| "execution_workspace_environment"
| "execution_workspace_base_ref"
| "execution_workspace_branch_template"
| "execution_workspace_worktree_parent_dir"
| "execution_workspace_provision_command"
| "execution_workspace_runtime_provision_command"
| "execution_workspace_teardown_command";
const SHARED_WORKSPACE_CONCURRENCY_OPTIONS: {
value: SharedWorkspaceConcurrency;
label: string;
help: string;
}[] = [
{
value: "auto",
label: "Auto",
help: "Concurrent runs on local/SSH runners; runs take turns in cloud environments.",
},
{
value: "serialize",
label: "Serialize",
help: "Sandbox runs take turns in the shared project workspace. Local/SSH folders allow concurrent runs.",
},
{
value: "allow",
label: "Allow",
help: "Runs never wait for the workspace; concurrent edits are possible.",
},
];
function SaveIndicator({ state }: { state: ProjectFieldSaveState }) {
if (state === "saving") {
return (
<span className="inline-flex items-center gap-1 text-(length:--text-micro) text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Saving
</span>
);
}
if (state === "saved") {
return (
<span className="inline-flex items-center gap-1 text-(length:--text-micro) text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
Saved
</span>
);
}
if (state === "error") {
return (
<span className="inline-flex items-center gap-1 text-(length:--text-micro) text-destructive">
<AlertCircle className="h-3 w-3" />
Failed
</span>
);
}
return null;
}
function FieldLabel({
label,
state,
}: {
label: string;
state: ProjectFieldSaveState;
}) {
// The label column is a fixed 80px wide, so the indicator stacks below the
// label instead of sitting beside it; otherwise "Description" + "Saving"
// overflows into the value column and overlaps the field content.
return (
<div className="flex flex-col items-start gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<SaveIndicator state={state} />
</div>
);
}
function PropertyRow({
label,
children,
alignStart = false,
valueClassName = "",
}: {
label: React.ReactNode;
children: React.ReactNode;
alignStart?: boolean;
valueClassName?: string;
}) {
return (
<div className={cn("flex gap-3 py-1.5 items-start")}>
<div className="shrink-0 w-20 mt-0.5">{label}</div>
<div className={cn("min-w-0 flex-1", alignStart ? "pt-0.5" : "flex items-center gap-1.5 flex-wrap", valueClassName)}>
{children}
</div>
</div>
);
}
function ArchiveDangerZone({
project,
onArchive,
archivePending,
}: {
project: Project;
onArchive: (archived: boolean) => void;
archivePending?: boolean;
}) {
const [confirming, setConfirming] = useState(false);
const isArchive = !project.archivedAt;
const action = isArchive ? "Archive" : "Unarchive";
return (
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
<p className="text-sm text-muted-foreground">
{isArchive
? "Archive this project to hide it from the sidebar and project selectors."
: "Unarchive this project to restore it in the sidebar and project selectors."}
</p>
{archivePending ? (
<Button size="sm" variant="destructive" disabled>
<Loader2 className="h-3 w-3 animate-spin mr-1" />
{isArchive ? "Archiving..." : "Unarchiving..."}
</Button>
) : confirming ? (
<div className="flex items-center gap-2">
<span className="text-sm text-destructive font-medium">
{action} &ldquo;{project.name}&rdquo;?
</span>
<Button
size="sm"
variant="destructive"
onClick={() => {
setConfirming(false);
onArchive(isArchive);
}}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirming(false)}
>
Cancel
</Button>
</div>
) : (
<Button
size="sm"
variant="destructive"
onClick={() => setConfirming(true)}
>
{isArchive ? (
<><Archive className="h-3 w-3 mr-1" />{action} project</>
) : (
<><ArchiveRestore className="h-3 w-3 mr-1" />{action} project</>
)}
</Button>
)}
</div>
);
}
export function ProjectProperties({ project, repositories, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<"local" | null>(null);
const [workspaceCwd, setWorkspaceCwd] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const commitField = (field: ProjectConfigFieldKey, data: Record<string, unknown>) => {
if (onFieldUpdate) {
onFieldUpdate(field, data);
return;
}
onUpdate?.(data);
};
const fieldState = (field: ProjectConfigFieldKey): ProjectFieldSaveState => getFieldSaveState?.(field) ?? "idle";
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
const { data: availableSecrets = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const { data: userSecretDefinitions = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.userDefinitions(selectedCompanyId)
: ["user-secret-definitions", "none"],
queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
retry: false,
});
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select an organization to create secrets");
return secretsApi.create(selectedCompanyId, input);
},
onSuccess: () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
const { data: environments } = useQuery({
queryKey: queryKeys.environments.list(selectedCompanyId!),
queryFn: () => environmentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && environmentsEnabled,
});
const workspaces = project.workspaces ?? [];
const codebase = project.codebase;
const primaryCodebaseWorkspace = project.primaryWorkspace ?? null;
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id && !workspace.metadata?.githubRepositoryId);
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
const executionWorkspaceDefaultMode =
executionWorkspacePolicy?.defaultMode === "isolated_workspace" ? "isolated_workspace" : "shared_workspace";
// Absent/unset round-trips as "auto" — we only write a value once the user picks one.
const executionWorkspaceSharedConcurrency: SharedWorkspaceConcurrency =
executionWorkspacePolicy?.sharedWorkspaceConcurrency ?? "auto";
const executionWorkspaceEnvironmentId = executionWorkspacePolicy?.environmentId ?? "";
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
type: "git_worktree",
baseRef: "",
branchTemplate: "",
worktreeParentDir: "",
};
// Defense in depth alongside the server's managed-sandbox-only read
// filter: a cached environments list may still carry the local row.
const managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
// The gate for the host-path surfaces below. It fails closed whenever the
// policy is unknown — in flight and also on a failed read: an unresolved
// policy reads as "not managed", which would show the local folder the policy
// exists to hide.
const hideHostPaths = experimentalSettings === undefined || managedSandboxOnly;
const runSelectableEnvironments = filterManagedSandboxSelectableEnvironments(
environments ?? [],
managedSandboxOnly,
).filter((environment) => {
if (environment.driver === "local" || environment.driver === "ssh") return true;
if (environment.driver !== "sandbox") return false;
const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null;
return provider !== null && provider !== "fake";
});
const showExecutionWorkspaceEnvironmentControl = environmentsEnabled && runSelectableEnvironments.length > 1;
const invalidateProject = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
if (project.urlKey !== project.id) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) });
}
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(selectedCompanyId) });
}
};
const createWorkspace = useMutation({
mutationFn: (data: Record<string, unknown>) => projectsApi.createWorkspace(project.id, data),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
},
});
const removeWorkspace = useMutation({
mutationFn: (workspaceId: string) => projectsApi.removeWorkspace(project.id, workspaceId),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
},
});
const updateWorkspace = useMutation({
mutationFn: ({ workspaceId, data }: { workspaceId: string; data: Record<string, unknown> }) =>
projectsApi.updateWorkspace(project.id, workspaceId, data),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
},
});
const updateExecutionWorkspacePolicy = (patch: Record<string, unknown>) => {
if (!onUpdate && !onFieldUpdate) return;
return {
executionWorkspacePolicy: {
enabled: executionWorkspacesEnabled,
defaultMode: executionWorkspaceDefaultMode,
allowIssueOverride: executionWorkspacePolicy?.allowIssueOverride ?? true,
...executionWorkspacePolicy,
...patch,
},
};
};
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const 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;
}
};
const deriveSourceType = (cwd: string | null, repoUrl: string | null) => {
if (repoUrl) return "git_repo";
if (cwd) return "local_path";
return undefined;
};
const persistCodebase = (patch: { cwd?: string | null; repoUrl?: string | null }) => {
const nextCwd = patch.cwd !== undefined ? patch.cwd : codebase.localFolder;
const nextRepoUrl = patch.repoUrl !== undefined ? patch.repoUrl : codebase.repoUrl;
if (!nextCwd && !nextRepoUrl) {
if (primaryCodebaseWorkspace) {
removeWorkspace.mutate(primaryCodebaseWorkspace.id);
}
return;
}
const data: Record<string, unknown> = {
...(patch.cwd !== undefined ? { cwd: patch.cwd } : {}),
...(patch.repoUrl !== undefined ? { repoUrl: patch.repoUrl } : {}),
...(deriveSourceType(nextCwd, nextRepoUrl) ? { sourceType: deriveSourceType(nextCwd, nextRepoUrl) } : {}),
isPrimary: true,
};
if (primaryCodebaseWorkspace) {
updateWorkspace.mutate({ workspaceId: primaryCodebaseWorkspace.id, data });
return;
}
createWorkspace.mutate(data);
};
const submitLocalWorkspace = () => {
const cwd = workspaceCwd.trim();
if (!cwd) {
setWorkspaceError(null);
persistCodebase({ cwd: null });
return;
}
if (!isAbsolutePath(cwd)) {
setWorkspaceError("Local folder must be a full absolute path.");
return;
}
setWorkspaceError(null);
persistCodebase({ cwd });
};
const clearLocalWorkspace = () => {
const confirmed = window.confirm(
codebase.repoUrl
? "Clear local folder from this workspace?"
: "Delete this workspace local folder?",
);
if (!confirmed) return;
persistCodebase({ cwd: null });
};
return (
<div>
<div className="space-y-1 pb-4">
<PropertyRow label={<FieldLabel label="Name" state={fieldState("name")} />}>
{onUpdate || onFieldUpdate ? (
<DraftInput
value={project.name}
onCommit={(name) => commitField("name", { name })}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-sm outline-none"
placeholder="Project name"
/>
) : (
<span className="text-sm">{project.name}</span>
)}
</PropertyRow>
<PropertyRow
label={<FieldLabel label="Description" state={fieldState("description")} />}
alignStart
valueClassName="space-y-0.5"
>
{onUpdate || onFieldUpdate ? (
<InlineEditor
value={project.description ?? ""}
onSave={(description) => commitField("description", { description })}
nullable
as="p"
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
/>
) : (
<p className="text-sm text-muted-foreground">
{project.description?.trim() || "No description"}
</p>
)}
</PropertyRow>
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
<PropertyRow
label={<FieldLabel label="Env" state={fieldState("env")} />}
alignStart
valueClassName="space-y-2"
>
<div className="space-y-2">
<EnvironmentVariablesEditor
footerHint={null}
value={project.env ?? {}}
secrets={availableSecrets}
userSecretDefinitions={userSecretDefinitions}
onCreateSecret={async (name, value) => {
const created = await createSecret.mutateAsync({ name, value });
return created;
}}
onChange={(env) => commitField("env", { env: env ?? null })}
/>
</div>
</PropertyRow>
<PropertyRow label={<FieldLabel label="Updated" state="idle" />}>
<span className="text-sm">{formatDate(project.updatedAt)}</span>
</PropertyRow>
{project.targetDate && (
<PropertyRow label={<FieldLabel label="Target Date" state="idle" />}>
<span className="text-sm">{formatDate(project.targetDate)}</span>
</PropertyRow>
)}
</div>
<Separator className="my-4" />
<div className="space-y-1 py-4">
{(!hideHostPaths || (primaryCodebaseWorkspace?.runtimeServices?.length ?? 0) > 0) && <div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Codebase</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-(length:--text-nano) text-muted-foreground hover:text-foreground"
aria-label="Codebase help"
>
?
</button>
</TooltipTrigger>
<TooltipContent side="top">
{hideHostPaths
? "Repo identifies the source of truth. Agents check it out in the platform-managed environment."
: "Repo identifies the source of truth. Local folder is the default place agents write code."}
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-2 rounded-md border border-border/70 p-3">
{/*
The local folder is an absolute path on the execution host. Under
the managed-sandbox-only policy every agent runs in the
platform-managed environment, so the path, the folder controls,
and the edit panel below all disappear. A managed checkout keeps
its one-line label so the codebase still reads as accounted for,
but never renders the path itself.
*/}
{hideHostPaths ? (
codebase.origin === "managed_checkout" ? (
<div className="text-(length:--text-micro) text-muted-foreground">Paperclip-managed folder.</div>
) : null
) : (
<div className="space-y-1">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Local folder</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="min-w-0 break-all font-mono text-xs text-muted-foreground">
{codebase.effectiveLocalFolder}
</div>
{codebase.origin === "managed_checkout" && (
<div className="text-(length:--text-micro) text-muted-foreground">Paperclip-managed folder.</div>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode("local");
setWorkspaceCwd(codebase.localFolder ?? "");
setWorkspaceError(null);
}}
>
{codebase.localFolder ? "Change local folder" : "Set local folder"}
</Button>
{codebase.localFolder ? (
<Button
variant="ghost"
size="icon-xs"
onClick={clearLocalWorkspace}
aria-label="Clear local folder"
>
<Trash2 className="h-3 w-3" />
</Button>
) : null}
</div>
</div>
</div>
)}
{hasAdditionalLegacyWorkspaces && (
<div className="text-(length:--text-micro) text-muted-foreground">
Additional legacy workspace records exist on this project. Paperclip is using the primary workspace as the codebase view.
</div>
)}
{primaryCodebaseWorkspace?.runtimeServices && primaryCodebaseWorkspace.runtimeServices.length > 0 ? (
<div className="space-y-1">
{primaryCodebaseWorkspace.runtimeServices.map((service) => (
<div
key={service.id}
className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2 py-1"
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-(length:--text-micro) font-medium">{service.serviceName}</span>
<Badge variant="ghost"
className={cn(
"px-1.5 text-(length:--text-nano) uppercase tracking-wide",
service.status === "running"
? "bg-green-500/15 text-green-700 dark:text-green-300"
: service.status === "failed"
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-muted text-muted-foreground",
)}
>
{service.status}
</Badge>
</div>
<div className="text-(length:--text-micro) text-muted-foreground">
{service.url ? (
<a
href={service.url}
target="_blank"
rel="noreferrer"
className="hover:text-foreground hover:underline"
>
{service.url}
</a>
) : (
service.command ?? "No URL"
)}
</div>
{service.exposure && service.exposure.state !== "removed" ? (
<div
className={cn(
"text-(length:--text-nano)",
service.exposure.state === "failed" || service.exposure.state === "cleanup_pending"
? "text-destructive"
: "text-muted-foreground",
)}
>
HTTPS {service.exposure.state.replace("_", " ")}
</div>
) : null}
</div>
<div className="text-(length:--text-nano) text-muted-foreground whitespace-nowrap">
{service.lifecycle}
</div>
</div>
))}
</div>
) : null}
</div>
{!hideHostPaths && workspaceMode === "local" && (
<div className="space-y-1.5 rounded-md border border-border p-2">
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceCwd}
onChange={(e) => setWorkspaceCwd(e.target.value)}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
disabled={(!workspaceCwd.trim() && !primaryCodebaseWorkspace) || createWorkspace.isPending || updateWorkspace.isPending}
onClick={submitLocalWorkspace}
>
Save
</Button>
<Button
variant="ghost"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode(null);
setWorkspaceCwd("");
setWorkspaceError(null);
}}
>
Cancel
</Button>
</div>
</div>
)}
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}
{createWorkspace.isError && (
<p className="text-xs text-destructive">Failed to save workspace.</p>
)}
{removeWorkspace.isError && (
<p className="text-xs text-destructive">Failed to delete workspace.</p>
)}
{updateWorkspace.isError && (
<p className="text-xs text-destructive">Failed to update workspace.</p>
)}
</div>}
{isolatedWorkspacesEnabled && workspaceIsolationControlsVisible ? (
<>
<Separator className="my-4" />
<div className="py-1.5 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Execution Workspaces</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-(length:--text-nano) text-muted-foreground hover:text-foreground"
aria-label="Execution workspaces help"
>
?
</button>
</TooltipTrigger>
<TooltipContent side="top">
Project-owned defaults for isolated task checkouts and execution workspace behavior.
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<div className="flex items-center gap-2 text-sm font-medium">
<span>Enable isolated task checkouts</span>
<SaveIndicator state={fieldState("execution_workspace_enabled")} />
</div>
<div className="text-xs text-muted-foreground">
Let tasks choose between the project's primary checkout and an isolated execution workspace.
</div>
</div>
{onUpdate || onFieldUpdate ? (
<ToggleSwitch
checked={executionWorkspacesEnabled}
onCheckedChange={() =>
commitField(
"execution_workspace_enabled",
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
)}
/>
) : (
<span className="text-xs text-muted-foreground">
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
</span>
)}
</div>
{executionWorkspacesEnabled ? (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<div className="flex items-center gap-2 text-sm">
<span>New tasks default to isolated checkout</span>
<SaveIndicator state={fieldState("execution_workspace_default_mode")} />
</div>
<div className="text-(length:--text-micro) text-muted-foreground">
If disabled, new tasks stay on the project's primary checkout unless someone opts in.
</div>
</div>
<ToggleSwitch
checked={executionWorkspaceDefaultMode === "isolated_workspace"}
onCheckedChange={() =>
commitField(
"execution_workspace_default_mode",
updateExecutionWorkspacePolicy({
defaultMode:
executionWorkspaceDefaultMode === "isolated_workspace"
? "shared_workspace"
: "isolated_workspace",
})!,
)}
/>
</div>
<div className="space-y-0.5">
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-sm">
<span>Shared workspace concurrency</span>
<SaveIndicator state={fieldState("execution_workspace_shared_concurrency")} />
</label>
</div>
{onUpdate || onFieldUpdate ? (
<select
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
aria-label="Shared workspace concurrency"
value={executionWorkspaceSharedConcurrency}
onChange={(e) =>
commitField(
"execution_workspace_shared_concurrency",
updateExecutionWorkspacePolicy({
sharedWorkspaceConcurrency: e.target.value as SharedWorkspaceConcurrency,
})!,
)}
>
{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
<div className="text-xs">
{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.find(
(option) => option.value === executionWorkspaceSharedConcurrency,
)?.label}
</div>
)}
<p className="text-(length:--text-micro) text-muted-foreground">
{SHARED_WORKSPACE_CONCURRENCY_OPTIONS.find(
(option) => option.value === executionWorkspaceSharedConcurrency,
)?.help}
</p>
</div>
<div className="border-t border-border/60 pt-2">
<button
type="button"
className="flex w-full items-center gap-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setExecutionWorkspaceAdvancedOpen((open) => !open)}
>
{executionWorkspaceAdvancedOpen
? "Hide advanced checkout settings"
: "Show advanced checkout settings"}
</button>
</div>
{executionWorkspaceAdvancedOpen ? (
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
Host-managed implementation: <span className="text-foreground">Git worktree</span>
</div>
{showExecutionWorkspaceEnvironmentControl ? (
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Environment</span>
<SaveIndicator state={fieldState("execution_workspace_environment")} />
</label>
</div>
<select
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={executionWorkspaceEnvironmentId}
onChange={(e) =>
commitField(
"execution_workspace_environment",
updateExecutionWorkspacePolicy({
environmentId: e.target.value || null,
})!,
)}
>
<option value="">No environment</option>
{runSelectableEnvironments.map((environment) => (
<option key={environment.id} value={environment.id}>
{environmentDisplayLabel(environment)}
</option>
))}
</select>
</div>
) : null}
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Base ref</span>
<SaveIndicator state={fieldState("execution_workspace_base_ref")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.baseRef ?? ""}
onCommit={(value) =>
commitField("execution_workspace_base_ref", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
baseRef: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="origin/main"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Branch template</span>
<SaveIndicator state={fieldState("execution_workspace_branch_template")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.branchTemplate ?? ""}
onCommit={(value) =>
commitField("execution_workspace_branch_template", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
branchTemplate: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="{{issue.identifier}}-{{slug}}"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Worktree parent dir</span>
<SaveIndicator state={fieldState("execution_workspace_worktree_parent_dir")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.worktreeParentDir ?? ""}
onCommit={(value) =>
commitField("execution_workspace_worktree_parent_dir", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
worktreeParentDir: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder=".paperclip/worktrees"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Provision command</span>
<SaveIndicator state={fieldState("execution_workspace_provision_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.provisionCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_provision_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
provisionCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/provision-worktree.sh"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Runtime provision command</span>
<SaveIndicator state={fieldState("execution_workspace_runtime_provision_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.runtimeProvisionCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_runtime_provision_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
runtimeProvisionCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/provision-worktree-runtime.sh"
/>
<p className="mt-1 text-xs text-muted-foreground">
Runs once before the first runtime-service start (heavy setup, e.g. DB seed). Leave empty to keep eager provisioning.
</p>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Teardown command</span>
<SaveIndicator state={fieldState("execution_workspace_teardown_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.teardownCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_teardown_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
teardownCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/teardown-worktree.sh"
/>
</div>
<p className="text-(length:--text-micro) text-muted-foreground">
Provision runs inside the derived worktree before agent execution. Teardown is stored here for
future cleanup flows.
</p>
</div>
) : null}
</div>
) : null}
</div>
</div>
</>
) : null}
</div>
{onArchive && (
<>
<Separator className="my-4" />
<div className="space-y-4 py-4">
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
Danger Zone
</div>
<ArchiveDangerZone
project={project}
onArchive={onArchive}
archivePending={archivePending}
/>
</div>
</>
)}
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
</div>
);
}