diff --git a/frontend/package.json b/frontend/package.json index 5271bab0..39a9d329 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "prototype:database-management": "vite --config vite.database-management-prototype.config.ts", "prototype:administration-pages": "vite --config vite.administration-pages-prototype.config.ts", "prototype:administration-review": "vite --config vite.administration-review.config.ts", + "prototype:application-context": "vite --config vite.application-context.config.ts", "build": "tsc -b && vite build", "preview": "vite preview", "test": "vitest run", diff --git a/frontend/prototypes/administration-review/README.md b/frontend/prototypes/administration-review/README.md index f2d8591c..c2bc59a5 100644 --- a/frontend/prototypes/administration-review/README.md +++ b/frontend/prototypes/administration-review/README.md @@ -2,6 +2,10 @@ Throwaway prototype, 2026-09-10. **No variant selected yet.** +Historical scope: the 2026-09-12 [full application context review V3](../application-context/README.md) +supersedes the independent administrative workspace assumption below. This V2 +prototype and all three variants are preserved for comparison. + Question: which page structure best supports the five existing administrative workflows inside the space allocated by Omics Portal? diff --git a/frontend/prototypes/application-context/App.tsx b/frontend/prototypes/application-context/App.tsx new file mode 100644 index 00000000..af1e6dc9 --- /dev/null +++ b/frontend/prototypes/application-context/App.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from "react"; +import { ArrowLeft, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronDown, ChevronRight, Cpu, Database, FileText, FolderClock, Layers, LayoutDashboard, LockKeyhole, Menu, MessageSquare, Play, Search, Settings2, ShieldCheck, SlidersHorizontal, Terminal, Users, X } from "lucide-react"; +import { Content, type ContentProps, type ConnectionValues } from "./Content"; +import { Button } from "./ui"; +import { inventory, type Item, type Scenario, type Surface, type Variant } from "../administration-review/model"; +import { models, modules, readLocation, sessions, variants, workspaces, type Job, type Page, type Route, type Session } from "./model"; + +// Three complete-app proposals on the established namespaced routes. V3 is a +// disposable simulation, with no API, persistence, authorization or server writes. +const icons = { core: MessageSquare, sessions: FolderClock, database: Database, memory: BookOpen, evidence: FileText, workspace: Layers, pi: Terminal }; +const titles: Record = { core: "Analytical workspace", sessions: "Sessions", database: "Database", memory: "Memory", evidence: "Evidence", workspace: "Workspace", pi: "Pi runtime" }; +type Availability = "available" | "unavailable" | "no-workspaces" | "no-models" | "needs-preprocessing"; + +export function App() { + const [route, setRoute] = useState(readLocation); + const { page, workspace, variant } = route; + const [modelId, setModelId] = useState(""); + const [availability, setAvailability] = useState("available"); + const [width, setWidth] = useState("full"); + const [portalOpen, setPortalOpen] = useState(false); + const [navOpen, setNavOpen] = useState(false); + const [shelfOpen, setShelfOpen] = useState(true); + const [notice, setNotice] = useState(""); + const [dirty, setDirty] = useState(false); + const [editing, setEditing] = useState(false); + const [section, setSection] = useState(0); + const [focused, setFocused] = useState(false); + const [selection, setSelection] = useState>({}); + const [filters, setFilters] = useState>({}); + const [cards, setCards] = useState>({}); + const [connections, setConnections] = useState>({}); + const [operation, setOperation] = useState(null); + const busy = operation?.state === "running"; + const busyRef = useRef(false); busyRef.current = busy; + const [active, setActive] = useState(null); + const [resumeTarget, setResumeTarget] = useState(null); + const [question, setQuestion] = useState(""); + const [artifact, setArtifact] = useState("Question"); + const [scenario, setScenario] = useState("ready"); + const model = models.find(m => m.id === modelId); + const currentWorkspace = workspaces.find(w => w.id === workspace); + const ready = !!currentWorkspace && !!model && !["unavailable", "no-workspaces", "no-models"].includes(availability); + const admin = page !== "core" && page !== "sessions"; + const scope = `${page}:${page === "pi" ? "installation" : workspace}`; + const dirtyRef = useRef(false); dirtyRef.current = dirty; + const lastUrl = useRef(location.href); + const mainRef = useRef(null); + const canLeave = () => !dirtyRef.current || window.confirm("Discard unsaved changes? Saved records and running operations will be kept."); + const resetDetail = () => { setDirty(false); setEditing(false); setSection(0); setFocused(false); setScenario("ready"); }; + function writeRoute(next: Route, replace = false) { + const url = new URL(location.href); + url.searchParams.set("thoth_route", `${next.page === "core" || next.page === "sessions" ? "analysis" : "administration"}/${next.page}`); + url.searchParams.set("variant", next.variant); + if (next.workspace) url.searchParams.set("thoth_workspace", next.workspace); else url.searchParams.delete("thoth_workspace"); + history[replace ? "replaceState" : "pushState"](null, "", url); + lastUrl.current = url.href; setRoute(next); + } + function navigate(next: Page) { + if (!ready || !canLeave()) return; + writeRoute({ ...route, page: next }); resetDetail(); setQuestion(""); setNavOpen(false); setResumeTarget(null); setNotice(""); + requestAnimationFrame(() => mainRef.current?.focus()); + } + function changeWorkspace(id: string) { + if (busy || id === workspace || !canLeave()) return; + setOperation(null); writeRoute({ ...route, workspace: id }); resetDetail(); setActive(null); setQuestion(""); setResumeTarget(null); + setNotice("Workspace changed across ThothII. Your model selection is unchanged. No operation is running."); + } + function changeModel(id: string) { + if (busy) return; + setOperation(null); setModelId(id); + setNotice("Global model updated for analytical work, administration and session resume."); + } + function cycle(delta: number) { + if (!canLeave()) return; + const keys: Variant[] = ["A", "B", "C"]; + writeRoute({ ...route, variant: keys[(keys.indexOf(variant) + delta + 3) % 3] }, true); + resetDetail(); setQuestion(""); setShelfOpen(!ready); + } + useEffect(() => { + const pop = () => { + if (busyRef.current) { history.pushState(null, "", lastUrl.current); setNotice("Finish the current operation before following browser history."); return; } + if (!canLeave()) { history.pushState(null, "", lastUrl.current); return; } + setRoute(readLocation()); lastUrl.current = location.href; resetDetail(); setActive(null); setQuestion(""); setResumeTarget(null); + }; + const unload = (e: BeforeUnloadEvent) => { if (dirtyRef.current) { e.preventDefault(); e.returnValue = ""; } }; + window.addEventListener("popstate", pop); window.addEventListener("beforeunload", unload); + return () => { window.removeEventListener("popstate", pop); window.removeEventListener("beforeunload", unload); }; + }, []); + useEffect(() => { + const key = (e: KeyboardEvent) => { + if ((e.target as HTMLElement).closest("input,textarea,select,button,[contenteditable],[role=tablist]")) return; + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); cycle(e.key === "ArrowRight" ? 1 : -1); } + }; + window.addEventListener("keydown", key); return () => window.removeEventListener("keydown", key); + }); + function startJob(label: string, kind: "llm" | "embedding" | "system" = "system") { + if (busy || !ready || (kind === "llm" && !model?.metadata && admin && page !== "pi")) return; + setOperation({ id: Date.now(), label, workspace: page === "pi" || label.includes("repository") ? "installation" : workspace, model: kind === "llm" ? modelId : kind === "embedding" ? "Installation embedding model" : "No LLM required", state: "running" }); + setResumeTarget(null); + setNotice("Operation started. Workspace, model and other operations are locked until it finishes."); + } + function resume(s: Session) { + if (busy || !ready || s.status === "archived" || availability === "needs-preprocessing" || !canLeave()) return; + setActive(s); setResumeTarget(null); resetDetail(); setQuestion(""); setArtifact("Question"); + writeRoute({ ...route, workspace: s.workspace, page: "core" }); + setOperation({ id: Date.now(), label: `Resume: ${s.title}`, workspace: s.workspace, model: modelId, state: "running" }); + setNotice("Proposed resume: the original workspace and revision are retained; this run uses the globally selected model. Backend changes are required."); + } + const activeJob = busy && operation.workspace === workspace && (operation.label.startsWith("Resume:") || operation.label.startsWith("Analysis:")) ? operation : null; + const selectedContext =
+ + +
; + const contextBar =
Working contextOne selection, all of ThothII
{selectedContext}{ready ? : } {busy ? "Locked during operation" : ready ? "Ready" : "Required"}
; + const contextShelf =
{(shelfOpen || !ready) &&
{selectedContext}

Changing workspace updates every page. Both selectors are locked while a core or administrative operation is running.

{ready && }
}
; + const nav = (p: Page) => { + const Icon = icons[p]; + return { e.preventDefault(); navigate(p); }}>{titles[p]}{p === page && ready && }; + }; + const gate =

