diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 9fe1f9d9dfb..55198b0e726 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -10,6 +10,7 @@ import { AgentDetail } from "./pages/AgentDetail"; import { Projects } from "./pages/Projects"; import { ProjectDetail } from "./pages/ProjectDetail"; import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; +import { Workspaces } from "./pages/Workspaces"; import { Issues } from "./pages/Issues"; import { IssueDetail } from "./pages/IssueDetail"; import { Routines } from "./pages/Routines"; @@ -89,6 +90,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -293,6 +295,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/ProjectWorkspacesContent.tsx b/ui/src/components/ProjectWorkspacesContent.tsx new file mode 100644 index 00000000000..4f18ded2d9d --- /dev/null +++ b/ui/src/components/ProjectWorkspacesContent.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ExecutionWorkspace } from "@paperclipai/shared"; +import { executionWorkspacesApi } from "../api/execution-workspaces"; +import { projectsApi } from "../api/projects"; +import { queryKeys } from "../lib/queryKeys"; +import type { ProjectWorkspaceSummary } from "../lib/project-workspaces-tab"; +import { ExecutionWorkspaceCloseDialog } from "./ExecutionWorkspaceCloseDialog"; +import { ProjectWorkspaceSummaryCard } from "./ProjectWorkspaceSummaryCard"; + +export function ProjectWorkspacesContent({ + companyId, + projectId, + projectRef, + summaries, +}: { + companyId: string; + projectId: string; + projectRef: string; + summaries: ProjectWorkspaceSummary[]; +}) { + const queryClient = useQueryClient(); + const [runtimeActionKey, setRuntimeActionKey] = useState(null); + const [closingWorkspace, setClosingWorkspace] = useState<{ + id: string; + name: string; + status: ExecutionWorkspace["status"]; + } | null>(null); + const controlWorkspaceRuntime = useMutation({ + mutationFn: async (input: { + key: string; + kind: "project_workspace" | "execution_workspace"; + workspaceId: string; + action: "start" | "stop" | "restart"; + }) => { + setRuntimeActionKey(`${input.key}:${input.action}`); + if (input.kind === "project_workspace") { + return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId); + } + return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action); + }, + onSettled: () => { + setRuntimeActionKey(null); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) }); + }, + }); + + if (summaries.length === 0) { + return

No non-default workspace activity yet.

; + } + + const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed"); + const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed"); + + return ( + <> +
+
+ {activeSummaries.map((summary) => ( + controlWorkspaceRuntime.mutate(input)} + onCloseWorkspace={(input) => setClosingWorkspace(input)} + /> + ))} +
+ {cleanupFailedSummaries.length > 0 ? ( +
+
+ Cleanup attention needed +
+
+ {cleanupFailedSummaries.map((summary) => ( + controlWorkspaceRuntime.mutate(input)} + onCloseWorkspace={(input) => setClosingWorkspace(input)} + /> + ))} +
+
+ ) : null} +
+ {closingWorkspace ? ( + { + if (!open) setClosingWorkspace(null); + }} + onClosed={() => { + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) }); + setClosingWorkspace(null); + }} + /> + ) : null} + + ); +} diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx new file mode 100644 index 00000000000..6d52ddc3f2f --- /dev/null +++ b/ui/src/components/Sidebar.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Sidebar } from "./Sidebar"; + +const mockHeartbeatsApi = vi.hoisted(() => ({ + liveRunsForCompany: vi.fn(), +})); + +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +vi.mock("@/lib/router", () => ({ + NavLink: ({ to, children, className, ...props }: { + to: string; + children: ReactNode; + className?: string | ((state: { isActive: boolean }) => string); + }) => ( + + {children} + + ), +})); + +vi.mock("../context/DialogContext", () => ({ + useDialog: () => ({ + openNewIssue: vi.fn(), + }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" }, + }), +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + setSidebarOpen: vi.fn(), + }), +})); + +vi.mock("../api/heartbeats", () => ({ + heartbeatsApi: mockHeartbeatsApi, +})); + +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + +vi.mock("../hooks/useInboxBadge", () => ({ + useInboxBadge: () => ({ inbox: 0, failedRuns: 0 }), +})); + +vi.mock("@/plugins/slots", () => ({ + PluginSlotOutlet: () => null, +})); + +vi.mock("./SidebarCompanyMenu", () => ({ + SidebarCompanyMenu: () =>
Company menu
, +})); + +vi.mock("./SidebarProjects", () => ({ + SidebarProjects: () => null, +})); + +vi.mock("./SidebarAgents", () => ({ + SidebarAgents: () => null, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("Sidebar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("does not flash the Workspaces link while experimental settings are loading", async () => { + mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {})); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).not.toContain("Workspaces"); + + await act(async () => { + root.unmount(); + }); + }); + + it("shows the Workspaces link when isolated workspaces are enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Workspaces"); + expect(link?.getAttribute("href")).toBe("/workspaces"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 1b494576494..28e8957d3f4 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -10,6 +10,7 @@ import { Network, Boxes, Repeat, + GitBranch, Settings, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; @@ -20,6 +21,7 @@ import { SidebarAgents } from "./SidebarAgents"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { heartbeatsApi } from "../api/heartbeats"; +import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; import { useInboxBadge } from "../hooks/useInboxBadge"; import { Button } from "@/components/ui/button"; @@ -30,6 +32,10 @@ export function Sidebar() { const { openNewIssue } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); const inboxBadge = useInboxBadge(selectedCompanyId); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); const { data: liveRuns } = useQuery({ queryKey: queryKeys.liveRuns(selectedCompanyId!), queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), @@ -37,6 +43,7 @@ export function Sidebar() { refetchInterval: 10_000, }); const liveRunCount = liveRuns?.length ?? 0; + const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; function openSearch() { document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true })); @@ -94,6 +101,9 @@ export function Sidebar() { + {showWorkspacesLink ? ( + + ) : null} diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 48d71ef2f34..a14eff0f8ee 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "org", "agents", "projects", + "workspaces", "execution-workspaces", "issues", "routines", diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index eaa65f0972c..5fc126310a5 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace } from "@paperclipai/shared"; +import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared"; import { budgetsApi } from "../api/budgets"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -19,18 +19,16 @@ import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveSta import { InlineEditor } from "../components/InlineEditor"; import { StatusBadge } from "../components/StatusBadge"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; -import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; -import { ProjectWorkspaceSummaryCard } from "../components/ProjectWorkspaceSummaryCard"; +import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent"; import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab"; import { projectRouteRef } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; -import { Loader2 } from "lucide-react"; /* ── Top-level tab types ── */ @@ -215,110 +213,6 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan ); } -function ProjectWorkspacesContent({ - companyId, - projectId, - projectRef, - summaries, -}: { - companyId: string; - projectId: string; - projectRef: string; - summaries: ReturnType; -}) { - const queryClient = useQueryClient(); - const [runtimeActionKey, setRuntimeActionKey] = useState(null); - const [closingWorkspace, setClosingWorkspace] = useState<{ - id: string; - name: string; - status: ExecutionWorkspace["status"]; - } | null>(null); - const controlWorkspaceRuntime = useMutation({ - mutationFn: async (input: { - key: string; - kind: "project_workspace" | "execution_workspace"; - workspaceId: string; - action: "start" | "stop" | "restart"; - }) => { - setRuntimeActionKey(`${input.key}:${input.action}`); - if (input.kind === "project_workspace") { - return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId); - } - return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action); - }, - onSettled: () => { - setRuntimeActionKey(null); - queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) }); - queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) }); - }, - }); - - if (summaries.length === 0) { - return

No non-default workspace activity yet.

; - } - - const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed"); - const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed"); - - return ( - <> -
-
- {activeSummaries.map((summary) => ( - controlWorkspaceRuntime.mutate(input)} - onCloseWorkspace={(input) => setClosingWorkspace(input)} - /> - ))} -
- {cleanupFailedSummaries.length > 0 ? ( -
-
- Cleanup attention needed -
-
- {cleanupFailedSummaries.map((summary) => ( - controlWorkspaceRuntime.mutate(input)} - onCloseWorkspace={(input) => setClosingWorkspace(input)} - /> - ))} -
-
- ) : null} -
- {closingWorkspace ? ( - { - if (!open) setClosingWorkspace(null); - }} - onClosed={() => { - queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) }); - queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) }); - setClosingWorkspace(null); - }} - /> - ) : null} - - ); -} - /* ── Main project page ── */ export function ProjectDetail() { diff --git a/ui/src/pages/Workspaces.tsx b/ui/src/pages/Workspaces.tsx new file mode 100644 index 00000000000..11432525406 --- /dev/null +++ b/ui/src/pages/Workspaces.tsx @@ -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(); + 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(); + 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 ; + if (!isolatedWorkspacesEnabled) return ; + if (dataLoading) return ; + if (error) return

{error.message}

; + + return ( +
+
+

Workspaces

+
+ + {groups.length === 0 ? ( +

No workspace activity yet.

+ ) : ( +
+ {groups.map((group) => ( +
+
+
+ + {group.project.name} + + {group.project.description ? ( +

+ {group.project.description} +

+ ) : null} +
+ + {group.summaries.length} workspace{group.summaries.length === 1 ? "" : "s"} + +
+ +
+ ))} +
+ )} +
+ ); +}