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" + /> + +
+ +
+ +