mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board reads API data and keeps the current task on screen. > - A restart or temporary network failure can interrupt those reads. > - Separate page error handlers can hide cached data or expose raw server codes. > - The app needs one connection state, readable error copy and a consistent read policy. > - This change provides that foundation and moves affected read surfaces onto it. > - The dependent write layer in #17960 preserves edits and submissions across the same failures. ## Linked Issues or Issue Description **What happened?** Temporary API failures could replace loaded content with errors. Different surfaces handled the same outage differently. Some showed raw server codes. A successful query to an unrelated service could also falsely report that Paperclip had recovered. **Expected behavior** Keep cached content visible. Show one connection banner during sustained trouble. Confirm recovery through the Paperclip health probe or its WebSocket. Retry transient reads and show readable errors for permanent failures. **Steps to reproduce** 1. Open a populated task or workspace page. 2. Interrupt API requests with a 503 or a dropped connection. 3. Keep an unrelated external request in flight. 4. Confirm that cached content remains and that the external result cannot clear the outage. 5. Restore the API and check that reads refresh. The write layer first merged into this feature branch in #15769 now has its own dependent PR, #17960. This PR contains the shared foundation and read changes. Merge this PR first. ## What Changed - Classify API errors and provide readable error copy, including non-JSON proxy responses and Retry-After handling. - Use one connection store and health probe with bounded backoff. Confirm recovery from the server health probe or live-updates WebSocket. - Pause reads while disconnected, retry transient errors, and refresh active reads after recovery. - Keep cached data visible through shared query views and affected page, panel and plugin read surfaces. - Add one app-level connection banner and support the pending-write count consumed by the dependent write layer. - Add a query-error rendering guard. Call it directly from the existing CI step. Update development documentation. ## Verification - Historical-chat lookup now follows the same ready/stale roster state as rendering. Three regressions fail before the fix; the six new cases pass after it, including working Retry, fresh data, no-cache error, permanent 403 and company isolation. All six focused query/recovery suites pass 200 tests. Final-commit CI passes and Apex is 5/5 with no unresolved findings. Canary Dry Run initially refused a preview version that was already on npm. It passed after one rerun with fresh registry metadata; no check was disabled. - Integrated current master and retained both transcript interest registration and connectivity reporting on socket open. The seven focused recovery/query suites pass 288 tests, and UI typecheck and production build pass. All 9,451 UI tests across 799 files pass on the merge commit. Final-commit CI passes and Apex is 5/5 with no unresolved findings. - Current head `70d9335d732219352ef0f98414439766bbcb33df`: all hosted CI checks pass. Apex is **5/5** on that head with no unresolved findings. The branch has no merge conflict. GitHub still requires code-owner approval. - The foundation passes 9,430 UI tests in 804 files. After integrating current master page paths, 190 focused page tests pass. - Two unrelated-success regressions fail before the latest fix. With the fix, 62 focused UI tests, all six guard tests and UI TypeScript pass. - The composed foundation and write layer pass **9,505 UI tests in 808 files**, UI TypeScript, a production UI build, and query-error, token and map guards. - A real Chromium journey on the composed source injects a 503 during title save. The loaded task stays visible; one reconnect banner appears; the edit saves after recovery; another task stays unchanged; the saved title survives reload. - Full recursive local checks were not repeated because Runner compilation exhausted local disk during another repair. Hosted checks must pass before merge. No new staging deployment is claimed. - Latest review follow-up: a cached CaseDetail now survives a failed HTTP 429 refresh. Its page regression fails before the fix and passes after it. Eighty focused foundation tests, UI TypeScript, build and guards pass after that change. - Earlier CI evidence is retained: several foundation jobs received a runner shutdown signal together. The write-layer head also failed an unchanged Runner descriptor close test with `provider_transport_failed`. The same Runner source passed that suite in the foundation run. New CI runs cover the updated heads; no check or assertion was weakened. ## Risks - Shared retry defaults change the time before transient read errors appear. Permanent errors still fail promptly. - ApiError.message now contains readable copy. Callers that compare raw codes must use ApiError.code. - Cache success can come from another service or local data. It must not confirm Paperclip recovery. - Durable writes are reviewed separately in #17960. This PR does not change database or mutation contracts. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) produced the original work with tool use. OpenAI Codex, GPT-6 family, assisted with restoration, integration, tests and review fixes. The Codex session does not expose its exact serving model ID or context window size. ## 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 --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Amy (Opus) <claude@users.noreply.github.com>
772 lines
33 KiB
TypeScript
772 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 { QueryErrorState, useQueryView } from "@/components/QueryView";
|
||
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 projectView = useQueryView(projectQuery);
|
||
|
||
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 || projectView.kind === "reconnecting") {
|
||
return <p className="text-sm text-muted-foreground">Loading workspace…</p>;
|
||
}
|
||
if (projectView.kind === "error") {
|
||
return (
|
||
<QueryErrorState
|
||
size="page"
|
||
error={projectQuery.error}
|
||
action="load this workspace"
|
||
onRetry={projectView.retry}
|
||
retrying={projectView.isFetching}
|
||
/>
|
||
);
|
||
}
|
||
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>
|
||
);
|
||
}
|