Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/components/EditorDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { flushSync } from "react-dom";
import { useSettings } from "@/components/SettingsProvider";
import { useResolvedTheme } from "@/hooks/use-resolved-theme";

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

const ADD_CHECK_WATCH_COMMAND_ID = "playground.addCheckWatchFromAssertion";

const ASK_ASSISTANT_DEBUG_COMMAND_ID = "playground.askAssistantDebug";

// Maps each editor model's URI to the datastore kind it displays, so the
// module-scope assistant-debug CodeLens provider (which only receives a model)
// can restrict itself to the schema and assertions documents and label the
// prompt's error source correctly.
const modelKindByUri = new Map<string, DataStoreItemKind>();

export type EditorDisplayProps = {
datastore: DataStore;
services: Services;
Expand Down Expand Up @@ -418,6 +429,7 @@ export function EditorDisplay(props: EditorDisplayProps) {
registerDSLanguage(monacoInstance);
registerTupleLanguage(monacoInstance, () => latestLocalParseStateRef.current!);
registerAssertionFixes(monacoInstance);
registerAssistantDebugLenses(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
Expand All @@ -431,6 +443,9 @@ export function EditorDisplay(props: EditorDisplayProps) {
[itemId]: editor,
};

const model = editor.getModel();
if (model) modelKindByUri.set(model.uri.toString(), currentItem.kind);

editor.onDidChangeCursorPosition((e: monaco.editor.ICursorPositionChangedEvent) => {
debouncedSetEditorPosition(e.position);
if (props.onPositionChange !== undefined) {
Expand All @@ -453,6 +468,7 @@ export function EditorDisplay(props: EditorDisplayProps) {
}
resizeObserversRef.current[itemId]?.disconnect();
delete resizeObserversRef.current[itemId];
if (model) modelKindByUri.delete(model.uri.toString());
});

updateMarkers();
Expand Down Expand Up @@ -745,3 +761,75 @@ function registerAssertionFixes(monacoInstance: typeof monaco) {
codeLensProviderRef.current = codeLensProvider;
monacoInstance.languages.registerCodeLensProvider("yaml", codeLensProvider);
}

/**
* registerAssistantDebugLenses wires an "Ask assistant to fix" CodeLens above
* each error marker in the schema and assertions editors. Clicking it opens the
* assistant and auto-submits a prompt describing that specific error. Mirrors
* registerAssertionFixes (registered once at module scope, refreshed on marker
* changes). Gated on AppConfig().aiEnabled so nothing appears when AI is off.
*
* Markers in each editor are already source-scoped by updateMarkers (the schema
* editor only shows SCHEMA-source errors; the assertions editor only
* ASSERTION-source), so we key the error source off the model's datastore kind
* via modelKindByUri and restrict to the schema + assertions documents.
*/
function registerAssistantDebugLenses(monacoInstance: typeof monaco) {
monacoInstance.editor.registerCommand(
ASK_ASSISTANT_DEBUG_COMMAND_ID,
(_accessor: unknown, arg: ErrorPromptInput) => {
requestAssistantDebug(buildErrorPrompt(arg), "editor");
},
);

const providerRef: { current: monaco.languages.CodeLensProvider | null } = { current: null };
const emitter = new monacoInstance.Emitter<monaco.languages.CodeLensProvider>();
monacoInstance.editor.onDidChangeMarkers(() => {
if (providerRef.current) emitter.fire(providerRef.current);
});

const provider: monaco.languages.CodeLensProvider = {
onDidChange: emitter.event,
provideCodeLenses: (model) => {
const empty = { lenses: [], dispose: () => undefined };
if (!AppConfig().aiEnabled) return empty;
const kind = modelKindByUri.get(model.uri.toString());
if (kind !== DataStoreItemKind.SCHEMA && kind !== DataStoreItemKind.ASSERTIONS) {
return empty;
}
const source =
kind === DataStoreItemKind.SCHEMA
? DeveloperError_Source.SCHEMA
: DeveloperError_Source.ASSERTION;
const markers = monacoInstance.editor.getModelMarkers({ resource: model.uri });
const lenses: monaco.languages.CodeLens[] = [];
for (const marker of markers) {
if (marker.severity !== monacoInstance.MarkerSeverity.Error) continue;
const context = typeof marker.code === "string" ? marker.code : (marker.code?.value ?? "");
const arg: ErrorPromptInput = {
source,
line: marker.startLineNumber,
message: marker.message,
context,
};
lenses.push({
range: {
startLineNumber: marker.startLineNumber,
startColumn: 1,
endLineNumber: marker.startLineNumber,
endColumn: 1,
},
command: {
id: ASK_ASSISTANT_DEBUG_COMMAND_ID,
title: "$(lightbulb) Ask assistant to fix",
arguments: [arg],
},
});
}
return { lenses, dispose: () => undefined };
},
};
providerRef.current = provider;
monacoInstance.languages.registerCodeLensProvider(DS_LANGUAGE_NAME, provider);
monacoInstance.languages.registerCodeLensProvider("yaml", provider);
}
72 changes: 72 additions & 0 deletions src/components/panels/AskAssistantActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Sparkles } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";

import { buildCheckWatchPrompt, buildErrorPrompt } from "../../services/assistant/debugPrompts";
import { requestAssistantDebug } from "../../services/assistant/debugRequest";
import { LiveCheckItem } from "../../services/check";
import { DeveloperError } from "../../spicedb-common/protodefs/developer/v1/developer_pb";

/**
* AskAssistantFixAction is the per-error "Ask assistant to fix" button used in
* the Problems panel. Its parent renders it only when AI is enabled.
*/
export function AskAssistantFixAction({ error }: { error: DeveloperError }) {
const onClick = () =>
requestAssistantDebug(
buildErrorPrompt({
source: error.source,
line: error.line,
message: error.message,
context: error.context ?? "",
}),
"problems",
);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button size="xs" variant="ghost" onClick={onClick}>
<Sparkles />
Ask assistant to fix
</Button>
</TooltipTrigger>
<TooltipContent>Have the assistant debug and fix this error</TooltipContent>
</Tooltip>
);
}

/**
* AskAssistantDebugButton is the per-watch "Ask assistant to debug" icon button
* used in the Watches panel. Its parent renders it only when AI is enabled and
* the watch is in a non-passing state (isDebuggableWatchStatus).
*/
export function AskAssistantDebugButton({ item }: { item: LiveCheckItem }) {
const onClick = () =>
requestAssistantDebug(
buildCheckWatchPrompt({
object: item.object,
action: item.action,
subject: item.subject,
context: item.context,
status: item.status,
errorMessage: item.errorMessage,
}),
"watches",
);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
aria-label="Ask assistant to debug"
onClick={onClick}
>
<Sparkles />
</Button>
</TooltipTrigger>
<TooltipContent>Have the assistant debug and fix this check</TooltipContent>
</Tooltip>
);
}
4 changes: 4 additions & 0 deletions src/components/panels/assistant/AssistantPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { type DisplayMessage, useAssistantStore } from "../../../services/assistant/store";
import type { HistoryRecorder } from "../../../services/assistant/types";
import { useAssistantController } from "../../../services/assistant/useAssistantController";
import { usePendingPromptConsumer } from "../../../services/assistant/usePendingPrompt";
import type { DataStore } from "../../../services/datastore";
import { useHistoryStore } from "../../../services/history/historyStore";
import { restoreRevision } from "../../../services/history/useHistoryRecorder";
Expand All @@ -23,6 +24,9 @@ export function AssistantPanel({
history: HistoryRecorder;
}) {
const { submit, stop } = useAssistantController(services, datastore, history);
// Drain any externally-requested debug prompt (inline "Ask assistant to fix"
// affordances) into a turn now that the panel is mounted.
usePendingPromptConsumer(submit);
const display = useAssistantStore((s) => s.display);
const status = useAssistantStore((s) => s.status);
const reset = useAssistantStore((s) => s.reset);
Expand Down
22 changes: 19 additions & 3 deletions src/components/panels/problems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { cn } from "@/lib/utils";

import { assertionStringToCheckWatch, LiveCheckService } from "../../services/check";
import AppConfig from "../../services/configservice";
import { Services } from "../../services/services";
import {
DeveloperError,
Expand All @@ -15,6 +16,7 @@ import {
import { DocumentLink } from "../document-link";
import { useDrawerStore } from "../drawer/state";

import { AskAssistantFixAction } from "./AskAssistantActions";
import { DeveloperSourceDisplay, DeveloperWarningSourceDisplay } from "./errordisplays";

interface ProblemsPanelProps {
Expand All @@ -26,6 +28,7 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) {
const warnings = services.problemService.warnings;
const invalidRels = services.problemService.invalidRelationships;
const allValidationErrors = services.problemService.validationErrors;
const aiEnabled = AppConfig().aiEnabled;

const schemaErrors = requestErrors.filter((e) => e.source === DeveloperError_Source.SCHEMA);
const relationshipRequestErrors = requestErrors.filter(
Expand All @@ -46,7 +49,11 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) {
<div className="p-2 space-y-1">
<Group title="Schema" errorCount={schemaErrors.length} warningCount={warnings.length}>
{schemaErrors.map((de, i) => (
<ErrorRow key={`s${i}`} error={de} />
<ErrorRow
key={`s${i}`}
error={de}
action={aiEnabled ? <AskAssistantFixAction error={de} /> : undefined}
/>
))}
{warnings.map((dw, i) => (
<WarningRow key={`w${i}`} warning={dw} />
Expand Down Expand Up @@ -78,7 +85,12 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) {
<ErrorRow
key={`a${i}`}
error={de}
action={<AddCheckWatchAction error={de} liveCheckService={services.liveCheckService} />}
action={
<div className="flex items-center gap-1">
<AddCheckWatchAction error={de} liveCheckService={services.liveCheckService} />
{aiEnabled && <AskAssistantFixAction error={de} />}
</div>
}
/>
))}
</Group>
Expand All @@ -97,7 +109,11 @@ export function ProblemsPanel({ services }: ProblemsPanelProps) {
}
>
{validationErrors.map((ve, i) => (
<ErrorRow key={`v${i}`} error={ve} />
<ErrorRow
key={`v${i}`}
error={ve}
action={aiEnabled ? <AskAssistantFixAction error={ve} /> : undefined}
/>
))}
</Group>
</div>
Expand Down
21 changes: 17 additions & 4 deletions src/components/panels/watches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ import {
TableRow,
} from "@/components/ui/table";

import { isDebuggableWatchStatus } from "../../services/assistant/debugPrompts";
import {
LiveCheckItem,
LiveCheckItemStatus,
LiveCheckService,
LiveCheckStatus,
} from "../../services/check";
import AppConfig from "../../services/configservice";
import { DataStore, DataStoreItemKind } from "../../services/datastore";
import { LocalParseService } from "../../services/localparse";
import { Services } from "../../services/services";
Expand All @@ -42,6 +44,8 @@ import { RelationTuple as Relationship } from "../../spicedb-common/protodefs/co
import { CheckDebugTraceView } from "../CheckDebugTraceView";
import { Alert, AlertTitle, AlertDescription } from "../ui/alert";

import { AskAssistantDebugButton } from "./AskAssistantActions";

interface WatchesPanelProps {
services: Services;
datastore: DataStore;
Expand Down Expand Up @@ -315,10 +319,19 @@ function LiveCheckRow(props: LiveCheckRowProps) {
className="font-mono placeholder:text-muted-foreground/50"
/>
</TableCell>
<TableCell className="w-8">
<Button size="icon-sm" variant="ghost" onClick={() => liveCheckService.removeItem(item)}>
<Trash2 />
</Button>
<TableCell className="w-auto whitespace-nowrap">
<div className="flex items-center justify-end gap-1">
{AppConfig().aiEnabled && isDebuggableWatchStatus(item.status) && (
<AskAssistantDebugButton item={item} />
)}
<Button
size="icon-sm"
variant="ghost"
onClick={() => liveCheckService.removeItem(item)}
>
<Trash2 />
</Button>
</div>
</TableCell>
</TableRow>
{item.debugInformation !== undefined && isExpanded && (
Expand Down
Loading
Loading