From d88f44b397c7a4aaf47ad711b4a08fd8eec3f090 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 17 Sep 2026 16:19:19 -0400 Subject: [PATCH 1/4] feat(schema-annotations): inline AI schema explanations in the editor Adds an agent-driven Explain control to the schema editor that overlays AI-generated explanations inline in Monaco without modifying the schema text. A tristate toggle (Off / Compact / Full) controls density; a Regenerate button re-runs the agent on demand. - Off: clean schema. Compact: a short ghost tag at each symbol's line end, click to expand its full block. Full: an indented explanation block above each definition/relation/permission/caveat. Hover any symbol for its full explanation with a regenerate link. - New explain_schema assistant client tool (the model supplies the content; nothing is written into the schema). Explanations anchor to symbols by name via @authzed/spicedb-parser-js, re-position as you edit, and mark themselves stale when a symbol's body changes; regeneration is on-demand to control token spend. - Pure resolution/staleness layer, a persisted Zustand store, a Monaco decoration + view-zone renderer, and a dsl hover provider whose command trust is scoped to the regenerate command only. - Gated on AppConfig().aiEnabled; no structural backend change (client-side tool via the existing handoff path, plus guidance in PLAYGROUND_INSTRUCTIONS). Covered by unit tests (resolution, store, tool, trigger, helpers) and a Playwright renderer test. The live LLM round-trip is verified manually only. --- api/_lib/openrouter.ts | 7 + src/components/EditorDisplay.tsx | 177 ++++++++++++++++++ src/components/FullPlayground.tsx | 2 + .../schemaAnnotations/SchemaExplainToggle.tsx | 132 +++++++++++++ .../reconcileGeneratingStatus.test.ts | 58 ++++++ .../schemaAnnotations/renderAnnotations.ts | 176 +++++++++++++++++ .../renderLightMarkdown.test.ts | 15 ++ .../schemaAnnotations/shouldGenerate.test.ts | 16 ++ .../schemaAnnotations/toggleLogic.ts | 31 +++ src/index.css | 33 ++++ .../assistant/tools/explainSchema.test.ts | 118 ++++++++++++ src/services/assistant/tools/explainSchema.ts | 75 ++++++++ src/services/assistant/tools/index.test.ts | 3 +- src/services/assistant/tools/index.ts | 2 + .../schemaAnnotations/generate.test.ts | 27 +++ src/services/schemaAnnotations/generate.ts | 18 ++ .../schemaAnnotations/resolve.test.ts | 112 +++++++++++ src/services/schemaAnnotations/resolve.ts | 117 ++++++++++++ src/services/schemaAnnotations/store.test.ts | 42 +++++ src/services/schemaAnnotations/store.ts | 45 +++++ src/services/schemaAnnotations/types.ts | 28 +++ .../schema-annotations-renderer.test.ts | 83 ++++++++ vitest.config.ts | 4 + 23 files changed, 1320 insertions(+), 1 deletion(-) create mode 100644 src/components/schemaAnnotations/SchemaExplainToggle.tsx create mode 100644 src/components/schemaAnnotations/reconcileGeneratingStatus.test.ts create mode 100644 src/components/schemaAnnotations/renderAnnotations.ts create mode 100644 src/components/schemaAnnotations/renderLightMarkdown.test.ts create mode 100644 src/components/schemaAnnotations/shouldGenerate.test.ts create mode 100644 src/components/schemaAnnotations/toggleLogic.ts create mode 100644 src/services/assistant/tools/explainSchema.test.ts create mode 100644 src/services/assistant/tools/explainSchema.ts create mode 100644 src/services/schemaAnnotations/generate.test.ts create mode 100644 src/services/schemaAnnotations/generate.ts create mode 100644 src/services/schemaAnnotations/resolve.test.ts create mode 100644 src/services/schemaAnnotations/resolve.ts create mode 100644 src/services/schemaAnnotations/store.test.ts create mode 100644 src/services/schemaAnnotations/store.ts create mode 100644 src/services/schemaAnnotations/types.ts create mode 100644 src/tests/browser/schema-annotations-renderer.test.ts diff --git a/api/_lib/openrouter.ts b/api/_lib/openrouter.ts index bbfe9339..33812f3c 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 9a580013..d98ee12d 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 750129cb..6c70f44e 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"; @@ -475,6 +476,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 00000000..9e83d14c --- /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 00000000..86abd3cd --- /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 00000000..a31665bb --- /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 00000000..8dbf65e0 --- /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 00000000..5ca534f2 --- /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 de5e02c7..372326f8 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 00000000..eb269924 --- /dev/null +++ b/src/services/assistant/tools/explainSchema.test.ts @@ -0,0 +1,118 @@ +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(); + }); + + 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 00000000..812b5f91 --- /dev/null +++ b/src/services/assistant/tools/explainSchema.ts @@ -0,0 +1,75 @@ +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(), +}); +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) { + useSchemaAnnotationStore.getState().setAnnotations(valid, input.show ?? "full"); + } + 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 56793335..eb609506 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 8351dad9..1751f75b 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 00000000..3054d5b9 --- /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 00000000..2629a3e6 --- /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 00000000..c1f60eed --- /dev/null +++ b/src/services/schemaAnnotations/resolve.test.ts @@ -0,0 +1,112 @@ +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 00000000..d135c52d --- /dev/null +++ b/src/services/schemaAnnotations/resolve.ts @@ -0,0 +1,117 @@ +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 00000000..736194f1 --- /dev/null +++ b/src/services/schemaAnnotations/store.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { useSchemaAnnotationStore } from "./store"; +import { hashSymbolSource } from "./resolve"; + +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 00000000..3f2b404e --- /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 00000000..eed9d6fe --- /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 00000000..84566af9 --- /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/vitest.config.ts b/vitest.config.ts index b3b8a13f..38a4b6c5 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"] } }, }, }), ); From 4598dd025f9a1aeefd9bc8c38447fe18aa27d377 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Fri, 18 Sep 2026 16:04:20 -0700 Subject: [PATCH 2/4] fix(schema-annotations): keep inline explanations after switching tabs Schema, Assertions, Expected and Relationships (code editor) render EditorDisplay at the same tree position, so React reused one instance and only swapped the Monaco model. EditorDisplay registers its editor under the item it was first mounted for, so when that wasn't Schema the annotation renderer found no editor for Schema and silently skipped rendering. Key each EditorDisplay by its document so a tab switch remounts it, as the grid path already did and as the pre-editor-groups layout did. Adds browser regression tests covering Full and Compact modes across tab round trips, including the case where the shared editor is first mounted for another tab. Co-Authored-By: Claude Sonnet 5 --- src/components/FullPlayground.tsx | 10 ++ .../schema-annotations-tab-switch.test.tsx | 97 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/tests/browser/schema-annotations-tab-switch.test.tsx diff --git a/src/components/FullPlayground.tsx b/src/components/FullPlayground.tsx index 6c70f44e..d3d4abec 100644 --- a/src/components/FullPlayground.tsx +++ b/src/components/FullPlayground.tsx @@ -448,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); @@ -486,6 +492,7 @@ export function ThemedAppView(props: {
) : (
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); + }, + ); +}); From 287b875dc2b92947c0f435131df1f6f414aa02b3 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Fri, 18 Sep 2026 16:19:04 -0700 Subject: [PATCH 3/4] fix(schema-annotations): keep the chosen density after generating explanations The explain_schema tool wrote its results with `input.show ?? "full"`, so turning on Compact and generating jumped the toggle to Full as soon as the explanations arrived. The model is never told about `show`, so the default always won. When explanations are already showing, keep the user's density; `show` (default Full) now only decides how to reveal them when they were hidden. Document that on the `show` parameter and add tests for each case. Co-Authored-By: Claude Sonnet 5 --- .../assistant/tools/explainSchema.test.ts | 40 +++++++++++++++++++ src/services/assistant/tools/explainSchema.ts | 15 ++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/services/assistant/tools/explainSchema.test.ts b/src/services/assistant/tools/explainSchema.test.ts index eb269924..b91600cb 100644 --- a/src/services/assistant/tools/explainSchema.test.ts +++ b/src/services/assistant/tools/explainSchema.test.ts @@ -64,6 +64,46 @@ describe("explainSchemaTool", () => { 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( { diff --git a/src/services/assistant/tools/explainSchema.ts b/src/services/assistant/tools/explainSchema.ts index 812b5f91..d33ccf99 100644 --- a/src/services/assistant/tools/explainSchema.ts +++ b/src/services/assistant/tools/explainSchema.ts @@ -17,7 +17,13 @@ const EntrySchema = z.object({ const InputSchema = z.object({ annotations: z.array(EntrySchema).min(1), - show: z.enum(["compact", "full"]).optional(), + 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; @@ -59,7 +65,12 @@ export const explainSchemaTool: AssistantTool 0) { - useSchemaAnnotationStore.getState().setAnnotations(valid, input.show ?? "full"); + 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 }; }, From 5041a9060538fb7ac54880aa57e957c5e186cea1 Mon Sep 17 00:00:00 2001 From: Sam Kim Date: Sat, 19 Sep 2026 09:19:07 -0700 Subject: [PATCH 4/4] style(schema-annotations): apply oxfmt formatting Formatting only: re-wrap a few long statements and order imports, so that `npm run format:check` passes. Co-Authored-By: Claude Sonnet 5 --- src/services/schemaAnnotations/resolve.test.ts | 8 +++++++- src/services/schemaAnnotations/resolve.ts | 12 ++++++++---- src/services/schemaAnnotations/store.test.ts | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/services/schemaAnnotations/resolve.test.ts b/src/services/schemaAnnotations/resolve.test.ts index c1f60eed..0feb8266 100644 --- a/src/services/schemaAnnotations/resolve.test.ts +++ b/src/services/schemaAnnotations/resolve.test.ts @@ -69,7 +69,13 @@ describe("listSchemaSymbols", () => { 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.arrayContaining([ + "user", + "document", + "document/viewer", + "document/view", + "is_tuesday", + ]), ); expect(syms.find((s) => s.symbolPath === "is_tuesday")!.startLine).toBe(8); }); diff --git a/src/services/schemaAnnotations/resolve.ts b/src/services/schemaAnnotations/resolve.ts index d135c52d..9104f51d 100644 --- a/src/services/schemaAnnotations/resolve.ts +++ b/src/services/schemaAnnotations/resolve.ts @@ -35,9 +35,9 @@ export function resolveSymbol( // 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; + 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"). @@ -69,7 +69,11 @@ export function listSchemaSymbols(schemaText: string): UnexplainedSymbol[] { for (const rd of resolver.listDefinitions()) { const def = rd.definition; - out.push({ symbolKind: "definition", symbolPath: def.name, startLine: def.range.startIndex.line }); + 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" diff --git a/src/services/schemaAnnotations/store.test.ts b/src/services/schemaAnnotations/store.test.ts index 736194f1..bbb10cb2 100644 --- a/src/services/schemaAnnotations/store.test.ts +++ b/src/services/schemaAnnotations/store.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { useSchemaAnnotationStore } from "./store"; import { hashSymbolSource } from "./resolve"; +import { useSchemaAnnotationStore } from "./store"; describe("useSchemaAnnotationStore", () => { beforeEach(() => useSchemaAnnotationStore.getState().reset());