Add project workspaces tab
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6a72faf83b
commit
b75ac76b13
4 changed files with 465 additions and 4 deletions
|
|
@ -144,6 +144,7 @@ function boardRoutes() {
|
||||||
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
||||||
|
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
||||||
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
||||||
<Route path="issues" element={<Issues />} />
|
<Route path="issues" element={<Issues />} />
|
||||||
|
|
|
||||||
198
ui/src/lib/project-workspaces-tab.test.ts
Normal file
198
ui/src/lib/project-workspaces-tab.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||||
|
import { buildProjectWorkspaceSummaries } from "./project-workspaces-tab";
|
||||||
|
|
||||||
|
function createProjectWorkspace(overrides: Partial<ProjectWorkspace>): ProjectWorkspace {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? "workspace-default",
|
||||||
|
companyId: overrides.companyId ?? "company-1",
|
||||||
|
projectId: overrides.projectId ?? "project-1",
|
||||||
|
name: overrides.name ?? "paperclip",
|
||||||
|
sourceType: overrides.sourceType ?? "local_path",
|
||||||
|
cwd: overrides.cwd ?? "/repo",
|
||||||
|
repoUrl: overrides.repoUrl ?? null,
|
||||||
|
repoRef: overrides.repoRef ?? null,
|
||||||
|
defaultRef: overrides.defaultRef ?? null,
|
||||||
|
visibility: overrides.visibility ?? "default",
|
||||||
|
setupCommand: overrides.setupCommand ?? null,
|
||||||
|
cleanupCommand: overrides.cleanupCommand ?? null,
|
||||||
|
remoteProvider: overrides.remoteProvider ?? null,
|
||||||
|
remoteWorkspaceRef: overrides.remoteWorkspaceRef ?? null,
|
||||||
|
sharedWorkspaceKey: overrides.sharedWorkspaceKey ?? null,
|
||||||
|
metadata: overrides.metadata ?? null,
|
||||||
|
isPrimary: overrides.isPrimary ?? false,
|
||||||
|
runtimeServices: overrides.runtimeServices ?? [],
|
||||||
|
createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||||
|
updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIssue(overrides: Partial<Issue>): Issue {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? "issue-1",
|
||||||
|
companyId: overrides.companyId ?? "company-1",
|
||||||
|
projectId: overrides.projectId ?? "project-1",
|
||||||
|
projectWorkspaceId: overrides.projectWorkspaceId ?? null,
|
||||||
|
goalId: overrides.goalId ?? null,
|
||||||
|
parentId: overrides.parentId ?? null,
|
||||||
|
title: overrides.title ?? "Issue",
|
||||||
|
description: overrides.description ?? null,
|
||||||
|
status: overrides.status ?? "todo",
|
||||||
|
priority: overrides.priority ?? "medium",
|
||||||
|
assigneeAgentId: overrides.assigneeAgentId ?? null,
|
||||||
|
assigneeUserId: overrides.assigneeUserId ?? null,
|
||||||
|
checkoutRunId: overrides.checkoutRunId ?? null,
|
||||||
|
executionRunId: overrides.executionRunId ?? null,
|
||||||
|
executionAgentNameKey: overrides.executionAgentNameKey ?? null,
|
||||||
|
executionLockedAt: overrides.executionLockedAt ?? null,
|
||||||
|
createdByAgentId: overrides.createdByAgentId ?? null,
|
||||||
|
createdByUserId: overrides.createdByUserId ?? null,
|
||||||
|
issueNumber: overrides.issueNumber ?? null,
|
||||||
|
identifier: overrides.identifier ?? null,
|
||||||
|
requestDepth: overrides.requestDepth ?? 0,
|
||||||
|
billingCode: overrides.billingCode ?? null,
|
||||||
|
assigneeAdapterOverrides: overrides.assigneeAdapterOverrides ?? null,
|
||||||
|
executionWorkspaceId: overrides.executionWorkspaceId ?? null,
|
||||||
|
executionWorkspacePreference: overrides.executionWorkspacePreference ?? null,
|
||||||
|
executionWorkspaceSettings: overrides.executionWorkspaceSettings ?? null,
|
||||||
|
startedAt: overrides.startedAt ?? null,
|
||||||
|
completedAt: overrides.completedAt ?? null,
|
||||||
|
cancelledAt: overrides.cancelledAt ?? null,
|
||||||
|
hiddenAt: overrides.hiddenAt ?? null,
|
||||||
|
createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||||
|
updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||||
|
} as Issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExecutionWorkspace(overrides: Partial<ExecutionWorkspace>): ExecutionWorkspace {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? "exec-1",
|
||||||
|
companyId: overrides.companyId ?? "company-1",
|
||||||
|
projectId: overrides.projectId ?? "project-1",
|
||||||
|
projectWorkspaceId: overrides.projectWorkspaceId ?? "workspace-default",
|
||||||
|
sourceIssueId: overrides.sourceIssueId ?? null,
|
||||||
|
mode: overrides.mode ?? "isolated_workspace",
|
||||||
|
strategyType: overrides.strategyType ?? "git_worktree",
|
||||||
|
name: overrides.name ?? "PAP-893",
|
||||||
|
status: overrides.status ?? "active",
|
||||||
|
cwd: overrides.cwd ?? "/repo/.worktrees/PAP-893",
|
||||||
|
repoUrl: overrides.repoUrl ?? null,
|
||||||
|
baseRef: overrides.baseRef ?? "public-gh/master",
|
||||||
|
branchName: overrides.branchName ?? "PAP-893-workspaces-tab",
|
||||||
|
providerType: overrides.providerType ?? "git_worktree",
|
||||||
|
providerRef: overrides.providerRef ?? null,
|
||||||
|
derivedFromExecutionWorkspaceId: overrides.derivedFromExecutionWorkspaceId ?? null,
|
||||||
|
lastUsedAt: overrides.lastUsedAt ?? new Date("2026-03-26T10:00:00Z"),
|
||||||
|
openedAt: overrides.openedAt ?? new Date("2026-03-26T09:00:00Z"),
|
||||||
|
closedAt: overrides.closedAt ?? null,
|
||||||
|
cleanupEligibleAt: overrides.cleanupEligibleAt ?? null,
|
||||||
|
cleanupReason: overrides.cleanupReason ?? null,
|
||||||
|
metadata: overrides.metadata ?? null,
|
||||||
|
createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"),
|
||||||
|
updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildProjectWorkspaceSummaries", () => {
|
||||||
|
const primaryWorkspace = createProjectWorkspace({
|
||||||
|
id: "workspace-default",
|
||||||
|
isPrimary: true,
|
||||||
|
name: "paperclip",
|
||||||
|
});
|
||||||
|
const featureWorkspace = createProjectWorkspace({
|
||||||
|
id: "workspace-feature",
|
||||||
|
name: "feature-checkout",
|
||||||
|
repoRef: "feature/workspaces",
|
||||||
|
updatedAt: new Date("2026-03-25T09:00:00Z"),
|
||||||
|
});
|
||||||
|
const project = {
|
||||||
|
workspaces: [primaryWorkspace, featureWorkspace],
|
||||||
|
primaryWorkspace,
|
||||||
|
} satisfies Pick<Project, "workspaces" | "primaryWorkspace">;
|
||||||
|
|
||||||
|
it("groups isolated execution workspace issues ahead of shared non-primary workspace issues", () => {
|
||||||
|
const summaries = buildProjectWorkspaceSummaries({
|
||||||
|
project,
|
||||||
|
issues: [
|
||||||
|
createIssue({
|
||||||
|
id: "issue-primary",
|
||||||
|
projectWorkspaceId: primaryWorkspace.id,
|
||||||
|
updatedAt: new Date("2026-03-26T08:00:00Z"),
|
||||||
|
}),
|
||||||
|
createIssue({
|
||||||
|
id: "issue-feature-older",
|
||||||
|
projectWorkspaceId: featureWorkspace.id,
|
||||||
|
identifier: "PAP-800",
|
||||||
|
updatedAt: new Date("2026-03-25T10:00:00Z"),
|
||||||
|
}),
|
||||||
|
createIssue({
|
||||||
|
id: "issue-feature-newer",
|
||||||
|
projectWorkspaceId: featureWorkspace.id,
|
||||||
|
identifier: "PAP-801",
|
||||||
|
updatedAt: new Date("2026-03-25T11:00:00Z"),
|
||||||
|
}),
|
||||||
|
createIssue({
|
||||||
|
id: "issue-exec",
|
||||||
|
projectWorkspaceId: primaryWorkspace.id,
|
||||||
|
executionWorkspaceId: "exec-1",
|
||||||
|
identifier: "PAP-893",
|
||||||
|
updatedAt: new Date("2026-03-26T11:00:00Z"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
executionWorkspaces: [
|
||||||
|
createExecutionWorkspace({
|
||||||
|
id: "exec-1",
|
||||||
|
name: "PAP-893",
|
||||||
|
branchName: "PAP-893-workspaces-tab",
|
||||||
|
lastUsedAt: new Date("2026-03-26T10:30:00Z"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summaries).toHaveLength(2);
|
||||||
|
expect(summaries[0]).toMatchObject({
|
||||||
|
key: "execution:exec-1",
|
||||||
|
kind: "execution_workspace",
|
||||||
|
workspaceName: "PAP-893",
|
||||||
|
branchName: "PAP-893-workspaces-tab",
|
||||||
|
executionWorkspaceId: "exec-1",
|
||||||
|
});
|
||||||
|
expect(summaries[0]?.issues.map((issue) => issue.id)).toEqual(["issue-exec"]);
|
||||||
|
|
||||||
|
expect(summaries[1]).toMatchObject({
|
||||||
|
key: "project:workspace-feature",
|
||||||
|
kind: "project_workspace",
|
||||||
|
workspaceName: "feature-checkout",
|
||||||
|
branchName: "feature/workspaces",
|
||||||
|
projectWorkspaceId: "workspace-feature",
|
||||||
|
});
|
||||||
|
expect(summaries[1]?.issues.map((issue) => issue.id)).toEqual([
|
||||||
|
"issue-feature-newer",
|
||||||
|
"issue-feature-older",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate non-primary workspace issues when an execution workspace owns them", () => {
|
||||||
|
const summaries = buildProjectWorkspaceSummaries({
|
||||||
|
project,
|
||||||
|
issues: [
|
||||||
|
createIssue({
|
||||||
|
id: "issue-exec-derived",
|
||||||
|
projectWorkspaceId: featureWorkspace.id,
|
||||||
|
executionWorkspaceId: "exec-2",
|
||||||
|
updatedAt: new Date("2026-03-26T12:00:00Z"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
executionWorkspaces: [
|
||||||
|
createExecutionWorkspace({
|
||||||
|
id: "exec-2",
|
||||||
|
projectWorkspaceId: featureWorkspace.id,
|
||||||
|
name: "feature-branch run",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summaries).toHaveLength(1);
|
||||||
|
expect(summaries[0]?.key).toBe("execution:exec-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
108
ui/src/lib/project-workspaces-tab.ts
Normal file
108
ui/src/lib/project-workspaces-tab.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
type ProjectWorkspaceLike = Pick<Project, "workspaces" | "primaryWorkspace">;
|
||||||
|
|
||||||
|
export interface ProjectWorkspaceSummary {
|
||||||
|
key: string;
|
||||||
|
kind: "execution_workspace" | "project_workspace";
|
||||||
|
workspaceId: string;
|
||||||
|
workspaceName: string;
|
||||||
|
branchName: string | null;
|
||||||
|
lastUpdatedAt: Date;
|
||||||
|
projectWorkspaceId: string | null;
|
||||||
|
executionWorkspaceId: string | null;
|
||||||
|
issues: Issue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDate(value: Date | string | null | undefined): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxDate(...values: Array<Date | string | null | undefined>): Date {
|
||||||
|
let latest = new Date(0);
|
||||||
|
for (const value of values) {
|
||||||
|
const date = toDate(value);
|
||||||
|
if (date && date.getTime() > latest.getTime()) latest = date;
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function primaryWorkspaceId(project: ProjectWorkspaceLike): string | null {
|
||||||
|
return project.primaryWorkspace?.id
|
||||||
|
?? project.workspaces.find((workspace) => workspace.isPrimary)?.id
|
||||||
|
?? project.workspaces[0]?.id
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectWorkspaceSummaries(input: {
|
||||||
|
project: ProjectWorkspaceLike;
|
||||||
|
issues: Issue[];
|
||||||
|
executionWorkspaces: ExecutionWorkspace[];
|
||||||
|
}): ProjectWorkspaceSummary[] {
|
||||||
|
const primaryId = primaryWorkspaceId(input.project);
|
||||||
|
const executionWorkspacesById = new Map(
|
||||||
|
input.executionWorkspaces.map((workspace) => [workspace.id, workspace] as const),
|
||||||
|
);
|
||||||
|
const projectWorkspacesById = new Map(
|
||||||
|
input.project.workspaces.map((workspace) => [workspace.id, workspace] as const),
|
||||||
|
);
|
||||||
|
const summaries = new Map<string, ProjectWorkspaceSummary>();
|
||||||
|
|
||||||
|
for (const issue of input.issues) {
|
||||||
|
if (issue.executionWorkspaceId) {
|
||||||
|
const executionWorkspace = executionWorkspacesById.get(issue.executionWorkspaceId);
|
||||||
|
if (!executionWorkspace) continue;
|
||||||
|
|
||||||
|
const existing = summaries.get(`execution:${executionWorkspace.id}`);
|
||||||
|
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||||
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
summaries.set(`execution:${executionWorkspace.id}`, {
|
||||||
|
key: `execution:${executionWorkspace.id}`,
|
||||||
|
kind: "execution_workspace",
|
||||||
|
workspaceId: executionWorkspace.id,
|
||||||
|
workspaceName: executionWorkspace.name,
|
||||||
|
branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null,
|
||||||
|
lastUpdatedAt: maxDate(
|
||||||
|
existing?.lastUpdatedAt,
|
||||||
|
executionWorkspace.lastUsedAt,
|
||||||
|
executionWorkspace.updatedAt,
|
||||||
|
issue.updatedAt,
|
||||||
|
),
|
||||||
|
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
||||||
|
executionWorkspaceId: executionWorkspace.id,
|
||||||
|
issues: nextIssues,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!issue.projectWorkspaceId || issue.projectWorkspaceId === primaryId) continue;
|
||||||
|
const projectWorkspace = projectWorkspacesById.get(issue.projectWorkspaceId);
|
||||||
|
if (!projectWorkspace) continue;
|
||||||
|
|
||||||
|
const existing = summaries.get(`project:${projectWorkspace.id}`);
|
||||||
|
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||||
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
summaries.set(`project:${projectWorkspace.id}`, {
|
||||||
|
key: `project:${projectWorkspace.id}`,
|
||||||
|
kind: "project_workspace",
|
||||||
|
workspaceId: projectWorkspace.id,
|
||||||
|
workspaceName: projectWorkspace.name,
|
||||||
|
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
||||||
|
lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt),
|
||||||
|
projectWorkspaceId: projectWorkspace.id,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
issues: nextIssues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...summaries.values()].sort((a, b) => {
|
||||||
|
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||||
|
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace, type Issue, type Project } from "@paperclipai/shared";
|
||||||
import { budgetsApi } from "../api/budgets";
|
import { budgetsApi } from "../api/budgets";
|
||||||
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
|
@ -20,14 +22,17 @@ import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { projectRouteRef, cn } from "../lib/utils";
|
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
||||||
|
import { projectRouteRef } from "../lib/utils";
|
||||||
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||||
|
import { Clock3, GitBranch, Rows3 } from "lucide-react";
|
||||||
|
|
||||||
/* ── Top-level tab types ── */
|
/* ── Top-level tab types ── */
|
||||||
|
|
||||||
type ProjectBaseTab = "overview" | "list" | "configuration" | "budget";
|
type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget";
|
||||||
type ProjectPluginTab = `plugin:${string}`;
|
type ProjectPluginTab = `plugin:${string}`;
|
||||||
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||||||
|
|
||||||
|
|
@ -44,6 +49,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
|
||||||
if (tab === "configuration") return "configuration";
|
if (tab === "configuration") return "configuration";
|
||||||
if (tab === "budget") return "budget";
|
if (tab === "budget") return "budget";
|
||||||
if (tab === "issues") return "list";
|
if (tab === "issues") return "list";
|
||||||
|
if (tab === "workspaces") return "workspaces";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,6 +206,88 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProjectWorkspacesContent({
|
||||||
|
summaries,
|
||||||
|
}: {
|
||||||
|
summaries: ReturnType<typeof buildProjectWorkspaceSummaries>;
|
||||||
|
}) {
|
||||||
|
if (summaries.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
|
{summaries.map((summary) => {
|
||||||
|
const visibleIssues = summary.issues.slice(0, 3);
|
||||||
|
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={summary.key}
|
||||||
|
className="border-b border-border px-4 py-3 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
{summary.executionWorkspaceId ? (
|
||||||
|
<Link
|
||||||
|
to={`/execution-workspaces/${summary.executionWorkspaceId}`}
|
||||||
|
className="truncate text-sm font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{summary.workspaceName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="truncate text-sm font-medium">{summary.workspaceName}</div>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{summary.kind === "execution_workspace" ? "Isolated workspace" : "Project workspace"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-mono">{summary.branchName ?? "No branch info"}</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Rows3 className="h-3.5 w-3.5" />
|
||||||
|
{summary.issues.length} linked {summary.issues.length === 1 ? "issue" : "issues"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{visibleIssues.map((issue) => (
|
||||||
|
<Link
|
||||||
|
key={issue.id}
|
||||||
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
|
className="inline-flex max-w-full items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||||
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{issue.title}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{hiddenIssueCount > 0 ? (
|
||||||
|
<span className="inline-flex items-center rounded-md border border-dashed border-border px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||||
|
... and {hiddenIssueCount} more
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Clock3 className="h-3.5 w-3.5" />
|
||||||
|
{timeAgo(summary.lastUpdatedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main project page ── */
|
/* ── Main project page ── */
|
||||||
|
|
||||||
export function ProjectDetail() {
|
export function ProjectDetail() {
|
||||||
|
|
@ -241,6 +329,10 @@ export function ProjectDetail() {
|
||||||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||||
const projectLookupRef = project?.id ?? routeProjectRef;
|
const projectLookupRef = project?.id ?? routeProjectRef;
|
||||||
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
|
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
|
||||||
|
const experimentalSettingsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
});
|
||||||
const {
|
const {
|
||||||
slots: pluginDetailSlots,
|
slots: pluginDetailSlots,
|
||||||
isLoading: pluginDetailSlotsLoading,
|
isLoading: pluginDetailSlotsLoading,
|
||||||
|
|
@ -259,6 +351,39 @@ export function ProjectDetail() {
|
||||||
[pluginDetailSlots],
|
[pluginDetailSlots],
|
||||||
);
|
);
|
||||||
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
|
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
|
||||||
|
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
|
||||||
|
const workspaceTabProjectId = project?.id ?? null;
|
||||||
|
const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading, error: workspaceTabIssuesError } = useQuery({
|
||||||
|
queryKey: workspaceTabProjectId && resolvedCompanyId
|
||||||
|
? queryKeys.issues.listByProject(resolvedCompanyId, workspaceTabProjectId)
|
||||||
|
: ["issues", "__workspace-tab__", "disabled"],
|
||||||
|
queryFn: () => issuesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }),
|
||||||
|
enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
data: workspaceTabExecutionWorkspaces = [],
|
||||||
|
isLoading: isWorkspaceTabExecutionWorkspacesLoading,
|
||||||
|
error: workspaceTabExecutionWorkspacesError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: workspaceTabProjectId && resolvedCompanyId
|
||||||
|
? queryKeys.executionWorkspaces.list(resolvedCompanyId, { projectId: workspaceTabProjectId })
|
||||||
|
: ["execution-workspaces", "__workspace-tab__", "disabled"],
|
||||||
|
queryFn: () => executionWorkspacesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }),
|
||||||
|
enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled),
|
||||||
|
});
|
||||||
|
const workspaceSummaries = useMemo(() => {
|
||||||
|
if (!project || !isolatedWorkspacesEnabled) return [];
|
||||||
|
return buildProjectWorkspaceSummaries({
|
||||||
|
project,
|
||||||
|
issues: workspaceTabIssues,
|
||||||
|
executionWorkspaces: workspaceTabExecutionWorkspaces,
|
||||||
|
});
|
||||||
|
}, [project, isolatedWorkspacesEnabled, workspaceTabIssues, workspaceTabExecutionWorkspaces]);
|
||||||
|
const showWorkspacesTab = isolatedWorkspacesEnabled && workspaceSummaries.length > 0;
|
||||||
|
const workspaceTabDecisionLoaded =
|
||||||
|
experimentalSettingsQuery.isFetched &&
|
||||||
|
(!isolatedWorkspacesEnabled || (!isWorkspaceTabIssuesLoading && !isWorkspaceTabExecutionWorkspacesLoading));
|
||||||
|
const workspaceTabError = (workspaceTabIssuesError ?? workspaceTabExecutionWorkspacesError) as Error | null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||||
|
|
@ -345,6 +470,10 @@ export function ProjectDetail() {
|
||||||
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (activeTab === "workspaces") {
|
||||||
|
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeTab === "list") {
|
if (activeTab === "list") {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
||||||
|
|
@ -455,6 +584,10 @@ export function ProjectDetail() {
|
||||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeTab === "workspaces" && workspaceTabDecisionLoaded && !showWorkspacesTab) {
|
||||||
|
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect bare /projects/:id to cached tab or default /issues
|
// Redirect bare /projects/:id to cached tab or default /issues
|
||||||
if (routeProjectRef && activeTab === null) {
|
if (routeProjectRef && activeTab === null) {
|
||||||
let cachedTab: string | null = null;
|
let cachedTab: string | null = null;
|
||||||
|
|
@ -470,6 +603,12 @@ export function ProjectDetail() {
|
||||||
if (cachedTab === "budget") {
|
if (cachedTab === "budget") {
|
||||||
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
|
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
|
||||||
}
|
}
|
||||||
|
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
|
||||||
|
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
|
||||||
|
}
|
||||||
|
if (cachedTab === "workspaces" && !workspaceTabDecisionLoaded) {
|
||||||
|
return <PageSkeleton variant="detail" />;
|
||||||
|
}
|
||||||
if (isProjectPluginTab(cachedTab)) {
|
if (isProjectPluginTab(cachedTab)) {
|
||||||
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
|
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
@ -491,6 +630,8 @@ export function ProjectDetail() {
|
||||||
}
|
}
|
||||||
if (tab === "overview") {
|
if (tab === "overview") {
|
||||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||||
|
} else if (tab === "workspaces") {
|
||||||
|
navigate(`/projects/${canonicalProjectRef}/workspaces`);
|
||||||
} else if (tab === "budget") {
|
} else if (tab === "budget") {
|
||||||
navigate(`/projects/${canonicalProjectRef}/budget`);
|
navigate(`/projects/${canonicalProjectRef}/budget`);
|
||||||
} else if (tab === "configuration") {
|
} else if (tab === "configuration") {
|
||||||
|
|
@ -561,6 +702,7 @@ export function ProjectDetail() {
|
||||||
items={[
|
items={[
|
||||||
{ value: "list", label: "Issues" },
|
{ value: "list", label: "Issues" },
|
||||||
{ value: "overview", label: "Overview" },
|
{ value: "overview", label: "Overview" },
|
||||||
|
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
{ value: "budget", label: "Budget" },
|
{ value: "budget", label: "Budget" },
|
||||||
...pluginTabItems.map((item) => ({
|
...pluginTabItems.map((item) => ({
|
||||||
|
|
@ -589,6 +731,18 @@ export function ProjectDetail() {
|
||||||
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "workspaces" ? (
|
||||||
|
workspaceTabDecisionLoaded ? (
|
||||||
|
workspaceTabError ? (
|
||||||
|
<p className="text-sm text-destructive">{workspaceTabError.message}</p>
|
||||||
|
) : (
|
||||||
|
<ProjectWorkspacesContent summaries={workspaceSummaries} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading workspaces...</p>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
{activeTab === "configuration" && (
|
{activeTab === "configuration" && (
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<ProjectProperties
|
<ProjectProperties
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue