[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

163
ui/src/pages/Workspaces.tsx Normal file
View 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>
);
}