diff --git a/app/globals.css b/app/globals.css index 225af33..467d70f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -655,6 +655,9 @@ main.analysis-main { max-width: min(132rem, 100%); } .extag.on { background: var(--accent-ink); border-color: var(--accent-ink); color: var(--bg); } .extag.on .meta { color: var(--bg); opacity: 0.8; } .extag.is-static { cursor: default; } +.exfacet { align-items: baseline; } +.exfacet-l { font-size: var(--t-xs); color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; min-width: 3.6rem; } +.exempty { color: var(--muted); } .vkind { font-size: var(--t-xs); padding: 0.1rem 0.45rem; border-radius: var(--r-pill); white-space: nowrap; background: color-mix(in oklab, var(--accent-ink) 16%, transparent); color: var(--accent-ink); diff --git a/app/lab/agent-tools.ts b/app/lab/agent-tools.ts index 54fff7a..54dbef6 100644 --- a/app/lab/agent-tools.ts +++ b/app/lab/agent-tools.ts @@ -8,6 +8,7 @@ import { exposeTools, type AgentTool, type Json } from "../../lib/webmcp.ts"; import { analysisText } from "../../lib/result-text.ts"; import { AGENT_GRAMMAR, EXAMPLES, type DslExample } from "../../lib/dsl-reference.ts"; +import { exampleFeatures } from "../../lib/example-features.ts"; import type { AnalysisDoc, AnalysisResult, CohortDef, ExploreResult, PartitionDef } from "../../lib/types.ts"; import { withoutPartition, editSplits, splitKey } from "../../lib/partition-arms.ts"; import { api } from "./api.ts"; @@ -211,9 +212,9 @@ export function installLabTools(get: () => LabBridge): { webmcp: boolean; cleanu const j = await (await api.studies()).json(); return (j.studies || j).map((s: any) => ({ id: s.id, name: s.name, cancerType: s.cancerType, patients: s.patients, samples: s.samples, timelinePatients: s.timelinePatients })); } }, - { name: "list_examples", description: "The worked example analyses in the gallery: title and what each demonstrates. Load one with analysis_load_example.", + { name: "list_examples", description: "The worked example analyses in the gallery: title, what each demonstrates, the view kinds it draws (`views`) and its topics (`topics`). Load one with analysis_load_example.", inputSchema: { type: "object", properties: {} }, - run: async () => EXAMPLES.map((e) => ({ title: e.title, demonstrates: e.demonstrates, tags: e.tags })) }, + run: async () => EXAMPLES.map((e) => ({ title: e.title, demonstrates: e.demonstrates, ...exampleFeatures(e) })) }, // ---- edit -------------------------------------------------------------------------- { name: "analysis_set_document", description: "Replace the whole Analysis document. The engine runs it first; if it rejects the document (or any view errors), the page is left unchanged and the reason is returned.", diff --git a/app/lab/examples-tab.tsx b/app/lab/examples-tab.tsx index ec5e137..013d7f4 100644 --- a/app/lab/examples-tab.tsx +++ b/app/lab/examples-tab.tsx @@ -1,12 +1,17 @@ "use client"; -// The Examples tab: the documented analyses (lib/dsl-reference.ts EXAMPLES), filtered by tag. The -// tag belongs to the page, which keeps it while this unmounts. The examples are written against the +// The Examples tab: the documented analyses (lib/dsl-reference.ts EXAMPLES), filtered by one view kind +// or topic (lib/example-features.ts) and by a search over their text. Both belong to the page, which +// keeps them while this unmounts; the search box keeps its own draft and hands it over after a pause, +// so a keystroke re-renders this tab, not the page. The examples are written against the // public warehouse's studies; where this deployment's database does not hold them (EXAMPLES_URL, // lib/site.ts), Run opens each on the deployment that does, after saying the reader is leaving. -import { useState } from "react"; -import { Check, ExternalLink, Link2, Play } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Check, ExternalLink, Link2, Play, Search } from "lucide-react"; import { EXAMPLES, type DslExample } from "../../lib/dsl-reference.ts"; +import { exampleFeatures, matchesSearch, studiesOf } from "../../lib/example-features.ts"; import { Button } from "../_ui/button"; +import { Input } from "../_ui/input"; +import { cn } from "../../lib/cn"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../_ui/dialog"; import { usePublicEnv } from "../_env/public-env-context.tsx"; import { parseExamplesUrl } from "../../lib/site.ts"; @@ -14,11 +19,11 @@ import { parseExamplesUrl } from "../../lib/site.ts"; /** a tag filter: an outline chip, filled while it is the one shown */ const TAG = "group rounded-pill px-2.5 text-sm font-normal aria-pressed:border-link aria-pressed:bg-link aria-pressed:text-card aria-pressed:hover:bg-link/90 aria-pressed:hover:text-card"; const TAG_N = "text-muted-foreground group-aria-pressed:text-card/80"; -const TAGS = [...new Set(EXAMPLES.flatMap(e => e.tags || []))].sort(); -const viewKinds = (ex: DslExample): string[] => [...new Set((ex.doc.views || []).map(v => String(v.kind)))]; -const studiesOf = (ex: DslExample): string[] => [...new Set(Object.values(ex.doc.cohorts || {}) - .flatMap((c: any) => (c && c.studies) || (c && c.study ? [c.study] : [])) - .concat(ex.doc.study ? [ex.doc.study] : []))]; +const FEATURES = new Map(EXAMPLES.map(e => [e, exampleFeatures(e)])); +const byName = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" }); +const VIEWS = [...new Set([...FEATURES.values()].flatMap(f => f.views))].sort(byName); +const TOPICS = [...new Set([...FEATURES.values()].flatMap(f => f.topics))].sort(byName); +const has = (ex: DslExample, t: string) => { const f = FEATURES.get(ex)!; return f.views.includes(t) || f.topics.includes(t); }; const hostOf = (url: string) => { try { return new URL(url).host; } catch { return url; } }; @@ -30,9 +35,12 @@ export function useExamplesUrl(): { examplesUrl: string | null; examplesUrlError catch (e) { return { examplesUrl: null, examplesUrlError: String((e as Error)?.message || e) }; } } -export function ExamplesTab({ tag, onTag, busy, onRun, link, elsewhere, elsewhereError, onError }: { +export function ExamplesTab({ tag, onTag, query, onQuery, busy, onRun, link, elsewhere, elsewhereError, onError }: { tag: string; onTag: (t: string) => void; + /** the search over titles, descriptions and pills */ + query: string; + onQuery: (q: string) => void; busy: boolean; onRun: (ex: DslExample) => void; /** the example's shareable ?doc= link — on the deployment that runs it */ @@ -52,7 +60,26 @@ export function ExamplesTab({ tag, onTag, busy, onRun, link, elsewhere, elsewher try { await navigator.clipboard.writeText(link(ex)); setCopied(ex.title); setTimeout(() => setCopied(null), 1800); } catch { onError("could not copy to clipboard"); } }; - const shown = tag === "all" ? EXAMPLES : EXAMPLES.filter(e => (e.tags || []).includes(tag)); + const [draft, setDraft] = useState(query); + const latest = useRef(draft); + latest.current = draft; + useEffect(() => { + if (draft === query) return; + const t = setTimeout(() => onQuery(draft), 250); + return () => clearTimeout(t); + }, [draft, query, onQuery]); + // hand a draft still waiting on its pause to the page when the tab is left + useEffect(() => () => onQuery(latest.current), [onQuery]); + const searched = EXAMPLES.filter(e => matchesSearch(e, draft)); + const shown = tag === "all" ? searched : searched.filter(e => has(e, tag)); + const chip = (t: string) => { + const n = searched.filter(e => has(e, t)).length; + return ( + + ); + }; return (
{ex.demonstrates}
} {ex.customData?.length ?
diff --git a/app/lab/page.tsx b/app/lab/page.tsx
index 1203542..e9ded45 100644
--- a/app/lab/page.tsx
+++ b/app/lab/page.tsx
@@ -312,7 +312,7 @@ export default function AnalysisPage() {
const toolActions = useToolActions({ doc, docs, goTab, persistSpaces, queryClient, setError, setStudy, setValidating, study });
const { commitDoc, proposePartition, saveAs, validateDoc } = toolActions;
const library = useLibrary({ docs, evalDef, goTab, persist, saved, sharedSaved: savedArtifacts.sharedCohorts, setError, setStudy, study });
- const { analyzePicked, cohortsSubTab, docLink, exEvals, exTag, picked, previewExample, runDocExample, saveExample, setCohortsSubTab, setExTag, setPicked, togglePick } = library;
+ const { analyzePicked, cohortsSubTab, docLink, exEvals, exQuery, exTag, picked, previewExample, runDocExample, saveExample, setCohortsSubTab, setExQuery, setExTag, setPicked, togglePick } = library;
// where the gallery examples run (EXAMPLES_URL): here, or a deployment that holds their studies
const { examplesUrl, examplesUrlError } = useExamplesUrl();
const exampleLink = (ex: DslExample) => examplesUrl ? examplesUrl + "/lab?doc=" + docParam(ex.doc) : docLink(ex.doc);
@@ -468,7 +468,7 @@ export default function AnalysisPage() {
wsError={wsError} onCustomData={showClauseData} />}
{/* ================= Examples tab ================= */}
- {tab === "examples" &&