fix(i18n): close four gaps no gate can see (plurals, fallback copy, orphans) (#2388)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
<AzureDevOpsTaskLauncher
|
||||
workspaceId={workspaceId}
|
||||
workflows={[workflow]}
|
||||
steps={[step]}
|
||||
repositories={[repository]}
|
||||
payload={{
|
||||
kind: "work-item",
|
||||
item: {
|
||||
id: 74,
|
||||
revision: 1,
|
||||
title: "Has a description",
|
||||
type: "Issue",
|
||||
state: "To Do",
|
||||
project: "project-1",
|
||||
description: "<div>Real <b>description</b></div>",
|
||||
},
|
||||
}}
|
||||
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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -432,7 +432,7 @@ function KanbanCardActions({
|
||||
<div className="flex items-center gap-2">
|
||||
{renderSubagentCountChip(
|
||||
task,
|
||||
t("activeSubagents", { count: task.activeSubagentCount ?? 0 }),
|
||||
t("common:activeSubagents", { count: task.activeSubagentCount ?? 0 }),
|
||||
)}
|
||||
{statusIcon}
|
||||
{showMaximizeButton && onOpenFullPage && hasKnownSession && (
|
||||
|
||||
@@ -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(
|
||||
<GetTaskPlanRenderer args={{ task_id: "t1" }} result={{ content }} status="complete" />,
|
||||
);
|
||||
}
|
||||
|
||||
function updatedPlan(content: string | undefined) {
|
||||
return renderToStaticMarkup(
|
||||
<UpdateTaskPlanRenderer args={{ task_id: "t1" }} result={{ content }} status="complete" />,
|
||||
);
|
||||
}
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -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 }) =>
|
||||
<SummaryDot />
|
||||
</>
|
||||
)}
|
||||
<span>{hasPlan ? summarizeContent(content) : t("task:noPlan")}</span>
|
||||
<span>{hasPlan ? summarizeContent(t, content) : t("task:noPlan")}</span>
|
||||
</span>
|
||||
}
|
||||
status={status}
|
||||
@@ -153,7 +156,7 @@ export const UpdateTaskPlanRenderer: KandevRenderer = ({ args, result, status })
|
||||
<SummaryDot />
|
||||
</>
|
||||
)}
|
||||
<span>{summarizeContent(displayContent)}</span>
|
||||
<span>{summarizeContent(t, displayContent)}</span>
|
||||
</span>
|
||||
}
|
||||
status={status}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
<ToolEditMessage comment={writeComment("Write utils.ts", "only line")} />,
|
||||
);
|
||||
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(
|
||||
<ToolEditMessage comment={writeComment("Write utils.ts", "a\nb\nc")} />,
|
||||
);
|
||||
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(<ToolEditMessage comment={comment} />);
|
||||
expect(html).toContain("Edit utils.ts");
|
||||
expect(html).not.toContain("line)");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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(<ToolReadMessage comment={readCommentWithLines(1)} />);
|
||||
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(<ToolReadMessage comment={readCommentWithLines(137)} />);
|
||||
expect(screen.getByText("Read 137 lines")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the bare 'Read' label when no line count is reported", () => {
|
||||
render(<ToolReadMessage comment={readCommentWithLines(undefined)} />);
|
||||
expect(screen.getByText("Read")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("forwards the message session when opening a file at its read line", () => {
|
||||
const openFile = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="inline-flex items-center gap-1.5 shrink-0 whitespace-nowrap">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{getReadSummary(readOutput?.line_count)}
|
||||
{getReadSummary(t, readOutput?.line_count)}
|
||||
</span>
|
||||
{!isSuccess && <ReadStatusIcon status={status} />}
|
||||
</span>
|
||||
|
||||
@@ -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(<ToolSearchMessage comment={searchComment(["a.ts"])} />);
|
||||
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(
|
||||
<ToolSearchMessage comment={searchComment(["a.ts", "b.ts", "c.ts"])} />,
|
||||
);
|
||||
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(<ToolSearchMessage comment={searchComment(["a.ts"], 12)} />);
|
||||
expect(html).toContain("Found 12 files");
|
||||
});
|
||||
|
||||
it("falls back to the searching label before any result arrives", () => {
|
||||
const html = renderToStaticMarkup(<ToolSearchMessage comment={searchComment([])} />);
|
||||
expect(html).toContain("Searching");
|
||||
expect(html).not.toContain("Found");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{getSearchSummary(searchOutput)}
|
||||
{getSearchSummary(t, searchOutput)}
|
||||
</span>
|
||||
{!isSuccess && <SearchStatusIcon status={status} />}
|
||||
</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) {
|
||||
placeholder,
|
||||
isAgentBusy,
|
||||
hasAgentCommands,
|
||||
steerPlaceholder: supportsSteering ? t("composerSteerPlaceholder") : undefined,
|
||||
steerPlaceholder: supportsSteering ? t("chat:composerSteerPlaceholder") : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -117,7 +117,7 @@ export const TaskSwitcher = memo(function TaskSwitcher(props: TaskSwitcherProps)
|
||||
return (
|
||||
<>
|
||||
{loadErrorNotice}
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground">{t("noTasksYet")}</div>
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground">{t("sidebar:noTasksYet")}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<void>,
|
||||
handleActive: (id: string) => Promise<void>,
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"delete": "Delete",
|
||||
"deleteWatchConfirm": "Delete this watch?",
|
||||
"description": "Description",
|
||||
"descriptionFallback": "(no description)",
|
||||
"disable": "Disable",
|
||||
"disabled": "Disabled",
|
||||
"discussion": "Discussion",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}”</1>",
|
||||
@@ -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",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"delete": "Ďēĺēţē",
|
||||
"deleteWatchConfirm": "Ďēĺēţē ţĥĩś ŵàţćĥ?",
|
||||
"description": "Ďēśćŕĩƥţĩōń",
|
||||
"descriptionFallback": "(ńō ďēśćŕĩƥţĩōń)",
|
||||
"disable": "Ďĩśàƀĺē",
|
||||
"disabled": "Ďĩśàƀĺēď",
|
||||
"discussion": "Ďĩśćũśśĩōń",
|
||||
|
||||
@@ -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ũēũēď ḿēśśàĝēś ńōŵ",
|
||||
|
||||
@@ -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": "Ćōĺĺàƥśē śĩďēƀàŕ",
|
||||
|
||||
@@ -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}}”</1>",
|
||||
@@ -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": "Ţŕēē àćţĩōń ƒàĩĺēď",
|
||||
|
||||
Reference in New Issue
Block a user