mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
[codex] Improve workspace navigation and runtime UI (#4089)
## Thinking Path > - Paperclip agents do real work in project and execution workspaces. > - Operators need workspace state to be visible, navigable, and copyable without digging through raw run logs. > - The branch included related workspace cards, navigation, runtime controls, stale-service handling, and issue-property visibility. > - These changes share the workspace UI and runtime-control surfaces and can stand alone from unrelated access/profile work. > - This pull request groups the workspace experience changes into one standalone branch. > - The benefit is a clearer workspace overview, better metadata copy flows, and more accurate runtime service controls. ## What Changed - Polished project workspace summary cards and made workspace metadata copyable. - Added a workspace navigation overview and extracted reusable project workspace content. - Squared and polished the execution workspace configuration page. - Fixed stale workspace command matching and hid stopped stale services in runtime controls. - Showed live workspace service context in issue properties. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/lib/project-workspaces-tab.test.ts ui/src/components/Sidebar.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx ui/src/components/IssueProperties.test.tsx` - `pnpm exec vitest run packages/shared/src/workspace-commands.test.ts --config /dev/null` because the root Vitest project config does not currently include `packages/shared` tests. - Split integration check: merged after runtime/governance, dev-infra/backups, and access/profiles with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Medium risk: touches workspace navigation, runtime controls, and issue property rendering. - Visual layout changes may need browser QA, especially around smaller screens and dense workspace metadata. - No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] 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>
This commit is contained in:
parent
d8b63a18e7
commit
fee514efcb
19 changed files with 1348 additions and 351 deletions
|
|
@ -4,8 +4,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
|
@ -188,10 +191,10 @@ function Field({
|
|||
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}
|
||||
<label className="block space-y-2">
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
|
||||
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||
{hint ? <span className="text-xs text-muted-foreground sm:text-right">{hint}</span> : null}
|
||||
</div>
|
||||
{children}
|
||||
</label>
|
||||
|
|
@ -532,22 +535,19 @@ export function ExecutionWorkspaceDetail() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4 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">
|
||||
Source: {runtimeConfigSource === "execution_workspace"
|
||||
? "execution workspace override"
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "project workspace default"
|
||||
: "none"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Services and jobs</CardTitle>
|
||||
<CardDescription>
|
||||
Source: {runtimeConfigSource === "execution_workspace"
|
||||
? "execution workspace override"
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "project workspace default"
|
||||
: "none"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WorkspaceRuntimeControls
|
||||
className="mt-4"
|
||||
sections={runtimeControlSections}
|
||||
isPending={controlRuntimeServices.isPending}
|
||||
pendingRequest={pendingRuntimeAction}
|
||||
|
|
@ -566,7 +566,8 @@ export function ExecutionWorkspaceDetail() {
|
|||
/>
|
||||
{runtimeActionErrorMessage ? <p className="mt-4 text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
|
||||
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="mt-4 text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs value={activeTab ?? "configuration"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
|
||||
<PageTabBar
|
||||
|
|
@ -583,181 +584,203 @@ export function ExecutionWorkspaceDetail() {
|
|||
|
||||
{activeTab === "configuration" ? (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm: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-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Configuration
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">Workspace settings</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setCloseDialogOpen(true)}
|
||||
disabled={workspace.status === "archived"}
|
||||
>
|
||||
{workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workspace settings</CardTitle>
|
||||
<CardDescription>
|
||||
Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setCloseDialogOpen(true)}
|
||||
disabled={workspace.status === "archived"}
|
||||
>
|
||||
{workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<Separator className="my-5" />
|
||||
<CardContent>
|
||||
|
||||
<div className="space-y-4">
|
||||
<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="Execution workspace name"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Branch name" hint="Useful for isolated worktrees">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.branchName}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
|
||||
placeholder="PAP-946-workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Working directory">
|
||||
<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>
|
||||
|
||||
<Field label="Provider path / ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.providerRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
|
||||
placeholder="/path/to/worktree or provider ref"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<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="Base ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.baseRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
||||
value={form.provisionCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/provision-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-28"
|
||||
value={form.teardownCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
||||
<textarea
|
||||
className="min-h-16 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none sm:min-h-24"
|
||||
value={form.cleanupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||
placeholder="pkill -f vite || true"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Runtime config source
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{runtimeConfigSource === "execution_workspace"
|
||||
? "This execution workspace currently overrides the project workspace runtime config."
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "This execution workspace is inheriting the project workspace runtime config."
|
||||
: "No runtime config is currently defined on this execution workspace or its project workspace."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
size="sm"
|
||||
disabled={!linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime}
|
||||
onClick={() =>
|
||||
setForm((current) => current ? {
|
||||
...current,
|
||||
inheritRuntime: true,
|
||||
workspaceRuntime: "",
|
||||
} : current)
|
||||
}
|
||||
>
|
||||
Reset to inherit
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">General</div>
|
||||
<Field label="Workspace name">
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
||||
placeholder="Execution workspace name"
|
||||
/>
|
||||
</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">
|
||||
Override the inherited workspace command model only when this execution workspace truly needs different service or job behavior.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Field label="Workspace commands JSON" hint="Legacy `services` arrays still work, but `commands` supports both services and jobs.">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
id="inherit-runtime-config"
|
||||
type="checkbox"
|
||||
checked={form.inheritRuntime}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
setForm((current) => {
|
||||
if (!current) return current;
|
||||
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
|
||||
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
|
||||
}
|
||||
return { ...current, inheritRuntime: checked };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="min-h-64 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-96"
|
||||
value={form.workspaceRuntime}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
||||
disabled={form.inheritRuntime}
|
||||
placeholder={'{\n "commands": [\n {\n "id": "web",\n "name": "web",\n "kind": "service",\n "command": "pnpm dev",\n "cwd": ".",\n "port": { "type": "auto" }\n },\n {\n "id": "db-migrate",\n "name": "db:migrate",\n "kind": "job",\n "command": "pnpm db:migrate",\n "cwd": "."\n }\n ]\n}'}
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Source control</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Branch name" hint="Useful for isolated worktrees">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={form.branchName}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
|
||||
placeholder="PAP-946-workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Base ref">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={form.baseRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Field label="Repo URL">
|
||||
<Input
|
||||
value={form.repoUrl}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
|
||||
placeholder="https://github.com/org/repo"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Paths</div>
|
||||
<Field label="Working directory">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={form.cwd}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
|
||||
placeholder="/absolute/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Provider path / ref">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={form.providerRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
|
||||
placeholder="/path/to/worktree or provider ref"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Lifecycle commands</div>
|
||||
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
||||
<Textarea
|
||||
className="min-h-20 font-mono"
|
||||
value={form.provisionCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/provision-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
||||
<Textarea
|
||||
className="min-h-20 font-mono"
|
||||
value={form.teardownCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
||||
<Textarea
|
||||
className="min-h-16 font-mono"
|
||||
value={form.cleanupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||
placeholder="pkill -f vite || true"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Runtime config</div>
|
||||
<div className="rounded-md border border-dashed border-border/70 bg-muted/30 px-4 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
Runtime config source
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{runtimeConfigSource === "execution_workspace"
|
||||
? "This execution workspace currently overrides the project workspace runtime config."
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "This execution workspace is inheriting the project workspace runtime config."
|
||||
: "No runtime config is currently defined on this execution workspace or its project workspace."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
size="sm"
|
||||
disabled={!linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime}
|
||||
onClick={() =>
|
||||
setForm((current) => current ? {
|
||||
...current,
|
||||
inheritRuntime: true,
|
||||
workspaceRuntime: "",
|
||||
} : current)
|
||||
}
|
||||
>
|
||||
Reset to inherit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="rounded-md border border-dashed border-border/70 bg-muted/30 px-4 py-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Override the inherited workspace command model only when this execution workspace truly needs different service or job behavior.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Field label="Workspace commands JSON" hint="Legacy `services` arrays still work, but `commands` supports both services and jobs.">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
id="inherit-runtime-config"
|
||||
type="checkbox"
|
||||
className="rounded border-border"
|
||||
checked={form.inheritRuntime}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
setForm((current) => {
|
||||
if (!current) return current;
|
||||
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
|
||||
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
|
||||
}
|
||||
return { ...current, inheritRuntime: checked };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-64 font-mono sm:min-h-96"
|
||||
value={form.workspaceRuntime}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
||||
disabled={form.inheritRuntime}
|
||||
placeholder={'{\n "commands": [\n {\n "id": "web",\n "name": "web",\n "kind": "service",\n "command": "pnpm dev",\n "cwd": ".",\n "port": { "type": "auto" }\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>
|
||||
|
||||
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className="mt-6 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
|
||||
|
|
@ -778,14 +801,15 @@ export function ExecutionWorkspaceDetail() {
|
|||
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
|
||||
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
|
||||
<h2 className="text-lg font-semibold">Workspace context</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workspace context</CardTitle>
|
||||
<CardDescription>Linked objects and relationships</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DetailRow label="Project">
|
||||
{project ? <Link to={`/projects/${projectRef}`} className="hover:underline">{project.name}</Link> : <MonoValue value={workspace.projectId} />}
|
||||
</DetailRow>
|
||||
|
|
@ -823,14 +847,15 @@ export function ExecutionWorkspaceDetail() {
|
|||
<DetailRow label="Workspace ID">
|
||||
<MonoValue value={workspace.id} />
|
||||
</DetailRow>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
|
||||
<h2 className="text-lg font-semibold">Concrete location</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Concrete location</CardTitle>
|
||||
<CardDescription>Paths and refs</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DetailRow label="Working dir">
|
||||
{workspace.cwd ? <MonoValue value={workspace.cwd} copy /> : "None"}
|
||||
</DetailRow>
|
||||
|
|
@ -867,15 +892,16 @@ export function ExecutionWorkspaceDetail() {
|
|||
? `${formatDateTime(workspace.cleanupEligibleAt)}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}`
|
||||
: "Not scheduled"}
|
||||
</DetailRow>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : activeTab === "runtime_logs" ? (
|
||||
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
|
||||
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Runtime and cleanup logs</CardTitle>
|
||||
<CardDescription>Recent operations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{workspaceOperationsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading workspace operations…</p>
|
||||
) : workspaceOperationsQuery.error ? (
|
||||
|
|
@ -887,7 +913,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspaceOperationsQuery.data.map((operation) => (
|
||||
<div key={operation.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div key={operation.id} className="rounded-md border border-border/80 bg-muted/30 px-4 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
|
||||
|
|
@ -909,7 +935,8 @@ export function ExecutionWorkspaceDetail() {
|
|||
) : (
|
||||
<p className="text-sm text-muted-foreground">No workspace operations have been recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ExecutionWorkspaceIssuesList
|
||||
companyId={workspace.companyId}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
|
@ -19,18 +19,16 @@ import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveSta
|
|||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { ProjectWorkspaceSummaryCard } from "../components/ProjectWorkspaceSummaryCard";
|
||||
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
|
||||
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
||||
import { projectRouteRef } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/* ── Top-level tab types ── */
|
||||
|
||||
|
|
@ -215,110 +213,6 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
|||
);
|
||||
}
|
||||
|
||||
function ProjectWorkspacesContent({
|
||||
companyId,
|
||||
projectId,
|
||||
projectRef,
|
||||
summaries,
|
||||
}: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
projectRef: string;
|
||||
summaries: ReturnType<typeof buildProjectWorkspaceSummaries>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [runtimeActionKey, setRuntimeActionKey] = useState<string | null>(null);
|
||||
const [closingWorkspace, setClosingWorkspace] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: ExecutionWorkspace["status"];
|
||||
} | null>(null);
|
||||
const controlWorkspaceRuntime = useMutation({
|
||||
mutationFn: async (input: {
|
||||
key: string;
|
||||
kind: "project_workspace" | "execution_workspace";
|
||||
workspaceId: string;
|
||||
action: "start" | "stop" | "restart";
|
||||
}) => {
|
||||
setRuntimeActionKey(`${input.key}:${input.action}`);
|
||||
if (input.kind === "project_workspace") {
|
||||
return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId);
|
||||
}
|
||||
return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action);
|
||||
},
|
||||
onSettled: () => {
|
||||
setRuntimeActionKey(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||
},
|
||||
});
|
||||
|
||||
if (summaries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
|
||||
}
|
||||
|
||||
const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed");
|
||||
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{activeSummaries.map((summary) => (
|
||||
<ProjectWorkspaceSummaryCard
|
||||
key={summary.key}
|
||||
projectRef={projectRef}
|
||||
summary={summary}
|
||||
runtimeActionKey={runtimeActionKey}
|
||||
runtimeActionPending={controlWorkspaceRuntime.isPending}
|
||||
onRuntimeAction={(input) => controlWorkspaceRuntime.mutate(input)}
|
||||
onCloseWorkspace={(input) => setClosingWorkspace(input)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{cleanupFailedSummaries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Cleanup attention needed
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-amber-500/20 bg-amber-500/5">
|
||||
{cleanupFailedSummaries.map((summary) => (
|
||||
<ProjectWorkspaceSummaryCard
|
||||
key={summary.key}
|
||||
projectRef={projectRef}
|
||||
summary={summary}
|
||||
runtimeActionKey={runtimeActionKey}
|
||||
runtimeActionPending={controlWorkspaceRuntime.isPending}
|
||||
onRuntimeAction={(input) => controlWorkspaceRuntime.mutate(input)}
|
||||
onCloseWorkspace={(input) => setClosingWorkspace(input)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{closingWorkspace ? (
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
workspaceId={closingWorkspace.id}
|
||||
workspaceName={closingWorkspace.name}
|
||||
currentStatus={closingWorkspace.status}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setClosingWorkspace(null);
|
||||
}}
|
||||
onClosed={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||
setClosingWorkspace(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main project page ── */
|
||||
|
||||
export function ProjectDetail() {
|
||||
|
|
|
|||
163
ui/src/pages/Workspaces.tsx
Normal file
163
ui/src/pages/Workspaces.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { Link, Navigate } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { buildProjectWorkspaceSummaries, type ProjectWorkspaceSummary } from "../lib/project-workspaces-tab";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { projectRouteRef } from "../lib/utils";
|
||||
|
||||
type ProjectWorkspaceGroup = {
|
||||
project: Project;
|
||||
projectRef: string;
|
||||
summaries: ProjectWorkspaceSummary[];
|
||||
lastUpdatedAt: Date;
|
||||
runningServiceCount: number;
|
||||
};
|
||||
|
||||
function buildProjectWorkspaceGroups(input: {
|
||||
projects: Project[];
|
||||
issues: Issue[];
|
||||
executionWorkspaces: ExecutionWorkspace[];
|
||||
}): ProjectWorkspaceGroup[] {
|
||||
const issuesByProjectId = new Map<string, Issue[]>();
|
||||
for (const issue of input.issues) {
|
||||
if (!issue.projectId) continue;
|
||||
const existing = issuesByProjectId.get(issue.projectId) ?? [];
|
||||
existing.push(issue);
|
||||
issuesByProjectId.set(issue.projectId, existing);
|
||||
}
|
||||
|
||||
const executionWorkspacesByProjectId = new Map<string, ExecutionWorkspace[]>();
|
||||
for (const workspace of input.executionWorkspaces) {
|
||||
if (!workspace.projectId) continue;
|
||||
const existing = executionWorkspacesByProjectId.get(workspace.projectId) ?? [];
|
||||
existing.push(workspace);
|
||||
executionWorkspacesByProjectId.set(workspace.projectId, existing);
|
||||
}
|
||||
|
||||
return input.projects
|
||||
.map((project) => {
|
||||
const summaries = buildProjectWorkspaceSummaries({
|
||||
project,
|
||||
issues: issuesByProjectId.get(project.id) ?? [],
|
||||
executionWorkspaces: executionWorkspacesByProjectId.get(project.id) ?? [],
|
||||
});
|
||||
if (summaries.length === 0) return null;
|
||||
return {
|
||||
project,
|
||||
projectRef: projectRouteRef(project),
|
||||
summaries,
|
||||
lastUpdatedAt: summaries.reduce(
|
||||
(latest, summary) => summary.lastUpdatedAt.getTime() > latest.getTime() ? summary.lastUpdatedAt : latest,
|
||||
new Date(0),
|
||||
),
|
||||
runningServiceCount: summaries.reduce((count, summary) => count + summary.runningServiceCount, 0),
|
||||
};
|
||||
})
|
||||
.filter((group): group is ProjectWorkspaceGroup => group !== null)
|
||||
.sort((a, b) => {
|
||||
const runningDiff = b.runningServiceCount - a.runningServiceCount;
|
||||
if (runningDiff !== 0) return runningDiff;
|
||||
const updatedDiff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||
return updatedDiff !== 0 ? updatedDiff : a.project.name.localeCompare(b.project.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function Workspaces() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const experimentalSettingsQuery = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
|
||||
|
||||
const { data: projects = [], isLoading: projectsLoading, error: projectsError } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.projects.list(selectedCompanyId) : ["projects", "__workspaces__", "disabled"],
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId && isolatedWorkspacesEnabled),
|
||||
});
|
||||
const { data: issues = [], isLoading: issuesLoading, error: issuesError } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.issues.list(selectedCompanyId) : ["issues", "__workspaces__", "disabled"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId && isolatedWorkspacesEnabled),
|
||||
});
|
||||
const {
|
||||
data: executionWorkspaces = [],
|
||||
isLoading: executionWorkspacesLoading,
|
||||
error: executionWorkspacesError,
|
||||
} = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.executionWorkspaces.list(selectedCompanyId)
|
||||
: ["execution-workspaces", "__workspaces__", "disabled"],
|
||||
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId && isolatedWorkspacesEnabled),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Workspaces" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => buildProjectWorkspaceGroups({ projects, issues, executionWorkspaces }),
|
||||
[executionWorkspaces, issues, projects],
|
||||
);
|
||||
const dataLoading = projectsLoading || issuesLoading || executionWorkspacesLoading;
|
||||
const error = (projectsError ?? issuesError ?? executionWorkspacesError) as Error | null;
|
||||
|
||||
if (experimentalSettingsQuery.isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (!isolatedWorkspacesEnabled) return <Navigate to="/issues" replace />;
|
||||
if (dataLoading) return <PageSkeleton variant="list" />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Workspaces</h2>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No workspace activity yet.</p>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{groups.map((group) => (
|
||||
<section key={group.project.id} className="space-y-3">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/projects/${group.projectRef}/workspaces`}
|
||||
className="text-base font-semibold hover:underline"
|
||||
>
|
||||
{group.project.name}
|
||||
</Link>
|
||||
{group.project.description ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{group.project.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{group.summaries.length} workspace{group.summaries.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<ProjectWorkspacesContent
|
||||
companyId={selectedCompanyId!}
|
||||
projectId={group.project.id}
|
||||
projectRef={group.projectRef}
|
||||
summaries={group.summaries}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue