[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:
Dotta 2026-04-20 06:14:32 -05:00 committed by GitHub
parent d8b63a18e7
commit fee514efcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1348 additions and 351 deletions

View file

@ -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}