diff --git a/apps/web/components/linear/linear-issue-watch-dialog.tsx b/apps/web/components/linear/linear-issue-watch-dialog.tsx index 051905fa7d..7e2b561a3b 100644 --- a/apps/web/components/linear/linear-issue-watch-dialog.tsx +++ b/apps/web/components/linear/linear-issue-watch-dialog.tsx @@ -4,7 +4,6 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@kandev/ui/button"; import { Separator } from "@kandev/ui/separator"; import { Label } from "@kandev/ui/label"; -import { Input } from "@kandev/ui/input"; import { Dialog, DialogContent, @@ -13,7 +12,6 @@ import { DialogDescription, DialogFooter, } from "@kandev/ui/dialog"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { IconInfoCircle } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { CliModeIcon } from "@/components/cli-mode-icon"; @@ -25,36 +23,26 @@ import { ScriptEditor, computeEditorHeight, } from "@/components/settings/profile-edit/script-editor"; -import { - LabelMultiSelect, - PriorityMultiSelect, - SettingsFields, - StateMultiSelect, - useTeamsAndStates, -} from "./linear-issue-watch-fields"; -import { LINEAR_ISSUE_WATCH_PLACEHOLDERS } from "./linear-issue-watch-placeholders"; +import { SettingsFields } from "./linear-issue-watch-fields"; +import { FilterFields, SelectField } from "./linear-issue-watch-filter-fields"; +import { linearIssueWatchPlaceholders } from "./linear-issue-watch-placeholders"; import { STEP_DEFAULT, STEP_DEFAULT_LABEL, resolveProfileId } from "@/lib/watcher-profile-default"; import { WatcherRepositoryFields } from "@/components/watcher-repository-fields"; import { clearWorkspaceScopedForm } from "@/lib/watcher-repository-default"; import { - ASSIGNED_ANY, - CREATOR_ANY, type FormState, - type LinearPriority, buildWatchPayload, - creatorPlaceholder, formStateFromWatch, isWatchFormReady, makeEmptyForm, - userOptionLabel, } from "./linear-issue-watch-form"; import type { CreateLinearIssueWatchInput, LinearIssueWatch, - LinearTeam, - LinearUser, UpdateLinearIssueWatchInput, } from "@/lib/types/linear"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; type Props = { open: boolean; @@ -82,254 +70,9 @@ function useFormData(workspaceId: string) { return { workflows, agentProfiles, allExecutorProfiles }; } -type SelectFieldItem = { id: string; label: string; icon?: React.ReactNode }; - -function SelectField(props: { - label: string; - description?: string; - value: string; - onChange: (v: string) => void; - placeholder: string; - items: SelectFieldItem[]; - disabled?: boolean; -}) { - return ( -
- - {props.description &&

{props.description}

} - -
- ); -} - -function FilterFields({ - form, - setForm, -}: { - form: FormState; - setForm: React.Dispatch>; -}) { - const { teams, states, labels, users, loadingStates, loadingLabels, loadingUsers } = - useTeamsAndStates(form.workspaceId, form.teamKey); - const toggleState = useCallback( - (id: string) => - setForm((p) => ({ - ...p, - stateIds: p.stateIds.includes(id) - ? p.stateIds.filter((s) => s !== id) - : [...p.stateIds, id], - })), - [setForm], - ); - const toggleLabel = useCallback( - (id: string) => - setForm((p) => ({ - ...p, - labelIds: p.labelIds.includes(id) - ? p.labelIds.filter((l) => l !== id) - : [...p.labelIds, id], - })), - [setForm], - ); - const togglePriority = useCallback( - (priority: LinearPriority) => - setForm((p) => ({ - ...p, - priorities: p.priorities.includes(priority) - ? p.priorities.filter((x) => x !== priority) - : [...p.priorities, priority], - })), - [setForm], - ); - - return ( - <> - - -
- -

- Click to toggle. Matches issues at ANY of the selected priorities. -

- -
-
- -

- {form.teamKey - ? "Click states to toggle. Empty matches every state on the team." - : "Pick a team to choose specific workflow states."} -

- -
-
- -

- {form.teamKey - ? "Click to toggle. Matches ANY of the selected labels." - : "Pick a team to choose specific labels."} -

- -
- - - - ); -} - -type FormSetter = React.Dispatch>; - -function TeamRow({ - form, - setForm, - teams, -}: { - form: FormState; - setForm: FormSetter; - teams: LinearTeam[]; -}) { - return ( - - setForm((p) => ({ ...p, teamKey: v, stateIds: [], labelIds: [], creatorId: "" })) - } - placeholder="(any team)" - items={teams.map((t) => ({ id: t.key, label: `${t.name} (${t.key})` }))} - /> - ); -} - -function AssigneeAndCreatorRow({ - form, - setForm, - users, - loadingUsers, -}: { - form: FormState; - setForm: FormSetter; - users: LinearUser[]; - loadingUsers: boolean; -}) { - return ( -
- setForm((p) => ({ ...p, assigned: v === ASSIGNED_ANY ? "" : v }))} - placeholder="(any)" - items={[ - { id: ASSIGNED_ANY, label: "(any)" }, - { id: "me", label: "Me" }, - { id: "unassigned", label: "Unassigned" }, - ]} - /> - setForm((p) => ({ ...p, creatorId: v === CREATOR_ANY ? "" : v }))} - placeholder={creatorPlaceholder(form.teamKey, loadingUsers)} - items={[ - { id: CREATOR_ANY, label: "(any)" }, - ...users.map((u) => ({ id: u.id, label: userOptionLabel(u) })), - ]} - disabled={!form.teamKey || loadingUsers} - /> -
- ); -} - -function EstimateRow({ form, setForm }: { form: FormState; setForm: FormSetter }) { - return ( -
-
- -

Lower bound in points (optional).

- setForm((p) => ({ ...p, estimateMin: e.target.value }))} - min={0} - step="0.5" - placeholder="e.g. 1" - /> -
-
- -

Upper bound in points (optional).

- setForm((p) => ({ ...p, estimateMax: e.target.value }))} - min={0} - step="0.5" - placeholder="e.g. 5" - /> -
-
- ); -} - -function QueryField({ form, setForm }: { form: FormState; setForm: FormSetter }) { - return ( -
- -

- Free-text match across title and description (optional). -

- setForm((p) => ({ ...p, query: e.target.value }))} - placeholder="auth bug" - /> -
- ); -} - function PlaceholdersHelp() { + const { t } = useTranslation(); + const placeholders = useMemo(() => linearIssueWatchPlaceholders(t), [t]); return ( @@ -337,9 +80,9 @@ function PlaceholdersHelp() { -

Available placeholders:

+

{t("linear:availablePlaceholders")}

    - {LINEAR_ISSUE_WATCH_PLACEHOLDERS.map((p) => ( + {placeholders.map((p) => (
  • {`{{${p.key}}}`}{" "} {p.description} @@ -353,14 +96,22 @@ function PlaceholdersHelp() { } function PromptField({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const { t } = useTranslation(); + // Memoized because `ScriptEditor` keys its Monaco completion-provider + // registration on `placeholders` identity. This used to be a module-scope + // const, so it was stable for free; now that it is built from `t`, a fresh + // array on every render would re-register the provider on every keystroke. + const placeholders = useMemo(() => linearIssueWatchPlaceholders(t), [t]); return (
    - +

    - The prompt sent to the agent for each new issue. Type {"{{"} to insert placeholders. + {/* The `{{` token is passed as a value so it never reaches the catalog, + where i18next would interpolate it away. */} + {t("linear:promptFieldHelp", { token: "{{" })}

    @@ -385,14 +136,15 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { + const { t } = useTranslation(); const workspaces = useAppStore((s) => s.workspaces.items); return ( ({ id: w.id, label: w.name }))} disabled={disabled} /> @@ -406,22 +158,23 @@ function AutomationFields({ form: FormState; setForm: React.Dispatch>; }) { + const { t } = useTranslation(); const { workflows, agentProfiles, allExecutorProfiles } = useFormData(form.workspaceId); const { steps, loading: stepsLoading } = useWorkflowSteps(form.workflowId); return ( <>
    setForm((p) => ({ ...p, workflowId: v, workflowStepId: "" }))} - placeholder="Select workflow" + placeholder={t("linear:selectWorkflow")} items={workflows.map((w) => ({ id: w.id, label: w.name }))} /> setForm((p) => ({ ...p, workflowStepId: v }))} placeholder={stepPlaceholder(form.workflowId, stepsLoading, steps.length)} @@ -440,8 +193,8 @@ function AutomationFields({ />
    setForm((p) => ({ ...p, agentProfileId: resolveProfileId(v) }))} placeholder={STEP_DEFAULT_LABEL} @@ -455,8 +208,8 @@ function AutomationFields({ ]} /> setForm((p) => ({ ...p, executorProfileId: resolveProfileId(v) }))} placeholder={STEP_DEFAULT_LABEL} @@ -470,9 +223,9 @@ function AutomationFields({ ); } -function savingLabel(saving: boolean, isEdit: boolean): string { - if (saving) return "Saving…"; - return isEdit ? "Update" : "Create"; +function savingLabel(t: TFunction, saving: boolean, isEdit: boolean): string { + if (saving) return t("linear:saving"); + return isEdit ? t("linear:update") : t("linear:create"); } export function LinearIssueWatchDialog({ @@ -483,6 +236,7 @@ export function LinearIssueWatchDialog({ onCreate, onUpdate, }: Props) { + const { t } = useTranslation(); const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); const [saving, setSaving] = useState(false); const [form, setForm] = useState(() => makeEmptyForm(workspaceId ?? "")); @@ -520,12 +274,10 @@ export function LinearIssueWatchDialog({ - {watch ? "Edit Linear Watcher" : "Create Linear Watcher"} - - Poll Linear with a structured filter and auto-create a Kandev task for each - newly-matching issue. Optionally bind a repository so each task runs against that - codebase, or leave it unset to run with no repository. - + + {watch ? t("linear:editLinearWatcher") : t("linear:createLinearWatcher")} + + {t("linear:watchDialogDescription")}
    diff --git a/apps/web/components/linear/linear-issue-watch-fields.tsx b/apps/web/components/linear/linear-issue-watch-fields.tsx index 641d28b25e..b82f19da86 100644 --- a/apps/web/components/linear/linear-issue-watch-fields.tsx +++ b/apps/web/components/linear/linear-issue-watch-fields.tsx @@ -20,6 +20,8 @@ import { type LinearPriority, } from "./linear-issue-watch-form"; import type { LinearLabel, LinearTeam, LinearUser, LinearWorkflowState } from "@/lib/types/linear"; +import { useTranslation } from "react-i18next"; +import { resolveOptionLabel } from "@/lib/i18n/option-label"; // useTeamsAndStates loads the team list once Linear is configured, plus the // states, labels, and users for the currently-selected team. Each per-team @@ -128,16 +130,17 @@ export function StateMultiSelect({ onToggle: (id: string) => void; disabled: boolean; }) { + const { t } = useTranslation(); if (disabled) { // Caller's row description already explains the disabled state — render // nothing here to avoid duplicate prose. return null; } if (loading) { - return

    Loading states…

    ; + return

    {t("linear:loadingStates")}

    ; } if (states.length === 0) { - return

    No workflow states available.

    ; + return

    {t("linear:noWorkflowStatesAvailable")}

    ; } return (
    @@ -151,6 +154,7 @@ export function StateMultiSelect({ aria-pressed={active} className="cursor-pointer" > + {/* Workflow state names come from the Linear API — user data. */} {s.name} ); @@ -166,6 +170,7 @@ export function PriorityMultiSelect({ selected: LinearPriority[]; onToggle: (p: LinearPriority) => void; }) { + const { t } = useTranslation(); return (
    {PRIORITY_OPTIONS.map((opt) => { @@ -178,7 +183,7 @@ export function PriorityMultiSelect({ aria-pressed={active} className="cursor-pointer" > - {opt.label} + {resolveOptionLabel(t, opt)} ); })} @@ -199,16 +204,19 @@ export function LabelMultiSelect({ onToggle: (id: string) => void; disabled: boolean; }) { + const { t } = useTranslation(); if (disabled) { // Caller's row description already explains the disabled state — render // nothing here to avoid duplicate prose. return null; } if (loading) { - return

    Loading labels…

    ; + return

    {t("linear:loadingLabels")}

    ; } if (labels.length === 0) { - return

    No labels available for this team.

    ; + return ( +

    {t("linear:noLabelsAvailableForThisTeam")}

    + ); } return (
    @@ -222,6 +230,7 @@ export function LabelMultiSelect({ aria-pressed={active} className="cursor-pointer" > + {/* Label names come from the Linear API — user data. */} {l.name} ); @@ -238,13 +247,11 @@ type FormSetter = Dispatch>; const SORT_BY_DEFAULT_SENTINEL = "__default__"; export function SortByField({ form, setForm }: { form: FormState; setForm: FormSetter }) { + const { t } = useTranslation(); return (
    - -

    - When the in-flight cap is reached, issues are dispatched in this order so the most important - ones run first. -

    + +

    {t("linear:dispatchOrderHelp")}

    setForm((p) => ({ ...p, maxInflightTasks: e.target.value }))} min={1} step={1} - placeholder="(no cap)" + placeholder={t("linear:noCap")} aria-invalid={invalid} /> {invalid && ( -

    Enter a positive integer or leave blank.

    +

    {t("linear:enterAPositiveIntegerOrBlank")}

    )}
    ); @@ -304,13 +309,12 @@ export function MaxInflightTasksField({ form, setForm }: { form: FormState; setF // SettingsFields renders the poll-interval, throttle-cap, and enabled toggle — // the trailing "Settings" block of the watcher dialog. export function SettingsFields({ form, setForm }: { form: FormState; setForm: FormSetter }) { + const { t } = useTranslation(); return ( <>
    - -

    - How often to re-run the search. Minimum 60s, maximum 3600s. -

    + +

    {t("linear:pollIntervalHelp")}

    - -

    Pause or resume polling.

    + +

    {t("linear:enabledHelp")}

    void; + placeholder: string; + items: SelectFieldItem[]; + disabled?: boolean; +}) { + return ( +
    + + {props.description &&

    {props.description}

    } + +
    + ); +} + +type FormSetter = React.Dispatch>; + +export function FilterFields({ form, setForm }: { form: FormState; setForm: FormSetter }) { + const { t } = useTranslation(); + const { teams, states, labels, users, loadingStates, loadingLabels, loadingUsers } = + useTeamsAndStates(form.workspaceId, form.teamKey); + const toggleState = useCallback( + (id: string) => + setForm((p) => ({ + ...p, + stateIds: p.stateIds.includes(id) + ? p.stateIds.filter((s) => s !== id) + : [...p.stateIds, id], + })), + [setForm], + ); + const toggleLabel = useCallback( + (id: string) => + setForm((p) => ({ + ...p, + labelIds: p.labelIds.includes(id) + ? p.labelIds.filter((l) => l !== id) + : [...p.labelIds, id], + })), + [setForm], + ); + const togglePriority = useCallback( + (priority: LinearPriority) => + setForm((p) => ({ + ...p, + priorities: p.priorities.includes(priority) + ? p.priorities.filter((x) => x !== priority) + : [...p.priorities, priority], + })), + [setForm], + ); + + return ( + <> + + +
    + +

    {t("linear:priorityHelp")}

    + +
    +
    + +

    + {form.teamKey ? t("linear:statesHelp") : t("linear:statesHelpNoTeam")} +

    + +
    +
    + +

    + {form.teamKey ? t("linear:labelsHelp") : t("linear:labelsHelpNoTeam")} +

    + +
    + + + + ); +} + +function TeamRow({ + form, + setForm, + teams, +}: { + form: FormState; + setForm: FormSetter; + teams: LinearTeam[]; +}) { + const { t } = useTranslation(); + return ( + + setForm((p) => ({ ...p, teamKey: v, stateIds: [], labelIds: [], creatorId: "" })) + } + placeholder={t("linear:anyTeam")} + // Team names and keys come from the Linear API — user data, never + // translated, so they travel as a plain `label`. + items={teams.map((team) => ({ id: team.key, label: `${team.name} (${team.key})` }))} + /> + ); +} + +function AssigneeAndCreatorRow({ + form, + setForm, + users, + loadingUsers, +}: { + form: FormState; + setForm: FormSetter; + users: LinearUser[]; + loadingUsers: boolean; +}) { + const { t } = useTranslation(); + return ( +
    + setForm((p) => ({ ...p, assigned: v === ASSIGNED_ANY ? "" : v }))} + placeholder={t("linear:any")} + // `id` values are sentinels the filter payload is built from — "me" and + // "unassigned" are sent to the backend verbatim and must never be + // translated. Only the labels are copy. + items={[ + { id: ASSIGNED_ANY, label: t("linear:any") }, + { id: "me", label: t("linear:me") }, + { id: "unassigned", label: t("linear:unassigned") }, + ]} + /> + setForm((p) => ({ ...p, creatorId: v === CREATOR_ANY ? "" : v }))} + placeholder={creatorPlaceholder(t, form.teamKey, loadingUsers)} + items={[ + { id: CREATOR_ANY, label: t("linear:any") }, + ...users.map((u) => ({ id: u.id, label: userOptionLabel(u) })), + ]} + disabled={!form.teamKey || loadingUsers} + /> +
    + ); +} + +function EstimateRow({ form, setForm }: { form: FormState; setForm: FormSetter }) { + const { t } = useTranslation(); + return ( +
    +
    + +

    {t("linear:estimateMinHelp")}

    + setForm((p) => ({ ...p, estimateMin: e.target.value }))} + min={0} + step="0.5" + placeholder={t("linear:estimateMinPlaceholder")} + /> +
    +
    + +

    {t("linear:estimateMaxHelp")}

    + setForm((p) => ({ ...p, estimateMax: e.target.value }))} + min={0} + step="0.5" + placeholder={t("linear:estimateMaxPlaceholder")} + /> +
    +
    + ); +} + +function QueryField({ form, setForm }: { form: FormState; setForm: FormSetter }) { + const { t } = useTranslation(); + return ( +
    + +

    {t("linear:queryHelp")}

    + setForm((p) => ({ ...p, query: e.target.value }))} + placeholder={t("linear:queryPlaceholder")} + /> +
    + ); +} diff --git a/apps/web/components/linear/linear-issue-watch-form.ts b/apps/web/components/linear/linear-issue-watch-form.ts index 21c1409bc7..3c10d6e22d 100644 --- a/apps/web/components/linear/linear-issue-watch-form.ts +++ b/apps/web/components/linear/linear-issue-watch-form.ts @@ -13,25 +13,34 @@ export type LinearPriority = 0 | 1 | 2 | 3 | 4; // Linear priorities: 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low. Rendered as // toggle chips, mirroring the States and Labels multi-selects. -export const PRIORITY_OPTIONS: { value: LinearPriority; label: string }[] = [ - { value: 1, label: "Urgent" }, - { value: 2, label: "High" }, - { value: 3, label: "Medium" }, - { value: 4, label: "Low" }, - { value: 0, label: "No priority" }, +// +// `value` is a numeric union sent to the backend as the filter's priority list +// — never translated. The chip text travels as `labelKey` and resolves at +// render through `resolveOptionLabel`; a module-scope `t()` here would freeze +// the copy at the boot locale (see docs/i18n.md). +export const PRIORITY_OPTIONS: { value: LinearPriority; labelKey: string }[] = [ + { value: 1, labelKey: "linear:priorityUrgent" }, + { value: 2, labelKey: "linear:priorityHigh" }, + { value: 3, labelKey: "linear:priorityMedium" }, + { value: 4, labelKey: "linear:priorityLow" }, + { value: 0, labelKey: "linear:priorityNone" }, ]; // Dispatch order applied when the in-flight cap limits how many matched issues // run at once. Order matters — most useful first; the empty value is Linear's // natural (recently-updated) order. -export const SORT_BY_OPTIONS: { value: LinearIssueSortBy; label: string }[] = [ - { value: "priority", label: "Priority (high → low)" }, - { value: "priority_asc", label: "Priority (low → high)" }, - { value: "created_desc", label: "Created (newest first)" }, - { value: "created_asc", label: "Created (oldest first)" }, - { value: "updated_desc", label: "Updated (recently updated first)" }, - { value: "updated_asc", label: "Updated (least recently updated first)" }, - { value: "", label: "Default (Linear order)" }, +// +// `value` is a `LinearIssueSortBy` string-literal union: persisted on the +// watch, compared against `form.sortBy`, and sent upstream. Only `labelKey` is +// copy. +export const SORT_BY_OPTIONS: { value: LinearIssueSortBy; labelKey: string }[] = [ + { value: "priority", labelKey: "linear:sortPriorityDesc" }, + { value: "priority_asc", labelKey: "linear:sortPriorityAsc" }, + { value: "created_desc", labelKey: "linear:sortCreatedDesc" }, + { value: "created_asc", labelKey: "linear:sortCreatedAsc" }, + { value: "updated_desc", labelKey: "linear:sortUpdatedDesc" }, + { value: "updated_asc", labelKey: "linear:sortUpdatedAsc" }, + { value: "", labelKey: "linear:sortDefault" }, ]; export interface FormState { @@ -243,14 +252,27 @@ export function buildFilterPayload(form: FormState): LinearSearchFilter { }; } +/** + * Display text for one Linear user in the Creator picker. Every branch is user + * data straight from the Linear API — a display name, a full name, an email + * address, or the raw id — so nothing here is translatable copy. + */ export function userOptionLabel(u: LinearUser): string { const name = u.displayName?.trim() || u.name?.trim() || u.email?.trim() || u.id; if (u.email && u.email !== name) return `${name} (${u.email})`; return name; } -export function creatorPlaceholder(teamKey: string, loadingUsers: boolean): string { - if (loadingUsers) return "Loading…"; - if (!teamKey) return "Pick a team first"; - return "(any)"; +// `t` is threaded in rather than read from a hook: this is a plain function in a +// `.ts` module, and `mode: "jsx-only"` means the guard never inspects this file, +// so a literal here would survive lint. The caller passes its `useTranslation` +// `t`, which keeps the copy resolving at render. +export function creatorPlaceholder( + t: (key: string) => string, + teamKey: string, + loadingUsers: boolean, +): string { + if (loadingUsers) return t("linear:loadingEllipsis"); + if (!teamKey) return t("linear:pickATeamFirst"); + return t("linear:any"); } diff --git a/apps/web/components/linear/linear-issue-watch-placeholders.ts b/apps/web/components/linear/linear-issue-watch-placeholders.ts index 39faf2c10c..6fa488d249 100644 --- a/apps/web/components/linear/linear-issue-watch-placeholders.ts +++ b/apps/web/components/linear/linear-issue-watch-placeholders.ts @@ -1,67 +1,91 @@ +import type { TFunction } from "i18next"; import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions"; -export const LINEAR_ISSUE_WATCH_PLACEHOLDERS: ScriptPlaceholder[] = [ - { - key: "issue.url", - description: "Linear issue URL", - example: "https://linear.app/acme/issue/ENG-7", - executor_types: [], - }, - { - key: "issue.identifier", - description: "Issue identifier", - example: "ENG-7", - executor_types: [], - }, - { - key: "issue.title", - description: "Issue title", - example: "Login fails on mobile", - executor_types: [], - }, - { - key: "issue.team", - description: "Team key", - example: "ENG", - executor_types: [], - }, - { - key: "issue.state", - description: "Workflow state name", - example: "In Progress", - executor_types: [], - }, - { - key: "issue.priority", - description: "Priority label", - example: "High", - executor_types: [], - }, - { - key: "issue.assignee", - description: "Assignee display name", - example: "Alice", - executor_types: [], - }, - { - key: "issue.creator", - description: "Issue creator display name", - example: "Bob", - executor_types: [], - }, - { - key: "issue.description", - description: "Issue description body", - example: "Tap submit, nothing happens.", - executor_types: [], - }, -]; +/** + * Placeholder tokens available in the issue-watch prompt. + * + * A function rather than a const because `description` is copy — it is rendered + * in the prompt field's tooltip and in the editor's completion list — and a + * module-scope `t()` would freeze it at the boot locale (see docs/i18n.md). + * + * `key` and `example` are NOT copy. `key` is the token the prompt template is + * interpolated against, and each `example` is sample Linear data: an issue + * identifier, a team key, a workflow state name, a priority label, a display + * name. Both are identifiers, so they stay out of the catalog where the + * pseudo-locale would transliterate them. Same contract as + * `jiraIssueWatchPlaceholders` in components/jira. + */ +export function linearIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] { + return [ + { + key: "issue.url", + description: t("linear:placeholderIssueUrl"), + example: "https://linear.app/acme/issue/ENG-7", + executor_types: [], + }, + { + key: "issue.identifier", + description: t("linear:placeholderIssueIdentifier"), + example: "ENG-7", + executor_types: [], + }, + { + key: "issue.title", + description: t("linear:placeholderIssueTitle"), + example: "Login fails on mobile", + executor_types: [], + }, + { + key: "issue.team", + description: t("linear:placeholderTeamKey"), + example: "ENG", + executor_types: [], + }, + { + key: "issue.state", + description: t("linear:placeholderWorkflowStateName"), + example: "In Progress", + executor_types: [], + }, + { + key: "issue.priority", + description: t("linear:placeholderPriorityLabel"), + example: "High", + executor_types: [], + }, + { + key: "issue.assignee", + description: t("linear:placeholderAssigneeDisplayName"), + example: "Alice", + executor_types: [], + }, + { + key: "issue.creator", + description: t("linear:placeholderCreatorDisplayName"), + example: "Bob", + executor_types: [], + }, + { + key: "issue.description", + description: t("linear:placeholderIssueDescription"), + example: "Tap submit, nothing happens.", + executor_types: [], + }, + ]; +} // DEFAULT_LINEAR_ISSUE_WATCH_PROMPT mirrors apps/backend/config/prompts/linear-issue-watch-default.md. // Kept in sync by hand: the UI shows this when the user clears the field, and // the backend reads the .md when the saved prompt is empty. Diverging would // surprise the user — they'd see one default in the dialog and another get // sent to the agent. +// +// Deliberately NOT translated, for the same reason as +// DEFAULT_JIRA_ISSUE_WATCH_PROMPT: it is seeded into the field, then persisted +// on the watch and sent to the agent verbatim. A watch created under one locale +// would keep that locale's prompt forever, and the agent reads it as an +// instruction rather than as UI copy — it also has to keep matching the backend +// .md, which has no locale at all. export const DEFAULT_LINEAR_ISSUE_WATCH_PROMPT = `You have been assigned a Linear issue to work on. **Issue:** {{issue.url}} diff --git a/apps/web/components/linear/linear-issue-watch-table.tsx b/apps/web/components/linear/linear-issue-watch-table.tsx index 3e169b5d3f..6a4fff9dc7 100644 --- a/apps/web/components/linear/linear-issue-watch-table.tsx +++ b/apps/web/components/linear/linear-issue-watch-table.tsx @@ -13,6 +13,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; import type { LinearIssueWatch, LinearSearchFilter } from "@/lib/types/linear"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { formatRelative } from "@/lib/i18n/formats"; type LinearIssueWatchTableProps = { watches: LinearIssueWatch[]; @@ -27,21 +30,29 @@ type LinearIssueWatchTableProps = { onToggleEnabled: (watch: LinearIssueWatch) => void; }; -function formatLastPolled(dateStr?: string | null): string { - if (!dateStr) return "Never"; - const diff = Date.now() - new Date(dateStr).getTime(); - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return "Just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; +// `t` is threaded in rather than read from a hook: this is a plain function, and +// the guard never inspects a return value, so a literal here would survive lint. +// +// The bucket copy now comes from `formatRelative`, which owns the same +// just-now/m/h/d ladder for every surface and reads the active app locale. Two +// consequences, both deliberate and matching the Jira table: the first bucket +// reads "just now" rather than "Just now" (it is shared with the rest of the +// app), and an unparseable timestamp renders empty instead of "NaNm ago". +function formatLastPolled(t: TFunction, dateStr?: string | null): string { + if (!dateStr) return t("linear:never"); + return formatRelative(dateStr); } // summarizeFilter renders the structured filter as a short tag-style label // the user can scan at a glance. Falls back to "(any)" when somehow empty — // the backend rejects empty filters at create/update time, so this is just // defensive for forward-compat. +// +// Deliberately NOT translated. This is a filter *expression*, not prose: the +// `team:` / `state:` / `assigned:` / `q:` prefixes are Linear filter field +// names, the values are user data (a team key, a free-text query), and it is +// rendered in a monospace column. It is the direct analogue of the Jira watch +// table's column, which shows the raw JQL verbatim for the same reason. function summarizeFilter(filter: LinearSearchFilter | undefined): string { if (!filter) return "(any)"; const parts: string[] = []; @@ -69,6 +80,7 @@ function WatchActions({ onReset: (id: string) => void; onDelete: (id: string) => void; }) { + const { t } = useTranslation(); return (
    @@ -91,7 +103,7 @@ function WatchActions({ )} - {watch.enabled ? "Pause" : "Enable"} + {watch.enabled ? t("linear:pause") : t("linear:enable")} @@ -107,7 +119,7 @@ function WatchActions({ - Check now + {t("linear:checkNow")} @@ -116,7 +128,7 @@ function WatchActions({ size="sm" className="h-7 w-7 p-0 cursor-pointer" data-testid="watch-reset-button" - aria-label="Reset watch" + aria-label={t("linear:resetWatch")} onClick={(e) => { e.stopPropagation(); onReset(watch.id); @@ -125,7 +137,7 @@ function WatchActions({ - Reset + {t("common:reset")} @@ -141,7 +153,7 @@ function WatchActions({ - Delete + {t("linear:delete")}
    ); @@ -157,13 +169,14 @@ export function LinearIssueWatchTable({ onReset, onToggleEnabled, }: LinearIssueWatchTableProps) { + const { t } = useTranslation(); const workspaces = useAppStore((s) => s.workspaces.items); const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; if (watches.length === 0) { return (

    - No Linear watchers configured. Create one to auto-create tasks from filtered issues. + {t("linear:noLinearWatchersConfiguredCreateOne")}

    ); } @@ -172,12 +185,12 @@ export function LinearIssueWatchTable({ - {showWorkspace && Workspace} - Filter - Interval - Last Polled - Status - Actions + {showWorkspace && {t("common:workspace")}} + {t("linear:filter")} + {t("linear:interval")} + {t("linear:lastPolled")} + {t("common:status")} + {t("linear:actions")} @@ -202,14 +215,16 @@ export function LinearIssueWatchTable({ {summarizeFilter(watch.filter)} - {Math.round(watch.pollIntervalSeconds / 60)}m + {t("linear:intervalMinutes", { + count: Math.round(watch.pollIntervalSeconds / 60), + })} - {formatLastPolled(watch.lastPolledAt)} + {formatLastPolled(t, watch.lastPolledAt)} - {watch.enabled ? "Active" : "Paused"} + {watch.enabled ? t("linear:active") : t("linear:paused")} diff --git a/apps/web/components/linear/linear-issue-watchers-section.tsx b/apps/web/components/linear/linear-issue-watchers-section.tsx index a5b63a6d74..407a030e4b 100644 --- a/apps/web/components/linear/linear-issue-watchers-section.tsx +++ b/apps/web/components/linear/linear-issue-watchers-section.tsx @@ -12,6 +12,7 @@ import { ResetWatchDialog, useWatchResetController } from "@/components/watches/ import { LinearIssueWatchTable } from "./linear-issue-watch-table"; import { LinearIssueWatchDialog } from "./linear-issue-watch-dialog"; import type { LinearIssueWatch } from "@/lib/types/linear"; +import { useTranslation } from "react-i18next"; // LinearIssueWatchersSection lists watches across every workspace in a single // flat table on the install-wide settings page. The dialog's create flow asks @@ -26,45 +27,46 @@ type RawActions = { }; function useToastedActions({ create, update, remove, trigger, reset }: RawActions) { + const { t } = useTranslation(); const { toast } = useToast(); const wrappedCreate = useCallback( async (req: Parameters[0]) => { try { await create(req); - toast({ description: "Watcher created", variant: "success" }); + toast({ description: t("linear:watcherCreated"), variant: "success" }); } catch (err) { - toast({ description: `Create failed: ${String(err)}`, variant: "error" }); + toast({ description: t("linear:createFailed", { error: String(err) }), variant: "error" }); throw err; } }, - [create, toast], + [create, t, toast], ); const wrappedUpdate = useCallback( async (id: string, req: Parameters[1], rowWorkspaceId: string) => { try { await update(id, req, rowWorkspaceId); - toast({ description: "Watcher updated", variant: "success" }); + toast({ description: t("linear:watcherUpdated"), variant: "success" }); } catch (err) { - toast({ description: `Update failed: ${String(err)}`, variant: "error" }); + toast({ description: t("linear:updateFailed", { error: String(err) }), variant: "error" }); throw err; } }, - [update, toast], + [update, t, toast], ); const wrappedDelete = useCallback( async (w: LinearIssueWatch) => { - if (!confirm("Delete this Linear watcher?")) return; + if (!confirm(t("linear:deleteThisLinearWatcher"))) return; try { await remove(w.id, w.workspaceId); - toast({ description: "Watcher deleted", variant: "success" }); + toast({ description: t("linear:watcherDeleted"), variant: "success" }); } catch (err) { - toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + toast({ description: t("linear:deleteFailed", { error: String(err) }), variant: "error" }); } }, - [remove, toast], + [remove, t, toast], ); const wrappedTrigger = useCallback( @@ -72,14 +74,16 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction try { const res = await trigger(w.id, w.workspaceId); const n = res?.newIssues ?? 0; + // "issue(s)" was an English plural hack: the count now selects a form, + // so a language with more than two can express them all. const description = - n > 0 ? `Found ${n} new issue(s) — tasks will appear shortly.` : "No new issues matched."; + n > 0 ? t("linear:foundNewIssues", { count: n }) : t("linear:noNewIssuesMatched"); toast({ description, variant: "success" }); } catch (err) { - toast({ description: `Check failed: ${String(err)}`, variant: "error" }); + toast({ description: t("linear:checkFailed", { error: String(err) }), variant: "error" }); } }, - [trigger, toast], + [trigger, t, toast], ); const wrappedReset = useCallback( @@ -90,16 +94,16 @@ function useToastedActions({ create, update, remove, trigger, reset }: RawAction toast({ description: n > 0 - ? `Reset complete — deleted ${n} task(s); next poll will re-import matches.` - : "Reset complete — next poll will re-import matches.", + ? t("linear:resetCompleteDeletedTasks", { count: n }) + : t("linear:resetCompleteNoTasksDeleted"), variant: "success", }); } catch (err) { - toast({ description: `Reset failed: ${String(err)}`, variant: "error" }); + toast({ description: t("linear:resetFailed", { error: String(err) }), variant: "error" }); throw err; } }, - [reset, toast], + [reset, t, toast], ); return { @@ -122,6 +126,7 @@ function useEnabledDrafts(items: LinearIssueWatch[], update: RawActions["update" } export function LinearIssueWatchersSection() { + const { t } = useTranslation(); const { items, loading, create, update, remove, trigger, previewReset, reset } = useLinearIssueWatches(); const actions = useToastedActions({ create, update, remove, trigger, reset }); @@ -175,12 +180,12 @@ export function LinearIssueWatchersSection() { return ( } - title="Linear watchers" - description="Poll a Linear filter and auto-create a Kandev task for each newly-matching issue." + title={t("linear:linearWatchers")} + description={t("linear:linearWatchersDescription")} action={ } > @@ -216,7 +221,7 @@ export function LinearIssueWatchersSection() { diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx index 149a516a1b..b9d5318bc3 100644 --- a/apps/web/components/linear/linear-settings.tsx +++ b/apps/web/components/linear/linear-settings.tsx @@ -29,6 +29,8 @@ import { listLinearTeams, } from "@/lib/api/domains/linear-api"; import type { LinearConfig, LinearTeam, TestLinearConnectionResult } from "@/lib/types/linear"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { LinearIssueWatchersSection } from "./linear-issue-watchers-section"; type FormState = { @@ -38,6 +40,16 @@ type FormState = { const emptyForm: FormState = { defaultTeamKey: "", secret: "" }; +// Not copy. The mask is a glyph run, `lin_api_...` is the literal shape of a +// Linear personal API key, and the link is a Linear-owned URL — its visible +// text is the same URL with the scheme trimmed. All four would be corrupted by +// the pseudo-locale into something the user cannot match against Linear's own +// UI, so they stay out of the catalog. +const SECRET_MASK = "••••••••"; +const SECRET_EXAMPLE = "lin_api_..."; +const API_KEY_SETTINGS_URL = "https://linear.app/settings/account/security"; +const API_KEY_SETTINGS_LABEL = "linear.app/settings/account/security"; + function configToForm(cfg: LinearConfig | null): FormState { if (!cfg) return emptyForm; return { defaultTeamKey: cfg.defaultTeamKey, secret: "" }; @@ -60,35 +72,34 @@ function SecretField({ update, hasSavedSecret, }: Omit) { + const { t } = useTranslation(); return (
    update("secret", e.target.value)} disabled={loading} />

    - Create a personal API key at{" "} + {t("linear:createPersonalApiKeyAt")}{" "} - linear.app/settings/account/security + {API_KEY_SETTINGS_LABEL}

    @@ -96,9 +107,10 @@ function SecretField({ } function TeamSelector({ form, baseline, loading, update, teams, loadingTeams }: FieldsRowProps) { + const { t } = useTranslation(); return (
    - +