From dd244c076d5f17d56d5592c7fb173dc7fdc2bbae Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:11:41 +0200 Subject: [PATCH 01/13] fix(pi): remove running rows for refused subagent resumes (#7369) A text-only refusal finishes successfully without identifying a child. Remove its provisional subagent row when the tool completes, while retaining the refusal in the tool timeline. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../fixtures/refused-resume.json | 68 +++++++++++++++++++ .../gotgenes-pi-subagents/index.test.ts | 26 +++++++ .../extensions/gotgenes-pi-subagents/index.ts | 2 +- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/refused-resume.json diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/refused-resume.json b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/refused-resume.json new file mode 100644 index 0000000000..b275677a29 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/refused-resume.json @@ -0,0 +1,68 @@ +{ + "provenance": { + "package": "@gotgenes/pi-subagents", + "version": "23.4.0", + "sourceCommit": "63476ff869efd8ea339040f496fa734048b89e70", + "piVersion": "0.84.4", + "captureDate": "2026-10-19" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "resume-call", + "toolName": "subagent", + "args": { + "subagent_type": "general-purpose", + "description": "resume", + "prompt": "Continue", + "resume": "expired-child", + "run_in_background": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "resume-call", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent \"expired-child\" had its session released after its retention window; resume is unavailable, but its result is still retrievable via get_subagent_result." + } + ] + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "resume-call", + "name": "subagent", + "arguments": { + "subagent_type": "general-purpose", + "description": "resume", + "prompt": "Continue", + "resume": "expired-child", + "run_in_background": true + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "resume-call", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Agent \"expired-child\" had its session released after its retention window; resume is unavailable, but its result is still retrievable via get_subagent_result." + } + ], + "isError": false + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts index 6bbe1b3b06..f89a9aefd3 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { fileURLToPath } from "node:url"; +import { ProviderSubagentStore } from "../../../../provider-subagents/store.js"; import { createPiExtensionHost } from "../index.js"; import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; import { streamPiHistory } from "../../history-mapper.js"; @@ -25,6 +26,31 @@ function publishService(agents: Array<{ id: string; outputFile?: string }>): voi } describe("@gotgenes/pi-subagents adapter", () => { + test.each([true, false])( + "removes a refused resume from live and replayed subagents (background=%s)", + async (background) => { + // The result came from 23.4.0's real AgentTool and SubagentManager after releaseSession(). + // The child session was a fake; releaseSession replaced waiting for the retention sweep. + const fixture = readSubagentFixture( + new URL("./fixtures/refused-resume.json", import.meta.url), + ); + const start = fixture.events[0]!; + if (start.type !== "tool_execution_start") throw new Error("Expected a tool start"); + start.args = { ...(start.args as object), run_in_background: background }; + const assistant = fixture.messages[0]!; + if (assistant.role !== "assistant") throw new Error("Expected an assistant tool call"); + for (const block of assistant.content) { + if (block.type === "toolCall") block.arguments = start.args; + } + const events = await verifySubagentFixture(fixture); + const store = new ProviderSubagentStore(); + store.apply("parent", "pi", { type: "upsert", id: "previous-child", status: "completed" }); + for (const event of events) store.apply("parent", "pi", event.event); + expect(events.map(({ event }) => event.type)).toEqual(["upsert", "remove"]); + expect(store.list("parent")).toMatchObject([{ id: "previous-child", status: "completed" }]); + }, + ); + test("reports a child's file after the child republishes the gotgenes service", async () => { const notifications: string[] = []; const emit = loadRuntimeBridge(); diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts index 62db93c79e..e1869705f8 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts @@ -86,7 +86,7 @@ export const gotgenesPiSubagents: PiExtension = { detail, subagents: [{ type: "upsert" as const, id: call.callId, status: "failed" as const }], } - : { detail }; + : { detail, subagents: [{ type: "remove" as const, id: call.callId }] }; callsByAgent.set(details.data.agentId, call.callId); const file = outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId); From 05920a236cb6ba8eeae128ee7bb6677cc226cd3a Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:49:47 +0200 Subject: [PATCH 02/13] test(codex): observe permission events in auto-review scenarios (#7382) List snapshots do not subscribe modern clients to permission events. Activate that feed before both scenarios so the request assertion and auto-review absence assertion exercise delivery. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../server/daemon-e2e/codex-auto-review.local.e2e.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts index 8237ce9ef7..f9e2bd9c11 100644 --- a/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts @@ -288,6 +288,12 @@ async function runScenario(modeId: "auto" | "auto-review") { try { writeMockCodexConfig(codexHome, mockServer.url); await client.connect(); + const permissionEvents = client.observeEvents([ + "agent_permission_request", + "agent_permission_resolved", + ]); + permissionEvents.subscribe({ snapshot: () => {}, update: () => {} }); + await permissionEvents.ready; await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ From 91154ba4e8ace3484595a05a4366279fb4818745 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:58:21 +0200 Subject: [PATCH 03/13] fix(opencode): advertise agent-scoped bridge tool schemas (#7383) Co-authored-by: gszep Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- docs/providers.md | 2 +- .../src/server/agent/provider-runtime.ts | 2 +- .../opencode-bridge.local.e2e.test.ts | 60 ++++++++ .../agent/providers/opencode/bridge.test.ts | 144 +++++++++++++++++- .../server/agent/providers/opencode/bridge.ts | 4 +- .../src/server/agent/tools/paseo-tools.ts | 23 ++- packages/server/src/server/bootstrap.ts | 5 +- 7 files changed, 229 insertions(+), 11 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 298bee651b..1a43558ae3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls. -Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool. +Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool. An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers. diff --git a/packages/server/src/server/agent/provider-runtime.ts b/packages/server/src/server/agent/provider-runtime.ts index 9094527739..398d35746d 100644 --- a/packages/server/src/server/agent/provider-runtime.ts +++ b/packages/server/src/server/agent/provider-runtime.ts @@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "./tools/types.js"; export interface AgentProviderRuntime { snapshotManager: ProviderSnapshotManager; - setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void; + setPaseoToolCatalog(catalog: Pick | null): void; shutdown(): Promise; } diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index d3d295cc8e..843ef21ebb 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -13,6 +13,11 @@ import { getRealProviderRuntimeSettings, } from "../../daemon-e2e/real-provider-test-config.js"; import type { PaseoToolCatalog } from "../tools/types.js"; +import { AgentManager } from "../agent-manager.js"; +import { AgentStorage } from "../agent-storage.js"; +import { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; +import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../tools/paseo-tools.js"; +import { serializePaseoToolInputParameters } from "../tools/paseo-tool-serialization.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js"; import { OpenCodeBridge } from "./opencode/bridge.js"; @@ -23,6 +28,61 @@ import { requireSessionId, } from "./opencode/test-utils/v2-local-e2e-helpers.js"; +test("real OpenCode server advertises agent-scoped notification descriptions", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-manifest-real-")); + const logger = createTestLogger(); + const providerSnapshotManager = new ProviderSnapshotManager({ logger }); + const dependencies = { + agentManager: new AgentManager({ logger }), + agentStorage: new AgentStorage(path.join(root, "agents"), logger), + providerSnapshotManager, + logger, + }; + const bridge = new OpenCodeBridge({ paseoHome: root, logger }); + bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies)); + const manager = new OpenCodeServerManager({ + logger, + resolveHomeDir: () => root, + runtimeSettings: { + env: { + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_CACHE_HOME: path.join(root, "cache"), + OPENCODE_DISABLE_AUTO_UPDATE: "1", + }, + }, + decorateServerEnv: (env) => bridge.decorateServerEnv(env), + }); + let inspection: Awaited> | undefined; + try { + await bridge.start(); + inspection = await manager.acquireCurrent(); + const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: root }); + const response = await sdk.tool.list({ directory: root, provider: "openai", model: "gpt-5.4" }); + expect(response.error).toBeUndefined(); + const agentCatalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: "caller" }); + for (const name of ["create_agent", "send_agent_prompt"]) { + const actual = response.data!.find((tool) => tool.id === `paseo_${name}`)!; + expect(actual).toBeDefined(); + const expected = serializePaseoToolInputParameters(agentCatalog.getTool(name)!); + expect(actual.parameters).toMatchObject({ + properties: { + notifyOnFinish: { + description: (expected.properties as Record) + .notifyOnFinish.description, + }, + }, + }); + } + } finally { + await inspection?.release(); + await manager.shutdown(); + await bridge.close(); + await providerSnapshotManager.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + test("real OpenCode server persists provider permissions across creation and resume", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-")); const cwd = path.join(root, "repo"); diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 4b42410845..8fc6c23d93 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -5,15 +5,18 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { z } from "zod"; +import Ajv from "ajv"; import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; import { PaseoToolInputError } from "../../tools/types.js"; -import { createPaseoToolCatalog } from "../../tools/paseo-tools.js"; import { AgentManager } from "../../agent-manager.js"; import { AgentStorage } from "../../agent-storage.js"; import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js"; +import { createTestAgentClients } from "../../../test-utils/fake-agent-client.js"; +import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tools/paseo-tools.js"; +import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js"; import { OpenCodeBridge, loadOpenCodeBridgePluginArtifact, @@ -72,6 +75,144 @@ function readPluginOptions(env: Record): { } describe("OpenCodeBridge", () => { + test("advertises agent-scoped schemas that execute through both bridge plugins", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-manifest-")); + temporaryDirectories.push(paseoHome); + const logger = createTestLogger(); + const clients = createTestAgentClients(); + const agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger); + const agentManager = new AgentManager({ clients, registry: agentStorage, logger }); + const providerSnapshotManager = new ProviderSnapshotManager({ logger, extraClients: clients }); + const dependencies = { agentManager, agentStorage, providerSnapshotManager, logger }; + const bridge = new OpenCodeBridge({ paseoHome, logger }); + await bridge.start(); + bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies)); + try { + const parent = await agentManager.createAgent( + { provider: "codex", cwd: paseoHome }, + undefined, + { workspaceId: "manifest-workspace" }, + ); + const catalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: parent.id }); + bridge.bindSession({ sessionId: "bound", env: {}, tools: catalog }); + const plugin = readPluginOptions(bridge.decorateServerEnv({})); + const response = await fetch(`${plugin.baseUrl}/_internal/opencode/tools`, { + headers: { Authorization: `Bearer ${plugin.token}` }, + }); + expect(response.status).toBe(200); + const manifest = z + .object({ + tools: z.array( + z.object({ + name: z.string(), + description: z.string(), + inputSchema: z.record(z.string(), z.unknown()), + }), + ), + }) + .parse(await response.json()); + const definitions = new Map(manifest.tools.map((tool) => [tool.name, tool])); + const createDefinition = definitions.get("create_agent")!; + const sendDefinition = definitions.get("send_agent_prompt")!; + const topLevelCatalog = createPaseoToolCatalog(dependencies); + for (const name of ["create_agent", "send_agent_prompt"]) { + expect(serializePaseoToolInputParameters(topLevelCatalog.getTool(name)!)).toMatchObject({ + properties: { notifyOnFinish: { default: false } }, + }); + } + const ajv = new Ajv({ useDefaults: true, strict: false }); + const createInput = { + title: "Bridge child", + provider: "codex/gpt-5.4", + initialPrompt: "Hello", + notifyOnFinish: false, + }; + expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true); + + const v2Config = z + .object({ plugins: z.array(z.object({ package: z.string() })) }) + .parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT)); + const v2Module: { + default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; + } = await import( + pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href + ); + const tools = new Map(); + const dispose = await v2Module.default.setup({ + options: plugin, + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "bound" }), + hook: async () => ({ dispose: async () => undefined }), + }, + }); + try { + const created = await tools + .get("paseo_create_agent")! + .execute(createInput, { sessionID: "bound" }); + const children = agentManager.listAgents().filter((agent) => agent.id !== parent.id); + expect(children).toHaveLength(1); + const child = children[0]!; + expect(created).toMatchObject({ + content: [{ type: "text", text: expect.stringContaining(child.id) }], + }); + expect(child.workspaceId).toBe("manifest-workspace"); + expect([...definitions.keys()]).toEqual([...catalog.tools.keys()]); + for (const definition of manifest.tools) { + const accepted = catalog.getTool(definition.name)!; + expect(definition.inputSchema).toEqual(serializePaseoToolInputParameters(accepted)); + expect(definition.description).toBe(accepted.description); + expect(tools.get(`paseo_${definition.name}`)!.input).toEqual(definition.inputSchema); + } + expect(createDefinition.inputSchema).toMatchObject({ + properties: { notifyOnFinish: { default: true } }, + }); + expect(createDefinition.inputSchema.properties).not.toHaveProperty("background"); + expect(sendDefinition.inputSchema).toMatchObject({ + properties: { notifyOnFinish: { default: true } }, + }); + await expect( + catalog.executeTool("create_agent", { ...createInput, background: true }), + ).rejects.toThrow("background"); + + const v1Module = await import(plugin.pluginUrl); + const hooks = await v1Module.default({}, plugin); + expect(Object.keys(hooks.tool.paseo_create_agent.args)).toEqual( + Object.keys( + z.record(z.string(), z.unknown()).parse(createDefinition.inputSchema.properties), + ), + ); + const sendInput = { agentId: child.id, prompt: "Hello again", notifyOnFinish: false }; + expect(ajv.compile(sendDefinition.inputSchema)(sendInput)).toBe(true); + await expect( + hooks.tool.paseo_send_agent_prompt.execute(sendInput, { + sessionID: "bound", + ask: async () => undefined, + }), + ).resolves.toMatchObject({ output: expect.stringContaining('"success": true') }); + } finally { + await dispose(); + } + } finally { + await bridge.close(); + await Promise.all( + agentManager.listAgents().map((agent) => agentManager.closeAgent(agent.id)), + ); + await agentManager.flushForShutdown(); + await providerSnapshotManager.shutdown(); + } + }); + test("loads packaged bundle bytes without invoking source compilation", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-artifact-")); temporaryDirectories.push(root); @@ -696,6 +837,7 @@ describe("OpenCodeBridge", () => { interface V2TestTool { name: string; + input?: unknown; execute(input: unknown, call: { sessionID: string }): Promise; } interface V2TestContext { diff --git a/packages/server/src/server/agent/providers/opencode/bridge.ts b/packages/server/src/server/agent/providers/opencode/bridge.ts index 4e9902bd7a..7abe34475d 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.ts @@ -60,7 +60,7 @@ export class OpenCodeBridge { private baseUrl: string | null = null; private pluginUrl: string | null = null; private v2PluginUrl: string | null = null; - private manifestCatalog: PaseoToolCatalog | null = null; + private manifestCatalog: Pick | null = null; constructor(options: OpenCodeBridgeOptions) { this.paseoHome = options.paseoHome; @@ -89,7 +89,7 @@ export class OpenCodeBridge { this.baseUrl = `http://127.0.0.1:${address.port}`; } - setManifestCatalog(catalog: PaseoToolCatalog | null): void { + setManifestCatalog(catalog: Pick | null): void { this.manifestCatalog = catalog; } diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 34a8d5ebeb..30f28426b1 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -637,6 +637,20 @@ function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInp } export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog { + return createToolCatalog(options, options.callerAgentId ? "agent" : "top-level"); +} + +export function createPaseoAgentToolManifest( + options: PaseoToolHostDependencies, +): Pick { + // Plugins register before a caller exists, but every execution uses an agent-bound catalog. + return { tools: createToolCatalog(options, "agent").tools }; +} + +function createToolCatalog( + options: PaseoToolHostDependencies, + scope: "agent" | "top-level", +): PaseoToolCatalog { const { agentManager, agentStorage, @@ -1209,7 +1223,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."), }; const createAgentInputSchema = z - .object(callerAgentId ? agentToAgentInputSchema : canonicalTopLevelInputSchema) + .object(scope === "agent" ? agentToAgentInputSchema : canonicalTopLevelInputSchema) .passthrough(); const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict(); const legacyAgentToAgentCreateAgentArgsSchema = z.object(legacyAgentToAgentInputSchema).strict(); @@ -1245,9 +1259,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase "Agent-scoped only: get notified when the prompted agent finishes, errors, or needs permission.", ), }; - const sendAgentPromptInputSchema = callerAgentId - ? agentToAgentSendAgentPromptInputSchema - : topLevelSendAgentPromptInputSchema; + const sendAgentPromptInputSchema = + scope === "agent" ? agentToAgentSendAgentPromptInputSchema : topLevelSendAgentPromptInputSchema; const inspectProviderInputSchema = { provider: ProviderOrProviderModelInputSchema.describe( "Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).", @@ -2653,7 +2666,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .optional() .describe("IANA time zone for the cron cadence. For example: America/New_York."), name: z.string().optional(), - provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe( + provider: (scope === "agent" ? AgentProviderEnum.optional() : AgentProviderEnum).describe( "Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.", ), cwd: z.string().optional(), diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 727c7b7899..73e668735f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -137,6 +137,7 @@ import { attachAgentStoragePersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; import { createPaseoToolCatalog, + createPaseoAgentToolManifest, type PaseoToolHostDependencies, } from "./agent/tools/paseo-tools.js"; import type { PaseoToolRuntimeContext } from "./agent/tools/types.js"; @@ -1457,7 +1458,9 @@ export async function createPaseoDaemon( const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) => createPaseoToolCatalog(createAgentToolHostDependencies(runtime)); const setAgentProviderToolsEnabled = (enabled: boolean) => { - agentProviderRuntime.setPaseoToolCatalog(enabled ? createAgentToolCatalog({}) : null); + agentProviderRuntime.setPaseoToolCatalog( + enabled ? createPaseoAgentToolManifest(createAgentToolHostDependencies({})) : null, + ); }; agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog); agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false); From eda3e4318f4e06cbe1e1dee8639725d6d95240fc Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:01:45 +0200 Subject: [PATCH 04/13] Clarify and localize provider usage (#7376) * Localize provider usage UI * fix: restore compact provider usage meter * fix: localize compact context percentage * fix(app): deduplicate context meter refresh * fix(app): localize provider usage time counts * fix(app): stabilize compact provider usage row * fix(app): localize provider usage plurals * test(app): allow locale-specific plural keys * fix(app): show compact quota without context usage * fix(app): preserve context overflow percentage * fix(app): truncate provider balance reset values * fix(app): add Korean provider usage translations * fix(app): refresh provider usage timing * fix(app): localize provider usage labels * fix(app): clarify compact usage policies * fix(app): localize monthly provider usage * test(app): require Arabic provider usage plurals * docs(i18n): keep migration guidance focused * fix(app): hide empty compact usage slot * fix(app): keep usage in context tooltip * fix(app): align usage with sidebar reports * test(app): pass usage timing clock explicitly * fix(app): restore context usage details * fix(app): make context usage tooltip interactive * fix(app): support keyboard tooltip controls * fix(app): keep focused tooltip controls open * fix(app): address usage localization review * fix(app): localize bulk usage refresh * fix(app): localize Claude session window * fix(app): complete provider usage localization * fix(app): keep mounted usage times and translations current Pass time and translation values into usage rendering so React Compiler invalidates cached labels when they change. Use the same localized window names and percentage meanings in sidebar summaries. * test(desktop): wait for restored tabs before shortcut input Document load completes before the workspace reconnects. Wait for the restored selection so Ctrl+1 reaches the mounted workspace rather than the startup screen. * test(app): allow the shared usage clock minute phase The browser clock advances independently of daemon timestamps, and the shared ticker samples on wall-minute boundaries. Assert the bounded one-to-two-minute age and matching countdown progress instead of a single phase-dependent value. --------- Co-authored-by: Michael Wu Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../e2e/browser/context-window-meter.spec.ts | 4 +- packages/app/e2e/browser/usage-modal.spec.ts | 113 +++++++++++-- .../app/e2e/browser/usage-settings.spec.ts | 6 +- .../src/components/context-window-meter.tsx | 7 +- .../context-window-meter.utils.test.ts | 9 ++ .../components/context-window-meter.utils.ts | 7 + packages/app/src/i18n/resources.test.ts | 51 ++++-- packages/app/src/i18n/resources/ar.ts | 100 ++++++++++++ packages/app/src/i18n/resources/en.ts | 80 ++++++++- packages/app/src/i18n/resources/es.ts | 64 ++++++++ packages/app/src/i18n/resources/fr.ts | 64 ++++++++ packages/app/src/i18n/resources/ja.ts | 64 ++++++++ packages/app/src/i18n/resources/ko.ts | 64 ++++++++ packages/app/src/i18n/resources/pt-BR.ts | 64 ++++++++ packages/app/src/i18n/resources/ru.ts | 64 ++++++++ packages/app/src/i18n/resources/zh-CN.ts | 64 ++++++++ packages/app/src/usage/agent-usage.tsx | 5 +- packages/app/src/usage/balance-bar.tsx | 48 ++++-- packages/app/src/usage/bars.browser.test.tsx | 53 ++++++ packages/app/src/usage/card.tsx | 38 +++-- packages/app/src/usage/copy.ts | 49 ++---- packages/app/src/usage/format.test.ts | 114 ++++++++++++- packages/app/src/usage/format.ts | 152 +++++++++++++++--- packages/app/src/usage/host-usage-section.tsx | 5 +- packages/app/src/usage/model.test.ts | 40 +++-- packages/app/src/usage/model.ts | 59 ++++--- packages/app/src/usage/options.tsx | 28 ++-- packages/app/src/usage/pinned.test.ts | 26 +++ packages/app/src/usage/pinned.ts | 10 +- packages/app/src/usage/queries.ts | 20 ++- packages/app/src/usage/refresh-button.tsx | 5 +- packages/app/src/usage/sidebar-item.tsx | 3 +- packages/app/src/usage/usage-modal.tsx | 4 +- packages/app/src/usage/usage-section.tsx | 16 +- .../app/src/usage/use-relative-time-tick.ts | 13 ++ packages/app/src/usage/window-bar.tsx | 31 ++-- packages/app/vitest.config.ts | 21 +++ .../e2e/keyboard-shortcut-unassign.spec.ts | 2 + 38 files changed, 1375 insertions(+), 192 deletions(-) create mode 100644 packages/app/src/components/context-window-meter.utils.test.ts create mode 100644 packages/app/src/usage/bars.browser.test.tsx create mode 100644 packages/app/src/usage/use-relative-time-tick.ts diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 5d024de761..79a572a24b 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -149,7 +149,7 @@ for (const layout of [ await expect(details.getByText(/tokens$/)).toHaveCount(0); const account = usageCard(details, "login-journey:account"); await expect(account.getByText("Session", { exact: true })).toBeVisible(); - await expect(account.getByText("31%", { exact: true })).toBeVisible(); + await expect(account.getByText("31% used", { exact: true })).toBeVisible(); await qaScreenshot(page, `context-without-data-${layout.name}`); await layout.close(page); }); @@ -161,7 +161,7 @@ for (const layout of [ await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible(); await expect(details.getByText("No context data", { exact: true })).toHaveCount(0); await expect( - usageCard(details, "login-journey:account").getByText("31%", { exact: true }), + usageCard(details, "login-journey:account").getByText("31% used", { exact: true }), ).toBeVisible(); }); } finally { diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index f51c0ad510..b680754da2 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -4,9 +4,10 @@ import path from "node:path"; import type { Page } from "@playwright/test"; import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { expect, test } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; import { addConnectedHostAndReload } from "../support/helpers/hosts"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { openSettingsSection } from "../support/helpers/settings"; import { getServerId } from "../support/helpers/server-id"; import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { @@ -131,7 +132,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt await refreshAllUsage(page); await usage.waitForListRequests(requestCount + 1); const group = page.getByTestId(`usage-host-${getServerId()}`); - await expect(group.getByText("41%", { exact: true })).toBeVisible(); + await expect(group.getByText("41% used", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled(); } @@ -178,7 +179,7 @@ test.describe("usage modal", () => { await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(hostFilter(page)).toHaveCount(0); await qaScreenshot(page, "usage-modal-one-host"); - await expect(group.getByText("31%")).toBeVisible(); + await expect(group.getByText("31% used")).toBeVisible(); await expect(group.getByText("Beta plan", { exact: true })).toBeVisible(); await expect(group.getByText("Unavailable", { exact: true })).toBeVisible(); @@ -240,15 +241,15 @@ test.describe("usage modal", () => { await gotoAppShell(page); await openUsageFromItem(page); const group = page.getByTestId(`usage-host-${serverId}`); - await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 }); + await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 }); const alphaRefresh = group.getByTestId("usage-refresh").first(); await alphaRefresh.click(); await expect .poll(() => forcedRefreshes(usage)) .toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]); - await expect(group.getByText("58%")).toBeVisible(); - await expect(group.getByText("12%")).toBeVisible(); + await expect(group.getByText("58% used")).toBeVisible(); + await expect(group.getByText("12% used")).toBeVisible(); await group.getByTestId("usage-refresh").nth(1).hover(); await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago"); @@ -332,7 +333,7 @@ test.describe("usage modal", () => { await hostFilter(page).click(); await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click(); await expect( - page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), + page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"), ).toBeVisible({ timeout: 30_000 }); await expect(hostFilter(page)).toContainText("Secondary box"); await qaScreenshot(page, "usage-modal-picked-host"); @@ -347,7 +348,7 @@ test.describe("usage modal", () => { await expectPinnedUsage(page, ["12% Weekly"]); await openUsageFromItem(page); await expect( - page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), + page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"), ).toBeVisible({ timeout: 30_000 }); } finally { await secondary.close().catch(() => undefined); @@ -513,7 +514,7 @@ for (const theme of ["light", "dark"] as const) { await expect(screen.getByText("69% left")).toBeVisible(); await showUsageAs(page, "used"); await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName( - /^Pin Claude Session, 31% · resets /, + /^Pin Claude Session, 31% used · resets /, ); await openUsageOptions(page); await closeUsageOptions(page); @@ -576,7 +577,7 @@ test("an account with all logins failing shows each harness and remedy, then onl }, ]); await refreshLoginUsage(page); - await expectLoginUsageOnly(page, "42%"); + await expectLoginUsageOnly(page, "42% used"); } finally { await fixture.cleanup(); } @@ -599,6 +600,7 @@ for (const compact of [false, true]) { }) => { const fixture = await installLoginUsage({ status: "error", error: "Initial failure" }); try { + await page.clock.install(); await page.setViewportSize( compact ? { width: 390, height: 844 } : { width: 1440, height: 900 }, ); @@ -615,7 +617,8 @@ for (const compact of [false, true]) { // Move only the browser clock; real daemon fetches continue using the host clock. await page.clock.setFixedTime(new Date(Date.now() + 3_900_000)); await refreshLoginUsage(page); - await expectLoginUsageOnly(page, "42%"); + await expectLoginUsageOnly(page, "42% used"); + await advanceUsageClock(page, 60_000); await expectLoginUsageFreshness(page, "Updated 1h ago", compact); await fixture.setReport({ status: "error", error: "Rate limited" }); @@ -640,8 +643,9 @@ for (const compact of [false, true]) { }); await page.clock.setFixedTime(new Date(Date.now() + 150_000)); await refreshLoginUsage(page); - await expectLoginUsageOnly(page, "43%"); - await expect(page.getByText("42%", { exact: true })).toHaveCount(0); + await expectLoginUsageOnly(page, "43% used"); + await advanceUsageClock(page, 60_000); + await expect(page.getByText("42% used", { exact: true })).toHaveCount(0); await expectLoginUsageFreshness(page, "Updated 2m ago", compact); } finally { await fixture.cleanup(); @@ -651,7 +655,88 @@ for (const compact of [false, true]) { async function expectRetainedLoginUsage(page: Page, message: string) { const card = page.getByTestId("usage-report-login-journey:account"); - await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible(); + await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible(); await expect(card.getByTestId("usage-login-error")).toHaveText(message); await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible(); } + +async function switchToJapanese(page: Page) { + await page.getByRole("button", { name: "Close", exact: true }).click(); + await openSettings(page); + await openSettingsSection(page, "general"); + await page.getByRole("button", { name: "System", exact: true }).click(); + await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click(); + await page.getByRole("button", { name: "戻る", exact: true }).click(); +} + +async function openJapaneseUsage(page: Page) { + await page.getByRole("button", { name: "使用状況", exact: true }).click(); + await expect(page.getByTestId("usage-modal").first()).toBeVisible(); +} + +test("usage percentages state their meaning and follow the selected language", async ({ page }) => { + test.setTimeout(120_000); + const login = await installLoginUsage({ + status: "available", + windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }], + }); + try { + await gotoAppShell(page); + await openUsageFromIcon(page); + await expect(page.getByText("70% used", { exact: true })).toBeVisible(); + await switchToJapanese(page); + await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/); + await openJapaneseUsage(page); + await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "設定", exact: true }).last().click(); + await expect(page.getByText("割合", { exact: true })).toBeVisible(); + await expect(page.getByText("使用済み", { exact: true })).toBeVisible(); + await expect(page.getByText("残り", { exact: true })).toBeVisible(); + } finally { + await login.cleanup(); + } +}); + +test("open usage clocks advance without refreshing or reopening", async ({ page }) => { + test.setTimeout(120_000); + const now = new Date(); + const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString(); + const login = await installLoginUsage({ + status: "available", + windows: [ + { id: "session", label: "Session", usedPct: 42, resetsAt: reset }, + { id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 }, + ], + balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }], + }); + try { + await openCompactUsageWithClock(page, now); + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now"); + await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible(); + // The shared minute clock is wall-aligned, and the daemon timestamps use real time. + // After two browser minutes the latest displayed tick is one or two minutes old. + await advanceUsageClock(page, 120_000); + await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/); + await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible(); + await expect(card.getByText(/runs out (28|29)m/)).toBeVisible(); + await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText( + /^resets (28|29)m$/, + ); + } finally { + await login.cleanup(); + } +}); + +async function openCompactUsageWithClock(page: Page, now: Date) { + await page.setViewportSize({ width: 390, height: 844 }); + await page.clock.install({ time: now }); + await gotoAppShell(page); + await openCompactSidebar(page); + await openUsageFromIcon(page); +} + +async function advanceUsageClock(page: Page, milliseconds: number) { + await page.clock.fastForward(milliseconds); +} diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 1b509f5640..15dd2fdb7a 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -93,7 +93,7 @@ test.describe("usage settings", () => { await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible(); await expect(card.getByText("Beta plan", { exact: true })).toBeVisible(); - await expect(card.getByText("70%")).toBeVisible(); + await expect(card.getByText("70% used")).toBeVisible(); await expect(card.getByText("1,234 left", { exact: true })).toBeVisible(); await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible(); await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); @@ -131,11 +131,11 @@ test.describe("usage settings", () => { await openSettings(page); await openSettingsHostSection(page, serverId, "usage"); const card = page.getByTestId("usage-card"); - await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 }); + await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 }); await refreshAllUsage(page); await expect.poll(() => forcedRefreshCount(usage)).toBe(1); - await expect(card.getByText("64%")).toBeVisible(); + await expect(card.getByText("64% used")).toBeVisible(); }); test("asks to update a host without usage support and never calls it", async ({ page }) => { diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 11931bdd41..f9cfabcf8d 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -11,6 +11,7 @@ import { useHostReportsUsage } from "@/usage"; import type { Theme } from "@/styles/theme"; import { ContextWindowDetails } from "./context-window-details"; import { ContextWindowSheet } from "./context-window-sheet"; +import { formatContextPercentage } from "./context-window-meter.utils"; interface ContextWindowMeterProps { serverId: string; @@ -150,7 +151,7 @@ export function ContextWindowMeter({ showPercentage = false, glyphSize, }: ContextWindowMeterProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { width } = useWindowDimensions(); // Usage cards need a wider popover; without them it keeps the plain tooltip shape. const showsUsage = useHostReportsUsage(serverId); @@ -192,7 +193,9 @@ export function ContextWindowMeter({ ); const percentageLabel = showPercentage && context ? ( - {`${context.percentage}%`} + + {formatContextPercentage(context.percentage, i18n.resolvedLanguage)} + ) : null; const accessibilityLabel = context ? t("contextWindow.accessibility", { percentage: context.percentage }) diff --git a/packages/app/src/components/context-window-meter.utils.test.ts b/packages/app/src/components/context-window-meter.utils.test.ts new file mode 100644 index 0000000000..394c933176 --- /dev/null +++ b/packages/app/src/components/context-window-meter.utils.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { formatContextPercentage } from "./context-window-meter.utils"; + +describe("context window meter formatting", () => { + it("localizes percentages without hiding context overflow", () => { + expect(formatContextPercentage(125, "en")).toBe("125%"); + expect(formatContextPercentage(42, "fr")).toBe("42\u00a0%"); + }); +}); diff --git a/packages/app/src/components/context-window-meter.utils.ts b/packages/app/src/components/context-window-meter.utils.ts index a0fa45ccbf..055c01386c 100644 --- a/packages/app/src/components/context-window-meter.utils.ts +++ b/packages/app/src/components/context-window-meter.utils.ts @@ -1,3 +1,10 @@ +export function formatContextPercentage(value: number, locale?: string): string { + return new Intl.NumberFormat(locale, { + style: "percent", + maximumFractionDigits: 0, + }).format(value / 100); +} + export function formatTokenCount(value: number): string { if (value >= 1_000_000) { return `${Math.round(value / 1_000_000)}m`; diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index bf42a3b61a..c837815a7c 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -20,6 +20,30 @@ function flattenKeys(value: unknown, prefix = ""): string[] { return entries.flatMap(([key, child]) => flattenKeys(child, prefix ? `${prefix}.${key}` : key)); } +const pluralSuffixPattern = /_(zero|one|two|few|many|other)$/; +const arabicPluralSuffixes = ["zero", "one", "two", "few", "many", "other"] as const; +const arabicProviderUsagePluralKeys = [ + "providerUsage.duration.days", + "providerUsage.duration.hours", + "providerUsage.duration.minutes", + "providerUsage.timing.daysAgo", + "providerUsage.timing.hoursAgo", + "providerUsage.timing.minutesAgo", +] as const; + +function canonicalKeys(value: unknown): string[] { + const keys = flattenKeys(value); + const keySet = new Set(keys); + return [ + ...new Set( + keys.map((key) => { + const baseKey = key.replace(pluralSuffixPattern, ""); + return baseKey !== key && keySet.has(baseKey) ? baseKey : key; + }), + ), + ].sort(); +} + function flattenStrings(value: unknown, prefix = ""): Record { if (typeof value === "string") { return { [prefix]: value }; @@ -144,15 +168,24 @@ function findUntranslatedConnectionErrors(): string[] { describe("translation resources", () => { it("keeps all supported language keys in sync with English", () => { - const englishKeys = flattenKeys(en).sort(); - expect(flattenKeys(ar).sort()).toEqual(englishKeys); - expect(flattenKeys(es).sort()).toEqual(englishKeys); - expect(flattenKeys(fr).sort()).toEqual(englishKeys); - expect(flattenKeys(ja).sort()).toEqual(englishKeys); - expect(flattenKeys(ko).sort()).toEqual(englishKeys); - expect(flattenKeys(ptBR).sort()).toEqual(englishKeys); - expect(flattenKeys(ru).sort()).toEqual(englishKeys); - expect(flattenKeys(zhCN).sort()).toEqual(englishKeys); + const englishKeys = canonicalKeys(en); + expect(canonicalKeys(ar)).toEqual(englishKeys); + expect(canonicalKeys(es)).toEqual(englishKeys); + expect(canonicalKeys(fr)).toEqual(englishKeys); + expect(canonicalKeys(ja)).toEqual(englishKeys); + expect(canonicalKeys(ko)).toEqual(englishKeys); + expect(canonicalKeys(ptBR)).toEqual(englishKeys); + expect(canonicalKeys(ru)).toEqual(englishKeys); + expect(canonicalKeys(zhCN)).toEqual(englishKeys); + }); + + it("includes every Arabic form for provider-usage durations", () => { + const arabicStrings = flattenStrings(ar); + for (const key of arabicProviderUsagePluralKeys) { + for (const suffix of arabicPluralSuffixes) { + expect(arabicStrings[`${key}_${suffix}`]).toBeTypeOf("string"); + } + } }); it("keeps non-English supported languages translated beyond fallback labels", () => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b7b81c66ee..27be1cd3ea 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2046,6 +2046,106 @@ export const ar: TranslationResources = { saveAccessibility: "حفظ تعليق المراجعة", }, }, + providerUsage: { + options: "الإعدادات", + refreshFailed: "تعذّر تحديث الاستخدام", + noHosts: "لا توجد مضيفات متصلة", + agentError: "تعذّر تحميل الاستخدام: {{reason}}", + pin: "تثبيت", + unpin: "إلغاء التثبيت", + displayAs: "النسب المئوية", + displayUsed: "المستخدم", + displayRemaining: "المتبقي", + showInSidebar: "ملخص في الشريط الجانبي", + showInSidebarHint: "تظهر الفترات المثبتة في أسفل الشريط الجانبي", + runToRefresh: "شغّل {{command}} للتحديث.", + signInAgain: "سجّل الدخول مجددًا.", + loginRejected: "تم رفض تسجيل الدخول (HTTP {{status}}). {{remedy}}", + loginExpired: "انتهت صلاحية تسجيل الدخول {{relativeTime}}. {{remedy}}", + title: "استخدام الخطة", + refresh: "تحديث", + refreshAll: "تحديث الكل", + refreshing: "جارٍ التحديث...", + loading: "جارٍ تحميل بيانات الاستخدام...", + empty: "لا توجد بيانات استخدام", + errorTitle: "تعذّر تحميل بيانات الاستخدام", + hostUnavailable: "اتصل بـ{{host}} لعرض استخدام المزوّد", + hostUpgradeRequired: "حدّث {{host}} لعرض استخدام المزوّد", + clientUnavailable: "اتصال المضيف غير جاهز", + retry: "حاول مرة أخرى", + tooltipLoading: "جارٍ تحميل استخدام الخطة...", + status: { + error: "خطأ", + unavailable: "غير متاح", + }, + values: { + used: "تم استخدام {{percentage}}", + remaining: "متبقٍ {{amount}}", + }, + labels: { + session: "الجلسة", + weekly: "أسبوعي", + monthly: "شهري", + codeReview: "مراجعة التعليمات البرمجية", + credits: "الأرصدة", + monthlyCredits: "أرصدة شهرية", + }, + duration: { + days: "{{value}} يوم", + days_zero: "{{value}} يوم", + days_one: "يوم واحد", + days_two: "يومين", + days_few: "{{value}} أيام", + days_many: "{{value}} يومًا", + days_other: "{{value}} يوم", + hours: "{{value}} ساعة", + hours_zero: "{{value}} ساعة", + hours_one: "ساعة واحدة", + hours_two: "ساعتين", + hours_few: "{{value}} ساعات", + hours_many: "{{value}} ساعة", + hours_other: "{{value}} ساعة", + minutes: "{{value}} دقيقة", + minutes_zero: "{{value}} دقيقة", + minutes_one: "دقيقة واحدة", + minutes_two: "دقيقتين", + minutes_few: "{{value}} دقائق", + minutes_many: "{{value}} دقيقة", + minutes_other: "{{value}} دقيقة", + }, + timing: { + lastUpdated: "آخر تحديث {{relativeTime}}", + noSuccessfulUpdate: "لا يوجد تحديث ناجح", + resetTimePassed: "مضى وقت إعادة التعيين", + resettingNow: "تتم إعادة التعيين الآن", + resetsIn: "تتم إعادة التعيين خلال {{duration}}", + runsOutNow: "ينفد الآن", + runsOutIn: "ينفد خلال {{duration}}", + justNow: "الآن", + daysAgo: "قبل {{value}} يومًا", + daysAgo_zero: "قبل {{value}} يوم", + daysAgo_one: "قبل يوم واحد", + daysAgo_two: "قبل يومين", + daysAgo_few: "قبل {{value}} أيام", + daysAgo_many: "قبل {{value}} يومًا", + daysAgo_other: "قبل {{value}} يوم", + hoursAgo: "قبل {{value}} ساعة", + hoursAgo_zero: "قبل {{value}} ساعة", + hoursAgo_one: "قبل ساعة واحدة", + hoursAgo_two: "قبل ساعتين", + hoursAgo_few: "قبل {{value}} ساعات", + hoursAgo_many: "قبل {{value}} ساعة", + hoursAgo_other: "قبل {{value}} ساعة", + minutesAgo: "قبل {{value}} دقيقة", + minutesAgo_zero: "قبل {{value}} دقيقة", + minutesAgo_one: "قبل دقيقة واحدة", + minutesAgo_two: "قبل دقيقتين", + minutesAgo_few: "قبل {{value}} دقائق", + minutesAgo_many: "قبل {{value}} دقيقة", + minutesAgo_other: "قبل {{value}} دقيقة", + updated: "تم التحديث {{relativeTime}}", + }, + }, settings: { title: "إعدادات", loading: "جارٍ تحميل الإعدادات...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index d8a4081dda..c81039affa 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2071,6 +2071,70 @@ export const en = { saveAccessibility: "Save review comment", }, }, + providerUsage: { + options: "Settings", + refreshFailed: "Unable to refresh usage", + noHosts: "No connected hosts", + agentError: "Unable to load usage: {{reason}}", + pin: "Pin", + unpin: "Unpin", + displayAs: "Percentages", + displayUsed: "Used", + displayRemaining: "Remaining", + showInSidebar: "Summary in sidebar", + showInSidebarHint: "Pinned windows show in the sidebar footer", + runToRefresh: "Run {{command}} to refresh it.", + signInAgain: "Sign in again.", + loginRejected: "Login rejected (HTTP {{status}}). {{remedy}}", + loginExpired: "Login expired {{relativeTime}}. {{remedy}}", + title: "Plan usage", + refresh: "Refresh", + refreshAll: "Refresh all", + refreshing: "Refreshing...", + loading: "Loading usage...", + empty: "No usage data", + errorTitle: "Unable to load usage", + hostUnavailable: "Connect to {{host}} to see usage", + hostUpgradeRequired: "Update {{host}} to see usage", + clientUnavailable: "Host connection is not ready", + retry: "Try again", + tooltipLoading: "Loading plan usage...", + status: { + error: "Error", + unavailable: "Unavailable", + }, + values: { + used: "{{percentage}} used", + remaining: "{{amount}} left", + }, + labels: { + session: "Session", + weekly: "Weekly", + monthly: "Monthly", + codeReview: "Code review", + credits: "Credits", + monthlyCredits: "Monthly credits", + }, + duration: { + days: "{{value}}d", + hours: "{{value}}h", + minutes: "{{value}}m", + }, + timing: { + lastUpdated: "Last updated {{relativeTime}}", + noSuccessfulUpdate: "No successful update", + resetTimePassed: "Reset time passed", + resettingNow: "resetting now", + resetsIn: "resets {{duration}}", + runsOutNow: "runs out now", + runsOutIn: "runs out {{duration}}", + justNow: "just now", + daysAgo: "{{value}}d ago", + hoursAgo: "{{value}}h ago", + minutesAgo: "{{value}}m ago", + updated: "Updated {{relativeTime}}", + }, + }, settings: { title: "Settings", loading: "Loading settings...", @@ -2973,4 +3037,18 @@ type WidenStringLeaves = { [K in keyof T]: T[K] extends string ? string : WidenStringLeaves; }; -export type TranslationResources = WidenStringLeaves; +type TranslationResourcesBase = WidenStringLeaves; +type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other"; +type DurationPluralKey = `${"days" | "hours" | "minutes"}_${PluralCategory}`; +type AgoPluralKey = `${"daysAgo" | "hoursAgo" | "minutesAgo"}_${PluralCategory}`; + +// i18next plural categories differ by locale, so locale catalogs may add the +// explicit v4 suffixes they need while retaining the canonical fallback keys. +export type TranslationResources = TranslationResourcesBase & { + providerUsage: TranslationResourcesBase["providerUsage"] & { + duration: TranslationResourcesBase["providerUsage"]["duration"] & + Partial>; + timing: TranslationResourcesBase["providerUsage"]["timing"] & + Partial>; + }; +}; diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7fba1c22b9..03511d299c 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2094,6 +2094,70 @@ export const es: TranslationResources = { saveAccessibility: "Guardar comentario de revisión", }, }, + providerUsage: { + options: "Configuración", + refreshFailed: "No se pudo actualizar el uso", + noHosts: "No hay hosts conectados", + agentError: "No se pudo cargar el uso: {{reason}}", + pin: "Fijar", + unpin: "Desfijar", + displayAs: "Porcentajes", + displayUsed: "Usado", + displayRemaining: "Restante", + showInSidebar: "Resumen en la barra lateral", + showInSidebarHint: "Los períodos fijados aparecen al pie de la barra lateral", + runToRefresh: "Ejecuta {{command}} para actualizar.", + signInAgain: "Vuelve a iniciar sesión.", + loginRejected: "Inicio de sesión rechazado (HTTP {{status}}). {{remedy}}", + loginExpired: "La sesión caducó {{relativeTime}}. {{remedy}}", + title: "Uso del plan", + refresh: "Actualizar", + refreshAll: "Actualizar todo", + refreshing: "Actualizando...", + loading: "Cargando uso...", + empty: "No hay datos de uso", + errorTitle: "No se pudo cargar el uso", + hostUnavailable: "Conéctate a {{host}} para ver el uso del proveedor", + hostUpgradeRequired: "Actualiza {{host}} para ver el uso del proveedor", + clientUnavailable: "La conexión con el host no está lista", + retry: "Intentar de nuevo", + tooltipLoading: "Cargando uso del plan...", + status: { + error: "Error", + unavailable: "No disponible", + }, + values: { + used: "Usado: {{percentage}}", + remaining: "Quedan {{amount}}", + }, + labels: { + session: "Sesión", + weekly: "Semanal", + monthly: "Mensual", + codeReview: "Revisión de código", + credits: "Créditos", + monthlyCredits: "Créditos mensuales", + }, + duration: { + days: "{{value}} d", + hours: "{{value}} h", + minutes: "{{value}} min", + }, + timing: { + lastUpdated: "Última actualización {{relativeTime}}", + noSuccessfulUpdate: "No hay actualizaciones correctas", + resetTimePassed: "Pasó la hora de restablecimiento", + resettingNow: "se restablece ahora", + resetsIn: "se restablece en {{duration}}", + runsOutNow: "se agota ahora", + runsOutIn: "se agota en {{duration}}", + justNow: "ahora mismo", + daysAgo: "hace {{value}} d", + hoursAgo: "hace {{value}} h", + minutesAgo: "hace {{value}} min", + updated: "Actualizado {{relativeTime}}", + }, + }, settings: { title: "Ajustes", loading: "Cargando configuración...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 54b6de6098..e581139dd0 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2094,6 +2094,70 @@ export const fr: TranslationResources = { saveAccessibility: "Enregistrer le commentaire de revue", }, }, + providerUsage: { + options: "Paramètres", + refreshFailed: "Impossible d’actualiser l’utilisation", + noHosts: "Aucun hôte connecté", + agentError: "Impossible de charger l’utilisation : {{reason}}", + pin: "Épingler", + unpin: "Désépingler", + displayAs: "Pourcentages", + displayUsed: "Utilisé", + displayRemaining: "Restant", + showInSidebar: "Résumé dans la barre latérale", + showInSidebarHint: "Les périodes épinglées apparaissent en bas de la barre latérale", + runToRefresh: "Exécutez {{command}} pour actualiser.", + signInAgain: "Reconnectez-vous.", + loginRejected: "Connexion refusée (HTTP {{status}}). {{remedy}}", + loginExpired: "Connexion expirée {{relativeTime}}. {{remedy}}", + title: "Utilisation du forfait", + refresh: "Actualiser", + refreshAll: "Tout actualiser", + refreshing: "Actualisation...", + loading: "Chargement de l’utilisation...", + empty: "Aucune donnée d’utilisation", + errorTitle: "Impossible de charger l’utilisation", + hostUnavailable: "Connectez-vous à {{host}} pour voir l’utilisation du fournisseur", + hostUpgradeRequired: "Mettez à jour {{host}} pour voir l’utilisation du fournisseur", + clientUnavailable: "La connexion à l’hôte n’est pas prête", + retry: "Réessayer", + tooltipLoading: "Chargement de l’utilisation du forfait...", + status: { + error: "Erreur", + unavailable: "Indisponible", + }, + values: { + used: "Utilisé : {{percentage}}", + remaining: "Il reste {{amount}}", + }, + labels: { + session: "Session", + weekly: "Hebdomadaire", + monthly: "Mensuel", + codeReview: "Révision de code", + credits: "Crédits", + monthlyCredits: "Crédits mensuels", + }, + duration: { + days: "{{value}} j", + hours: "{{value}} h", + minutes: "{{value}} min", + }, + timing: { + lastUpdated: "Dernière mise à jour {{relativeTime}}", + noSuccessfulUpdate: "Aucune mise à jour réussie", + resetTimePassed: "Heure de réinitialisation dépassée", + resettingNow: "réinitialisation en cours", + resetsIn: "se réinitialise dans {{duration}}", + runsOutNow: "s’épuise maintenant", + runsOutIn: "s’épuise dans {{duration}}", + justNow: "à l’instant", + daysAgo: "il y a {{value}} j", + hoursAgo: "il y a {{value}} h", + minutesAgo: "il y a {{value}} min", + updated: "Mis à jour {{relativeTime}}", + }, + }, settings: { title: "Paramètres", loading: "Chargement des paramètres…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 31f1d4241f..07993a6b2c 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2067,6 +2067,70 @@ export const ja: TranslationResources = { saveAccessibility: "レビューコメントを保存", }, }, + providerUsage: { + options: "設定", + refreshFailed: "使用状況を更新できません", + noHosts: "接続中のホストがありません", + agentError: "使用状況を読み込めません: {{reason}}", + pin: "ピン留め", + unpin: "ピン留めを解除", + displayAs: "割合", + displayUsed: "使用済み", + displayRemaining: "残り", + showInSidebar: "サイドバーに概要を表示", + showInSidebarHint: "ピン留めした期間をサイドバーの下部に表示します", + runToRefresh: "{{command}} を実行して更新してください。", + signInAgain: "再度サインインしてください。", + loginRejected: "ログインが拒否されました (HTTP {{status}})。{{remedy}}", + loginExpired: "ログインは{{relativeTime}}に期限切れになりました。{{remedy}}", + title: "プラン使用量", + refresh: "更新", + refreshAll: "すべて更新", + refreshing: "更新中...", + loading: "使用状況を読み込み中...", + empty: "使用状況データがありません", + errorTitle: "使用状況を読み込めません", + hostUnavailable: "{{host}} に接続してプロバイダーの使用状況を確認してください", + hostUpgradeRequired: "{{host}} を更新してプロバイダーの使用状況を確認してください", + clientUnavailable: "ホスト接続の準備ができていません", + retry: "再試行", + tooltipLoading: "プラン使用状況を読み込み中...", + status: { + error: "エラー", + unavailable: "利用不可", + }, + values: { + used: "{{percentage}} 使用済み", + remaining: "残り {{amount}}", + }, + labels: { + session: "セッション", + weekly: "週間", + monthly: "月間", + codeReview: "コードレビュー", + credits: "クレジット", + monthlyCredits: "月間クレジット", + }, + duration: { + days: "{{value}}日", + hours: "{{value}}時間", + minutes: "{{value}}分", + }, + timing: { + lastUpdated: "最終更新: {{relativeTime}}", + noSuccessfulUpdate: "正常な更新はまだありません", + resetTimePassed: "リセット時刻を過ぎています", + resettingNow: "まもなくリセット", + resetsIn: "{{duration}}後にリセット", + runsOutNow: "まもなく上限に到達", + runsOutIn: "{{duration}}後に上限に到達", + justNow: "たった今", + daysAgo: "{{value}}日前", + hoursAgo: "{{value}}時間前", + minutesAgo: "{{value}}分前", + updated: "更新: {{relativeTime}}", + }, + }, settings: { title: "設定", loading: "設定を読み込み中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 1214aa2435..00e18ad1e3 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2056,6 +2056,70 @@ export const ko: TranslationResources = { saveAccessibility: "리뷰 댓글 저장", }, }, + providerUsage: { + options: "설정", + refreshFailed: "사용량을 새로 고칠 수 없습니다", + noHosts: "연결된 호스트가 없습니다", + agentError: "사용량을 불러올 수 없습니다: {{reason}}", + pin: "고정", + unpin: "고정 해제", + displayAs: "백분율", + displayUsed: "사용됨", + displayRemaining: "남음", + showInSidebar: "사이드바에 요약 표시", + showInSidebarHint: "고정된 기간이 사이드바 하단에 표시됩니다", + runToRefresh: "{{command}} 명령을 실행하여 새로 고치세요.", + signInAgain: "다시 로그인하세요.", + loginRejected: "로그인이 거부되었습니다 (HTTP {{status}}). {{remedy}}", + loginExpired: "로그인이 {{relativeTime}}에 만료되었습니다. {{remedy}}", + title: "플랜 사용량", + refresh: "새로 고침", + refreshAll: "모두 새로 고침", + refreshing: "새로 고치는 중...", + loading: "사용량 불러오는 중...", + empty: "사용량 데이터가 없습니다", + errorTitle: "사용량을 불러올 수 없습니다", + hostUnavailable: "{{host}}에 연결하여 제공업체 사용량을 확인하세요", + hostUpgradeRequired: "{{host}}를 업데이트하여 제공업체 사용량을 확인하세요", + clientUnavailable: "호스트 연결이 준비되지 않았습니다", + retry: "다시 시도", + tooltipLoading: "플랜 사용량 불러오는 중...", + status: { + error: "오류", + unavailable: "사용할 수 없음", + }, + values: { + used: "{{percentage}} 사용됨", + remaining: "{{amount}} 남음", + }, + labels: { + session: "세션", + weekly: "주간", + monthly: "월간", + codeReview: "코드 검토", + credits: "크레딧", + monthlyCredits: "월간 크레딧", + }, + duration: { + days: "{{value}}일", + hours: "{{value}}시간", + minutes: "{{value}}분", + }, + timing: { + lastUpdated: "마지막 업데이트: {{relativeTime}}", + noSuccessfulUpdate: "성공한 업데이트가 없습니다", + resetTimePassed: "초기화 시간이 지났습니다", + resettingNow: "지금 재설정 중", + resetsIn: "{{duration}} 후 재설정", + runsOutNow: "지금 한도에 도달", + runsOutIn: "{{duration}} 후 한도에 도달", + justNow: "방금", + daysAgo: "{{value}}일 전", + hoursAgo: "{{value}}시간 전", + minutesAgo: "{{value}}분 전", + updated: "{{relativeTime}}에 업데이트됨", + }, + }, settings: { title: "설정", loading: "설정 불러오는 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1a24fd1f83..6303dd2c46 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2079,6 +2079,70 @@ export const ptBR: TranslationResources = { saveAccessibility: "Salvar comentário de revisão", }, }, + providerUsage: { + options: "Configurações", + refreshFailed: "Não foi possível atualizar o uso", + noHosts: "Nenhum host conectado", + agentError: "Não foi possível carregar o uso: {{reason}}", + pin: "Fixar", + unpin: "Desafixar", + displayAs: "Porcentagens", + displayUsed: "Usado", + displayRemaining: "Restante", + showInSidebar: "Resumo na barra lateral", + showInSidebarHint: "Os períodos fixados aparecem no rodapé da barra lateral", + runToRefresh: "Execute {{command}} para atualizar.", + signInAgain: "Entre novamente.", + loginRejected: "Login rejeitado (HTTP {{status}}). {{remedy}}", + loginExpired: "O login expirou {{relativeTime}}. {{remedy}}", + title: "Uso do plano", + refresh: "Atualizar", + refreshAll: "Atualizar tudo", + refreshing: "Atualizando...", + loading: "Carregando uso...", + empty: "Sem dados de uso", + errorTitle: "Não foi possível carregar o uso", + hostUnavailable: "Conecte-se a {{host}} para ver o uso do provedor", + hostUpgradeRequired: "Atualize {{host}} para ver o uso do provedor", + clientUnavailable: "A conexão com o host não está pronta", + retry: "Tentar novamente", + tooltipLoading: "Carregando uso do plano...", + status: { + error: "Erro", + unavailable: "Indisponível", + }, + values: { + used: "{{percentage}} usado", + remaining: "{{amount}} restantes", + }, + labels: { + session: "Sessão", + weekly: "Semanal", + monthly: "Mensal", + codeReview: "Revisão de código", + credits: "Créditos", + monthlyCredits: "Créditos mensais", + }, + duration: { + days: "{{value}} d", + hours: "{{value}} h", + minutes: "{{value}} min", + }, + timing: { + lastUpdated: "Última atualização {{relativeTime}}", + noSuccessfulUpdate: "Nenhuma atualização bem-sucedida", + resetTimePassed: "O horário de redefinição passou", + resettingNow: "redefinindo agora", + resetsIn: "redefine em {{duration}}", + runsOutNow: "esgota agora", + runsOutIn: "esgota em {{duration}}", + justNow: "agora mesmo", + daysAgo: "há {{value}} d", + hoursAgo: "há {{value}} h", + minutesAgo: "há {{value}} min", + updated: "Atualizado {{relativeTime}}", + }, + }, settings: { title: "Configurações", loading: "Carregando configurações...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 3b444895e5..fb58a4db29 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2079,6 +2079,70 @@ export const ru: TranslationResources = { saveAccessibility: "Сохранить комментарий к ревью", }, }, + providerUsage: { + options: "Настройки", + refreshFailed: "Не удалось обновить использование", + noHosts: "Нет подключённых хостов", + agentError: "Не удалось загрузить использование: {{reason}}", + pin: "Закрепить", + unpin: "Открепить", + displayAs: "Проценты", + displayUsed: "Использовано", + displayRemaining: "Осталось", + showInSidebar: "Сводка на боковой панели", + showInSidebarHint: "Закреплённые периоды отображаются внизу боковой панели", + runToRefresh: "Выполните {{command}} для обновления.", + signInAgain: "Войдите снова.", + loginRejected: "Вход отклонён (HTTP {{status}}). {{remedy}}", + loginExpired: "Срок входа истёк {{relativeTime}}. {{remedy}}", + title: "Использование плана", + refresh: "Обновить", + refreshAll: "Обновить всё", + refreshing: "Обновление...", + loading: "Загрузка данных об использовании...", + empty: "Нет данных об использовании", + errorTitle: "Не удалось загрузить данные об использовании", + hostUnavailable: "Подключитесь к {{host}}, чтобы увидеть использование провайдера", + hostUpgradeRequired: "Обновите {{host}}, чтобы увидеть использование провайдера", + clientUnavailable: "Соединение с хостом не готово", + retry: "Повторить", + tooltipLoading: "Загрузка использования плана...", + status: { + error: "Ошибка", + unavailable: "Недоступно", + }, + values: { + used: "Использовано: {{percentage}}", + remaining: "Осталось: {{amount}}", + }, + labels: { + session: "Сессия", + weekly: "Еженедельно", + monthly: "Ежемесячно", + codeReview: "Проверка кода", + credits: "Кредиты", + monthlyCredits: "Ежемесячные кредиты", + }, + duration: { + days: "{{value}} дн.", + hours: "{{value}} ч", + minutes: "{{value}} мин", + }, + timing: { + lastUpdated: "Последнее обновление {{relativeTime}}", + noSuccessfulUpdate: "Успешных обновлений нет", + resetTimePassed: "Время сброса прошло", + resettingNow: "сброс сейчас", + resetsIn: "сброс через {{duration}}", + runsOutNow: "закончится сейчас", + runsOutIn: "закончится через {{duration}}", + justNow: "только что", + daysAgo: "{{value}} дн. назад", + hoursAgo: "{{value}} ч назад", + minutesAgo: "{{value}} мин назад", + updated: "Обновлено {{relativeTime}}", + }, + }, settings: { title: "Настройки", loading: "Загрузка настроек...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 7b517fafb1..7e7e2382c6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2024,6 +2024,70 @@ export const zhCN: TranslationResources = { saveAccessibility: "保存 review 评论", }, }, + providerUsage: { + options: "设置", + refreshFailed: "无法刷新用量", + noHosts: "没有已连接的主机", + agentError: "无法加载用量:{{reason}}", + pin: "固定", + unpin: "取消固定", + displayAs: "百分比", + displayUsed: "已使用", + displayRemaining: "剩余", + showInSidebar: "在侧边栏显示摘要", + showInSidebarHint: "固定的时间窗口显示在侧边栏底部", + runToRefresh: "运行 {{command}} 以刷新。", + signInAgain: "请重新登录。", + loginRejected: "登录被拒绝(HTTP {{status}})。{{remedy}}", + loginExpired: "登录已于{{relativeTime}}过期。{{remedy}}", + title: "套餐用量", + refresh: "刷新", + refreshAll: "全部刷新", + refreshing: "刷新中...", + loading: "正在加载用量...", + empty: "暂无用量数据", + errorTitle: "无法加载用量", + hostUnavailable: "连接到 {{host}} 以查看提供商用量", + hostUpgradeRequired: "更新 {{host}} 以查看提供商用量", + clientUnavailable: "主机连接尚未就绪", + retry: "重试", + tooltipLoading: "正在加载套餐用量...", + status: { + error: "错误", + unavailable: "不可用", + }, + values: { + used: "已使用 {{percentage}}", + remaining: "剩余 {{amount}}", + }, + labels: { + session: "会话", + weekly: "每周", + monthly: "每月", + codeReview: "代码审查", + credits: "积分", + monthlyCredits: "每月积分", + }, + duration: { + days: "{{value}}天", + hours: "{{value}}小时", + minutes: "{{value}}分钟", + }, + timing: { + lastUpdated: "上次更新于 {{relativeTime}}", + noSuccessfulUpdate: "尚无成功更新", + resetTimePassed: "重置时间已过", + resettingNow: "正在重置", + resetsIn: "将在 {{duration}}后重置", + runsOutNow: "即将用尽", + runsOutIn: "将在 {{duration}}后用尽", + justNow: "刚刚", + daysAgo: "{{value}}天前", + hoursAgo: "{{value}}小时前", + minutesAgo: "{{value}}分钟前", + updated: "更新于 {{relativeTime}}", + }, + }, settings: { title: "设置", loading: "正在加载设置...", diff --git a/packages/app/src/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx index 0199c08ff2..bf10054db6 100644 --- a/packages/app/src/usage/agent-usage.tsx +++ b/packages/app/src/usage/agent-usage.tsx @@ -1,7 +1,7 @@ +import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { UsageCard } from "./card"; -import { usageCopy } from "./copy"; import { useUsageDisplay } from "./display"; import { useAgentUsage } from "./queries"; import type { UsageReportEntry } from "./types"; @@ -21,6 +21,7 @@ export function AgentUsage({ /** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */ refreshable: boolean; }) { + const { t } = useTranslation(); const view = useAgentUsage(serverId, agentId); if (view.kind === "none") return null; return ( @@ -35,7 +36,7 @@ export function AgentUsage({ /> ) : ( - {view.kind === "loading" ? usageCopy.loading : view.message} + {view.kind === "loading" ? t("providerUsage.loading") : view.message} )} diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 38b2f0804a..def723c554 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -1,16 +1,26 @@ import { useMemo } from "react"; +import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { clampPct, formatAmount, formatResetLabel } from "./format"; +import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format"; import type { UsageBalance, UsageTone } from "./types"; +import { useRelativeTimeTick } from "./use-relative-time-tick"; interface ResolvedBalance { amountText: string; usedPct: number | null; } -function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance { +function resolveBalance({ + balance, + t, + locale, +}: { + balance: UsageBalance; + t: TFunction; + locale?: string; +}): ResolvedBalance { const { used, remaining, limit, unit } = balance; const format = (value: number) => formatAmount(value, unit, locale); if (limit != null && limit > 0) { @@ -20,7 +30,10 @@ function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance return { amountText: `${usedText} / ${format(limit)}`, usedPct }; } if (remaining != null) { - return { amountText: `${format(remaining)} left`, usedPct: null }; + return { + amountText: t("providerUsage.values.remaining", { amount: format(remaining) }), + usedPct: null, + }; } if (used != null) { return { amountText: format(used), usedPct: null }; @@ -48,10 +61,11 @@ export function UsageBalanceBar({ balance: UsageBalance; retained?: boolean; }) { - const { i18n } = useTranslation(); - const { amountText, usedPct } = resolveBalance(balance, i18n.language); + const { t, i18n } = useTranslation(); + const now = useRelativeTimeTick({ enabled: balance.resetsAt != null }); + const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage }); const tone = balance.tone ?? "default"; - const resetLabel = formatResetLabel(balance.resetsAt, retained); + const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t); const fillStyle = useMemo>( () => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }], @@ -62,12 +76,18 @@ export function UsageBalanceBar({ - {balance.label} - - - {amountText} - {resetLabel ? {` · ${resetLabel}`} : null} + {formatProviderUsageLabel(balance.id, balance.label, t)} + + + {amountText} + + {resetLabel ? ( + + {resetLabel} + + ) : null} + {usedPct != null ? ( @@ -94,13 +114,19 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, value: { + flexShrink: 1, + alignItems: "flex-end", + }, + amount: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, reset: { color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, + textAlign: "right", }, track: { height: 4, diff --git a/packages/app/src/usage/bars.browser.test.tsx b/packages/app/src/usage/bars.browser.test.tsx new file mode 100644 index 0000000000..7b978f7ced --- /dev/null +++ b/packages/app/src/usage/bars.browser.test.tsx @@ -0,0 +1,53 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import { UsageWindowBar } from "./window-bar"; +import type { UsageBalance, UsageWindow } from "./types"; +import { UsageBalanceBar } from "./balance-bar"; + +const window: UsageWindow = { id: "session", label: "Session", usedPct: 70 }; +const balance: UsageBalance = { id: "credits", label: "Credits", unit: "usd", remaining: 12 }; +const ignorePin = () => {}; + +let root: Root; +let container: HTMLDivElement; +beforeEach(async () => { + vi.stubGlobal("React", React); + await i18n.changeLanguage("en"); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(async () => { + act(() => root.unmount()); + container.remove(); + await i18n.changeLanguage("en"); + vi.unstubAllGlobals(); +}); + +it("translates mounted usage rows when the language changes", async () => { + act(() => + root.render( + <> + + + , + ), + ); + expect(container.textContent).toContain("Session70% used"); + expect(container.textContent).toContain("Credits"); + await act(async () => { + await i18n.changeLanguage("ja"); + }); + expect(container.textContent).toContain("セッション70% 使用済み"); + expect(container.textContent).toContain("クレジット"); +}); diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index 562f3805a1..7d0f9903da 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -1,5 +1,6 @@ import { RotateCw } from "lucide-react-native"; import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Text, View, type StyleProp, type TextStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { @@ -12,19 +13,22 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane- import { StatusBadge } from "@/components/ui/status-badge"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; -import { useCompactTimeAgo } from "@/hooks/use-time-ago"; +import type { TFunction } from "i18next"; import { UsageBalanceBar } from "./balance-bar"; -import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; -import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model"; +import { formatAgo, formatProviderUsageLabel } from "./format"; +import { resolveUsageReport, type UsageRefresh } from "./model"; import { useReportRefresh } from "./queries"; import { UsageSourceIcon } from "./source-icon"; import type { UsageReport, UsageReportEntry, UsageWindow } from "./types"; import { UsageWindowBar } from "./window-bar"; +import { useRelativeTimeTick } from "./use-relative-time-tick"; -function statusText(report: UsageReport): string | null { +function statusText(report: UsageReport, t: TFunction): string | null { if (report.status === "available") return null; - return report.status === "error" ? "Error" : "Unavailable"; + return report.status === "error" + ? t("providerUsage.status.error") + : t("providerUsage.status.unavailable"); } const ThemedRotateCw = withUnistyles(RotateCw); @@ -49,15 +53,16 @@ export function UsageCard({ refreshable: boolean; compact?: boolean; }) { + const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId); // Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip. const showsFreshnessInline = isNative || isCompact || !refreshable; const usage = entry.report; - const status = statusText(usage); + const status = statusText(usage, t); const footer = entry.account.label ?? null; const { windows, balances, details, planLabel, successfulAt, retained, messages } = - resolveUsageReport(entry); + resolveUsageReport(entry, t); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -156,7 +161,7 @@ export function UsageCard({ {refreshState === "failed" ? ( - {usageCopy.refreshFailed} + {t("providerUsage.refreshFailed")} ) : null} @@ -176,6 +181,7 @@ function CardWindowBar({ display: UsageDisplay; pinnable: boolean; }) { + const { t } = useTranslation(); const pin = useMemo( () => ({ sourceId: entry.sourceId, windowId: window.id }), [entry.sourceId, window.id], @@ -190,7 +196,7 @@ function CardWindowBar({ pinnable={pinnable} pinned={display.isPinned(pin)} onTogglePin={toggle} - pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`} + pinLabel={`${t("providerUsage.pin")} ${entry.sourceLabel} ${formatProviderUsageLabel(window.id, window.label, t)}`} pinTestID={`usage-pin-${entry.sourceId}-${window.id}`} /> ); @@ -212,6 +218,7 @@ function UsageRefreshButton({ onRefresh: () => void; compact: boolean; }) { + const { t } = useTranslation(); const isPending = refreshState === "pending"; const iconSize = paneContentToolbarIconSize(compact); const freshness = useMemo( @@ -227,7 +234,7 @@ function UsageRefreshButton({ ); return ( ; testID: string; }) { - const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null); + const { t } = useTranslation(); + const now = useRelativeTimeTick({ enabled: successfulAt != null }); + const relativeTime = formatAgo(successfulAt, now, t); + const freshness = relativeTime + ? t(retained ? "providerUsage.timing.lastUpdated" : "providerUsage.timing.updated", { + relativeTime, + }) + : t("providerUsage.timing.noSuccessfulUpdate"); return ( - {successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"} + {freshness} ); } diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index 639272283b..14bcfe7d95 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -1,40 +1,23 @@ +import type { TFunction } from "i18next"; import type { UsageProblem } from "@getpaseo/protocol/messages"; -import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time"; +import { i18n } from "@/i18n/i18next"; +import { formatAgo } from "./format"; -// User-facing copy for the usage surfaces, kept in one file so localization is a -// single-file change. +// Messages for usage failures outside the UI. Rendered copy uses useTranslation. export const usageCopy = { - problem: (problem: UsageProblem, now: Date = new Date()): string => { + problem: (problem: UsageProblem, now: Date = new Date(), t: TFunction = i18n.t): string => { if (problem.kind === "no_quota") return problem.detail; const remedy = problem.refreshedBy - ? `Run ${problem.refreshedBy} to refresh it.` - : "Sign in again."; - if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`; - const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now)); - return `Login expired ${ago}. ${remedy}`; + ? t("providerUsage.runToRefresh", { command: problem.refreshedBy }) + : t("providerUsage.signInAgain"); + if (problem.kind === "rejected") + return t("providerUsage.loginRejected", { status: problem.status, remedy }); + return t("providerUsage.loginExpired", { + relativeTime: formatAgo(problem.expiresAt, now.getTime(), t), + remedy, + }); + }, + get clientUnavailable() { + return i18n.t("providerUsage.clientUnavailable"); }, - title: "Usage", - planUsage: "Plan usage", - options: "Settings", - refresh: "Refresh", - refreshAll: "Refresh all", - refreshing: "Refreshing...", - refreshFailed: "Unable to refresh usage", - updated: "Updated", - loading: "Loading usage...", - empty: "No usage data", - noHosts: "No connected hosts", - errorTitle: "Unable to load usage", - agentError: (reason: string) => `Unable to load usage: ${reason}`, - hostUnavailable: (host: string) => `Connect to ${host} to see usage`, - hostUpgradeRequired: (host: string) => `Update ${host} to see usage`, - clientUnavailable: "Host connection is not ready", - retry: "Try again", - pin: "Pin", - unpin: "Unpin", - displayAs: "Percentages", - displayUsed: "Used", - displayRemaining: "Remaining", - showInSidebar: "Summary in sidebar", - showInSidebarHint: "Pinned windows show in the sidebar footer", } as const; diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 22529c8d71..b7b81f0e5a 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -1,7 +1,25 @@ -import { describe, expect, it } from "vitest"; -import { formatAmount, formatResetLabel } from "./format"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import { + formatDisplayPct, + formatAgo, + formatAmount, + formatPct, + formatProviderUsageLabel, + formatResetLabel, + formatRunsOutLabel, +} from "./format"; + +const NOW = Date.parse("2026-07-19T00:00:00.000Z"); describe("formatAmount", () => { + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + afterEach(async () => { + await i18n.changeLanguage("en"); + }); it("groups thousands in the app's language", () => { expect(formatAmount(12345, "credits", "en")).toBe("12,345"); expect(formatAmount(12345, "requests", "en")).toBe("12,345"); @@ -12,6 +30,68 @@ describe("formatAmount", () => { expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50"); expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US"); }); + + it("localizes usage timing without patching the global clock", async () => { + const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString(); + const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString(); + + expect(formatPct(7, "en")).toBe("7%"); + expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h"); + + await i18n.changeLanguage("ja"); + expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("2時間後に上限に到達"); + expect(formatAgo(threeDaysAgo, NOW)).toBe("3日前"); + }); + + it("formats percentages with locale-specific spacing", () => { + expect(formatPct(42, "fr")).toBe("42\u00a0%"); + }); + + it("formats relative-time counts with the active locale", async () => { + await i18n.changeLanguage("fr"); + const days = 1_234; + const daysFromNow = new Date(NOW + days * 24 * 60 * 60 * 1000).toISOString(); + const daysAgo = new Date(NOW - days * 24 * 60 * 60 * 1000).toISOString(); + + expect(formatResetLabel(daysFromNow, false, NOW)).toBe("se réinitialise dans 1\u202f234 j"); + expect(formatRunsOutLabel(daysFromNow, NOW)).toBe("s’épuise dans 1\u202f234 j"); + expect(formatAgo(daysAgo, NOW)).toBe("il y a 1\u202f234 j"); + }); + + it("selects Arabic plural forms for relative durations", async () => { + await i18n.changeLanguage("ar"); + const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString(); + const threeHoursFromNow = new Date(NOW + 3 * 60 * 60 * 1000).toISOString(); + const twoDaysAgo = new Date(NOW - 2 * 24 * 60 * 60 * 1000).toISOString(); + + expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("تتم إعادة التعيين خلال ساعتين"); + expect(formatRunsOutLabel(threeHoursFromNow, NOW)).toBe("ينفد خلال 3 ساعات"); + expect(formatAgo(twoDaysAgo, NOW)).toBe("قبل يومين"); + }); + + it("uses locale-aware compact notation for token balances", () => { + expect(formatAmount(1_234, "tokens", "en")).toBe("1.2K"); + expect(formatAmount(1_234, "tokens", "ar")).toBe("1.2\u00a0ألف"); + }); + + it("localizes known provider-usage labels and preserves provider-specific labels", async () => { + await i18n.changeLanguage("ko"); + + expect(formatProviderUsageLabel("session", "Session")).toBe("세션"); + expect(formatProviderUsageLabel("five_hour", "Session")).toBe("세션"); + expect(formatProviderUsageLabel("five_hour", "5-hour limit")).toBe("5-hour limit"); + expect(formatProviderUsageLabel("monthly", "Monthly")).toBe("월간"); + expect(formatProviderUsageLabel("code_review:weekly", "Code review · Weekly")).toBe( + "코드 검토 · 주간", + ); + expect(formatProviderUsageLabel("code_review:five_hour", "Code review · 5-hour")).toBe( + "코드 검토 · 5-hour", + ); + expect(formatProviderUsageLabel("credits", "Credits")).toBe("크레딧"); + expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit"); + }); }); it("retained reset times that have passed do not claim the quota is resetting", () => { @@ -20,3 +100,33 @@ it("retained reset times that have passed do not claim the quota is resetting", expect(formatResetLabel(past)).toBe("resetting now"); expect(formatResetLabel(null, true)).toBeNull(); }); + +it("says that a usage window percentage is consumed", () => { + expect(formatDisplayPct(70, "used")).toBe("70% used"); + expect(formatDisplayPct(30, "remaining")).toBe("30% left"); +}); +it("translates the usage refresh control into Japanese", async () => { + try { + await i18n.changeLanguage("ja"); + expect(i18n.t("providerUsage.refresh")).toBe("更新"); + } finally { + await i18n.changeLanguage("en"); + } +}); + +it("translates the neighboring usage percentage settings", async () => { + try { + await i18n.changeLanguage("ja"); + expect(i18n.t("providerUsage.displayUsed")).toBe("使用済み"); + expect(i18n.t("providerUsage.displayRemaining")).toBe("残り"); + } finally { + await i18n.changeLanguage("en"); + } +}); + +it("preserves provider-defined labels even when their IDs match a standard period", () => { + expect(formatProviderUsageLabel("monthly", "Extra allotment")).toBe("Extra allotment"); + expect(formatProviderUsageLabel("code_review:weekly", "Custom review allowance")).toBe( + "Custom review allowance", + ); +}); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index e13a550e7b..e7a6ba6f7b 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,47 +1,163 @@ -import { formatTokenCount } from "@/components/context-window-meter.utils"; +import type { TFunction } from "i18next"; +import { i18n } from "@/i18n/i18next"; import type { UsageDisplayAs } from "./preferences"; import type { UsageBalanceUnit } from "./types"; +const providerUsageLabels = { + session: { label: "Session", key: "providerUsage.labels.session" }, + weekly: { label: "Weekly", key: "providerUsage.labels.weekly" }, + monthly: { label: "Monthly", key: "providerUsage.labels.monthly" }, + code_review: { label: "Code review", key: "providerUsage.labels.codeReview" }, + credits: { label: "Credits", key: "providerUsage.labels.credits" }, + monthly_credits: { label: "Monthly credits", key: "providerUsage.labels.monthlyCredits" }, +} as const; + +export function formatProviderUsageLabel(id: string, label: string, t: TFunction = i18n.t): string { + const reviewPrefix = "Code review · "; + if (id.startsWith("code_review:") && label.startsWith(reviewPrefix)) { + const periodId = id.slice("code_review:".length); + const periodLabel = label.slice(reviewPrefix.length); + return `${t(providerUsageLabels.code_review.key)} · ${formatProviderUsageLabel(periodId, periodLabel, t)}`; + } + const standard = + providerUsageLabels[id as keyof typeof providerUsageLabels] ?? + (id === "five_hour" ? providerUsageLabels.session : undefined); + return standard?.label === label ? t(standard.key) : label; +} + export function clampPct(value: number): number { return Math.max(0, Math.min(100, value)); } -export function formatPct(value: number): string { - return `${Math.round(clampPct(value))}%`; +export function formatPct(value: number, locale?: string): string { + const resolvedLocale = locale ?? i18n.resolvedLanguage; + return new Intl.NumberFormat(resolvedLocale, { + style: "percent", + maximumFractionDigits: 0, + }).format(clampPct(value) / 100); } -/** "31%" of the window used, or "69% left" of it. */ -export function formatDisplayPct(value: number, displayAs: UsageDisplayAs): string { - return displayAs === "used" ? formatPct(value) : `${formatPct(value)} left`; +/** A localized percentage that says whether it is used or remaining. */ +export function formatDisplayPct( + value: number, + displayAs: UsageDisplayAs, + locale?: string, + t: TFunction = i18n.t, +): string { + const percentage = formatPct(value, locale); + return displayAs === "used" + ? t("providerUsage.values.used", { percentage }) + : t("providerUsage.values.remaining", { amount: percentage }); } -function relativeDuration(iso: string): string | null { - const diffMs = new Date(iso).getTime() - Date.now(); +type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number }; + +function formatCount(value: number): string { + return new Intl.NumberFormat(i18n.resolvedLanguage).format(value); +} + +function relativeDuration(iso: string, now: number): RelativeDuration | null { + const diffMs = new Date(iso).getTime() - now; if (!Number.isFinite(diffMs)) return null; - if (diffMs <= 0) return "now"; + if (diffMs <= 0) return { unit: "now" }; const diffMinutes = Math.floor(diffMs / 60_000); const diffHours = Math.floor(diffMinutes / 60); const diffDays = Math.floor(diffHours / 24); - if (diffDays > 0) return `${diffDays}d`; - if (diffHours > 0) return `${diffHours}h`; - return `${diffMinutes}m`; + if (diffDays > 0) return { unit: "days", count: diffDays }; + if (diffHours > 0) return { unit: "hours", count: diffHours }; + return { unit: "minutes", count: diffMinutes }; } -export function formatResetLabel(iso: string | null | undefined, retained = false): string | null { +function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null { + switch (duration.unit) { + case "now": + return null; + case "days": + return t("providerUsage.duration.days", { + count: duration.count, + value: formatCount(duration.count), + }); + case "hours": + return t("providerUsage.duration.hours", { + count: duration.count, + value: formatCount(duration.count), + }); + case "minutes": + return t("providerUsage.duration.minutes", { + count: duration.count, + value: formatCount(duration.count), + }); + } +} + +export function formatResetLabel( + iso: string | null | undefined, + retained = false, + now = Date.now(), + t: TFunction = i18n.t, +): string | null { if (!iso) return null; - const rel = relativeDuration(iso); + const rel = relativeDuration(iso, now); if (!rel) return null; - if (rel === "now" && retained) return "Reset time passed"; - return rel === "now" ? "resetting now" : `resets ${rel}`; + if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed"); + if (rel.unit === "now") return t("providerUsage.timing.resettingNow"); + const duration = formatRelativeDuration(rel, t); + return duration ? t("providerUsage.timing.resetsIn", { duration }) : null; +} + +export function formatRunsOutLabel( + iso: string | null | undefined, + now = Date.now(), + t: TFunction = i18n.t, +): string | null { + if (!iso) return null; + const rel = relativeDuration(iso, now); + if (!rel) return null; + if (rel.unit === "now") return t("providerUsage.timing.runsOutNow"); + const duration = formatRelativeDuration(rel, t); + return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null; +} + +export function formatAgo( + iso: string | null | undefined, + now = Date.now(), + t: TFunction = i18n.t, +): string | null { + if (!iso) return null; + const diffMs = now - new Date(iso).getTime(); + if (!Number.isFinite(diffMs)) return null; + if (diffMs < 60_000) return t("providerUsage.timing.justNow"); + const diffMinutes = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + if (diffDays > 0) { + return t("providerUsage.timing.daysAgo", { + count: diffDays, + value: formatCount(diffDays), + }); + } + if (diffHours > 0) { + return t("providerUsage.timing.hoursAgo", { + count: diffHours, + value: formatCount(diffHours), + }); + } + return t("providerUsage.timing.minutesAgo", { + count: diffMinutes, + value: formatCount(diffMinutes), + }); } /** A balance amount as the app's language writes it: "$1,234.50", "12,345". */ -export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string { +export function formatAmount(value: number, unit: UsageBalanceUnit, locale?: string): string { switch (unit) { case "usd": return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value); case "tokens": - return formatTokenCount(value); + return new Intl.NumberFormat(locale, { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); default: return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value); } diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx index 927d32e7d8..e80e839680 100644 --- a/packages/app/src/usage/host-usage-section.tsx +++ b/packages/app/src/usage/host-usage-section.tsx @@ -1,16 +1,17 @@ -import { usageCopy } from "./copy"; +import { useTranslation } from "react-i18next"; import { useUsagePreferences } from "./display"; import { useHostUsage } from "./queries"; import { UsageSection } from "./usage-section"; /** A host's usage reports, for its settings page. */ export function HostUsageSection({ serverId }: { serverId: string }) { + const { t } = useTranslation(); const { view, refresh } = useHostUsage(serverId); const { display } = useUsagePreferences(serverId); return ( { ).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" }); }); + it("localizes unavailable host messages while retaining the host name", async () => { + await i18n.changeLanguage("ko"); + expect( + resolveUsageView({ + hostLabel: "Laptop", + isConnected: false, + supportsUsage: false, + query: undefined, + }), + ).toEqual({ kind: "unavailable", message: "Laptop에 연결하여 제공업체 사용량을 확인하세요" }); + }); + it("moves from loading to ready to error", () => { const base = { hostLabel: "Laptop", isConnected: true, supportsUsage: true }; expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" }); @@ -80,6 +92,10 @@ describe("resolveUsageView", () => { }); }); +afterEach(async () => { + await i18n.changeLanguage("en"); +}); + const hosts: UsageHost[] = [ { serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true }, { serverId: "old", label: "Old", isConnected: true, supportsUsage: false }, @@ -185,14 +201,6 @@ describe("displayPercent", () => { }); }); -describe("formatUsageFreshness", () => { - it("says when the report was fetched", () => { - expect(formatUsageFreshness("now")).toBe("Updated just now"); - expect(formatUsageFreshness("3m")).toBe("Updated 3m ago"); - expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15"); - }); -}); - describe("resolveUsageRefresh", () => { it("is pending while a refresh runs, even after an earlier failure", () => { expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending"); @@ -338,7 +346,7 @@ it("resolves retained usage and successful time without inventing old-daemon his report: success.report as Extract, }, }; - expect(resolveUsageReport(failure)).toEqual({ + expect(resolveUsageReport(failure, i18n.t)).toEqual({ windows: success.report.status === "available" ? success.report.windows : [], balances: [], details: [], @@ -347,7 +355,7 @@ it("resolves retained usage and successful time without inventing old-daemon his retained: true, messages: ["Rate limited"], }); - expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({ + expect(resolveUsageReport({ ...failure, lastSuccessful: undefined }, i18n.t)).toEqual({ windows: [], balances: [], details: [], @@ -356,7 +364,7 @@ it("resolves retained usage and successful time without inventing old-daemon his retained: false, messages: ["Rate limited"], }); - expect(resolveUsageReport(success)).toEqual({ + expect(resolveUsageReport(success, i18n.t)).toEqual({ windows: success.report.status === "available" ? success.report.windows : [], balances: [], details: [], @@ -384,7 +392,7 @@ it("orders failed polls by attempt time even when successful data is older", () it("available and retained usage show the same successful completion time", () => { const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" }; if (success.report.status !== "available") throw new Error("fixture must be available"); - expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z"); + expect(resolveUsageReport(success, i18n.t).successfulAt).toBe("2026-01-01T00:00:15.000Z"); const failure: UsageReportEntry = { ...success, successfulAt: undefined, @@ -392,6 +400,8 @@ it("available and retained usage show the same successful completion time", () = report: { status: "error", error: "Rate limited" }, lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report }, }; - expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt); + expect(resolveUsageReport(failure, i18n.t).successfulAt).toBe( + resolveUsageReport(success, i18n.t).successfulAt, + ); expect(upsertReport([failure], success)).toEqual([failure]); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 1f1c82cce3..48143ad23f 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -1,4 +1,5 @@ -import { formatCompactTimeAgoAsProse } from "@/utils/time"; +import type { TFunction } from "i18next"; +import { i18n } from "@/i18n/i18next"; import { usageCopy } from "./copy"; import type { UsageDisplayAs } from "./preferences"; import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types"; @@ -31,11 +32,6 @@ export function usageWindowRowLabel(input: { return `${input.pinLabel}, ${summary}`; } -/** When a report was fetched, from its compact relative time: "Updated 3m ago". */ -export function formatUsageFreshness(compactTimeAgo: string, retained = false): string { - return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; -} - /** A user-requested refresh of one report. The previous report stays on screen throughout. */ export type UsageRefresh = "idle" | "pending" | "failed"; @@ -108,16 +104,26 @@ export interface UsageQueryState { isFetching: boolean; } -export function resolveUsageView(input: { - hostLabel: string; - isConnected: boolean; - supportsUsage: boolean; - query: UsageQueryState | undefined; -}): UsageView { +export function resolveUsageView( + input: { + hostLabel: string; + isConnected: boolean; + supportsUsage: boolean; + query: UsageQueryState | undefined; + }, + t: TFunction = i18n.t, +): UsageView { const { hostLabel, isConnected, supportsUsage, query } = input; - if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) }; + if (!isConnected) + return { + kind: "unavailable", + message: t("providerUsage.hostUnavailable", { host: hostLabel }), + }; if (!supportsUsage) { - return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) }; + return { + kind: "unavailable", + message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }), + }; } if (query?.data) { return { kind: "ready", reports: query.data, isRefreshing: query.isFetching }; @@ -138,17 +144,20 @@ export type AgentUsageView = | { kind: "error"; message: string } | { kind: "ready"; reports: UsageReportEntry[] }; -export function resolveAgentUsageView(input: { - canReport: boolean; - query: UsageQueryState; -}): AgentUsageView { +export function resolveAgentUsageView( + input: { + canReport: boolean; + query: UsageQueryState; + }, + t: TFunction = i18n.t, +): AgentUsageView { const { canReport, query } = input; if (!canReport) return { kind: "none" }; // A failed request keeps the reports from before it, or those that streamed in before it failed; // shown alone they would pass for the agent's complete, current usage. if (query.error) { const reason = query.error instanceof Error ? query.error.message : String(query.error); - return { kind: "error", message: usageCopy.agentError(reason) }; + return { kind: "error", message: t("providerUsage.agentError", { reason }) }; } if (query.data) { return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data }; @@ -194,7 +203,7 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null } /** Current outcome owns messages; only a successful poll owns values and freshness. */ -export function resolveUsageReport(entry: UsageReportEntry) { +export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) { const available = entry.report.status === "available"; // COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. // Older daemons cannot provide history; a failed attempt is never a successful update. @@ -204,10 +213,12 @@ export function resolveUsageReport(entry: UsageReportEntry) { if (!available) { if (entry.loginErrors) messages.push( - ...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`), + ...entry.loginErrors.map( + (login) => `${login.harness}: ${usageProblemText(login.report, t)}`, + ), ); // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. - else messages.push(usageProblemText(entry.report)); + else messages.push(usageProblemText(entry.report, t)); } return { windows: content?.windows ?? [], @@ -224,8 +235,8 @@ export function resolveUsageReport(entry: UsageReportEntry) { }; } -function usageProblemText(report: UsageReport): string { +function usageProblemText(report: UsageReport, t: TFunction): string { if (report.status === "error") return report.error; - if (report.status === "unavailable") return usageCopy.problem(report.problem); + if (report.status === "unavailable") return usageCopy.problem(report.problem, new Date(), t); return ""; } diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx index 886c21e190..b735b612aa 100644 --- a/packages/app/src/usage/options.tsx +++ b/packages/app/src/usage/options.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { Settings } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; @@ -8,20 +9,23 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { SettingsRow, SettingsSwitch } from "@/components/settings"; import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; import { settingsStyles } from "@/styles/settings"; -import { usageCopy } from "./copy"; import { useUsagePreferences } from "./display"; import { useUsageInSidebar } from "./in-sidebar"; import type { UsageDisplayAs } from "./preferences"; -const DISPLAY_AS_OPTIONS: SegmentedControlOption[] = [ - { value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" }, - { value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" }, -]; - const ThemedSettings = withUnistyles(Settings); /** The shared usage preferences, opened from a cog as a popover or compact sheet. */ export function UsageOptions() { + const { t } = useTranslation(); + const displayAsOptions: SegmentedControlOption[] = [ + { value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" }, + { + value: "remaining", + label: t("providerUsage.displayRemaining"), + testID: "usage-display-remaining", + }, + ]; const compact = useIsCompactFormFactor(); const { display } = useUsagePreferences(); const { inSidebar, setInSidebar } = useUsageInSidebar(); @@ -29,7 +33,7 @@ export function UsageOptions() { @@ -41,21 +45,21 @@ export function UsageOptions() { - + { + const { i18n } = await import("@/i18n/i18next"); + await i18n.changeLanguage("ja"); + try { + const entries = [ + report({ + sourceId: "test", + sourceLabel: "Test", + windows: [ + { id: "session", label: "Session", summary: true, usedPct: 70 }, + { id: "weekly", label: "My allowance", summary: true, usedPct: 20 }, + ], + }), + ]; + expect(windows(entries, preferences(null)).map((w) => [w.label, w.shortLabel])).toEqual([ + ["Test セッション 70% 使用済み", "セッション"], + ["Test My allowance 20% 使用済み", "My allowance"], + ]); + expect(windows(entries, preferences(null, "remaining"))[0].label).toBe( + "Test セッション 残り 30%", + ); + } finally { + await i18n.changeLanguage("en"); + } +}); diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts index 547b2c3e48..21fd4cf06c 100644 --- a/packages/app/src/usage/pinned.ts +++ b/packages/app/src/usage/pinned.ts @@ -1,4 +1,6 @@ -import { formatPct } from "./format"; +import type { TFunction } from "i18next"; +import { i18n } from "@/i18n/i18next"; +import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format"; import { displayPercent } from "./model"; import { effectiveUsagePins, type UsagePreferences } from "./preferences"; import { windowTone } from "./tone"; @@ -68,9 +70,9 @@ function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] export function resolvePinnedUsage( reports: readonly UsageReportEntry[], preferences: UsagePreferences, + t: TFunction = i18n.t, ): PinnedUsageSource[] { const pins = effectiveUsagePins(preferences, reports); - const meaning = preferences.displayAs === "remaining" ? "left" : "used"; return groupBySource(reports).flatMap((entry) => { if (entry.report.status !== "available") return []; const windows = entry.report.windows @@ -84,8 +86,8 @@ export function resolvePinnedUsage( const percentText = formatPct(percent); return { key: `${entry.id}/${window.id}`, - label: `${describe(entry, window.label)} ${percentText} ${meaning}`, - shortLabel: window.shortLabel ?? window.label, + label: `${describe(entry, formatProviderUsageLabel(window.id, window.label, t))} ${formatDisplayPct(percent, preferences.displayAs, undefined, t)}`, + shortLabel: window.shortLabel ?? formatProviderUsageLabel(window.id, window.label, t), percent, percentText, tone: windowTone(window), diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index 7b6f210543..c5ec32bd2d 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client"; import { useCallback, useMemo } from "react"; import { @@ -144,13 +145,17 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const refresh = useCallback(() => { void refreshReports(queryClient, serverId).catch(() => undefined); }, [queryClient, serverId]); + const { t } = useTranslation(); const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId; - const view = resolveUsageView({ - hostLabel, - isConnected, - supportsUsage: isSupported, - query: toQueryState(query), - }); + const view = resolveUsageView( + { + hostLabel, + isConnected, + supportsUsage: isSupported, + query: toQueryState(query), + }, + t, + ); return { view, refresh }; } @@ -196,7 +201,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView retry: false, staleTimeMs: REPORTS_STALE_TIME_MS, }); - return resolveAgentUsageView({ canReport, query: toQueryState(query) }); + const { t } = useTranslation(); + return resolveAgentUsageView({ canReport, query: toQueryState(query) }, t); } /** Whether a host is connected and reports usage, read without fetching anything. */ diff --git a/packages/app/src/usage/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx index 48754c4e19..473e4442cc 100644 --- a/packages/app/src/usage/refresh-button.tsx +++ b/packages/app/src/usage/refresh-button.tsx @@ -1,10 +1,10 @@ +import { useTranslation } from "react-i18next"; import { RotateCw } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { usageCopy } from "./copy"; const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -14,11 +14,12 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); * header's island, so its highlight is round. */ export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) { + const { t } = useTranslation(); const compact = useIsCompactFormFactor(); const iconSize = paneContentToolbarIconSize(compact); return ( resolvePinnedUsage(reports, preferences), [preferences, reports]); + return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]); } /** Whether the sidebar Usage item has anything to show. */ diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx index 8305c3f878..bf92eaa60f 100644 --- a/packages/app/src/usage/usage-modal.tsx +++ b/packages/app/src/usage/usage-modal.tsx @@ -4,7 +4,6 @@ import { View } from "react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useHostUsageWithControls } from "./controls"; -import { usageCopy } from "./copy"; import { useUsagePreferences } from "./display"; import { useUsageHostSelection } from "./hosts"; import type { UsageHost } from "./model"; @@ -22,11 +21,12 @@ interface UsageModalProps { * title row. */ export function UsageModal(props: UsageModalProps) { + const { t } = useTranslation(); const { serverId, connectedHosts, select } = useUsageHostSelection(); if (!serverId) { return ( - + ); } diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx index ffb40e985d..26699fd447 100644 --- a/packages/app/src/usage/usage-section.tsx +++ b/packages/app/src/usage/usage-section.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { useMemo } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -5,7 +6,6 @@ import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { settingsStyles } from "@/styles/settings"; import { SettingsSection } from "@/components/settings/headings/settings-section"; -import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; import { UsageControls } from "./controls"; import { UsageList } from "./list"; @@ -50,26 +50,32 @@ export function UsageBody({ display: UsageDisplay; onRefresh: () => void; }) { + const { t } = useTranslation(); if (view.kind === "unavailable") { return ; } if (view.kind === "loading") { - return ; + return ; } if (view.kind === "error") { return ( - + ); } if (view.reports.length === 0) { - return ; + return ; } return ; diff --git a/packages/app/src/usage/use-relative-time-tick.ts b/packages/app/src/usage/use-relative-time-tick.ts new file mode 100644 index 0000000000..c21c1398cb --- /dev/null +++ b/packages/app/src/usage/use-relative-time-tick.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; +import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker"; + +/** The current time as a render input, updated while a relative-time label is mounted. */ +export function useRelativeTimeTick({ enabled }: { enabled: boolean }): number { + const [now, setNow] = useState(Date.now); + + useEffect(() => { + if (!enabled) return undefined; + return subscribeToRelativeTimeTick("minute", () => setNow(Date.now())); + }, [enabled]); + return now; +} diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 79ea6b9f2d..9082f2dbac 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,15 +1,21 @@ import { Pin } from "lucide-react-native"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { usageCopy } from "./copy"; -import { formatDisplayPct, formatResetLabel } from "./format"; +import { + formatDisplayPct, + formatProviderUsageLabel, + formatResetLabel, + formatRunsOutLabel, +} from "./format"; import { UsageMeter } from "./meter"; import { displayPercent, usageWindowRowLabel } from "./model"; import type { UsageDisplayAs } from "./preferences"; import { windowTone } from "./tone"; import type { UsageTone, UsageWindow } from "./types"; +import { useRelativeTimeTick } from "./use-relative-time-tick"; // Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength, // so a hover never reads as the selection. Pinned rows do not react to hover. @@ -39,21 +45,24 @@ export function UsageWindowBar({ pinLabel: string; pinTestID: string; }) { + const { t, i18n } = useTranslation(); const shownPct = displayPercent(window, displayAs); const tone = windowTone(window); + const now = useRelativeTimeTick({ enabled: window.runsOutAt != null || window.resetsAt != null }); const isAtRisk = window.runsOutAt != null && window.shortfallPct != null && - (!retained || Date.parse(window.runsOutAt) > Date.now()); + (!retained || Date.parse(window.runsOutAt) > now); const trailing = isAtRisk - ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() - : formatResetLabel(window.resetsAt, retained); + ? formatRunsOutLabel(window.runsOutAt, now, t) + : formatResetLabel(window.resetsAt, retained, now, t); - const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; + const value = + shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—"; const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); const content = { - label: window.label, + label: formatProviderUsageLabel(window.id, window.label, t), value, trailing, isAtRisk, @@ -124,7 +133,7 @@ function WindowRowContent({ {label} - + {value} {trailing ? ( {` · ${trailing}`} @@ -142,6 +151,7 @@ function WindowRowContent({ const ThemedPin = withUnistyles(Pin); function UsagePinGlyph({ pinned }: { pinned: boolean }) { + const { t } = useTranslation(); const iconMapping = useMemo( () => (theme: { colors: { foregroundMuted: string } }) => ({ color: theme.colors.foregroundMuted, @@ -160,7 +170,9 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) { - {pinned ? usageCopy.unpin : usageCopy.pin} + + {pinned ? t("providerUsage.unpin") : t("providerUsage.pin")} + ); @@ -209,6 +221,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, value: { + flexShrink: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index e2d67aca70..7a45d4a771 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig, configDefaults } from "vitest/config"; import { playwright } from "@vitest/browser-playwright"; import path from "path"; import fs from "fs"; +import { transformSync } from "@babel/core"; // Set this before Vite resolves dependencies and Vitest starts its workers. Object.assign(process.env, { NODE_ENV: "test" }); @@ -16,6 +17,25 @@ const resolvePackageEntry = (packageName: string) => { }; export default defineConfig({ + plugins: [ + { + name: "compile-usage-components", + enforce: "pre", + transform(source, id) { + // These labels must react to time and language in the compiled app too. + if (!id.includes("/src/usage/") || !id.endsWith(".tsx") || id.includes(".test.")) return; + const result = transformSync(source, { + filename: id, + babelrc: false, + configFile: false, + parserOpts: { plugins: ["typescript", "jsx"] }, + plugins: [["babel-plugin-react-compiler", { target: "19" }]], + sourceMaps: true, + }); + if (result?.code) return { code: result.code, map: result.map }; + }, + }, + ], test: { environment: "node", exclude: [...configDefaults.exclude, "e2e/**"], @@ -82,6 +102,7 @@ export default defineConfig({ // Bundle the CJS dependencies of the excluded gesture-handler package for the browser. include: [ "react/jsx-runtime", + "react/compiler-runtime", "react-native-gesture-handler > hoist-non-react-statics", "react-native-gesture-handler > invariant", ], diff --git a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts index 86aef1b880..d745f983be 100644 --- a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts +++ b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts @@ -240,6 +240,8 @@ async function openTwoDraftTabs(page: Page, workspaceId: string) { async function expectTabNumberNavigation(page: Page) { const tabs = page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }); + await expect(page.getByTestId("settings-sidebar")).not.toBeVisible(); + await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("Control+1"); await expect(tabs.nth(0)).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("Control+2"); From 71e7cf6ff80ee8a0e7bccc0490c7e8e22e220711 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:09:07 +0200 Subject: [PATCH 05/13] Build native Android modules with the project NDK (#7403) * fix(android): align Unistyles NDK version * fix(android): use the project NDK for every native module The diff module still selected the Android Gradle default NDK, causing a second installation after Unistyles was aligned. Inherit the project toolchain and check all configured native projects. --------- Co-authored-by: dwyanewang <908280968@qq.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../paseo-diff-prototype/android/build.gradle | 1 + .../app/scripts/verify-android-ndk.gradle | 22 +++++++++++++++++++ patches/react-native-unistyles+3.2.4.patch | 12 ++++++++++ 3 files changed, 35 insertions(+) create mode 100644 packages/app/scripts/verify-android-ndk.gradle diff --git a/packages/app/modules/paseo-diff-prototype/android/build.gradle b/packages/app/modules/paseo-diff-prototype/android/build.gradle index f218c03ef8..3f717f4de4 100644 --- a/packages/app/modules/paseo-diff-prototype/android/build.gradle +++ b/packages/app/modules/paseo-diff-prototype/android/build.gradle @@ -12,6 +12,7 @@ useDefaultAndroidSdkVersions() def skiaPackage = new File(["node", "--print", "require.resolve('@shopify/react-native-skia/package.json')"].execute(null, projectDir).text.trim()).parentFile android { + ndkVersion rootProject.ext.ndkVersion namespace "sh.paseo.diffprototype" defaultConfig { externalNativeBuild { diff --git a/packages/app/scripts/verify-android-ndk.gradle b/packages/app/scripts/verify-android-ndk.gradle new file mode 100644 index 0000000000..a53a5406bb --- /dev/null +++ b/packages/app/scripts/verify-android-ndk.gradle @@ -0,0 +1,22 @@ +// After Expo prebuild, run from packages/app/android: +// ./gradlew --init-script ../scripts/verify-android-ndk.gradle verifyAndroidNdk +gradle.projectsEvaluated { + rootProject.tasks.register('verifyAndroidNdk') { + doLast { + def expected = rootProject.ext.ndkVersion + def nativeProjects = rootProject.subprojects.findAll { subproject -> + def android = subproject.extensions.findByName('android') + android != null && (android.externalNativeBuild.cmake.path != null || + android.externalNativeBuild.ndkBuild.path != null) + } + assert !nativeProjects.empty : 'No native Android modules found' + println "Project NDK: ${expected}" + def mismatches = nativeProjects.findAll { subproject -> + def actual = subproject.android.ndkVersion + println "${subproject.path} NDK: ${actual}" + actual != expected + } + assert mismatches.empty : "Native Android modules must use project NDK ${expected}: ${mismatches*.path}" + } + } +} diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index 25324c6218..0195868340 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -317,3 +317,15 @@ index 39e0d9d..a21ba95 100644 this.stylesCache.clear() this.dependenciesMap.clear() this.disposeListenersMap.clear() +diff --git a/node_modules/react-native-unistyles/android/build.gradle b/node_modules/react-native-unistyles/android/build.gradle +index 195954b..c4ac28c 100644 +--- a/node_modules/react-native-unistyles/android/build.gradle ++++ b/node_modules/react-native-unistyles/android/build.gradle +@@ -38,6 +38,7 @@ if (isNewArchitectureEnabled()) { + + android { + compileSdkVersion safeExtGet('compileSdkVersion', 35) ++ ndkVersion safeExtGet('ndkVersion', '27.1.12297006') + namespace "com.unistyles" + + defaultConfig { From fc775b6db5e99ce4e0b1b3718250a023284557da Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:03:24 +0200 Subject: [PATCH 06/13] Load translations when their language is selected (#7410) * perf(app): load translations when their language is selected Eager locale imports evaluated and registered all nine translation bundles on every cold start. Load bundled locales through a synchronous i18next backend so language selection keeps its before-render contract and offline behavior. Co-authored-by: Ivan Malison * test(app): normalize translation entry paths across platforms --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Ivan Malison --- .../src/git/pull-request-panel/data.test.ts | 7 +- packages/app/src/i18n/i18next.startup.test.ts | 92 +++++++++++++++++++ packages/app/src/i18n/i18next.ts | 91 +++++++++++------- packages/app/vitest.config.ts | 19 ++++ 4 files changed, 177 insertions(+), 32 deletions(-) create mode 100644 packages/app/src/i18n/i18next.startup.test.ts diff --git a/packages/app/src/git/pull-request-panel/data.test.ts b/packages/app/src/git/pull-request-panel/data.test.ts index 837433417e..1f72cb1d95 100644 --- a/packages/app/src/git/pull-request-panel/data.test.ts +++ b/packages/app/src/git/pull-request-panel/data.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import type { CheckoutPipelineJob, CheckoutPrStatusResponse, @@ -780,6 +780,11 @@ describe("formatAge", () => { }); }); +// Fixed translators do not select a language, so request their resources first. +beforeAll(async () => { + await i18n.loadLanguages("fr"); +}); + const english = i18n.getFixedT("en"); const french = i18n.getFixedT("fr"); diff --git a/packages/app/src/i18n/i18next.startup.test.ts b/packages/app/src/i18n/i18next.startup.test.ts new file mode 100644 index 0000000000..f439a5bb10 --- /dev/null +++ b/packages/app/src/i18n/i18next.startup.test.ts @@ -0,0 +1,92 @@ +import { execFileSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { build } from "esbuild"; +import { beforeAll, describe, expect, it } from "vitest"; + +let bundle: string; + +// Exercise the bundled entry in fresh processes so the test runner's module cache +// cannot hide eager evaluation. The probe only records resource module execution. +beforeAll(async () => { + const result = await build({ + entryPoints: [path.join(import.meta.dirname, "i18next.ts")], + bundle: true, + platform: "node", + format: "cjs", + external: ["i18next", "react-i18next"], + write: false, + plugins: [ + { + name: "observe-translation-evaluation", + setup(builder) { + builder.onLoad( + { filter: /[/\\]i18n[/\\]resources[/\\][a-z]{2}(?:-[A-Z]{2})?\.ts$/ }, + async ({ path: file }) => ({ + contents: `globalThis.evaluatedLocales.push(${JSON.stringify(path.basename(file, ".ts"))});\n${await readFile(file, "utf8")}`, + loader: "ts", + }), + ); + }, + }, + ], + }); + bundle = result.outputFiles[0].text; +}); + +function runStartupProbe(actions = "") { + return JSON.parse( + execFileSync(process.execPath, ["-"], { + cwd: path.resolve(import.meta.dirname, "../.."), + encoding: "utf8", + input: `globalThis.evaluatedLocales = []; + (function () { ${bundle} })(); + const { i18n } = module.exports; + ${actions} + console.log(JSON.stringify({ + evaluated: globalThis.evaluatedLocales, + registered: Object.keys(i18n.store.data), + language: i18n.language, + cancel: i18n.t("common.actions.cancel"), + fallback: i18n.t("startupProbeOnlyInEnglish"), + }));`, + }), + ); +} + +describe("bundled translation startup", () => { + it("evaluates and registers only English before a language is selected", () => { + expect(runStartupProbe()).toMatchObject({ + evaluated: ["en"], + registered: ["en"], + language: "en", + cancel: "Cancel", + }); + }); + + it.each([ + ["ar", "يلغي"], + ["es", "Cancelar"], + ["fr", "Annuler"], + ["ja", "キャンセル"], + ["ko", "취소"], + ["pt-BR", "Cancelar"], + ["ru", "Отмена"], + ["zh-CN", "取消"], + ])("loads %s synchronously on selection and reuses it", (locale, cancel) => { + const result = runStartupProbe(` + i18n.addResource("en", "translation", "startupProbeOnlyInEnglish", "English fallback"); + i18n.changeLanguage(${JSON.stringify(locale)}); + if (i18n.language !== ${JSON.stringify(locale)}) throw new Error("Language selection became asynchronous"); + i18n.changeLanguage("en"); + i18n.changeLanguage(${JSON.stringify(locale)}); + `); + expect(result).toEqual({ + evaluated: ["en", locale], + registered: ["en", locale], + language: locale, + cancel, + fallback: "English fallback", + }); + }); +}); diff --git a/packages/app/src/i18n/i18next.ts b/packages/app/src/i18n/i18next.ts index 05ceeee6ee..ccbfb8da46 100644 --- a/packages/app/src/i18n/i18next.ts +++ b/packages/app/src/i18n/i18next.ts @@ -1,41 +1,70 @@ -import { createInstance } from "i18next"; +import { createInstance, type BackendModule } from "i18next"; import { initReactI18next } from "react-i18next"; import { observeI18nInit } from "./init"; -import { ar } from "./resources/ar"; import { en } from "./resources/en"; -import { es } from "./resources/es"; -import { fr } from "./resources/fr"; -import { ja } from "./resources/ja"; -import { ko } from "./resources/ko"; -import { ptBR } from "./resources/pt-BR"; -import { ru } from "./resources/ru"; -import { zhCN } from "./resources/zh-CN"; + +// Literal requires keep every locale bundled for offline use, while evaluating +// its resource module only when selected. Synchronous reads preserve the language +// provider's contract: translations are ready before it renders children. +const bundledTranslations: BackendModule = { + type: "backend", + init() {}, + read(language, _namespace, callback) { + switch (language) { + case "en": + callback(null, en); + return; + case "ar": + callback(null, require("./resources/ar").ar); + return; + case "es": + callback(null, require("./resources/es").es); + return; + case "fr": + callback(null, require("./resources/fr").fr); + return; + case "ja": + callback(null, require("./resources/ja").ja); + return; + case "ko": + callback(null, require("./resources/ko").ko); + return; + case "pt-BR": + callback(null, require("./resources/pt-BR").ptBR); + return; + case "ru": + callback(null, require("./resources/ru").ru); + return; + case "zh-CN": + callback(null, require("./resources/zh-CN").zhCN); + return; + default: + callback(null, undefined); + } + }, +}; const i18n = createInstance(); observeI18nInit( - i18n.use(initReactI18next).init({ - compatibilityJSON: "v4", - fallbackLng: "en", - lng: "en", - resources: { - ar: { translation: ar }, - en: { translation: en }, - es: { translation: es }, - fr: { translation: fr }, - ja: { translation: ja }, - ko: { translation: ko }, - "pt-BR": { translation: ptBR }, - ru: { translation: ru }, - "zh-CN": { translation: zhCN }, - }, - interpolation: { - escapeValue: false, - }, - react: { - useSuspense: false, - }, - }), + i18n + .use(bundledTranslations) + .use(initReactI18next) + .init({ + compatibilityJSON: "v4", + fallbackLng: "en", + lng: "en", + partialBundledLanguages: true, + resources: { + en: { translation: en }, + }, + interpolation: { + escapeValue: false, + }, + react: { + useSuspense: false, + }, + }), ); export { i18n }; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 7a45d4a771..6274691582 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -3,6 +3,7 @@ import { playwright } from "@vitest/browser-playwright"; import path from "path"; import fs from "fs"; import { transformSync } from "@babel/core"; +import { build } from "esbuild"; // Set this before Vite resolves dependencies and Vitest starts its workers. Object.assign(process.env, { NODE_ENV: "test" }); @@ -35,6 +36,24 @@ export default defineConfig({ if (result?.code) return { code: result.code, map: result.map }; }, }, + { + name: "bundle-i18n-resources", + // Metro resolves literal requires lazily. Vite's TS transform leaves them + // to Node, which cannot resolve the resource modules. Bundle this boundary + // so unit and browser consumers exercise the same deferred module loading. + async transform(_source, id) { + if (path.resolve(id) !== path.resolve(__dirname, "src/i18n/i18next.ts")) return; + const result = await build({ + entryPoints: [id], + bundle: true, + format: "esm", + platform: "neutral", + external: ["i18next", "react-i18next"], + write: false, + }); + return { code: result.outputFiles[0].text, map: null }; + }, + }, ], test: { environment: "node", From 90f3cab5850b9aed92c66eba0e3974f882527701 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:58:09 +0200 Subject: [PATCH 07/13] fix(nix): report the project license in package metadata (#7415) The Apache-2.0 relicensing left both Nix derivations declaring AGPL. Match their metadata to the project license and check both packages against the root manifest on every supported Nix system. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- flake.nix | 15 +++++++++++++++ nix/desktop-package.nix | 2 +- nix/package.nix | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 9f98752e7e..c9e3aa57d9 100644 --- a/flake.nix +++ b/flake.nix @@ -45,6 +45,21 @@ } ); + checks = forAllSystems ( + system: + let + pkgs = pkgsFor system; + packages = self.packages.${system}; + license = (builtins.fromJSON (builtins.readFile ./package.json)).license; + in + { + license-metadata = + assert packages.paseo.meta.license.spdxId == license; + assert packages.desktop.meta.license.spdxId == license; + pkgs.runCommand "paseo-license-metadata" { } "touch $out"; + } + ); + nixosModules.default = self.nixosModules.paseo; nixosModules.paseo = { pkgs, lib, ... }: diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index 3fd5f47093..00ed6cd926 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -257,7 +257,7 @@ buildNpmPackage { meta = { description = "Paseo desktop app (Electron wrapper)"; homepage = "https://github.com/getpaseo/paseo"; - license = lib.licenses.agpl3Plus; + license = lib.licenses.asl20; mainProgram = "paseo-desktop"; platforms = lib.platforms.linux ++ lib.platforms.darwin; }; diff --git a/nix/package.nix b/nix/package.nix index b032d88396..f291784ea2 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -148,7 +148,7 @@ buildNpmPackage rec { meta = { description = "Self-hosted daemon for Claude Code, Codex, and OpenCode"; homepage = "https://github.com/getpaseo/paseo"; - license = lib.licenses.agpl3Plus; + license = lib.licenses.asl20; mainProgram = "paseo"; platforms = lib.platforms.linux ++ lib.platforms.darwin; }; From 69ef741a73559b6fd00fc483aa1a331fac21c229 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:26:01 +0200 Subject: [PATCH 08/13] fix(app): pause native status rings in hidden panels (#7423) Retained panels keep their children mounted, so mount-only registration kept hidden rings subscribed to the shared UI clock. Detach inactive rings and retain a local phase so hidden animated styles also stop receiving updates while visible rings continue. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Pete Keen --- packages/app/e2e/mobile/status-ring/README.md | 45 ++++++++++++ .../app/e2e/mobile/status-ring/android.mjs | 71 +++++++++++++++++++ .../app/e2e/mobile/status-ring/native.tsx | 69 ++++++++++++++++++ .../app/src/components/status-ring/clock.ts | 30 ++++++-- 4 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 packages/app/e2e/mobile/status-ring/README.md create mode 100644 packages/app/e2e/mobile/status-ring/android.mjs create mode 100644 packages/app/e2e/mobile/status-ring/native.tsx diff --git a/packages/app/e2e/mobile/status-ring/README.md b/packages/app/e2e/mobile/status-ring/README.md new file mode 100644 index 0000000000..213af8a580 --- /dev/null +++ b/packages/app/e2e/mobile/status-ring/README.md @@ -0,0 +1,45 @@ +# Native retained status-ring regression + +This runs the production native ring and clock inside real retained panels with +Reanimated on Android. A probe consumes the same public rotation hook and counts +UI-runtime value changes. No Reanimated mocks or provider processes are used. + +Use an Android development client compatible with the checkout's native dependencies. +In a disposable checkout, temporarily replace `packages/app/index.ts` with: + +```tsx +import "./src/polyfills/install-early"; +import "./src/styles/unistyles"; +import { registerRootComponent } from "expo"; +import NativeRingRegression from "./e2e/mobile/status-ring/native"; +registerRootComponent(NativeRingRegression); +``` + +Start Metro in `packages/app`: + +```sh +APP_VARIANT=development npx expo start --localhost --port 8096 +``` + +With one Android emulator running, open that bundle: + +```sh +agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096 +``` + +From the repository root: + +```sh +node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096 +``` + +The test checks visible rotation, hidden-panel inactivity while another ring is +visible, inactivity with both panels hidden, and synchronized resumption. Each +sample reads counters on the UI runtime; elapsed time includes device-command +latency. The baseline fails at the first hidden-panel assertion. + +Restore the original `packages/app/index.ts`, close the agent-device session, +and stop Metro afterward. The fixture substitutes running rings for a live agent +and toggles retained panels directly; it does not exercise workspace navigation +or measure whole-app CPU usage. It is excluded from routine unit tests because +it requires a native runtime. diff --git a/packages/app/e2e/mobile/status-ring/android.mjs b/packages/app/e2e/mobile/status-ring/android.mjs new file mode 100644 index 0000000000..7b5735fa2b --- /dev/null +++ b/packages/app/e2e/mobile/status-ring/android.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; + +const [, , session, metroUrl] = process.argv; +assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx"); +function ad(...args) { + return execFileSync("agent-device", ["--session", session, ...args], { + encoding: "utf8", + timeout: 60_000, + }); +} +function evaluate(expression) { + const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim(); + return value === "undefined" ? undefined : JSON.parse(value); +} +let target; +const deadline = Date.now() + 30_000; +do { + target = ad("cdp", "target", "list", "--url", metroUrl) + .split("\n") + .find((line) => line.includes("sh.paseo.debug")); + if (!target) await delay(250); +} while (!target && Date.now() < deadline); +assert(target, "Open the Android dev client on this Metro bundle first"); +ad("cdp", "target", "select", target.split(/\s+/)[0]); +let ready = false; +do { + ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2"); + if (!ready) await delay(250); +} while (!ready && Date.now() < deadline); +assert(ready, "The two native probes must mount before sampling"); +async function sample() { + evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())"); + await delay(100); + return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)")); +} +async function observe(label) { + const before = await sample(); + await delay(600); + const after = await sample(); + const deltas = Object.fromEntries( + Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]), + ); + console.log(JSON.stringify({ label, deltas, after })); + return { deltas, after }; +} +const visible = await observe("both visible"); +assert(visible.deltas.first > 0 && visible.deltas.second > 0); +assert.equal(visible.after.first.rotation, visible.after.second.rotation); +ad("press", 'label="HIDE FIRST"'); +await delay(200); +const hidden = await observe("first hidden, second visible"); +assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates"); +assert(hidden.deltas.second > 0, "Visible rings must continue rotating"); +ad("press", 'label="HIDE SECOND"'); +await delay(200); +const allHidden = await observe("both hidden"); +assert.equal(allHidden.deltas.first, 0); +assert.equal(allHidden.deltas.second, 0); +ad("press", 'label="SHOW FIRST"'); +ad("press", 'label="SHOW SECOND"'); +await delay(200); +const restored = await observe("both shown again"); +assert(restored.deltas.first > 0 && restored.deltas.second > 0); +assert.equal( + restored.after.first.rotation, + restored.after.second.rotation, + "Restored rings must share the current phase", +); +console.log("PASS native retained-ring lifecycle"); diff --git a/packages/app/e2e/mobile/status-ring/native.tsx b/packages/app/e2e/mobile/status-ring/native.tsx new file mode 100644 index 0000000000..987a225431 --- /dev/null +++ b/packages/app/e2e/mobile/status-ring/native.tsx @@ -0,0 +1,69 @@ +import { useCallback, useLayoutEffect, useState } from "react"; +import { Button, Text, View } from "react-native"; +import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; +import { scheduleOnRN, scheduleOnUI } from "react-native-worklets"; +import { RetainedPanel } from "@/components/retained-panel"; +import { useStatusRingRotation } from "@/components/status-ring/clock"; +import { StatusRing } from "@/components/status-ring"; + +interface Sample { + updates: number; + rotation: number; +} + +declare global { + var __ringSamples: Record; + var __ringReaders: Record void>; +} + +function recordSample(name: string, value: { updates: number; rotation: number }) { + globalThis.__ringSamples[name] = value; +} + +function Probe({ name }: { name: string }) { + const rotation = useStatusRingRotation(); + const updates = useSharedValue(0); + useAnimatedReaction( + () => rotation.value, + () => { + updates.value += 1; + }, + ); + // Read the real UI runtime, without scheduling a continuous JS observer. + useLayoutEffect(() => { + globalThis.__ringSamples ??= {}; + globalThis.__ringReaders ??= {}; + globalThis.__ringReaders[name] = () => + scheduleOnUI(() => { + "worklet"; + const sample = { updates: updates.value, rotation: rotation.value }; + scheduleOnRN(recordSample, name, sample); + }); + return () => { + delete globalThis.__ringReaders[name]; + }; + }, [name, rotation, updates]); + return ; +} + +const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" }; + +export default function NativeRingRegression() { + const [first, setFirst] = useState(true); + const [second, setSecond] = useState(true); + const toggleFirst = useCallback(() => setFirst((value) => !value), []); + const toggleSecond = useCallback(() => setSecond((value) => !value), []); + return ( + + Native retained status rings +