feat(i18n): localize Settings → Integrations → Linear (#2179)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-02 23:08:18 +02:00
committed by GitHub
parent b5631da7ee
commit d0ee6d989b
12 changed files with 917 additions and 473 deletions
@@ -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 (
<div className="space-y-1.5">
<Label>{props.label}</Label>
{props.description && <p className="text-xs text-muted-foreground">{props.description}</p>}
<Select
value={props.value || undefined}
onValueChange={props.onChange}
disabled={props.disabled}
>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent>
{props.items.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.icon ? (
<span className="flex items-center gap-1.5">
<span>{item.label}</span>
{item.icon}
</span>
) : (
item.label
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function FilterFields({
form,
setForm,
}: {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
}) {
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 (
<>
<TeamRow form={form} setForm={setForm} teams={teams} />
<AssigneeAndCreatorRow
form={form}
setForm={setForm}
users={users}
loadingUsers={loadingUsers}
/>
<div className="space-y-1.5">
<Label>Priority</Label>
<p className="text-xs text-muted-foreground">
Click to toggle. Matches issues at ANY of the selected priorities.
</p>
<PriorityMultiSelect selected={form.priorities} onToggle={togglePriority} />
</div>
<div className="space-y-1.5">
<Label>States</Label>
<p className="text-xs text-muted-foreground">
{form.teamKey
? "Click states to toggle. Empty matches every state on the team."
: "Pick a team to choose specific workflow states."}
</p>
<StateMultiSelect
states={states}
loading={loadingStates}
selected={form.stateIds}
onToggle={toggleState}
disabled={!form.teamKey}
/>
</div>
<div className="space-y-1.5">
<Label>Labels</Label>
<p className="text-xs text-muted-foreground">
{form.teamKey
? "Click to toggle. Matches ANY of the selected labels."
: "Pick a team to choose specific labels."}
</p>
<LabelMultiSelect
labels={labels}
loading={loadingLabels}
selected={form.labelIds}
onToggle={toggleLabel}
disabled={!form.teamKey}
/>
</div>
<EstimateRow form={form} setForm={setForm} />
<QueryField form={form} setForm={setForm} />
</>
);
}
type FormSetter = React.Dispatch<React.SetStateAction<FormState>>;
function TeamRow({
form,
setForm,
teams,
}: {
form: FormState;
setForm: FormSetter;
teams: LinearTeam[];
}) {
return (
<SelectField
label="Team"
description="Restrict matches to one team."
value={form.teamKey}
onChange={(v) =>
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 (
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Assignee"
description="Filter by who an issue is assigned to."
value={form.assigned || ASSIGNED_ANY}
onChange={(v) => setForm((p) => ({ ...p, assigned: v === ASSIGNED_ANY ? "" : v }))}
placeholder="(any)"
items={[
{ id: ASSIGNED_ANY, label: "(any)" },
{ id: "me", label: "Me" },
{ id: "unassigned", label: "Unassigned" },
]}
/>
<SelectField
label="Creator"
description="Match issues created by one user."
value={form.creatorId || CREATOR_ANY}
onChange={(v) => 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}
/>
</div>
);
}
function EstimateRow({ form, setForm }: { form: FormState; setForm: FormSetter }) {
return (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Estimate min</Label>
<p className="text-xs text-muted-foreground">Lower bound in points (optional).</p>
<Input
type="number"
value={form.estimateMin}
onChange={(e) => setForm((p) => ({ ...p, estimateMin: e.target.value }))}
min={0}
step="0.5"
placeholder="e.g. 1"
/>
</div>
<div className="space-y-1.5">
<Label>Estimate max</Label>
<p className="text-xs text-muted-foreground">Upper bound in points (optional).</p>
<Input
type="number"
value={form.estimateMax}
onChange={(e) => setForm((p) => ({ ...p, estimateMax: e.target.value }))}
min={0}
step="0.5"
placeholder="e.g. 5"
/>
</div>
</div>
);
}
function QueryField({ form, setForm }: { form: FormState; setForm: FormSetter }) {
return (
<div className="space-y-1.5">
<Label>Query</Label>
<p className="text-xs text-muted-foreground">
Free-text match across title and description (optional).
</p>
<Input
value={form.query}
onChange={(e) => setForm((p) => ({ ...p, query: e.target.value }))}
placeholder="auth bug"
/>
</div>
);
}
function PlaceholdersHelp() {
const { t } = useTranslation();
const placeholders = useMemo(() => linearIssueWatchPlaceholders(t), [t]);
return (
<TooltipProvider>
<Tooltip>
@@ -337,9 +80,9 @@ function PlaceholdersHelp() {
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground/50 hover:text-muted-foreground cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent className="max-w-xs" align="start">
<p className="text-xs font-medium mb-1">Available placeholders:</p>
<p className="text-xs font-medium mb-1">{t("linear:availablePlaceholders")}</p>
<ul className="text-xs space-y-0.5">
{LINEAR_ISSUE_WATCH_PLACEHOLDERS.map((p) => (
{placeholders.map((p) => (
<li key={p.key}>
<code className="text-[10px] bg-white/15 px-1 rounded">{`{{${p.key}}}`}</code>{" "}
<span className="opacity-70">{p.description}</span>
@@ -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 (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label>Task Prompt</Label>
<Label>{t("linear:taskPrompt")}</Label>
<PlaceholdersHelp />
</div>
<p className="text-xs text-muted-foreground">
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: "{{" })}
</p>
<div className="rounded-md border border-border overflow-hidden">
<ScriptEditor
@@ -369,7 +120,7 @@ function PromptField({ value, onChange }: { value: string; onChange: (v: string)
language="markdown"
height={computeEditorHeight(value)}
lineNumbers="off"
placeholders={LINEAR_ISSUE_WATCH_PLACEHOLDERS}
placeholders={placeholders}
/>
</div>
</div>
@@ -385,14 +136,15 @@ function WorkspacePicker({
onChange: (v: string) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const workspaces = useAppStore((s) => s.workspaces.items);
return (
<SelectField
label="Workspace"
description="Tasks created by this watcher land in the selected workspace."
label={t("common:workspace")}
description={t("linear:workspaceHelp")}
value={value}
onChange={onChange}
placeholder="Select workspace"
placeholder={t("linear:selectWorkspace")}
items={workspaces.map((w) => ({ id: w.id, label: w.name }))}
disabled={disabled}
/>
@@ -406,22 +158,23 @@ function AutomationFields({
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
}) {
const { t } = useTranslation();
const { workflows, agentProfiles, allExecutorProfiles } = useFormData(form.workspaceId);
const { steps, loading: stepsLoading } = useWorkflowSteps(form.workflowId);
return (
<>
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Workflow"
description="Tasks are created in this workflow."
label={t("linear:workflow")}
description={t("linear:workflowHelp")}
value={form.workflowId}
onChange={(v) => setForm((p) => ({ ...p, workflowId: v, workflowStepId: "" }))}
placeholder="Select workflow"
placeholder={t("linear:selectWorkflow")}
items={workflows.map((w) => ({ id: w.id, label: w.name }))}
/>
<SelectField
label="Workflow Step"
description="Initial step for new tasks."
label={t("linear:workflowStep")}
description={t("linear:workflowStepHelp")}
value={form.workflowStepId}
onChange={(v) => setForm((p) => ({ ...p, workflowStepId: v }))}
placeholder={stepPlaceholder(form.workflowId, stepsLoading, steps.length)}
@@ -440,8 +193,8 @@ function AutomationFields({
/>
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Agent Profile"
description="Optional — falls back to step default."
label={t("linear:agentProfile")}
description={t("linear:fallsBackToStepDefault")}
value={form.agentProfileId || STEP_DEFAULT}
onChange={(v) => setForm((p) => ({ ...p, agentProfileId: resolveProfileId(v) }))}
placeholder={STEP_DEFAULT_LABEL}
@@ -455,8 +208,8 @@ function AutomationFields({
]}
/>
<SelectField
label="Executor Profile"
description="Optional — falls back to step default."
label={t("linear:executorProfile")}
description={t("linear:fallsBackToStepDefault")}
value={form.executorProfileId || STEP_DEFAULT}
onChange={(v) => 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<FormState>(() => makeEmptyForm(workspaceId ?? ""));
@@ -520,12 +274,10 @@ export function LinearIssueWatchDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-full sm:w-[800px] sm:max-w-none max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{watch ? "Edit Linear Watcher" : "Create Linear Watcher"}</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
<DialogTitle>
{watch ? t("linear:editLinearWatcher") : t("linear:createLinearWatcher")}
</DialogTitle>
<DialogDescription>{t("linear:watchDialogDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<WorkspacePicker
@@ -551,10 +303,10 @@ export function LinearIssueWatchDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
Cancel
{t("common:cancel")}
</Button>
<Button onClick={handleSave} disabled={saving || !canSave} className="cursor-pointer">
{savingLabel(saving, !!watch)}
{savingLabel(t, saving, !!watch)}
</Button>
</DialogFooter>
</DialogContent>
@@ -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 <p className="text-xs text-muted-foreground">Loading states…</p>;
return <p className="text-xs text-muted-foreground">{t("linear:loadingStates")}</p>;
}
if (states.length === 0) {
return <p className="text-xs text-muted-foreground">No workflow states available.</p>;
return <p className="text-xs text-muted-foreground">{t("linear:noWorkflowStatesAvailable")}</p>;
}
return (
<div className="flex flex-wrap gap-1.5">
@@ -151,6 +154,7 @@ export function StateMultiSelect({
aria-pressed={active}
className="cursor-pointer"
>
{/* Workflow state names come from the Linear API — user data. */}
<Badge variant={active ? "default" : "outline"}>{s.name}</Badge>
</button>
);
@@ -166,6 +170,7 @@ export function PriorityMultiSelect({
selected: LinearPriority[];
onToggle: (p: LinearPriority) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-1.5">
{PRIORITY_OPTIONS.map((opt) => {
@@ -178,7 +183,7 @@ export function PriorityMultiSelect({
aria-pressed={active}
className="cursor-pointer"
>
<Badge variant={active ? "default" : "outline"}>{opt.label}</Badge>
<Badge variant={active ? "default" : "outline"}>{resolveOptionLabel(t, opt)}</Badge>
</button>
);
})}
@@ -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 <p className="text-xs text-muted-foreground">Loading labels…</p>;
return <p className="text-xs text-muted-foreground">{t("linear:loadingLabels")}</p>;
}
if (labels.length === 0) {
return <p className="text-xs text-muted-foreground">No labels available for this team.</p>;
return (
<p className="text-xs text-muted-foreground">{t("linear:noLabelsAvailableForThisTeam")}</p>
);
}
return (
<div className="flex flex-wrap gap-1.5">
@@ -222,6 +230,7 @@ export function LabelMultiSelect({
aria-pressed={active}
className="cursor-pointer"
>
{/* Label names come from the Linear API — user data. */}
<Badge variant={active ? "default" : "outline"}>{l.name}</Badge>
</button>
);
@@ -238,13 +247,11 @@ type FormSetter = Dispatch<SetStateAction<FormState>>;
const SORT_BY_DEFAULT_SENTINEL = "__default__";
export function SortByField({ form, setForm }: { form: FormState; setForm: FormSetter }) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<Label>Dispatch order</Label>
<p className="text-xs text-muted-foreground">
When the in-flight cap is reached, issues are dispatched in this order so the most important
ones run first.
</p>
<Label>{t("linear:dispatchOrder")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:dispatchOrderHelp")}</p>
<Select
value={form.sortBy || SORT_BY_DEFAULT_SENTINEL}
onValueChange={(v) =>
@@ -263,7 +270,7 @@ export function SortByField({ form, setForm }: { form: FormState; setForm: FormS
key={o.value || SORT_BY_DEFAULT_SENTINEL}
value={o.value || SORT_BY_DEFAULT_SENTINEL}
>
{o.label}
{resolveOptionLabel(t, o)}
</SelectItem>
))}
</SelectContent>
@@ -276,26 +283,24 @@ export function SortByField({ form, setForm }: { form: FormState; setForm: FormS
// validation. Lives here (rather than the dialog) to keep the dialog file under
// its line ceiling.
export function MaxInflightTasksField({ form, setForm }: { form: FormState; setForm: FormSetter }) {
const { t } = useTranslation();
const parsed = parseMaxInflightTasks(form.maxInflightTasks);
const invalid = parsed === "invalid";
return (
<div className="space-y-1.5">
<Label>Max in-flight tasks</Label>
<p className="text-xs text-muted-foreground">
Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred
to the next poll when the cap is reached.
</p>
<Label>{t("linear:maxInflightTasks")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:maxInflightTasksHelp")}</p>
<Input
type="number"
value={form.maxInflightTasks}
onChange={(e) => setForm((p) => ({ ...p, maxInflightTasks: e.target.value }))}
min={1}
step={1}
placeholder="(no cap)"
placeholder={t("linear:noCap")}
aria-invalid={invalid}
/>
{invalid && (
<p className="text-xs text-destructive">Enter a positive integer or leave blank.</p>
<p className="text-xs text-destructive">{t("linear:enterAPositiveIntegerOrBlank")}</p>
)}
</div>
);
@@ -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 (
<>
<div className="space-y-1.5">
<Label>Poll Interval (seconds)</Label>
<p className="text-xs text-muted-foreground">
How often to re-run the search. Minimum 60s, maximum 3600s.
</p>
<Label>{t("linear:pollIntervalSeconds")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:pollIntervalHelp")}</p>
<Input
type="number"
value={form.pollInterval}
@@ -323,8 +327,8 @@ export function SettingsFields({ form, setForm }: { form: FormState; setForm: Fo
<SortByField form={form} setForm={setForm} />
<div className="flex items-center justify-between">
<div>
<Label>Enabled</Label>
<p className="text-xs text-muted-foreground">Pause or resume polling.</p>
<Label>{t("linear:enabled")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:enabledHelp")}</p>
</div>
<Switch
checked={form.enabled}
@@ -0,0 +1,269 @@
"use client";
import { useCallback } from "react";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { useTranslation } from "react-i18next";
import {
LabelMultiSelect,
PriorityMultiSelect,
StateMultiSelect,
useTeamsAndStates,
} from "./linear-issue-watch-fields";
import {
ASSIGNED_ANY,
CREATOR_ANY,
type FormState,
type LinearPriority,
creatorPlaceholder,
userOptionLabel,
} from "./linear-issue-watch-form";
import type { LinearTeam, LinearUser } from "@/lib/types/linear";
// The filter half of the watcher dialog — the "which issues match?" block.
// Split out of linear-issue-watch-dialog.tsx so that file stays under the
// 600-line ceiling once every string routes through `t()`.
export type SelectFieldItem = { id: string; label: string; icon?: React.ReactNode };
export function SelectField(props: {
label: string;
description?: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
items: SelectFieldItem[];
disabled?: boolean;
}) {
return (
<div className="space-y-1.5">
<Label>{props.label}</Label>
{props.description && <p className="text-xs text-muted-foreground">{props.description}</p>}
<Select
value={props.value || undefined}
onValueChange={props.onChange}
disabled={props.disabled}
>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent>
{props.items.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.icon ? (
<span className="flex items-center gap-1.5">
<span>{item.label}</span>
{item.icon}
</span>
) : (
item.label
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
type FormSetter = React.Dispatch<React.SetStateAction<FormState>>;
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 (
<>
<TeamRow form={form} setForm={setForm} teams={teams} />
<AssigneeAndCreatorRow
form={form}
setForm={setForm}
users={users}
loadingUsers={loadingUsers}
/>
<div className="space-y-1.5">
<Label>{t("linear:priority")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:priorityHelp")}</p>
<PriorityMultiSelect selected={form.priorities} onToggle={togglePriority} />
</div>
<div className="space-y-1.5">
<Label>{t("linear:states")}</Label>
<p className="text-xs text-muted-foreground">
{form.teamKey ? t("linear:statesHelp") : t("linear:statesHelpNoTeam")}
</p>
<StateMultiSelect
states={states}
loading={loadingStates}
selected={form.stateIds}
onToggle={toggleState}
disabled={!form.teamKey}
/>
</div>
<div className="space-y-1.5">
<Label>{t("linear:labels")}</Label>
<p className="text-xs text-muted-foreground">
{form.teamKey ? t("linear:labelsHelp") : t("linear:labelsHelpNoTeam")}
</p>
<LabelMultiSelect
labels={labels}
loading={loadingLabels}
selected={form.labelIds}
onToggle={toggleLabel}
disabled={!form.teamKey}
/>
</div>
<EstimateRow form={form} setForm={setForm} />
<QueryField form={form} setForm={setForm} />
</>
);
}
function TeamRow({
form,
setForm,
teams,
}: {
form: FormState;
setForm: FormSetter;
teams: LinearTeam[];
}) {
const { t } = useTranslation();
return (
<SelectField
label={t("linear:team")}
description={t("linear:teamHelp")}
value={form.teamKey}
onChange={(v) =>
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 (
<div className="grid grid-cols-2 gap-4">
<SelectField
label={t("linear:assignee")}
description={t("linear:assigneeHelp")}
value={form.assigned || ASSIGNED_ANY}
onChange={(v) => 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") },
]}
/>
<SelectField
label={t("linear:creator")}
description={t("linear:creatorHelp")}
value={form.creatorId || CREATOR_ANY}
onChange={(v) => 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}
/>
</div>
);
}
function EstimateRow({ form, setForm }: { form: FormState; setForm: FormSetter }) {
const { t } = useTranslation();
return (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>{t("linear:estimateMin")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:estimateMinHelp")}</p>
<Input
type="number"
value={form.estimateMin}
onChange={(e) => setForm((p) => ({ ...p, estimateMin: e.target.value }))}
min={0}
step="0.5"
placeholder={t("linear:estimateMinPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label>{t("linear:estimateMax")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:estimateMaxHelp")}</p>
<Input
type="number"
value={form.estimateMax}
onChange={(e) => setForm((p) => ({ ...p, estimateMax: e.target.value }))}
min={0}
step="0.5"
placeholder={t("linear:estimateMaxPlaceholder")}
/>
</div>
</div>
);
}
function QueryField({ form, setForm }: { form: FormState; setForm: FormSetter }) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<Label>{t("linear:query")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:queryHelp")}</p>
<Input
value={form.query}
onChange={(e) => setForm((p) => ({ ...p, query: e.target.value }))}
placeholder={t("linear:queryPlaceholder")}
/>
</div>
);
}
@@ -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");
}
@@ -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}}
@@ -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 (
<div className="flex items-center justify-end gap-1">
<Tooltip>
@@ -91,7 +103,7 @@ function WatchActions({
)}
</Button>
</TooltipTrigger>
<TooltipContent>{watch.enabled ? "Pause" : "Enable"}</TooltipContent>
<TooltipContent>{watch.enabled ? t("linear:pause") : t("linear:enable")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -107,7 +119,7 @@ function WatchActions({
<IconRefresh className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Check now</TooltipContent>
<TooltipContent>{t("linear:checkNow")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -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({
<IconRestore className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Reset</TooltipContent>
<TooltipContent>{t("common:reset")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -141,7 +153,7 @@ function WatchActions({
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
<TooltipContent>{t("linear:delete")}</TooltipContent>
</Tooltip>
</div>
);
@@ -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 (
<p className="text-sm text-muted-foreground py-4 text-center">
No Linear watchers configured. Create one to auto-create tasks from filtered issues.
{t("linear:noLinearWatchersConfiguredCreateOne")}
</p>
);
}
@@ -172,12 +185,12 @@ export function LinearIssueWatchTable({
<Table>
<TableHeader>
<TableRow>
{showWorkspace && <TableHead>Workspace</TableHead>}
<TableHead>Filter</TableHead>
<TableHead>Interval</TableHead>
<TableHead>Last Polled</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
{showWorkspace && <TableHead>{t("common:workspace")}</TableHead>}
<TableHead>{t("linear:filter")}</TableHead>
<TableHead>{t("linear:interval")}</TableHead>
<TableHead>{t("linear:lastPolled")}</TableHead>
<TableHead>{t("common:status")}</TableHead>
<TableHead className="text-right">{t("linear:actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -202,14 +215,16 @@ export function LinearIssueWatchTable({
{summarizeFilter(watch.filter)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{Math.round(watch.pollIntervalSeconds / 60)}m
{t("linear:intervalMinutes", {
count: Math.round(watch.pollIntervalSeconds / 60),
})}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatLastPolled(watch.lastPolledAt)}
{formatLastPolled(t, watch.lastPolledAt)}
</TableCell>
<TableCell>
<Badge variant={watch.enabled ? "default" : "secondary"} className="text-xs">
{watch.enabled ? "Active" : "Paused"}
{watch.enabled ? t("linear:active") : t("linear:paused")}
</Badge>
</TableCell>
<TableCell className="text-right">
@@ -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<typeof create>[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<typeof update>[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 (
<SettingsSection
icon={<IconBellRinging className="h-5 w-5" />}
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={
<Button size="sm" onClick={openCreate} className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-1" />
New watcher
{t("linear:newWatcher")}
</Button>
}
>
@@ -216,7 +221,7 @@ export function LinearIssueWatchersSection() {
<ResetWatchDialog
open
onOpenChange={resetCtrl.onOpenChange}
integrationLabel="Linear watcher"
integrationLabel={t("linear:linearWatcher")}
previewLoader={resetCtrl.previewLoader}
onConfirm={resetCtrl.confirmReset}
/>
+72 -34
View File
@@ -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<FieldsRowProps, "teams" | "loadingTeams">) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<Label htmlFor="linear-secret">
API key
{t("linear:apiKey")}
{hasSavedSecret && (
<span className="text-xs text-muted-foreground ml-2">
(saved — leave blank to keep the current value)
</span>
<span className="text-xs text-muted-foreground ml-2">{t("linear:savedLeaveBlank")}</span>
)}
</Label>
<Input
id="linear-secret"
data-testid="linear-secret-input"
type="password"
placeholder={hasSavedSecret ? "••••••••" : "lin_api_..."}
placeholder={hasSavedSecret ? SECRET_MASK : SECRET_EXAMPLE}
value={form.secret}
data-settings-dirty={form.secret !== baseline.secret}
onChange={(e) => update("secret", e.target.value)}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
Create a personal API key at{" "}
{t("linear:createPersonalApiKeyAt")}{" "}
<a
className="underline cursor-pointer"
href="https://linear.app/settings/account/security"
href={API_KEY_SETTINGS_URL}
target="_blank"
rel="noreferrer"
>
linear.app/settings/account/security
{API_KEY_SETTINGS_LABEL}
</a>
</p>
</div>
@@ -96,9 +107,10 @@ function SecretField({
}
function TeamSelector({ form, baseline, loading, update, teams, loadingTeams }: FieldsRowProps) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<Label htmlFor="linear-team">Default team (optional)</Label>
<Label htmlFor="linear-team">{t("linear:defaultTeamOptional")}</Label>
<Select
value={form.defaultTeamKey || "__none__"}
onValueChange={(v) => update("defaultTeamKey", v === "__none__" ? "" : v)}
@@ -109,13 +121,17 @@ function TeamSelector({ form, baseline, loading, update, teams, loadingTeams }:
className="w-full"
data-settings-dirty={form.defaultTeamKey !== baseline.defaultTeamKey}
>
<SelectValue placeholder={loadingTeams ? "Loading teams…" : "Choose a team"} />
<SelectValue
placeholder={loadingTeams ? t("linear:loadingTeams") : t("linear:chooseATeam")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No default</SelectItem>
{teams.map((t) => (
<SelectItem key={t.id} value={t.key}>
{t.name} ({t.key})
<SelectItem value="__none__">{t("linear:noDefault")}</SelectItem>
{/* Renamed from `t` so it cannot shadow the translation function
above; team names and keys are Linear API data, not copy. */}
{teams.map((team) => (
<SelectItem key={team.id} value={team.key}>
{team.name} ({team.key})
</SelectItem>
))}
</SelectContent>
@@ -124,15 +140,30 @@ function TeamSelector({ form, baseline, loading, update, teams, loadingTeams }:
);
}
// The identity and the org name are Linear API data, so they are interpolated
// rather than written into the catalog — a locale (pseudo included) must not
// rewrite the account the user is checking they connected as.
//
// Every field on the result is optional, so each interpolated value needs a
// fallback: i18next leaves an unmatched placeholder in place, so a partial
// response would render the literal "{{name}}" to the user rather than the
// old template's "undefined".
function testResultMessage(t: TFunction, result: TestLinearConnectionResult): string {
if (!result.ok) {
return t("linear:testFailed", { error: result.error || t("linear:unknownError") });
}
const name = result.displayName || result.email || result.userId || t("linear:unknownAccount");
return result.orgName
? t("linear:connectedAsWithOrg", { name, org: result.orgName })
: t("linear:connectedAs", { name });
}
function TestResultAlert({ result }: { result: TestLinearConnectionResult | null }) {
const { t } = useTranslation();
if (!result) return null;
return (
<Alert variant={result.ok ? "default" : "destructive"}>
<AlertDescription>
{result.ok
? `Connected as ${result.displayName || result.email || result.userId}${result.orgName ? ` (${result.orgName})` : ""}`
: `Failed: ${result.error}`}
</AlertDescription>
<AlertDescription>{testResultMessage(t, result)}</AlertDescription>
</Alert>
);
}
@@ -157,6 +188,7 @@ type ActionBarProps = {
};
function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete }: ActionBarProps) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2">
<Button
@@ -165,10 +197,10 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete
onClick={onTest}
disabled={testing || loading || disableTest}
className="cursor-pointer"
title={disableTest ? "Paste an API key to test the connection" : undefined}
title={disableTest ? t("linear:pasteAnApiKeyToTest") : undefined}
data-testid="linear-test-button"
>
{testing ? "Testing..." : "Test connection"}
{testing ? t("linear:testing") : t("linear:testConnection")}
</Button>
{hasConfig && (
<Button
@@ -178,7 +210,7 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete
className="ml-auto cursor-pointer"
data-testid="linear-delete-button"
>
Remove configuration
{t("linear:removeConfiguration")}
</Button>
)}
</div>
@@ -202,6 +234,7 @@ function useSettingsActions({
setForm,
setTestResult,
}: SettingsActionsArgs) {
const { t } = useTranslation();
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
@@ -243,28 +276,28 @@ function useSettingsActions({
JSON.stringify(current) === JSON.stringify(submitted) ? configToForm(saved) : current,
);
setTestResult(null);
toast({ description: "Linear configuration saved", variant: "success" });
toast({ description: t("linear:configurationSaved"), variant: "success" });
} catch (err) {
toast({ description: `Save failed: ${String(err)}`, variant: "error" });
toast({ description: t("linear:saveFailed", { error: String(err) }), variant: "error" });
throw err;
} finally {
setSaving(false);
}
}, [workspaceId, form, toast, setConfig, setBaselineConfig, setForm, setTestResult]);
}, [workspaceId, form, t, toast, setConfig, setBaselineConfig, setForm, setTestResult]);
const handleDelete = useCallback(async () => {
if (!confirm("Remove Linear configuration?")) return;
if (!confirm(t("linear:removeLinearConfigurationConfirm"))) return;
try {
await deleteLinearConfig({ workspaceId });
setConfig(null);
setBaselineConfig(null);
setForm(emptyForm);
setTestResult(null);
toast({ description: "Linear configuration removed", variant: "success" });
toast({ description: t("linear:configurationRemoved"), variant: "success" });
} catch (err) {
toast({ description: `Delete failed: ${String(err)}`, variant: "error" });
toast({ description: t("linear:deleteFailed", { error: String(err) }), variant: "error" });
}
}, [workspaceId, toast, setConfig, setBaselineConfig, setForm, setTestResult]);
}, [workspaceId, t, toast, setConfig, setBaselineConfig, setForm, setTestResult]);
return { saving, testing, handleTest, handleSave, handleDelete };
}
@@ -301,6 +334,7 @@ function useTeamsLoader(
}
function useLinearSettings(workspaceId: string) {
const { t } = useTranslation();
const { toast } = useToast();
const [config, setConfig] = useState<LinearConfig | null>(null);
const [baselineConfig, setBaselineConfig] = useState<LinearConfig | null>(null);
@@ -318,11 +352,14 @@ function useLinearSettings(workspaceId: string) {
setBaselineConfig(cfg);
setForm(configToForm(cfg));
} catch (err) {
toast({ description: `Failed to load Linear config: ${String(err)}`, variant: "error" });
toast({
description: t("linear:failedToLoadConfig", { error: String(err) }),
variant: "error",
});
} finally {
setLoading(false);
}
}, [workspaceId, toast]);
}, [workspaceId, t, toast]);
useEffect(() => {
void load();
@@ -381,6 +418,7 @@ function EnabledPill() {
}
export function LinearConnectionSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const s = useLinearSettings(workspaceId);
const baseline = configToForm(s.baselineConfig);
const missingSecret = !s.config?.hasSecret && !s.form.secret;
@@ -394,7 +432,7 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: string }
revision,
isDirty: dirty,
canSave: !disableSave,
invalidReason: missingSecret ? "An API key is required." : undefined,
invalidReason: missingSecret ? t("linear:anApiKeyIsRequired") : undefined,
save: s.handleSave,
discard: s.discard,
});
@@ -402,8 +440,8 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: string }
return (
<SettingsSection
icon={<IconHexagon className="h-5 w-5" />}
title="Linear integration"
description="Connect this workspace to Linear with a personal API key. Credentials are stored encrypted server-side for the selected workspace."
title={t("linear:linearIntegration")}
description={t("linear:linearIntegrationDescription")}
action={<EnabledPill />}
>
<SettingsCard isDirty={dirty}>
@@ -127,7 +127,8 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [
],
},
{ name: "settings — task actions", url: "/settings/general/task-actions" },
// NOT YET: "settings — integrations github", "… gitlab", "… jira". Each page's
// NOT YET: "settings — integrations github", "… gitlab", "… jira",
// "… linear". Each page's
// own copy is fully migrated (verified by running this oracle against it —
// every string the integration owns renders accented), but the route expands
// the Workspaces > Integrations branch of the settings nav, and
@@ -139,13 +140,14 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [
// in the PR that migrates the settings nav — allowlisting those nav labels here
// would hide real misses instead.
//
// Two shared components rendered by all three pages are also still English and
// Two shared components rendered by all four pages are also still English and
// would have to be migrated (or allowlisted, which is worse) first:
// `components/integrations/drafted-integration-enabled-control.tsx`
// ("Enabled"/"Disabled") and `components/watcher-repository-fields.tsx`
// ("Repository", "Base Branch", "(no repository)"), plus `STEP_DEFAULT_LABEL`
// and `stepPlaceholder`. All four are shared with the un-migrated Linear,
// Sentry and Azure DevOps surfaces.
// and `stepPlaceholder`. All four are shared with the un-migrated Sentry and
// Azure DevOps surfaces, plus `components/integrations/settings-section.tsx`
// chrome and the watcher card's own empty/loading states.
];
/**
+27
View File
@@ -349,4 +349,31 @@ export const i18nGuardFiles = [
"components/jira/my-jira/presets.ts",
"components/jira/my-jira/use-task-presets.ts",
"components/jira/task-presets-section.tsx",
// Settings → Integrations → Linear: the page and the settings subtree of
// `components/linear` — the connection card, the Linear-watcher section with
// its table, and the watch dialog with its filter/settings field modules. The
// issue task surface (`linear-issue-button`, `linear-issue-dialog`,
// `linear-issue-common`), the import bar and the quick-task launcher live in
// the same directory and are NOT migrated, which is why this is a file list
// rather than `components/linear/**`.
//
// The two `.ts` entries hold no JSX, so `mode: "jsx-only"` never inspects
// them; the entries record that they are migrated, and only the pseudo-locale
// can prove it stays that way. Each carries copy that is deliberately left in
// English because it is PERSISTED or sent to an agent verbatim — the
// `DEFAULT_LINEAR_ISSUE_WATCH_PROMPT` in
// `linear-issue-watch-placeholders.ts`, which must also keep matching
// `apps/backend/config/prompts/linear-issue-watch-default.md`. The placeholder
// `example` values in the same file, and the option `value`s in
// `linear-issue-watch-form.ts`, are identifiers rather than copy; the option
// labels there now travel as `labelKey` and resolve at render.
"app/settings/integrations/linear/**/*.{ts,tsx}",
"components/linear/linear-issue-watch-dialog.tsx",
"components/linear/linear-issue-watch-fields.tsx",
"components/linear/linear-issue-watch-filter-fields.tsx",
"components/linear/linear-issue-watch-form.ts",
"components/linear/linear-issue-watch-placeholders.ts",
"components/linear/linear-issue-watch-table.tsx",
"components/linear/linear-issue-watchers-section.tsx",
"components/linear/linear-settings.tsx",
];
+143
View File
@@ -0,0 +1,143 @@
{
"actions": "Actions",
"active": "Active",
"agentProfile": "Agent Profile",
"anApiKeyIsRequired": "An API key is required.",
"any": "(any)",
"anyTeam": "(any team)",
"apiKey": "API key",
"assignee": "Assignee",
"assigneeHelp": "Filter by who an issue is assigned to.",
"availablePlaceholders": "Available placeholders:",
"checkFailed": "Check failed: {{error}}",
"checkNow": "Check now",
"chooseATeam": "Choose a team",
"configurationRemoved": "Linear configuration removed",
"configurationSaved": "Linear configuration saved",
"connectedAs": "Connected as {{name}}",
"connectedAsWithOrg": "Connected as {{name}} ({{org}})",
"create": "Create",
"createFailed": "Create failed: {{error}}",
"createLinearWatcher": "Create Linear Watcher",
"createPersonalApiKeyAt": "Create a personal API key at",
"creator": "Creator",
"creatorHelp": "Match issues created by one user.",
"defaultTeamOptional": "Default team (optional)",
"delete": "Delete",
"deleteFailed": "Delete failed: {{error}}",
"deleteThisLinearWatcher": "Delete this Linear watcher?",
"dispatchOrder": "Dispatch order",
"dispatchOrderHelp": "When the in-flight cap is reached, issues are dispatched in this order so the most important ones run first.",
"editLinearWatcher": "Edit Linear Watcher",
"enable": "Enable",
"enabled": "Enabled",
"enabledHelp": "Pause or resume polling.",
"enterAPositiveIntegerOrBlank": "Enter a positive integer or leave blank.",
"estimateMax": "Estimate max",
"estimateMaxHelp": "Upper bound in points (optional).",
"estimateMaxPlaceholder": "e.g. 5",
"estimateMin": "Estimate min",
"estimateMinHelp": "Lower bound in points (optional).",
"estimateMinPlaceholder": "e.g. 1",
"executorProfile": "Executor Profile",
"failedToLoadConfig": "Failed to load Linear config: {{error}}",
"fallsBackToStepDefault": "Optional — falls back to step default.",
"filter": "Filter",
"foundNewIssues_one": "Found {{count}} new issue — tasks will appear shortly.",
"foundNewIssues_other": "Found {{count}} new issues — tasks will appear shortly.",
"interval": "Interval",
"intervalMinutes_one": "{{count}}m",
"intervalMinutes_other": "{{count}}m",
"labels": "Labels",
"labelsHelp": "Click to toggle. Matches ANY of the selected labels.",
"labelsHelpNoTeam": "Pick a team to choose specific labels.",
"lastPolled": "Last Polled",
"linearIntegration": "Linear integration",
"linearIntegrationDescription": "Connect this workspace to Linear with a personal API key. Credentials are stored encrypted server-side for the selected workspace.",
"linearWatcher": "Linear watcher",
"linearWatchers": "Linear watchers",
"linearWatchersDescription": "Poll a Linear filter and auto-create a Kandev task for each newly-matching issue.",
"loadingEllipsis": "Loading…",
"loadingLabels": "Loading labels…",
"loadingStates": "Loading states…",
"loadingTeams": "Loading teams…",
"maxInflightTasks": "Max in-flight tasks",
"maxInflightTasksHelp": "Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred to the next poll when the cap is reached.",
"me": "Me",
"never": "Never",
"newWatcher": "New watcher",
"noCap": "(no cap)",
"noDefault": "No default",
"noLabelsAvailableForThisTeam": "No labels available for this team.",
"noLinearWatchersConfiguredCreateOne": "No Linear watchers configured. Create one to auto-create tasks from filtered issues.",
"noNewIssuesMatched": "No new issues matched.",
"noWorkflowStatesAvailable": "No workflow states available.",
"pasteAnApiKeyToTest": "Paste an API key to test the connection",
"pause": "Pause",
"paused": "Paused",
"pickATeamFirst": "Pick a team first",
"placeholderAssigneeDisplayName": "Assignee display name",
"placeholderCreatorDisplayName": "Issue creator display name",
"placeholderIssueDescription": "Issue description body",
"placeholderIssueIdentifier": "Issue identifier",
"placeholderIssueTitle": "Issue title",
"placeholderIssueUrl": "Linear issue URL",
"placeholderPriorityLabel": "Priority label",
"placeholderTeamKey": "Team key",
"placeholderWorkflowStateName": "Workflow state name",
"pollIntervalHelp": "How often to re-run the search. Minimum 60s, maximum 3600s.",
"pollIntervalSeconds": "Poll Interval (seconds)",
"priority": "Priority",
"priorityHelp": "Click to toggle. Matches issues at ANY of the selected priorities.",
"priorityHigh": "High",
"priorityLow": "Low",
"priorityMedium": "Medium",
"priorityNone": "No priority",
"priorityUrgent": "Urgent",
"promptFieldHelp": "The prompt sent to the agent for each new issue. Type {{token}} to insert placeholders.",
"query": "Query",
"queryHelp": "Free-text match across title and description (optional).",
"queryPlaceholder": "auth bug",
"removeConfiguration": "Remove configuration",
"removeLinearConfigurationConfirm": "Remove Linear configuration?",
"resetCompleteDeletedTasks_one": "Reset complete — deleted {{count}} task; next poll will re-import matches.",
"resetCompleteDeletedTasks_other": "Reset complete — deleted {{count}} tasks; next poll will re-import matches.",
"resetCompleteNoTasksDeleted": "Reset complete — next poll will re-import matches.",
"resetFailed": "Reset failed: {{error}}",
"resetWatch": "Reset watch",
"saveFailed": "Save failed: {{error}}",
"savedLeaveBlank": "(saved — leave blank to keep the current value)",
"saving": "Saving…",
"selectWorkflow": "Select workflow",
"selectWorkspace": "Select workspace",
"sortCreatedAsc": "Created (oldest first)",
"sortCreatedDesc": "Created (newest first)",
"sortDefault": "Default (Linear order)",
"sortPriorityAsc": "Priority (low → high)",
"sortPriorityDesc": "Priority (high → low)",
"sortUpdatedAsc": "Updated (least recently updated first)",
"sortUpdatedDesc": "Updated (recently updated first)",
"states": "States",
"statesHelp": "Click states to toggle. Empty matches every state on the team.",
"statesHelpNoTeam": "Pick a team to choose specific workflow states.",
"taskPrompt": "Task Prompt",
"team": "Team",
"teamHelp": "Restrict matches to one team.",
"testConnection": "Test connection",
"testFailed": "Failed: {{error}}",
"testing": "Testing…",
"unassigned": "Unassigned",
"unknownAccount": "Unknown account",
"unknownError": "Unknown error",
"update": "Update",
"updateFailed": "Update failed: {{error}}",
"watchDialogDescription": "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.",
"watcherCreated": "Watcher created",
"watcherDeleted": "Watcher deleted",
"watcherUpdated": "Watcher updated",
"workflow": "Workflow",
"workflowHelp": "Tasks are created in this workflow.",
"workflowStep": "Workflow Step",
"workflowStepHelp": "Initial step for new tasks.",
"workspaceHelp": "Tasks created by this watcher land in the selected workspace."
}
+143
View File
@@ -0,0 +1,143 @@
{
"actions": "Àćţĩōńś",
"active": "Àćţĩvē",
"agentProfile": "Àĝēńţ Ƥŕōƒĩĺē",
"anApiKeyIsRequired": "Àń ÀƤĨ ķēŷ ĩś ŕēqũĩŕēď.",
"any": "(àńŷ)",
"anyTeam": "(àńŷ ţēàḿ)",
"apiKey": "ÀƤĨ ķēŷ",
"assignee": "Àśśĩĝńēē",
"assigneeHelp": "Ƒĩĺţēŕ ƀŷ ŵĥō àń ĩśśũē ĩś àśśĩĝńēď ţō.",
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
"checkFailed": "Ćĥēćķ ƒàĩĺēď: {{error}}",
"checkNow": "Ćĥēćķ ńōŵ",
"chooseATeam": "Ćĥōōśē à ţēàḿ",
"configurationRemoved": "Ĺĩńēàŕ ćōńƒĩĝũŕàţĩōń ŕēḿōvēď",
"configurationSaved": "Ĺĩńēàŕ ćōńƒĩĝũŕàţĩōń śàvēď",
"connectedAs": "Ćōńńēćţēď àś {{name}}",
"connectedAsWithOrg": "Ćōńńēćţēď àś {{name}} ({{org}})",
"create": "Ćŕēàţē",
"createFailed": "Ćŕēàţē ƒàĩĺēď: {{error}}",
"createLinearWatcher": "Ćŕēàţē Ĺĩńēàŕ Ŵàţćĥēŕ",
"createPersonalApiKeyAt": "Ćŕēàţē à ƥēŕśōńàĺ ÀƤĨ ķēŷ àţ",
"creator": "Ćŕēàţōŕ",
"creatorHelp": "Ḿàţćĥ ĩśśũēś ćŕēàţēď ƀŷ ōńē ũśēŕ.",
"defaultTeamOptional": "Ďēƒàũĺţ ţēàḿ (ōƥţĩōńàĺ)",
"delete": "Ďēĺēţē",
"deleteFailed": "Ďēĺēţē ƒàĩĺēď: {{error}}",
"deleteThisLinearWatcher": "Ďēĺēţē ţĥĩś Ĺĩńēàŕ ŵàţćĥēŕ?",
"dispatchOrder": "Ďĩśƥàţćĥ ōŕďēŕ",
"dispatchOrderHelp": "Ŵĥēń ţĥē ĩń-ƒĺĩĝĥţ ćàƥ ĩś ŕēàćĥēď, ĩśśũēś àŕē ďĩśƥàţćĥēď ĩń ţĥĩś ōŕďēŕ śō ţĥē ḿōśţ ĩḿƥōŕţàńţ ōńēś ŕũń ƒĩŕśţ.",
"editLinearWatcher": "Ēďĩţ Ĺĩńēàŕ Ŵàţćĥēŕ",
"enable": "Ēńàƀĺē",
"enabled": "Ēńàƀĺēď",
"enabledHelp": "Ƥàũśē ōŕ ŕēśũḿē ƥōĺĺĩńĝ.",
"enterAPositiveIntegerOrBlank": "Ēńţēŕ à ƥōśĩţĩvē ĩńţēĝēŕ ōŕ ĺēàvē ƀĺàńķ.",
"estimateMax": "Ēśţĩḿàţē ḿàx",
"estimateMaxHelp": "Ũƥƥēŕ ƀōũńď ĩń ƥōĩńţś (ōƥţĩōńàĺ).",
"estimateMaxPlaceholder": "ē.ĝ. 5",
"estimateMin": "Ēśţĩḿàţē ḿĩń",
"estimateMinHelp": "Ĺōŵēŕ ƀōũńď ĩń ƥōĩńţś (ōƥţĩōńàĺ).",
"estimateMinPlaceholder": "ē.ĝ. 1",
"executorProfile": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
"failedToLoadConfig": "Ƒàĩĺēď ţō ĺōàď Ĺĩńēàŕ ćōńƒĩĝ: {{error}}",
"fallsBackToStepDefault": "Ōƥţĩōńàĺ — ƒàĺĺś ƀàćķ ţō śţēƥ ďēƒàũĺţ.",
"filter": "Ƒĩĺţēŕ",
"foundNewIssues_one": "Ƒōũńď {{count}} ńēŵ ĩśśũē — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
"foundNewIssues_other": "Ƒōũńď {{count}} ńēŵ ĩśśũēś — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
"interval": "Ĩńţēŕvàĺ",
"intervalMinutes_one": "{{count}}ḿ",
"intervalMinutes_other": "{{count}}ḿ",
"labels": "Ĺàƀēĺś",
"labelsHelp": "Ćĺĩćķ ţō ţōĝĝĺē. Ḿàţćĥēś ÀŃŶ ōƒ ţĥē śēĺēćţēď ĺàƀēĺś.",
"labelsHelpNoTeam": "Ƥĩćķ à ţēàḿ ţō ćĥōōśē śƥēćĩƒĩć ĺàƀēĺś.",
"lastPolled": "Ĺàśţ Ƥōĺĺēď",
"linearIntegration": "Ĺĩńēàŕ ĩńţēĝŕàţĩōń",
"linearIntegrationDescription": "Ćōńńēćţ ţĥĩś ŵōŕķśƥàćē ţō Ĺĩńēàŕ ŵĩţĥ à ƥēŕśōńàĺ ÀƤĨ ķēŷ. Ćŕēďēńţĩàĺś àŕē śţōŕēď ēńćŕŷƥţēď śēŕvēŕ-śĩďē ƒōŕ ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
"linearWatcher": "Ĺĩńēàŕ ŵàţćĥēŕ",
"linearWatchers": "Ĺĩńēàŕ ŵàţćĥēŕś",
"linearWatchersDescription": "Ƥōĺĺ à Ĺĩńēàŕ ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē.",
"loadingEllipsis": "Ĺōàďĩńĝ…",
"loadingLabels": "Ĺōàďĩńĝ ĺàƀēĺś…",
"loadingStates": "Ĺōàďĩńĝ śţàţēś…",
"loadingTeams": "Ĺōàďĩńĝ ţēàḿś…",
"maxInflightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś",
"maxInflightTasksHelp": "Ćàƥ ōń ōƥēń ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ. Ĺēàvē ƀĺàńķ ƒōŕ ńō ćàƥ. Ńēŵ ḿàţćĥēś àŕē ďēƒēŕŕēď ţō ţĥē ńēxţ ƥōĺĺ ŵĥēń ţĥē ćàƥ ĩś ŕēàćĥēď.",
"me": "Ḿē",
"never": "Ńēvēŕ",
"newWatcher": "Ńēŵ ŵàţćĥēŕ",
"noCap": "(ńō ćàƥ)",
"noDefault": "Ńō ďēƒàũĺţ",
"noLabelsAvailableForThisTeam": "Ńō ĺàƀēĺś àvàĩĺàƀĺē ƒōŕ ţĥĩś ţēàḿ.",
"noLinearWatchersConfiguredCreateOne": "Ńō Ĺĩńēàŕ ŵàţćĥēŕś ćōńƒĩĝũŕēď. Ćŕēàţē ōńē ţō àũţō-ćŕēàţē ţàśķś ƒŕōḿ ƒĩĺţēŕēď ĩśśũēś.",
"noNewIssuesMatched": "Ńō ńēŵ ĩśśũēś ḿàţćĥēď.",
"noWorkflowStatesAvailable": "Ńō ŵōŕķƒĺōŵ śţàţēś àvàĩĺàƀĺē.",
"pasteAnApiKeyToTest": "Ƥàśţē àń ÀƤĨ ķēŷ ţō ţēśţ ţĥē ćōńńēćţĩōń",
"pause": "Ƥàũśē",
"paused": "Ƥàũśēď",
"pickATeamFirst": "Ƥĩćķ à ţēàḿ ƒĩŕśţ",
"placeholderAssigneeDisplayName": "Àśśĩĝńēē ďĩśƥĺàŷ ńàḿē",
"placeholderCreatorDisplayName": "Ĩśśũē ćŕēàţōŕ ďĩśƥĺàŷ ńàḿē",
"placeholderIssueDescription": "Ĩśśũē ďēśćŕĩƥţĩōń ƀōďŷ",
"placeholderIssueIdentifier": "Ĩśśũē ĩďēńţĩƒĩēŕ",
"placeholderIssueTitle": "Ĩśśũē ţĩţĺē",
"placeholderIssueUrl": "Ĺĩńēàŕ ĩśśũē ŨŔĹ",
"placeholderPriorityLabel": "Ƥŕĩōŕĩţŷ ĺàƀēĺ",
"placeholderTeamKey": "Ţēàḿ ķēŷ",
"placeholderWorkflowStateName": "Ŵōŕķƒĺōŵ śţàţē ńàḿē",
"pollIntervalHelp": "Ĥōŵ ōƒţēń ţō ŕē-ŕũń ţĥē śēàŕćĥ. Ḿĩńĩḿũḿ 60ś, ḿàxĩḿũḿ 3600ś.",
"pollIntervalSeconds": "Ƥōĺĺ Ĩńţēŕvàĺ (śēćōńďś)",
"priority": "Ƥŕĩōŕĩţŷ",
"priorityHelp": "Ćĺĩćķ ţō ţōĝĝĺē. Ḿàţćĥēś ĩśśũēś àţ ÀŃŶ ōƒ ţĥē śēĺēćţēď ƥŕĩōŕĩţĩēś.",
"priorityHigh": "Ĥĩĝĥ",
"priorityLow": "Ĺōŵ",
"priorityMedium": "Ḿēďĩũḿ",
"priorityNone": "Ńō ƥŕĩōŕĩţŷ",
"priorityUrgent": "Ũŕĝēńţ",
"promptFieldHelp": "Ţĥē ƥŕōḿƥţ śēńţ ţō ţĥē àĝēńţ ƒōŕ ēàćĥ ńēŵ ĩśśũē. Ţŷƥē {{token}} ţō ĩńśēŕţ ƥĺàćēĥōĺďēŕś.",
"query": "Qũēŕŷ",
"queryHelp": "Ƒŕēē-ţēxţ ḿàţćĥ àćŕōśś ţĩţĺē àńď ďēśćŕĩƥţĩōń (ōƥţĩōńàĺ).",
"queryPlaceholder": "àũţĥ ƀũĝ",
"removeConfiguration": "Ŕēḿōvē ćōńƒĩĝũŕàţĩōń",
"removeLinearConfigurationConfirm": "Ŕēḿōvē Ĺĩńēàŕ ćōńƒĩĝũŕàţĩōń?",
"resetCompleteDeletedTasks_one": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķ; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
"resetCompleteDeletedTasks_other": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķś; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
"resetCompleteNoTasksDeleted": "Ŕēśēţ ćōḿƥĺēţē — ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
"resetFailed": "Ŕēśēţ ƒàĩĺēď: {{error}}",
"resetWatch": "Ŕēśēţ ŵàţćĥ",
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
"savedLeaveBlank": "(śàvēď — ĺēàvē ƀĺàńķ ţō ķēēƥ ţĥē ćũŕŕēńţ vàĺũē)",
"saving": "Śàvĩńĝ…",
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
"selectWorkspace": "Śēĺēćţ ŵōŕķśƥàćē",
"sortCreatedAsc": "Ćŕēàţēď (ōĺďēśţ ƒĩŕśţ)",
"sortCreatedDesc": "Ćŕēàţēď (ńēŵēśţ ƒĩŕśţ)",
"sortDefault": "Ďēƒàũĺţ (Ĺĩńēàŕ ōŕďēŕ)",
"sortPriorityAsc": "Ƥŕĩōŕĩţŷ (ĺōŵ → ĥĩĝĥ)",
"sortPriorityDesc": "Ƥŕĩōŕĩţŷ (ĥĩĝĥ → ĺōŵ)",
"sortUpdatedAsc": "Ũƥďàţēď (ĺēàśţ ŕēćēńţĺŷ ũƥďàţēď ƒĩŕśţ)",
"sortUpdatedDesc": "Ũƥďàţēď (ŕēćēńţĺŷ ũƥďàţēď ƒĩŕśţ)",
"states": "Śţàţēś",
"statesHelp": "Ćĺĩćķ śţàţēś ţō ţōĝĝĺē. Ēḿƥţŷ ḿàţćĥēś ēvēŕŷ śţàţē ōń ţĥē ţēàḿ.",
"statesHelpNoTeam": "Ƥĩćķ à ţēàḿ ţō ćĥōōśē śƥēćĩƒĩć ŵōŕķƒĺōŵ śţàţēś.",
"taskPrompt": "Ţàśķ Ƥŕōḿƥţ",
"team": "Ţēàḿ",
"teamHelp": "Ŕēśţŕĩćţ ḿàţćĥēś ţō ōńē ţēàḿ.",
"testConnection": "Ţēśţ ćōńńēćţĩōń",
"testFailed": "Ƒàĩĺēď: {{error}}",
"testing": "Ţēśţĩńĝ…",
"unassigned": "Ũńàśśĩĝńēď",
"unknownAccount": "Ũńķńōŵń àććōũńţ",
"unknownError": "Ũńķńōŵń ēŕŕōŕ",
"update": "Ũƥďàţē",
"updateFailed": "Ũƥďàţē ƒàĩĺēď: {{error}}",
"watchDialogDescription": "Ƥōĺĺ Ĺĩńēàŕ ŵĩţĥ à śţŕũćţũŕēď ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē. Ōƥţĩōńàĺĺŷ ƀĩńď à ŕēƥōśĩţōŕŷ śō ēàćĥ ţàśķ ŕũńś àĝàĩńśţ ţĥàţ ćōďēƀàśē, ōŕ ĺēàvē ĩţ ũńśēţ ţō ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.",
"watcherCreated": "Ŵàţćĥēŕ ćŕēàţēď",
"watcherDeleted": "Ŵàţćĥēŕ ďēĺēţēď",
"watcherUpdated": "Ŵàţćĥēŕ ũƥďàţēď",
"workflow": "Ŵōŕķƒĺōŵ",
"workflowHelp": "Ţàśķś àŕē ćŕēàţēď ĩń ţĥĩś ŵōŕķƒĺōŵ.",
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ",
"workflowStepHelp": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ńēŵ ţàśķś.",
"workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē."
}