mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Add workspace navigation overview
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7b686b2678
commit
37ce7750c1
7 files changed
+451
-108
No files matched your search
@@ -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() {
|
||||
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
||||
<Route path="workspaces" element={<Workspaces />} />
|
||||
<Route path="issues" element={<Issues />} />
|
||||
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
||||
@@ -293,6 +295,7 @@ export function App() {
|
||||
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
|
||||
|
||||
@@ -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<string | null>(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 <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
|
||||
}
|
||||
|
||||
const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed");
|
||||
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{activeSummaries.map((summary) => (
|
||||
<ProjectWorkspaceSummaryCard
|
||||
key={summary.key}
|
||||
projectRef={projectRef}
|
||||
summary={summary}
|
||||
runtimeActionKey={runtimeActionKey}
|
||||
runtimeActionPending={controlWorkspaceRuntime.isPending}
|
||||
onRuntimeAction={(input) => controlWorkspaceRuntime.mutate(input)}
|
||||
onCloseWorkspace={(input) => setClosingWorkspace(input)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{cleanupFailedSummaries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Cleanup attention needed
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{cleanupFailedSummaries.map((summary) => (
|
||||
<ProjectWorkspaceSummaryCard
|
||||
key={summary.key}
|
||||
projectRef={projectRef}
|
||||
summary={summary}
|
||||
runtimeActionKey={runtimeActionKey}
|
||||
runtimeActionPending={controlWorkspaceRuntime.isPending}
|
||||
onRuntimeAction={(input) => controlWorkspaceRuntime.mutate(input)}
|
||||
onCloseWorkspace={(input) => setClosingWorkspace(input)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{closingWorkspace ? (
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
workspaceId={closingWorkspace.id}
|
||||
workspaceName={closingWorkspace.name}
|
||||
currentStatus={closingWorkspace.status}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}) => (
|
||||
<a
|
||||
href={to}
|
||||
className={typeof className === "function" ? className({ isActive: false }) : className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
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: () => <div>Company menu</div>,
|
||||
}));
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Workspaces");
|
||||
expect(link?.getAttribute("href")).toBe("/workspaces");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
|
||||
@@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"org",
|
||||
"agents",
|
||||
"projects",
|
||||
"workspaces",
|
||||
"execution-workspaces",
|
||||
"issues",
|
||||
"routines",
|
||||
|
||||
@@ -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<typeof buildProjectWorkspaceSummaries>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [runtimeActionKey, setRuntimeActionKey] = useState<string | null>(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 <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
|
||||
}
|
||||
|
||||
const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed");
|
||||
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{activeSummaries.map((summary) => (
|
||||
<ProjectWorkspaceSummaryCard
|
||||
key={summary.key}
|
||||
projectRef={projectRef}
|
||||
summary={summary}
|
||||
runtimeActionKey={runtimeActionKey}
|
||||
runtimeActionPending={controlWorkspaceRuntime.isPending}
|
||||
onRuntimeAction={(input) => controlWorkspaceRuntime.mutate(input)}
|
||||
onCloseWorkspace={(input) => setClosingWorkspace(input)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{cleanupFailedSummaries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Cleanup attention needed
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{cleanupFailedSummaries.map((summary) => (
|
||||
<ProjectWorkspaceSummaryCard
|
||||
key={summary.key}
|
||||
projectRef={projectRef}
|
||||
summary={summary}
|
||||
runtimeActionKey={runtimeActionKey}
|
||||
runtimeActionPending={controlWorkspaceRuntime.isPending}
|
||||
onRuntimeAction={(input) => controlWorkspaceRuntime.mutate(input)}
|
||||
onCloseWorkspace={(input) => setClosingWorkspace(input)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{closingWorkspace ? (
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
workspaceId={closingWorkspace.id}
|
||||
workspaceName={closingWorkspace.name}
|
||||
currentStatus={closingWorkspace.status}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user