Add execution workspace issues tab

This commit is contained in:
dotta 2026-04-07 16:33:37 -05:00
parent 93355bae6b
commit 1cbb0a5e34
3 changed files with 532 additions and 431 deletions

View file

@ -161,6 +161,8 @@ function boardRoutes() {
<Route path="routines" element={<Routines />} /> <Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} /> <Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} /> <Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} /> <Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} /> <Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} /> <Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
@ -349,6 +351,8 @@ export function App() {
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} /> <Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} /> <Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} /> <Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} /> <Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} /> <Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}> <Route path=":companyPrefix" element={<Layout />}>

View file

@ -9,16 +9,23 @@ import {
describe("company routes", () => { describe("company routes", () => {
it("treats execution workspace paths as board routes that need a company prefix", () => { it("treats execution workspace paths as board routes that need a company prefix", () => {
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true); expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/issues")).toBe(true);
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull(); expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe( expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
"/PAP/execution-workspaces/workspace-123", "/PAP/execution-workspaces/workspace-123",
); );
expect(applyCompanyPrefix("/execution-workspaces/workspace-123/issues", "PAP")).toBe(
"/PAP/execution-workspaces/workspace-123/issues",
);
}); });
it("normalizes prefixed execution workspace paths back to company-relative paths", () => { it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe( expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
"/execution-workspaces/workspace-123", "/execution-workspaces/workspace-123",
); );
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123/configuration")).toBe(
"/execution-workspaces/workspace-123/configuration",
);
}); });
/** /**

View file

@ -1,15 +1,20 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "@/lib/router"; import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ExecutionWorkspace, Project, ProjectWorkspace } from "@paperclipai/shared"; import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
import { ArrowLeft, Check, Copy, ExternalLink, Loader2 } from "lucide-react"; import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tabs } from "@/components/ui/tabs";
import { CopyText } from "../components/CopyText"; import { CopyText } from "../components/CopyText";
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
import { agentsApi } from "../api/agents";
import { executionWorkspacesApi } from "../api/execution-workspaces"; import { executionWorkspacesApi } from "../api/execution-workspaces";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { IssuesList } from "../components/IssuesList";
import { PageTabBar } from "../components/PageTabBar";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
@ -29,6 +34,18 @@ type WorkspaceFormState = {
workspaceRuntime: string; workspaceRuntime: string;
}; };
type ExecutionWorkspaceTab = "configuration" | "issues";
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
const segments = pathname.split("/").filter(Boolean);
const executionWorkspacesIndex = segments.indexOf("execution-workspaces");
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
const tab = segments[executionWorkspacesIndex + 2];
if (tab === "issues") return "issues";
if (tab === "configuration") return "configuration";
return null;
}
function isSafeExternalUrl(value: string | null | undefined) { function isSafeExternalUrl(value: string | null | undefined) {
if (!value) return false; if (!value) return false;
try { try {
@ -214,8 +231,79 @@ function WorkspaceLink({
return <Link to={projectWorkspaceUrl(project, workspace.id)} className="hover:underline">{workspace.name}</Link>; return <Link to={projectWorkspaceUrl(project, workspace.id)} className="hover:underline">{workspace.name}</Link>;
} }
function ExecutionWorkspaceIssuesList({
companyId,
workspaceId,
issues,
isLoading,
error,
project,
}: {
companyId: string;
workspaceId: string;
issues: Issue[];
isLoading: boolean;
error: Error | null;
project: Project | null;
}) {
const queryClient = useQueryClient();
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId),
enabled: !!companyId,
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(companyId),
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
enabled: !!companyId,
refetchInterval: 5000,
});
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspaceId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
if (project?.id) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, project.id) });
}
},
});
const projectOptions = useMemo(
() => (project ? [{ id: project.id, name: project.name, workspaces: project.workspaces ?? [] }] : undefined),
[project],
);
return (
<IssuesList
issues={issues}
isLoading={isLoading}
error={error}
agents={agents}
projects={projectOptions}
liveIssueIds={liveIssueIds}
projectId={project?.id}
viewStateKey={`paperclip:execution-workspace-view:${workspaceId}`}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
);
}
export function ExecutionWorkspaceDetail() { export function ExecutionWorkspaceDetail() {
const { workspaceId } = useParams<{ workspaceId: string }>(); const { workspaceId } = useParams<{ workspaceId: string }>();
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const { selectedCompanyId, setSelectedCompanyId } = useCompany(); const { selectedCompanyId, setSelectedCompanyId } = useCompany();
@ -223,6 +311,7 @@ export function ExecutionWorkspaceDetail() {
const [closeDialogOpen, setCloseDialogOpen] = useState(false); const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const activeTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null;
const workspaceQuery = useQuery({ const workspaceQuery = useQuery({
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!), queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
@ -357,6 +446,24 @@ export function ExecutionWorkspaceDetail() {
} }
if (!workspace || !form || !initialState) return null; if (!workspace || !form || !initialState) return null;
if (workspaceId && activeTab === null) {
let cachedTab: ExecutionWorkspaceTab = "configuration";
try {
const storedTab = localStorage.getItem(`paperclip:execution-workspace-tab:${workspaceId}`);
if (storedTab === "issues" || storedTab === "configuration") {
cachedTab = storedTab;
}
} catch {}
return <Navigate to={`/execution-workspaces/${workspaceId}/${cachedTab}`} replace />;
}
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
try {
localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab);
} catch {}
navigate(`/execution-workspaces/${workspace.id}/${tab}`);
};
const saveChanges = () => { const saveChanges = () => {
const validationError = validateForm(form); const validationError = validateForm(form);
if (validationError) { if (validationError) {
@ -393,8 +500,6 @@ export function ExecutionWorkspaceDetail() {
</StatusPill> </StatusPill>
</div> </div>
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
<div className="min-w-0 space-y-4 sm:space-y-6">
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5"> <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="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
@ -404,9 +509,7 @@ export function ExecutionWorkspaceDetail() {
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1> <h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground"> <p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete runtime workspace that Paperclip reuses for this issue flow. Configure the concrete runtime workspace that Paperclip reuses for this issue flow.
<span className="hidden sm:inline"> These settings stay <span className="hidden sm:inline"> These settings stay attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown, and runtime-service behavior in sync with the actual workspace being reused.</span>
attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown,
and runtime-service behavior in sync with the actual workspace being reused.</span>
</p> </p>
</div> </div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto"> <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
@ -420,6 +523,33 @@ export function ExecutionWorkspaceDetail() {
</Button> </Button>
</div> </div>
</div> </div>
</div>
<Tabs value={activeTab ?? "configuration"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
<PageTabBar
items={[
{ value: "configuration", label: "Configuration" },
{ value: "issues", label: "Issues" },
]}
align="start"
value={activeTab ?? "configuration"}
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
/>
</Tabs>
{activeTab === "configuration" ? (
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
<div className="min-w-0 space-y-4 sm:space-y-6">
<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">
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>
<Separator className="my-5" /> <Separator className="my-5" />
@ -551,7 +681,6 @@ export function ExecutionWorkspaceDetail() {
const checked = event.target.checked; const checked = event.target.checked;
setForm((current) => { setForm((current) => {
if (!current) return current; if (!current) return current;
// When unchecking "inherit" and the field is empty, copy inherited config as a starting point
if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) { if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) {
return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) }; return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) };
} }
@ -627,7 +756,7 @@ export function ExecutionWorkspaceDetail() {
</DetailRow> </DetailRow>
<DetailRow label="Derived from"> <DetailRow label="Derived from">
{derivedWorkspace ? ( {derivedWorkspace ? (
<Link to={`/execution-workspaces/${derivedWorkspace.id}`} className="hover:underline"> <Link to={`/execution-workspaces/${derivedWorkspace.id}/configuration`} className="hover:underline">
{derivedWorkspace.name} {derivedWorkspace.name}
</Link> </Link>
) : workspace.derivedFromExecutionWorkspaceId ? ( ) : workspace.derivedFromExecutionWorkspaceId ? (
@ -806,56 +935,17 @@ export function ExecutionWorkspaceDetail() {
</div> </div>
</div> </div>
</div> </div>
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
<h2 className="text-lg font-semibold">Issues using this workspace</h2>
<p className="text-sm text-muted-foreground">
Any issue attached to this execution workspace appears here so you can review the full session context before reusing or closing it.
</p>
</div>
<StatusPill>{linkedIssues.length} linked</StatusPill>
</div>
<Separator className="my-4" />
{linkedIssuesQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading linked issues</p>
) : linkedIssuesQuery.error ? (
<p className="text-sm text-destructive">
{linkedIssuesQuery.error instanceof Error
? linkedIssuesQuery.error.message
: "Failed to load linked issues."}
</p>
) : linkedIssues.length > 0 ? (
<div className="-mx-1 flex flex-col gap-3 px-1 pb-1 sm:flex-row sm:overflow-x-auto">
{linkedIssues.map((issue) => (
<Link
key={issue.id}
to={issueUrl(issue)}
className="rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20 sm:min-w-72"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</div>
<div className="line-clamp-2 text-sm font-medium">{issue.title}</div>
</div>
<StatusPill className="shrink-0">{issue.status}</StatusPill>
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="uppercase tracking-[0.16em]">{issue.priority}</span>
<span>{formatDateTime(issue.updatedAt)}</span>
</div>
</Link>
))}
</div>
) : ( ) : (
<p className="text-sm text-muted-foreground">No issues are currently linked to this execution workspace.</p> <ExecutionWorkspaceIssuesList
companyId={workspace.companyId}
workspaceId={workspace.id}
issues={linkedIssues}
isLoading={linkedIssuesQuery.isLoading}
error={linkedIssuesQuery.error as Error | null}
project={project}
/>
)} )}
</div> </div>
</div>
<ExecutionWorkspaceCloseDialog <ExecutionWorkspaceCloseDialog
workspaceId={workspace.id} workspaceId={workspace.id}
workspaceName={workspace.name} workspaceName={workspace.name}