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; export interface WorkspaceEditorProps { draft?: WorkspaceDraft; onSaveDraft: (draft: WorkspaceDraft) => void; /** Reserved for Task 10; saving a draft never publishes it. */ onPublish: (request: PublishWorkspaceRequest) => Promise; 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): 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 (
{children({ id, describedBy, invalid: Boolean(error) })} {hint &&

{hint}

} {error && }
); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } 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(draft?.workspace ?? emptyWorkspace()); const [errors, setErrors] = useState({}); 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 (
{ event.preventDefault(); saveDraft(); }} noValidate>
{({ id, describedBy, invalid }) => update((value) => ({ ...value, workspace: { ...value.workspace, id: event.target.value } }))} />} {({ id, describedBy, invalid }) => update((value) => ({ ...value, workspace: { ...value.workspace, name: event.target.value } }))} />} {({ id, describedBy, invalid }) => update((value) => ({ ...value, workspace: { ...value.workspace, description: event.target.value || undefined } }))} />} {({ id, describedBy, invalid }) => }
{({ id, describedBy, invalid }) => } {({ id, describedBy, invalid }) => } {({ id, describedBy, invalid }) => update((value) => ({ ...value, dwh: { ...value.dwh, database: event.target.value } }))} />} {({ id, describedBy, invalid }) => update((value) => ({ ...value, dwh: { ...value.dwh, schema: event.target.value } }))} />} {({ id, describedBy, invalid }) => update((value) => ({ ...value, dwh: { ...value.dwh, port: numberOrUndefined(event.target.value) } }))} />} {({ id, describedBy, invalid }) => update((value) => ({ ...value, dwh: { ...value.dwh, timeout_ms: numberOrUndefined(event.target.value) } }))} />}
{({ id, describedBy, invalid }) => } {({ id, describedBy, invalid }) => } {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, database: event.target.value } } }))} />} {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, schema: event.target.value } } }))} />} {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, collection: event.target.value } } }))} />} {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, port: numberOrUndefined(event.target.value) } } }))} />} {({ id, describedBy, invalid }) => } {({ id, describedBy, invalid }) => 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 } } }; })} />} {({ id, describedBy, invalid }) => } {({ id, describedBy, invalid }) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, embedding: { ...value.semantic_index.embedding, model: event.target.value } } }))} />}
{({ id, describedBy, invalid }) => 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 } : {}) } }; })} />} {({ id, describedBy, invalid }) => }

Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in workspace drafts.

); }