feat: add workspace management editor
This commit is contained in:
@@ -18,6 +18,7 @@ beforeEach(() => {
|
||||
(globalThis as any).EventSource = FakeEventSource;
|
||||
useSessionStore.getState().resetSession();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/me", () => HttpResponse.json({ issuer: "test", subject: "test", displayName: "Test", isAdmin: false })),
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])),
|
||||
http.get("http://localhost:8787/settings", () =>
|
||||
HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
|
||||
@@ -148,6 +149,19 @@ test("model selector shows the three Pi-enabled models and stores the selected p
|
||||
}));
|
||||
});
|
||||
|
||||
test("opens Workspace management from the right sidebar without interrupting the shell", async () => {
|
||||
server.use(
|
||||
http.get("http://localhost:8787/workspace-registry/status", () =>
|
||||
HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })),
|
||||
);
|
||||
renderShell();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
|
||||
expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed");
|
||||
});
|
||||
|
||||
|
||||
test("marks the composer as awaiting input for a pending freetext gate", () => {
|
||||
useSessionStore.setState({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSessionStore } from "../store/sessionStore";
|
||||
import { WidgetHost } from "./WidgetHost";
|
||||
import { CentralStatus } from "./CentralStatus";
|
||||
import { ModelActivityPanel } from "./ModelActivityPanel";
|
||||
import { WorkspaceManager } from "./WorkspaceManager";
|
||||
import { useActivityPanelResize } from "./useActivityPanelResize";
|
||||
import { useSessionPanelResize } from "./useSessionPanelResize";
|
||||
import { NavSessions } from "./NavSessions";
|
||||
@@ -99,6 +100,7 @@ export function AppShell() {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false);
|
||||
const [activeOpen, setActiveOpen] = useState(true);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
|
||||
@@ -492,7 +494,7 @@ export function AppShell() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} />}
|
||||
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setWorkspaceManagerOpen(true)} />}
|
||||
{showActivity && desktopSplit && (
|
||||
<div className="relative w-0 shrink-0">
|
||||
<div
|
||||
@@ -608,6 +610,14 @@ export function AppShell() {
|
||||
>
|
||||
New session
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setWorkspaceManagerOpen(true)}
|
||||
>
|
||||
Workspace management
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{principal?.isAdmin && (
|
||||
@@ -758,6 +768,7 @@ export function AppShell() {
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<WorkspaceManager open={workspaceManagerOpen} onClose={() => setWorkspaceManagerOpen(false)} />
|
||||
|
||||
<Toaster />
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Settings2, X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ActivityEntry, ActivityKind } from "../api/types";
|
||||
@@ -61,9 +61,11 @@ function ActivityRow({ entry }: { entry: ActivityEntry }) {
|
||||
export function ModelActivityPanel({
|
||||
desktopSplit = false,
|
||||
onClose,
|
||||
onOpenWorkspaceManager,
|
||||
}: {
|
||||
desktopSplit?: boolean;
|
||||
onClose: () => void;
|
||||
onOpenWorkspaceManager?: () => void;
|
||||
}) {
|
||||
const activityLog = useSessionStore((s) => s.activityLog);
|
||||
const visibleActivity = activityLog.filter(isVisibleModelActivity);
|
||||
@@ -82,11 +84,16 @@ export function ModelActivityPanel({
|
||||
? "static z-auto flex min-w-0 w-[var(--activity-panel-width)] shrink-0 flex-col border-r-0 bg-sidebar"
|
||||
: "fixed inset-y-0 left-0 z-30 flex min-w-0 w-[min(90vw,24rem)] shrink-0 flex-col border-r border-border bg-sidebar"
|
||||
}>
|
||||
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
|
||||
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
|
||||
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" aria-label="Workspace management" onClick={onOpenWorkspaceManager}>
|
||||
<Settings2 className="size-3.5" />Workspace management
|
||||
</Button>
|
||||
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { CanonicalWorkspace } from "../api/workspaces";
|
||||
import type { WorkspaceDraft } from "../workspaces/drafts";
|
||||
import { WorkspaceEditor } from "./WorkspaceEditor";
|
||||
|
||||
const workspace: CanonicalWorkspace = {
|
||||
workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" },
|
||||
dwh: {
|
||||
engine: "postgres", database: "clinical", schema: "datawarehouse", port: 5432,
|
||||
supported_transports: ["postgres_direct"],
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "pgvector", database: "vectors", schema: "public", collection: "clinical",
|
||||
dimensions: 768, distance: "cosine", port: 5432, supported_transports: ["pgvector_direct"],
|
||||
},
|
||||
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
|
||||
},
|
||||
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
|
||||
const draft: WorkspaceDraft = {
|
||||
workspaceId: "psd-clinical",
|
||||
baseCommit: "a".repeat(40),
|
||||
baseBlob: "b".repeat(40),
|
||||
workspace,
|
||||
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||
};
|
||||
|
||||
test("uses closed choices for transport and rejects an invalid free-form port before save", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WorkspaceEditor draft={draft} onSaveDraft={vi.fn()} onPublish={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("listbox", { name: "DWH transport" })).toHaveTextContent("postgres_direct");
|
||||
await user.clear(screen.getByLabelText("DWH port"));
|
||||
await user.type(screen.getByLabelText("DWH port"), "70000");
|
||||
await user.click(screen.getByRole("button", { name: "Save draft" }));
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Port must be between 1 and 65535");
|
||||
expect(screen.getByLabelText("DWH port")).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
test("keeps vector dimensions and embedding dimensions atomic in a draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaveDraft = vi.fn();
|
||||
render(<WorkspaceEditor draft={draft} onSaveDraft={onSaveDraft} onPublish={vi.fn()} />);
|
||||
|
||||
await user.clear(screen.getByLabelText("Semantic index dimensions"));
|
||||
await user.type(screen.getByLabelText("Semantic index dimensions"), "1024");
|
||||
await user.click(screen.getByRole("button", { name: "Save draft" }));
|
||||
|
||||
expect(onSaveDraft).toHaveBeenCalledWith(expect.objectContaining({
|
||||
workspace: expect.objectContaining({
|
||||
semantic_index: expect.objectContaining({
|
||||
vector_store: expect.objectContaining({ dimensions: 1024 }),
|
||||
embedding: expect.objectContaining({ dimensions: 1024 }),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
test("uses native closed selects for each workspace enum and embedding provider", () => {
|
||||
render(<WorkspaceEditor draft={draft} onSaveDraft={vi.fn()} onPublish={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "Workspace language" })).toHaveValue("en");
|
||||
expect(screen.getByRole("combobox", { name: "Vector distance" })).toHaveValue("cosine");
|
||||
expect(screen.getByRole("combobox", { name: "Embedding provider" })).toHaveValue("ollama_compatible");
|
||||
expect(screen.getByRole("listbox", { name: "DWH transport" })).toHaveProperty("multiple", true);
|
||||
expect(screen.getByRole("listbox", { name: "Vector transport" })).toHaveProperty("multiple", true);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import type { CanonicalWorkspace, PublishWorkspaceRequest } from "../api/workspaces";
|
||||
import type { WorkspaceDraft } from "../workspaces/drafts";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
type FieldErrors = Record<string, string>;
|
||||
|
||||
export interface WorkspaceEditorProps {
|
||||
draft?: WorkspaceDraft;
|
||||
onSaveDraft: (draft: WorkspaceDraft) => void;
|
||||
/** Reserved for Task 10; saving a draft never publishes it. */
|
||||
onPublish: (request: PublishWorkspaceRequest) => Promise<void>;
|
||||
idLocked?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_COMMIT = "0".repeat(40);
|
||||
|
||||
function emptyWorkspace(): CanonicalWorkspace {
|
||||
return {
|
||||
workspace: { schema_version: 2, id: "new-workspace", name: "New workspace", language: "en" },
|
||||
dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] },
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "pgvector", database: "vectors", schema: "public", collection: "documents",
|
||||
dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"],
|
||||
},
|
||||
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
}
|
||||
|
||||
function positiveInteger(value: number | undefined, label: string, max = Number.MAX_SAFE_INTEGER): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Number.isInteger(value) || value < 1 || value > max) {
|
||||
return max === 65_535 ? "Port must be between 1 and 65535" : `${label} must be a positive whole number`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validate(workspace: CanonicalWorkspace): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspace.workspace.id)) {
|
||||
errors["workspace.id"] = "Use 3–63 lowercase letters, numbers, or hyphens; start with a letter";
|
||||
}
|
||||
if (!workspace.workspace.name.trim()) errors["workspace.name"] = "Workspace name is required";
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.dwh.database)) errors["dwh.database"] = "Use a database identifier";
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.dwh.schema)) errors["dwh.schema"] = "Use a schema identifier";
|
||||
if (!workspace.dwh.supported_transports.length) errors["dwh.transport"] = "Choose at least one DWH transport";
|
||||
const dwhPort = positiveInteger(workspace.dwh.port, "DWH port", 65_535);
|
||||
if (dwhPort) errors["dwh.port"] = dwhPort;
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.database)) errors["vector.database"] = "Use a database identifier";
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.schema)) errors["vector.schema"] = "Use a schema identifier";
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.collection)) errors["vector.collection"] = "Use a collection identifier";
|
||||
if (!workspace.semantic_index.vector_store.supported_transports.length) errors["vector.transport"] = "Choose at least one vector transport";
|
||||
const vectorPort = positiveInteger(workspace.semantic_index.vector_store.port, "Vector port", 65_535);
|
||||
if (vectorPort) errors["vector.port"] = vectorPort;
|
||||
const dimensions = positiveInteger(workspace.semantic_index.vector_store.dimensions, "Dimensions", 32_768);
|
||||
if (dimensions) errors["semantic.dimensions"] = dimensions;
|
||||
if (workspace.semantic_index.embedding.dimensions !== workspace.semantic_index.vector_store.dimensions) {
|
||||
errors["semantic.dimensions"] = "Vector and embedding dimensions must match";
|
||||
}
|
||||
if (!workspace.semantic_index.embedding.model.trim()) errors["embedding.model"] = "Embedding model is required";
|
||||
if (!workspace.llm_policy.allowed.length || workspace.llm_policy.allowed.some((model) => !/^[^/\s]+\/[^/\s]+$/.test(model))) {
|
||||
errors["llm.allowed"] = "Use provider/model entries separated by commas";
|
||||
}
|
||||
if (workspace.llm_policy.default && !workspace.llm_policy.allowed.includes(workspace.llm_policy.default)) {
|
||||
errors["llm.default"] = "Default model must be in the allowlist";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function selectedValues(event: React.ChangeEvent<HTMLSelectElement>): string[] {
|
||||
return Array.from(event.currentTarget.selectedOptions, (option) => option.value);
|
||||
}
|
||||
|
||||
function numberOrUndefined(value: string): number | undefined {
|
||||
return value.trim() === "" ? undefined : Number(value);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label, error, children, hint,
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: (props: { id: string; describedBy?: string; invalid: boolean }) => React.ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const errorId = `${id}-error`;
|
||||
const hintId = `${id}-hint`;
|
||||
const describedBy = [hint ? hintId : undefined, error ? errorId : undefined].filter(Boolean).join(" ") || undefined;
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-semibold text-foreground" htmlFor={id}>{label}</label>
|
||||
{children({ id, describedBy, invalid: Boolean(error) })}
|
||||
{hint && <p id={hintId} className="text-xs text-muted-foreground">{hint}</p>}
|
||||
{error && <p id={errorId} role="alert" className="text-xs font-medium text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="border-t border-border/70 pt-5 first:border-t-0 first:pt-0" aria-label={title}>
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">{title}</h3>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 aria-invalid:border-destructive";
|
||||
|
||||
export function WorkspaceEditor({ draft, onSaveDraft, onPublish: _onPublish, idLocked = Boolean(draft?.baseBlob) }: WorkspaceEditorProps) {
|
||||
const [workspace, setWorkspace] = useState<CanonicalWorkspace>(draft?.workspace ?? emptyWorkspace());
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const allowedModels = useMemo(() => workspace.llm_policy.allowed.join(", "), [workspace.llm_policy.allowed]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspace(draft?.workspace ?? emptyWorkspace());
|
||||
setErrors({});
|
||||
}, [draft]);
|
||||
|
||||
function update(change: (previous: CanonicalWorkspace) => CanonicalWorkspace) {
|
||||
setWorkspace((previous) => {
|
||||
const next = change(previous);
|
||||
setErrors(validate(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
const nextErrors = validate(workspace);
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
onSaveDraft({
|
||||
workspaceId: workspace.workspace.id,
|
||||
baseCommit: draft?.baseCommit ?? EMPTY_COMMIT,
|
||||
...(draft?.baseBlob ? { baseBlob: draft.baseBlob } : {}),
|
||||
workspace,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="grid gap-6" onSubmit={(event) => { event.preventDefault(); saveDraft(); }} noValidate>
|
||||
<Section title="General">
|
||||
<Field label="Workspace ID" error={errors["workspace.id"]} hint={idLocked ? "Technical IDs cannot change after publication." : "Lowercase, immutable technical identifier."}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Workspace ID" aria-describedby={describedBy} aria-invalid={invalid} disabled={idLocked} className={fieldClass} value={workspace.workspace.id} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, id: event.target.value } }))} />}
|
||||
</Field>
|
||||
<Field label="Workspace name" error={errors["workspace.name"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Workspace name" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.workspace.name} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, name: event.target.value } }))} />}
|
||||
</Field>
|
||||
<Field label="Description">
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Description" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.workspace.description ?? ""} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, description: event.target.value || undefined } }))} />}
|
||||
</Field>
|
||||
<Field label="Workspace language">
|
||||
{({ id, describedBy, invalid }) => <select id={id} aria-label="Workspace language" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.workspace.language} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, language: event.target.value as "en" | "it" } }))}><option value="en">English</option><option value="it">Italian</option></select>}
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="DWH">
|
||||
<Field label="DWH engine">
|
||||
{({ id, describedBy, invalid }) => <select id={id} aria-label="DWH engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="postgres" disabled><option value="postgres">PostgreSQL</option></select>}
|
||||
</Field>
|
||||
<Field label="DWH transport" error={errors["dwh.transport"]} hint="Choose supported installation transports.">
|
||||
{({ id, describedBy, invalid }) => <select id={id} multiple aria-label="DWH transport" aria-describedby={describedBy} aria-invalid={invalid} className={`${fieldClass} h-24 py-1`} value={workspace.dwh.supported_transports} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, supported_transports: selectedValues(event) as CanonicalWorkspace["dwh"]["supported_transports"] } }))}><option value="postgres_direct">postgres_direct</option><option value="rest_api">rest_api</option><option value="ssh_tunnel">ssh_tunnel</option></select>}
|
||||
</Field>
|
||||
<Field label="DWH database" error={errors["dwh.database"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="DWH database" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.database} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, database: event.target.value } }))} />}
|
||||
</Field>
|
||||
<Field label="DWH schema" error={errors["dwh.schema"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="DWH schema" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.schema} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, schema: event.target.value } }))} />}
|
||||
</Field>
|
||||
<Field label="DWH port" error={errors["dwh.port"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="65535" aria-label="DWH port" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.port ?? ""} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, port: numberOrUndefined(event.target.value) } }))} />}
|
||||
</Field>
|
||||
<Field label="DWH timeout (ms)">
|
||||
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" aria-label="DWH timeout (ms)" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.timeout_ms ?? ""} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, timeout_ms: numberOrUndefined(event.target.value) } }))} />}
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="Semantic index">
|
||||
<Field label="Vector store engine">
|
||||
{({ id, describedBy, invalid }) => <select id={id} aria-label="Vector store engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="pgvector" disabled><option value="pgvector">pgvector</option></select>}
|
||||
</Field>
|
||||
<Field label="Vector transport" error={errors["vector.transport"]}>
|
||||
{({ id, describedBy, invalid }) => <select id={id} multiple aria-label="Vector transport" aria-describedby={describedBy} aria-invalid={invalid} className={`${fieldClass} h-24 py-1`} value={workspace.semantic_index.vector_store.supported_transports} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, supported_transports: selectedValues(event) as CanonicalWorkspace["semantic_index"]["vector_store"]["supported_transports"] } } }))}><option value="pgvector_direct">pgvector_direct</option><option value="rest_api">rest_api</option><option value="ssh_tunnel">ssh_tunnel</option></select>}
|
||||
</Field>
|
||||
<Field label="Vector database" error={errors["vector.database"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector database" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.database} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, database: event.target.value } } }))} />}
|
||||
</Field>
|
||||
<Field label="Vector schema" error={errors["vector.schema"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector schema" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.schema} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, schema: event.target.value } } }))} />}
|
||||
</Field>
|
||||
<Field label="Vector collection" error={errors["vector.collection"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector collection" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.collection} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, collection: event.target.value } } }))} />}
|
||||
</Field>
|
||||
<Field label="Vector port" error={errors["vector.port"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="65535" aria-label="Vector port" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.port ?? ""} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, port: numberOrUndefined(event.target.value) } } }))} />}
|
||||
</Field>
|
||||
<Field label="Vector distance">
|
||||
{({ id, describedBy, invalid }) => <select id={id} aria-label="Vector distance" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.distance} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, distance: event.target.value as "cosine" | "l2" | "inner_product" } } }))}><option value="cosine">cosine</option><option value="l2">l2</option><option value="inner_product">inner_product</option></select>}
|
||||
</Field>
|
||||
<Field label="Semantic index dimensions" error={errors["semantic.dimensions"]} hint="One value is shared by the vector collection and embedding model.">
|
||||
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="32768" aria-label="Semantic index dimensions" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.dimensions} onChange={(event) => update((value) => { const dimensions = numberOrUndefined(event.target.value) ?? 0; return { ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, dimensions }, embedding: { ...value.semantic_index.embedding, dimensions } } }; })} />}
|
||||
</Field>
|
||||
<Field label="Embedding provider">
|
||||
{({ id, describedBy, invalid }) => <select id={id} aria-label="Embedding provider" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.embedding.provider} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, embedding: { ...value.semantic_index.embedding, provider: event.target.value as "ollama_compatible" | "openai_compatible" } } }))}><option value="ollama_compatible">ollama_compatible</option><option value="openai_compatible">openai_compatible</option></select>}
|
||||
</Field>
|
||||
<Field label="Embedding model" error={errors["embedding.model"]}>
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.embedding.model} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, embedding: { ...value.semantic_index.embedding, model: event.target.value } } }))} />}
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="LLM policy">
|
||||
<Field label="Allowed models" error={errors["llm.allowed"]} hint="Comma-separated provider/model identifiers.">
|
||||
{({ id, describedBy, invalid }) => <input id={id} aria-label="Allowed models" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={allowedModels} onChange={(event) => update((value) => { const allowed = event.target.value.split(",").map((model) => model.trim()).filter(Boolean) as `${string}/${string}`[]; return { ...value, llm_policy: { allowed, ...(value.llm_policy.default && allowed.includes(value.llm_policy.default) ? { default: value.llm_policy.default } : {}) } }; })} />}
|
||||
</Field>
|
||||
<Field label="Default model" error={errors["llm.default"]} hint="Only models in the allowlist are selectable.">
|
||||
{({ id, describedBy, invalid }) => <select id={id} aria-label="Default model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.llm_policy.default ?? ""} onChange={(event) => update((value) => ({ ...value, llm_policy: { ...value.llm_policy, ...(event.target.value ? { default: event.target.value as `${string}/${string}` } : {}) } }))}><option value="">No default</option>{workspace.llm_policy.allowed.map((model) => <option key={model} value={model}>{model}</option>)}</select>}
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="Installation requirements">
|
||||
<p className="sm:col-span-2 text-sm text-muted-foreground">Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in workspace drafts.</p>
|
||||
</Section>
|
||||
|
||||
<div className="flex justify-end border-t border-border/70 pt-4">
|
||||
<Button type="submit">Save draft</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { beforeEach, expect, test } from "vitest";
|
||||
import { server } from "../test/msw";
|
||||
import { WorkspaceManager } from "./WorkspaceManager";
|
||||
|
||||
const workspace = {
|
||||
workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" },
|
||||
dwh: { engine: "postgres", database: "clinical", schema: "datawarehouse", port: 5432, supported_transports: ["postgres_direct"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "pgvector", database: "vectors", schema: "public", collection: "clinical", dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"] },
|
||||
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
|
||||
},
|
||||
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
|
||||
} as const;
|
||||
|
||||
function renderManager() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}><WorkspaceManager open onClose={() => undefined} /></QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/workspace-registry/status", () =>
|
||||
HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })),
|
||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{
|
||||
id: "psd-clinical", name: "PSD Clinical", displayName: "PSD Clinical", description: "Clinical data",
|
||||
language: "en", file: "workspaces/psd-clinical.yaml",
|
||||
revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" },
|
||||
}])),
|
||||
http.get("http://localhost:8787/workspaces/psd-clinical", () => HttpResponse.json({
|
||||
workspace,
|
||||
revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd", state: "operational" },
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
test("lists registry workspaces and saves a new workspace only as a browser draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderManager();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
|
||||
expect(await screen.findByRole("button", { name: "PSD Clinical" })).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "New workspace" }));
|
||||
await user.clear(screen.getByLabelText("Workspace ID"));
|
||||
await user.type(screen.getByLabelText("Workspace ID"), "trial-registry");
|
||||
await user.click(screen.getByRole("button", { name: "Save draft" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Draft saved in this browser.")).toBeVisible());
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v1.draft.trial-registry")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("stages duplicate and delete operations without publishing", async () => {
|
||||
const user = userEvent.setup();
|
||||
let published = false;
|
||||
server.use(http.post("http://localhost:8787/workspaces/publish", () => {
|
||||
published = true;
|
||||
return HttpResponse.json({});
|
||||
}));
|
||||
renderManager();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
await user.click(screen.getByRole("button", { name: "Duplicate workspace" }));
|
||||
expect(screen.getByLabelText("Workspace ID")).not.toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "PSD Clinical" }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete workspace" }));
|
||||
|
||||
expect(screen.getByText("Deletion draft staged locally.")).toBeVisible();
|
||||
expect(published).toBe(false);
|
||||
});
|
||||
|
||||
test("runs validation and installation test with only sanitized messages", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })),
|
||||
http.post("http://localhost:8787/workspaces/psd-clinical/test", () => HttpResponse.json({
|
||||
activatable: false,
|
||||
diagnostics: [{ level: "warning", code: "binding_missing", field: "dwh", message: "DWH binding is not configured" }],
|
||||
})),
|
||||
);
|
||||
renderManager();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
await user.click(screen.getByRole("button", { name: "Validate workspace" }));
|
||||
expect(await screen.findByText("Workspace definition is valid.")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Test on this installation" }));
|
||||
expect(await screen.findByText("binding_missing: DWH binding is not configured")).toBeVisible();
|
||||
expect(within(screen.getByTestId("workspace-diagnostics")).queryByText(/password|token|secret/i)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, CheckCircle2, ClipboardCheck, FlaskConical, Plus, Trash2, Copy, X } from "lucide-react";
|
||||
import {
|
||||
asWorkspaceApiError, getWorkspace, getWorkspaceRegistryStatus, listWorkspaces, testWorkspace,
|
||||
validateWorkspace, type CanonicalWorkspace, type PublishWorkspaceRequest, type WorkspaceRecord,
|
||||
} from "../api/workspaces";
|
||||
import { workspaceDeletionDrafts, workspaceDrafts, type WorkspaceDeletionDraft, type WorkspaceDraft } from "../workspaces/drafts";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||
import { WorkspaceEditor } from "./WorkspaceEditor";
|
||||
|
||||
const EMPTY_COMMIT = "0".repeat(40);
|
||||
|
||||
function newWorkspace(): CanonicalWorkspace {
|
||||
return {
|
||||
workspace: { schema_version: 2, id: "new-workspace", name: "New workspace", language: "en" },
|
||||
dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "pgvector", database: "vectors", schema: "public", collection: "documents", dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"] },
|
||||
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
}
|
||||
|
||||
function draftFromRecord(record: WorkspaceRecord): WorkspaceDraft {
|
||||
return {
|
||||
workspaceId: record.workspace.workspace.id,
|
||||
baseCommit: record.revision.commit,
|
||||
baseBlob: record.revision.blob,
|
||||
workspace: record.workspace,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function proposedId(id: string): string {
|
||||
return `${id}-copy`.slice(0, 63);
|
||||
}
|
||||
|
||||
export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [localDraft, setLocalDraft] = useState<WorkspaceDraft>();
|
||||
const [notice, setNotice] = useState<string>();
|
||||
const [diagnostics, setDiagnostics] = useState<string[]>([]);
|
||||
const [deletionDraft, setDeletionDraft] = useState<WorkspaceDeletionDraft>();
|
||||
const { data: status } = useQuery({ queryKey: ["workspace-registry-status"], queryFn: getWorkspaceRegistryStatus, enabled: open });
|
||||
const { data: workspaces = [], isLoading: workspacesLoading } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open });
|
||||
const { data: record, isLoading: recordLoading } = useQuery({
|
||||
queryKey: ["workspace", selectedId],
|
||||
queryFn: () => getWorkspace(selectedId!),
|
||||
enabled: Boolean(open && selectedId && !localDraft),
|
||||
});
|
||||
|
||||
const savedDraft = selectedId && !localDraft ? workspaceDrafts.load(selectedId) : undefined;
|
||||
const savedDeletionDraft = selectedId && !deletionDraft ? workspaceDeletionDrafts.load(selectedId) : undefined;
|
||||
const currentDraft = localDraft ?? savedDraft ?? (record ? draftFromRecord(record) : undefined);
|
||||
const activeDeletionDraft = deletionDraft ?? savedDeletionDraft;
|
||||
const canTest = Boolean(record && currentDraft?.workspaceId === record.workspace.workspace.id);
|
||||
const selectedSummary = useMemo(() => workspaces.find((workspace) => workspace.id === selectedId), [selectedId, workspaces]);
|
||||
|
||||
function selectWorkspace(id: string) {
|
||||
setSelectedId(id);
|
||||
setLocalDraft(undefined);
|
||||
setDeletionDraft(undefined);
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
}
|
||||
|
||||
function createWorkspace() {
|
||||
const draft: WorkspaceDraft = { workspaceId: "new-workspace", baseCommit: EMPTY_COMMIT, workspace: newWorkspace(), updatedAt: new Date().toISOString() };
|
||||
setSelectedId(draft.workspaceId);
|
||||
setLocalDraft(draft);
|
||||
setDeletionDraft(undefined);
|
||||
setNotice("New draft. Choose its immutable workspace ID before saving.");
|
||||
setDiagnostics([]);
|
||||
}
|
||||
|
||||
function duplicateWorkspace() {
|
||||
if (!currentDraft) return;
|
||||
const id = proposedId(currentDraft.workspace.workspace.id);
|
||||
const duplicate: WorkspaceDraft = {
|
||||
...currentDraft,
|
||||
workspaceId: id,
|
||||
workspace: { ...currentDraft.workspace, workspace: { ...currentDraft.workspace.workspace, id, name: `${currentDraft.workspace.workspace.name} copy` } },
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setSelectedId(id);
|
||||
setLocalDraft(duplicate);
|
||||
setDeletionDraft(undefined);
|
||||
setNotice("Duplicate draft. Give it a new immutable workspace ID before publishing.");
|
||||
}
|
||||
|
||||
function saveDraft(draft: WorkspaceDraft) {
|
||||
workspaceDrafts.save(draft);
|
||||
setSelectedId(draft.workspaceId);
|
||||
setLocalDraft(draft);
|
||||
setNotice("Draft saved in this browser.");
|
||||
}
|
||||
|
||||
async function validateCurrent() {
|
||||
if (!currentDraft) return;
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
try {
|
||||
const result = await validateWorkspace(currentDraft.workspace);
|
||||
setLocalDraft({ ...currentDraft, workspace: result.workspace, updatedAt: new Date().toISOString() });
|
||||
setNotice("Workspace definition is valid.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed"]);
|
||||
}
|
||||
}
|
||||
|
||||
async function testCurrent() {
|
||||
if (!record) return;
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
try {
|
||||
const result = await testWorkspace(record.workspace.workspace.id);
|
||||
setDiagnostics(result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`));
|
||||
if (result.diagnostics.length === 0) setNotice(result.activatable ? "Installation test passed." : "Installation test completed.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "connector_unavailable: Installation test could not be completed"]);
|
||||
}
|
||||
}
|
||||
|
||||
function stageDeletion() {
|
||||
if (!currentDraft?.baseBlob || !record || currentDraft.workspaceId !== record.workspace.workspace.id) return;
|
||||
const draft = { id: currentDraft.workspaceId, baseCommit: currentDraft.baseCommit, baseBlob: currentDraft.baseBlob, updatedAt: new Date().toISOString() };
|
||||
workspaceDeletionDrafts.save(draft);
|
||||
setDeletionDraft(draft);
|
||||
setNotice("Deletion draft staged locally.");
|
||||
setDiagnostics([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
|
||||
<DialogContent showCloseButton={false} className="max-h-[min(90vh,54rem)] max-w-[min(94vw,74rem)] overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
|
||||
<DialogTitle>Workspace management</DialogTitle>
|
||||
<DialogDescription>Draft shared workspace definitions locally. Installation bindings and secrets stay outside this page.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<button type="button" aria-label="Close workspace management" onClick={onClose} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"><X className="size-4" /></button>
|
||||
<div className="grid max-h-[calc(min(90vh,54rem)-6.75rem)] min-h-[32rem] grid-cols-[14rem_minmax(0,1fr)] overflow-hidden">
|
||||
<nav aria-label="Workspaces" className="flex min-h-0 flex-col border-r border-border/70 bg-muted/30 p-3">
|
||||
<Button size="sm" className="mb-3 w-full" onClick={createWorkspace}><Plus />New workspace</Button>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{workspacesLoading ? <p className="p-2 text-xs text-muted-foreground">Loading workspaces…</p> : workspaces.map((workspace) => (
|
||||
<button key={workspace.id} type="button" aria-label={workspace.displayName} aria-current={selectedId === workspace.id ? "page" : undefined} onClick={() => selectWorkspace(workspace.id)} className="mb-1 w-full rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted aria-[current=page]:bg-primary/10 aria-[current=page]:font-semibold">
|
||||
<span className="block truncate">{workspace.displayName}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{workspace.id}</span>
|
||||
</button>
|
||||
))}
|
||||
{!workspacesLoading && workspaces.length === 0 && <p className="p-2 text-xs text-muted-foreground">No published workspaces.</p>}
|
||||
</div>
|
||||
<div className="border-t border-border/70 pt-3 text-xs text-muted-foreground">
|
||||
<p className="font-semibold text-foreground">Git status</p>
|
||||
<p>{status?.branch ?? "Loading…"}</p>
|
||||
{status && <p>{status.degraded ? "Degraded" : "Current"} · ↑{status.ahead} ↓{status.behind}</p>}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 overflow-y-auto px-5 py-5">
|
||||
{!currentDraft && !recordLoading && <div className="grid min-h-64 place-items-center text-center"><div><h3 className="font-heading font-semibold">Select a workspace</h3><p className="mt-1 text-sm text-muted-foreground">Review an existing definition or start a browser-only draft.</p></div></div>}
|
||||
{(currentDraft || recordLoading) && (
|
||||
<>
|
||||
{recordLoading && !currentDraft ? <p className="text-sm text-muted-foreground">Loading workspace definition…</p> : currentDraft && <>
|
||||
<div className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-border/70 pb-4">
|
||||
<div>
|
||||
<p className="thot-label">Workspace definition</p>
|
||||
<h2 className="font-heading text-lg font-semibold">{currentDraft.workspace.workspace.name}</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">{currentDraft.workspaceId}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={duplicateWorkspace}><Copy />Duplicate workspace</Button>
|
||||
<Button size="sm" variant="outline" onClick={validateCurrent}><ClipboardCheck />Validate workspace</Button>
|
||||
<Button size="sm" variant="outline" disabled={!canTest} onClick={testCurrent}><FlaskConical />Test on this installation</Button>
|
||||
<Button size="sm" variant="destructive" disabled={!canTest || !currentDraft.baseBlob} onClick={stageDeletion}><Trash2 />Delete workspace</Button>
|
||||
</div>
|
||||
</div>
|
||||
{notice && <p role="status" className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm"><CheckCircle2 className="size-4 text-primary" />{notice}</p>}
|
||||
{diagnostics.length > 0 && <div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">{diagnostics.map((diagnostic) => <p key={diagnostic} className="flex items-start gap-2"><AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}</p>)}</div>}
|
||||
{activeDeletionDraft && <div className="mb-4 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm"><p className="font-semibold">Deletion draft</p><p>The published workspace is unchanged. Publishing this staged deletion is a Task 10 action.</p><Button size="xs" variant="ghost" className="mt-2" onClick={() => { workspaceDeletionDrafts.discard(activeDeletionDraft.id); setDeletionDraft(undefined); setNotice(undefined); }}>Discard deletion draft</Button></div>}
|
||||
<WorkspaceEditor draft={currentDraft} idLocked={Boolean(currentDraft.baseBlob && selectedSummary?.id === currentDraft.workspaceId)} onSaveDraft={saveDraft} onPublish={async () => undefined} />
|
||||
<section className="mt-6 border-t border-border/70 pt-5" aria-label="Git status and history">
|
||||
<h3 className="font-heading text-sm font-semibold">Git status & history</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{status?.degraded ? "Using the last valid local snapshot." : "Registry checkout is current."}</p>
|
||||
{record && <p className="mt-1 font-mono text-xs text-muted-foreground">Revision {record.revision.commit.slice(0, 12)} · {record.revision.state}</p>}
|
||||
</section>
|
||||
</>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, expect, test } from "vitest";
|
||||
import type { CanonicalWorkspace } from "../api/workspaces";
|
||||
import { workspaceDrafts, workspacePreferences } from "./drafts";
|
||||
import { workspaceDeletionDrafts, workspaceDrafts, workspacePreferences } from "./drafts";
|
||||
|
||||
const workspace: CanonicalWorkspace = {
|
||||
workspace: { schema_version: 2, id: "psd-clinical", name: "PSD Clinical", language: "en" },
|
||||
@@ -42,6 +42,23 @@ test("reloads a canonical workspace draft and discards it by workspace ID", () =
|
||||
expect(workspaceDrafts.load("psd-clinical")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("persists a deletion draft without retaining a workspace definition", () => {
|
||||
workspaceDeletionDrafts.save({
|
||||
id: "psd-clinical",
|
||||
baseCommit: "a".repeat(40),
|
||||
baseBlob: "b".repeat(40),
|
||||
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(workspaceDeletionDrafts.load("psd-clinical")).toEqual({
|
||||
id: "psd-clinical",
|
||||
baseCommit: "a".repeat(40),
|
||||
baseBlob: "b".repeat(40),
|
||||
updatedAt: "2026-08-04T10:00:00.000Z",
|
||||
});
|
||||
expect(localStorage.getItem("thothii.workspace-registry.v1.delete.psd-clinical")).not.toContain("PSD Clinical");
|
||||
});
|
||||
|
||||
test("keeps valid canonical diagnostic configuration", () => {
|
||||
const configured = {
|
||||
...workspace,
|
||||
|
||||
@@ -9,6 +9,14 @@ export interface WorkspaceDraft {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** A publishable deletion intent; it deliberately carries no workspace body. */
|
||||
export interface WorkspaceDeletionDraft {
|
||||
id: string;
|
||||
baseCommit: string;
|
||||
baseBlob: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const WORKSPACE_SUMMARY_ERROR = "Could not load workspace registry. Please retry.";
|
||||
export const WORKSPACE_POLICY_ERROR = "Could not load selected workspace policy. Please retry.";
|
||||
|
||||
@@ -114,6 +122,7 @@ export const workspacePolicyGate = {
|
||||
|
||||
const PREFIX = "thothii.workspace-registry.v1";
|
||||
const DRAFT_PREFIX = `${PREFIX}.draft.`;
|
||||
const DELETE_DRAFT_PREFIX = `${PREFIX}.delete.`;
|
||||
|
||||
function storage(): Storage | undefined {
|
||||
try { return window.localStorage; } catch { return undefined; }
|
||||
@@ -342,6 +351,25 @@ function key(id: string): string {
|
||||
return `${DRAFT_PREFIX}${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
function deletionKey(id: string): string {
|
||||
return `${DELETE_DRAFT_PREFIX}${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
function normalizeDeletion(value: unknown): WorkspaceDeletionDraft | undefined {
|
||||
const source = exactRecord(value, ["id", "baseCommit", "baseBlob", "updatedAt"]);
|
||||
const id = workspaceId(source?.id);
|
||||
const baseCommit = typeof source?.baseCommit === "string" && /^[0-9a-f]{40}$/.test(source.baseCommit)
|
||||
? source.baseCommit
|
||||
: undefined;
|
||||
const baseBlob = typeof source?.baseBlob === "string" && /^[0-9a-f]{40}$/.test(source.baseBlob)
|
||||
? source.baseBlob
|
||||
: undefined;
|
||||
const updatedAt = typeof source?.updatedAt === "string" && Number.isFinite(Date.parse(source.updatedAt))
|
||||
? source.updatedAt
|
||||
: undefined;
|
||||
return id && baseCommit && baseBlob && updatedAt ? { id, baseCommit, baseBlob, updatedAt } : undefined;
|
||||
}
|
||||
|
||||
/** Browser-only workspace drafts. Saving or editing one never calls the server. */
|
||||
export const workspaceDrafts = {
|
||||
load(id: string): WorkspaceDraft | undefined {
|
||||
@@ -363,3 +391,25 @@ export const workspaceDrafts = {
|
||||
try { storage()?.removeItem(key(id)); } catch { /* storage is optional */ }
|
||||
},
|
||||
};
|
||||
|
||||
/** Browser-only deletion drafts. Task 10 alone may publish one. */
|
||||
export const workspaceDeletionDrafts = {
|
||||
load(id: string): WorkspaceDeletionDraft | undefined {
|
||||
try {
|
||||
const raw = storage()?.getItem(deletionKey(id));
|
||||
return raw ? normalizeDeletion(JSON.parse(raw)) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
save(draft: WorkspaceDeletionDraft): void {
|
||||
const safe = normalizeDeletion(draft);
|
||||
if (!safe) return;
|
||||
try { storage()?.setItem(deletionKey(safe.id), JSON.stringify(safe)); } catch { /* storage is optional */ }
|
||||
},
|
||||
|
||||
discard(id: string): void {
|
||||
try { storage()?.removeItem(deletionKey(id)); } catch { /* storage is optional */ }
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user