diff --git a/apps/web/app/tasks/tasks-list-view.tsx b/apps/web/app/tasks/tasks-list-view.tsx index 467254e594..6517fdb3ec 100644 --- a/apps/web/app/tasks/tasks-list-view.tsx +++ b/apps/web/app/tasks/tasks-list-view.tsx @@ -143,12 +143,16 @@ function TaskRows({ onDelete: (taskId: string, opts?: { cascade?: boolean }) => Promise; 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) { diff --git a/apps/web/components/task/task-switcher-color-menu.tsx b/apps/web/components/task/task-switcher-color-menu.tsx index 4eea2431ea..2875985a2e 100644 --- a/apps/web/components/task/task-switcher-color-menu.tsx +++ b/apps/web/components/task/task-switcher-color-menu.tsx @@ -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 ( - {TASK_COLOR_LABEL[color]} + {t(TASK_COLOR_LABEL_KEYS[color])} {selected && } ); diff --git a/apps/web/e2e/tests/task/sidebar-layout.spec.ts b/apps/web/e2e/tests/task/sidebar-layout.spec.ts index ec3b4d4d9e..7dbaf06e13 100644 --- a/apps/web/e2e/tests/task/sidebar-layout.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-layout.spec.ts @@ -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 }); diff --git a/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts b/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts index 8e51c5237b..40d481813f 100644 --- a/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts +++ b/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts @@ -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 }); diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs index a001d02d64..0baaed0eea 100644 --- a/apps/web/eslint.i18n.options.mjs +++ b/apps/web/eslint.i18n.options.mjs @@ -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)." diff --git a/apps/web/lib/notifications/events.ts b/apps/web/lib/notifications/events.ts index 26d3cb241b..50896923a0 100644 --- a/apps/web/lib/notifications/events.ts +++ b/apps/web/lib/notifications/events.ts @@ -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 = { - [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.", - }, -}; diff --git a/apps/web/lib/sidebar/apply-view-labels.test.ts b/apps/web/lib/sidebar/apply-view-labels.test.ts new file mode 100644 index 0000000000..4ddfed1bbb --- /dev/null +++ b/apps/web/lib/sidebar/apply-view-labels.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/web/lib/sidebar/apply-view.ts b/apps/web/lib/sidebar/apply-view.ts index a36f9f0428..7570a5d853 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -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 = { @@ -255,28 +269,33 @@ function getEffectiveStateGroup( } const groupExtractors: Record, 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, }; } diff --git a/apps/web/lib/task-colors.test.ts b/apps/web/lib/task-colors.test.ts index ea064f3ce0..8dc3e9d652 100644 --- a/apps/web/lib/task-colors.test.ts +++ b/apps/web/lib/task-colors.test.ts @@ -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); + } + }); +}); diff --git a/apps/web/lib/task-colors.ts b/apps/web/lib/task-colors.ts index ad8aa95755..9304d006e1 100644 --- a/apps/web/lib/task-colors.ts +++ b/apps/web/lib/task-colors.ts @@ -14,14 +14,21 @@ export const TASK_COLOR_BAR_CLASS: Record = { pink: "bg-pink-500", }; -export const TASK_COLOR_LABEL: Record = { - 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 = { + 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"; diff --git a/apps/web/lib/ui/state-labels.test.ts b/apps/web/lib/ui/state-labels.test.ts index 503b30824a..857013f8f0 100644 --- a/apps/web/lib/ui/state-labels.test.ts +++ b/apps/web/lib/ui/state-labels.test.ts @@ -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"); + }); }); diff --git a/apps/web/lib/ui/state-labels.ts b/apps/web/lib/ui/state-labels.ts index f694a1b021..f7d747dc93 100644 --- a/apps/web/lib/ui/state-labels.ts +++ b/apps/web/lib/ui/state-labels.ts @@ -1,35 +1,58 @@ +import { t } from "@/lib/i18n"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; -const TASK_STATE_LABELS: Record = { - 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 = { + 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 = { - 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 = { + 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; } diff --git a/apps/web/lib/ws/handlers/notifications.test.ts b/apps/web/lib/ws/handlers/notifications.test.ts index f4cf81b2b1..c3fc1a548f 100644 --- a/apps/web/lib/ws/handlers/notifications.test.ts +++ b/apps/web/lib/ws/handlers/notifications.test.ts @@ -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" }); + }); +}); diff --git a/apps/web/lib/ws/handlers/notifications.ts b/apps/web/lib/ws/handlers/notifications.ts index fddaa91c9f..1ab6cd49e7 100644 --- a/apps/web/lib/ws/handlers/notifications.ts +++ b/apps/web/lib/ws/handlers/notifications.ts @@ -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, eventType: string, - fallback: Pick, + 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): 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 }) => diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index f92ac0a61e..ff31a0335d 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -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 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}} to your GitHub account and opens a PR from your fork, credited to you", diff --git a/apps/web/src/locales/en/sidebar.json b/apps/web/src/locales/en/sidebar.json index 19cd62b742..5f05b592cf 100644 --- a/apps/web/src/locales/en/sidebar.json +++ b/apps/web/src/locales/en/sidebar.json @@ -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", diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 69f45e5eaf..b30465127f 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -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", diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json index 286f40d1a8..f41dad6dc4 100644 --- a/apps/web/src/locales/pseudo/common.json +++ b/apps/web/src/locales/pseudo/common.json @@ -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>śķĩĺĺś ŷōũ ćàń àśķ ţĥē àĝēńţ ţō ŕũń ďũŕĩńĝ ţĥē ŵōŕķƒĺōŵ.", @@ -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}} ţō ŷōũŕ ĜĩţĤũƀ àććōũńţ àńď ōƥēńś à ƤŔ ƒŕōḿ ŷōũŕ ƒōŕķ, ćŕēďĩţēď ţō ŷōũ", diff --git a/apps/web/src/locales/pseudo/sidebar.json b/apps/web/src/locales/pseudo/sidebar.json index 8ebd505afa..73f46b2263 100644 --- a/apps/web/src/locales/pseudo/sidebar.json +++ b/apps/web/src/locales/pseudo/sidebar.json @@ -36,6 +36,9 @@ "expandGroup": "Ēxƥàńď {{label}}", "expandSidebar": "Ēxƥàńď śĩďēƀàŕ", "filtersAndSort": "Ƒĩĺţēŕś àńď śōŕţ", + "groupAll": "Àĺĺ", + "groupMultiRepo": "Ḿũĺţĩ-ŕēƥō", + "groupUnassigned": "Ũńàśśĩĝńēď", "home": "Ĥōḿē", "improveKandev": "Ĩḿƥŕōvē Ķàńďēv", "inbox": "Ĩńƀōx", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 5b6d1feb23..cb1a7fce54 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -197,6 +197,13 @@ "collapseSubtasks": "Ćōĺĺàƥśē śũƀţàśķś", "collapseToolbar": "Ćōĺĺàƥśē ţōōĺƀàŕ", "color": "Ćōĺōŕ", + "colorBlue": "Ɓĺũē", + "colorGreen": "Ĝŕēēń", + "colorOrange": "Ōŕàńĝē", + "colorPink": "Ƥĩńķ", + "colorPurple": "Ƥũŕƥĺē", + "colorRed": "Ŕēď", + "colorYellow": "Ŷēĺĺōŵ", "command": "Ćōḿḿàńď", "commandCopied": "Ćōḿḿàńď ćōƥĩēď", "commandFailed": "Ćōḿḿàńď ƒàĩĺēď",