feat(i18n): localize Automations (#2247)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-04 10:46:40 +02:00
committed by GitHub
parent d6b95500eb
commit 2a7a8dcd48
31 changed files with 1031 additions and 233 deletions
+7 -5
View File
@@ -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 (
<div className="rounded-lg border border-dashed p-8 text-center">
<p className="text-sm font-medium">No workspaces yet</p>
<p className="text-sm font-medium">{t("automations:noWorkspacesYet")}</p>
<p className="mt-1 text-xs text-muted-foreground">
Create a workspace first, then schedule automations inside it.
{t("automations:noWorkspacesDescription")}
</p>
<Link
href="/settings/workspace"
className="mt-4 inline-block rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 cursor-pointer"
>
Create workspace
{t("automations:createWorkspace")}
</Link>
</div>
);
@@ -41,8 +43,8 @@ export default function AutomationsTopLevelPage() {
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Automations</h2>
<p className="text-sm text-muted-foreground">Pick a workspace to manage its automations.</p>
<h2 className="text-lg font-semibold">{t("common:automations")}</h2>
<p className="text-sm text-muted-foreground">{t("automations:pickWorkspace")}</p>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{workspaces.map((ws) => (
@@ -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 (
<div
className="space-y-2 rounded-lg border bg-card p-4"
data-settings-dirty={isDirty}
data-settings-dirty-level="container"
>
<RequiredFieldLabel htmlFor="automation-name">Name</RequiredFieldLabel>
<RequiredFieldLabel htmlFor="automation-name">
{t("automations:nameLabel")}
</RequiredFieldLabel>
<Input
id="automation-name"
data-testid="automation-name-input"
value={value}
data-settings-dirty={isDirty}
onChange={(event) => 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() && (
<p id="automation-name-help" className="text-xs text-muted-foreground">
Enter an automation name to enable saving.
{t("automations:nameHelp")}
</p>
)}
</div>
@@ -91,11 +95,12 @@ export function WhenSection({
savedTriggers: AutomationTrigger[];
isDirty: boolean;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<div>
<h3 className="text-base font-medium">When</h3>
<p className="text-sm text-muted-foreground">What causes this automation to run</p>
<h3 className="text-base font-medium">{t("automations:whenTitle")}</h3>
<p className="text-sm text-muted-foreground">{t("automations:whenDescription")}</p>
</div>
<div
className="rounded-lg border bg-card p-4"
@@ -135,6 +140,7 @@ export function ThenSection({
savedForm: FormState;
updateField: UpdateField;
}) {
const { t } = useTranslation();
const dirtyFields: Array<keyof FormState> = [
"taskTitleTemplate",
"prompt",
@@ -149,10 +155,8 @@ export function ThenSection({
return (
<div className="space-y-2">
<div>
<h3 className="text-base font-medium">Then</h3>
<p className="text-sm text-muted-foreground">
A new task will be created each time this automation triggers
</p>
<h3 className="text-base font-medium">{t("automations:thenTitle")}</h3>
<p className="text-sm text-muted-foreground">{t("automations:thenDescription")}</p>
</div>
<div
className="rounded-lg border bg-card p-4 space-y-4"
@@ -160,18 +164,18 @@ export function ThenSection({
data-settings-dirty-level="container"
>
<div className="space-y-1.5">
<Label className="text-xs">Task title</Label>
<Label className="text-xs">{t("automations:taskTitleLabel")}</Label>
<Input
value={form.taskTitleTemplate}
data-settings-dirty={isAutomationFieldDirty(form, savedForm, "taskTitleTemplate")}
onChange={(event) =>
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")}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the default. Supports placeholders.
</p>
<p className="text-xs text-muted-foreground">{t("automations:taskTitleHelp")}</p>
</div>
<PromptSection
value={form.prompt}
@@ -221,6 +225,7 @@ export function SettingsSection({
savedForm: FormState;
updateField: UpdateField;
}) {
const { t } = useTranslation();
const enabledIsDirty = isAutomationFieldDirty(form, savedForm, "enabled");
const maxRunsIsDirty = isAutomationFieldDirty(form, savedForm, "maxConcurrentRuns");
return (
@@ -229,7 +234,9 @@ export function SettingsSection({
data-settings-dirty={enabledIsDirty || maxRunsIsDirty}
data-settings-dirty-level="container"
>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Settings</Label>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
{t("common:settings")}
</Label>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
@@ -238,10 +245,10 @@ export function SettingsSection({
onCheckedChange={(value) => updateField("enabled", value)}
className="cursor-pointer"
/>
<Label className="text-sm">Enabled</Label>
<Label className="text-sm">{t("automations:enabledLabel")}</Label>
</div>
<div className="flex items-center gap-2">
<Label className="text-sm">Max concurrent runs</Label>
<Label className="text-sm">{t("automations:maxConcurrentRuns")}</Label>
<Input
type="number"
min={1}
@@ -267,6 +274,7 @@ export function EditorFooter({
isNew: boolean;
onDelete: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3 pt-4">
{!isNew && (
@@ -278,7 +286,7 @@ export function EditorFooter({
disabled={saving}
>
<IconTrash className="h-4 w-4 mr-1" />
Delete
{t("automations:delete")}
</Button>
)}
</div>
@@ -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<void> {
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<void> {
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";
@@ -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,
@@ -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 (
<div className="space-y-1.5" data-testid={testId} data-settings-dirty={isDirty}>
<Label className="text-xs">Repository</Label>
<Label className="text-xs">{t("automations:repositoryLabel")}</Label>
<div className="space-y-2">
{selections.map((selection, index) => (
<RepositoryRow
@@ -82,13 +84,10 @@ export function AutomationRepositoryRows({
onClick={addRow}
>
<IconPlus className="h-3.5 w-3.5 mr-1" />
Add repository
{t("automations:addRepository")}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
With no repositories selected, this automation runs against the workspace&apos;s first
repository.
</p>
<p className="text-[10px] text-muted-foreground">{t("automations:noRepositoriesHelp")}</p>
</div>
);
}
@@ -106,12 +105,13 @@ function RepositoryRow({
onChange: (optionId: string) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const currentOptionId = selectionToOptionId(selection);
return (
<div className="flex items-center gap-2">
<Select value={currentOptionId || undefined} onValueChange={onChange}>
<SelectTrigger className="cursor-pointer flex-1">
<SelectValue placeholder="Select a repository" />
<SelectValue placeholder={t("automations:selectRepositoryPlaceholder")} />
</SelectTrigger>
<SelectContent>
{baseItems.map((item) => {
@@ -121,7 +121,7 @@ function RepositoryRow({
key={item.id}
value={item.id}
disabled={alreadyAdded}
title={alreadyAdded ? "Already added" : undefined}
title={alreadyAdded ? t("automations:alreadyAdded") : undefined}
>
{item.label}
</SelectItem>
@@ -134,7 +134,7 @@ function RepositoryRow({
variant="ghost"
size="icon"
className="cursor-pointer"
aria-label="Remove repository"
aria-label={t("automations:removeRepository")}
onClick={onRemove}
>
<IconTrash className="h-3.5 w-3.5 text-destructive" />
@@ -25,9 +25,16 @@ export function selectionToOptionId(sel: RepositorySelection): string {
// used by the single-picker fallback (executor doesn't support multi-repo)
// but omitted from per-row multi-repo pickers, where removing a row is how
// you clear it.
//
// `t` is threaded in rather than imported from @/lib/i18n at module scope for
// two reasons: this is a plain .ts helper with no JSX, so the eslint guard
// never sees the option label at all, and both callers memoize the result —
// a captured module-level `t` would leave the memo stale after a locale
// switch. Every repository name in the list is user data and stays verbatim.
export function buildRepositoryItems(
workspaceRepos: Repository[],
discoveredRepos: LocalRepository[],
t: (key: string) => string,
options: { includeNone?: boolean } = {},
): Array<{ id: string; label: string }> {
const registeredPaths = new Set(
@@ -39,7 +46,7 @@ export function buildRepositoryItems(
const items: Array<{ id: string; label: string }> =
options.includeNone === false
? []
: [{ id: REPO_NONE_OPTION_ID, label: "None — no repository" }];
: [{ id: REPO_NONE_OPTION_ID, label: t("automations:repositoryNone") }];
for (const r of workspaceRepos) {
items.push({ id: r.id, label: r.name || `${r.provider_owner}/${r.provider_name}` });
}
@@ -62,6 +69,9 @@ export function pickSelectionFromOptionId(
return {
kind: "discovered",
path,
// Persisted as the created repository's name (see resolveOneRepositoryId
// in automation-payload.ts) — user data, so it stays English rather than
// writing a locale-dependent name into the record.
name: match?.name ?? path.split("/").pop() ?? "New Repository",
defaultBranch: match?.default_branch ?? "",
};
@@ -0,0 +1,148 @@
import { cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { ScheduleSelector } from "./schedule-selector";
import { WebhookConfig } from "./trigger-configs/webhook-config";
import { WebhookCreatedDialog } from "./webhook-created-dialog";
vi.mock("@/lib/api/domains/automation-api", () => ({
revealWebhookSecret: vi.fn().mockResolvedValue({ webhook_secret: "s3cret" }),
}));
afterEach(cleanup);
/**
* The three help sentences in this directory are `<Trans>` blocks whose `<n>`
* indices address `<code>` elements positionally, and every cron expression,
* header name and placeholder path travels as an interpolation VALUE so the
* pseudo-locale cannot accent it into syntax that no longer parses.
*
* Both halves fail silently. A drifted index renders duplicated fragments with
* empty tags, and `check-trans-indices.mjs` only proves each `<n>` lands on an
* element — not that the reassembled sentence is the one the surface used to
* render. A token moved into the message body would only show up in a
* translated build. So each test reconstructs the whole sentence and asserts
* the syntax landed inside a `<code>`.
*
* `{{webhook.<path>}}` additionally checks that i18next does not re-interpolate
* a substituted value: the placeholder's own braces must survive verbatim.
*
* One thing these do NOT detect, deliberately: permuting the `<n>` indices among
* themselves. Every child here is an identical self-closing `<code />`, so any
* permutation renders byte-identically — the drift docs/i18n.md warns about
* needs children that differ. Probing with an index swap therefore proves
* nothing; the mutations these were verified against are a value baked into the
* message body, a dropped tag, and a reworded sentence.
*/
function codeTexts(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll("code")).map((el) => el.textContent ?? "");
}
function text(el: Element | null): string {
return (el?.textContent ?? "").replace(/\s+/g, " ").trim();
}
function renderSchedule(onChange = vi.fn()) {
return render(
<TooltipProvider>
<ScheduleSelector config={null} onChange={onChange} />
</TooltipProvider>,
);
}
describe("ScheduleSelector syntax help", () => {
it("renders the @every help as one sentence with each expression in a code element", () => {
const { container } = renderSchedule();
const paragraphs = Array.from(container.querySelectorAll("p"));
const help = paragraphs.find((p) => p.textContent?.includes("@every"));
expect(text(help ?? null)).toBe(
"Use @every with a duration (e.g., @every 10m, @every 2h30m) or shorthands like @hourly, @daily, @weekly.",
);
expect(codeTexts(container)).toEqual([
"@every",
"@every 10m",
"@every 2h30m",
"@hourly",
"@daily",
"@weekly",
]);
});
it("labels the interval presets from count rather than a baked-in plural", () => {
const { container } = renderSchedule();
const labels = Array.from(container.querySelectorAll("button")).map((b) => text(b));
expect(labels).toEqual(["5 min", "15 min", "30 min", "1 hour", "6 hours", "Daily", "Weekly"]);
});
it("keeps @every verbatim in the invalid-expression error", async () => {
const { container, findByText } = renderSchedule();
const input = container.querySelector('[data-testid="schedule-custom-input"]')!;
fireEvent.change(input, { target: { value: "not a cron" } });
fireEvent.blur(input);
// The token travels as an interpolation value, so an accented pseudo build
// still tells the user to type the string the scheduler actually accepts.
await findByText(
"Invalid expression. Use @every with a duration, a shorthand, or a 5-field cron.",
);
});
it("keeps the cron expression out of the preset's persisted value", () => {
const { container } = renderSchedule();
// The testid carries the expression, so it doubles as a check that the
// persisted value is the untranslated shorthand and not the label.
expect(container.querySelector('[data-testid="schedule-preset-@hourly"]')).not.toBeNull();
expect(container.querySelector('[data-testid="schedule-preset-@every 6h"]')).not.toBeNull();
});
});
describe("WebhookConfig usage help", () => {
it("renders one sentence with the header and both placeholder paths in code elements", async () => {
const { container, findByText } = render(<WebhookConfig automationId="a1" workspaceId="w1" />);
await findByText("Webhook secret");
const help = Array.from(container.querySelectorAll("p")).find((p) =>
p.textContent?.includes("POST"),
);
expect(text(help ?? null)).toBe(
"Send a POST request with a JSON body and the secret in the X-Webhook-Secret header. " +
"Reference fields from the payload with {{webhook.<path>}}, e.g. " +
"{{webhook.pull_request.number}}.",
);
// The placeholder paths carry their own `{{ }}`. i18next must substitute
// them verbatim and not treat the result as another interpolation — an
// empty or mangled path here is the failure this asserts against.
expect(codeTexts(container)).toEqual(
expect.arrayContaining([
"X-Webhook-Secret",
"{{webhook.<path>}}",
"{{webhook.pull_request.number}}",
]),
);
});
});
describe("WebhookCreatedDialog description", () => {
it("renders one sentence with the secret header in a code element", () => {
render(
<WebhookCreatedDialog
open
webhookUrl="http://localhost:3000/api/v1/automations/webhook/a1"
webhookSecret="s3cret"
onClose={vi.fn()}
/>,
);
// Radix portals dialog content to document.body, so scope to that.
const body = document.body;
const description = body.querySelector("[data-slot='dialog-description']") ?? body;
expect(text(description)).toContain(
"Configure your external system to POST to this URL with the secret in the X-Webhook-Secret header.",
);
expect(codeTexts(body)).toContain("X-Webhook-Secret");
});
});
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { Button } from "@kandev/ui/button";
import { Separator } from "@kandev/ui/separator";
@@ -13,6 +14,7 @@ type AutomationsListPageProps = {
};
export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) {
const { t } = useTranslation();
const router = useRouter();
const { items, loading, enable, disable, trigger, remove } = useAutomations(workspaceId);
const enabledDrafts = useAutomationEnabledDrafts({ automations: items, enable, disable });
@@ -31,11 +33,9 @@ export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) {
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
<IconBolt className="h-5 w-5" />
Automations
{t("common:automations")}
</h2>
<p className="text-sm text-muted-foreground">
Create rules that automatically trigger agent tasks.
</p>
<p className="text-sm text-muted-foreground">{t("automations:listDescription")}</p>
</div>
<Button
data-testid="new-automation-button"
@@ -43,12 +43,14 @@ export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) {
onClick={() => router.push(`/settings/workspace/${workspaceId}/automations/new`)}
>
<IconPlus className="h-4 w-4 mr-1" />
New Automation
{t("automations:newAutomation")}
</Button>
</div>
<Separator />
{loading && items.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">Loading automations...</div>
<div className="py-12 text-center text-muted-foreground">
{t("automations:loadingAutomations")}
</div>
) : (
<AutomationsTable
automations={enabledDrafts.automations}
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
@@ -27,24 +28,28 @@ const TRIGGER_BADGE_VARIANT: Record<TriggerType, string> = {
webhook: "bg-orange-500/15 text-orange-400 border-orange-500/20",
};
const TRIGGER_LABELS: Record<TriggerType, string> = {
scheduled: "Scheduled",
github_pr: "GitHub PR",
github_push: "GitHub Push",
github_ci: "GitHub CI",
webhook: "Webhook",
// The keys are persisted TriggerType identifiers the backend stores and
// replays — only the catalog key is display copy, so the map holds keys and
// resolves them at render (a module-scope t() would freeze at the boot locale).
const TRIGGER_LABEL_KEYS: Record<TriggerType, string> = {
scheduled: "automations:triggerLabelScheduled",
github_pr: "automations:triggerLabelGithubPr",
github_push: "automations:triggerLabelGithubPush",
github_ci: "automations:triggerLabelGithubCi",
webhook: "automations:triggerLabelWebhook",
};
function TriggerBadges({ triggers }: { triggers: Automation["triggers"] }) {
const { t } = useTranslation();
const items = triggers ?? [];
if (items.length === 0) {
return <span className="text-xs text-muted-foreground">None</span>;
return <span className="text-xs text-muted-foreground">{t("automations:noTriggers")}</span>;
}
return (
<div className="flex flex-wrap gap-1">
{items.map((t) => (
<Badge key={t.id} variant="outline" className={TRIGGER_BADGE_VARIANT[t.type]}>
{TRIGGER_LABELS[t.type]}
{items.map((trigger) => (
<Badge key={trigger.id} variant="outline" className={TRIGGER_BADGE_VARIANT[trigger.type]}>
{t(TRIGGER_LABEL_KEYS[trigger.type])}
</Badge>
))}
</div>
@@ -60,6 +65,7 @@ function RowActions({
onTrigger: (id: string) => void;
onDelete: (id: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-end gap-0.5">
<Tooltip>
@@ -73,7 +79,7 @@ function RowActions({
<IconPlayerPlay className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Trigger manually</TooltipContent>
<TooltipContent>{t("automations:triggerManually")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -86,7 +92,7 @@ function RowActions({
<IconTrash className="h-3.5 w-3.5 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
<TooltipContent>{t("automations:delete")}</TooltipContent>
</Tooltip>
</div>
);
@@ -100,6 +106,7 @@ export function AutomationsTable({
onTrigger,
onDelete,
}: AutomationsTableProps) {
const { t } = useTranslation();
const router = useRouter();
return (
@@ -112,11 +119,11 @@ export function AutomationsTable({
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent focus-within:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Triggers</TableHead>
<TableHead>Enabled</TableHead>
<TableHead>Last Triggered</TableHead>
<TableHead>{t("automations:columnName")}</TableHead>
<TableHead>{t("automations:columnMode")}</TableHead>
<TableHead>{t("automations:columnTriggers")}</TableHead>
<TableHead>{t("automations:columnEnabled")}</TableHead>
<TableHead>{t("automations:columnLastTriggered")}</TableHead>
<TableHead className="w-[100px]" />
</TableRow>
</TableHeader>
@@ -128,7 +135,7 @@ export function AutomationsTable({
className="text-center text-muted-foreground py-8"
data-testid="automations-empty"
>
No automations yet
{t("automations:noAutomationsYet")}
</TableCell>
</TableRow>
) : (
@@ -146,7 +153,9 @@ export function AutomationsTable({
<TableCell className="font-medium">{a.name}</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{a.execution_mode === "run" ? "Run" : "Task"}
{a.execution_mode === "run"
? t("automations:executionModeRunShort")
: t("automations:executionModeTaskShort")}
</Badge>
</TableCell>
<TableCell>
@@ -163,7 +172,9 @@ export function AutomationsTable({
/>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{a.last_triggered_at ? formatRelativeTime(a.last_triggered_at) : "Never"}
{a.last_triggered_at
? formatRelativeTime(a.last_triggered_at)
: t("automations:neverTriggered")}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<RowActions id={a.id} onTrigger={onTrigger} onDelete={onDelete} />
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { useAppStore } from "@/components/state-provider";
@@ -72,9 +73,12 @@ const CLEAN_FIELDS = {
repositorySelections: false,
};
// `id` is the persisted ExecutionMode the backend stores and replays; only the
// catalog key is copy. Resolved at render — a module-scope t() would freeze at
// the boot locale.
const EXECUTION_MODE_ITEMS = [
{ id: "task", label: "Task — creates a tracked kanban task" },
{ id: "run", label: "Run — fire-and-forget, hidden from kanban" },
{ id: "task", labelKey: "automations:executionModeTask" },
{ id: "run", labelKey: "automations:executionModeRun" },
];
function useDiscoveredRepositories(workspaceId: string) {
@@ -99,9 +103,16 @@ function useDiscoveredRepositories(workspaceId: string) {
type StepOption = { id: string; name: string };
function getWorkflowStepHelpText(workflowId: string, workflowStepId: string): string | undefined {
if (!workflowId) return "Select a workflow before choosing a step.";
if (!workflowStepId) return "Select a workflow step to enable saving.";
// A plain function returning copy is invisible to `i18next/no-literal-string`
// (it only inspects literals in JSX), so `t` is threaded in explicitly rather
// than imported at module scope.
function getWorkflowStepHelpText(
workflowId: string,
workflowStepId: string,
t: (key: string) => string,
): string | undefined {
if (!workflowId) return t("automations:workflowStepHelpNoWorkflow");
if (!workflowStepId) return t("automations:workflowStepHelp");
return undefined;
}
@@ -142,6 +153,7 @@ function useConfigSectionComputed({
repositorySelections,
repositories,
discoveredRepos,
t,
}: {
agentProfiles: AgentProfileLike[];
executors: ExecutorLike[];
@@ -149,6 +161,7 @@ function useConfigSectionComputed({
repositorySelections: RepositorySelection[];
repositories: Repository[];
discoveredRepos: LocalRepository[];
t: (key: string) => string;
}) {
const filteredAgentProfiles = agentProfiles.filter((profile) => !profile.cli_passthrough);
// Profiles returned by the executors list/boot payload don't always carry
@@ -177,8 +190,8 @@ function useConfigSectionComputed({
};
});
const singleRepositoryItems = useMemo(
() => buildRepositoryItems(repositories, discoveredRepos),
[repositories, discoveredRepos],
() => buildRepositoryItems(repositories, discoveredRepos, t),
[repositories, discoveredRepos, t],
);
return { filteredAgentProfiles, executorItems, supportsMultiRepo, singleRepositoryItems };
}
@@ -200,6 +213,7 @@ export function ConfigSection({
onRepositoriesChange,
onExecutionModeChange,
}: ConfigSectionProps) {
const { t } = useTranslation();
useSettingsData(true);
useWorkflows(workspaceId, true);
const { repositories } = useRepositories(workspaceId, true);
@@ -220,22 +234,23 @@ export function ConfigSection({
repositorySelections,
repositories,
discoveredRepos,
t,
});
return (
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
Configuration
{t("automations:configurationLabel")}
</Label>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<SelectField
testId="execution-mode-selector"
label="Execution Mode"
label={t("automations:executionModeLabel")}
value={executionMode}
isDirty={dirtyFields.executionMode}
onChange={(v) => onExecutionModeChange(v as ExecutionMode)}
placeholder="Select mode"
items={EXECUTION_MODE_ITEMS}
placeholder={t("automations:executionModePlaceholder")}
items={EXECUTION_MODE_ITEMS.map((item) => ({ id: item.id, label: t(item.labelKey) }))}
/>
{!isRunMode && (
<WorkflowFields
@@ -250,22 +265,22 @@ export function ConfigSection({
/>
)}
<SelectField
label="Agent Profile"
label={t("automations:agentProfileLabel")}
value={agentProfileId}
isDirty={dirtyFields.agentProfileId}
onChange={onAgentProfileChange}
placeholder="Select agent"
placeholder={t("automations:agentProfilePlaceholder")}
items={filteredAgentProfiles.map((p) => ({
id: p.id,
label: p.label,
}))}
/>
<SelectField
label="Executor Profile"
label={t("automations:executorProfileLabel")}
value={executorProfileId}
isDirty={dirtyFields.executorProfileId}
onChange={onExecutorProfileChange}
placeholder="Select executor"
placeholder={t("automations:executorProfilePlaceholder")}
items={executorItems}
/>
<RepositoryPickerField
@@ -307,6 +322,7 @@ function RepositoryPickerField({
isDirty: boolean;
onRepositoriesChange: (selections: RepositorySelection[]) => void;
}) {
const { t } = useTranslation();
if (supportsMultiRepo && !isPRTrigger) {
return (
<AutomationRepositoryRows
@@ -322,17 +338,17 @@ function RepositoryPickerField({
return (
<SelectField
testId="repository-selector"
label="Repository"
label={t("automations:repositoryLabel")}
value={selectionToOptionId(repositorySelections[0] ?? { kind: "none" })}
isDirty={isDirty}
onChange={(v) => {
const picked = pickSelectionFromOptionId(v, repositories, discoveredRepos);
onRepositoriesChange(picked.kind === "none" ? [] : [picked]);
}}
placeholder="Auto"
placeholder={t("automations:repositoryPlaceholderAuto")}
items={singleRepositoryItems}
disabled={isPRTrigger}
helpText={isPRTrigger ? "PR triggers always use the PR's own repository." : undefined}
helpText={isPRTrigger ? t("automations:prTriggerRepositoryHelp") : undefined}
/>
);
}
@@ -356,6 +372,7 @@ function WorkflowFields({
onWorkflowChange: (id: string) => void;
onStepChange: (id: string) => void;
}) {
const { t } = useTranslation();
// The step list is empty until a workflow is picked. Showing an empty
// dropdown next to the workflow select invites users to click it first
// and bounce off — keep the field in the DOM (so its testid is stable
@@ -366,26 +383,30 @@ function WorkflowFields({
<>
<SelectField
testId="workflow-selector"
label="Workflow"
label={t("automations:workflowLabel")}
required
value={workflowId}
isDirty={workflowDirty}
onChange={onWorkflowChange}
placeholder="Select workflow"
placeholder={t("automations:workflowPlaceholder")}
items={workflows.map((w) => ({ id: w.id, label: w.name }))}
helpText={!hasWorkflow ? "Select a workflow to enable saving." : undefined}
helpText={!hasWorkflow ? t("automations:workflowHelp") : undefined}
/>
<SelectField
testId="workflow-step-selector"
label="Workflow Step"
label={t("automations:workflowStepLabel")}
required
value={workflowStepId}
isDirty={workflowStepDirty}
onChange={onStepChange}
placeholder={hasWorkflow ? "Select step" : "Pick a workflow first"}
placeholder={
hasWorkflow
? t("automations:workflowStepPlaceholder")
: t("automations:workflowStepPlaceholderNoWorkflow")
}
items={steps.map((s) => ({ id: s.id, label: s.name }))}
disabled={!hasWorkflow}
helpText={getWorkflowStepHelpText(workflowId, workflowStepId)}
helpText={getWorkflowStepHelpText(workflowId, workflowStepId, t)}
/>
</>
);
@@ -1,6 +1,7 @@
"use client";
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 type { PlaceholderInfo } from "@/lib/types/automation";
@@ -14,11 +15,14 @@ type PromptSectionProps = {
};
export function PromptSection({ value, isDirty, onChange, placeholders }: PromptSectionProps) {
const { t } = useTranslation();
const scriptPlaceholders = useMemo(() => toScriptPlaceholders(placeholders), [placeholders]);
return (
<div className="space-y-3">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Instructions</Label>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
{t("automations:instructionsLabel")}
</Label>
<div
className="rounded-md border border-transparent"
data-settings-dirty={isDirty}
@@ -34,7 +38,7 @@ export function PromptSection({ value, isDirty, onChange, placeholders }: Prompt
</div>
{placeholders.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium">Available placeholders:</p>
<p className="font-medium">{t("automations:availablePlaceholdersColon")}</p>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{placeholders.map((p) => (
<span key={p.key} title={p.description}>
@@ -1,4 +1,5 @@
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@kandev/ui/label";
import { cn } from "@/lib/utils";
@@ -7,13 +8,14 @@ export function RequiredFieldLabel({
className,
...props
}: ComponentProps<typeof Label>) {
const { t } = useTranslation();
return (
<Label className={cn("gap-1.5", className)} {...props}>
<span>{children}</span>
<span aria-hidden className="text-destructive">
*
</span>
<span className="sr-only">required</span>
<span className="sr-only">{t("automations:required")}</span>
</Label>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import {
AlertDialog,
@@ -28,24 +29,27 @@ type RunsSectionProps = {
workspaceId: string;
};
// The keys are persisted RunStatus values the backend writes and the frontend
// matches on; only the catalog key is copy, and it is resolved at render so a
// locale switch is picked up (a module-scope t() would freeze at boot).
const STATUS_BADGE: Record<
RunStatus,
{ variant: "default" | "destructive" | "secondary" | "outline"; label: string }
{ variant: "default" | "destructive" | "secondary" | "outline"; labelKey: string }
> = {
triggered: { variant: "secondary", label: "Triggered" },
task_created: { variant: "secondary", label: "Running" },
succeeded: { variant: "default", label: "Succeeded" },
failed: { variant: "destructive", label: "Failed" },
skipped: { variant: "outline", label: "Skipped" },
triggered: { variant: "secondary", labelKey: "automations:runStatusTriggered" },
task_created: { variant: "secondary", labelKey: "automations:runStatusRunning" },
succeeded: { variant: "default", labelKey: "automations:runStatusSucceeded" },
failed: { variant: "destructive", labelKey: "automations:runStatusFailed" },
skipped: { variant: "outline", labelKey: "automations:runStatusSkipped" },
// The generating task was archived — via the UI or by the agent itself
// (e.g. an "archive this task" instruction). Distinct from a genuine
// user cancellation: archiving just closes the task out, it doesn't
// mean the run's work was rejected. See internal/automation.RunStatusArchived.
archived: { variant: "outline", label: "Archived" },
archived: { variant: "outline", labelKey: "automations:runStatusArchived" },
// The generating task no longer exists, or its current primary session
// is CANCELLED — a real cancellation, distinct from archived.
// See internal/automation.RunStatusCancelled.
cancelled: { variant: "outline", label: "Cancelled" },
cancelled: { variant: "outline", labelKey: "automations:runStatusCancelled" },
};
type RunRowProps = {
@@ -56,6 +60,7 @@ type RunRowProps = {
};
function RunRow({ run, taskClickable, onDelete, onNavigate }: RunRowProps) {
const { t } = useTranslation();
const badge = STATUS_BADGE[run.status] ?? STATUS_BADGE.triggered;
const rowClickable = taskClickable && !!run.task_id;
return (
@@ -67,9 +72,11 @@ function RunRow({ run, taskClickable, onDelete, onNavigate }: RunRowProps) {
}
onClick={rowClickable ? () => onNavigate(run.task_id) : undefined}
>
{/* trigger_type is the persisted TriggerType identifier the backend
stored on the run — an id, not copy. */}
<TableCell className="text-sm">{run.trigger_type}</TableCell>
<TableCell>
<Badge variant={badge.variant}>{badge.label}</Badge>
<Badge variant={badge.variant}>{t(badge.labelKey)}</Badge>
</TableCell>
<TableCell className="text-sm font-mono">
{run.task_id ? run.task_id.slice(0, 8) : "-"}
@@ -90,7 +97,7 @@ function RunRow({ run, taskClickable, onDelete, onNavigate }: RunRowProps) {
e.preventDefault();
onDelete(run.id);
}}
title="Delete run"
title={t("automations:deleteRun")}
data-testid="delete-run"
>
<IconTrash className="h-3.5 w-3.5" />
@@ -103,6 +110,7 @@ function RunRow({ run, taskClickable, onDelete, onNavigate }: RunRowProps) {
type DeleteAllButtonProps = { disabled: boolean; onConfirm: () => void };
function DeleteAllButton({ disabled, onConfirm }: DeleteAllButtonProps) {
const { t } = useTranslation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -111,7 +119,7 @@ function DeleteAllButton({ disabled, onConfirm }: DeleteAllButtonProps) {
size="icon-sm"
className="cursor-pointer text-destructive hover:text-destructive"
disabled={disabled}
title="Delete all runs"
title={t("automations:deleteAllRuns")}
data-testid="delete-all-runs"
>
<IconTrash className="h-3.5 w-3.5" />
@@ -119,20 +127,19 @@ function DeleteAllButton({ disabled, onConfirm }: DeleteAllButtonProps) {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete all runs?</AlertDialogTitle>
<AlertDialogTitle>{t("automations:deleteAllRunsTitle")}</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove all run records for this automation — including any not
currently loaded — and their associated tasks. This cannot be undone.
{t("automations:deleteAllRunsDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirm}
data-testid="delete-all-runs-confirm"
>
Delete all
{t("automations:deleteAll")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -141,6 +148,7 @@ function DeleteAllButton({ disabled, onConfirm }: DeleteAllButtonProps) {
}
export function RunsSection({ automationId, executionMode, workspaceId }: RunsSectionProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const { runs, loading, refresh, deleteRun, deleteAllRuns } = useAutomationRuns(
automationId,
@@ -158,7 +166,7 @@ export function RunsSection({ automationId, executionMode, workspaceId }: RunsSe
onClick={() => setExpanded(!expanded)}
>
<Label className="text-xs uppercase tracking-wider text-muted-foreground cursor-pointer">
Recent Runs ({runs.length})
{t("automations:recentRuns", { count: runs.length })}
</Label>
{expanded ? (
<IconChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
@@ -174,7 +182,7 @@ export function RunsSection({ automationId, executionMode, workspaceId }: RunsSe
className="cursor-pointer"
onClick={refresh}
disabled={loading}
title="Refresh"
title={t("automations:refresh")}
>
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
@@ -187,11 +195,11 @@ export function RunsSection({ automationId, executionMode, workspaceId }: RunsSe
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent focus-within:bg-transparent">
<TableHead>Trigger</TableHead>
<TableHead>Status</TableHead>
<TableHead>Task</TableHead>
<TableHead>Time</TableHead>
<TableHead>Error</TableHead>
<TableHead>{t("automations:runsColumnTrigger")}</TableHead>
<TableHead>{t("common:status")}</TableHead>
<TableHead>{t("automations:runsColumnTask")}</TableHead>
<TableHead>{t("automations:runsColumnTime")}</TableHead>
<TableHead>{t("automations:runsColumnError")}</TableHead>
<TableHead className="w-8" />
</TableRow>
</TableHeader>
@@ -199,7 +207,7 @@ export function RunsSection({ automationId, executionMode, workspaceId }: RunsSe
{runs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-4">
{loading ? "Loading..." : "No runs yet"}
{loading ? t("automations:loading") : t("automations:noRunsYet")}
</TableCell>
</TableRow>
) : (
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
@@ -26,17 +27,33 @@ function isValidExpression(expr: string): boolean {
return false;
}
const PRESETS = [
{ label: "5 min", expression: "@every 5m" },
{ label: "15 min", expression: "@every 15m" },
{ label: "30 min", expression: "@every 30m" },
{ label: "1 hour", expression: "@hourly" },
{ label: "6 hours", expression: "@every 6h" },
{ label: "Daily", expression: "@daily" },
{ label: "Weekly", expression: "@weekly" },
] as const;
// `expression` is the cron/shorthand syntax the backend parses and persists —
// never translated. The label is copy: the interval presets carry a `count` so
// the plural form is chosen by i18next rather than baked into English, and the
// two named presets are plain messages.
const PRESETS: ReadonlyArray<{ labelKey: string; count?: number; expression: string }> = [
{ labelKey: "automations:presetMinutes", count: 5, expression: "@every 5m" },
{ labelKey: "automations:presetMinutes", count: 15, expression: "@every 15m" },
{ labelKey: "automations:presetMinutes", count: 30, expression: "@every 30m" },
{ labelKey: "automations:presetHours", count: 1, expression: "@hourly" },
{ labelKey: "automations:presetHours", count: 6, expression: "@every 6h" },
{ labelKey: "automations:presetDaily", expression: "@daily" },
{ labelKey: "automations:presetWeekly", expression: "@weekly" },
];
// Cron syntax shown inside the help text. These travel as interpolation values
// so the pseudo-locale cannot accent them into an expression that no longer
// parses — see docs/i18n.md.
const CRON_EVERY = "@every";
const CRON_EVERY_10M = "@every 10m";
const CRON_EVERY_2H30M = "@every 2h30m";
const CRON_HOURLY = "@hourly";
const CRON_DAILY = "@daily";
const CRON_WEEKLY = "@weekly";
const codeClass = "bg-muted px-1 rounded";
export function ScheduleSelector({ config, isDirty = false, onChange }: ScheduleSelectorProps) {
const { t } = useTranslation();
const configExpr = (config?.cron_expression as string) ?? "";
const [customInput, setCustomInput] = useState(configExpr);
const [error, setError] = useState<string | null>(null);
@@ -65,7 +82,11 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule
return;
}
if (!isValidExpression(trimmed)) {
setError("Invalid expression. Use @every with a duration, a shorthand, or a 5-field cron.");
// `@every` is the token the scheduler accepts and the user must type, so
// it is interpolated rather than left in the catalog — otherwise the
// pseudo-locale renders `@ēvēŕŷ` and the error tells them to type
// something that can never parse.
setError(t("automations:invalidExpression", { every: CRON_EVERY }));
return;
}
setError(null);
@@ -84,7 +105,7 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule
className="cursor-pointer"
onClick={() => handlePreset(preset.expression)}
>
{preset.label}
{t(preset.labelKey, { count: preset.count })}
</Button>
))}
<Tooltip>
@@ -92,13 +113,14 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground ml-1" />
</TooltipTrigger>
<TooltipContent className="max-w-[280px]">
How often to check for matching events. Checked every 30 seconds by a background
process. Schedules persist across backend restarts.
{t("automations:scheduleTooltip")}
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Custom interval</Label>
<Label className="text-xs text-muted-foreground">
{t("automations:customIntervalLabel")}
</Label>
<Input
value={customInput}
onChange={(e) => {
@@ -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 && <p className="text-xs text-destructive">{error}</p>}
<p className="text-xs text-muted-foreground">
Use <code className="bg-muted px-1 rounded">@every</code> with a duration (e.g.,{" "}
<code className="bg-muted px-1 rounded">@every 10m</code>,{" "}
<code className="bg-muted px-1 rounded">@every 2h30m</code>) or shorthands like{" "}
<code className="bg-muted px-1 rounded">@hourly</code>,{" "}
<code className="bg-muted px-1 rounded">@daily</code>,{" "}
<code className="bg-muted px-1 rounded">@weekly</code>.
<Trans
i18nKey="automations:scheduleSyntaxHelp"
values={{
every: CRON_EVERY,
example1: CRON_EVERY_10M,
example2: CRON_EVERY_2H30M,
hourly: CRON_HOURLY,
daily: CRON_DAILY,
weekly: CRON_WEEKLY,
}}
>
<code className={codeClass} />
<code className={codeClass} />
<code className={codeClass} />
<code className={codeClass} />
<code className={codeClass} />
<code className={codeClass} />
</Trans>
</p>
</div>
</div>
@@ -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<TriggerType, string> = {
webhook: "text-orange-400",
};
const CRON_PRESETS: Record<string, string> = {
"@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<string, string> = {
"@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<Record<TriggerType, string>> = {
github_push: "Push to branch",
github_ci: "CI completed",
webhook: "Webhook",
// Keyed by the persisted TriggerType.
const SIMPLE_SUMMARY_KEYS: Partial<Record<TriggerType, string>> = {
github_push: "automations:summaryPushToBranch",
github_ci: "automations:summaryCiCompleted",
webhook: "automations:summaryWebhook",
};
const TRIGGER_INFO: Record<TriggerType, string> = {
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<TriggerType, string> = {
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, unknown>) => 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)}
</button>
<Tooltip>
<TooltipTrigger asChild>
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
</TooltipTrigger>
<TooltipContent>{TRIGGER_INFO[trigger.type]}</TooltipContent>
<TooltipContent>{t(TRIGGER_INFO_KEYS[trigger.type])}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
@@ -170,6 +190,7 @@ function TriggerConfigForm({
workspaceId: string;
onUpdate: (config: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
switch (trigger.type) {
case "scheduled":
return <ScheduledConfig config={trigger.config} onUpdate={onUpdate} />;
@@ -182,6 +203,6 @@ function TriggerConfigForm({
case "webhook":
return <WebhookConfig automationId={automationId} workspaceId={workspaceId} />;
default:
return <p className="text-sm text-muted-foreground">Unknown trigger type</p>;
return <p className="text-sm text-muted-foreground">{t("automations:unknownTriggerType")}</p>;
}
}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Checkbox } from "@kandev/ui/checkbox";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
@@ -10,13 +11,16 @@ type GitHubCIConfigProps = {
onUpdate: (config: Record<string, unknown>) => void;
};
// `value` is the GitHub check-run conclusion persisted in the trigger config
// and matched by the backend — protocol. Only the label is copy.
const CI_CONCLUSIONS = [
{ value: "success", label: "Success" },
{ value: "failure", label: "Failure" },
{ value: "cancelled", label: "Cancelled" },
{ value: "success", labelKey: "automations:ciConclusionSuccess" },
{ value: "failure", labelKey: "automations:ciConclusionFailure" },
{ value: "cancelled", labelKey: "automations:ciConclusionCancelled" },
] as const;
export function GitHubCIConfig({ config, onUpdate }: GitHubCIConfigProps) {
const { t } = useTranslation();
const conclusions = (config.conclusions as string[]) ?? [];
const configCheckNames = ((config.check_names as string[]) ?? []).join(", ");
const [checkNames, setCheckNames] = useState(configCheckNames);
@@ -42,7 +46,7 @@ export function GitHubCIConfig({ config, onUpdate }: GitHubCIConfigProps) {
return (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs">Conclusions</Label>
<Label className="text-xs">{t("automations:ciConclusionsLabel")}</Label>
<div className="flex flex-wrap gap-3">
{CI_CONCLUSIONS.map((c) => (
<label key={c.value} className="flex items-center gap-1.5 cursor-pointer">
@@ -51,13 +55,15 @@ export function GitHubCIConfig({ config, onUpdate }: GitHubCIConfigProps) {
onCheckedChange={() => toggleConclusion(c.value)}
className="cursor-pointer"
/>
<span className="text-sm">{c.label}</span>
<span className="text-sm">{t(c.labelKey)}</span>
</label>
))}
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Check names (comma-separated, optional)</Label>
<Label className="text-xs">{t("automations:checkNamesLabel")}</Label>
{/* The placeholder is an example list of literal CI check names — data
the user types verbatim, not copy. */}
<Input
value={checkNames}
onChange={(e) => setCheckNames(e.target.value)}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Checkbox } from "@kandev/ui/checkbox";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
@@ -15,16 +16,18 @@ type GitHubPRConfigProps = {
onUpdate: (config: Record<string, unknown>) => void;
};
// `value` is the GitHub PR event name persisted in the trigger config and
// matched by the backend — protocol. Label and tooltip are copy.
const PR_EVENTS = [
{
value: "opened",
label: "Opened",
tooltip:
"Fires once when a new open PR is detected. Each PR number is deduped — the same PR won't trigger again.",
labelKey: "automations:prEventOpened",
tooltipKey: "automations:prEventOpenedTooltip",
},
] as const;
export function GitHubPRConfig({ config, onUpdate }: GitHubPRConfigProps) {
const { t } = useTranslation();
const events = (config.events as string[]) ?? [];
const repos = (config.repos as RepoFilter[]) ?? [];
const allRepos = (config.all_repos as boolean) ?? true;
@@ -74,7 +77,8 @@ export function GitHubPRConfig({ config, onUpdate }: GitHubPRConfigProps) {
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Base branches (comma-separated)</Label>
<Label className="text-xs">{t("automations:baseBranchesLabel")}</Label>
{/* Example branch names — data the user types verbatim, not copy. */}
<Input
value={branches}
onChange={(e) => setBranches(e.target.value)}
@@ -83,7 +87,8 @@ export function GitHubPRConfig({ config, onUpdate }: GitHubPRConfigProps) {
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Authors (comma-separated)</Label>
<Label className="text-xs">{t("automations:authorsLabel")}</Label>
{/* Example GitHub usernames — data, not copy. */}
<Input
value={authors}
onChange={(e) => setAuthors(e.target.value)}
@@ -99,7 +104,7 @@ export function GitHubPRConfig({ config, onUpdate }: GitHubPRConfigProps) {
onCheckedChange={(checked) => onUpdate({ ...config, exclude_draft: checked })}
className="cursor-pointer"
/>
<Label className="text-xs">Exclude draft PRs</Label>
<Label className="text-xs">{t("automations:excludeDraftPrs")}</Label>
</div>
</div>
);
@@ -112,9 +117,10 @@ function EventsSection({
events: string[];
onToggle: (event: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label className="text-xs">Events</Label>
<Label className="text-xs">{t("automations:eventsLabel")}</Label>
<div className="flex flex-wrap gap-3">
{PR_EVENTS.map((evt) => (
<label key={evt.value} className="flex items-center gap-1.5 cursor-pointer">
@@ -123,12 +129,12 @@ function EventsSection({
onCheckedChange={() => onToggle(evt.value)}
className="cursor-pointer"
/>
<span className="text-sm">{evt.label}</span>
<span className="text-sm">{t(evt.labelKey)}</span>
<Tooltip>
<TooltipTrigger asChild>
<IconInfoCircle className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-[220px]">{evt.tooltip}</TooltipContent>
<TooltipContent className="max-w-[220px]">{t(evt.tooltipKey)}</TooltipContent>
</Tooltip>
</label>
))}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
@@ -10,6 +11,7 @@ type GitHubPushConfigProps = {
};
export function GitHubPushConfig({ config, onUpdate }: GitHubPushConfigProps) {
const { t } = useTranslation();
const configBranches = ((config.branches as string[]) ?? []).join(", ");
const [branches, setBranches] = useState(configBranches);
useEffect(() => {
@@ -27,16 +29,15 @@ export function GitHubPushConfig({ config, onUpdate }: GitHubPushConfigProps) {
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Branch patterns (comma-separated, supports globs)</Label>
<Label className="text-xs">{t("automations:branchPatternsLabel")}</Label>
{/* Example branch glob patterns — data the user types verbatim. */}
<Input
value={branches}
onChange={(e) => setBranches(e.target.value)}
onBlur={handleBlur}
placeholder="main, release/*"
/>
<p className="text-xs text-muted-foreground">
Triggers when code is pushed to matching branches
</p>
<p className="text-xs text-muted-foreground">{t("automations:pushTriggerHelp")}</p>
</div>
</div>
);
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
@@ -10,13 +11,20 @@ type ScheduledConfigProps = {
onUpdate: (config: Record<string, unknown>) => void;
};
// `expression` is the cron shorthand the backend parses and persists — syntax,
// never translated. Only the label is copy.
const PRESETS = [
{ label: "Every hour", expression: "@hourly" },
{ label: "Every day", expression: "@daily" },
{ label: "Every week", expression: "@weekly" },
{ labelKey: "automations:cronEveryHour", expression: "@hourly" },
{ labelKey: "automations:cronEveryDay", expression: "@daily" },
{ labelKey: "automations:cronEveryWeek", expression: "@weekly" },
] as const;
// Shown inside the help text as an example; interpolated so the pseudo-locale
// cannot accent it into syntax that no longer parses.
const CRON_SHORTCUT_EXAMPLES = "@hourly, @daily, @weekly, @every 5m";
export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) {
const { t } = useTranslation();
const configExpr = (config.cron_expression as string) ?? "";
const [cronExpression, setCronExpression] = useState(configExpr);
useEffect(() => {
@@ -47,12 +55,12 @@ export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) {
className="cursor-pointer"
onClick={() => handlePreset(preset.expression)}
>
{preset.label}
{t(preset.labelKey)}
</Button>
))}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Cron expression</Label>
<Label className="text-xs">{t("automations:cronExpressionLabel")}</Label>
<Input
value={cronExpression}
onChange={(e) => handleCustomChange(e.target.value)}
@@ -61,7 +69,7 @@ export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) {
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Standard 5-field cron or shortcuts (@hourly, @daily, @weekly, @every 5m)
{t("automations:cronHelp", { shortcuts: CRON_SHORTCUT_EXAMPLES })}
</p>
</div>
</div>
@@ -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.<path>}}";
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 (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Webhook URL will be available after saving the automation.
</p>
<p className="text-xs text-muted-foreground">{t("automations:webhookUrlAfterSave")}</p>
<SamplePayloadSection
samplePayload={samplePayload}
onChange={setSamplePayload}
@@ -105,10 +115,18 @@ export function WebhookConfig({ automationId, workspaceId }: WebhookConfigProps)
onCopy={(value) => copyValue(value, "secret")}
/>
<p className="text-xs text-muted-foreground">
Send a POST request with a JSON body and the secret in the{" "}
<code className="bg-muted px-1 rounded">X-Webhook-Secret</code> header. Reference fields
from the payload with <code className="bg-muted px-1 rounded">{`{{webhook.<path>}}`}</code>,
e.g. <code className="bg-muted px-1 rounded">{`{{webhook.pull_request.number}}`}</code>.
<Trans
i18nKey="automations:webhookUsageHelp"
values={{
header: WEBHOOK_SECRET_HEADER,
placeholder: WEBHOOK_PATH_PLACEHOLDER,
example: WEBHOOK_PATH_EXAMPLE,
}}
>
<code className={codeClass} />
<code className={codeClass} />
<code className={codeClass} />
</Trans>
</p>
<SamplePayloadSection
samplePayload={samplePayload}
@@ -120,9 +138,10 @@ export function WebhookConfig({ automationId, workspaceId }: WebhookConfigProps)
}
function UrlField({ url, copied, onCopy }: { url: string; copied: boolean; onCopy: () => void }) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<Label className="text-xs">Webhook URL</Label>
<Label className="text-xs">{t("automations:webhookUrlLabel")}</Label>
<div className="flex gap-2">
<Input value={url} readOnly className="font-mono text-xs" />
<Button variant="outline" size="sm" className="cursor-pointer shrink-0" onClick={onCopy}>
@@ -135,13 +154,16 @@ function UrlField({ url, copied, onCopy }: { url: string; copied: boolean; onCop
type SecretState = { status: "loading" } | { status: "ready"; value: string } | { status: "error" };
// A plain function returning copy is invisible to `i18next/no-literal-string`,
// so the loading label is resolved by the caller and passed in.
function inputValueFor(
status: SecretState["status"],
secret: string | null,
hidden: boolean,
masked: string,
loadingLabel: string,
): string {
if (status === "loading") return "Loading…";
if (status === "loading") return loadingLabel;
if (!secret || hidden) return masked;
return secret;
}
@@ -157,6 +179,7 @@ function SecretField({
copied: boolean;
onCopy: (value: string) => void;
}) {
const { t } = useTranslation();
// The secret is revealable any time — fetch it once on mount so users
// landing on a saved automation can see/copy it without an extra click.
// Combining status + value in one state avoids a setState(true) inside
@@ -173,8 +196,9 @@ function SecretField({
})
.catch((err) => {
if (cancelled) return;
// The interpolated value is an API/network diagnostic and stays English.
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Failed to load webhook secret: ${msg}`);
toast.error(translate("automations:failedToLoadWebhookSecret", { error: msg }));
setState({ status: "error" });
});
return () => {
@@ -184,11 +208,17 @@ function SecretField({
const secret = state.status === "ready" ? state.value : null;
const masked = "•".repeat(32);
const inputValue = inputValueFor(state.status, secret, hidden, masked);
const inputValue = inputValueFor(
state.status,
secret,
hidden,
masked,
t("automations:webhookSecretLoading"),
);
return (
<div className="space-y-1.5">
<Label className="text-xs">Webhook secret</Label>
<Label className="text-xs">{t("automations:webhookSecretLabel")}</Label>
<div className="flex gap-2">
<Input
value={inputValue}
@@ -229,10 +259,12 @@ function SamplePayloadSection({
onChange: (value: string) => void;
detectedKeys: string[];
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<div className="space-y-1.5">
<Label className="text-xs">Sample payload (optional)</Label>
<Label className="text-xs">{t("automations:samplePayloadLabel")}</Label>
{/* An example JSON body — a value, not prose. */}
<Textarea
value={samplePayload}
onChange={(e) => onChange(e.target.value)}
@@ -240,12 +272,10 @@ function SamplePayloadSection({
className="font-mono text-xs min-h-[60px] resize-y"
rows={2}
/>
<p className="text-xs text-muted-foreground">
Paste an example JSON body to discover available placeholders.
</p>
<p className="text-xs text-muted-foreground">{t("automations:samplePayloadHelp")}</p>
</div>
<div className="space-y-1">
<Label className="text-xs">Available placeholders</Label>
<Label className="text-xs">{t("automations:availablePlaceholders")}</Label>
<div className="flex flex-wrap gap-1.5">
<PlaceholderBadge value="webhook.body" />
{detectedKeys.map((key) => (
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Command, CommandInput, CommandList, CommandGroup, CommandItem } from "@kandev/ui/command";
@@ -14,17 +15,27 @@ type TriggerPickerProps = {
};
type CategoryMeta = {
heading: string;
headingKey: string;
icon: typeof IconBrandGithub;
color: string;
};
// Keyed by the backend's trigger-type category id; only the heading is copy.
const CATEGORY_META: Record<string, CategoryMeta> = {
github: { heading: "GitHub", icon: IconBrandGithub, color: "text-purple-400" },
webhook: { heading: "Webhook", icon: IconWebhook, color: "text-blue-400" },
github: {
headingKey: "automations:triggerCategoryGithub",
icon: IconBrandGithub,
color: "text-purple-400",
},
webhook: {
headingKey: "automations:triggerCategoryWebhook",
icon: IconWebhook,
color: "text-blue-400",
},
};
export function TriggerPicker({ triggerTypes, onSelect }: TriggerPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// Only show condition types (not schedule — that's handled separately).
@@ -59,12 +70,12 @@ export function TriggerPicker({ triggerTypes, onSelect }: TriggerPickerProps) {
className="cursor-pointer text-muted-foreground"
>
<IconPlus className="h-4 w-4 mr-1" />
Add Condition
{t("automations:addCondition")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="Search conditions..." />
<CommandInput placeholder={t("automations:searchConditions")} />
<CommandList>
{groups.map(([category, items]) => {
const meta = CATEGORY_META[category];
@@ -72,7 +83,7 @@ export function TriggerPicker({ triggerTypes, onSelect }: TriggerPickerProps) {
return (
<PickerGroup
key={category}
heading={meta.heading}
heading={t(meta.headingKey)}
icon={meta.icon}
color={meta.color}
items={items}
@@ -100,6 +111,7 @@ function PickerGroup({
items: TriggerTypeInfo[];
onSelect: (info: TriggerTypeInfo) => void;
}) {
const { t } = useTranslation();
return (
<CommandGroup heading={heading}>
{items.map((item) => (
@@ -110,9 +122,11 @@ function PickerGroup({
className={!item.enabled ? "opacity-50" : "cursor-pointer"}
>
<Icon className={`h-4 w-4 mr-2 ${color}`} />
{/* item.label and item.description are authored by the backend's
trigger-type registry, not the frontend. */}
<span className="flex-1">
{item.label}
{!item.enabled && " (Coming soon)"}
{!item.enabled && t("automations:comingSoonSuffix")}
</span>
<Tooltip>
<TooltipTrigger asChild>
@@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Label } from "@kandev/ui/label";
import type { AutomationTrigger, TriggerType, TriggerTypeInfo } from "@/lib/types/automation";
@@ -31,6 +32,7 @@ export function TriggersSection({
onToggleTrigger,
onDeleteTrigger,
}: TriggersSectionProps) {
const { t } = useTranslation();
const webhookTrigger = useMemo(() => triggers.find((t) => t.type === "webhook"), [triggers]);
const savedWebhookTrigger = useMemo(
() => savedTriggers.find((t) => t.type === "webhook"),
@@ -85,7 +87,10 @@ export function TriggersSection({
onToggleEnabled={(enabled) => onToggleTrigger(webhookTrigger.id, enabled)}
onDelete={handleRemoveWebhook}
/>
<SwitchModeButton label="Switch to scheduled" onClick={handleRemoveWebhook} />
<SwitchModeButton
label={t("automations:switchToScheduled")}
onClick={handleRemoveWebhook}
/>
</div>
);
}
@@ -108,7 +113,7 @@ export function TriggersSection({
onToggleTrigger={onToggleTrigger}
onDeleteTrigger={onDeleteTrigger}
/>
<SwitchModeButton label="Or use a webhook instead" onClick={handleAddWebhook} />
<SwitchModeButton label={t("automations:orUseWebhookInstead")} onClick={handleAddWebhook} />
</div>
);
}
@@ -122,9 +127,10 @@ function ScheduleArea({
savedScheduleTrigger: AutomationTrigger | undefined;
onScheduleChange: (config: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label className="text-xs font-medium">Schedule</Label>
<Label className="text-xs font-medium">{t("automations:scheduleLabel")}</Label>
<ScheduleSelector
config={scheduleTrigger?.config ?? null}
isDirty={
@@ -158,9 +164,10 @@ function ConditionArea({
onToggleTrigger: (triggerId: string, enabled: boolean) => void;
onDeleteTrigger: (triggerId: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label className="text-xs font-medium">Watch for</Label>
<Label className="text-xs font-medium">{t("automations:watchForLabel")}</Label>
{trigger ? (
<div className="space-y-2">
<TriggerCard
@@ -1,6 +1,7 @@
"use client";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import {
Dialog,
@@ -14,6 +15,11 @@ import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
// The HTTP header name the backend checks — protocol, not copy, so it travels
// as an interpolation value rather than sitting in the catalog where the
// pseudo-locale would accent it.
const WEBHOOK_SECRET_HEADER = "X-Webhook-Secret";
type WebhookCreatedDialogProps = {
open: boolean;
webhookUrl: string;
@@ -27,26 +33,30 @@ export function WebhookCreatedDialog({
webhookSecret,
onClose,
}: WebhookCreatedDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-xl" data-testid="webhook-created-dialog">
<DialogHeader>
<DialogTitle>Automation created</DialogTitle>
<DialogTitle>{t("automations:automationCreated")}</DialogTitle>
<DialogDescription>
Configure your external system to POST to this URL with the secret in the{" "}
<code className="bg-muted px-1 rounded">X-Webhook-Secret</code> header. You can come
back to this automation any time to copy these values again.
<Trans
i18nKey="automations:webhookCreatedDescription"
values={{ header: WEBHOOK_SECRET_HEADER }}
>
<code className="bg-muted px-1 rounded" />
</Trans>
</DialogDescription>
</DialogHeader>
<CopyableField label="Webhook URL" value={webhookUrl} />
<CopyableField label="Webhook secret" value={webhookSecret} mono />
<CopyableField label={t("automations:webhookUrlLabel")} value={webhookUrl} />
<CopyableField label={t("automations:webhookSecretLabel")} value={webhookSecret} mono />
<DialogFooter>
<Button
onClick={onClose}
className="cursor-pointer"
data-testid="webhook-created-dialog-close"
>
Done
{t("automations:done")}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,17 +1,27 @@
import { t } from "@/lib/i18n";
const MULTI_REPO_SUPPORTED_EXECUTOR_TYPES = new Set(["worktree", "local_docker", "ssh", "sprites"]);
/**
* Returns the selector explanation for runtimes that cannot launch a task
* with sibling repositories. This is deliberately a pure capability check:
* changing repository rows must never replace a user's supported executor.
*
* The three messages are copy: they render as the `title` on a disabled option
* in the automation editor's Executor Profile picker and in the task-create
* dialog. This module is shared by both surfaces and belongs to neither, so the
* Automations migration translated the strings its own picker renders without
* adding the file to `i18nGuardFiles` — allowlisting it would claim a
* completeness the task-create dialog has not had. Resolved at call time rather
* than module scope, so a locale switch is picked up.
*/
export function getMultiRepoExecutorDisabledReason(executorType: string | null | undefined) {
if (MULTI_REPO_SUPPORTED_EXECUTOR_TYPES.has(executorType ?? "")) return null;
if (executorType === "local" || executorType === "local_pc") {
return "Multi-repo tasks are unavailable on Local until its initial launch path can project sibling repositories.";
return t("common:multiRepoUnavailableLocal");
}
if (executorType === "remote_docker") {
return "Multi-repo tasks are unavailable on Remote Docker until it supports creating task instances.";
return t("common:multiRepoUnavailableRemoteDocker");
}
return "Multi-repo tasks are not supported by this executor.";
return t("common:multiRepoUnsupportedExecutor");
}
+74 -3
View File
@@ -694,7 +694,13 @@ export const i18nGuardFiles = [
// union, and the `configure_session` rule `operation`s. Workflow and step
// NAMES are user data throughout and are always interpolated as values,
// never built into a message by concatenation.
"app/settings/workspace/[id]/workflows/**/*.{ts,tsx}",
// `[[]id[]]`, not `[id]`: glob brackets are a character class, so the
// unescaped form matches nothing and the entry silently guards no files.
// This one and the two under Workspaces were escaped by #2247, which hit the
// same trap on its own dynamic route. `pnpm lint` is unchanged by the repair,
// so these directories were always clean — just unguarded. See docs/i18n.md
// ("An entry can be born dead").
"app/settings/workspace/[[]id[]]/workflows/**/*.{ts,tsx}",
"app/settings/workspace/use-workflow-creation.ts",
"app/settings/workspace/workspace-workflows-client.tsx",
"app/settings/workspace/workspace-workflows-dialogs.tsx",
@@ -933,8 +939,8 @@ export const i18nGuardFiles = [
// workspace's own name, so there is no translatable type-to-confirm token
// here to break; the name travels into the sentence as an interpolated value.
"app/settings/workspace/page.tsx",
"app/settings/workspace/[id]/page.tsx",
"app/settings/workspace/[id]/repositories/page.tsx",
"app/settings/workspace/[[]id[]]/page.tsx",
"app/settings/workspace/[[]id[]]/repositories/page.tsx",
"app/settings/workspace/workspace-edit-client.tsx",
"app/settings/workspace/workspace-not-found-card.tsx",
"app/settings/workspace/workspace-repositories-client.tsx",
@@ -1128,4 +1134,69 @@ export const i18nGuardFiles = [
// chrome — and `configuration-chat-toggle.tsx`, which `ConfigChatSetup`
// renders — is still English and belongs to the quick-chat migration.
"components/config-chat/*.{ts,tsx}",
// Automations — `components/automations/**` (incl. `trigger-configs/`) and
// the three `app/settings/**/automations/**` routes. Copy lives in a new
// `automations` namespace, except three strings reused verbatim from
// `common`: `common:automations` (the word SEGMENT_LABEL_KEYS already names
// as this settings route's owner), `common:cancel`, `common:status` and
// `common:requestFailed` — each diffed byte-for-byte against what the live
// surface rendered before reusing it. Inside the namespace the reverse call
// was made for "Webhook": the badge, the card summary and the picker's group
// heading get one key each rather than sharing one, because they are three
// different grammatical contexts and a language that inflects them
// differently would otherwise have nowhere to say so.
//
// Most of this directory's copy was invisible to the guard. `pnpm run
// lint:i18n` reported 116; the migration converted 156. The extra 40 were in
// shapes the rule does not inspect: SCREAMING_CASE config tables
// (`TRIGGER_LABELS`, `STATUS_BADGE`, `EXECUTION_MODE_ITEMS`, `PRESETS` in two
// files, `CRON_PRESETS`/`SIMPLE_SUMMARIES`/`TRIGGER_INFO`, `CI_CONCLUSIONS`,
// `PR_EVENTS`, `CATEGORY_META`), plain functions returning copy
// (`getTriggerSummary`, `getWorkflowStepHelpText`, `inputValueFor`), toasts
// and a save-contributor `invalidReason` in `automation-editor.tsx`, an
// `sr-only` "required" the guard's single-lowercase-token pattern skipped,
// and `automation-repository-selection.ts` — a `.ts` module with no JSX that
// reported 0 and held a picker option label.
//
// Persisted/protocol strings deliberately left in English, because a
// translated one breaks a stored automation with nothing failing until a
// locale switch: `DEFAULT_PROMPT` in `automation-editor.tsx` (persisted, sent
// to the agent verbatim, and compared with `===`), the `"New Automation"` and
// `"New Repository"` name fallbacks (both written to a record), every cron
// expression and shorthand, every TriggerType / RunStatus / ExecutionMode /
// PR-event / CI-conclusion id, `X-Webhook-Secret`, the `{{webhook.*}}`
// placeholder paths, and the example values in `placeholder` attributes
// (branch names, CI check names, GitHub usernames, a sample JSON body).
// `run.trigger_type` renders raw in the runs table for the same reason.
// Where syntax appears inside a sentence it travels as an interpolation
// value, so the pseudo-locale cannot accent it into something that no longer
// parses.
//
// NOT listed, and deliberately so:
// - `components/task-create-dialog-multi-repo-guard.ts`. Its three disabled
// -executor explanations render as a `title` in this editor's Executor
// Profile picker, so they are migrated here (into `common:multiRepo*`),
// but the module is shared with the un-migrated task-create dialog and
// belongs to neither. Allowlisting it would claim a completeness that
// dialog has not had; whichever migration takes the dialog adds it.
// - `formatRelativeTime` in `lib/utils.ts`, which the runs table and the
// automations table both render. It is a repo-wide formatter with 21
// consumers and `lib/i18n/formats.ts` already holds its intended
// replacement (`formatRelative`); swapping it is a cross-cutting change,
// not part of a copy migration.
//
// NOTE the `[[]id[]]` escaping on the dynamic route. Written as `[id]`, the
// brackets are a glob CHARACTER CLASS matching a single `i` or `d`, so the
// pattern matches nothing and the route is silently unguarded — verified by
// putting a hardcoded literal in `automations/new/page.tsx` and watching
// `pnpm lint` report 0 errors. `check-guard-allowlist.mjs` cannot catch this:
// it only inspects entries that LEFT the array, so an entry that never
// matched anything looks identical to a healthy one. `fs.globSync` on the
// escaped form is what proves an entry is live.
"components/automations/*.{ts,tsx}",
"components/automations/trigger-configs/*.tsx",
"app/settings/automations/page.tsx",
"app/settings/workspace/[[]id[]]/automations/**/*.tsx",
"hooks/domains/settings/use-automation-runs.ts",
];
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useCallback } from "react";
import { t } from "@/lib/i18n";
import { toast } from "@/lib/toast/sonner";
import {
listAutomationRuns,
@@ -75,7 +76,9 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
removeRun(automationId, runId);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "Failed to delete run";
// An Error message here is an API diagnostic and stays English; the
// fallback for a non-Error rejection is copy.
const msg = err instanceof Error ? err.message : t("automations:failedToDeleteRun");
toast.error(msg);
// revert on failure
listAutomationRuns(automationId)
@@ -88,7 +91,7 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
if (deletedRun) {
restoreRun(automationId, deletedRun);
}
toast.error("Could not refresh runs — restored from local cache");
toast.error(t("automations:couldNotRefreshRuns"));
});
});
},
@@ -108,7 +111,7 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
clearRuns(automationId);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "Failed to delete runs";
const msg = err instanceof Error ? err.message : t("automations:failedToDeleteRuns");
toast.error(msg);
// revert on failure
listAutomationRuns(automationId)
@@ -118,7 +121,7 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
// would stay permanently empty even though delete-all never
// succeeded server-side. Fall back to the pre-clear snapshot.
setRuns(automationId, previousRuns);
toast.error("Could not refresh runs — restored from local cache");
toast.error(t("automations:couldNotRefreshRuns"));
});
});
}, [automationId, clearRuns, setRuns, storeApi, workspaceId]);
+155
View File
@@ -0,0 +1,155 @@
{
"addCondition": "Add Condition",
"addRepository": "Add repository",
"agentProfileLabel": "Agent Profile",
"agentProfilePlaceholder": "Select agent",
"alreadyAdded": "Already added",
"authorsLabel": "Authors (comma-separated)",
"automationCreated": "Automation created",
"availablePlaceholders": "Available placeholders",
"availablePlaceholdersColon": "Available placeholders:",
"baseBranchesLabel": "Base branches (comma-separated)",
"branchPatternsLabel": "Branch patterns (comma-separated, supports globs)",
"checkNamesLabel": "Check names (comma-separated, optional)",
"ciConclusionCancelled": "Cancelled",
"ciConclusionFailure": "Failure",
"ciConclusionSuccess": "Success",
"ciConclusionsLabel": "Conclusions",
"columnEnabled": "Enabled",
"columnLastTriggered": "Last Triggered",
"columnMode": "Mode",
"columnName": "Name",
"columnTriggers": "Triggers",
"comingSoonSuffix": " (Coming soon)",
"completeRequiredFields": "Complete the required automation fields before saving.",
"configurationLabel": "Configuration",
"couldNotRefreshRuns": "Could not refresh runs — restored from local cache",
"createWorkspace": "Create workspace",
"cronEveryDay": "Every day",
"cronEveryHour": "Every hour",
"cronEveryWeek": "Every week",
"cronExpressionLabel": "Cron expression",
"cronHelp": "Standard 5-field cron or shortcuts ({{shortcuts}})",
"customIntervalLabel": "Custom interval",
"delete": "Delete",
"deleteAll": "Delete all",
"deleteAllRuns": "Delete all runs",
"deleteAllRunsDescription": "This will permanently remove all run records for this automation — including any not currently loaded — and their associated tasks. This cannot be undone.",
"deleteAllRunsTitle": "Delete all runs?",
"deleteRun": "Delete run",
"done": "Done",
"enabledLabel": "Enabled",
"eventsLabel": "Events",
"excludeDraftPrs": "Exclude draft PRs",
"executionModeLabel": "Execution Mode",
"executionModePlaceholder": "Select mode",
"executionModeRun": "Run — fire-and-forget, hidden from kanban",
"executionModeRunShort": "Run",
"executionModeTask": "Task — creates a tracked kanban task",
"executionModeTaskShort": "Task",
"executorProfileLabel": "Executor Profile",
"executorProfilePlaceholder": "Select executor",
"failedToDeleteAutomation": "Failed to delete automation",
"failedToDeleteRun": "Failed to delete run",
"failedToDeleteRuns": "Failed to delete runs",
"failedToLoadWebhookSecret": "Failed to load webhook secret: {{error}}",
"failedToSaveAutomation": "Failed to save automation: {{error}}",
"instructionsLabel": "Instructions",
"invalidExpression": "Invalid expression. Use {{every}} with a duration, a shorthand, or a 5-field cron.",
"listDescription": "Create rules that automatically trigger agent tasks.",
"loading": "Loading...",
"loadingAutomations": "Loading automations...",
"maxConcurrentRuns": "Max concurrent runs",
"nameHelp": "Enter an automation name to enable saving.",
"nameLabel": "Name",
"namePlaceholder": "Automation name",
"neverTriggered": "Never",
"newAutomation": "New Automation",
"noAutomationsYet": "No automations yet",
"noRepositoriesHelp": "With no repositories selected, this automation runs against the workspace's first repository.",
"noRunsYet": "No runs yet",
"noTriggers": "None",
"noWorkspacesDescription": "Create a workspace first, then schedule automations inside it.",
"noWorkspacesYet": "No workspaces yet",
"orUseWebhookInstead": "Or use a webhook instead",
"pickWorkspace": "Pick a workspace to manage its automations.",
"prEventOpened": "Opened",
"prEventOpenedTooltip": "Fires once when a new open PR is detected. Each PR number is deduped — the same PR won't trigger again.",
"prTriggerRepositoryHelp": "PR triggers always use the PR's own repository.",
"presetDaily": "Daily",
"presetHours_one": "{{count}} hour",
"presetHours_other": "{{count}} hours",
"presetMinutes_one": "{{count}} min",
"presetMinutes_other": "{{count}} min",
"presetWeekly": "Weekly",
"pushTriggerHelp": "Triggers when code is pushed to matching branches",
"recentRuns_one": "Recent Runs ({{count}})",
"recentRuns_other": "Recent Runs ({{count}})",
"refresh": "Refresh",
"removeRepository": "Remove repository",
"repositoryLabel": "Repository",
"repositoryNone": "None — no repository",
"repositoryPlaceholderAuto": "Auto",
"required": "required",
"runStatusArchived": "Archived",
"runStatusCancelled": "Cancelled",
"runStatusFailed": "Failed",
"runStatusRunning": "Running",
"runStatusSkipped": "Skipped",
"runStatusSucceeded": "Succeeded",
"runStatusTriggered": "Triggered",
"runsColumnError": "Error",
"runsColumnTask": "Task",
"runsColumnTime": "Time",
"runsColumnTrigger": "Trigger",
"samplePayloadHelp": "Paste an example JSON body to discover available placeholders.",
"samplePayloadLabel": "Sample payload (optional)",
"scheduleLabel": "Schedule",
"scheduleSyntaxHelp": "Use <0>{{every}}</0> with a duration (e.g., <1>{{example1}}</1>, <2>{{example2}}</2>) or shorthands like <3>{{hourly}}</3>, <4>{{daily}}</4>, <5>{{weekly}}</5>.",
"scheduleTooltip": "How often to check for matching events. Checked every 30 seconds by a background process. Schedules persist across backend restarts.",
"searchConditions": "Search conditions...",
"selectRepositoryPlaceholder": "Select a repository",
"summaryCiCompleted": "CI completed",
"summaryCron": "Cron: {{expression}}",
"summaryCustomSchedule": "Custom schedule",
"summaryPrEvents": "PR: {{events}}",
"summaryPullRequestEvent": "Pull request event",
"summaryPushToBranch": "Push to branch",
"summaryWebhook": "Webhook",
"switchToScheduled": "Switch to scheduled",
"taskTitleHelp": "Leave empty to use the default. Supports placeholders.",
"taskTitleLabel": "Task title",
"taskTitlePlaceholder": "[Auto] automation name",
"thenDescription": "A new task will be created each time this automation triggers",
"thenTitle": "Then",
"triggerCategoryGithub": "GitHub",
"triggerCategoryWebhook": "Webhook",
"triggerInfoGithubPr": "Polls GitHub API on your schedule for PRs matching your filters.",
"triggerInfoNotImplemented": "Not yet implemented.",
"triggerInfoScheduled": "Checked every 30 seconds. Fires when the cron schedule matches.",
"triggerInfoWebhook": "Fires immediately when a POST request hits the webhook URL.",
"triggerLabelGithubCi": "GitHub CI",
"triggerLabelGithubPr": "GitHub PR",
"triggerLabelGithubPush": "GitHub Push",
"triggerLabelScheduled": "Scheduled",
"triggerLabelWebhook": "Webhook",
"triggerManually": "Trigger manually",
"unknownTriggerType": "Unknown trigger type",
"watchForLabel": "Watch for",
"webhookCreatedDescription": "Configure your external system to POST to this URL with the secret in the <0>{{header}}</0> header. You can come back to this automation any time to copy these values again.",
"webhookSecretLabel": "Webhook secret",
"webhookSecretLoading": "Loading…",
"webhookUrlAfterSave": "Webhook URL will be available after saving the automation.",
"webhookUrlLabel": "Webhook URL",
"webhookUsageHelp": "Send a POST request with a JSON body and the secret in the <0>{{header}}</0> header. Reference fields from the payload with <1>{{placeholder}}</1>, e.g. <2>{{example}}</2>.",
"whenDescription": "What causes this automation to run",
"whenTitle": "When",
"workflowHelp": "Select a workflow to enable saving.",
"workflowLabel": "Workflow",
"workflowPlaceholder": "Select workflow",
"workflowStepHelp": "Select a workflow step to enable saving.",
"workflowStepHelpNoWorkflow": "Select a workflow before choosing a step.",
"workflowStepLabel": "Workflow Step",
"workflowStepPlaceholder": "Select step",
"workflowStepPlaceholderNoWorkflow": "Pick a workflow first"
}
+3
View File
@@ -46,6 +46,9 @@
"integrations": "Integrations",
"justNow": "just now",
"mAgo": "{{diffMin}}m ago",
"multiRepoUnavailableLocal": "Multi-repo tasks are unavailable on Local until its initial launch path can project sibling repositories.",
"multiRepoUnavailableRemoteDocker": "Multi-repo tasks are unavailable on Remote Docker until it supports creating task instances.",
"multiRepoUnsupportedExecutor": "Multi-repo tasks are not supported by this executor.",
"plugins": "Plugins",
"prDetails": "PR Details",
"presetIconBug": "Bug",
@@ -0,0 +1,155 @@
{
"addCondition": "Àďď Ćōńďĩţĩōń",
"addRepository": "Àďď ŕēƥōśĩţōŕŷ",
"agentProfileLabel": "Àĝēńţ Ƥŕōƒĩĺē",
"agentProfilePlaceholder": "Śēĺēćţ àĝēńţ",
"alreadyAdded": "Àĺŕēàďŷ àďďēď",
"authorsLabel": "Àũţĥōŕś (ćōḿḿà-śēƥàŕàţēď)",
"automationCreated": "Àũţōḿàţĩōń ćŕēàţēď",
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś",
"availablePlaceholdersColon": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
"baseBranchesLabel": "Ɓàśē ƀŕàńćĥēś (ćōḿḿà-śēƥàŕàţēď)",
"branchPatternsLabel": "Ɓŕàńćĥ ƥàţţēŕńś (ćōḿḿà-śēƥàŕàţēď, śũƥƥōŕţś ĝĺōƀś)",
"checkNamesLabel": "Ćĥēćķ ńàḿēś (ćōḿḿà-śēƥàŕàţēď, ōƥţĩōńàĺ)",
"ciConclusionCancelled": "Ćàńćēĺĺēď",
"ciConclusionFailure": "Ƒàĩĺũŕē",
"ciConclusionSuccess": "Śũććēśś",
"ciConclusionsLabel": "Ćōńćĺũśĩōńś",
"columnEnabled": "Ēńàƀĺēď",
"columnLastTriggered": "Ĺàśţ Ţŕĩĝĝēŕēď",
"columnMode": "Ḿōďē",
"columnName": "Ńàḿē",
"columnTriggers": "Ţŕĩĝĝēŕś",
"comingSoonSuffix": " (Ćōḿĩńĝ śōōń)",
"completeRequiredFields": "Ćōḿƥĺēţē ţĥē ŕēqũĩŕēď àũţōḿàţĩōń ƒĩēĺďś ƀēƒōŕē śàvĩńĝ.",
"configurationLabel": "Ćōńƒĩĝũŕàţĩōń",
"couldNotRefreshRuns": "Ćōũĺď ńōţ ŕēƒŕēśĥ ŕũńś — ŕēśţōŕēď ƒŕōḿ ĺōćàĺ ćàćĥē",
"createWorkspace": "Ćŕēàţē ŵōŕķśƥàćē",
"cronEveryDay": "Ēvēŕŷ ďàŷ",
"cronEveryHour": "Ēvēŕŷ ĥōũŕ",
"cronEveryWeek": "Ēvēŕŷ ŵēēķ",
"cronExpressionLabel": "Ćŕōń ēxƥŕēśśĩōń",
"cronHelp": "Śţàńďàŕď 5-ƒĩēĺď ćŕōń ōŕ śĥōŕţćũţś ({{shortcuts}})",
"customIntervalLabel": "Ćũśţōḿ ĩńţēŕvàĺ",
"delete": "Ďēĺēţē",
"deleteAll": "Ďēĺēţē àĺĺ",
"deleteAllRuns": "Ďēĺēţē àĺĺ ŕũńś",
"deleteAllRunsDescription": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē àĺĺ ŕũń ŕēćōŕďś ƒōŕ ţĥĩś àũţōḿàţĩōń — ĩńćĺũďĩńĝ àńŷ ńōţ ćũŕŕēńţĺŷ ĺōàďēď — àńď ţĥēĩŕ àśśōćĩàţēď ţàśķś. Ţĥĩś ćàńńōţ ƀē ũńďōńē.",
"deleteAllRunsTitle": "Ďēĺēţē àĺĺ ŕũńś?",
"deleteRun": "Ďēĺēţē ŕũń",
"done": "Ďōńē",
"enabledLabel": "Ēńàƀĺēď",
"eventsLabel": "Ēvēńţś",
"excludeDraftPrs": "Ēxćĺũďē ďŕàƒţ ƤŔś",
"executionModeLabel": "Ēxēćũţĩōń Ḿōďē",
"executionModePlaceholder": "Śēĺēćţ ḿōďē",
"executionModeRun": "Ŕũń — ƒĩŕē-àńď-ƒōŕĝēţ, ĥĩďďēń ƒŕōḿ ķàńƀàń",
"executionModeRunShort": "Ŕũń",
"executionModeTask": "Ţàśķ — ćŕēàţēś à ţŕàćķēď ķàńƀàń ţàśķ",
"executionModeTaskShort": "Ţàśķ",
"executorProfileLabel": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
"executorProfilePlaceholder": "Śēĺēćţ ēxēćũţōŕ",
"failedToDeleteAutomation": "Ƒàĩĺēď ţō ďēĺēţē àũţōḿàţĩōń",
"failedToDeleteRun": "Ƒàĩĺēď ţō ďēĺēţē ŕũń",
"failedToDeleteRuns": "Ƒàĩĺēď ţō ďēĺēţē ŕũńś",
"failedToLoadWebhookSecret": "Ƒàĩĺēď ţō ĺōàď ŵēƀĥōōķ śēćŕēţ: {{error}}",
"failedToSaveAutomation": "Ƒàĩĺēď ţō śàvē àũţōḿàţĩōń: {{error}}",
"instructionsLabel": "Ĩńśţŕũćţĩōńś",
"invalidExpression": "Ĩńvàĺĩď ēxƥŕēśśĩōń. Ũśē {{every}} ŵĩţĥ à ďũŕàţĩōń, à śĥōŕţĥàńď, ōŕ à 5-ƒĩēĺď ćŕōń.",
"listDescription": "Ćŕēàţē ŕũĺēś ţĥàţ àũţōḿàţĩćàĺĺŷ ţŕĩĝĝēŕ àĝēńţ ţàśķś.",
"loading": "Ĺōàďĩńĝ...",
"loadingAutomations": "Ĺōàďĩńĝ àũţōḿàţĩōńś...",
"maxConcurrentRuns": "Ḿàx ćōńćũŕŕēńţ ŕũńś",
"nameHelp": "Ēńţēŕ àń àũţōḿàţĩōń ńàḿē ţō ēńàƀĺē śàvĩńĝ.",
"nameLabel": "Ńàḿē",
"namePlaceholder": "Àũţōḿàţĩōń ńàḿē",
"neverTriggered": "Ńēvēŕ",
"newAutomation": "Ńēŵ Àũţōḿàţĩōń",
"noAutomationsYet": "Ńō àũţōḿàţĩōńś ŷēţ",
"noRepositoriesHelp": "Ŵĩţĥ ńō ŕēƥōśĩţōŕĩēś śēĺēćţēď, ţĥĩś àũţōḿàţĩōń ŕũńś àĝàĩńśţ ţĥē ŵōŕķśƥàćē'ś ƒĩŕśţ ŕēƥōśĩţōŕŷ.",
"noRunsYet": "Ńō ŕũńś ŷēţ",
"noTriggers": "Ńōńē",
"noWorkspacesDescription": "Ćŕēàţē à ŵōŕķśƥàćē ƒĩŕśţ, ţĥēń śćĥēďũĺē àũţōḿàţĩōńś ĩńśĩďē ĩţ.",
"noWorkspacesYet": "Ńō ŵōŕķśƥàćēś ŷēţ",
"orUseWebhookInstead": "Ōŕ ũśē à ŵēƀĥōōķ ĩńśţēàď",
"pickWorkspace": "Ƥĩćķ à ŵōŕķśƥàćē ţō ḿàńàĝē ĩţś àũţōḿàţĩōńś.",
"prEventOpened": "Ōƥēńēď",
"prEventOpenedTooltip": "Ƒĩŕēś ōńćē ŵĥēń à ńēŵ ōƥēń ƤŔ ĩś ďēţēćţēď. Ēàćĥ ƤŔ ńũḿƀēŕ ĩś ďēďũƥēď — ţĥē śàḿē ƤŔ ŵōń'ţ ţŕĩĝĝēŕ àĝàĩń.",
"prTriggerRepositoryHelp": "ƤŔ ţŕĩĝĝēŕś àĺŵàŷś ũśē ţĥē ƤŔ'ś ōŵń ŕēƥōśĩţōŕŷ.",
"presetDaily": "Ďàĩĺŷ",
"presetHours_one": "{{count}} ĥōũŕ",
"presetHours_other": "{{count}} ĥōũŕś",
"presetMinutes_one": "{{count}} ḿĩń",
"presetMinutes_other": "{{count}} ḿĩń",
"presetWeekly": "Ŵēēķĺŷ",
"pushTriggerHelp": "Ţŕĩĝĝēŕś ŵĥēń ćōďē ĩś ƥũśĥēď ţō ḿàţćĥĩńĝ ƀŕàńćĥēś",
"recentRuns_one": "Ŕēćēńţ Ŕũńś ({{count}})",
"recentRuns_other": "Ŕēćēńţ Ŕũńś ({{count}})",
"refresh": "Ŕēƒŕēśĥ",
"removeRepository": "Ŕēḿōvē ŕēƥōśĩţōŕŷ",
"repositoryLabel": "Ŕēƥōśĩţōŕŷ",
"repositoryNone": "Ńōńē — ńō ŕēƥōśĩţōŕŷ",
"repositoryPlaceholderAuto": "Àũţō",
"required": "ŕēqũĩŕēď",
"runStatusArchived": "Àŕćĥĩvēď",
"runStatusCancelled": "Ćàńćēĺĺēď",
"runStatusFailed": "Ƒàĩĺēď",
"runStatusRunning": "Ŕũńńĩńĝ",
"runStatusSkipped": "Śķĩƥƥēď",
"runStatusSucceeded": "Śũććēēďēď",
"runStatusTriggered": "Ţŕĩĝĝēŕēď",
"runsColumnError": "Ēŕŕōŕ",
"runsColumnTask": "Ţàśķ",
"runsColumnTime": "Ţĩḿē",
"runsColumnTrigger": "Ţŕĩĝĝēŕ",
"samplePayloadHelp": "Ƥàśţē àń ēxàḿƥĺē ĴŚŌŃ ƀōďŷ ţō ďĩśćōvēŕ àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś.",
"samplePayloadLabel": "Śàḿƥĺē ƥàŷĺōàď (ōƥţĩōńàĺ)",
"scheduleLabel": "Śćĥēďũĺē",
"scheduleSyntaxHelp": "Ũśē <0>{{every}}</0> ŵĩţĥ à ďũŕàţĩōń (ē.ĝ., <1>{{example1}}</1>, <2>{{example2}}</2>) ōŕ śĥōŕţĥàńďś ĺĩķē <3>{{hourly}}</3>, <4>{{daily}}</4>, <5>{{weekly}}</5>.",
"scheduleTooltip": "Ĥōŵ ōƒţēń ţō ćĥēćķ ƒōŕ ḿàţćĥĩńĝ ēvēńţś. Ćĥēćķēď ēvēŕŷ 30 śēćōńďś ƀŷ à ƀàćķĝŕōũńď ƥŕōćēśś. Śćĥēďũĺēś ƥēŕśĩśţ àćŕōśś ƀàćķēńď ŕēśţàŕţś.",
"searchConditions": "Śēàŕćĥ ćōńďĩţĩōńś...",
"selectRepositoryPlaceholder": "Śēĺēćţ à ŕēƥōśĩţōŕŷ",
"summaryCiCompleted": "ĆĨ ćōḿƥĺēţēď",
"summaryCron": "Ćŕōń: {{expression}}",
"summaryCustomSchedule": "Ćũśţōḿ śćĥēďũĺē",
"summaryPrEvents": "ƤŔ: {{events}}",
"summaryPullRequestEvent": "Ƥũĺĺ ŕēqũēśţ ēvēńţ",
"summaryPushToBranch": "Ƥũśĥ ţō ƀŕàńćĥ",
"summaryWebhook": "Ŵēƀĥōōķ",
"switchToScheduled": "Śŵĩţćĥ ţō śćĥēďũĺēď",
"taskTitleHelp": "Ĺēàvē ēḿƥţŷ ţō ũśē ţĥē ďēƒàũĺţ. Śũƥƥōŕţś ƥĺàćēĥōĺďēŕś.",
"taskTitleLabel": "Ţàśķ ţĩţĺē",
"taskTitlePlaceholder": "[Àũţō] àũţōḿàţĩōń ńàḿē",
"thenDescription": "À ńēŵ ţàśķ ŵĩĺĺ ƀē ćŕēàţēď ēàćĥ ţĩḿē ţĥĩś àũţōḿàţĩōń ţŕĩĝĝēŕś",
"thenTitle": "Ţĥēń",
"triggerCategoryGithub": "ĜĩţĤũƀ",
"triggerCategoryWebhook": "Ŵēƀĥōōķ",
"triggerInfoGithubPr": "Ƥōĺĺś ĜĩţĤũƀ ÀƤĨ ōń ŷōũŕ śćĥēďũĺē ƒōŕ ƤŔś ḿàţćĥĩńĝ ŷōũŕ ƒĩĺţēŕś.",
"triggerInfoNotImplemented": "Ńōţ ŷēţ ĩḿƥĺēḿēńţēď.",
"triggerInfoScheduled": "Ćĥēćķēď ēvēŕŷ 30 śēćōńďś. Ƒĩŕēś ŵĥēń ţĥē ćŕōń śćĥēďũĺē ḿàţćĥēś.",
"triggerInfoWebhook": "Ƒĩŕēś ĩḿḿēďĩàţēĺŷ ŵĥēń à ƤŌŚŢ ŕēqũēśţ ĥĩţś ţĥē ŵēƀĥōōķ ŨŔĹ.",
"triggerLabelGithubCi": "ĜĩţĤũƀ ĆĨ",
"triggerLabelGithubPr": "ĜĩţĤũƀ ƤŔ",
"triggerLabelGithubPush": "ĜĩţĤũƀ Ƥũśĥ",
"triggerLabelScheduled": "Śćĥēďũĺēď",
"triggerLabelWebhook": "Ŵēƀĥōōķ",
"triggerManually": "Ţŕĩĝĝēŕ ḿàńũàĺĺŷ",
"unknownTriggerType": "Ũńķńōŵń ţŕĩĝĝēŕ ţŷƥē",
"watchForLabel": "Ŵàţćĥ ƒōŕ",
"webhookCreatedDescription": "Ćōńƒĩĝũŕē ŷōũŕ ēxţēŕńàĺ śŷśţēḿ ţō ƤŌŚŢ ţō ţĥĩś ŨŔĹ ŵĩţĥ ţĥē śēćŕēţ ĩń ţĥē <0>{{header}}</0> ĥēàďēŕ. Ŷōũ ćàń ćōḿē ƀàćķ ţō ţĥĩś àũţōḿàţĩōń àńŷ ţĩḿē ţō ćōƥŷ ţĥēśē vàĺũēś àĝàĩń.",
"webhookSecretLabel": "Ŵēƀĥōōķ śēćŕēţ",
"webhookSecretLoading": "Ĺōàďĩńĝ…",
"webhookUrlAfterSave": "Ŵēƀĥōōķ ŨŔĹ ŵĩĺĺ ƀē àvàĩĺàƀĺē àƒţēŕ śàvĩńĝ ţĥē àũţōḿàţĩōń.",
"webhookUrlLabel": "Ŵēƀĥōōķ ŨŔĹ",
"webhookUsageHelp": "Śēńď à ƤŌŚŢ ŕēqũēśţ ŵĩţĥ à ĴŚŌŃ ƀōďŷ àńď ţĥē śēćŕēţ ĩń ţĥē <0>{{header}}</0> ĥēàďēŕ. Ŕēƒēŕēńćē ƒĩēĺďś ƒŕōḿ ţĥē ƥàŷĺōàď ŵĩţĥ <1>{{placeholder}}</1>, ē.ĝ. <2>{{example}}</2>.",
"whenDescription": "Ŵĥàţ ćàũśēś ţĥĩś àũţōḿàţĩōń ţō ŕũń",
"whenTitle": "Ŵĥēń",
"workflowHelp": "Śēĺēćţ à ŵōŕķƒĺōŵ ţō ēńàƀĺē śàvĩńĝ.",
"workflowLabel": "Ŵōŕķƒĺōŵ",
"workflowPlaceholder": "Śēĺēćţ ŵōŕķƒĺōŵ",
"workflowStepHelp": "Śēĺēćţ à ŵōŕķƒĺōŵ śţēƥ ţō ēńàƀĺē śàvĩńĝ.",
"workflowStepHelpNoWorkflow": "Śēĺēćţ à ŵōŕķƒĺōŵ ƀēƒōŕē ćĥōōśĩńĝ à śţēƥ.",
"workflowStepLabel": "Ŵōŕķƒĺōŵ Śţēƥ",
"workflowStepPlaceholder": "Śēĺēćţ śţēƥ",
"workflowStepPlaceholderNoWorkflow": "Ƥĩćķ à ŵōŕķƒĺōŵ ƒĩŕśţ"
}
+3
View File
@@ -46,6 +46,9 @@
"integrations": "Ĩńţēĝŕàţĩōńś",
"justNow": "ĵũśţ ńōŵ",
"mAgo": "{{diffMin}}ḿ àĝō",
"multiRepoUnavailableLocal": "Ḿũĺţĩ-ŕēƥō ţàśķś àŕē ũńàvàĩĺàƀĺē ōń Ĺōćàĺ ũńţĩĺ ĩţś ĩńĩţĩàĺ ĺàũńćĥ ƥàţĥ ćàń ƥŕōĵēćţ śĩƀĺĩńĝ ŕēƥōśĩţōŕĩēś.",
"multiRepoUnavailableRemoteDocker": "Ḿũĺţĩ-ŕēƥō ţàśķś àŕē ũńàvàĩĺàƀĺē ōń Ŕēḿōţē Ďōćķēŕ ũńţĩĺ ĩţ śũƥƥōŕţś ćŕēàţĩńĝ ţàśķ ĩńśţàńćēś.",
"multiRepoUnsupportedExecutor": "Ḿũĺţĩ-ŕēƥō ţàśķś àŕē ńōţ śũƥƥōŕţēď ƀŷ ţĥĩś ēxēćũţōŕ.",
"plugins": "Ƥĺũĝĩńś",
"prDetails": "ƤŔ Ďēţàĩĺś",
"presetIconBug": "Ɓũĝ",
+24
View File
@@ -235,6 +235,30 @@ new PR's. Entries that a broader glob already covers are deliberate and stay —
they record which PR migrated which half of a group — so this is an exact-match
check, not a redundancy check.
#### An entry can be born dead, and only a glob expansion shows it
Both checks above ask what happened to an entry *between* two states. Neither
asks the prior question: **does this entry match any file at all?** An entry that
matched nothing on the day it was added is indistinguishable from a healthy one —
it never leaves the array, it is never a duplicate, and `pnpm lint` passes
because the rule simply has no files to apply to. The list grows, the ratchet
reports "N added", and the path is unguarded.
A dynamic route is the way to hit this, because Next-style directories are named
in glob syntax. `[id]` is a **character class** matching a single `i` or `d`:
```bash
node -e 'console.log(require("fs").globSync("app/settings/workspace/[id]/automations/**/*.tsx").length)' # 0
node -e 'console.log(require("fs").globSync("app/settings/workspace/[[]id[]]/automations/**/*.tsx").length)' # 4
```
The Automations migration (#2247) shipped the unescaped form, and review caught
it. So when an entry contains `[`, escape it as `[[]…[]]` — and prove the entry
is live rather than trusting the glob result, the same way you would prove any
other check earned its pass: put a hardcoded literal in one of the files the
entry is supposed to cover and confirm `pnpm lint` reports
`i18next/no-literal-string`, then remove it. A silent 0 errors is the tell.
### …but new code is guarded everywhere
Scoping the whole-file guard leaves a hole: a brand-new component under an