Phase 7: AI-assisted query authoring (autocomplete, explain, fix, optimize, NL translation)

Adds a self-hosted (Ollama, qwen2.5-coder) model provider abstraction
with a pluggable opt-in cloud adapter, schema grounding, and a shared
cost/safety guard every AI-suggested query is assessed against --
compiling to and executing through the same unchanged Phase 2 IR/
compiler and Phase 4 tenant scoping as a hand-written query, no
parallel execution path.

Track A (built into the query bar): inline ghost-text autocomplete,
"Explain this query", "Fix this query" with a diff view, and a
rule-based "Optimize" suggestion. Track B: natural-language-to-query
translation, always a separate review step from execution, with
`sentryctl query --nl` requiring explicit confirmation to run.
Every accepted/dismissed translate-fix-optimize interaction is logged
into the same append-only audit_log table Phase 4 built.

Two real product bugs were found and fixed via live browser
verification (a Svelte effect re-running on every keystroke that
silently cancelled the ghost-text debounce; a ghost-text widget
positioned at document offset 0 instead of the cursor), and a real
costguard logic bug (unbounded-aggregation vs. raw-row) was caught by
its own test suite. New integration tests wire a real Ollama client
through the real HTTP handler against a mock server matching Ollama's
wire contract (hack/mock-ollama), keeping model-quality verification
out of CI as a disclosed, periodic human-run check instead.

