feat(settings): unify prompt editors (#2855)

This commit is contained in:
Carlos Florêncio
2026-08-21 16:40:58 +01:00
committed by GitHub
parent d99092e09c
commit 24462de07d
88 changed files with 3388 additions and 510 deletions
+5
View File
@@ -1740,6 +1740,11 @@ html[data-rendering-engine="webkit"] [data-webkit-safe-motion="true"][data-state
}
/* Monaco Editor - Peek view / references widget overrides */
.settings-prompt-editor .monaco-editor .suggest-widget {
/* Keep completion rows above the mobile settings save bar. */
z-index: 50;
}
.monaco-editor .peekview-widget .head {
box-sizing: border-box;
}
@@ -0,0 +1,39 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PromptSection } from "./prompt-section";
const promptEditor = vi.hoisted(() => vi.fn());
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div data-testid="automation-prompt-editor" />;
},
}));
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("PromptSection", () => {
it("uses shared prompt references and automation placeholders", () => {
render(
<PromptSection
value="Run {{pr.title}}"
isDirty
onChange={vi.fn()}
placeholders={[{ key: "pr.title", description: "PR title", example: "Ship it" }]}
/>,
);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
language: "plaintext",
isDirty: true,
placeholders: expect.arrayContaining([expect.objectContaining({ key: "pr.title" })]),
}),
);
});
});
@@ -3,7 +3,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@kandev/ui/label";
import { ScriptEditor } from "@/components/settings/profile-edit/script-editor";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import type { PlaceholderInfo } from "@/lib/types/automation";
import { toScriptPlaceholders } from "./automation-placeholders";
@@ -23,19 +23,17 @@ export function PromptSection({ value, isDirty, onChange, placeholders }: Prompt
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
{t("automations:instructionsLabel")}
</Label>
<div
className="rounded-md border border-transparent"
data-settings-dirty={isDirty}
data-settings-dirty-level="container"
>
<ScriptEditor
value={value}
onChange={onChange}
language="plaintext"
height="160px"
placeholders={scriptPlaceholders}
/>
</div>
<SettingsPromptEditor
value={value}
onChange={onChange}
language="plaintext"
height="160px"
placeholders={scriptPlaceholders}
promptReferences
isDirty={isDirty}
dirtyLevel="container"
testId="automation-prompt-editor"
/>
{placeholders.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium">{t("automations:availablePlaceholdersColon")}</p>
@@ -1,13 +1,20 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
const mocks = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() }));
const mocks = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), promptEditor: vi.fn() }));
vi.mock("@/lib/api/domains/azure-devops-api", () => ({
getAzureDevOpsWorkspaceSettings: mocks.get,
updateAzureDevOpsWorkspaceSettings: mocks.update,
}));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: ({ help, ...props }: { help?: ReactNode; [key: string]: unknown }) => {
mocks.promptEditor(props);
return help ?? null;
},
}));
import { AzureDevOpsQuickActionsSection } from "./azure-devops-quick-actions";
@@ -68,6 +75,12 @@ describe("AzureDevOpsQuickActionsSection", () => {
});
expect(screen.getByRole("button", { name: "Reset" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Edit prompt" }));
const props = mocks.promptEditor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(props.promptReferences).toBe(true);
expect((props.placeholders as Array<{ key: string }>).map((item) => item.key)).toEqual([
"url",
"title",
]);
// The hint is a `<Trans>` whose `<n>` indices address the JSX children
// positionally, so a reflow of those children silently reassembles the
// sentence into fragments. Assert the whole reconstructed hint, including
@@ -8,10 +8,7 @@ import { CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
import {
ACTION_PRESET_ICON_CHOICES,
@@ -39,8 +36,8 @@ type Translate = (key: string, values?: Record<string, unknown>) => string;
* A function rather than a module-scope constant: `t()` at module scope would
* freeze the descriptions at the boot locale. `key` is the placeholder token
* the backend substitutes and `example` is sample data — neither is copy.
* Memoize at every call site: ScriptEditor keys its completion-provider
* registration on array identity.
* Memoize at every call site so the shared editor does not rebuild the
* completion provider on every keystroke.
*/
function actionPromptPlaceholders(t: Translate): ScriptPlaceholder[] {
return [
@@ -166,36 +163,35 @@ function ActionPromptPanel({
onPatch: (patch: Partial<AzureDevOpsActionPreset>) => void;
}) {
const { t } = useTranslation();
// ScriptEditor keys its completion-provider registration on array identity, so
// a fresh array per render would re-register on every keystroke.
const placeholders = useMemo(() => actionPromptPlaceholders(t), [t]);
return (
<div className="space-y-1 px-3 pb-3 sm:px-2 sm:pb-2">
<div
className="overflow-hidden rounded-md border"
data-settings-dirty={action.promptTemplate !== baseline?.promptTemplate}
data-settings-dirty-level="container"
>
<ScriptEditor
value={action.promptTemplate}
onChange={(promptTemplate) => onPatch({ promptTemplate })}
language="markdown"
height={computeEditorHeight(action.promptTemplate)}
lineNumbers="off"
placeholders={placeholders}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
{/* The three tokens are prompt syntax, passed as values so neither
i18next interpolation nor the pseudo-locale rewrites them. */}
<Trans
i18nKey="azuredevops:promptPlaceholdersHint"
values={{ open: PROMPT_OPEN_BRACES, url: PROMPT_URL_TOKEN, title: PROMPT_TITLE_TOKEN }}
>
Type {PROMPT_OPEN_BRACES} to see available placeholders. <code>{PROMPT_URL_TOKEN}</code>{" "}
and <code>{PROMPT_TITLE_TOKEN}</code> are substituted when the action runs.
</Trans>
</p>
<SettingsPromptEditor
value={action.promptTemplate}
onChange={(promptTemplate) => onPatch({ promptTemplate })}
placeholders={placeholders}
promptReferences
isDirty={action.promptTemplate !== baseline?.promptTemplate}
testId={`azure-action-prompt-editor-${action.id}`}
help={
<p className="text-[11px] text-muted-foreground/60">
{/* The three tokens are prompt syntax, passed as values so neither
i18next interpolation nor the pseudo-locale rewrites them. */}
<Trans
i18nKey="azuredevops:promptPlaceholdersHint"
values={{
open: PROMPT_OPEN_BRACES,
url: PROMPT_URL_TOKEN,
title: PROMPT_TITLE_TOKEN,
}}
>
Type {PROMPT_OPEN_BRACES} to see available placeholders.{" "}
<code>{PROMPT_URL_TOKEN}</code> and <code>{PROMPT_TITLE_TOKEN}</code> are substituted
when the action runs.
</Trans>
</p>
}
/>
</div>
);
}
@@ -0,0 +1,102 @@
import type { TFunction } from "i18next";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
export function azureWorkItemWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
return [
{
key: "work_item.url",
description: t("azuredevops:placeholderUrlDescription"),
example: "https://dev.azure.com/acme/Platform/_workitems/edit/42",
executor_types: [],
},
{
key: "work_item.title",
description: t("azuredevops:placeholderTitleDescription"),
example: "Fix login page crash",
executor_types: [],
},
{
key: "work_item.project",
description: t("azuredevops:placeholderProjectDescription"),
example: "Platform",
executor_types: [],
},
{
key: "work_item.id",
description: t("azuredevops:placeholderIdDescription"),
example: "42",
executor_types: [],
},
{
key: "work_item.description",
description: t("azuredevops:placeholderDescriptionBody"),
example: "When clicking login...",
executor_types: [],
},
{
key: "work_item.state",
description: t("azuredevops:placeholderStateDescription"),
example: "Active",
executor_types: [],
},
{
key: "work_item.type",
description: t("azuredevops:placeholderTypeDescription"),
example: "Bug",
executor_types: [],
},
];
}
export function azurePullRequestWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
return [
{
key: "pull_request.url",
description: t("azuredevops:placeholderUrlDescription"),
example: "https://dev.azure.com/acme/Platform/_git/app/pullrequest/42",
executor_types: [],
},
{
key: "pull_request.title",
description: t("azuredevops:placeholderTitleDescription"),
example: "Fix login page crash",
executor_types: [],
},
{
key: "pull_request.project",
description: t("azuredevops:placeholderProjectDescription"),
example: "Platform",
executor_types: [],
},
{
key: "pull_request.id",
description: t("azuredevops:placeholderIdDescription"),
example: "42",
executor_types: [],
},
{
key: "pull_request.description",
description: t("azuredevops:placeholderDescriptionBody"),
example: "When clicking login...",
executor_types: [],
},
{
key: "pull_request.state",
description: t("azuredevops:placeholderStateDescription"),
example: "Active",
executor_types: [],
},
{
key: "pull_request.source_branch",
description: t("azuredevops:placeholderSourceBranchDescription"),
example: "feature/login",
executor_types: [],
},
{
key: "pull_request.target_branch",
description: t("azuredevops:placeholderTargetBranchDescription"),
example: "main",
executor_types: [],
},
];
}
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/lib/i18n";
import type {
@@ -9,11 +9,17 @@ import type {
const PR_CARD = "azure-pull-request-watch-pr-1";
const WI_CARD = "azure-work-item-watch-wi-1";
const mocks = vi.hoisted(() => ({ watches: vi.fn() }));
const mocks = vi.hoisted(() => ({ watches: vi.fn(), promptEditor: vi.fn() }));
vi.mock("@/hooks/domains/azure-devops/use-azure-devops-watches", () => ({
useAzureDevOpsWatches: mocks.watches,
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
mocks.promptEditor(props);
return <div data-testid={props.testId as string} />;
},
}));
import { AzureDevOpsWatchSettings } from "@/components/azure-devops/azure-devops-watch-settings";
@@ -116,4 +122,21 @@ describe("AzureDevOpsWatchSettings", () => {
render(<AzureDevOpsWatchSettings workspaceId="workspace-a" />);
expect(screen.getByTestId(PR_CARD).textContent).toContain("Śţàţũś: draft");
});
it("uses typed Azure DevOps placeholders with saved-prompt references", () => {
render(<AzureDevOpsWatchSettings workspaceId="workspace-a" />);
fireEvent.click(screen.getByTestId("azure-pull-request-watch-edit-pr-1"));
expect(mocks.promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "azure-pull-request-watch-prompt-editor",
placeholders: expect.arrayContaining([
expect.objectContaining({ key: "pull_request.url" }),
expect.objectContaining({ key: "pull_request.target_branch" }),
]),
}),
);
});
});
@@ -2,7 +2,7 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconPlayerPlay, IconPlus, IconRefresh, IconTrash, IconX } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
@@ -18,6 +18,11 @@ import { SettingsSection } from "@/components/settings/settings-section";
import { formatDateTime } from "@/lib/i18n/formats";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAzureDevOpsWatches } from "@/hooks/domains/azure-devops/use-azure-devops-watches";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import {
azurePullRequestWatchPlaceholders,
azureWorkItemWatchPlaceholders,
} from "./azure-devops-watch-placeholders";
import type {
AzureDevOpsCleanupPolicy,
AzureDevOpsPullRequestWatch,
@@ -296,6 +301,13 @@ function WatchEditor({
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const current = kind === "work-item" ? workItem : pullRequest;
const placeholders = useMemo(
() =>
kind === "work-item"
? azureWorkItemWatchPlaceholders(t)
: azurePullRequestWatchPlaceholders(t),
[kind, t],
);
const set = (key: string, value: string | number) => {
if (kind === "work-item") setWorkItem((previous) => ({ ...previous, [key]: value }));
else setPullRequest((previous) => ({ ...previous, [key]: value }));
@@ -470,10 +482,13 @@ function WatchEditor({
</div>
<div className="space-y-1.5">
<Label>{t("azuredevops:prompt")}</Label>
<textarea
className="min-h-24 w-full rounded-md border bg-background p-3 text-sm"
<SettingsPromptEditor
value={current.prompt}
onChange={(event) => set("prompt", event.target.value)}
onChange={(value) => set("prompt", value)}
placeholders={placeholders}
promptReferences
ariaLabel={t("azuredevops:prompt")}
testId={`azure-${kind}-watch-prompt-editor`}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
@@ -0,0 +1,70 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
const mocks = vi.hoisted(() => ({
usePresets: vi.fn(),
promptEditor: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/hooks/domains/github/use-github-action-presets", () => ({
useGitHubActionPresets: mocks.usePresets,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/components/settings/profile-edit/script-editor", () => ({
ScriptEditor: () => null,
computeEditorHeight: () => "80px",
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: unknown) => {
mocks.promptEditor(props);
return null;
},
}));
import { ActionPresetsSection } from "./action-presets-section";
describe("ActionPresetsSection prompt editor", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("uses the shared editor with GitHub action placeholders and saved prompts", () => {
mocks.usePresets.mockReturnValue({
presets: {
pr: [
{
id: "review",
label: "Review",
hint: "Review it",
icon: "eye",
prompt_template: "Review {{title}}",
},
],
issue: [],
},
save: vi.fn(),
reset: vi.fn(),
loading: false,
});
render(
<SettingsSaveProvider>
<ActionPresetsSection workspaceId="workspace-1" />
</SettingsSaveProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Edit prompt" }));
const props = mocks.promptEditor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(props.promptReferences).toBe(true);
expect((props.placeholders as Array<{ key: string }>).map((item) => item.key)).toEqual([
"url",
"title",
]);
expect(props.isDirty).toBe(false);
});
});
@@ -19,10 +19,7 @@ import {
iconForPresetKey,
} from "@/components/github/my-github/action-presets";
import type { GitHubActionPreset } from "@/lib/types/github";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -187,34 +184,30 @@ function PresetPromptEditor({
const { t } = useTranslation();
const placeholders = useMemo(() => actionPromptPlaceholders(t), [t]);
return (
<div className="px-2 pb-2 space-y-1">
<div
className="rounded-md border overflow-hidden"
data-settings-dirty={preset.prompt_template !== baseline?.prompt_template}
data-settings-dirty-level="container"
>
<ScriptEditor
value={preset.prompt_template}
onChange={(v) => onPatch({ prompt_template: v })}
language="markdown"
height={computeEditorHeight(preset.prompt_template)}
lineNumbers="off"
placeholders={placeholders}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
{/* The three `{{…}}` tokens are passed as values, never written into the
catalog, where i18next would interpolate them away. */}
<Trans
i18nKey="github:actionPromptPlaceholderHelp"
values={{ token: "{{", url: "{{url}}", title: "{{title}}" }}
>
Type {"{{token}}"} to see available placeholders.{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{url}}"}</code> and{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{title}}"}</code> are
substituted when the action runs.
</Trans>
</p>
<div className="px-2 pb-2">
<SettingsPromptEditor
value={preset.prompt_template}
onChange={(v) => onPatch({ prompt_template: v })}
placeholders={placeholders}
promptReferences
isDirty={preset.prompt_template !== baseline?.prompt_template}
testId={`github-action-prompt-editor-${preset.id}`}
help={
<p className="text-[11px] text-muted-foreground/60">
{/* The three `{{…}}` tokens are passed as values, never written into the
catalog, where i18next would interpolate them away. */}
<Trans
i18nKey="github:actionPromptPlaceholderHelp"
values={{ token: "{{", url: "{{url}}", title: "{{title}}" }}
>
Type {"{{token}}"} to see available placeholders.{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{url}}"}</code> and{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{title}}"}</code> are
substituted when the action runs.
</Trans>
</p>
}
/>
</div>
);
}
@@ -0,0 +1,59 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueWatchDialog } from "./issue-watch-dialog";
const promptEditor = vi.hoisted(() => vi.fn());
const store = {
workspaces: { activeId: "ws-1", items: [] },
workflows: { items: [{ id: "workflow", name: "Delivery", hidden: false }] },
agentProfiles: { items: [] },
executors: { items: [] },
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (value: typeof store) => unknown) => selector(store),
}));
vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ useSettingsData: vi.fn() }));
vi.mock("@/hooks/use-workflows", () => ({ useWorkflows: vi.fn() }));
vi.mock("@/hooks/use-workflow-steps", () => ({
useWorkflowSteps: () => ({ steps: [{ id: "step", name: "Implement" }], loading: false }),
stepPlaceholder: () => "Select step",
}));
vi.mock("@/components/github/repo-filter-selector", () => ({
RepoFilterSelector: () => <div>Repository filters</div>,
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div data-testid="issue-watch-prompt-editor" />;
},
}));
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("IssueWatchDialog", () => {
it("uses the shared editor with GitHub issue placeholders and saved prompts", () => {
render(
<IssueWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId="ws-1"
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "github-issue-watch-prompt-editor",
placeholders: expect.arrayContaining([expect.objectContaining({ key: "issue.title" })]),
}),
);
});
});
@@ -22,10 +22,7 @@ import { useAppStore } from "@/components/state-provider";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { useWorkflows } from "@/hooks/use-workflows";
import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import {
issueWatchPlaceholders,
DEFAULT_ISSUE_WATCH_PROMPT,
@@ -332,21 +329,21 @@ function IssueAutomationFields({
<Label>{t("github:taskPrompt")}</Label>
<PlaceholdersHelp />
</div>
<p className="text-xs text-muted-foreground">
{/* `{{` is passed as a value so it never reaches the catalog, where
i18next would read it as an interpolation opener. */}
{t("github:issueWatchPromptHelp", { token: "{{" })}
</p>
<div className="rounded-md border border-border overflow-hidden">
<ScriptEditor
value={form.prompt}
onChange={(v) => setForm((prev) => ({ ...prev, prompt: v }))}
language="markdown"
height={computeEditorHeight(form.prompt)}
lineNumbers="off"
placeholders={placeholders}
/>
</div>
<SettingsPromptEditor
value={form.prompt}
onChange={(v) => setForm((prev) => ({ ...prev, prompt: v }))}
placeholders={placeholders}
promptReferences
ariaLabel={t("github:taskPrompt")}
testId="github-issue-watch-prompt-editor"
help={
<p className="text-xs text-muted-foreground">
{/* `{{` is passed as a value so it never reaches the catalog, where
i18next would read it as an interpolation opener. */}
{t("github:issueWatchPromptHelp", { token: "{{" })}
</p>
}
/>
</div>
</>
);
@@ -2,6 +2,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReviewWatchDialog } from "./review-watch-dialog";
const promptEditor = vi.hoisted(() => vi.fn());
const store = {
workspaces: { activeId: "ws-1", items: [] },
workflows: { items: [{ id: "workflow", name: "Delivery", hidden: false }] },
@@ -22,8 +24,17 @@ vi.mock("@/hooks/use-workflow-steps", () => ({
vi.mock("@/components/github/repo-filter-selector", () => ({
RepoFilterSelector: () => <div>Repository filters</div>,
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div data-testid="review-watch-prompt-editor" />;
},
}));
afterEach(cleanup);
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("ReviewWatchDialog", () => {
it("explains lifecycle retention for Auto and the Always delete override", () => {
@@ -46,4 +57,25 @@ describe("ReviewWatchDialog", () => {
fireEvent.click(screen.getByRole("option", { name: "Always delete" }));
expect(screen.getByText(/Always delete.*overrides retention/i)).not.toBeNull();
});
it("uses the shared editor with placeholders and saved-prompt references", () => {
render(
<ReviewWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId="ws-1"
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "github-review-watch-prompt-editor",
placeholders: expect.arrayContaining([expect.objectContaining({ key: "pr.title" })]),
}),
);
});
});
@@ -3,12 +3,8 @@
import { IconInfoCircle } from "@tabler/icons-react";
import { Label } from "@kandev/ui/label";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import { reviewWatchPlaceholders } from "@/components/github/review-watch-placeholders";
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
import { Trans, useTranslation } from "react-i18next";
import { useMemo } from "react";
@@ -58,7 +54,6 @@ export function ReviewWatchPromptField({
onChange: (v: string) => void;
}) {
const { t } = useTranslation();
const { prompts } = useCustomPrompts();
const placeholders = useMemo(() => reviewWatchPlaceholders(t), [t]);
return (
<div className="space-y-1.5">
@@ -71,28 +66,27 @@ export function ReviewWatchPromptField({
catalog, where i18next would read `{{` as an interpolation opener. */}
{t("github:reviewWatchPromptHelp", { token: "{{", mention: "@" })}
</p>
<div className="rounded-md border border-border overflow-hidden">
<ScriptEditor
value={value}
onChange={onChange}
language="markdown"
height={computeEditorHeight(value)}
lineNumbers="off"
placeholders={placeholders}
mentionPrompts={prompts}
/>
</div>
<p className="text-xs text-muted-foreground/70">
<Trans
i18nKey="github:workflowStepPromptWrapHelp"
values={{ stepPrompt: STEP_PROMPT_EXAMPLE, finalPrompt: FINAL_PROMPT_EXAMPLE }}
>
The workflow step prompt wraps this prompt. For example, if the step prompt is{" "}
<code className="text-[10px] bg-muted px-1 rounded">{"{{stepPrompt}}"}</code>, the final
prompt becomes{" "}
<code className="text-[10px] bg-muted px-1 rounded">{"{{finalPrompt}}"}</code>
</Trans>
</p>
<SettingsPromptEditor
value={value}
onChange={onChange}
placeholders={placeholders}
promptReferences
ariaLabel={t("github:taskPrompt")}
testId="github-review-watch-prompt-editor"
help={
<p className="text-xs text-muted-foreground/70">
<Trans
i18nKey="github:workflowStepPromptWrapHelp"
values={{ stepPrompt: STEP_PROMPT_EXAMPLE, finalPrompt: FINAL_PROMPT_EXAMPLE }}
>
The workflow step prompt wraps this prompt. For example, if the step prompt is{" "}
<code className="text-[10px] bg-muted px-1 rounded">{"{{stepPrompt}}"}</code>, the
final prompt becomes{" "}
<code className="text-[10px] bg-muted px-1 rounded">{"{{finalPrompt}}"}</code>
</Trans>
</p>
}
/>
</div>
);
}
@@ -0,0 +1,64 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
const mocks = vi.hoisted(() => ({
usePresets: vi.fn(),
promptEditor: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/hooks/domains/gitlab/use-gitlab-action-presets", () => ({
useGitLabActionPresets: mocks.usePresets,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: unknown) => {
mocks.promptEditor(props);
return null;
},
}));
import { GitLabActionPresetsSection } from "./action-presets-section";
describe("GitLabActionPresetsSection prompt editor", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("uses the shared editor with GitLab action placeholders and saved prompts", () => {
mocks.usePresets.mockReturnValue({
presets: {
mr: [
{
id: "review",
label: "Review",
hint: "Review it",
icon: "eye",
prompt_template: "Review {{title}}",
},
],
issue: [],
},
loading: false,
update: vi.fn(),
reset: vi.fn(),
});
render(
<SettingsSaveProvider>
<GitLabActionPresetsSection workspaceId="workspace-1" />
</SettingsSaveProvider>,
);
const props = mocks.promptEditor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(props.promptReferences).toBe(true);
expect((props.placeholders as Array<{ key: string }>).map((item) => item.key)).toEqual([
"url",
"title",
]);
});
});
@@ -6,14 +6,32 @@ import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import { Textarea } from "@kandev/ui/textarea";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsSection } from "@/components/settings/settings-section";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { useToast } from "@/components/toast-provider";
import { useGitLabActionPresets } from "@/hooks/domains/gitlab/use-gitlab-action-presets";
import type { GitLabActionPreset, GitLabActionPresets } from "@/lib/types/gitlab";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
function actionPromptPlaceholders(t: TFunction) {
return [
{
key: "url",
description: t("gitlab:placeholderActionUrl"),
example: "https://gitlab.com/org/repo/-/merge_requests/42",
executor_types: [],
},
{
key: "title",
description: t("gitlab:placeholderActionTitle"),
example: "Fix login page crash",
executor_types: [],
},
];
}
// `label` is PERSISTED to workspace settings as part of `GitLabActionPreset`, and
// the row below is immediately editable, so it must stay locale-neutral: a preset
@@ -40,6 +58,7 @@ function PresetList({
addLabel: string;
}) {
const { t } = useTranslation();
const placeholders = useMemo(() => actionPromptPlaceholders(t), [t]);
const patch = (index: number, change: Partial<GitLabActionPreset>) =>
onChange(
presets.map((preset, current) => (current === index ? { ...preset, ...change } : preset)),
@@ -72,17 +91,13 @@ function PresetList({
<IconTrash className="h-4 w-4" />
</Button>
</div>
<Textarea
aria-label={t("gitlab:actionPrompt", { index: index + 1 })}
<SettingsPromptEditor
ariaLabel={t("gitlab:actionPrompt", { index: index + 1 })}
value={preset.prompt_template}
// The two `{{…}}` tokens are passed as values so they never reach the
// catalog, where i18next would interpolate them away.
placeholder={t("gitlab:promptUsingUrlAndTitle", {
url: "{{url}}",
title: "{{title}}",
})}
className="min-h-24 font-mono text-xs"
onChange={(event) => patch(index, { prompt_template: event.target.value })}
onChange={(value) => patch(index, { prompt_template: value })}
placeholders={placeholders}
promptReferences
testId={`gitlab-action-prompt-editor-${preset.id}`}
/>
</div>
))}
@@ -13,6 +13,7 @@ const workspaceId = "workspace-1";
const defaultHost = "https://gitlab.com";
const credentialsContributorId = "gitlab-credentials";
const appState = {
prompts: { items: [], loaded: true, loading: false },
gitlabReviewWatches: { items: [] as ReviewWatch[], loaded: false, loading: false },
gitlabIssueWatches: { items: [], loaded: false, loading: false },
gitlabActionPresets: {
@@ -50,6 +51,27 @@ vi.mock("@/components/settings/settings-save-provider", () => ({
useSettingsSaveContributor: vi.fn(),
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: ({
value,
onChange,
ariaLabel,
testId,
}: {
value: string;
onChange: (value: string) => void;
ariaLabel?: string;
testId?: string;
}) => (
<textarea
aria-label={ariaLabel}
data-testid={testId}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
vi.mock("@/lib/api/domains/gitlab-api", () => ({
clearGitLabToken: vi.fn(),
configureGitLabHost: vi.fn(),
@@ -2,6 +2,8 @@ import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueWatchDialog } from "./issue-watch-dialog";
const promptEditor = vi.hoisted(() => vi.fn());
const store = {
workspaces: { activeId: "ws-1" },
workflows: { items: [] },
@@ -21,8 +23,17 @@ vi.mock("@/hooks/use-workflow-steps", () => ({
vi.mock("@/components/watcher-repository-fields", () => ({
WatcherRepositoryFields: () => <div>Repository and base branch</div>,
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div aria-label={props.ariaLabel as string} data-testid={props.testId as string} />;
},
}));
afterEach(cleanup);
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("IssueWatchDialog", () => {
it("renders labels and GitLab query controls", () => {
@@ -39,4 +50,25 @@ describe("IssueWatchDialog", () => {
expect(screen.getByLabelText("Labels")).toBeTruthy();
expect(screen.getByLabelText("GitLab query parameters")).toBeTruthy();
});
it("uses GitLab issue placeholders and saved-prompt references", () => {
render(
<IssueWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId="ws-1"
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "issue-watch-prompt-editor",
placeholders: expect.arrayContaining([expect.objectContaining({ key: "issue.url" })]),
}),
);
});
});
@@ -1,7 +1,37 @@
export const GITLAB_ISSUE_WATCH_PLACEHOLDERS = [
"issue.url",
"issue.title",
"issue.project",
"issue.iid",
"issue.description",
];
import type { TFunction } from "i18next";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
export function gitlabIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
return [
{
key: "issue.url",
description: t("gitlab:placeholderActionUrl"),
example: "https://gitlab.com/group/project/-/issues/42",
executor_types: [],
},
{
key: "issue.title",
description: t("gitlab:placeholderActionTitle"),
example: "Fix login page crash",
executor_types: [],
},
{
key: "issue.project",
description: t("gitlab:placeholderProjectPath"),
example: "group/project",
executor_types: [],
},
{
key: "issue.iid",
description: t("gitlab:placeholderIssueIid"),
example: "42",
executor_types: [],
},
{
key: "issue.description",
description: t("gitlab:placeholderDescription"),
example: "When clicking login...",
executor_types: [],
},
];
}
@@ -2,6 +2,8 @@ import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReviewWatchDialog } from "./review-watch-dialog";
const promptEditor = vi.hoisted(() => vi.fn());
const store = {
workspaces: { activeId: "ws-1" },
workflows: { items: [{ id: "workflow", name: "Delivery", hidden: false }] },
@@ -21,8 +23,17 @@ vi.mock("@/hooks/use-workflow-steps", () => ({
vi.mock("@/components/watcher-repository-fields", () => ({
WatcherRepositoryFields: () => <div>Repository and base branch</div>,
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div aria-label={props.ariaLabel as string} data-testid={props.testId as string} />;
},
}));
afterEach(cleanup);
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("ReviewWatchDialog", () => {
it("renders the complete watch controls in a narrow-safe dialog", () => {
@@ -45,4 +56,25 @@ describe("ReviewWatchDialog", () => {
screen.getByText(/leave empty to match merge requests requesting your review/i),
).toBeTruthy();
});
it("uses GitLab merge request placeholders and saved-prompt references", () => {
render(
<ReviewWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId="ws-1"
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "review-watch-prompt-editor",
placeholders: expect.arrayContaining([expect.objectContaining({ key: "mr.url" })]),
}),
);
});
});
@@ -1,7 +1,37 @@
export const GITLAB_REVIEW_WATCH_PLACEHOLDERS = [
"mr.url",
"mr.title",
"mr.project",
"mr.iid",
"mr.description",
];
import type { TFunction } from "i18next";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
export function gitlabReviewWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
return [
{
key: "mr.url",
description: t("gitlab:placeholderActionUrl"),
example: "https://gitlab.com/group/project/-/merge_requests/42",
executor_types: [],
},
{
key: "mr.title",
description: t("gitlab:placeholderActionTitle"),
example: "Fix login page crash",
executor_types: [],
},
{
key: "mr.project",
description: t("gitlab:placeholderProjectPath"),
example: "group/project",
executor_types: [],
},
{
key: "mr.iid",
description: t("gitlab:placeholderMergeRequestIid"),
example: "42",
executor_types: [],
},
{
key: "mr.description",
description: t("gitlab:placeholderDescription"),
example: "When clicking login...",
executor_types: [],
},
];
}
+18 -5
View File
@@ -14,7 +14,6 @@ import {
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Textarea } from "@kandev/ui/textarea";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { WatcherRepositoryFields } from "@/components/watcher-repository-fields";
@@ -38,6 +37,9 @@ import {
} from "./watch-form";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import { gitlabIssueWatchPlaceholders } from "./issue-watch-placeholders";
import { gitlabReviewWatchPlaceholders } from "./review-watch-placeholders";
type Watch = ReviewWatch | IssueWatch;
type CreateRequest = CreateReviewWatchRequest | CreateIssueWatchRequest;
@@ -207,6 +209,10 @@ function AutomationFields({ kind, form, setForm }: FormFieldsProps) {
const { t } = useTranslation();
const stepDefaultLabel = t("common:useStepDefaultOption");
const data = useDialogData(form.workspaceId, form.workflowId);
const placeholders = useMemo(
() => (kind === "review" ? gitlabReviewWatchPlaceholders(t) : gitlabIssueWatchPlaceholders(t)),
[kind, t],
);
return (
<div className="space-y-4">
<SectionTitle>{t("gitlab:taskAutomation")}</SectionTitle>
@@ -271,11 +277,18 @@ function AutomationFields({ kind, form, setForm }: FormFieldsProps) {
<div className="space-y-1.5">
<Label htmlFor={`${kind}-watch-prompt`}>{t("gitlab:taskPrompt")}</Label>
<p className="text-xs text-muted-foreground">{t("gitlab:promptSentToTheSelectedAgent")}</p>
<Textarea
id={`${kind}-watch-prompt`}
<SettingsPromptEditor
value={form.prompt}
onChange={(event) => setForm((current) => ({ ...current, prompt: event.target.value }))}
rows={5}
onChange={(value) => setForm((current) => ({ ...current, prompt: value }))}
placeholders={placeholders}
promptReferences
ariaLabel={t("gitlab:taskPrompt")}
testId={`${kind}-watch-prompt-editor`}
help={
<p className="text-xs text-muted-foreground">
{t("gitlab:promptSentToTheSelectedAgent")}
</p>
}
/>
</div>
</div>
@@ -0,0 +1,59 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { JiraIssueWatchDialog } from "./jira-issue-watch-dialog";
const promptEditor = vi.hoisted(() => vi.fn());
const store = {
workspaces: { activeId: "ws-1", items: [{ id: "ws-1", name: "Workspace" }] },
workflows: { items: [{ id: "workflow", name: "Delivery", hidden: false }] },
agentProfiles: { items: [] },
executors: { items: [] },
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (value: typeof store) => unknown) => selector(store),
}));
vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ useSettingsData: vi.fn() }));
vi.mock("@/hooks/use-workflows", () => ({ useWorkflows: vi.fn() }));
vi.mock("@/hooks/use-workflow-steps", () => ({
useWorkflowSteps: () => ({ steps: [{ id: "step", name: "Implement" }], loading: false }),
stepPlaceholder: () => "Select step",
}));
vi.mock("@/components/watcher-repository-fields", () => ({
WatcherRepositoryFields: () => <div>Repository and base branch</div>,
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div aria-label={props.ariaLabel as string} data-testid={props.testId as string} />;
},
}));
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("JiraIssueWatchDialog", () => {
it("uses Jira placeholders with saved-prompt references", () => {
render(
<JiraIssueWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId="ws-1"
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "jira-issue-watch-prompt-editor",
placeholders: expect.arrayContaining([expect.objectContaining({ key: "issue.key" })]),
}),
);
});
});
@@ -23,10 +23,7 @@ import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { useWorkflows } from "@/hooks/use-workflows";
import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps";
import { searchJiraTickets } from "@/lib/api/domains/jira-api";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import {
jiraIssueWatchPlaceholders,
DEFAULT_JIRA_ISSUE_WATCH_PROMPT,
@@ -289,21 +286,21 @@ function PromptField({ value, onChange }: { value: string; onChange: (v: string)
<Label>{t("jira:taskPrompt")}</Label>
<PlaceholdersHelp />
</div>
<p className="text-xs text-muted-foreground">
{/* The `{{` token is passed as a value so it never reaches the catalog,
where i18next would interpolate it away. */}
{t("jira:promptFieldHelp", { token: "{{" })}
</p>
<div className="rounded-md border border-border overflow-hidden">
<ScriptEditor
value={value}
onChange={onChange}
language="markdown"
height={computeEditorHeight(value)}
lineNumbers="off"
placeholders={placeholders}
/>
</div>
<SettingsPromptEditor
value={value}
onChange={onChange}
placeholders={placeholders}
promptReferences
ariaLabel={t("jira:taskPrompt")}
testId="jira-issue-watch-prompt-editor"
help={
<p className="text-xs text-muted-foreground">
{/* The `{{` token is passed as a value so it never reaches the catalog,
where i18next would interpolate it away. */}
{t("jira:promptFieldHelp", { token: "{{" })}
</p>
}
/>
</div>
);
}
@@ -1,11 +1,16 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
import { useJiraTaskPresets } from "@/components/jira/my-jira/use-task-presets";
import { DEFAULT_JIRA_PRESETS, type JiraStoredPreset } from "@/components/jira/my-jira/presets";
import { TaskPresetsSection } from "./task-presets-section";
const mocks = vi.hoisted(() => ({ save: vi.fn(), reset: vi.fn(), toast: vi.fn() }));
const mocks = vi.hoisted(() => ({
save: vi.fn(),
reset: vi.fn(),
toast: vi.fn(),
promptEditor: vi.fn(),
}));
vi.mock("@/components/jira/my-jira/use-task-presets", () => ({
useJiraTaskPresets: vi.fn(),
@@ -17,6 +22,12 @@ vi.mock("@/components/settings/profile-edit/script-editor", () => ({
ScriptEditor: () => null,
computeEditorHeight: () => 100,
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: unknown) => {
mocks.promptEditor(props);
return null;
},
}));
const customPreset: JiraStoredPreset = {
id: "custom",
@@ -27,6 +38,38 @@ const customPreset: JiraStoredPreset = {
};
describe("TaskPresetsSection", () => {
afterEach(() => {
cleanup();
mocks.promptEditor.mockReset();
});
it("uses the shared editor with Jira action placeholders and saved prompts", () => {
vi.mocked(useJiraTaskPresets).mockReturnValue({
stored: [customPreset],
isCustomized: true,
taskPresets: [],
save: mocks.save,
reset: mocks.reset,
loaded: true,
});
render(
<SettingsSaveProvider>
<TaskPresetsSection />
</SettingsSaveProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Edit prompt" }));
const props = mocks.promptEditor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(props.promptReferences).toBe(true);
expect((props.placeholders as Array<{ key: string }>).map((item) => item.key)).toEqual([
"key",
"url",
"title",
"description",
]);
});
it("stages reset-to-default until the shared save action", async () => {
vi.mocked(useJiraTaskPresets).mockReturnValue({
stored: [customPreset],
@@ -16,10 +16,7 @@ import {
iconForPresetKey,
type JiraStoredPreset,
} from "@/components/jira/my-jira/presets";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -202,38 +199,38 @@ function PresetPromptEditor({
// array on every render would re-register the provider on every keystroke.
const placeholders = useMemo(() => jiraPromptPlaceholders(t), [t]);
return (
<div className="px-2 pb-2 space-y-1">
<div className="rounded-md border overflow-hidden" data-settings-dirty={isDirty}>
<ScriptEditor
value={preset.prompt_template}
onChange={(v) => onPatch({ prompt_template: v })}
language="markdown"
height={computeEditorHeight(preset.prompt_template)}
lineNumbers="off"
placeholders={placeholders}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
{/* The five `{{…}}` tokens are passed as values, never written into the
catalog, where i18next would interpolate them away. */}
<Trans
i18nKey="jira:presetPromptPlaceholderHelp"
values={{
token: "{{",
key: "{{key}}",
url: "{{url}}",
title: "{{title}}",
description: "{{description}}",
}}
>
Type {"{{token}}"} to see available placeholders.{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{key}}"}</code>,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{url}}"}</code>,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{title}}"}</code>, and{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{description}}"}</code> are
substituted when the action runs.
</Trans>
</p>
<div className="px-2 pb-2">
<SettingsPromptEditor
value={preset.prompt_template}
onChange={(v) => onPatch({ prompt_template: v })}
placeholders={placeholders}
promptReferences
isDirty={isDirty}
testId={`jira-task-prompt-editor-${preset.id}`}
help={
<p className="text-[11px] text-muted-foreground/60">
{/* The five `{{…}}` tokens are passed as values, never written into the
catalog, where i18next would interpolate them away. */}
<Trans
i18nKey="jira:presetPromptPlaceholderHelp"
values={{
token: "{{",
key: "{{key}}",
url: "{{url}}",
title: "{{title}}",
description: "{{description}}",
}}
>
Type {"{{token}}"} to see available placeholders.{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{key}}"}</code>,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{url}}"}</code>,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{title}}"}</code>, and{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{description}}"}</code>{" "}
are substituted when the action runs.
</Trans>
</p>
}
/>
</div>
);
}
@@ -0,0 +1,66 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LinearIssueWatchDialog } from "./linear-issue-watch-dialog";
const promptEditor = vi.hoisted(() => vi.fn());
const store = {
workspaces: { activeId: "ws-1", items: [{ id: "ws-1", name: "Workspace" }] },
workflows: { items: [{ id: "workflow", name: "Delivery", hidden: false }] },
agentProfiles: { items: [] },
executors: { items: [] },
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (value: typeof store) => unknown) => selector(store),
}));
vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ useSettingsData: vi.fn() }));
vi.mock("@/hooks/use-workflows", () => ({ useWorkflows: vi.fn() }));
vi.mock("@/hooks/use-workflow-steps", () => ({
useWorkflowSteps: () => ({ steps: [{ id: "step", name: "Implement" }], loading: false }),
stepPlaceholder: () => "Select step",
}));
vi.mock("@/components/watcher-repository-fields", () => ({
WatcherRepositoryFields: () => <div>Repository and base branch</div>,
}));
vi.mock("./linear-issue-watch-filter-fields", () => ({
FilterFields: () => null,
SelectField: ({ label }: { label: string }) => <div>{label}</div>,
}));
vi.mock("./linear-issue-watch-fields", () => ({
SettingsFields: () => null,
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div aria-label={props.ariaLabel as string} data-testid={props.testId as string} />;
},
}));
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("LinearIssueWatchDialog", () => {
it("uses Linear placeholders with saved-prompt references", () => {
render(
<LinearIssueWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId="ws-1"
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "linear-issue-watch-prompt-editor",
placeholders: expect.arrayContaining([expect.objectContaining({ key: "issue.url" })]),
}),
);
});
});
@@ -19,10 +19,7 @@ import { useAppStore } from "@/components/state-provider";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { useWorkflows } from "@/hooks/use-workflows";
import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import { SettingsFields } from "./linear-issue-watch-fields";
import { FilterFields, SelectField } from "./linear-issue-watch-filter-fields";
import { linearIssueWatchPlaceholders } from "./linear-issue-watch-placeholders";
@@ -108,21 +105,21 @@ function PromptField({ value, onChange }: { value: string; onChange: (v: string)
<Label>{t("linear:taskPrompt")}</Label>
<PlaceholdersHelp />
</div>
<p className="text-xs text-muted-foreground">
{/* The `{{` token is passed as a value so it never reaches the catalog,
where i18next would interpolate it away. */}
{t("linear:promptFieldHelp", { token: "{{" })}
</p>
<div className="rounded-md border border-border overflow-hidden">
<ScriptEditor
value={value}
onChange={onChange}
language="markdown"
height={computeEditorHeight(value)}
lineNumbers="off"
placeholders={placeholders}
/>
</div>
<SettingsPromptEditor
value={value}
onChange={onChange}
placeholders={placeholders}
promptReferences
ariaLabel={t("linear:taskPrompt")}
testId="linear-issue-watch-prompt-editor"
help={
<p className="text-xs text-muted-foreground">
{/* The `{{` token is passed as a value so it never reaches the catalog,
where i18next would interpolate it away. */}
{t("linear:promptFieldHelp", { token: "{{" })}
</p>
}
/>
</div>
);
}
@@ -10,6 +10,7 @@ import type * as SentryIssueWatchMultiselectModule from "./sentry-issue-watch-mu
import { SentryIssueWatchDialog } from "./sentry-issue-watch-dialog";
const { WORKSPACE_ID } = vi.hoisted(() => ({ WORKSPACE_ID: "ws-1" }));
const promptEditor = vi.hoisted(() => vi.fn());
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
@@ -27,9 +28,11 @@ vi.mock("@/hooks/use-workflow-steps", () => ({
stepPlaceholder: () => "Select workflow first",
}));
vi.mock("@/components/watcher-repository-fields", () => ({ WatcherRepositoryFields: () => null }));
vi.mock("@/components/settings/profile-edit/script-editor", () => ({
ScriptEditor: () => null,
computeEditorHeight: () => 0,
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div aria-label={props.ariaLabel as string} data-testid={props.testId as string} />;
},
}));
vi.mock("./sentry-issue-watch-multiselect", async (importOriginal) => {
const actual = await importOriginal<typeof SentryIssueWatchMultiselectModule>();
@@ -124,6 +127,29 @@ beforeEach(() => {
});
describe("SentryIssueWatchDialog", () => {
it("uses Sentry placeholders with saved-prompt references", async () => {
render(
<SentryIssueWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId={WORKSPACE_ID}
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
await waitFor(() => {
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "sentry-issue-watch-prompt-editor",
placeholders: expect.arrayContaining([expect.objectContaining({ key: "issue.url" })]),
}),
);
});
});
it("removes the previous instance's org and project choices before the new lookup resolves", async () => {
render(
<SentryIssueWatchDialog
@@ -155,7 +181,9 @@ describe("SentryIssueWatchDialog", () => {
fireEvent.click(selectTrigger("Project slug"));
expect(screen.queryByRole("option", { name: FRONTEND_OPTION })).toBeNull();
});
});
describe("SentryIssueWatchDialog project selection", () => {
it("allows selecting multiple projects for a single Sentry watcher", async () => {
vi.mocked(listSentryProjects).mockResolvedValue({
projects: [
@@ -20,10 +20,7 @@ import { useAppStore } from "@/components/state-provider";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { useWorkflows } from "@/hooks/use-workflows";
import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import { listSentryInstances, listSentryOrganizations } from "@/lib/api/domains/sentry-api";
import { WatcherRepositoryFields } from "@/components/watcher-repository-fields";
import { clearWorkspaceScopedForm } from "@/lib/watcher-repository-default";
@@ -124,21 +121,21 @@ function PromptField({ value, onChange }: { value: string; onChange: (v: string)
<Label>{t("sentry:taskPrompt")}</Label>
<PlaceholdersHelp placeholders={placeholders} />
</div>
<p className="text-xs text-muted-foreground">
{/* The `{{` token is passed as a value so it never reaches the catalog,
where i18next would interpolate it away. */}
{t("sentry:promptFieldHelp", { token: "{{" })}
</p>
<div className="rounded-md border border-border overflow-hidden">
<ScriptEditor
value={value}
onChange={onChange}
language="markdown"
height={computeEditorHeight(value)}
lineNumbers="off"
placeholders={placeholders}
/>
</div>
<SettingsPromptEditor
value={value}
onChange={onChange}
placeholders={placeholders}
promptReferences
ariaLabel={t("sentry:taskPrompt")}
testId="sentry-issue-watch-prompt-editor"
help={
<p className="text-xs text-muted-foreground">
{/* The `{{` token is passed as a value so it never reaches the catalog,
where i18next would interpolate it away. */}
{t("sentry:promptFieldHelp", { token: "{{" })}
</p>
}
/>
</div>
);
}
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import { createPromptMentionCompletionProvider } from "./script-editor-completions";
import {
createPlaceholderCompletionProvider,
createPromptMentionCompletionProvider,
type ScriptPlaceholder,
} from "./script-editor-completions";
import type { PromptReference } from "@/lib/prompts/expand-prompt-references";
import type { editor, languages, Position } from "monaco-editor";
const monacoStub = {
languages: {
CompletionItemKind: { Reference: 17 },
CompletionItemKind: { Reference: 17, Variable: 18 },
},
} as unknown as typeof import("monaco-editor");
@@ -30,15 +34,30 @@ function complete(
return result as languages.CompletionList;
}
function completePlaceholder(
placeholders: ScriptPlaceholder[],
line: string,
column: number,
): languages.CompletionList {
const provider = createPlaceholderCompletionProvider(monacoStub, placeholders);
const result = provider.provideCompletionItems(
modelForLine(line),
{ lineNumber: 1, column } as unknown as Position,
{} as languages.CompletionContext,
{} as import("monaco-editor").CancellationToken,
);
return result as languages.CompletionList;
}
const prompts: PromptReference[] = [
{ id: "1", name: "review", content: "Review this code carefully for bugs." },
{ id: "2", name: "release-notes", content: "Write release notes." },
];
describe("createPromptMentionCompletionProvider", () => {
it("has @ as its only trigger character", () => {
it("triggers after @ and spaces so multi-word names can be filtered", () => {
const provider = createPromptMentionCompletionProvider(monacoStub, prompts);
expect(provider.triggerCharacters).toEqual(["@"]);
expect(provider.triggerCharacters).toEqual(["@", " "]);
});
it("suggests prompts when @ is at the start of a line", () => {
@@ -74,4 +93,53 @@ describe("createPromptMentionCompletionProvider", () => {
const { suggestions } = complete(prompts, "@", 2);
expect(suggestions[0].detail).toBe("Review this code carefully for bugs.");
});
it("uses the prompt name for filtering while displaying the @-prefixed label", () => {
const { suggestions } = complete(
[{ id: "1", name: "changes-walkthrough", content: "Walk through changes." }],
"@c",
3,
);
expect(suggestions[0].label).toBe("@changes-walkthrough");
expect(suggestions[0].filterText).toBe("changes-walkthrough");
});
it("keeps suggestions available while filtering a multi-word prompt name", () => {
const { suggestions } = complete(
[{ id: "1", name: "Daily Summary", content: "Summarize the daily work." }],
"@Daily ",
8,
);
expect(suggestions).toHaveLength(1);
expect(suggestions[0].label).toBe("@Daily Summary");
expect(suggestions[0].filterText).toBe("Daily Summary");
expect(suggestions[0].range).toMatchObject({ startColumn: 2, endColumn: 8 });
});
});
describe("createPlaceholderCompletionProvider", () => {
const placeholders: ScriptPlaceholder[] = [
{ key: "task_prompt", description: "Task prompt", example: "Fix the bug", executor_types: [] },
];
it("does not duplicate closing braces that already follow the cursor", () => {
const { suggestions } = completePlaceholder(placeholders, "{{}}", 3);
expect(suggestions[0].insertText).toBe("task_prompt");
});
it("replaces the remaining partial placeholder name before existing braces", () => {
const { suggestions } = completePlaceholder(placeholders, "{{ta_prompt}}", 5);
expect(suggestions[0].insertText).toBe("task_prompt");
expect(suggestions[0].range).toMatchObject({ startColumn: 3, endColumn: 12 });
});
it("adds one closing brace pair for an unfinished placeholder token", () => {
const { suggestions } = completePlaceholder(placeholders, "{{", 3);
expect(suggestions[0].insertText).toBe("task_prompt}}");
});
});
@@ -8,6 +8,24 @@ export type ScriptPlaceholder = {
executor_types: string[];
};
/**
* Keeps a language-level completion registration from serving another editor's
* model. Monaco registers providers by language, so every editor must reject
* completion requests that do not belong to its mounted model.
*/
export function scopeCompletionProviderToModel(
provider: languages.CompletionItemProvider,
model: editor.ITextModel,
): languages.CompletionItemProvider {
return {
...provider,
provideCompletionItems(candidateModel, position, context, token) {
if (candidateModel !== model) return { suggestions: [] };
return provider.provideCompletionItems(candidateModel, position, context, token);
},
};
}
/**
* Creates a Monaco CompletionItemProvider that suggests {{placeholder}} values.
* Triggers on `{` and filters by executor type.
@@ -25,6 +43,7 @@ export function createPlaceholderCompletionProvider(
): languages.ProviderResult<languages.CompletionList> {
const line = model.getLineContent(position.lineNumber);
const textBefore = line.substring(0, position.column - 1);
const textAfter = line.substring(position.column - 1);
// Only trigger after `{{`
if (!textBefore.endsWith("{{") && !textBefore.match(/\{\{[\w.]*$/)) {
@@ -34,12 +53,13 @@ export function createPlaceholderCompletionProvider(
// Find the range to replace (from {{ to cursor)
const match = textBefore.match(/\{\{([\w.]*)$/);
const startCol = match ? position.column - match[1].length : position.column;
const existingPlaceholderSuffix = textAfter.match(/^([\w.]*)\}\}/);
const range: IRange = {
startLineNumber: position.lineNumber,
startColumn: startCol,
endLineNumber: position.lineNumber,
endColumn: position.column,
endColumn: position.column + (existingPlaceholderSuffix?.[1].length ?? 0),
};
const filtered = executorType
@@ -56,7 +76,7 @@ export function createPlaceholderCompletionProvider(
kind: monaco.languages.CompletionItemKind.Variable,
detail: p.description,
documentation: p.example ? `Example: ${p.example}` : undefined,
insertText: `${p.key}}}`,
insertText: existingPlaceholderSuffix ? p.key : p.key + "}}",
range,
sortText: String(i).padStart(3, "0"),
}));
@@ -66,8 +86,6 @@ export function createPlaceholderCompletionProvider(
};
}
const MENTION_NAME_PATTERN = /[\w-]*$/;
function isMentionStartColumn(line: string, atColumn: number): boolean {
if (atColumn === 1) return true;
const charBefore = line[atColumn - 2];
@@ -90,31 +108,31 @@ export function createPromptMentionCompletionProvider(
prompts: PromptReference[],
): languages.CompletionItemProvider {
return {
triggerCharacters: ["@"],
triggerCharacters: ["@", " "],
provideCompletionItems(
model: editor.ITextModel,
position: { lineNumber: number; column: number },
): languages.ProviderResult<languages.CompletionList> {
const line = model.getLineContent(position.lineNumber);
const textBefore = line.substring(0, position.column - 1);
const atIndex = textBefore.lastIndexOf("@");
const atColumn = atIndex + 1;
const nameMatch = textBefore.match(MENTION_NAME_PATTERN);
const namePrefix = nameMatch ? nameMatch[0] : "";
const atColumn = position.column - namePrefix.length - 1;
if (atColumn < 1 || line[atColumn - 1] !== "@" || !isMentionStartColumn(line, atColumn)) {
if (atIndex < 0 || !isMentionStartColumn(line, atColumn)) {
return { suggestions: [] };
}
const namePrefix = textBefore.slice(atIndex + 1);
const range: IRange = {
startLineNumber: position.lineNumber,
startColumn: atColumn + 1,
startColumn: position.column - namePrefix.length,
endLineNumber: position.lineNumber,
endColumn: position.column,
};
const suggestions: languages.CompletionItem[] = prompts.map((prompt, i) => ({
label: `@${prompt.name}`,
filterText: prompt.name,
kind: monaco.languages.CompletionItemKind.Reference,
detail: previewContent(prompt.content),
documentation: previewContent(prompt.content),
@@ -0,0 +1,203 @@
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useEffect } from "react";
import type { CancellationToken, Position, editor, languages } from "monaco-editor";
const mocks = vi.hoisted(() => {
const registrations: Array<{
provider: languages.CompletionItemProvider;
dispose: ReturnType<typeof vi.fn>;
}> = [];
const mountedModels = new Map<string, editor.ITextModel>();
const pendingMounts: Array<() => void> = [];
const editorOptions: Array<{ ariaLabel?: string }> = [];
const monaco = {
editor: { defineTheme: vi.fn() },
languages: {
CompletionItemKind: { Reference: 2, Variable: 1 },
registerCompletionItemProvider: vi.fn(
(_language: string, provider: languages.CompletionItemProvider) => {
const registration = { provider, dispose: vi.fn() };
registrations.push(registration);
return registration;
},
),
},
};
return { monaco, registrations, mountedModels, pendingMounts, editorOptions, deferMount: false };
});
const MENTION_EDITOR_VALUE = "mention-editor";
vi.mock("@/lib/routing/client-dynamic", () => ({
default: () =>
function MockMonacoEditor({
value,
beforeMount,
onMount,
options,
}: {
value: string;
beforeMount?: (monaco: typeof mocks.monaco) => void;
onMount?: (
editor: { getModel: () => editor.ITextModel },
monaco: typeof mocks.monaco,
) => void;
options?: { ariaLabel?: string };
}) {
mocks.editorOptions.push(options ?? {});
useEffect(() => {
const model = {
getLineContent: () => (value === MENTION_EDITOR_VALUE ? "@" : "{{"),
} as unknown as editor.ITextModel;
const mount = () => {
mocks.mountedModels.set(value, model);
beforeMount?.(mocks.monaco);
onMount?.({ getModel: () => model }, mocks.monaco);
};
if (mocks.deferMount) mocks.pendingMounts.push(mount);
else mount();
}, [beforeMount, onMount, value]);
return null;
},
}));
import { ScriptEditor } from "./script-editor";
function complete(
provider: languages.CompletionItemProvider,
model: editor.ITextModel,
): languages.CompletionList {
return provider.provideCompletionItems(
model,
{ lineNumber: 1, column: 3 } as unknown as Position,
{} as languages.CompletionContext,
{} as CancellationToken,
) as languages.CompletionList;
}
describe("ScriptEditor completion ownership", () => {
afterEach(() => {
cleanup();
mocks.registrations.length = 0;
mocks.mountedModels.clear();
mocks.pendingMounts.length = 0;
mocks.editorOptions.length = 0;
mocks.deferMount = false;
vi.clearAllMocks();
});
it("passes the accessible name to Monaco", () => {
render(<ScriptEditor value="" onChange={() => undefined} ariaLabel="Prompt editor" />);
expect(mocks.editorOptions.at(-1)?.ariaLabel).toBe("Prompt editor");
});
it("keeps completion providers scoped to the Monaco model that mounted them", async () => {
render(
<>
<ScriptEditor
value="first"
onChange={() => undefined}
placeholders={[
{ key: "first", description: "First", example: "one", executor_types: [] },
]}
/>
<ScriptEditor
value="second"
onChange={() => undefined}
placeholders={[
{ key: "second", description: "Second", example: "two", executor_types: [] },
]}
/>
</>,
);
const activeRegistrations = () =>
mocks.registrations.filter((registration) => registration.dispose.mock.calls.length === 0);
await waitFor(() => expect(activeRegistrations()).toHaveLength(2));
const firstModel = mocks.mountedModels.get("first");
const secondModel = mocks.mountedModels.get("second");
const [firstProvider, secondProvider] = activeRegistrations().map(
(registration) => registration.provider,
);
expect(firstModel).toBeDefined();
expect(secondModel).toBeDefined();
expect(complete(firstProvider, firstModel!).suggestions[0].insertText).toBe("first}}");
expect(complete(secondProvider, secondModel!).suggestions[0].insertText).toBe("second}}");
expect(complete(firstProvider, secondModel!).suggestions).toEqual([]);
expect(complete(secondProvider, firstModel!).suggestions).toEqual([]);
});
it("updates one editor without disposing the other editor's provider", async () => {
const firstPlaceholders = [
{ key: "first", description: "First", example: "one", executor_types: [] },
];
const secondPlaceholders = [
{ key: "second", description: "Second", example: "two", executor_types: [] },
];
const view = render(
<>
<ScriptEditor value="first" onChange={() => undefined} placeholders={firstPlaceholders} />
<ScriptEditor value="second" onChange={() => undefined} placeholders={secondPlaceholders} />
</>,
);
const activeRegistrations = () =>
mocks.registrations.filter((registration) => registration.dispose.mock.calls.length === 0);
await waitFor(() => expect(activeRegistrations()).toHaveLength(2));
const secondRegistration = activeRegistrations()[1];
view.rerender(
<>
<ScriptEditor
value="first"
onChange={() => undefined}
placeholders={[
{ key: "updated", description: "Updated", example: "three", executor_types: [] },
]}
/>
<ScriptEditor value="second" onChange={() => undefined} placeholders={secondPlaceholders} />
</>,
);
await waitFor(() => expect(activeRegistrations()).toHaveLength(2));
expect(secondRegistration.dispose).not.toHaveBeenCalled();
});
});
describe("ScriptEditor async completion ownership", () => {
it("registers prompt mentions that load before Monaco mounts", async () => {
mocks.deferMount = true;
const prompt = { id: "prompt-1", name: "review", content: "Review this carefully." };
const view = render(
<ScriptEditor value={MENTION_EDITOR_VALUE} onChange={() => undefined} mentionPrompts={[]} />,
);
await waitFor(() => expect(mocks.pendingMounts).toHaveLength(1));
view.rerender(
<ScriptEditor
value={MENTION_EDITOR_VALUE}
onChange={() => undefined}
mentionPrompts={[prompt]}
/>,
);
mocks.deferMount = false;
mocks.pendingMounts.shift()?.();
const activeRegistrations = () =>
mocks.registrations.filter((registration) => registration.dispose.mock.calls.length === 0);
await waitFor(() => expect(activeRegistrations()).toHaveLength(1));
const model = mocks.mountedModels.get(MENTION_EDITOR_VALUE);
const result = activeRegistrations()[0].provider.provideCompletionItems(
model!,
{ lineNumber: 1, column: 2 } as unknown as Position,
{} as languages.CompletionContext,
{} as CancellationToken,
) as languages.CompletionList;
expect(result.suggestions[0].label).toBe("@review");
});
});
@@ -8,6 +8,7 @@ import type { PromptReference } from "@/lib/prompts/expand-prompt-references";
import {
createPlaceholderCompletionProvider,
createPromptMentionCompletionProvider,
scopeCompletionProviderToModel,
type ScriptPlaceholder,
} from "./script-editor-completions";
import { useTranslation } from "react-i18next";
@@ -30,57 +31,12 @@ const MonacoEditor = dynamic(() => import("@monaco-editor/react").then((m) => m.
});
type Monaco = typeof import("monaco-editor");
type MonacoModel = import("monaco-editor").editor.ITextModel;
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);
@@ -107,12 +63,95 @@ type ScriptEditorProps = {
* 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).
* Callers should preserve the array identity when its contents are unchanged
* to avoid unnecessary provider re-registration.
*/
mentionPrompts?: PromptReference[];
readOnly?: boolean;
lineNumbers?: "on" | "off";
ariaLabel?: string;
testId?: string;
};
type CompletionProviderOptions = Pick<
ScriptEditorProps,
"language" | "completionProvider" | "placeholders" | "executorType" | "mentionPrompts"
> & { language: string };
function useCompletionProviders({
language,
completionProvider,
placeholders,
executorType,
mentionPrompts,
}: CompletionProviderOptions): OnMount {
const monacoRef = useRef<Monaco | null>(null);
const modelRef = useRef<MonacoModel | null>(null);
const primaryDisposableRef = useRef<{ dispose: () => void } | null>(null);
const mentionDisposableRef = useRef<{ dispose: () => void } | null>(null);
const primaryRegistrationRef = useRef<() => void>(() => undefined);
const mentionRegistrationRef = useRef<() => void>(() => undefined);
const disposePrimaryProvider = useCallback(() => {
primaryDisposableRef.current?.dispose();
primaryDisposableRef.current = null;
}, []);
const disposeMentionProvider = useCallback(() => {
mentionDisposableRef.current?.dispose();
mentionDisposableRef.current = null;
}, []);
const registerPrimaryProvider = useCallback(() => {
disposePrimaryProvider();
const monaco = monacoRef.current;
const model = modelRef.current;
const factory = resolveFactory(completionProvider, placeholders, executorType);
if (!monaco || !model || !factory) return;
primaryDisposableRef.current = monaco.languages.registerCompletionItemProvider(
language,
scopeCompletionProviderToModel(factory(monaco), model),
);
}, [completionProvider, disposePrimaryProvider, executorType, language, placeholders]);
const registerMentionProvider = useCallback(() => {
disposeMentionProvider();
const monaco = monacoRef.current;
const model = modelRef.current;
const factory = resolveMentionFactory(mentionPrompts);
if (!monaco || !model || !factory) return;
mentionDisposableRef.current = monaco.languages.registerCompletionItemProvider(
language,
scopeCompletionProviderToModel(factory(monaco), model),
);
}, [disposeMentionProvider, language, mentionPrompts]);
// Monaco calls onMount only once. Keep its callback pointed at the latest
// provider registrations so data that loads before Monaco finishes mounting
// is available on the first registration.
primaryRegistrationRef.current = registerPrimaryProvider;
mentionRegistrationRef.current = registerMentionProvider;
useEffect(() => {
registerPrimaryProvider();
return disposePrimaryProvider;
}, [disposePrimaryProvider, registerPrimaryProvider]);
useEffect(() => {
registerMentionProvider();
return disposeMentionProvider;
}, [disposeMentionProvider, registerMentionProvider]);
const handleMount: OnMount = useCallback((editor, monaco) => {
monacoRef.current = monaco;
modelRef.current = editor.getModel();
primaryRegistrationRef.current();
mentionRegistrationRef.current();
}, []);
return handleMount;
}
export function ScriptEditor({
value,
onChange,
@@ -124,97 +163,53 @@ export function ScriptEditor({
mentionPrompts,
readOnly = false,
lineNumbers = "on",
ariaLabel,
testId,
}: 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 handleMount = useCompletionProviders({
language,
completionProvider,
placeholders,
executorType,
mentionPrompts,
});
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",
fontFamily: EDITOR_FONT_FAMILY,
fontSize: EDITOR_FONT_SIZE,
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,
},
}}
/>
<div data-testid={testId} className="h-full w-full">
<MonacoEditor
height={height}
language={language}
value={value}
onChange={(v) => onChange(v ?? "")}
beforeMount={handleBeforeMount}
onMount={handleMount}
theme="kandev-dark"
options={{
ariaLabel,
minimap: { enabled: false },
lineNumbers,
wordWrap: "on",
fontFamily: EDITOR_FONT_FAMILY,
fontSize: EDITOR_FONT_SIZE,
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,
},
}}
/>
</div>
);
}
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
updatePrompt: vi.fn(),
setPrompts: vi.fn(),
toast: vi.fn(),
promptEditor: vi.fn(),
}));
vi.mock("@/hooks/domains/settings/use-custom-prompts", () => ({
@@ -24,6 +25,19 @@ vi.mock("@/components/state-provider", () => ({
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
mocks.promptEditor(props);
return (
<textarea
data-testid={props.testId as string}
value={props.value as string}
onChange={(event) => (props.onChange as (value: string) => void)(event.target.value)}
placeholder="Prompt content"
/>
);
},
}));
vi.mock("@/lib/api", () => ({
createPrompt: mocks.createPrompt,
@@ -73,9 +87,15 @@ describe("PromptsSettings coordinated creation", () => {
fireEvent.change(screen.getByPlaceholderText("Prompt name"), {
target: { value: "Review" },
});
fireEvent.change(screen.getByPlaceholderText("Prompt content"), {
fireEvent.change(screen.getByTestId("prompt-content-input"), {
target: { value: "Review this change" },
});
expect(mocks.promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "prompt-content-input",
}),
);
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() =>
@@ -14,8 +14,8 @@ import {
DialogTitle,
} from "@kandev/ui/dialog";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { SettingsPageTemplate } from "@/components/settings/settings-page-template";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import { useToast } from "@/components/toast-provider";
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
import { useAppStore } from "@/components/state-provider";
@@ -80,15 +80,14 @@ function PromptCreateForm({ formState, onFormChange, onCancel, isBusy }: PromptC
disabled={isBusy}
data-settings-dirty={nameIsDirty}
/>
<Textarea
<SettingsPromptEditor
value={formState.content}
onChange={(event) => onFormChange({ content: event.target.value })}
placeholder={t("settings:promptContentPlaceholder")}
rows={5}
className="resize-y max-h-60 overflow-auto"
data-testid="prompt-content-input"
disabled={isBusy}
data-settings-dirty={contentIsDirty}
onChange={(value) => onFormChange({ content: value })}
promptReferences
readOnly={isBusy}
testId="prompt-content-input"
isDirty={contentIsDirty}
dirtyLevel="field"
/>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onCancel} disabled={isBusy}>
@@ -181,15 +180,15 @@ function PromptListItem({
disabled={isBusy}
data-settings-dirty={nameIsDirty}
/>
<Textarea
<SettingsPromptEditor
value={formState.content}
onChange={(event) => onFormChange({ content: event.target.value })}
placeholder={t("settings:promptContentPlaceholder")}
rows={5}
className="resize-y max-h-60 overflow-auto"
data-testid="prompt-content-input"
disabled={isBusy}
data-settings-dirty={contentIsDirty}
onChange={(value) => onFormChange({ content: value })}
promptReferences
excludedPromptIds={[prompt.id]}
readOnly={isBusy}
testId="prompt-content-input"
isDirty={contentIsDirty}
dirtyLevel="field"
/>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onCancel} disabled={isBusy}>
@@ -0,0 +1,120 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
promptState: {
prompts: [
{ id: "review", name: "review-helper", content: "Review the change." },
{ id: "release", name: "release-notes", content: "Write release notes." },
],
loaded: true,
loading: false,
},
scriptEditor: vi.fn(),
useCustomPrompts: vi.fn(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/hooks/domains/settings/use-custom-prompts", () => ({
useCustomPrompts: (options: unknown) => {
mocks.useCustomPrompts(options);
return mocks.promptState;
},
}));
vi.mock("./profile-edit/script-editor", () => ({
ScriptEditor: (props: unknown) => {
mocks.scriptEditor(props);
return null;
},
computeEditorHeight: (value: string, minLines: number) => `${value.length + minLines}px`,
}));
import { SettingsPromptEditor } from "./settings-prompt-editor";
describe("SettingsPromptEditor", () => {
afterEach(() => {
cleanup();
mocks.scriptEditor.mockReset();
mocks.useCustomPrompts.mockReset();
mocks.promptState.loaded = true;
mocks.promptState.loading = false;
});
it("passes placeholder and saved-prompt completion modes to its controlled editor", () => {
const placeholders = [
{ key: "title", description: "Title", example: "Bug", executor_types: [] },
];
const { container } = render(
<SettingsPromptEditor
value="Review @release"
onChange={() => undefined}
placeholders={placeholders}
promptReferences
excludedPromptIds={["review"]}
minLines={5}
isDirty
ariaLabel="Prompt editor"
testId="prompt-editor"
/>,
);
const props = mocks.scriptEditor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(props.placeholders).toBe(placeholders);
expect(props.mentionPrompts).toEqual([mocks.promptState.prompts[1]]);
expect(props.height).toBe("20px");
expect(props.ariaLabel).toBe("Prompt editor");
expect(props.testId).toBe("prompt-editor-editor");
expect(document.body.textContent).toContain("settings:promptEditorBothHint");
expect(container.firstElementChild?.getAttribute("data-settings-dirty")).toBe("true");
expect(container.firstElementChild?.getAttribute("data-settings-dirty-level")).toBe(
"container",
);
});
it("does not offer saved prompts while the prompt list is loading", () => {
mocks.promptState.loading = true;
mocks.promptState.loaded = false;
render(
<SettingsPromptEditor
value="{{title}}"
onChange={() => undefined}
placeholders={[]}
promptReferences
/>,
);
const props = mocks.scriptEditor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(props.mentionPrompts).toEqual([]);
});
it("keeps utility-style editors on placeholder completion only", () => {
render(
<SettingsPromptEditor
value="{{workspace}}"
onChange={() => undefined}
placeholders={[
{ key: "workspace", description: "Workspace", example: "repo", executor_types: [] },
]}
/>,
);
const props = mocks.scriptEditor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(props.mentionPrompts).toBeUndefined();
expect(document.body.textContent).toContain("settings:promptEditorPlaceholderHint");
expect(mocks.useCustomPrompts).toHaveBeenLastCalledWith({ enabled: false });
});
it("shows saved-prompt guidance when placeholders are not available", () => {
render(<SettingsPromptEditor value="Review @" onChange={() => undefined} promptReferences />);
expect(document.body.textContent).toContain("settings:promptEditorPromptReferenceHint");
});
});
@@ -0,0 +1,98 @@
"use client";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
import {
computeEditorHeight,
ScriptEditor,
} from "@/components/settings/profile-edit/script-editor";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
const EMPTY_EXCLUDED_PROMPT_IDS: string[] = [];
export type SettingsPromptEditorProps = {
value: string;
onChange: (value: string) => void;
placeholders?: ScriptPlaceholder[];
promptReferences?: boolean;
excludedPromptIds?: string[];
language?: "markdown" | "plaintext";
height?: string | number;
minLines?: number;
readOnly?: boolean;
ariaLabel?: string;
testId?: string;
isDirty?: boolean;
dirtyLevel?: "field" | "container";
help?: ReactNode;
};
function completionHint(
t: (key: string, options?: Record<string, string>) => string,
hasPlaceholders: boolean,
hasPromptReferences: boolean,
) {
if (hasPlaceholders && hasPromptReferences) {
return t("settings:promptEditorBothHint", { placeholder: "{{", mention: "@" });
}
if (hasPlaceholders) {
return t("settings:promptEditorPlaceholderHint", { placeholder: "{{" });
}
if (hasPromptReferences) {
return t("settings:promptEditorPromptReferenceHint", { mention: "@" });
}
return null;
}
export function SettingsPromptEditor({
value,
onChange,
placeholders = [],
promptReferences = false,
excludedPromptIds = EMPTY_EXCLUDED_PROMPT_IDS,
language = "markdown",
height,
minLines = 3,
readOnly = false,
ariaLabel,
testId,
isDirty,
dirtyLevel = "container",
help,
}: SettingsPromptEditorProps) {
const { t } = useTranslation();
const { prompts, loaded, loading } = useCustomPrompts({ enabled: promptReferences });
const excludedIds = useMemo(() => new Set(excludedPromptIds), [excludedPromptIds]);
const mentionPrompts = useMemo(() => {
if (!loaded || loading) return [];
return prompts.filter((prompt) => !excludedIds.has(prompt.id));
}, [excludedIds, loaded, loading, prompts]);
const hint = completionHint(t, placeholders.length > 0, promptReferences);
return (
<div
className="settings-prompt-editor space-y-1.5"
data-settings-dirty={isDirty}
data-settings-dirty-level={dirtyLevel}
>
<div className="rounded-md border border-border overflow-hidden" data-testid={testId}>
<ScriptEditor
value={value}
onChange={onChange}
language={language}
height={height ?? computeEditorHeight(value, minLines)}
lineNumbers="off"
placeholders={placeholders}
mentionPrompts={promptReferences ? mentionPrompts : undefined}
readOnly={readOnly}
ariaLabel={ariaLabel ?? t("settings:promptEditorAriaLabel")}
testId={testId ? `${testId}-editor` : undefined}
/>
</div>
{hint && <p className="text-[11px] text-muted-foreground/60">{hint}</p>}
{help}
</div>
);
}
@@ -0,0 +1,58 @@
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { UtilityAgentDialog } from "./utility-agent-dialog";
const mocks = vi.hoisted(() => ({
promptEditor: vi.fn(),
getTemplateVariables: vi.fn(),
createUtilityAgent: vi.fn(),
updateUtilityAgent: vi.fn(),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
agentProfiles: {
items: [{ id: "profile-1", label: "Default", enabled: true, cli_passthrough: false }],
},
}),
}));
vi.mock("@/lib/api/domains/utility-api", () => ({
getTemplateVariables: mocks.getTemplateVariables,
createUtilityAgent: mocks.createUtilityAgent,
updateUtilityAgent: mocks.updateUtilityAgent,
}));
vi.mock("./utility-agent-profile-picker", () => ({
UtilityAgentProfilePicker: () => <div data-testid="utility-profile-picker" />,
}));
vi.mock("./settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
mocks.promptEditor(props);
return <div data-testid={props.testId as string} />;
},
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("UtilityAgentDialog", () => {
it("keeps saved-prompt references disabled while exposing utility variables", async () => {
mocks.getTemplateVariables.mockResolvedValue({
variables: [{ name: "workspace", description: "Workspace name", example: "kandev" }],
});
render(<UtilityAgentDialog open onOpenChange={vi.fn()} agent={null} onSuccess={vi.fn()} />);
await waitFor(() => {
expect(mocks.promptEditor).toHaveBeenLastCalledWith(
expect.objectContaining({
language: "plaintext",
placeholders: [expect.objectContaining({ key: "workspace" })],
testId: "utility-agent-prompt-editor",
}),
);
});
});
});
@@ -13,7 +13,7 @@ import {
getTemplateVariables,
} from "@/lib/api/domains/utility-api";
import { useAppStore } from "@/components/state-provider";
import { ScriptEditor } from "./profile-edit/script-editor";
import { SettingsPromptEditor } from "./settings-prompt-editor";
import { UtilityAgentProfilePicker } from "./utility-agent-profile-picker";
import type { ScriptPlaceholder } from "./profile-edit/script-editor-completions";
@@ -141,25 +141,25 @@ export function UtilityAgentDialog({ open, onOpenChange, agent, onSuccess }: Pro
</div>
<div className="space-y-2">
<Label>{t("settings:utilityAgentPromptTemplate")}</Label>
<div className="border rounded-md overflow-hidden">
<ScriptEditor
value={form.prompt}
onChange={(value) => setForm((current) => ({ ...current, prompt: value }))}
language="plaintext"
height="200px"
placeholders={placeholders}
lineNumbers="off"
/>
</div>
<p className="text-xs text-muted-foreground">
<Trans
i18nKey="settings:utilityAgentPromptHint"
values={{ sigil: PROMPT_VARIABLE_SIGIL }}
>
Type <code>{PROMPT_VARIABLE_SIGIL}</code> to see available variables with
autocomplete
</Trans>
</p>
<SettingsPromptEditor
value={form.prompt}
onChange={(value) => setForm((current) => ({ ...current, prompt: value }))}
language="plaintext"
height="200px"
placeholders={placeholders}
testId="utility-agent-prompt-editor"
help={
<p className="text-xs text-muted-foreground">
<Trans
i18nKey="settings:utilityAgentPromptHint"
values={{ sigil: PROMPT_VARIABLE_SIGIL }}
>
Type <code>{PROMPT_VARIABLE_SIGIL}</code> to see available variables with
autocomplete
</Trans>
</p>
}
/>
</div>
</div>
<DialogFooter>
@@ -4,6 +4,22 @@ import type { Workflow } from "@/lib/types/http";
import { WorkflowPromptSection } from "./workflow-prompt-section";
const PROMPT_INPUT = "workflow-prompt-input";
const promptEditor = vi.hoisted(() => vi.fn());
vi.mock("./settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return (
<textarea
data-testid={props.testId as string}
data-settings-dirty={String(props.isDirty)}
value={props.value as string}
onChange={(event) => (props.onChange as (value: string) => void)(event.target.value)}
readOnly={props.readOnly as boolean}
/>
);
},
}));
const baseWorkflow = {
id: "workflow-1",
@@ -16,6 +32,7 @@ const baseWorkflow = {
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("WorkflowPromptSection", () => {
@@ -36,6 +53,13 @@ describe("WorkflowPromptSection", () => {
const input = screen.getByTestId(PROMPT_INPUT) as HTMLTextAreaElement;
expect(input.value).toBe(workflow.prompt);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: PROMPT_INPUT,
isDirty: true,
}),
);
});
it("auto-expands when an empty prompt becomes non-empty", () => {
@@ -5,9 +5,9 @@ import { useTranslation } from "react-i18next";
import { IconChevronRight } from "@tabler/icons-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { Label } from "@kandev/ui/label";
import { Textarea } from "@kandev/ui/textarea";
import { cn } from "@/lib/utils";
import type { Workflow } from "@/lib/types/http";
import { SettingsPromptEditor } from "./settings-prompt-editor";
import { HelpTip } from "./workflow-pipeline-editor-helpers";
import { isWorkflowFieldDirty } from "./workflow-dirty-state";
@@ -68,15 +68,20 @@ export function WorkflowPromptSection({
)}
</div>
<CollapsibleContent className="space-y-1.5">
<Textarea
<SettingsPromptEditor
value={prompt}
onChange={(e) => onUpdate(e.target.value)}
disabled={readOnly}
placeholder={t("workflows:workflowPromptPlaceholder")}
rows={4}
className="min-h-24 font-mono text-sm"
data-testid="workflow-prompt-input"
data-settings-dirty={isWorkflowFieldDirty(workflow, savedWorkflow, "prompt")}
onChange={onUpdate}
promptReferences
readOnly={readOnly}
ariaLabel={t("workflows:workflowPrompt")}
testId="workflow-prompt-input"
isDirty={isWorkflowFieldDirty(workflow, savedWorkflow, "prompt")}
dirtyLevel="field"
help={
<p className="text-xs text-muted-foreground">
{t("workflows:workflowPromptPlaceholder")}
</p>
}
/>
</CollapsibleContent>
</Collapsible>
@@ -0,0 +1,51 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WorkflowStep } from "@/lib/types/http";
import { StepPromptSection } from "./workflow-step-prompt-section";
const promptEditor = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/domains/settings/use-custom-prompts", () => ({
useCustomPrompts: () => ({ prompts: [], loaded: true, loading: false }),
}));
vi.mock("./settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div data-testid={props.testId as string} />;
},
}));
const step = {
id: "step-1",
workflow_id: "workflow-1",
name: "Implement",
prompt: "Implement {{task_prompt}}",
} as WorkflowStep;
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("StepPromptSection", () => {
it("uses shared prompt references and step placeholders", () => {
render(
<StepPromptSection
step={step}
savedStep={{ ...step, prompt: "Previous prompt" }}
localPrompt={step.prompt ?? ""}
onLocalPromptChange={vi.fn()}
debouncedUpdatePrompt={vi.fn()}
readOnly={false}
/>,
);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
isDirty: true,
placeholders: expect.arrayContaining([expect.objectContaining({ key: "task_prompt" })]),
}),
);
});
});
@@ -1,13 +1,10 @@
"use client";
import type { WorkflowStep } from "@/lib/types/http";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@kandev/ui/label";
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import { SettingsPromptEditor } from "./settings-prompt-editor";
import {
HelpTip,
stepPromptPlaceholders,
@@ -30,7 +27,7 @@ export function StepPromptSection({
readOnly: boolean;
}) {
const { t } = useTranslation();
const { prompts } = useCustomPrompts();
const placeholders = useMemo(() => stepPromptPlaceholders(t), [t]);
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
@@ -60,25 +57,21 @@ export function StepPromptSection({
))}
</div>
)}
<div
className="overflow-hidden rounded-md border"
data-settings-dirty={!savedStep || localPrompt !== (savedStep.prompt ?? "")}
>
<ScriptEditor
value={localPrompt}
onChange={(value) => {
if (readOnly) return;
onLocalPromptChange(value);
debouncedUpdatePrompt(value);
}}
language="markdown"
height={computeEditorHeight(localPrompt)}
lineNumbers="off"
readOnly={readOnly}
placeholders={stepPromptPlaceholders(t)}
mentionPrompts={prompts}
/>
</div>
<SettingsPromptEditor
value={localPrompt}
onChange={(value) => {
if (readOnly) return;
onLocalPromptChange(value);
debouncedUpdatePrompt(value);
}}
language="markdown"
readOnly={readOnly}
placeholders={placeholders}
promptReferences
testId={`workflow-step-prompt-${step.id}`}
isDirty={!savedStep || localPrompt !== (savedStep.prompt ?? "")}
dirtyLevel="container"
/>
<p className="text-[11px] text-muted-foreground/60">
{t("workflows:stepPromptUsageHint", {
open: "{{",
+7
View File
@@ -679,6 +679,13 @@ export class ApiClient {
await this.request("DELETE", `/api/v1/prompts/${promptId}`);
}
async resetGitHubActionPresets(workspaceId: string): Promise<void> {
await this.request(
"POST",
`/api/v1/github/action-presets/reset?workspace_id=${encodeURIComponent(workspaceId)}`,
);
}
async createTaskWithAgent(
workspaceId: string,
title: string,
@@ -0,0 +1,95 @@
import { type Locator, type Page, expect } from "@playwright/test";
type PromptEditorOptions = {
touch?: boolean;
};
type CompletionOptions = {
keyboard?: boolean;
touch?: boolean;
};
/** Return the Monaco host inside a SettingsPromptEditor wrapper. */
export function monacoPromptEditor(editor: Locator): Locator {
return editor.locator(".monaco-editor");
}
/** Focus Monaco's EditContext target, which is the input surface in this build. */
export async function focusPromptEditor(
editor: Locator,
{ touch = false }: PromptEditorOptions = {},
): Promise<Locator> {
const monaco = monacoPromptEditor(editor);
await expect(monaco).toBeVisible({ timeout: 10_000 });
if (touch) await monaco.tap();
else await monaco.click();
const editContext = monaco.locator(".native-edit-context");
await expect(editContext).toBeFocused({ timeout: 5_000 });
return monaco;
}
export async function replacePromptEditor(
page: Page,
editor: Locator,
value: string,
options?: PromptEditorOptions,
): Promise<Locator> {
const monaco = await focusPromptEditor(editor, options);
await page.keyboard.press("Control+A");
await page.keyboard.press("Backspace");
await page.keyboard.type(value);
return monaco;
}
export async function waitForPromptInStore(page: Page, promptName: string): Promise<void> {
await page.waitForFunction(
(name) => {
const store = (
window as Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
prompts: {
loaded: boolean;
items: Array<{ name: string }>;
};
};
};
}
).__KANDEV_E2E_STORE__;
const prompts = store?.getState().prompts;
return prompts?.loaded === true && prompts.items.some((prompt) => prompt.name === name);
},
promptName,
{
timeout: 10_000,
message: `saved prompt ${promptName} was not loaded into the settings store`,
},
);
}
export async function acceptPromptCompletion(
page: Page,
label: string,
{ keyboard = false, touch = false }: CompletionOptions = {},
): Promise<void> {
const suggestWidget = page.locator(".suggest-widget:visible").last();
await expect(suggestWidget).toBeVisible({ timeout: 5_000 });
const suggestion = suggestWidget.locator(".monaco-list-row").filter({ hasText: label }).first();
await expect(suggestion).toBeVisible({ timeout: 5_000 });
if (!keyboard) {
if (touch) await suggestion.tap();
else await suggestion.click();
return;
}
const index = Number((await suggestion.getAttribute("data-index")) ?? "0");
await page.keyboard.press("Home");
for (let step = 0; step < index; step += 1) {
await page.keyboard.press("ArrowDown");
}
await page.keyboard.press("Enter");
}
export function promptEditorText(editor: Locator): Locator {
return monacoPromptEditor(editor).locator(".view-lines");
}
@@ -0,0 +1,71 @@
import { test, expect } from "../../fixtures/test-base";
import {
acceptPromptCompletion,
promptEditorText,
replacePromptEditor,
waitForPromptInStore,
} from "../../helpers/settings-prompt-editor";
test.describe("GitHub quick action prompt autocomplete", () => {
test("inserts saved prompt mentions and GitHub action placeholders, then persists them", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
test.setTimeout(90_000);
const prompt = await apiClient.createPrompt(
`e2e-github-action-prompt-${Date.now()}`,
"Reusable review guidance",
);
try {
const settingsPath = `/settings/workspaces/${seedData.workspaceId}/integrations/github`;
await testPage.goto(settingsPath);
const quickActions = testPage
.getByRole("heading", { name: "Quick actions", exact: true })
.locator("xpath=ancestor::section[1]");
await expect(quickActions).toBeVisible();
await quickActions.getByRole("tab", { name: "Pull requests" }).click();
await quickActions.getByRole("button", { name: "Edit prompt" }).first().click();
await waitForPromptInStore(testPage, prompt.name);
const editor = quickActions.locator('[data-testid^="github-action-prompt-editor-"]').first();
await replacePromptEditor(testPage, editor, "Review @");
await acceptPromptCompletion(testPage, prompt.name);
await testPage.keyboard.type(" {{");
await acceptPromptCompletion(testPage, "{{title}}");
await expect(promptEditorText(editor)).toContainText(`@${prompt.name}`);
await expect(promptEditorText(editor)).toContainText("{{title}}");
await expect(testPage.getByTestId("settings-floating-save")).toBeVisible();
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
.click();
await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible({
timeout: 15_000,
});
await testPage.goto(settingsPath);
const reloadedQuickActions = testPage
.getByRole("heading", { name: "Quick actions", exact: true })
.locator("xpath=ancestor::section[1]");
await reloadedQuickActions.getByRole("tab", { name: "Pull requests" }).click();
await reloadedQuickActions.getByRole("button", { name: "Edit prompt" }).first().click();
await waitForPromptInStore(testPage, prompt.name);
const reloadedEditor = reloadedQuickActions
.locator('[data-testid^="github-action-prompt-editor-"]')
.first();
await expect(promptEditorText(reloadedEditor)).toContainText(`@${prompt.name}`);
await expect(promptEditorText(reloadedEditor)).toContainText("{{title}}");
await prCapture.screenshot("github-action-prompt-editor", {
caption: "GitHub quick action prompt editor with saved-prompt and placeholder completions.",
});
} finally {
await apiClient.deletePrompt(prompt.id).catch(() => undefined);
await apiClient.resetGitHubActionPresets(seedData.workspaceId).catch(() => undefined);
}
});
});
@@ -0,0 +1,58 @@
import { test, expect } from "../../fixtures/test-base";
import {
acceptPromptCompletion,
promptEditorText,
replacePromptEditor,
waitForPromptInStore,
} from "../../helpers/settings-prompt-editor";
test.describe("GitHub quick action prompt autocomplete on mobile", () => {
test("supports touch editing without horizontal document overflow", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
test.setTimeout(90_000);
const prompt = await apiClient.createPrompt(
`Daily Summary ${Date.now()}`,
"Reusable mobile review guidance",
);
try {
const settingsPath = `/settings/workspaces/${seedData.workspaceId}/integrations/github`;
await testPage.goto(settingsPath);
const quickActions = testPage
.getByRole("heading", { name: "Quick actions", exact: true })
.locator("xpath=ancestor::section[1]");
await expect(quickActions).toBeVisible();
await quickActions.getByRole("tab", { name: "Pull requests" }).tap();
await quickActions.getByRole("button", { name: "Edit prompt" }).first().tap();
await waitForPromptInStore(testPage, prompt.name);
const editor = quickActions.locator('[data-testid^="github-action-prompt-editor-"]').first();
await replacePromptEditor(testPage, editor, "Review @Daily ", { touch: true });
await acceptPromptCompletion(testPage, prompt.name, { touch: true });
await testPage.keyboard.type(" {{");
await acceptPromptCompletion(testPage, "{{title}}", { touch: true });
await expect(promptEditorText(editor)).toContainText(`@${prompt.name}`);
await expect(promptEditorText(editor)).toContainText("{{title}}");
const editorBox = await editor.boundingBox();
expect(editorBox).not.toBeNull();
expect(editorBox!.width).toBeLessThanOrEqual(testPage.viewportSize()!.width);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await prCapture.screenshot("mobile-github-action-prompt-editor", {
caption: "Mobile GitHub quick action prompt editor with touch completion controls.",
});
} finally {
await apiClient.deletePrompt(prompt.id).catch(() => undefined);
}
});
});
@@ -1,4 +1,10 @@
import { test, expect } from "../../fixtures/test-base";
import {
acceptPromptCompletion,
promptEditorText,
replacePromptEditor,
waitForPromptInStore,
} from "../../helpers/settings-prompt-editor";
// Custom prompts share a UNIQUE name index with built-in prompts. Prior to the
// 409-mapping fix, posting a duplicate name returned 500 and the frontend
@@ -28,7 +34,7 @@ test.describe("Prompts settings — duplicate name handling", () => {
const form = testPage.getByTestId("prompt-create-form");
await expect(form).toBeVisible();
await form.getByTestId("prompt-name-input").fill("dupe-prompt");
await form.getByTestId("prompt-content-input").fill("second content");
await replacePromptEditor(testPage, form.getByTestId("prompt-content-input"), "second content");
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
@@ -87,7 +93,7 @@ test.describe("Prompts settings — duplicate name handling", () => {
const form = testPage.getByTestId("prompt-create-form");
await form.getByTestId("prompt-name-input").fill("e2e-fresh-prompt");
await form.getByTestId("prompt-content-input").fill("hello world");
await replacePromptEditor(testPage, form.getByTestId("prompt-content-input"), "hello world");
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
@@ -99,4 +105,36 @@ test.describe("Prompts settings — duplicate name handling", () => {
// Form should be reset / closed on success.
await expect(testPage.getByTestId("prompt-create-form")).toHaveCount(0);
});
test("does not suggest the prompt currently being edited", async ({ testPage, apiClient }) => {
test.setTimeout(60_000);
const current = await apiClient.createPrompt("e2e-current-prompt", "Current prompt content");
const other = await apiClient.createPrompt("e2e-other-prompt", "Reusable prompt content");
try {
await testPage.goto("/settings/prompts");
await waitForPromptInStore(testPage, other.name);
const row = testPage.locator(
'[data-testid="prompt-list-item"][data-prompt-name="e2e-current-prompt"]',
);
await expect(row).toBeVisible();
await row.getByTestId("prompt-edit-button").click();
const editor = row.getByTestId("prompt-content-input");
await replacePromptEditor(testPage, editor, "Use ");
await testPage.keyboard.type("@");
const suggestWidget = testPage.locator(".suggest-widget:visible").last();
await expect(suggestWidget).toBeVisible({ timeout: 5_000 });
await expect(suggestWidget).toContainText(other.name);
await expect(suggestWidget).not.toContainText(current.name);
await acceptPromptCompletion(testPage, other.name);
await expect(promptEditorText(editor)).toContainText(`@${other.name}`);
} finally {
await apiClient.deletePrompt(current.id).catch(() => undefined);
await apiClient.deletePrompt(other.id).catch(() => undefined);
}
});
});
@@ -1,5 +1,6 @@
import { expect } from "@playwright/test";
import { test } from "../../fixtures/test-base";
import { promptEditorText } from "../../helpers/settings-prompt-editor";
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
import { seedWorkflowDuplication } from "./workflow-duplication-helpers";
@@ -31,7 +32,7 @@ test.describe("workflow duplication", () => {
await expect(copyCard.getByTestId("workflow-description-input")).toHaveValue(
"Copied workflow description",
);
await expect(copyCard.getByTestId("workflow-prompt-input")).toHaveValue(
await expect(promptEditorText(copyCard.getByTestId("workflow-prompt-input"))).toContainText(
"Copied workflow prompt",
);
await expect(copyCard.getByText("Review", { exact: true })).toBeVisible();
@@ -1,5 +1,6 @@
import type { Locator } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { promptEditorText } from "../../helpers/settings-prompt-editor";
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
import { dwell } from "../../helpers/causal-waits";
@@ -566,9 +567,10 @@ test.describe("Workflow settings", () => {
await expect(descriptionInput).toHaveValue("New description from another tab");
await expect(descriptionInput).toHaveAttribute("data-settings-dirty", "false");
const promptInput = card.getByTestId("workflow-prompt-input");
const promptEditor = promptInput.locator("..");
await expect(promptInput).toBeVisible();
await expect(promptInput).toHaveValue("New prompt from another tab");
await expect(promptInput).toHaveAttribute("data-settings-dirty", "false");
await expect(promptEditorText(promptInput)).toContainText("New prompt from another tab");
await expect(promptEditor).toHaveAttribute("data-settings-dirty", "false");
// The unsaved local name edit was not clobbered by the cross-tab sync.
await expect(nameInput).toHaveValue("Unsaved local name");
@@ -43,12 +43,45 @@ test.describe("Workflow step prompt autocomplete", () => {
await expect(suggestion).toContainText("task_prompt");
});
test("does not duplicate closing braces when completing inside a token", async ({
testPage,
seedData,
}) => {
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
const card = await page.findWorkflowCard("E2E Workflow");
await expect(card).toBeVisible();
const stepNodes = card.locator(".group.relative");
await stepNodes.first().click();
const monacoEditor = card.locator(".monaco-editor");
await expect(monacoEditor).toBeVisible({ timeout: 10_000 });
await monacoEditor.click();
await expect(monacoEditor.locator(".native-edit-context")).toBeFocused({ timeout: 5_000 });
await testPage.keyboard.insertText("{{}}");
await testPage.keyboard.press("ArrowLeft");
await testPage.keyboard.press("ArrowLeft");
await testPage.keyboard.press("Control+Space");
const suggestWidget = testPage.locator(".monaco-editor .suggest-widget");
await expect(suggestWidget).toBeVisible({ timeout: 5_000 });
const suggestion = suggestWidget.locator(".monaco-list-row").filter({ hasText: "task_prompt" });
await expect(suggestion.first()).toBeVisible();
await suggestion.first().click();
await expect(monacoEditor).toContainText("{{task_prompt}}");
await expect(monacoEditor).not.toContainText("{{task_prompt}}}}");
});
test("shows and inserts a saved prompt mention when typing @ in step prompt editor", async ({
testPage,
seedData,
apiClient,
}) => {
const promptName = "e2e-mention-prompt";
const promptName = `Daily Summary ${Date.now()}`;
await apiClient.createPrompt(promptName, "Some reusable prompt content for e2e mentions.");
try {
@@ -72,8 +105,8 @@ test.describe("Workflow step prompt autocomplete", () => {
// textarea. See the note in the first test.
await expect(monacoEditor.locator(".native-edit-context")).toBeFocused({ timeout: 5_000 });
// Type @ to trigger the prompt-mention autocomplete
await testPage.keyboard.type("@");
// Type a multi-word name prefix to trigger and filter the prompt-mention autocomplete
await testPage.keyboard.type("@Daily ");
// The Monaco suggest widget should appear with the seeded prompt
const suggestWidget = testPage.locator(".monaco-editor .suggest-widget");
@@ -0,0 +1,57 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listPrompts: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
listPrompts: mocks.listPrompts,
}));
import { StateProvider } from "@/components/state-provider";
import { useCustomPrompts } from "./use-custom-prompts";
function PromptEditorLoaders() {
const first = useCustomPrompts();
const second = useCustomPrompts();
return (
<output data-testid="prompt-editor-loaders">
{String(first.loading)}:{String(second.loading)}
</output>
);
}
describe("useCustomPrompts", () => {
afterEach(() => {
cleanup();
mocks.listPrompts.mockReset();
});
it("deduplicates the prompt request for two mounted editors", async () => {
let resolveRequest!: (response: { prompts: [] }) => void;
mocks.listPrompts.mockReturnValue(
new Promise<{ prompts: [] }>((resolve) => {
resolveRequest = resolve;
}),
);
render(
<StateProvider>
<PromptEditorLoaders />
</StateProvider>,
);
await waitFor(() => expect(mocks.listPrompts).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(screen.getByTestId("prompt-editor-loaders").textContent).toBe("true:true"),
);
resolveRequest({ prompts: [] });
await waitFor(() =>
expect(screen.getByTestId("prompt-editor-loaders").textContent).toBe("false:false"),
);
});
});
@@ -3,7 +3,31 @@
import { useEffect } from "react";
import { listPrompts } from "@/lib/api";
import { useAppStore } from "@/components/state-provider";
export function useCustomPrompts() {
import type { CustomPrompt } from "@/lib/types/http";
type UseCustomPromptsOptions = {
enabled?: boolean;
};
let inFlightPromptsRequest: Promise<CustomPrompt[]> | null = null;
function requestPrompts() {
if (inFlightPromptsRequest) return inFlightPromptsRequest;
const request = listPrompts({ cache: "no-store" }).then((response) => response.prompts ?? []);
inFlightPromptsRequest = request;
void request.then(
() => {
if (inFlightPromptsRequest === request) inFlightPromptsRequest = null;
},
() => {
if (inFlightPromptsRequest === request) inFlightPromptsRequest = null;
},
);
return request;
}
export function useCustomPrompts({ enabled = true }: UseCustomPromptsOptions = {}) {
const prompts = useAppStore((state) => state.prompts.items);
const loaded = useAppStore((state) => state.prompts.loaded);
const loading = useAppStore((state) => state.prompts.loading);
@@ -11,11 +35,11 @@ export function useCustomPrompts() {
const setPromptsLoading = useAppStore((state) => state.setPromptsLoading);
useEffect(() => {
if (loaded || loading) return;
if (!enabled || loaded || loading) return;
setPromptsLoading(true);
listPrompts({ cache: "no-store" })
.then((response) => {
setPrompts(response.prompts ?? []);
requestPrompts()
.then((nextPrompts) => {
setPrompts(nextPrompts);
})
.catch(() => {
setPrompts([]);
@@ -23,7 +47,7 @@ export function useCustomPrompts() {
.finally(() => {
setPromptsLoading(false);
});
}, [loaded, loading, setPrompts, setPromptsLoading]);
}, [enabled, loaded, loading, setPrompts, setPromptsLoading]);
return {
prompts,
+8 -1
View File
@@ -146,6 +146,13 @@
"personalAccessToken": "Personal Access Token",
"placeholderTitleDescription": "Title of the Azure DevOps work item or pull request",
"placeholderUrlDescription": "URL of the Azure DevOps work item or pull request",
"placeholderDescriptionBody": "Description body",
"placeholderIdDescription": "Work item or pull request ID",
"placeholderProjectDescription": "Project name",
"placeholderSourceBranchDescription": "Source branch",
"placeholderStateDescription": "Current state",
"placeholderTargetBranchDescription": "Target branch",
"placeholderTypeDescription": "Work item type",
"planningAndEffort": "Planning and effort",
"policiesLabelled": "Policies: {{state}}",
"pollIntervalSeconds": "Poll interval (seconds)",
@@ -153,7 +160,7 @@
"previousPullRequestPage": "Previous pull request page",
"project": "Project",
"prompt": "Prompt",
"promptPlaceholdersHint": "Type {{open}} to see available placeholders. <3>{{url}}</3> and <6>{{title}}</6> are substituted when the action runs.",
"promptPlaceholdersHint": "Type {{open}} to see available placeholders. <4>{{url}}</4> and <6>{{title}}</6> are substituted when the action runs.",
"pullRequestAny": "any",
"pullRequestFeedback": "Pull request feedback",
"pullRequestFilter": "Pull-request filter",
+6
View File
@@ -205,6 +205,12 @@
"projectPaths": "Project paths",
"projects": "Projects",
"promptSentToTheSelectedAgent": "Prompt sent to the selected agent profile for each new match.",
"placeholderActionTitle": "Title of the merge request or issue",
"placeholderActionUrl": "URL of the merge request or issue",
"placeholderDescription": "Description body",
"placeholderIssueIid": "Issue IID",
"placeholderMergeRequestIid": "Merge request IID",
"placeholderProjectPath": "Project path",
"promptUsingUrlAndTitle": "Prompt using {{url}} and {{title}}",
"query": "Query",
"quickActions": "Quick actions",
+4 -1
View File
@@ -492,7 +492,10 @@
"programming": "Programming",
"profileStartModelNotAdvertisedOnHost": "The host probe did not advertise {{model}}. The selected executor will decide the model at launch.",
"promptAdd": "Add prompt",
"promptContentPlaceholder": "Prompt content",
"promptEditorAriaLabel": "Prompt editor",
"promptEditorBothHint": "Type {{placeholder}} for available placeholders or {{mention}} for saved prompts.",
"promptEditorPlaceholderHint": "Type {{placeholder}} to see available placeholders.",
"promptEditorPromptReferenceHint": "Type {{mention}} to see saved prompts.",
"promptCreateFailed": "Couldn't create prompt",
"promptDelete": "Delete prompt",
"promptDeleteDescription": "This will permanently remove <2>{{name}}</2>. This action cannot be undone.",
+8 -1
View File
@@ -146,6 +146,13 @@
"personalAccessToken": "Ƥēŕśōńàĺ Àććēśś Ţōķēń",
"placeholderTitleDescription": "Ţĩţĺē ōƒ ţĥē Àźũŕē ĎēvŌƥś ŵōŕķ ĩţēḿ ōŕ ƥũĺĺ ŕēqũēśţ",
"placeholderUrlDescription": "ŨŔĹ ōƒ ţĥē Àźũŕē ĎēvŌƥś ŵōŕķ ĩţēḿ ōŕ ƥũĺĺ ŕēqũēśţ",
"placeholderDescriptionBody": "Ďēśćŕĩƥţĩōń ƀōďŷ",
"placeholderIdDescription": "Ŵōŕķ ĩţēḿ ōŕ ƥũĺĺ ŕēqũēśţ ĨĎ",
"placeholderProjectDescription": "Ƥŕōĵēćţ ńàḿē",
"placeholderSourceBranchDescription": "Śōũŕćē ƀŕàńćĥ",
"placeholderStateDescription": "Ćũŕŕēńţ śţàţē",
"placeholderTargetBranchDescription": "Ţàŕĝēţ ƀŕàńćĥ",
"placeholderTypeDescription": "Ŵōŕķ ĩţēḿ ţŷƥē",
"planningAndEffort": "Ƥĺàńńĩńĝ àńď ēƒƒōŕţ",
"policiesLabelled": "Ƥōĺĩćĩēś: {{state}}",
"pollIntervalSeconds": "Ƥōĺĺ ĩńţēŕvàĺ (śēćōńďś)",
@@ -153,7 +160,7 @@
"previousPullRequestPage": "Ƥŕēvĩōũś ƥũĺĺ ŕēqũēśţ ƥàĝē",
"project": "Ƥŕōĵēćţ",
"prompt": "Ƥŕōḿƥţ",
"promptPlaceholdersHint": "Ţŷƥē {{open}} ţō śēē àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś. <3>{{url}}</3> àńď <6>{{title}}</6> àŕē śũƀśţĩţũţēď ŵĥēń ţĥē àćţĩōń ŕũńś.",
"promptPlaceholdersHint": "Ţŷƥē {{open}} ţō śēē àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś. <4>{{url}}</4> àńď <6>{{title}}</6> àŕē śũƀśţĩţũţēď ŵĥēń ţĥē àćţĩōń ŕũńś.",
"pullRequestAny": "àńŷ",
"pullRequestFeedback": "Ƥũĺĺ ŕēqũēśţ ƒēēďƀàćķ",
"pullRequestFilter": "Ƥũĺĺ-ŕēqũēśţ ƒĩĺţēŕ",
+6
View File
@@ -205,6 +205,12 @@
"projectPaths": "Ƥŕōĵēćţ ƥàţĥś",
"projects": "Ƥŕōĵēćţś",
"promptSentToTheSelectedAgent": "Ƥŕōḿƥţ śēńţ ţō ţĥē śēĺēćţēď àĝēńţ ƥŕōƒĩĺē ƒōŕ ēàćĥ ńēŵ ḿàţćĥ.",
"placeholderActionTitle": "Ţĩţĺē ōƒ ţĥē ḿēŕĝē ŕēqũēśţ ōŕ ĩśśũē",
"placeholderActionUrl": "ŨŔĹ ōƒ ţĥē ḿēŕĝē ŕēqũēśţ ōŕ ĩśśũē",
"placeholderDescription": "Ďēśćŕĩƥţĩōń ƀōďŷ",
"placeholderIssueIid": "Ĩśśũē ĨĨĎ",
"placeholderMergeRequestIid": "Ḿēŕĝē ŕēqũēśţ ĨĨĎ",
"placeholderProjectPath": "Ƥŕōĵēćţ ƥàţĥ",
"promptUsingUrlAndTitle": "Ƥŕōḿƥţ ũśĩńĝ {{url}} àńď {{title}}",
"query": "Qũēŕŷ",
"quickActions": "Qũĩćķ àćţĩōńś",
+4 -1
View File
@@ -492,7 +492,10 @@
"programming": "Ƥŕōĝŕàḿḿĩńĝ",
"profileStartModelNotAdvertisedOnHost": "Ţĥē ĥōśţ ƥŕōƀē ďĩď ńōţ àďvēŕţĩśē {{model}}. Ţĥē śēĺēćţēď ēxēćũţōŕ ŵĩĺĺ ďēćĩďē ţĥē ḿōďēĺ àţ ĺàũńćĥ.",
"promptAdd": "Àďď ƥŕōḿƥţ",
"promptContentPlaceholder": "Ƥŕōḿƥţ ćōńţēńţ",
"promptEditorAriaLabel": "Ƥŕōḿƥţ ēďĩţōŕ",
"promptEditorBothHint": "Ţŷƥē {{placeholder}} ƒōŕ àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś ōŕ {{mention}} ƒōŕ śàvēď ƥŕōḿƥţś.",
"promptEditorPlaceholderHint": "Ţŷƥē {{placeholder}} ţō śēē àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś.",
"promptEditorPromptReferenceHint": "Ţŷƥē {{mention}} ţō śēē śàvēď ƥŕōḿƥţś.",
"promptCreateFailed": "Ćōũĺďń'ţ ćŕēàţē ƥŕōḿƥţ",
"promptDelete": "Ďēĺēţē ƥŕōḿƥţ",
"promptDeleteDescription": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē <2>{{name}}</2>. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
+8 -1
View File
@@ -146,6 +146,13 @@
"personalAccessToken": "Token de acesso pessoal",
"placeholderTitleDescription": "Título do item de trabalho ou do pull request do Azure DevOps",
"placeholderUrlDescription": "URL do item de trabalho ou do pull request do Azure DevOps",
"placeholderDescriptionBody": "Corpo da descrição",
"placeholderIdDescription": "ID do item de trabalho ou do pull request",
"placeholderProjectDescription": "Nome do projeto",
"placeholderSourceBranchDescription": "Ramo de origem",
"placeholderStateDescription": "Estado atual",
"placeholderTargetBranchDescription": "Ramo de destino",
"placeholderTypeDescription": "Tipo de item de trabalho",
"planningAndEffort": "Planeamento e esforço",
"policiesLabelled": "Políticas: {{state}}",
"pollIntervalSeconds": "Intervalo de sondagem (segundos)",
@@ -153,7 +160,7 @@
"previousPullRequestPage": "Página anterior de pull requests",
"project": "Projeto",
"prompt": "Prompt",
"promptPlaceholdersHint": "Escreva {{open}} para ver os marcadores disponíveis. <3>{{url}}</3> e <6>{{title}}</6> são substituídos quando a ação é executada.",
"promptPlaceholdersHint": "Escreva {{open}} para ver os marcadores disponíveis. <4>{{url}}</4> e <6>{{title}}</6> são substituídos quando a ação é executada.",
"pullRequestAny": "qualquer",
"pullRequestFeedback": "Comentários do pull request",
"pullRequestFilter": "Filtro de pull requests",
+6
View File
@@ -205,6 +205,12 @@
"projectPaths": "Caminhos dos projetos",
"projects": "Projetos",
"promptSentToTheSelectedAgent": "Prompt enviado ao perfil de agente selecionado por cada nova correspondência.",
"placeholderActionTitle": "Título do merge request ou problema",
"placeholderActionUrl": "URL do merge request ou problema",
"placeholderDescription": "Corpo da descrição",
"placeholderIssueIid": "IID do problema",
"placeholderMergeRequestIid": "IID do merge request",
"placeholderProjectPath": "Caminho do projeto",
"promptUsingUrlAndTitle": "Prompt com {{url}} e {{title}}",
"query": "Consulta",
"quickActions": "Ações rápidas",
+4 -1
View File
@@ -478,7 +478,10 @@
"profileForTasksCreatedByAgents": "Perfil das tarefas criadas por agentes",
"programming": "Programação",
"promptAdd": "Adicionar prompt",
"promptContentPlaceholder": "Conteúdo do prompt",
"promptEditorAriaLabel": "Editor de prompts",
"promptEditorBothHint": "Escreva {{placeholder}} para ver os marcadores disponíveis ou {{mention}} para ver prompts guardados.",
"promptEditorPlaceholderHint": "Escreva {{placeholder}} para ver os marcadores disponíveis.",
"promptEditorPromptReferenceHint": "Escreva {{mention}} para ver os prompts guardados.",
"promptCreateFailed": "Não foi possível criar o prompt",
"promptDelete": "Eliminar prompt",
"promptDeleteDescription": "Isto remove permanentemente <2>{{name}}</2>. Esta ação não pode ser anulada.",
+8 -1
View File
@@ -146,6 +146,13 @@
"personalAccessToken": "个人访问令牌",
"placeholderTitleDescription": "Azure DevOps 工作项或拉取请求的标题",
"placeholderUrlDescription": "Azure DevOps 工作项或拉取请求的 URL",
"placeholderDescriptionBody": "描述正文",
"placeholderIdDescription": "工作项或拉取请求 ID",
"placeholderProjectDescription": "项目名称",
"placeholderSourceBranchDescription": "源分支",
"placeholderStateDescription": "当前状态",
"placeholderTargetBranchDescription": "目标分支",
"placeholderTypeDescription": "工作项类型",
"planningAndEffort": "规划与工作量",
"policiesLabelled": "策略:{{state}}",
"pollIntervalSeconds": "轮询间隔(秒)",
@@ -153,7 +160,7 @@
"previousPullRequestPage": "上一页拉取请求",
"project": "项目",
"prompt": "提示词",
"promptPlaceholdersHint": "输入 {{open}} 可查看可用占位符。运行操作时会替换 <3>{{url}}</3> 和 <6>{{title}}</6>。",
"promptPlaceholdersHint": "输入 {{open}} 可查看可用占位符。运行操作时会替换 <4>{{url}}</4> 和 <6>{{title}}</6>。",
"pullRequestAny": "任意",
"pullRequestFeedback": "拉取请求反馈",
"pullRequestFilter": "拉取请求筛选条件",
+6
View File
@@ -205,6 +205,12 @@
"projectPaths": "项目路径",
"projects": "项目",
"promptSentToTheSelectedAgent": "为每个新匹配项发送给所选智能体配置的提示词。",
"placeholderActionTitle": "合并请求或问题的标题",
"placeholderActionUrl": "合并请求或问题的 URL",
"placeholderDescription": "描述正文",
"placeholderIssueIid": "问题 IID",
"placeholderMergeRequestIid": "合并请求 IID",
"placeholderProjectPath": "项目路径",
"promptUsingUrlAndTitle": "使用 {{url}} 和 {{title}} 的提示词",
"query": "查询",
"quickActions": "快捷操作",
+4 -1
View File
@@ -491,7 +491,10 @@
"profileForTasksCreatedByAgents": "智能体创建任务时使用的配置",
"programming": "编程",
"promptAdd": "添加提示词",
"promptContentPlaceholder": "提示词内容",
"promptEditorAriaLabel": "提示词编辑器",
"promptEditorBothHint": "输入 {{placeholder}} 查看可用占位符,或输入 {{mention}} 查看已保存的提示词。",
"promptEditorPlaceholderHint": "输入 {{placeholder}} 查看可用占位符。",
"promptEditorPromptReferenceHint": "输入 {{mention}} 查看已保存的提示词。",
"promptCreateFailed": "无法创建提示词",
"promptDelete": "删除提示词",
"promptDeleteDescription": "这将永久移除 <2>{{name}}</2>。此操作无法撤销。",
+8 -1
View File
@@ -146,6 +146,13 @@
"personalAccessToken": "個人存取權杖",
"placeholderTitleDescription": "Azure DevOps 工作項或拉取要求的標題",
"placeholderUrlDescription": "Azure DevOps 工作項或拉取要求的 URL",
"placeholderDescriptionBody": "描述正文",
"placeholderIdDescription": "工作項或拉取要求 ID",
"placeholderProjectDescription": "項目名稱",
"placeholderSourceBranchDescription": "來源分支",
"placeholderStateDescription": "目前狀態",
"placeholderTargetBranchDescription": "目標分支",
"placeholderTypeDescription": "工作項類型",
"planningAndEffort": "規劃與工作量",
"policiesLabelled": "策略:{{state}}",
"pollIntervalSeconds": "輪詢間隔(秒)",
@@ -153,7 +160,7 @@
"previousPullRequestPage": "上一頁拉取要求",
"project": "項目",
"prompt": "提示詞",
"promptPlaceholdersHint": "輸入 {{open}} 可查看可用佔位符。執行操作時會替換 <3>{{url}}</3> 和 <6>{{title}}</6>。",
"promptPlaceholdersHint": "輸入 {{open}} 可查看可用佔位符。執行操作時會替換 <4>{{url}}</4> 和 <6>{{title}}</6>。",
"pullRequestAny": "任意",
"pullRequestFeedback": "拉取要求反饋",
"pullRequestFilter": "拉取要求篩選條件",
+6
View File
@@ -205,6 +205,12 @@
"projectPaths": "項目路徑",
"projects": "項目",
"promptSentToTheSelectedAgent": "為每個新匹配項傳送給所選代理程式設定檔的提示詞。",
"placeholderActionTitle": "合併請求或問題的標題",
"placeholderActionUrl": "合併請求或問題的 URL",
"placeholderDescription": "描述正文",
"placeholderIssueIid": "問題 IID",
"placeholderMergeRequestIid": "合併請求 IID",
"placeholderProjectPath": "項目路徑",
"promptUsingUrlAndTitle": "使用 {{url}} 和 {{title}} 的提示詞",
"query": "查詢",
"quickActions": "快捷操作",
+4 -1
View File
@@ -491,7 +491,10 @@
"profileForTasksCreatedByAgents": "代理程式建立任務時使用的設定",
"programming": "編程",
"promptAdd": "新增提示詞",
"promptContentPlaceholder": "提示詞內容",
"promptEditorAriaLabel": "提示詞編輯器",
"promptEditorBothHint": "輸入 {{placeholder}} 查看可用佔位符,或輸入 {{mention}} 查看已儲存的提示詞。",
"promptEditorPlaceholderHint": "輸入 {{placeholder}} 查看可用佔位符。",
"promptEditorPromptReferenceHint": "輸入 {{mention}} 查看已儲存的提示詞。",
"promptCreateFailed": "無法建立提示詞",
"promptDelete": "刪除提示詞",
"promptDeleteDescription": "這將永久移除 <2>{{name}}</2>。此操作無法撤銷。",
+8 -1
View File
@@ -146,6 +146,13 @@
"personalAccessToken": "個人存取權杖",
"placeholderTitleDescription": "Azure DevOps 工作項或提取要求的標題",
"placeholderUrlDescription": "Azure DevOps 工作項或提取要求的 URL",
"placeholderDescriptionBody": "描述內容",
"placeholderIdDescription": "工作項或提取要求 ID",
"placeholderProjectDescription": "專案名稱",
"placeholderSourceBranchDescription": "來源分支",
"placeholderStateDescription": "目前狀態",
"placeholderTargetBranchDescription": "目標分支",
"placeholderTypeDescription": "工作項類型",
"planningAndEffort": "規劃與工作量",
"policiesLabelled": "策略:{{state}}",
"pollIntervalSeconds": "輪詢間隔(秒)",
@@ -153,7 +160,7 @@
"previousPullRequestPage": "上一頁提取要求",
"project": "專案",
"prompt": "提示詞",
"promptPlaceholdersHint": "輸入 {{open}} 可檢視可用佔位符。執行操作時會替換 <3>{{url}}</3> 和 <6>{{title}}</6>。",
"promptPlaceholdersHint": "輸入 {{open}} 可檢視可用佔位符。執行操作時會替換 <4>{{url}}</4> 和 <6>{{title}}</6>。",
"pullRequestAny": "任意",
"pullRequestFeedback": "提取要求意見回饋",
"pullRequestFilter": "提取要求篩選條件",
+6
View File
@@ -205,6 +205,12 @@
"projectPaths": "專案路徑",
"projects": "專案",
"promptSentToTheSelectedAgent": "為每個新匹配項傳送給所選代理程式設定檔的提示詞。",
"placeholderActionTitle": "合併請求或問題的標題",
"placeholderActionUrl": "合併請求或問題的 URL",
"placeholderDescription": "描述內容",
"placeholderIssueIid": "問題 IID",
"placeholderMergeRequestIid": "合併請求 IID",
"placeholderProjectPath": "專案路徑",
"promptUsingUrlAndTitle": "使用 {{url}} 和 {{title}} 的提示詞",
"query": "查詢",
"quickActions": "快捷操作",
+4 -1
View File
@@ -491,7 +491,10 @@
"profileForTasksCreatedByAgents": "代理程式建立任務時使用的設定",
"programming": "程式設計",
"promptAdd": "新增提示詞",
"promptContentPlaceholder": "提示詞內容",
"promptEditorAriaLabel": "提示詞編輯器",
"promptEditorBothHint": "輸入 {{placeholder}} 查看可用佔位符,或輸入 {{mention}} 查看已儲存的提示詞。",
"promptEditorPlaceholderHint": "輸入 {{placeholder}} 查看可用佔位符。",
"promptEditorPromptReferenceHint": "輸入 {{mention}} 查看已儲存的提示詞。",
"promptCreateFailed": "無法建立提示詞",
"promptDelete": "刪除提示詞",
"promptDeleteDescription": "這將永久移除 <2>{{name}}</2>。此操作無法撤銷。",
@@ -0,0 +1,44 @@
# ADR-2026-08-20-settings-prompt-editor-provider-ownership: Settings Prompt Editors Own Monaco Completion Providers
**Status:** accepted
**Date:** 2026-08-20
**Area:** frontend
## Context
Monaco registers completion providers by language, but Settings can mount more
than one prompt editor with different placeholder and saved-prompt catalogs.
The previous language-wide singleton allowed the last mounted editor to replace
the provider used by another open editor. Saved-prompt catalogs also load
asynchronously, so registration must handle data arriving before or after
Monaco mounts.
## Decision
Each `ScriptEditor` instance owns its primary and saved-prompt completion
registrations. The registrations wrap their providers with a model-identity
check and return no suggestions for another model. Provider disposables are
updated and cleaned up per instance. The shared `SettingsPromptEditor` enables
saved-prompt registration only for fields whose runtime expands `@name`
references, and its completion widget is layered above the mobile Settings Save
bar.
## Consequences
Open editors no longer share mutable provider state, so placeholder and saved-
prompt suggestions remain local to the editor that requested them. Each editor
performs its own registration and disposal work, and provider changes replace
only that editor's registration. The shared prompt editor must retain the
latest registration callback because prompt data may load before Monaco's
one-shot mount callback.
## Alternatives Considered
- Keep one language-wide provider per slot. This is simpler, but it reintroduces
cross-editor catalog leakage and makes unmount order observable.
- Keep a global provider and route requests through the active editor. This
requires focus and model bookkeeping outside Monaco and is fragile when
several editors are mounted or a completion request races a focus change.
- Give every editor a distinct Monaco language identifier. This avoids some
collisions, but multiplies language registrations and does not improve the
provider lifecycle or async catalog handling.
+1
View File
@@ -179,5 +179,6 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-17-separate-task-activity-from-summary-freshness | [Separate Task Activity From Summary Freshness](2026-08-17-separate-task-activity-from-summary-freshness.md) | accepted | backend, frontend, protocol | 2026-08-17 |
| 2026-08-17-release-pr-ruleset-bypass | [Give Stable Release PRs an Administrator Token Bypass](2026-08-17-release-pr-ruleset-bypass.md) | accepted | infra, workflow, security | 2026-08-17 |
| 2026-08-18-never-started-agent-stall-terminal | [Treat Never-Started Agent Stalls as Terminal](2026-08-18-never-started-agent-stall-terminal.md) | accepted | backend, frontend, protocol | 2026-08-18 |
| 2026-08-20-settings-prompt-editor-provider-ownership | [Settings Prompt Editors Own Monaco Completion Providers](2026-08-20-settings-prompt-editor-provider-ownership.md) | accepted | frontend | 2026-08-20 |
| 2026-08-14-kandev-native-agent-rich-output | [Keep Agent Rich Output Host Native](2026-08-14-kandev-native-agent-rich-output.md) | accepted | backend, frontend, protocol, security | 2026-08-14 |
| 2026-08-20-acp-client-non-underscore-extension-methods | [Route Non-Underscore Inbound Client Methods to the Extension Handler](2026-08-20-acp-client-non-underscore-extension-methods.md) | accepted | backend, protocol | 2026-08-20 |
@@ -0,0 +1,142 @@
---
spec: docs/specs/ui/settings-prompt-editor.md
created: 2026-08-20
status: done
---
# Fix Plan: Settings Prompt Completion Regressions
## Overview
Correct completion metadata and loading races in the shared Monaco prompt
editor. Placeholder completion must account for an unfinished name suffix
before existing closing braces. Saved-prompt completion must expose the prompt
name as its filtering value, keep the `@name` display label, and continue
filtering across spaces in valid multi-word names. Multiple mounted editors
must share one in-flight prompt request. The repair remains frontend-only and
preserves the existing plain-text and save contracts.
## Confirmed Root Cause
- For `{{|}}`, the placeholder provider uses the cursor-only replacement range
and always inserts `${key}}}`. The existing `}}` remains after the cursor,
so selection produces `{{key}}}}`. The same range bug leaves a suffix such
as `_prompt}}` behind when the cursor is inside `{{ta_prompt}}`.
- For `@c`, the saved-prompt provider uses an `@name` label but its
replacement range starts after `@`. Monaco filters the typed name prefix
against the label when `filterText` is absent, so a valid prefix such as
`c` does not match the label's first character, `@`.
- The `[\w-]*` mention-prefix pattern stops at a space. A valid prompt named
`Daily Summary` therefore loses its mention start when the user types
`@Daily `.
- Every mounted editor can observe `loading=false` in the same render and
start its own request. A late failure can then overwrite the shared prompt
list with an empty result.
## Frontend
### Completion provider behavior
Update `apps/web/components/settings/profile-edit/script-editor-completions.ts`:
- Make placeholder completion insert only the placeholder name when the text
after the cursor contains the remaining placeholder name followed by `}}`.
Extend the replacement range across that suffix.
- Keep the existing insertion of one closing pair for an unfinished token.
- Set each saved-prompt completion item's `filterText` to `prompt.name` while
retaining the `@${prompt.name}` display label and the existing insertion
value.
- Find the whitespace-bounded mention start directly, use the complete
substring after `@` as the active prefix, and retrigger completion after a
space.
- Share an in-flight `listPrompts` request across mounted hook instances while
applying the settled result to each subscribing store consumer.
No component API, backend contract, persistence format, or mobile composition
changes are required.
## Tests
- **What:** Placeholder selection does not duplicate an existing closing pair
and still closes an unfinished token.
**File:** `apps/web/components/settings/profile-edit/script-editor-completions.test.ts`.
**How:** Call the provider with `{{}}` and the cursor between the pairs, then
with `{{` at the end, and assert the exact `insertText` values.
- **What:** Saved-prompt suggestions remain filterable by the typed name.
**File:** `apps/web/components/settings/profile-edit/script-editor-completions.test.ts`.
**How:** Assert each suggestion exposes the prompt name as `filterText` while
its label remains prefixed with `@`.
## E2E Tests
- **Scenario:** **GIVEN** a saved prompt named `changes-walkthrough`, **WHEN**
the user types `@c` in a shared Settings prompt editor, **THEN** the matching
suggestion remains visible and can be selected.
**Files:** `apps/web/e2e/tests/workflow/workflow-step-autocomplete.spec.ts`
and the existing GitHub quick-action autocomplete coverage if needed.
- **Scenario:** **GIVEN** the editor contains `{{}}` with the cursor inside,
**WHEN** the user selects a placeholder, **THEN** the persisted draft contains
one balanced token rather than duplicated closing braces.
**Files:** `apps/web/e2e/tests/integrations/github-quick-action-prompt-autocomplete.spec.ts`
and its mobile counterpart.
## Review fixup scope
The review follow-up extends the original repair with these cases:
- A partially typed `{{ta_prompt}}` replaces the remaining name suffix before
existing `}}` and inserts only the selected key.
- A whitespace-bounded mention such as `@Daily ` keeps a valid multi-word
prompt name available. Space retriggers use the full text after `@` as the
active replacement range.
- Two mounted prompt editors share one in-flight `listPrompts` request, and
each consumer receives the settled result.
- Desktop and mobile fixtures use unique prompt names. The GitHub quick-action
fixture resets action presets in `finally` after it saves a draft.
The focused provider suite and hook suite cover the first three cases. The
existing desktop and mobile completion scenarios cover browser filtering and
cleanup.
## Verification Results
- RED: `pnpm --filter @kandev/web test -- --run components/settings/profile-edit/script-editor-completions.test.ts` failed as expected with 2 regression failures: duplicated placeholder braces and missing saved-prompt `filterText`.
- GREEN: the same focused provider suite passed 10/10 tests.
- `make build-backend build-web-e2e build-e2e-plugin-package` completed successfully; only existing build warnings were emitted.
- `pnpm e2e:raw --project=chromium e2e/tests/workflow/workflow-step-autocomplete.spec.ts e2e/tests/integrations/github-quick-action-prompt-autocomplete.spec.ts` passed 5/5.
- `pnpm e2e:raw --project=mobile-chrome e2e/tests/integrations/mobile-github-quick-action-prompt-autocomplete.spec.ts` passed 1/1.
- `pnpm run typecheck` passed.
- Focused ESLint passed for the changed completion source and unit test.
- `git diff --check` passed. No temporary source or browser fixture files remain.
- Review fixup RED: provider regressions failed for the partial placeholder and
multi-word mention, while the two-editor hook regression observed two prompt
requests.
- Review fixup GREEN: focused provider, hook, shared-editor, and Monaco suites
passed 21 tests; web typecheck and focused ESLint passed.
- Review fixup GREEN: `make build-backend build-web-e2e
build-e2e-plugin-package` passed; Chromium coverage passed 5/5 and mobile
coverage passed 1/1. The GitHub test completed its prompt and preset cleanup.
- Review fixup GREEN: `git diff --check` passed.
## Implementation Waves And Parallel Candidates
Wave 1:
- [x] [task-01-completion-provider-regressions](task-01-completion-provider-regressions.md)
The task is sequential because it changes one shared provider and its unit and
browser regression coverage.
## Out of Scope
- Changes to saved-prompt expansion, placeholder interpolation, or backend APIs.
- Changes to Monaco theme, layout, mobile composition, or completion contents.
- New public documentation, because this repair restores the behavior already
promised by the shipped prompt-editor spec.
## Risks
- Completion insertion must distinguish a cursor before existing `}}` from an
unfinished token without changing replacement ranges for typed names.
- `filterText` must affect Monaco's filtering only; the visible `@name` label
and inserted `name` value must remain unchanged.
@@ -0,0 +1,106 @@
---
id: "01-completion-provider-regressions"
title: "Fix prompt completion insertion and filtering"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/settings-prompt-editor.md"
---
# Task 01: Fix prompt completion insertion and filtering
## Acceptance
- Selecting a placeholder at `{{|}}` produces exactly `{{key}}` and does not
duplicate the existing closing braces.
- Selecting a placeholder after `ta` in `{{ta_prompt}}` replaces the full
partial token name and produces exactly `{{key}}`.
- Selecting a placeholder at an unfinished `{{|` still produces exactly
`{{key}}`.
- Saved-prompt completion labels remain `@name`, while typing a name prefix
keeps matching suggestions available and selection still inserts `@name`.
- Saved prompts with spaces remain filterable after typing a prefix such as
`@Daily `.
- Two mounted prompt editors share one in-flight saved-prompt request.
- Desktop and mobile shared prompt-editor flows retain the same behavior.
## Verification
Follow TDD. Add the provider regression tests first and confirm they fail for
the current insertion metadata and missing `filterText`.
From `apps`:
```bash
pnpm --filter @kandev/web test -- --run components/settings/profile-edit/script-editor-completions.test.ts
pnpm --filter @kandev/web test -- --run hooks/domains/settings/use-custom-prompts.test.tsx
```
After the unit fix, rebuild the production web/backend artifacts required by
the E2E fixture and run the focused desktop and mobile scenarios:
```bash
make build-backend build-web-e2e build-e2e-plugin-package
cd apps/web && pnpm e2e:raw --project=chromium e2e/tests/workflow/workflow-step-autocomplete.spec.ts e2e/tests/integrations/github-quick-action-prompt-autocomplete.spec.ts
cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/integrations/mobile-github-quick-action-prompt-autocomplete.spec.ts
```
Also run `cd apps/web && pnpm run typecheck`, the focused ESLint command for
the changed files, and `git diff --check` before marking the task done.
## Files likely touched
- `apps/web/components/settings/profile-edit/script-editor-completions.ts`
- `apps/web/components/settings/profile-edit/script-editor-completions.test.ts`
- `apps/web/e2e/tests/workflow/workflow-step-autocomplete.spec.ts`
- `apps/web/e2e/tests/integrations/github-quick-action-prompt-autocomplete.spec.ts`
- `apps/web/e2e/tests/integrations/mobile-github-quick-action-prompt-autocomplete.spec.ts`
- `docs/specs/ui/settings-prompt-editor.md`
- `docs/plans/settings-prompt-completion-regressions/plan.md`
- `docs/plans/settings-prompt-completion-regressions/task-01-completion-provider-regressions.md`
## Dependencies
None.
## Parallelism
Sequential. The provider and all regression tests share the same completion
contract.
## Inputs
- The repaired behavior and scenarios in `docs/specs/ui/settings-prompt-editor.md`.
- The confirmed root causes and exact source files in `plan.md`.
- Existing Monaco provider tests and the shared prompt-editor E2E helpers.
## Output contract
Report the RED failures, implementation result, files changed, exact commands
and outcomes, any browser screenshot or cleanup evidence, and synchronized task
and plan status.
## Review fixup scope
- Cover the partial `{{ta_prompt}}` replacement range and multi-word `@Daily `
filtering contract.
- Deduplicate prompt loading for two mounted editors and deliver one settled
result to both consumers.
- Use unique prompt fixtures and restore GitHub action presets in `finally`.
## Results
- RED: `pnpm --filter @kandev/web test -- --run components/settings/profile-edit/script-editor-completions.test.ts` failed with the expected two assertions: `filterText` was undefined and `{{}}` completion returned `task_prompt}}`.
- `apps/web/components/settings/profile-edit/script-editor-completions.ts` now preserves an existing trailing `}}` and sets saved-prompt `filterText` to the prompt name.
- Added unit regressions for balanced and unfinished placeholder tokens and `@` name filtering.
- Updated desktop workflow E2E coverage to complete inside `{{}}` and type `@c`; prompt fixtures use unique `c-` names to avoid seeded-data collisions.
- Updated the mobile GitHub quick-action E2E flow to type `@c` and verify touch filtering.
- GREEN: focused provider suite passed 10/10.
- `make build-backend build-web-e2e build-e2e-plugin-package` completed successfully.
- Desktop Playwright command passed 5/5; mobile Playwright command passed 1/1.
- `pnpm run typecheck`, focused ESLint, and `git diff --check` passed.
- No temporary browser artifacts were added; generated E2E failure artifacts were produced only under the existing ignored test-results directory.
- Review fixup GREEN: the partial placeholder, multi-word mention, and
two-editor request regressions passed. Chromium passed 5/5 and mobile passed
1/1 after the fixture cleanup changes.
+192
View File
@@ -0,0 +1,192 @@
---
spec: docs/specs/ui/settings-prompt-editor.md
created: 2026-08-20
status: done
---
# Implementation Plan: Settings Prompt Editor
## Overview
Add a prompt-specific Settings editor above the existing Monaco `ScriptEditor`. First, scope completion providers to each Monaco model.
Then migrate all prompt-authoring settings fields to the shared editor.
The change preserves all stored strings and current save APIs. It adds frontend behavior, focused tests, desktop and mobile E2E coverage, and public documentation.
## Audit
The current prompt editors have four different states:
- Workflow step and GitHub review-watch prompts support placeholders and saved-prompt references.
- GitHub, Jira, and Azure DevOps quick actions support placeholders only.
- Several watcher, workflow, automation, and utility prompts use Monaco with only part of the available completion contract.
- GitLab quick actions, GitLab watches, Azure DevOps watches, workflow prompts, and custom prompts use plain textareas.
`ScriptEditor` registers one completion provider per language and slot. The last mounted editor can replace the provider for another mounted editor.
## Backend
No backend, database, API, or interpolation change is required.
Task descriptions already resolve saved-prompt references during workflow prompt assembly. Utility prompts use a separate sessionless template engine and do not resolve these references.
## Frontend
### Shared prompt editor
Create `apps/web/components/settings/settings-prompt-editor.tsx` with a controlled plain-text contract:
- `value` and `onChange`
- `placeholders?: ScriptPlaceholder[]`
- `promptReferences?: boolean`
- `excludedPromptIds?: string[]`
- `language?: "markdown" | "plaintext"`
- `height`, `minLines`, `readOnly`, `ariaLabel`, `testId`, and dirty-state props
- optional supplemental help content
The component reads saved prompts through `useCustomPrompts` when prompt references are enabled. It filters excluded prompts before it passes them to `ScriptEditor`.
The component owns the border, completion hint, loading-safe prompt list, and content-based default height. Consumers keep field labels and context-specific placeholder descriptions.
Add localized completion hints to the `settings` catalogs. Use one hint for placeholders, one for prompt references, and one for both completion types.
### Monaco completion ownership
Update `apps/web/components/settings/profile-edit/script-editor.tsx`. Replace the language-wide singleton registrations with per-instance disposables.
Wrap each completion provider with a model identity check. A provider returns no items when Monaco calls it for a different model.
Dispose both providers when the editor unmounts. Re-register only the changed provider when placeholders or saved prompts change.
Expose stable accessibility and test attributes from `ScriptEditor`. Keep the existing script-editor consumers compatible.
### Quick actions
Use `SettingsPromptEditor` in these files:
- `apps/web/components/github/action-presets-section.tsx`
- `apps/web/components/gitlab/action-presets-section.tsx`
- `apps/web/components/jira/task-presets-section.tsx`
- `apps/web/components/azure-devops/azure-devops-quick-actions.tsx`
Enable saved-prompt references for every quick action. Keep each provider's current `{{...}}` tokens.
Add GitLab `{{url}}` and `{{title}}` definitions. Keep the current quick-action data model and shared save contributor.
### Watch prompts
Use `SettingsPromptEditor` for GitHub, GitLab, Jira, Linear, Sentry, and Azure DevOps watch prompts. Enable saved-prompt references for all watch prompts.
Reuse existing placeholder definitions for GitHub, GitLab, Jira, Linear, and Sentry. Add typed Azure DevOps definitions for work-item and pull-request watch tokens.
Do not migrate provider query fields such as WIQL or JQL. These fields are not agent prompts.
### Other Settings prompts
Use `SettingsPromptEditor` for these settings fields:
- workflow-level prompts
- workflow step prompts
- automation instructions
- custom prompt content
- utility agent prompt templates
Enable saved-prompt references for workflow, automation, and custom prompt fields. Exclude the current custom prompt from its own completion list.
Disable saved-prompt references for utility prompts. Keep utility template variables enabled.
Update existing tests that assume a native textarea. Use the stable editor test ID and controlled-value assertions instead.
### Mobile design contract
- **Desktop outcome and mobile entry:** both viewports use the existing Settings routes and existing quick-action expansion control.
- **Nearest mobile exemplars:** `mobile-github-integration-layout.spec.ts` and `mobile-workflow-settings.spec.ts` define the settings-page composition and overflow rules.
- **Hierarchy:** the field label and completion hint remain above and below the inline editor. The route-level Save changes control remains primary.
- **Presentation:** keep the editor inline. A drawer adds no value because prompt editing is the primary content of the expanded settings row.
- **Scroll owner:** the Settings page remains the page scroll owner. Monaco owns vertical editor scroll after the content-height cap.
- **Touch and geometry:** completion items support touch selection. The editor and suggestion widget must stay inside the phone viewport.
- **Shared logic:** editor value, completion providers, dirty state, and save behavior are identical across viewports.
## Tests
- **What:** two mounted editors keep separate placeholder and saved-prompt providers.
**Files:** `apps/web/components/settings/profile-edit/script-editor.test.tsx` and `script-editor-completions.test.ts`.
**How:** use Monaco stubs with distinct models. Make sure that each provider rejects the other model and disposes on unmount.
- **What:** the shared editor selects the correct completion modes and excludes the open custom prompt.
**File:** `apps/web/components/settings/settings-prompt-editor.test.tsx`.
**How:** mock `ScriptEditor` and `useCustomPrompts`. Assert props, dynamic prompt updates, dirty state, help text, and no prompt fetch error effect.
- **What:** quick actions preserve drafts, placeholders, reset behavior, and save coordination while enabling prompt references.
**Files:** focused tests beside the four quick-action sections.
**How:** mock the shared editor and assert provider tokens, prompt-reference mode, and unchanged save payloads.
- **What:** watcher and core Settings prompts use the shared editor with the correct completion contract.
**Files:** existing watcher, workflow, and custom-prompt component tests. Add focused tests where no consumer test exists.
**How:** assert component props and controlled updates. Keep persistence tests on the existing save coordinator.
## E2E Tests
- **Scenario:** **GIVEN** a saved prompt and GitHub quick action, **WHEN** the user selects `@name` and `{{title}}`, **THEN** both references persist after reload.
**File:** `apps/web/e2e/tests/integrations/github-quick-action-prompt-autocomplete.spec.ts`.
**What to verify:** both Monaco menus, keyboard or pointer selection, route save, reload persistence, and no automatic save on selection.
- **Scenario:** **GIVEN** GitHub settings on a phone, **WHEN** the user selects both completion types, **THEN** the editor remains usable without page overflow.
**File:** `apps/web/e2e/tests/integrations/mobile-github-quick-action-prompt-autocomplete.spec.ts`.
**What to verify:** touch entry, suggestion selection, viewport containment, internal editor scrolling, and no document horizontal overflow.
- **Scenario:** **GIVEN** a custom prompt editor, **WHEN** the user types `@`, **THEN** other prompts appear and the current prompt is absent.
**File:** `apps/web/e2e/tests/settings/prompts-settings.spec.ts`.
**What to verify:** create and edit paths, selection, save, and reload persistence.
- **Scenario:** existing workflow step completion behavior remains available through the shared editor.
**File:** `apps/web/e2e/tests/workflow/workflow-step-autocomplete.spec.ts`.
**What to verify:** existing placeholder and saved-prompt cases remain green.
## Public documentation
Update these user guides:
- `docs/public/integrations.md` for quick-action and watch prompt completion.
- `docs/public/workflow-tips.md` for workflow-level and step prompt completion.
- `docs/public/developer-tools.md` for nested saved prompts and the utility prompt exception.
These pages remain how-to or explanation pages. No new navigation entry is required.
## Verification Results
- Focused unit/component suites pass for the shared editor, Monaco ownership, quick actions, watch prompts, and core Settings prompts.
- `pnpm run typecheck` and `pnpm exec tsc --noEmit --incremental false` pass.
- `pnpm run i18n:check` and `pnpm run i18n:ratchet` pass.
- `node --test scripts/validate-public-docs.test.mjs` passes (61 tests), and `node scripts/validate-public-docs.mjs` validates 41 published pages.
- Desktop and mobile managed Playwright runs pass for the new completion flows, custom-prompt exclusion, and existing workflow autocomplete.
- `git diff --check` passes.
## Implementation Waves And Parallel Candidates
Wave 1:
- [x] [task-01-shared-editor](task-01-shared-editor.md)
Wave 2:
- [x] [task-02-quick-actions](task-02-quick-actions.md)
Wave 3:
- [x] [task-03-watch-prompts](task-03-watch-prompts.md)
- [x] [task-04-core-settings-prompts](task-04-core-settings-prompts.md)
Wave 4:
- [x] [task-05-browser-coverage](task-05-browser-coverage.md)
- [x] [task-06-public-documentation](task-06-public-documentation.md)
Tasks 03 and 04 are parallel-safe after Task 01 because their production files are disjoint.
Tasks 05 and 06 are parallel-safe after Tasks 02 through 04.
The primary conversation runs tasks sequentially by default. These labels do not authorize subagents.
## Risks
- Monaco completion providers are language-global. Model scoping must prevent cross-editor suggestions without breaking existing script completions.
- Migrated native textareas change E2E input mechanics. Tests must focus Monaco's `native-edit-context` before they type.
- GitLab and Azure DevOps watcher placeholders exist in backend interpolators. Frontend lists must match those exact tokens.
- A saved-prompt suggestion is valid only when the runtime expands references. Utility prompts must not enable that completion mode.
- Several settings routes can mount more than one editor. The component must avoid repeated prompt requests and provider churn.
@@ -0,0 +1,66 @@
---
id: "01-shared-editor"
title: "Build shared prompt editor"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/settings-prompt-editor.md"
---
# Task 01: Build shared prompt editor
## Acceptance
- `SettingsPromptEditor` provides controlled plain text, configured placeholders, optional saved-prompt references, exclusions, accessibility, dirty state, and localized hints.
- Two mounted Monaco editors return completion items only for their own model.
- Provider updates and unmounts dispose the correct Monaco registrations without changing existing script-editor behavior.
## Verification
Follow TDD. Add the model-isolation and shared-component tests first. Make sure that they fail against the language-wide provider ownership.
```bash
cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run components/settings/settings-prompt-editor.test.tsx components/settings/profile-edit/script-editor.test.tsx components/settings/profile-edit/script-editor-completions.test.ts && cd web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet && pnpm exec eslint components/settings/settings-prompt-editor.tsx components/settings/settings-prompt-editor.test.tsx components/settings/profile-edit/script-editor.tsx components/settings/profile-edit/script-editor.test.tsx components/settings/profile-edit/script-editor-completions.ts components/settings/profile-edit/script-editor-completions.test.ts
```
## Files likely touched
- `apps/web/components/settings/settings-prompt-editor.tsx`
- `apps/web/components/settings/settings-prompt-editor.test.tsx`
- `apps/web/components/settings/profile-edit/script-editor.tsx`
- `apps/web/components/settings/profile-edit/script-editor.test.tsx`
- `apps/web/components/settings/profile-edit/script-editor-completions.ts`
- `apps/web/components/settings/profile-edit/script-editor-completions.test.ts`
- `apps/web/src/locales/en/settings.json`
- `apps/web/src/locales/pt-pt/settings.json`
- `apps/web/src/locales/zh-cn/settings.json`
- `apps/web/src/locales/zh-hk/settings.json`
- `apps/web/src/locales/zh-tw/settings.json`
## Dependencies
None.
## Parallelism
Sequential. All later tasks depend on this component and its provider ownership contract.
## Inputs
- Spec: `What`, `Failure modes`, and the model-isolation scenarios.
- Plan: `Shared prompt editor`, `Monaco completion ownership`, and `Risks`.
- Existing patterns: `ScriptEditor`, `useCustomPrompts`, and `script-editor-completions.test.ts`.
## Output contract
Report the RED error, final API, model-isolation method, files changed, exact command results, blockers, risks, and synchronized task and plan status.
## Results
- The initial browser run exposed a lifecycle race: saved prompts loaded before Monaco mounted, but the one-shot `onMount` callback still held an empty prompt list. The editor now keeps the latest registration callbacks in refs and registers per-model providers.
- `ScriptEditor` owns one primary and one saved-prompt registration per mounted model, scopes both through `scopeCompletionProviderToModel`, and disposes them independently.
- `SettingsPromptEditor` owns the controlled plain-text contract, localized completion hints, dirty attributes, stable editor attributes, prompt loading, and current-prompt exclusions.
- Focused shared-editor/component/completion tests pass: 3 files, 13 tests; the regression test covers prompt data loading before Monaco mount.
- `pnpm run typecheck` and `pnpm exec tsc --noEmit --incremental false` pass.
- `pnpm run i18n:check` and `pnpm run i18n:ratchet` pass.
@@ -0,0 +1,62 @@
---
id: "02-quick-actions"
title: "Migrate quick action prompts"
status: done
wave: 2
depends_on: ["01-shared-editor"]
plan: "plan.md"
spec: "../../specs/ui/settings-prompt-editor.md"
---
# Task 02: Migrate quick action prompts
## Acceptance
- GitHub, GitLab, Jira, and Azure DevOps quick actions use `SettingsPromptEditor`.
- Each provider offers its exact placeholders and saved-prompt references.
- Draft, reset, dirty-state, validation, and shared Save changes behavior remain unchanged.
## Verification
Follow TDD. Add consumer assertions for completion modes and tokens before each migration.
```bash
cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run components/github/action-presets-section.test.tsx components/gitlab/action-presets-section.test.tsx components/jira/task-presets-section.test.tsx components/azure-devops/azure-devops-quick-actions.test.tsx && cd web && pnpm run i18n:ratchet && pnpm exec eslint components/github/action-presets-section.tsx components/github/action-presets-section.test.tsx components/gitlab/action-presets-section.tsx components/gitlab/action-presets-section.test.tsx components/jira/task-presets-section.tsx components/jira/task-presets-section.test.tsx components/azure-devops/azure-devops-quick-actions.tsx components/azure-devops/azure-devops-quick-actions.test.tsx
```
## Files likely touched
- `apps/web/components/github/action-presets-section.tsx`
- `apps/web/components/github/action-presets-section.test.tsx`
- `apps/web/components/gitlab/action-presets-section.tsx`
- `apps/web/components/gitlab/action-presets-section.test.tsx`
- `apps/web/components/jira/task-presets-section.tsx`
- `apps/web/components/jira/task-presets-section.test.tsx`
- `apps/web/components/azure-devops/azure-devops-quick-actions.tsx`
- `apps/web/components/azure-devops/azure-devops-quick-actions.test.tsx`
- GitLab locale catalogs only if new provider-specific placeholder descriptions are required
## Dependencies
Task 01.
## Parallelism
Sequential. All four consumers share the new component contract and quick-action E2E surface.
## Inputs
- Spec: `Settings surfaces` and the GitHub quick-action scenarios.
- Plan: `Quick actions` and `Shared prompt editor`.
- Existing patterns: GitHub and Azure DevOps expandable prompt rows, Jira placeholder definitions, and each save contributor.
## Output contract
Report the RED error, provider token lists, save-payload evidence, files changed, exact command results, blockers, risks, and synchronized task and plan status.
## Results
- GitHub, GitLab, Jira, and Azure DevOps quick-action prompt fields use `SettingsPromptEditor` with saved-prompt references and their provider-specific placeholder maps.
- GitLab now exposes localized `{{url}}` and `{{title}}` descriptions. Existing draft, reset, dirty-state, and save-contributor paths remain unchanged.
- Focused consumer tests pass: 7 tests across 4 files.
- `pnpm run i18n:ratchet` passes with no new hardcoded-copy violations.
@@ -0,0 +1,69 @@
---
id: "03-watch-prompts"
title: "Migrate watch prompts"
status: done
wave: 3
depends_on: ["01-shared-editor"]
plan: "plan.md"
spec: "../../specs/ui/settings-prompt-editor.md"
---
# Task 03: Migrate watch prompts
## Acceptance
- GitHub, GitLab, Jira, Linear, Sentry, and Azure DevOps watch prompts use `SettingsPromptEditor`.
- Each watch offers backend-supported placeholders and saved-prompt references.
- Query fields, watcher validation, responsive dialog composition, and persistence remain unchanged.
## Verification
Follow TDD. Add focused assertions for GitLab and Azure DevOps token lists before the textarea migrations.
```bash
cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run components/github/review-watch-dialog.test.tsx components/gitlab/review-watch-dialog.test.tsx components/gitlab/issue-watch-dialog.test.tsx components/azure-devops/azure-devops-watch-settings.test.tsx components/sentry/sentry-issue-watch-dialog.test.tsx && cd web && pnpm run i18n:ratchet && pnpm exec eslint components/github/issue-watch-dialog.tsx components/github/review-watch-prompt-field.tsx components/gitlab/watch-dialog.tsx components/gitlab/review-watch-placeholders.ts components/gitlab/issue-watch-placeholders.ts components/jira/jira-issue-watch-dialog.tsx components/linear/linear-issue-watch-dialog.tsx components/sentry/sentry-issue-watch-dialog.tsx components/azure-devops/azure-devops-watch-settings.tsx
```
## Files likely touched
- `apps/web/components/github/issue-watch-dialog.tsx`
- `apps/web/components/github/review-watch-prompt-field.tsx`
- `apps/web/components/github/review-watch-dialog.test.tsx`
- `apps/web/components/gitlab/watch-dialog.tsx`
- `apps/web/components/gitlab/review-watch-placeholders.ts`
- `apps/web/components/gitlab/issue-watch-placeholders.ts`
- `apps/web/components/gitlab/review-watch-dialog.test.tsx`
- `apps/web/components/gitlab/issue-watch-dialog.test.tsx`
- `apps/web/components/jira/jira-issue-watch-dialog.tsx`
- `apps/web/components/linear/linear-issue-watch-dialog.tsx`
- `apps/web/components/sentry/sentry-issue-watch-dialog.tsx`
- `apps/web/components/sentry/sentry-issue-watch-dialog.test.tsx`
- `apps/web/components/azure-devops/azure-devops-watch-settings.tsx`
- `apps/web/components/azure-devops/azure-devops-watch-placeholders.ts`
- `apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx`
- Provider locale catalogs for new placeholder descriptions and completion help
## Dependencies
Task 01.
## Parallelism
Parallel-safe with Task 04 after Task 01. The tasks use disjoint production and test files.
## Inputs
- Spec: the watch-prompt row in `Settings surfaces` and the saved-prompt failure scenario.
- Plan: `Watch prompts` and `Mobile design contract`.
- Backend token authority: `source_gitlab.go`, `source_azuredevops.go`, and existing provider placeholder modules.
## Output contract
Report the RED error, exact token maps, responsive behavior, files changed, command results, blockers, risks, and synchronized task and plan status.
## Results
- GitHub, GitLab, Jira, Linear, Sentry, and Azure DevOps watch prompt fields use `SettingsPromptEditor`; query fields such as WIQL and JQL remain native query inputs.
- GitLab placeholder descriptions are localized and Azure DevOps work-item and pull-request token maps are typed against the backend-supported fields.
- Focused watch tests pass: 13 tests across 7 files, plus 5 Azure DevOps watch-settings tests.
- No watcher save, validation, dialog, or persistence contract changed.
@@ -0,0 +1,64 @@
---
id: "04-core-settings-prompts"
title: "Migrate core settings prompts"
status: done
wave: 3
depends_on: ["01-shared-editor"]
plan: "plan.md"
spec: "../../specs/ui/settings-prompt-editor.md"
---
# Task 04: Migrate core settings prompts
## Acceptance
- Workflow, automation, custom-prompt, and utility-agent fields use `SettingsPromptEditor`.
- Workflow, automation, and custom prompts offer saved-prompt references. The open custom prompt is excluded.
- Utility prompts offer only utility variables. Existing dirty, save, reset, read-only, and cross-tab behavior remains unchanged.
## Verification
Follow TDD. Update native-textarea assumptions only after the shared-editor assertions fail.
```bash
cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run components/settings/workflow-prompt-section.test.tsx components/settings/workflow-step-prompt-section.test.tsx components/settings/prompts-settings.test.tsx components/settings/utility-agent-dialog.test.tsx components/automations/prompt-section.test.tsx && cd web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet && pnpm exec eslint components/settings/workflow-prompt-section.tsx components/settings/workflow-prompt-section.test.tsx components/settings/workflow-step-prompt-section.tsx components/settings/workflow-step-prompt-section.test.tsx components/settings/prompts-settings.tsx components/settings/prompts-settings.test.tsx components/settings/utility-agent-dialog.tsx components/settings/utility-agent-dialog.test.tsx components/automations/prompt-section.tsx components/automations/prompt-section.test.tsx
```
## Files likely touched
- `apps/web/components/settings/workflow-prompt-section.tsx`
- `apps/web/components/settings/workflow-prompt-section.test.tsx`
- `apps/web/components/settings/workflow-step-prompt-section.tsx`
- `apps/web/components/settings/workflow-step-prompt-section.test.tsx`
- `apps/web/components/settings/prompts-settings.tsx`
- `apps/web/components/settings/prompts-settings.test.tsx`
- `apps/web/components/settings/utility-agent-dialog.tsx`
- `apps/web/components/settings/utility-agent-dialog.test.tsx`
- `apps/web/components/automations/prompt-section.tsx`
- `apps/web/components/automations/prompt-section.test.tsx`
- Relevant locale catalogs when existing help text changes
## Dependencies
Task 01.
## Parallelism
Parallel-safe with Task 03 after Task 01. The tasks use disjoint production and test files.
## Inputs
- Spec: `Settings surfaces`, `Failure modes`, and the custom-prompt and utility scenarios.
- Plan: `Other Settings prompts` and `Shared prompt editor`.
- Existing patterns: workflow step prompt references, prompt save coordination, automation placeholders, and utility variables.
## Output contract
Report the RED error, enabled completion modes, textarea-test migrations, files changed, command results, blockers, risks, and synchronized task and plan status.
## Results
- Workflow, workflow-step, automation, custom-prompt, and utility-agent fields use `SettingsPromptEditor`.
- Workflow, automation, and custom prompts enable saved-prompt references; the open custom prompt is excluded. Utility prompts keep only their template-variable completion mode.
- Focused core tests pass: 10 tests across 5 files.
- Existing save, reset, read-only, dirty-state, and controlled-value behavior remains owned by each consumer.
@@ -0,0 +1,63 @@
---
id: "05-browser-coverage"
title: "Add browser coverage"
status: done
wave: 4
depends_on: ["02-quick-actions", "03-watch-prompts", "04-core-settings-prompts"]
plan: "plan.md"
spec: "../../specs/ui/settings-prompt-editor.md"
---
# Task 05: Add browser coverage
## Acceptance
- Desktop E2E proves GitHub quick-action placeholder and saved-prompt completion, save coordination, and reload persistence.
- Custom-prompt E2E proves that an editor offers other prompts but omits itself.
- Mobile E2E proves the same GitHub completion value with touch selection and no viewport overflow.
## Verification
Follow E2E TDD. Run each new scenario before the production migration and record the missing-menu or textarea failure.
```bash
cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run tests/integrations/github-quick-action-prompt-autocomplete.spec.ts tests/settings/prompts-settings.spec.ts tests/workflow/workflow-step-autocomplete.spec.ts && pnpm e2e:run --project mobile-chrome tests/integrations/mobile-github-quick-action-prompt-autocomplete.spec.ts
```
## Files likely touched
- `apps/web/e2e/helpers/settings-prompt-editor.ts`
- `apps/web/e2e/tests/integrations/github-quick-action-prompt-autocomplete.spec.ts`
- `apps/web/e2e/tests/integrations/mobile-github-quick-action-prompt-autocomplete.spec.ts`
- `apps/web/e2e/tests/settings/prompts-settings.spec.ts`
- `apps/web/e2e/tests/workflow/workflow-step-autocomplete.spec.ts`
- `apps/web/e2e/tests/workflow/workflow-settings.spec.ts`
- `apps/web/e2e/tests/workflow/workflow-duplication.spec.ts`
- `apps/web/e2e/pages/workflow-settings-page.ts` only if a reusable editor locator belongs there
## Dependencies
Tasks 02, 03, and 04.
## Parallelism
Parallel-safe with Task 06 after Tasks 02 through 04. This task owns only browser helpers and E2E files.
## Inputs
- Spec: all Scenarios, with emphasis on quick actions, custom prompts, persistence, and phone behavior.
- Plan: `E2E Tests` and `Mobile design contract`.
- Existing patterns: `workflow-step-autocomplete.spec.ts`, `mobile-github-integration-layout.spec.ts`, and Monaco's `native-edit-context` focus target.
## Output contract
Report RED failures, discovered test counts, desktop and mobile results, screenshots or traces, cleanup evidence, overflow measurements, blockers, risks, and synchronized task and plan status.
## Results
- The first desktop run failed because the saved-prompt provider was not registered when prompt data loaded before Monaco's one-shot mount callback. The fix added latest-callback registration and a focused regression test.
- A mobile run then exposed Monaco's default `z-index: 40` competing with the mobile floating Save bar. The Settings prompt editor raises its suggestion widget to `z-index: 50`, allowing real touch selection.
- Desktop GitHub quick-action completion and reload persistence pass: 1 test.
- Mobile GitHub quick-action completion passes with touch editor entry, touch completion selection, viewport containment, and no document horizontal overflow: 1 test.
- Existing workflow completion coverage passes: 3 tests. Custom-prompt exclusion and existing prompt settings coverage passes: 4 tests.
- Fresh desktop and mobile PR asset captures were generated and visually inspected for the migrated GitHub quick-action editor.
@@ -0,0 +1,54 @@
---
id: "06-public-documentation"
title: "Update public documentation"
status: done
wave: 4
depends_on: ["02-quick-actions", "03-watch-prompts", "04-core-settings-prompts"]
plan: "plan.md"
spec: "../../specs/ui/settings-prompt-editor.md"
---
# Task 06: Update public documentation
## Acceptance
- Integration documentation explains placeholder and saved-prompt completion for quick actions and watch prompts.
- Workflow and developer-tool documentation explain the shared completion behavior and utility-agent exception.
- Public documentation validation passes without a new navigation entry.
## Verification
```bash
rg -n "quick actions|saved prompt|workflow prompt|utility" docs/public/integrations.md docs/public/workflow-tips.md docs/public/developer-tools.md && node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs
```
## Files likely touched
- `docs/public/integrations.md`
- `docs/public/workflow-tips.md`
- `docs/public/developer-tools.md`
## Dependencies
Tasks 02, 03, and 04.
## Parallelism
Parallel-safe with Task 05 after Tasks 02 through 04. This task owns only public documentation files.
## Inputs
- Spec: `What`, `Settings surfaces`, and `Out of scope`.
- Plan: `Public documentation`.
- Current docs: existing saved-prompt, workflow, watcher, quick-action, and utility sections.
## Output contract
Report pages changed, each page's primary Diátaxis type, exact command results, link impact, blockers, risks, and synchronized task and plan status.
## Results
- Updated `docs/public/integrations.md`, `docs/public/workflow-tips.md`, and `docs/public/developer-tools.md`.
- The pages explain placeholder and saved-prompt completion, route-level save/reset behavior, nested references, current-prompt exclusion, and the utility-agent exception.
- `node --test scripts/validate-public-docs.test.mjs` passes: 61 tests.
- `node scripts/validate-public-docs.mjs` passes: 41 published pages validated.
+3 -1
View File
@@ -95,7 +95,7 @@ Open **Settings > Prompts** (`/settings/prompts`) to add, edit, or delete reusab
Type `@` in the task chat composer and select a prompt. The visible message keeps the `@name`; Kandev expands the prompt content into hidden system context for the agent. References are recognized only at the start of the text or after whitespace and must match the stored name. Prompt content can reference other saved prompts. Expansion stops at a depth of eight, skips cycles, and includes each prompt only once.
The same `@name` reference works in a workflow step's Prompt field and in a GitHub Review Watch's prompt; see [Saved prompt references in step prompts](workflow-tips.md#saved-prompt-references-in-step-prompts).
The Settings prompt editor also offers the same `@name` completion when you edit a saved prompt, a workflow prompt, a workflow step, an automation instruction, a quick action, or a provider watch. The prompt being edited is excluded from its own completion list, so selecting a reference cannot create a direct self-reference by accident. The same `@name` reference works in a workflow step's Prompt field and in a GitHub Review Watch's prompt; see [Saved prompt references in step prompts](workflow-tips.md#saved-prompt-references-in-step-prompts).
Kandev seeds these built-ins:
@@ -111,6 +111,8 @@ A saved prompt is an instruction, not an authorization or policy boundary. Execu
</details>
Utility-agent prompt templates use a separate template engine. Type `{{` to select the utility variables shown by the editor, but do not use `@name` saved-prompt references there. Utility calls are sessionless and do not resolve saved prompts at runtime. Put reusable nested instructions in a saved prompt used by a task, workflow, automation, or integration prompt instead.
## Voice Mode
<details>
+8
View File
@@ -43,6 +43,14 @@ GitHub, GitLab, Azure DevOps, Jira, and Linear configuration are workspace-speci
Provider secrets saved by these forms use Kandev's encrypted secret store. The backend must still decrypt them to make API requests. Limit access to settings and the Kandev data directory, and use the narrowest provider scope that works.
### Author prompt fields
Quick actions and provider watch prompts use the same inline prompt editor. Type `{{` to open the provider's placeholder list, or type `@` after whitespace to find a [saved prompt](developer-tools.md#saved-prompts). Select an item to insert it into the draft. The menu shows the exact tokens supported by that provider and prompt type.
Common quick-action tokens are `{{url}}` and `{{title}}`. Jira quick actions also provide `{{key}}` and `{{description}}`. Watch prompts expose provider data such as `pr.*`, `issue.*`, `mr.*`, `work_item.*`, or `pull_request.*`, depending on the integration and watch type.
Selecting a completion changes the local draft only. Use the route-level **Save changes** action to persist it; **Reset** restores the provider defaults and **Cancel** or discard returns to the last saved draft. Saved-prompt references are expanded when the watch or action runs, so later edits to the referenced prompt apply without changing each integration setting.
### The Enabled switch
Jira, Linear, and Sentry pages show an **Enabled** switch. It is a browser-local preference, saved per installation in that browser and on by default. It controls some client-side entry points, availability checks, and configuration fetches; settings pages can still poll provider health. It does not delete backend configuration and does not stop a server-side watch. Pause/delete watches or remove the provider configuration when processing must stop.
+6
View File
@@ -146,6 +146,12 @@ promotion.
Pull candidates are selected by board position, then priority, queue time, creation time, and ID. A candidate that cannot be moved is skipped. Pulling runs for every limited step; a feeder is only needed for overflow created outside the destination step.
### Complete prompts while editing
Workflow and step prompt fields use the inline prompt editor. Type `@` after whitespace to select a saved prompt. In a step prompt, type `{{` to select `{{task_prompt}}` and other tokens supported by that step. The completion menu inserts the reference into the draft; it does not save the workflow. Use **Save changes** when the prompt is ready.
The workflow-level prompt supports saved-prompt references but does not expand step-only variables. `{{task_prompt}}` is available in a step prompt because it is replaced with the task description when that step runs.
## Events and actions
<details>
+1
View File
@@ -219,6 +219,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [message-queue-send-now](ui/message-queue-send-now.md) | shipped |
| [settings-manual-save](ui/settings-manual-save.md) | shipped |
| [settings-discovery](ui/settings-discovery.md) | shipped |
| [settings-prompt-editor](ui/settings-prompt-editor.md) | shipped |
| [settings-typography](settings-typography/spec.md) | draft |
| [executor-settings-card-spacing](ui/executor-settings-card-spacing.md) | shipped |
| [quick-chat-elevation](ui/quick-chat-elevation.md) | building |
+106
View File
@@ -0,0 +1,106 @@
---
status: shipped
created: 2026-08-20
owner: Kandev
---
# Settings Prompt Editor
## Why
Prompt fields in Settings use different editors and completion rules. Users cannot reliably discover placeholders or reference saved prompts across these fields.
## What
- Kandev provides one shared prompt editor for prompt-authoring fields in Settings.
- The editor stores plain text and preserves each existing settings and save contract.
- Typing `{{` shows only the placeholders that the current prompt context supports.
- Selecting a placeholder inside an existing `{{...}}` pair replaces only the
placeholder name and preserves the pair's closing braces. Selecting a
placeholder after an unfinished `{{` adds exactly one closing pair.
- Typing `@` shows saved prompts when the runtime resolves saved-prompt references for that field.
- Typing a name prefix after `@` keeps saved prompts whose names match that
prefix available for selection.
- Prompt names may contain spaces. The active mention starts at a
whitespace-bounded `@`, and the full text after it remains part of the
filtering range while the user types.
- A selected saved prompt inserts its `@name` reference. The editor does not inline or copy the saved prompt content.
- The saved-prompt list updates when a prompt is added, edited, or removed.
- A custom prompt can reference other saved prompts. Its editor does not suggest the prompt that is currently open.
- Keyboard, pointer, and touch users can open, filter, and select completion items.
- The editor keeps completion providers scoped to their editor model. One open editor cannot show completion items from another editor.
- Each field keeps its existing dirty state, validation, reset behavior, save coordinator, and persisted text format.
- Desktop and mobile settings routes provide the same completion functions.
### Settings surfaces
| Surface | Placeholder completion | Saved-prompt completion |
|---|---|---|
| GitHub, GitLab, Jira, and Azure DevOps quick actions | Provider placeholders | Yes |
| GitHub, GitLab, Jira, Linear, Sentry, and Azure DevOps watch prompts | Provider placeholders when available | Yes |
| Workflow prompt | Workflow placeholders | Yes |
| Workflow step prompt | Step placeholders | Yes |
| Automation instructions | Trigger placeholders | Yes |
| Custom prompt content | None | Yes, excluding the open prompt |
| Utility agent prompt | Utility template variables | No |
Utility prompts run through the sessionless utility template engine. That engine does not resolve saved-prompt references.
The shared editor does not replace script, query, JSON, or description fields. Those fields have different languages and completion contracts.
## Failure modes
- If saved prompts fail to load, placeholder completion continues to work. The `@` menu shows no saved prompts.
- If a field has no configured placeholders, `{{` shows no placeholder items.
- If Monaco loads slowly, the field shows the existing localized editor loading state. The controlled draft remains unchanged.
- If two prompt editors are mounted together, each editor uses only its own placeholders and saved-prompt rules.
- Concurrent mounted editors share one in-flight saved-prompt request. A
failed request cannot replace a successful result from another editor.
## Persistence guarantees
This feature adds no new persistence. Each settings surface continues to store its current plain-text value through its current API.
Selecting a completion item changes only the local draft. The existing settings save action remains the persistence boundary.
## Scenarios
- **GIVEN** a saved prompt named `review-helper`, **WHEN** a user types `@rev` in a GitHub quick action, **THEN** the editor offers `@review-helper`.
- **GIVEN** that suggestion, **WHEN** the user selects it, **THEN** the draft contains `@review-helper` and no save starts.
- **GIVEN** a GitHub quick action editor, **WHEN** a user types `{{`, **THEN** the editor offers `{{url}}` and `{{title}}`.
- **GIVEN** an editor contains `{{}}` with the cursor between the brace pairs,
**WHEN** the user selects `{{task_prompt}}`, **THEN** the draft contains
exactly `{{task_prompt}}` with no duplicated closing braces.
- **GIVEN** an editor contains an unfinished `{{` token, **WHEN** the user
selects `{{task_prompt}}`, **THEN** the draft contains exactly
`{{task_prompt}}` with one closing brace pair.
- **GIVEN** an editor contains `{{ta_prompt}}` with the cursor after `ta`,
**WHEN** the user selects `{{task_prompt}}`, **THEN** the partial name and
its existing suffix are replaced and the draft contains exactly
`{{task_prompt}}`.
- **GIVEN** a saved prompt named `changes-walkthrough`, **WHEN** a user types
`@c`, **THEN** the completion list keeps `@changes-walkthrough` available.
- **GIVEN** a saved prompt named `Daily Summary`, **WHEN** a user types
`@Daily `, **THEN** the completion list keeps `@Daily Summary` available
and selecting it inserts the complete mention.
- **GIVEN** two open editors with different placeholder lists, **WHEN** a user types `{{` in either editor, **THEN** only that editor's placeholders appear.
- **GIVEN** an open workflow prompt, **WHEN** another route adds a saved prompt, **THEN** the next `@` list includes it.
- **GIVEN** a custom prompt named `release`, **WHEN** the user edits `release` and types `@`, **THEN** the list omits `@release` and includes other saved prompts.
- **GIVEN** a utility agent prompt, **WHEN** the user types `{{`, **THEN** utility variables appear and no saved-prompt completion is offered.
- **GIVEN** a failed saved-prompt request, **WHEN** the user types a supported placeholder prefix, **THEN** placeholder completion still works.
- **GIVEN** an unsaved prompt edit, **WHEN** the user leaves the route, **THEN** the existing settings navigation guard still handles the draft.
- **GIVEN** GitHub quick actions on a phone, **WHEN** the user selects both completion types, **THEN** no horizontal page overflow occurs.
- **GIVEN** a saved quick action that contains a placeholder and saved-prompt reference, **WHEN** the page reloads, **THEN** the editor shows the same stored text.
## Out of scope
- Rich-text formatting, Markdown preview, or Tiptap document storage.
- Changes to saved-prompt expansion, placeholder interpolation, or backend APIs.
- Prompt composers outside Settings, such as task creation and task chat.
- Completion for scripts, WIQL, JQL, JSON, descriptions, or confirmation fields.
## Implementation plan
See [`../../plans/settings-prompt-editor/plan.md`](../../plans/settings-prompt-editor/plan.md).
The completion regression repair is tracked in
[`../../plans/settings-prompt-completion-regressions/plan.md`](../../plans/settings-prompt-completion-regressions/plan.md).