From c6779b570f44079a80a360f3c6d6255a92fb55e0 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 21:58:29 -0500 Subject: [PATCH 01/45] feat(ui): add workspace and parent issue grouping to issues list Adds two new groupBy options on the issues page: "Workspace" groups issues by their projectWorkspaceId, and "Parent Issue" groups by parentId. Groups with items sort first; sentinel groups (No Workspace / No Parent) appear last. Creating a new issue from a parent group pre-fills parentId. Co-Authored-By: Paperclip --- ui/src/components/IssuesList.tsx | 58 ++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 4760d360..06718d6e 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -45,7 +45,7 @@ export type IssueViewState = { projects: string[]; sortField: "status" | "priority" | "title" | "created" | "updated"; sortDir: "asc" | "desc"; - groupBy: "status" | "priority" | "assignee" | "none"; + groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; viewMode: "list" | "board"; collapsedGroups: string[]; collapsedParents: string[]; @@ -155,6 +155,7 @@ interface Agent { interface ProjectOption { id: string; name: string; + workspaces?: { id: string; name: string }[]; } interface IssuesListProps { @@ -265,6 +266,24 @@ export function IssuesList({ return agents.find((a) => a.id === id)?.name ?? null; }, [agents]); + const workspaceNameMap = useMemo(() => { + const map = new Map(); + for (const project of projects ?? []) { + for (const ws of project.workspaces ?? []) { + map.set(ws.id, ws.name || project.name); + } + } + return map; + }, [projects]); + + const issueTitleMap = useMemo(() => { + const map = new Map(); + for (const issue of issues) { + map.set(issue.id, issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title); + } + return map; + }, [issues]); + const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); @@ -295,6 +314,36 @@ export function IssuesList({ .filter((p) => groups[p]?.length) .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); } + if (viewState.groupBy === "workspace") { + const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace"); + return Object.keys(groups) + .sort((a, b) => { + // Groups with items first, "no workspace" last + if (a === "__no_workspace") return 1; + if (b === "__no_workspace") return -1; + return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0); + }) + .map((key) => ({ + key, + label: key === "__no_workspace" ? "No Workspace" : (workspaceNameMap.get(key) ?? key.slice(0, 8)), + items: groups[key]!, + })); + } + if (viewState.groupBy === "parent") { + const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent"); + return Object.keys(groups) + .sort((a, b) => { + // Groups with items first, "no parent" last + if (a === "__no_parent") return 1; + if (b === "__no_parent") return -1; + return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0); + }) + .map((key) => ({ + key, + label: key === "__no_parent" ? "No Parent" : (issueTitleMap.get(key) ?? key.slice(0, 8)), + items: groups[key]!, + })); + } // assignee const groups = groupBy( filtered, @@ -310,7 +359,7 @@ export function IssuesList({ : (agentName(key) ?? key.slice(0, 8)), items: groups[key]!, })); - }, [filtered, viewState.groupBy, agents, agentName, currentUserId]); + }, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]); const newIssueDefaults = useCallback((groupKey?: string) => { const defaults: Record = {}; @@ -322,6 +371,9 @@ export function IssuesList({ if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length); else defaults.assigneeAgentId = groupKey; } + else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") { + defaults.parentId = groupKey; + } } return defaults; }, [projectId, viewState.groupBy]); @@ -605,6 +657,8 @@ export function IssuesList({ ["status", "Status"], ["priority", "Priority"], ["assignee", "Assignee"], + ["workspace", "Workspace"], + ["parent", "Parent Issue"], ["none", "None"], ] as const).map(([value, label]) => ( -
- - { - setIssueSearch(e.target.value); - onSearchChange?.(e.target.value); - }} - placeholder="Search issues..." - className="pl-7 text-xs sm:text-sm" - aria-label="Search issues" - /> -
+ { + setIssueSearch(nextSearch); + onSearchChange?.(nextSearch); + }} + />
From 1cbb0a5e34e801aa8576cc5ab5e110d43c1d7a0d Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 7 Apr 2026 16:33:37 -0500 Subject: [PATCH 03/45] Add execution workspace issues tab --- ui/src/App.tsx | 4 + ui/src/lib/company-routes.test.ts | 7 + ui/src/pages/ExecutionWorkspaceDetail.tsx | 952 ++++++++++++---------- 3 files changed, 532 insertions(+), 431 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 804e8d48..7aac9dfa 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -161,6 +161,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -349,6 +351,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> }> diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index ced25744..866b4a62 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -9,16 +9,23 @@ import { describe("company routes", () => { 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/issues")).toBe(true); expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull(); expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe( "/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", () => { expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe( "/execution-workspaces/workspace-123", ); + expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123/configuration")).toBe( + "/execution-workspaces/workspace-123/configuration", + ); }); /** diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index e5445890..20168b66 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -1,15 +1,20 @@ 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 type { ExecutionWorkspace, Project, ProjectWorkspace } from "@paperclipai/shared"; -import { ArrowLeft, Check, Copy, ExternalLink, Loader2 } from "lucide-react"; +import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared"; +import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { Tabs } from "@/components/ui/tabs"; import { CopyText } from "../components/CopyText"; import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; +import { agentsApi } from "../api/agents"; import { executionWorkspacesApi } from "../api/execution-workspaces"; +import { heartbeatsApi } from "../api/heartbeats"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; +import { IssuesList } from "../components/IssuesList"; +import { PageTabBar } from "../components/PageTabBar"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; @@ -29,6 +34,18 @@ type WorkspaceFormState = { 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) { if (!value) return false; try { @@ -214,8 +231,79 @@ function WorkspaceLink({ return {workspace.name}; } +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(); + 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 }) => 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 ( + updateIssue.mutate({ id, data })} + /> + ); +} + export function ExecutionWorkspaceDetail() { const { workspaceId } = useParams<{ workspaceId: string }>(); + const location = useLocation(); + const navigate = useNavigate(); const queryClient = useQueryClient(); const { setBreadcrumbs } = useBreadcrumbs(); const { selectedCompanyId, setSelectedCompanyId } = useCompany(); @@ -223,6 +311,7 @@ export function ExecutionWorkspaceDetail() { const [closeDialogOpen, setCloseDialogOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); + const activeTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null; const workspaceQuery = useQuery({ queryKey: queryKeys.executionWorkspaces.detail(workspaceId!), @@ -357,6 +446,24 @@ export function ExecutionWorkspaceDetail() { } 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 ; + } + + const handleTabChange = (tab: ExecutionWorkspaceTab) => { + try { + localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab); + } catch {} + navigate(`/execution-workspaces/${workspace.id}/${tab}`); + }; + const saveChanges = () => { const validationError = validateForm(form); if (validationError) { @@ -393,468 +500,451 @@ export function ExecutionWorkspaceDetail() {
-
-
-
-
-
+
+
+
+
+ Execution workspace +
+

{workspace.name}

+

+ Configure the concrete runtime workspace that Paperclip reuses for this issue flow. + 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. +

+
+
+ +
+
+
+ + handleTabChange(value as ExecutionWorkspaceTab)}> + handleTabChange(value as ExecutionWorkspaceTab)} + /> + + + {activeTab === "configuration" ? ( +
+
+
+
- Execution workspace + Configuration
-

{workspace.name}

-

- Configure the concrete runtime workspace that Paperclip reuses for this issue flow. - 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. +

Workspace settings

+

+ Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace.

-
+ + + +
+ + setForm((current) => current ? { ...current, name: event.target.value } : current)} + placeholder="Execution workspace name" + /> + + + setForm((current) => current ? { ...current, branchName: event.target.value } : current)} + placeholder="PAP-946-workspace" + /> + +
+ +
+ + setForm((current) => current ? { ...current, cwd: event.target.value } : current)} + placeholder="/absolute/path/to/workspace" + /> + + + setForm((current) => current ? { ...current, providerRef: event.target.value } : current)} + placeholder="/path/to/worktree or provider ref" + /> + +
+ +
+ + setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)} + placeholder="https://github.com/org/repo" + /> + + + setForm((current) => current ? { ...current, baseRef: event.target.value } : current)} + placeholder="origin/main" + /> + +
+ +
+ +