From 97fba4ec4b409fb64e6dcf0a6a9bca2a7d9804b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 7 Aug 2026 20:03:10 +0200 Subject: [PATCH] fix(i18n): close four gaps no gate can see (plurals, fallback copy, orphans) (#2388) Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 5 (1M context) --- .../azure-devops-task-launcher.test.tsx | 32 ++++++++++ .../azure-devops-task-launcher.tsx | 8 ++- apps/web/components/kanban-card-content.tsx | 2 +- .../kandev/document-renderers.test.tsx | 38 ++++++++++++ .../messages/kandev/document-renderers.tsx | 13 ++-- .../task/chat/messages/monitor-message.tsx | 4 +- .../chat/messages/tool-edit-message.test.tsx | 59 +++++++++++++++++++ .../task/chat/messages/tool-edit-message.tsx | 7 ++- .../chat/messages/tool-read-message.test.tsx | 34 +++++++++++ .../task/chat/messages/tool-read-message.tsx | 9 +-- .../messages/tool-search-message.test.tsx | 47 +++++++++++++++ .../chat/messages/tool-search-message.tsx | 10 ++-- .../task/chat/messages/turn-group-message.tsx | 2 +- .../task/chat/use-chat-input-container.ts | 2 +- apps/web/components/task/task-switcher.tsx | 2 +- .../hooks/use-sidebar-multi-select.test.ts | 40 ++++++++++++- apps/web/hooks/use-sidebar-multi-select.ts | 12 ++-- apps/web/src/locales/en/azuredevops.json | 1 + apps/web/src/locales/en/chat.json | 2 - apps/web/src/locales/en/sidebar.json | 4 ++ apps/web/src/locales/en/task.json | 13 ++++ apps/web/src/locales/pseudo/azuredevops.json | 1 + apps/web/src/locales/pseudo/chat.json | 2 - apps/web/src/locales/pseudo/sidebar.json | 4 ++ apps/web/src/locales/pseudo/task.json | 13 ++++ 25 files changed, 327 insertions(+), 34 deletions(-) create mode 100644 apps/web/components/task/chat/messages/kandev/document-renderers.test.tsx create mode 100644 apps/web/components/task/chat/messages/tool-edit-message.test.tsx create mode 100644 apps/web/components/task/chat/messages/tool-search-message.test.tsx diff --git a/apps/web/components/azure-devops/azure-devops-task-launcher.test.tsx b/apps/web/components/azure-devops/azure-devops-task-launcher.test.tsx index 262361bed4..efdc63fc42 100644 --- a/apps/web/components/azure-devops/azure-devops-task-launcher.test.tsx +++ b/apps/web/components/azure-devops/azure-devops-task-launcher.test.tsx @@ -224,6 +224,38 @@ it("uses the generated description when an action prompt is blank", () => { expect(screen.getByTestId("task-description").textContent).toContain( "Azure DevOps work item: 73", ); + // The fallback is UI copy the user reads (and can edit) in the create dialog, + // so it resolves through the catalog rather than a bare literal. The English + // is unchanged. + expect(screen.getByTestId("task-description").textContent).toContain("(no description)"); +}); + +it("strips markup from a work-item description instead of using the fallback", () => { + render( + Real description", + }, + }} + onClose={mocks.close} + />, + ); + + const text = screen.getByTestId("task-description").textContent ?? ""; + expect(text).toContain("Real description"); + expect(text).not.toContain("(no description)"); }); it("reports a missing work-item project after creating the task", async () => { diff --git a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx index 1203aa264c..595a464c87 100644 --- a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx +++ b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx @@ -18,15 +18,19 @@ import type { } from "@/lib/types/azure-devops"; import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http"; import { truncateRemoteTaskTitle } from "@/lib/task-title"; +import { t } from "@/lib/i18n"; export type AzureDevOpsLaunchPayload = | { kind: "work-item"; item: AzureDevOpsWorkItem; action?: AzureDevOpsActionPreset } | { kind: "pull-request"; pullRequest: AzureDevOpsPullRequest; action?: AzureDevOpsActionPreset }; function plainText(value: string | undefined): string { - if (!value) return "(no description)"; + // Resolved per call, not at module load, so the fallback follows the active + // locale. `launchText` runs inside the launcher's `useMemo`, i.e. at render. + const fallback = t("azuredevops:descriptionFallback"); + if (!value) return fallback; const document = new DOMParser().parseFromString(value, "text/html"); - return document.body.textContent?.trim() || "(no description)"; + return document.body.textContent?.trim() || fallback; } function launchText(payload: AzureDevOpsLaunchPayload) { diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index f21ccffe35..fc19e09cf9 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -432,7 +432,7 @@ function KanbanCardActions({
{renderSubagentCountChip( task, - t("activeSubagents", { count: task.activeSubagentCount ?? 0 }), + t("common:activeSubagents", { count: task.activeSubagentCount ?? 0 }), )} {statusIcon} {showMaximizeButton && onOpenFullPage && hasKnownSession && ( diff --git a/apps/web/components/task/chat/messages/kandev/document-renderers.test.tsx b/apps/web/components/task/chat/messages/kandev/document-renderers.test.tsx new file mode 100644 index 0000000000..ba16656655 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/document-renderers.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GetTaskPlanRenderer, UpdateTaskPlanRenderer } from "./document-renderers"; + +function plan(content: string | undefined) { + return renderToStaticMarkup( + , + ); +} + +function updatedPlan(content: string | undefined) { + return renderToStaticMarkup( + , + ); +} + +// The plan/document summary carries two counts. i18next selects a plural form on +// `count` alone, so lines own `count` and the character total rides along as a +// plain interpolated value. English must stay byte-identical. +describe("document summary", () => { + it("uses the singular line label for single-line content", () => { + expect(plan("one line")).toContain("1 line · 8 chars"); + }); + + it("uses the plural line label for multi-line content", () => { + expect(plan("a\nb\nc")).toContain("3 lines · 5 chars"); + }); + + it("keeps 'chars' out of the plural decision", () => { + // A single character with several lines must still pluralise on lines only, + // matching the pre-i18n string exactly. + expect(plan("\n\n")).toContain("3 lines · 2 chars"); + }); + + it("falls back to the empty label when there is no content", () => { + expect(updatedPlan(undefined)).toContain("empty"); + }); +}); diff --git a/apps/web/components/task/chat/messages/kandev/document-renderers.tsx b/apps/web/components/task/chat/messages/kandev/document-renderers.tsx index 0dc66ca6e0..1c2c307f02 100644 --- a/apps/web/components/task/chat/messages/kandev/document-renderers.tsx +++ b/apps/web/components/task/chat/messages/kandev/document-renderers.tsx @@ -17,6 +17,7 @@ import { EmptyListNote, IdChip, KandevBody, KandevRow, KeyValueRow, SummaryDot } import { pickArray, pickNumber, pickString } from "./parse"; import type { KandevRenderer } from "./types"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; // MarkdownBody renders task plan / document content. We pre-trim and use the // shared markdown component set so heading sizes, code blocks, and mermaid @@ -43,11 +44,13 @@ function ContentBox({ children }: { children: ReactNode }) { ); } -function summarizeContent(content: string | undefined): string { - if (!content) return "empty"; +// Two counts in one label, but i18next selects a plural form on `count` alone. +// Lines own `count`; the character total rides along as a plain value. +function summarizeContent(t: TFunction, content: string | undefined): string { + if (!content) return t("task:documentSummaryEmpty"); const lines = content.split("\n").length; const chars = content.length; - return `${lines} line${lines === 1 ? "" : "s"} · ${chars} chars`; + return t("task:documentSummary", { count: lines, chars }); } // ---------- get_task_plan ---------- @@ -70,7 +73,7 @@ export const GetTaskPlanRenderer: KandevRenderer = ({ args, result, status }) => )} - {hasPlan ? summarizeContent(content) : t("task:noPlan")} + {hasPlan ? summarizeContent(t, content) : t("task:noPlan")} } status={status} @@ -153,7 +156,7 @@ export const UpdateTaskPlanRenderer: KandevRenderer = ({ args, result, status }) )} - {summarizeContent(displayContent)} + {summarizeContent(t, displayContent)} } status={status} diff --git a/apps/web/components/task/chat/messages/monitor-message.tsx b/apps/web/components/task/chat/messages/monitor-message.tsx index 4b4fedf71a..8b1267db40 100644 --- a/apps/web/components/task/chat/messages/monitor-message.tsx +++ b/apps/web/components/task/chat/messages/monitor-message.tsx @@ -46,7 +46,9 @@ type MonitorViewModel = { function pluralizeEventCount(n: number): string { if (n <= 0) return ""; - return ` · ${n} event${n === 1 ? "" : "s"}`; + // The leading separator stays in code — it is layout punctuation between the + // title and the count, not copy a translator should have to reproduce. + return ` · ${t("task:eventCount", { count: n })}`; } function buildMonitorViewModel(comment: Message): MonitorViewModel { diff --git a/apps/web/components/task/chat/messages/tool-edit-message.test.tsx b/apps/web/components/task/chat/messages/tool-edit-message.test.tsx new file mode 100644 index 0000000000..3799eb8909 --- /dev/null +++ b/apps/web/components/task/chat/messages/tool-edit-message.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Message } from "@/lib/types/http"; +import { ToolEditMessage } from "./tool-edit-message"; + +function writeComment(content: string, body: string): Message { + return { + id: "m1", + content, + metadata: { + status: "complete", + normalized: { + modify_file: { + file_path: "apps/web/lib/utils.ts", + mutations: [{ type: "create", content: body, start_line: 1 }], + }, + }, + }, + } as unknown as Message; +} + +// The write-card header used to append "(N line(s))" with a template-literal +// plural. It now selects _one/_other, with the path kept out of the plural +// decision and passed through as an interpolated value. +describe("ToolEditMessage write summary", () => { + it("uses the singular line suffix for a one-line write", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Write utils.ts (1 line)"); + expect(html).not.toContain("(1 lines)"); + }); + + it("uses the plural line suffix for a multi-line write", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Write utils.ts (3 lines)"); + }); + + it("leaves the summary untouched for an edit that is not a write", () => { + const comment = { + id: "m1", + content: "Edit utils.ts", + metadata: { + status: "complete", + normalized: { + modify_file: { + file_path: "apps/web/lib/utils.ts", + mutations: [{ type: "edit", old_string: "a", new_string: "b", start_line: 1 }], + }, + }, + }, + } as unknown as Message; + const html = renderToStaticMarkup(); + expect(html).toContain("Edit utils.ts"); + expect(html).not.toContain("line)"); + }); +}); diff --git a/apps/web/components/task/chat/messages/tool-edit-message.tsx b/apps/web/components/task/chat/messages/tool-edit-message.tsx index 28c42e68c5..2effbb671a 100644 --- a/apps/web/components/task/chat/messages/tool-edit-message.tsx +++ b/apps/web/components/task/chat/messages/tool-edit-message.tsx @@ -19,6 +19,7 @@ import { transformFileMutation, type FileMutation } from "@/lib/diff"; import { useExpandState } from "./use-expand-state"; import { useOpenFileAtLine } from "@/hooks/use-file-editors"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; type ModifyFilePayload = { file_path?: string; @@ -48,6 +49,7 @@ function EditStatusIcon({ status }: { status: string | undefined }) { // getEditSummary returns the short header label for an edit/write card. function getEditSummary( + t: TFunction, content: string, worktreePath: string | undefined, isWriteOperation: boolean, @@ -55,7 +57,7 @@ function getEditSummary( ): string { const baseSummary = transformPathsInText(content, worktreePath); if (isWriteOperation && lineCount > 0) { - return `${baseSummary} (${lineCount} line${lineCount !== 1 ? "s" : ""})`; + return t("task:editSummaryLines", { summary: baseSummary, count: lineCount }); } return baseSummary; } @@ -166,6 +168,7 @@ export const ToolEditMessage = memo(function ToolEditMessage({ sessionId, onOpenFile, }: ToolEditMessageProps) { + const { t } = useTranslation(); const { status, filePath, @@ -181,7 +184,7 @@ export const ToolEditMessage = memo(function ToolEditMessage({ const autoExpanded = status === "running"; const { isExpanded, handleToggle } = useExpandState(status, autoExpanded); const Icon = isWriteOperation ? IconFilePlus : IconEdit; - const summary = getEditSummary(comment.content, worktreePath, isWriteOperation, lineCount); + const summary = getEditSummary(t, comment.content, worktreePath, isWriteOperation, lineCount); const handleCopyPath = (e: React.MouseEvent) => { e.stopPropagation(); diff --git a/apps/web/components/task/chat/messages/tool-read-message.test.tsx b/apps/web/components/task/chat/messages/tool-read-message.test.tsx index 38416e9e9d..f98b7de849 100644 --- a/apps/web/components/task/chat/messages/tool-read-message.test.tsx +++ b/apps/web/components/task/chat/messages/tool-read-message.test.tsx @@ -24,12 +24,46 @@ function readComment(filePath: string, offset?: number, limit?: number): Message } as unknown as Message; } +function readCommentWithLines(lineCount?: number): Message { + return { + id: "m1", + metadata: { + status: "complete", + normalized: { + read_file: { + file_path: "apps/web/lib/read-selector.ts", + output: { line_count: lineCount }, + }, + }, + }, + } as unknown as Message; +} + describe("ToolReadMessage", () => { afterEach(() => { cleanup(); useOpenFileAtLine.mockClear(); }); + // The header label used to build its own "s" in a template literal, which no + // i18n gate could see. It now selects _one/_other; the English must be + // unchanged, because e2e specs query this card by its accessible name. + it("uses the singular header label for a one-line read", () => { + render(); + expect(screen.getByText("Read 1 line")).toBeTruthy(); + expect(screen.queryByText("Read 1 lines")).toBeNull(); + }); + + it("uses the plural header label for a multi-line read", () => { + render(); + expect(screen.getByText("Read 137 lines")).toBeTruthy(); + }); + + it("falls back to the bare 'Read' label when no line count is reported", () => { + render(); + expect(screen.getByText("Read")).toBeTruthy(); + }); + it("forwards the message session when opening a file at its read line", () => { const openFile = vi.fn(); render( diff --git a/apps/web/components/task/chat/messages/tool-read-message.tsx b/apps/web/components/task/chat/messages/tool-read-message.tsx index 35ddd6bf72..9886976ca8 100644 --- a/apps/web/components/task/chat/messages/tool-read-message.tsx +++ b/apps/web/components/task/chat/messages/tool-read-message.tsx @@ -10,6 +10,7 @@ import { useExpandState } from "./use-expand-state"; import { useOpenFileAtLine } from "@/hooks/use-file-editors"; import { splitReadFiles, type ReadFileRef } from "@/lib/read-selector"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; type ReadFileOutput = { content?: string; @@ -48,9 +49,9 @@ function ReadStatusIcon({ status }: { status: string | undefined }) { } // getReadSummary returns the short "Read N lines" header label for the card. -function getReadSummary(lineCount: number | undefined): string { - if (lineCount) return `Read ${lineCount} line${lineCount !== 1 ? "s" : ""}`; - return "Read"; +function getReadSummary(t: TFunction, lineCount: number | undefined): string { + if (lineCount) return t("task:readLines", { count: lineCount }); + return t("task:read"); } // formatLineRange renders the range the agent read (carried separately from @@ -144,7 +145,7 @@ export const ToolReadMessage = memo(function ToolReadMessage({
- {getReadSummary(readOutput?.line_count)} + {getReadSummary(t, readOutput?.line_count)} {!isSuccess && } diff --git a/apps/web/components/task/chat/messages/tool-search-message.test.tsx b/apps/web/components/task/chat/messages/tool-search-message.test.tsx new file mode 100644 index 0000000000..2e36633b0e --- /dev/null +++ b/apps/web/components/task/chat/messages/tool-search-message.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Message } from "@/lib/types/http"; +import { ToolSearchMessage } from "./tool-search-message"; + +function searchComment(files?: string[], fileCount?: number): Message { + return { + id: "m1", + metadata: { + status: "complete", + normalized: { + code_search: { pattern: "useTranslation", output: { files, file_count: fileCount } }, + }, + }, + } as unknown as Message; +} + +// The header label used to build its own "s" in a template literal, which the +// jsx-only lint rule and the inline-plural check both miss. It now selects +// _one/_other, and the English has to stay byte-identical. +describe("ToolSearchMessage", () => { + it("uses the singular header label for a single hit", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Found 1 file"); + expect(html).not.toContain("Found 1 files"); + }); + + it("uses the plural header label for several hits", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Found 3 files"); + }); + + it("prefers the reported file_count over the returned file list length", () => { + // The backend truncates `files` but keeps the true total in `file_count`, + // so the label must plural-select on the count it actually renders. + const html = renderToStaticMarkup(); + expect(html).toContain("Found 12 files"); + }); + + it("falls back to the searching label before any result arrives", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Searching"); + expect(html).not.toContain("Found"); + }); +}); diff --git a/apps/web/components/task/chat/messages/tool-search-message.tsx b/apps/web/components/task/chat/messages/tool-search-message.tsx index 5629173fce..e6b1358cb1 100644 --- a/apps/web/components/task/chat/messages/tool-search-message.tsx +++ b/apps/web/components/task/chat/messages/tool-search-message.tsx @@ -9,6 +9,7 @@ import type { Message } from "@/lib/types/http"; import { ExpandableRow } from "./expandable-row"; import { useExpandState } from "./use-expand-state"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; type CodeSearchOutput = { files?: string[]; @@ -44,12 +45,12 @@ function SearchStatusIcon({ status }: { status: string | undefined }) { return null; } -function getSearchSummary(searchOutput: CodeSearchOutput | undefined): string { +function getSearchSummary(t: TFunction, searchOutput: CodeSearchOutput | undefined): string { if (searchOutput?.files && searchOutput.files.length > 0) { const count = searchOutput.file_count || searchOutput.files.length; - return `Found ${count} file${count !== 1 ? "s" : ""}`; + return t("task:foundFiles", { count }); } - return "Searching"; + return t("task:toolSearching"); } type SearchResultsProps = { @@ -98,6 +99,7 @@ export const ToolSearchMessage = memo(function ToolSearchMessage({ worktreePath, onOpenFile, }: ToolSearchMessageProps) { + const { t } = useTranslation(); const { status, searchOutput, searchPath, searchPattern, hasOutput, isSuccess } = parseSearchMetadata(comment); const autoExpanded = status === "running"; @@ -110,7 +112,7 @@ export const ToolSearchMessage = memo(function ToolSearchMessage({
- {getSearchSummary(searchOutput)} + {getSearchSummary(t, searchOutput)} {!isSuccess && } diff --git a/apps/web/components/task/chat/messages/turn-group-message.tsx b/apps/web/components/task/chat/messages/turn-group-message.tsx index 15b2c535c3..9ed0b855d8 100644 --- a/apps/web/components/task/chat/messages/turn-group-message.tsx +++ b/apps/web/components/task/chat/messages/turn-group-message.tsx @@ -370,7 +370,7 @@ export const TurnGroupMessage = memo(function TurnGroupMessage({ // calls and nothing else. const rawDescription = isGroupRunning ? getActiveGroupDescription(group.messages) - : t("turnGroupToolCalls", { count: group.messages.length }); + : t("chat:turnGroupToolCalls", { count: group.messages.length }); const description = transformPathsInText(rawDescription, worktreePath); const count = group.messages.length; diff --git a/apps/web/components/task/chat/use-chat-input-container.ts b/apps/web/components/task/chat/use-chat-input-container.ts index 63556e59c8..37e24c2106 100644 --- a/apps/web/components/task/chat/use-chat-input-container.ts +++ b/apps/web/components/task/chat/use-chat-input-container.ts @@ -266,7 +266,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { placeholder, isAgentBusy, hasAgentCommands, - steerPlaceholder: supportsSteering ? t("composerSteerPlaceholder") : undefined, + steerPlaceholder: supportsSteering ? t("chat:composerSteerPlaceholder") : undefined, }); return { diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 06402ff620..8f6652548e 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -117,7 +117,7 @@ export const TaskSwitcher = memo(function TaskSwitcher(props: TaskSwitcherProps) return ( <> {loadErrorNotice} -
{t("noTasksYet")}
+
{t("sidebar:noTasksYet")}
); } diff --git a/apps/web/hooks/use-sidebar-multi-select.test.ts b/apps/web/hooks/use-sidebar-multi-select.test.ts index 80c55acd67..f9f80fbb25 100644 --- a/apps/web/hooks/use-sidebar-multi-select.test.ts +++ b/apps/web/hooks/use-sidebar-multi-select.test.ts @@ -99,7 +99,7 @@ describe("useSidebarMultiSelect", () => { }); }); -describe("useSidebarMultiSelect — bulk actions", () => { +describe("useSidebarMultiSelect — bulk archive", () => { it("bulkArchive removes all on full success and clears the selection", async () => { const { result } = renderHook(() => useSidebarMultiSelect("ws1")); await act(async () => { @@ -121,7 +121,24 @@ describe("useSidebarMultiSelect — bulk actions", () => { }); expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a"])); expect(result.current.selectedIds).toEqual(new Set(["b"])); - expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" })); + // The title used to build its own "s" in a template literal. It now resolves + // through _one/_other, and the English must be unchanged. + expect(toast).toHaveBeenCalledWith({ + title: "Failed to archive 1 task", + variant: "error", + }); + }); + + it("bulkArchive pluralises the failure toast when several tasks fail", async () => { + archiveTaskById.mockImplementation(() => Promise.reject(new Error("nope"))); + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + await act(async () => { + await result.current.bulkArchive(["a", "b"]); + }); + expect(toast).toHaveBeenCalledWith({ + title: "Failed to archive 2 tasks", + variant: "error", + }); }); it("bulkArchive routes the active task through the switch-aware path", async () => { @@ -143,7 +160,9 @@ describe("useSidebarMultiSelect — bulk actions", () => { }); expect(archiveTaskById).not.toHaveBeenCalled(); }); +}); +describe("useSidebarMultiSelect — bulk delete", () => { it("bulkDelete removes all on full success and clears the selection", async () => { const { result } = renderHook(() => useSidebarMultiSelect("ws1")); await act(async () => { @@ -164,7 +183,22 @@ describe("useSidebarMultiSelect — bulk actions", () => { await result.current.bulkDelete(["a", "b"]); }); expect(result.current.selectedIds).toEqual(new Set(["b"])); - expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" })); + expect(toast).toHaveBeenCalledWith({ + title: "Failed to delete 1 task", + variant: "error", + }); + }); + + it("bulkDelete pluralises the failure toast when several tasks fail", async () => { + deleteTaskById.mockImplementation(() => Promise.reject(new Error("nope"))); + const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + await act(async () => { + await result.current.bulkDelete(["a", "b"]); + }); + expect(toast).toHaveBeenCalledWith({ + title: "Failed to delete 2 tasks", + variant: "error", + }); }); it("bulkDelete routes the active task through the switch-aware removal", async () => { diff --git a/apps/web/hooks/use-sidebar-multi-select.ts b/apps/web/hooks/use-sidebar-multi-select.ts index 3a08611b26..afb4a728b5 100644 --- a/apps/web/hooks/use-sidebar-multi-select.ts +++ b/apps/web/hooks/use-sidebar-multi-select.ts @@ -11,6 +11,7 @@ import { useTaskRemoval } from "./use-task-removal"; import { useTaskWorkflowMove } from "./use-task-workflow-move"; import { useToast } from "@/components/toast-provider"; import { useAppStoreApi } from "@/components/state-provider"; +import { useTranslation } from "react-i18next"; type BulkOpts = { cascade?: boolean }; @@ -31,6 +32,7 @@ function useSidebarBulkActions( const { getWorkflowIdForTask, removeTasksFromStore } = useTaskMultiSelectStore(); const moveTasks = useTaskWorkflowMove(); const { toast } = useToast(); + const { t } = useTranslation(); const [isArchiving, setIsArchiving] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -40,7 +42,7 @@ function useSidebarBulkActions( per: (id: string) => Promise, handleActive: (id: string) => Promise, setBusy: (v: boolean) => void, - noun: string, + failureKey: string, ) => { if (ids.length === 0) return; const activeId = store.getState().tasks.activeTaskId; @@ -63,7 +65,7 @@ function useSidebarBulkActions( // Keep the failed ids selected so the user can retry, and surface it. dispatch({ type: "set_selected", ids: new Set(failed) }); toast({ - title: `Failed to ${noun} ${failed.length} task${failed.length === 1 ? "" : "s"}`, + title: t(failureKey, { count: failed.length }), variant: "error", }); return; @@ -73,7 +75,7 @@ function useSidebarBulkActions( setBusy(false); } }, - [store, removeTasksFromStore, dispatch, toast, clearSelection], + [store, removeTasksFromStore, dispatch, toast, clearSelection, t], ); const bulkArchive = useCallback( @@ -83,7 +85,7 @@ function useSidebarBulkActions( (id) => archiveTaskById(id, opts), (id) => archiveAndSwitch(id, opts), setIsArchiving, - "archive", + "sidebar:bulkArchiveFailed", ), [runBulkRemoval, archiveTaskById, archiveAndSwitch], ); @@ -102,7 +104,7 @@ function useSidebarBulkActions( }); }, setIsDeleting, - "delete", + "sidebar:bulkDeleteFailed", ), [runBulkRemoval, deleteTaskById, removeTaskFromBoard, store], ); diff --git a/apps/web/src/locales/en/azuredevops.json b/apps/web/src/locales/en/azuredevops.json index 8c7def19be..828d88c090 100644 --- a/apps/web/src/locales/en/azuredevops.json +++ b/apps/web/src/locales/en/azuredevops.json @@ -51,6 +51,7 @@ "delete": "Delete", "deleteWatchConfirm": "Delete this watch?", "description": "Description", + "descriptionFallback": "(no description)", "disable": "Disable", "disabled": "Disabled", "discussion": "Discussion", diff --git a/apps/web/src/locales/en/chat.json b/apps/web/src/locales/en/chat.json index 11edeef19f..dd98e60f63 100644 --- a/apps/web/src/locales/en/chat.json +++ b/apps/web/src/locales/en/chat.json @@ -25,7 +25,6 @@ "clearAll": "Clear all", "clearAllQueuedMessages": "Clear all queued messages", "close": "Close {{name}}", - "closeQuickChat": "Close quick chat", "collapseClarification": "Collapse clarification", "collapseMessage": "Collapse message", "collapseQueuedMessages": "Collapse queued messages", @@ -99,7 +98,6 @@ "startChatAction": "Start chat", "starting": "Starting...", "startingChat": "Starting chat...", - "startNewChat": "Start new chat", "sendNow": "Send Now", "sendNowQueuedMessage": "Send queued message now", "sendNowQueuedMessages": "Send all queued messages now", diff --git a/apps/web/src/locales/en/sidebar.json b/apps/web/src/locales/en/sidebar.json index 488a9d4f28..19cd62b742 100644 --- a/apps/web/src/locales/en/sidebar.json +++ b/apps/web/src/locales/en/sidebar.json @@ -11,6 +11,10 @@ "apiTokens": "API Tokens", "applicationStatus": "Application status", "applicationStatusItems": "Application status items", + "bulkArchiveFailed_one": "Failed to archive {{count}} task", + "bulkArchiveFailed_other": "Failed to archive {{count}} tasks", + "bulkDeleteFailed_one": "Failed to delete {{count}} task", + "bulkDeleteFailed_other": "Failed to delete {{count}} tasks", "closeSettings": "Close settings", "collapseGroup": "Collapse {{label}}", "collapseSidebar": "Collapse sidebar", diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 79ab8a6419..25a3c311b4 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -348,6 +348,9 @@ "documentSaved": "Document saved", "documents": "Documents", "documentsAvailable": "Documents available", + "documentSummary_one": "{{count}} line · {{chars}} chars", + "documentSummary_other": "{{count}} lines · {{chars}} chars", + "documentSummaryEmpty": "empty", "done": "Done", "doubleClickToEditOrPress": "Double-click to edit (or press Enter)", "doubleClickToKeepThisTab": "Double-click to keep this tab open", @@ -364,6 +367,8 @@ "editBranch2": "Edit branch", "editComment": "Edit comment", "editMarkdownComment": "Edit markdown comment", + "editSummaryLines_one": "{{summary}} ({{count}} line)", + "editSummaryLines_other": "{{summary}} ({{count}} lines)", "editTheMessageForTheMost": "Edit the message for the most recent commit.", "empty": "(empty)", "emptyGroup": "Empty group", @@ -389,6 +394,8 @@ "environmentSetupFinishedWithErrors": "Environment setup finished with errors", "error": "Error", "errorData": "Error Data", + "eventCount_one": "{{count}} event", + "eventCount_other": "{{count}} events", "executor2": "Executor", "executorEnvironmentIsUnavailable": "Executor environment is unavailable.", "executorHintDocker": "A Docker container will be created from the selected base branch and checked out on a task branch.", @@ -490,6 +497,8 @@ "forkANewBranchFromA": "Fork a new branch from a base (turn off to use current checkout)", "forkANewBranchFromA2": "Fork a new branch from a base instead of using current checkout", "forkModeANewBranchWill": "Fork mode: a new branch will be created from the selected base before the agent runs. Click to turn off and use your repository's current checkout instead.", + "foundFiles_one": "Found {{count}} file", + "foundFiles_other": "Found {{count}} files", "freshBranchTooltip": "Create a new branch from the selected base. Any uncommitted changes in your local clone will be discarded; you’ll be asked to confirm if there are any.", "fromAgent": "From agent", "fromAgentInTask": "From agent in task <1>“{{fullTitle}}”", @@ -901,6 +910,9 @@ "ranCommands_one": "· ran {{count}} command", "ranCommands_other": "· ran {{count}} commands", "random": "Random", + "read": "Read", + "readLines_one": "Read {{count}} line", + "readLines_other": "Read {{count}} lines", "reasonAddSessionDescription": "Add a session description", "reasonAddTaskPrompt": "Add a task prompt", "reasonAddTaskTitle": "Add a task title", @@ -1316,6 +1328,7 @@ "toolCount_one": "{{count}} tool", "toolCount_other": "{{count}} tools", "tools": "tools", + "toolSearching": "Searching", "transcribing": "Transcribing…", "transcribingVoiceInput": "Transcribing voice input", "treeActionFailed": "Tree action failed", diff --git a/apps/web/src/locales/pseudo/azuredevops.json b/apps/web/src/locales/pseudo/azuredevops.json index e4ae2c3d06..7b67a23d08 100644 --- a/apps/web/src/locales/pseudo/azuredevops.json +++ b/apps/web/src/locales/pseudo/azuredevops.json @@ -51,6 +51,7 @@ "delete": "Ďēĺēţē", "deleteWatchConfirm": "Ďēĺēţē ţĥĩś ŵàţćĥ?", "description": "Ďēśćŕĩƥţĩōń", + "descriptionFallback": "(ńō ďēśćŕĩƥţĩōń)", "disable": "Ďĩśàƀĺē", "disabled": "Ďĩśàƀĺēď", "discussion": "Ďĩśćũśśĩōń", diff --git a/apps/web/src/locales/pseudo/chat.json b/apps/web/src/locales/pseudo/chat.json index 23f0d39625..8c677c662a 100644 --- a/apps/web/src/locales/pseudo/chat.json +++ b/apps/web/src/locales/pseudo/chat.json @@ -25,7 +25,6 @@ "clearAll": "Ćĺēàŕ àĺĺ", "clearAllQueuedMessages": "Ćĺēàŕ àĺĺ qũēũēď ḿēśśàĝēś", "close": "Ćĺōśē {{name}}", - "closeQuickChat": "Ćĺōśē qũĩćķ ćĥàţ", "collapseClarification": "Ćōĺĺàƥśē ćĺàŕĩƒĩćàţĩōń", "collapseMessage": "Ćōĺĺàƥśē ḿēśśàĝē", "collapseQueuedMessages": "Ćōĺĺàƥśē qũēũēď ḿēśśàĝēś", @@ -99,7 +98,6 @@ "startChatAction": "Śţàŕţ ćĥàţ", "starting": "Śţàŕţĩńĝ...", "startingChat": "Śţàŕţĩńĝ ćĥàţ...", - "startNewChat": "Śţàŕţ ńēŵ ćĥàţ", "sendNow": "Śēńď Ńōŵ", "sendNowQueuedMessage": "Śēńď qũēũēď ḿēśśàĝē ńōŵ", "sendNowQueuedMessages": "Śēńď àĺĺ qũēũēď ḿēśśàĝēś ńōŵ", diff --git a/apps/web/src/locales/pseudo/sidebar.json b/apps/web/src/locales/pseudo/sidebar.json index 50314dfcf2..8ebd505afa 100644 --- a/apps/web/src/locales/pseudo/sidebar.json +++ b/apps/web/src/locales/pseudo/sidebar.json @@ -11,6 +11,10 @@ "apiTokens": "ÀƤĨ Ţōķēńś", "applicationStatus": "Àƥƥĺĩćàţĩōń śţàţũś", "applicationStatusItems": "Àƥƥĺĩćàţĩōń śţàţũś ĩţēḿś", + "bulkArchiveFailed_one": "Ƒàĩĺēď ţō àŕćĥĩvē {{count}} ţàśķ", + "bulkArchiveFailed_other": "Ƒàĩĺēď ţō àŕćĥĩvē {{count}} ţàśķś", + "bulkDeleteFailed_one": "Ƒàĩĺēď ţō ďēĺēţē {{count}} ţàśķ", + "bulkDeleteFailed_other": "Ƒàĩĺēď ţō ďēĺēţē {{count}} ţàśķś", "closeSettings": "Ćĺōśē śēţţĩńĝś", "collapseGroup": "Ćōĺĺàƥśē {{label}}", "collapseSidebar": "Ćōĺĺàƥśē śĩďēƀàŕ", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 021b6c92b3..9ae5a6a381 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -348,6 +348,9 @@ "documentSaved": "Ďōćũḿēńţ śàvēď", "documents": "Ďōćũḿēńţś", "documentsAvailable": "Ďōćũḿēńţś àvàĩĺàƀĺē", + "documentSummary_one": "{{count}} ĺĩńē · {{chars}} ćĥàŕś", + "documentSummary_other": "{{count}} ĺĩńēś · {{chars}} ćĥàŕś", + "documentSummaryEmpty": "ēḿƥţŷ", "done": "Ďōńē", "doubleClickToEditOrPress": "Ďōũƀĺē-ćĺĩćķ ţō ēďĩţ (ōŕ ƥŕēśś Ēńţēŕ)", "doubleClickToKeepThisTab": "Ďōũƀĺē-ćĺĩćķ ţō ķēēƥ ţĥĩś ţàƀ ōƥēń", @@ -364,6 +367,8 @@ "editBranch2": "Ēďĩţ ƀŕàńćĥ", "editComment": "Ēďĩţ ćōḿḿēńţ", "editMarkdownComment": "Ēďĩţ ḿàŕķďōŵń ćōḿḿēńţ", + "editSummaryLines_one": "{{summary}} ({{count}} ĺĩńē)", + "editSummaryLines_other": "{{summary}} ({{count}} ĺĩńēś)", "editTheMessageForTheMost": "Ēďĩţ ţĥē ḿēśśàĝē ƒōŕ ţĥē ḿōśţ ŕēćēńţ ćōḿḿĩţ.", "empty": "(ēḿƥţŷ)", "emptyGroup": "Ēḿƥţŷ ĝŕōũƥ", @@ -389,6 +394,8 @@ "environmentSetupFinishedWithErrors": "Ēńvĩŕōńḿēńţ śēţũƥ ƒĩńĩśĥēď ŵĩţĥ ēŕŕōŕś", "error": "Ēŕŕōŕ", "errorData": "Ēŕŕōŕ Ďàţà", + "eventCount_one": "{{count}} ēvēńţ", + "eventCount_other": "{{count}} ēvēńţś", "executor2": "Ēxēćũţōŕ", "executorEnvironmentIsUnavailable": "Ēxēćũţōŕ ēńvĩŕōńḿēńţ ĩś ũńàvàĩĺàƀĺē.", "executorHintDocker": "À Ďōćķēŕ ćōńţàĩńēŕ ŵĩĺĺ ƀē ćŕēàţēď ƒŕōḿ ţĥē śēĺēćţēď ƀàśē ƀŕàńćĥ àńď ćĥēćķēď ōũţ ōń à ţàśķ ƀŕàńćĥ.", @@ -490,6 +497,8 @@ "forkANewBranchFromA": "Ƒōŕķ à ńēŵ ƀŕàńćĥ ƒŕōḿ à ƀàśē (ţũŕń ōƒƒ ţō ũśē ćũŕŕēńţ ćĥēćķōũţ)", "forkANewBranchFromA2": "Ƒōŕķ à ńēŵ ƀŕàńćĥ ƒŕōḿ à ƀàśē ĩńśţēàď ōƒ ũśĩńĝ ćũŕŕēńţ ćĥēćķōũţ", "forkModeANewBranchWill": "Ƒōŕķ ḿōďē: à ńēŵ ƀŕàńćĥ ŵĩĺĺ ƀē ćŕēàţēď ƒŕōḿ ţĥē śēĺēćţēď ƀàśē ƀēƒōŕē ţĥē àĝēńţ ŕũńś. Ćĺĩćķ ţō ţũŕń ōƒƒ àńď ũśē ŷōũŕ ŕēƥōśĩţōŕŷ'ś ćũŕŕēńţ ćĥēćķōũţ ĩńśţēàď.", + "foundFiles_one": "Ƒōũńď {{count}} ƒĩĺē", + "foundFiles_other": "Ƒōũńď {{count}} ƒĩĺēś", "freshBranchTooltip": "Ćŕēàţē à ńēŵ ƀŕàńćĥ ƒŕōḿ ţĥē śēĺēćţēď ƀàśē. Àńŷ ũńćōḿḿĩţţēď ćĥàńĝēś ĩń ŷōũŕ ĺōćàĺ ćĺōńē ŵĩĺĺ ƀē ďĩśćàŕďēď; ŷōũ’ĺĺ ƀē àśķēď ţō ćōńƒĩŕḿ ĩƒ ţĥēŕē àŕē àńŷ.", "fromAgent": "Ƒŕōḿ àĝēńţ", "fromAgentInTask": "Ƒŕōḿ àĝēńţ ĩń ţàśķ <1>“{{fullTitle}}”", @@ -901,6 +910,9 @@ "ranCommands_one": "· ŕàń {{count}} ćōḿḿàńď", "ranCommands_other": "· ŕàń {{count}} ćōḿḿàńďś", "random": "Ŕàńďōḿ", + "read": "Ŕēàď", + "readLines_one": "Ŕēàď {{count}} ĺĩńē", + "readLines_other": "Ŕēàď {{count}} ĺĩńēś", "reasonAddSessionDescription": "Àďď à śēśśĩōń ďēśćŕĩƥţĩōń", "reasonAddTaskPrompt": "Àďď à ţàśķ ƥŕōḿƥţ", "reasonAddTaskTitle": "Àďď à ţàśķ ţĩţĺē", @@ -1316,6 +1328,7 @@ "toolCount_one": "{{count}} ţōōĺ", "toolCount_other": "{{count}} ţōōĺś", "tools": "ţōōĺś", + "toolSearching": "Śēàŕćĥĩńĝ", "transcribing": "Ţŕàńśćŕĩƀĩńĝ…", "transcribingVoiceInput": "Ţŕàńśćŕĩƀĩńĝ vōĩćē ĩńƥũţ", "treeActionFailed": "Ţŕēē àćţĩōń ƒàĩĺēď",