feat(settings): unify prompt editors (#2855)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user