Show the PR checks summary, sidebar status, and "Worked for" line in the app language (#6196)

* fix(app): translate the PR checks summary, sidebar status labels, and "Worked for" line

These were written in the components, so they stayed English in every
other language. They now come from the translation files, with copy for
all nine supported languages.

Closes #6190

* fix(app): rebuild translated labels when the app language changes

The checks summary, sidebar status groups, and turn footer now take the
translation function, so their memoized labels rebuild on a language
switch. Russian one-check group labels use singular wording.

* test(app): compare compact footer targets at sub-pixel precision

The browser reports the 44pt footer buttons as 43.99999 wide on some
runs, which fails the finger-size check on main as well.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-06 09:19:52 +02:00
committed by GitHub
parent 0255738ee7
commit 81a5e5bc61
26 changed files with 607 additions and 76 deletions
@@ -183,13 +183,16 @@ async function openCompactSidebar(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
}
const MIN_TARGET_SIZE = 44;
async function expectFingerSizedFooterTargets(page: Page): Promise<void> {
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
await expect(buttons).toHaveCount(5);
for (const button of await buttons.all()) {
const bounds = await button.boundingBox();
expect(bounds?.width).toBeGreaterThanOrEqual(44);
expect(bounds?.height).toBeGreaterThanOrEqual(44);
// Layout can report a 44pt box as 43.99999, so compare at a hundredth of a point.
expect(bounds?.width).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
expect(bounds?.height).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
// Glyphs stay at the composer toolbar size (some optically smaller); only the target grows.
const glyphWidth = await button.locator("svg").first().getAttribute("width");
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { getTurnDurationLabel } from "./assistant-turn-footer-label";
describe("getTurnDurationLabel", () => {
it("says how long the turn worked", () => {
expect(getTurnDurationLabel(14_000, i18n.t)).toBe("Worked for 14s");
});
it("reads in the active app language", async () => {
await i18n.changeLanguage("fr");
try {
expect(getTurnDurationLabel(14_000, i18n.t)).toBe("A travaillé pendant 14s");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -0,0 +1,6 @@
import type { TFunction } from "i18next";
import { formatDuration } from "@/utils/time";
export function getTurnDurationLabel(durationMs: number, t: TFunction): string {
return t("message.turnFooter.workedFor", { duration: formatDuration(durationMs) });
}
+4 -4
View File
@@ -74,6 +74,7 @@ import { useRevealedText } from "@/hooks/use-revealed-text";
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { getTurnDurationLabel } from "./assistant-turn-footer-label";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
@@ -629,6 +630,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
durationMs,
onFork,
}: AssistantTurnFooterProps) {
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [pressedReveal, setPressedReveal] = useState(false);
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -644,10 +646,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
const durationLabel = useMemo(
() =>
durationMs !== undefined && durationMs !== null
? `Worked for ${formatDuration(durationMs)}`
: "",
[durationMs],
durationMs !== undefined && durationMs !== null ? getTurnDurationLabel(durationMs, t) : "",
[durationMs, t],
);
const timestampLabel = useMemo(
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
@@ -1,8 +1,9 @@
import { ActivityIndicator, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
@@ -170,6 +171,7 @@ function ProjectStatusBadge({
statusBucket: SidebarStateBucket;
backdrop: SidebarSurfaceBackdrop;
}) {
const { t } = useTranslation();
// Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout,
// so nesting it inside would clip it against the very thing that was meant to separate it from
// the icon. It anchors to the same corner instead, growing around the centre the dot had.
@@ -177,7 +179,7 @@ function ProjectStatusBadge({
return (
<View
role="status"
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
accessibilityLabel={getStatusBucketLabel(statusBucket, t)}
style={styles.statusRingAnchor}
testID="project-status-badge"
>
@@ -188,7 +190,7 @@ function ProjectStatusBadge({
return (
<View
role="status"
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
accessibilityLabel={getStatusBucketLabel(statusBucket, t)}
style={[styles.statusBadge, getStatusBadgeBackdropStyle(backdrop)]}
testID="project-status-badge"
>
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
import {
useSidebarWorkspacesList,
@@ -54,6 +55,7 @@ export function SidebarModelProvider({
active?: boolean;
children: ReactNode;
}) {
const { t } = useTranslation();
const list = useSidebarWorkspacesList({ enabled: active });
const groupMode = useSidebarViewStore((state) => state.groupMode);
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
@@ -150,6 +152,7 @@ export function SidebarModelProvider({
pinnedCollapsed,
collapsedProjectKeys,
collapsedWorkspaceGroupKeys,
t,
}),
[
collapsedProjectKeys,
@@ -161,6 +164,7 @@ export function SidebarModelProvider({
pinnedKeys,
pinnedWorkspaceOrder,
filteredWorkspaceEntriesByKey,
t,
],
);
const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]);
@@ -1,3 +1,4 @@
import { i18n } from "@/i18n/i18next";
import { describe, expect, it } from "vitest";
import type {
SidebarProjectEntry,
@@ -85,6 +86,7 @@ function projectionInput(options?: {
pinnedCollapsed: options?.pinnedCollapsed ?? false,
collapsedProjectKeys: new Set<string>(),
collapsedWorkspaceGroupKeys: new Set<string>(),
t: i18n.t,
};
}
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
import {
splitPinnedSidebarGroups,
@@ -45,6 +46,7 @@ export interface SidebarProjectionInput {
pinnedCollapsed: boolean;
collapsedProjectKeys: ReadonlySet<string>;
collapsedWorkspaceGroupKeys: ReadonlySet<string>;
t: TFunction;
}
export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection {
@@ -100,7 +102,7 @@ function buildWorkspaceGroups(
return [];
case "status":
return statusWorkspaceGroups(
buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey),
buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey, input.t),
);
}
}
@@ -380,6 +380,7 @@ export function SidebarWorkspaceContextMenu({
serviceLabel: serviceSummary
? t(workspaceServiceLabelKey(serviceSummary), { name: serviceSummary.name })
: null,
t,
});
const workspaceTarget = useMemo<WorkspaceLabelTarget>(
() => ({
@@ -1,3 +1,4 @@
import { i18n } from "@/i18n/i18next";
import { describe, expect, it } from "vitest";
import {
resolveSidebarWorkspaceAccessibilityLabel,
@@ -39,6 +40,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" },
workspaceTitleSource: "title",
hostBadgeLabel: "Build host",
t: i18n.t,
});
expect(label).toBe("Investigate search, Build host");
@@ -56,6 +58,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
hostBadgeLabel: "Build host",
pullRequestLabel: "Pull request 42",
serviceLabel: "Service web running",
t: i18n.t,
});
expect(label).toBe(
@@ -69,6 +72,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
workspaceTitleSource: "title",
leadingProjectName: "Search project",
hostBadgeLabel: "Build host",
t: i18n.t,
});
expect(label).toBe("Search project, Investigate search, Build host");
@@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
export function resolveSidebarWorkspacePrimaryLabel(input: {
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch">;
@@ -19,6 +20,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: {
hostBadgeLabel?: string | null;
pullRequestLabel?: string | null;
serviceLabel?: string | null;
t: TFunction;
}): string {
return [
input.leadingProjectName,
@@ -28,7 +30,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: {
input.serviceLabel,
input.workspace.statusBucket === "done"
? null
: STATUS_BUCKET_LABELS[input.workspace.statusBucket],
: getStatusBucketLabel(input.workspace.statusBucket, input.t),
]
.filter((label): label is string => Boolean(label))
.join(", ");
@@ -1,6 +1,7 @@
import { useCallback, useMemo } from "react";
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -71,7 +72,8 @@ export function ChecksSection({
loadingCheckKeys,
onAddLogsToChat,
}: ChecksSectionProps) {
const summary = useMemo(() => summarizeChecks(checks), [checks]);
const { t } = useTranslation();
const summary = useMemo(() => summarizeChecks(checks, t), [checks, t]);
const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState();
return (
@@ -91,13 +93,14 @@ export function ChecksSection({
</Text>
{summary.parts.length > 0 ? (
<Text style={styles.detail} numberOfLines={1} testID="pr-pane-check-summary">
{summary.detailLead}
{summary.parts.map((part, index) => (
<Text key={part.status}>
{index > 0 ? ", " : ""}
<Text testID={PART_TEST_ID[part.status]}>{part.text}</Text>
</Text>
))}
{` ${summary.countNoun}`}
{summary.detailTrail}
</Text>
) : null}
</View>
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import type { CheckStatus } from "./check-status";
import { summarizeChecks } from "./checks-summary";
import type { PrPaneCheck } from "./data";
@@ -9,7 +10,7 @@ function check(name: string, status: CheckStatus): PrPaneCheck {
describe("summarizeChecks", () => {
it("reports no checks without claiming the run passed", () => {
const summary = summarizeChecks([]);
const summary = summarizeChecks([], i18n.t);
expect(summary.outcome).toBe("none");
expect(summary.headline).toBe("No checks");
@@ -18,11 +19,10 @@ describe("summarizeChecks", () => {
});
it("leads with failures when anything failed", () => {
const summary = summarizeChecks([
check("build", "success"),
check("lint", "failure"),
check("e2e", "pending"),
]);
const summary = summarizeChecks(
[check("build", "success"), check("lint", "failure"), check("e2e", "pending")],
i18n.t,
);
expect(summary.outcome).toBe("failure");
expect(summary.headline).toBe("Some checks were not successful");
@@ -31,7 +31,7 @@ describe("summarizeChecks", () => {
});
it("reports an unfinished run as in progress when nothing failed", () => {
const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")]);
const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")], i18n.t);
expect(summary.outcome).toBe("pending");
expect(summary.headline).toBe("Some checks haven't completed yet");
@@ -39,7 +39,10 @@ describe("summarizeChecks", () => {
});
it("treats a run of only successes and skips as passed", () => {
const summary = summarizeChecks([check("build", "success"), check("deploy", "skipped")]);
const summary = summarizeChecks(
[check("build", "success"), check("deploy", "skipped")],
i18n.t,
);
expect(summary.outcome).toBe("success");
expect(summary.headline).toBe("All checks have passed");
@@ -47,11 +50,10 @@ describe("summarizeChecks", () => {
});
it("keeps the checks of each group together under a counted label", () => {
const summary = summarizeChecks([
check("build", "failure"),
check("lint", "success"),
check("test", "failure"),
]);
const summary = summarizeChecks(
[check("build", "failure"), check("lint", "success"), check("test", "failure")],
i18n.t,
);
const [failing, successful] = summary.groups;
expect(failing?.label).toBe("2 failing checks");
@@ -60,9 +62,34 @@ describe("summarizeChecks", () => {
});
it("says check rather than checks for a run of one", () => {
const summary = summarizeChecks([check("build", "failure")]);
const summary = summarizeChecks([check("build", "failure")], i18n.t);
expect(summary.countNoun).toBe("check");
expect(summary.detail).toBe("1 failing check");
});
it("reads in the active app language", async () => {
await i18n.changeLanguage("fr");
try {
const summary = summarizeChecks(
[
check("build", "success"),
check("deploy", "skipped"),
check("lint", "failure"),
check("test", "failure"),
],
i18n.t,
);
expect(summary.headline).toBe("Certaines vérifications ont échoué");
expect(summary.detail).toBe("Vérifications : 2 en échec, 1 réussie(s), 1 ignorée(s)");
expect(summary.groups.map((group) => group.label)).toEqual([
"2 vérifications en échec",
"1 vérification réussie",
"1 vérification ignorée",
]);
expect(summarizeChecks([], i18n.t).headline).toBe("Aucune vérification");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -1,4 +1,5 @@
import { classifyCheck, type CheckPresentation } from "@/git/check-presentation";
import type { TFunction } from "i18next";
import type { PrPaneCheck } from "./data";
/**
@@ -16,27 +17,10 @@ const PRESENTATION_ORDER = [
"ignored",
] as const satisfies readonly CheckPresentation[];
/** How each status reads inside a count phrase: "2 failing", "4 in progress". */
const PRESENTATION_NOUN: Record<CheckPresentation, string> = {
actionRequired: "needs action",
warning: "warning",
failure: "failing",
pending: "in progress",
manual: "manual",
success: "successful",
ignored: "skipped",
};
/** The worst thing happening in the run, which is what the headline and the ring report. */
export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none";
const OUTCOME_HEADLINE: Record<ChecksOutcome, string> = {
actionRequired: "Some checks need your attention",
failure: "Some checks were not successful",
pending: "Some checks haven't completed yet",
success: "All checks have passed",
none: "No checks",
};
const COPY = "workspace.git.pr.checksOverview";
export interface ChecksCountPart {
status: CheckPresentation;
@@ -58,8 +42,12 @@ export interface ChecksSummary {
headline: string;
/** The count phrases behind `detail`, kept apart so the header can label each one. */
parts: readonly ChecksCountPart[];
/** The noun closing the detail line — "checks", or "check" for a run of one. */
countNoun: string;
/**
* The words around the count phrases in `detail`: "" and " checks" in English, where the noun
* closes the line, while other languages lead with it.
*/
detailLead: string;
detailTrail: string;
/** The whole line: "2 failing, 4 in progress, 1 successful checks". Empty with no checks. */
detail: string;
total: number;
@@ -72,7 +60,7 @@ export interface ChecksSummary {
* header, the ring, and the grouped list all read from one derivation instead of each
* filtering the array again with its own idea of what counts.
*/
export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary {
export function summarizeChecks(checks: readonly PrPaneCheck[], t: TFunction): ChecksSummary {
const groups: ChecksGroup[] = [];
const parts: ChecksCountPart[] = [];
@@ -83,24 +71,27 @@ export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary {
}
groups.push({
status,
label: `${matching.length} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`,
label: t(`${COPY}.${matching.length === 1 ? "groupOne" : "groupMany"}.${status}`, {
count: matching.length,
}),
checks: matching,
});
parts.push({
status,
count: matching.length,
text: `${matching.length} ${PRESENTATION_NOUN[status]}`,
text: t(`${COPY}.count.${status}`, { count: matching.length }),
});
}
const outcome = selectOutcome(checks);
const noun = countNoun(checks.length);
const { lead, trail } = detailFrame(checks.length, t);
return {
outcome,
headline: OUTCOME_HEADLINE[outcome],
headline: t(`${COPY}.headline.${outcome}`),
parts,
countNoun: noun,
detail: parts.length === 0 ? "" : `${parts.map((part) => part.text).join(", ")} ${noun}`,
detailLead: lead,
detailTrail: trail,
detail: parts.length === 0 ? "" : `${lead}${parts.map((part) => part.text).join(", ")}${trail}`,
total: checks.length,
groups,
};
@@ -131,6 +122,13 @@ function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome {
return "success";
}
function countNoun(count: number): string {
return count === 1 ? "check" : "checks";
/**
* The header renders each count phrase on its own, so the translated line is split around
* where the phrases go.
*/
function detailFrame(total: number, t: TFunction): { lead: string; trail: string } {
const marker = "\u0000";
const line = t(`${COPY}.${total === 1 ? "detailOne" : "detailMany"}`, { parts: marker });
const [lead = "", trail = ""] = line.split(marker);
return { lead, trail };
}
@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model";
import {
buildStatusGroups,
buildStatusShortcutIndex,
STATUS_BUCKET_LABELS,
getStatusBucketLabel,
STATUS_BUCKET_ORDER,
type StatusGroup,
} from "./sidebar-status-view-model";
@@ -55,7 +56,7 @@ describe("buildStatusGroups", () => {
ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t);
expect(groups.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]);
expect(groups[0]?.label).toBe("Needs input");
@@ -69,7 +70,7 @@ describe("buildStatusGroups", () => {
ws({ workspaceKey: "srv:b", statusBucket: "running" }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t);
expect(groups.map((g) => g.bucket)).toEqual(["running", "done"]);
});
@@ -93,7 +94,7 @@ describe("buildStatusGroups", () => {
}),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:new", "srv:mid", "srv:old"]);
});
@@ -109,7 +110,7 @@ describe("buildStatusGroups", () => {
ws({ workspaceKey: "srv:null-b", statusBucket: "done", statusEnteredAt: null }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([
"srv:ts",
@@ -145,13 +146,13 @@ describe("buildStatusGroups", () => {
}),
];
const groups = buildStatusGroups(workspaces, projectNames);
const groups = buildStatusGroups(workspaces, projectNames, i18n.t);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]);
});
it("returns empty array for no workspaces", () => {
const groups = buildStatusGroups([], emptyProjectNames);
const groups = buildStatusGroups([], emptyProjectNames, i18n.t);
expect(groups).toEqual([]);
});
@@ -180,11 +181,11 @@ describe("buildStatusGroups", () => {
ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t);
expect(groups.map((g) => g.bucket)).toEqual(STATUS_BUCKET_ORDER);
expect(groups.map((g) => g.label)).toEqual(
STATUS_BUCKET_ORDER.map((b) => STATUS_BUCKET_LABELS[b]),
STATUS_BUCKET_ORDER.map((b) => getStatusBucketLabel(b, i18n.t)),
);
// Each group has exactly one row with the matching bucket
for (const group of groups) {
@@ -230,3 +231,20 @@ describe("buildStatusShortcutIndex", () => {
expect(index.size).toBe(0);
});
});
describe("getStatusBucketLabel", () => {
it("reads in the active app language", async () => {
await i18n.changeLanguage("fr");
try {
expect(STATUS_BUCKET_ORDER.map((bucket) => getStatusBucketLabel(bucket, i18n.t))).toEqual([
"Attend une réponse",
"Échec",
"À relire",
"En cours",
"Terminé",
]);
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state";
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
@@ -5,13 +6,17 @@ export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
export { STATUS_BUCKET_ORDER };
export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
needs_input: "Needs input",
failed: "Failed",
attention: "Ready to review",
running: "Working",
done: "Done",
};
const STATUS_BUCKET_LABEL_KEYS = {
needs_input: "sidebar.statusBucket.needsInput",
failed: "sidebar.statusBucket.failed",
attention: "sidebar.statusBucket.readyToReview",
running: "sidebar.statusBucket.working",
done: "sidebar.statusBucket.done",
} as const satisfies Record<StatusBucket, string>;
export function getStatusBucketLabel(bucket: StatusBucket, t: TFunction): string {
return t(STATUS_BUCKET_LABEL_KEYS[bucket]);
}
export interface StatusGroup {
bucket: StatusBucket;
@@ -22,6 +27,7 @@ export interface StatusGroup {
export function buildStatusGroups(
workspaces: SidebarWorkspaceEntry[],
projectNamesByViewKey: Map<string, string>,
t: TFunction,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
@@ -42,7 +48,7 @@ export function buildStatusGroups(
if (!rows || rows.length === 0) continue;
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey));
groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows });
groups.push({ bucket, label: getStatusBucketLabel(bucket, t), rows });
}
return groups;
+48
View File
@@ -382,6 +382,9 @@ export const ar: TranslationResources = {
completed: "اكتملت",
},
},
turnFooter: {
workedFor: "عمل لمدة {{duration}}",
},
compaction: {
loading: "الضغط...",
auto: "يتم ضغط السياق تلقائيًا",
@@ -971,6 +974,44 @@ export const ar: TranslationResources = {
viewPullRequest: "عرض",
openOn: "فتح على {{brand}}",
},
checksOverview: {
headline: {
actionRequired: "بعض الفحوصات تحتاج إلى انتباهك",
failure: "بعض الفحوصات لم تنجح",
pending: "بعض الفحوصات لم تكتمل بعد",
success: "نجحت جميع الفحوصات",
none: "لا توجد فحوصات",
},
count: {
actionRequired: "{{count}} تحتاج إجراءً",
warning: "{{count}} مع تحذير",
failure: "{{count}} فاشلة",
pending: "{{count}} قيد التشغيل",
manual: "{{count}} يدوية",
success: "{{count}} ناجحة",
ignored: "{{count}} متخطاة",
},
detailOne: "الفحص: {{parts}}",
detailMany: "الفحوصات: {{parts}}",
groupOne: {
actionRequired: "{{count}} فحص يحتاج إجراءً",
warning: "{{count}} فحص مع تحذير",
failure: "{{count}} فحص فاشل",
pending: "{{count}} فحص قيد التشغيل",
manual: "{{count}} فحص يدوي",
success: "{{count}} فحص ناجح",
ignored: "{{count}} فحص متخطى",
},
groupMany: {
actionRequired: "{{count}} فحوصات تحتاج إجراءً",
warning: "{{count}} فحوصات مع تحذير",
failure: "{{count}} فحوصات فاشلة",
pending: "{{count}} فحوصات قيد التشغيل",
manual: "{{count}} فحوصات يدوية",
success: "{{count}} فحوصات ناجحة",
ignored: "{{count}} فحوصات متخطاة",
},
},
checksSummary: {
passedLabel: "نجح",
failedLabel: "فشل",
@@ -1088,6 +1129,13 @@ export const ar: TranslationResources = {
},
},
sidebar: {
statusBucket: {
needsInput: "تحتاج إدخالاً",
failed: "فشل",
readyToReview: "جاهزة للمراجعة",
working: "قيد العمل",
done: "تم",
},
display: {
trigger: "تفضيلات العرض",
heading: "العرض",
+48
View File
@@ -379,6 +379,9 @@ export const en = {
completed: "Completed",
},
},
turnFooter: {
workedFor: "Worked for {{duration}}",
},
compaction: {
loading: "Compacting...",
auto: "Context automatically compacted",
@@ -979,6 +982,44 @@ export const en = {
viewPullRequest: "View",
openOn: "Open on {{brand}}",
},
checksOverview: {
headline: {
actionRequired: "Some checks need your attention",
failure: "Some checks were not successful",
pending: "Some checks haven't completed yet",
success: "All checks have passed",
none: "No checks",
},
count: {
actionRequired: "{{count}} needs action",
warning: "{{count}} warning",
failure: "{{count}} failing",
pending: "{{count}} in progress",
manual: "{{count}} manual",
success: "{{count}} successful",
ignored: "{{count}} skipped",
},
detailOne: "{{parts}} check",
detailMany: "{{parts}} checks",
groupOne: {
actionRequired: "{{count}} needs action check",
warning: "{{count}} warning check",
failure: "{{count}} failing check",
pending: "{{count}} in progress check",
manual: "{{count}} manual check",
success: "{{count}} successful check",
ignored: "{{count}} skipped check",
},
groupMany: {
actionRequired: "{{count}} needs action checks",
warning: "{{count}} warning checks",
failure: "{{count}} failing checks",
pending: "{{count}} in progress checks",
manual: "{{count}} manual checks",
success: "{{count}} successful checks",
ignored: "{{count}} skipped checks",
},
},
checksSummary: {
passedLabel: "passed",
failedLabel: "failed",
@@ -1096,6 +1137,13 @@ export const en = {
},
},
sidebar: {
statusBucket: {
needsInput: "Needs input",
failed: "Failed",
readyToReview: "Ready to review",
working: "Working",
done: "Done",
},
display: {
trigger: "Display preferences",
heading: "Display",
+48
View File
@@ -387,6 +387,9 @@ export const es: TranslationResources = {
completed: "Completada",
},
},
turnFooter: {
workedFor: "Trabajó durante {{duration}}",
},
compaction: {
loading: "Compactando...",
auto: "Contexto compactado automáticamente",
@@ -1004,6 +1007,44 @@ export const es: TranslationResources = {
viewPullRequest: "Ver",
openOn: "Abrir en {{brand}}",
},
checksOverview: {
headline: {
actionRequired: "Algunas comprobaciones requieren tu atención",
failure: "Algunas comprobaciones no se superaron",
pending: "Algunas comprobaciones aún no han terminado",
success: "Todas las comprobaciones se superaron",
none: "Sin comprobaciones",
},
count: {
actionRequired: "{{count}} con acción pendiente",
warning: "{{count}} con advertencia",
failure: "{{count}} con error",
pending: "{{count}} en curso",
manual: "{{count}} manual(es)",
success: "{{count}} superada(s)",
ignored: "{{count}} omitida(s)",
},
detailOne: "Comprobación: {{parts}}",
detailMany: "Comprobaciones: {{parts}}",
groupOne: {
actionRequired: "{{count}} comprobación con acción pendiente",
warning: "{{count}} comprobación con advertencia",
failure: "{{count}} comprobación con error",
pending: "{{count}} comprobación en curso",
manual: "{{count}} comprobación manual",
success: "{{count}} comprobación superada",
ignored: "{{count}} comprobación omitida",
},
groupMany: {
actionRequired: "{{count}} comprobaciones con acción pendiente",
warning: "{{count}} comprobaciones con advertencia",
failure: "{{count}} comprobaciones con error",
pending: "{{count}} comprobaciones en curso",
manual: "{{count}} comprobaciones manuales",
success: "{{count}} comprobaciones superadas",
ignored: "{{count}} comprobaciones omitidas",
},
},
checksSummary: {
passedLabel: "pasó",
failedLabel: "falló",
@@ -1124,6 +1165,13 @@ export const es: TranslationResources = {
},
},
sidebar: {
statusBucket: {
needsInput: "Necesita datos",
failed: "Con error",
readyToReview: "Para revisar",
working: "En ejecución",
done: "Terminado",
},
display: {
trigger: "Preferencias de visualización",
heading: "Visualización",
+48
View File
@@ -387,6 +387,9 @@ export const fr: TranslationResources = {
completed: "Terminée",
},
},
turnFooter: {
workedFor: "A travaillé pendant {{duration}}",
},
compaction: {
loading: "Compaction…",
auto: "Contexte compacté automatiquement",
@@ -995,6 +998,44 @@ export const fr: TranslationResources = {
viewPullRequest: "Voir",
openOn: "Ouvrir sur {{brand}}",
},
checksOverview: {
headline: {
actionRequired: "Certaines vérifications demandent votre attention",
failure: "Certaines vérifications ont échoué",
pending: "Certaines vérifications ne sont pas terminées",
success: "Toutes les vérifications ont réussi",
none: "Aucune vérification",
},
count: {
actionRequired: "{{count}} à traiter",
warning: "{{count}} avec avertissement",
failure: "{{count}} en échec",
pending: "{{count}} en cours",
manual: "{{count}} manuelle(s)",
success: "{{count}} réussie(s)",
ignored: "{{count}} ignorée(s)",
},
detailOne: "Vérification : {{parts}}",
detailMany: "Vérifications : {{parts}}",
groupOne: {
actionRequired: "{{count}} vérification à traiter",
warning: "{{count}} vérification avec avertissement",
failure: "{{count}} vérification en échec",
pending: "{{count}} vérification en cours",
manual: "{{count}} vérification manuelle",
success: "{{count}} vérification réussie",
ignored: "{{count}} vérification ignorée",
},
groupMany: {
actionRequired: "{{count}} vérifications à traiter",
warning: "{{count}} vérifications avec avertissement",
failure: "{{count}} vérifications en échec",
pending: "{{count}} vérifications en cours",
manual: "{{count}} vérifications manuelles",
success: "{{count}} vérifications réussies",
ignored: "{{count}} vérifications ignorées",
},
},
checksSummary: {
passedLabel: "réussies",
failedLabel: "en échec",
@@ -1115,6 +1156,13 @@ export const fr: TranslationResources = {
},
},
sidebar: {
statusBucket: {
needsInput: "Attend une réponse",
failed: "Échec",
readyToReview: "À relire",
working: "En cours",
done: "Terminé",
},
display: {
trigger: "Préférences d’affichage",
heading: "Affichage",
+48
View File
@@ -387,6 +387,9 @@ export const ja: TranslationResources = {
completed: "完了",
},
},
turnFooter: {
workedFor: "作業時間 {{duration}}",
},
compaction: {
loading: "コンテキストを圧縮中...",
auto: "コンテキストが自動的に圧縮されました",
@@ -984,6 +987,44 @@ export const ja: TranslationResources = {
viewPullRequest: "表示",
openOn: "{{brand}}で開く",
},
checksOverview: {
headline: {
actionRequired: "対応が必要なチェックがあります",
failure: "失敗したチェックがあります",
pending: "完了していないチェックがあります",
success: "すべてのチェックに合格しました",
none: "チェックなし",
},
count: {
actionRequired: "{{count}} 件要対応",
warning: "{{count}} 件警告",
failure: "{{count}} 件失敗",
pending: "{{count}} 件実行中",
manual: "{{count}} 件手動",
success: "{{count}} 件成功",
ignored: "{{count}} 件スキップ",
},
detailOne: "チェック: {{parts}}",
detailMany: "チェック: {{parts}}",
groupOne: {
actionRequired: "要対応のチェック {{count}} 件",
warning: "警告のチェック {{count}} 件",
failure: "失敗したチェック {{count}} 件",
pending: "実行中のチェック {{count}} 件",
manual: "手動のチェック {{count}} 件",
success: "成功したチェック {{count}} 件",
ignored: "スキップされたチェック {{count}} 件",
},
groupMany: {
actionRequired: "要対応のチェック {{count}} 件",
warning: "警告のチェック {{count}} 件",
failure: "失敗したチェック {{count}} 件",
pending: "実行中のチェック {{count}} 件",
manual: "手動のチェック {{count}} 件",
success: "成功したチェック {{count}} 件",
ignored: "スキップされたチェック {{count}} 件",
},
},
checksSummary: {
passedLabel: "成功",
failedLabel: "失敗",
@@ -1101,6 +1142,13 @@ export const ja: TranslationResources = {
},
},
sidebar: {
statusBucket: {
needsInput: "入力待ち",
failed: "失敗",
readyToReview: "レビュー待ち",
working: "実行中",
done: "完了",
},
display: {
trigger: "表示設定",
heading: "表示",
+48
View File
@@ -383,6 +383,9 @@ export const ko: TranslationResources = {
completed: "완료됨",
},
},
turnFooter: {
workedFor: "작업 시간 {{duration}}",
},
compaction: {
loading: "압축하는 중...",
auto: "컨텍스트가 자동으로 압축되었습니다",
@@ -978,6 +981,44 @@ export const ko: TranslationResources = {
viewPullRequest: "보기",
openOn: "{{brand}}에서 열기",
},
checksOverview: {
headline: {
actionRequired: "확인이 필요한 검사가 있습니다",
failure: "실패한 검사가 있습니다",
pending: "아직 완료되지 않은 검사가 있습니다",
success: "모든 검사를 통과했습니다",
none: "검사 없음",
},
count: {
actionRequired: "{{count}}개 조치 필요",
warning: "{{count}}개 경고",
failure: "{{count}}개 실패",
pending: "{{count}}개 진행 중",
manual: "{{count}}개 수동",
success: "{{count}}개 성공",
ignored: "{{count}}개 건너뜀",
},
detailOne: "검사: {{parts}}",
detailMany: "검사: {{parts}}",
groupOne: {
actionRequired: "조치 필요 검사 {{count}}개",
warning: "경고 검사 {{count}}개",
failure: "실패한 검사 {{count}}개",
pending: "진행 중인 검사 {{count}}개",
manual: "수동 검사 {{count}}개",
success: "성공한 검사 {{count}}개",
ignored: "건너뛴 검사 {{count}}개",
},
groupMany: {
actionRequired: "조치 필요 검사 {{count}}개",
warning: "경고 검사 {{count}}개",
failure: "실패한 검사 {{count}}개",
pending: "진행 중인 검사 {{count}}개",
manual: "수동 검사 {{count}}개",
success: "성공한 검사 {{count}}개",
ignored: "건너뛴 검사 {{count}}개",
},
},
checksSummary: {
passedLabel: "통과",
failedLabel: "실패",
@@ -1095,6 +1136,13 @@ export const ko: TranslationResources = {
},
},
sidebar: {
statusBucket: {
needsInput: "입력 필요",
failed: "실패",
readyToReview: "검토 대기",
working: "실행 중",
done: "완료",
},
display: {
trigger: "표시 설정",
heading: "표시",
+48
View File
@@ -386,6 +386,9 @@ export const ptBR: TranslationResources = {
completed: "Concluída",
},
},
turnFooter: {
workedFor: "Trabalhou por {{duration}}",
},
compaction: {
loading: "Compactando...",
auto: "Contexto compactado automaticamente",
@@ -994,6 +997,44 @@ export const ptBR: TranslationResources = {
viewPullRequest: "Ver",
openOn: "Abrir no {{brand}}",
},
checksOverview: {
headline: {
actionRequired: "Algumas verificações precisam da sua atenção",
failure: "Algumas verificações não foram bem-sucedidas",
pending: "Algumas verificações ainda não terminaram",
success: "Todas as verificações foram aprovadas",
none: "Nenhuma verificação",
},
count: {
actionRequired: "{{count}} com ação pendente",
warning: "{{count}} com aviso",
failure: "{{count}} com falha",
pending: "{{count}} em andamento",
manual: "{{count}} manual(is)",
success: "{{count}} aprovada(s)",
ignored: "{{count}} ignorada(s)",
},
detailOne: "Verificação: {{parts}}",
detailMany: "Verificações: {{parts}}",
groupOne: {
actionRequired: "{{count}} verificação com ação pendente",
warning: "{{count}} verificação com aviso",
failure: "{{count}} verificação com falha",
pending: "{{count}} verificação em andamento",
manual: "{{count}} verificação manual",
success: "{{count}} verificação aprovada",
ignored: "{{count}} verificação ignorada",
},
groupMany: {
actionRequired: "{{count}} verificações com ação pendente",
warning: "{{count}} verificações com aviso",
failure: "{{count}} verificações com falha",
pending: "{{count}} verificações em andamento",
manual: "{{count}} verificações manuais",
success: "{{count}} verificações aprovadas",
ignored: "{{count}} verificações ignoradas",
},
},
checksSummary: {
passedLabel: "passou",
failedLabel: "falhou",
@@ -1114,6 +1155,13 @@ export const ptBR: TranslationResources = {
},
},
sidebar: {
statusBucket: {
needsInput: "Precisa de resposta",
failed: "Com falha",
readyToReview: "Para revisar",
working: "Em execução",
done: "Concluído",
},
display: {
trigger: "Preferências de exibição",
heading: "Exibição",
+48
View File
@@ -386,6 +386,9 @@ export const ru: TranslationResources = {
completed: "Завершена",
},
},
turnFooter: {
workedFor: "Время работы: {{duration}}",
},
compaction: {
loading: "Сжатие контекста...",
auto: "Контекст сжат автоматически",
@@ -988,6 +991,44 @@ export const ru: TranslationResources = {
viewPullRequest: "Просмотреть",
openOn: "Открыть на {{brand}}",
},
checksOverview: {
headline: {
actionRequired: "Некоторые проверки требуют внимания",
failure: "Некоторые проверки не пройдены",
pending: "Некоторые проверки ещё не завершены",
success: "Все проверки пройдены",
none: "Нет проверок",
},
count: {
actionRequired: "требует действий: {{count}}",
warning: "с предупреждением: {{count}}",
failure: "не пройдено: {{count}}",
pending: "выполняется: {{count}}",
manual: "вручную: {{count}}",
success: "пройдено: {{count}}",
ignored: "пропущено: {{count}}",
},
detailOne: "Проверка ({{parts}})",
detailMany: "Проверки ({{parts}})",
groupOne: {
actionRequired: "Требует действий: {{count}}",
warning: "С предупреждением: {{count}}",
failure: "Не пройдена: {{count}}",
pending: "Выполняется: {{count}}",
manual: "Ручная: {{count}}",
success: "Пройдена: {{count}}",
ignored: "Пропущена: {{count}}",
},
groupMany: {
actionRequired: "Требуют действий: {{count}}",
warning: "С предупреждением: {{count}}",
failure: "Не пройдены: {{count}}",
pending: "Выполняются: {{count}}",
manual: "Ручные: {{count}}",
success: "Пройдены: {{count}}",
ignored: "Пропущены: {{count}}",
},
},
checksSummary: {
passedLabel: "успешно",
failedLabel: "ошибка",
@@ -1105,6 +1146,13 @@ export const ru: TranslationResources = {
},
},
sidebar: {
statusBucket: {
needsInput: "Ожидает ввода",
failed: "Ошибка",
readyToReview: "На проверку",
working: "В работе",
done: "Готово",
},
display: {
trigger: "Настройки отображения",
heading: "Отображение",
+48
View File
@@ -382,6 +382,9 @@ export const zhCN: TranslationResources = {
completed: "已完成",
},
},
turnFooter: {
workedFor: "工作了 {{duration}}",
},
compaction: {
loading: "正在压缩...",
auto: "上下文已自动压缩",
@@ -963,6 +966,44 @@ export const zhCN: TranslationResources = {
viewPullRequest: "查看",
openOn: "在 {{brand}} 上打开",
},
checksOverview: {
headline: {
actionRequired: "部分检查需要你处理",
failure: "部分检查未通过",
pending: "部分检查尚未完成",
success: "所有检查均已通过",
none: "没有检查",
},
count: {
actionRequired: "{{count}} 个需处理",
warning: "{{count}} 个警告",
failure: "{{count}} 个失败",
pending: "{{count}} 个进行中",
manual: "{{count}} 个手动",
success: "{{count}} 个成功",
ignored: "{{count}} 个已跳过",
},
detailOne: "检查:{{parts}}",
detailMany: "检查:{{parts}}",
groupOne: {
actionRequired: "{{count}} 个需处理的检查",
warning: "{{count}} 个有警告的检查",
failure: "{{count}} 个失败的检查",
pending: "{{count}} 个进行中的检查",
manual: "{{count}} 个手动检查",
success: "{{count}} 个成功的检查",
ignored: "{{count}} 个已跳过的检查",
},
groupMany: {
actionRequired: "{{count}} 个需处理的检查",
warning: "{{count}} 个有警告的检查",
failure: "{{count}} 个失败的检查",
pending: "{{count}} 个进行中的检查",
manual: "{{count}} 个手动检查",
success: "{{count}} 个成功的检查",
ignored: "{{count}} 个已跳过的检查",
},
},
checksSummary: {
passedLabel: "通过",
failedLabel: "失败",
@@ -1080,6 +1121,13 @@ export const zhCN: TranslationResources = {
},
},
sidebar: {
statusBucket: {
needsInput: "需要输入",
failed: "失败",
readyToReview: "待查看",
working: "运行中",
done: "已完成",
},
display: {
trigger: "显示偏好",
heading: "显示",
@@ -1,3 +1,4 @@
import { i18n } from "@/i18n/i18next";
import { describe, expect, it } from "vitest";
import type {
SidebarProjectEntry,
@@ -211,6 +212,7 @@ describe("buildStatusSidebarShortcutModel", () => {
["p1", "Project 1"],
["p2", "Project 2"],
]),
i18n.t,
),
});
@@ -247,7 +249,7 @@ describe("buildStatusSidebarShortcutModel", () => {
];
const model = buildStatusSidebarShortcutModel({
groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])),
groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]]), i18n.t),
collapsedStatusGroupKeys: new Set(["needs_input"]),
});