prototype: refine context shelf with remembered defaults and session tabs
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
"prototype:administration-pages": "vite --config vite.administration-pages-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:administration-review": "vite --config vite.administration-review.config.ts",
|
||||||
"prototype:application-context": "vite --config vite.application-context.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",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -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
|
model selector live when analytical work and all five administrative pages share
|
||||||
one application context inside Omics Portal?
|
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
|
## Run and compare
|
||||||
|
|
||||||
From `frontend/`, run `npm run prototype:application-context`.
|
From `frontend/`, run `npm run prototype:application-context`.
|
||||||
|
|||||||
@@ -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<Page, string> = { 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<Route>(() => ({ ...readLocation(), workspace: initialPreferences.workspace }));
|
||||||
|
const { page, workspace, variant } = route;
|
||||||
|
const [modelId, setModelId] = useState(initialPreferences.model);
|
||||||
|
const [availability, setAvailability] = useState<Availability>("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<Record<string, string>>({});
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [cards, setCards] = useState<Record<string, Item[]>>({});
|
||||||
|
const [connections, setConnections] = useState<Record<string, ConnectionValues>>({});
|
||||||
|
const [operation, setOperation] = useState<Job | null>(null);
|
||||||
|
const busy = operation?.state === "running";
|
||||||
|
const busyRef = useRef(false); busyRef.current = busy;
|
||||||
|
const [active, setActive] = useState<Session | null>(null);
|
||||||
|
const [resumeTarget, setResumeTarget] = useState<Session | null>(null);
|
||||||
|
const [question, setQuestion] = useState("");
|
||||||
|
const [artifact, setArtifact] = useState("Question");
|
||||||
|
const [scenario, setScenario] = useState<Scenario>("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<HTMLElement>(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 = <div className="gc-selectors" aria-label="Global working context">
|
||||||
|
<label className="gc-selector"><span><Layers size={15}/>Workspace</span><select id="global-workspace" aria-label="Global workspace" value={workspace} onChange={e => changeWorkspace(e.target.value)} aria-describedby={busy ? "operation-lock" : undefined} disabled={busy || availability === "no-workspaces"}><option value="">Choose a workspace</option>{workspaces.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}</select><small><span className="cs-source">{sources.workspace}</span> Core, sessions and administration</small></label>
|
||||||
|
<label className="gc-selector"><span><Cpu size={15}/>AI interaction model</span><select id="global-model" aria-label="Global AI model" value={modelId} onChange={e => changeModel(e.target.value)} aria-describedby={busy ? "operation-lock" : undefined} disabled={busy || availability === "no-models"}><option value="">Choose a model</option>{models.map(m => <option key={m.id} value={m.id}>{m.name}{m.metadata ? "" : " · Core only"}</option>)}</select><small><span className="cs-source">{sources.model}</span> {model ? `${model.provider} · ${model.metadata ? "Core + administration" : "Core only"}` : "Independent of the workspace"}</small></label>
|
||||||
|
</div>;
|
||||||
|
const contextShelf = <section className={`gc-shelf cs-shelf-${variant}`}>
|
||||||
|
<button className="gc-shelf-trigger" aria-expanded={shelfOpen || !ready} aria-controls="context-shelf" onClick={() => { if (ready) setShelfOpen(!shelfOpen); }}>
|
||||||
|
{variant === "A" && <><SlidersHorizontal size={18}/><span className="cs-summary-compact"><small>WORKING CONTEXT</small><strong>{currentWorkspace?.name ?? "Choose workspace"}<span className="gc-separator">/</span>{model?.name ?? "Choose model"}</strong></span></>}
|
||||||
|
{variant === "B" && <div className="cs-summary-ribbon"><span><Layers size={18}/><span><small>WORKSPACE</small><strong>{currentWorkspace?.name ?? "Choose workspace"}</strong></span></span><span><Cpu size={18}/><span><small>AI INTERACTION MODEL</small><strong>{model?.name ?? "Choose model"}</strong></span></span></div>}
|
||||||
|
{variant === "C" && <div className="cs-summary-heading"><span><small>THOTHII / WORKING CONTEXT</small><strong>{currentWorkspace?.name ?? "Choose workspace"}</strong></span><span className="cs-model-chip"><Cpu size={16}/>{model?.name ?? "Choose model"}</span></div>}
|
||||||
|
<span className="cs-context-action">{busy ? <LockKeyhole size={16}/> : <SlidersHorizontal size={16}/>}<span>{busy ? "Locked" : !ready ? "Required" : shelfOpen ? "Close" : "Change context"}</span><ChevronDown size={16}/></span>
|
||||||
|
</button>
|
||||||
|
{(shelfOpen || !ready) && <div id="context-shelf" className="gc-shelf-body"><div className="cs-context-intro"><p className="rv-eyebrow">Working preferences</p><h2>{variant === "C" ? "Where you work. Which AI you use." : "Choose once, return to the same context."}</h2><p>Your last choices take precedence over installation defaults. Workspace and model are remembered independently.</p></div>{selectedContext}<div className="cs-context-footer"><span>{busy ? "Both selectors are locked until the current operation ends." : "Saved on this browser for the prototype’s demo user."}</span>{ready && <Button onClick={() => setShelfOpen(false)}>Done</Button>}</div></div>}
|
||||||
|
</section>;
|
||||||
|
const nav = (p: Page) => {
|
||||||
|
const Icon = icons[p];
|
||||||
|
return <a key={p} href={`?thoth_route=${p === "core" || p === "sessions" ? "analysis" : "administration"}/${p}&variant=${variant}&thoth_workspace=${workspace}`} aria-current={page === p && ready ? "page" : undefined} aria-disabled={!ready} tabIndex={ready ? 0 : -1} onClick={e => { e.preventDefault(); navigate(p); }}><Icon size={17}/><span>{titles[p]}</span>{p === page && ready && <ChevronRight size={14}/>}</a>;
|
||||||
|
};
|
||||||
|
const gate = <section className="gc-gate"><span className="gc-gate-icon"><LockKeyhole size={28}/></span><p className="rv-eyebrow">ThothII · Start here</p><h1>Choose your working context</h1><p>Select a workspace and an AI model in the panel above. Both are required before opening analytical work or administration.</p><ol><li className={currentWorkspace && availability !== "no-workspaces" ? "done" : ""}><span>{currentWorkspace ? <Check size={16}/> : "1"}</span><div><strong>Where you work</strong><small>{currentWorkspace?.name ?? "Workspace, data, knowledge and sessions"}</small></div></li><li className={model && availability !== "no-models" ? "done" : ""}><span>{model ? <Check size={16}/> : "2"}</span><div><strong>Which AI you use</strong><small>{model?.name ?? "A single model preference for the application"}</small></div></li></ol>{["unavailable","no-models","no-workspaces"].includes(availability) && <div className="gc-warning" role="alert"><strong>{availability === "unavailable" ? "The selected provider is unavailable" : availability === "no-models" ? "No usable model is configured" : "No workspace is available"}</strong><p>No activity can start. Ask the installation operator to check the catalog, credentials or workspace registry. No automatic model fallback.</p></div>}<p className="gc-gate-foot">Your data stays inside the selected workspace. AI models belong to the installation.</p></section>;
|
||||||
|
const pageHeader = (title: string, subtitle: string, eyebrow = "Analysis") => <header className="gc-page-heading"><p className="rv-eyebrow">{eyebrow} <span>/ {page === "pi" ? "Installation" : currentWorkspace?.name}</span></p><h1>{title}</h1><p>{subtitle}</p></header>;
|
||||||
|
const resumeReview = resumeTarget && <section className="gc-resume" role="region" aria-label="Resume review"><p className="rv-eyebrow">Before resuming</p><h2 lang="it">{resumeTarget.title}</h2><dl><div><dt>Original workspace, retained</dt><dd>{workspaces.find(w => w.id === resumeTarget.workspace)?.name} · <code>{resumeTarget.revision}</code></dd></div><div><dt>Model for this resume</dt><dd>{model?.name} <small>From your global selection, not the session’s previous model</small></dd></div></dl>{resumeTarget.workspace !== workspace && <p className="gc-warning">Opening this session will switch the global workspace to its original workspace. Your model selection will not change.</p>}{availability === "needs-preprocessing" && <p className="gc-warning">Workspace preprocessing is required before resume.</p>}<div className="rv-actions"><Button primary disabled={busy || !ready || availability === "needs-preprocessing"} onClick={() => resume(resumeTarget)}>Resume with {model?.name ?? "selected model"}</Button><Button onClick={() => setResumeTarget(null)}>Cancel</Button></div></section>;
|
||||||
|
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" && <div className="gc-warning"><strong>Prepare this workspace before starting the core</strong><p>Workspace and model are selected. Administration remains available so you can complete preprocessing.</p><Button onClick={() => navigate("workspace")}>Open workspace readiness</Button></div>}
|
||||||
|
{active ? <div className="gc-analysis"><div className="gc-session-context"><LockKeyhole size={15}/><strong>{currentWorkspace?.name}</strong><span>Session revision {active.revision}</span><span>{active.status === "archived" ? "Read-only archive" : activeJob ? `Run model: ${models.find(m => m.id === activeJob.model)?.name}` : `Next AI operation: ${model?.name}`}</span></div><div className="gc-workflow"><ol>{["Question", "Schema linking", "SQL draft", "Validation"].map((t,i) => <li key={t} className={i === 1 ? "current" : ""}><span>{String(i+1).padStart(2,"0")}</span>{t}</li>)}</ol><small>Workflow excerpt · 8 phases in production</small></div><div className="gc-artifact-layout"><section className="gc-artifact"><nav aria-label="Session artifacts">{["Question","Schema","SQL"].map(t => <button key={t} aria-current={artifact === t ? "page" : undefined} onClick={() => setArtifact(t)}>{t}</button>)}</nav><div><p className="rv-eyebrow">Persisted artifact · example</p><h2>{artifact === "Question" ? "Domanda analitica" : artifact === "Schema" ? "Collegamento allo schema" : "Query proposta"}</h2>{artifact === "SQL" ? <pre><code>{`SELECT COUNT(DISTINCT patient_id)\nFROM ${currentWorkspace?.schema}.visits\nWHERE visited_at >= DATE '2025-01-01'\n AND visited_at < DATE '2026-01-01';`}</code></pre> : <><p lang="it">{artifact === "Question" ? active.title : "La relazione tra pazienti e visite definisce la popolazione osservata. Il conteggio deve mantenere distinti i pazienti."}</p><div className="gc-evidence-line"><FileText size={16}/><span>Evidence: popolazione di riferimento</span></div><p className="rv-small">Synthetic illustration, not generated from a database.</p></>}</div></section><section className="gc-review"><p className="rv-eyebrow">Human review</p><h2>{active.status === "archived" ? "Retained decisions" : activeJob ? "AI operation in progress" : "Confirm the interpretation"}</h2><p>{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."}</p>{active.status !== "archived" && <Button primary disabled={busy} onClick={() => startJob(`Analysis: ${active.title}`, "llm")}>{activeJob ? "Waiting for result" : `Approve & continue with ${model?.name}`}</Button>}<p className="rv-small">{active.status === "archived" ? "Archived and finalized sessions remain read-only." : "Model output proposes. The reviewer decides."}</p></section></div></div> : <div className="gc-core-start"><section className="gc-question"><div className="gc-question-heading"><span className="gc-symbol"><MessageSquare size={23}/></span><div><h2>Start an analytical question</h2><p>{currentWorkspace?.database} · Evidence and Memory in this workspace</p></div></div><form onSubmit={e => { e.preventDefault(); submitQuestion(); }}><label htmlFor="analysis-question" className="rv-eyebrow">Your question</label><textarea id="analysis-question" rows={4} value={question} placeholder="Quanti pazienti hanno almeno una visita di follow-up nel 2025?" onChange={e => { setQuestion(e.target.value); setDirty(!!e.target.value); }}/><div className="gc-composer-footer"><span><Cpu size={15}/>{model?.name}<small>Global model</small></span><button className="rv-button rv-primary" type="submit" disabled={!question.trim() || availability === "needs-preprocessing" || busy}>Start analysis<ArrowRight size={16}/></button></div></form></section><section className="gc-method"><p className="rv-eyebrow">A reviewable path</p><div><span><strong>01</strong>Ask a question</span><ChevronRight size={15}/><span><strong>02</strong>Review context</span><ChevronRight size={15}/><span><strong>03</strong>Validate the query</span></div><p>Every analytical result is backed by persisted artifacts and explicit review decisions.</p></section></div>}
|
||||||
|
</>;
|
||||||
|
const rows = page === "memory" ? cards[workspace] ?? inventory.memory : admin ? inventory[page] : [];
|
||||||
|
const selected = selection[scope] ?? rows[0]?.id;
|
||||||
|
const item = rows.find(r => r.id === selected);
|
||||||
|
const hasList = ["database","memory","evidence"].includes(page);
|
||||||
|
const list = <div className="rv-index"><div className="rv-index-title"><h2>{admin ? modules[page].noun : ""}</h2><span>{rows.length}</span></div><label className="rv-search"><Search size={16}/><input aria-label="Search records" placeholder="Search records" value={filters[scope] ?? ""} onChange={e => setFilters(f => ({ ...f, [scope]: e.target.value }))}/></label><div className="rv-records">{rows.filter(r => r.title.toLowerCase().includes((filters[scope] ?? "").toLowerCase())).map(r => <button key={r.id} className="rv-record" aria-current={selected === r.id ? "true" : undefined} onClick={() => { if (!canLeave()) return; setSelection(s => ({ ...s, [scope]: r.id })); setFocused(true); setEditing(false); setDirty(false); }}><small>{r.category}</small><strong>{r.title}</strong><span>{r.meta}</span><ChevronRight size={15}/></button>)}</div></div>;
|
||||||
|
function changeSection(n: number) { if (!canLeave()) return; setSection(n); setEditing(false); setDirty(false); setFocused(true); }
|
||||||
|
const sections = admin && <nav className="rv-step-nav" aria-label="Management sections">{modules[page].sections.map((s,i) => <button key={s} aria-current={section === i ? "page" : undefined} onClick={() => changeSection(i)}>{<span>{String(i+1).padStart(2,"0")}</span>}{s}</button>)}</nav>;
|
||||||
|
const contentProps: ContentProps | null = admin && ready && currentWorkspace && model ? {
|
||||||
|
surface: page, workspace: currentWorkspace, modelName: model.name, providerName: model.provider, scenario, item, section, editing,
|
||||||
|
onEdit: () => setEditing(true), onDirty: setDirty, onCancel: () => { if (canLeave()) { setEditing(false); setDirty(false); } },
|
||||||
|
onSave: updated => { if (updated) { setCards(c => ({ ...c, [workspace]: selected === "new" ? [updated, ...rows] : rows.map(r => r.id === updated.id ? updated : r) })); setSelection(s => ({ ...s, [scope]: updated.id })); } setDirty(false); setEditing(false); setNotice("Saved in this workspace’s demo data only."); },
|
||||||
|
onDelete: () => { const rest = rows.filter(r => r.id !== selected); setCards(c => ({ ...c, [workspace]: rest })); setSelection(s => ({ ...s, [scope]: rest[0]?.id ?? "" })); },
|
||||||
|
connectionValues: connections[workspace], onConnectionSave: values => { setConnections(c => ({ ...c, [workspace]: values })); setEditing(false); setDirty(false); setNotice("Connection saved in memory only. No real credentials were used."); },
|
||||||
|
onNavigate: navigate, onSection: changeSection, onScenario: setScenario, onNotice: setNotice,
|
||||||
|
onOperation: label => startJob(label, /generation|sensitivity|model test/i.test(label) ? "llm" : /preprocessing|index/i.test(label) ? "embedding" : "system"),
|
||||||
|
} : null;
|
||||||
|
const incompatible = page === "database" && section === 2 && !model?.metadata;
|
||||||
|
const detail = <>{incompatible && <div className="gc-warning" role="alert"><strong>{model?.name} is not enabled for metadata AI</strong><p>Select a model enabled for core and administration in the global selector. No other model will be used automatically.</p><Button onClick={() => { setShelfOpen(true); requestAnimationFrame(() => document.getElementById("global-model")?.focus()); }}>Change global model</Button></div>}<fieldset className="gc-content-fieldset" disabled={busy || incompatible}>{contentProps && <Content key={`${scope}:${selected}:${section}:${editing}`} {...contentProps}/>}</fieldset></>;
|
||||||
|
const administration = admin && <>{pageHeader(modules[page].title, modules[page].subtitle, "Administration")}<div className="gc-admin-context"><span>{page === "pi" ? "Installation scope: applies to all workspaces" : `Workspace: ${currentWorkspace?.name}`}</span><span>{page === "pi" ? "Global LLM selection is not configuration authoring" : `Global LLM: ${model?.name}`}</span>{page === "memory" && <Button primary disabled={busy} onClick={() => { if (busy || !canLeave()) return; setSelection(s => ({ ...s, [scope]: "new" })); setEditing(true); setFocused(true); setDirty(false); setSection(0); }}>New card</Button>}</div>
|
||||||
|
{page === "workspace" && <p className="gc-footnote">Preprocessing uses the installation embedding model. Selecting an interaction LLM does not change index compatibility.</p>}
|
||||||
|
{<div className="rv-operations"><div className="gc-operation-intro"><ShieldCheck size={19}/><span>{page === "pi" ? "Installation diagnostics" : `${currentWorkspace?.name} · Operational context`}</span><strong>{scenario === "ready" ? "Ready for review" : scenario}</strong></div><div className="rv-operation-body">{sections}<div className="rv-operation-detail">{hasList && section === 0 && <details className="rv-choose-record"><summary>Choose record<span>{item?.title ?? "New record"}</span></summary>{list}</details>}{detail}</div></div></div>}
|
||||||
|
</>;
|
||||||
|
return <><div className={`rv-host rv-host-${width} gc-host cs-host ${portalOpen ? "gc-portal-open" : ""}`}><header className="rv-topbar"><button aria-label="Toggle portal navigation" onClick={() => setPortalOpen(!portalOpen)}><Menu size={20}/></button><strong>Omics <span>Portal</span></strong><span className="rv-topbar-divider"/><span className="rv-topbar-location">Clinical data & research</span><span className="rv-topbar-user">Administrator <span className="rv-avatar">AD</span></span></header><div className="gc-host-body"><aside className="gc-portal" aria-label="Illustrative portal navigation"><p className="rv-eyebrow">Portal</p>{[[LayoutDashboard,"Overview"],[Users,"Patients"],[Database,"Clinical data"],[Settings2,"ThothII"]].map(([Icon,label]) => { const I = Icon as typeof Menu; return <div key={String(label)} className={label === "ThothII" ? "current" : ""}><I size={18}/><span>{String(label)}</span></div>; })}<small>Host shell preview</small></aside><div className="gc-mount"><div className="gc-brandline"><span className="gc-brand">Thoth<span>II</span></span><span>Clinical analytics workbench</span><small>Context shelf review · V4</small></div><div className={`gc-app gc-variant-${variant}`}><div className="gc-global-top">{contextShelf}</div>{operation && <section className="gc-operation-strip" aria-label="Current operation"><div><p className="rv-eyebrow">{busy ? "Operation running" : `Operation ${operation.state}`}</p><strong>{operation.label}</strong><small>{workspaces.find(w => w.id === operation.workspace)?.name ?? "Installation"} · {models.find(m => m.id === operation.model)?.name ?? operation.model}</small><p id="operation-lock">{busy ? "Workspace and model are locked. Finish this operation before starting another." : "Workspace and model are available again."}</p></div>{busy && <div className="rv-actions"><Button onClick={() => setOperation({ ...operation, state: "complete" })}>Demo: finish</Button><Button onClick={() => setOperation({ ...operation, state: "cancelled" })}>Demo: stop</Button><Button onClick={() => setOperation({ ...operation, state: "failed" })}>Demo: fail</Button></div>}</section>}<div className="gc-body"><aside className="gc-rail"><button className="gc-mobile-nav" onClick={() => setNavOpen(!navOpen)} aria-expanded={navOpen}><Menu size={17}/>Sessions & navigation<ChevronDown size={16}/></button><div className={`gc-navigation ${navOpen ? "open" : ""}`}><nav className="cs-core-nav" aria-label="Core navigation">{nav("core")}</nav><SessionRail workspace={workspace} ready={ready} busy={busy} activeId={active?.id} canReadAll={canReadAll} onOpen={s => { if (busy || !ready || !canLeave()) return; setNavOpen(false); if (s.status === "paused") setResumeTarget(s); else { resetDetail(); setQuestion(""); setActive(s); writeRoute({ ...route, page: "core" }); } requestAnimationFrame(() => mainRef.current?.focus()); }}/>{canReadAll && <details className="cs-admin-nav" open={variant === "A" ? true : undefined}><summary><Settings2 size={16}/>Administration<ChevronDown size={14}/></summary><nav aria-label="Administration navigation">{(["database","memory","evidence","workspace","pi"] as Page[]).map(nav)}</nav></details>}</div></aside><main ref={mainRef} tabIndex={-1} className={`gc-main rv-variant-${variant}`} aria-label={ready ? titles[page] : "Context required"}>{notice && <div className="rv-notice" role="status"><span>{notice}</span><button aria-label="Dismiss notice" onClick={() => setNotice("")}><X size={16}/></button></div>}{dirty && <div className="rv-dirty" role="status">Unsaved draft. Save or discard before changing workspace or page.</div>}{!ready ? gate : <>{resumeReview}{page === "core" && active && <div className="gc-new-question"><Button disabled={busy} onClick={() => { if (busy || !canLeave()) return; setActive(null); setQuestion(""); setDirty(false); setNotice("Ready for a new question in the selected workspace."); }}>New question<ArrowRight size={15}/></Button></div>}{page === "core" || page === "sessions" ? core : administration}</>}<footer className="gc-page-footer"><span>Prototype · Synthetic content only</span><span>{ready ? "Context applies across ThothII" : "Core and administration are locked"}</span></footer></main></div></div></div></div></div>
|
||||||
|
{import.meta.env.DEV && <aside className="rv-lab gc-lab" aria-label="Prototype review controls"><div className="rv-lab-main"><span className="rv-lab-tag">CONTEXT SHELF · V4</span><button aria-label="Previous variant" onClick={() => cycle(-1)}><ArrowLeft size={17}/></button><select aria-label="Layout variant" value={variant} onChange={e => { if (canLeave()) { writeRoute({ ...route, variant: e.target.value as Variant }, true); resetDetail(); setQuestion(""); setShelfOpen(!ready); } }}>{Object.entries(variants).map(([k,v]) => <option key={k} value={k}>{k} · {v}</option>)}</select><button aria-label="Next variant" onClick={() => cycle(1)}><ArrowRight size={17}/></button><span className="rv-lab-help">No server writes</span></div><details><summary>Review scenarios & state</summary><div className="rv-lab-options"><label>Host width<select aria-label="Host width" value={width} onChange={e => setWidth(e.target.value)}><option value="full">Available width</option><option value="compact">960 px host</option><option value="phone">390 px host</option></select></label><label>Availability<select aria-label="Availability scenario" disabled={busy} value={availability} onChange={e => { if (canLeave()) { setAvailability(e.target.value as Availability); resetDetail(); } }}><option value="available">Available</option><option value="unavailable">Provider unavailable</option><option value="no-workspaces">No workspaces</option><option value="no-models">No models</option><option value="needs-preprocessing">Needs preprocessing</option></select></label><Button disabled={busy} onClick={() => { if (busy || !canLeave()) return; setOperation(null); writeRoute({ ...route, workspace: "" }); setModelId(""); resetDetail(); setActive(null); setQuestion(""); setResumeTarget(null); setShelfOpen(true); }}>Clear current context</Button><Button disabled={busy || !ready} onClick={() => { if (!busy && canLeave()) { setResumeTarget(sessions[2]); setNavOpen(false); } }}>Demo: Genetics session link</Button><Button disabled={busy} onClick={() => { if (busy || !canLeave()) return; if (!forget()) { setNotice("Browser storage could not be cleared."); return; } location.reload(); }}>Forget saved choices</Button><Button disabled={busy} onClick={() => { if (busy || !canLeave()) return; setCanReadAll(!canReadAll); writeRoute({ ...route, page: "core" }); resetDetail(); }}>Demo access: {canReadAll ? "Administrator" : "Analyst"}</Button><p>Workspace source: {sources.workspace} · Model source: {sources.model}</p><p>Page: {page} · Workspace: {workspace || "none"} · Model: {model?.name ?? "none"} · Gate: {ready ? "open" : "closed"} · Draft: {dirty ? "unsaved" : "clean"} · Operation: {operation?.state ?? "idle"}</p><p>Only explicit workspace/model choices persist on this browser. Reload resets simulated operations and drafts. Defaults are illustrative. V1/V2/V3 are preserved on ports 5174/5175/5176.</p></div></details></aside>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Context shelf review V4
|
||||||
|
|
||||||
|
2026-09-12. **C is the chosen direction; C1/C2/C3 remain under review.**
|
||||||
|
Throwaway, development-only UI. No production API calls or server configuration
|
||||||
|
changes. Existing V1, V2 and V3 prototypes are retained.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
From `frontend/`, run `npm run prototype:context-shelf`.
|
||||||
|
|
||||||
|
- C1: <http://127.0.0.1:5177/?thoth_route=analysis/core&variant=A>
|
||||||
|
- C2: <http://127.0.0.1:5177/?thoth_route=analysis/core&variant=B>
|
||||||
|
- C3: <http://127.0.0.1:5177/?thoth_route=analysis/core&variant=C>
|
||||||
|
|
||||||
|
The URL keys A/B/C identify the three descendants of C in this new review, not
|
||||||
|
the old context-bar/context-rail/context-shelf alternatives. Switch in the bottom
|
||||||
|
review bar without reloading, or with arrow keys outside interactive controls.
|
||||||
|
|
||||||
|
## What this iteration compares
|
||||||
|
|
||||||
|
All variants have one collapsible panel at the top of the entire ThothII app.
|
||||||
|
The panel is collapsed when valid selections have been resolved, but opens when
|
||||||
|
the required context is incomplete. A visible summary always identifies workspace
|
||||||
|
and interaction model. Opening the panel reveals both selectors and their origins.
|
||||||
|
|
||||||
|
| Proposal | Top panel | Session rail |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C1: Compact strip | A compact workspace/model summary; two side-by-side selectors when expanded | Administration precedes the session tabs, closest to the previous navigation order |
|
||||||
|
| C2: Context ribbon | Separate labelled workspace/model areas aligned with the expanded fields | Sessions first, with compact divided rows; administration below |
|
||||||
|
| C3: Workspace heading | Workspace is the main heading, model a secondary read-only label; expanded panel has explanation beside stacked fields | Sessions first, with individually bounded rows and administration below |
|
||||||
|
|
||||||
|
On narrow containers, fields stack and sessions/navigation expand inline above
|
||||||
|
the main content. The illustrative left navigation belongs to Omics Portal,
|
||||||
|
not to the ThothII application. Warm surfaces, Manrope controls, Fraunces headings
|
||||||
|
and the red portal frame continue the prior Impeccable direction.
|
||||||
|
|
||||||
|
## Session management restored to the right rail
|
||||||
|
|
||||||
|
- Remove the central “Continue where you left off” area and its separate session
|
||||||
|
navigation link. Access sessions from the right rail in every proposal.
|
||||||
|
- Restore My sessions / All sessions tabs, with an explicit selected tab and
|
||||||
|
keyboard Left/Right/Home/End navigation. Search and paused/archived groupings
|
||||||
|
are represented with synthetic records.
|
||||||
|
- All sessions is an authorized administrator view, not a cross-workspace selector.
|
||||||
|
Both tabs use the globally selected workspace. The administrator fixture adds
|
||||||
|
a colleague-owned archived record; the analyst fixture hides All sessions and
|
||||||
|
administration. Actual production permissions remain backend-owned.
|
||||||
|
- Resume stays behind a review of the session's original workspace/revision and
|
||||||
|
the globally selected model. Genuine archives remain read-only pending the
|
||||||
|
already-asked archive/restoration clarification.
|
||||||
|
- This is a layout study, not authorization to remove original session grouping,
|
||||||
|
rename, archive, bulk operations or other existing behavior from production.
|
||||||
|
Those must be retained and verified in the eventual implementation.
|
||||||
|
|
||||||
|
## Default and last-choice audit
|
||||||
|
|
||||||
|
The concepts already exist in current code, but in different places:
|
||||||
|
|
||||||
|
| Value | Existing source | Finding |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Installation workspace default | `Settings.workspace`, read from `SETTINGS_FILE`, normally `backend/data/settings.json` in local development | The inspected local file has `workspace: "psd"`. It is not a newly introduced YAML field. |
|
||||||
|
| Session model default | `modelCatalog.defaults.session` in the installation YAML | The PSD configuration has `zai/glm-5.3`. |
|
||||||
|
| Metadata model default | `modelCatalog.defaults.metadataGeneration` in the same YAML | The PSD configuration also has `zai/glm-5.3`; the schema allows the two defaults to differ. |
|
||||||
|
| Last personal choice | `frontend/src/workspaces/preferences.ts` | Currently module-level memory only, not durable browser storage. A page reload loses it. |
|
||||||
|
|
||||||
|
Evidence: `backend/src/settings/settings-store.ts`, `backend/src/routes/settings.ts`,
|
||||||
|
`backend/src/app.ts` (settings resolution), `backend/src/config.ts` (file location),
|
||||||
|
`frontend/src/shell/SteerInput.tsx`, `frontend/src/workspaces/preferences.ts`,
|
||||||
|
`tools/tht/internal/config/model_catalog.go`, and `deploy/psd/thothii-installation.yaml`.
|
||||||
|
|
||||||
|
The backend currently falls back to the first active registry workspace when the
|
||||||
|
settings file has no workspace. The frontend validates the returned workspace
|
||||||
|
against selectable revisions. This fallback is not an explicitly authored default
|
||||||
|
in the workspace repository. V4 does not invent a new YAML field or modify this
|
||||||
|
production behavior; its fixtures represent an explicit configured default.
|
||||||
|
|
||||||
|
The actual deployed settings and registry were not inspected. The local value
|
||||||
|
`psd` must not be confused with the synthetic prototype ID `psd-clinical`.
|
||||||
|
|
||||||
|
For the unified model preference, V4 represents the existing common PSD default.
|
||||||
|
If session and metadata defaults diverge in another installation, the production
|
||||||
|
spec must choose a single initial interaction preference explicitly; it must not
|
||||||
|
silently switch to a second model on entering administration. Eligibility gates
|
||||||
|
from V3 continue to prevent unsupported metadata AI operations.
|
||||||
|
|
||||||
|
## Requested resolution rule
|
||||||
|
|
||||||
|
Resolve workspace and model **independently**:
|
||||||
|
|
||||||
|
1. Use that field's last explicit user choice, if present.
|
||||||
|
2. Otherwise use its installation default.
|
||||||
|
3. Validate the resolved ID. If a remembered choice is unavailable, leave that
|
||||||
|
field unselected, explain the problem and open the panel. Do not silently
|
||||||
|
select a different workspace or model.
|
||||||
|
|
||||||
|
Defaults populate the selectors, so valid defaults satisfy the required-context
|
||||||
|
gate without an extra confirmation. Resolving a default does not mark it as an
|
||||||
|
explicit user choice. Changing just the model does not freeze the default workspace
|
||||||
|
as a remembered choice. Cancelling a dirty-workspace transition does not persist it.
|
||||||
|
An explicit confirmed resume adopts and remembers the session's workspace; the
|
||||||
|
global model remains independent. Page-history navigation does not restore an
|
||||||
|
obsolete workspace selection. Last-choice/default resolution takes precedence
|
||||||
|
over a stale workspace query parameter on a fresh visit.
|
||||||
|
|
||||||
|
## Prototype-only persistence
|
||||||
|
|
||||||
|
Only selected identifiers are persisted, using localStorage key
|
||||||
|
`thothii:prototype:context-shelf-v4:demo-user`. It is separate from production keys
|
||||||
|
and shared by these three variants on port 5177. Reload or reopening the same
|
||||||
|
browser restores the choices. There is no cross-device or authenticated-user
|
||||||
|
profile synchronization. No secrets, records or clinical data are stored.
|
||||||
|
|
||||||
|
The review bar exposes Forget saved choices, which removes only this prototype's
|
||||||
|
key and reloads with defaults. If browser storage is unavailable, selections work
|
||||||
|
for the current visit and the UI reports that they could not be remembered.
|
||||||
|
Drafts, record edits and the simulated operation remain ephemeral.
|
||||||
|
|
||||||
|
Production persistence must be scoped to installation and authenticated user;
|
||||||
|
the fixed demo-user key is not a production storage design. It must not update
|
||||||
|
shared server installation defaults with a person's choices.
|
||||||
|
|
||||||
|
## Retained operation rule
|
||||||
|
|
||||||
|
Exactly one core or administrative operation at a time. Both selectors and all
|
||||||
|
other operation starts are disabled until the running operation completes, stops
|
||||||
|
or fails. Browsing another page does not release this lock. This prototype does
|
||||||
|
not implement backend exclusion, reconnect recovery or persistent running jobs.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Isolated strict TypeScript check and whitespace check passed.
|
||||||
|
- C1/C2/C3: first visit resolves configured defaults without saving them as user
|
||||||
|
choices; model-only choice retains independent workspace default; reload restores
|
||||||
|
the last model and workspace; source labels reflect the correct precedence.
|
||||||
|
- C1/C2/C3: session tabs, owner filtering, keyboard tab navigation, removal of the
|
||||||
|
former central recent-sessions area, and operation locking/unlocking verified.
|
||||||
|
- Additional checks: unavailable saved workspace fails closed; rejected context
|
||||||
|
change preserves draft and storage; forgetting choices restores defaults;
|
||||||
|
analyst fixture hides All sessions and administration.
|
||||||
|
- 96 Chromium layout checks: 3 variants, 4 viewport widths (1600/1280/960/390),
|
||||||
|
open/closed panel plus core and all five administrative pages. No root overflow
|
||||||
|
or runtime errors; page and rail containment checked on the content pages.
|
||||||
|
- 390 px embedded host inside a 1600 px browser also checked, with a 368 px main
|
||||||
|
region and no host overflow. Desktop and mobile screenshots visually reviewed.
|
||||||
|
- No physical-device, Safari/WebKit, real Omics/Bootstrap or deployed-server
|
||||||
|
acceptance is claimed. Core and administrative actions use synthetic fixtures.
|
||||||
|
|
||||||
|
Next: choose the V4 arrangement, resolve any outstanding domain questions, then
|
||||||
|
continue the agreed to-spec / to-tickets process on Gitea. No new implementation
|
||||||
|
agents or tickets were started during this prototype review. Final integration
|
||||||
|
remains on the server, not the local Omics Portal checkout.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useRef, useState, type KeyboardEvent } from "react";
|
||||||
|
import { ChevronRight, FolderClock, Search } from "lucide-react";
|
||||||
|
import { sessions, type Session } from "./model";
|
||||||
|
|
||||||
|
export function SessionRail({ workspace, ready, busy, activeId, canReadAll, onOpen }: { workspace: string; ready: boolean; busy: boolean; activeId?: string; canReadAll: boolean; onOpen: (s: Session) => void }) {
|
||||||
|
const [scope, setScope] = useState<"mine" | "all">("mine");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const tabs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
|
const effectiveScope = canReadAll ? scope : "mine";
|
||||||
|
const rows = ready ? sessions.filter(s => s.workspace === workspace && (effectiveScope === "all" || s.owner === "You") && s.title.toLocaleLowerCase().includes(query.toLocaleLowerCase())) : [];
|
||||||
|
function onKey(e: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||||
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
|
||||||
|
e.preventDefault(); const target = e.key === "Home" ? 0 : e.key === "End" ? 1 : 1-index;
|
||||||
|
setScope(target === 0 ? "mine" : "all"); tabs.current[target]?.focus();
|
||||||
|
}
|
||||||
|
return <section className="cs-sessions" aria-label="Session navigation"><div className="cs-session-heading"><FolderClock size={17}/><h2>Sessions</h2><span>{rows.length}</span></div>{canReadAll ? <div className="cs-session-tabs" role="tablist" aria-label="Session scope">{(["mine","all"] as const).map((value,i) => <button key={value} ref={node => { tabs.current[i] = node; }} id={`scope-${value}`} type="button" role="tab" aria-controls="session-list" aria-selected={effectiveScope === value} tabIndex={effectiveScope === value ? 0 : -1} disabled={!ready} onClick={() => setScope(value)} onKeyDown={e => onKey(e,i)}>{value === "mine" ? "My sessions" : "All sessions"}</button>)}</div> : <p className="cs-mine-label">My sessions</p>}<div id="session-list" role={canReadAll ? "tabpanel" : undefined} aria-labelledby={canReadAll ? `scope-${effectiveScope}` : undefined}>{effectiveScope === "all" && <p className="cs-scope-note">Administrator view · Selected workspace</p>}<label className="rv-search"><Search size={15}/><input aria-label="Search sessions" placeholder="Search sessions" disabled={!ready} value={query} onChange={e => setQuery(e.target.value)}/></label>{(["paused","archived"] as const).map(status => <div className="cs-session-group" key={status}><p className="rv-eyebrow">{status === "paused" ? "In progress" : "Archived"}<span>{rows.filter(s => s.status === status).length}</span></p>{rows.filter(s => s.status === status).map(s => <button key={s.id} className="cs-session-row" disabled={busy || !ready} aria-current={s.id === activeId ? "true" : undefined} onClick={() => onOpen(s)}><span className="cs-session-mark">{status === "paused" ? String(s.phase).padStart(2,"0") : "✓"}</span><span><strong lang="it">{s.title}</strong><small>{effectiveScope === "all" ? `${s.owner} · ` : ""}{status === "paused" ? "Paused · Can resume" : "Read-only archive"}</small></span><ChevronRight size={13}/></button>)}</div>)}{!rows.length && <p className="cs-session-empty">{!ready ? "Choose a valid workspace and model to access sessions." : query ? "No sessions match this search." : "No sessions in this workspace yet."}</p>}</div></section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><title>ThothII · Context shelf variants</title><link rel="preconnect" href="https://fonts.googleapis.com"/><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/><link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,500;9..144,600&display=swap" rel="stylesheet"/></head><body><div id="root"></div><script type="module" src="/main.tsx"></script></body></html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "../administration-review/review.css";
|
||||||
|
import "../application-context/context.css";
|
||||||
|
import "./shelf.css";
|
||||||
|
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { sessions as originalSessions } from "../application-context/model";
|
||||||
|
export { models, modules, readLocation, workspaces } from "../application-context/model";
|
||||||
|
export type { Page, Route, Session, Job } from "../application-context/model";
|
||||||
|
export const variants = { A: "C1 · Compact strip", B: "C2 · Context ribbon", C: "C3 · Workspace heading" };
|
||||||
|
export const sessions = [
|
||||||
|
...originalSessions.map(s => ({ ...s, owner: "You" })),
|
||||||
|
{ id: "colleague-psd", title: "Popolazione osservata per anno", workspace: "psd-clinical", revision: "a71c9f2", status: "archived" as const, lastModel: "zai/glm-5.3", phase: 8, owner: "Elena Rossi" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { models, workspaces } from "./model";
|
||||||
|
|
||||||
|
// Prototype-only storage. Never read or overwrite production preference keys.
|
||||||
|
export const PREFERENCE_KEY = "thothii:prototype:context-shelf-v4:demo-user";
|
||||||
|
// Synthetic equivalents of settings.workspace and modelCatalog.defaults.session.
|
||||||
|
// No installation configuration is authored or changed by this prototype.
|
||||||
|
export const defaults = { workspace: "psd-clinical", model: "zai/glm-5.3" };
|
||||||
|
type Saved = { workspace?: string; model?: string };
|
||||||
|
export function readSaved(): Saved {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(localStorage.getItem(PREFERENCE_KEY) ?? "{}");
|
||||||
|
if (!value || typeof value !== "object") return {};
|
||||||
|
return {
|
||||||
|
...(typeof value.workspace === "string" && value.workspace ? { workspace: value.workspace } : {}),
|
||||||
|
...(typeof value.model === "string" && value.model ? { model: value.model } : {}),
|
||||||
|
};
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
export function resolvePreferences() {
|
||||||
|
const saved = readSaved();
|
||||||
|
const wantedWorkspace = saved.workspace ?? defaults.workspace;
|
||||||
|
const wantedModel = saved.model ?? defaults.model;
|
||||||
|
const workspace = workspaces.some(w => w.id === wantedWorkspace) ? wantedWorkspace : "";
|
||||||
|
const model = models.some(m => m.id === wantedModel) ? wantedModel : "";
|
||||||
|
return { workspace, model, sources: { workspace: saved.workspace ? "Last choice" : "Installation default", model: saved.model ? "Last choice" : "Installation default" }, invalid: !workspace || !model };
|
||||||
|
}
|
||||||
|
export function remember(patch: Saved): boolean {
|
||||||
|
try { localStorage.setItem(PREFERENCE_KEY, JSON.stringify({ ...readSaved(), ...patch })); return true; }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
export function forget(): boolean {
|
||||||
|
try { localStorage.removeItem(PREFERENCE_KEY); return true; } catch { return false; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/* V4: three descendants of C. All context controls expand inline above the full
|
||||||
|
application; none occupy the session rail. The existing product typography and
|
||||||
|
red Omics host are retained. Structure changes, not palette swaps. */
|
||||||
|
.cs-host .gc-brandline > small { font-size: .62rem; }
|
||||||
|
.cs-host .gc-shelf-trigger { min-height: 74px; gap: 20px; }
|
||||||
|
.cs-host .gc-shelf-trigger > span:nth-child(2) { flex: unset; }
|
||||||
|
.cs-host .cs-summary-compact { flex: 1 !important; min-width: 0; }
|
||||||
|
.cs-context-action { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin-left: auto; color: var(--rv-red-dark); font-size: .75rem; font-weight: 700; }
|
||||||
|
.cs-context-action > span { white-space: nowrap; }
|
||||||
|
.cs-host .gc-shelf-body { padding: 26px; }
|
||||||
|
.cs-context-intro { margin-bottom: 22px; }
|
||||||
|
.cs-context-intro > h2 { margin: 8px 0 10px; font-size: 1.125rem; }
|
||||||
|
.cs-context-intro > p:last-child { color: var(--rv-muted); font-size: .8rem; line-height: 1.8; }
|
||||||
|
.cs-context-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px; margin-top: 22px; border-top: 1px solid var(--rv-line); padding-top: 17px; }
|
||||||
|
.cs-context-footer > span { font-size: .72rem; color: var(--rv-muted); }
|
||||||
|
.cs-host .gc-selectors { max-width: none; }
|
||||||
|
.cs-host .gc-selector > small { display: flex; align-items: baseline; flex-wrap: wrap; gap: 7px; }
|
||||||
|
.cs-source { display: inline-block; color: var(--rv-ink); border: 1px solid var(--rv-line); background: var(--rv-paper); padding: 2px 6px; border-radius: 3px; font-size: .62rem; font-weight: 650; white-space: nowrap; }
|
||||||
|
.cs-summary-ribbon { flex: 1; min-width: 0; display: grid; grid-template-columns: minmax(0,1fr); gap: 14px; }
|
||||||
|
.cs-summary-ribbon > span { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.cs-summary-ribbon > span > span { min-width: 0; }
|
||||||
|
.cs-summary-ribbon svg { color: var(--rv-red-dark); }
|
||||||
|
.cs-summary-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; min-width: 0; flex: 1; }
|
||||||
|
.cs-summary-heading > span:first-child { min-width: 0; }
|
||||||
|
.cs-host .cs-summary-heading strong { font: 500 1.5rem/1.3 var(--rv-serif); letter-spacing: -.025em; }
|
||||||
|
.cs-model-chip { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 8px 12px; border: 1px solid var(--rv-line); background: var(--rv-paper); border-radius: 5px; font-size: .8rem; font-weight: 650; }
|
||||||
|
.cs-host .cs-shelf-C .gc-shelf-trigger { padding-block: 24px; }
|
||||||
|
.cs-host .gc-navigation { padding: 18px; }
|
||||||
|
.cs-host .gc-navigation.open { display: flex; flex-direction: column; }
|
||||||
|
.cs-core-nav { margin-bottom: 18px; }
|
||||||
|
.cs-host .gc-navigation .cs-core-nav { display: block; }
|
||||||
|
.cs-core-nav a { border: 1px solid var(--rv-line); background: var(--rv-paper); }
|
||||||
|
.cs-session-heading { display: flex; align-items: center; gap: 9px; margin: 4px 0 17px; }
|
||||||
|
.cs-host .cs-session-heading h2 { font-size: 1rem; letter-spacing: -.015em; }
|
||||||
|
.cs-session-heading > span { margin-left: auto; color: var(--rv-muted); font: .72rem var(--rv-mono); }
|
||||||
|
.cs-session-heading > svg { color: var(--rv-red-dark); }
|
||||||
|
.cs-sessions { min-width: 0; }
|
||||||
|
.cs-session-tabs { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); border-bottom: 1px solid var(--rv-line); margin-bottom: 17px; }
|
||||||
|
.cs-session-tabs button { border: 1px solid transparent; border-bottom: 2px solid transparent; background: transparent; padding: 12px 5px; font-size: .75rem !important; font-weight: 700 !important; }
|
||||||
|
.cs-session-tabs button[aria-selected=true] { background: var(--rv-red-soft); border-color: oklch(85% .025 23); border-bottom-color: var(--rv-red); color: var(--rv-red-dark); border-radius: 4px 4px 0 0; }
|
||||||
|
.cs-session-tabs button:hover { background: var(--rv-sunken); }
|
||||||
|
.cs-mine-label, .cs-scope-note { font-size: .7rem; margin: 9px 0 16px !important; color: var(--rv-muted); }
|
||||||
|
.cs-scope-note { color: var(--rv-amber); }
|
||||||
|
.cs-session-group { margin: 22px 0 12px; }
|
||||||
|
.cs-session-group > p { display: flex; justify-content: space-between; margin-bottom: 10px; }
|
||||||
|
.cs-session-group > p > span { color: var(--rv-muted); }
|
||||||
|
.cs-session-row { display: flex; width: 100%; gap: 10px; align-items: flex-start; text-align: left; background: transparent; padding: 14px 9px; border: 1px solid transparent; border-radius: 5px; }
|
||||||
|
.cs-session-row:hover { background: var(--rv-sunken); }
|
||||||
|
.cs-session-row[aria-current] { background: var(--rv-red-soft); border-color: oklch(83% .035 23); }
|
||||||
|
.cs-session-mark { display: grid; place-items: center; min-width: 23px; font: .65rem var(--rv-mono); color: var(--rv-red-dark); padding-top: 3px; }
|
||||||
|
.cs-session-row > span:nth-child(2) { min-width: 0; flex: 1; }
|
||||||
|
.cs-session-row strong { display: block; font-size: .8rem; line-height: 1.65; font-weight: 650; overflow-wrap: anywhere; }
|
||||||
|
.cs-session-row small { display: block; margin-top: 7px; font-size: .64rem; line-height: 1.6; color: var(--rv-muted); }
|
||||||
|
.cs-session-row > svg { margin-top: 5px; }
|
||||||
|
.cs-session-empty { font-size: .78rem; color: var(--rv-muted); line-height: 1.8; margin-block: 16px !important; }
|
||||||
|
.cs-admin-nav { margin-top: 26px; border-top: 1px solid var(--rv-line); padding-top: 17px; }
|
||||||
|
.cs-admin-nav > summary { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: .75rem; font-weight: 750; min-height: 40px; list-style: none; }
|
||||||
|
.cs-admin-nav > summary > svg:last-child { margin-left: auto; }
|
||||||
|
.cs-admin-nav[open] > summary > svg:last-child { transform: rotate(180deg); }
|
||||||
|
.cs-admin-nav nav { margin-top: 10px; }
|
||||||
|
.cs-host .gc-navigation .cs-admin-nav nav { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||||
|
.cs-host .gc-variant-A .cs-admin-nav { order: 1; margin: 0 0 22px; padding: 10px 0; border-block: 1px solid var(--rv-line); }
|
||||||
|
.cs-host .gc-variant-A .cs-sessions { order: 2; }
|
||||||
|
.cs-host .gc-variant-A .cs-core-nav { order: 0; }
|
||||||
|
.cs-host .gc-variant-B .cs-sessions { padding: 18px 14px; border: 1px solid var(--rv-line); border-radius: 6px; background: var(--rv-paper); }
|
||||||
|
.cs-host .gc-variant-B .cs-session-row { border-radius: 0; border-bottom: 1px solid var(--rv-line); padding-inline: 0; }
|
||||||
|
.cs-host .gc-variant-B .cs-session-mark { display: none; }
|
||||||
|
.cs-host .gc-variant-C .cs-session-heading { padding: 15px 0; border-block: 1px solid var(--rv-line); }
|
||||||
|
.cs-host .gc-variant-C .cs-session-row { background: var(--rv-paper); border-color: var(--rv-line); margin-block: 8px; }
|
||||||
|
.cs-host .gc-variant-C .cs-session-row[aria-current] { background: var(--rv-red-soft); border-color: oklch(83% .035 23); }
|
||||||
|
.cs-host .gc-method { padding-top: 30px; }
|
||||||
|
@container application (min-width: 42rem) {
|
||||||
|
.cs-summary-ribbon { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||||
|
.cs-summary-ribbon > span:last-child { padding-left: 25px; border-left: 1px solid var(--rv-line); }
|
||||||
|
.cs-host .cs-shelf-B .gc-shelf-trigger { min-height: 96px; }
|
||||||
|
.cs-host .cs-shelf-B .cs-context-intro { display: none; }
|
||||||
|
.cs-host .cs-shelf-C .gc-shelf-body { display: grid; grid-template-columns: minmax(0,.8fr) minmax(0,1.2fr); gap: 0 32px; }
|
||||||
|
.cs-host .cs-shelf-C .gc-selectors { grid-template-columns: minmax(0,1fr); }
|
||||||
|
.cs-host .cs-shelf-C .cs-context-footer { grid-column: 1 / -1; }
|
||||||
|
.cs-host .cs-shelf-C .cs-context-intro h2 { font: 500 1.5rem/1.4 var(--rv-serif); }
|
||||||
|
}
|
||||||
|
@container application (min-width: 55rem) {
|
||||||
|
.cs-host .gc-body, .cs-host .gc-variant-B .gc-body { grid-template-columns: minmax(0,1fr) 310px; }
|
||||||
|
.cs-host .gc-navigation { display: flex; flex-direction: column; }
|
||||||
|
.cs-host .gc-navigation .cs-admin-nav nav { grid-template-columns: minmax(0,1fr); }
|
||||||
|
}
|
||||||
|
@container application (max-width: 41.999rem) {
|
||||||
|
.cs-host .gc-shelf-trigger { gap: 13px; padding: 17px 16px; align-items: flex-start; }
|
||||||
|
.cs-host .cs-context-action > span { display: none; }
|
||||||
|
.cs-context-action { flex-wrap: nowrap; margin-top: 4px; }
|
||||||
|
.cs-host .gc-shelf-body { padding: 20px 16px; }
|
||||||
|
.cs-host .cs-summary-heading strong { font-size: 1.3rem; }
|
||||||
|
.cs-host .cs-summary-heading { gap: 12px; }
|
||||||
|
.cs-model-chip { padding: 6px 8px; font-size: .75rem; }
|
||||||
|
.cs-host .gc-brandline > small { display: none; }
|
||||||
|
}
|
||||||
|
@media (pointer: coarse) { .cs-session-tabs button, .cs-session-row, .cs-admin-nav > summary { min-height: 44px; } }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
export default defineConfig(({ command }) => {
|
||||||
|
if (command !== "serve") throw new Error("Context-shelf prototype is development-only.");
|
||||||
|
return { root: path.resolve(__dirname, "prototypes/context-shelf"), plugins: [react()], server: { host: "127.0.0.1", port: 5177, strictPort: true } };
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user