diff --git a/frontend/package.json b/frontend/package.json index 39a9d329..76e3c892 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "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", + "prototype:context-shelf": "vite --config vite.context-shelf.config.ts", "build": "tsc -b && vite build", "preview": "vite preview", "test": "vitest run", diff --git a/frontend/prototypes/application-context/README.md b/frontend/prototypes/application-context/README.md index f5ac203a..cef3a0e5 100644 --- a/frontend/prototypes/application-context/README.md +++ b/frontend/prototypes/application-context/README.md @@ -7,6 +7,10 @@ Question: where should the single workspace selector and independent global AI model selector live when analytical work and all five administrative pages share one application context inside Omics Portal? +Update: the owner selected direction C and requested three refinements in +[Context shelf review V4](../context-shelf/README.md), restoring right-rail session +tabs and resolving last choices before installation defaults. V3 is preserved. + ## Run and compare From `frontend/`, run `npm run prototype:application-context`. diff --git a/frontend/prototypes/context-shelf/App.tsx b/frontend/prototypes/context-shelf/App.tsx new file mode 100644 index 00000000..dcc582ea --- /dev/null +++ b/frontend/prototypes/context-shelf/App.tsx @@ -0,0 +1,182 @@ +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 "../application-context/Content"; +import { Button } from "../application-context/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" }; +import { SessionRail } from "./SessionRail"; +import { forget, remember, resolvePreferences } from "./preferences"; + +type Availability = "available" | "unavailable" | "no-workspaces" | "no-models" | "needs-preprocessing"; + +export function App() { + const [initialPreferences] = useState(resolvePreferences); + const [sources, setSources] = useState(initialPreferences.sources); + const [canReadAll, setCanReadAll] = useState(true); + const [route, setRoute] = useState(() => ({ ...readLocation(), workspace: initialPreferences.workspace })); + const { page, workspace, variant } = route; + const [modelId, setModelId] = useState(initialPreferences.model); + const [availability, setAvailability] = useState("available"); + const [width, setWidth] = useState("full"); + const [portalOpen, setPortalOpen] = useState(false); + const [navOpen, setNavOpen] = useState(false); + const [shelfOpen, setShelfOpen] = useState(initialPreferences.invalid); + const [notice, setNotice] = useState(initialPreferences.invalid ? "A remembered choice is no longer available. Choose a valid workspace and model; no alternative has been selected silently." : ""); + const [dirty, setDirty] = useState(false); + const [editing, setEditing] = useState(false); + const [section, setSection] = useState(0); + const [, 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 saveChoice(patch: { workspace?: string; model?: string }) { + const saved = remember(patch); + setSources(old => ({ ...old, ...(patch.workspace ? { workspace: saved ? "Last choice" : "This visit only" } : {}), ...(patch.model ? { model: saved ? "Last choice" : "This visit only" } : {}) })); + return saved; + } + function changeWorkspace(id: string) { + if (busy || id === workspace || !canLeave()) return; + setOperation(null); writeRoute({ ...route, workspace: id }); resetDetail(); setActive(null); setQuestion(""); setResumeTarget(null); + const saved = saveChoice({ workspace: id }); + setNotice(saved ? "Workspace remembered for your next visit. Your model selection is unchanged." : "Browser storage is unavailable. This workspace selection will last only for this visit."); + } + function changeModel(id: string) { + if (busy) return; + setOperation(null); setModelId(id); + const saved = saveChoice({ model: id }); + setNotice(saved ? "Model remembered for your next visit, independently of the workspace." : "Browser storage is unavailable. This model selection will last only for this visit."); + } + 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(prev => ({ ...readLocation(), workspace: prev.workspace })); 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 url = new URL(location.href); + if (workspace) url.searchParams.set("thoth_workspace", workspace); else url.searchParams.delete("thoth_workspace"); + history.replaceState(null, "", url); lastUrl.current = url.href; + }, [workspace, page, variant]); + 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; + saveChoice({ workspace: s.workspace }); 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 contextShelf =
+ + {(shelfOpen || !ready) &&

Working preferences

{variant === "C" ? "Where you work. Which AI you use." : "Choose once, return to the same context."}

Your last choices take precedence over installation defaults. Workspace and model are remembered independently.

{selectedContext}
{busy ? "Both selectors are locked until the current operation ends." : "Saved on this browser for the prototype’s demo user."}{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 panel 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 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(); }}>