Files
kandev/apps/web/components/settings/profile-edit/script-editor.tsx
T
João Salavisa ac6d8eb3be feat: resolve @prompt references in workflow prompts (#1865)
* feat: resolve @prompt_name saved-prompt references in workflow step prompts

Workflow step prompts, the GitHub review-watch task prompt, and the CI
auto-fix prompt now resolve "@name" references to saved custom prompts
(Settings > Prompts) at invocation time, appending the referenced
content as hidden context while keeping the mention visible - matching
how chat messages and message_task already resolve @mentions. Editing
a saved prompt now propagates to every workflow step that references
it without needing to update each step individually.

Adds a Monaco "@" completion provider to the step-prompt and
review-watch editors, alongside the existing "{{placeholder}}"
provider.

* fix: address PR review findings on prompt-reference expansion

- Make AppendReferenceExpansions idempotent: a prompt that already
  contains a previously-appended expansion block is returned unchanged,
  since the block's own "### @name" headers would otherwise be
  re-scanned as fresh mentions on a second call.
- Skip "@name" expansion entirely for CLI-passthrough sessions: their
  prompts are written raw to a PTY with no <kandev-system> stripping,
  so the hidden block would otherwise leak into the terminal verbatim.

* fix: fail safe on passthrough lookup error in startTask

A session-lookup error while resolving isPassthrough for the Kandev
MCP wrap and prompt-reference expansion previously defaulted to
false (non-passthrough), risking a hidden <kandev-system> block
leaking into a real passthrough session's PTY on a transient error.
Extracted resolveIsPassthroughForLaunch, which now fails safe by
defaulting to true (skip injection) and logs a warning.

* fix: scope expansion idempotency marker to system blocks

The idempotency guard added in a prior commit checked for the
expansion marker anywhere in the prompt, which could falsely
suppress a legitimate first-time expansion if caller-controlled
text happened to contain that literal phrase. Add
sysprompt.HasSystemMarker, mirroring the existing HasKandevContext
pattern, so the marker is only recognized inside a formatter-
produced <kandev-system> block. Also strengthens a passthrough test
assertion that didn't actually distinguish the snapshot path from
the fail-safe default.

* fix: match exact expansion-block prefix in idempotency guard

The prior scoping fix accepted the marker anywhere inside any
<kandev-system> block, which could still false-positive against an
unrelated system block (e.g. a plan-mode prefix or MCP context
wrap) that happens to mention the marker phrase without it being
the block's own content. Check for the exact literal prefix this
package's formatter produces instead: the opening tag immediately
followed by the marker, with nothing in between.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-07-23 13:14:54 +01:00

229 lines
7.0 KiB
TypeScript

"use client";
import { useEffect, useRef, useCallback } from "react";
import dynamic from "@/lib/routing/client-dynamic";
import type { BeforeMount, OnMount } from "@monaco-editor/react";
import { KANDEV_MONACO_DARK } from "@/lib/theme/editor-theme";
import type { PromptReference } from "@/lib/prompts/expand-prompt-references";
import {
createPlaceholderCompletionProvider,
createPromptMentionCompletionProvider,
type ScriptPlaceholder,
} from "./script-editor-completions";
const MonacoEditor = dynamic(() => import("@monaco-editor/react").then((m) => m.default), {
ssr: false,
loading: () => (
<div className="h-full w-full flex items-center justify-center text-muted-foreground text-xs border rounded-md bg-muted/20">
Loading editor...
</div>
),
});
type Monaco = typeof import("monaco-editor");
export type CompletionProviderFactory = (
monaco: Monaco,
) => import("monaco-editor").languages.CompletionItemProvider;
// Per-language, per-slot singletons so a markdown editor and a shell editor
// can coexist without their providers fighting over the same global slot.
// "primary" holds the placeholder/custom completion provider; "mention"
// holds the @prompt-name mention provider — both can be registered at once.
type ProviderSlot = "primary" | "mention";
const disposablesByKey: Map<string, { dispose: () => void }> = new Map();
const instanceCountsByLanguage: Map<string, number> = new Map();
function slotKey(language: string, slot: ProviderSlot) {
return `${language}:${slot}`;
}
function disposeFor(language: string, slot: ProviderSlot) {
const key = slotKey(language, slot);
const existing = disposablesByKey.get(key);
if (existing) {
existing.dispose();
disposablesByKey.delete(key);
}
}
function swapProvider(
monaco: Monaco,
language: string,
slot: ProviderSlot,
factory: CompletionProviderFactory,
) {
disposeFor(language, slot);
disposablesByKey.set(
slotKey(language, slot),
monaco.languages.registerCompletionItemProvider(language, factory(monaco)),
);
}
function unregisterProvider(language: string) {
const next = (instanceCountsByLanguage.get(language) ?? 1) - 1;
if (next <= 0) {
disposeFor(language, "primary");
disposeFor(language, "mention");
instanceCountsByLanguage.delete(language);
return;
}
instanceCountsByLanguage.set(language, next);
}
/** Compute editor height from content lines (min 80px, max 400px). */
export function computeEditorHeight(value: string, minLines = 3): string {
const lineCount = Math.max((value || "").split("\n").length, minLines);
const lineHeight = 19;
const padding = 16;
const height = Math.min(Math.max(lineCount * lineHeight + padding, 80), 400);
return `${height}px`;
}
type ScriptEditorProps = {
value: string;
onChange: (value: string) => void;
language?: string;
height?: string | number;
placeholders?: ScriptPlaceholder[];
executorType?: string;
/**
* Optional custom completion provider factory. Takes precedence over
* `placeholders` — use for non-placeholder languages (e.g. markdown
* file-path autocomplete).
*/
completionProvider?: CompletionProviderFactory;
/**
* Saved custom prompts to suggest as `@name` mentions. Registered
* alongside the placeholder/custom completion provider (independent
* trigger character, so both can be active at once).
*/
mentionPrompts?: PromptReference[];
readOnly?: boolean;
lineNumbers?: "on" | "off";
};
export function ScriptEditor({
value,
onChange,
language = "shell",
height = "300px",
placeholders,
executorType,
completionProvider,
mentionPrompts,
readOnly = false,
lineNumbers = "on",
}: ScriptEditorProps) {
const mountedRef = useRef(false);
const monacoRef = useRef<Monaco | null>(null);
useEffect(() => {
return () => {
if (mountedRef.current) {
unregisterProvider(language);
mountedRef.current = false;
}
};
}, [language]);
const ensureProviderRegistered = useCallback(
(monaco: Monaco) => {
const factory = resolveFactory(completionProvider, placeholders, executorType);
const mentionFactory = resolveMentionFactory(mentionPrompts);
if (!factory && !mentionFactory) {
// Nothing to register. If a previous render had registered providers
// for this instance, dispose them now so stale suggestions (e.g. a
// deleted prompt list) don't linger while the editor stays mounted.
if (mountedRef.current) {
disposeFor(language, "primary");
disposeFor(language, "mention");
}
return;
}
if (!mountedRef.current) {
mountedRef.current = true;
instanceCountsByLanguage.set(language, (instanceCountsByLanguage.get(language) ?? 0) + 1);
}
if (factory) {
swapProvider(monaco, language, "primary", factory);
} else {
disposeFor(language, "primary");
}
if (mentionFactory) {
swapProvider(monaco, language, "mention", mentionFactory);
} else {
disposeFor(language, "mention");
}
},
[completionProvider, placeholders, executorType, mentionPrompts, language],
);
useEffect(() => {
if (monacoRef.current) ensureProviderRegistered(monacoRef.current);
}, [ensureProviderRegistered]);
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
monaco.editor.defineTheme("kandev-dark", KANDEV_MONACO_DARK);
}, []);
const handleMount: OnMount = useCallback(
(_editor, monaco) => {
monacoRef.current = monaco;
ensureProviderRegistered(monaco);
},
[ensureProviderRegistered],
);
return (
<MonacoEditor
height={height}
language={language}
value={value}
onChange={(v) => onChange(v ?? "")}
beforeMount={handleBeforeMount}
onMount={handleMount}
theme="kandev-dark"
options={{
minimap: { enabled: false },
lineNumbers,
wordWrap: "on",
fontSize: 13,
scrollBeyondLastLine: false,
readOnly,
bracketPairColorization: { enabled: true },
padding: { top: 8, bottom: 8 },
renderLineHighlight: "none",
overviewRulerLanes: 0,
fixedOverflowWidgets: true,
wordBasedSuggestions: "off",
scrollbar: {
vertical: "auto",
horizontal: "auto",
alwaysConsumeMouseWheel: false,
},
}}
/>
);
}
function resolveFactory(
custom: CompletionProviderFactory | undefined,
placeholders: ScriptPlaceholder[] | undefined,
executorType: string | undefined,
): CompletionProviderFactory | null {
if (custom) return custom;
if (placeholders && placeholders.length > 0) {
return (monaco) => createPlaceholderCompletionProvider(monaco, placeholders, executorType);
}
return null;
}
function resolveMentionFactory(
mentionPrompts: PromptReference[] | undefined,
): CompletionProviderFactory | null {
if (!mentionPrompts || mentionPrompts.length === 0) return null;
return (monaco) => createPromptMentionCompletionProvider(monaco, mentionPrompts);
}