Files
PaperClipAI/ui/src/components/ProjectProperties.tsx
T
Nicky LeachandClaude Opus 5.5 be20d4ad97 fix(projects): keep projects on the storage chosen at creation (#19036)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A project stores its work in Core/local storage or in a storage
plugin
> - Today, Core finds the installed default each time it reads a project
> - So installing a default storage plugin moves existing projects,
without a saved change
> - Agents that cannot reach the new storage then lose access to new
workspaces
> - This pull request makes Core apply the default once, when it creates
a project
> - The benefit is that a project stays on its storage until a person
moves it

## Linked Issues or Issue Description

No issue exists. This describes the problem. I searched open pull
requests for related work and found no duplicate.

**What existing behavior does this improve?**

How Core chooses the storage for a project when a plugin claims
`defaultForProjects`.

**Current behavior**

Installing such a plugin changes the storage of every existing project
with no explicit choice. Disabling the plugin changes it back. Nothing
is saved, so the project has no fixed owner. Execution environments and
storage are separate contracts, so an agent can lose access to new
workspaces on a project that it used before.

**Proposed behavior**

Core saves the storage choice on the project when it creates the
project. A project with no saved choice uses Core/local storage. A
plugin-backed project stays attached to its plugin. A Core/local project
can move to a plugin.

**Reason and benefit**

A project stays on its storage until a person moves it, so installing or
disabling a plugin does not change where work goes.

**Alternatives considered**

- Keep the default at read time and add a process that saves a choice on
existing projects when a plugin arrives. This adds a background step and
a race with new projects.
- Add a new database column. The existing policy field already holds the
choice, so a migration is not necessary.

## What Changed

- `resolveProjectWorkspaceProvider` reads only the saved choice. It no
longer applies an installed default.
- `withDefaultProjectStorage` applies the default when Core creates a
project, including a personal private project. With more than one
default, a project with no explicit choice is refused. This is the
existing conflict rule, now applied at creation.
- `projectService.update` refuses a change of storage on a plugin-backed
project. An update that omits the field keeps the attachment.
- Plugin lifecycle delivery sends project events to a storage provider
only for projects attached to it, and for deleted projects. Core
acknowledges the other events for that provider. Other plugins continue
to receive all project events.
- The project settings control has no "Inherit" option. It is locked on
a plugin-backed project.
- `ProjectWorkspaceProviderConfiguration.source` no longer has the
`"default"` value.
- A project with no repository or folder ignores its saved storage
choice when a task runs. A storage plugin cannot manage this type of
project, so its tasks use a plain task directory. Without this change,
the second run of a task reset the agent session, and remote agents
could not use an empty task directory.
- The plugin SDK README and the plugin authoring guide describe the new
rules.

## Verification

- `npx vitest run
server/src/__tests__/project-workspace-provider.integration.test.ts
server/src/__tests__/plugin-lifecycle-delivery.test.ts
server/src/__tests__/plugin-workspaces.integration.test.ts
packages/plugins/sdk/tests/workspace-provider.test.ts`: 93 tests pass.
- `npx vitest run src/components/ProjectProperties.concurrency.test.tsx`
in `ui/`: 9 tests pass.
- `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter
@paperclipai/ui typecheck` pass.
- `npx vitest run` on `heartbeat-session-preparation`,
`workspace-preparation`, `remote-workspace-source`,
`project-workspace-provider.integration`, and
`plugin-workspaces.integration` in `server/`: 231 tests pass.
- I did not run the full test suite locally. CI runs it.
- I did not test this change on a running instance.

## Risks

- **Behavior change at upgrade:** an existing project that uses a plugin
only through the installed default, with no saved choice, moves to
Core/local storage after upgrade. Its plugin files stay in place, but
new work does not use them until an operator sets
`storageProviderPluginId` on the project. This pull request has no
automatic backfill.
- **Missed lifecycle events:** a provider does not receive events for a
Core/local project. If the project moves to that provider later, the
update event starts its reconcile.
- **One-way move:** a person cannot move a plugin-backed project back
through the API or the UI. This is intended. If a plugin is disabled,
the project fails closed, as it does today for an explicit choice.
- This pull request does not stop a person from assigning an agent to a
project with storage that the agent cannot reach. That is follow-up
work.
- This changes core plugin behavior, and I did not discuss it in `#dev`
first. Maintainers can tell me if they want a discussion before review.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), through Claude Code, with tool
use and code execution.

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:07:29 -08:00

