From b70abf7112f38974334992dc6194db0f5339bd14 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 15 May 2026 10:52:01 -0500 Subject: [PATCH] Address diff viewer review feedback --- .../plugin-workspace-diff/src/manifest.ts | 2 +- .../plugin-workspace-diff/src/ui/index.tsx | 139 +++++++++++++----- .../tests/plugin.spec.ts | 4 +- packages/shared/src/constants.ts | 1 + packages/shared/src/validators/plugin.test.ts | 6 +- .../src/__tests__/project-routes-env.test.ts | 87 +++++++++++ server/src/routes/projects.ts | 26 ++++ server/src/services/plugin-host-services.ts | 29 +++- server/src/services/workspace-diff.ts | 10 +- ui/src/api/projects.ts | 20 +++ ui/src/pages/ProjectWorkspaceDetail.tsx | 69 ++++++++- 11 files changed, 345 insertions(+), 48 deletions(-) diff --git a/packages/plugins/plugin-workspace-diff/src/manifest.ts b/packages/plugins/plugin-workspace-diff/src/manifest.ts index 6ddb1ddb7af..7d8bb833349 100644 --- a/packages/plugins/plugin-workspace-diff/src/manifest.ts +++ b/packages/plugins/plugin-workspace-diff/src/manifest.ts @@ -26,7 +26,7 @@ const manifest: PaperclipPluginManifestV1 = { id: CHANGES_TAB_SLOT_ID, displayName: "Changes", exportName: "ChangesTab", - entityTypes: ["execution_workspace"], + entityTypes: ["execution_workspace", "project_workspace"], order: 5, }, ], diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index b241813ea02..5b4c916e5d7 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -19,6 +19,7 @@ import { type WorkspaceDiffData = WorkspaceDiffResponse; type WorkspacePatchDiffOptions = PatchDiffProps["options"]; +type DiffViewMode = "working-tree" | "head"; function buttonClass(active = false) { return [ @@ -272,17 +273,22 @@ function CollapsedFilePanel({ export function ChangesTab({ context }: PluginDetailTabProps) { const toast = usePluginToast(); const [mode, setMode] = useState("split"); + const [view, setView] = useState("working-tree"); + const [baseRef, setBaseRef] = useState(""); const [includeUntracked, setIncludeUntracked] = useState(false); const [expandedFiles, setExpandedFiles] = useState>(() => new Set()); const [selectedPath, setSelectedPath] = useState(null); const fileSectionRefs = useRef(new Map()); + const diffScrollRef = useRef(null); + const scrollSyncFrameRef = useRef(null); const params = useMemo(() => ({ workspaceId: context.entityId, companyId: context.companyId ?? "", - view: "working-tree", + view, + baseRef: baseRef.trim() || null, includeUntracked, - }), [context.companyId, context.entityId, includeUntracked]); + }), [baseRef, context.companyId, context.entityId, includeUntracked, view]); const { data, loading, error, refresh } = usePluginData("workspace-diff", params); const files = useMemo(() => toFileViewModels(data), [data]); @@ -305,6 +311,36 @@ export function ChangesTab({ context }: PluginDetailTabProps) { }); }, []); + const syncSelectedPathFromScroll = useCallback(() => { + const container = diffScrollRef.current; + if (!container || files.length === 0) return; + + const containerTop = container.getBoundingClientRect().top; + let nextPath = files[0]?.path ?? null; + for (const file of files) { + const section = fileSectionRefs.current.get(file.path); + if (!section) continue; + const offsetFromScrollTop = section.getBoundingClientRect().top - containerTop; + if (offsetFromScrollTop <= 48) { + nextPath = file.path; + } else { + break; + } + } + + if (nextPath) { + setSelectedPath((current) => current === nextPath ? current : nextPath); + } + }, [files]); + + const handleDiffScroll = useCallback(() => { + if (scrollSyncFrameRef.current !== null) return; + scrollSyncFrameRef.current = window.requestAnimationFrame(() => { + scrollSyncFrameRef.current = null; + syncSelectedPathFromScroll(); + }); + }, [syncSelectedPathFromScroll]); + useEffect(() => { if (files.length === 0) { setExpandedFiles(new Set()); @@ -315,6 +351,14 @@ export function ChangesTab({ context }: PluginDetailTabProps) { setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null); }, [files]); + useEffect(() => { + return () => { + if (scrollSyncFrameRef.current !== null) { + window.cancelAnimationFrame(scrollSyncFrameRef.current); + } + }; + }, []); + const copyPath = async (filePath: string) => { try { await navigator.clipboard.writeText(filePath); @@ -352,14 +396,34 @@ export function ChangesTab({ context }: PluginDetailTabProps) { Unified - +
+ + +
+ {view === "head" ? ( + setBaseRef(event.target.value)} + placeholder="origin/master" + aria-label="Base ref" + /> + ) : null} + {view === "working-tree" ? ( + + ) : null} @@ -393,7 +457,12 @@ export function ChangesTab({ context }: PluginDetailTabProps) { -
+
{files .map((file) => (
- +
+ + +
-
{expandedFiles.has(file.path) ? ( diff --git a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts index fb6185f33e4..4060bd4211c 100644 --- a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts +++ b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts @@ -5,13 +5,13 @@ import plugin from "../src/worker.js"; import { diffResponse } from "./fixtures.js"; describe("workspace diff plugin", () => { - it("declares the execution workspace Changes tab and host diff capability", () => { + it("declares workspace Changes tabs and host diff capability", () => { expect(manifest.capabilities).toContain("ui.detailTab.register"); expect(manifest.capabilities).toContain("execution.workspaces.read"); expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({ type: "detailTab", displayName: "Changes", - entityTypes: ["execution_workspace"], + entityTypes: ["execution_workspace", "project_workspace"], })); }); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5309a5cbd58..9c295eae956 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -963,6 +963,7 @@ export const PLUGIN_UI_SLOT_ENTITY_TYPES = [ "run", "comment", "execution_workspace", + "project_workspace", ] as const; export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number]; diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 5369902c12b..1984aa6b209 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -105,16 +105,16 @@ describe("plugin UI slot validators", () => { expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true); }); - it("accepts execution_workspace as a detailTab entityType", () => { + it("accepts workspace entity types as detailTab targets", () => { const parsed = pluginUiSlotDeclarationSchema.parse({ type: "detailTab", id: "workspace-diff-viewer", displayName: "Diff", exportName: "WorkspaceDiffViewer", - entityTypes: ["execution_workspace"], + entityTypes: ["execution_workspace", "project_workspace"], }); - expect(parsed.entityTypes).toEqual(["execution_workspace"]); + expect(parsed.entityTypes).toEqual(["execution_workspace", "project_workspace"]); }); it("accepts execution_workspace as a toolbarButton entityType", () => { diff --git a/server/src/__tests__/project-routes-env.test.ts b/server/src/__tests__/project-routes-env.test.ts index 10450a37b72..91874741f1d 100644 --- a/server/src/__tests__/project-routes-env.test.ts +++ b/server/src/__tests__/project-routes-env.test.ts @@ -21,6 +21,9 @@ const mockEnvironmentService = vi.hoisted(() => ({ getById: vi.fn(), })); const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockWorkspaceDiffService = vi.hoisted(() => ({ + getDiff: vi.fn(), +})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); @@ -44,6 +47,10 @@ vi.mock("../services/secrets.js", () => ({ secretService: () => mockSecretService, })); +vi.mock("../services/workspace-diff.js", () => ({ + workspaceDiffService: () => mockWorkspaceDiffService, +})); + vi.mock("../services/workspace-runtime.js", () => ({ startRuntimeServicesForWorkspaceControl: vi.fn(), stopRuntimeServicesForProjectWorkspace: vi.fn(), @@ -70,6 +77,10 @@ function registerModuleMocks() { secretService: () => mockSecretService, })); + vi.doMock("../services/workspace-diff.js", () => ({ + workspaceDiffService: () => mockWorkspaceDiffService, + })); + vi.doMock("../services/workspace-runtime.js", () => ({ startRuntimeServicesForWorkspaceControl: vi.fn(), stopRuntimeServicesForProjectWorkspace: vi.fn(), @@ -150,6 +161,37 @@ describe("project env routes", () => { mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockProjectService.createWorkspace.mockResolvedValue(null); mockProjectService.listWorkspaces.mockResolvedValue([]); + mockWorkspaceDiffService.getDiff.mockResolvedValue({ + workspaceId: "workspace-1", + companyId: "company-1", + repoRoot: "/tmp/project", + cwd: "/tmp/project", + view: "head", + baseRef: "origin/master", + headSha: "abc123", + includeUntracked: false, + paths: [], + files: [], + stats: { + fileCount: 0, + stagedFileCount: 0, + unstagedFileCount: 0, + untrackedFileCount: 0, + binaryFileCount: 0, + oversizedFileCount: 0, + truncatedFileCount: 0, + additions: 0, + deletions: 0, + }, + warnings: [], + caps: { + maxFiles: 200, + maxFileBytes: 524288, + maxPatchBytes: 262144, + maxTotalPatchBytes: 1048576, + }, + truncated: false, + }); mockEnvironmentService.getById.mockReset(); mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); }); @@ -219,4 +261,49 @@ describe("project env routes", () => { }), ); }); + + it("returns project workspace diffs with explicit upstream refs", async () => { + mockProjectService.getById.mockResolvedValue(buildProject()); + mockProjectService.listWorkspaces.mockResolvedValue([ + { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + name: "Primary", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: null, + repoRef: "master", + defaultRef: "origin/master", + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const app = await createApp(); + const res = await request(app) + .get("/api/projects/project-1/workspaces/workspace-1/diff?view=head&baseRef=origin/master&includeUntracked=false"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockWorkspaceDiffService.getDiff).toHaveBeenCalledWith({ + id: "workspace-1", + companyId: "company-1", + cwd: "/tmp/project", + baseRef: "origin/master", + }, { + view: "head", + baseRef: "origin/master", + includeUntracked: false, + paths: [], + }); + }); }); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index eccf3f7f746..048af150e6a 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -8,12 +8,14 @@ import { matchWorkspaceRuntimeServiceToCommand, updateProjectSchema, updateProjectWorkspaceSchema, + workspaceDiffQuerySchema, workspaceRuntimeControlTargetSchema, } from "@paperclipai/shared"; import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared"; import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { projectService, logActivity, workspaceOperationService } from "../services/index.js"; +import { workspaceDiffService } from "../services/workspace-diff.js"; import { conflict, forbidden } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { @@ -42,6 +44,7 @@ export function projectRoutes(db: Db) { const router = Router(); const svc = projectService(db); const secretsSvc = secretService(db); + const diffSvc = workspaceDiffService(); const workspaceOperations = workspaceOperationService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; const environmentsSvc = environmentService(db); @@ -339,6 +342,29 @@ export function projectRoutes(db: Db) { }, ); + router.get("/projects/:id/workspaces/:workspaceId/diff", async (req, res) => { + const id = req.params.id as string; + const workspaceId = req.params.workspaceId as string; + const project = await svc.getById(id); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + assertCompanyAccess(req, project.companyId); + const workspace = (await svc.listWorkspaces(id)).find((entry) => entry.id === workspaceId); + if (!workspace) { + res.status(404).json({ error: "Project workspace not found" }); + return; + } + const query = workspaceDiffQuerySchema.parse(req.query); + res.json(await diffSvc.getDiff({ + id: workspace.id, + companyId: workspace.companyId, + cwd: workspace.cwd, + baseRef: workspace.defaultRef ?? workspace.repoRef ?? null, + }, query)); + }); + async function handleProjectWorkspaceRuntimeCommand(req: Request, res: Response) { const id = req.params.id as string; const workspaceId = req.params.workspaceId as string; diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 5df8531907e..c04817c10d0 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -7,6 +7,7 @@ import { heartbeatRuns, issues as issuesTable, pluginLogs, + projectWorkspaces as projectWorkspacesTable, } from "@paperclipai/db"; import { eq, and, like, desc, inArray, sql } from "drizzle-orm"; import type { @@ -1206,10 +1207,32 @@ export function buildHostServices( const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); const workspace = await executionWorkspaces.getById(params.workspaceId); - if (!inCompany(workspace, companyId)) { - throw new Error("Execution workspace not found"); + if (inCompany(workspace, companyId)) { + return workspaceDiff.getDiff(workspace, { + view: params.options?.view ?? "working-tree", + baseRef: params.options?.baseRef ?? null, + includeUntracked: params.options?.includeUntracked ?? true, + paths: params.options?.paths ?? [], + }); } - return workspaceDiff.getDiff(workspace, { + + const projectWorkspace = await db + .select() + .from(projectWorkspacesTable) + .where(and( + eq(projectWorkspacesTable.id, params.workspaceId), + eq(projectWorkspacesTable.companyId, companyId), + )) + .then((rows) => rows[0] ?? null); + if (!projectWorkspace) { + throw new Error("Workspace not found"); + } + return workspaceDiff.getDiff({ + id: projectWorkspace.id, + companyId: projectWorkspace.companyId, + cwd: projectWorkspace.cwd, + baseRef: projectWorkspace.defaultRef ?? projectWorkspace.repoRef ?? null, + }, { view: params.options?.view ?? "working-tree", baseRef: params.options?.baseRef ?? null, includeUntracked: params.options?.includeUntracked ?? true, diff --git a/server/src/services/workspace-diff.ts b/server/src/services/workspace-diff.ts index 9a4f7c96177..6df62495c74 100644 --- a/server/src/services/workspace-diff.ts +++ b/server/src/services/workspace-diff.ts @@ -44,6 +44,8 @@ interface PatchBudget { totalPatchBytes: number; } +type WorkspaceDiffTarget = Pick; + function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning { return { code, message, path: filePath }; } @@ -94,7 +96,7 @@ function isWithinDirectory(childPath: string, parentPath: string) { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } -async function resolveWorkspacePaths(workspace: ExecutionWorkspace) { +async function resolveWorkspacePaths(workspace: WorkspaceDiffTarget) { if (!workspace.cwd?.trim()) { throw workspaceDiffError( "missing_cwd", @@ -548,7 +550,7 @@ async function resolveHeadSha(cwd: string) { } } -async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: ExecutionWorkspace) { +async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) { const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null; if (!resolvedBaseRef) { throw workspaceDiffError( @@ -575,7 +577,7 @@ async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: Ex async function collectFiles(input: { cwd: string; - workspace: ExecutionWorkspace; + workspace: WorkspaceDiffTarget; query: WorkspaceDiffQueryOptions; paths: string[]; }) { @@ -606,7 +608,7 @@ async function collectFiles(input: { export function workspaceDiffService() { return { - async getDiff(workspace: ExecutionWorkspace, query: WorkspaceDiffQueryOptions): Promise { + async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise { const { cwd, repoRoot } = await resolveWorkspacePaths(workspace); const paths = normalizePathFilters(query.paths); const warnings: WorkspaceDiffWarning[] = []; diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index e975f829c11..7b898fc76af 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,6 +1,8 @@ import type { Project, ProjectWorkspace, + WorkspaceDiffQueryOptions, + WorkspaceDiffResponse, WorkspaceOperation, WorkspaceRuntimeControlTarget, } from "@paperclipai/shared"; @@ -33,6 +35,24 @@ export const projectsApi = { projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`), data, ), + getWorkspaceDiff: ( + projectId: string, + workspaceId: string, + options: Partial = {}, + companyId?: string, + ) => { + const params = new URLSearchParams(); + if (options.view) params.set("view", options.view); + if (options.baseRef) params.set("baseRef", options.baseRef); + if (typeof options.includeUntracked === "boolean") { + params.set("includeUntracked", String(options.includeUntracked)); + } + for (const filePath of options.paths ?? []) { + params.append("path", filePath); + } + const suffix = `/workspaces/${encodeURIComponent(workspaceId)}/diff${params.toString() ? `?${params}` : ""}`; + return api.get(projectPath(projectId, companyId, suffix)); + }, controlWorkspaceRuntimeServices: ( projectId: string, workspaceId: string, diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx index cfb68265d3a..b5297d41040 100644 --- a/ui/src/pages/ProjectWorkspaceDetail.tsx +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -1,12 +1,15 @@ import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useParams } from "@/lib/router"; +import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared"; import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { Tabs } from "@/components/ui/tabs"; import { ChoosePathButton } from "../components/PathInstructionsModal"; import { projectsApi } from "../api/projects"; +import { PageTabBar } from "../components/PageTabBar"; +import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; import { buildWorkspaceRuntimeControlSections, WorkspaceRuntimeControls, @@ -35,6 +38,13 @@ type WorkspaceFormState = { type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"]; type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"]; +type ProjectWorkspaceBaseTab = "configuration"; +type ProjectWorkspacePluginTab = `plugin:${string}`; +type ProjectWorkspaceTab = ProjectWorkspaceBaseTab | ProjectWorkspacePluginTab; + +function isProjectWorkspacePluginTab(value: string | null): value is ProjectWorkspacePluginTab { + return typeof value === "string" && value.startsWith("plugin:"); +} const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [ { value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." }, @@ -217,6 +227,7 @@ export function ProjectWorkspaceDetail() { }>(); const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); + const location = useLocation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [form, setForm] = useState(null); @@ -224,6 +235,11 @@ export function ProjectWorkspaceDetail() { const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); const routeProjectRef = projectId ?? ""; const routeWorkspaceId = workspaceId ?? ""; + const pluginTabFromSearch = useMemo(() => { + const tab = new URLSearchParams(location.search).get("tab"); + return isProjectWorkspacePluginTab(tab) ? tab : null; + }, [location.search]); + const activeTab: ProjectWorkspaceTab = pluginTabFromSearch ?? "configuration"; const routeCompanyId = useMemo(() => { if (!companyPrefix) return null; @@ -247,6 +263,21 @@ export function ProjectWorkspaceDetail() { const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef; const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]); const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState)); + const { slots: pluginDetailSlots } = usePluginSlots({ + slotTypes: ["detailTab"], + entityType: "project_workspace", + companyId: project?.companyId ?? null, + enabled: Boolean(project?.companyId), + }); + const pluginTabItems = useMemo( + () => pluginDetailSlots.map((slot) => ({ + value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectWorkspacePluginTab, + label: slot.displayName, + slot, + })), + [pluginDetailSlots], + ); + const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null; useEffect(() => { if (!project?.companyId || project.companyId === selectedCompanyId) return; @@ -363,6 +394,14 @@ export function ProjectWorkspaceDetail() { }; const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null; + const handleTabChange = (tab: ProjectWorkspaceTab) => { + const workspacePath = projectWorkspaceUrl(project, routeWorkspaceId); + if (isProjectWorkspacePluginTab(tab)) { + navigate(`${workspacePath}?tab=${encodeURIComponent(tab)}`); + return; + } + navigate(workspacePath); + }; return (
@@ -378,6 +417,19 @@ export function ProjectWorkspaceDetail() {
+ handleTabChange(value as ProjectWorkspaceTab)}> + ({ value: item.value, label: item.label })), + ]} + align="start" + value={activeTab} + onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)} + /> + + + {activeTab === "configuration" ? (
@@ -643,6 +695,21 @@ export function ProjectWorkspaceDetail() {
+ ) : null} + + {activePluginTab ? ( + + ) : null} ); }