mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00: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
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