fix(i18n): externalize four SCREAMING_CASE copy tables the lint rule cannot see (#2409)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-08 17:07:07 +02:00
committed by GitHub
parent 131ef6f6c2
commit 7f0d4bdbb7
20 changed files with 451 additions and 93 deletions
+6 -2
View File
@@ -143,12 +143,16 @@ function TaskRows({
onDelete: (taskId: string, opts?: { cascade?: boolean }) => Promise<void>;
onRowClick: (task: Task) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const workflowMap = useMemo(() => new Map(workflows.map((w) => [w.id, w.name])), [workflows]);
const repoMap = useMemo(() => new Map(repositories.map((r) => [r.id, r.name])), [repositories]);
// `groupForTask` resolves its headings from the catalog (the no-workflow /
// no-repository fallbacks, and now the task-state vocabulary). Without the
// language in the deps a section header keeps the previous locale until the
// task list itself changes.
const sections = useMemo(
() => buildTaskSections(tasks, { groupBy: tasksListGroup, workflowMap, repoMap }),
[repoMap, tasks, tasksListGroup, workflowMap],
[repoMap, tasks, tasksListGroup, workflowMap, i18n.language],
);
if (isLoading) {
@@ -12,7 +12,7 @@ import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color";
import {
TASK_COLORS,
TASK_COLOR_BAR_CLASS,
TASK_COLOR_LABEL,
TASK_COLOR_LABEL_KEYS,
type TaskColor,
} from "@/lib/task-colors";
import { cn } from "@/lib/utils";
@@ -64,10 +64,11 @@ function TaskColorMenuItem({
selected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
return (
<ContextMenuItem onSelect={onSelect}>
<span className={cn("mr-2 inline-block h-2 w-2 rounded-full", TASK_COLOR_BAR_CLASS[color])} />
{TASK_COLOR_LABEL[color]}
{t(TASK_COLOR_LABEL_KEYS[color])}
{selected && <IconCheck className="ml-auto h-3.5 w-3.5" />}
</ContextMenuItem>
);
@@ -128,9 +128,12 @@ test.describe("Sidebar layout — repo groups", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
// In a single-repo workspace, no "Unassigned" group should appear
// In a single-repo workspace, no "Unassigned" group should appear.
// Keyed on `data-group-key`, the identity, not `data-group-label` — the
// label is catalog-backed now, so a label selector would silently stop
// matching under any non-English locale.
const unassignedGroup = session.sidebar.locator(
"[data-testid='sidebar-group-header'][data-group-label='Unassigned']",
"[data-testid='sidebar-group-header'][data-group-key='__unassigned__']",
);
await expect(unassignedGroup).not.toBeVisible({ timeout: 5_000 });
@@ -116,7 +116,14 @@ test.describe("Sidebar — cross-workspace isolation", () => {
timeout: 10_000,
});
await expect(session.sidebar.getByText("Workspace A Task", { exact: true })).toHaveCount(0);
await expect(session.sidebar.getByTestId("sidebar-repo-group-Unassigned")).toHaveCount(0);
// `sidebar-repo-group-Unassigned` was never a real testid, so this assertion
// was passing on nothing. The group header carries `data-group-key`, the
// untranslated identity — the label beside it is catalog-backed.
await expect(
session.sidebar.locator(
"[data-testid='sidebar-group-header'][data-group-key='__unassigned__']",
),
).toHaveCount(0);
await gotoTaskList(testPage);
await expect(taskInList(testPage, "Workspace B Task")).toBeVisible({ timeout: 10_000 });
+6 -2
View File
@@ -1345,8 +1345,12 @@ export const i18nGuardFiles = [
// does resolve at call time. Only a locale-switch test covers it, which is
// what `use-filter-value-options-locale.test.tsx` is for.
//
// Still English in `lib/sidebar/apply-view.ts` and belonging to whoever
// migrates `lib/sidebar`: `UNASSIGNED_LABEL` and `MULTI_REPO_LABEL`.
// `UNASSIGNED_LABEL` and `MULTI_REPO_LABEL` in `lib/sidebar/apply-view.ts`
// were the English remainder noted here; they are now
// `sidebar:groupUnassigned` / `sidebar:groupMultiRepo`, resolved at call time
// alongside the `__all__` heading. `lib/sidebar` is still NOT on this list —
// the rest of the directory was not swept, so whoever migrates it adds the
// entry.
//
// One deliberate English change, the only one in this PR: the SSH
// running-sessions confirm read "This executor has 3 running session(s)."
-19
View File
@@ -4,22 +4,3 @@ export const NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE = "system.update_availab
export const NOTIFICATION_EVENT_OFFICE_INBOX_ITEM = "office.inbox_item";
export const DEFAULT_NOTIFICATION_EVENTS = [NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED];
export const EVENT_LABELS: Record<string, { title: string; description: string }> = {
[NOTIFICATION_EVENT_SESSION_TURN_FINISHED]: {
title: "Agent turn finished",
description: "Notify after each completed agent turn.",
},
[NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED]: {
title: "Agent needs an answer",
description: "Notify when the agent explicitly asks you a question.",
},
[NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE]: {
title: "Kandev update available",
description: "Notify when a newer Kandev release is available.",
},
[NOTIFICATION_EVENT_OFFICE_INBOX_ITEM]: {
title: "Office inbox item",
description: "Notify when a new inbox item needs your attention.",
},
};
@@ -0,0 +1,89 @@
import { afterEach, describe, it, expect } from "vitest";
import type { TaskSwitcherItem } from "@/components/task/task-switcher";
import { applyGroup } from "./apply-view";
import { i18n } from "@/lib/i18n";
import type { TaskState } from "@/lib/types/http";
function task(overrides: Partial<TaskSwitcherItem>): TaskSwitcherItem {
return { id: overrides.id ?? "t", title: overrides.title ?? "Task", ...overrides };
}
const ALPHA = "/repos/alpha";
const BETA = "/repos/beta";
/** Two repos keep the `__unassigned__` bucket; one repo merges it away. */
const REPO_TASKS = [
task({ id: "a", repositoryPath: ALPHA }),
task({ id: "b", repositoryPath: BETA }),
task({ id: "c" }),
task({ id: "d", repositories: [ALPHA, BETA] }),
];
function labelFor(groups: { key: string; label: string }[], key: string): string | undefined {
return groups.find((g) => g.key === key)?.label;
}
afterEach(async () => {
await i18n.changeLanguage("en");
});
describe("applyGroup — group labels are catalog-backed", () => {
it("labels the repository sentinel buckets in English", () => {
const { groups } = applyGroup(REPO_TASKS, "repository");
expect(labelFor(groups, "__multi__")).toBe("Multi-repo");
expect(labelFor(groups, "__unassigned__")).toBe("Unassigned");
});
it("labels the ungrouped bucket", () => {
const { groups } = applyGroup([task({ id: "a" })], "none");
expect(groups).toHaveLength(1);
expect(groups[0].key).toBe("__all__");
expect(groups[0].label).toBe("All");
});
it.each(["workflow", "workflowStep", "executorType"] as const)(
"labels the %s bucket for a task with no value",
(groupKey) => {
const { groups } = applyGroup([task({ id: "a" })], groupKey);
expect(labelFor(groups, "__unassigned__")).toBe("Unassigned");
},
);
it("uses the task-state vocabulary for state group headings", () => {
const { groups } = applyGroup(
[task({ id: "a", state: "IN_PROGRESS" }), task({ id: "b" })],
"state",
);
expect(labelFor(groups, "IN_PROGRESS")).toBe("In progress");
expect(labelFor(groups, "__not_started__")).toBe("Not started");
});
it("echoes an unmapped task state rather than rendering a blank heading", () => {
const { groups } = applyGroup(
[task({ id: "a", state: "UNKNOWN_FUTURE" as TaskState })],
"state",
);
expect(labelFor(groups, "UNKNOWN_FUTURE")).toBe("UNKNOWN_FUTURE");
});
it("translates labels on a locale switch while group keys stay identity", async () => {
await i18n.changeLanguage("pseudo");
const { groups } = applyGroup(REPO_TASKS, "repository");
// Keys are compared in mergeSingleRepoUnassigned/sortRepoGroups and
// persisted in the view's collapsedGroups — they must never localize.
expect(groups.map((g) => g.key).sort()).toEqual([ALPHA, BETA, "__multi__", "__unassigned__"]);
expect(labelFor(groups, "__multi__")).not.toBe("Multi-repo");
expect(labelFor(groups, "__multi__")).toMatch(/[^\p{ASCII}]/u);
expect(labelFor(groups, "__unassigned__")).toMatch(/[^\p{ASCII}]/u);
// A repository path is user data, not copy.
expect(labelFor(groups, ALPHA)).toBe(ALPHA);
});
it("translates the ungrouped label on a locale switch", async () => {
await i18n.changeLanguage("pseudo");
const { groups } = applyGroup([task({ id: "a" })], "none");
expect(groups[0].key).toBe("__all__");
expect(groups[0].label).not.toBe("All");
expect(groups[0].label).toMatch(/[^\p{ASCII}]/u);
});
});
+38 -19
View File
@@ -1,6 +1,7 @@
import { classifyTask, type TaskBucket } from "@/components/task/task-classify";
import type { TaskSwitcherItem } from "@/components/task/task-switcher";
import { getExecutorLabel } from "@/lib/executor-icons";
import { t } from "@/lib/i18n";
import { formatTaskStateLabel } from "@/lib/ui/state-labels";
import type {
FilterClause,
@@ -183,8 +184,21 @@ export function applySort(
return withIndex.map((x) => x.t);
}
const UNASSIGNED_LABEL = "Unassigned";
const MULTI_REPO_LABEL = "Multi-repo";
/**
* Catalog keys, not resolved labels: a `t()` at module scope would freeze at the
* boot locale. `applyGroup` runs inside its callers' render, so it resolves
* through the module-level `t` at call time — both `useGroupedSidebarView` and
* the mobile task-switcher sheet already carry `i18n.language` in their memo
* deps for the same reason.
*
* Only `label` is copy. The group `key`s below (`__multi__`, `__unassigned__`,
* `__all__`, `__not_started__`) are identity: they are compared in
* `mergeSingleRepoUnassigned` / `sortRepoGroups`, index `STATE_GROUP_ORDER`, and
* are persisted in the view's `collapsedGroups`. They are never translated.
*/
const UNASSIGNED_LABEL_KEY = "sidebar:groupUnassigned";
const MULTI_REPO_LABEL_KEY = "sidebar:groupMultiRepo";
const ALL_GROUP_LABEL_KEY = "sidebar:groupAll";
const NOT_STARTED_STATE_GROUP_KEY = "__not_started__";
const STATE_GROUP_ORDER: Record<string, number> = {
@@ -255,28 +269,33 @@ function getEffectiveStateGroup(
}
const groupExtractors: Record<Exclude<GroupKey, "none">, GroupExtractor> = {
repository: (t) => {
if (t.repositories && t.repositories.length > 1) {
return { key: "__multi__", label: MULTI_REPO_LABEL };
// Parameter named `task`, not `t`: this file's other extractors and loops use
// `t` for a task, which shadows the imported translator. These four resolve
// copy, so they must not. Rename the parameter before adding a `t()` call to
// any of the others.
repository: (task) => {
if (task.repositories && task.repositories.length > 1) {
return { key: "__multi__", label: t(MULTI_REPO_LABEL_KEY) };
}
if (t.repositoryPath) return { key: t.repositoryPath, label: t.repositoryPath };
return { key: "__unassigned__", label: UNASSIGNED_LABEL };
if (task.repositoryPath) return { key: task.repositoryPath, label: task.repositoryPath };
return { key: "__unassigned__", label: t(UNASSIGNED_LABEL_KEY) };
},
workflow: (t) => {
if (t.workflowId) return { key: t.workflowId, label: t.workflowName ?? t.workflowId };
return { key: "__unassigned__", label: UNASSIGNED_LABEL };
workflow: (task) => {
if (task.workflowId)
return { key: task.workflowId, label: task.workflowName ?? task.workflowId };
return { key: "__unassigned__", label: t(UNASSIGNED_LABEL_KEY) };
},
workflowStep: (t) => {
if (t.workflowStepId) {
return { key: t.workflowStepId, label: t.workflowStepTitle ?? t.workflowStepId };
workflowStep: (task) => {
if (task.workflowStepId) {
return { key: task.workflowStepId, label: task.workflowStepTitle ?? task.workflowStepId };
}
return { key: "__unassigned__", label: UNASSIGNED_LABEL };
return { key: "__unassigned__", label: t(UNASSIGNED_LABEL_KEY) };
},
executorType: (t) => {
if (t.remoteExecutorType) {
return { key: t.remoteExecutorType, label: getExecutorLabel(t.remoteExecutorType) };
executorType: (task) => {
if (task.remoteExecutorType) {
return { key: task.remoteExecutorType, label: getExecutorLabel(task.remoteExecutorType) };
}
return { key: "__unassigned__", label: UNASSIGNED_LABEL };
return { key: "__unassigned__", label: t(UNASSIGNED_LABEL_KEY) };
},
// State groups use persisted task states so headings match task status labels.
state: getTaskStateGroup,
@@ -310,7 +329,7 @@ export function applyGroup(
if (groupKey === "none") {
return {
groups: [{ key: "__all__", label: "All", tasks: rootTasks }],
groups: [{ key: "__all__", label: t(ALL_GROUP_LABEL_KEY), tasks: rootTasks }],
subTasksByParentId,
};
}
+34 -1
View File
@@ -1,10 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getTaskColor,
setTaskColor,
TASK_COLORS,
TASK_COLOR_BAR_CLASS,
TASK_COLOR_LABEL_KEYS,
TASK_COLORS_CHANGED_EVENT,
TASK_COLORS_STORAGE_KEY,
} from "./task-colors";
import { i18n, t } from "@/lib/i18n";
describe("task colors storage", () => {
beforeEach(() => {
@@ -68,3 +72,32 @@ describe("task colors storage", () => {
window.removeEventListener(TASK_COLORS_CHANGED_EVENT, listener);
});
});
describe("TASK_COLOR_LABEL_KEYS", () => {
afterEach(async () => {
await i18n.changeLanguage("en");
});
it("covers every colour, keyed by the persisted wire value", () => {
expect(Object.keys(TASK_COLOR_LABEL_KEYS).sort()).toEqual([...TASK_COLORS].sort());
// The same wire values index the class table, so they are identity.
expect(Object.keys(TASK_COLOR_BAR_CLASS).sort()).toEqual([...TASK_COLORS].sort());
});
it("holds catalog keys that resolve to English copy", () => {
expect(TASK_COLOR_LABEL_KEYS.red).toBe("task:colorRed");
const labels = TASK_COLORS.map((c) => t(TASK_COLOR_LABEL_KEYS[c]));
expect(labels).toEqual(["Red", "Orange", "Yellow", "Green", "Blue", "Purple", "Pink"]);
});
it("stores keys rather than resolved copy, so a locale switch takes effect", async () => {
await i18n.changeLanguage("pseudo");
for (const color of TASK_COLORS) {
const label = t(TASK_COLOR_LABEL_KEYS[color]);
// A missing key would resolve to the key itself; frozen copy would stay
// English. Both are ruled out by an accented, non-key label.
expect(label).not.toBe(TASK_COLOR_LABEL_KEYS[color]);
expect(label).toMatch(/[^\p{ASCII}]/u);
}
});
});
+15 -8
View File
@@ -14,14 +14,21 @@ export const TASK_COLOR_BAR_CLASS: Record<TaskColor, string> = {
pink: "bg-pink-500",
};
export const TASK_COLOR_LABEL: Record<TaskColor, string> = {
red: "Red",
orange: "Orange",
yellow: "Yellow",
green: "Green",
blue: "Blue",
purple: "Purple",
pink: "Pink",
/**
* Catalog keys, not resolved labels: a `t()` at module scope would resolve once
* at import and freeze at the boot locale. The colour menu resolves at render.
*
* The record keys are the persisted `TaskColor` values (localStorage, and the
* `TASK_COLOR_BAR_CLASS` lookup) and are never translated.
*/
export const TASK_COLOR_LABEL_KEYS: Record<TaskColor, string> = {
red: "task:colorRed",
orange: "task:colorOrange",
yellow: "task:colorYellow",
green: "task:colorGreen",
blue: "task:colorBlue",
purple: "task:colorPurple",
pink: "task:colorPink",
};
export const TASK_COLORS_STORAGE_KEY = "kandev.taskColors";
+31 -1
View File
@@ -1,7 +1,12 @@
import { describe, it, expect } from "vitest";
import { afterEach, describe, it, expect } from "vitest";
import { formatTaskStateLabel, formatTaskSessionStateLabel } from "./state-labels";
import { i18n } from "@/lib/i18n";
import type { TaskState, TaskSessionState } from "@/lib/types/http";
afterEach(async () => {
await i18n.changeLanguage("en");
});
describe("formatTaskStateLabel", () => {
it("maps known task states to human labels", () => {
expect(formatTaskStateLabel("IN_PROGRESS")).toBe("In progress");
@@ -24,6 +29,18 @@ describe("formatTaskStateLabel", () => {
it("falls back to the raw value for unknown states", () => {
expect(formatTaskStateLabel("UNKNOWN_FUTURE" as TaskState)).toBe("UNKNOWN_FUTURE");
});
it("resolves at call time, so a locale switch changes the label", async () => {
await i18n.changeLanguage("pseudo");
const label = formatTaskStateLabel("IN_PROGRESS");
expect(label).not.toBe("In progress");
expect(label).toMatch(/[^\p{ASCII}]/u);
});
it("leaves an unmapped wire value untranslated under any locale", async () => {
await i18n.changeLanguage("pseudo");
expect(formatTaskStateLabel("UNKNOWN_FUTURE" as TaskState)).toBe("UNKNOWN_FUTURE");
});
});
describe("formatTaskSessionStateLabel", () => {
@@ -35,6 +52,7 @@ describe("formatTaskSessionStateLabel", () => {
expect(formatTaskSessionStateLabel("FAILED")).toBe("Failed");
expect(formatTaskSessionStateLabel("CANCELLED")).toBe("Cancelled");
expect(formatTaskSessionStateLabel("CREATED")).toBe("Created");
expect(formatTaskSessionStateLabel("IDLE")).toBe("Idle");
});
it("returns empty string for null/undefined", () => {
@@ -45,4 +63,16 @@ describe("formatTaskSessionStateLabel", () => {
it("falls back to the raw value for unknown states", () => {
expect(formatTaskSessionStateLabel("UNKNOWN" as TaskSessionState)).toBe("UNKNOWN");
});
it("resolves at call time, so a locale switch changes the label", async () => {
await i18n.changeLanguage("pseudo");
const label = formatTaskSessionStateLabel("RUNNING");
expect(label).not.toBe("Running");
expect(label).toMatch(/[^\p{ASCII}]/u);
});
it("leaves an unmapped wire value untranslated under any locale", async () => {
await i18n.changeLanguage("pseudo");
expect(formatTaskSessionStateLabel("UNKNOWN" as TaskSessionState)).toBe("UNKNOWN");
});
});
+46 -23
View File
@@ -1,35 +1,58 @@
import { t } from "@/lib/i18n";
import type { TaskState, TaskSessionState } from "@/lib/types/http";
const TASK_STATE_LABELS: Record<TaskState, string> = {
CREATED: "Created",
SCHEDULING: "Scheduling",
TODO: "To do",
IN_PROGRESS: "In progress",
REVIEW: "Review",
BLOCKED: "Blocked",
WAITING_FOR_INPUT: "Waiting for input",
COMPLETED: "Completed",
FAILED: "Failed",
CANCELLED: "Cancelled",
/**
* Catalog keys for the task-state and session-state vocabularies.
*
* Keys, not resolved labels: these are module-scope tables, so a `t()` here
* would resolve once at import and freeze at the boot locale. The exported
* helpers are plain functions called during their callers' render, so they use
* the module-level `t` and resolve at call time; every consumer re-renders on
* `languageChanged` (the sidebar and mobile switcher memos carry
* `i18n.language` in their deps).
*
* The record keys are the wire `TaskState` / `TaskSessionState` values and are
* never translated — they are compared, persisted and sent to the backend.
*
* The two vocabularies keep separate catalog keys even where the English
* coincides ("Completed", "Failed", …): a task and a session are different
* subjects, and languages that inflect for gender or number need to say them
* differently.
*/
const TASK_STATE_LABEL_KEYS: Record<TaskState, string> = {
CREATED: "common:taskStateCreated",
SCHEDULING: "common:taskStateScheduling",
TODO: "common:taskStateTodo",
IN_PROGRESS: "common:taskStateInProgress",
REVIEW: "common:taskStateReview",
BLOCKED: "common:taskStateBlocked",
WAITING_FOR_INPUT: "common:taskStateWaitingForInput",
COMPLETED: "common:taskStateCompleted",
FAILED: "common:taskStateFailed",
CANCELLED: "common:taskStateCancelled",
};
const TASK_SESSION_STATE_LABELS: Record<TaskSessionState, string> = {
CREATED: "Created",
IDLE: "Idle",
STARTING: "Starting",
RUNNING: "Running",
WAITING_FOR_INPUT: "Waiting for input",
COMPLETED: "Completed",
FAILED: "Failed",
CANCELLED: "Cancelled",
const TASK_SESSION_STATE_LABEL_KEYS: Record<TaskSessionState, string> = {
CREATED: "common:sessionStateCreated",
IDLE: "common:sessionStateIdle",
STARTING: "common:sessionStateStarting",
RUNNING: "common:sessionStateRunning",
WAITING_FOR_INPUT: "common:sessionStateWaitingForInput",
COMPLETED: "common:sessionStateCompleted",
FAILED: "common:sessionStateFailed",
CANCELLED: "common:sessionStateCancelled",
};
export function formatTaskStateLabel(state: TaskState | null | undefined): string {
if (!state) return "Not started";
return TASK_STATE_LABELS[state] ?? state;
if (!state) return t("common:taskStateNotStarted");
// A wire value this build does not know about echoes raw rather than
// rendering blank — the same fallback the English table had.
const key = TASK_STATE_LABEL_KEYS[state];
return key ? t(key) : state;
}
export function formatTaskSessionStateLabel(state: TaskSessionState | null | undefined): string {
if (!state) return "";
return TASK_SESSION_STATE_LABELS[state] ?? state;
const key = TASK_SESSION_STATE_LABEL_KEYS[state];
return key ? t(key) : state;
}
+80 -4
View File
@@ -7,6 +7,7 @@ import {
NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE,
} from "@/lib/notifications/events";
import type { BackendMessageMap } from "@/lib/types/backend";
import { i18n } from "@/lib/i18n";
import { registerNotificationsHandlers } from "./notifications";
vi.mock("@/lib/notifications/sound", () => ({
@@ -21,6 +22,8 @@ const OTHER_TASK_ID = "task-2";
const MESSAGE_TITLE = "Agent needs your answer";
const MESSAGE_BODY = 'The agent asked a question on "Task one".';
const notificationMock = vi.fn();
const TURN_FINISHED = NOTIFICATION_EVENT_SESSION_TURN_FINISHED;
const TURN_FINISHED_FALLBACK_TITLE = "Agent turn finished";
type SemanticNotificationMessage =
| BackendMessageMap["session.clarification_requested"]
| BackendMessageMap["session.turn_finished"]
@@ -286,12 +289,12 @@ describe("semantic session notification malformed payloads", () => {
getHandler(
makeStore(),
"session.turn_finished",
TURN_FINISHED,
)(
makeMessage(
"turn-1",
"session.turn_finished",
"Agent turn finished",
TURN_FINISHED,
TURN_FINISHED_FALLBACK_TITLE,
'The agent finished a turn on "Task one".',
),
);
@@ -299,7 +302,7 @@ describe("semantic session notification malformed payloads", () => {
expect(invoke).toHaveBeenCalledWith("show_native_notification", {
request: expect.objectContaining({
eventId: "session.turn_finished:turn-1",
title: "Agent turn finished",
title: TURN_FINISHED_FALLBACK_TITLE,
body: 'The agent finished a turn on "Task one".',
}),
});
@@ -340,3 +343,76 @@ describe("office inbox notification delivery", () => {
});
});
});
describe("empty-payload fallback copy", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal(
"Notification",
Object.assign(notificationMock, { permission: "granted" as NotificationPermission }),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
await i18n.changeLanguage("en");
});
function fireEmpty(action: "session.turn_finished" | "session.clarification_requested") {
const message = {
...makeMessage("empty-1", action, "", ""),
payload: {
task_id: TASK_ID,
session_id: SESSION_ID,
occurrence_id: "o-1",
title: "",
body: "",
},
} as SemanticNotificationMessage;
getHandler(makeStore(), action)(message);
}
it("renders the English fallback when the backend sends no title or body", () => {
fireEmpty(TURN_FINISHED);
expect(notificationMock).toHaveBeenCalledWith(TURN_FINISHED_FALLBACK_TITLE, {
body: "The agent finished a turn.",
});
notificationMock.mockClear();
fireEmpty("session.clarification_requested");
expect(notificationMock).toHaveBeenCalledWith("Agent needs your answer", {
body: "The agent asked a question.",
});
});
it("resolves the fallback per notification, not once at handler registration", async () => {
// Registering under `en` and firing under `pseudo` is the regression: the
// handlers are registered once when the WS client starts, so a fallback
// resolved at registration would stay English for the rest of the session.
const handler = getHandler(makeStore(), TURN_FINISHED);
await i18n.changeLanguage("pseudo");
handler({
...makeMessage("empty-2", TURN_FINISHED, "", ""),
payload: {
task_id: TASK_ID,
session_id: SESSION_ID,
occurrence_id: "o-2",
title: "",
body: "",
},
} as SemanticNotificationMessage);
const [title, options] = notificationMock.mock.calls[0] as [string, { body: string }];
expect(title).not.toBe(TURN_FINISHED_FALLBACK_TITLE);
expect(title).toMatch(/[^\p{ASCII}]/u);
expect(options.body).toMatch(/[^\p{ASCII}]/u);
});
it("prefers a backend-supplied title over the fallback", () => {
getHandler(
makeStore(),
TURN_FINISHED,
)(makeMessage("kept-1", TURN_FINISHED, "Custom title", "Custom body"));
expect(notificationMock).toHaveBeenCalledWith("Custom title", { body: "Custom body" });
});
});
+20 -9
View File
@@ -6,6 +6,9 @@ import {
NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE,
} from "@/lib/notifications/events";
import { playWaitingForInputSound } from "@/lib/notifications/sound";
// Module-level `t`, resolved at call time: these handlers are plain callbacks
// invoked by the WS client, not components, so there is no hook to bind.
import { t } from "@/lib/i18n";
import { nativeNotifications } from "@/lib/desktop/native-notification-client";
import type { AppState } from "@/lib/state/store";
import type {
@@ -39,10 +42,18 @@ function showDesktopNotification(payload: NotificationPayload): void {
new Notification(payload.title, { body: payload.body });
}
/**
* Catalog keys for the copy shown when the backend sends an empty title/body.
* Keys rather than resolved strings: the handlers are registered once when the
* WS client starts, so resolving here would pin the fallback to whichever locale
* was active at registration. `t` runs inside the handler, per notification.
*/
type NotificationFallbackKeys = { titleKey: string; bodyKey: string };
function registerNotificationHandler(
store: StoreApi<AppState>,
eventType: string,
fallback: Pick<NotificationPayload, "title" | "body">,
fallback: NotificationFallbackKeys,
) {
return (message: { id?: string; payload: NotificationPayload }) => {
const sessionId = message.payload?.session_id;
@@ -55,8 +66,8 @@ function registerNotificationHandler(
playWaitingForInputSound();
const payload = {
...message.payload,
title: message.payload.title || fallback.title,
body: message.payload.body || fallback.body,
title: message.payload.title || t(fallback.titleKey),
body: message.payload.body || t(fallback.bodyKey),
};
const occurrenceID =
message.id ??
@@ -86,24 +97,24 @@ export function registerNotificationsHandlers(store: StoreApi<AppState>): WsHand
store,
NOTIFICATION_EVENT_SESSION_TURN_FINISHED,
{
title: "Agent turn finished",
body: "The agent finished a turn.",
titleKey: "common:notificationTurnFinishedTitle",
bodyKey: "common:notificationTurnFinishedBody",
},
),
[NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED]: registerNotificationHandler(
store,
NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED,
{
title: "Agent needs your answer",
body: "The agent asked a question.",
titleKey: "common:notificationClarificationTitle",
bodyKey: "common:notificationClarificationBody",
},
),
[NOTIFICATION_EVENT_OFFICE_INBOX_ITEM]: registerNotificationHandler(
store,
NOTIFICATION_EVENT_OFFICE_INBOX_ITEM,
{
title: "New inbox item",
body: "A new item needs your attention.",
titleKey: "common:notificationInboxItemTitle",
bodyKey: "common:notificationInboxItemBody",
},
),
[NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE]: (message: { payload: UpdateAvailablePayload }) =>
+25
View File
@@ -183,6 +183,12 @@
"noFilesFound": "No files found.",
"noFoldersHerePickThisOne": "No folders here — pick this one or go up",
"noResults": "No results.",
"notificationClarificationBody": "The agent asked a question.",
"notificationClarificationTitle": "Agent needs your answer",
"notificationInboxItemBody": "A new item needs your attention.",
"notificationInboxItemTitle": "New inbox item",
"notificationTurnFinishedBody": "The agent finished a turn.",
"notificationTurnFinishedTitle": "Agent turn finished",
"notInstalled": "Not installed",
"notYetInstalled": "Not yet installed",
"officeIsNotEnabledForThis": "Office is not enabled for this runtime.",
@@ -277,6 +283,14 @@
"searchQueriesAreLimitedTo200": "Search queries are limited to 200 characters.",
"searchTaskContents": "Search task contents…",
"select": "Select",
"sessionStateCancelled": "Cancelled",
"sessionStateCompleted": "Completed",
"sessionStateCreated": "Created",
"sessionStateFailed": "Failed",
"sessionStateIdle": "Idle",
"sessionStateRunning": "Running",
"sessionStateStarting": "Starting",
"sessionStateWaitingForInput": "Waiting for input",
"settings": "Settings",
"shell": "Shell",
"shellCommandPlusSlashCommandSkills": "Shell command you can run in the secondary instance, plus slash-command <2>skills</2> you can ask the agent to run during the workflow.",
@@ -294,6 +308,17 @@
"todos": "Todos",
"taskLoading": "Loading task...",
"tasks": "Tasks",
"taskStateBlocked": "Blocked",
"taskStateCancelled": "Cancelled",
"taskStateCompleted": "Completed",
"taskStateCreated": "Created",
"taskStateFailed": "Failed",
"taskStateInProgress": "In progress",
"taskStateNotStarted": "Not started",
"taskStateReview": "Review",
"taskStateScheduling": "Scheduling",
"taskStateTodo": "To do",
"taskStateWaitingForInput": "Waiting for input",
"taskUnavailable": "Task unavailable",
"taskUnavailableDescription": "We could not load this task. It may have been deleted or you may not have access.",
"theAgentForksKandevToYourAccount": "The agent forks <1>{{repo}}</1> to your GitHub account and opens a PR from your fork, credited to you",
+3
View File
@@ -36,6 +36,9 @@
"expandGroup": "Expand {{label}}",
"expandSidebar": "Expand sidebar",
"filtersAndSort": "Filters and sort",
"groupAll": "All",
"groupMultiRepo": "Multi-repo",
"groupUnassigned": "Unassigned",
"home": "Home",
"improveKandev": "Improve Kandev",
"inbox": "Inbox",
+7
View File
@@ -197,6 +197,13 @@
"collapseSubtasks": "Collapse subtasks",
"collapseToolbar": "Collapse toolbar",
"color": "Color",
"colorBlue": "Blue",
"colorGreen": "Green",
"colorOrange": "Orange",
"colorPink": "Pink",
"colorPurple": "Purple",
"colorRed": "Red",
"colorYellow": "Yellow",
"command": "Command",
"commandCopied": "Command copied",
"commandFailed": "Command failed",
+25
View File
@@ -183,6 +183,12 @@
"noFilesFound": "Ńō ƒĩĺēś ƒōũńď.",
"noFoldersHerePickThisOne": "Ńō ƒōĺďēŕś ĥēŕē — ƥĩćķ ţĥĩś ōńē ōŕ ĝō ũƥ",
"noResults": "Ńō ŕēśũĺţś.",
"notificationClarificationBody": "Ţĥē àĝēńţ àśķēď à qũēśţĩōń.",
"notificationClarificationTitle": "Àĝēńţ ńēēďś ŷōũŕ àńśŵēŕ",
"notificationInboxItemBody": "À ńēŵ ĩţēḿ ńēēďś ŷōũŕ àţţēńţĩōń.",
"notificationInboxItemTitle": "Ńēŵ ĩńƀōx ĩţēḿ",
"notificationTurnFinishedBody": "Ţĥē àĝēńţ ƒĩńĩśĥēď à ţũŕń.",
"notificationTurnFinishedTitle": "Àĝēńţ ţũŕń ƒĩńĩśĥēď",
"notInstalled": "Ńōţ ĩńśţàĺĺēď",
"notYetInstalled": "Ńōţ ŷēţ ĩńśţàĺĺēď",
"officeIsNotEnabledForThis": "Ōƒƒĩćē ĩś ńōţ ēńàƀĺēď ƒōŕ ţĥĩś ŕũńţĩḿē.",
@@ -277,6 +283,14 @@
"searchQueriesAreLimitedTo200": "Śēàŕćĥ qũēŕĩēś àŕē ĺĩḿĩţēď ţō 200 ćĥàŕàćţēŕś.",
"searchTaskContents": "Śēàŕćĥ ţàśķ ćōńţēńţś…",
"select": "Śēĺēćţ",
"sessionStateCancelled": "Ćàńćēĺĺēď",
"sessionStateCompleted": "Ćōḿƥĺēţēď",
"sessionStateCreated": "Ćŕēàţēď",
"sessionStateFailed": "Ƒàĩĺēď",
"sessionStateIdle": "Ĩďĺē",
"sessionStateRunning": "Ŕũńńĩńĝ",
"sessionStateStarting": "Śţàŕţĩńĝ",
"sessionStateWaitingForInput": "Ŵàĩţĩńĝ ƒōŕ ĩńƥũţ",
"settings": "Śēţţĩńĝś",
"shell": "Śĥēĺĺ",
"shellCommandPlusSlashCommandSkills": "Śĥēĺĺ ćōḿḿàńď ŷōũ ćàń ŕũń ĩń ţĥē śēćōńďàŕŷ ĩńśţàńćē, ƥĺũś śĺàśĥ-ćōḿḿàńď <2>śķĩĺĺś</2> ŷōũ ćàń àśķ ţĥē àĝēńţ ţō ŕũń ďũŕĩńĝ ţĥē ŵōŕķƒĺōŵ.",
@@ -294,6 +308,17 @@
"todos": "Ţōďōś",
"taskLoading": "Ĺōàďĩńĝ ţàśķ...",
"tasks": "Ţàśķś",
"taskStateBlocked": "Ɓĺōćķēď",
"taskStateCancelled": "Ćàńćēĺĺēď",
"taskStateCompleted": "Ćōḿƥĺēţēď",
"taskStateCreated": "Ćŕēàţēď",
"taskStateFailed": "Ƒàĩĺēď",
"taskStateInProgress": "Ĩń ƥŕōĝŕēśś",
"taskStateNotStarted": "Ńōţ śţàŕţēď",
"taskStateReview": "Ŕēvĩēŵ",
"taskStateScheduling": "Śćĥēďũĺĩńĝ",
"taskStateTodo": "Ţō ďō",
"taskStateWaitingForInput": "Ŵàĩţĩńĝ ƒōŕ ĩńƥũţ",
"taskUnavailable": "Ţàśķ ũńàvàĩĺàƀĺē",
"taskUnavailableDescription": "Ŵē ćōũĺď ńōţ ĺōàď ţĥĩś ţàśķ. Ĩţ ḿàŷ ĥàvē ƀēēń ďēĺēţēď ōŕ ŷōũ ḿàŷ ńōţ ĥàvē àććēśś.",
"theAgentForksKandevToYourAccount": "Ţĥē àĝēńţ ƒōŕķś <1>{{repo}}</1> ţō ŷōũŕ ĜĩţĤũƀ àććōũńţ àńď ōƥēńś à ƤŔ ƒŕōḿ ŷōũŕ ƒōŕķ, ćŕēďĩţēď ţō ŷōũ",
+3
View File
@@ -36,6 +36,9 @@
"expandGroup": "Ēxƥàńď {{label}}",
"expandSidebar": "Ēxƥàńď śĩďēƀàŕ",
"filtersAndSort": "Ƒĩĺţēŕś àńď śōŕţ",
"groupAll": "Àĺĺ",
"groupMultiRepo": "Ḿũĺţĩ-ŕēƥō",
"groupUnassigned": "Ũńàśśĩĝńēď",
"home": "Ĥōḿē",
"improveKandev": "Ĩḿƥŕōvē Ķàńďēv",
"inbox": "Ĩńƀōx",
+7
View File
@@ -197,6 +197,13 @@
"collapseSubtasks": "Ćōĺĺàƥśē śũƀţàśķś",
"collapseToolbar": "Ćōĺĺàƥśē ţōōĺƀàŕ",
"color": "Ćōĺōŕ",
"colorBlue": "Ɓĺũē",
"colorGreen": "Ĝŕēēń",
"colorOrange": "Ōŕàńĝē",
"colorPink": "Ƥĩńķ",
"colorPurple": "Ƥũŕƥĺē",
"colorRed": "Ŕēď",
"colorYellow": "Ŷēĺĺōŵ",
"command": "Ćōḿḿàńď",
"commandCopied": "Ćōḿḿàńď ćōƥĩēď",
"commandFailed": "Ćōḿḿàńď ƒàĩĺēď",