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
@@ -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>
);
}