Fix Control UI i18n tooltip placeholders

Summary:
- Render the Sessions active filter tooltip with the configured minute count instead of a literal N.
- Update all Control UI locale bundles and i18n translation memory rows to preserve the {count} placeholder.
- Add a placeholder parity guard to the Control UI i18n check with regression coverage.

Verification:
- pnpm ui:i18n:check
- pnpm test src/scripts/control-ui-i18n.test.ts ui/src/ui/views/sessions.test.ts
- git diff --check
- Testbox exact-head pnpm check:changed passed on prior rebased head 1333aac90b before latest main churn.
- GitHub CI on fd2068c378 only failed the pre-existing unrelated checks-node-core-fast timeout in src/auto-reply/reply/followup-delivery.test.ts:176, also present on recent main runs b31c001a2b and e5f5989aa9.
This commit is contained in:
Val Alexander
2026-05-04 03:18:34 -05:00
committed by GitHub
parent 281b5bd511
commit a1304c92c6
63 changed files with 220 additions and 90 deletions

View File

@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
@@ -13,6 +13,8 @@ interface TranslationMap {
[key: string]: string | TranslationMap;
}
type TranslationValue = string | { [key: string]: TranslationValue };
type LocaleEntry = {
exportName: string;
fileName: string;
@@ -357,6 +359,68 @@ function compareStringArrays(left: string[], right: string[]) {
return left.every((value, index) => value === right[index]);
}
export type PlaceholderMismatch = {
key: string;
locale: string;
sourcePlaceholders: string[];
translatedPlaceholders: string[];
};
function extractTranslationPlaceholders(text: string): string[] {
return [...new Set([...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1] ?? ""))]
.filter(Boolean)
.toSorted((left, right) => left.localeCompare(right));
}
export function findPlaceholderMismatches(
sourceFlat: ReadonlyMap<string, string>,
translatedFlat: ReadonlyMap<string, string>,
locale: string,
): PlaceholderMismatch[] {
const mismatches: PlaceholderMismatch[] = [];
for (const [key, sourceText] of sourceFlat.entries()) {
const sourcePlaceholders = extractTranslationPlaceholders(sourceText);
const translatedPlaceholders = extractTranslationPlaceholders(translatedFlat.get(key) ?? "");
if (!compareStringArrays(sourcePlaceholders, translatedPlaceholders)) {
mismatches.push({
key,
locale,
sourcePlaceholders,
translatedPlaceholders,
});
}
}
return mismatches;
}
function assertPlaceholderParity(
sourceFlat: ReadonlyMap<string, string>,
translatedFlat: ReadonlyMap<string, string>,
locale: string,
) {
const mismatches = findPlaceholderMismatches(sourceFlat, translatedFlat, locale);
if (mismatches.length === 0) {
return;
}
const details = mismatches
.slice(0, 20)
.map(
(mismatch) =>
`${mismatch.locale}:${mismatch.key} expected {${mismatch.sourcePlaceholders.join("},{")}} got {${mismatch.translatedPlaceholders.join("},{")}}`,
)
.join("\n");
throw new Error(
[
`control-ui-i18n placeholder mismatch detected for ${locale}.`,
details,
mismatches.length > 20 ? `...and ${mismatches.length - 20} more` : "",
]
.filter(Boolean)
.join("\n"),
);
}
function isIdentifier(value: string): boolean {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
}
@@ -1048,12 +1112,12 @@ class PiRpcClient {
private readonly stderrChunks: string[] = [];
private closed = false;
private pending: PendingPrompt | null = null;
private readonly process;
private readonly stdin;
private readonly process: ChildProcessWithoutNullStreams;
private readonly stdin: ChildProcessWithoutNullStreams["stdin"];
private requestCount = 0;
private sequence = Promise.resolve();
private sequence: Promise<unknown> = Promise.resolve();
private constructor(processHandle: ReturnType<typeof spawn>) {
private constructor(processHandle: ChildProcessWithoutNullStreams) {
this.process = processHandle;
this.stdin = processHandle.stdin;
}
@@ -1174,7 +1238,7 @@ class PiRpcClient {
}
async prompt(message: string, label: string): Promise<string> {
this.sequence = this.sequence.then(async () => {
const result = this.sequence.then(async () => {
if (this.closed) {
throw new Error(`pi process unavailable${this.stderr() ? ` (${this.stderr()})` : ""}`);
}
@@ -1236,7 +1300,8 @@ class PiRpcClient {
});
});
return (await this.sequence) as string;
this.sequence = result.catch(() => undefined);
return await result;
}
async close() {
@@ -1507,6 +1572,8 @@ async function syncLocale(
// legitimately stay identical to English. Track fallback keys from actual
// fallback decisions and previous fallback metadata instead.
assertPlaceholderParity(sourceFlat, nextFlat, entry.locale);
const nextMap: TranslationMap = {};
for (const [key, value] of sourceFlat.entries()) {
setNestedValue(nextMap, key, nextFlat.get(key) ?? value);
@@ -1698,7 +1765,14 @@ async function main() {
}
}
await main().catch((error) => {
console.error(formatErrorMessage(error));
process.exit(1);
});
function isCliEntrypoint() {
const entrypoint = process.argv[1];
return Boolean(entrypoint && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href);
}
if (isCliEntrypoint()) {
await main().catch((error) => {
console.error(formatErrorMessage(error));
process.exit(1);
});
}