diff --git a/apps/web/app/settings/automations/page.tsx b/apps/web/app/settings/automations/page.tsx index 10fb804b1d..72d9b139ae 100644 --- a/apps/web/app/settings/automations/page.tsx +++ b/apps/web/app/settings/automations/page.tsx @@ -1,12 +1,14 @@ "use client"; import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; import { useAppStore } from "@/components/state-provider"; import { useRouter } from "@/lib/routing/client-router"; export default function AutomationsTopLevelPage() { + const { t } = useTranslation(); const router = useRouter(); const workspaces = useAppStore((s) => s.workspaces.items); @@ -24,15 +26,15 @@ export default function AutomationsTopLevelPage() { if (workspaces.length === 0) { return (
-

No workspaces yet

+

{t("automations:noWorkspacesYet")}

- Create a workspace first, then schedule automations inside it. + {t("automations:noWorkspacesDescription")}

- Create workspace + {t("automations:createWorkspace")}
); @@ -41,8 +43,8 @@ export default function AutomationsTopLevelPage() { return (
-

Automations

-

Pick a workspace to manage its automations.

+

{t("common:automations")}

+

{t("automations:pickWorkspace")}

{workspaces.map((ws) => ( diff --git a/apps/web/components/automations/automation-editor-sections.tsx b/apps/web/components/automations/automation-editor-sections.tsx index b1f389a30e..a10365991f 100644 --- a/apps/web/components/automations/automation-editor-sections.tsx +++ b/apps/web/components/automations/automation-editor-sections.tsx @@ -1,4 +1,5 @@ import { IconTrash } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; @@ -30,26 +31,29 @@ export function NameField({ isDirty: boolean; onChange: (value: string) => void; }) { + const { t } = useTranslation(); return (
- Name + + {t("automations:nameLabel")} + onChange(event.target.value)} - placeholder="Automation name" + placeholder={t("automations:namePlaceholder")} aria-describedby={!value.trim() ? "automation-name-help" : undefined} aria-invalid={!value.trim() ? true : undefined} /> {!value.trim() && (

- Enter an automation name to enable saving. + {t("automations:nameHelp")}

)}
@@ -91,11 +95,12 @@ export function WhenSection({ savedTriggers: AutomationTrigger[]; isDirty: boolean; }) { + const { t } = useTranslation(); return (
-

When

-

What causes this automation to run

+

{t("automations:whenTitle")}

+

{t("automations:whenDescription")}

= [ "taskTitleTemplate", "prompt", @@ -149,10 +155,8 @@ export function ThenSection({ return (
-

Then

-

- A new task will be created each time this automation triggers -

+

{t("automations:thenTitle")}

+

{t("automations:thenDescription")}

- + updateField("taskTitleTemplate", clampTaskTitleInput(event.target.value)) } - placeholder={defaultTaskTitle || "[Auto] automation name"} + // defaultTaskTitle is the backend trigger type's own template — a + // persisted value, not copy. The fallback is the example hint. + placeholder={defaultTaskTitle || t("automations:taskTitlePlaceholder")} /> -

- Leave empty to use the default. Supports placeholders. -

+

{t("automations:taskTitleHelp")}

- +
updateField("enabled", value)} className="cursor-pointer" /> - +
- + void; }) { + const { t } = useTranslation(); return (
{!isNew && ( @@ -278,7 +286,7 @@ export function EditorFooter({ disabled={saving} > - Delete + {t("automations:delete")} )}
diff --git a/apps/web/components/automations/automation-editor.tsx b/apps/web/components/automations/automation-editor.tsx index bf15b7dfd3..07d44f8a6d 100644 --- a/apps/web/components/automations/automation-editor.tsx +++ b/apps/web/components/automations/automation-editor.tsx @@ -1,6 +1,8 @@ "use client"; import { useState, useEffect, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; import { useRouter } from "@/lib/routing/client-router"; import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard"; import { toast } from "@/lib/toast/sonner"; @@ -45,6 +47,10 @@ type AutomationEditorProps = { automationId: string | null; // null = create mode }; +// The seeded prompt is persisted on the automation and sent to the agent +// verbatim, and it is compared with `===` in useAutoPromptUpdate to decide +// whether the user has edited it. Both make it protocol, not copy — the same +// call the Jira (#2177), Linear (#2179) and Sentry (#2182) migrations made. const DEFAULT_PROMPT = "Run scheduled automation.\n\nTrigger: {{trigger.type}}"; const defaultForm: FormState = { @@ -169,7 +175,7 @@ function useSaveHandler(opts: SaveHandlerOpts): () => Promise { setCurrentId(a.id); setCreatedWebhook({ url: buildWebhookUrl(a.id), secret: a.webhook_secret }); } else { - toast.success("Automation created"); + toast.success(t("automations:automationCreated")); runWithNavigationBlockerBypassed(() => router.push(`/settings/workspace/${workspaceId}/automations`), ); @@ -181,8 +187,10 @@ function useSaveHandler(opts: SaveHandlerOpts): () => Promise { onSaved(formWithPromotedSelections, persistedTriggers); } } catch (err) { + // The interpolated value is an API/network diagnostic and stays English + // by design — see docs/i18n.md ("interpolated value" limit). const msg = err instanceof Error ? err.message : String(err); - toast.error(`Failed to save automation: ${msg}`); + toast.error(t("automations:failedToSaveAutomation", { error: msg })); throw err; } finally { setSaving(false); @@ -271,12 +279,18 @@ function useAutomationSaveContributor(options: { discard: () => void; }) { const { isNew, currentId, revision, savedRevision, canSave, save, discard } = options; + // `invalidReason` is resolved during RENDER, so it needs the hook rather than + // the module-level `t` the toasts below use: nothing else in AutomationEditor + // calls useTranslation(), so without this subscription the tooltip would keep + // the previous locale's text until some unrelated re-render. (The toasts are + // fine on the module-level `t` — they resolve at call time inside a callback.) + const { t: translate } = useTranslation(); useSettingsSaveContributor({ id: `automation:${currentId ?? "new"}`, revision, isDirty: isNew || revision !== savedRevision, canSave, - invalidReason: canSave ? undefined : "Complete the required automation fields before saving.", + invalidReason: canSave ? undefined : translate("automations:completeRequiredFields"), save, discard, }); @@ -317,8 +331,10 @@ function useAutomationPersistence(options: AutomationPersistenceOptions) { options.remove, options.router, (error) => - toast.error("Failed to delete automation", { - description: error instanceof Error ? error.message : "Request failed", + toast.error(t("automations:failedToDeleteAutomation"), { + // An Error message here is an API diagnostic and stays English; the + // fallback for a missing payload is copy. + description: error instanceof Error ? error.message : t("common:requestFailed"), }), ); const isRunMode = options.form.executionMode === "run"; diff --git a/apps/web/components/automations/automation-payload.ts b/apps/web/components/automations/automation-payload.ts index a2e0a80610..0cb649922d 100644 --- a/apps/web/components/automations/automation-payload.ts +++ b/apps/web/components/automations/automation-payload.ts @@ -125,6 +125,9 @@ export function buildCreatePayload( ): CreateAutomationRequest { return { workspace_id: workspaceId, + // Persisted as the automation's name — user data, so it stays English + // rather than writing a locale-dependent value into the record. (Unreachable + // in practice: canSave requires a non-empty name.) name: form.name || "New Automation", description: form.description, workflow_id: form.workflowId, diff --git a/apps/web/components/automations/automation-repository-rows.tsx b/apps/web/components/automations/automation-repository-rows.tsx index de323d1610..b3064444fa 100644 --- a/apps/web/components/automations/automation-repository-rows.tsx +++ b/apps/web/components/automations/automation-repository-rows.tsx @@ -5,6 +5,7 @@ import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { IconPlus, IconTrash } from "@tabler/icons-react"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import type { LocalRepository, Repository } from "@/lib/types/http"; import { buildRepositoryItems, @@ -37,9 +38,10 @@ export function AutomationRepositoryRows({ onChange, isDirty, }: AutomationRepositoryRowsProps) { + const { t } = useTranslation(); const baseItems = useMemo( - () => buildRepositoryItems(repositories, discoveredRepos, { includeNone: false }), - [repositories, discoveredRepos], + () => buildRepositoryItems(repositories, discoveredRepos, t, { includeNone: false }), + [repositories, discoveredRepos, t], ); const usedOptionIds = new Set(selections.map(selectionToOptionId)); const nextAvailableItem = baseItems.find((item) => !usedOptionIds.has(item.id)); @@ -61,7 +63,7 @@ export function AutomationRepositoryRows({ return (
- +
{selections.map((selection, index) => ( - Add repository + {t("automations:addRepository")}
-

- With no repositories selected, this automation runs against the workspace's first - repository. -

+

{t("automations:noRepositoriesHelp")}

); } @@ -106,12 +105,13 @@ function RepositoryRow({ onChange: (optionId: string) => void; onRemove: () => void; }) { + const { t } = useTranslation(); const currentOptionId = selectionToOptionId(selection); return (
{ @@ -108,17 +130,31 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule onBlur={handleCustomBlur} data-testid="schedule-custom-input" data-settings-dirty={isDirty} - placeholder="@every 2h30m" + // Cron syntax the user types verbatim, not copy — same value the + // help text below interpolates. + placeholder={CRON_EVERY_2H30M} className={`font-mono text-sm max-w-xs ${error ? "border-destructive" : ""}`} /> {error &&

{error}

}

- Use @every with a duration (e.g.,{" "} - @every 10m,{" "} - @every 2h30m) or shorthands like{" "} - @hourly,{" "} - @daily,{" "} - @weekly. + + + + + + + +

diff --git a/apps/web/components/automations/trigger-card.tsx b/apps/web/components/automations/trigger-card.tsx index e35c6b874a..4713dee5db 100644 --- a/apps/web/components/automations/trigger-card.tsx +++ b/apps/web/components/automations/trigger-card.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Switch } from "@kandev/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -46,42 +47,60 @@ const TRIGGER_COLOR: Record = { webhook: "text-orange-400", }; -const CRON_PRESETS: Record = { - "@hourly": "Every hour", - "0 * * * *": "Every hour", - "@daily": "Every day", - "0 0 * * *": "Every day", - "@weekly": "Every week", - "0 0 * * 0": "Every week", +// Keyed by the cron expression the backend parses — syntax, never translated. +// Only the value is copy, so these hold catalog keys resolved at render. +const CRON_PRESET_KEYS: Record = { + "@hourly": "automations:cronEveryHour", + "0 * * * *": "automations:cronEveryHour", + "@daily": "automations:cronEveryDay", + "0 0 * * *": "automations:cronEveryDay", + "@weekly": "automations:cronEveryWeek", + "0 0 * * 0": "automations:cronEveryWeek", }; -const SIMPLE_SUMMARIES: Partial> = { - github_push: "Push to branch", - github_ci: "CI completed", - webhook: "Webhook", +// Keyed by the persisted TriggerType. +const SIMPLE_SUMMARY_KEYS: Partial> = { + github_push: "automations:summaryPushToBranch", + github_ci: "automations:summaryCiCompleted", + webhook: "automations:summaryWebhook", }; -const TRIGGER_INFO: Record = { - scheduled: "Checked every 30 seconds. Fires when the cron schedule matches.", - github_pr: "Polls GitHub API on your schedule for PRs matching your filters.", - github_push: "Not yet implemented.", - github_ci: "Not yet implemented.", - webhook: "Fires immediately when a POST request hits the webhook URL.", +const TRIGGER_INFO_KEYS: Record = { + scheduled: "automations:triggerInfoScheduled", + github_pr: "automations:triggerInfoGithubPr", + github_push: "automations:triggerInfoNotImplemented", + github_ci: "automations:triggerInfoNotImplemented", + webhook: "automations:triggerInfoWebhook", }; -function getTriggerSummary(trigger: AutomationTrigger): string { - const simple = SIMPLE_SUMMARIES[trigger.type]; - if (simple) return simple; +// A plain function returning copy is invisible to `i18next/no-literal-string`, +// which only inspects literals in JSX — `t` is threaded in from the caller so +// the summary re-resolves on a locale switch. +function getTriggerSummary( + trigger: AutomationTrigger, + t: (key: string, values?: Record) => string, +): string { + const simple = SIMPLE_SUMMARY_KEYS[trigger.type]; + if (simple) return t(simple); const cfg = trigger.config; if (trigger.type === "scheduled") { const expr = (cfg.cron_expression as string) ?? ""; - return CRON_PRESETS[expr] ?? (expr ? `Cron: ${expr}` : "Custom schedule"); + const presetKey = CRON_PRESET_KEYS[expr]; + if (presetKey) return t(presetKey); + // The expression itself is syntax and travels as an interpolated value. + return expr + ? t("automations:summaryCron", { expression: expr }) + : t("automations:summaryCustomSchedule"); } if (trigger.type === "github_pr") { + // Event names are the persisted GitHub event ids, not copy. const events = (cfg.events as string[]) ?? []; - return events.length > 0 ? `PR: ${events.join(", ")}` : "Pull request event"; + return events.length > 0 + ? t("automations:summaryPrEvents", { events: events.join(", ") }) + : t("automations:summaryPullRequestEvent"); } + // Fallback for a trigger type this build does not know: the raw persisted id. return trigger.type; } @@ -94,6 +113,7 @@ export function TriggerCard({ onToggleEnabled, onDelete, }: TriggerCardProps) { + const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const Icon = TRIGGER_ICON[trigger.type]; const color = TRIGGER_COLOR[trigger.type]; @@ -114,13 +134,13 @@ export function TriggerCard({ className="flex-1 text-sm text-left cursor-pointer hover:underline" onClick={() => setExpanded(!expanded)} > - {getTriggerSummary(trigger)} + {getTriggerSummary(trigger, t)} - {TRIGGER_INFO[trigger.type]} + {t(TRIGGER_INFO_KEYS[trigger.type])} ))}
- + handleCustomChange(e.target.value)} @@ -61,7 +69,7 @@ export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) { className="font-mono text-sm" />

- Standard 5-field cron or shortcuts (@hourly, @daily, @weekly, @every 5m) + {t("automations:cronHelp", { shortcuts: CRON_SHORTCUT_EXAMPLES })}

diff --git a/apps/web/components/automations/trigger-configs/webhook-config.tsx b/apps/web/components/automations/trigger-configs/webhook-config.tsx index b94cacadf9..8499dca90e 100644 --- a/apps/web/components/automations/trigger-configs/webhook-config.tsx +++ b/apps/web/components/automations/trigger-configs/webhook-config.tsx @@ -1,6 +1,8 @@ "use client"; import { useState, useMemo, useCallback, useEffect } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { t as translate } from "@/lib/i18n"; import { toast } from "@/lib/toast/sonner"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; @@ -15,6 +17,15 @@ type WebhookConfigProps = { workspaceId: string; }; +// Wire syntax the user copies verbatim: the header the backend checks, and the +// placeholder paths the prompt renderer resolves. These are interpolated into +// the help text rather than written into the catalog, so the pseudo-locale +// cannot accent them into a header or path that no longer resolves. +const WEBHOOK_SECRET_HEADER = "X-Webhook-Secret"; +const WEBHOOK_PATH_PLACEHOLDER = "{{webhook.}}"; +const WEBHOOK_PATH_EXAMPLE = "{{webhook.pull_request.number}}"; +const codeClass = "bg-muted px-1 rounded"; + // extractPaths walks a parsed JSON object two levels deep and returns the // dot-paths to leaf scalars (string/number/bool). For nested objects it // returns the children's full paths so the badges suggest @@ -59,6 +70,7 @@ function extractKeys(json: string): string[] { } export function WebhookConfig({ automationId, workspaceId }: WebhookConfigProps) { + const { t } = useTranslation(); const [copied, setCopied] = useState<"url" | "secret" | null>(null); const [samplePayload, setSamplePayload] = useState(""); @@ -74,9 +86,7 @@ export function WebhookConfig({ automationId, workspaceId }: WebhookConfigProps) if (!automationId) { return (
-

- Webhook URL will be available after saving the automation. -

+

{t("automations:webhookUrlAfterSave")}

copyValue(value, "secret")} />

- Send a POST request with a JSON body and the secret in the{" "} - X-Webhook-Secret header. Reference fields - from the payload with {`{{webhook.}}`}, - e.g. {`{{webhook.pull_request.number}}`}. + + + + +

void }) { + const { t } = useTranslation(); return (
- +