mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 00:32:45 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e83384f138 | ||
|
|
98bc82f043 | ||
|
|
662e57541e | ||
|
|
ab6cdb7c89 | ||
|
|
0d61cca50c | ||
|
|
0eee4fd9b0 | ||
|
|
69ef741a73 | ||
|
|
90f3cab585 | ||
|
|
fc775b6db5 | ||
|
|
71e7cf6ff8 | ||
|
|
eda3e4318f | ||
|
|
91154ba4e8 | ||
|
|
05920a236c |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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, ... }:
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+77
@@ -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()
|
||||
}
|
||||
}
|
||||
+36
@@ -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) }
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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))
|
||||
}
|
||||
|
||||
+61
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+59
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: "جارٍ تحميل الإعدادات...",
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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: "設定を読み込み中...",
|
||||
|
||||
@@ -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: "설정 불러오는 중...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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: "Загрузка настроек...",
|
||||
|
||||
@@ -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: "正在加载设置...",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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">) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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("クレジット");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`);
|
||||
Reference in New Issue
Block a user