mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 23:36:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - An instance can turn on the `enableManagedSandboxOnly` feature, which hides the local environment and runs every agent in the platform-managed environment > - That feature already gated the environment pickers, the onboarding wizard, and the server-side run selection, but many other screens still showed absolute paths on the execution host and still let the user pick an execution engine > - On such an instance those controls name a filesystem the user cannot reach; a path written there is stored and then ignored, which reads as a broken control > - This pull request hides the remaining host-path and execution-engine surfaces behind the same feature, adds a server rule that refuses a project-workspace path write while the feature is on, and closes a related route gap in the isolated-workspace pages > - The benefit is that a managed instance shows no host path and no folder picker anywhere, and a write that carries a path now fails with a clear message instead of being silently discarded ## Linked Issues or Issue Description No public issue exists. The description below follows `.github/ISSUE_TEMPLATE/enhancement.yml`. **What existing behavior does this improve?** The `enableManagedSandboxOnly` instance feature, and the UI surfaces that show a host filesystem path: project properties, the new-project dialog, the project workspace and execution workspace detail pages, the workspace and task cards, plugin local folders, and the agent configuration form with its per-adapter fields. It also improves route gating for `enableIsolatedWorkspaces`. **Subsystem affected** Cross-cutting (`ui/` and `server/`). **Current behavior** When `enableManagedSandboxOnly` is on, the local environment disappears from the environment pickers and the server refuses to run an agent on the local host. Everything else stays visible. A user still sees: - the project "Local folder" row, its absolute path, and the Set/Change/Clear buttons - the "Local folder" field and its "Choose" folder picker in the new-project dialog - the "Local path" field and fact row on a project workspace - the "Paths" and "Lifecycle commands" groups on an execution workspace - the working directory on workspace cards, task properties, and runtime service rows - the plugin "Local folders" section - "Working directory (deprecated)", "Command", "Execution engine", "ACP server command", "ACP state directory", and "Agent instructions file" in the agent configuration form A path typed into any of these names a filesystem no agent on the instance uses. The project workspace API also accepts a `cwd` write and stores it. Separately, `/workspaces`, `/execution-workspaces/*`, and `/projects/:projectId/workspaces/:workspaceId` render for anyone who types or bookmarks the URL, even with `enableIsolatedWorkspaces` off. Only the sidebar entry reads that flag. **Proposed behavior** With `enableManagedSandboxOnly` on, none of those surfaces render. A project whose codebase came from a managed checkout keeps its one-line "Paperclip-managed folder." label and shows no path. The non-path controls stay: repo URL, branch, service URL, port, command output, ACP session mode, ACP non-interactive permissions, Codex fast mode, and the sandbox toggles. The project-workspace create and patch routes, and the nested workspace on project create, answer `422` with "This instance runs agents only in the platform-managed environment; local folders are not configurable." when the payload carries a non-null `cwd`. A `cwd: null` write still passes, so an instance that just turned the feature on can clear a stale path. With `enableIsolatedWorkspaces` off, the three workspace route groups redirect to the dashboard. **Reason and benefit** A control that cannot do anything is worse than a missing control: the user fills it in, saves, and gets no error and no effect. The server rule turns that silent no-op into a clear refusal. The route gate stops a feature that an instance has turned off from staying reachable by URL, which is the same standard the Cases, Pipelines, and hidden-settings pages already meet. **Breaking changes** None for a default instance: both flags are off by default for self-hosted and managed instances, so nothing changes unless an operator turns them on. Stored `adapterConfig` values are never cleared, so turning the feature off restores every previous value. ## What Changed - Add `ui/src/hooks/useManagedSandboxOnly.ts`, modelled on `useAppsEnabled`, for components that do not already read the experimental settings. It exposes `hideHostPaths`, which fails closed while the settings query is in flight, so a cold cache never flashes a host path before the policy resolves. Components that keep their own settings read compute the same gate from `isFetched`. - Add `managedSandboxOnly` to `AdapterConfigFieldsProps` and populate it where `AgentConfigForm` builds the adapter field props. Resolve the effective instructions-file gate once as `hideInstructionsFile || hideHostPaths`, so every adapter hides that path field with no per-adapter edit. - Hide under the flag: the project "Local folder" block and its absolute-path edit panel (a managed checkout keeps its label, without the path); the new-project "Local folder" field; the project-workspace "Local path" field and fact row; the execution-workspace "Paths" and "Lifecycle commands" groups; the working directory on the workspace summary card, the task workspace card, the task properties "Folder" row, and the runtime service rows; the plugin "Local folders" section; "Working directory (deprecated)" and "Command" in the agent form; and the per-adapter "Execution engine", "ACP server command", and "ACP state directory" for `claude_local`, `codex_local`, and `gemini_local`. - Drop two working-directory fallbacks that had no gate to read: the close-workspace dialog now falls back to "No additional details", and the reuse-existing workspace label and picker subtitle fall back to a neutral phrase. - Refuse a non-null `cwd` with `422` on `POST /projects/:id/workspaces`, `PATCH /projects/:id/workspaces/:workspaceId`, and the nested workspace on `POST /companies/:companyId/projects`, following the `assertNoAgentHostWorkspaceCommandMutation` precedent on those routes. - Add `IsolatedWorkspacesRouteGate` and wrap the `/workspaces`, `/execution-workspaces/*`, and `/projects/:projectId/workspaces/:workspaceId` routes with it. - Leave the SSH "Remote workspace path" and the workspace file browser alone, with a comment explaining why. ## Verification Automated: - `pnpm --filter @paperclipai/ui exec vitest run` — 479 of 480 files pass (4455 of 4456 tests). The one failure is `OnboardingWizard.test.tsx > renders instead of throwing when the browser denies storage access`, which also fails on `origin/master` and is unrelated to this change. - `pnpm --filter @paperclipai/server exec vitest run project workspace instance-settings` — 45 of 50 files pass. Four files fail on macOS for reasons unrelated to this change: `workspace-instance-cleanup`, `workspace-runtime`, `execution-workspace-runtime-control-conflict`, and `workspace-runtime-exposure` compare `/var/...` against the resolved `/private/var/...` or bind real ports. The same files fail on a clean `master` checkout on the same machine. - `pnpm --filter @paperclipai/ui typecheck` - `tsc --noEmit` in `server/` (after `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps`). The package `typecheck` script also builds the Rust runner, which needs `cargo`; it is not installed on the machine that ran this. New and extended tests: - `ui/src/adapters/managed-sandbox-only-config-fields.test.tsx` — the three adapters drop the execution engine, the ACP paths, the instructions-file path, and every "Choose" button when the flag is on, and keep the non-path controls. - `ui/src/components/AgentConfigForm.render.test.tsx` — flag-on and flag-off renders for the working directory, the command, the engine, the ACP paths, and the resolved adapter field props. - `ui/src/components/ProjectProperties.managed-sandbox.test.tsx`, `ui/src/components/NewProjectDialog.managed-sandbox.test.tsx`, `ui/src/pages/ProjectWorkspaceDetail.test.tsx`, `ui/src/components/ProjectWorkspaceSummaryCard.test.tsx`, `ui/src/components/WorkspaceRuntimeControls.test.tsx`. - `ui/src/components/IsolatedWorkspacesRouteGate.test.tsx` — redirect when off, render when on, and render nothing while the flag query is in flight. - "Still loading" cases for the project properties, the new-project dialog, the workspace summary card, the runtime service rows, and the agent configuration form, each asserting that no host path renders before the policy resolves. - `server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts` — the `422` on all three write paths, the `cwd: null` pass-through, and the flag-off pass-through. Manual check to reproduce: turn on Managed Environment Only in instance experimental settings, then open a project, the new-project dialog, an agent's configuration, and a workspace page. No path, folder icon, or "Choose" button appears. Turn the setting off and each control returns with its stored value. No documentation change was needed. The operator-facing text for both settings lives in the feature catalog entry, which already states the contract this pull request now enforces across the UI. ## Risks - Low. Both flags default to off, so a default instance is unchanged. - The hidden fields are presentation only. No stored `adapterConfig` value is cleared, because an import carries adapter configuration written on another instance and clearing it would break that flow. Turning the setting off shows every previous value again. - The `422` is the one behavior change for an API caller, and only while the setting is on. `cwd: null` still passes so a stale path can be cleared. - The route gate renders nothing until the flag query settles, so an instance with isolated workspaces on never flashes a redirect. An instance with the feature off now redirects a bookmarked workspace URL to the dashboard. - Every host-path guard fails closed while the settings query is in flight, so a default instance shows those controls a moment later than before on a cold load. That is the safe direction: the alternative flashes a path a managed instance must never show. - Two path surfaces stay on purpose, each with a comment: the SSH "Remote workspace path" is a path on the user's own remote host, and the workspace file browser shows workspace-relative paths. The instance Adapters page also keeps its "Local path" install option, since that page is an instance-admin surface the hosting operator can already hide through the hidden-settings mechanism. ## Model Used Claude (Anthropic), Claude Opus, 1M context window, extended thinking, agentic tool use through Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
764 lines
33 KiB
TypeScript
764 lines
33 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { Link, useLocation, 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 { Card } from "@/components/ui/card";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Tabs } from "@/components/ui/tabs";
|
||
import { ChoosePathButton } from "../components/PathInstructionsModal";
|
||
import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder";
|
||
import { projectsApi } from "../api/projects";
|
||
import { PageTabBar } from "../components/PageTabBar";
|
||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||
import {
|
||
buildWorkspaceRuntimeControlSections,
|
||
WorkspaceRuntimeControls,
|
||
type WorkspaceRuntimeControlRequest,
|
||
} from "../components/WorkspaceRuntimeControls";
|
||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||
import { useCompany } from "../context/CompanyContext";
|
||
import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly";
|
||
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"];
|
||
type ProjectWorkspaceBaseTab = "configuration";
|
||
type ProjectWorkspacePluginTab = `plugin:${string}`;
|
||
type ProjectWorkspaceTab = ProjectWorkspaceBaseTab | ProjectWorkspacePluginTab;
|
||
type OrderedProjectWorkspaceTabItem = {
|
||
value: ProjectWorkspaceTab;
|
||
label: string;
|
||
order: number;
|
||
};
|
||
|
||
const DEFAULT_PLUGIN_DETAIL_TAB_ORDER = 100;
|
||
const PROJECT_WORKSPACE_BASE_TAB_ITEMS: OrderedProjectWorkspaceTabItem[] = [
|
||
{ value: "configuration", label: "Configuration", order: 30 },
|
||
];
|
||
|
||
function isProjectWorkspacePluginTab(value: string | null): value is ProjectWorkspacePluginTab {
|
||
return typeof value === "string" && value.startsWith("plugin:");
|
||
}
|
||
|
||
function projectWorkspaceTabFromSearch(search: string): ProjectWorkspaceTab {
|
||
const tab = new URLSearchParams(search).get("tab");
|
||
if (isProjectWorkspacePluginTab(tab)) return tab;
|
||
return "configuration";
|
||
}
|
||
|
||
function orderProjectWorkspaceTabItems(items: OrderedProjectWorkspaceTabItem[]) {
|
||
return items
|
||
.map((item, index) => ({ item, index }))
|
||
.sort((left, right) => left.item.order - right.item.order || left.index - right.index)
|
||
.map(({ item }) => item);
|
||
}
|
||
|
||
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-(--tracking-eyebrow) text-muted-foreground">{label}</span>
|
||
{hint ? <span className="text-(length:--text-micro) 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 location = useLocation();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const { hideHostPaths } = useManagedSandboxOnly();
|
||
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 activeTab = useMemo(() => projectWorkspaceTabFromSearch(location.search), [location.search]);
|
||
|
||
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));
|
||
const {
|
||
slots: pluginDetailSlots,
|
||
isLoading: pluginDetailSlotsLoading,
|
||
errorMessage: pluginDetailSlotsError,
|
||
} = usePluginSlots({
|
||
slotTypes: ["detailTab"],
|
||
entityType: "project_workspace",
|
||
companyId: project?.companyId ?? null,
|
||
enabled: Boolean(project?.companyId),
|
||
});
|
||
const pluginTabItems = useMemo(
|
||
() => pluginDetailSlots.map((slot) => ({
|
||
value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectWorkspacePluginTab,
|
||
label: slot.displayName,
|
||
order: slot.order ?? DEFAULT_PLUGIN_DETAIL_TAB_ORDER,
|
||
slot,
|
||
})),
|
||
[pluginDetailSlots],
|
||
);
|
||
const tabItems = useMemo(
|
||
() => orderProjectWorkspaceTabItems([...PROJECT_WORKSPACE_BASE_TAB_ITEMS, ...pluginTabItems]),
|
||
[pluginTabItems],
|
||
);
|
||
|
||
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)}${location.search}`, { replace: true });
|
||
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, location.search, 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.all(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. Task execution is not paused."
|
||
: request.action === "restart"
|
||
? "Workspace service restarted. Task execution is not paused."
|
||
: "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;
|
||
const handleTabChange = (tab: ProjectWorkspaceTab) => {
|
||
const workspacePath = projectWorkspaceUrl(project, routeWorkspaceId);
|
||
if (isProjectWorkspacePluginTab(tab)) {
|
||
navigate(`${workspacePath}?tab=${encodeURIComponent(tab)}`);
|
||
return;
|
||
}
|
||
navigate(workspacePath);
|
||
};
|
||
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? 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>
|
||
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0 space-y-2">
|
||
<div className="text-xs font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
|
||
Project workspace
|
||
</div>
|
||
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
|
||
</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 project’s primary codebase workspace.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Tabs value={activeTab} onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}>
|
||
<PageTabBar
|
||
items={tabItems.map((item) => ({ value: item.value, label: item.label }))}
|
||
align="start"
|
||
value={activeTab}
|
||
onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}
|
||
/>
|
||
</Tabs>
|
||
|
||
{activeTab === "configuration" ? (
|
||
<div className="grid gap-6 lg:grid-cols-(--gtc-53)">
|
||
<div className="space-y-6">
|
||
<Card className="block p-5">
|
||
<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>
|
||
|
||
<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>
|
||
|
||
{/*
|
||
The local path is an absolute path on the execution host. Under
|
||
the managed-sandbox-only policy every agent runs in the
|
||
platform-managed environment, so neither the field nor the
|
||
folder picker renders; the server refuses a cwd write anyway.
|
||
*/}
|
||
{!hideHostPaths && (
|
||
<div className="grid gap-4 md:grid-cols-(--gtc-13)">
|
||
<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-background 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>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
<Card className="block p-5">
|
||
<div className="space-y-1">
|
||
<div className="text-xs font-medium uppercase tracking-(--tracking-eyebrow) 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>
|
||
{hideHostPaths ? null : (
|
||
<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>
|
||
</Card>
|
||
|
||
<Card className="block 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-(--tracking-eyebrow) 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)}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{isProjectWorkspacePluginTab(activeTab) ? (
|
||
activePluginTab ? (
|
||
<PluginSlotMount
|
||
slot={activePluginTab.slot}
|
||
context={{
|
||
companyId: project.companyId,
|
||
companyPrefix: companyPrefix ?? null,
|
||
projectId: project.id,
|
||
entityId: workspace.id,
|
||
entityType: "project_workspace",
|
||
}}
|
||
missingBehavior="placeholder"
|
||
/>
|
||
) : pluginDetailSlotsLoading || pluginDetailSlotsError ? (
|
||
<div className="rounded-lg border border-dashed border-border bg-background px-4 py-8 text-sm text-muted-foreground">
|
||
{pluginDetailSlotsError ? pluginDetailSlotsError : "Loading workspace plugin..."}
|
||
</div>
|
||
) : (
|
||
<MissingPluginTabPlaceholder
|
||
defaultTabHref={`${projectWorkspaceUrl(project, routeWorkspaceId)}?tab=configuration`}
|
||
defaultTabLabel="Back to configuration"
|
||
/>
|
||
)
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|