ThothII · Start here

Choose your working context

Select a workspace and an AI model in the {variant === "B" ? "context panel" : "bar above"}. Both are required before opening analytical work or administration.

  1. {currentWorkspace ? : "1"}
    Where you work{currentWorkspace?.name ?? "Workspace, data, knowledge and sessions"}
  2. {model ? : "2"}
    Which AI you use{model?.name ?? "A single model preference for the application"}
{["unavailable","no-models","no-workspaces"].includes(availability) &&
{availability === "unavailable" ? "The selected provider is unavailable" : availability === "no-models" ? "No usable model is configured" : "No workspace is available"}

No activity can start. Ask the installation operator to check the catalog, credentials or workspace registry. No automatic model fallback.

}

Your data stays inside the selected workspace. AI models belong to the installation.

; + const pageHeader = (title: string, subtitle: string, eyebrow = "Analysis") =>

{eyebrow} / {page === "pi" ? "Installation" : currentWorkspace?.name}

{title}

{subtitle}

; + const sessionList = <>{pageHeader("Your analytical sessions", "Resume unfinished work or inspect the retained artifacts of a completed analysis.")}
{sessions.filter(s => s.workspace === workspace).map(s =>
{s.status === "paused" ? "Paused · Can resume" : "Archived · Read-only"}

{s.title}

Phase {s.phase} of 8 · Workspace revision {s.revision}

Last model: {models.find(m => m.id === s.lastModel)?.name}
)}{!sessions.some(s => s.workspace === workspace) &&

No sessions in this workspace

Start an analytical question to create the first session.

}
; + const resumeReview = resumeTarget &&

