mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - That operator experience depends not just on issue chat, but also on how workspaces, inbox groups, and navigation state behave over long-running sessions > - The current branch included a separate cluster of workspace-runtime controls, inbox grouping, sidebar ordering, and worktree lifecycle fixes > - Those changes cross server, shared contracts, database state, and UI navigation, but they still form one coherent operator workflow area > - This pull request isolates the workspace/runtime and navigation ergonomics work into one standalone branch > - The benefit is better workspace recovery and navigation persistence without forcing reviewers through the unrelated issue-detail/chat work ## What Changed - Improved execution workspace and project workspace controls, request wiring, layout, and JSON editor ergonomics - Hardened linked worktree reuse/startup behavior and documented the `worktree repair` flow for recovering linked worktrees safely - Added inbox workspace grouping, mobile collapse, archive undo, keyboard navigation, shared group-header styling, and persisted collapsed-group behavior - Added persistent sidebar order preferences with the supporting DB migration, shared/server contracts, routes, services, hooks, and UI integration - Scoped issue-list preferences by context and added targeted UI/server tests for workspace controls, inbox behavior, sidebar preferences, and worktree validation ## Verification - `pnpm vitest run server/src/__tests__/sidebar-preferences-routes.test.ts ui/src/pages/Inbox.test.tsx ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx ui/src/api/workspace-runtime-control.test.ts` - `server/src/__tests__/workspace-runtime.test.ts` was attempted, but the embedded Postgres suite self-skipped/hung on this host after reporting an init-script issue, so it is not counted as a local pass here ## Risks - Medium: this branch includes migration-backed preference storage plus worktree/runtime behavior, so merge review should pay attention to state persistence and worktree recovery semantics - The sidebar preference migration is standalone, but it should still be watched for conflicts if another migration lands first ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
648 lines
29 KiB
TypeScript
648 lines
29 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { Link, useNavigate, useParams } from "@/lib/router";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared";
|
||
import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { ChoosePathButton } from "../components/PathInstructionsModal";
|
||
import { projectsApi } from "../api/projects";
|
||
import {
|
||
buildWorkspaceRuntimeControlSections,
|
||
WorkspaceRuntimeControls,
|
||
type WorkspaceRuntimeControlRequest,
|
||
} from "../components/WorkspaceRuntimeControls";
|
||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||
import { useCompany } from "../context/CompanyContext";
|
||
import { queryKeys } from "../lib/queryKeys";
|
||
import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||
|
||
type WorkspaceFormState = {
|
||
name: string;
|
||
sourceType: ProjectWorkspaceSourceType;
|
||
cwd: string;
|
||
repoUrl: string;
|
||
repoRef: string;
|
||
defaultRef: string;
|
||
visibility: ProjectWorkspaceVisibility;
|
||
setupCommand: string;
|
||
cleanupCommand: string;
|
||
remoteProvider: string;
|
||
remoteWorkspaceRef: string;
|
||
sharedWorkspaceKey: string;
|
||
runtimeConfig: string;
|
||
};
|
||
|
||
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
|
||
type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"];
|
||
|
||
const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [
|
||
{ value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." },
|
||
{ value: "non_git_path", label: "Local non-git path", description: "A local folder without git semantics." },
|
||
{ value: "git_repo", label: "Remote git repo", description: "A repo URL with optional refs and local checkout." },
|
||
{ value: "remote_managed", label: "Remote-managed workspace", description: "A hosted workspace tracked by external reference." },
|
||
];
|
||
|
||
const VISIBILITY_OPTIONS: Array<{ value: ProjectWorkspaceVisibility; label: string }> = [
|
||
{ value: "default", label: "Default" },
|
||
{ value: "advanced", label: "Advanced" },
|
||
];
|
||
|
||
function isSafeExternalUrl(value: string | null | undefined) {
|
||
if (!value) return false;
|
||
try {
|
||
const parsed = new URL(value);
|
||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function isAbsolutePath(value: string) {
|
||
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
||
}
|
||
|
||
function readText(value: string | null | undefined) {
|
||
return value ?? "";
|
||
}
|
||
|
||
function formatJson(value: Record<string, unknown> | null | undefined) {
|
||
if (!value || Object.keys(value).length === 0) return "";
|
||
return JSON.stringify(value, null, 2);
|
||
}
|
||
|
||
function formStateFromWorkspace(workspace: ProjectWorkspace): WorkspaceFormState {
|
||
return {
|
||
name: workspace.name,
|
||
sourceType: workspace.sourceType,
|
||
cwd: readText(workspace.cwd),
|
||
repoUrl: readText(workspace.repoUrl),
|
||
repoRef: readText(workspace.repoRef),
|
||
defaultRef: readText(workspace.defaultRef),
|
||
visibility: workspace.visibility,
|
||
setupCommand: readText(workspace.setupCommand),
|
||
cleanupCommand: readText(workspace.cleanupCommand),
|
||
remoteProvider: readText(workspace.remoteProvider),
|
||
remoteWorkspaceRef: readText(workspace.remoteWorkspaceRef),
|
||
sharedWorkspaceKey: readText(workspace.sharedWorkspaceKey),
|
||
runtimeConfig: formatJson(workspace.runtimeConfig?.workspaceRuntime),
|
||
};
|
||
}
|
||
|
||
function normalizeText(value: string) {
|
||
const trimmed = value.trim();
|
||
return trimmed.length > 0 ? trimmed : null;
|
||
}
|
||
|
||
function parseRuntimeConfigJson(value: string) {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return { ok: true as const, value: null as Record<string, unknown> | null };
|
||
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
return {
|
||
ok: false as const,
|
||
error: "Workspace commands JSON must be a JSON object.",
|
||
};
|
||
}
|
||
return { ok: true as const, value: parsed as Record<string, unknown> };
|
||
} catch (error) {
|
||
return {
|
||
ok: false as const,
|
||
error: error instanceof Error ? error.message : "Invalid JSON.",
|
||
};
|
||
}
|
||
}
|
||
|
||
function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) {
|
||
const patch: Record<string, unknown> = {};
|
||
const maybeAssign = (key: keyof WorkspaceFormState, transform?: (value: string) => unknown) => {
|
||
const initialValue = initialState[key];
|
||
const nextValue = nextState[key];
|
||
if (initialValue === nextValue) return;
|
||
patch[key] = transform ? transform(nextValue) : nextValue;
|
||
};
|
||
|
||
maybeAssign("name", normalizeText);
|
||
maybeAssign("sourceType");
|
||
maybeAssign("cwd", normalizeText);
|
||
maybeAssign("repoUrl", normalizeText);
|
||
maybeAssign("repoRef", normalizeText);
|
||
maybeAssign("defaultRef", normalizeText);
|
||
maybeAssign("visibility");
|
||
maybeAssign("setupCommand", normalizeText);
|
||
maybeAssign("cleanupCommand", normalizeText);
|
||
maybeAssign("remoteProvider", normalizeText);
|
||
maybeAssign("remoteWorkspaceRef", normalizeText);
|
||
maybeAssign("sharedWorkspaceKey", normalizeText);
|
||
if (initialState.runtimeConfig !== nextState.runtimeConfig) {
|
||
const parsed = parseRuntimeConfigJson(nextState.runtimeConfig);
|
||
if (!parsed.ok) throw new Error(parsed.error);
|
||
patch.runtimeConfig = {
|
||
workspaceRuntime: parsed.value,
|
||
};
|
||
}
|
||
|
||
return patch;
|
||
}
|
||
|
||
function validateWorkspaceForm(form: WorkspaceFormState) {
|
||
const cwd = normalizeText(form.cwd);
|
||
const repoUrl = normalizeText(form.repoUrl);
|
||
const remoteWorkspaceRef = normalizeText(form.remoteWorkspaceRef);
|
||
|
||
if (form.sourceType === "remote_managed") {
|
||
if (!remoteWorkspaceRef && !repoUrl) {
|
||
return "Remote-managed workspaces require a remote workspace ref or repo URL.";
|
||
}
|
||
} else if (!cwd && !repoUrl) {
|
||
return "Workspace requires at least one local path or repo URL.";
|
||
}
|
||
|
||
if (cwd && (form.sourceType === "local_path" || form.sourceType === "non_git_path") && !isAbsolutePath(cwd)) {
|
||
return "Local workspace path must be absolute.";
|
||
}
|
||
|
||
if (repoUrl) {
|
||
try {
|
||
new URL(repoUrl);
|
||
} catch {
|
||
return "Repo URL must be a valid URL.";
|
||
}
|
||
}
|
||
|
||
const runtimeConfig = parseRuntimeConfigJson(form.runtimeConfig);
|
||
if (!runtimeConfig.ok) {
|
||
return runtimeConfig.error;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
hint,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
hint?: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<label className="space-y-1.5">
|
||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||
<span className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
|
||
{hint ? <span className="text-[11px] leading-relaxed text-muted-foreground sm:text-right">{hint}</span> : null}
|
||
</div>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
|
||
<div className="shrink-0 text-xs text-muted-foreground sm:w-28">{label}</div>
|
||
<div className="min-w-0 flex-1 text-sm">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ProjectWorkspaceDetail() {
|
||
const { companyPrefix, projectId, workspaceId } = useParams<{
|
||
companyPrefix?: string;
|
||
projectId: string;
|
||
workspaceId: string;
|
||
}>();
|
||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||
const { setBreadcrumbs } = useBreadcrumbs();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const [form, setForm] = useState<WorkspaceFormState | null>(null);
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||
const routeProjectRef = projectId ?? "";
|
||
const routeWorkspaceId = workspaceId ?? "";
|
||
|
||
const routeCompanyId = useMemo(() => {
|
||
if (!companyPrefix) return null;
|
||
const requestedPrefix = companyPrefix.toUpperCase();
|
||
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
|
||
}, [companies, companyPrefix]);
|
||
|
||
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
|
||
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
|
||
const projectQuery = useQuery({
|
||
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
||
queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
|
||
enabled: canFetchProject,
|
||
});
|
||
|
||
const project = projectQuery.data ?? null;
|
||
const workspace = useMemo(
|
||
() => project?.workspaces.find((item) => item.id === routeWorkspaceId) ?? null,
|
||
[project, routeWorkspaceId],
|
||
);
|
||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
|
||
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
|
||
|
||
useEffect(() => {
|
||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||
setSelectedCompanyId(project.companyId, { source: "route_sync" });
|
||
}, [project?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||
|
||
useEffect(() => {
|
||
if (!workspace) return;
|
||
setForm(formStateFromWorkspace(workspace));
|
||
setErrorMessage(null);
|
||
}, [workspace]);
|
||
|
||
useEffect(() => {
|
||
if (!project) return;
|
||
setBreadcrumbs([
|
||
{ label: "Projects", href: "/projects" },
|
||
{ label: project.name, href: `/projects/${canonicalProjectRef}` },
|
||
{ label: "Workspaces", href: `/projects/${canonicalProjectRef}/workspaces` },
|
||
{ label: workspace?.name ?? routeWorkspaceId },
|
||
]);
|
||
}, [setBreadcrumbs, project, canonicalProjectRef, workspace?.name, routeWorkspaceId]);
|
||
|
||
useEffect(() => {
|
||
if (!project) return;
|
||
if (routeProjectRef === canonicalProjectRef) return;
|
||
navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true });
|
||
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]);
|
||
|
||
const invalidateProject = () => {
|
||
if (!project) return;
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) });
|
||
if (lookupCompanyId) {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(lookupCompanyId) });
|
||
}
|
||
};
|
||
|
||
const updateWorkspace = useMutation({
|
||
mutationFn: (patch: Record<string, unknown>) =>
|
||
projectsApi.updateWorkspace(project!.id, routeWorkspaceId, patch, lookupCompanyId),
|
||
onSuccess: () => {
|
||
invalidateProject();
|
||
setErrorMessage(null);
|
||
},
|
||
onError: (error) => {
|
||
setErrorMessage(error instanceof Error ? error.message : "Failed to save workspace.");
|
||
},
|
||
});
|
||
|
||
const setPrimaryWorkspace = useMutation({
|
||
mutationFn: () => projectsApi.updateWorkspace(project!.id, routeWorkspaceId, { isPrimary: true }, lookupCompanyId),
|
||
onSuccess: () => {
|
||
invalidateProject();
|
||
setErrorMessage(null);
|
||
},
|
||
onError: (error) => {
|
||
setErrorMessage(error instanceof Error ? error.message : "Failed to update workspace.");
|
||
},
|
||
});
|
||
|
||
const controlRuntimeServices = useMutation({
|
||
mutationFn: (request: WorkspaceRuntimeControlRequest) =>
|
||
projectsApi.controlWorkspaceCommands(project!.id, routeWorkspaceId, request.action, lookupCompanyId, request),
|
||
onSuccess: (result, request) => {
|
||
invalidateProject();
|
||
setErrorMessage(null);
|
||
setRuntimeActionMessage(
|
||
request.action === "run"
|
||
? "Workspace job completed."
|
||
: request.action === "stop"
|
||
? "Workspace service stopped."
|
||
: request.action === "restart"
|
||
? "Workspace service restarted."
|
||
: "Workspace service started.",
|
||
);
|
||
},
|
||
onError: (error) => {
|
||
setRuntimeActionMessage(null);
|
||
setErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands.");
|
||
},
|
||
});
|
||
|
||
if (projectQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace…</p>;
|
||
if (projectQuery.error) {
|
||
return (
|
||
<p className="text-sm text-destructive">
|
||
{projectQuery.error instanceof Error ? projectQuery.error.message : "Failed to load workspace"}
|
||
</p>
|
||
);
|
||
}
|
||
if (!project || !workspace || !form || !initialState) {
|
||
return <p className="text-sm text-muted-foreground">Workspace not found for this project.</p>;
|
||
}
|
||
|
||
const canRunWorkspaceCommands = Boolean(workspace.cwd);
|
||
const canStartRuntimeServices = Boolean(workspace.runtimeConfig?.workspaceRuntime) && canRunWorkspaceCommands;
|
||
const runtimeControlSections = buildWorkspaceRuntimeControlSections({
|
||
runtimeConfig: workspace.runtimeConfig?.workspaceRuntime ?? null,
|
||
runtimeServices: workspace.runtimeServices ?? [],
|
||
canStartServices: canStartRuntimeServices,
|
||
canRunJobs: canRunWorkspaceCommands,
|
||
});
|
||
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
|
||
|
||
const saveChanges = () => {
|
||
const validationError = validateWorkspaceForm(form);
|
||
if (validationError) {
|
||
setErrorMessage(validationError);
|
||
return;
|
||
}
|
||
const patch = buildWorkspacePatch(initialState, form);
|
||
if (Object.keys(patch).length === 0) return;
|
||
updateWorkspace.mutate(patch);
|
||
};
|
||
|
||
const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null;
|
||
|
||
return (
|
||
<div className="mx-auto max-w-5xl space-y-6">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Button variant="ghost" size="sm" asChild>
|
||
<Link to={`/projects/${canonicalProjectRef}/workspaces`}>
|
||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||
Back to workspaces
|
||
</Link>
|
||
</Button>
|
||
<div className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
|
||
<div className="space-y-6">
|
||
<div className="rounded-2xl border border-border bg-card p-5">
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||
Project workspace
|
||
</div>
|
||
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||
Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace
|
||
checkout behavior, default runtime services for child execution workspaces, and let you override setup
|
||
or cleanup commands when one workspace needs special handling.
|
||
</p>
|
||
</div>
|
||
{!workspace.isPrimary ? (
|
||
<Button
|
||
variant="outline"
|
||
className="w-full sm:w-auto"
|
||
disabled={setPrimaryWorkspace.isPending}
|
||
onClick={() => setPrimaryWorkspace.mutate()}
|
||
>
|
||
{setPrimaryWorkspace.isPending
|
||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
: <Check className="mr-2 h-4 w-4" />}
|
||
Make primary
|
||
</Button>
|
||
) : (
|
||
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
|
||
<Sparkles className="h-4 w-4" />
|
||
This is the project’s primary codebase workspace.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Separator className="my-5" />
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<Field label="Workspace name">
|
||
<input
|
||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||
value={form.name}
|
||
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
||
placeholder="Workspace name"
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Visibility">
|
||
<select
|
||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||
value={form.visibility}
|
||
onChange={(event) =>
|
||
setForm((current) => current ? { ...current, visibility: event.target.value as ProjectWorkspaceVisibility } : current)
|
||
}
|
||
>
|
||
{VISIBILITY_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>{option.label}</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-4">
|
||
<Field label="Source type" hint={sourceTypeDescription ?? undefined}>
|
||
<select
|
||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||
value={form.sourceType}
|
||
onChange={(event) =>
|
||
setForm((current) => current ? { ...current, sourceType: event.target.value as ProjectWorkspaceSourceType } : current)
|
||
}
|
||
>
|
||
{SOURCE_TYPE_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>{option.label}</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
|
||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
|
||
<Field label="Local path">
|
||
<input
|
||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||
value={form.cwd}
|
||
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
|
||
placeholder="/absolute/path/to/workspace"
|
||
/>
|
||
</Field>
|
||
<div className="flex items-end">
|
||
<ChoosePathButton />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<Field label="Repo URL">
|
||
<input
|
||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||
value={form.repoUrl}
|
||
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
|
||
placeholder="https://github.com/org/repo"
|
||
/>
|
||
</Field>
|
||
<Field label="Repo ref">
|
||
<input
|
||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||
value={form.repoRef}
|
||
onChange={(event) => setForm((current) => current ? { ...current, repoRef: event.target.value } : current)}
|
||
placeholder="origin/main"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<Field label="Default ref">
|
||
<input
|
||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||
value={form.defaultRef}
|
||
onChange={(event) => setForm((current) => current ? { ...current, defaultRef: event.target.value } : current)}
|
||
placeholder="origin/main"
|
||
/>
|
||
</Field>
|
||
<Field label="Shared workspace key">
|
||
<input
|
||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||
value={form.sharedWorkspaceKey}
|
||
onChange={(event) => setForm((current) => current ? { ...current, sharedWorkspaceKey: event.target.value } : current)}
|
||
placeholder="frontend"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<Field label="Remote provider">
|
||
<input
|
||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||
value={form.remoteProvider}
|
||
onChange={(event) => setForm((current) => current ? { ...current, remoteProvider: event.target.value } : current)}
|
||
placeholder="codespaces"
|
||
/>
|
||
</Field>
|
||
<Field label="Remote workspace ref">
|
||
<input
|
||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||
value={form.remoteWorkspaceRef}
|
||
onChange={(event) => setForm((current) => current ? { ...current, remoteWorkspaceRef: event.target.value } : current)}
|
||
placeholder="workspace-123"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<Field label="Setup command" hint="Runs when this workspace needs custom bootstrap">
|
||
<textarea
|
||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||
value={form.setupCommand}
|
||
onChange={(event) => setForm((current) => current ? { ...current, setupCommand: event.target.value } : current)}
|
||
placeholder="pnpm install && pnpm dev"
|
||
/>
|
||
</Field>
|
||
<Field label="Cleanup command" hint="Runs before project-level execution workspace teardown">
|
||
<textarea
|
||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||
value={form.cleanupCommand}
|
||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||
placeholder="pkill -f vite || true"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<details className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
|
||
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
Paperclip derives Services and Jobs from this JSON. Prefer editing named commands first; use raw JSON for advanced lifecycle, port, readiness, or environment settings.
|
||
</p>
|
||
<div className="mt-3">
|
||
<Field label="Workspace commands JSON" hint="Execution workspaces inherit this config unless they override it. Legacy `services` arrays still work, but `commands` supports both services and jobs.">
|
||
<textarea
|
||
className="min-h-96 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||
value={form.runtimeConfig}
|
||
onChange={(event) => setForm((current) => current ? { ...current, runtimeConfig: event.target.value } : current)}
|
||
placeholder={"{\n \"commands\": [\n {\n \"id\": \"web\",\n \"name\": \"web\",\n \"kind\": \"service\",\n \"command\": \"pnpm dev\",\n \"cwd\": \".\",\n \"port\": { \"type\": \"auto\" },\n \"readiness\": {\n \"type\": \"http\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"expose\": {\n \"type\": \"url\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"lifecycle\": \"shared\",\n \"reuseScope\": \"project_workspace\"\n },\n {\n \"id\": \"db-migrate\",\n \"name\": \"db:migrate\",\n \"kind\": \"job\",\n \"command\": \"pnpm db:migrate\",\n \"cwd\": \".\"\n }\n ]\n}"}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
|
||
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||
Save changes
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="w-full sm:w-auto"
|
||
disabled={!isDirty || updateWorkspace.isPending}
|
||
onClick={() => {
|
||
setForm(initialState);
|
||
setErrorMessage(null);
|
||
}}
|
||
>
|
||
Reset
|
||
</Button>
|
||
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
|
||
{!errorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
||
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
<div className="rounded-2xl border border-border bg-card p-5">
|
||
<div className="space-y-1">
|
||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace facts</div>
|
||
<h2 className="text-lg font-semibold">Current state</h2>
|
||
</div>
|
||
<Separator className="my-4" />
|
||
<DetailRow label="Project">
|
||
<Link to={`/projects/${canonicalProjectRef}`} className="hover:underline">{project.name}</Link>
|
||
</DetailRow>
|
||
<DetailRow label="Workspace ID">
|
||
<span className="break-all font-mono text-xs">{workspace.id}</span>
|
||
</DetailRow>
|
||
<DetailRow label="Local path">
|
||
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
|
||
</DetailRow>
|
||
<DetailRow label="Repo">
|
||
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
|
||
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||
{workspace.repoUrl}
|
||
<ExternalLink className="h-3 w-3" />
|
||
</a>
|
||
) : workspace.repoUrl ? (
|
||
<span className="break-all font-mono text-xs">{workspace.repoUrl}</span>
|
||
) : "None"}
|
||
</DetailRow>
|
||
<DetailRow label="Default ref">{workspace.defaultRef ?? "None"}</DetailRow>
|
||
<DetailRow label="Updated">{new Date(workspace.updatedAt).toLocaleString()}</DetailRow>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-border bg-card p-5">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="space-y-1">
|
||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace commands</div>
|
||
<h2 className="text-lg font-semibold">Services and jobs</h2>
|
||
<p className="text-sm text-muted-foreground">
|
||
Long-running services stay supervised here, while one-shot jobs run on demand against this workspace. Execution workspaces inherit this config unless they override it.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<WorkspaceRuntimeControls
|
||
className="mt-4"
|
||
sections={runtimeControlSections}
|
||
isPending={controlRuntimeServices.isPending}
|
||
pendingRequest={pendingRuntimeAction}
|
||
serviceEmptyMessage={
|
||
workspace.runtimeConfig?.workspaceRuntime
|
||
? "No services have been started for this workspace yet."
|
||
: "No workspace command config is defined for this workspace yet."
|
||
}
|
||
jobEmptyMessage="No one-shot jobs are configured for this workspace yet."
|
||
disabledHint="Project workspaces need a working directory before local commands can run, and services also need runtime config."
|
||
onAction={(request) => controlRuntimeServices.mutate(request)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|