loopable / web /src /query /QueryPage.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
13.6 kB
// ---------------------------------------------------------------------------
// query/QueryPage.tsx — WAVE 32 item 7 (ruling R1, contract C5): the Query module.
//
// Ask a question about ONE database you already have; the assistant answers with
// a VIEW, you read what it will actually do, and you keep it or you do not.
//
// ⛔ CHROME, NOT A REGISTRY MODULE, and `granted` is why that is allowed. `nav.ts`'s
// law for this set is one sentence — "A CHROME ROUTE RENDERS NOTHING THE SERVER DID
// NOT ALREADY GRANT" — so the database picker is the nav's OWN entries, handed down
// by the Shell, never a directory this page fetches. (A registry row would have been
// worse than wrong: every provisioned tenant's `modules` list is `['analyst',
// 'automation']`, so a registry key is silently OMITTED from the rail in every
// tenant while every gate stays green.)
//
// ⛔ NO SECOND GRID. Opening a saved query NAVIGATES to that database and asks the
// real surface to select the view (`VIEW_OPEN_EVENT`), which is how the view renders
// in its declared kind without this file learning what a kanban is. That channel's
// own contract says it: "the SHELL routes to the table and the GRID owns view
// selection, so neither has to learn the other's state."
//
// ⭐ AND THIS IS ITS FIRST EMITTER. `VIEW_OPEN_EVENT` was added in wave 20 for an
// alert's click-through and has had a LISTENER (`CustomerGrid`) and no sender ever
// since — the deep link it was built for was never wired ([[artifact-with-no-importer]]
// on the emit side). Two consequences handled here: the listener checks its own view
// list first (`views.some(...)`), so an event that arrives before the target grid has
// loaded is DROPPED, and there is no acknowledgement channel to wait on. So the emit
// is repeated on a short bounded schedule and `selectView` early-returns once the view
// is active, which makes a re-emit free rather than a flicker.
//
// ⚠ THE SENTENCE UNDER THE ANSWER IS THE LOAD-BEARING PART OF THIS PAGE. Measured in
// the prototype (W32-T50 section 4d): a free model produced a spec whose every column
// was real, every op legal, kind in the vocabulary and target granted — and which
// answered a different question ("Revenue by Creator" against a database holding no
// revenue). No server-side validation can see that class. So the server DERIVES a
// read-back from the spec it accepted, this page shows it, and nothing is saved until
// a person says so. Never shorten `explain` away to make the page tidier.
//
// ⚠ NO CLIENT UNION OVER `kind` — the server's vocabulary arrives as a string (the
// wave-9 law), so a kind we do not have a label for renders as itself rather than
// dropping the row.
// ---------------------------------------------------------------------------
import { useCallback, useEffect, useState } from "react";
import { VIEW_OPEN_EVENT, signal } from "../apiContract";
import { databaseEntries } from "../shell/nav";
import type { NavEntry } from "../shell/nav";
import { buildQuery, deleteQuery, fetchQueries, saveQuery } from "./queryApi";
import type { BuildResult, SavedQuery } from "./queryApi";
import "./query.css";
export interface QueryPageProps {
/**
* ⛔ REQUIRED, and it is the nav's own entries. An optional prop here would
* degrade to "the feature does not exist" the first time a mount forgot it,
* which is indistinguishable from never having been built (the PRD's words for
* this wiring). The Shell already holds these — passing them is also what keeps
* the chrome law true by construction rather than by promise.
*/
granted: NavEntry[];
}
/** Human words for a kind. A kind absent from here renders as itself, never dropped. */
const KIND_LABEL: Record<string, string> = {
grid: "Table",
list: "List",
chart: "Chart",
kanban: "Board",
calendar: "Calendar",
timeseries: "Time series",
map: "Map",
};
/**
* When the view is opened in another surface, the target grid may not have loaded its
* views yet — and its listener drops an event naming a view it cannot see. Re-emit on
* this schedule (ms); `selectView` is idempotent, so a late arrival costs nothing.
*/
export const OPEN_RETRIES = [0, 250, 700, 1500, 2600] as const;
export function openSavedView(scope: string, viewId: string): void {
if (typeof window === "undefined") return;
if (window.location.hash.replace(/^#\/?/, "") !== scope) {
window.location.hash = `#/${scope}`;
}
for (const delay of OPEN_RETRIES) {
window.setTimeout(() => signal(VIEW_OPEN_EVENT, { topic: scope, viewId }), delay);
}
}
/**
* The answer block: a refusal, or the read-back plus the two ways out.
*
* ⚠ A NAMED EXPORT ON PURPOSE, so `verify_query`'s render leg can paint the three states
* directly. `renderToString` does not run effects, so the states this page reaches only
* AFTER a fetch would otherwise be unprovable without a browser — and the read-back and
* the refusal sentence are precisely the parts that must not quietly stop rendering
* ([[ui-invisible-to-assertions]]).
*/
export function QueryAnswer({ result, saving, onKeep, onDiscard }: {
result: BuildResult;
saving?: boolean;
onKeep: () => void;
onDiscard: () => void;
}) {
if (result.refused) {
return (
<section className="qy-answer is-refused" role="status">
<p className="qy-answer-head">That is not a view this database can draw</p>
<p className="qy-answer-body">{result.refused}</p>
</section>
);
}
if (!result.spec) return null;
return (
<section className="qy-answer">
<p className="qy-answer-head">Here is what that view would do</p>
{/* ⛔ THE READ-BACK. Derived by the SERVER from the spec it accepted, so it
describes what will be built rather than what the model meant. */}
{result.explain ? <p className="qy-answer-body">{result.explain}</p> : null}
<div className="qy-answer-acts">
<button type="button" className="qy-btn qy-btn--primary" disabled={saving}
onClick={onKeep}>
{saving ? "Saving…" : "Keep this view"}
</button>
<button type="button" className="qy-btn" onClick={onDiscard}>
Discard
</button>
</div>
</section>
);
}
/** One saved view. Named for the same reason as {@link QueryAnswer}. */
export function SavedQueryRow({ view, dbLabel, onOpen, onDelete }: {
view: SavedQuery;
dbLabel: string;
onOpen: () => void;
onDelete: () => void;
}) {
return (
<li className="qy-row">
<div className="qy-row-main">
<button type="button" className="qy-row-open" onClick={onOpen}>
{view.name}
</button>
<span className="qy-row-meta">
{KIND_LABEL[view.kind] ?? view.kind} · {dbLabel}
</span>
</div>
{view.question ? <p className="qy-row-q">“{view.question}”</p> : null}
{view.explain ? <p className="qy-row-explain">{view.explain}</p> : null}
<div className="qy-row-acts">
<button type="button" className="qy-btn" onClick={onOpen}>
Open
</button>
<button type="button" className="qy-btn qy-btn--quiet" onClick={onDelete}>
Delete
</button>
</div>
</li>
);
}
export default function QueryPage({ granted }: QueryPageProps) {
const [scope, setScope] = useState<string>("");
const [question, setQuestion] = useState("");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<BuildResult | null>(null);
const [problem, setProblem] = useState("");
const [saved, setSaved] = useState<SavedQuery[]>([]);
const [builtins, setBuiltins] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [loaded, setLoaded] = useState(false);
/**
* The databases this page may ASK ABOUT.
*
* `databaseEntries` drops SURFACE rows (that fact is decided where rows are built, not
* re-derived here) and a group head has no destination. ⛔ BUT "granted and not a surface" is
* WIDER THAN THE BUILD DOOR ACCEPTS: it takes any `ut_*` key plus the built-in topics it
* publishes as `builtins`. Offering anything else would be a control that lies — the picker
* would list a database and the build call would 404 on it. So the server's own accepted set is
* mirrored, never guessed, and until it arrives only `ut_*` keys are offered (fail NARROW).
*/
const databases = databaseEntries(granted).filter(
(e) => e.kind !== "group" && (e.key.startsWith("ut_") || builtins.includes(e.key))
);
// Keep the picker on a database that still exists: a grant can be withdrawn while
// this page is open, and the server prunes on read, so the client must not pin a key
// the payload no longer carries.
useEffect(() => {
if (databases.length && !databases.some((d) => d.key === scope)) {
setScope(databases[0].key);
}
}, [databases, scope]);
const reload = useCallback(async () => {
const res = await fetchQueries();
setLoaded(true);
if (res.ok) {
setSaved(res.value.views);
setBuiltins(res.value.builtins);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const ask = useCallback(async () => {
const q = question.trim();
if (!q || !scope || busy) return;
setBusy(true);
setProblem("");
setResult(null);
const res = await buildQuery(q, scope);
setBusy(false);
if (!res.ok) {
setProblem(res.message);
return;
}
setResult(res.value);
}, [busy, question, scope]);
const keep = useCallback(async () => {
if (!result?.spec || saving) return;
setSaving(true);
const res = await saveQuery(question.trim(), scope, result.spec);
setSaving(false);
if (!res.ok) {
setProblem(res.message);
return;
}
setResult(null);
setQuestion("");
await reload();
}, [question, reload, result, saving, scope]);
const forget = useCallback(async (id: string) => {
const res = await deleteQuery(id);
if (!res.ok) {
setProblem(res.message);
return;
}
await reload();
}, [reload]);
const labelOf = (key: string) =>
databases.find((d) => d.key === key)?.label ?? key;
return (
<div className="qy-page">
<h1 className="qy-title">Query</h1>
<p className="qy-lede">
Ask about one of your databases. You get a view you can read before you keep it —
and a plain answer when the question is not one this product can draw.
</p>
{databases.length === 0 ? (
// ⚠ TWO DIFFERENT FACTS, TWO DIFFERENT SENTENCES. Before the index arrives the offerable
// set is deliberately narrow (see `databases`), so "you have no database" would be a claim
// this page cannot yet make.
<p className="qy-note">
{loaded
? "You do not have a database to ask about yet. Create one from the Database menu, "
+ "then come back and ask about it."
: "Loading your databases…"}
</p>
) : (
<section className="qy-ask">
<label className="qy-field">
<span className="qy-label">Database</span>
<select
className="qy-select"
value={scope}
onChange={(e) => setScope(e.currentTarget.value)}
>
{databases.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</label>
<label className="qy-field qy-field--grow">
<span className="qy-label">Question</span>
<input
className="qy-input"
value={question}
placeholder="Everyone with more than 50,000 followers, biggest first"
onChange={(e) => setQuestion(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void ask();
}}
/>
</label>
<button
type="button"
className="qy-btn qy-btn--primary"
disabled={busy || !question.trim()}
onClick={() => void ask()}
>
{busy ? "Working…" : "Build a view"}
</button>
</section>
)}
{problem ? (
<p className="qy-note is-warn" role="status">
{problem}
</p>
) : null}
{/* A REFUSAL IS AN ANSWER, and it gets the same weight as a success — a sentence,
never an error page (R1's own words). Both states live in `QueryAnswer`. */}
{result ? (
<QueryAnswer
result={result}
saving={saving}
onKeep={() => void keep()}
onDiscard={() => setResult(null)}
/>
) : null}
<h2 className="qy-sub">Your views</h2>
{!loaded ? (
<p className="qy-note">
<span className="lp-spin" role="status" aria-label="Loading" />
</p>
) : saved.length === 0 ? (
<p className="qy-note">
Nothing yet. A view you keep lands here, and in the database it was built from.
</p>
) : (
<ul className="qy-list">
{saved.map((v) => (
<SavedQueryRow
key={v.id}
view={v}
dbLabel={labelOf(v.scope)}
onOpen={() => openSavedView(v.scope, v.viewId)}
onDelete={() => void forget(v.id)}
/>
))}
</ul>
)}
</div>
);
}