diff --git a/docs/architecture.md b/docs/architecture.md index 5e716341..8f10d33a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,6 +40,8 @@ The harness, models, and settings exist only once `#open` has set them up, after Extensions never see `PocketApp`. They get `PocketHost` (`host.ts`), whose functions call into the app lazily. The HTTP layer parses a request, checks it, calls a part, and writes the answer; it holds no state of its own beyond invites and the event streams' pollers. +A `PocketExtension` may include optional `uiLocales`: locale metadata plus plain `strings` and `templates` dictionaries. `ExtensionLoader` validates these data, rejects duplicate locale registrations, and reports packs only from modules currently installed. A failed reload does not replace the previous module or its locale data. Locale dictionaries are not executable browser code. + ## Startup `PocketApp.#open` runs in this order, and the order matters: @@ -112,12 +114,16 @@ Each browser tab is a `Client` (`room.ts`), fed by an event stream or by long po 3. then, when it shows a conversation, the room's full view; 4. then only what changed. +For an authenticated client, `hello.server.uiLocales` carries the data packs of enabled extensions; the authenticated `/api/me` response carries the same server metadata. There is no public dictionary endpoint. Without an authenticated hello or without an enabled pack, the web app uses English. + ## The web app Plain ES modules, served as they are, with no build. Editing a file under `web/` reloads every open tab, and a syntax error blanks the screen for everyone. **Loading order matters.** `store.js` is the root, and a few modules do work when they load (listed in [map.md](map.md#web-web-preact-and-htm-no-build)). `theme.js` must load before anything that reads `prefs()` as it loads. `store.set` calls subscribers in the order they subscribed. When moving code between modules, keep both orders. `back.js` sits under `store.js` and imports nothing of the app's, so anything may use it. +`store.js` installs the available locale data from `hello` before updating shared state. `i18n.js` exports `t()`, but it translates only explicit source strings passed by UI code; it does not scan the DOM or user content. Calls with parameters use the matching template dictionary, and parameter values are interpolated once without recursive translation. The browser chooses `default: true` first and then sorts by locale string; with no pack it stays in English. The choice is not stored and users do not select a per-client language. + **History is the back stack (`back.js`).** Only `back.js` writes history. Each route (`/s/12`) and each layer over it (a sheet, the drawer, the launcher, the Files tile or Browser panel where they cover the screen, a file over the tile's tree) is an entry, and each entry says how many layers it stands for and how many steps back the session list's entry is. The app's state is the truth and history follows it a moment later: opening a layer pushes an entry, closing one with a tap goes back past it, and a new route first goes back past the layers above the current one. Back that the person makes closes the layers above the entry it lands on; back to another route reaches `store.js` through `onRoute`. Traversals `back.js` makes itself reach no one. So: - Navigate with `navigate()` (which calls `pushRoute`); do not call `history.pushState` or `history.back()` elsewhere. To drop a query from the address, use `replaceAddress`, which keeps the entry's marks. @@ -128,7 +134,7 @@ Plain ES modules, served as they are, with no build. Editing a file under `web/` **Contracts with the server.** -- Event names and shapes (`hello`, `view`, `chat`, `peek`, `subagents`, …) and the sheet types the server names (`sheet: "chat"`). +- Event names and shapes (`hello`, `view`, `chat`, `peek`, `subagents`, …) and the sheet types the server names (`sheet: "chat"`). `hello.server.uiLocales` is authenticated connection metadata for enabled extension packs, not a public dictionary API. - The `pocket.*` keys in local and session storage. - `ATTACHMENTS_HEADING`, which `ui.js` repeats from `entry-format.ts`. diff --git a/docs/extensions.md b/docs/extensions.md index df02f73d..6621a5ac 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -56,6 +56,53 @@ The file: - has a file name no built-in module has (a drop-in named `browser.ts` is not loaded). Files that start with `_` or end in `.test.ts` are not modules: use them for helpers and tests. A helper is not reloaded when it changes, even when the module that imports it is: a change to it takes a restart. Keep what you will change often in the module itself. - starts with a `/** … */` comment whose first sentence says what it does. +### UI locale packs (data only) + +A Pocket extension may also return optional `uiLocales` data alongside its ordinary tools, sections, hooks, or tasks. The shape is: + +```typescript +type PocketUiLocale = { + locale: string; // a canonical BCP 47 language tag, such as "zh-CN" + label: string; + default?: boolean; + strings: Record; + templates?: Record; +}; +type PocketExtension = Extension & { uiLocales?: PocketUiLocale[] }; +``` + +Use exact English source strings as keys. `strings` handles a key with no parameters; `templates` handles a source template with named placeholders: + +```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}} 项待你处理", + }, + }, + ], + }; +} +``` + +The web app translates only where its code explicitly calls `t("Files")` or `t("Back to sessions, {{count}} waiting for you", { count })`. Missing keys stay in English. A source template and its translation must contain the same set of placeholder names; for example, both templates above contain `{{count}}`. Interpolated values are inserted as-is; they are not looked up or translated again. This is not a DOM-wide translation pass: user messages, names, paths, model names, logs, and other dynamic content are not translated automatically. + +The loader accepts locale packs only as validated data; their strings and templates are sent to the browser, not extension JavaScript. The same locale cannot be registered by multiple loaded extensions; a conflicting or invalid module is rejected. A drop-in still starts off. Its locale data follows the extension lifecycle: turning it off or removing it removes the pack, editing an enabled module reloads it, and a failed reload leaves the previous extension and pack running. + +Only signed-in clients receive the available packs, as `hello.server.uiLocales` (also through the authenticated `/api/me` response). There is no public dictionary endpoint. An unauthenticated page, or a signed-in client with no enabled locale packs, uses English. The browser prefers a pack marked `default: true`, then sorts by the locale string; `default` is only a preference among packs already enabled, not a switch that enables an extension. There is no per-user language choice or saved locale. + +The loader's canonical-tag and data validation rules may evolve; write canonical BCP 47 tags and do not rely on undocumented size or entry limits. + ### Names Installing an extension with a name already installed replaces it, and a tool with the name of another replaces it in every session. So Pi Pocket refuses a drop-in that would take a name it keeps for itself, or one another module already has, and says so in Menu → Extensions: diff --git a/docs/features.md b/docs/features.md index e3beeead..b2bfa462 100644 --- a/docs/features.md +++ b/docs/features.md @@ -60,6 +60,7 @@ The full tour. For setup, see the [README](../README.md). - **Docs for agents.** Pi's system prompt says where Pi Pocket's documentation is ([index.md](index.md)), to read when someone asks about Pi Pocket itself: changing how Pi works without code, writing extensions (with tested examples), and changing Pi Pocket's code while it runs, checked first on a throwaway copy (`node test/serve.ts`). - **Drop-in extensions.** Put your own extension module (a `.ts` file that default-exports `(host) => Extension`) in `~/.pi-pocket/extensions/`. It shows in the Extensions sheet, stays off until the owner turns it on, can import Pi Durable and pi-ai like the built-in modules, and reloads when edited. One that would take the name of a built-in extension or tool, or of another drop-in's, is not loaded, and the sheet says why: installing it would replace that one. - **Extensions sheet.** Menu → **Extensions** (or tap "guard" in the status line) lists Pi Pocket's extensions with their tools and any load error. The owner can turn Artifacts, Browser, Subagents, Scheduled messages, Done when, Plan mode, Codemode, Lancet Guard, or a drop-in off and on, or reload one; the change applies to every session at once, stays after restarts (`disabledExtensions` and `enabledExtensions` in `~/.pi-pocket/config.json`), and everyone in a session sees who changed what. Turning Lancet Guard off here leaves Pi's own setting (`~/.pi/lancet-guard.json`) alone. The system prompt cannot be turned off. +- **UI locale packs.** An extension can provide a data-only language pack for exact UI strings and templates. The web app translates only strings its code explicitly passes to `t()`; missing labels remain English, and dynamic values, user messages, names, and paths are not translated automatically. Enabled packs reach signed-in clients in `hello.server.uiLocales`; there is no public dictionary endpoint. With no pack the UI is English. The pack marked `default: true` wins, then locale strings are sorted; this does not enable the extension, and there is no per-user language selector or saved language choice. Drop-ins stay off until the owner turns them on; disabling, editing, or removing a module manages its locale data with the rest of that extension. See [extensions.md](extensions.md#ui-locale-packs-data-only). - **Uploads.** Attach files (or paste images). Files are saved on the server and their paths are given to the agent; images also go straight to models that accept them. - **Rich replies.** Code blocks in Pi's replies show their language and a Copy button, with syntax colors for the common languages (no dependencies, so it works offline), and a block of more than 40 lines folds until you ask for the rest. Some show more than code, with the code a tab away: an `html` block (or a reply that is a whole page) is previewed as a page, inline, with its scripts off and nothing loaded from anywhere; **▶ Run** runs its scripts in the artifacts' sandbox, where it can reach the internet but not the app; an `svg` block is drawn as an image (tap it for full screen); and a `diff` or `patch` block opens in the diff viewer. While Pi is still writing, they show as code. Write cards color the file they write, and the file viewer previews `.html` files the same way, with Run. - **Images inline.** Attached images show as thumbnails (also in the composer before you send). When Pi writes `![description](path)` with a path on the server (absolute, `~/…`, or relative to the session's folder), the image shows in its reply. Images a tool returns and SVG artifacts show inline too. Tap any image to see it full screen. Only real image files are shown: the server checks a file's bytes, not just its name. diff --git a/docs/index.md b/docs/index.md index 75b02527..eef9cc3d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,7 +25,7 @@ A drop-in extension survives updates to Pi Pocket and can be turned off from the ## The documents - [customizing.md](customizing.md): changing how Pi works without code. -- [extensions.md](extensions.md): writing extensions (tools, prompt sections, hooks), with working examples in [examples/](examples/). +- [extensions.md](extensions.md): writing extensions (tools, prompt sections, hooks, and UI locale packs), with working examples in [examples/](examples/). - [self-editing.md](self-editing.md): changing Pi Pocket's code while it runs, checking the change, and getting a broken one back. - [map.md](map.md): where each part of the code is, and how to add the common things. - [architecture.md](architecture.md): how the parts depend on each other, and the orderings they rely on. diff --git a/docs/map.md b/docs/map.md index 0fe39f95..ea1c22b3 100644 --- a/docs/map.md +++ b/docs/map.md @@ -18,18 +18,18 @@ A module split into parts keeps its name for the part others import, with the re ### The core -| File | Owns | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `main.ts` | The server process: options, the port, signals, then `PocketApp.open` and `createHandler` | -| `app.ts` | `PocketApp`: opening the harness, reading state back at startup (`#recover`), the commit coordinator (`#committed`), connected tabs and their peek tiles, access checks (`canSee`, `requireSteer`, `requireDriver`), the session list (each session's subagents counted by state), every subagent for the subagents board (`subagents`, `setBoard`), `hello`, extensions on and off | -| `attribution.ts` | Who Pi works for: who wrote each message to Pi or whose work it is (authors documents, from request ids), who queued each submission; read back at startup | -| `room.ts` | One conversation's live view for its tabs: Pi Durable's view plus `ROOM_DOCS`, sent every 90 ms as changes; its peek tile (`peek`, at most once a second) for the tabs that show it (`PocketApp.setPeeks`) | -| `projection.ts` | Entries as compact JSON for browsers; `usageCost` | -| `entry-format.ts` | The kinds of Pi Pocket's own entries, and the markers it writes into message text (`[from: …]`, attached files). Stored data and the web app depend on their exact values | -| `docs.ts` | Every durable document Pi Pocket defines | -| `host.ts`, `reload.ts` | `PocketHost` (what extensions get) and `Approvals`; the extension loader: built-ins in `ORDER`, drop-ins (never with a name Pi Pocket keeps, `BUILT_IN_TOOLS`), live reload | -| `lock.ts` | One process per data folder | -| `errors.ts`, `paths.ts` | `HttpError`, `describe`, and input checks; `~` paths | +| File | Owns | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `main.ts` | The server process: options, the port, signals, then `PocketApp.open` and `createHandler` | +| `app.ts` | `PocketApp`: opening the harness, reading state back at startup (`#recover`), the commit coordinator (`#committed`), connected tabs and their peek tiles, access checks (`canSee`, `requireSteer`, `requireDriver`), the session list, subagents, `hello` (including enabled UI locales), extensions on and off | +| `attribution.ts` | Who Pi works for: who wrote each message to Pi or whose work it is (authors documents, from request ids), who queued each submission; read back at startup | +| `room.ts` | One conversation's live view for its tabs: Pi Durable's view plus `ROOM_DOCS`, sent every 90 ms as changes; its peek tile (`peek`, at most once a second) for the tabs that show it (`PocketApp.setPeeks`) | +| `projection.ts` | Entries as compact JSON for browsers; `usageCost` | +| `entry-format.ts` | The kinds of Pi Pocket's own entries, and the markers it writes into message text (`[from: …]`, attached files). Stored data and the web app depend on their exact values | +| `docs.ts` | Every durable document Pi Pocket defines | +| `host.ts`, `reload.ts` | `PocketHost`/`PocketExtension` (`uiLocales`) and `Approvals`; loader validates packs, rejects duplicate locales, manages enable/edit/delete, keeps the old module on failed reload | +| `lock.ts` | One process per data folder | +| `errors.ts`, `paths.ts` | `HttpError`, `describe`, and input checks; `~` paths | ### People @@ -70,18 +70,18 @@ A module split into parts keeps its name for the part others import, with the re ### HTTP: `http.ts` and `http/` -| File | Owns | -| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `http.ts` | The one request handler: which part answers a path, and the error boundary that turns what it throws into a status | -| `http/api.ts` | `/api`: signing in, the `X-Pocket` header, and the app-wide routes (people, sessions, Pi's sessions from the terminal, peeks, the subagents board, spend, folders, settings, extensions, providers, invites, restart) | -| `http/conversation-routes.ts` | `/api/c/:id`: one conversation's routes, after the API checked that the person may see it | -| `http/browser-routes.ts` | `/api/c/:id/browser`: the Browser panel's frames, console, and input | -| `http/push-routes.ts` | `/api/push`: this device's subscription, what to notify about, a test | -| `http/events.ts` | Each tab's events: the event stream (`/api/events`), or long polling (`/api/poll`) where a tunnel holds streams back | -| `http/assets.ts` | The web app's files, the vendored modules, the app's content security policy, and image files | -| `http/sign-in.ts` | `/login`, `/join/:code`, and `/share` before the service worker takes shares | -| `http/artifacts.ts` | `/a/…`: an artifact as its own page, in a sandbox; `/a/frame`, the page a reply's HTML runs in once someone taps Run | -| `http/io.ts` | What every route uses: reading a body, writing JSON, `ApiRequest` | +| File | Owns | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `http.ts` | The one request handler: which part answers a path, and the error boundary that turns what it throws into a status | +| `http/api.ts` | `/api`: signing in, `X-Pocket`, authenticated `/api/me` hello metadata (including locales); app routes (people, sessions, peeks, spend, settings, extensions, providers, invites, restart); no public locale dictionary | +| `http/conversation-routes.ts` | `/api/c/:id`: one conversation's routes, after the API checked that the person may see it | +| `http/browser-routes.ts` | `/api/c/:id/browser`: the Browser panel's frames, console, and input | +| `http/push-routes.ts` | `/api/push`: this device's subscription, what to notify about, a test | +| `http/events.ts` | Each tab's events: the event stream (`/api/events`), or long polling (`/api/poll`) where a tunnel holds streams back | +| `http/assets.ts` | The web app's files, the vendored modules, the app's content security policy, and image files | +| `http/sign-in.ts` | `/login`, `/join/:code`, and `/share` before the service worker takes shares | +| `http/artifacts.ts` | `/a/…`: an artifact as its own page, in a sandbox; `/a/frame`, the page a reply's HTML runs in once someone taps Run | +| `http/io.ts` | What every route uses: reading a body, writing JSON, `ApiRequest` | ### The built-in browser: `browser.ts` and `browser/` @@ -114,7 +114,8 @@ Plain ES modules, loaded as they are. `store.js` holds the shared state, and not | File | Owns | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `store.js` | State, the event stream (SSE, or long polling when a tunnel holds it back), `api()`, `actions` | +| `store.js` | State, event stream, `api()`, `actions`; installs locale packs from hello before updating shared state | +| `i18n.js` | Explicit UI lookup: `t(source)` uses `strings`, `t(template, params)` uses `templates`; values interpolate once | | `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 | | `gestures.js` | Touch gestures, each following its own finger by its identifier, passive where they can be: sheets dragged down and the drawer dragged left to close (`useDragToClose`); on phones, swipes on the conversation (right back, left Files), in Files (right back), and up from the message box (the places); a swipe in from the left edge goes back in an iPhone's home screen app | | `app.js` | Layout (tiled windows, and the subagents board in the conversation's place), top bar (with what changed, from `diff.js`), notices, keyboard shortcuts, routing | diff --git a/src/server/app.ts b/src/server/app.ts index 552f5c0f..0ac3356a 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -389,6 +389,7 @@ export class PocketApp { host, { builtIn: join(APP_ROOT, "src", "server", "extensions"), dropIn }, (file) => this.config.extensionChoice(file), + () => this.#refreshClients(), ); await this.loader.loadAll(); @@ -1299,6 +1300,8 @@ export class PocketApp { home: homedir(), defaultCwd: this.defaultCwd, extensions: this.loader.extensionNames(), + // Data-only UI translation packs from currently enabled extension modules. + uiLocales: this.loader.uiLocales(), // Tells the web app this server has people chat and typing indicators. chat: true, // Tells the web app this server sends peek tiles (`POST /api/peeks`, `peek` events). diff --git a/src/server/host.ts b/src/server/host.ts index c89f9cf2..d02a8892 100644 --- a/src/server/host.ts +++ b/src/server/host.ts @@ -24,6 +24,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. @@ -137,5 +156,5 @@ export interface PocketHost { /** The shape of every module in `src/server/extensions/`: a default export building one or more extensions. */ export type ExtensionModule = { - default: (host: PocketHost) => Extension | readonly Extension[]; + default: (host: PocketHost) => PocketExtension | readonly PocketExtension[]; }; diff --git a/src/server/reload.ts b/src/server/reload.ts index 911cb3d7..21487690 100644 --- a/src/server/reload.ts +++ b/src/server/reload.ts @@ -21,9 +21,9 @@ import { } from "node:fs"; import { basename, join } from "node:path"; import { pathToFileURL } from "node:url"; -import type { Extension, Registry } from "@earendil-works/pi-durable"; +import { createRegistry, type Registry } from "@earendil-works/pi-durable"; import { describe } from "./errors.ts"; -import type { ExtensionModule, PocketHost } from "./host.ts"; +import type { ExtensionModule, PocketExtension, PocketHost, PocketUiLocale } from "./host.ts"; /** Built-in extension modules, in install order. Other `.ts` files in the directory load after them, by name. */ const ORDER = [ @@ -134,16 +134,266 @@ export function prepareDropInFolder(directory: string, appModules: string): void symlinkSync(appModules, link, process.platform === "win32" ? "junction" : "dir"); } +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; + }); +} + export class ExtensionLoader { readonly #registry: Registry; readonly #host: PocketHost; readonly #builtIn: string; readonly #dropIn: string | undefined; readonly #choice: (file: string) => boolean | undefined; + readonly #onChange: () => 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(); + /** Each load owns a version so disabling, deletion, or a newer reload makes it stale. */ + readonly #loadVersions = new Map(); /** Drop-ins already reported for having a built-in module's name, so each is reported once. */ readonly #clashes = new Set(); readonly #watchers: FSWatcher[] = []; @@ -158,12 +408,14 @@ export class ExtensionLoader { host: PocketHost, folders: { builtIn: string; dropIn?: string }, choice: (file: string) => boolean | undefined = () => undefined, + onChange: () => Promise = async () => {}, ) { this.#registry = registry; this.#host = host; this.#builtIn = folders.builtIn; this.#dropIn = folders.dropIn; this.#choice = choice; + this.#onChange = onChange; } /** Every module, built-in ones first in install order. A drop-in with a built-in's name is left out. */ @@ -239,14 +491,18 @@ export class ExtensionLoader { continue; } + const version = this.#beginLoad(module.file); + try { - await this.#load(module.file, false); + await this.#load(module.file, false, version); } catch (error) { - this.#errors.set(module.file, describe(error)); - this.#host.notice( - "error", - `Extension ${module.file} failed to load: ${describe(error)}`, - ); + if (this.#loadVersions.get(module.file) === version) { + this.#errors.set(module.file, describe(error)); + this.#host.notice( + "error", + `Extension ${module.file} failed to load: ${describe(error)}`, + ); + } } } } @@ -269,7 +525,24 @@ export class ExtensionLoader { this.#uninstall(file); } + #beginLoad(file: string): number { + const timer = this.#timers.get(file); + + if (timer !== undefined) { + clearTimeout(timer); + this.#timers.delete(file); + } + + const version = (this.#loadVersions.get(file) ?? 0) + 1; + + this.#loadVersions.set(file, version); + + return version; + } + #uninstall(file: string): void { + this.#beginLoad(file); + for (const extension of this.#installed.get(file) ?? []) { this.#registry.uninstall(extension); } @@ -279,11 +552,15 @@ export class ExtensionLoader { } /** Import a module again and install what it builds. */ - async reload(file: string): Promise { + async reload(file: string): Promise { + const version = this.#beginLoad(file); + try { - return await this.#load(file, true); + return await this.#load(file, true, version); } catch (error) { - this.#errors.set(file, describe(error)); + if (this.#loadVersions.get(file) === version) { + this.#errors.set(file, describe(error)); + } throw error; } @@ -315,7 +592,18 @@ export class ExtensionLoader { return [...this.#installed.values()].flat().map((extension) => extension.name); } - async #load(file: string, cacheBust: boolean): Promise { + /** The data-only locale packs registered by extensions that are on, in deterministic priority 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, version: number): Promise { const module = this.#module(file); if (module === undefined) { @@ -324,22 +612,55 @@ export class ExtensionLoader { const url = pathToFileURL(join(module.directory, file)).href + - (cacheBust ? `?v=${Date.now()}` : ""); + (cacheBust ? `?v=${Date.now()}-${version}` : ""); const loaded = (await import(url)) as ExtensionModule; + const current = this.#module(file); + + if ( + this.#loadVersions.get(file) !== version || + !this.enabled(file) || + current?.directory !== module.directory || + current.source !== module.source + ) { + return this.#installed.get(file) ?? []; + } if (typeof loaded.default !== "function") { throw new Error("the module has no default export function"); } const built = loaded.default(this.#host); - const extensions = (Array.isArray(built) ? built : [built]) as Extension[]; + const extensions = normalizeUiLocales( + file, + (Array.isArray(built) ? built : [built]) as PocketExtension[], + ); if (module.source === "drop-in") { this.#checkNames(file, extensions); } + this.#checkUiLocaleNames(file, extensions); + const previous = this.#installed.get(file) ?? []; + // Validate the complete replacement before publishing any part of it. Registry.install() publishes + // synchronously and a later extension can fail (for example, on a task-name collision). + const staged = createRegistry(); + + for (const extension of this.#registry.snapshot().installed()) { + staged.install(extension); + } + + for (const extension of extensions) { + staged.install(extension); + } + + for (const old of previous) { + if (!extensions.some((extension) => extension.name === old.name)) { + staged.uninstall(old); + } + } + for (const extension of extensions) { this.#registry.install(extension); } @@ -360,7 +681,7 @@ export class ExtensionLoader { * Refuse a drop-in that would take a name Pi Pocket keeps, or one another module installed: installing it would * replace that one. Throws before anything is installed, so a version that loaded before keeps running. */ - #checkNames(file: string, extensions: readonly Extension[]): void { + #checkNames(file: string, extensions: readonly PocketExtension[]): void { const owners = new Map(); const tools = new Map(); @@ -407,6 +728,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.#onChange().catch((error: unknown) => + this.#host.notice( + "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 { for (const directory of [this.#builtIn, this.#dropIn]) { @@ -445,6 +815,7 @@ export class ExtensionLoader { // A drop-in that was removed takes away what it installed. if (module === undefined && directory === this.#dropIn && this.#installed.has(file)) { this.#uninstall(file); + this.#notifyClients(); this.#host.notice("info", `Removed the drop-in extension ${file}.`); return; @@ -468,18 +839,22 @@ export class ExtensionLoader { const loaded = this.#installed.has(file); this.reload(file).then( - (extensions) => + (extensions) => { + this.#notifyClients(); this.#host.notice( "info", `Reloaded ${file}: ${extensions.map((extension) => extension.name).join(", ")}`, - ), - (error: unknown) => + ); + }, + (error: unknown) => { + this.#notifyClients(); this.#host.notice( "error", loaded ? `Kept the previous ${file}; the edited one failed to load: ${describe(error)}` : `${file} failed to load: ${describe(error)}`, - ), + ); + }, ); }, 300), ); @@ -490,8 +865,8 @@ export class ExtensionLoader { watcher.close(); } - for (const timer of this.#timers.values()) { - clearTimeout(timer); + for (const file of new Set([...this.#timers.keys(), ...this.#loadVersions.keys()])) { + this.#beginLoad(file); } } } diff --git a/test/dropins.test.ts b/test/dropins.test.ts index ed8bccdf..7fb58d89 100644 --- a/test/dropins.test.ts +++ b/test/dropins.test.ts @@ -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"), @@ -92,7 +111,7 @@ test("drop-ins are listed after the built-in modules, off until the owner turns assert.equal(listed?.path, join(dropIns, "shout.ts")); assert.equal(listed?.summary, "Shout offers a tool that shouts!."); assert.equal(listed?.enabled, false); - assert.equal(app.loader.list().at(-1)?.file, "shout.ts"); + assert.equal(app.loader.list().at(-1)?.file, "ui-locale.ts"); assert.deepEqual( module("plan.ts").map((each) => each.source), ["built-in"], @@ -155,6 +174,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"; @@ -228,3 +323,17 @@ test("the built-in tool names a drop-in may not take are every tool the built-in assert.ok(installed.length >= 9, "the built-ins are on"); assert.deepEqual([...installed].sort(), [...BUILT_IN_TOOLS].sort()); }); + +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, []); +}); diff --git a/test/i18n-ui.test.ts b/test/i18n-ui.test.ts new file mode 100644 index 00000000..08ebc172 --- /dev/null +++ b/test/i18n-ui.test.ts @@ -0,0 +1,297 @@ +// 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 { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { join } from "node:path"; +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 = "i18n-ui-fixture.ts"; +const draft = "Draft stays here while the interface changes."; + +const localeModule = `/** A small test locale for the live UI translation test. */ +import { defineExtension } from "@earendil-works/pi-durable"; + +export default () => ({ + ...defineExtension({ name: "i18n-ui-fixture" }), + uiLocales: [ + { + locale: "zh-CN", + label: "简体中文", + default: true, + strings: { + Menu: "菜单", + Extensions: "扩展", + Appearance: "外观", + }, + templates: {}, + }, + ], +}); +`; + +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 = ""; + +before(async () => { + if (chromium === undefined) { + return; + } + + const dropIns = join(dataDir, "extensions"); + + mkdirSync(dropIns, { recursive: true }); + writeFileSync(join(dropIns, localeFile), localeModule); + 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, + ); + } +}); + +after(async () => { + await browsers?.closeAll({ final: true }); + server?.closeAllConnections(); + server?.close(); + await app?.close(); + cleanUp(); +}); + +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( + "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 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); + } + }, +); 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/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/web/app.js b/web/app.js index a998cbdd..b8de0794 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, useRef, useState } from "preact/hooks"; @@ -77,12 +78,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` @@ -143,7 +148,7 @@ function Topbar() { ${ conversation?.kind === "subagent" ? html` - subagent of ${conversation.parent?.title ?? "?"} + ${t("subagent of {{title}}", { title: conversation.parent?.title ?? "?" })} ` : html`${ folder && @@ -155,13 +160,13 @@ function Topbar() { (branchAvailable() ? html`` - : html` + : html` ⎇ ${branch} `) }` @@ -172,8 +177,8 @@ function Topbar() { busySubagents > 0 && html` + ` } -
${ 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`
<${Icon} name="globe" size=${28} />

- ${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`
`; @@ -813,19 +814,19 @@ export function BrowserPanel({ leaving = false }) { } 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`
@@ -838,7 +839,7 @@ export function BrowserPanel({ leaving = false }) { ${ steer && html`` }
` @@ -848,15 +849,15 @@ export function BrowserPanel({ leaving = false }) { return html`
<${ResizeEdge} />
` : html` ` }
` } @@ -399,14 +424,14 @@ function ChatTab({ highlight, focus }) { (person) => html` <${Avatar} person=${person} size=${18} /> ${person.name} - ${person.id === me?.id ? " (you)" : person.away ? " · away" : ""} + ${person.id === me?.id ? ` (${t("you")})` : person.away ? ` · ${t("away")}` : ""} `, )}
${ chat.length === 0 && - html`
No messages yet. Pi does not see this chat.
` + html`
${t("No messages yet. Pi does not see this chat.")}
` } ${chat.map((message, index) => { const previous = chat[index - 1]; @@ -426,6 +451,7 @@ function ChatTab({ highlight, focus }) { users=${users} me=${me} steer=${canSteer()} + localeRevision=${store.state.uiLocaleRevision} onSelect=${setSelected} />`; })} @@ -434,10 +460,10 @@ function ChatTab({ highlight, focus }) { ${ chatQuote && html`
- Discussing ${chatQuote.text} + ${t("Discussing")} ${chatQuote.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() && @@ -625,14 +651,14 @@ function NotesTab() { } ${ editing && - html`` + html`` }
`; } @@ -646,13 +672,13 @@ function PeopleTabs({ ask, focus }) { const tabs = collab() ? html`
` : null; @@ -678,7 +704,7 @@ export function ChatSheet() { // While the sheet animates out, the store has no sheet any more. const sheet = store.state.sheet ?? {}; - return html`<${Sheet} title="People" onClose=${closeSheet}> + return html`<${Sheet} title=${t("People")} onClose=${closeSheet}> <${PeopleTabs} ask=${sheet} focus=${true} /> `; } @@ -714,16 +740,16 @@ export function PeoplePanel({ leaving = false }) { return html`
-

People

+

${t("People")}

+ ${name(id)} asks to drive `, )} ${ others.length > 0 && html`` } - + ${ handing && html`
@@ -183,7 +184,7 @@ function DriverBar() { ${ canTake && html`` }
`; @@ -202,20 +203,20 @@ 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.")} ${subagent ? "This subagent follows its session: it reads, and changes nothing." : "Pi reads and proposes; nothing changes until you approve."} ${ !blocked && !subagent && html` ` }
`; @@ -486,7 +487,7 @@ export function Composer() { const command = full.replace(/^!!?/, "").trim(); if (command === "") { - return notify("info", "Say which command to run, such as !git status."); + return notify("info", t("Say which command to run, such as !git status.")); } setSending(true); @@ -772,7 +773,7 @@ export function Composer() { ), ), (error) => { - notify("error", `Upload failed: ${error.message}`); + notify("error", `${t("Upload failed: ")}${error.message}`); if (preview) { URL.revokeObjectURL(preview); @@ -820,9 +821,9 @@ export function Composer() { const placeholder = busy ? steer - ? "Steer the current run…" - : "Queue a message for after this run…" - : "Ask Pi anything…"; + ? t("Steer the current run…") + : t("Queue a message for after this run…") + : t("Ask Pi anything…"); const inbox = view.inbox ?? []; const { me, users } = store.state; const queuedBy = (item) => @@ -842,10 +843,10 @@ export function Composer() {
- 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} /> @@ -884,8 +885,8 @@ export function Composer() { (!blocked || item.by === me?.id) && html``, )} - ${matches.length === 0 && html`
Finding files…
`} + ${matches.length === 0 && html`
${t("Finding files…")}
`} ${ found.truncated && html`
@@ -1096,7 +1097,7 @@ export function Composer() { ${file.state === "uploading" ? html`<${Spinner} />` : file.preview ? "" : "📎"} ${file.name} ${formatBytes(file.size)} ` } ${ @@ -1172,7 +1173,7 @@ export function Composer() { html`
{ // Arrows move between the two, as in any group of radio buttons. if (/^Arrow(Left|Right|Up|Down)$/.test(event.key)) { @@ -1195,10 +1196,10 @@ export function Composer() { aria-checked=${steer === value ? "true" : "false"} tabindex=${steer === value ? "0" : "-1"} class=${steer === value ? "on" : ""} - title=${tip} + title=${t(tip)} onClick=${() => setSteer(value)} > - ${label} + ${t(label)} `, )}
` @@ -1207,8 +1208,8 @@ export function Composer() { ${busy && html`<${StopButton} />`} ` : html`${ !first && html`` } ${ !last && html` + `; } @@ -610,15 +611,15 @@ function DiffFile({ if (loading) { body = null; } 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`
@@ -626,14 +627,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.")}

` }
`; @@ -692,7 +693,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" : ""}`} /> @@ -710,13 +711,13 @@ function FileHead({ file, tools, viewed, onViewed, open, onToggle, note }) { ${tools} ${ onViewed && - html`
`; } @@ -1433,7 +1434,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false const next = nextLayout(diffPrefs.layout); setDiffPrefs({ layout: next }); - notify("info", `Diffs: ${next}.`); + notify("info", `${t("Diffs: ")}${next}.`); } else if (key === "w") { setDiffPrefs({ wrap: !diffPrefs.wrap }); } else { @@ -1448,7 +1449,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false } if (!changes) { - return html`
<${Loader} label="Asking git" />
`; + return html`
<${Loader} label=${t("Asking git")} />
`; } const all = changes.files; @@ -1467,32 +1468,32 @@ export function DiffReview({ active = true, autoFocus = false, covering = false ? html`` - : html`⎇ ${changes.repo.branch}`) + : html`⎇ ${changes.repo.branch}`) }
` - : html`Not a git repository: only Pi's edits are listed.` + : html`${t("Not a git repository: only Pi's edits are listed.")}` } ${ all.length > 0 && html` - ${files.length} ${files.length === 1 ? "file" : "files"} + ${files.length} ${t("files")} <${ChangeCounts} added=${added} removed=${removed} /> - + - ${seen}/${files.length} viewed + ${t("{{seen}}/{{total}} viewed", { seen, total: files.length })} ` } ` } <${DiffSettings} /> @@ -1531,7 +1532,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false class=${`chip toggle ${treeOpen ? "on" : ""}`} onClick=${() => setTreeOpen(!treeOpen)} > - Files + ${t("Files")} ` } ` @@ -1541,7 +1542,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false all.length === 0 && html`
<${Icon} name="check" size=${22} /> -

No uncommitted changes${changes.repo.branch ? ` on ${changes.repo.branch}` : ""}.

+

${t("No uncommitted changes{{branch}}.", { branch: changes.repo.branch ? ` ${t("on branch")} ${changes.repo.branch}` : "" })}

` } ${ @@ -1549,7 +1550,7 @@ export function DiffReview({ active = true, autoFocus = false, covering = false html`
${ (wide || treeOpen) && - html`
@@ -220,13 +221,13 @@ export function CwdSheet({ mode }) { <${Icon} name="chevron" size=${14} /> `, )} - ${listing.dirs.length === 0 && html`
No folders here.
`} + ${listing.dirs.length === 0 && html`
${t("No folders here.")}
`} ` } ${ listing?.recent?.length > 0 && html`
-
Recent
+
${t("Recent")}
${listing.recent.map( (dir) => html` ${ @@ -191,7 +195,7 @@ export function ModelPicker() { <${Icon} name="search" size=${15} /> { setQuery(event.currentTarget.value); @@ -200,17 +204,17 @@ export function ModelPicker() { /> ` } -
+
${ models.length === 0 && html`

- No models are available. Add a provider first. + ${t("No models are available. Add a provider first.")}

` } ${ models.length > 0 && shown.length === 0 && - html`

No model matches “${query.trim()}”.

` + html`

${t("No model matches “")}${query.trim()}”.

` } ${shown.map( (model, index) => html`
- ${ROLE_TEXT[role]}.${role === "guest" && only ? " Seeing one session in the app does not limit what Pi can reach on the machine." : ""} + ${t(ROLE_TEXT[role])}.${role === "guest" && only ? ` ${t("Seeing one session in the app does not limit what Pi can reach on the machine.")}` : ""}
${ here && html`
-
In
+
${t("In")}
` @@ -182,7 +183,7 @@ export function InviteSheet({ session = null }) { ${ !asOwner && html`
-
Expires after
+
${t("Expires after")}
${LASTS.map( ([each, label]) => html``, )}
@@ -199,13 +200,18 @@ export function InviteSheet({ session = null }) {

${ asOwner - ? "Scan this on your other device, or send it the link. That device signs in as you, the owner. It works once and expires in 15 minutes." - : `Scan this on the other device, or send it the link. It works once and expires ${lasting}. Whoever joins sees ${where}.` + ? t( + "Scan this on your other device, or send it the link. That device signs in as you, the owner. It works once and expires in 15 minutes.", + ) + : t( + "Scan this on the other device, or send it the link. It works once and expires {{lasting}}. Whoever joins sees {{where}}.", + { lasting, where }, + ) }

${ invite?.access?.url && - html`

Other devices connect through ${invite.access.label}.

` + html`

${t("Other devices connect through {{label}}.", { label: invite.access.label })}

` } ${ invite @@ -213,22 +219,22 @@ export function InviteSheet({ session = null }) { ${ invite.local && html`
- This link only works on this device. To let other devices in, press a in the Pi Pocket terminal (or start it with --access) and pick Local network, Cloudflare Tunnel, or Tailscale. Then make a new invite. + ${t("This link only works on this device. To let other devices in, press {{key}} in the Pi Pocket terminal (or start it with {{flag}}) and pick {{choices}}. Then make a new invite.", { key: "a", flag: "--access", choices: t("Local network, Cloudflare Tunnel, or Tailscale") })}
` }
- Invite code + ${t("Invite code")}
${invite.code.slice(5)}
- Or enter it on the other device's sign-in screen. + ${t("Or enter it on the other device's sign-in screen.")}
@@ -253,11 +259,11 @@ export function InviteSheet({ session = null }) { class="button" onClick=${() => copyText(invite.url).then( - () => notify("info", "Link copied."), - () => notify("error", "Could not copy."), + () => notify("info", t("Link copied.")), + () => notify("error", t("Could not copy.")), )} > - Copy + ${t("Copy")}
${ @@ -270,24 +276,29 @@ export function InviteSheet({ session = null }) { }
` : failed - ? html`

No invite yet. Tap New invite to try again.

` - : html`<${Loader} label="Making an invite" />` + ? html`

${t("No invite yet. Tap New invite to try again.")}

` + : html`<${Loader} label=${t("Making an invite")} />` } - + ${!collab() && html`<${PeopleList} />`} `; } function lastSeen(person) { if (person.online) { - return "here now"; + return t("here now"); } if (!person.lastSeen) { - return "not seen yet"; + return t("not seen yet"); } - return `seen ${timeAgo(person.lastSeen)}${timeAgo(person.lastSeen) === "now" ? "" : " ago"}`; + return t("seen {{time}}", { + time: + timeAgo(person.lastSeen) === "now" + ? t("now") + : t("{{time}} ago", { time: timeAgo(person.lastSeen) }), + }); } /** Everyone with access: who is online, when the others were last here, and (for the owner) what each may do. */ @@ -304,7 +315,7 @@ function PeopleList() { attempt(async () => store.set({ users: await actions.setAccess(person.id, patch) })); return html`
-
People
+
${t("People")}
${ordered.map( (person) => html`
@@ -312,11 +323,11 @@ function PeopleList() {
${person.name} - ${person.id === me?.id ? html` (you)` : ""} + ${person.id === me?.id ? html` (${t("you")})` : ""}
- ${person.role === "owner" ? "owner" : person.role === "viewer" ? "view only" : "can steer"} - ${person.sessions ? ` · only ${person.sessions.map(sessionTitle).join(", ")}` : ""} · ${lastSeen(person)} + ${t(person.role === "owner" ? "owner" : person.role === "viewer" ? "view only" : "can steer")} + ${person.sessions ? ` · ${t("only {{sessions}}", { sessions: person.sessions.map(sessionTitle).join(", ") })}` : ""} · ${lastSeen(person)}
${ @@ -327,10 +338,10 @@ function PeopleList() { collab() && html`` } ${ @@ -338,16 +349,20 @@ function PeopleList() { person.sessions && html`` }
` } @@ -369,16 +384,16 @@ function PeopleList() { export function PeopleSheet() { const canInvite = canSteer() && !scoped(); - return html`<${Sheet} title="People" onClose=${closeSheet}> + return html`<${Sheet} title=${t("People")} onClose=${closeSheet}> ${ canInvite && html`` } <${PeopleList} /> `; } diff --git a/web/sheets/providers.js b/web/sheets/providers.js index 2a246528..e36353f3 100644 --- a/web/sheets/providers.js +++ b/web/sheets/providers.js @@ -1,5 +1,6 @@ // Model providers: signing in and out, and the dialog a sign-in uses to ask for a code or a key. import { useEffect, useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { api, attempt, closeSheet, store } from "../store.js"; import { html, Icon, Loader, Sheet } from "../ui.js"; @@ -17,7 +18,9 @@ export function ProvidersSheet() { attempt(() => api(`providers/${encodeURIComponent(provider.id)}/login`, { type })); const logout = (provider) => confirm( - `Log out of ${provider.name}? Pi uses the same sign-in, so it is signed out too.`, + t("Log out of {{provider}}? Pi uses the same sign-in, so it is signed out too.", { + provider: provider.name, + }), ) && attempt(async () => { await api(`providers/${encodeURIComponent(provider.id)}/logout`, {}); @@ -33,19 +36,19 @@ export function ProvidersSheet() { (a, b) => Number(b.configured) - Number(a.configured) || a.name.localeCompare(b.name), ); - return html`<${Sheet} title="Providers" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Providers")} onClose=${closeSheet}>

- Pi Pocket shares Pi's sign-ins (~/.pi/agent/auth.json). ${owner ? "" : "Only the owner can change them."} + ${t("Pi Pocket shares Pi's sign-ins")} (~/.pi/agent/auth.json). ${owner ? "" : t("Only the owner can change them.")}

- ${providers === null && html`<${Loader} label="Loading providers" />`} + ${providers === null && html`<${Loader} label=${t("Loading providers")} />`} ${list.map( (provider) => html`
@@ -53,7 +56,7 @@ export function ProvidersSheet() { ${provider.name} ${provider.configured && html`✓`}
- ${provider.configured ? `signed in${provider.label ? ` · ${provider.label}` : provider.source ? ` · ${provider.source}` : ""}` : "not configured"} · ${provider.models} models + ${provider.configured ? `${t("signed in")}${provider.label ? ` · ${provider.label}` : provider.source ? ` · ${provider.source}` : ""}` : t("not configured")} · ${t("{{count}} models", { count: provider.models })}
${ @@ -65,7 +68,7 @@ export function ProvidersSheet() { class="button small" onClick=${() => login(provider, "oauth")} > - ${provider.oauth} + ${provider.oauth === "Sign in with ChatGPT" ? t("Sign in with ChatGPT") : provider.oauth} ` } ${ @@ -74,7 +77,7 @@ export function ProvidersSheet() { class="button small" onClick=${() => login(provider, "api_key")} > - API key + ${t("API key")} ` } ${ @@ -84,7 +87,7 @@ export function ProvidersSheet() { class="button small ghost" onClick=${() => logout(provider)} > - Log out + ${t("Log out")} ` }
` @@ -120,12 +123,12 @@ export function AuthDialog() { const prompt = auth.prompt; - return html`<${Sheet} title=${`Sign in: ${auth.providerId}`} onClose=${close}> + return html`<${Sheet} title=${t("Sign in: {{provider}}", { provider: auth.providerId })} onClose=${close}> ${auth.events.map((event) => { if (event.type === "auth_url") { return html`
- Open the sign-in page + ${t("Open the sign-in page")} ${event.instructions && html`

${event.instructions}

`}
`; @@ -134,7 +137,7 @@ export function AuthDialog() { if (event.type === "device_code") { return html`

- Enter this code at ${event.verificationUri} + ${t("Enter this code at")} ${event.verificationUri}

${event.userCode}
`; @@ -177,12 +180,12 @@ export function AuthDialog() { onKeyDown=${(event) => event.key === "Enter" && answer({ value })} />
`) } - ${!prompt && !auth.done && html`<${Loader} label="Waiting for the provider" />`} + ${!prompt && !auth.done && html`<${Loader} label=${t("Waiting for the provider")} />`} ${auth.done && !auth.done.ok && html`
${auth.done.error}
`} `; } diff --git a/web/sheets/running.js b/web/sheets/running.js index 23cf2ec3..0311b682 100644 --- a/web/sheets/running.js +++ b/web/sheets/running.js @@ -1,5 +1,6 @@ // Running now: everything Pi is doing, across sessions. import { useEffect, useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { api, attempt, canSteer, closeSheet, navigate, notify } from "../store.js"; import { html, Loader, Sheet, Spinner } from "../ui.js"; @@ -33,9 +34,9 @@ export function RunningSheet() { navigate(id); }; - return html`<${Sheet} title="Running now" onClose=${closeSheet}> - ${sessions === null && html`<${Loader} label="Looking" />`} - ${sessions?.length === 0 && html`

Nothing is running.

`} + return html`<${Sheet} title=${t("Running now")} onClose=${closeSheet}> + ${sessions === null && html`<${Loader} label=${t("Looking")} />`} + ${sessions?.length === 0 && html`

${t("Nothing is running.")}

`} ${sessions?.map( (session) => html`
@@ -55,7 +56,7 @@ export function RunningSheet() { class="button small" onClick=${() => attempt(() => api(`c/${session.id}/abort`, {}))} > - Stop + ${t("Stop")} ` }
@@ -77,7 +78,7 @@ export function RunningSheet() { class="link small" onClick=${() => attempt(() => api(`c/${task.conversationId}/schedules/${encodeURIComponent(task.scheduleId)}/cancel`, {}))} > - Cancel + ${t("Cancel")} ` } ${ @@ -88,7 +89,7 @@ export function RunningSheet() { class="link small" onClick=${() => attempt(() => api(`c/${task.conversationId}/abort`, {}))} > - Stop + ${t("Stop")} ` } ${ @@ -98,7 +99,7 @@ export function RunningSheet() { class="link small" onClick=${() => attempt(() => api(`c/${task.conversationId}/shell/${task.id}/stop`, {}))} > - Stop + ${t("Stop")} ` } ${task.status} diff --git a/web/sheets/schedules.js b/web/sheets/schedules.js index 250eec8d..a0deb241 100644 --- a/web/sheets/schedules.js +++ b/web/sheets/schedules.js @@ -1,5 +1,6 @@ // Scheduled messages of this session. import { useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { actions, attempt, canSteer, closeSheet, store } from "../store.js"; import { formatWhen, html, Sheet } from "../ui.js"; @@ -10,44 +11,44 @@ export function SchedulesSheet() { const steer = canSteer(); const setBy = (id) => id === undefined - ? "Pi" + ? t("Pi") : id === me?.id - ? "you" - : (users.find((user) => user.id === id)?.name ?? "someone"); + ? t("you") + : (users.find((user) => user.id === id)?.name ?? t("someone")); const add = () => attempt(async () => { await actions.schedule(when); setWhen(""); }); - return html`<${Sheet} title="Scheduled messages" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Scheduled messages")} onClose=${closeSheet}>

- Pi gets these at their time, also when nobody is here, and the people in this session get a notification when it is done. + ${t("Pi gets these at their time, also when nobody is here, and the people in this session get a notification when it is done.")}

${ steer && html`
setWhen(event.currentTarget.value)} onKeyDown=${(event) => event.key === "Enter" && add()} />

- Start with when: in 30m, 7:00, tomorrow 9am, fri 17:30, every 2h, every weekday 8:00. Then what Pi gets. + ${t("Start with when: in 30m, 7:00, tomorrow 9am, fri 17:30, every 2h, every weekday 8:00. Then what Pi gets.")}

` } - ${view.schedules.length === 0 && html`

Nothing is scheduled.

`} + ${view.schedules.length === 0 && html`

${t("Nothing is scheduled.")}

`} ${view.schedules.map( (schedule) => html`
${schedule.text}
- ${schedule.repeat ? `${schedule.repeat} · next ${formatWhen(schedule.next)}` : formatWhen(schedule.next)} · set by ${setBy(schedule.by)} + ${schedule.repeat ? `${schedule.repeat} · ${t("next")} ${formatWhen(schedule.next)}` : formatWhen(schedule.next)} · ${t("set by")} ${setBy(schedule.by)}
${ @@ -56,7 +57,7 @@ export function SchedulesSheet() { class="button small ghost" onClick=${() => attempt(() => actions.cancelSchedule(schedule.id))} > - Cancel + ${t("Cancel")} ` }
`, diff --git a/web/sheets/shortcuts.js b/web/sheets/shortcuts.js index cc74ea83..f450f4b4 100644 --- a/web/sheets/shortcuts.js +++ b/web/sheets/shortcuts.js @@ -1,5 +1,6 @@ // The keyboard shortcuts, as a list. The keys themselves are handled in app.js. import { closeSheet } from "../store.js"; +import { t } from "../i18n.js"; import { html, Keys, Sheet } from "../ui.js"; const SHORTCUTS = [ @@ -31,15 +32,15 @@ const SHORTCUTS = [ ]; export function ShortcutsSheet() { - return html`<${Sheet} title="Keyboard shortcuts" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Keyboard shortcuts")} onClose=${closeSheet}>
${SHORTCUTS.map( ([keys, text]) => html`
<${Keys} keys=${keys} />
-
${text}
`, +
${t(text)}
`, )}

- In the launcher, start with > for actions, @ for sessions, or # for themes. Arrowing onto a theme shows it; Enter keeps it. + ${t("In the launcher, start with > for actions, @ for sessions, or # for themes. Arrowing onto a theme shows it; Enter keeps it.")}

`; } diff --git a/web/sheets/spend.js b/web/sheets/spend.js index 63a65364..f521f7a8 100644 --- a/web/sheets/spend.js +++ b/web/sheets/spend.js @@ -1,5 +1,6 @@ // Spend: what each session and person cost, and their limits. import { useEffect, useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { api, attempt, closeSheet, store } from "../store.js"; import { html, Loader, Sheet } from "../ui.js"; @@ -11,7 +12,7 @@ function SpendLimit({ budget, editable, save }) { if (draft === null) { return html` - ${budget === undefined ? "no limit" : `limit ${money(budget)}`} + ${budget === undefined ? t("no limit") : t("limit {{amount}}", { amount: money(budget) })} ${ editable && @@ -19,7 +20,7 @@ function SpendLimit({ budget, editable, save }) { class="link small" onClick=${() => setDraft(budget === undefined ? "" : String(budget))} > - Change + ${t("Change")} ` }`; } @@ -37,11 +38,11 @@ function SpendLimit({ budget, editable, save }) { disabled=${!(Number(draft) > 0)} onClick=${() => done(Number(draft))} > - Set + ${t("Set")} ${ budget !== undefined && - html`` + html`` } `; } @@ -56,19 +57,20 @@ export function SpendSheet() { }, []); const save = (target) => async (budget) => setData(await api("spend", { ...target, budget })); - return html`<${Sheet} title="Spend" onClose=${closeSheet}> - ${data === null && html`<${Loader} label="Adding it up" />`} + return html`<${Sheet} title=${t("Spend")} onClose=${closeSheet}> + ${data === null && html`<${Loader} label=${t("Adding it up")} />`} ${ data?.total !== undefined && - html`

Pi spent ${money(data.total)} on this server so far.

` + html`

${t("Pi spent {{amount}} on this server so far.", { amount: money(data.total) })}

` }

- Spend goes to whoever asked for the work. Past a limit, Pi takes no new messages there, a run that crosses it stops, and subagents' reports wait until it is raised. + ${t("Spend goes to whoever asked for the work. Past a limit, Pi takes no new messages there, a run that crosses it stops, and subagents' reports wait until it is raised.")} +

${ data && html`
-
People
+
${t("People")}
${data.people.map( (person) => html`
${person.name} @@ -82,7 +84,7 @@ export function SpendSheet() { )}
-
Sessions
+
${t("Sessions")}
${data.sessions.map( (session) => html`
${session.title} diff --git a/web/sheets/text.js b/web/sheets/text.js index e8a7196d..e607258b 100644 --- a/web/sheets/text.js +++ b/web/sheets/text.js @@ -1,5 +1,6 @@ // One text field and a button: renaming, resetting, instructions, compacting, and your name. import { useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { attempt, closeSheet } from "../store.js"; import { html, Sheet } from "../ui.js"; @@ -11,7 +12,7 @@ export function TextSheet({ placeholder = "", submit, multiline = false, - button = "Save", + button = t("Save"), }) { const [value, setValue] = useState(initial); const save = () => diff --git a/web/sheets/viewers.js b/web/sheets/viewers.js index c8e8c9af..8bae725a 100644 --- a/web/sheets/viewers.js +++ b/web/sheets/viewers.js @@ -1,5 +1,6 @@ // Artifacts and images, each in a viewer of its own. import { useEffect, useState } from "preact/hooks"; +import { t } from "../i18n.js"; import { closeSheet, openSheet, store } from "../store.js"; import { formatBytes, html, Icon, Sheet, timeAgo } from "../ui.js"; @@ -9,11 +10,11 @@ export function ArtifactsSheet() { (a, b) => (b.versions.at(-1)?.createdAt ?? 0) - (a.versions.at(-1)?.createdAt ?? 0), ); - return html`<${Sheet} title="Artifacts" onClose=${closeSheet}> + return html`<${Sheet} title=${t("Artifacts")} onClose=${closeSheet}> ${ items.length === 0 && html`

- No artifacts yet. Ask Pi to build something you can look at: a chart, a demo, a game. + ${t("No artifacts yet. Ask Pi to build something you can look at: a chart, a demo, a game.")}

` } ${items.map((artifact) => @@ -50,14 +51,14 @@ export function ArtifactViewer({ id, version }) { ${artifact?.title ?? id} ${artifact?.type ?? ""} · version ${shown} - ${latest && shown !== latest ? html` · ` : ""} + ${latest && shown !== latest ? html` · ` : ""}
${ artifact && artifact.versions.length > 1 && html`` } - - Open tab - @@ -99,14 +100,14 @@ export function ImageViewer({ src, alt }) { return html`
@@ -364,7 +366,7 @@ function Collapsible({ text, limit = 600 }) { return html`
<${Markdown} text=${open ? text : `${text.slice(0, limit)}…`} />
`; } @@ -574,10 +576,10 @@ function ThoughtStep({ block, streaming }) {
${ @@ -653,7 +655,7 @@ function ToolStep({ call, result, slot, approval, entryId, streaming, canRun, bu class="link small tool-open" onClick=${() => openFile(`${args.path}${at}`)} > - Open ${args.path.split("/").pop()} → + ${t("Open")} ${args.path.split("/").pop()} → `, ); } @@ -759,7 +761,9 @@ function ToolStep({ call, result, slot, approval, entryId, streaming, canRun, bu // A call still streaming has no stored entry to load yet. if (clipped && entryId !== undefined) { - parts.push(html``); + parts.push( + html``, + ); } body = html`
${parts}
`; @@ -812,7 +816,7 @@ function ToolStep({ call, result, slot, approval, entryId, streaming, canRun, bu child !== undefined && html`
${ decision && - html`
- <${Icon} name="shield" size=${12} /> ${decision.allow ? "Allowed" : "Denied"} by ${decision.by} + html`
+ <${Icon} name="shield" size=${12} /> ${t("{{decision}} by {{name}}", { decision: t(decision.allow ? "Allowed" : "Denied"), name: decision.by })}
` } ${ @@ -833,7 +837,7 @@ function ToolStep({ call, result, slot, approval, entryId, streaming, canRun, bu html`<${EntryImages} entryId=${result.id} count=${result.images} - label=${`${call.name} image`} + label=${t("{{tool}} image", { tool: call.name })} />` } ${ @@ -857,13 +861,13 @@ function ToolStep({ call, result, slot, approval, entryId, streaming, canRun, bu class="artifact-link" onClick=${() => openSheet({ type: "viewer", id: artifact.id, version: artifact.version })} > - Open ${artifact.title} · version ${artifact.version} → + ${t("Open")} ${artifact.title} · ${t("version {{version}}", { version: artifact.version })} → ` } ${ watch && html`` }
` @@ -1117,22 +1121,22 @@ function ApprovalCard({ approval }) { return html`
- <${Icon} name="shield" size=${16} /> Lancet Guard asks before this ${approval.tool} call + <${Icon} name="shield" size=${16} /> ${t("Lancet Guard asks before this {{tool}} call", { tool: approval.tool })}
${approval.reason}
${approval.subject}
${ !canSteer() - ? html`
Waiting for someone who can steer to answer.
` + ? html`
${t("Waiting for someone who can steer to answer.")}
` : html`${ ownCall && html`
- Someone else has to allow this: it came from your message. You can deny it. + ${t("Someone else has to allow this: it came from your message. You can deny it.")}
` }
${ !ownCall && @@ -1141,7 +1145,7 @@ function ApprovalCard({ approval }) { disabled=${busy} onClick=${() => answer(true)} > - Allow + ${t("Allow")} ` }
` @@ -1184,8 +1188,8 @@ function AnswerActions({ entry }) {
`; } @@ -1238,9 +1242,9 @@ function AnswerEnd({ entry }) { return html`${ entry.stopReason === "error" && - html`
${entry.error ?? "The model request failed."}
` + html`
${entry.error ?? t("The model request failed.")}
` } - ${entry.stopReason === "aborted" && html`
Stopped.
`} + ${entry.stopReason === "aborted" && html`
${t("Stopped.")}
`} ${answer && html`<${AnswerActions} entry=${entry} />`}`; } @@ -1272,14 +1276,14 @@ function Divider({ entry }) { // A session continued from Pi in the terminal: what is above is Pi's, from that session's file. if (entry.kind === "fromPi") { return html`
- Continued from Pi in the terminal + ${t("Continued from Pi in the terminal")}
${entry.file}
`; } if (entry.kind === "reset") { return html`
- New context + ${t("New context")} ${entry.text && html`
<${Markdown} text=${entry.text} />
`}
`; } @@ -1307,7 +1311,7 @@ function History({ firstId, results, keepPlace, conversationId }) { store.set({ history: earlier }); })} > - Show earlier messages + ${t("Show earlier messages")} `; } @@ -1371,7 +1375,7 @@ function ShellEntry({ entry }) { ${ (long || longCommand) && html`` } ${ @@ -1385,7 +1389,7 @@ function ShellEntry({ entry }) { setOpen(true); })} > - Load everything + ${t("Load everything")} ` } @@ -1395,7 +1399,7 @@ function ShellEntry({ entry }) { /** Something a person did that Pi was told about, such as undoing a file. */ function NoteEntry({ entry }) { return html`
- ${entry.name} ${entry.text}. Pi was told. + ${entry.name} ${entry.text}. ${t("Pi was told.")}
`; } @@ -1449,7 +1453,7 @@ function PendingShells({ conversationId, rows }) { class="link small" onClick=${() => attempt(() => actions.stopShell(each.taskId))} > - Stop + ${t("Stop")} @@ -1499,7 +1503,15 @@ const agentsKey = (view) => */ function rowDeps(entry) { const { view, users, me, server } = store.state; - const deps = [entry, users, me, server, view.conversation?.id, view.conversation?.kind]; + const deps = [ + entry, + users, + me, + server, + view.conversation?.id, + view.conversation?.kind, + store.state.uiLocaleRevision, + ]; if (entry.kind === "user") { deps.push(view.authors?.[entry.id]); @@ -1781,7 +1793,7 @@ function LiveLine({ live, approvals, start }) { : html`<${Spinner} hidden=${true} />` } - ${state}${retry && !waiting && seconds > 0 && html``} + ${t(state)}${retry && !waiting && seconds > 0 && html``} ${start !== undefined && html``} ${ @@ -1798,7 +1810,7 @@ function LiveLine({ live, approvals, start }) { live.busy && canSteer() && !COARSE.matches && - html`Esc stops` + html`${t("Esc stops")}` } `; } @@ -1977,7 +1989,7 @@ export function Transcript() { return html`

${missing}

- +
`; } @@ -2052,7 +2064,7 @@ export function Transcript() { sessionsLoaded && (source ? html`` : html`

<${Icon} name="fork" size=${14} /> Forked from another session @@ -2061,7 +2073,7 @@ export function Transcript() { ${ start > 0 ? html`

- +
` : first && (first.kind === "compaction" || first.kind === "reset") && @@ -2078,7 +2090,7 @@ export function Transcript() { html`
π

- ${conversation.kind === "subagent" ? "This subagent has no messages yet." : "Ask anything. Pi works in this session's folder, and keeps working if the server restarts."} + ${t(conversation.kind === "subagent" ? "This subagent has no messages yet." : "Ask anything. Pi works in this session's folder, and keeps working if the server restarts.")}

` } @@ -2106,7 +2118,7 @@ export function Transcript() { showJump && html` @@ -1051,7 +1066,7 @@ export const anchorStyle = (anchor) => /** The short name of a model for chips: "Claude Opus 5.5" stays, long ids lose their date suffix. */ export function modelLabel(agent) { if (!agent?.model) { - return "No model"; + return t("No model"); } return agent.modelName ?? agent.model.modelId.replace(/-\d{8}$/, "");