Compare commits

...
Author SHA1 Message Date
Mohamed Boudra e83384f138 fix(app): preserve cumulative browser profiling history 2026-10-20 04:53:04 +02:00
Mohamed Boudra 98bc82f043 refactor(app): own watchdog sinks and bounded log storage 2026-10-20 04:32:18 +02:00
Mohamed Boudra 662e57541e feat(app): record Android stalls in opt-in diagnostic builds 2026-10-20 04:21:28 +02:00
paseo-bot[bot] ab6cdb7c89 fix(claude): return to Auto when approving a plan (#7453)
Offer Auto the same explicit resume action as Bypass and apply the saved mode before releasing plan approval. Ordinary Implement keeps selecting Accept File Edits.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-20 03:57:25 +02:00
paseo-bot[bot] 0d61cca50c fix(app): identify Android certificate failures as TLS errors (#7429)
Recognize Android certificate-path validation failures before the generic not-found classification, so Direct connection no longer tells users to debug DNS for an untrusted certificate. Keep the existing TLS copy and technical details.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-20 00:53:37 +02:00
paseo-bot[bot] 0eee4fd9b0 docs(schedules): explain archive-on-finish behavior (#7426)
The schedule and worktree guides omitted the existing retention control and its default. Document the app setting and clarify that CLI and MCP tools do not expose it.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 23:56:49 +02:00
paseo-bot[bot]andPete Keen 69ef741a73 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 <pete@petekeen.net>
2026-10-19 23:26:01 +02:00
paseo-bot[bot] 90f3cab585 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>
2026-10-19 21:58:09 +02:00
paseo-bot[bot]andIvan Malison fc775b6db5 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 <IvanMalison@gmail.com>

* 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 <IvanMalison@gmail.com>
2026-10-19 21:03:24 +02:00
paseo-bot[bot]anddwyanewang 71e7cf6ff8 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>
2026-10-19 20:09:07 +02:00
paseo-bot[bot]andMichael Wu eda3e4318f 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 <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 18:01:45 +02:00
paseo-bot[bot]andgszep 91154ba4e8 fix(opencode): advertise agent-scoped bridge tool schemas (#7383)
Co-authored-by: gszep <contact@gszep.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 16:58:21 +02:00
paseo-bot[bot] 05920a236c 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>
2026-10-19 15:49:47 +02:00
95 changed files with 3276 additions and 479 deletions
+8
View File
@@ -417,6 +417,14 @@ jobs:
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Run Android module tests
run: npm run test:android --workspace=@getpaseo/app
playwright-1:
name: playwright (shard 1/8)
needs: changes
+2
View File
@@ -106,6 +106,8 @@ npm run format:check # Check formatting without writing
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
When Mo requests an APK, use `npm run build:apk:arm64` for an ARM64 build: writes `artifacts/paseo-dogfood-arm64.apk` with diagnostics without starting a device; `npm run build:apk` builds all four Android architectures.
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Release branches
+49 -23
View File
@@ -64,36 +64,62 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
From repo root:
```bash
npm run android:development # Debug build
npm run android:production # Release build
npm run android:clear # Remove generated Android project
npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86
npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones
npm run dev:android # Build/install the debug app and start development
npm run dev:ios # Build/install the iOS development app
```
For a production-ID release APK that local Android profiling tools can attach to:
`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes
`artifacts/paseo-dogfood-arm64.apk`. Neither command launches or installs on a device. Both enable
`PASEO_WATCHDOG_BUILD=1`, the production React profiling renderer, and Android's shell-profileable
flag. These builds keep the production package ID (`sh.paseo`) and release optimizations. Use this APK for
personal dogfooding. Android system-trace spans require the separate `PASEO_PROFILE_BUILD=1` flag;
watchdog recording does not send duplicate spans to that sink by default. Normal store/release
workflows leave diagnostics disabled.
The older `android`, `android:development`, and `ios` commands remain aliases for development.
`android:production` still builds and installs a release through Expo; use `build:apk` when you
need an artifact. `android:clear` removes the generated Android project.
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
### Diagnostic logs without ADB
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
retained `.jsonl` files. No app screen, storage permission, or attached debugger is required.
The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation
replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall
can leave older Downloads files that the new installation cannot manage.
Each file identifies its build and session. Events include application-transport frame sizes and
message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree
render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents
are excluded; connection identifiers include host addresses. Each size has a unit: **bytes** when
already available, otherwise **UTF-16 code units** for text. These are
client transport observations, after relay decryption where applicable, not packet arrival times.
The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval).
A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration.
Backgrounding suspends detection. `js.stall.started` can be written while JS remains blocked;
`js.stall.recovered` records the gap when JS resumes. Recovery can appear without a start event
when JS resumes before the next native sampling tick. Process death leaves earlier files intact,
but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash.
`react.render.slow` records subtree rendering taking at least 16 ms; one-second summaries include
faster renders. Nested profiler timings overlap: don't add parent and child times. React's render
measurements do not measure native layout/paint or the complete commit phase. `recorder.dropped`
means the bounded queue overflowed; missing events in that interval must not be treated as absence
of activity. Storage failure stops recording rather than blocking the app.
For an ordinary release APK with system trace markers only:
```bash
PASEO_PROFILE_BUILD=1 npm run android:production
```
This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
`<profileable android:shell="true" />` and enables local Android trace markers for workspace mounts
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
Or from `packages/app`:
```bash
# Debug
npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive
npx cross-env APP_VARIANT=development expo run:android --variant=debug
# Release
npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive
npx cross-env APP_VARIANT=production expo run:android --variant=release
# Clear generated Android project
rm -rf android
```
Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`);
they do not produce Downloads files.
## Running on an emulator against a worktree daemon
+1 -1
View File
@@ -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.
+15
View File
@@ -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, ... }:
+1 -1
View File
@@ -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;
};
+1 -1
View File
@@ -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;
};
+6 -3
View File
@@ -41,12 +41,16 @@
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
"dev:android": "npm run dev:android --workspace=@getpaseo/app",
"dev:ios": "npm run dev:ios --workspace=@getpaseo/app",
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
"test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
"build": "npm run build --workspaces --if-present",
"build:apk": "node scripts/build-apk.mjs",
"build:apk:arm64": "node scripts/build-apk.mjs arm64",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:plugin": "npm run build --workspace=@getpaseo/plugin",
@@ -80,15 +84,14 @@
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android": "npm run dev:android",
"android:development": "npm run android:development --workspace=@getpaseo/app",
"android:production": "npm run android:production --workspace=@getpaseo/app",
"android:release": "npm run android:production --workspace=@getpaseo/app",
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs",
"fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check",
"ios": "npm run ios --workspace=@getpaseo/app",
"ios": "npm run dev:ios",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
+5 -1
View File
@@ -10,6 +10,7 @@ const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
const buildProfile = isFdroidBuild
@@ -180,7 +181,7 @@ export default {
},
],
...buildProfile.fdroidPlugins,
...(isProfileBuild ? [withAndroidProfileable] : []),
...(isProfileBuild || isWatchdogBuild ? [withAndroidProfileable] : []),
],
experiments: {
typedRoutes: true,
@@ -190,6 +191,9 @@ export default {
extra: {
fdroidBuild: isFdroidBuild,
profileBuild: isProfileBuild,
watchdogBuild: isWatchdogBuild,
diagnosticRevision: process.env.PASEO_DIAGNOSTIC_REVISION,
diagnosticBuildTime: process.env.PASEO_DIAGNOSTIC_BUILD_TIME,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
@@ -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 {
+99 -14
View File
@@ -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);
}
@@ -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 }) => {
@@ -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.
@@ -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");
@@ -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<string, Sample>;
var __ringReaders: Record<string, () => 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 <StatusRing />;
}
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 (
<View style={containerStyle}>
<Text>Native retained status rings</Text>
<Button title={first ? "Hide first" : "Show first"} onPress={toggleFirst} />
<Button title={second ? "Hide second" : "Show second"} onPress={toggleSecond} />
<RetainedPanel active={first}>
<Probe name="first" />
</RetainedPanel>
<RetainedPanel active={second}>
<Probe name="second" />
</RetainedPanel>
</View>
);
}
+2
View File
@@ -9,6 +9,8 @@ import "@expo/metro-runtime";
// oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects.
import "expo-router/build/fast-refresh";
import { renderRootComponent } from "expo-router/build/renderRootComponent";
import { startWatchdog } from "./src/watchdog";
import { RootApp } from "./src/root-app";
startWatchdog();
renderRootComponent(RootApp);
+10
View File
@@ -5,6 +5,7 @@ const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const fdroidModuleOverrides = {
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
@@ -16,6 +17,7 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
.toLowerCase();
const config = getDefaultConfig(projectRoot);
config.cacheVersion = isWatchdogBuild ? "watchdog" : "standard";
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
@@ -75,6 +77,14 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
}
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (
isWatchdogBuild &&
platform === "android" &&
/[/\\]Renderer[/\\]shims[/\\]/.test(context.originModulePath) &&
/implementations\/(ReactFabric|ReactNativeRenderer)-prod$/.test(moduleName)
) {
return defaultResolveRequest(context, moduleName.replace(/-prod$/, "-profiling"), platform);
}
if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) {
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
}
@@ -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 {
@@ -13,3 +13,7 @@ useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.trace"
}
dependencies {
testImplementation "junit:junit:4.13.2"
}
@@ -0,0 +1,77 @@
package sh.paseo.trace
import android.content.Context
import android.os.SystemClock
import android.util.Log
import org.json.JSONObject
import java.util.UUID
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
/** All serialization and storage belongs to the worker, never the JS/UI threads. */
internal class DiagnosticRecorder(private val context: Context, private val metadata: Map<String, String>) {
private data class Event(val name: String, val data: Map<String, String>, val monoMs: Long = SystemClock.elapsedRealtime(), val wallMs: Long = System.currentTimeMillis())
private val queue = ArrayBlockingQueue<Event>(2048)
private val dropped = AtomicLong()
private val worker = Executors.newSingleThreadScheduledExecutor()
private val session = UUID.randomUUID().toString()
@Volatile private var failed = false
private val watchdog = HeartbeatWatchdog(SystemClock.elapsedRealtime(), ::record)
private val log = RotatingLog(DownloadsLogStorage(context, session), { part ->
serialize(Event("file.start", metadata + mapOf("schema" to "1", "part" to "$part")))
}, { error ->
failed = true
queue.clear()
Log.e("PaseoDiagnostics", "Diagnostic recording stopped: storage unavailable", error)
})
init {
record("session.start", metadata + mapOf("session" to session, "schema" to "1", "heartbeatMs" to "${HeartbeatTiming.intervalMs}", "stallThresholdMs" to "${HeartbeatTiming.overdueMs}"))
worker.scheduleWithFixedDelay({ drain() }, 0, HeartbeatTiming.intervalMs, TimeUnit.MILLISECONDS)
}
fun record(name: String, data: Map<String, String>) {
if (failed) return
// Limit native memory even if an instrumentation caller accidentally supplies large fields.
val bounded = data.entries.take(24).associate { it.key.take(80) to it.value.take(256) }
if (!queue.offer(Event(name.take(120), bounded))) dropped.incrementAndGet()
}
fun heartbeat() = watchdog.heartbeat(SystemClock.elapsedRealtime())
fun active(value: Boolean) = watchdog.active(value, SystemClock.elapsedRealtime())
private fun drain() {
if (failed) return
watchdog.check(SystemClock.elapsedRealtime())
val lost = dropped.getAndSet(0)
if (lost > 0) write(Event("recorder.dropped", mapOf("count" to "$lost")))
var remaining = 2048
while (remaining-- > 0 && !failed) {
val event = queue.poll() ?: break
write(event)
}
log.finishBatch()
}
private fun write(event: Event) = log.write(serialize(event))
private fun serialize(event: Event): ByteArray = (JSONObject().apply {
put("event", event.name)
put("session", session)
put("wallMs", event.wallMs)
put("monoMs", event.monoMs)
put("data", JSONObject(event.data))
}.toString() + "\n").toByteArray(Charsets.UTF_8)
fun close() {
worker.execute {
record("session.end", emptyMap())
drain()
log.finishBatch()
}
worker.shutdown()
}
}
@@ -0,0 +1,36 @@
package sh.paseo.trace
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import java.io.OutputStream
internal class DownloadsLogStorage(private val context: Context, private val session: String) : LogStorage {
private val resolver = context.contentResolver
private val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
private val directory = "Download/Paseo diagnostics/"
override fun create(part: Int): String {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, "paseo-diagnostics-${System.currentTimeMillis()}-${session.take(8)}-$part.jsonl")
put(MediaStore.MediaColumns.MIME_TYPE, "application/x-ndjson")
put(MediaStore.MediaColumns.RELATIVE_PATH, directory)
}
return checkNotNull(resolver.insert(collection, values)).toString()
}
override fun append(id: String): OutputStream =
checkNotNull(resolver.openOutputStream(Uri.parse(id), "wa")).buffered(64 * 1024)
override fun newest(): List<String> = buildList {
resolver.query(collection, arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?",
arrayOf(directory, context.packageName), "${MediaStore.MediaColumns._ID} DESC")?.use { cursor ->
while (cursor.moveToNext()) add(ContentUris.withAppendedId(collection, cursor.getLong(0)).toString())
}
}
override fun delete(id: String) { resolver.delete(Uri.parse(id), null, null) }
}
@@ -0,0 +1,38 @@
package sh.paseo.trace
internal object HeartbeatTiming {
const val intervalMs = 250L
const val overdueMs = 500L
const val gapMs = intervalMs + overdueMs
}
/** Monotonic time is supplied by the owner, so suspension and recovery are deterministic. */
internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map<String, String>) -> Unit) {
private var foreground = true
private var lastHeartbeat = startMs
private var overdue = false
@Synchronized fun heartbeat(now: Long) {
if (foreground && now - lastHeartbeat > HeartbeatTiming.gapMs) {
emit("js.stall.recovered", mapOf("heartbeatGapMs" to "${now - lastHeartbeat}", "overdueMs" to "${now - lastHeartbeat - HeartbeatTiming.intervalMs}"))
}
lastHeartbeat = now
overdue = false
}
@Synchronized fun active(value: Boolean, now: Long) {
if (foreground == value) return
emit("app.lifecycle", mapOf("state" to if (value) "foreground" else "background", "stallOpen" to "$overdue"))
foreground = value
lastHeartbeat = now
overdue = false
}
@Synchronized fun check(now: Long) {
val gap = now - lastHeartbeat
if (foreground && gap > HeartbeatTiming.gapMs && !overdue) {
emit("js.stall.started", mapOf("heartbeatGapMs" to "$gap", "lastHeartbeatMonoMs" to "$lastHeartbeat"))
overdue = true
}
}
}
@@ -5,9 +5,23 @@ import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoNativeTraceModule : Module() {
private var recorder: DiagnosticRecorder? = null
override fun definition() = ModuleDefinition {
Name("PaseoNativeTrace")
Function("startRecorder") { metadata: Map<String, String> ->
if (recorder == null) {
appContext.reactContext?.applicationContext?.let { recorder = DiagnosticRecorder(it, metadata) }
}
HeartbeatTiming.intervalMs
}
Function("record") { name: String, data: Map<String, String> -> recorder?.record(name, data) }
Function("heartbeat") { recorder?.heartbeat() }
OnActivityEntersForeground { recorder?.active(true) }
OnActivityEntersBackground { recorder?.active(false) }
OnDestroy { recorder?.close(); recorder = null }
Function("beginSection") { name: String ->
Trace.beginSection(name.take(127))
}
@@ -0,0 +1,61 @@
package sh.paseo.trace
import java.io.OutputStream
/** Storage supplies installation-owned files, newest first. */
internal interface LogStorage {
fun create(part: Int): String
fun append(id: String): OutputStream
fun newest(): List<String>
fun delete(id: String)
}
/** Owns file identity, bounded retention and the terminal storage-failure state. */
internal class RotatingLog(
private val storage: LogStorage,
private val header: (Int) -> ByteArray,
private val onFailure: (Exception) -> Unit,
private val maxBytes: Int = 5 * 1024 * 1024,
) {
private var current: String? = null
private var output: OutputStream? = null
private var size = 0
private var part = 0
var failed = false
private set
fun write(bytes: ByteArray) = attempt {
if (current == null || size + bytes.size > maxBytes) {
finishBatch()
if (failed) return@attempt
val nextPart = part++
current = storage.create(nextPart)
output = storage.append(checkNotNull(current))
val first = header(nextPart)
output!!.write(first)
size = first.size
storage.newest().drop(8).forEach(storage::delete)
}
if (output == null) output = storage.append(checkNotNull(current))
output!!.write(bytes)
size += bytes.size
}
// Closing publishes size and modification time in Android Files.
fun finishBatch() = attempt {
output?.close()
output = null
}
private fun attempt(action: () -> Unit) {
if (failed) return
try {
action()
} catch (error: Exception) {
failed = true
runCatching { output?.close() }
output = null
onFailure(error)
}
}
}
@@ -0,0 +1,40 @@
package sh.paseo.trace
import org.junit.Assert.assertEquals
import org.junit.Test
class HeartbeatWatchdogTest {
@Test fun recordsOneStartAndRecoveryWithMeasuredGap() {
val events = mutableListOf<Pair<String, Map<String, String>>>()
val watchdog = HeartbeatWatchdog(0) { name, fields -> events.add(name to fields) }
watchdog.heartbeat(250)
watchdog.check(1000)
assertEquals(emptyList<Pair<String, Map<String, String>>>(), events)
watchdog.check(1001)
watchdog.check(2000)
watchdog.heartbeat(2250)
assertEquals(listOf(
"js.stall.started" to mapOf("heartbeatGapMs" to "751", "lastHeartbeatMonoMs" to "250"),
"js.stall.recovered" to mapOf("heartbeatGapMs" to "2000", "overdueMs" to "1750")
), events)
}
@Test fun backgroundAndResumeDoNotLookLikeStalls() {
val events = mutableListOf<String>()
val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
watchdog.active(false, 100)
watchdog.check(300000)
watchdog.heartbeat(300100)
watchdog.active(true, 400000)
watchdog.heartbeat(400250)
watchdog.check(400500)
assertEquals(listOf("app.lifecycle", "app.lifecycle"), events)
}
@Test fun recoveryIsReportedEvenIfNativeSamplingMissedTheGap() {
val events = mutableListOf<String>()
val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
watchdog.heartbeat(900)
assertEquals(listOf("js.stall.recovered"), events)
}
}
@@ -0,0 +1,59 @@
package sh.paseo.trace
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
import org.junit.Assert.*
import org.junit.Test
class RotatingLogTest {
private class MemoryStorage : LogStorage {
val files = linkedMapOf<String, ByteArrayOutputStream>()
var fail = false
var opens = 0
override fun create(part: Int): String {
val id = "part-$part"
files[id] = ByteArrayOutputStream()
return id
}
override fun append(id: String): OutputStream {
opens++
if (fail) throw IOException("disk full")
return files.getValue(id)
}
override fun newest() = files.keys.reversed()
override fun delete(id: String) { files.remove(id) }
}
@Test fun rotatesAtByteLimitAndKeepsEightIncludingCurrent() {
val storage = MemoryStorage()
val log = RotatingLog(storage, { "header:$it\n".toByteArray() }, { throw it }, 20)
repeat(10) { log.write("123456789012".toByteArray()); log.finishBatch() }
assertEquals((2..9).map { "part-$it" }, storage.files.keys.toList())
for (part in 2..9) assertEquals("header:$part\n123456789012", storage.files.getValue("part-$part").toString("UTF-8"))
assertFalse(log.failed)
}
@Test fun reopeningBatchAppendsWithoutRepeatingHeaderAndExactLimitFits() {
val storage = MemoryStorage()
val log = RotatingLog(storage, { "H".toByteArray() }, { throw it }, 5)
log.write("12".toByteArray())
log.finishBatch()
log.write("34".toByteArray())
assertEquals(listOf("part-0"), storage.files.keys.toList())
assertEquals("H1234", storage.files.getValue("part-0").toString("UTF-8"))
log.write("5".toByteArray())
assertEquals("H5", storage.files.getValue("part-1").toString("UTF-8"))
}
@Test fun storageFailureStopsRecordingWithoutRetryStorm() {
val storage = MemoryStorage()
var failures = 0
val log = RotatingLog(storage, { byteArrayOf() }, { failures++ })
storage.fail = true
repeat(100) { log.write(byteArrayOf(1)); log.finishBatch() }
assertTrue(log.failed)
assertEquals(1, failures)
assertEquals(1, storage.opens)
}
}
+9 -4
View File
@@ -8,12 +8,12 @@
"start:expo": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
"android": "npm run android:development",
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android": "npm run dev:android",
"android:development": "npm run dev:android",
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"ios": "npm --prefix ../.. run build:client && expo run:ios",
"ios": "npm run dev:ios",
"ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release",
"web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"",
"web:expo": "expo start --web",
@@ -36,7 +36,12 @@
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -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}"
}
}
}
+1 -90
View File
@@ -11,10 +11,7 @@ import {
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
@@ -195,92 +192,6 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
};
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: DirectConnectionLabels,
): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: DirectConnectionLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}
export interface AddHostModalProps {
visible: boolean;
onClose: () => void;
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
const labels = {
hostRequired: "host required",
invalidPort: "invalid port",
invalidConnection: "invalid connection",
failedToConnect: (endpoint: string) => `Failed: ${endpoint}`,
noAdditionalDetails: (detail: string) => `No details: ${detail}`,
timedOut: "timeout",
refused: "refused",
hostNotFound: "DNS failure",
hostUnreachable: "unreachable",
tlsError: "TLS failure",
unableToConnect: "connection failure",
};
function failure(message: string) {
return buildConnectionFailureCopy({
endpoint: "tcp://example.test:443?ssl=true",
error: new DaemonConnectionTestError(message, {
reason: "transport error",
lastError: message,
}),
labels,
});
}
describe("direct connection failure copy", () => {
it("identifies Android certificate validation errors without calling them DNS failures", () => {
const message =
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
expect(failure(message)).toEqual({
title: "Failed: tcp://example.test:443?ssl=true",
detail: labels.tlsError,
raw: `transport error — ${message}`,
});
});
it.each([
["certificate not found", labels.tlsError],
["SSL handshake failed", labels.tlsError],
["TLS handshake failed", labels.tlsError],
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
["Host not found", labels.hostNotFound],
["connect ECONNREFUSED", labels.refused],
["connection timed out", labels.timedOut],
["EHOSTUNREACH", labels.hostUnreachable],
["unexpected transport failure", labels.unableToConnect],
])("classifies %s", (message, expected) => {
expect(failure(message).detail).toBe(expected);
});
it("preserves authentication failures and their technical details", () => {
const message = "Invalid daemon password";
expect(
buildConnectionFailureCopy({
endpoint: "example.test:6767",
error: new DaemonConnectionTestError(message, {
reason: message,
lastError: message,
authFailureReason: "incorrect_password",
}),
labels,
}),
).toMatchObject({ detail: message, raw: message });
});
});
@@ -0,0 +1,103 @@
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
interface ConnectionFailureLabels {
failedToConnect: (endpoint: string) => string;
noAdditionalDetails: (detail: string) => string;
timedOut: string;
refused: string;
hostNotFound: string;
hostUnreachable: string;
tlsError: string;
unableToConnect: string;
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: ConnectionFailureLabels,
): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
export function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: ConnectionFailureLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("certpathvalidator") ||
rawLower.includes("trust anchor") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}
@@ -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 ? (
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
<Text style={styles.percentageLabel}>
{formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
</Text>
) : null;
const accessibilityLabel = context
? t("contextWindow.accessibility", { percentage: context.percentage })
@@ -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%");
});
});
@@ -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`;
@@ -1,11 +1,13 @@
import { useLayoutEffect } from "react";
import { useLayoutEffect, useState } from "react";
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
import { scheduleOnUI } from "react-native-worklets";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { getStatusRingRotation } from "@/components/status-ring/geometry";
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
const activeRingCount = makeMutable(0);
const clockRunning = makeMutable(false);
let nextListenerId = 1;
function advanceSharedRotation(): void {
"worklet";
@@ -18,13 +20,21 @@ function advanceSharedRotation(): void {
requestAnimationFrame(advanceSharedRotation);
}
function registerStatusRing(registered: SharedValue<boolean>): void {
function registerStatusRing(
rotation: SharedValue<number>,
registered: SharedValue<boolean>,
listenerId: number,
): void {
"worklet";
if (registered.value) {
return;
}
registered.value = true;
rotation.value = getStatusRingRotation(Date.now());
sharedRotation.addListener(listenerId, (value) => {
rotation.value = value;
});
activeRingCount.value += 1;
if (!clockRunning.value) {
@@ -34,25 +44,31 @@ function registerStatusRing(registered: SharedValue<boolean>): void {
}
}
function unregisterStatusRing(registered: SharedValue<boolean>): void {
function unregisterStatusRing(registered: SharedValue<boolean>, listenerId: number): void {
"worklet";
if (!registered.value) {
return;
}
registered.value = false;
sharedRotation.removeListener(listenerId);
activeRingCount.value -= 1;
}
export function useStatusRingRotation(): SharedValue<number> {
const active = useRetainedPanelActive();
// Hidden retained views must not subscribe their animated styles to the shared clock.
const rotation = useSharedValue(getStatusRingRotation(Date.now()));
const registered = useSharedValue(false);
const [listenerId] = useState(() => nextListenerId++);
useLayoutEffect(() => {
scheduleOnUI(registerStatusRing, registered);
if (!active) return;
scheduleOnUI(registerStatusRing, rotation, registered, listenerId);
return () => {
scheduleOnUI(unregisterStatusRing, registered);
scheduleOnUI(unregisterStatusRing, registered, listenerId);
};
}, [registered]);
}, [active, listenerId, registered, rotation]);
return sharedRotation;
return rotation;
}
@@ -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");
@@ -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",
});
});
});
+60 -31
View File
@@ -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 };
+42 -9
View File
@@ -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<string, string> {
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", () => {
+100
View File
@@ -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: "جارٍ تحميل الإعدادات...",
+79 -1
View File
@@ -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<T> = {
[K in keyof T]: T[K] extends string ? string : WidenStringLeaves<T[K]>;
};
export type TranslationResources = WidenStringLeaves<typeof en>;
type TranslationResourcesBase = WidenStringLeaves<typeof en>;
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<Record<DurationPluralKey, string>>;
timing: TranslationResourcesBase["providerUsage"]["timing"] &
Partial<Record<AgoPluralKey, string>>;
};
};
+64
View File
@@ -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...",
+64
View File
@@ -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…",
+64
View File
@@ -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: "設定を読み込み中...",
+64
View File
@@ -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: "설정 불러오는 중...",
+64
View File
@@ -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...",
+64
View File
@@ -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: "Загрузка настроек...",
+64
View File
@@ -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: "正在加载设置...",
+33 -16
View File
@@ -1,3 +1,4 @@
import { watchdog } from "@/watchdog";
import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client";
import { requireOptionalNativeModule } from "expo-modules-core";
import { isProfileBuild } from "@/constants/build-profile";
@@ -9,17 +10,14 @@ interface PaseoNativeTraceModule {
const traceModule = requireOptionalNativeModule<PaseoNativeTraceModule>("PaseoNativeTrace");
export const nativePerformanceTrace: DaemonClientTrace = {
isEnabled() {
return isProfileBuild && traceModule !== null;
},
beginSection(name, args) {
traceModule?.beginSection(formatSectionName(name, args));
},
endSection() {
traceModule?.endSection();
},
};
const nativeSink: DaemonClientTrace | null =
isProfileBuild && traceModule
? {
isEnabled: () => true,
beginSection: (name, args) => traceModule.beginSection(formatSectionName(name, args)),
endSection: () => traceModule.endSection(),
}
: null;
function formatSectionName(name: string, args?: Record<string, string>): string {
if (!args) {
@@ -29,10 +27,29 @@ function formatSectionName(name: string, args?: Record<string, string>): string
return `${name}|${fields.join(";")}`;
}
const instantSinks: ((name: string, args?: Record<string, string>) => void)[] = [];
if (watchdog) instantSinks.push(watchdog.record);
if (nativeSink)
instantSinks.push((name, args) => {
nativeSink.beginSection(name, args);
nativeSink.endSection();
});
export function traceInstant(name: string, args?: Record<string, string>): void {
if (!nativePerformanceTrace.isEnabled()) {
return;
}
nativePerformanceTrace.beginSection(name, args);
nativePerformanceTrace.endSection();
for (const write of instantSinks) write(name, args);
}
/** Each client owns its section stack; overlapping hosts never share span identity. */
export function createPerformanceTrace(context: Record<string, string>): DaemonClientTrace {
const sinks = [watchdog?.trace(context), nativeSink].filter(
(sink): sink is DaemonClientTrace => sink != null,
);
return {
isEnabled: () => sinks.length > 0,
beginSection(name, args) {
for (const sink of sinks) sink.beginSection(name, args);
},
endSection() {
for (const sink of sinks) sink.endSection();
},
};
}
+6 -1
View File
@@ -1,3 +1,4 @@
import { RenderProfile } from "@/utils/render-profiler";
import { useCallback, useState, type ComponentProps } from "react";
import { ExpoRoot } from "expo-router";
import Head from "expo-router/head";
@@ -13,7 +14,11 @@ const { ctx: context } = require("expo-router/_ctx") as {
};
export function RootApp() {
return <RootRouter context={context} />;
return (
<RenderProfile id="App">
<RootRouter context={context} />
</RenderProfile>
);
}
export function RootRouter({ context: routes }: Pick<ComponentProps<typeof ExpoRoot>, "context">) {
+12 -5
View File
@@ -1,3 +1,4 @@
import { watchdog } from "@/watchdog";
import { useSyncExternalStore, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6";
@@ -83,7 +84,7 @@ import {
type ViewedTimelineOwnerPorts,
} from "@/timeline/viewed-timeline-sync";
import { projectIconCache } from "@/projects/icon-cache";
import { nativePerformanceTrace } from "@/performance/native-trace";
import { createPerformanceTrace } from "@/performance/native-trace";
import { revokePushNotifications } from "@/push-notifications";
import { createAppWebSocketFactory } from "./websocket-factory";
@@ -544,7 +545,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration,
capabilities: appCapabilities,
trace: nativePerformanceTrace,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
providerSnapshots: "wire",
...(host.password ? { password: host.password } : {}),
localCredential: () => readDesktopManagedLocalCredential(connection),
@@ -604,7 +605,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
localCredential: () => readDesktopManagedLocalCredential(connection),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
capabilities: appCapabilities,
trace: nativePerformanceTrace,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
}),
getClientId: () => getOrCreateClientId(),
mountClientHandlers: ({ client, host }) => {
@@ -1159,12 +1160,18 @@ export class HostRuntimeController {
});
}
private logConnectionTransition(_input: {
private logConnectionTransition(input: {
from: HostRuntimeConnectionMachineState["tag"];
to: HostRuntimeConnectionMachineState["tag"];
event: HostRuntimeConnectionMachineEvent;
}): void {
// Intentionally empty - logging removed.
watchdog?.record("host.transition", {
host: this.host.serverId,
from: input.from,
to: input.to,
event: input.event.type,
epoch: String(this.connectionEpoch),
});
}
private trackConnectionFirstSeen(): void {
+3 -2
View File
@@ -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({
/>
) : (
<Text style={styles.message}>
{view.kind === "loading" ? usageCopy.loading : view.message}
{view.kind === "loading" ? t("providerUsage.loading") : view.message}
</Text>
)}
</>
+37 -11
View File
@@ -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<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
@@ -62,12 +76,18 @@ export function UsageBalanceBar({
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{balance.label}
</Text>
<Text style={styles.value}>
{amountText}
{resetLabel ? <Text style={styles.reset}>{` · ${resetLabel}`}</Text> : null}
{formatProviderUsageLabel(balance.id, balance.label, t)}
</Text>
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}>
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail">
{amountText}
</Text>
{resetLabel ? (
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}>
{resetLabel}
</Text>
) : null}
</View>
</View>
{usedPct != null ? (
<View style={styles.track}>
@@ -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,
@@ -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(
<>
<UsageWindowBar
window={window}
displayAs="used"
pinnable={false}
pinned={false}
onTogglePin={ignorePin}
pinLabel=""
pinTestID=""
/>
<UsageBalanceBar balance={balance} />
</>,
),
);
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("クレジット");
});
+26 -12
View File
@@ -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" ? (
<Text style={styles.error} testID="usage-refresh-error">
{usageCopy.refreshFailed}
{t("providerUsage.refreshFailed")}
</Text>
) : null}
</View>
@@ -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 (
<ToolbarButton
label={`${usageCopy.refresh} ${sourceLabel}`}
label={`${t("providerUsage.refresh")} ${sourceLabel}`}
tooltip={freshness}
tooltipSide="top"
compact={compact}
@@ -257,10 +264,17 @@ function UsageFreshness({
style: StyleProp<TextStyle>;
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 (
<Text style={style} numberOfLines={1} testID={testID}>
{successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"}
{freshness}
</Text>
);
}
+16 -33
View File
@@ -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;
+112 -2
View File
@@ -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",
);
});
+134 -18
View File
@@ -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);
}
@@ -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 (
<UsageSection
serverId={serverId}
title={usageCopy.planUsage}
title={t("providerUsage.title")}
view={view}
display={display}
onRefresh={refresh}
+25 -15
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
resolveUsageReport,
displayPercent,
formatUsageFreshness,
resolveAgentUsageView,
replaceReport,
resolveUsageRefresh,
@@ -65,6 +65,18 @@ describe("resolveUsageView", () => {
).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<UsageReportEntry["report"], { status: "available" }>,
},
};
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]);
});
+35 -24
View File
@@ -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 "";
}
+16 -12
View File
@@ -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<UsageDisplayAs>[] = [
{ 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<UsageDisplayAs>[] = [
{ 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() {
<DropdownMenu compactMode="sheet">
<ToolbarButton
kind="menu"
label={usageCopy.options}
label={t("providerUsage.options")}
compact={compact}
testID="usage-options-menu"
>
@@ -41,21 +45,21 @@ export function UsageOptions() {
<DropdownMenuContent
align="end"
width={340}
sheetTitle={usageCopy.options}
sheetTitle={t("providerUsage.options")}
testID="usage-options-surface"
>
<View testID="usage-options-fields">
<SettingsSwitch
label={usageCopy.showInSidebar}
hint={usageCopy.showInSidebarHint}
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
<View style={settingsStyles.rowBorder}>
<SettingsRow label={usageCopy.displayAs}>
<SettingsRow label={t("providerUsage.displayAs")}>
<SegmentedControl
options={DISPLAY_AS_OPTIONS}
options={displayAsOptions}
value={display.displayAs}
onValueChange={display.setDisplayAs}
size="sm"
+26
View File
@@ -275,3 +275,29 @@ it("keeps unavailable and error reports out of the sidebar summary even with ret
};
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
});
it("localizes pinned labels and meanings while preserving custom window names", async () => {
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");
}
});
+6 -4
View File
@@ -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),
+13 -7
View File
@@ -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. */
+3 -2
View File
@@ -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 (
<ToolbarButton
label={usageCopy.refreshAll}
label={t("providerUsage.refreshAll")}
compact={compact}
disabled={busy}
onPress={onRefresh}
+2 -1
View File
@@ -34,9 +34,10 @@ import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
const { preferences } = useUsagePreferences();
const reports = useUsageHostReports(useUsageHostId());
return useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
/** Whether the sidebar Usage item has anything to show. */
+2 -2
View File
@@ -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 (
<UsageModalFrame {...props}>
<UsageMessage text={usageCopy.noHosts} />
<UsageMessage text={t("providerUsage.noHosts")} />
</UsageModalFrame>
);
}
+11 -5
View File
@@ -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 <UsageMessage text={view.message} />;
}
if (view.kind === "loading") {
return <UsageMessage text={usageCopy.loading} />;
return <UsageMessage text={t("providerUsage.loading")} />;
}
if (view.kind === "error") {
return (
<Alert size="sm" variant="error" title={usageCopy.errorTitle} description={view.message}>
<Alert
size="sm"
variant="error"
title={t("providerUsage.errorTitle")}
description={view.message}
>
<Button variant="outline" size="sm" onPress={onRefresh}>
{usageCopy.retry}
{t("providerUsage.retry")}
</Button>
</Alert>
);
}
if (view.reports.length === 0) {
return <UsageMessage text={usageCopy.empty} />;
return <UsageMessage text={t("providerUsage.empty")} />;
}
return <UsageList serverId={serverId} reports={view.reports} display={display} />;
@@ -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;
}
+22 -9
View File
@@ -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({
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value}>
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
{value}
{trailing ? (
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
@@ -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 }) {
</View>
</TooltipTrigger>
<TooltipContent side="top">
<Text style={styles.tooltipText}>{pinned ? usageCopy.unpin : usageCopy.pin}</Text>
<Text style={styles.tooltipText}>
{pinned ? t("providerUsage.unpin") : t("providerUsage.pin")}
</Text>
</TooltipContent>
</Tooltip>
);
@@ -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,
+36 -34
View File
@@ -1,3 +1,4 @@
import { watchdog } from "@/watchdog";
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from "react";
export interface RenderProfileSample {
@@ -28,12 +29,40 @@ function getSearchParam(name: string): string | null {
return new URLSearchParams(search).get(name);
}
export function isRenderProfileEnabled(): boolean {
return (
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ === true || getSearchParam("renderProfile") === "1"
);
interface RenderSink {
isEnabled(): boolean;
render(sample: RenderProfileSample): void;
reasons(id: string, reasons: string[]): void;
}
function createBrowserSink(): RenderSink {
return {
isEnabled: () =>
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ === true ||
getSearchParam("renderProfile") === "1",
render(sample) {
globalThis.__PASEO_RENDER_PROFILE__ ??= [];
globalThis.__PASEO_RESET_RENDER_PROFILE__ ??= () => {
globalThis.__PASEO_RENDER_PROFILE__ = [];
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
};
// Browser QA compares cumulative commit counts until an explicit reset.
globalThis.__PASEO_RENDER_PROFILE__.push(sample);
},
reasons(id, reasons) {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
const counts = (globalThis.__PASEO_RENDER_PROFILE_REASONS__[id] ??= {});
for (const reason of reasons) counts[reason] = (counts[reason] ?? 0) + 1;
},
};
}
// Choose the destination once. Browser QA can still enable capture at runtime.
const sink: RenderSink = watchdog
? { isEnabled: () => true, render: watchdog.render, reasons: () => {} }
: createBrowserSink();
export const isRenderProfileEnabled = sink.isEnabled;
const onRender: ProfilerOnRenderCallback = (
id,
phase,
@@ -41,30 +70,10 @@ const onRender: ProfilerOnRenderCallback = (
baseDuration,
startTime,
commitTime,
) => {
globalThis.__PASEO_RENDER_PROFILE__ ??= [];
globalThis.__PASEO_RENDER_PROFILE__.push({
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
});
};
) => sink.render({ id, phase, actualDuration, baseDuration, startTime, commitTime });
export function RenderProfile({ id, children }: { id: string; children: ReactNode }) {
if (!isRenderProfileEnabled()) {
return children;
}
globalThis.__PASEO_RENDER_PROFILE__ ??= [];
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
globalThis.__PASEO_RESET_RENDER_PROFILE__ = () => {
globalThis.__PASEO_RENDER_PROFILE__ = [];
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
};
if (!sink.isEnabled()) return children;
return (
<Profiler id={id} onRender={onRender}>
{children}
@@ -73,12 +82,5 @@ export function RenderProfile({ id, children }: { id: string; children: ReactNod
}
export function recordRenderProfileReasons(id: string, reasons: string[]) {
if (!isRenderProfileEnabled() || reasons.length === 0) {
return;
}
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
const counts = (globalThis.__PASEO_RENDER_PROFILE_REASONS__[id] ??= {});
for (const reason of reasons) {
counts[reason] = (counts[reason] ?? 0) + 1;
}
if (sink.isEnabled()) sink.reasons(id, reasons);
}
+35
View File
@@ -0,0 +1,35 @@
import { requireOptionalNativeModule } from "expo-modules-core";
import Constants from "expo-constants";
import { resolveAppVersion } from "@/utils/app-version";
import { createRecorder } from "./internal/recorder";
interface NativeRecorder {
startRecorder(metadata: Record<string, string>): number;
record(name: string, fields: Record<string, string>): void;
heartbeat(): void;
}
const native =
Constants.expoConfig?.extra?.watchdogBuild === true
? requireOptionalNativeModule<NativeRecorder>("PaseoNativeTrace")
: null;
export const watchdog = native
? createRecorder(
(name, fields) => native.record(name, fields),
() => performance.now(),
)
: null;
let started = false;
export function startWatchdog(): void {
if (!native || !watchdog || started) return;
started = true;
const heartbeatMs = native.startRecorder({
version: resolveAppVersion() ?? "unknown",
build: String(Constants.expoConfig?.extra?.diagnosticRevision ?? "unknown"),
builtAt: String(Constants.expoConfig?.extra?.diagnosticBuildTime ?? "unknown"),
reactProfiling: "true",
});
setInterval(() => native.heartbeat(), heartbeatMs);
setInterval(() => watchdog.flushRenders(), 1000);
}
@@ -0,0 +1,103 @@
import { expect, test } from "vitest";
import { createRecorder } from "./recorder";
function fixture() {
const events: { name: string; fields: Record<string, string> }[] = [];
let now = 0;
const recorder = createRecorder(
(name, fields) => events.push({ name, fields }),
() => now,
);
return {
recorder,
events,
advance: (ms: number) => {
now += ms;
},
};
}
test("interleaved hosts keep independent parent spans and durations", () => {
const { recorder, events, advance } = fixture();
const a = recorder.trace({ host: "a" });
const b = recorder.trace({ host: "b" });
a.beginSection("frame");
advance(5);
b.beginSection("frame");
a.beginSection("parse");
advance(15);
a.endSection();
b.endSection();
a.endSection();
expect(
events.map(({ name, fields }) => [
name,
fields.host,
fields.span,
fields.parent,
fields.durationMs,
]),
).toEqual([
["frame.begin", "a", "1:1", "", undefined],
["frame.begin", "b", "2:1", "", undefined],
["parse.begin", "a", "1:2", "1:1", undefined],
["parse.end", "a", "1:2", undefined, "15"],
["frame.end", "b", "2:1", undefined, "15"],
["frame.end", "a", "1:1", undefined, "20"],
]);
});
test("slow renders preserve commit identity while fast renders are summarized", () => {
const { recorder, events } = fixture();
for (const actualDuration of [1, 2, 30])
recorder.render({
id: "Stream",
phase: "update",
actualDuration,
startTime: 4,
commitTime: 40,
});
recorder.flushRenders();
recorder.flushRenders();
expect(events).toEqual([
{
name: "react.render.slow",
fields: {
region: "Stream",
phase: "update",
renderMs: "30",
startMs: "4",
commitMs: "40",
jsMonoMs: "0",
},
},
{
name: "react.render.summary",
fields: { region: "Stream", count: "3", totalMs: "33", maxMs: "30", jsMonoMs: "0" },
},
]);
});
test("dynamic regions are bounded and summaries reset each interval", () => {
const { recorder, events } = fixture();
for (let i = 0; i < 10000; i++)
recorder.render({
id: String(i),
phase: "mount",
actualDuration: 1,
startTime: 0,
commitTime: 1,
});
recorder.flushRenders();
expect(events.length).toBe(129);
expect(events.at(-1)?.fields).toEqual({
region: "other",
count: "9872",
totalMs: "9872",
maxMs: "1",
jsMonoMs: "0",
});
recorder.render({ id: "new", phase: "mount", actualDuration: 1, startTime: 0, commitTime: 1 });
recorder.flushRenders();
expect(events.at(-1)?.fields.region).toBe("new");
});
@@ -0,0 +1,79 @@
import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client";
type Fields = Record<string, string>;
interface RenderSample {
id: string;
phase: string;
actualDuration: number;
startTime: number;
commitTime: number;
}
/** Bounded render aggregation and scoped trace identity; no payloads enter this boundary. */
export function createRecorder(write: (name: string, fields: Fields) => void, now: () => number) {
let nextClient = 0;
const renders = new Map<string, { count: number; totalMs: number; maxMs: number }>();
const record = (name: string, fields: Fields = {}) =>
write(name, { ...fields, jsMonoMs: String(now()) });
return {
record,
trace(context: Fields): DaemonClientTrace {
const client = String(++nextClient);
let sequence = 0;
const sections: { id: string; name: string; start: number }[] = [];
return {
isEnabled: () => true,
beginSection(name, fields) {
const id = `${client}:${++sequence}`;
record(`${name}.begin`, {
...context,
...fields,
client,
span: id,
parent: sections.at(-1)?.id ?? "",
});
sections.push({ id, name, start: now() });
},
endSection() {
const section = sections.pop();
if (section)
record(`${section.name}.end`, {
...context,
client,
span: section.id,
durationMs: String(now() - section.start),
});
},
};
},
render(sample: RenderSample) {
if (sample.actualDuration >= 16) {
record("react.render.slow", {
region: sample.id,
phase: sample.phase,
renderMs: String(sample.actualDuration),
startMs: String(sample.startTime),
commitMs: String(sample.commitTime),
});
}
// Dynamic agent/tab IDs cannot grow this map without bound.
const key = renders.has(sample.id) || renders.size < 128 ? sample.id : "other";
const summary = renders.get(key) ?? { count: 0, totalMs: 0, maxMs: 0 };
summary.count++;
summary.totalMs += sample.actualDuration;
summary.maxMs = Math.max(summary.maxMs, sample.actualDuration);
renders.set(key, summary);
},
flushRenders() {
for (const [region, summary] of renders) {
record("react.render.summary", {
region,
count: String(summary.count),
totalMs: String(summary.totalMs),
maxMs: String(summary.maxMs),
});
}
renders.clear();
},
};
}
+40
View File
@@ -2,6 +2,8 @@ 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";
import { build } from "esbuild";
// Set this before Vite resolves dependencies and Vitest starts its workers.
Object.assign(process.env, { NODE_ENV: "test" });
@@ -16,6 +18,43 @@ 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 };
},
},
{
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",
exclude: [...configDefaults.exclude, "e2e/**"],
@@ -82,6 +121,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",
],
+69 -32
View File
@@ -200,38 +200,75 @@ test("traces WebSocket frames, message types, and JSON parse duration", async ()
mock.triggerOpen({ preserveSent: true });
await connectPromise;
expect(recorder.records).toEqual([
{
phase: "begin",
name: "paseo.ws.message.outbound",
args: { envelopeType: "hello", messageType: "hello" },
},
{ phase: "end" },
{
phase: "begin",
name: "paseo.ws.frame.outbound",
args: { kind: "text", size: expect.any(String) },
},
{ phase: "end" },
{
phase: "begin",
name: "paseo.ws.frame.inbound",
args: { kind: "text", size: expect.any(String) },
},
{
phase: "begin",
name: "paseo.ws.json.parse",
args: { size: expect.any(String) },
},
{ phase: "end" },
{
phase: "begin",
name: "paseo.ws.message.inbound",
args: { envelopeType: "session", messageType: "status" },
},
{ phase: "end" },
{ phase: "end" },
]);
const response = client.fetchAgents({ requestId: "diagnostic-request", scope: "active" });
mock.triggerMessage(
wrapSessionMessage({
type: "fetch_agents_response",
payload: {
requestId: "diagnostic-request",
entries: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
},
}),
);
await response;
const spans = recorder.records.filter((record) => record.phase === "begin");
expect(spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "paseo.ws.frame.inbound",
args: {
kind: "text",
size: expect.any(String),
sizeUnit: "utf16-code-units",
connectionAttempt: "1",
},
}),
expect.objectContaining({ name: "paseo.ws.json.parse" }),
expect.objectContaining({ name: "paseo.ws.validate" }),
expect.objectContaining({
name: "paseo.ws.message.outbound",
args: {
envelopeType: "session",
messageType: "fetch_agents_request",
requestId: "diagnostic-request",
connectionAttempt: "1",
},
}),
expect.objectContaining({
name: "paseo.ws.message.inbound",
args: {
envelopeType: "session",
messageType: "fetch_agents_response",
requestId: "diagnostic-request",
connectionAttempt: "1",
},
}),
expect.objectContaining({
name: "paseo.ws.dispatch",
args: {
messageType: "fetch_agents_response",
requestId: "diagnostic-request",
connectionAttempt: "1",
},
}),
]),
);
const stack: string[] = [];
const parentage: [string, string | undefined][] = [];
for (const record of recorder.records) {
if (record.phase === "begin") {
parentage.push([record.name, stack.at(-1)]);
stack.push(record.name);
} else {
expect(stack.length).toBeGreaterThan(0);
stack.pop();
}
}
expect(stack).toEqual([]);
expect(parentage).toContainEqual(["paseo.ws.frame.outbound", "paseo.ws.message.outbound"]);
expect(parentage).toContainEqual(["paseo.ws.dispatch", "paseo.ws.frame.inbound"]);
expect(JSON.stringify(recorder.records)).not.toContain("trace_unit_test");
});
test("does not infer browser automation capabilities from Electron runtime", async () => {
+84 -21
View File
@@ -1147,19 +1147,34 @@ function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): voi
throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES);
}
function describeInboundTransportFrame(
frame: unknown,
rawBytes: Uint8Array | null,
): Record<string, string> {
function describeInboundTransportFrame(frame: unknown): Record<string, string> {
if (typeof frame === "string") {
return { kind: "text", size: String(frame.length) };
return { kind: "text", size: String(frame.length), sizeUnit: "utf16-code-units" };
}
if (rawBytes) {
return { kind: "binary", size: String(rawBytes.byteLength) };
if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) {
return { kind: "binary", size: String(frame.byteLength), sizeUnit: "bytes" };
}
return { kind: "unknown", size: "0" };
}
function describeRequestIdentity(value: unknown): Record<string, string> {
if (!value || typeof value !== "object") return {};
const message = "message" in value ? value.message : value;
if (!message || typeof message !== "object") return {};
if ("requestId" in message && typeof message.requestId === "string")
return { requestId: message.requestId };
if (
"payload" in message &&
message.payload &&
typeof message.payload === "object" &&
"requestId" in message.payload &&
typeof message.payload.requestId === "string"
) {
return { requestId: message.payload.requestId };
}
return {};
}
function hashForLog(value: string): string {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
@@ -1277,6 +1292,7 @@ export class DaemonClient {
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
private pendingGenericTransportErrorTimeout: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempt = 0;
private traceConnectionAttempt = 0;
private shouldReconnect = true;
private connectPromise: Promise<void> | null = null;
private connectResolve: (() => void) | null = null;
@@ -1429,6 +1445,7 @@ export class DaemonClient {
headers: selected.headers,
...(selected.protocols ? { protocols: selected.protocols } : {}),
});
this.traceConnectionAttempt += 1;
this.transport = transport;
this.lastServerInfoMessage = null;
this.authFailureReasonValue = null;
@@ -1457,6 +1474,7 @@ export class DaemonClient {
this.transportCleanup = [
transport.onOpen(() => {
this.traceInstant("paseo.connection.open");
if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout);
this.pendingGenericTransportErrorTimeout = null;
@@ -1465,6 +1483,11 @@ export class DaemonClient {
void this.sendHelloMessage();
}),
transport.onClose((event) => {
this.traceInstant("paseo.connection.close", {
code: String(
event && typeof event === "object" && "code" in event ? event.code : "unknown",
),
});
this.resetConnectTimeout();
if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout);
@@ -1484,6 +1507,7 @@ export class DaemonClient {
});
}),
transport.onError((event) => {
this.traceInstant("paseo.connection.error");
this.resetConnectTimeout();
const reason = describeTransportError(event);
const isGeneric = reason === "Transport error";
@@ -1755,7 +1779,7 @@ export class DaemonClient {
if (!trace?.isEnabled()) {
return false;
}
trace.beginSection(name, args);
trace.beginSection(name, { ...args, connectionAttempt: String(this.traceConnectionAttempt) });
return true;
}
@@ -1770,12 +1794,26 @@ export class DaemonClient {
this.endTraceSection(isOpen);
}
private beginSessionDispatchTrace(message: SessionOutboundMessage): boolean {
if (!this.config.trace?.isEnabled()) return false;
const fields = { messageType: message.type, ...describeRequestIdentity(message) };
this.traceInstant("paseo.ws.message.inbound", { envelopeType: "session", ...fields });
return this.beginTraceSection("paseo.ws.dispatch", fields);
}
private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void {
this.traceInstant("paseo.ws.message.outbound", {
envelopeType,
messageType,
});
this.sendTransportFrame(JSON.stringify(message));
const isOpen =
this.config.trace?.isEnabled() === true &&
this.beginTraceSection("paseo.ws.message.outbound", {
envelopeType,
messageType,
...describeRequestIdentity(message),
});
try {
this.sendTransportFrame(JSON.stringify(message));
} finally {
this.endTraceSection(isOpen);
}
}
private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void {
@@ -1787,6 +1825,7 @@ export class DaemonClient {
const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
kind: typeof frame === "string" ? "text" : "binary",
size: String(getTransportFrameSize(frame)),
sizeUnit: typeof frame === "string" ? "utf16-code-units" : "bytes",
});
try {
this.transport.send(frame);
@@ -6392,12 +6431,13 @@ export class DaemonClient {
return;
}
const rawBytes = asUint8Array(rawData);
const isOpen = this.beginTraceSection(
"paseo.ws.frame.inbound",
describeInboundTransportFrame(rawData, rawBytes),
describeInboundTransportFrame(rawData),
);
try {
// Record arrival before conversion: encoding a large text frame is JS work too.
const rawBytes = asUint8Array(rawData);
if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
return;
}
@@ -6417,17 +6457,26 @@ export class DaemonClient {
let parsedJson: unknown;
const parseTraceOpen = this.beginTraceSection("paseo.ws.json.parse", {
size: String(bytes),
sizeUnit: rawBytesLength === undefined ? "utf16-code-units" : "bytes",
});
try {
parsedJson = JSON.parse(payload);
} catch {
this.traceInstant("paseo.ws.json.invalid");
return;
} finally {
this.endTraceSection(parseTraceOpen);
}
const parsed = validateWSOutboundMessage(parsedJson);
const validationTrace = this.beginTraceSection("paseo.ws.validate");
let parsed: ReturnType<typeof validateWSOutboundMessage>;
try {
parsed = validateWSOutboundMessage(parsedJson);
} finally {
this.endTraceSection(validationTrace);
}
if (!parsed.success) {
this.traceInstant("paseo.ws.validation.failed");
const responseIdentity = extractCorrelatedResponseIdentity(parsedJson);
const envelopeType =
parsedJson != null &&
@@ -6460,6 +6509,7 @@ export class DaemonClient {
}
if (parsed.data.type === "hello.rejected") {
this.traceInstant("paseo.connection.rejected", { reason: parsed.data.reason });
const reasonMessage = {
password_required: "Password required",
incorrect_password: "Incorrect password",
@@ -6471,11 +6521,12 @@ export class DaemonClient {
return;
}
this.traceInstant("paseo.ws.message.inbound", {
envelopeType: "session",
messageType: parsed.data.message.type,
});
this.handleSessionMessage(parsed.data.message);
const dispatchTrace = this.beginSessionDispatchTrace(parsed.data.message);
try {
this.handleSessionMessage(parsed.data.message);
} finally {
this.endTraceSection(dispatchTrace);
}
const msgType = parsed.data.message.type;
this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs);
if (parsed.data.message.type === "agent_stream") {
@@ -6490,6 +6541,7 @@ export class DaemonClient {
envelopeType: "binary",
messageType: "file",
opcode: String(fileFrame.opcode),
requestId: fileFrame.requestId,
});
this.consecutiveLivenessFailures = 0;
this.handleFileTransferFrame(fileFrame);
@@ -6505,6 +6557,7 @@ export class DaemonClient {
envelopeType: "binary",
messageType: "terminal",
opcode: String(frame.opcode),
slot: String(frame.slot),
});
this.consecutiveLivenessFailures = 0;
const binaryStartMs = perfNow();
@@ -6623,6 +6676,12 @@ export class DaemonClient {
next.status === "disconnected" && typeof next.reason === "string" ? next.reason : null;
const reason = metadata?.reason ?? reasonFromNext;
const reasonCode = metadata?.reasonCode ?? toReasonCode(reason);
this.traceInstant("paseo.connection.state", {
from: previous.status,
to: next.status,
event: metadata?.event ?? "STATE_UPDATE",
reasonCode: reasonCode ?? "none",
});
this.logger.debug(
{
serverId: this.logServerId,
@@ -6722,6 +6781,10 @@ export class DaemonClient {
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
this.traceInstant("paseo.connection.reconnect", {
attempt: String(attempt),
delayMs: String(delay),
});
this.reconnectAttempt = attempt + 1;
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
@@ -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");
@@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "./tools/types.js";
export interface AgentProviderRuntime {
snapshotManager: ProviderSnapshotManager;
setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void;
setPaseoToolCatalog(catalog: Pick<PaseoToolCatalog, "tools"> | null): void;
shutdown(): Promise<void>;
}
@@ -0,0 +1,92 @@
import { expect, test } from "vitest";
import type { CanUseTool, PermissionMode, Query } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentPermissionRequest } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
// The injected SDK adapter supplies requests through Claude's permission callback.
async function createPlanApproval(mode: PermissionMode) {
let requestPermission: CanUseTool | undefined;
let sdkMode: PermissionMode = mode;
const query = Object.assign((async function* () {})(), {
async setPermissionMode(next: PermissionMode) {
sdkMode = next;
},
close() {},
});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude",
queryFactory: ({ options }) => {
requestPermission = options.canUseTool;
return query as Query;
},
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: mode,
});
let request: AgentPermissionRequest | undefined;
session.subscribe((event) => {
if (event.type === "permission_requested") request = event.request;
});
await session.setMode("plan");
if (!requestPermission) throw new Error("SDK permission callback was not installed");
const resolution = requestPermission(
"ExitPlanMode",
{ plan: "Implement the plan" },
{
signal: new AbortController().signal,
toolUseID: "plan-approval",
},
);
if (!request) throw new Error("No plan approval request was emitted");
return { session, request, resolution, sdkMode: () => sdkMode };
}
test.each([
["auto", "Implement with Auto mode"],
["bypassPermissions", "Implement with Bypass"],
] as const)("plan approval offers an explicit return to %s", async (mode, label) => {
const plan = await createPlanApproval(mode);
try {
expect(plan.request.actions).toContainEqual(
expect.objectContaining({
id: "implement_resume",
label,
behavior: "allow",
intent: "implement_resume",
}),
);
} finally {
await plan.session.respondToPermission(plan.request.id, { behavior: "deny" });
await plan.resolution;
await plan.session.close();
}
});
test.each([
["auto", "implement_resume", "auto"],
["bypassPermissions", "implement_resume", "bypassPermissions"],
["auto", "implement", "acceptEdits"],
["bypassPermissions", "implement", "acceptEdits"],
["default", "implement_resume", "acceptEdits"],
["acceptEdits", "implement_resume", "acceptEdits"],
] as const)("approving from %s with %s enters %s", async (mode, action, expected) => {
const plan = await createPlanApproval(mode);
try {
await plan.session.respondToPermission(plan.request.id, {
behavior: "allow",
selectedActionId: action,
});
await expect(plan.resolution).resolves.toMatchObject({ behavior: "allow" });
expect(plan.sdkMode()).toBe(expected);
expect(await plan.session.getCurrentMode()).toBe(expected);
if (mode === "default" || mode === "acceptEdits") {
expect(plan.request.actions?.some((entry) => entry.id === "implement_resume")).toBe(false);
}
} finally {
await plan.session.close();
}
});
@@ -1172,7 +1172,7 @@ function buildClaudePlanPermissionActions(
},
];
if (resumeMode === "bypassPermissions") {
if (resumeMode === "bypassPermissions" || resumeMode === "auto") {
actions.push({
id: "implement_resume",
label: `Implement with ${getClaudeModeLabel(resumeMode)}`,
@@ -2813,11 +2813,11 @@ class ClaudeAgentSession implements AgentSession {
if (response.behavior === "allow") {
if (pending.request.kind === "plan") {
const selectedActionId = response.selectedActionId;
const resumeMode = this.planResumeMode;
const shouldResumePriorMode =
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
const targetMode: PermissionMode = shouldResumePriorMode
? "bypassPermissions"
: "acceptEdits";
selectedActionId === "implement_resume" &&
(resumeMode === "bypassPermissions" || resumeMode === "auto");
const targetMode: PermissionMode = shouldResumePriorMode ? resumeMode : "acceptEdits";
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
@@ -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<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | 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<string, { description: string }>)
.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");
@@ -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<string, string>): {
}
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<void>> };
} = await import(
pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href
);
const tools = new Map<string, V2TestTool>();
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<unknown>;
}
interface V2TestContext {
@@ -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<PaseoToolCatalog, "tools"> | 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<PaseoToolCatalog, "tools"> | null): void {
this.manifestCatalog = catalog;
}
@@ -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<PaseoToolCatalog, "tools"> {
// 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(),
+4 -1
View File
@@ -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);
@@ -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({
@@ -0,0 +1,16 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs");
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])(
"%s makes schedule run retention discoverable",
async (file) => {
const markdown = await readFile(path.join(docsRoot, file), "utf8");
expect(markdown).toContain("Archive on finish");
if (file !== "schedules.md") {
expect(markdown).toContain("/docs/schedules#keep-run-workspaces");
}
},
);
@@ -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 {
+2
View File
@@ -155,6 +155,8 @@ Both use the same cron engine, but they have deliberately different interfaces.
MCP heartbeats are ephemeral: create or delete them. To change one, delete it and create a replacement. Pause, resume, update, inspect, logs, and run-once apply to new-agent schedules only.
New-agent run workspaces are archived when each run ends by default. `create_schedule` and `update_schedule` do not expose **Archive on finish** (`archiveOnFinish`); [change the option in the app](/docs/schedules#keep-run-workspaces) to keep subsequent run workspaces active.
### Agent profiles
| Tool | Function |
+2
View File
@@ -10,6 +10,8 @@ category: Schedules
`paseo schedule` creates and manages new-agent [schedules](/docs/schedules) from your terminal, useful for headless boxes and scripts. Every run starts a fresh agent.
Run workspaces are archived when each run ends by default. The CLI has no **Archive on finish** flag; [change the option in the app](/docs/schedules#keep-run-workspaces) to keep subsequent run workspaces active.
## Create
Overnight refactor on Codex:
+8
View File
@@ -35,3 +35,11 @@ Both run on a cadence you set. To start an agent from an external event instead
- **[From chat](/docs/schedules-chat)** — ask the agent in a chat and it sets the schedule up for you.
- **[From the CLI](/docs/schedules-cli)** — `paseo schedule create`, for headless boxes and scripts.
- **[Over MCP](/docs/mcp)** — agents create and manage schedules programmatically.
## Keep run workspaces
Each new-agent schedule run creates a workspace. **Archive on finish** is on by default: when the run ends, Paseo archives that workspace and its agent. With worktree isolation, archiving also removes the worktree after its last active workspace is archived.
To keep runs available, open the schedule in the app's Schedules view, turn off **Archive on finish**, and save. Paseo keeps each run's workspace and agent active until you archive them; runs that fail before creating an agent are still cleaned up. This option also applies to local isolation; archiving a local workspace leaves the project directory in place.
The stored option is `target.config.archiveOnFinish`, a boolean that defaults to `true` when omitted. The CLI and MCP schedule tools do not expose this option; use the app to change it. Heartbeats use an existing agent and do not have this option.
+2
View File
@@ -40,6 +40,8 @@ With a custom root, Paseo keeps the same hashed layout under that directory:
3. Review the diff against the base branch
4. Merge or archive the workspace; after the last workspace using it is archived, Paseo runs teardown and removes the worktree
Scheduled runs archive their workspaces when they finish by default. Turn off **Archive on finish** in the schedule's app form to [keep run workspaces](/docs/schedules#keep-run-workspaces) and their worktrees until you archive them.
## Create a worktree-backed workspace
The examples below use the current directory as the source checkout. Pass `--path ~/dev/my-app` to create the workspace from another checkout.
+52
View File
@@ -0,0 +1,52 @@
import { spawnSync, execFileSync } from "node:child_process";
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const target = process.argv[2] ?? "universal";
const architectures = {
universal: "armeabi-v7a,arm64-v8a,x86,x86_64",
arm64: "arm64-v8a",
}[target];
if (!architectures) {
throw new Error(`Unknown APK target: ${target}. Use universal or arm64.`);
}
const root = fileURLToPath(new URL("..", import.meta.url));
const app = path.join(root, "packages/app");
const env = {
...process.env,
APP_VARIANT: "production",
PASEO_WATCHDOG_BUILD: "1",
PASEO_DIAGNOSTIC_BUILD_TIME: new Date().toISOString(),
PASEO_DIAGNOSTIC_REVISION:
execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8" }).trim() +
(execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).trim()
? "-dirty"
: ""),
};
function run(command, args, cwd) {
const result = spawnSync(command, args, { cwd, env, stdio: "inherit" });
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
run("npm", ["run", "build:app-deps"], root);
run("npx", ["expo", "prebuild", "--platform", "android", "--no-install"], app);
// Gradle does not track Metro's environment flags. Always regenerate the JS artifact
// so a previous ordinary release cannot silently bypass the profiling renderer.
rmSync(path.join(app, "android/app/build/generated/assets"), { recursive: true, force: true });
run(
"./gradlew",
[
"assembleRelease",
"--no-daemon",
"--max-workers=2",
"-Dorg.gradle.parallel=false",
`-PreactNativeArchitectures=${architectures}`,
],
path.join(app, "android"),
);
const destination = path.join(root, `artifacts/paseo-dogfood-${target}.apk`);
mkdirSync(path.dirname(destination), { recursive: true });
copyFileSync(path.join(app, "android/app/build/outputs/apk/release/app-release.apk"), destination);
console.log(`\nDiagnostic release APK: ${destination}\nLogs: Downloads/Paseo diagnostics\n`);