See /docs/phase-7-ai-design.md and /docs/phase-7-runbook.md.
This commit is contained in:
2026-08-16 18:06:27 -07:00
parent 661568085e
commit 7d316f92db
37 changed files with 5230 additions and 20 deletions
+467 -4
View File
@@ -2,9 +2,23 @@
// Extracted from the root query page in Phase 3 so the dashboard panel
// editor and the alert rule editor can reuse the same input --
// deliberately just the input+run affordance, not results/history,
// which differ per consumer.
// which differ per consumer. Phase 7 adds AI-assisted authoring
// (explain/fix/optimize) here rather than as a separate mode/page, so
// every consumer of this component gets it -- errorMessage/warnings
// are optional props specifically so existing callers that don't pass
// them see no behavior change at all.
import type { Language } from '$lib/api';
import { Button } from '$lib/components/ui';
import {
aiExplain,
aiFix,
aiOptimize,
aiTranslate,
logInteraction,
type FixResponse,
type OptimizeResponse,
type TranslateResponse
} from '$lib/api';
import { Button, Modal } from '$lib/components/ui';
import QueryEditor from '$lib/query-editor/QueryEditor.svelte';
let {
@@ -12,13 +26,23 @@
language = $bindable<Language>(''),
onRun,
loading = false,
placeholder = 'service=api | where status>=500 | stats count by host | sort -count'
placeholder = 'service=api | where status>=500 | stats count by host | sort -count',
// Set by the caller after a failed run (parse or execution error)
// -- presence alone drives the "Fix this" affordance below, this
// component never calls runQuery itself to find out.
errorMessage = '',
// Set by the caller from a successful run's QueryResult.warnings
// (costguard, Phase 7 task 4) -- presence alone drives the
// "Optimize" affordance.
warnings = []
}: {
query: string;
language: Language;
onRun: () => void;
loading?: boolean;
placeholder?: string;
errorMessage?: string;
warnings?: string[];
} = $props();
// Client-side mirror of the backend's auto-detect heuristic
@@ -30,10 +54,245 @@
}
let detected = $derived(detectedLanguage(query));
let effectiveLanguage = $derived(language === '' ? detected : language);
// ---- "looks like natural language" detection (task 10) ----
//
// Deliberately NOT "wait for a parse error" -- the pipe grammar's
// own free-text rule (bare words AND'd together, see
// /docs/query-language-reference.md's "Free-text search" section)
// means a plain-English question like "show me errors from the last
// hour" *parses successfully* as a search for records containing
// all of those words literally. It never fails to parse; it just
// silently returns an unhelpful result. Waiting for a parse error
// would miss the single most common real case this feature exists
// for. Instead: a cheap client-side heuristic flags text that has
// none of the pipe syntax's structural markers (`|`, a comparison
// operator, `:`) and is long enough (4+ words) that it's very
// unlikely to be an intentional short free-text search someone
// actually wants run literally -- a single bare word or a quoted
// phrase is common and legitimate, and stays untouched.
function looksLikeNaturalLanguage(q: string): boolean {
const trimmed = q.trim();
if (trimmed === '' || /^\s*select\b/i.test(trimmed)) return false;
if (/[|=<>:]/.test(trimmed)) return false;
return trimmed.split(/\s+/).length >= 4;
}
let looksLikeNL = $derived(looksLikeNaturalLanguage(query));
// ---- interaction audit logging (task 12) ----
// Fire-and-forget: an audit write failure here must never block or
// surface an error on the button press that triggered it (same
// posture as the AI operations themselves being optional). Scoped to
// translate/fix/optimize only -- see logInteraction's/InteractionLogger's
// doc comments for why complete and explain are excluded.
function logFixOrOptimizeInteraction(
operation: 'fix' | 'optimize',
input: string,
suggestedQuery: string,
accepted: boolean
) {
logInteraction({
operation,
input,
output: suggestedQuery,
accepted,
edited: false,
finalQuery: suggestedQuery
}).catch(() => {});
}
// ---- explain ----
let explainOpen = $state(false);
let explainLoading = $state(false);
let explainText = $state('');
let explainError = $state('');
async function runExplain() {
explainOpen = true;
explainLoading = true;
explainError = '';
explainText = '';
try {
const res = await aiExplain(query, effectiveLanguage);
explainText = res.explanation;
} catch {
// Any failure (including "AI not configured," a plain 404) is
// shown as a quiet unavailable message, not a scary error --
// this is an optional enhancement, not a core feature whose
// failure should read as something broken.
explainError = 'AI explanation is not available right now.';
} finally {
explainLoading = false;
}
}
// ---- fix ----
let fixOpen = $state(false);
let fixLoading = $state(false);
let fixResult: FixResponse | null = $state(null);
let fixError = $state('');
async function runFix() {
fixOpen = true;
fixLoading = true;
fixError = '';
fixResult = null;
try {
fixResult = await aiFix(query, effectiveLanguage, { executionError: errorMessage });
} catch {
fixError = 'AI fix suggestions are not available right now.';
} finally {
fixLoading = false;
}
}
function acceptFix() {
if (!fixResult?.suggestedQuery) return;
logFixOrOptimizeInteraction('fix', query, fixResult.suggestedQuery, true);
query = fixResult.suggestedQuery;
fixOpen = false;
}
function dismissFix() {
if (fixResult?.suggestedQuery) {
logFixOrOptimizeInteraction('fix', query, fixResult.suggestedQuery, false);
}
fixOpen = false;
}
// ---- optimize ----
let optimizeOpen = $state(false);
let optimizeLoading = $state(false);
let optimizeResult: OptimizeResponse | null = $state(null);
let optimizeError = $state('');
async function runOptimize() {
optimizeOpen = true;
optimizeLoading = true;
optimizeError = '';
optimizeResult = null;
try {
optimizeResult = await aiOptimize(query, effectiveLanguage);
} catch {
optimizeError = 'AI optimization suggestions are not available right now.';
} finally {
optimizeLoading = false;
}
}
function acceptOptimize() {
if (!optimizeResult?.suggestedQuery) return;
logFixOrOptimizeInteraction('optimize', query, optimizeResult.suggestedQuery, true);
query = optimizeResult.suggestedQuery;
optimizeOpen = false;
}
function dismissOptimize() {
if (optimizeResult?.suggestedQuery) {
logFixOrOptimizeInteraction('optimize', query, optimizeResult.suggestedQuery, false);
}
optimizeOpen = false;
}
// ---- translate (Track B) ----
let translateOpen = $state(false);
let translateLoading = $state(false);
let translateNL = $state('');
let translateResult: TranslateResponse | null = $state(null);
let translateExplanation = $state('');
let translateError = $state('');
// Tracks edits made in the review textarea after a result arrives --
// see editedQuery's own comment below for why this exists.
let editedQuery = $state('');
function openTranslate() {
// Pre-fill with the current query bar content -- that's exactly
// what triggered the "looks like natural language" affordance in
// the first place, so re-typing it would be pure friction.
translateNL = query;
translateOpen = true;
runTranslate();
}
async function runTranslate() {
translateLoading = true;
translateError = '';
translateResult = null;
translateExplanation = '';
try {
const res = await aiTranslate(translateNL);
translateResult = res;
editedQuery = res.query;
if (res.query && res.compiles) {
// Reuses Explain rather than building a separate
// "describe the translation" mechanism -- task 10's
// explicit instruction. OriginalIntent lets the prompt
// speak to *how* the question became this query, not
// just describe the query in isolation.
try {
const explainRes = await aiExplain(res.query, 'spl', translateNL);
translateExplanation = explainRes.explanation;
} catch {
// Explanation is a nice-to-have on top of the
// translation itself -- a failure here shouldn't
// blank out an otherwise-successful translation.
}
}
} catch {
translateError = 'AI translation is not available right now.';
} finally {
translateLoading = false;
}
}
function useTranslatedQuery() {
if (!editedQuery.trim()) return;
if (translateResult) {
logInteraction({
operation: 'translate',
input: translateNL,
output: translateResult.query,
confidence: translateResult.confidence,
accepted: true,
edited: editedQuery !== translateResult.query,
finalQuery: editedQuery
}).catch(() => {});
}
query = editedQuery;
translateOpen = false;
}
function cancelTranslate() {
if (translateResult?.query) {
logInteraction({
operation: 'translate',
input: translateNL,
output: translateResult.query,
confidence: translateResult.confidence,
accepted: false,
edited: false,
finalQuery: translateResult.query
}).catch(() => {});
}
translateOpen = false;
}
// A blocked suggestion the user has since edited is their own text
// now, not the original flagged one -- costguard will assess
// whatever they actually run anyway (every /query response carries
// its own warnings, Track A task 4), so re-blocking an edit they
// made specifically to address the concern would be actively
// unhelpful, not extra-safe.
let translateUseDisabled = $derived.by(() => {
if (!editedQuery.trim()) return true;
const result = translateResult;
if (!result) return false;
return result.blocked && editedQuery === result.query;
});
</script>
<div class="query-bar">
<QueryEditor bind:value={query} {onRun} {placeholder} />
<QueryEditor bind:value={query} {onRun} {placeholder} language={effectiveLanguage} />
<div class="controls">
<label>
Language:
@@ -49,10 +308,146 @@
<Button variant="primary" onclick={onRun} disabled={loading || query.trim() === ''}>
{loading ? 'Running…' : 'Run query'}
</Button>
<Button variant="ghost" size="sm" onclick={runExplain} disabled={query.trim() === ''}>
Explain this query
</Button>
{#if errorMessage}
<Button variant="ghost" size="sm" onclick={runFix}>Try AI fix</Button>
{/if}
{#if warnings.length > 0}
<Button variant="ghost" size="sm" onclick={runOptimize}>Optimize</Button>
{/if}
{#if looksLikeNL}
<Button variant="ghost" size="sm" onclick={openTranslate}>Interpret as natural language</Button>
{/if}
<span class="hint">⌘/Ctrl+Enter to run</span>
</div>
{#if warnings.length > 0}
<p class="cost-warning">{warnings.join('; ')}</p>
{/if}
</div>
<Modal bind:open={explainOpen} title="What this query does">
{#if explainLoading}
<p class="muted">Thinking…</p>
{:else if explainError}
<p class="muted">{explainError}</p>
{:else}
<p>{explainText}</p>
{/if}
</Modal>
<Modal bind:open={fixOpen} title="AI-suggested fix">
{#if fixLoading}
<p class="muted">Thinking…</p>
{:else if fixError}
<p class="muted">{fixError}</p>
{:else if fixResult}
{#if !fixResult.suggestedQuery}
<p class="muted">
{fixResult.explanation || "The AI couldn't determine a fix for this error."}
</p>
{:else}
<div class="diff">
<div class="diff-row removed">
<span class="diff-label">Current</span>
<code>{query}</code>
</div>
<div class="diff-row added">
<span class="diff-label">Suggested</span>
<code>{fixResult.suggestedQuery}</code>
</div>
</div>
{#if fixResult.explanation}<p>{fixResult.explanation}</p>{/if}
{#if fixResult.blocked}
<p class="cost-warning">
⚠ This suggestion isn't offered as directly runnable: {(fixResult.costWarnings ?? []).join('; ')}
You can still copy it and adjust manually.
</p>
{/if}
<div class="actions">
<Button variant="secondary" onclick={dismissFix}>Dismiss</Button>
<Button variant="primary" onclick={acceptFix} disabled={fixResult.blocked}>
Accept
</Button>
</div>
{/if}
{/if}
</Modal>
<Modal bind:open={optimizeOpen} title="Optimize this query">
{#if optimizeLoading}
<p class="muted">Thinking…</p>
{:else if optimizeError}
<p class="muted">{optimizeError}</p>
{:else if optimizeResult}
<p>{optimizeResult.phrased || optimizeResult.findings.join('; ')}</p>
{#if optimizeResult.suggestedQuery}
<div class="diff">
<div class="diff-row removed">
<span class="diff-label">Current</span>
<code>{query}</code>
</div>
<div class="diff-row added">
<span class="diff-label">Suggested</span>
<code>{optimizeResult.suggestedQuery}</code>
</div>
</div>
<div class="actions">
<Button variant="secondary" onclick={dismissOptimize}>Dismiss</Button>
<Button variant="primary" onclick={acceptOptimize}>Accept</Button>
</div>
{/if}
{/if}
</Modal>
<Modal bind:open={translateOpen} title="Ask in plain English">
<label class="nl-label" for="nl-question">Your question</label>
<textarea id="nl-question" class="nl-input" bind:value={translateNL} rows="2"></textarea>
<div class="actions translate-actions">
<Button variant="secondary" size="sm" onclick={runTranslate} disabled={translateLoading || !translateNL.trim()}>
{translateLoading ? 'Translating…' : 'Translate again'}
</Button>
</div>
{#if translateLoading && !translateResult}
<p class="muted">Thinking…</p>
{:else if translateError}
<p class="muted">{translateError}</p>
{:else if translateResult}
{#if !translateResult.query}
<!-- Honest low-confidence handling (task 10): no guess shown
with false confidence, just the reason plainly stated. -->
<p class="muted">
{translateResult.lowConfidenceReason || "Not confident enough to guess -- try rephrasing."}
</p>
{:else}
{#if translateResult.confidence === 'low'}
<p class="cost-warning">
⚠ Low confidence{translateResult.lowConfidenceReason ? `: ${translateResult.lowConfidenceReason}` : ''}. Review carefully before using.
</p>
{/if}
<label class="nl-label" for="nl-generated">Generated query (editable)</label>
<textarea id="nl-generated" class="nl-input mono" bind:value={editedQuery} rows="3"></textarea>
{#if !translateResult.compiles}
<p class="cost-warning">⚠ This doesn't parse as a valid query: {translateResult.compileError}. Edit it above before using.</p>
{/if}
{#if translateExplanation}<p>{translateExplanation}</p>{/if}
{#if translateResult.blocked && editedQuery === translateResult.query}
<p class="cost-warning">
⚠ Not offered as directly runnable: {(translateResult.costWarnings ?? []).join('; ')} Edit the query above to address this, or copy it manually.
</p>
{/if}
<div class="actions">
<Button variant="secondary" onclick={cancelTranslate}>Cancel</Button>
<Button variant="primary" onclick={useTranslatedQuery} disabled={translateUseDisabled}>
Use this query
</Button>
</div>
{/if}
{/if}
</Modal>
<style>
.controls {
margin-top: var(--space-3);
@@ -84,4 +479,72 @@
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.cost-warning {
margin-top: var(--space-2);
font-size: var(--text-sm);
color: var(--color-sev-warn);
}
.muted {
color: var(--color-text-muted);
}
.diff {
display: flex;
flex-direction: column;
gap: var(--space-2);
font-family: var(--font-mono);
font-size: var(--text-sm);
margin-bottom: var(--space-3);
}
.diff-row {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
border-radius: var(--radius-sm);
}
.diff-row code {
white-space: pre-wrap;
word-break: break-word;
}
.diff-row.removed {
background: var(--color-sev-error-bg);
}
.diff-row.added {
background: var(--color-sev-info-bg);
}
.diff-label {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.nl-label {
display: block;
font-size: var(--text-xs);
color: var(--color-text-muted);
margin-bottom: var(--space-1);
}
.nl-input {
width: 100%;
box-sizing: border-box;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2);
font-family: var(--font-ui);
font-size: var(--text-sm);
resize: vertical;
}
.nl-input.mono {
font-family: var(--font-mono);
}
.translate-actions {
justify-content: flex-start;
margin: var(--space-2) 0 var(--space-3);
}
</style>
+119 -1
View File
@@ -16,7 +16,13 @@ export const enterpriseAuthBase = import.meta.env.VITE_ENTERPRISE_AUTH_BASE_URL
export type Language = '' | 'sql' | 'spl';
export type QueryResult = { columns: string[]; rows: unknown[][] };
// warnings (Phase 7) is populated by the shared costguard package's
// assessment of every query, hand-written or AI-suggested alike --
// informational only, never a reason a query didn't run. Optional/absent
// (not an empty array) when there's nothing to say, matching the
// backend's `omitempty` -- see api/queryapi/handler.go's doc comment on
// why this is additive, not new enforcement.
export type QueryResult = { columns: string[]; rows: unknown[][]; warnings?: string[] };
export type VizType = 'table' | 'line' | 'bar' | 'single_stat' | 'top_n' | 'heatmap';
@@ -83,6 +89,117 @@ export function runQuery(query: string, language: Language): Promise<QueryResult
return request('/query', { method: 'POST', body: JSON.stringify({ query, language }) });
}
// ---- AI-assisted query authoring (Phase 7 Track A) ----
// Every function here returns text/suggestions only -- running anything
// still goes through runQuery above, unchanged, per the phase's
// non-negotiable "no parallel execution path" design principle. A
// deployment with no OLLAMA_BASE_URL configured has these routes
// entirely unregistered server-side (api/cmd/api's main.go), so a 404
// here is a normal, expected "AI isn't enabled" response, not a bug --
// callers (QueryBar.svelte) treat any error from these functions as
// "AI unavailable right now," never a user-facing failure.
export type Confidence = 'high' | 'medium' | 'low';
export function aiComplete(queryPrefix: string, language: string): Promise<{ suggestion: string }> {
return request('/ai/complete', { method: 'POST', body: JSON.stringify({ queryPrefix, language }) });
}
export function aiExplain(
query: string,
language: string,
originalIntent?: string
): Promise<{ explanation: string }> {
return request('/ai/explain', {
method: 'POST',
body: JSON.stringify({ query, language, originalIntent: originalIntent ?? '' })
});
}
export type FixResponse = {
suggestedQuery: string;
explanation: string;
confidence: Confidence | '';
blocked: boolean;
costWarnings?: string[];
};
export function aiFix(
query: string,
language: string,
opts: { parseError?: string; executionError?: string }
): Promise<FixResponse> {
return request('/ai/fix', {
method: 'POST',
body: JSON.stringify({
query,
language,
parseError: opts.parseError ?? '',
executionError: opts.executionError ?? ''
})
});
}
export type OptimizeResponse = {
findings: string[];
phrased: string;
suggestedQuery?: string;
};
export function aiOptimize(query: string, language: string): Promise<OptimizeResponse> {
return request('/ai/optimize', { method: 'POST', body: JSON.stringify({ query, language }) });
}
// ---- Natural language translation (Phase 7 Track B) ----
// Same non-negotiable split as every other AI operation: this returns a
// query for review, never executes it. Running the result is the exact
// same runQuery() above, reused unchanged -- task 9's explicit
// requirement.
export type TranslateResponse = {
query: string;
confidence: Confidence | '';
lowConfidenceReason?: string;
compiles: boolean;
compileError?: string;
blocked: boolean;
costWarnings?: string[];
};
export function aiTranslate(nlQuery: string): Promise<TranslateResponse> {
return request('/ai/translate', { method: 'POST', body: JSON.stringify({ nlQuery }) });
}
// ---- Interaction audit logging (task 12) ----
// Fire-and-forget: a failure here (including AI being unconfigured
// server-side, same 404-is-normal posture as every other /ai/* call)
// must never block or surface an error for the accept/dismiss action
// that triggered it, so callers should not await rejection handling
// beyond a swallowed .catch(() => {}).
export type InteractionOperation = 'translate' | 'fix' | 'optimize';
export function logInteraction(entry: {
operation: InteractionOperation;
input: string;
output: string;
confidence?: Confidence | '';
accepted: boolean;
edited: boolean;
finalQuery?: string;
}): Promise<void> {
return request('/ai/log-interaction', {
method: 'POST',
body: JSON.stringify({
operation: entry.operation,
input: entry.input,
output: entry.output,
confidence: entry.confidence ?? '',
accepted: entry.accepted,
edited: entry.edited,
finalQuery: entry.finalQuery ?? ''
})
});
}
export function listDashboards(): Promise<Dashboard[]> {
return request('/dashboards').then((d) => (d as Dashboard[]) ?? []);
}
@@ -355,3 +472,4 @@ export function createNotificationTarget(input: {
}): Promise<NotificationTarget> {
return alertingRequest('/targets', { method: 'POST', body: JSON.stringify(input) });
}
+188 -7
View File
@@ -7,18 +7,161 @@
// well-established dependency for exactly this job (the same
// "reach for a real editor primitive" reasoning ECharts and GridStack
// already followed elsewhere in Phase 5).
import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view';
import { untrack } from 'svelte';
import {
EditorView,
keymap,
placeholder as placeholderExt,
Decoration,
WidgetType,
type DecorationSet
} from '@codemirror/view';
import { StateField, StateEffect } from '@codemirror/state';
import { autocompletion, closeBrackets, completionKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { pipeLanguage, pipeSyntaxHighlighting } from './language';
import { pipeCompletions } from './completions';
import { aiComplete } from '$lib/api';
let {
value = $bindable(''),
onRun,
placeholder = '',
ariaLabel = 'Query'
}: { value?: string; onRun?: () => void; placeholder?: string; ariaLabel?: string } = $props();
ariaLabel = 'Query',
language = 'spl',
aiCompleteEnabled = true
}: {
value?: string;
onRun?: () => void;
placeholder?: string;
ariaLabel?: string;
// language, not the bindable Language type QueryBar owns -- this
// component only needs the string to pass through to /ai/complete,
// never interprets it itself.
language?: string;
// Baseline autocomplete (pipeCompletions, above) is always on --
// this only gates the AI ghost-text layer, so a caller embedding
// QueryEditor somewhere AI assistance doesn't make sense (if any)
// can opt out without losing deterministic completion. Task 5's
// "AI assistance augments, never replaces" holds either way: the
// two mechanisms are fully independent extensions, not one
// swapped for the other.
aiCompleteEnabled?: boolean;
} = $props();
// ---- ghost-text AI completion (task 5) ----
// A StateField + a Decoration.widget, not @codemirror/autocomplete's
// dropdown machinery -- ghost text renders inline after the cursor
// and accepts on Tab, a genuinely different interaction from a
// completion list, so it gets its own small extension rather than
// contorting autocompletion() into rendering it. Hand-built on
// CodeMirror's own primitives (already a Phase 5 dependency) rather
// than pulling in a new inline-completion package -- this codebase's
// "boring, well-understood dependencies" convention, and the
// primitives involved (StateField, Decoration.widget) are standard,
// commonly-used CodeMirror 6 building blocks for exactly this pattern.
const setGhost = StateEffect.define<string | null>();
class GhostTextWidget extends WidgetType {
text: string;
constructor(text: string) {
super();
this.text = text;
}
eq(other: GhostTextWidget) {
return other.text === this.text;
}
toDOM() {
const span = document.createElement('span');
span.className = 'cm-ghost-text';
span.textContent = this.text;
span.setAttribute('aria-hidden', 'true');
return span;
}
}
// The field stores a positioned DecorationSet directly, not a bare
// string -- computing the widget's position (tr.state.doc.length,
// the end of the document, since ghost text is only ever set when
// the cursor is at the end -- see scheduleCompletion) has to happen
// here, inside update(), where the *current* document length is
// available. An earlier version of this field stored just the
// suggestion string and hardcoded the widget at position 0 in a
// separate `provide` callback that only receives the field's value,
// not the document -- a real bug (ghost text rendered at the start
// of the query, not after what was typed), caught by live-browser
// verification, not by type-checking, since both are type-correct
// CodeMirror usage.
const ghostField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
for (const effect of tr.effects) {
if (effect.is(setGhost)) {
if (effect.value === null) return Decoration.none;
const pos = tr.state.doc.length;
return Decoration.set([Decoration.widget({ widget: new GhostTextWidget(effect.value), side: 1 }).range(pos)]);
}
}
// Any document change or selection move that isn't the ghost
// effect itself invalidates a stale suggestion -- showing
// ghost text for text that no longer reflects what's actually
// typed would be actively misleading, worse than showing
// nothing.
if (tr.docChanged || tr.selection) return Decoration.none;
return deco;
},
provide: (field) => EditorView.decorations.from(field)
});
function currentGhostText(view: EditorView): string | null {
let found: string | null = null;
view.state.field(ghostField).between(0, view.state.doc.length, (_from, _to, deco) => {
if (deco.spec.widget instanceof GhostTextWidget) found = deco.spec.widget.text;
});
return found;
}
let completeGeneration = 0;
let completeTimer: ReturnType<typeof setTimeout> | undefined;
const completeDebounceMs = 300;
function scheduleCompletion(view: EditorView) {
if (completeTimer) clearTimeout(completeTimer);
const gen = ++completeGeneration;
const sel = view.state.selection.main;
const atEnd = sel.empty && sel.head === view.state.doc.length;
const text = view.state.doc.toString();
if (!atEnd || text.trim() === '') return;
completeTimer = setTimeout(async () => {
let result: { suggestion: string };
try {
result = await aiComplete(text, language);
} catch {
return; // provider unavailable/slow/erroring -- silently no ghost text, never a user-facing error (task 5's graceful degradation)
}
// Stale response guard: the user kept typing (or the
// component unmounted) while this request was in flight.
if (gen !== completeGeneration || !view.hasFocus) return;
const curSel = view.state.selection.main;
const stillAtEnd =
curSel.empty && curSel.head === view.state.doc.length && view.state.doc.toString() === text;
if (!stillAtEnd || !result.suggestion) return;
view.dispatch({ effects: setGhost.of(result.suggestion) });
}, completeDebounceMs);
}
function acceptGhost(view: EditorView): boolean {
const ghost = currentGhostText(view);
if (!ghost) return false;
const end = view.state.doc.length;
view.dispatch({
changes: { from: end, to: end, insert: ghost },
selection: { anchor: end + ghost.length },
effects: setGhost.of(null)
});
return true;
}
let container: HTMLDivElement | undefined = $state();
let view: EditorView | undefined;
@@ -60,14 +203,38 @@
backgroundColor: 'var(--color-accent)',
color: 'var(--color-on-accent)'
},
'.cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--color-accent) 25%, transparent) !important' }
'.cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--color-accent) 25%, transparent) !important' },
'.cm-ghost-text': {
color: 'var(--color-text-faint)',
// Not user-selectable/editable -- it's a suggestion, not real
// document content, and must never end up copy-pasted or
// merged into a selection as if it were part of the query.
userSelect: 'none',
pointerEvents: 'none'
}
});
// Reactive dependency on `container` only -- deliberately not on
// `value`, even though the view's initial doc needs it. Reading
// `value` normally here would make this effect a dependent of it,
// and the updateListener below writes `value` on every keystroke to
// keep the bindable prop in sync -- if that write re-triggered this
// effect, it would destroy and recreate the *entire* EditorView on
// every keystroke. That's not just wasteful: a real, confirmed bug
// found while building task 5's ghost-text completion --
// `scheduleCompletion`'s debounce timer lives on `view` and gets
// silently cancelled by this effect's own cleanup
// (`clearTimeout(completeTimer)`) moments after being set, because
// the effect re-runs right after the triggering keystroke. Wrapping
// the initial `value` read in `untrack` breaks that dependency --
// the effect now only re-runs when `container` itself changes
// (mount), matching what it actually needs to do.
$effect(() => {
if (!container) return;
lastEmitted = value;
const initialValue = untrack(() => value);
lastEmitted = initialValue;
view = new EditorView({
doc: value,
doc: initialValue,
parent: container,
extensions: [
pipeLanguage,
@@ -78,7 +245,17 @@
autocompletion({ override: [pipeCompletions] }),
placeholderExt(placeholder),
EditorView.contentAttributes.of({ 'aria-label': ariaLabel }),
ghostField,
keymap.of([
// Tab accepts ghost text when one is showing -- checked
// first, ahead of completionKeymap/defaultKeymap, so it
// never competes with the deterministic dropdown's own
// Tab/Enter handling for which one wins; the two
// mechanisms are mutually exclusive at any given moment
// in practice (a dropdown being open is itself a
// docChanged-adjacent state ghost text's own
// invalidation logic tends to have already cleared).
{ key: 'Tab', run: acceptGhost },
...completionKeymap,
...defaultKeymap,
...historyKeymap,
@@ -95,11 +272,15 @@
if (update.docChanged) {
lastEmitted = update.state.doc.toString();
value = lastEmitted;
if (aiCompleteEnabled) scheduleCompletion(update.view);
}
})
]
});
return () => view?.destroy();
return () => {
if (completeTimer) clearTimeout(completeTimer);
view?.destroy();
};
});
// External changes to `value` (e.g. clicking a history entry) need to
+4 -1
View File
@@ -23,6 +23,7 @@
let columns = $state<string[]>([]);
let rows = $state<unknown[][]>([]);
let error = $state('');
let warnings = $state<string[]>([]);
let loading = $state(false);
let hasRun = $state(false);
let history = $state<HistoryEntry[]>(loadHistory());
@@ -55,10 +56,12 @@
async function runQuery() {
loading = true;
error = '';
warnings = [];
try {
const result = await apiRunQuery(query, language);
columns = result.columns ?? [];
rows = result.rows ?? [];
warnings = result.warnings ?? [];
saveHistory({ query, language, at: Date.now() });
} catch (e) {
error = e instanceof Error ? e.message : String(e);
@@ -98,7 +101,7 @@
sheet below. Build reusable queries into a <a href="/dashboards">dashboard</a>.
</p>
<QueryBar bind:query bind:language onRun={runQuery} {loading} />
<QueryBar bind:query bind:language onRun={runQuery} {loading} errorMessage={error} {warnings} />
{#if error}
<p class="error">Error: {error}</p>