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({
-