Skip to content

Commit 37831d5

Browse files
committed
feat(assistant): one-click "Ask assistant to debug/fix" affordances
Add entry points that open the AI assistant and auto-submit a targeted debug prompt for a specific failure. Everything is gated on AppConfig().aiEnabled and renders/registers nothing when AI is off. Surfaces: - Inline "Ask assistant to fix" CodeLens on schema syntax errors and failed assertions, in the schema/assertions editors. - "Ask assistant to debug" button in the Watches panel for every non-passing check-watch state (denied, errored, couldn't run, missing context). - "Ask assistant to fix" action on Problems-panel error rows (schema, assertion, validation). Mechanism: a transient, non-persisted pendingPrompt on the assistant store; requestAssistantDebug(prompt, source) opens the assistant dock and stashes the prompt; a usePendingPromptConsumer hook in AssistantPanel submits it once the panel is not mid-turn (it defers only while a turn is streaming or executing tools, not on the terminal error state, so a request after a failed turn still fires). A pure debugPrompts module builds the prompts. Emits a single playground_ai_debug_requested analytics event tagged with the source surface. Covered by unit tests (prompt builders, predicate, store actions, dispatcher) and browser tests (affordance components and the pending-prompt hook, including the error-state case).
1 parent 78bcdbf commit 37831d5

14 files changed

Lines changed: 642 additions & 7 deletions

src/components/EditorDisplay.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import { flushSync } from "react-dom";
1010
import { useSettings } from "@/components/SettingsProvider";
1111
import { useResolvedTheme } from "@/hooks/use-resolved-theme";
1212

13+
import { buildErrorPrompt, type ErrorPromptInput } from "../services/assistant/debugPrompts";
14+
import { requestAssistantDebug } from "../services/assistant/debugRequest";
1315
import { assertionStringToCheckWatch, CheckWatch, LiveCheckService } from "../services/check";
16+
import AppConfig from "../services/configservice";
1417
import { ScrollLocation, useCookieService } from "../services/cookieservice";
1518
import { DataStore, DataStoreItem, DataStoreItemKind } from "../services/datastore";
1619
import { LocalParseState } from "../services/localparse";
@@ -48,6 +51,14 @@ const latestLiveCheckServiceRef: { current: LiveCheckService | null } = { curren
4851

4952
const ADD_CHECK_WATCH_COMMAND_ID = "playground.addCheckWatchFromAssertion";
5053

54+
const ASK_ASSISTANT_DEBUG_COMMAND_ID = "playground.askAssistantDebug";
55+
56+
// Maps each editor model's URI to the datastore kind it displays, so the
57+
// module-scope assistant-debug CodeLens provider (which only receives a model)
58+
// can restrict itself to the schema and assertions documents and label the
59+
// prompt's error source correctly.
60+
const modelKindByUri = new Map<string, DataStoreItemKind>();
61+
5162
export type EditorDisplayProps = {
5263
datastore: DataStore;
5364
services: Services;
@@ -418,6 +429,7 @@ export function EditorDisplay(props: EditorDisplayProps) {
418429
registerDSLanguage(monacoInstance);
419430
registerTupleLanguage(monacoInstance, () => latestLocalParseStateRef.current!);
420431
registerAssertionFixes(monacoInstance);
432+
registerAssistantDebugLenses(monacoInstance);
421433
languagesRegistered = true;
422434
// Themes are defined inside registerDSLanguage. The Editor already rendered
423435
// with the theme prop before defineTheme ran, so Monaco fell back to its
@@ -431,6 +443,9 @@ export function EditorDisplay(props: EditorDisplayProps) {
431443
[itemId]: editor,
432444
};
433445

446+
const model = editor.getModel();
447+
if (model) modelKindByUri.set(model.uri.toString(), currentItem.kind);
448+
434449
editor.onDidChangeCursorPosition((e: monaco.editor.ICursorPositionChangedEvent) => {
435450
debouncedSetEditorPosition(e.position);
436451
if (props.onPositionChange !== undefined) {
@@ -453,6 +468,7 @@ export function EditorDisplay(props: EditorDisplayProps) {
453468
}
454469
resizeObserversRef.current[itemId]?.disconnect();
455470
delete resizeObserversRef.current[itemId];
471+
if (model) modelKindByUri.delete(model.uri.toString());
456472
});
457473

458474
updateMarkers();
@@ -745,3 +761,75 @@ function registerAssertionFixes(monacoInstance: typeof monaco) {
745761
codeLensProviderRef.current = codeLensProvider;
746762
monacoInstance.languages.registerCodeLensProvider("yaml", codeLensProvider);
747763
}
764+
765+
/**
766+
* registerAssistantDebugLenses wires an "Ask assistant to fix" CodeLens above
767+
* each error marker in the schema and assertions editors. Clicking it opens the
768+
* assistant and auto-submits a prompt describing that specific error. Mirrors
769+
* registerAssertionFixes (registered once at module scope, refreshed on marker
770+
* changes). Gated on AppConfig().aiEnabled so nothing appears when AI is off.
771+
*
772+
* Markers in each editor are already source-scoped by updateMarkers (the schema
773+
* editor only shows SCHEMA-source errors; the assertions editor only
774+
* ASSERTION-source), so we key the error source off the model's datastore kind
775+
* via modelKindByUri and restrict to the schema + assertions documents.
776+
*/
777+
function registerAssistantDebugLenses(monacoInstance: typeof monaco) {
778+
monacoInstance.editor.registerCommand(
779+
ASK_ASSISTANT_DEBUG_COMMAND_ID,
780+
(_accessor: unknown, arg: ErrorPromptInput) => {
781+
requestAssistantDebug(buildErrorPrompt(arg), "editor");
782+
},
783+
);
784+
785+
const providerRef: { current: monaco.languages.CodeLensProvider | null } = { current: null };
786+
const emitter = new monacoInstance.Emitter<monaco.languages.CodeLensProvider>();
787+
monacoInstance.editor.onDidChangeMarkers(() => {
788+
if (providerRef.current) emitter.fire(providerRef.current);
789+
});
790+
791+
const provider: monaco.languages.CodeLensProvider = {
792+
onDidChange: emitter.event,
793+
provideCodeLenses: (model) => {
794+
const empty = { lenses: [], dispose: () => undefined };
795+
if (!AppConfig().aiEnabled) return empty;
796+
const kind = modelKindByUri.get(model.uri.toString());
797+
if (kind !== DataStoreItemKind.SCHEMA && kind !== DataStoreItemKind.ASSERTIONS) {
798+
return empty;
799+
}
800+
const source =
801+
kind === DataStoreItemKind.SCHEMA
802+
? DeveloperError_Source.SCHEMA
803+
: DeveloperError_Source.ASSERTION;
804+
const markers = monacoInstance.editor.getModelMarkers({ resource: model.uri });
805+
const lenses: monaco.languages.CodeLens[] = [];
806+
for (const marker of markers) {
807+
if (marker.severity !== monacoInstance.MarkerSeverity.Error) continue;
808+
const context = typeof marker.code === "string" ? marker.code : (marker.code?.value ?? "");
809+
const arg: ErrorPromptInput = {
810+
source,
811+
line: marker.startLineNumber,
812+
message: marker.message,
813+
context,
814+
};
815+
lenses.push({
816+
range: {
817+
startLineNumber: marker.startLineNumber,
818+
startColumn: 1,
819+
endLineNumber: marker.startLineNumber,
820+
endColumn: 1,
821+
},
822+
command: {
823+
id: ASK_ASSISTANT_DEBUG_COMMAND_ID,
824+
title: "$(lightbulb) Ask assistant to fix",
825+
arguments: [arg],
826+
},
827+
});
828+
}
829+
return { lenses, dispose: () => undefined };
830+
},
831+
};
832+
providerRef.current = provider;
833+
monacoInstance.languages.registerCodeLensProvider(DS_LANGUAGE_NAME, provider);
834+
monacoInstance.languages.registerCodeLensProvider("yaml", provider);
835+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Sparkles } from "lucide-react";
2+
3+
import { Button } from "@/components/ui/button";
4+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
5+
6+
import { buildCheckWatchPrompt, buildErrorPrompt } from "../../services/assistant/debugPrompts";
7+
import { requestAssistantDebug } from "../../services/assistant/debugRequest";
8+
import { LiveCheckItem } from "../../services/check";
9+
import { DeveloperError } from "../../spicedb-common/protodefs/developer/v1/developer_pb";
10+
11+
/**
12+
* AskAssistantFixAction is the per-error "Ask assistant to fix" button used in
13+
* the Problems panel. Its parent renders it only when AI is enabled.
14+
*/
15+
export function AskAssistantFixAction({ error }: { error: DeveloperError }) {
16+
const onClick = () =>
17+
requestAssistantDebug(
18+
buildErrorPrompt({
19+
source: error.source,
20+
line: error.line,
21+
message: error.message,
22+
context: error.context ?? "",
23+
}),
24+
"problems",
25+
);
26+
return (
27+
<Tooltip>
28+
<TooltipTrigger asChild>
29+
<Button size="xs" variant="ghost" onClick={onClick}>
30+
<Sparkles />
31+
Ask assistant to fix
32+
</Button>
33+
</TooltipTrigger>
34+
<TooltipContent>Have the assistant debug and fix this error</TooltipContent>
35+
</Tooltip>
36+
);
37+
}
38+
39+
/**
40+
* AskAssistantDebugButton is the per-watch "Ask assistant to debug" icon button
41+
* used in the Watches panel. Its parent renders it only when AI is enabled and
42+
* the watch is in a non-passing state (isDebuggableWatchStatus).
43+
*/
44+
export function AskAssistantDebugButton({ item }: { item: LiveCheckItem }) {
45+
const onClick = () =>
46+
requestAssistantDebug(
47+
buildCheckWatchPrompt({
48+
object: item.object,
49+
action: item.action,
50+
subject: item.subject,
51+
context: item.context,
52+
status: item.status,
53+
errorMessage: item.errorMessage,
54+
}),
55+
"watches",
56+
);
57+
return (
58+
<Tooltip>
59+
<TooltipTrigger asChild>
60+
<Button
61+
size="icon-sm"
62+
variant="ghost"
63+
aria-label="Ask assistant to debug"
64+
onClick={onClick}
65+
>
66+
<Sparkles />
67+
</Button>
68+
</TooltipTrigger>
69+
<TooltipContent>Have the assistant debug and fix this check</TooltipContent>
70+
</Tooltip>
71+
);
72+
}

src/components/panels/assistant/AssistantPanel.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
55
import { type DisplayMessage, useAssistantStore } from "../../../services/assistant/store";
66
import type { HistoryRecorder } from "../../../services/assistant/types";
77
import { useAssistantController } from "../../../services/assistant/useAssistantController";
8+
import { usePendingPromptConsumer } from "../../../services/assistant/usePendingPrompt";
89
import type { DataStore } from "../../../services/datastore";
910
import { useHistoryStore } from "../../../services/history/historyStore";
1011
import { restoreRevision } from "../../../services/history/useHistoryRecorder";
@@ -23,6 +24,9 @@ export function AssistantPanel({
2324
history: HistoryRecorder;
2425
}) {
2526
const { submit, stop } = useAssistantController(services, datastore, history);
27+
// Drain any externally-requested debug prompt (inline "Ask assistant to fix"
28+
// affordances) into a turn now that the panel is mounted.
29+
usePendingPromptConsumer(submit);
2630
const display = useAssistantStore((s) => s.display);
2731
const status = useAssistantStore((s) => s.status);
2832
const reset = useAssistantStore((s) => s.reset);

src/components/panels/problems.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
66
import { cn } from "@/lib/utils";
77

88
import { assertionStringToCheckWatch, LiveCheckService } from "../../services/check";
9+
import AppConfig from "../../services/configservice";
910
import { Services } from "../../services/services";
1011
import {
1112
DeveloperError,
@@ -15,6 +16,7 @@ import {
1516
import { DocumentLink } from "../document-link";
1617
import { useDrawerStore } from "../drawer/state";
1718

19+
import { AskAssistantFixAction } from "./AskAssistantActions";
1820
import { DeveloperSourceDisplay, DeveloperWarningSourceDisplay } from "./errordisplays";
1921

2022
interface ProblemsPanelProps {
@@ -26,6 +28,7 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) {
2628
const warnings = services.problemService.warnings;
2729
const invalidRels = services.problemService.invalidRelationships;
2830
const allValidationErrors = services.problemService.validationErrors;
31+
const aiEnabled = AppConfig().aiEnabled;
2932

3033
const schemaErrors = requestErrors.filter((e) => e.source === DeveloperError_Source.SCHEMA);
3134
const relationshipRequestErrors = requestErrors.filter(
@@ -46,7 +49,11 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) {
4649
<div className="p-2 space-y-1">
4750
<Group title="Schema" errorCount={schemaErrors.length} warningCount={warnings.length}>
4851
{schemaErrors.map((de, i) => (
49-
<ErrorRow key={`s${i}`} error={de} />
52+
<ErrorRow
53+
key={`s${i}`}
54+
error={de}
55+
action={aiEnabled ? <AskAssistantFixAction error={de} /> : undefined}
56+
/>
5057
))}
5158
{warnings.map((dw, i) => (
5259
<WarningRow key={`w${i}`} warning={dw} />
@@ -78,7 +85,12 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) {
7885
<ErrorRow
7986
key={`a${i}`}
8087
error={de}
81-
action={<AddCheckWatchAction error={de} liveCheckService={services.liveCheckService} />}
88+
action={
89+
<div className="flex items-center gap-1">
90+
<AddCheckWatchAction error={de} liveCheckService={services.liveCheckService} />
91+
{aiEnabled && <AskAssistantFixAction error={de} />}
92+
</div>
93+
}
8294
/>
8395
))}
8496
</Group>
@@ -97,7 +109,11 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) {
97109
}
98110
>
99111
{validationErrors.map((ve, i) => (
100-
<ErrorRow key={`v${i}`} error={ve} />
112+
<ErrorRow
113+
key={`v${i}`}
114+
error={ve}
115+
action={aiEnabled ? <AskAssistantFixAction error={ve} /> : undefined}
116+
/>
101117
))}
102118
</Group>
103119
</div>

src/components/panels/watches.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ import {
2828
TableRow,
2929
} from "@/components/ui/table";
3030

31+
import { isDebuggableWatchStatus } from "../../services/assistant/debugPrompts";
3132
import {
3233
LiveCheckItem,
3334
LiveCheckItemStatus,
3435
LiveCheckService,
3536
LiveCheckStatus,
3637
} from "../../services/check";
38+
import AppConfig from "../../services/configservice";
3739
import { DataStore, DataStoreItemKind } from "../../services/datastore";
3840
import { LocalParseService } from "../../services/localparse";
3941
import { Services } from "../../services/services";
@@ -42,6 +44,8 @@ import { RelationTuple as Relationship } from "../../spicedb-common/protodefs/co
4244
import { CheckDebugTraceView } from "../CheckDebugTraceView";
4345
import { Alert, AlertTitle, AlertDescription } from "../ui/alert";
4446

47+
import { AskAssistantDebugButton } from "./AskAssistantActions";
48+
4549
interface WatchesPanelProps {
4650
services: Services;
4751
datastore: DataStore;
@@ -315,10 +319,19 @@ function LiveCheckRow(props: LiveCheckRowProps) {
315319
className="font-mono placeholder:text-muted-foreground/50"
316320
/>
317321
</TableCell>
318-
<TableCell className="w-8">
319-
<Button size="icon-sm" variant="ghost" onClick={() => liveCheckService.removeItem(item)}>
320-
<Trash2 />
321-
</Button>
322+
<TableCell className="w-auto whitespace-nowrap">
323+
<div className="flex items-center justify-end gap-1">
324+
{AppConfig().aiEnabled && isDebuggableWatchStatus(item.status) && (
325+
<AskAssistantDebugButton item={item} />
326+
)}
327+
<Button
328+
size="icon-sm"
329+
variant="ghost"
330+
onClick={() => liveCheckService.removeItem(item)}
331+
>
332+
<Trash2 />
333+
</Button>
334+
</div>
322335
</TableCell>
323336
</TableRow>
324337
{item.debugInformation !== undefined && isExpanded && (

0 commit comments

Comments
 (0)