mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 23:20:53 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Its UI is governed by a design system (`DESIGN.md` + the token layer in `ui/src/index.css`, merged in #9134) whose first principle is "one way to say each thing" — one Card, one Badge, one nav row > - After the token extraction landed, ~35 files still hand-rolled card containers, ~45 files hand-rolled pill spans, the sidebar agents section duplicated the nav-row chrome, and the inbox and tasks lists rendered the same task rows two subtly different ways > - Each divergence is a place where a future design change (radius, hover language, status vocabulary) silently misses surfaces, defeating the "edit tokens + run checks" model the design system exists for > - This pull request converges those surfaces onto the shared primitives, codifies the radius scale as the modern multiplicative shadcn ladder, and unifies the row/hover/tree-guide language across the inbox and tasks lists — every visible delta was human-reviewed screen-by-screen against a live instance across nine feedback rounds > - The benefit is that the app's look is now steerable from single knobs (one `--radius` anchor, one Card, one Badge, one row component), and the visual regression suite covers the result (514 snapshots including a new AgentDetail page story) ## Linked Issues or Issue Description No public GitHub issue exists for this work; describing per the feature template: - **Problem**: after the design-token foundation (#9134), component-level drift remained — hand-rolled cards/pills, duplicated sidebar row chrome, and two different renderings of task rows (inbox vs tasks list) meant design changes had to be applied per-surface and frequently missed spots (e.g. status glyphs rendered 16px in the inbox but 20px in the tasks list because a slot override silently beat the component default). - **Proposed behavior**: all card-shaped containers render via `Card`, all label pills via `Badge`, sidebar rows via `SidebarNavItem`, and both task-list surfaces via one `IssueRow` configuration; the radius scale is a single multiplicative ladder anchored at `--radius: 0.5rem`. - **Alternatives considered**: converting interactive `<button>`/`<Link>` cards to `Card` divs (rejected — breaks semantics; documented inline with `design-allow` comments instead); keeping the legacy additive radius ladder (rejected in favor of the standard shadcn multiplicative mapping). ## What Changed - `Card` adoption across ~35 files (settings pages, auth/board flows, dashboards, list containers, KPI tiles); non-adoptable sites (interactive cards, `<li>` rows, class-string props, chart tooltip) carry documented `design-allow(card-pattern)` comments - `Card` gains an `interactive` prop — one quiet hover affordance for clickable cards (cursor, border darken, shadow lift, focus ring), applied to skills tiles, artifact cards, and the company selector; cards carry no resting shadow - `Badge` adoption for 113 hand-rolled pill spans across ~45 files; `PropertyChip` wraps `Badge` internally; `StatusBadge`, external-object chips, and match chips stay bespoke by documented decision (WCAG-tuned status mechanics) - Radius ladder becomes the multiplicative shadcn mapping (`sm/md/lg/xl/2xl/3xl/4xl = 0.6/0.8/1.0/1.4/1.8/2.2/2.6 × --radius`, anchor `0.5rem`); every card surface unifies on `rounded-lg`; the orphaned 8px literal token is deleted - Sidebar: agent rows render via `SidebarNavItem` (new additive props: `iconNode`, `active`, `trailing`, `liveAccessory`); live dots use `--status-agent-running`; one row rhythm and inset rounded pill highlight; right-aligned trailing badges; every labeled section is collapsible - Inbox + tasks lists unified: md status glyphs, `accent/50` rounded row hovers, vertical tree guides under parent rows (opaque underlay so dark-mode translucent borders don't stack), no horizontal dividers under expanded parents; the swipe-to-archive reveal layer shows only mid-swipe; board toggle uses the `SquareKanban` glyph - Kanban: every column carries a status-hued tint; lanes default expanded (including empty); compact mode collapses empty lanes to labeled rails (fixes a clipped, label-less empty-column state) - Storybook: new AgentDetail page story (realistic fixtures, light+dark) joins the visual suite; suite captures with `reducedMotion: 'reduce'` and the ux-lab reasoning ticker honors `prefers-reduced-motion`; a stale lexical alias in `storybook/main.ts` is fixed (build was broken since the lexical 0.46 bump) - Keyboard navigation, from live review of the unified lists: inbox navigation keys work on every tab (archive keys stay scoped to the archivable tab); keyboard-driven scrolling no longer hands the selection to whatever row lands under the stationary cursor (hover selects only after real pointer movement); the tasks list view gains the same j/k / arrows / Enter selection model as the inbox; and the `g` then `i` go-to-inbox chord works app-wide instead of only on the issue detail page - Token gates restored to 3/3 CLEAN (tokenized a post-#9134 regression in the recovery card); decisions recorded in `doc/design/DECISION-SHEET.md` and `doc/design/COMPONENT-INVENTORY.md` (investigation verdicts: FileTree vs WorkspaceFileBrowser and the four entity pickers stay separate — evidence included) ## Verification - `pnpm check:token-gates` → 3/3 CLEAN - `pnpm typecheck` → green (all packages) - `cd ui && npx vitest run` → 2106/2106 (assertions updated in lockstep where they documented superseded decisions; new tests for the global go-to-inbox chord) - `pnpm --filter @paperclipai/ui build` → exit 0 - `pnpm build-storybook` → succeeds (also fixes the lexical-alias break on master) - Visual regression: 514-snapshot Playwright suite green against the updated baseline (zero diffs from the keyboard-navigation round — those changes are purely behavioral). Note: baselines live outside git per the suite design and the baseline-manifest archive is not yet published, so CI cannot run this suite — it was run locally throughout; every visible delta was reviewed screen-by-screen in a live instance across nine review rounds. Review evidence (before/after triplets) intentionally kept out of the repo for size; available on request. - Manual: exercised dashboard, tasks (list + board), inbox, agents, skills, costs, settings, and artifact surfaces in light and dark themes ## Risks - Wide but shallow visual surface: most changes are class-string substitutions with behavior preserved (props, handlers, roles, test ids). The riskiest areas — dnd-kit card refs (React 19 ref-as-prop), inbox swipe-to-archive, and sidebar overlays — are covered by existing unit tests (all green) and were manually exercised. - Intentional visual deltas (rounded cards, tinted kanban columns, md status glyphs, unified hovers) are design decisions recorded in `doc/design/DECISION-SHEET.md`; each maps to a re-baselined snapshot set locally. - The visual suite cannot yet run in CI (unpublished baseline archive — pre-existing condition from #9134); until that lands, snapshot coverage is local-only. ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic coding with tool use (file editing, test execution, Playwright screenshot verification); extended thinking 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) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (will confirm once CI runs) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending first review) - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
752 lines
33 KiB
TypeScript
752 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 { 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 [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.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. 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>
|
||
|
||
<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>
|
||
<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>
|
||
);
|
||
}
|