diff --git a/docs/architecture.md b/docs/architecture.md index c7acc474..7a52e9cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,6 +61,8 @@ Extensions never see `PocketApp`. They get `PocketHost` (`host.ts`), whose funct Subagents leave Agent Teams out of every subagent (`extensions/subagents.ts`), even while it is off, so turning it on later gives no subagent a way to start sessions. +Enabled extensions may additionally register validated, data-only `uiLocales`; authenticated `hello` messages carry those packs to the browser. The client translates explicit UI strings, falling back to English for unknown keys. This does not translate user content or change server behavior. + ## PiP's conversation PiP (`pip.ts`) is a conversation with a `pip` mark in its session entry, which `sessions()` leaves out of every list. The harness settings select every installed extension but PiP's (`pocket-pip`) by default; PiP's conversation stores its own selection (`PIP_EXTENSIONS`, without bash, write, or edit), which `ensure` sets again each time it opens, so PiP's tools reach no other conversation. Its tools act through `Commands` and `Schedules` as PiP's person, with that person's checks, so `canSee` keeps every PiP from everyone but its person: anyone who could write to it would act as them. Its messages to sessions are once per tool call (a request id hashed from the call's key), and each follow leaves a mark in its list (`FollowsDoc`) when it reports or is stopped, so a replayed call neither sends nor follows twice. A tab's PiP panel is a second client in PiP's room (`setPip`), whose `view` events reach the tab as `pipview`; `watching` counts it, so PiP's answers push nothing while it shows. diff --git a/docs/examples/zh-cn-ui.ts b/docs/examples/zh-cn-ui.ts new file mode 100644 index 00000000..71517619 --- /dev/null +++ b/docs/examples/zh-cn-ui.ts @@ -0,0 +1,99 @@ +/** A small data-only Simplified Chinese UI locale pack. It translates the fixed Pocket UI text that its own tests exercise; a complete dictionary belongs in a separate extension. */ +import { defineExtension } from "@earendil-works/pi-durable"; + +// Keys are the exact English source strings used by t() at the UI call sites. A key that +// is absent here, and every user-written or model-written string, stays English. + +const strings: Record = { + Menu: "菜单", + Files: "文件", + Extensions: "扩展", + Appearance: "外观", + Discover: "发现", + "Installed extensions": "已安装扩展", + "Install theme": "安装主题", + "Install (stays off)": "安装(保持关闭)", + "Index settings and status": "索引设置与状态", + "Technical details": "技术详情", + Installed: "已安装", + "Installed at": "安装时间", + "Where these come from": "这些内容的来源", + "Extensions install turned off. Nothing runs until you turn it on.": + "安装后保持关闭,启用前不会运行。", + On: "开启", + Revoked: "已撤销", + "View source": "查看源码", + Update: "更新", + Updates: "更新", + "Reply speed": "回复速度", + "Status line": "状态栏", + "Mark unread": "标记为未读", + "Mark read": "标记为已读", + Select: "选择", + Sessions: "会话", + "New session": "新会话", + People: "成员", + Memory: "记忆", + Providers: "模型提供方", + Model: "模型", + Save: "保存", + Cancel: "取消", + Close: "关闭", + Search: "搜索", + Rename: "重命名", + Send: "发送", + Stop: "停止", + Copy: "复制", + Theme: "主题", + Edit: "编辑", + Done: "完成", + Loading: "加载中", + "No matches.": "没有匹配项。", + "Try again": "重试", + "Turn off": "关闭", + "Turn on": "开启", + "Sign out": "退出登录", + New: "新建", + Open: "打开", + Remove: "移除", + Add: "添加", + Back: "返回", + Name: "名称", + Details: "详情", + Install: "安装", + Uninstall: "卸载", + Running: "运行中", + Context: "上下文", +}; + +const templates: Record = { + "{{count}} entries need a newer Pi Pocket.": "{{count}} 个条目需要更新版本的 Pi Pocket。", + "{{count}} entry needs a newer Pi Pocket.": "{{count}} 个条目需要更新版本的 Pi Pocket。", + "Update {{title}}": "更新 {{title}}", + "Source: {{title}}": "源码:{{title}}", + "{{rate}} tokens a second": "每秒 {{rate}} 个 token", + "its first token after {{seconds}}s": "首个 token 耗时 {{seconds}} 秒", + "in {{seconds}}s": "{{seconds}} 秒后", + "How fast the last answer came: {{detail}}": "上一条回复的生成速度:{{detail}}", + "Could not attach {{name}}: {{error}}": "无法附加 {{name}}:{{error}}", + "Back to {{tab}}": "返回{{tab}}", + "{{model}} (not signed in)": "{{model}}(未登录)", + "{{verb}} {{what}}. {{action}} to undo.": "{{verb}} {{what}}。{{action}}可撤销。", + "{{count}} allowed: {{models}}": "已允许 {{count}} 个:{{models}}", + "Back to sessions, {{count}} waiting for you": "返回会话列表,{{count}} 项待你处理", +}; + +export default function createZhCnUiLocale() { + return { + ...defineExtension({ name: "zh-cn-ui" }), + uiLocales: [ + { + locale: "zh-CN", + label: "简体中文", + default: true, + strings, + templates, + }, + ], + }; +} diff --git a/docs/extensions.md b/docs/extensions.md index e19163fa..b4297af2 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -99,6 +99,41 @@ These examples work. `npm test` runs them (`test/docs-examples.test.ts`): - [examples/no-force-push.ts](examples/no-force-push.ts): a hook that stops some bash calls, and a prompt section that tells Pi so. - [examples/read-page.ts](examples/read-page.ts): a tool that reads a public web page with `host.fetchPublic`, and fences its text with `host.text`. - [examples/server-status.ts](examples/server-status.ts) with [examples/server-status.js](examples/server-status.js): a page on the rail, with a route that reads the machine's load and memory, and events that keep it live. +- [examples/zh-cn-ui.ts](examples/zh-cn-ui.ts): a data-only Simplified Chinese UI locale pack. + +## UI locale packs (data only) + +A module may return optional `uiLocales` data with its ordinary Pi Durable extensions. Locale packs translate fixed Pocket UI text; they do not translate user-authored text, model output, provider names, or arbitrary HTML. The module itself is still ordinary server code: it runs with the server's rights, while its locale data is validated and sent as data, not as browser JavaScript. + +```typescript +import { defineExtension } from "@earendil-works/pi-durable"; + +export default function createUiLocale() { + return { + ...defineExtension({ name: "example-ui-locale" }), + uiLocales: [ + { + locale: "zh-CN", + label: "简体中文", + default: true, + strings: { Files: "文件" }, + templates: { + "Back to sessions, {{count}} waiting for you": + "返回会话列表,{{count}} 项待你处理", + }, + }, + ], + }; +} +``` + +- Use canonical BCP 47 tags and exact English source strings as keys. `strings` is for text without parameters; `templates` is for source text with named `{{placeholders}}`. +- The browser translates only where its code explicitly calls `t("Files")` or `t("Back to sessions, {{count}} waiting for you", { count })`. A missing key stays English. A translated template must have the same placeholder names as its source; values are inserted as-is and are not translated again. This is not a page-wide translation pass. +- The loader validates the locale data and rejects an invalid pack or a locale tag already registered by another enabled module. Limits may change; use canonical tags and do not rely on undocumented size or entry limits. +- A pack marked `default: true` is preferred among packs that are already enabled; it does not turn a module on. Without an enabled pack, the UI uses English. There is no per-person language setting or saved locale choice. +- Packs reach signed-in clients in `hello.server.uiLocales` and the authenticated `/api/me` response. There is no public dictionary endpoint. The locale data itself adds no route or external fetch. + +A module may also call `host.notice(level, message, conversationId, { key, params })` to give a notice an optional UI translation key and parameters. The original `message` remains in the server log and in the notice payload; the metadata is only for presentation. ## Rules that are easy to break diff --git a/docs/extensions/reference/host.md b/docs/extensions/reference/host.md index f9ce9aa1..4b9e9363 100644 --- a/docs/extensions/reference/host.md +++ b/docs/extensions/reference/host.md @@ -197,12 +197,12 @@ Calls `listener` each time the owner changes a spend limit. It returns a functio ### `notice` ```ts -notice(level: NoticeLevel, message: string, conversationId?: ConversationId): void; +notice(level: NoticeLevel, message: string, conversationId?: ConversationId, ui?: { key: string; params?: Record }): void; ``` Since 0.1.0 · Stable -Reports something to the server's log and to the app. `level` is `"info"`, `"warning"`, or `"error"`. With `conversationId`, only the tabs of that session show the notice. Pi Pocket 0.13.1 added `conversationId`. +Reports something to the server's log and to the app. `level` is `"info"`, `"warning"`, or `"error"`. With `conversationId`, only the tabs of that session show the notice. The optional fourth argument adds a data-only UI translation key and parameters; the original message remains in logs and payloads. Pi Pocket 0.13.1 added `conversationId`; UI locale metadata is experimental. ### `text` diff --git a/docs/extensions/reference/types.md b/docs/extensions/reference/types.md index 853d5e1d..03ac300a 100644 --- a/docs/extensions/reference/types.md +++ b/docs/extensions/reference/types.md @@ -47,7 +47,7 @@ The types file imports types from Pi Durable, Pi AI, and Preact by package name. - [`Web`](web.md) and the types that it uses: `WebAccess`, `WebMethod`, `WebHandler`, and `WebRequest`. - [`Command`, `CommandContext`, and `CommandResult`](command-context.md). - [`Pocket`](pocket.md) and the types that it uses: `BrowserModule`, `Action`, the objects that you give (`RailSpec`, `MenuSpec`, `LauncherSpec`, `PageSpec`, `SheetSpec`, `ToolCallSpec`, `CallSummary`), `Added`, `PageProps`, `SheetProps`, and the props of the pieces. -- `Module`, `Person`, `Role`, and `NoticeLevel`, on this page. +- `Module`, `PocketExtension`, `PocketUiLocale`, `Person`, `Role`, and `NoticeLevel`, on this page. The types file does not have the internal members of the host. It does not have Pi Durable's own API either: import `defineExtension`, `defineTool`, and the rest from `@earendil-works/pi-durable`. @@ -78,6 +78,36 @@ Since 0.1.0 · Stable Sets up the module. It gets the [host](host.md) and [`web`](web.md). It returns the module's extensions: one, a list, or nothing when the module only adds to the app. It can be async. Pi Pocket 0.14.0 added `web`, async setup, and modules that return nothing. +The returned extension may also include `uiLocales`, a list of validated, data-only UI translation packs. They translate only explicit `t()` calls; missing entries remain English. Packs do not run browser code, add routes, or enable the module. This API is experimental until released; see [UI locale packs](../../extensions.md#ui-locale-packs-data-only). + +### `PocketExtension` + +```ts +type PocketExtension = Extension & { + readonly uiLocales?: readonly PocketUiLocale[]; +}; +``` + +Experimental · Not yet released + +Pi Pocket's extension shape, with optional locale data. It wraps an ordinary Pi Durable `Extension`. + +### `PocketUiLocale` + +```ts +interface PocketUiLocale { + readonly locale: string; + readonly label: string; + readonly default?: boolean; + readonly strings: Readonly>; + readonly templates?: Readonly>; +} +``` + +Experimental · Not yet released + +A canonical language tag and data-only dictionaries. `strings` holds exact source strings without parameters; `templates` holds exact source strings with named `{{placeholders}}`. The loader validates their shapes and enforces placeholder parity. Dynamic values are inserted without translation. See [UI locale packs](../../extensions.md#ui-locale-packs-data-only) for full semantics and limits. + ### `pocketApi` ```ts diff --git a/docs/index.md b/docs/index.md index 9da74c6e..1a69a2a9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,7 +32,7 @@ A drop-in extension stays when Pi Pocket updates, and the owner can turn it off ## The documents - [customizing.md](customizing.md): how to change how Pi works, without code. -- [extensions.md](extensions.md): how to write extensions: tools, prompt sections, hooks, and pages, buttons, and routes in the app. It is an index: read it, then the guides in [extensions/](extensions/) that your task needs. +- [extensions.md](extensions.md): how to write extensions: tools, prompt sections, hooks, UI locale packs, and pages, buttons, and routes in the app. It is an index: read it, then the guides in [extensions/](extensions/) that your task needs. - The members of `host`, `web`, and `pocket` are in [extensions/reference/](extensions/reference/host.md). - The exact types are in [types/pocket.d.ts](../types/pocket.d.ts). - Working examples are in [examples/](examples/). diff --git a/docs/map.md b/docs/map.md index d3ca7209..0a5beaba 100644 --- a/docs/map.md +++ b/docs/map.md @@ -151,6 +151,7 @@ Plain ES modules, loaded as they are. `store.js` holds the shared state, and not | File | Owns | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `i18n.js` | Explicit fixed-UI translation through `t()`, validated locale packs, and live English fallback | | `store.js` | State, the event stream (SSE, or long polling when a tunnel holds it back), `api()`, `actions`, `workspaceOrder` (the numbers, by `session-tree.js`) | | `live.js` | The message Pi is writing, put together from view updates that carry what changed (`liveMessage`); it imports nothing, so the server's tests run it | | `back.js` | The back stack: routes and the layers over them (sheets, the drawer, panels that cover the screen, a file over the tree) as history entries, so back closes what opened last; `useBack`, `pushRoute`, `goHome`, the list put under a conversation opened straight away, taps let go that land on what just slid in | diff --git a/src/server/app.ts b/src/server/app.ts index ae5db95c..b85d8516 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -647,7 +647,8 @@ export class PocketApp { }, fetchPublic: createPublicFetch(), requesterOf: (conversationId) => this.attribution.requesterOf(conversationId), - notice: (level, message, conversationId) => this.notice(level, message, conversationId), + notice: (level, message, conversationId, ui) => + this.notice(level, message, conversationId, ui), heldBack: (conversationId) => this.spend.heldBack(conversationId), onLimitsChanged: (listener) => this.spend.onLimitsChanged(listener), linkedSessions: this.linkedSessions, @@ -684,8 +685,13 @@ export class PocketApp { host, { builtIn: join(APP_ROOT, "src", "server", "extensions"), dropIn }, (file) => this.config.extensionChoice(file), - // What a module's load says can name the server's folders, or quote a provider: the owner's to read. - { webs: this.webs, notify: (level, message) => this.ownerNotice(level, message) }, + { + // What a module's load says can name the server's folders, or quote a provider: the owner's to read. + webs: this.webs, + notify: (level, message) => this.ownerNotice(level, message), + // Locale packs travel only in authenticated hello data, so watched edits refresh that data too. + refreshClients: () => this.#refreshClients(), + }, ); await this.themes.load().catch((error: unknown) => { this.#log(`Themes could not be read: ${describe(error)}`); @@ -1100,6 +1106,7 @@ export class PocketApp { level: "info" | "warning" | "error", message: string, conversationId?: ConversationId, + ui?: { key: string; params?: Record }, ): void { this.#log(`[${level}] ${message}`); @@ -1114,7 +1121,7 @@ export class PocketApp { this.canSee(client.user, conversationId); if (reaches) { - client.send("notice", { level, message }); + client.send("notice", { level, message, ...(ui === undefined ? {} : { ui }) }); } } } @@ -1862,6 +1869,8 @@ export class PocketApp { home: homedir(), defaultCwd: this.defaultCwd, extensions: this.loader.extensionNames(), + // Only validated translation data, for explicit UI strings; clients receive this through authenticated hello. + uiLocales: this.loader.uiLocales(), // The browser modules this person's tabs load (`extension-ui.js`), each with the tools its module // installs: the only ones whose calls it may describe (`pocket.toolCall`). ui: this.#browserModules(user), @@ -2600,6 +2609,13 @@ export class PocketApp { this.notice( enabled ? "info" : "warning", `${user.name} turned ${module.title} ${enabled ? "on" : "off"}.`, + undefined, + { + key: enabled + ? "{{name}} turned {{extension}} on." + : "{{name}} turned {{extension}} off.", + params: { name: user.name, extension: module.title }, + }, ); } @@ -2679,6 +2695,14 @@ export class PocketApp { rule === "others" ? `${user.name} made approvals need someone other than who asked.` : `${user.name} let anyone who can steer allow risky calls.`, + undefined, + { + key: + rule === "others" + ? "{{name}} made approvals need someone other than who asked." + : "{{name}} let anyone who can steer allow risky calls.", + params: { name: user.name }, + }, ); } diff --git a/src/server/host.ts b/src/server/host.ts index 6eba4d14..894006e1 100644 --- a/src/server/host.ts +++ b/src/server/host.ts @@ -100,6 +100,25 @@ export interface ApprovalRequest { export type ApprovalAnswer = { allow: boolean; by: string }; +/** A data-only translation pack for explicit UI strings and safe placeholder templates. */ +export interface PocketUiLocale { + /** Canonical BCP 47 language tag, such as `zh-CN`. */ + readonly locale: string; + /** Human-readable name shown by the host when listing available languages. */ + readonly label: string; + /** Prefer this pack when more than one locale is enabled; ties go to the smallest locale tag. */ + readonly default?: boolean; + /** Exact source UI strings mapped to their translations. */ + readonly strings: Readonly>; + /** Exact source templates with `{{name}}` placeholders mapped to translated templates. */ + readonly templates?: Readonly>; +} + +/** A Pi Durable extension with optional data-only user-interface translations. */ +export type PocketExtension = Extension & { + readonly uiLocales?: readonly PocketUiLocale[]; +}; + /** * Tool calls waiting for a human. In memory on purpose: the guard hook runs before a call's intent is stored, so after * a restart the hook runs again and asks again, unless its memo already holds the answer. @@ -282,6 +301,7 @@ export interface PocketHost { level: "info" | "warning" | "error", message: string, conversationId?: ConversationId, + ui?: { key: string; params?: Record }, ): void; /** * Why work Pi would start in a conversation with nobody asking (a subagent's report, say) may not start now: the @@ -475,7 +495,11 @@ export type ExtensionModule = { default: ( host: PocketHost, web: ExtensionWeb, - ) => Extension | readonly Extension[] | void | Promise; + ) => + | PocketExtension + | readonly PocketExtension[] + | void + | Promise; /** The version of the extension API the module needs, when it says: a newer one than the app's is not loaded. */ pocketApi?: unknown; }; diff --git a/src/server/reload.ts b/src/server/reload.ts index e455036b..1b580ba5 100644 --- a/src/server/reload.ts +++ b/src/server/reload.ts @@ -30,7 +30,13 @@ import { pathToFileURL } from "node:url"; import type { Extension, Registry } from "@earendil-works/pi-durable"; import { describe } from "./errors.ts"; import { ExtensionWebs, OLDEST_POCKET_API, POCKET_API } from "./extension-web.ts"; -import type { ExtensionModule, PocketHost, WebAccess } from "./host.ts"; +import type { + ExtensionModule, + PocketExtension, + PocketHost, + PocketUiLocale, + WebAccess, +} from "./host.ts"; /** * How long a module may take to load (its import, and an async default export) before its load fails. At the server's @@ -282,6 +288,262 @@ function keepLink(link: string, target: string, type: "dir" | "file" | "junction symlinkSync(target, link, type); } +const MAX_UI_LOCALES = 16; +const MAX_UI_ENTRIES = 5000; +const MAX_UI_BYTES = 512 * 1024; +const UI_LOCALE_FIELDS = new Set(["locale", "label", "default", "strings", "templates"]); + +function plainRecord(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + + return prototype === Object.prototype || prototype === null; +} + +/** Read enumerable own data properties without invoking accessors. */ +function dataProperties( + value: Record, + file: string, + field: string, + allowed?: ReadonlySet, +): Map { + const descriptors = Object.getOwnPropertyDescriptors(value); + const result = new Map(); + + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== "string" || (allowed !== undefined && !allowed.has(key))) { + throw new Error(`${file} UI locale ${field} has an unknown field`); + } + + const descriptor = descriptors[key]!; + + if (!descriptor.enumerable || !("value" in descriptor)) { + throw new Error( + `${file} UI locale ${field} must contain only enumerable data properties`, + ); + } + + result.set(key, descriptor.value); + } + + return result; +} + +function checkedArray(value: unknown, file: string, field: string, limit: number): unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + throw new Error(`${file} UI locale ${field} must be a plain array`); + } + + const descriptors: Record = Object.getOwnPropertyDescriptors(value); + const lengthDescriptor = descriptors.length; + const length = lengthDescriptor?.value; + + if ( + lengthDescriptor === undefined || + !("value" in lengthDescriptor) || + !Number.isSafeInteger(length) || + length < 0 || + length > limit + ) { + throw new Error(`${file} UI locale ${field} has too many entries`); + } + + if (Reflect.ownKeys(descriptors).length !== length + 1) { + throw new Error(`${file} UI locale ${field} must be a dense array without extra fields`); + } + + const result: unknown[] = []; + + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors[String(index)]; + + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new Error(`${file} UI locale ${field} must be a dense array of data values`); + } + + result.push(descriptor.value); + } + + return result; +} + +function checkedStrings( + value: unknown, + file: string, + field: string, +): Readonly> { + if (!plainRecord(value)) { + throw new Error(`${file} UI locale ${field} must be a plain object of strings`); + } + + const entries = dataProperties(value, file, field); + + if (entries.size > MAX_UI_ENTRIES) { + throw new Error(`${file} UI locale ${field} has too many entries`); + } + + const result: Record = Object.create(null); + let bytes = 0; + + for (const [key, text] of entries) { + if (typeof text !== "string") { + throw new Error(`${file} UI locale ${field} values must be plain strings`); + } + + if (key.length === 0 || key.length > 1000 || text.length > 4000) { + throw new Error(`${file} UI locale ${field} contains an entry that is too long`); + } + + bytes += Buffer.byteLength(key) + Buffer.byteLength(text); + + if (bytes > MAX_UI_BYTES) { + throw new Error(`${file} UI locale ${field} is too large`); + } + + result[key] = text; + } + + return Object.freeze(result); +} + +function normalizeUiLocales( + file: string, + extensions: readonly PocketExtension[], +): PocketExtension[] { + const seen = new Set(); + let count = 0; + + return extensions.map((extension) => { + const descriptor = Object.getOwnPropertyDescriptor(extension, "uiLocales"); + + if (descriptor === undefined) { + if ("uiLocales" in extension) { + throw new Error(`${file} uiLocales must be an own data property`); + } + + return extension; + } + + if (!descriptor.enumerable || !("value" in descriptor)) { + throw new Error(`${file} uiLocales must be an array of data-only locale packs`); + } + + const rawLocales = checkedArray(descriptor.value, file, "uiLocales", MAX_UI_LOCALES); + const locales = rawLocales.map((raw: unknown) => { + if (!plainRecord(raw)) { + throw new Error(`${file} UI locale entries must be plain objects`); + } + + const fields = dataProperties(raw, file, "entry", UI_LOCALE_FIELDS); + const locale = fields.get("locale"); + const label = fields.get("label"); + const preferred = fields.get("default"); + + if (typeof locale !== "string" || locale.length > 35) { + throw new Error(`${file} UI locale needs a valid BCP 47 language tag`); + } + + let canonical: string; + + try { + canonical = Intl.getCanonicalLocales(locale)[0] ?? ""; + } catch { + canonical = ""; + } + + if (canonical === "" || canonical !== locale) { + throw new Error( + `${file} UI locale ${locale} must be a canonical BCP 47 language tag`, + ); + } + + if (seen.has(locale)) { + throw new Error(`${file} registers the ${locale} UI locale more than once`); + } + + seen.add(locale); + count += 1; + + if (count > MAX_UI_LOCALES) { + throw new Error(`${file} registers too many UI locales`); + } + + if (typeof label !== "string" || label.trim() === "" || label.length > 80) { + throw new Error(`${file} UI locale ${locale} needs a short label`); + } + + if (fields.has("default") && typeof preferred !== "boolean") { + throw new Error(`${file} UI locale ${locale} default must be true or false`); + } + + const strings = checkedStrings(fields.get("strings"), file, `${locale} strings`); + const templates = fields.has("templates") + ? checkedStrings(fields.get("templates"), file, `${locale} templates`) + : undefined; + + for (const [source, translated] of Object.entries(templates ?? {})) { + const placeholders = (text: string) => + [ + ...new Set( + [...text.matchAll(/\{\{([A-Za-z][A-Za-z0-9_]*)\}\}/g)].map( + (match) => match[1], + ), + ), + ].sort(); + + if ( + JSON.stringify(placeholders(source)) !== + JSON.stringify(placeholders(translated)) + ) { + throw new Error( + `${file} UI locale ${locale} template placeholders must match the source`, + ); + } + } + + const serializedBytes = Buffer.byteLength( + JSON.stringify({ + locale, + label, + ...(fields.has("default") ? { default: preferred } : {}), + strings, + ...(templates === undefined ? {} : { templates }), + }), + ); + + if (serializedBytes > MAX_UI_BYTES) { + throw new Error(`${file} UI locale ${locale} is too large`); + } + + return Object.freeze({ + locale, + label, + ...(fields.has("default") ? { default: preferred as boolean } : {}), + strings, + ...(templates === undefined ? {} : { templates }), + }); + }); + + const normalized = { ...extension, uiLocales: Object.freeze(locales) }; + + Object.defineProperty(normalized, "uiLocales", { writable: false, configurable: false }); + + return normalized; + }); +} + +type ExtensionLoaderOptions = { + webs?: ExtensionWebs; + notify?: (level: "info" | "warning" | "error", message: string) => void; + /** Refresh authenticated hello data after a watched module changes. */ + refreshClients?: () => Promise; + /** How long a load may take; tests make it short. */ + loadLimitMs?: number; +}; + export class ExtensionLoader { readonly #registry: Registry; readonly #host: PocketHost; @@ -289,8 +551,9 @@ export class ExtensionLoader { readonly #dropIn: string | undefined; readonly #choice: (file: string) => boolean | undefined; readonly #webs: ExtensionWebs; + readonly #refreshClients: () => Promise; /** Extension names each file installed, to uninstall the ones a new version no longer provides. */ - readonly #installed = new Map(); + readonly #installed = new Map(); readonly #errors = new Map(); readonly #timers = new Map(); /** Drop-ins already reported for having a built-in module's name, so each is reported once. */ @@ -320,21 +583,19 @@ export class ExtensionLoader { host: PocketHost, folders: { builtIn: string; dropIn?: string }, choice: (file: string) => boolean | undefined = () => undefined, - options: { - webs?: ExtensionWebs; - notify?: (level: "info" | "warning" | "error", message: string) => void; - /** How long a load may take; tests make it short. */ - loadLimitMs?: number; - } = {}, + options: ExtensionLoaderOptions | (() => Promise) = {}, ) { + const settings = typeof options === "function" ? { refreshClients: options } : options; + this.#registry = registry; this.#host = host; this.#builtIn = folders.builtIn; this.#dropIn = folders.dropIn; this.#choice = choice; - this.#webs = options.webs ?? new ExtensionWebs(); - this.#notify = options.notify ?? ((level, message) => host.notice(level, message)); - this.#loadLimitMs = options.loadLimitMs ?? LOAD_LIMIT_MS; + this.#webs = settings.webs ?? new ExtensionWebs(); + this.#notify = settings.notify ?? ((level, message) => host.notice(level, message)); + this.#refreshClients = settings.refreshClients ?? (async () => {}); + this.#loadLimitMs = settings.loadLimitMs ?? LOAD_LIMIT_MS; } /** Every module, built-in ones first in install order. A drop-in with a built-in's name is left out. */ @@ -547,7 +808,18 @@ export class ExtensionLoader { return [...this.#installed.values()].flat().map((extension) => extension.name); } - async #load(file: string, cacheBust: boolean, until?: number): Promise { + /** The data-only locale packs registered by enabled modules, in deterministic preference order. */ + uiLocales(): PocketUiLocale[] { + return [...this.#installed.values()] + .flatMap((extensions) => extensions.flatMap((extension) => extension.uiLocales ?? [])) + .sort( + (a, b) => + Number(b.default === true) - Number(a.default === true) || + (a.locale < b.locale ? -1 : a.locale > b.locale ? 1 : 0), + ); + } + + async #load(file: string, cacheBust: boolean, until?: number): Promise { const module = this.#module(file); if (module === undefined) { @@ -583,7 +855,7 @@ export class ExtensionLoader { generation: number, cacheBust: boolean, deadline: number, - ): Promise { + ): Promise { const { file } = module; // A load waits for the module (its import, and an async default export), but not forever: one that never settles // would hold up the server's start, and every load after it. @@ -631,9 +903,14 @@ export class ExtensionLoader { this.#stillLatest(file, generation); - const extensions = ( - built === undefined || built === null ? [] : Array.isArray(built) ? built : [built] - ) as Extension[]; + const extensions = normalizeUiLocales( + file, + (built === undefined || built === null + ? [] + : Array.isArray(built) + ? built + : [built]) as PocketExtension[], + ); // A default export that forgot to return its extensions would load as nothing at all. if ( @@ -662,6 +939,8 @@ export class ExtensionLoader { this.#checkNames(file, extensions); } + this.#checkUiLocaleNames(file, extensions); + // A new array, so that one the module froze or keeps is not changed. const installed = module.source === "drop-in" ? extensions.map(keepsContent) : extensions; @@ -840,6 +1119,55 @@ export class ExtensionLoader { } } + #checkUiLocaleNames(file: string, extensions: readonly PocketExtension[]): void { + const owners = new Map(); + let count = 0; + + for (const [other, installed] of this.#installed) { + if (other === file) { + continue; + } + + for (const extension of installed) { + for (const locale of extension.uiLocales ?? []) { + owners.set(locale.locale, other); + count += 1; + } + } + } + + for (const extension of extensions) { + for (const locale of extension.uiLocales ?? []) { + const other = owners.get(locale.locale); + + if (other !== undefined) { + throw new Error( + `${other} already registers the ${locale.locale} UI locale: choose another locale`, + ); + } + + count += 1; + + if (count > MAX_UI_LOCALES) { + throw new Error( + `${file} would register too many UI locales (maximum ${MAX_UI_LOCALES})`, + ); + } + + owners.set(locale.locale, file); + } + } + } + + #notifyClients(): void { + void this.#refreshClients().catch((error: unknown) => + this.#notify( + "warning", + `Could not refresh clients after an extension change: ${describe(error)}`, + ), + ); + } + /** Watch both folders and reload a module shortly after it changes. Keeps the old code when the new one fails. */ watch(): void { this.#webs.watch(); @@ -908,6 +1236,7 @@ export class ExtensionLoader { this.#uninstall(file); if (installed) { + this.#notifyClients(); this.#notify( "info", directory === this.#dropIn @@ -929,19 +1258,22 @@ export class ExtensionLoader { this.#reload(file).then( (extensions) => { if (extensions !== undefined) { + this.#notifyClients(); this.#notify( "info", `Reloaded ${file}: ${extensions.map((extension) => extension.name).join(", ") || "it adds to the app"}`, ); } }, - (error: unknown) => + (error: unknown) => { + this.#notifyClients(); this.#notify( "error", loaded ? `Kept the previous ${file}; the edited one failed to load: ${describe(error)}` : `${file} failed to load: ${describe(error)}`, - ), + ); + }, ); } diff --git a/test/artifact-inline.test.ts b/test/artifact-inline.test.ts index 787bea59..ae5eae82 100644 --- a/test/artifact-inline.test.ts +++ b/test/artifact-inline.test.ts @@ -484,6 +484,7 @@ test("one tap serves one follow-up: no other event of the same tap gives it back listener({ isTrusted: true, key: "a", ...fields }); } }; + const ask = (text: string) => (window.parent as typeof parent).postMessage({ type: "pocket-follow-up", text }, "*"); diff --git a/test/docs-examples.test.ts b/test/docs-examples.test.ts index 17c2a5a9..d2c35ab8 100644 --- a/test/docs-examples.test.ts +++ b/test/docs-examples.test.ts @@ -1,4 +1,4 @@ -// The documentation's example extensions work as drop-ins. +// The documentation's example modules work when installed as owner-managed drop-ins. import { cleanUp, lastText, @@ -92,7 +92,7 @@ after(async () => { /** What Pi's last answer in a session says. */ const answer = async (id: Parameters[1]) => (await modelTexts(app, id)).at(-1) ?? ""; -test("the example extensions load as drop-ins, off until the owner turns them on, and work", async () => { +test("the example modules load as drop-ins, off until the owner turns them on, and work", async () => { const listed = (await app.extensions(owner(app))).modules.filter( (module) => module.source === "drop-in", ); @@ -105,6 +105,7 @@ test("the example extensions load as drop-ins, off until the owner turns them on ["no-force-push.ts", false], ["read-page.ts", false], ["server-status.ts", false], + ["zh-cn-ui.ts", false], ], ); @@ -114,10 +115,15 @@ test("the example extensions load as drop-ins, off until the owner turns them on "no-force-push.ts", "read-page.ts", "server-status.ts", + "zh-cn-ui.ts", ]) { await app.setExtensionEnabled(owner(app), file, true); } + const locales = (await app.hello(owner(app))).server.uiLocales; + + assert.equal(locales.find((locale) => locale.locale === "zh-CN")?.strings.Files, "文件"); + const id = await newSession(app); await say(app, id, "what time is it in UTC?"); diff --git a/test/dropins.test.ts b/test/dropins.test.ts index 694edc86..292143f3 100644 --- a/test/dropins.test.ts +++ b/test/dropins.test.ts @@ -1,4 +1,4 @@ -// Drop-in extensions: the owner's own modules in the data folder load like built-in ones, but only once turned on. +// Owner-managed extension modules in the data folder: drop-ins load like built-ins, but only once turned on. import { type App, cleanUp, @@ -65,11 +65,30 @@ const route: FauxResponseStep = (request) => { const model = scriptedModel(route); +/** A drop-in that registers a data-only UI translation pack. */ +const uiLocale = (translation: string) => `/** Registers a test UI locale. */ +import { defineExtension } from "@earendil-works/pi-durable"; + +export default () => ({ + ...defineExtension({ name: "test-ui-locale" }), + uiLocales: [ + { + locale: "zh-CN", + label: "简体中文", + default: true, + strings: { Extensions: ${JSON.stringify(translation)} }, + templates: { "Back, {{count}} waiting": "返回,{{count}} 项待处理" }, + }, + ], +}); +`; + let app: App; before(async () => { mkdirSync(dropIns, { recursive: true }); writeFileSync(join(dropIns, "shout.ts"), shout("!")); + writeFileSync(join(dropIns, "ui-locale.ts"), uiLocale("扩展")); // The name of a built-in module: never loaded in its place. writeFileSync( join(dropIns, "plan.ts"), @@ -188,6 +207,82 @@ test("a drop-in that is on gives Pi its tools and hooks, reloads when edited, an } }); +test("enabled drop-ins publish data-only UI locales and edits or removal update every client's hello", async () => { + await app.setExtensionEnabled(owner(app), "ui-locale.ts", true); + + const hello = await app.hello(owner(app)); + + assert.deepEqual( + hello.server.uiLocales.map(({ locale, label }) => ({ locale, label })), + [{ locale: "zh-CN", label: "简体中文" }], + ); + assert.equal(hello.server.uiLocales[0]?.strings.Extensions, "扩展"); + + writeFileSync( + join(dropIns, "locale-conflict.ts"), + uiLocale("冲突").replace('name: "test-ui-locale"', 'name: "conflicting-locale"'), + ); + await assert.rejects( + app.setExtensionEnabled(owner(app), "locale-conflict.ts", true), + /already registers the zh-CN UI locale/, + ); + await app.setExtensionEnabled(owner(app), "locale-conflict.ts", false); + + const tab = fakeTab(undefined, owner(app)); + + await app.attach(tab.client); + + try { + writeFileSync(join(dropIns, "ui-locale.ts"), uiLocale("界面扩展")); + await until( + () => + tab.events.some( + (event) => + event.event === "hello" && + (event.data.server as { uiLocales: { strings: Record }[] }) + .uiLocales[0]?.strings.Extensions === "界面扩展", + ), + "edited locale data to reach connected clients", + ); + + rmSync(join(dropIns, "ui-locale.ts")); + await until( + () => + tab.events.some( + (event) => + event.event === "notice" && + String(event.data.message).includes( + "Removed the drop-in extension ui-locale.ts.", + ), + ), + "the locale pack to be uninstalled", + ); + assert.deepEqual(app.loader.uiLocales(), []); + const latestHello = [...tab.events] + .reverse() + .find((event) => event.event === "hello")?.data; + + assert.equal( + (latestHello?.server as { uiLocales: unknown[] }).uiLocales.length, + 0, + "removing the module tells connected clients to use English", + ); + + writeFileSync( + join(dropIns, "invalid-locale.ts"), + `import { defineExtension } from "@earendil-works/pi-durable";\nexport default () => ({ ...defineExtension({ name: "invalid-locale" }), uiLocales: [{ locale: "zh-CN", label: "bad", strings: { Extensions: 7 } }] });`, + ); + await assert.rejects( + app.setExtensionEnabled(owner(app), "invalid-locale.ts", true), + /values must be plain strings/, + ); + assert.deepEqual(app.loader.uiLocales(), [], "invalid data never reaches clients"); + await app.setExtensionEnabled(owner(app), "invalid-locale.ts", false); + } finally { + app.detach(tab.client); + } +}); + /** A drop-in with one extension, and one tool in it when `tool` is given. */ const named = (extension: string, tool?: string) => `/** ${extension}, for the names test. */ import { Type } from "@earendil-works/pi-ai"; @@ -217,6 +312,20 @@ test("a drop-in cannot take a name Pi Pocket keeps: it is refused", async () => await refused("my-bash.ts", named("my-bash", "bash"), /bash is a built-in tool's/); }); +test("locale enable and disable choices survive app restarts", async () => { + writeFileSync(join(dropIns, "ui-locale.ts"), uiLocale("扩展")); + await app.setExtensionEnabled(owner(app), "ui-locale.ts", false); + assert.deepEqual(app.loader.uiLocales(), []); + await app.setExtensionEnabled(owner(app), "ui-locale.ts", true); + await app.close(); + app = await openApp(model, dataDir); + assert.equal((await app.hello(owner(app))).server.uiLocales[0]?.strings.Extensions, "扩展"); + await app.setExtensionEnabled(owner(app), "ui-locale.ts", false); + await app.close(); + app = await openApp(model, dataDir); + assert.deepEqual((await app.hello(owner(app))).server.uiLocales, []); +}); + /** A drop-in that says which version of the extension API it was written for, with a tool that returns `output`. */ const versioned = (name: string, api: string) => `/** Versioned ${name}. */ import { Type } from "@earendil-works/pi-ai"; diff --git a/test/i18n-notice.test.ts b/test/i18n-notice.test.ts new file mode 100644 index 00000000..92e70f86 --- /dev/null +++ b/test/i18n-notice.test.ts @@ -0,0 +1,38 @@ +// Optional locale metadata changes presentation only: English notices, reach and raw errors remain intact. +import { cleanUp, fakeTab, newSession, openApp, owner, scriptedModel } from "./helpers.ts"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { setUiLocales, t } from "../web/i18n.js"; + +test("server notice locale metadata is additive, scoped, and preserves parameters", async () => { + const app = await openApp(scriptedModel()); + const id = await newSession(app); + const otherId = await newSession(app); + const tab = fakeTab(id, owner(app)); + const other = fakeTab(otherId, owner(app)); + const params = { name: "Menu {{name}} /file", error: "EXTERNAL RAW {{name}}" }; + const key = "Could not attach {{name}}: {{error}}"; + const message = `Could not attach ${params.name}: ${params.error}`; + + try { + await app.attach(tab.client); + await app.attach(other.client); + app.notice("warning", message, id, { key, params }); + assert.deepEqual(tab.last("notice"), { level: "warning", message, ui: { key, params } }); + assert.equal(other.last("notice"), undefined); + setUiLocales([ + { locale: "zh-CN", strings: {}, templates: { [key]: "无法附加 {{name}}:{{error}}" } }, + ]); + assert.equal(t(key, params), `无法附加 ${params.name}:${params.error}`); + setUiLocales([]); + assert.equal(t(key, params), message); + app.notice("error", "EXTERNAL RAW {{name}}", id); + assert.deepEqual(tab.last("notice"), { level: "error", message: "EXTERNAL RAW {{name}}" }); + } finally { + setUiLocales([]); + app.detach(tab.client); + app.detach(other.client); + await app.close(); + cleanUp(); + } +}); diff --git a/test/i18n-ui.test.ts b/test/i18n-ui.test.ts new file mode 100644 index 00000000..c1a955ce --- /dev/null +++ b/test/i18n-ui.test.ts @@ -0,0 +1,707 @@ +// A live locale change reaches already-open guest sessions without a reload, on phone and desktop. +import { + type App, + cleanUp, + newSession, + openApp, + owner, + root, + scriptedModel, + until, +} from "./helpers.ts"; +import assert from "node:assert/strict"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { join, relative } from "node:path"; +import { tmpdir } from "node:os"; +import { after, before, test } from "node:test"; +import type { ConversationId } from "@earendil-works/pi-durable"; +import { Browsers } from "../src/server/browser.ts"; +import { findBrowser } from "../src/server/browser/discovery.ts"; +import type { BrowserPage } from "../src/server/browser/page.ts"; +import { VIEWPORTS } from "../src/server/browser/viewport.ts"; +import { createHandler } from "../src/server/http.ts"; + +const chromium = findBrowser(); +const real = { + skip: chromium === undefined ? "no Chromium-based browser on this machine" : false, +} as const; +const dataDir = join(root, "i18n-ui-data"); +const localeFile = "zh-cn-ui.ts"; +const draft = "Draft stays here while the interface changes."; + +let app: App | undefined; +let server: Server | undefined; +let browsers: Browsers | undefined; +let mobile: BrowserPage | undefined; +let desktop: BrowserPage | undefined; +let base = ""; +let session: ConversationId; +let guestToken = ""; +const originalTmpdir = process.env.TMPDIR; + +before(async () => { + if (chromium === undefined) { + return; + } + + // Chromium's SingletonSocket has a short Unix path limit. Keep its temp files in + // the same candidate-local TMPDIR, spelled relative to cwd; helpers already made + // absolute isolated auth/data roots before this hook. + process.env.TMPDIR = relative(process.cwd(), tmpdir()) || "."; + const dropIns = join(dataDir, "extensions"); + + mkdirSync(dropIns, { recursive: true }); + copyFileSync( + join(import.meta.dirname, "../docs/examples/zh-cn-ui.ts"), + join(dropIns, localeFile), + ); + const active = await openApp(scriptedModel(), dataDir); + + app = active; + session = await newSession(active); + await active.commands.updateSession(session, owner(active), { title: "Menu" }); + guestToken = active.config.addUser("I18n guest", "guest", [String(session)]).token; + server = createServer( + createHandler({ app: active, listen: { host: "127.0.0.1", port: 0 }, restart: () => {} }), + ); + await new Promise((resolve) => server!.listen(0, "127.0.0.1", resolve)); + base = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + browsers = new Browsers({ + dataDir: mkdtempSync(join(root, "i18n-ui-browser-")), + load: async () => undefined, + save: () => {}, + }); + mobile = await browsers.open(501, { restore: false, viewport: VIEWPORTS.mobile }); + desktop = await browsers.open(502, { restore: false, viewport: VIEWPORTS.desktop }); + await mobile.setViewport(VIEWPORTS.mobile); + await desktop.setViewport(VIEWPORTS.desktop); + + for (const page of [mobile, desktop]) { + await page.navigate(`${base}/login?token=${encodeURIComponent(guestToken)}`); + await page.navigate(`${base}/s/${session}`); + await until( + async () => + JSON.parse( + await page.evaluate( + `return JSON.stringify(document.querySelector(".composer textarea") !== null && document.querySelector(".model-chip") !== null)`, + ), + ) === true, + "the signed-in guest session", + 20_000, + ).catch((error) => { + throw new Error(`${String(error)}; browser logs: ${JSON.stringify(page.logs())}`, { + cause: error, + }); + }); + } +}); + +after(async () => { + await browsers?.closeAll({ final: true }); + server?.closeAllConnections(); + server?.close(); + await app?.close(); + cleanUp(); + + if (originalTmpdir === undefined) { + delete process.env.TMPDIR; + } else { + process.env.TMPDIR = originalTmpdir; + } +}); + +type UiState = { + lang: string; + path: string; + timeOrigin: number; + title: string | null; + labels: string[]; + menuButton: string | null; + model: string | null; + draft: string | null; + innerWidth: number; + pageWidth: number; +}; + +/** Read the rendered state from a real Chromium page. */ +async function ui(page: BrowserPage): Promise { + return JSON.parse( + await page.evaluate(` + return JSON.stringify({ + lang: document.documentElement.lang, + path: location.pathname, + timeOrigin: performance.timeOrigin, + title: document.querySelector(".sheet-head h2")?.textContent.trim() ?? null, + labels: [...document.querySelectorAll(".sheet .group .list-item > span:first-child")] + .map((element) => element.textContent.trim()), + menuButton: document.querySelector('.topbar [aria-label="菜单"], .topbar [aria-label="Menu"]') + ?.getAttribute("aria-label") ?? null, + model: document.querySelector(".model-chip")?.textContent.trim() ?? null, + draft: document.querySelector(".composer textarea")?.value ?? null, + innerWidth, + pageWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), + }); + `), + ) as UiState; +} + +async function openMenu(page: BrowserPage): Promise { + await page.evaluate(`document.querySelector('.topbar [aria-label="Menu"]').click()`); + await until(async () => (await ui(page)).title === "Menu", "the open session menu"); +} + +async function postExtension(token: string, enabled: boolean): Promise { + return fetch(`${base}/api/extensions/${localeFile}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "x-pocket": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ enabled }), + }); +} + +function assertNoReloadAndNoDataChanges(state: UiState, initial: UiState) { + assert.equal(state.path, initial.path, "the open conversation stays put"); + assert.equal(state.timeOrigin, initial.timeOrigin, "the document was not reloaded"); + assert.equal(state.title, "Menu", "the user's conversation title is not translated"); + assert.equal(state.model, initial.model, "the model identifier is unchanged"); + assert.equal(state.draft, initial.draft, "the unsent draft is preserved"); + assert.ok( + state.pageWidth <= state.innerWidth, + `no horizontal overflow: page ${state.pageWidth}px, viewport ${state.innerWidth}px`, + ); +} + +test( + "0.18 gallery hub, technical details and navigation switch on phone and desktop with fixture-only data", + real, + async () => { + assert.ok(app && mobile && desktop); + const pages: BrowserPage[] = [mobile, desktop]; + + for (const page of pages) { + // Real user activation lets back.js create history entries; synthetic DOM + // clicks alone do not activate Chromium's history navigation. + await page.click({ selector: ".composer textarea" }); + // Presentation only: no index downloads, installed code, reviews or provider requests. + await page.evaluate(`(async () => { + const { store, openSheet } = await import('/store.js'); + window.__localeSaved = { me: store.state.me, trust: store.state.trust, server: store.state.server }; + window.__localeFetch = window.fetch; + window.__localeRequests = []; + const declares = { extensions: ['Menu'], tools: ['Menu'], readOnlyTools: [], hooks: [], sections: [], wraps: [], routes: [], commands: [], client: null, access: 'owner' }; + const installed = { name: 'fixture-installed', title: 'Menu', version: '1.0.0', file: 'fixture.ts', listed: true, enabled: false, revoked: null, changed: [], installedAt: Date.now(), repo: 'fixture/index', commit: 'a'.repeat(40), sourceUrl: 'https://example.invalid/source', declares, update: { version: '2.0.0' } }; + const entry = { name: installed.name, title: 'Menu', version: '2.0.0', description: 'RAW {{name}} ', tags: ['Menu'], author: { name: 'Menu' }, source: { repo: 'fixture/index', commit: 'a'.repeat(40) }, sourceUrl: installed.sourceUrl, revoked: [], files: [], declares, minPocket: '0.18.0', pocketApi: 1, installed, pin: 'fixture-pin' }; + const listing = { entries: [entry, { ...entry, name: 'fixture-new', installed: null }, { ...entry, name: 'fixture-theme', kind: 'theme', installed: null, themeApi: 1 }], installed: [installed], hidden: { incompatible: 2, invalid: [{ name: 'RAW {{name}} ', problem: 'RAW upstream error' }] }, address: 'fixture/index', fetchedAt: null }; + window.fetch = async (input, init) => { + const url = String(input); + if (url.startsWith('/api/x/gallery/') || url === '/api/extensions') { + window.__localeRequests.push({ url, method: init?.method ?? 'GET' }); + if (init?.method && init.method !== 'GET') throw new Error('Unexpected fixture write: ' + url); + const data = url === '/api/extensions' ? { modules: [] } + : url.includes('/source/') ? { title: 'Menu', from: 'installed', files: [{ name: 'Menu.ts', size: 9, text: '/* RAW {{name}} */', binary: false, pinned: true }] } + : url.includes('/update/') ? { title: 'Menu', from: { version: '1.0.0', repo: 'fixture/index', commit: 'a'.repeat(40) }, to: { version: '2.0.0', repo: 'fixture/index', commit: 'b'.repeat(40), pin: 'fixture-pin' }, sourceUrl: installed.sourceUrl, enabled: false, declares: { before: declares, after: declares }, edited: [], files: [], diff: '' } + : url.includes('/settings') ? { default: 'fixture/index' } : listing; + return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }); + } + return window.__localeFetch(input, init); + }; + // This remains an authenticated guest. Cache the trust presentation to prevent + // a synthetic owner role from making an unrelated owner-only background read. + store.set({ trust: { conversationId: store.state.conversationId, cwd: store.state.view.agent?.cwd, info: null }, me: { ...store.state.me, role: 'owner' }, server: { ...store.state.server, extensions: [...store.state.server.extensions, 'pocket-gallery'] } }); + openSheet({ type: 'gallery', tab: 'discover' }); + })()`); + } + + try { + for (const enabled of [true, false]) { + assert.equal((await postExtension(app.config.ownerToken, enabled)).status, 200); + + for (const page of pages) { + await until( + async () => (await ui(page)).lang === (enabled ? "zh-CN" : "en"), + "gallery locale changed", + ); + // The real locale hello restores the real guest/server permissions. + // Reapply only this presentation fixture after it; never change auth. + await page.evaluate(`const { store, openSheet } = await import('/store.js'); + store.set({ trust: { conversationId: store.state.conversationId, cwd: store.state.view.agent?.cwd, info: null }, me: { ...store.state.me, role: 'owner' }, server: { ...store.state.server, extensions: [...store.state.server.extensions, 'pocket-gallery'] } }); + openSheet({ type: 'gallery', tab: 'discover' });`); + const text = () => + page.evaluate(`document.querySelector('.sheet')?.textContent ?? ''`); + + await until( + async () => + (await text()).includes( + enabled ? "这些内容的来源" : "Where these come from", + ), + "gallery fixture rendered", + ); + const tabs = JSON.parse( + await page.evaluate( + `return JSON.stringify([...document.querySelectorAll('.ext-tabs [role=tab]')].map(e => e.textContent.trim()))`, + ), + ); + + assert.deepEqual( + tabs, + enabled + ? ["发现", "已安装", "更新1"] + : ["Discover", "Installed", "Updates1"], + ); + const content = await text(); + + assert.ok(content.includes(enabled ? "安装" : "Install")); + assert.ok( + content.includes( + enabled + ? "安装后保持关闭,启用前不会运行。" + : "Extensions install turned off. Nothing runs until you turn it on.", + ), + ); + assert.ok(content.includes("RAW {{name}} ")); + assert.equal( + await page.evaluate(`document.querySelectorAll('.sheet img').length`), + "0", + ); + await page.evaluate( + `document.querySelector('.extension-hub-list .gallery-technical summary').click()`, + ); + assert.equal( + await page.evaluate( + `document.querySelector('.extension-hub-list .gallery-technical').open`, + ), + "true", + ); + const technical = await page.evaluate( + `document.querySelector('.extension-hub-list .gallery-technical').textContent`, + ); + + assert.ok( + technical.includes( + enabled + ? "2 个条目需要更新版本的 Pi Pocket。" + : "2 entries need a newer Pi Pocket.", + ), + ); + assert.equal( + (technical.match(/need a newer Pi Pocket/g) ?? []).length, + enabled ? 0 : 1, + "no duplicated sentence suffix", + ); + assert.ok( + technical.includes("fixture/index") && + technical.includes("RAW upstream error"), + ); + await page.evaluate(`document.querySelector('.ext-row-open').click()`); + await until( + async () => + (await text()).includes(enabled ? "技术详情" : "Technical details"), + "entry selected", + ); + await page.evaluate( + `document.querySelector('.gallery-entry-content .gallery-technical summary').click()`, + ); + const facts = await page.evaluate( + `document.querySelector('.gallery-entry-content .gallery-technical').textContent`, + ); + + assert.ok(facts.includes(enabled ? "安装时间" : "Installed")); + assert.ok(facts.includes("Menu"), "declarations and author stay raw"); + const sourceLabel = enabled ? "查看源码" : "View source"; + + const layersBeforeSource = Number( + await page.evaluate(`history.state?.pocket?.layers ?? 0`), + ); + + await page.evaluate( + `[...document.querySelectorAll('.sheet button')].find(e => e.textContent.trim() === ` + + JSON.stringify(sourceLabel) + + `).click()`, + ); + await until( + async () => + (await text()).includes(enabled ? "源码:Menu" : "Source: Menu"), + "source sheet", + ); + assert.ok( + (await text()).includes("RAW {{name}} "), + "source text is untouched", + ); + await until( + async () => + Number(await page.evaluate(`history.state?.pocket?.layers ?? 0`)) > + layersBeforeSource, + `source history settled (before ${layersBeforeSource}, current ${await page.evaluate(`history.state?.pocket?.layers ?? 0`)})`, + ); + const sourceLayers = Number( + await page.evaluate(`history.state?.pocket?.layers ?? 0`), + ); + + await page.evaluate(`history.back()`); + await until( + async () => + Number(await page.evaluate(`history.state?.pocket?.layers ?? 0`)) < + sourceLayers, + "source Back reduces real history layer depth", + ); + await until( + async () => + (await text()).includes(enabled ? "技术详情" : "Technical details"), + "source returns to selected entry", + ); + + for (const tab of [1, 2]) { + await page.evaluate( + `document.querySelectorAll('.ext-tabs [role=tab]')[` + + tab + + `].click()`, + ); + await until( + async () => + ( + await page.evaluate( + `document.querySelector('.ext-tabs [role=tab].on').textContent`, + ) + ).includes( + tab === 1 + ? enabled + ? "已安装" + : "Installed" + : enabled + ? "更新" + : "Updates", + ), + "hub tab selected", + ); + assert.equal( + await page.evaluate(`document.querySelectorAll('.ext-row').length`), + "1", + ); + } + + await page.evaluate(`document.querySelector('.ext-row-end button').click()`); + await until( + async () => (await text()).includes(enabled ? "更新 Menu" : "Update Menu"), + "update preview", + ); + await until( + async () => + Number(await page.evaluate(`history.state?.pocket?.layers ?? 0`)) >= 2, + "update history settled", + ); + await page.evaluate(`history.back()`); + await until( + async () => + ( + await page.evaluate( + `document.querySelector('.ext-tabs [role=tab].on')?.textContent ?? ''`, + ) + ).includes(enabled ? "更新" : "Updates"), + "update returns to Updates tab", + ); + await page.evaluate( + `document.querySelectorAll('.ext-tabs [role=tab]')[0].click()`, + ); + await until( + async () => + (await text()).includes( + enabled ? "这些内容的来源" : "Where these come from", + ), + "return to Discover", + ); + assert.ok((await ui(page)).pageWidth <= (await ui(page)).innerWidth); + const requests = JSON.parse( + await page.evaluate(`JSON.stringify(window.__localeRequests)`), + ) as { url: string; method: string }[]; + + assert.ok( + requests.every( + (request) => + request.method === "GET" && + !/\/(?:install|uninstall|review)\//.test(request.url), + ), + ); + } + } + } finally { + for (const page of pages) { + await page.evaluate( + `const { store, closeSheet } = await import('/store.js'); closeSheet(); store.set(window.__localeSaved); window.fetch = window.__localeFetch; delete window.__localeFetch; delete window.__localeSaved; delete window.__localeRequests;`, + ); + } + } + }, +); + +test( + "guest trust reads and changes reproduce 403 without altering project trust", + real, + async () => { + assert.ok(app); + const trustFile = join(root, "agent", "trust.json"); + const trustBefore = existsSync(trustFile) ? readFileSync(trustFile, "utf8") : null; + + for (const method of ["GET", "POST"]) { + const response = await fetch(`${base}/api/c/${session}/trust`, { + method, + headers: { + authorization: `Bearer ${guestToken}`, + "x-pocket": "1", + "content-type": "application/json", + }, + ...(method === "POST" ? { body: JSON.stringify({ choice: "trust" }) } : {}), + }); + + assert.equal(response.status, 403, `guest ${method} trust stays owner-only`); + } + + assert.equal(existsSync(trustFile) ? readFileSync(trustFile, "utf8") : null, trustBefore); + }, +); + +test( + "upstream speed and unread controls retain live translations on phone and desktop", + real, + async () => { + assert.ok(app && mobile && desktop); + const pages: BrowserPage[] = [mobile, desktop]; + + for (const enabled of [true, false]) { + assert.equal((await postExtension(app.config.ownerToken, enabled)).status, 200); + + for (const page of pages) { + await until( + async () => (await ui(page)).lang === (enabled ? "zh-CN" : "en"), + "locale changed", + ); + await page.evaluate(`(async () => { + const { store } = await import('/store.js'); + const { setPrefs } = await import('/theme.js'); + setPrefs({ speed: true }); + store.set({ view: { ...store.state.view, stats: { ...store.state.view.stats, + tokensPerSec: 87.4, ttftMs: 1240, cacheRate: 0.83, contextTokens: 98000 } } }); + })()`); + await until( + async () => + ( + await page.evaluate(`document.querySelector('.speed')?.title ?? ''`) + ).includes(enabled ? "每秒 87.4 个 token" : "87.4 tokens a second"), + "speed tooltip translated", + ); + const tooltip = await page.evaluate(`document.querySelector('.speed').title`); + + assert.ok(tooltip.includes(enabled ? "1.24 秒" : "1.24s")); + await page.evaluate( + `(async () => { const { openSheet } = await import('/store.js'); openSheet({ type: 'appearance' }); })()`, + ); + await until( + async () => + ( + await page.evaluate( + `document.querySelector('.sheet')?.textContent ?? ''`, + ) + ).includes(enabled ? "回复速度" : "Reply speed"), + "appearance translated", + ); + await page.evaluate( + `(async () => { const { openSheet, store } = await import('/store.js'); openSheet({ type: 'session', id: store.state.conversationId }); })()`, + ); + await until( + async () => + ( + await page.evaluate( + `document.querySelector('.sheet')?.textContent ?? ''`, + ) + ).includes(enabled ? "标记为未读" : "Mark unread"), + "session menu translated", + ); + const result = await page.evaluate(` + const { t } = await import('/i18n.js'); + return JSON.stringify([t('How fast the last answer came: {{detail}}', { detail: 'Menu {{detail}} ' }), t('Unknown fixed key')]); + `); + + assert.deepEqual(JSON.parse(result), [ + (enabled ? "上一条回复的生成速度:" : "How fast the last answer came: ") + + "Menu {{detail}} ", + "Unknown fixed key", + ]); + assert.ok((await ui(page)).pageWidth <= (await ui(page)).innerWidth); + await page.evaluate( + `(async () => { const { closeSheet } = await import('/store.js'); closeSheet(); })()`, + ); + } + } + + for (const page of [mobile, desktop]) { + await page.evaluate( + `(async () => { const { setPrefs } = await import('/theme.js'); setPrefs({ speed: false }); })()`, + ); + } + }, +); + +test( + "live locale hello updates open guest menus on mobile and desktop, then restores English", + real, + async () => { + const active = app; + const phonePage = mobile; + const computerPage = desktop; + + assert.ok(active && phonePage && computerPage); + + await phonePage.type({ selector: ".composer textarea" }, draft); + await openMenu(phonePage); + await openMenu(computerPage); + + const initialMobile = await ui(phonePage); + const initialDesktop = await ui(computerPage); + + for (const [label, state] of [ + ["mobile", initialMobile], + ["desktop", initialDesktop], + ] as const) { + assert.equal(state.lang, "en", `${label} starts in English`); + assert.ok(state.labels.includes("Appearance"), `${label} shows English Appearance`); + assert.ok(state.labels.includes("Extensions"), `${label} shows English Extensions`); + assert.equal(state.title, "Menu", `${label} shows the unmodified user title`); + assert.equal(state.menuButton, "Menu", `${label} has the English Menu control`); + assert.match(state.model ?? "", /faux-1/, `${label} shows the model ID`); + assert.ok( + state.pageWidth <= state.innerWidth, + `${label} has no horizontal overflow (${state.pageWidth}/${state.innerWidth})`, + ); + } + + assert.equal(initialMobile.draft, draft); + assert.equal(initialDesktop.innerWidth, VIEWPORTS.desktop.width); + + const refused = await postExtension(guestToken, true); + + assert.equal(refused.status, 403, "a guest cannot enable a drop-in extension"); + assert.equal( + active.loader.list().find((module) => module.file === localeFile)?.enabled, + false, + "the denied request leaves the fixture disabled", + ); + + const enabled = await postExtension(active.config.ownerToken, true); + + assert.equal(enabled.status, 200, "the owner enables the test locale through the API"); + await until( + async () => { + const [phone, computer] = await Promise.all([ui(phonePage), ui(computerPage)]); + + return ( + phone.lang === "zh-CN" && + phone.labels.includes("扩展") && + phone.labels.includes("外观") && + phone.menuButton === "菜单" && + computer.lang === "zh-CN" && + computer.labels.includes("扩展") && + computer.labels.includes("外观") && + computer.menuButton === "菜单" + ); + }, + "both open guest menus to update to Chinese", + 20_000, + ); + + const chineseMobile = await ui(phonePage); + const chineseDesktop = await ui(computerPage); + + for (const [label, state, initial] of [ + ["mobile", chineseMobile, initialMobile], + ["desktop", chineseDesktop, initialDesktop], + ] as const) { + assert.equal(state.lang, "zh-CN", `${label} document language changes`); + assert.ok(state.labels.includes("扩展"), `${label} translates Extensions`); + assert.ok(state.labels.includes("外观"), `${label} translates Appearance`); + assert.equal(state.menuButton, "菜单", `${label} translates the Menu control`); + assertNoReloadAndNoDataChanges(state, initial); + } + + const rawError = "EXTERNAL RAW {{name}} "; + const attachment = "Menu {{name}} /some/path"; + const notice = `Could not attach ${attachment}: ${rawError}`; + + active.notice("error", notice, session, { + key: "Could not attach {{name}}: {{error}}", + params: { name: attachment, error: rawError }, + }); + active.notice("error", rawError, session); + await until(async () => { + const texts = await Promise.all( + [phonePage, computerPage].map((page) => + page.evaluate(`document.querySelector('.notices')?.textContent ?? ''`), + ), + ); + + return texts.every((text) => text.includes("无法附加") && text.includes(rawError)); + }, "both pages render structured notices without translating raw errors"); + + for (const page of [phonePage, computerPage]) { + assert.equal( + await page.evaluate(`document.querySelectorAll('.notices img').length`), + "0", + ); + } + + const disabled = await postExtension(active.config.ownerToken, false); + + assert.equal(disabled.status, 200, "the owner disables the test locale through the API"); + await until( + async () => { + const [phone, computer] = await Promise.all([ui(phonePage), ui(computerPage)]); + + return ( + phone.lang === "en" && + phone.labels.includes("Extensions") && + phone.labels.includes("Appearance") && + phone.menuButton === "Menu" && + computer.lang === "en" && + computer.labels.includes("Extensions") && + computer.labels.includes("Appearance") && + computer.menuButton === "Menu" + ); + }, + "both open guest menus to return to English", + 20_000, + ); + + const englishMobile = await ui(phonePage); + const englishDesktop = await ui(computerPage); + + for (const [label, state, initial] of [ + ["mobile", englishMobile, initialMobile], + ["desktop", englishDesktop, initialDesktop], + ] as const) { + assert.equal(state.lang, "en", `${label} document language returns to English`); + assert.ok(state.labels.includes("Extensions"), `${label} restores Extensions`); + assert.ok(state.labels.includes("Appearance"), `${label} restores Appearance`); + assert.equal(state.menuButton, "Menu", `${label} restores the English Menu control`); + assertNoReloadAndNoDataChanges(state, initial); + } + + for (const page of [phonePage, computerPage]) { + const noticeText = await page.evaluate( + `document.querySelector('.notices').textContent`, + ); + + assert.ok( + noticeText.includes(notice), + "open structured notice switches back to English", + ); + assert.ok(noticeText.includes(rawError), "raw external errors stay unchanged"); + assert.deepEqual( + page.logs().filter((entry) => entry.level === "error"), + [], + "no browser console errors", + ); + } + }, +); diff --git a/test/i18n.test.ts b/test/i18n.test.ts new file mode 100644 index 00000000..e2e7462b --- /dev/null +++ b/test/i18n.test.ts @@ -0,0 +1,65 @@ +// The UI translator accepts only explicit data strings and simple placeholders, never extension scripts. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { currentLocale, setUiLocales, t } from "../web/i18n.js"; + +const chinese = { + locale: "zh-CN", + label: "简体中文", + default: true, + strings: { Extensions: "扩展" }, + templates: { + "No model matches “{{query}}”.": "没有匹配的模型:“{{query}}”。", + }, +}; + +test("English is the default when no extension supplies a locale", () => { + assert.equal(setUiLocales([]), "en"); + assert.equal(currentLocale(), "en"); + assert.equal(t("Extensions"), "Extensions"); +}); + +test("registered locale packs translate explicit strings and retain dynamic content verbatim", () => { + assert.equal(setUiLocales([chinese]), "zh-CN"); + assert.equal(t("Extensions"), "扩展"); + assert.equal( + t("No model matches “{{query}}”.", { query: "faux-2" }), + "没有匹配的模型:“faux-2”。", + ); + assert.equal(t("No model matches “faux-2”."), "No model matches “faux-2”."); + assert.equal(t("A user message that says Extensions"), "A user message that says Extensions"); +}); + +test("parameters are substituted once, unknown and prototype keys fall back, order is deterministic", () => { + setUiLocales([chinese]); + assert.equal( + t("No model matches “{{query}}”.", { query: "{{query}}$&" }), + "没有匹配的模型:“{{query}}$&”。", + ); + assert.equal(t("constructor"), "constructor"); + assert.equal(t("__proto__"), "__proto__"); + assert.equal(t(null), null); + assert.equal(t("Missing {{value}}", { value: "Menu" }), "Missing Menu"); + const french = { + ...chinese, + locale: "fr", + label: "French", + strings: { Extensions: "Extensions FR" }, + }; + + setUiLocales([chinese, french]); + assert.equal(currentLocale(), "fr"); + setUiLocales([french, chinese]); + assert.equal(currentLocale(), "fr"); +}); + +test("removing all locale packs restores the original English strings", () => { + setUiLocales([chinese]); + assert.equal(t("Extensions"), "扩展"); + + setUiLocales([]); + + assert.equal(currentLocale(), "en"); + assert.equal(t("Extensions"), "Extensions"); + assert.equal(t("No model matches “faux-2”."), "No model matches “faux-2”."); +}); diff --git a/test/launcher-coverage.test.ts b/test/launcher-coverage.test.ts index 81edeb14..65223921 100644 --- a/test/launcher-coverage.test.ts +++ b/test/launcher-coverage.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; +import ts from "typescript"; const read = (path: string) => readFileSync(new URL(`../web/${path}`, import.meta.url), "utf8"); const menu = read("sheets/menu.js"); @@ -16,16 +17,45 @@ test("the launcher opens every sheet the menu opens", () => { assert.deepEqual(missing, [], `the launcher has no action for: ${missing.join(", ")}`); }); +/** The names a menu row shows, through the `t()` wrapper a localized row uses. */ test("the launcher has every row of the menu, by name", () => { - // `item("Name"`, and `item(cond ? "One" : "Other"` on the lines after it. - const names = [ - ...[...menu.matchAll(/item\(\s*"([^"]+)"/g)].map((match) => match[1]), - ...[...menu.matchAll(/item\(\s*\n\s*[^"\n]*\?\s*"([^"]+)"\s*:\s*"([^"]+)"/g)].flatMap( - (match) => [match[1], match[2]], - ), - ]; - const missing = names.filter((name) => !launcher.includes(`"${name}`)); - - assert.ok(names.includes("Webhooks and watches") && names.includes("Take turns")); + const names = new Set(); + + const collect = (node: ts.Node) => { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + names.add(node.text); + } else if (ts.isConditionalExpression(node)) { + collect(node.whenTrue); + collect(node.whenFalse); + } else if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "t" && + node.arguments[0] + ) { + collect(node.arguments[0]); + } + }; + + const source = ts.createSourceFile("menu.js", menu, ts.ScriptTarget.Latest, true); + + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "item" && + node.arguments[0] + ) { + collect(node.arguments[0]); + } + + ts.forEachChild(node, visit); + }; + + visit(source); + + const missing = [...names].filter((name) => !launcher.includes(`"${name}`)); + + assert.ok(names.has("Webhooks and watches") && names.has("Take turns")); assert.deepEqual(missing, [], `the launcher has no action named: ${missing.join(", ")}`); }); diff --git a/test/ui-locales.test.ts b/test/ui-locales.test.ts new file mode 100644 index 00000000..88c4b5ab --- /dev/null +++ b/test/ui-locales.test.ts @@ -0,0 +1,390 @@ +// Extension UI locale metadata: validation and reload behavior in isolated temporary extension/data folders. +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createRegistry } from "@earendil-works/pi-durable"; +import { test } from "node:test"; +import type { PocketHost } from "../src/server/host.ts"; +import { ExtensionLoader, prepareDropInFolder } from "../src/server/reload.ts"; + +interface Fixture { + root: string; + dropIn: string; + loader: ExtensionLoader; + registry: ReturnType; + choices: Map; + changes: number; + add(file: string, source: string): void; + enable(file: string): Promise; +} + +async function isolated(run: (fixture: Fixture) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), "pocket-ui-locales-")); + const builtIn = join(root, "built-in"); + const dropIn = join(root, "data", "extensions"); + const choices = new Map(); + let changes = 0; + + mkdirSync(builtIn, { recursive: true }); + prepareDropInFolder(dropIn, join(import.meta.dirname, "..", "node_modules")); + + const registry = createRegistry(); + const loader = new ExtensionLoader( + registry, + { notice: () => {} } as unknown as PocketHost, + { builtIn, dropIn }, + (file) => choices.get(file), + async () => { + changes += 1; + }, + ); + const fixture: Fixture = { + root, + dropIn, + loader, + registry, + choices, + get changes() { + return changes; + }, + add: (file, source) => writeFileSync(join(dropIn, file), source), + enable: async (file) => { + choices.set(file, true); + await loader.apply(file); + }, + }; + + try { + await run(fixture); + } finally { + loader.close(); + rmSync(root, { recursive: true, force: true }); + } +} + +function moduleSource(name: string, locales: string): string { + return `import { defineExtension } from "@earendil-works/pi-durable"; +export default () => ({ ...defineExtension({ name: ${JSON.stringify(name)} }), uiLocales: ${locales} });`; +} + +function pack(locale: string, strings = `{ Hello: "${locale}" }`, preferred = false): string { + return `{ locale: ${JSON.stringify(locale)}, label: ${JSON.stringify(locale)}, ${preferred ? "default: true," : ""} strings: ${strings} }`; +} + +async function waitFor(check: () => boolean, what: string): Promise { + const deadline = Date.now() + 5000; + + while (!check()) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${what}`); + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +test("locale packs are copied, frozen, and returned as an isolated deterministic snapshot", async () => { + await isolated(async ({ loader, choices, add }) => { + const key = `__pocket_locale_${crypto.randomUUID().replaceAll("-", "")}`; + const name = "copied-locale"; + + add( + "copy.ts", + `import { defineExtension } from "@earendil-works/pi-durable"; +const packs = [{ locale: "zh-CN", label: "简体中文", default: true, strings: { Hello: "你好" }, templates: { "{{count}}": "{{count}} 项" } }]; +globalThis[${JSON.stringify(key)}] = packs; +export default () => ({ ...defineExtension({ name: ${JSON.stringify(name)} }), uiLocales: packs });`, + ); + choices.set("copy.ts", true); + await loader.apply("copy.ts"); + const installed = (await loader.reload("copy.ts"))[0]!; + + assert.equal(Object.getOwnPropertyDescriptor(installed, "uiLocales")?.writable, false); + + const original = (globalThis as unknown as Record)[key] as { + strings: Record; + }[]; + const exposed = loader.uiLocales(); + const registered = exposed[0]!; + + try { + original[0]!.strings.Hello = "被改写"; + original.push({ strings: {} }); + + assert.equal(loader.uiLocales()[0]?.strings.Hello, "你好"); + assert.equal(Object.isFrozen(registered), true); + assert.equal(Object.isFrozen(registered.strings), true); + assert.equal(Object.isFrozen(registered.templates), true); + assert.equal(Object.isFrozen(loader.uiLocales()[0]?.templates), true); + assert.throws(() => { + (registered.strings as Record).Hello = "改写"; + }, TypeError); + + exposed.pop(); + assert.equal( + loader.uiLocales().length, + 1, + "a caller cannot mutate the loader's collection", + ); + } finally { + delete (globalThis as unknown as Record)[key]; + } + }); +}); + +test("a later registry validation error cannot leave earlier extensions from a reload installed", async () => { + await isolated(async ({ loader, registry, choices, add }) => { + add("existing.ts", moduleSource("existing", `[${pack("en", '{ Existing: "old" }')}]`)); + choices.set("existing.ts", true); + await loader.apply("existing.ts"); + + // Simulate a task collision detected by the real registry after an extension passed loader checks. + const task = { definition: { name: "locale-regression-collision" } } as never; + const colliding = { + name: "already-installed", + tasks: [task], + } as never; + + registry.install(colliding); + + add( + "partial.ts", + `import { defineExtension } from "@earendil-works/pi-durable"; +const task = { definition: { name: "locale-regression-collision" } }; +export default () => [ + { ...defineExtension({ name: "first-in-partial" }), uiLocales: [${pack("fr", '{ First: "premier" }')}] }, + { ...defineExtension({ name: "second-in-partial" }), tasks: [task], uiLocales: [${pack("de", '{ Second: "zweite" }')}] }, +];`, + ); + choices.set("partial.ts", true); + + await assert.rejects(loader.apply("partial.ts"), /locale-regression-collision/); + assert.equal(registry.snapshot().extension("first-in-partial"), undefined); + assert.equal(registry.snapshot().extension("second-in-partial"), undefined); + assert.deepEqual( + loader.uiLocales().map((locale) => locale.locale), + ["en"], + "the failed module contributes no locale packs", + ); + }); +}); + +test("locale schema rejects noncanonical tags, accessors, sparse arrays, and excessive aggregate packs", async () => { + await isolated(async ({ loader, choices, add }) => { + add("noncanonical.ts", moduleSource("noncanonical", `[${pack("en-us")}]`)); + choices.set("noncanonical.ts", true); + await assert.rejects(loader.apply("noncanonical.ts"), /canonical BCP 47/); + add("empty-locale.ts", moduleSource("empty-locale", `[${pack("")}]`)); + choices.set("empty-locale.ts", true); + await assert.rejects(loader.apply("empty-locale.ts"), /canonical BCP 47/); + + add( + "accessor.ts", + `import { defineExtension } from "@earendil-works/pi-durable"; +const entry = { locale: "en", label: "English", strings: { Hello: "Hello" } }; +Object.defineProperty(entry, "label", { enumerable: true, get() { globalThis.__localeGetterRan = true; return "English"; } }); +export default () => ({ ...defineExtension({ name: "accessor" }), uiLocales: [entry] });`, + ); + choices.set("accessor.ts", true); + await assert.rejects(loader.apply("accessor.ts"), /enumerable data properties/); + assert.equal( + (globalThis as unknown as Record).__localeGetterRan, + undefined, + ); + delete (globalThis as unknown as Record).__localeGetterRan; + + add("sparse.ts", moduleSource("sparse", "Array(1)")); + choices.set("sparse.ts", true); + await assert.rejects(loader.apply("sparse.ts"), /dense array/); + + add( + "bad-template.ts", + moduleSource( + "bad-template", + '[{ locale: "en", label: "English", strings: {}, templates: { "Hello {{name}}": "Hello" } }]', + ), + ); + choices.set("bad-template.ts", true); + await assert.rejects(loader.apply("bad-template.ts"), /template placeholders must match/); + + const tags = [ + "en", + "fr", + "de", + "es", + "it", + "pt", + "nl", + "sv", + "da", + "no", + "fi", + "is", + "pl", + "cs", + "sk", + "hu", + ]; + + add("many.ts", moduleSource("many", `[${tags.map((locale) => pack(locale)).join(",")}]`)); + choices.set("many.ts", true); + await loader.apply("many.ts"); + add("overflow.ts", moduleSource("overflow", `[${pack("ro")}]`)); + choices.set("overflow.ts", true); + await assert.rejects(loader.apply("overflow.ts"), /maximum 16/); + assert.equal(loader.uiLocales().length, 16); + }); +}); + +test("locale conflicts are explicit and multiple defaults use the smallest canonical tag", async () => { + await isolated(async ({ loader, choices, add }) => { + add("z-locale.ts", moduleSource("z-locale", `[${pack("zh-CN", undefined, true)}]`)); + add("a-locale.ts", moduleSource("a-locale", `[${pack("fr-FR", undefined, true)}]`)); + add("m-locale.ts", moduleSource("m-locale", `[${pack("de-DE")}]`)); + choices.set("z-locale.ts", true); + await loader.apply("z-locale.ts"); + choices.set("a-locale.ts", true); + await loader.apply("a-locale.ts"); + choices.set("m-locale.ts", true); + await loader.apply("m-locale.ts"); + + assert.deepEqual( + loader + .uiLocales() + .map(({ locale, default: preferred }) => [locale, preferred === true]), + [ + ["fr-FR", true], + ["zh-CN", true], + ["de-DE", false], + ], + ); + + add("conflict.ts", moduleSource("conflict", `[${pack("zh-CN")}]`)); + choices.set("conflict.ts", true); + await assert.rejects( + loader.apply("conflict.ts"), + /z-locale\.ts already registers the zh-CN UI locale/, + ); + assert.equal(loader.uiLocales().length, 3); + + add( + "duplicate.ts", + `import { defineExtension } from "@earendil-works/pi-durable"; +const locale = { locale: "en", label: "English", strings: {} }; +export default () => [ + { ...defineExtension({ name: "duplicate-one" }), uiLocales: [locale] }, + { ...defineExtension({ name: "duplicate-two" }), uiLocales: [locale] }, +];`, + ); + choices.set("duplicate.ts", true); + await assert.rejects( + loader.apply("duplicate.ts"), + /duplicate\.ts registers the en UI locale more than once/, + ); + assert.equal(loader.uiLocales().length, 3); + }); +}); + +test("a malformed reload preserves the last good locale dictionary", async () => { + await isolated(async ({ loader, choices, add }) => { + const file = "translation.ts"; + + add(file, moduleSource("translation", `[${pack("ja-JP", '{ Hello: "こんにちは" }')}]`)); + choices.set(file, true); + await loader.apply(file); + assert.equal(loader.uiLocales()[0]?.strings.Hello, "こんにちは"); + + add(file, moduleSource("translation", `[${pack("ja-JP", "{ Hello: 7 }")}]`)); + await assert.rejects(loader.reload(file), /values must be plain strings/); + assert.equal(loader.uiLocales()[0]?.strings.Hello, "こんにちは"); + + add(file, moduleSource("translation", `[${pack("ja-JP", '{ Hello: "おはよう" }')}]`)); + await loader.reload(file); + assert.equal(loader.uiLocales()[0]?.strings.Hello, "おはよう"); + }); +}); + +test("a newer reload wins over an older asynchronous import", async () => { + await isolated(async ({ loader, choices, add, root }) => { + const file = "newer-wins.ts"; + const started = join(root, "old.started"); + const release = join(root, "old.release"); + + add( + file, + `import { existsSync, writeFileSync } from "node:fs"; +import { defineExtension } from "@earendil-works/pi-durable"; +writeFileSync(${JSON.stringify(started)}, "started"); +while (!existsSync(${JSON.stringify(release)})) await new Promise((resolve) => setTimeout(resolve, 5)); +export default () => ({ ...defineExtension({ name: "newer-wins" }), uiLocales: [{ locale: "fr-FR", label: "Français", strings: { Hello: "Bonjour" } }] });`, + ); + choices.set(file, true); + const olderLoad = loader.apply(file); + + await waitFor(() => existsSync(started), "the older import to begin"); + add(file, moduleSource("newer-wins", `[${pack("es-ES", '{ Hello: "Hola" }')}]`)); + await loader.reload(file); + assert.equal(loader.uiLocales()[0]?.strings.Hello, "Hola"); + + writeFileSync(release, "release"); + await olderLoad; + assert.equal(loader.uiLocales()[0]?.strings.Hello, "Hola"); + }); +}); + +test("a load finishing after disable or deletion cannot resurrect its locale", async () => { + await isolated(async (fixture) => { + const { loader, choices, add, root, dropIn } = fixture; + const disableFile = "disable-race.ts"; + const disableStarted = join(root, "disable.started"); + const disableRelease = join(root, "disable.release"); + + add( + disableFile, + `import { existsSync, writeFileSync } from "node:fs"; +import { defineExtension } from "@earendil-works/pi-durable"; +writeFileSync(${JSON.stringify(disableStarted)}, "started"); +while (!existsSync(${JSON.stringify(disableRelease)})) await new Promise((resolve) => setTimeout(resolve, 5)); +export default () => ({ ...defineExtension({ name: "disable-race" }), uiLocales: [{ locale: "sv-SE", label: "Svenska", strings: { Hello: "Hej" } }] });`, + ); + choices.set(disableFile, true); + const enabling = loader.apply(disableFile); + + await waitFor(() => existsSync(disableStarted), "the in-flight extension import"); + choices.set(disableFile, false); + await loader.apply(disableFile); + writeFileSync(disableRelease, "release"); + await enabling; + assert.equal(loader.extensionNames().includes("disable-race"), false); + assert.equal(loader.uiLocales().length, 0); + + const deleteFile = "delete-race.ts"; + const deleteStarted = join(root, "delete.started"); + const deleteRelease = join(root, "delete.release"); + const source = `import { existsSync, writeFileSync } from "node:fs"; +import { defineExtension } from "@earendil-works/pi-durable"; +writeFileSync(${JSON.stringify(deleteStarted)}, "started"); +while (!existsSync(${JSON.stringify(deleteRelease)})) await new Promise((resolve) => setTimeout(resolve, 5)); +export default () => ({ ...defineExtension({ name: "delete-race" }), uiLocales: [{ locale: "da-DK", label: "Dansk", strings: { Hello: "Hej" } }] });`; + + add(deleteFile, moduleSource("delete-race", `[${pack("da-DK", '{ Hello: "Hej" }')}]`)); + choices.set(deleteFile, true); + await loader.apply(deleteFile); + add(deleteFile, source); + loader.watch(); + const reloading = loader.reload(deleteFile); + + await waitFor(() => existsSync(deleteStarted), "the in-flight reload before deletion"); + rmSync(join(dropIn, deleteFile)); + await waitFor( + () => !loader.extensionNames().includes("delete-race"), + "the deleted extension to uninstall", + ); + writeFileSync(deleteRelease, "release"); + await reloading; + await waitFor(() => loader.uiLocales().length === 0, "deleted locale data to disappear"); + assert.equal(loader.extensionNames().includes("delete-race"), false); + await waitFor(() => fixture.changes > 0, "client refresh after deletion"); + }); +}); diff --git a/test/zh-cn-ui.test.ts b/test/zh-cn-ui.test.ts new file mode 100644 index 00000000..6c5758ae --- /dev/null +++ b/test/zh-cn-ui.test.ts @@ -0,0 +1,45 @@ +// The shipped example is exercised by the real loader and Chromium in i18n-ui.test.ts. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import zhCnUi from "../docs/examples/zh-cn-ui.ts"; +import { setUiLocales, t } from "../web/i18n.js"; + +test("the example pack is data-only and keeps template placeholders intact", () => { + const extension = zhCnUi(); + const pack = extension.uiLocales[0]!; + const slots = (text: string) => + [...new Set([...text.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]))].sort(); + + assert.equal(extension.name, "zh-cn-ui"); + assert.equal(pack.locale, "zh-CN"); + assert.equal(pack.label, "简体中文"); + assert.equal(pack.default, true); + assert.ok(Object.keys(pack.strings).length >= 40); + assert.ok(Object.keys(pack.templates).length >= 10); + + for (const [key, value] of Object.entries(pack.templates)) { + assert.deepEqual(slots(value), slots(key), key); + } +}); + +test("the example pack translates registered text and leaves the rest English", () => { + const extension = zhCnUi(); + + try { + setUiLocales(extension.uiLocales); + assert.equal(t("Menu"), "菜单"); + assert.equal(t("Unregistered English UI"), "Unregistered English UI"); + const name = "Menu {{name}} /some/path"; + const error = "EXTERNAL RAW {{name}}"; + const translated = t("Could not attach {{name}}: {{error}}", { name, error }); + + assert.ok(translated.includes(name)); + assert.ok(translated.includes(error)); + assert.notEqual(translated, `Could not attach ${name}: ${error}`); + assert.equal(t("Back to {{tab}}", { tab: "Menu {{tab}} " }), "返回Menu {{tab}} "); + } finally { + setUiLocales([]); + } + + assert.equal(t("Menu"), "Menu"); +}); diff --git a/types/pocket.d.ts b/types/pocket.d.ts index fcf3fb88..4898661c 100644 --- a/types/pocket.d.ts +++ b/types/pocket.d.ts @@ -70,6 +70,23 @@ export interface Person { */ export type NoticeLevel = "info" | "warning" | "error"; +/** + * A data-only UI locale pack. Only explicit UI translation calls use these keys; missing keys stay in English. + * @experimental Not yet released. + */ +export interface PocketUiLocale { + readonly locale: string; + readonly label: string; + readonly default?: boolean; + readonly strings: Readonly>; + readonly templates?: Readonly>; +} + +/** A Pi Durable extension with optional UI locale data. @experimental Not yet released. */ +export type PocketExtension = Extension & { + readonly uiLocales?: readonly PocketUiLocale[]; +}; + // ─── The module ────────────────────────────────────────────────────────────── /** @@ -84,7 +101,11 @@ export interface Module { default( host: Host, web: Web, - ): Extension | readonly Extension[] | void | Promise; + ): + | PocketExtension + | readonly PocketExtension[] + | void + | Promise; /** * The version of the extension API that the module needs. An older Pi Pocket does not set it up. * @since 0.14.0 @@ -185,7 +206,12 @@ export interface Host { * tabs show it. * @since 0.1.0 */ - notice(level: NoticeLevel, message: string, conversationId?: ConversationId): void; + notice( + level: NoticeLevel, + message: string, + conversationId?: ConversationId, + ui?: { key: string; params?: Record }, + ): void; /** * Why Pi cannot start work in a session when nobody asked for it (a spend limit), or undefined when it can. * @since 0.12.0 diff --git a/web/agent-now.js b/web/agent-now.js index b9dfa1e2..d97d2d1d 100644 --- a/web/agent-now.js +++ b/web/agent-now.js @@ -1,6 +1,7 @@ // What a working subagent does this moment, from its peek: the call it waits on, the call it runs, or its words. The // subagents bar and the peek tiles both say it, for a row each. import { describeCall } from "./calls.js"; +import { t } from "./i18n.js"; import { store } from "./store.js"; /** What a working subagent does this moment: its peek's waiting call, running call, or words. */ @@ -8,7 +9,10 @@ export function nowDoing(agent, peek = store.state.peeks?.[agent.conversationId] const waiting = peek?.approvals?.[0]; if (waiting) { - return `Waiting for approval: ${waiting.tool} ${waiting.subject}`.trim(); + return t("Waiting for approval: {{tool}} {{subject}}", { + tool: waiting.tool, + subject: waiting.subject, + }).trim(); } const last = (peek?.lines ?? []).at(-1); @@ -28,5 +32,5 @@ export function nowDoing(agent, peek = store.state.peeks?.[agent.conversationId] } // Before its peek arrives, nothing is known but that it works. - return peek === undefined ? "Working…" : "Thinking…"; + return t(peek === undefined ? "Working…" : "Thinking…"); } diff --git a/web/app.js b/web/app.js index 4a6953cf..6f1845ef 100644 --- a/web/app.js +++ b/web/app.js @@ -1,3 +1,4 @@ +import { t } from "./i18n.js"; // Pi Pocket web app. No build step: edit a file under web/ and every open browser reloads. import { render } from "preact"; import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; @@ -87,12 +88,12 @@ function ChangesButton() { const removed = files.reduce((sum, file) => sum + (file.removed ?? 0), 0); const lines = added > 0 || removed > 0; const count = files.length + (changes.more ?? 0); - const counted = `${count} ${count === 1 ? "file" : "files"}`; + const counted = t(count === 1 ? "{{count}} file" : "{{count}} files", { count }); return html` @@ -242,7 +247,7 @@ function Topbar() { ${ conversation?.kind === "subagent" ? html` - subagent of ${conversation.parent?.title ?? "?"} + ${t("subagent of {{title}}", { title: conversation.parent?.title ?? "?" })} ` : html`${ folder && @@ -253,13 +258,13 @@ function Topbar() { (branchAvailable() ? html`` - : html` + : html` <${Icon} name="fork" size=${10} />${branch} `) } @@ -310,7 +315,7 @@ function Notice({ notice, leaving }) { }} > - ${notice.message} + ${notice.ui ? t(notice.ui.key, notice.ui.params) : notice.message} <${Icon} name=${notice.action ? "chevron" : "close"} size=${14} /> `; } @@ -471,7 +476,7 @@ function App() { if (state.me === undefined) { return html`<${Boot} - caption=${state.notices.some((notice) => notice.level === "error") ? "waiting for the server" : "starting"} + caption=${t(state.notices.some((notice) => notice.level === "error") ? "waiting for the server" : "starting")} /> <${Notices} />`; } @@ -849,7 +854,8 @@ function stopOnSecondEscape() { } escapedAt = Date.now(); - const notice = (escapeNotice = notify("info", "Press Esc again to stop Pi.")); + const ui = { key: "Press Esc again to stop Pi." }; + const notice = (escapeNotice = notify("info", t(ui.key), undefined, ui)); // Up only while a second press stops Pi. setTimeout(() => dismiss(notice), ESCAPE_TWICE_MS); @@ -874,7 +880,7 @@ startSwipes((where) => { // The subagents board or an extension's page in the conversation's place (or the home screen's): right goes back, // as its × does. if (where === "board") { - return { right: { path: iconPath("back"), label: "Back", run: goBack } }; + return { right: { path: iconPath("back"), label: t("Back"), run: goBack } }; } if (conversationId === null || !view.conversation || missing) { @@ -882,7 +888,7 @@ startSwipes((where) => { } if (where === "files") { - return { right: { path: iconPath("back"), label: "Back", run: goBack } }; + return { right: { path: iconPath("back"), label: t("Back"), run: goBack } }; } // Back to the list, or to the session before when that is where back goes. @@ -891,11 +897,11 @@ startSwipes((where) => { return { right: { path: iconPath("back"), - label: toList ? "Sessions" : "Back", + label: toList ? t("Sessions") : t("Back"), run: () => (canGoBack() ? goBack() : toSessions()), }, left: filesAvailable() - ? { path: iconPath("folder"), label: "Files", run: () => setFilesOpen(true) } + ? { path: iconPath("folder"), label: t("Files"), run: () => setFilesOpen(true) } : null, up: () => openSheet({ type: "places" }), }; @@ -946,7 +952,7 @@ if (shared !== null) { (share) => share ? store.set({ sheet: { type: "share", share } }) - : notify("error", "What was shared did not arrive. Share it again."), - () => notify("error", "What was shared could not be read. Share it again."), + : notify("error", t("What was shared did not arrive. Share it again.")), + () => notify("error", t("What was shared could not be read. Share it again.")), ); } diff --git a/web/browser-panel.js b/web/browser-panel.js index 048ee45b..ab79c04a 100644 --- a/web/browser-panel.js +++ b/web/browser-panel.js @@ -1,3 +1,4 @@ +import { t } from "./i18n.js"; // The Browser panel: the session's page in the server's browser, the same one Pi uses with its browser tool. Frames // come as JPEGs by long polling (which passes any tunnel); taps, drags, scrolls, and keys go back as input events. Wide // screens show it as a window beside the conversation; phones show it full screen. @@ -297,7 +298,7 @@ function Screen({ frame, interactive, send }) { return html`The page`; } @@ -443,7 +444,7 @@ function Screen({ frame, interactive, send }) { ref=${ref} class="browser-screen live" src=${frame.src} - alt="The page. Click to use it; once focused, keys go to the page." + alt=${t("The page. Click to use it; once focused, keys go to the page.")} tabindex="0" draggable="false" onPointerDown=${down} @@ -480,18 +481,18 @@ function TypeBar({ send, onClose, input, shown }) { - + ` } -
${ entries === null - ? html`
Loading…
` + ? html`
${t("Loading…")}
` : entries.length === 0 ? html`
- Nothing yet. Logs, errors, and failed loads show here. + ${t("Nothing yet. Logs, errors, and failed loads show here.")}
` : entries.map((entry) => entry.level === "nav" @@ -627,13 +628,13 @@ function StartScreen({ steer }) { return html`

Browser

- ${steer ? "Type an address above, such as localhost:5173, or ask Pi to open one." : "Nothing is open yet. Pi, or someone who can steer, opens pages here."} + ${steer ? t("Type an address above, such as {{address}}, or ask Pi to open one.", { address: "localhost:5173" }) : t("Nothing is open yet. Pi, or someone who can steer, opens pages here.")}

- ${owner && servers === null && html`<${Spinner} label="Looking for servers" />`} + ${owner && servers === null && html`<${Spinner} label=${t("Looking for servers")} />`} ${ servers?.length > 0 && html`
-
Running on this machine
+
${t("Running on this machine")}
${servers.slice(0, 8).map( (server) => html`
`; @@ -867,25 +868,25 @@ export function BrowserPanel({ leaving = false }) { stage = html`
<${Icon} name="globe" size=${28} />

- No browser was found on the machine Pi Pocket runs on. Install Chromium or Google Chrome there (or set PI_POCKET_BROWSER to a browser's path), then restart Pi Pocket. + ${t("No browser was found on the machine Pi Pocket runs on. Install Chromium or Google Chrome there (or set PI_POCKET_BROWSER to a browser's path), then restart Pi Pocket.")}

`; } else if (!open && why) { stage = html`

${why}

- ${steer && html``} + ${steer && html``}
`; } else if (!open && !steer) { stage = html`
<${StartScreen} steer=${false} />
`; } else if (waiting) { stage = html`
-

The page closed.

- +

${t("The page closed.")}

+
`; } else if (!open || !frame) { stage = html`
- <${Spinner} label="Starting the browser" /> - Starting the browser… + <${Spinner} label=${t("Starting the browser")} /> + ${t("Starting the browser…")}
`; } else { stage = html`
@@ -898,7 +899,7 @@ export function BrowserPanel({ leaving = false }) { ${ steer && html`` }
` @@ -912,7 +913,7 @@ export function BrowserPanel({ leaving = false }) { data-state=${leaving ? "closing" : undefined} role=${beside ? undefined : "dialog"} aria-modal=${beside ? undefined : "true"} - aria-label="Browser" + aria-label=${t("Browser")} tabindex="-1" inert=${leaving} > @@ -920,8 +921,8 @@ export function BrowserPanel({ leaving = false }) {
` : html` ` }
` } @@ -499,10 +526,10 @@ function ChatTab({ highlight, focus }) { }; return html`
- <${Icon} name="eye" size=${12} />Side chat · Pi does not see this chat. + <${Icon} name="eye" size=${12} /> ${t("Side chat · Pi does not see this chat.")}
- ${chat.length === 0 && html`
No messages yet.
`} + ${chat.length === 0 && html`
${t("No messages yet.")}
`} ${chat.map((message, index) => { const previous = chat[index - 1]; const grouped = @@ -521,6 +548,7 @@ function ChatTab({ highlight, focus }) { users=${users} me=${me} steer=${canSteer()} + localeRevision=${store.state.uiLocaleRevision} onSelect=${setSelected} />`; })} @@ -529,10 +557,10 @@ function ChatTab({ highlight, focus }) { ${ chatQuote && html`
- Discussing ${chatQuote.text} + ${t("Discussing")} ${chatQuote.text}
${pin.text}
· + ${t("Use theirs")} + ·
` }
- ${by ? `Saved by ${by} ${timeAgo(notes.at)}` : "Not saved yet"} - ${editing ? " · unsaved changes" : ""} + ${by ? t("Saved by {{name}} {{when}}", { name: by, when: timeAgo(notes.at) }) : t("Not saved yet")} + ${editing ? ` · ${t("unsaved changes")}` : ""} ${ canSteer() && @@ -715,19 +743,19 @@ function NotesTab() { class="button small" onClick=${() => insertIntoComposer(`Shared notes:\n\n${notes.text}\n\n`)} > - Send to Pi + ${t("Send to Pi")} ` } ${ editing && - html`` + html`` }
`; } @@ -810,13 +838,13 @@ function PeopleTabs({ ask, focus }) { const tabs = collab() ? html`
` : null; @@ -857,7 +885,7 @@ export function ChatSheet() { const sheet = store.state.sheet ?? {}; return html`<${Sheet} - title="People" + title=${t("People")} className="people-sheet" onClose=${closeSheet} actions=${html`${store.state.presence.length}`} @@ -899,17 +927,17 @@ export function PeoplePanel({ leaving = false }) { class=${`people window ${leaving ? "leaving" : ""}`} data-part="people-panel" data-state=${leaving ? "closing" : undefined} - aria-label="People" + aria-label=${t("People")} inert=${leaving} onKeyDown=${keepEscape} >
-

People

+

${t("People")}

${store.state.presence.length} + ${t("{{name}} asks to drive", { name: name(id) })} `, )} ${ others.length > 0 && html`` } - + ${ handing && html`
@@ -226,20 +227,20 @@ function DriverBar() { return html`
<${Icon} name="wheel" size=${14} /> - ${turns.driver === undefined ? "No one is driving" : `${name(turns.driver)} is driving${driverHere ? "" : " (away)"}`} + ${turns.driver === undefined ? t("No one is driving") : t(driverHere ? "{{name}} is driving" : "{{name}} is driving (away)", { name: name(turns.driver) })} ${ turns.driver !== undefined && driverHere && html`` } ${ canTake && html`` }
`; @@ -268,21 +269,21 @@ function PlanBar({ blocked }) { return html`
- Plan mode. ${subagent ? "This subagent follows its session: it reads, and changes nothing." : "Pi reads and proposes; nothing changes until you approve."} + ${t("Plan mode.")} ${t(subagent ? "This subagent follows its session: it reads, and changes nothing." : "Pi reads and proposes; nothing changes until you approve.")} ${ !blocked && !subagent && html` ` }
`; @@ -299,17 +300,17 @@ function GoalBar() { const command = html`${goal.command}`; const text = goal.status === "met" - ? html`Done: ${command} passes (check ${goal.tries} of ${goal.max}).` + ? html`${t("Done:")} ${command} ${t("passes (check {{tries}} of {{max}}).", { tries: goal.tries, max: goal.max })}` : goal.status === "gave-up" - ? html`Stopped: ${command} still fails after ${goal.max} checks.` - : html`Until ${command} passes${goal.tries > 0 ? ` · check ${goal.tries} of ${goal.max} failed` : ""}.`; + ? html`${t("Stopped:")} ${command} ${t("still fails after {{max}} checks.", { max: goal.max })}` + : html`${t("Until")} ${command} ${t("passes")}${goal.tries > 0 ? ` · ${t("check {{tries}} of {{max}} failed", { tries: goal.tries, max: goal.max })}` : ""}.`; return html`
${text} ${ canSteer() && html`` }
`; @@ -325,15 +326,17 @@ const WATCH_CHECKS = { function watchEnded(watch) { switch (watch.status) { case "merged": - return "was merged."; + return t("was merged."); case "closed": - return "was closed."; + return t("was closed."); case "gave-up": - return `had more to tell than ${watch.max} messages: watch stopped.`; + return t("had more to tell than {{max}} messages: watch stopped.", { max: watch.max }); case "expired": - return "had nothing new for two weeks: watch stopped."; + return t("had nothing new for two weeks: watch stopped."); default: - return `could not be watched: ${watch.note ?? "something went wrong"}.`; + return t("could not be watched: {{error}}.", { + error: watch.note ?? t("something went wrong"), + }); } } @@ -349,7 +352,7 @@ function WatchBar() { const checks = WATCH_CHECKS[watch.checks]; const text = watch.status === "watching" - ? html`Watching ${pr}${checks ? ` · ${checks}` : ""}${watch.sent > 0 ? ` · ${watch.sent} of ${watch.max} sent` : ""}${watch.unchecked ? " · owner's comments only" : ""}${watch.errors > 0 ? " · last look failed" : watch.note ? " · waiting" : ""}` + ? html`${t("Watching")} ${pr}${checks ? ` · ${t(checks)}` : ""}${watch.sent > 0 ? ` · ${t("{{sent}} of {{max}} sent", { sent: watch.sent, max: watch.max })}` : ""}${watch.unchecked ? ` · ${t("owner's comments only")}` : ""}${watch.errors > 0 ? ` · ${t("last look failed")}` : watch.note ? ` · ${t("waiting")}` : ""}` : html`${pr} ${watchEnded(watch)}`; return html`
{ - notify("error", `Upload failed: ${error.message}`); + notify("error", `${t("Upload failed: ")}${error.message}`); if (preview) { URL.revokeObjectURL(preview); @@ -1064,22 +1069,22 @@ export function Composer() { }; const placeholder = dictation.listening - ? "Listening…" + ? t("Listening…") : busy ? sendMode === "steer" - ? "Steer the current run…" + ? t("Steer the current run…") : sendMode === "interrupt" - ? "Stop the run and send this now…" - : "Queue a message for after this run…" - : "Ask Pi anything…"; + ? t("Stop the run and send this now…") + : t("Queue a message for after this run…") + : t("Ask Pi anything…"); const inbox = view.inbox ?? []; const { me, users } = store.state; const queuedBy = (item) => item.by === undefined ? "" : item.by === me?.id - ? " · you" - : ` · ${users.find((user) => user.id === item.by)?.name ?? "someone"}`; + ? ` · ${t("you")}` + : ` · ${users.find((user) => user.id === item.by)?.name ?? t("someone")}`; useDock(dock); // View only now, or someone else took the wheel: the box goes, and dictation with it. @@ -1100,10 +1105,10 @@ export function Composer() { <${ParentSessionButton} />
- View only. You can read along, react, and chat with the people here. + ${t("View only.")} ${t("You can read along, react, and chat with the people here.")}
<${StatusLine} /> @@ -1124,7 +1129,7 @@ export function Composer() { // How many wait: shown when the queue does not show them all (`dock.js`). inbox.length > 0 && html`
- ${inbox.length} ${inbox.length === 1 ? "message" : "messages"} waiting for Pi + ${t(inbox.length === 1 ? "{{count}} message waiting for Pi" : "{{count}} messages waiting for Pi", { count: inbox.length })}
` } ${ @@ -1148,8 +1153,8 @@ export function Composer() { (!blocked || item.by === me?.id) && html`
` }
` @@ -1259,14 +1264,14 @@ export function Composer() { ${ shell && html`
- ${shell.context ? "!" : "!!"} · runs in this session's folder · ${shell.context ? "Pi sees the output (!! to keep it from Pi)" : "only the people here see the output"} + ${shell.context ? "!" : "!!"} · ${t("runs in this session's folder")} · ${t(shell.context ? "Pi sees the output (!! to keep it from Pi)" : "only the people here see the output")}
` } ${ pasted && !shell && html`
- Pasted text goes out in full where its placeholder is. + ${t("Pasted text goes out in full where its placeholder is.")}
` } ${ @@ -1296,7 +1301,7 @@ export function Composer() { html`
${matches.map( @@ -1322,11 +1327,11 @@ export function Composer() { `, )} - ${matches.length === 0 && html`
Finding files…
`} + ${matches.length === 0 && html`
${t("Finding files…")}
`} ${ found.truncated && html`
- A large folder: only the files nearest its top are listed. + ${t("A large folder: only the files nearest its top are listed.")}
` }
` @@ -1345,9 +1350,9 @@ export function Composer() { /${command.name} ${ command.args && - html`${command.args}` + html`${command.template ? command.args : t(command.args)}` } - ${command.description} + ${command.template ? command.description : t(command.description)} `, )}
` @@ -1359,8 +1364,8 @@ export function Composer() { /${parsed.command.name} ${ parsed.command.args && - html` ${parsed.command.args}` - } · ${parsed.command.description} · not sent to Pi + html` ${parsed.command.template ? parsed.command.args : t(parsed.command.args)}` + } · ${parsed.command.template ? parsed.command.description : t(parsed.command.description)} · ${t("not sent to Pi")}
` } ${ @@ -1410,7 +1415,7 @@ export function Composer() { ${formatBytes(file.size)} `, )} ` @@ -1504,8 +1509,8 @@ export function Composer() { class=${`send-button ${canSend ? "ready" : ""}`} data-part="send-button" data-state=${canSend ? "ready" : undefined} - aria-label="Send" - title=${coarse ? "Send" : "Send (Enter)"} + aria-label=${t("Send")} + title=${t(coarse ? "Send" : "Send (Enter)")} disabled=${!canSend} onClick=${send} > @@ -1541,8 +1546,8 @@ defineItem("composer", { html``, }); @@ -1577,11 +1582,11 @@ defineItem("composer", { class=${`plan-chip ${view.plan?.on ? "on" : ""}`} data-part="composer-button plan-chip" aria-pressed=${view.plan?.on === true} - title="Plan mode: Pi reads and proposes, and changes nothing until you approve" + title=${t("Plan mode: Pi reads and proposes, and changes nothing until you approve")} onMouseDown=${(event) => event.preventDefault()} onClick=${() => attempt(() => actions.setPlan(!view.plan?.on))} > - Plan + ${t("Plan")} `, }); @@ -1614,8 +1619,8 @@ function StopButton() { return html``; }, }); diff --git a/web/diff.js b/web/diff.js index c42c67e2..328bc27e 100644 --- a/web/diff.js +++ b/web/diff.js @@ -3,6 +3,7 @@ // Parsing is web/diff-parse.js. import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; +import { t } from "./i18n.js"; import { useBack } from "./back.js"; import { jumpToEntry } from "./chat.js"; import { fingerprint, firstLine, hunkRows, markRanges, parseDiff } from "./diff-parse.js"; @@ -377,26 +378,26 @@ function GapRow({ left, columns, onShow, first, last }) { ${ all ? html`` : html`${ !first && html`` } ${ !last && html` + `; } @@ -605,17 +606,17 @@ function DiffFile({ if (error) { body = html`

${error}

`; } else if (loading) { - body = html`
<${Loader} label="Reading the diff" />
`; + body = html`
<${Loader} label=${t("Reading the diff")} />
`; } else if (file.binary) { - body = html`

A binary file: no lines to compare.

`; + body = html`

${t("A binary file: no lines to compare.")}

`; } else if (file.hunks.length === 0) { body = html`

- ${file.kind === "renamed" ? "Renamed, with no change to its text." : file.mode ? "Only its mode changed." : "No difference in text."} + ${t(file.kind === "renamed" ? "Renamed, with no change to its text." : file.mode ? "Only its mode changed." : "No difference in text.")}

`; } else if (!drawn) { body = html`

- ${`A large diff: ${size.toLocaleString()} changed lines. `} - + ${t("A large diff: {{count}} changed lines.", { count: size.toLocaleString() })} +

`; } else { body = html`
@@ -623,14 +624,14 @@ function DiffFile({ ${ file.truncated && html`

- ${"The diff is too long to show whole: the rest is left out."} + ${t("The diff is too long to show whole: the rest is left out.")}

` } ${ canExpand && !matches && html`

- ${fetched === false ? "This file is too big to show its other lines here." : "The file changed after this diff was read, so its other lines cannot be shown. Refresh to see them."} + ${t(fetched === false ? "This file is too big to show its other lines here." : "The file changed after this diff was read, so its other lines cannot be shown. Refresh to see them.")}

` }
`; @@ -678,7 +679,7 @@ function FileHead({ file, tools, viewed, onViewed, open, onToggle, note }) { class="dv-fold" type="button" aria-expanded=${open ? "true" : "false"} - aria-label=${open ? "Fold" : "Unfold"} + aria-label=${t(open ? "Fold" : "Unfold")} onClick=${onToggle} > <${Icon} name="chevron" size=${14} class=${`chev ${open ? "open" : ""}`} /> @@ -695,14 +696,14 @@ function FileHead({ file, tools, viewed, onViewed, open, onToggle, note }) { ${tools} ${ onViewed && - html`