1114 lines
50 KiB
TypeScript

import { authApi } from "@/api/auth";
import { accessApi } from "@/api/access";
import { canManageProjectPrivacy } from "@/lib/issuePrivacy";
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 { ProjectGitAuthorization } from "./ProjectGitAuthorization";
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";
import { ProjectAccessMembers } from "./ProjectAccessMembers";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "./ui/alert-dialog";
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"
| "visibility"
| "goals"
| "env"
| "workspace_storage_provider"
| "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 { data: privacySession } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() });
const { data: privacyAccess } = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess() });
const canManagePrivacy = canManageProjectPrivacy(project, privacySession?.user?.id ?? privacySession?.session?.userId ?? null, privacyAccess);
const queryClient = useQueryClient();
const [openProjectConfirmation, setOpenProjectConfirmation] = useState(false);
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 storedExecutionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
const providerConfiguration = project.workspaceProvider;
const executionWorkspacePolicy = providerConfiguration?.effectivePolicy ?? storedExecutionWorkspacePolicy;
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,
...storedExecutionWorkspacePolicy,
...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>
<AlertDialog open={openProjectConfirmation} onOpenChange={setOpenProjectConfirmation}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Open this project to the company?</AlertDialogTitle>
<AlertDialogDescription>
Everyone in the company will be able to discover this project. Its private tasks stay private.
Members who only have access through this project will lose access to those tasks.
Share tasks directly first if you want them to keep access.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep private</AlertDialogCancel>
<AlertDialogAction onClick={() => commitField("visibility", { visibility: "open" })}>
Open to company
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<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>
<PropertyRow label={<FieldLabel label="Visibility" state={fieldState("visibility")} />} alignStart>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<ToggleSwitch
aria-label="Private project"
checked={project.visibility === "private"}
onCheckedChange={(checked) => {
if (!checked) {
setOpenProjectConfirmation(true);
return;
}
commitField("visibility", { visibility: "private" });
}}
disabled={!canManagePrivacy || (!onUpdate && !onFieldUpdate)}
/>
<span>{project.visibility === "private" ? "Private" : "Open to company"}</span>
</label>
<p className="text-(length:--text-micro) text-muted-foreground">
{project.visibility === "private"
? "Only access members can discover this project. Tasks shared directly remain readable on their own."
: "Everyone in the company can discover this project. Individually private tasks keep their own access rules."}
</p>
{project.visibility === "private" ? <ProjectAccessMembers project={project} canManage={canManagePrivacy} /> : null}
</div>
</PropertyRow>
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
<ProjectGitAuthorization key={`git-${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>}
{providerConfiguration ? (
<div className="py-1.5 space-y-2">
<label className="flex items-center gap-2 text-sm font-medium" htmlFor="workspace-storage-provider">
Workspace storage
<SaveIndicator state={fieldState("workspace_storage_provider")} />
</label>
<select
id="workspace-storage-provider"
aria-label="Workspace storage provider"
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
disabled={Boolean(storedExecutionWorkspacePolicy?.storageProviderPluginId) || (!onUpdate && !onFieldUpdate)}
value={storedExecutionWorkspacePolicy?.storageProviderPluginId ?? "core"}
onChange={(event) => {
const policy = { enabled: true, ...storedExecutionWorkspacePolicy };
policy.storageProviderPluginId = event.target.value === "core" ? null : event.target.value;
commitField("workspace_storage_provider", { executionWorkspacePolicy: policy });
}}
>
<option value="core">Core / local</option>
{providerConfiguration.providers.map(provider => (
<option key={provider.id} value={provider.id}>{provider.displayName}</option>
))}
{storedExecutionWorkspacePolicy?.storageProviderPluginId && !providerConfiguration.providers.some(provider => provider.id === storedExecutionWorkspacePolicy.storageProviderPluginId) ? (
<option value={storedExecutionWorkspacePolicy.storageProviderPluginId}>Unavailable provider</option>
) : null}
</select>
<p className="text-xs text-muted-foreground">
{storedExecutionWorkspacePolicy?.storageProviderPluginId
? `This project stays on ${providerConfiguration.displayName}.`
: "A project moved to a storage plugin stays on that plugin."}
</p>
{providerConfiguration.error ? <p role="alert" className="text-xs text-destructive">{providerConfiguration.error}</p> : null}
</div>
) : null}
{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>
);
}