Address diff viewer review feedback

This commit is contained in:
Dotta committed 2026-05-15 12:54:28 -05:00
1 parent 53f3425cd4
commit b70abf7112
11 files changed
+345 -48

No files matched your search

@@ -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,
},
],
@@ -19,6 +19,7 @@ import {
type WorkspaceDiffData = WorkspaceDiffResponse;
type WorkspacePatchDiffOptions = PatchDiffProps<undefined>["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<DiffRenderMode>("split");
const [view, setView] = useState<DiffViewMode>("working-tree");
const [baseRef, setBaseRef] = useState("");
const [includeUntracked, setIncludeUntracked] = useState(false);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const fileSectionRefs = useRef(new Map<string, HTMLElement>());
const diffScrollRef = useRef<HTMLElement | null>(null);
const scrollSyncFrameRef = useRef<number | null>(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<WorkspaceDiffData>("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
</button>
</div>
<button
key="untracked"
type="button"
className={buttonClass(includeUntracked)}
onClick={() => setIncludeUntracked((value) => !value)}
>
{includeUntracked ? "Untracked shown" : "Show untracked"}
</button>
<div key="view" className="inline-flex gap-1" aria-label="Diff comparison">
<button key="working-tree" type="button" className={buttonClass(view === "working-tree")} onClick={() => setView("working-tree")}>
Working tree
</button>
<button key="head" type="button" className={buttonClass(view === "head")} onClick={() => setView("head")}>
Against ref
</button>
</div>
{view === "head" ? (
<input
key="base-ref"
className="h-8 w-40 rounded-md border border-border bg-background px-2.5 font-mono text-xs outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/40"
value={baseRef}
onChange={(event) => setBaseRef(event.target.value)}
placeholder="origin/master"
aria-label="Base ref"
/>
) : null}
{view === "working-tree" ? (
<button
key="untracked"
type="button"
className={buttonClass(includeUntracked)}
onClick={() => setIncludeUntracked((value) => !value)}
>
{includeUntracked ? "Untracked shown" : "Show untracked"}
</button>
) : null}
<button key="refresh" type="button" className={buttonClass(false)} onClick={() => refresh()}>
Refresh
</button>
@@ -393,7 +457,12 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
</div>
</aside>
<main key="diffs" className="max-h-[70vh] min-w-0 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1">
<main
key="diffs"
ref={diffScrollRef}
className="max-h-[70vh] min-w-0 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1"
onScroll={handleDiffScroll}
>
{files
.map((file) => (
<section
@@ -402,19 +471,31 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
className={file.path === selectedFile?.path ? "scroll-mt-2" : undefined}
>
<div key="header" className="flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-muted/35 px-3 py-2">
<button
key="select"
type="button"
className="min-w-0 text-left"
onClick={() => selectFile(file.path)}
>
<div key="path" className="truncate text-sm font-medium">{file.path}</div>
{file.oldPath ? (
<div key="old-path" className="truncate font-mono text-[11px] text-muted-foreground">
from {file.oldPath}
</div>
) : null}
</button>
<div key="left" className="flex min-w-0 items-start gap-2">
<button
key="collapse"
type="button"
className="mt-0.5 text-muted-foreground hover:text-foreground"
title={expandedFiles.has(file.path) ? "Collapse file" : "Expand file"}
aria-label={expandedFiles.has(file.path) ? `Collapse ${file.path}` : `Expand ${file.path}`}
onClick={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
>
{expandedFiles.has(file.path) ? "−" : "+"}
</button>
<button
key="select"
type="button"
className="min-w-0 text-left"
onClick={() => selectFile(file.path)}
>
<div key="path" className="truncate text-sm font-medium">{file.path}</div>
{file.oldPath ? (
<div key="old-path" className="truncate font-mono text-[11px] text-muted-foreground">
from {file.oldPath}
</div>
) : null}
</button>
</div>
<div key="actions" className="flex shrink-0 items-center gap-1">
<button
key="copy"
@@ -426,16 +507,6 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
>
⧉
</button>
<button
key="collapse"
type="button"
className={iconButtonClass(false)}
title={expandedFiles.has(file.path) ? "Collapse file" : "Expand file"}
aria-label={expandedFiles.has(file.path) ? `Collapse ${file.path}` : `Expand ${file.path}`}
onClick={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
>
{expandedFiles.has(file.path) ? "−" : "+"}
</button>
</div>
</div>
{expandedFiles.has(file.path) ? (
@@ -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"],
}));
});
+1
View File
@@ -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];
@@ -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", () => {
@@ -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: [],
});
});
});
+26
View File
@@ -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;
+26 -3
View File
@@ -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,
+6 -4
View File
@@ -44,6 +44,8 @@ interface PatchBudget {
totalPatchBytes: number;
}
type WorkspaceDiffTarget = Pick<ExecutionWorkspace, "id" | "companyId" | "cwd" | "baseRef">;
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<WorkspaceDiffResponse> {
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
const paths = normalizePathFilters(query.paths);
const warnings: WorkspaceDiffWarning[] = [];
+20
View File
@@ -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<WorkspaceDiffQueryOptions> = {},
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<WorkspaceDiffResponse>(projectPath(projectId, companyId, suffix));
},
controlWorkspaceRuntimeServices: (
projectId: string,
workspaceId: string,
+68 -1
View File
@@ -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<WorkspaceFormState | null>(null);
@@ -224,6 +235,11 @@ export function ProjectWorkspaceDetail() {
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(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 (
<div className="mx-auto max-w-5xl space-y-6">
@@ -378,6 +417,19 @@ export function ProjectWorkspaceDetail() {
</div>
</div>
<Tabs value={activeTab} onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}>
<PageTabBar
items={[
{ value: "configuration", label: "Configuration" },
...pluginTabItems.map((item) => ({ value: item.value, label: item.label })),
]}
align="start"
value={activeTab}
onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}
/>
</Tabs>
{activeTab === "configuration" ? (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
@@ -643,6 +695,21 @@ export function ProjectWorkspaceDetail() {
</div>
</div>
</div>
) : null}
{activePluginTab ? (
<PluginSlotMount
slot={activePluginTab.slot}
context={{
companyId: project.companyId,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
entityId: workspace.id,
entityType: "project_workspace",
}}
missingBehavior="placeholder"
/>
) : null}
</div>
);
}