From 24462de07dc404f6b9541d155c8eacd7d72e08c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 21 Aug 2026 16:40:58 +0100 Subject: [PATCH] feat(settings): unify prompt editors (#2855) --- apps/web/app/globals.css | 5 + .../automations/prompt-section.test.tsx | 39 +++ .../components/automations/prompt-section.tsx | 26 +- .../azure-devops-quick-actions.test.tsx | 15 +- .../azure-devops-quick-actions.tsx | 62 ++--- .../azure-devops-watch-placeholders.ts | 102 +++++++ .../azure-devops-watch-settings.test.tsx | 27 +- .../azure-devops-watch-settings.tsx | 23 +- .../github/action-presets-section.test.tsx | 70 +++++ .../github/action-presets-section.tsx | 57 ++-- .../github/issue-watch-dialog.test.tsx | 59 ++++ .../components/github/issue-watch-dialog.tsx | 35 ++- .../github/review-watch-dialog.test.tsx | 34 ++- .../github/review-watch-prompt-field.tsx | 50 ++-- .../gitlab/action-presets-section.test.tsx | 64 +++++ .../gitlab/action-presets-section.tsx | 37 ++- .../gitlab/gitlab-settings.test.tsx | 22 ++ .../gitlab/issue-watch-dialog.test.tsx | 34 ++- .../gitlab/issue-watch-placeholders.ts | 44 ++- .../gitlab/review-watch-dialog.test.tsx | 34 ++- .../gitlab/review-watch-placeholders.ts | 44 ++- apps/web/components/gitlab/watch-dialog.tsx | 23 +- .../jira/jira-issue-watch-dialog.test.tsx | 59 ++++ .../jira/jira-issue-watch-dialog.tsx | 35 ++- .../jira/task-presets-section.test.tsx | 49 +++- .../components/jira/task-presets-section.tsx | 69 +++-- .../linear/linear-issue-watch-dialog.test.tsx | 66 +++++ .../linear/linear-issue-watch-dialog.tsx | 35 ++- .../sentry/sentry-issue-watch-dialog.test.tsx | 34 ++- .../sentry/sentry-issue-watch-dialog.tsx | 35 ++- .../script-editor-completions.test.ts | 76 +++++- .../profile-edit/script-editor-completions.ts | 40 ++- .../profile-edit/script-editor.test.tsx | 203 ++++++++++++++ .../settings/profile-edit/script-editor.tsx | 257 +++++++++--------- .../settings/prompts-settings.test.tsx | 22 +- .../components/settings/prompts-settings.tsx | 33 ++- .../settings/settings-prompt-editor.test.tsx | 120 ++++++++ .../settings/settings-prompt-editor.tsx | 98 +++++++ .../settings/utility-agent-dialog.test.tsx | 58 ++++ .../settings/utility-agent-dialog.tsx | 40 +-- .../settings/workflow-prompt-section.test.tsx | 24 ++ .../settings/workflow-prompt-section.tsx | 23 +- .../workflow-step-prompt-section.test.tsx | 51 ++++ .../settings/workflow-step-prompt-section.tsx | 43 ++- apps/web/e2e/helpers/api-client.ts | 7 + .../web/e2e/helpers/settings-prompt-editor.ts | 95 +++++++ ...b-quick-action-prompt-autocomplete.spec.ts | 71 +++++ ...b-quick-action-prompt-autocomplete.spec.ts | 58 ++++ .../tests/settings/prompts-settings.spec.ts | 42 ++- .../workflow/workflow-duplication.spec.ts | 3 +- .../tests/workflow/workflow-settings.spec.ts | 6 +- .../workflow-step-autocomplete.spec.ts | 39 ++- .../settings/use-custom-prompts.test.tsx | 57 ++++ .../domains/settings/use-custom-prompts.ts | 36 ++- apps/web/src/locales/en/azuredevops.json | 9 +- apps/web/src/locales/en/gitlab.json | 6 + apps/web/src/locales/en/settings.json | 5 +- apps/web/src/locales/pseudo/azuredevops.json | 9 +- apps/web/src/locales/pseudo/gitlab.json | 6 + apps/web/src/locales/pseudo/settings.json | 5 +- apps/web/src/locales/pt-pt/azuredevops.json | 9 +- apps/web/src/locales/pt-pt/gitlab.json | 6 + apps/web/src/locales/pt-pt/settings.json | 5 +- apps/web/src/locales/zh-cn/azuredevops.json | 9 +- apps/web/src/locales/zh-cn/gitlab.json | 6 + apps/web/src/locales/zh-cn/settings.json | 5 +- apps/web/src/locales/zh-hk/azuredevops.json | 9 +- apps/web/src/locales/zh-hk/gitlab.json | 6 + apps/web/src/locales/zh-hk/settings.json | 5 +- apps/web/src/locales/zh-tw/azuredevops.json | 9 +- apps/web/src/locales/zh-tw/gitlab.json | 6 + apps/web/src/locales/zh-tw/settings.json | 5 +- ...ttings-prompt-editor-provider-ownership.md | 44 +++ docs/decisions/INDEX.md | 1 + .../plan.md | 142 ++++++++++ ...task-01-completion-provider-regressions.md | 106 ++++++++ docs/plans/settings-prompt-editor/plan.md | 192 +++++++++++++ .../task-01-shared-editor.md | 66 +++++ .../task-02-quick-actions.md | 62 +++++ .../task-03-watch-prompts.md | 69 +++++ .../task-04-core-settings-prompts.md | 64 +++++ .../task-05-browser-coverage.md | 63 +++++ .../task-06-public-documentation.md | 54 ++++ docs/public/developer-tools.md | 4 +- docs/public/integrations.md | 8 + docs/public/workflow-tips.md | 6 + docs/specs/INDEX.md | 1 + docs/specs/ui/settings-prompt-editor.md | 106 ++++++++ 88 files changed, 3388 insertions(+), 510 deletions(-) create mode 100644 apps/web/components/automations/prompt-section.test.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-watch-placeholders.ts create mode 100644 apps/web/components/github/action-presets-section.test.tsx create mode 100644 apps/web/components/github/issue-watch-dialog.test.tsx create mode 100644 apps/web/components/gitlab/action-presets-section.test.tsx create mode 100644 apps/web/components/jira/jira-issue-watch-dialog.test.tsx create mode 100644 apps/web/components/linear/linear-issue-watch-dialog.test.tsx create mode 100644 apps/web/components/settings/profile-edit/script-editor.test.tsx create mode 100644 apps/web/components/settings/settings-prompt-editor.test.tsx create mode 100644 apps/web/components/settings/settings-prompt-editor.tsx create mode 100644 apps/web/components/settings/utility-agent-dialog.test.tsx create mode 100644 apps/web/components/settings/workflow-step-prompt-section.test.tsx create mode 100644 apps/web/e2e/helpers/settings-prompt-editor.ts create mode 100644 apps/web/e2e/tests/integrations/github-quick-action-prompt-autocomplete.spec.ts create mode 100644 apps/web/e2e/tests/integrations/mobile-github-quick-action-prompt-autocomplete.spec.ts create mode 100644 apps/web/hooks/domains/settings/use-custom-prompts.test.tsx create mode 100644 docs/decisions/2026-08-20-settings-prompt-editor-provider-ownership.md create mode 100644 docs/plans/settings-prompt-completion-regressions/plan.md create mode 100644 docs/plans/settings-prompt-completion-regressions/task-01-completion-provider-regressions.md create mode 100644 docs/plans/settings-prompt-editor/plan.md create mode 100644 docs/plans/settings-prompt-editor/task-01-shared-editor.md create mode 100644 docs/plans/settings-prompt-editor/task-02-quick-actions.md create mode 100644 docs/plans/settings-prompt-editor/task-03-watch-prompts.md create mode 100644 docs/plans/settings-prompt-editor/task-04-core-settings-prompts.md create mode 100644 docs/plans/settings-prompt-editor/task-05-browser-coverage.md create mode 100644 docs/plans/settings-prompt-editor/task-06-public-documentation.md create mode 100644 docs/specs/ui/settings-prompt-editor.md diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 24eea61d83..af8db92f29 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; } diff --git a/apps/web/components/automations/prompt-section.test.tsx b/apps/web/components/automations/prompt-section.test.tsx new file mode 100644 index 0000000000..41164eb554 --- /dev/null +++ b/apps/web/components/automations/prompt-section.test.tsx @@ -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) => { + promptEditor(props); + return
; + }, +})); + +afterEach(() => { + cleanup(); + promptEditor.mockReset(); +}); + +describe("PromptSection", () => { + it("uses shared prompt references and automation placeholders", () => { + render( + , + ); + + expect(promptEditor).toHaveBeenCalledWith( + expect.objectContaining({ + promptReferences: true, + language: "plaintext", + isDirty: true, + placeholders: expect.arrayContaining([expect.objectContaining({ key: "pr.title" })]), + }), + ); + }); +}); diff --git a/apps/web/components/automations/prompt-section.tsx b/apps/web/components/automations/prompt-section.tsx index 263be25e89..81c9e509a8 100644 --- a/apps/web/components/automations/prompt-section.tsx +++ b/apps/web/components/automations/prompt-section.tsx @@ -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 -
- -
+ {placeholders.length > 0 && (

{t("automations:availablePlaceholdersColon")}

diff --git a/apps/web/components/azure-devops/azure-devops-quick-actions.test.tsx b/apps/web/components/azure-devops/azure-devops-quick-actions.test.tsx index 4d67ab0d33..2a1ca3d2bd 100644 --- a/apps/web/components/azure-devops/azure-devops-quick-actions.test.tsx +++ b/apps/web/components/azure-devops/azure-devops-quick-actions.test.tsx @@ -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; + expect(props.promptReferences).toBe(true); + expect((props.placeholders as Array<{ key: string }>).map((item) => item.key)).toEqual([ + "url", + "title", + ]); // The hint is a `` whose `` indices address the JSX children // positionally, so a reflow of those children silently reassembles the // sentence into fragments. Assert the whole reconstructed hint, including diff --git a/apps/web/components/azure-devops/azure-devops-quick-actions.tsx b/apps/web/components/azure-devops/azure-devops-quick-actions.tsx index 9d56bb4944..22abe83c35 100644 --- a/apps/web/components/azure-devops/azure-devops-quick-actions.tsx +++ b/apps/web/components/azure-devops/azure-devops-quick-actions.tsx @@ -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; * 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) => 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 (
-
- onPatch({ promptTemplate })} - language="markdown" - height={computeEditorHeight(action.promptTemplate)} - lineNumbers="off" - placeholders={placeholders} - /> -
-

- {/* The three tokens are prompt syntax, passed as values so neither - i18next interpolation nor the pseudo-locale rewrites them. */} - - Type {PROMPT_OPEN_BRACES} to see available placeholders. {PROMPT_URL_TOKEN}{" "} - and {PROMPT_TITLE_TOKEN} are substituted when the action runs. - -

+ onPatch({ promptTemplate })} + placeholders={placeholders} + promptReferences + isDirty={action.promptTemplate !== baseline?.promptTemplate} + testId={`azure-action-prompt-editor-${action.id}`} + help={ +

+ {/* The three tokens are prompt syntax, passed as values so neither + i18next interpolation nor the pseudo-locale rewrites them. */} + + Type {PROMPT_OPEN_BRACES} to see available placeholders.{" "} + {PROMPT_URL_TOKEN} and {PROMPT_TITLE_TOKEN} are substituted + when the action runs. + +

+ } + />
); } diff --git a/apps/web/components/azure-devops/azure-devops-watch-placeholders.ts b/apps/web/components/azure-devops/azure-devops-watch-placeholders.ts new file mode 100644 index 0000000000..cb6e1f17c0 --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-watch-placeholders.ts @@ -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: [], + }, + ]; +} diff --git a/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx b/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx index c05bc5998e..667ff52d2a 100644 --- a/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx +++ b/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx @@ -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) => { + mocks.promptEditor(props); + return
; + }, +})); import { AzureDevOpsWatchSettings } from "@/components/azure-devops/azure-devops-watch-settings"; @@ -116,4 +122,21 @@ describe("AzureDevOpsWatchSettings", () => { render(); expect(screen.getByTestId(PR_CARD).textContent).toContain("Śţàţũś: draft"); }); + + it("uses typed Azure DevOps placeholders with saved-prompt references", () => { + render(); + + 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" }), + ]), + }), + ); + }); }); diff --git a/apps/web/components/azure-devops/azure-devops-watch-settings.tsx b/apps/web/components/azure-devops/azure-devops-watch-settings.tsx index 66cb291a6d..f8e459b947 100644 --- a/apps/web/components/azure-devops/azure-devops-watch-settings.tsx +++ b/apps/web/components/azure-devops/azure-devops-watch-settings.tsx @@ -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(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({
-