Before resuming

{resumeTarget.title}

Original workspace, retained
{workspaces.find(w => w.id === resumeTarget.workspace)?.name} · {resumeTarget.revision}
Model for this resume
{model?.name} From your global selection, not the session’s previous model
{resumeTarget.workspace !== workspace &&

Opening this session will switch the global workspace to its original workspace. Your model selection will not change.

}{availability === "needs-preprocessing" &&

Workspace preprocessing is required before resume.

}
; + function submitQuestion() { + if (!ready || !question.trim() || availability === "needs-preprocessing" || busy) return; + const title = question.trim(); setActive({ id: `demo-${Date.now()}`, title, workspace, revision: "a71c9f2", status: "paused", lastModel: modelId, phase: 1 }); + setQuestion(""); setDirty(false); startJob(`Analysis: ${title}`, "llm"); + } + const core = <>{pageHeader(active ? active.title : "From a question to a trusted answer", active ? "Persisted artifacts and reviewer decisions guide this session." : "Ask about your data. Review the evidence, approve the interpretation, then build the query.")} + {availability === "needs-preprocessing" &&
Prepare this workspace before starting the core

Workspace and model are selected. Administration remains available so you can complete preprocessing.

} + {active ?
{currentWorkspace?.name}Session revision {active.revision}{active.status === "archived" ? "Read-only archive" : activeJob ? `Run model: ${models.find(m => m.id === activeJob.model)?.name}` : `Next AI operation: ${model?.name}`}
    {["Question", "Schema linking", "SQL draft", "Validation"].map((t,i) =>
  1. {String(i+1).padStart(2,"0")}{t}
  2. )}
Workflow excerpt · 8 phases in production

Persisted artifact · example

{artifact === "Question" ? "Domanda analitica" : artifact === "Schema" ? "Collegamento allo schema" : "Query proposta"}

{artifact === "SQL" ?
{`SELECT COUNT(DISTINCT patient_id)\nFROM ${currentWorkspace?.schema}.visits\nWHERE visited_at >= DATE '2025-01-01'\n  AND visited_at < DATE '2026-01-01';`}
: <>

{artifact === "Question" ? active.title : "La relazione tra pazienti e visite definisce la popolazione osservata. Il conteggio deve mantenere distinti i pazienti."}

Evidence: popolazione di riferimento

Synthetic illustration, not generated from a database.

}

Human review

{active.status === "archived" ? "Retained decisions" : activeJob ? "AI operation in progress" : "Confirm the interpretation"}

{active.status === "archived" ? "This archive can be inspected, but its workflow cannot currently be resumed." : activeJob ? "Workspace and model are locked until this operation finishes. No other operation can start in the meantime." : "Check the proposed population and time range before proceeding."}

{active.status !== "archived" && }

{active.status === "archived" ? "Archived and finalized sessions remain read-only." : "Model output proposes. The reviewer decides."}

:

Start an analytical question

{currentWorkspace?.database} · Evidence and Memory in this workspace

{ e.preventDefault(); submitQuestion(); }}>