diff --git a/api/_lib/openrouter.ts b/api/_lib/openrouter.ts index bbfe933..33812f3 100644 --- a/api/_lib/openrouter.ts +++ b/api/_lib/openrouter.ts @@ -13,6 +13,13 @@ SpiceDB schemas, write relationships/assertions, debug permissions, and answer s - Prefer running run_check and run_validation to verify your work rather than guessing. - When the user asks WHY a check is allowed/denied/conditional, call explain_check: it returns the debug trace (so you can explain the exact branch) and renders it in the chat. +- When the user asks you to explain or document the schema, call the explain_schema tool + exactly once. Provide one entry per definition, one per each of its relations and + permissions, and one per caveat. Use symbolPath "Name" for definitions and caveats, and + "Def/member" for relations and permissions. Keep shortLabel to a few words (it renders as + an end-of-line tag) and explanation to 1-3 sentences of plain markdown. Do not edit the + schema text to add explanations — explain_schema renders them as non-destructive inline + annotations. - Use read_skill_reference for detailed patterns/anti-patterns before non-trivial designs. - When you make a change, briefly say what you changed and why. - When you show a snippet in your reply, use a fenced code block tagged with the diff --git a/src/components/EditorDisplay.tsx b/src/components/EditorDisplay.tsx index 9a58001..d98ee12 100644 --- a/src/components/EditorDisplay.tsx +++ b/src/components/EditorDisplay.tsx @@ -17,6 +17,9 @@ import AppConfig from "../services/configservice"; import { ScrollLocation, useCookieService } from "../services/cookieservice"; import { DataStore, DataStoreItem, DataStoreItemKind } from "../services/datastore"; import { LocalParseState } from "../services/localparse"; +import { requestSchemaExplanation } from "../services/schemaAnnotations/generate"; +import { computeAnnotationViews } from "../services/schemaAnnotations/resolve"; +import { useSchemaAnnotationStore } from "../services/schemaAnnotations/store"; import { Services } from "../services/services"; import registerDSLanguage, { DS_LANGUAGE_NAME, @@ -34,6 +37,10 @@ import { useDrawerStore } from "./drawer/state"; import { useRevealStore } from "./editor-groups/revealStore"; import { ERROR_SOURCE_TO_ITEM } from "./panels/errordisplays"; import registerTupleLanguage, { TUPLE_LANGUAGE_NAME } from "./relationshipeditor/tuplelang"; +import { + createAnnotationRenderer, + type AnnotationRenderer, +} from "./schemaAnnotations/renderAnnotations"; // Module-level singletons for one-shot language registration. Monaco's // `register*` calls are global; calling them on every editor mount can stack @@ -59,6 +66,22 @@ const ASK_ASSISTANT_DEBUG_COMMAND_ID = "playground.askAssistantDebug"; // prompt's error source correctly. const modelKindByUri = new Map(); +const REGENERATE_SCHEMA_EXPLANATIONS_COMMAND_ID = "playground.regenerateSchemaExplanations"; + +// Bridges React-owned annotation views to the module-scope hover provider +// (which only receives a model + position) and the once-registered click +// handler (which maps a clicked tag line back to its symbol). Holds full symbol +// ranges so hover can hit-test the cursor. +const latestSchemaHoverRef: { + current: Array<{ + startLine: number; + endLine: number; + explanation: string; + stale: boolean; + symbolPath: string; + }>; +} = { current: [] }; + export type EditorDisplayProps = { datastore: DataStore; services: Services; @@ -353,6 +376,47 @@ export function EditorDisplay(props: EditorDisplayProps) { ); }; + // Drives the inline annotation decorations/view-zones for the schema editor + // and refreshes latestSchemaHoverRef so the module-scope hover provider + // (registerSchemaExplanationHover) can hit-test the cursor against the + // latest views. Mirrors updateMarkers: called once at the end of + // handleEditorMounted, and again from a useEffect whenever the schema text + // or annotation store changes. + const updateSchemaAnnotations = () => { + if (!AppConfig().aiEnabled) return; + if (currentItem?.kind !== DataStoreItemKind.SCHEMA) return; + const editors = editorRefs.current; + if (currentItem?.id === undefined || !(currentItem.id in editors)) return; + const editor = editors[currentItem.id]; + const monacoInstance = monacoInstanceRef.current; + if (!monacoInstance) return; + + if (!annotationRendererRef.current) { + annotationRendererRef.current = createAnnotationRenderer(editor, monacoInstance); + } + const schemaText = currentItem.editableContents ?? ""; + const { views, unexplained } = computeAnnotationViews(schemaText, schemaAnnotations); + annotationRendererRef.current.update({ + views, + unexplained, + toggleState: annotationToggleState, + expandedSymbols, + }); + // The schema editor runs with automaticLayout:false, so decorations and view + // zones added programmatically (e.g. right after the agent generates) aren't + // always flushed to the screen until the next layout pass — which is why + // they previously only appeared after a tab switch/reload. Nudge one on the + // next frame so freshly-generated annotations paint immediately. + requestAnimationFrame(() => editor.layout()); + latestSchemaHoverRef.current = views.map((v) => ({ + startLine: v.startLine, + endLine: v.endLine, + explanation: v.annotation.explanation, + stale: v.stale, + symbolPath: v.annotation.symbolPath, + })); + }; + const locationState = location.state as LocationState | undefined | null; const cookieService = useCookieService(); @@ -430,6 +494,7 @@ export function EditorDisplay(props: EditorDisplayProps) { registerTupleLanguage(monacoInstance, () => latestLocalParseStateRef.current!); registerAssertionFixes(monacoInstance); registerAssistantDebugLenses(monacoInstance); + registerSchemaExplanationHover(monacoInstance); languagesRegistered = true; // Themes are defined inside registerDSLanguage. The Editor already rendered // with the theme prop before defineTheme ran, so Monaco fell back to its @@ -457,6 +522,28 @@ export function EditorDisplay(props: EditorDisplayProps) { debouncedSetEditorScroll([e.scrollTop, e.scrollLeft]); }); + // In Compact mode, clicking a symbol's inline tag toggles its full + // explanation block above the line. We only act when the click landed on + // our injected ("after") tag text — not on normal end-of-line whitespace — + // so cursor placement keeps working everywhere else. + editor.onMouseDown((e) => { + if (currentItem.kind !== DataStoreItemKind.SCHEMA || !AppConfig().aiEnabled) return; + if (useSchemaAnnotationStore.getState().toggleState !== "compact") return; + const onInjectedTag = !!(e.target as unknown as { detail?: { injectedText?: unknown } }) + .detail?.injectedText; + if (!onInjectedTag) return; + const line = e.target.position?.lineNumber; + if (line === undefined) return; + const hit = latestSchemaHoverRef.current.find((h) => h.startLine === line); + if (!hit) return; + setExpandedSymbols((prev) => { + const next = new Set(prev); + if (next.has(hit.symbolPath)) next.delete(hit.symbolPath); + else next.add(hit.symbolPath); + return next; + }); + }); + attachResizeObserver(editor, itemId); // Clean up our refs when this editor instance is disposed (e.g. when @@ -469,10 +556,13 @@ export function EditorDisplay(props: EditorDisplayProps) { resizeObserversRef.current[itemId]?.disconnect(); delete resizeObserversRef.current[itemId]; if (model) modelKindByUri.delete(model.uri.toString()); + annotationRendererRef.current?.dispose(); + annotationRendererRef.current = null; }); updateMarkers(); updatePosition(); + updateSchemaAnnotations(); } }; @@ -495,6 +585,18 @@ export function EditorDisplay(props: EditorDisplayProps) { }; }, []); + // Schema annotation store: drives the inline renderer and hover provider + // for the schema editor (see updateSchemaAnnotations below). + const annotationToggleState = useSchemaAnnotationStore((s) => s.toggleState); + const schemaAnnotations = useSchemaAnnotationStore((s) => s.annotations); + const annotationRendererRef = useRef(null); + // Ephemeral: which symbols' full blocks are expanded via clicking their tag in + // Compact mode. Reset when leaving Compact (Full shows all; Off shows none). + const [expandedSymbols, setExpandedSymbols] = useState>(() => new Set()); + useEffect(() => { + if (annotationToggleState !== "compact") setExpandedSymbols((s) => (s.size ? new Set() : s)); + }, [annotationToggleState]); + // Drawer-driven relayout: the bottom drawer's resize handle mutates zustand // state synchronously during mousemove, but the drawer's height change // doesn't reliably propagate as a contentRect change to ResizeObserver @@ -624,6 +726,17 @@ export function EditorDisplay(props: EditorDisplayProps) { props.services.problemService.invalidRelationships, ]); + useEffect(() => { + updateSchemaAnnotations(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + currentItem?.id, + currentItem?.editableContents, + schemaAnnotations, + annotationToggleState, + expandedSymbols, + ]); + return (
{currentItem && ( @@ -833,3 +946,67 @@ function registerAssistantDebugLenses(monacoInstance: typeof monaco) { monacoInstance.languages.registerCodeLensProvider(DS_LANGUAGE_NAME, provider); monacoInstance.languages.registerCodeLensProvider("yaml", provider); } + +/** + * registerSchemaExplanationHover wires a hover provider over the schema + * editor that surfaces the AI-generated explanation for the symbol under the + * cursor, plus a command link to regenerate. Registered once at module scope + * (mirrors registerAssistantDebugLenses); reads the latest annotation views + * via latestSchemaHoverRef since the provider can't close over React state. + * Gated on AppConfig().aiEnabled, the schema document, and the annotation + * toggle being on. + */ +function registerSchemaExplanationHover(monacoInstance: typeof monaco) { + monacoInstance.editor.registerCommand(REGENERATE_SCHEMA_EXPLANATIONS_COMMAND_ID, () => { + requestSchemaExplanation(); + }); + + monacoInstance.languages.registerHoverProvider(DS_LANGUAGE_NAME, { + provideHover(model, position) { + if (!AppConfig().aiEnabled) return null; + if (modelKindByUri.get(model.uri.toString()) !== DataStoreItemKind.SCHEMA) return null; + if (useSchemaAnnotationStore.getState().toggleState === "off") return null; + + const hit = latestSchemaHoverRef.current.find( + (h) => position.lineNumber >= h.startLine && position.lineNumber <= h.endLine, + ); + if (!hit) return null; + + // NOTE: monaco-editor's public d.ts (this version) exposes IMarkdownString + // as a type only — there is no `monaco.MarkdownString` runtime class to + // `new` up, so we build the markdown value as a string and hand back a + // plain object satisfying IMarkdownString (value + isTrusted). + let mdValue = hit.explanation; + if (hit.stale) { + mdValue += "\n\n_Possibly out of date — the schema changed since this was generated._"; + } + mdValue += `\n\n[↻ Regenerate explanations](command:${REGENERATE_SCHEMA_EXPLANATIONS_COMMAND_ID})`; + + // Defensive clamp: the model may have shrunk since latestSchemaHoverRef + // was last populated (e.g. a rapid edit racing the debounced annotation + // recompute), so hit.endLine could point past the live model's last line. + const clampedEndLine = Math.min(hit.endLine, model.getLineCount()); + + return { + range: new monacoInstance.Range( + hit.startLine, + 1, + clampedEndLine, + model.getLineMaxColumn(clampedEndLine), + ), + // Scoped to only the regenerate command: hit.explanation is LLM-authored + // text echoed from schema comments, which may contain prompt-injected + // `command:` links. isTrusted: true would make ALL command links + // clickable (e.g. escalating to playground.askAssistantDebug); scoping + // enabledCommands to just this one command keeps the regenerate link + // functional while closing that off. + contents: [ + { + value: mdValue, + isTrusted: { enabledCommands: [REGENERATE_SCHEMA_EXPLANATIONS_COMMAND_ID] }, + }, + ], + }; + }, + }); +} diff --git a/src/components/FullPlayground.tsx b/src/components/FullPlayground.tsx index 750129c..d3d4abe 100644 --- a/src/components/FullPlayground.tsx +++ b/src/components/FullPlayground.tsx @@ -90,6 +90,7 @@ import { WatchesPanel } from "./panels/watches"; import { DockActivityBar } from "./rightdock/DockActivityBar"; import { RightDock } from "./rightdock/RightDock"; import type { DockPanelId } from "./rightdock/state"; +import { SchemaExplainToggle } from "./schemaAnnotations/SchemaExplainToggle"; import { Alert, AlertTitle } from "./ui/alert"; import { ValidateButton } from "./ValidationButton"; @@ -447,6 +448,12 @@ export function ThemedAppView(props: { const isReadOnly = sharingStatus === SharingStatus.SHARING || props.datastore.isOutOfDate(); // TODO: this is a component. + // + // Each EditorDisplay is keyed by its document so switching tabs remounts it. + // EditorDisplay registers its Monaco editor (and per-model state such as the + // schema annotations) under the item it was mounted for; the documents below + // render it at the same position, so without a key React would reuse one + // instance and swap the model underneath that bookkeeping. const renderDocument = (active: DocumentRef): ReactNode => { if (active === "schema") { const item = datastore.getSingletonByKind(DataStoreItemKind.SCHEMA); @@ -475,6 +482,7 @@ export function ThemedAppView(props: { Open the schema visualizer +
) : (
s.toggleState); + const annotations = useSchemaAnnotationStore((s) => s.annotations); + const status = useSchemaAnnotationStore((s) => s.status); + const setToggleState = useSchemaAnnotationStore((s) => s.setToggleState); + const setStatus = useSchemaAnnotationStore((s) => s.setStatus); + + const assistantStatus = useAssistantStore((s) => s.status); + const assistantError = useAssistantStore((s) => s.error); + + // Reconcile our "generating" flag with the assistant turn lifecycle. The tool + // clears it on success (setAnnotations -> status idle); here we clear/flag it + // if the turn ends or errors without producing annotations — but only once the + // turn has actually gone busy, so we don't disarm ourselves during the gap + // between requesting the prompt and the assistant starting it. + const sawBusyRef = useRef(false); + useEffect(() => { + const decision = reconcileGeneratingStatus(status, assistantStatus, sawBusyRef.current); + sawBusyRef.current = decision.sawBusy; + if (decision.status !== status) { + if (decision.status === "error") setStatus("error", assistantError?.message); + else setStatus(decision.status); + } + }, [assistantStatus, assistantError, status, setStatus]); + + if (!AppConfig().aiEnabled) return null; + + const generating = status === "generating"; + const schemaText = + props.datastore.getSingletonByKind(DataStoreItemKind.SCHEMA).editableContents ?? ""; + const views = computeAnnotationViews(schemaText, annotations).views; + const freshViewCount = views.filter((v) => !v.stale).length; + const anyStale = views.some((v) => v.stale); + const hasAnnotations = annotations.length > 0; + + const onValueChange = (next: string) => { + if (next !== "off" && next !== "compact" && next !== "full") return; // radix deselect => "" + setToggleState(next); + // Regenerate when turning on and nothing currently resolves to a fresh view + // (empty, or all stale / unresolvable after a wholesale schema change) — + // otherwise the toggle would show nothing and offer no way to regenerate. + if (shouldGenerate(next, freshViewCount)) requestSchemaExplanation(); + }; + + return ( +
+ + + Explain + + + + Off + + + Compact + + + Full + + + {hasAnnotations && ( + + + + + + {anyStale ? "Explanations are out of date — regenerate" : "Regenerate explanations"} + + + )} + {generating && !hasAnnotations && ( + + )} + {status === "error" && ( + + + + + Couldn’t generate explanations. Toggle again to retry. + + )} +
+ ); +} diff --git a/src/components/schemaAnnotations/reconcileGeneratingStatus.test.ts b/src/components/schemaAnnotations/reconcileGeneratingStatus.test.ts new file mode 100644 index 0000000..9e83d14 --- /dev/null +++ b/src/components/schemaAnnotations/reconcileGeneratingStatus.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { reconcileGeneratingStatus } from "./toggleLogic"; + +describe("reconcileGeneratingStatus", () => { + it("is a no-op when we are not generating", () => { + expect(reconcileGeneratingStatus("idle", "streaming", true)).toEqual({ + status: "idle", + sawBusy: false, + }); + expect(reconcileGeneratingStatus("error", "idle", true)).toEqual({ + status: "error", + sawBusy: false, + }); + }); + + it("keeps generating while the turn has not started yet (race case)", () => { + expect(reconcileGeneratingStatus("generating", "idle", false)).toEqual({ + status: "generating", + sawBusy: false, + }); + }); + + it("stays generating and records busy once the assistant is streaming", () => { + expect(reconcileGeneratingStatus("generating", "streaming", false)).toEqual({ + status: "generating", + sawBusy: true, + }); + }); + + it("stays generating and records busy once the assistant is executing tools", () => { + expect(reconcileGeneratingStatus("generating", "executing_tools", false)).toEqual({ + status: "generating", + sawBusy: true, + }); + }); + + it("clears to idle when the turn finished (was busy, now idle)", () => { + expect(reconcileGeneratingStatus("generating", "idle", true)).toEqual({ + status: "idle", + sawBusy: false, + }); + }); + + it("flags error when the turn errored after having run", () => { + expect(reconcileGeneratingStatus("generating", "error", true)).toEqual({ + status: "error", + sawBusy: false, + }); + }); + + it("stays generating on a pre-existing assistant error before our turn ran", () => { + expect(reconcileGeneratingStatus("generating", "error", false)).toEqual({ + status: "generating", + sawBusy: false, + }); + }); +}); diff --git a/src/components/schemaAnnotations/renderAnnotations.ts b/src/components/schemaAnnotations/renderAnnotations.ts new file mode 100644 index 0000000..86abd3c --- /dev/null +++ b/src/components/schemaAnnotations/renderAnnotations.ts @@ -0,0 +1,176 @@ +import type * as monacoNs from "monaco-editor"; + +import type { + AnnotationView, + ToggleState, + UnexplainedSymbol, +} from "../../services/schemaAnnotations/types"; + +const TAG_PREFIX = " ‹ "; +const TAG_SUFFIX = " ›"; + +/** Minimal, safe markdown: escapes HTML, then renders `code` and **bold**. */ +export function renderLightMarkdown(text: string): string { + const escaped = text.replace(/&/g, "&").replace(//g, ">"); + return escaped + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, "$1"); +} + +const EMPTY_EXPANDED: ReadonlySet = new Set(); + +export interface AnnotationRenderInputs { + views: AnnotationView[]; + unexplained: UnexplainedSymbol[]; + toggleState: ToggleState; + // In Compact mode, the symbolPaths whose full block the user has clicked to + // expand. Ignored in Full (all blocks show) and Off. Defaults to none. + expandedSymbols?: ReadonlySet; +} + +export interface AnnotationRenderer { + update(inputs: AnnotationRenderInputs): void; + dispose(): void; +} + +export function createAnnotationRenderer( + editor: monacoNs.editor.IStandaloneCodeEditor, + monaco: typeof monacoNs, +): AnnotationRenderer { + const decorations = editor.createDecorationsCollection([]); + let zones: { id: string; zone: monacoNs.editor.IViewZone; domNode: HTMLElement }[] = []; + + function clearZones() { + if (zones.length === 0) return; + editor.changeViewZones((accessor) => { + for (const z of zones) accessor.removeZone(z.id); + }); + zones = []; + } + + function tag( + model: monacoNs.editor.ITextModel, + line: number, + content: string, + className: string, + ) { + const clamped = Math.min(line, model.getLineCount()); + const col = model.getLineMaxColumn(clamped); + return { + range: new monaco.Range(clamped, col, clamped, col), + // The range above is collapsed (zero-width, at end of line); Monaco only + // renders injected ("after") text for collapsed ranges when + // showIfCollapsed is set, so this is required, not optional. + options: { after: { content, inlineClassName: className }, showIfCollapsed: true }, + }; + } + + function update({ views, unexplained, toggleState, expandedSymbols }: AnnotationRenderInputs) { + const model = editor.getModel(); + if (!model || toggleState === "off") { + decorations.set([]); + clearZones(); + return; + } + + // Compact mode shows a short end-of-line tag per symbol. Full mode shows the + // block explanations instead (below), so we don't also tag every symbol + // there — only the "not yet explained" hints for symbols with no block. + const decos: monacoNs.editor.IModelDeltaDecoration[] = []; + if (toggleState === "compact") { + for (const v of views) { + const content = `${TAG_PREFIX}${v.annotation.shortLabel}${v.stale ? " ↻" : ""}${TAG_SUFFIX}`; + decos.push( + tag( + model, + v.startLine, + content, + v.stale ? "schema-annot-tag schema-annot-stale" : "schema-annot-tag", + ), + ); + } + } + if (toggleState === "full") { + for (const u of unexplained) { + decos.push( + tag( + model, + u.startLine, + `${TAG_PREFIX}not yet explained${TAG_SUFFIX}`, + "schema-annot-unexplained", + ), + ); + } + } + decorations.set(decos); + + // Blocks (view zones): every view in Full; only the click-expanded views in + // Compact. + const expanded = expandedSymbols ?? EMPTY_EXPANDED; + const blockViews = + toggleState === "full" ? views : views.filter((v) => expanded.has(v.annotation.symbolPath)); + + clearZones(); + if (blockViews.length === 0) return; + + // Constrain each block to the editor's content width and shrink the font a + // touch so long explanations wrap inside the viewport instead of running off + // the right edge, and read as secondary to the code. + const layout = editor.getLayoutInfo(); + const maxWidthPx = Math.max(240, layout.width - layout.contentLeft - 24); + const fontInfo = editor.getOption(monaco.editor.EditorOption.fontInfo); + const tabSize = model.getOptions().tabSize; + + // Visual indentation (px) of a line's first non-whitespace char, so a block + // can be indented to line up with the symbol it explains on the line below. + const indentPxOf = (line: number): number => { + const content = model.getLineContent(line); + let cols = 0; + for (const ch of content) { + if (ch === "\t") cols += tabSize - (cols % tabSize); + else if (ch === " ") cols += 1; + else break; + } + return cols * fontInfo.spaceWidth; + }; + + editor.changeViewZones((accessor) => { + for (const v of blockViews) { + const domNode = document.createElement("div"); + domNode.className = v.stale + ? "schema-annot-block schema-annot-stale" + : "schema-annot-block"; + domNode.style.maxWidth = `${maxWidthPx}px`; + domNode.style.fontSize = `${Math.max(11, fontInfo.fontSize - 1)}px`; + domNode.style.paddingLeft = `${indentPxOf(v.startLine) + 6}px`; + domNode.innerHTML = renderLightMarkdown(v.annotation.explanation); + const zone: monacoNs.editor.IViewZone = { + afterLineNumber: Math.max(0, v.startLine - 1), + heightInPx: 24, + domNode, + }; + const id = accessor.addZone(zone); + zones.push({ id, zone, domNode }); + } + }); + + // Once attached, measure real content height (accounts for wrapping) and relayout. + requestAnimationFrame(() => { + if (zones.length === 0) return; + editor.changeViewZones((accessor) => { + for (const z of zones) { + z.zone.heightInPx = Math.max(24, z.domNode.scrollHeight); + accessor.layoutZone(z.id); + } + }); + }); + } + + return { + update, + dispose() { + decorations.clear(); + clearZones(); + }, + }; +} diff --git a/src/components/schemaAnnotations/renderLightMarkdown.test.ts b/src/components/schemaAnnotations/renderLightMarkdown.test.ts new file mode 100644 index 0000000..a31665b --- /dev/null +++ b/src/components/schemaAnnotations/renderLightMarkdown.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { renderLightMarkdown } from "./renderAnnotations"; + +describe("renderLightMarkdown", () => { + it("escapes HTML", () => { + expect(renderLightMarkdown("a < b & c > d")).toBe("a < b & c > d"); + }); + + it("renders inline code and bold", () => { + expect(renderLightMarkdown("use `viewer` and **owner**")).toBe( + 'use viewer and owner', + ); + }); +}); diff --git a/src/components/schemaAnnotations/shouldGenerate.test.ts b/src/components/schemaAnnotations/shouldGenerate.test.ts new file mode 100644 index 0000000..8dbf65e --- /dev/null +++ b/src/components/schemaAnnotations/shouldGenerate.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { shouldGenerate } from "./toggleLogic"; + +describe("shouldGenerate", () => { + it("generates when turning on with no annotations", () => { + expect(shouldGenerate("compact", 0)).toBe(true); + expect(shouldGenerate("full", 0)).toBe(true); + }); + it("does not generate when annotations already exist", () => { + expect(shouldGenerate("full", 3)).toBe(false); + }); + it("never generates when turning off", () => { + expect(shouldGenerate("off", 0)).toBe(false); + }); +}); diff --git a/src/components/schemaAnnotations/toggleLogic.ts b/src/components/schemaAnnotations/toggleLogic.ts new file mode 100644 index 0000000..5ca534f --- /dev/null +++ b/src/components/schemaAnnotations/toggleLogic.ts @@ -0,0 +1,31 @@ +import type { AssistantStatus } from "../../services/assistant/store"; +import type { AnnotationStatus } from "../../services/schemaAnnotations/store"; +import type { ToggleState } from "../../services/schemaAnnotations/types"; + +// `count` is the number of resolvable, non-stale (fresh) annotation views — +// NOT the raw annotation count. Annotations can exist but all be stale or fail +// to resolve (e.g. after the schema is replaced wholesale), in which case +// nothing would render inline and there'd be no symbol to hover for the +// regenerate link, so we must still regenerate. +export function shouldGenerate(next: ToggleState, count: number): boolean { + return next !== "off" && count === 0; +} + +// Decide how our "generating" status should reconcile with the assistant turn +// lifecycle. `sawBusy` = have we observed the assistant actually running +// (streaming/executing_tools) since we started generating? We must NOT clear +// "generating" the instant we flip on, because the pending prompt submits on a +// later render — the assistant is still "idle" then. Only once we've seen it go +// busy and come back do we clear (idle) or surface an error. +export function reconcileGeneratingStatus( + ourStatus: AnnotationStatus, + assistantStatus: AssistantStatus, + sawBusy: boolean, +): { status: AnnotationStatus; sawBusy: boolean } { + if (ourStatus !== "generating") return { status: ourStatus, sawBusy: false }; + const busy = assistantStatus === "streaming" || assistantStatus === "executing_tools"; + if (busy) return { status: "generating", sawBusy: true }; + if (!sawBusy) return { status: "generating", sawBusy: false }; // turn not started yet — keep waiting + if (assistantStatus === "error") return { status: "error", sawBusy: false }; + return { status: "idle", sawBusy: false }; +} diff --git a/src/index.css b/src/index.css index de5e02c..372326f 100644 --- a/src/index.css +++ b/src/index.css @@ -172,3 +172,36 @@ code { [data-flash-highlight="true"] > td { animation: flash-highlight 1s ease-out; } + +/* AI schema explanation annotations: ghosted overlays, never part of the buffer. */ +.schema-annot-tag { + color: var(--muted-foreground); + font-style: italic; + opacity: 0.85; +} +.schema-annot-unexplained { + color: color-mix(in oklch, var(--muted-foreground) 55%, transparent); + font-style: italic; +} +.schema-annot-stale { + opacity: 0.55; +} +.schema-annot-block { + padding: 2px 0 2px 12px; + border-left: 2px solid color-mix(in oklch, var(--primary) 45%, transparent); + color: var(--muted-foreground); + font-style: italic; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + box-sizing: border-box; + line-height: 1.4; + opacity: 0.9; +} +.schema-annot-block.schema-annot-stale { + opacity: 0.6; +} +.schema-annot-code { + font-family: var(--font-mono, monospace); + font-style: normal; +} diff --git a/src/services/assistant/tools/explainSchema.test.ts b/src/services/assistant/tools/explainSchema.test.ts new file mode 100644 index 0000000..b91600c --- /dev/null +++ b/src/services/assistant/tools/explainSchema.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { DataStoreItemKind } from "../../datastore"; +import { useSchemaAnnotationStore } from "../../schemaAnnotations/store"; +import { NOOP_HISTORY, type ToolContext } from "../types"; + +import { explainSchemaTool } from "./explainSchema"; + +const SCHEMA = `definition user {} + +definition document { + relation viewer: user + permission view = viewer +}`; + +function ctxWith(schema: string): ToolContext { + const item = { + id: "s", + kind: DataStoreItemKind.SCHEMA, + pathname: "schema", + editableContents: schema, + }; + return { + datastore: { getSingletonByKind: () => item, update: vi.fn() } as any, + getServices: () => ({}) as any, + reveal: vi.fn(), + openDocument: vi.fn(), + openWatchesPanel: vi.fn(), + history: NOOP_HISTORY, + }; +} + +describe("explainSchemaTool", () => { + beforeEach(() => useSchemaAnnotationStore.getState().reset()); + + it("writes resolved annotations to the store and switches density", async () => { + const res = await explainSchemaTool.execute( + { + show: "full", + annotations: [ + { + symbolKind: "definition", + symbolPath: "document", + shortLabel: "core resource", + explanation: "The document resource.", + }, + { + symbolKind: "permission", + symbolPath: "document/view", + shortLabel: "viewers can view", + explanation: "Anyone who is a viewer can view.", + }, + ], + }, + ctxWith(SCHEMA), + ); + + expect(res.ok).toBe(true); + expect(res.explained_count).toBe(2); + const stored = useSchemaAnnotationStore.getState(); + expect(stored.annotations).toHaveLength(2); + expect(stored.toggleState).toBe("full"); + // sourceHash must be populated so staleness works later. + expect(stored.annotations[0].sourceHash).toBeTruthy(); + }); + + describe("density after generating", () => { + const entries = [ + { symbolKind: "definition", symbolPath: "document", shortLabel: "core", explanation: "z" }, + ] as const; + + it("keeps Compact when the user turned it on before generating", async () => { + useSchemaAnnotationStore.getState().setToggleState("compact"); + await explainSchemaTool.execute({ annotations: [...entries] }, ctxWith(SCHEMA)); + expect(useSchemaAnnotationStore.getState().toggleState).toBe("compact"); + }); + + it("keeps the user's density even if the model passes a different `show`", async () => { + useSchemaAnnotationStore.getState().setToggleState("compact"); + await explainSchemaTool.execute({ show: "full", annotations: [...entries] }, ctxWith(SCHEMA)); + expect(useSchemaAnnotationStore.getState().toggleState).toBe("compact"); + }); + + it("keeps Full when the user is already on Full", async () => { + useSchemaAnnotationStore.getState().setToggleState("full"); + await explainSchemaTool.execute( + { show: "compact", annotations: [...entries] }, + ctxWith(SCHEMA), + ); + expect(useSchemaAnnotationStore.getState().toggleState).toBe("full"); + }); + + it("turns explanations on (default Full) when they were hidden", async () => { + await explainSchemaTool.execute({ annotations: [...entries] }, ctxWith(SCHEMA)); + expect(useSchemaAnnotationStore.getState().toggleState).toBe("full"); + }); + + it("honors `show` when explanations were hidden", async () => { + await explainSchemaTool.execute( + { show: "compact", annotations: [...entries] }, + ctxWith(SCHEMA), + ); + expect(useSchemaAnnotationStore.getState().toggleState).toBe("compact"); + }); + }); + + it("skips unknown symbols but keeps valid ones", async () => { + const res = await explainSchemaTool.execute( + { + annotations: [ + { symbolKind: "definition", symbolPath: "ghost", shortLabel: "x", explanation: "y" }, + { + symbolKind: "definition", + symbolPath: "document", + shortLabel: "core", + explanation: "z", + }, + ], + }, + ctxWith(SCHEMA), + ); + expect(res.explained_count).toBe(1); + expect(res.unknown_symbols).toEqual(["ghost"]); + expect(useSchemaAnnotationStore.getState().annotations).toHaveLength(1); + }); + + it("does not wipe prior annotations when every symbol is unknown", async () => { + // Seed the store with a prior explanation set. + useSchemaAnnotationStore.getState().setAnnotations( + [ + { + symbolKind: "definition", + symbolPath: "document", + shortLabel: "core", + explanation: "The document resource.", + sourceHash: "seed", + }, + ], + "full", + ); + + const res = await explainSchemaTool.execute( + { + annotations: [ + { symbolKind: "definition", symbolPath: "ghost", shortLabel: "x", explanation: "y" }, + ], + }, + ctxWith(SCHEMA), + ); + + expect(res).toEqual({ ok: false, explained_count: 0, unknown_symbols: ["ghost"] }); + // Prior annotations survive and the density toggle is untouched. + const stored = useSchemaAnnotationStore.getState(); + expect(stored.annotations).toHaveLength(1); + expect(stored.annotations[0].symbolPath).toBe("document"); + expect(stored.toggleState).toBe("full"); + }); +}); diff --git a/src/services/assistant/tools/explainSchema.ts b/src/services/assistant/tools/explainSchema.ts new file mode 100644 index 0000000..d33ccf9 --- /dev/null +++ b/src/services/assistant/tools/explainSchema.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +import { DataStoreItemKind } from "../../datastore"; +import { hashSymbolSource, resolveSymbol } from "../../schemaAnnotations/resolve"; +import { useSchemaAnnotationStore } from "../../schemaAnnotations/store"; +import type { SchemaAnnotation } from "../../schemaAnnotations/types"; +import type { AssistantTool, ToolContext } from "../types"; + +const EntrySchema = z.object({ + symbolPath: z + .string() + .describe("'Name' for a definition or caveat; 'Def/member' for a relation or permission."), + symbolKind: z.enum(["definition", "relation", "permission", "caveat"]), + shortLabel: z.string().describe("A concise end-of-line tag, a few words."), + explanation: z.string().describe("1-3 sentence markdown explanation."), +}); + +const InputSchema = z.object({ + annotations: z.array(EntrySchema).min(1), + show: z + .enum(["compact", "full"]) + .optional() + .describe( + "How to reveal the explanations if they are currently hidden. Ignored when the user " + + "already has them showing; defaults to full.", + ), +}); +export type ExplainSchemaInput = z.infer; + +export interface ExplainSchemaResult { + ok: boolean; + explained_count: number; + unknown_symbols: string[]; +} + +export const explainSchemaTool: AssistantTool = { + name: "explain_schema", + description: + "Explain the SpiceDB schema by attaching inline explanations to its symbols. Provide one " + + "entry per definition, each of its relations and permissions, and each caveat. The " + + "explanations render as ghosted inline annotations in the editor and are never written into " + + "the schema text.", + parameters: InputSchema, + execute(input, ctx: ToolContext): ExplainSchemaResult { + const schema = ctx.datastore.getSingletonByKind(DataStoreItemKind.SCHEMA).editableContents; + + const valid: SchemaAnnotation[] = []; + const unknown: string[] = []; + for (const entry of input.annotations) { + const resolved = resolveSymbol(schema, entry.symbolKind, entry.symbolPath); + if (!resolved) { + unknown.push(entry.symbolPath); + continue; + } + valid.push({ + symbolKind: entry.symbolKind, + symbolPath: entry.symbolPath, + shortLabel: entry.shortLabel, + explanation: entry.explanation, + sourceHash: hashSymbolSource(resolved.sourceText), + }); + } + + // Only write when at least one symbol resolved. An all-unknown call must + // report its unknowns for self-correction WITHOUT wiping any previously + // stored annotations or flipping the density toggle. + if (valid.length > 0) { + const store = useSchemaAnnotationStore.getState(); + // If explanations are already showing (e.g. the user picked Compact and + // that is what triggered this run), their chosen density wins; `show` only + // decides how to reveal them when they were hidden. + const density = store.toggleState === "off" ? (input.show ?? "full") : store.toggleState; + store.setAnnotations(valid, density); + } + return { ok: valid.length > 0, explained_count: valid.length, unknown_symbols: unknown }; + }, + summarize(result) { + const base = `Explained ${result.explained_count} symbol${result.explained_count === 1 ? "" : "s"}`; + return result.unknown_symbols.length + ? `${base}; skipped unknown: ${result.unknown_symbols.join(", ")}` + : base; + }, + icon: "💡", + label: "Explain schema", + progressLabel: "Explaining schema", +}; diff --git a/src/services/assistant/tools/index.test.ts b/src/services/assistant/tools/index.test.ts index 5679333..eb60950 100644 --- a/src/services/assistant/tools/index.test.ts +++ b/src/services/assistant/tools/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { CLIENT_TOOL_NAMES, TOOL_DISPLAY, buildDefaultRegistry } from "./index"; describe("buildDefaultRegistry", () => { - it("registers all nine client tools", () => { + it("registers all ten client tools", () => { const names = buildDefaultRegistry() .list() .map((t) => t.name) @@ -13,6 +13,7 @@ describe("buildDefaultRegistry", () => { "add_check_watch", "edit_document", "explain_check", + "explain_schema", "list_check_watches", "open_tab_to_line", "remove_check_watch", diff --git a/src/services/assistant/tools/index.ts b/src/services/assistant/tools/index.ts index 8351dad..1751f75 100644 --- a/src/services/assistant/tools/index.ts +++ b/src/services/assistant/tools/index.ts @@ -8,12 +8,14 @@ import { } from "./checkWatches"; import { editDocumentTool } from "./editDocument"; import { explainCheckTool } from "./explainCheck"; +import { explainSchemaTool } from "./explainSchema"; import { openTabToLineTool } from "./openTabToLine"; import { runCheckTool } from "./runCheck"; import { runValidationTool } from "./runValidation"; const ALL_CLIENT_TOOLS = [ editDocumentTool, + explainSchemaTool, runCheckTool, explainCheckTool, runValidationTool, diff --git a/src/services/schemaAnnotations/generate.test.ts b/src/services/schemaAnnotations/generate.test.ts new file mode 100644 index 0000000..3054d5b --- /dev/null +++ b/src/services/schemaAnnotations/generate.test.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("posthog-js", () => ({ default: { capture: vi.fn() } })); + +import { useRightDockStore } from "../../components/rightdock/state"; +import { useAssistantStore } from "../assistant/store"; + +import { requestSchemaExplanation, SCHEMA_EXPLAIN_PROMPT } from "./generate"; +import { useSchemaAnnotationStore } from "./store"; + +describe("requestSchemaExplanation", () => { + beforeEach(() => { + useAssistantStore.getState().reset(); + useRightDockStore.getState().closeDock(); + useSchemaAnnotationStore.getState().reset(); + vi.clearAllMocks(); + }); + + it("opens the assistant panel, queues the prompt, and marks generating", () => { + requestSchemaExplanation(); + expect(useAssistantStore.getState().pendingPrompt).toBe(SCHEMA_EXPLAIN_PROMPT); + expect(useSchemaAnnotationStore.getState().status).toBe("generating"); + const dock = useRightDockStore.getState(); + expect(dock.open).toBe(true); + expect(dock.activePanel).toBe("assistant"); + }); +}); diff --git a/src/services/schemaAnnotations/generate.ts b/src/services/schemaAnnotations/generate.ts new file mode 100644 index 0000000..2629a3e --- /dev/null +++ b/src/services/schemaAnnotations/generate.ts @@ -0,0 +1,18 @@ +import posthog from "posthog-js"; + +import { useRightDockStore } from "../../components/rightdock/state"; +import { useAssistantStore } from "../assistant/store"; + +import { useSchemaAnnotationStore } from "./store"; + +export const SCHEMA_EXPLAIN_PROMPT = + "Explain the current schema by calling the explain_schema tool exactly once. Include one entry " + + "per definition, each of its relations and permissions, and each caveat. Do not edit the schema."; + +/** Opens the assistant and runs a canned turn that populates schema annotations. */ +export function requestSchemaExplanation(): void { + posthog.capture("playground_ai_explain_schema_requested"); + useSchemaAnnotationStore.getState().setStatus("generating"); + useRightDockStore.getState().openPanel("assistant"); + useAssistantStore.getState().requestPrompt(SCHEMA_EXPLAIN_PROMPT); +} diff --git a/src/services/schemaAnnotations/resolve.test.ts b/src/services/schemaAnnotations/resolve.test.ts new file mode 100644 index 0000000..0feb826 --- /dev/null +++ b/src/services/schemaAnnotations/resolve.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { + computeAnnotationViews, + hashSymbolSource, + listSchemaSymbols, + resolveSymbol, +} from "./resolve"; +import type { SchemaAnnotation } from "./types"; + +const SCHEMA = `definition user {} + +definition document { + relation viewer: user + permission view = viewer +} + +caveat is_tuesday(day string) { + day == "tuesday" +}`; + +describe("resolveSymbol", () => { + it("resolves a definition to its range and source text", () => { + const r = resolveSymbol(SCHEMA, "definition", "document"); + expect(r).toBeDefined(); + expect(r!.startLine).toBe(3); + expect(r!.sourceText).toContain("permission view = viewer"); + }); + + it("resolves a permission member by 'def/member' path", () => { + const r = resolveSymbol(SCHEMA, "permission", "document/view"); + expect(r).toBeDefined(); + expect(r!.startLine).toBe(5); + expect(r!.sourceText).toContain("permission view"); + }); + + it("resolves a caveat", () => { + const r = resolveSymbol(SCHEMA, "caveat", "is_tuesday"); + expect(r).toBeDefined(); + expect(r!.startLine).toBe(8); + expect(r!.startColumn).toBe(1); + expect(r!.sourceText).toContain("day == "); + }); + + it("returns undefined for an unknown symbol", () => { + expect(resolveSymbol(SCHEMA, "definition", "nope")).toBeUndefined(); + expect(resolveSymbol(SCHEMA, "relation", "document/ghost")).toBeUndefined(); + }); + + it("returns undefined when the kind does not match the member", () => { + // 'view' is a permission, not a relation. + expect(resolveSymbol(SCHEMA, "relation", "document/view")).toBeUndefined(); + }); +}); + +describe("hashSymbolSource", () => { + it("is stable and change-sensitive", () => { + expect(hashSymbolSource("permission view = viewer")).toBe( + hashSymbolSource("permission view = viewer"), + ); + expect(hashSymbolSource("permission view = viewer")).not.toBe( + hashSymbolSource("permission view = viewer + editor"), + ); + }); +}); + +describe("listSchemaSymbols", () => { + it("lists definitions, members, and caveats with lines", () => { + const syms = listSchemaSymbols(SCHEMA); + const paths = syms.map((s) => s.symbolPath); + expect(paths).toEqual( + expect.arrayContaining([ + "user", + "document", + "document/viewer", + "document/view", + "is_tuesday", + ]), + ); + expect(syms.find((s) => s.symbolPath === "is_tuesday")!.startLine).toBe(8); + }); +}); + +describe("computeAnnotationViews", () => { + const base: SchemaAnnotation = { + symbolKind: "permission", + symbolPath: "document/view", + shortLabel: "viewers can view", + explanation: "Anyone who is a viewer can view.", + sourceHash: hashSymbolSource("permission view = viewer"), + }; + + it("marks a view fresh when the source is unchanged", () => { + const { views } = computeAnnotationViews(SCHEMA, [base]); + expect(views).toHaveLength(1); + expect(views[0].stale).toBe(false); + expect(views[0].startLine).toBe(5); + }); + + it("marks a view stale when the symbol's source changed", () => { + const edited = SCHEMA.replace("permission view = viewer", "permission view = viewer + owner"); + const { views } = computeAnnotationViews(edited, [base]); + expect(views[0].stale).toBe(true); + }); + + it("drops annotations whose symbol no longer resolves", () => { + const removed = SCHEMA.replace("\tpermission view = viewer\n", ""); + const { views } = computeAnnotationViews(removed, [base]); + expect(views).toHaveLength(0); + }); + + it("reports schema symbols with no annotation as unexplained", () => { + const { unexplained } = computeAnnotationViews(SCHEMA, [base]); + const paths = unexplained.map((u) => u.symbolPath); + expect(paths).toContain("document"); + expect(paths).not.toContain("document/view"); + }); +}); diff --git a/src/services/schemaAnnotations/resolve.ts b/src/services/schemaAnnotations/resolve.ts new file mode 100644 index 0000000..9104f51 --- /dev/null +++ b/src/services/schemaAnnotations/resolve.ts @@ -0,0 +1,121 @@ +import { parseSchema, Resolver, type TextRange } from "@authzed/spicedb-parser-js"; + +import type { AnnotationKind, AnnotationView, SchemaAnnotation, UnexplainedSymbol } from "./types"; + +/** djb2 string hash, base36. Deterministic; no crypto needed. */ +export function hashSymbolSource(text: string): string { + let h = 5381; + for (let i = 0; i < text.length; i++) { + h = ((h << 5) + h + text.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(36); +} + +export interface ResolvedSymbol { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + sourceText: string; +} + +export function resolveSymbol( + schemaText: string, + symbolKind: AnnotationKind, + symbolPath: string, +): ResolvedSymbol | undefined { + const schema = parseSchema(schemaText); + if (!schema) return undefined; + const resolver = new Resolver(schema); + + let range: TextRange | undefined; + if (symbolKind === "definition") { + range = resolver.lookupDefinition(symbolPath)?.definition.range; + } else if (symbolKind === "caveat") { + // Resolver's ResolvedCaveatDefinition does not carry a populated range in + // parser v1.2.0 (line/column/offset are all 0). The raw caveat node in the + // parsed AST does, so read the range directly from there. + const cav = schema.definitions.find((d) => d.kind === "caveatDef" && d.name === symbolPath) as + | { range: TextRange } + | undefined; + range = cav?.range; + } else { + // Member names contain no "/", but definition names may (e.g. "sub/user"). + // The member is always the final segment. + const idx = symbolPath.lastIndexOf("/"); + if (idx <= 0) return undefined; + const defName = symbolPath.slice(0, idx); + const member = symbolPath.slice(idx + 1); + const node = resolver.lookupDefinition(defName)?.lookupRelationOrPermission(member); + if (!node || node.kind !== symbolKind) return undefined; + range = node.range; + } + if (!range) return undefined; + + return { + startLine: range.startIndex.line, + startColumn: range.startIndex.column, + endLine: range.endIndex.line, + endColumn: range.endIndex.column, + sourceText: schema.stringValue.substring(range.startIndex.offset, range.endIndex.offset).trim(), + }; +} + +export function listSchemaSymbols(schemaText: string): UnexplainedSymbol[] { + const schema = parseSchema(schemaText); + if (!schema) return []; + const resolver = new Resolver(schema); + const out: UnexplainedSymbol[] = []; + + for (const rd of resolver.listDefinitions()) { + const def = rd.definition; + out.push({ + symbolKind: "definition", + symbolPath: def.name, + startLine: def.range.startIndex.line, + }); + for (const rp of rd.listRelationsAndPermissions()) { + out.push({ + symbolKind: rp.kind as AnnotationKind, // "relation" | "permission" + symbolPath: `${def.name}/${rp.name}`, + startLine: rp.range.startIndex.line, + }); + } + } + // Read caveats from the raw AST: Resolver's ResolvedCaveatDefinition does not + // carry a populated range in parser v1.2.0 (see resolveSymbol). + for (const d of schema.definitions) { + if (d.kind !== "caveatDef") continue; + out.push({ + symbolKind: "caveat", + symbolPath: d.name, + startLine: d.range.startIndex.line, + }); + } + return out; +} + +export function computeAnnotationViews( + schemaText: string, + annotations: SchemaAnnotation[], +): { views: AnnotationView[]; unexplained: UnexplainedSymbol[] } { + const views: AnnotationView[] = []; + for (const a of annotations) { + const r = resolveSymbol(schemaText, a.symbolKind, a.symbolPath); + if (!r) continue; + views.push({ + annotation: a, + startLine: r.startLine, + startColumn: r.startColumn, + endLine: r.endLine, + endColumn: r.endColumn, + stale: hashSymbolSource(r.sourceText) !== a.sourceHash, + }); + } + + const annotated = new Set(annotations.map((a) => `${a.symbolKind}:${a.symbolPath}`)); + const unexplained = listSchemaSymbols(schemaText).filter( + (s) => !annotated.has(`${s.symbolKind}:${s.symbolPath}`), + ); + return { views, unexplained }; +} diff --git a/src/services/schemaAnnotations/store.test.ts b/src/services/schemaAnnotations/store.test.ts new file mode 100644 index 0000000..bbb10cb --- /dev/null +++ b/src/services/schemaAnnotations/store.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { hashSymbolSource } from "./resolve"; +import { useSchemaAnnotationStore } from "./store"; + +describe("useSchemaAnnotationStore", () => { + beforeEach(() => useSchemaAnnotationStore.getState().reset()); + + it("starts off with no annotations", () => { + const s = useSchemaAnnotationStore.getState(); + expect(s.toggleState).toBe("off"); + expect(s.annotations).toEqual([]); + expect(s.status).toBe("idle"); + }); + + it("setAnnotations stores items, clears status, and can switch density", () => { + useSchemaAnnotationStore.getState().setStatus("generating"); + useSchemaAnnotationStore.getState().setAnnotations( + [ + { + symbolKind: "definition", + symbolPath: "document", + shortLabel: "core resource", + explanation: "The document resource.", + sourceHash: hashSymbolSource("definition document {}"), + }, + ], + "full", + ); + const s = useSchemaAnnotationStore.getState(); + expect(s.annotations).toHaveLength(1); + expect(s.toggleState).toBe("full"); + expect(s.status).toBe("idle"); + }); + + it("setStatus records an error message", () => { + useSchemaAnnotationStore.getState().setStatus("error", "boom"); + const s = useSchemaAnnotationStore.getState(); + expect(s.status).toBe("error"); + expect(s.errorMessage).toBe("boom"); + }); +}); diff --git a/src/services/schemaAnnotations/store.ts b/src/services/schemaAnnotations/store.ts new file mode 100644 index 0000000..3f2b404 --- /dev/null +++ b/src/services/schemaAnnotations/store.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +import type { SchemaAnnotation, ToggleState } from "./types"; + +export type AnnotationStatus = "idle" | "generating" | "error"; + +export interface SchemaAnnotationState { + annotations: SchemaAnnotation[]; + toggleState: ToggleState; + status: AnnotationStatus; + errorMessage?: string; + setToggleState: (next: ToggleState) => void; + setAnnotations: (items: SchemaAnnotation[], toggleState?: ToggleState) => void; + setStatus: (status: AnnotationStatus, errorMessage?: string) => void; + reset: () => void; +} + +export const useSchemaAnnotationStore = create()( + persist( + (set) => ({ + annotations: [], + toggleState: "off", + status: "idle", + errorMessage: undefined, + setToggleState: (toggleState) => set({ toggleState }), + setAnnotations: (annotations, toggleState) => + set((s) => ({ + annotations, + status: "idle", + errorMessage: undefined, + toggleState: toggleState ?? s.toggleState, + })), + setStatus: (status, errorMessage) => set({ status, errorMessage }), + reset: () => + set({ annotations: [], toggleState: "off", status: "idle", errorMessage: undefined }), + }), + { + name: "playground-schema-annotations", + version: 1, + // Persist content + chosen density only; status/error are transient. + partialize: (s) => ({ annotations: s.annotations, toggleState: s.toggleState }), + }, + ), +); diff --git a/src/services/schemaAnnotations/types.ts b/src/services/schemaAnnotations/types.ts new file mode 100644 index 0000000..eed9d6f --- /dev/null +++ b/src/services/schemaAnnotations/types.ts @@ -0,0 +1,28 @@ +export type AnnotationKind = "definition" | "relation" | "permission" | "caveat"; + +export type ToggleState = "off" | "compact" | "full"; + +export interface SchemaAnnotation { + symbolKind: AnnotationKind; + /** "document" or "has_role" for definitions/caveats; "document/view" for members. */ + symbolPath: string; + shortLabel: string; + explanation: string; + /** hashSymbolSource() of the symbol's source text at generation time. */ + sourceHash: string; +} + +export interface UnexplainedSymbol { + symbolKind: AnnotationKind; + symbolPath: string; + startLine: number; +} + +export interface AnnotationView { + annotation: SchemaAnnotation; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + stale: boolean; +} diff --git a/src/tests/browser/schema-annotations-renderer.test.ts b/src/tests/browser/schema-annotations-renderer.test.ts new file mode 100644 index 0000000..84566af --- /dev/null +++ b/src/tests/browser/schema-annotations-renderer.test.ts @@ -0,0 +1,83 @@ +import * as monaco from "monaco-editor"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createAnnotationRenderer } from "../../components/schemaAnnotations/renderAnnotations"; +import type { AnnotationView } from "../../services/schemaAnnotations/types"; + +let container: HTMLElement; +let editor: monaco.editor.IStandaloneCodeEditor; + +const SCHEMA = "definition document {\n\tpermission view = viewer\n}\n"; + +function viewFor(): AnnotationView { + return { + annotation: { + symbolKind: "permission", + symbolPath: "document/view", + shortLabel: "viewers can view", + explanation: "Anyone who is a viewer can view.", + sourceHash: "x", + }, + startLine: 2, + startColumn: 2, + endLine: 2, + endColumn: 26, + stale: false, + }; +} + +beforeEach(() => { + container = document.createElement("div"); + container.style.width = "800px"; + container.style.height = "400px"; + document.body.appendChild(container); + editor = monaco.editor.create(container, { value: SCHEMA, language: "plaintext" }); +}); + +afterEach(() => { + editor.dispose(); + container.remove(); +}); + +describe("createAnnotationRenderer", () => { + it("renders a compact tag and no block in Compact mode", async () => { + const r = createAnnotationRenderer(editor, monaco); + r.update({ views: [viewFor()], unexplained: [], toggleState: "compact" }); + // Monaco paints injected-text decorations on its next render frame (the + // decoration is set on the model synchronously, but the DOM span isn't + // painted until then), so wait a frame before asserting on the DOM. + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(container.querySelectorAll(".schema-annot-tag").length).toBe(1); + expect(container.querySelectorAll(".schema-annot-block").length).toBe(0); + r.dispose(); + }); + + it("renders a full block (and no compact tag) in Full mode, and clears on Off", () => { + const r = createAnnotationRenderer(editor, monaco); + r.update({ views: [viewFor()], unexplained: [], toggleState: "full" }); + expect(container.querySelectorAll(".schema-annot-block").length).toBe(1); + // Full mode shows blocks only — no redundant end-of-line tags for views. + expect(container.querySelectorAll(".schema-annot-tag").length).toBe(0); + + r.update({ views: [viewFor()], unexplained: [], toggleState: "off" }); + expect(container.querySelectorAll(".schema-annot-tag").length).toBe(0); + expect(container.querySelectorAll(".schema-annot-block").length).toBe(0); + r.dispose(); + }); + + it("expands a symbol's block in Compact only when it is in expandedSymbols", () => { + const r = createAnnotationRenderer(editor, monaco); + // Not expanded: tag only, no block. + r.update({ views: [viewFor()], unexplained: [], toggleState: "compact" }); + expect(container.querySelectorAll(".schema-annot-block").length).toBe(0); + // Expanded (as if the tag was clicked): the symbol's full block appears. + r.update({ + views: [viewFor()], + unexplained: [], + toggleState: "compact", + expandedSymbols: new Set(["document/view"]), + }); + expect(container.querySelectorAll(".schema-annot-block").length).toBe(1); + r.dispose(); + }); +}); diff --git a/src/tests/browser/schema-annotations-tab-switch.test.tsx b/src/tests/browser/schema-annotations-tab-switch.test.tsx new file mode 100644 index 0000000..b6d2d9d --- /dev/null +++ b/src/tests/browser/schema-annotations-tab-switch.test.tsx @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DataStoreItemKind, EphemeralDataStore } from "../../services/datastore"; +import { hashSymbolSource, resolveSymbol } from "../../services/schemaAnnotations/resolve"; +import { useSchemaAnnotationStore } from "../../services/schemaAnnotations/store"; + +import { mountPlayground } from "./helpers"; + +const blockCount = () => document.querySelectorAll(".schema-annot-block").length; +const tagCount = () => document.querySelectorAll(".schema-annot-tag").length; + +const clearRelationshipsEditorCookie = () => { + document.cookie = "relgrid-type=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/"; +}; + +// Regression: inline schema explanations vanished after switching to another +// top-level tab and back to Schema. +describe("schema annotations across tab switches", () => { + // Annotates the `user` definition on line 1, which is always inside the + // (very short) editor viewport of the browser test harness. + const seed = (toggle: "compact" | "full") => { + const schemaText = new EphemeralDataStore().getSingletonByKind( + DataStoreItemKind.SCHEMA, + ).editableContents!; + // Hash the real symbol source so the annotation is fresh (not stale). + const sourceHash = hashSymbolSource( + resolveSymbol(schemaText, "definition", "user")!.sourceText, + ); + useSchemaAnnotationStore.getState().reset(); + useSchemaAnnotationStore.getState().setAnnotations( + [ + { + symbolKind: "definition", + symbolPath: "user", + shortLabel: "a user", + explanation: "A user of the system.", + sourceHash, + }, + ], + toggle, + ); + }; + + beforeEach(() => { + vi.stubEnv("VITE_AI_ENABLED", "true"); + clearRelationshipsEditorCookie(); + }); + + afterEach(() => { + useSchemaAnnotationStore.getState().reset(); + clearRelationshipsEditorCookie(); + vi.unstubAllEnvs(); + }); + + it.each([ + ["full", "Relationships"], + ["full", "Assertions"], + ["compact", "Relationships"], + ["compact", "Assertions"], + ] as const)("keeps %s annotations after Schema -> %s -> Schema", async (toggle, otherTab) => { + seed(toggle); + const shown = toggle === "full" ? blockCount : tagCount; + const screen = await mountPlayground(); + await screen.getByRole("tab", { name: "Schema" }).click(); + await expect.poll(shown).toBe(1); + + await screen.getByRole("tab", { name: otherTab }).click(); + // Prove the switch really happened (the schema editor's annotations are gone). + await expect.poll(shown).toBe(0); + + await screen.getByRole("tab", { name: "Schema" }).click(); + await expect.poll(shown).toBe(1); + }); + + // Schema, Relationships (code editor) and Assertions render their + // EditorDisplay at the same position, so without a per-document key React + // reuses one instance whose editor registry only knows the item it was + // first mounted for. Here the instance is first mounted for Relationships + // (grid -> code), so Schema is never registered and its annotations vanish. + it.each(["full", "compact"] as const)( + "keeps %s annotations when the shared editor was first mounted for another tab", + async (toggle) => { + seed(toggle); + const shown = toggle === "full" ? blockCount : tagCount; + const screen = await mountPlayground(); + await screen.getByRole("tab", { name: "Schema" }).click(); + await expect.poll(shown).toBe(1); + + await screen.getByRole("tab", { name: "Relationships" }).click(); + await screen.getByRole("radio", { name: "code editor" }).click(); + await expect.poll(shown).toBe(0); + + await screen.getByRole("tab", { name: "Schema" }).click(); + await expect.poll(shown).toBe(1); + }, + ); +}); diff --git a/vitest.config.ts b/vitest.config.ts index b3b8a13..38a4b6c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,6 +30,10 @@ export default mergeConfig( }, }, ], + // @authzed/spicedb-parser-js ships ESM that re-exports named bindings from + // the CJS `parsimmon` package; inline it so Vitest transforms it and the + // named imports resolve under Node. + server: { deps: { inline: ["@authzed/spicedb-parser-js"] } }, }, }), );