Compare commits

..
62 changed files with 401 additions and 2095 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. 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.
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.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
-15
View File
@@ -45,21 +45,6 @@
}
);
checks = forAllSystems (
system:
let
pkgs = pkgsFor system;
packages = self.packages.${system};
license = (builtins.fromJSON (builtins.readFile ./package.json)).license;
in
{
license-metadata =
assert packages.paseo.meta.license.spdxId == license;
assert packages.desktop.meta.license.spdxId == license;
pkgs.runCommand "paseo-license-metadata" { } "touch $out";
}
);
nixosModules.default = self.nixosModules.paseo;
nixosModules.paseo =
{ pkgs, lib, ... }:
+1 -1
View File
@@ -257,7 +257,7 @@ buildNpmPackage {
meta = {
description = "Paseo desktop app (Electron wrapper)";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.asl20;
license = lib.licenses.agpl3Plus;
mainProgram = "paseo-desktop";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
+1 -1
View File
@@ -148,7 +148,7 @@ buildNpmPackage rec {
meta = {
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
homepage = "https://github.com/getpaseo/paseo";
license = lib.licenses.asl20;
license = lib.licenses.agpl3Plus;
mainProgram = "paseo";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
@@ -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% used", { exact: true })).toBeVisible();
await expect(account.getByText("31%", { 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% used", { exact: true }),
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
).toBeVisible();
});
} finally {
+14 -99
View File
@@ -4,10 +4,9 @@ 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, openSettings } from "../support/helpers/app";
import { gotoAppShell } 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 {
@@ -132,7 +131,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% used", { exact: true })).toBeVisible();
await expect(group.getByText("41%", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
}
@@ -179,7 +178,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% used")).toBeVisible();
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -241,15 +240,15 @@ test.describe("usage modal", () => {
await gotoAppShell(page);
await openUsageFromItem(page);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
await expect(group.getByText("31%")).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% used")).toBeVisible();
await expect(group.getByText("12% used")).toBeVisible();
await expect(group.getByText("58%")).toBeVisible();
await expect(group.getByText("12%")).toBeVisible();
await group.getByTestId("usage-refresh").nth(1).hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
@@ -333,7 +332,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% used"),
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await qaScreenshot(page, "usage-modal-picked-host");
@@ -348,7 +347,7 @@ test.describe("usage modal", () => {
await expectPinnedUsage(page, ["12% Weekly"]);
await openUsageFromItem(page);
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
} finally {
await secondary.close().catch(() => undefined);
@@ -514,7 +513,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% used · resets /,
/^Pin Claude Session, 31% · resets /,
);
await openUsageOptions(page);
await closeUsageOptions(page);
@@ -577,7 +576,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
},
]);
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42% used");
await expectLoginUsageOnly(page, "42%");
} finally {
await fixture.cleanup();
}
@@ -600,7 +599,6 @@ 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 },
);
@@ -617,8 +615,7 @@ 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% used");
await advanceUsageClock(page, 60_000);
await expectLoginUsageOnly(page, "42%");
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
await fixture.setReport({ status: "error", error: "Rate limited" });
@@ -643,9 +640,8 @@ for (const compact of [false, true]) {
});
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43% used");
await advanceUsageClock(page, 60_000);
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
await expectLoginUsageOnly(page, "43%");
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
} finally {
await fixture.cleanup();
@@ -655,88 +651,7 @@ 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% used(?:$| ·)/)).toBeVisible();
await expect(card.getByText(/^42%(?:$| ·)/)).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% used")).toBeVisible();
await expect(card.getByText("70%")).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% used")).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
await expect(card.getByText("64% used")).toBeVisible();
await expect(card.getByText("64%")).toBeVisible();
});
test("asks to update a host without usage support and never calls it", async ({ page }) => {
@@ -0,0 +1,91 @@
package sh.paseo.diagnosis;
import android.content.Context;
import android.content.res.Configuration;
import android.text.Layout;
import android.text.Spannable;
import android.util.Log;
import android.view.View;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.common.mapbuffer.WritableMapBuffer;
import com.facebook.react.soloader.OpenSourceMergedSoMapping;
import com.facebook.react.uimanager.DisplayMetricsHolder;
import com.facebook.react.uimanager.PixelUtil;
import com.facebook.react.uimanager.ThemedReactContext;
import com.facebook.react.views.text.ReactTextUpdate;
import com.facebook.react.views.text.ReactTextView;
import com.facebook.react.views.text.TextLayoutManager;
import com.facebook.soloader.SoLoader;
import com.facebook.yoga.YogaMeasureMode;
import com.facebook.yoga.YogaMeasureOutput;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/** Native reproduction for #4368. Uses the measurement and rendering paths used by RN Text. */
@RunWith(AndroidJUnit4.class)
public class AboutFontWeightTest {
@Test public void ordinaryVersionFitsItsMeasuredBox() { checkVersion(0); }
@Test public void boldVersionFitsItsMeasuredBox() { checkVersion(300); }
private void checkVersion(int adjustment) {
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
Context app = InstrumentationRegistry.getInstrumentation().getTargetContext();
Configuration configuration = new Configuration(app.getResources().getConfiguration());
configuration.fontWeightAdjustment = adjustment;
Context configured = app.createConfigurationContext(configuration);
try {
SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE);
} catch (java.io.IOException error) {
throw new AssertionError("Could not initialize React Native", error);
}
DisplayMetricsHolder.initDisplayMetrics(configured);
ThemedReactContext context = new ThemedReactContext(new BridgeReactContext(configured), configured);
WritableMapBuffer attributes = new WritableMapBuffer();
attributes.put(4, 15.0); // TextAttributeProps.TA_KEY_FONT_SIZE
attributes.put(9, true); // TextAttributeProps.TA_KEY_ALLOW_FONT_SCALING
WritableMapBuffer fragment = new WritableMapBuffer();
fragment.put(0, "v0.12.0");
fragment.put(1, 1);
fragment.put(5, attributes);
WritableMapBuffer fragments = new WritableMapBuffer();
fragments.put(0, fragment);
WritableMapBuffer text = new WritableMapBuffer();
text.put(1, "v0.12.0");
text.put(2, fragments);
text.put(4, attributes);
WritableMapBuffer paragraph = new WritableMapBuffer();
paragraph.put(2, "highQuality");
paragraph.put(5, "none");
long measured = TextLayoutManager.measureText(context, text, paragraph,
1000, YogaMeasureMode.AT_MOST, 1000, YogaMeasureMode.AT_MOST, null, null);
int width = (int) Math.ceil(PixelUtil.toPixelFromDIP(YogaMeasureOutput.getWidth(measured)));
int height = (int) Math.ceil(PixelUtil.toPixelFromDIP(YogaMeasureOutput.getHeight(measured)));
Spannable content = TextLayoutManager.INSTANCE.getOrCreateSpannableForText(context, text, null);
ReactTextView view = new ReactTextView(context);
view.setId(2); // Fabric tag, matching the app's new-architecture Text path.
view.setText(new ReactTextUpdate(content, -1, false, 0,
Layout.BREAK_STRATEGY_HIGH_QUALITY, Layout.JUSTIFICATION_MODE_NONE));
view.measure(exact(width), exact(height));
view.layout(0, 0, width, height);
Layout rendered = view.getLayout();
Log.i("AboutFontWeightTest", "adjustment=" + adjustment + " box=" + width + "x" + height
+ " lines=" + rendered.getLineCount() + " lineWidth=" + rendered.getLineWidth(0)
+ " weight=" + view.getTypeface().getWeight());
assertEquals("The complete version should fit on its measured line", 1, rendered.getLineCount());
assertTrue("The last digit must remain inside the allocated width",
rendered.getLineWidth(0) <= width);
assertTrue("The complete line must remain inside the allocated height",
rendered.getLineBottom(0) <= height);
});
}
private static int exact(int size) {
return View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY);
}
}
@@ -0,0 +1,66 @@
# About version clipping reproduction ([#4368](https://github.com/getpaseo/paseo/issues/4368))
This is a diagnostic branch, with no production fix. The Android test compares
React Native's measured box for `v0.12.0` with the native TextView layout that
renders the same attributed text. It runs both ordinary and system-bold text.
It does not turn off accessibility scaling or bold text.
## Run the native test
From the repository root, prepare a development Android project if necessary:
```sh
cd packages/app
APP_VARIANT=development npx expo prebuild --platform android --no-install
```
In generated `android/app/build.gradle`, add:
```groovy
android.defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
dependencies {
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
}
```
Copy this test into the generated test source set, then build the app and tests:
```sh
mkdir -p android/app/src/androidTest/java/sh/paseo/diagnosis
cp e2e/mobile/about-font-size/AboutFontWeightTest.java android/app/src/androidTest/java/sh/paseo/diagnosis/
cd android
APP_VARIANT=development ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest \
-PreactNativeArchitectures=x86_64 --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
adb shell am instrument -w -e class sh.paseo.diagnosis.AboutFontWeightTest \
sh.paseo.debug.test/androidx.test.runner.AndroidJUnitRunner
```
The test requires Android 12 or later for `fontWeightAdjustment`. The native
contexts set that adjustment to 0 and 300 independently; they do not change the
device setting. The selected device's density and font scale still apply.
On Android 16 at 540 dpi and 2× font scale, both contexts receive a 301×123-pixel
box. The ordinary-weight control passes, with one rendered line. The bold test
fails with two rendered lines, so the final digit falls outside the allocated
single-line height. This isolates the measurement/rendering disagreement from
the About row's flex layout. A native dependency/build decision is still needed
before implementing a fix; changing an About-row style would leave the producer
of the incorrect measurement in place.
## UI comparison
Observed on Android 16, with current main's JavaScript and a freshly built native
development app: open Settings → About with 2× system font scale and 540 dpi on
a 1080-pixel-wide emulator. With system bold text, the displayed app version
loses its final digit (`v0.12.`). At the same display and text sizes without
system bold, the complete `v0.12.0` is visible. Cold-launch after configuration
changes before comparing; changing font scale in a running app also produced
temporary vertical clipping, which disappeared after a cold launch.
This substitutes standard Android accessibility settings for Samsung Easy Mode.
It verifies the truncated-version symptom, not Samsung's complete UI or the
already-fixed home-screen overlap. The reporter used Paseo 0.7.2; this branch
uses main `dd244c076d5f17d56d5592c7fb173dc7fdc2bbae`.
@@ -1,45 +0,0 @@
# 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.
@@ -1,71 +0,0 @@
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");
@@ -1,69 +0,0 @@
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>
);
}
@@ -12,7 +12,6 @@ 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 {
@@ -1,22 +0,0 @@
// 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,7 +11,6 @@ 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;
@@ -151,7 +150,7 @@ export function ContextWindowMeter({
showPercentage = false,
glyphSize,
}: ContextWindowMeterProps) {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const { width } = useWindowDimensions();
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
const showsUsage = useHostReportsUsage(serverId);
@@ -193,9 +192,7 @@ export function ContextWindowMeter({
);
const percentageLabel =
showPercentage && context ? (
<Text style={styles.percentageLabel}>
{formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
</Text>
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
) : null;
const accessibilityLabel = context
? t("contextWindow.accessibility", { percentage: context.percentage })
@@ -1,9 +0,0 @@
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,10 +1,3 @@
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,13 +1,11 @@
import { useLayoutEffect, useState } from "react";
import { useLayoutEffect } 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";
@@ -20,21 +18,13 @@ function advanceSharedRotation(): void {
requestAnimationFrame(advanceSharedRotation);
}
function registerStatusRing(
rotation: SharedValue<number>,
registered: SharedValue<boolean>,
listenerId: number,
): void {
function registerStatusRing(registered: SharedValue<boolean>): 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) {
@@ -44,31 +34,25 @@ function registerStatusRing(
}
}
function unregisterStatusRing(registered: SharedValue<boolean>, listenerId: number): void {
function unregisterStatusRing(registered: SharedValue<boolean>): 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(() => {
if (!active) return;
scheduleOnUI(registerStatusRing, rotation, registered, listenerId);
scheduleOnUI(registerStatusRing, registered);
return () => {
scheduleOnUI(unregisterStatusRing, registered, listenerId);
scheduleOnUI(unregisterStatusRing, registered);
};
}, [active, listenerId, registered, rotation]);
}, [registered]);
return rotation;
return sharedRotation;
}
@@ -250,54 +250,6 @@ describe("resident browser webviews", () => {
]);
});
it("registers a browser when DOM readiness follows an attachment before method readiness", () => {
const webview = ensureTestBrowser({
browserId: "browser-not-ready-at-attachment",
workspaceId: "workspace-a",
url: "https://example.com",
});
if (!webview) {
throw new Error("Expected resident webview");
}
// Model Electron's method-readiness boundary. This event order is a
// hypothesis for #7348, not an observation from the packaged desktop.
let ready = false;
Object.assign(webview, {
getWebContentsId: () => {
if (!ready) {
throw new Error(
"The WebView must be attached to the DOM and the dom-ready event emitted before this method can be called.",
);
}
return 303;
},
});
const attachmentErrors: string[] = [];
const recordAttachmentError = (event: ErrorEvent) => {
attachmentErrors.push(event.message);
event.preventDefault();
};
window.addEventListener("error", recordAttachmentError);
try {
webview.dispatchEvent(new Event("did-attach"));
expect(attachedBrowsers).toEqual([]);
ready = true;
webview.dispatchEvent(new Event("dom-ready"));
expect(attachedBrowsers).toEqual([
{
browserId: "browser-not-ready-at-attachment",
workspaceId: "workspace-a",
webContentsId: 303,
},
]);
expect(attachmentErrors).toEqual([]);
} finally {
window.removeEventListener("error", recordAttachmentError);
}
});
it("normalizes an existing resident host back to permanent parking", () => {
const staleHost = document.createElement("div");
staleHost.id = RESIDENT_HOST_ID;
@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import type {
CheckoutPipelineJob,
CheckoutPrStatusResponse,
@@ -780,11 +780,6 @@ 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");
@@ -1,92 +0,0 @@
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",
});
});
});
+31 -60
View File
@@ -1,70 +1,41 @@
import { createInstance, type BackendModule } from "i18next";
import { createInstance } from "i18next";
import { initReactI18next } from "react-i18next";
import { observeI18nInit } from "./init";
import { ar } from "./resources/ar";
import { en } from "./resources/en";
// 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);
}
},
};
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";
const i18n = createInstance();
observeI18nInit(
i18n
.use(bundledTranslations)
.use(initReactI18next)
.init({
compatibilityJSON: "v4",
fallbackLng: "en",
lng: "en",
partialBundledLanguages: true,
resources: {
en: { translation: en },
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
}),
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,
},
}),
);
export { i18n };
+9 -42
View File
@@ -20,30 +20,6 @@ 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 };
@@ -168,24 +144,15 @@ function findUntranslatedConnectionErrors(): string[] {
describe("translation resources", () => {
it("keeps all supported language keys in sync with English", () => {
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");
}
}
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);
});
it("keeps non-English supported languages translated beyond fallback labels", () => {
-100
View File
@@ -2046,106 +2046,6 @@ 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: "جارٍ تحميل الإعدادات...",
+1 -79
View File
@@ -2071,70 +2071,6 @@ 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...",
@@ -3037,18 +2973,4 @@ type WidenStringLeaves<T> = {
[K in keyof T]: T[K] extends string ? string : WidenStringLeaves<T[K]>;
};
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>>;
};
};
export type TranslationResources = WidenStringLeaves<typeof en>;
-64
View File
@@ -2094,70 +2094,6 @@ export const es: TranslationResources = {
saveAccessibility: "Guardar comentario de revisión",
},
},
providerUsage: {
options: "Configuración",
refreshFailed: "No se pudo actualizar el uso",
noHosts: "No hay hosts conectados",
agentError: "No se pudo cargar el uso: {{reason}}",
pin: "Fijar",
unpin: "Desfijar",
displayAs: "Porcentajes",
displayUsed: "Usado",
displayRemaining: "Restante",
showInSidebar: "Resumen en la barra lateral",
showInSidebarHint: "Los períodos fijados aparecen al pie de la barra lateral",
runToRefresh: "Ejecuta {{command}} para actualizar.",
signInAgain: "Vuelve a iniciar sesión.",
loginRejected: "Inicio de sesión rechazado (HTTP {{status}}). {{remedy}}",
loginExpired: "La sesión caducó {{relativeTime}}. {{remedy}}",
title: "Uso del plan",
refresh: "Actualizar",
refreshAll: "Actualizar todo",
refreshing: "Actualizando...",
loading: "Cargando uso...",
empty: "No hay datos de uso",
errorTitle: "No se pudo cargar el uso",
hostUnavailable: "Conéctate a {{host}} para ver el uso del proveedor",
hostUpgradeRequired: "Actualiza {{host}} para ver el uso del proveedor",
clientUnavailable: "La conexión con el host no está lista",
retry: "Intentar de nuevo",
tooltipLoading: "Cargando uso del plan...",
status: {
error: "Error",
unavailable: "No disponible",
},
values: {
used: "Usado: {{percentage}}",
remaining: "Quedan {{amount}}",
},
labels: {
session: "Sesión",
weekly: "Semanal",
monthly: "Mensual",
codeReview: "Revisión de código",
credits: "Créditos",
monthlyCredits: "Créditos mensuales",
},
duration: {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
},
timing: {
lastUpdated: "Última actualización {{relativeTime}}",
noSuccessfulUpdate: "No hay actualizaciones correctas",
resetTimePassed: "Pasó la hora de restablecimiento",
resettingNow: "se restablece ahora",
resetsIn: "se restablece en {{duration}}",
runsOutNow: "se agota ahora",
runsOutIn: "se agota en {{duration}}",
justNow: "ahora mismo",
daysAgo: "hace {{value}} d",
hoursAgo: "hace {{value}} h",
minutesAgo: "hace {{value}} min",
updated: "Actualizado {{relativeTime}}",
},
},
settings: {
title: "Ajustes",
loading: "Cargando configuración...",
-64
View File
@@ -2094,70 +2094,6 @@ export const fr: TranslationResources = {
saveAccessibility: "Enregistrer le commentaire de revue",
},
},
providerUsage: {
options: "Paramètres",
refreshFailed: "Impossible d’actualiser l’utilisation",
noHosts: "Aucun hôte connecté",
agentError: "Impossible de charger l’utilisation : {{reason}}",
pin: "Épingler",
unpin: "Désépingler",
displayAs: "Pourcentages",
displayUsed: "Utilisé",
displayRemaining: "Restant",
showInSidebar: "Résumé dans la barre latérale",
showInSidebarHint: "Les périodes épinglées apparaissent en bas de la barre latérale",
runToRefresh: "Exécutez {{command}} pour actualiser.",
signInAgain: "Reconnectez-vous.",
loginRejected: "Connexion refusée (HTTP {{status}}). {{remedy}}",
loginExpired: "Connexion expirée {{relativeTime}}. {{remedy}}",
title: "Utilisation du forfait",
refresh: "Actualiser",
refreshAll: "Tout actualiser",
refreshing: "Actualisation...",
loading: "Chargement de l’utilisation...",
empty: "Aucune donnée d’utilisation",
errorTitle: "Impossible de charger l’utilisation",
hostUnavailable: "Connectez-vous à {{host}} pour voir l’utilisation du fournisseur",
hostUpgradeRequired: "Mettez à jour {{host}} pour voir l’utilisation du fournisseur",
clientUnavailable: "La connexion à l’hôte n’est pas prête",
retry: "Réessayer",
tooltipLoading: "Chargement de l’utilisation du forfait...",
status: {
error: "Erreur",
unavailable: "Indisponible",
},
values: {
used: "Utilisé : {{percentage}}",
remaining: "Il reste {{amount}}",
},
labels: {
session: "Session",
weekly: "Hebdomadaire",
monthly: "Mensuel",
codeReview: "Révision de code",
credits: "Crédits",
monthlyCredits: "Crédits mensuels",
},
duration: {
days: "{{value}} j",
hours: "{{value}} h",
minutes: "{{value}} min",
},
timing: {
lastUpdated: "Dernière mise à jour {{relativeTime}}",
noSuccessfulUpdate: "Aucune mise à jour réussie",
resetTimePassed: "Heure de réinitialisation dépassée",
resettingNow: "réinitialisation en cours",
resetsIn: "se réinitialise dans {{duration}}",
runsOutNow: "s’épuise maintenant",
runsOutIn: "s’épuise dans {{duration}}",
justNow: "à l’instant",
daysAgo: "il y a {{value}} j",
hoursAgo: "il y a {{value}} h",
minutesAgo: "il y a {{value}} min",
updated: "Mis à jour {{relativeTime}}",
},
},
settings: {
title: "Paramètres",
loading: "Chargement des paramètres…",
-64
View File
@@ -2067,70 +2067,6 @@ export const ja: TranslationResources = {
saveAccessibility: "レビューコメントを保存",
},
},
providerUsage: {
options: "設定",
refreshFailed: "使用状況を更新できません",
noHosts: "接続中のホストがありません",
agentError: "使用状況を読み込めません: {{reason}}",
pin: "ピン留め",
unpin: "ピン留めを解除",
displayAs: "割合",
displayUsed: "使用済み",
displayRemaining: "残り",
showInSidebar: "サイドバーに概要を表示",
showInSidebarHint: "ピン留めした期間をサイドバーの下部に表示します",
runToRefresh: "{{command}} を実行して更新してください。",
signInAgain: "再度サインインしてください。",
loginRejected: "ログインが拒否されました (HTTP {{status}})。{{remedy}}",
loginExpired: "ログインは{{relativeTime}}に期限切れになりました。{{remedy}}",
title: "プラン使用量",
refresh: "更新",
refreshAll: "すべて更新",
refreshing: "更新中...",
loading: "使用状況を読み込み中...",
empty: "使用状況データがありません",
errorTitle: "使用状況を読み込めません",
hostUnavailable: "{{host}} に接続してプロバイダーの使用状況を確認してください",
hostUpgradeRequired: "{{host}} を更新してプロバイダーの使用状況を確認してください",
clientUnavailable: "ホスト接続の準備ができていません",
retry: "再試行",
tooltipLoading: "プラン使用状況を読み込み中...",
status: {
error: "エラー",
unavailable: "利用不可",
},
values: {
used: "{{percentage}} 使用済み",
remaining: "残り {{amount}}",
},
labels: {
session: "セッション",
weekly: "週間",
monthly: "月間",
codeReview: "コードレビュー",
credits: "クレジット",
monthlyCredits: "月間クレジット",
},
duration: {
days: "{{value}}日",
hours: "{{value}}時間",
minutes: "{{value}}分",
},
timing: {
lastUpdated: "最終更新: {{relativeTime}}",
noSuccessfulUpdate: "正常な更新はまだありません",
resetTimePassed: "リセット時刻を過ぎています",
resettingNow: "まもなくリセット",
resetsIn: "{{duration}}後にリセット",
runsOutNow: "まもなく上限に到達",
runsOutIn: "{{duration}}後に上限に到達",
justNow: "たった今",
daysAgo: "{{value}}日前",
hoursAgo: "{{value}}時間前",
minutesAgo: "{{value}}分前",
updated: "更新: {{relativeTime}}",
},
},
settings: {
title: "設定",
loading: "設定を読み込み中...",
-64
View File
@@ -2056,70 +2056,6 @@ export const ko: TranslationResources = {
saveAccessibility: "리뷰 댓글 저장",
},
},
providerUsage: {
options: "설정",
refreshFailed: "사용량을 새로 고칠 수 없습니다",
noHosts: "연결된 호스트가 없습니다",
agentError: "사용량을 불러올 수 없습니다: {{reason}}",
pin: "고정",
unpin: "고정 해제",
displayAs: "백분율",
displayUsed: "사용됨",
displayRemaining: "남음",
showInSidebar: "사이드바에 요약 표시",
showInSidebarHint: "고정된 기간이 사이드바 하단에 표시됩니다",
runToRefresh: "{{command}} 명령을 실행하여 새로 고치세요.",
signInAgain: "다시 로그인하세요.",
loginRejected: "로그인이 거부되었습니다 (HTTP {{status}}). {{remedy}}",
loginExpired: "로그인이 {{relativeTime}}에 만료되었습니다. {{remedy}}",
title: "플랜 사용량",
refresh: "새로 고침",
refreshAll: "모두 새로 고침",
refreshing: "새로 고치는 중...",
loading: "사용량 불러오는 중...",
empty: "사용량 데이터가 없습니다",
errorTitle: "사용량을 불러올 수 없습니다",
hostUnavailable: "{{host}}에 연결하여 제공업체 사용량을 확인하세요",
hostUpgradeRequired: "{{host}}를 업데이트하여 제공업체 사용량을 확인하세요",
clientUnavailable: "호스트 연결이 준비되지 않았습니다",
retry: "다시 시도",
tooltipLoading: "플랜 사용량 불러오는 중...",
status: {
error: "오류",
unavailable: "사용할 수 없음",
},
values: {
used: "{{percentage}} 사용됨",
remaining: "{{amount}} 남음",
},
labels: {
session: "세션",
weekly: "주간",
monthly: "월간",
codeReview: "코드 검토",
credits: "크레딧",
monthlyCredits: "월간 크레딧",
},
duration: {
days: "{{value}}일",
hours: "{{value}}시간",
minutes: "{{value}}분",
},
timing: {
lastUpdated: "마지막 업데이트: {{relativeTime}}",
noSuccessfulUpdate: "성공한 업데이트가 없습니다",
resetTimePassed: "초기화 시간이 지났습니다",
resettingNow: "지금 재설정 중",
resetsIn: "{{duration}} 후 재설정",
runsOutNow: "지금 한도에 도달",
runsOutIn: "{{duration}} 후 한도에 도달",
justNow: "방금",
daysAgo: "{{value}}일 전",
hoursAgo: "{{value}}시간 전",
minutesAgo: "{{value}}분 전",
updated: "{{relativeTime}}에 업데이트됨",
},
},
settings: {
title: "설정",
loading: "설정 불러오는 중...",
-64
View File
@@ -2079,70 +2079,6 @@ export const ptBR: TranslationResources = {
saveAccessibility: "Salvar comentário de revisão",
},
},
providerUsage: {
options: "Configurações",
refreshFailed: "Não foi possível atualizar o uso",
noHosts: "Nenhum host conectado",
agentError: "Não foi possível carregar o uso: {{reason}}",
pin: "Fixar",
unpin: "Desafixar",
displayAs: "Porcentagens",
displayUsed: "Usado",
displayRemaining: "Restante",
showInSidebar: "Resumo na barra lateral",
showInSidebarHint: "Os períodos fixados aparecem no rodapé da barra lateral",
runToRefresh: "Execute {{command}} para atualizar.",
signInAgain: "Entre novamente.",
loginRejected: "Login rejeitado (HTTP {{status}}). {{remedy}}",
loginExpired: "O login expirou {{relativeTime}}. {{remedy}}",
title: "Uso do plano",
refresh: "Atualizar",
refreshAll: "Atualizar tudo",
refreshing: "Atualizando...",
loading: "Carregando uso...",
empty: "Sem dados de uso",
errorTitle: "Não foi possível carregar o uso",
hostUnavailable: "Conecte-se a {{host}} para ver o uso do provedor",
hostUpgradeRequired: "Atualize {{host}} para ver o uso do provedor",
clientUnavailable: "A conexão com o host não está pronta",
retry: "Tentar novamente",
tooltipLoading: "Carregando uso do plano...",
status: {
error: "Erro",
unavailable: "Indisponível",
},
values: {
used: "{{percentage}} usado",
remaining: "{{amount}} restantes",
},
labels: {
session: "Sessão",
weekly: "Semanal",
monthly: "Mensal",
codeReview: "Revisão de código",
credits: "Créditos",
monthlyCredits: "Créditos mensais",
},
duration: {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
},
timing: {
lastUpdated: "Última atualização {{relativeTime}}",
noSuccessfulUpdate: "Nenhuma atualização bem-sucedida",
resetTimePassed: "O horário de redefinição passou",
resettingNow: "redefinindo agora",
resetsIn: "redefine em {{duration}}",
runsOutNow: "esgota agora",
runsOutIn: "esgota em {{duration}}",
justNow: "agora mesmo",
daysAgo: "há {{value}} d",
hoursAgo: "há {{value}} h",
minutesAgo: "há {{value}} min",
updated: "Atualizado {{relativeTime}}",
},
},
settings: {
title: "Configurações",
loading: "Carregando configurações...",
-64
View File
@@ -2079,70 +2079,6 @@ export const ru: TranslationResources = {
saveAccessibility: "Сохранить комментарий к ревью",
},
},
providerUsage: {
options: "Настройки",
refreshFailed: "Не удалось обновить использование",
noHosts: "Нет подключённых хостов",
agentError: "Не удалось загрузить использование: {{reason}}",
pin: "Закрепить",
unpin: "Открепить",
displayAs: "Проценты",
displayUsed: "Использовано",
displayRemaining: "Осталось",
showInSidebar: "Сводка на боковой панели",
showInSidebarHint: "Закреплённые периоды отображаются внизу боковой панели",
runToRefresh: "Выполните {{command}} для обновления.",
signInAgain: "Войдите снова.",
loginRejected: "Вход отклонён (HTTP {{status}}). {{remedy}}",
loginExpired: "Срок входа истёк {{relativeTime}}. {{remedy}}",
title: "Использование плана",
refresh: "Обновить",
refreshAll: "Обновить всё",
refreshing: "Обновление...",
loading: "Загрузка данных об использовании...",
empty: "Нет данных об использовании",
errorTitle: "Не удалось загрузить данные об использовании",
hostUnavailable: "Подключитесь к {{host}}, чтобы увидеть использование провайдера",
hostUpgradeRequired: "Обновите {{host}}, чтобы увидеть использование провайдера",
clientUnavailable: "Соединение с хостом не готово",
retry: "Повторить",
tooltipLoading: "Загрузка использования плана...",
status: {
error: "Ошибка",
unavailable: "Недоступно",
},
values: {
used: "Использовано: {{percentage}}",
remaining: "Осталось: {{amount}}",
},
labels: {
session: "Сессия",
weekly: "Еженедельно",
monthly: "Ежемесячно",
codeReview: "Проверка кода",
credits: "Кредиты",
monthlyCredits: "Ежемесячные кредиты",
},
duration: {
days: "{{value}} дн.",
hours: "{{value}} ч",
minutes: "{{value}} мин",
},
timing: {
lastUpdated: "Последнее обновление {{relativeTime}}",
noSuccessfulUpdate: "Успешных обновлений нет",
resetTimePassed: "Время сброса прошло",
resettingNow: "сброс сейчас",
resetsIn: "сброс через {{duration}}",
runsOutNow: "закончится сейчас",
runsOutIn: "закончится через {{duration}}",
justNow: "только что",
daysAgo: "{{value}} дн. назад",
hoursAgo: "{{value}} ч назад",
minutesAgo: "{{value}} мин назад",
updated: "Обновлено {{relativeTime}}",
},
},
settings: {
title: "Настройки",
loading: "Загрузка настроек...",
-64
View File
@@ -2024,70 +2024,6 @@ 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: "正在加载设置...",
+2 -3
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { UsageCard } from "./card";
import { usageCopy } from "./copy";
import { useUsageDisplay } from "./display";
import { useAgentUsage } from "./queries";
import type { UsageReportEntry } from "./types";
@@ -21,7 +21,6 @@ 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 (
@@ -36,7 +35,7 @@ export function AgentUsage({
/>
) : (
<Text style={styles.message}>
{view.kind === "loading" ? t("providerUsage.loading") : view.message}
{view.kind === "loading" ? usageCopy.loading : view.message}
</Text>
)}
</>
+11 -37
View File
@@ -1,26 +1,16 @@
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, formatProviderUsageLabel, formatResetLabel } from "./format";
import { clampPct, formatAmount, 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,
t,
locale,
}: {
balance: UsageBalance;
t: TFunction;
locale?: string;
}): ResolvedBalance {
function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance {
const { used, remaining, limit, unit } = balance;
const format = (value: number) => formatAmount(value, unit, locale);
if (limit != null && limit > 0) {
@@ -30,10 +20,7 @@ function resolveBalance({
return { amountText: `${usedText} / ${format(limit)}`, usedPct };
}
if (remaining != null) {
return {
amountText: t("providerUsage.values.remaining", { amount: format(remaining) }),
usedPct: null,
};
return { amountText: `${format(remaining)} left`, usedPct: null };
}
if (used != null) {
return { amountText: format(used), usedPct: null };
@@ -61,11 +48,10 @@ export function UsageBalanceBar({
balance: UsageBalance;
retained?: boolean;
}) {
const { t, i18n } = useTranslation();
const now = useRelativeTimeTick({ enabled: balance.resetsAt != null });
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
const { i18n } = useTranslation();
const { amountText, usedPct } = resolveBalance(balance, i18n.language);
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
const resetLabel = formatResetLabel(balance.resetsAt, retained);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
@@ -76,18 +62,12 @@ export function UsageBalanceBar({
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{formatProviderUsageLabel(balance.id, balance.label, t)}
{balance.label}
</Text>
<Text style={styles.value}>
{amountText}
{resetLabel ? <Text style={styles.reset}>{` · ${resetLabel}`}</Text> : null}
</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}>
@@ -114,19 +94,13 @@ 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,
@@ -1,53 +0,0 @@
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("クレジット");
});
+12 -26
View File
@@ -1,6 +1,5 @@
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 {
@@ -13,22 +12,19 @@ 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 type { TFunction } from "i18next";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
import { UsageBalanceBar } from "./balance-bar";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { formatAgo, formatProviderUsageLabel } from "./format";
import { resolveUsageReport, type UsageRefresh } from "./model";
import { formatUsageFreshness, 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, t: TFunction): string | null {
function statusText(report: UsageReport): string | null {
if (report.status === "available") return null;
return report.status === "error"
? t("providerUsage.status.error")
: t("providerUsage.status.unavailable");
return report.status === "error" ? "Error" : "Unavailable";
}
const ThemedRotateCw = withUnistyles(RotateCw);
@@ -53,16 +49,15 @@ 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, t);
const status = statusText(usage);
const footer = entry.account.label ?? null;
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
resolveUsageReport(entry, t);
resolveUsageReport(entry);
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
@@ -161,7 +156,7 @@ export function UsageCard({
{refreshState === "failed" ? (
<Text style={styles.error} testID="usage-refresh-error">
{t("providerUsage.refreshFailed")}
{usageCopy.refreshFailed}
</Text>
) : null}
</View>
@@ -181,7 +176,6 @@ function CardWindowBar({
display: UsageDisplay;
pinnable: boolean;
}) {
const { t } = useTranslation();
const pin = useMemo(
() => ({ sourceId: entry.sourceId, windowId: window.id }),
[entry.sourceId, window.id],
@@ -196,7 +190,7 @@ function CardWindowBar({
pinnable={pinnable}
pinned={display.isPinned(pin)}
onTogglePin={toggle}
pinLabel={`${t("providerUsage.pin")} ${entry.sourceLabel} ${formatProviderUsageLabel(window.id, window.label, t)}`}
pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`}
pinTestID={`usage-pin-${entry.sourceId}-${window.id}`}
/>
);
@@ -218,7 +212,6 @@ function UsageRefreshButton({
onRefresh: () => void;
compact: boolean;
}) {
const { t } = useTranslation();
const isPending = refreshState === "pending";
const iconSize = paneContentToolbarIconSize(compact);
const freshness = useMemo(
@@ -234,7 +227,7 @@ function UsageRefreshButton({
);
return (
<ToolbarButton
label={`${t("providerUsage.refresh")} ${sourceLabel}`}
label={`${usageCopy.refresh} ${sourceLabel}`}
tooltip={freshness}
tooltipSide="top"
compact={compact}
@@ -264,17 +257,10 @@ function UsageFreshness({
style: StyleProp<TextStyle>;
testID: string;
}) {
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");
const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null);
return (
<Text style={style} numberOfLines={1} testID={testID}>
{freshness}
{successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"}
</Text>
);
}
+33 -16
View File
@@ -1,23 +1,40 @@
import type { TFunction } from "i18next";
import type { UsageProblem } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { formatAgo } from "./format";
import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time";
// Messages for usage failures outside the UI. Rendered copy uses useTranslation.
// User-facing copy for the usage surfaces, kept in one file so localization is a
// single-file change.
export const usageCopy = {
problem: (problem: UsageProblem, now: Date = new Date(), t: TFunction = i18n.t): string => {
problem: (problem: UsageProblem, now: Date = new Date()): string => {
if (problem.kind === "no_quota") return problem.detail;
const remedy = problem.refreshedBy
? 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");
? `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}`;
},
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;
+2 -112
View File
@@ -1,25 +1,7 @@
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");
import { describe, expect, it } from "vitest";
import { formatAmount, formatResetLabel } from "./format";
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");
@@ -30,68 +12,6 @@ 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", () => {
@@ -100,33 +20,3 @@ 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",
);
});
+18 -134
View File
@@ -1,163 +1,47 @@
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatTokenCount } from "@/components/context-window-meter.utils";
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, locale?: string): string {
const resolvedLocale = locale ?? i18n.resolvedLanguage;
return new Intl.NumberFormat(resolvedLocale, {
style: "percent",
maximumFractionDigits: 0,
}).format(clampPct(value) / 100);
export function formatPct(value: number): string {
return `${Math.round(clampPct(value))}%`;
}
/** 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 });
/** "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`;
}
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;
function relativeDuration(iso: string): string | null {
const diffMs = new Date(iso).getTime() - Date.now();
if (!Number.isFinite(diffMs)) return null;
if (diffMs <= 0) return { unit: "now" };
if (diffMs <= 0) return "now";
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return { unit: "days", count: diffDays };
if (diffHours > 0) return { unit: "hours", count: diffHours };
return { unit: "minutes", count: diffMinutes };
if (diffDays > 0) return `${diffDays}d`;
if (diffHours > 0) return `${diffHours}h`;
return `${diffMinutes}m`;
}
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 {
export function formatResetLabel(iso: string | null | undefined, retained = false): string | null {
if (!iso) return null;
const rel = relativeDuration(iso, now);
const rel = relativeDuration(iso);
if (!rel) return null;
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),
});
if (rel === "now" && retained) return "Reset time passed";
return rel === "now" ? "resetting now" : `resets ${rel}`;
}
/** 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 new Intl.NumberFormat(locale, {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
return formatTokenCount(value);
default:
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
}
@@ -1,17 +1,16 @@
import { useTranslation } from "react-i18next";
import { usageCopy } from "./copy";
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={t("providerUsage.title")}
title={usageCopy.planUsage}
view={view}
display={display}
onRefresh={refresh}
+15 -25
View File
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { describe, expect, it } from "vitest";
import {
resolveUsageReport,
displayPercent,
formatUsageFreshness,
resolveAgentUsageView,
replaceReport,
resolveUsageRefresh,
@@ -65,18 +65,6 @@ 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" });
@@ -92,10 +80,6 @@ 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 },
@@ -201,6 +185,14 @@ 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");
@@ -346,7 +338,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, i18n.t)).toEqual({
expect(resolveUsageReport(failure)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
@@ -355,7 +347,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
retained: true,
messages: ["Rate limited"],
});
expect(resolveUsageReport({ ...failure, lastSuccessful: undefined }, i18n.t)).toEqual({
expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({
windows: [],
balances: [],
details: [],
@@ -364,7 +356,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
retained: false,
messages: ["Rate limited"],
});
expect(resolveUsageReport(success, i18n.t)).toEqual({
expect(resolveUsageReport(success)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
@@ -392,7 +384,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, i18n.t).successfulAt).toBe("2026-01-01T00:00:15.000Z");
expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z");
const failure: UsageReportEntry = {
...success,
successfulAt: undefined,
@@ -400,8 +392,6 @@ 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, i18n.t).successfulAt).toBe(
resolveUsageReport(success, i18n.t).successfulAt,
);
expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt);
expect(upsertReport([failure], success)).toEqual([failure]);
});
+24 -35
View File
@@ -1,5 +1,4 @@
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatCompactTimeAgoAsProse } from "@/utils/time";
import { usageCopy } from "./copy";
import type { UsageDisplayAs } from "./preferences";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
@@ -32,6 +31,11 @@ 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";
@@ -104,26 +108,16 @@ export interface UsageQueryState {
isFetching: boolean;
}
export function resolveUsageView(
input: {
hostLabel: string;
isConnected: boolean;
supportsUsage: boolean;
query: UsageQueryState | undefined;
},
t: TFunction = i18n.t,
): UsageView {
export function resolveUsageView(input: {
hostLabel: string;
isConnected: boolean;
supportsUsage: boolean;
query: UsageQueryState | undefined;
}): UsageView {
const { hostLabel, isConnected, supportsUsage, query } = input;
if (!isConnected)
return {
kind: "unavailable",
message: t("providerUsage.hostUnavailable", { host: hostLabel }),
};
if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) };
if (!supportsUsage) {
return {
kind: "unavailable",
message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
};
return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) };
}
if (query?.data) {
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
@@ -144,20 +138,17 @@ export type AgentUsageView =
| { kind: "error"; message: string }
| { kind: "ready"; reports: UsageReportEntry[] };
export function resolveAgentUsageView(
input: {
canReport: boolean;
query: UsageQueryState;
},
t: TFunction = i18n.t,
): AgentUsageView {
export function resolveAgentUsageView(input: {
canReport: boolean;
query: UsageQueryState;
}): 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: t("providerUsage.agentError", { reason }) };
return { kind: "error", message: usageCopy.agentError(reason) };
}
if (query.data) {
return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data };
@@ -203,7 +194,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, t: TFunction) {
export function resolveUsageReport(entry: UsageReportEntry) {
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.
@@ -213,12 +204,10 @@ export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
if (!available) {
if (entry.loginErrors)
messages.push(
...entry.loginErrors.map(
(login) => `${login.harness}: ${usageProblemText(login.report, t)}`,
),
...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`),
);
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
else messages.push(usageProblemText(entry.report, t));
else messages.push(usageProblemText(entry.report));
}
return {
windows: content?.windows ?? [],
@@ -235,8 +224,8 @@ export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
};
}
function usageProblemText(report: UsageReport, t: TFunction): string {
function usageProblemText(report: UsageReport): string {
if (report.status === "error") return report.error;
if (report.status === "unavailable") return usageCopy.problem(report.problem, new Date(), t);
if (report.status === "unavailable") return usageCopy.problem(report.problem);
return "";
}
+12 -16
View File
@@ -1,4 +1,3 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Settings } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
@@ -9,23 +8,20 @@ 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();
@@ -33,7 +29,7 @@ export function UsageOptions() {
<DropdownMenu compactMode="sheet">
<ToolbarButton
kind="menu"
label={t("providerUsage.options")}
label={usageCopy.options}
compact={compact}
testID="usage-options-menu"
>
@@ -45,21 +41,21 @@ export function UsageOptions() {
<DropdownMenuContent
align="end"
width={340}
sheetTitle={t("providerUsage.options")}
sheetTitle={usageCopy.options}
testID="usage-options-surface"
>
<View testID="usage-options-fields">
<SettingsSwitch
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
label={usageCopy.showInSidebar}
hint={usageCopy.showInSidebarHint}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
<View style={settingsStyles.rowBorder}>
<SettingsRow label={t("providerUsage.displayAs")}>
<SettingsRow label={usageCopy.displayAs}>
<SegmentedControl
options={displayAsOptions}
options={DISPLAY_AS_OPTIONS}
value={display.displayAs}
onValueChange={display.setDisplayAs}
size="sm"
-26
View File
@@ -275,29 +275,3 @@ 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");
}
});
+4 -6
View File
@@ -1,6 +1,4 @@
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
import { formatPct } from "./format";
import { displayPercent } from "./model";
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
import { windowTone } from "./tone";
@@ -70,9 +68,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
@@ -86,8 +84,8 @@ export function resolvePinnedUsage(
const percentText = formatPct(percent);
return {
key: `${entry.id}/${window.id}`,
label: `${describe(entry, formatProviderUsageLabel(window.id, window.label, t))} ${formatDisplayPct(percent, preferences.displayAs, undefined, t)}`,
shortLabel: window.shortLabel ?? formatProviderUsageLabel(window.id, window.label, t),
label: `${describe(entry, window.label)} ${percentText} ${meaning}`,
shortLabel: window.shortLabel ?? window.label,
percent,
percentText,
tone: windowTone(window),
+7 -13
View File
@@ -1,4 +1,3 @@
import { useTranslation } from "react-i18next";
import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client";
import { useCallback, useMemo } from "react";
import {
@@ -145,17 +144,13 @@ 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),
},
t,
);
const view = resolveUsageView({
hostLabel,
isConnected,
supportsUsage: isSupported,
query: toQueryState(query),
});
return { view, refresh };
}
@@ -201,8 +196,7 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView
retry: false,
staleTimeMs: REPORTS_STALE_TIME_MS,
});
const { t } = useTranslation();
return resolveAgentUsageView({ canReport, query: toQueryState(query) }, t);
return resolveAgentUsageView({ canReport, query: toQueryState(query) });
}
/** Whether a host is connected and reports usage, read without fetching anything. */
+2 -3
View File
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import { RotateCw } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usageCopy } from "./copy";
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -14,12 +14,11 @@ 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={t("providerUsage.refreshAll")}
label={usageCopy.refreshAll}
compact={compact}
disabled={busy}
onPress={onRefresh}
+1 -2
View File
@@ -34,10 +34,9 @@ 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, t), [preferences, reports, t]);
return useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
}
/** Whether the sidebar Usage item has anything to show. */
+2 -2
View File
@@ -4,6 +4,7 @@ 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";
@@ -21,12 +22,11 @@ interface UsageModalProps {
* title row.
*/
export function UsageModal(props: UsageModalProps) {
const { t } = useTranslation();
const { serverId, connectedHosts, select } = useUsageHostSelection();
if (!serverId) {
return (
<UsageModalFrame {...props}>
<UsageMessage text={t("providerUsage.noHosts")} />
<UsageMessage text={usageCopy.noHosts} />
</UsageModalFrame>
);
}
+5 -11
View File
@@ -1,4 +1,3 @@
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -6,6 +5,7 @@ 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,32 +50,26 @@ 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={t("providerUsage.loading")} />;
return <UsageMessage text={usageCopy.loading} />;
}
if (view.kind === "error") {
return (
<Alert
size="sm"
variant="error"
title={t("providerUsage.errorTitle")}
description={view.message}
>
<Alert size="sm" variant="error" title={usageCopy.errorTitle} description={view.message}>
<Button variant="outline" size="sm" onPress={onRefresh}>
{t("providerUsage.retry")}
{usageCopy.retry}
</Button>
</Alert>
);
}
if (view.reports.length === 0) {
return <UsageMessage text={t("providerUsage.empty")} />;
return <UsageMessage text={usageCopy.empty} />;
}
return <UsageList serverId={serverId} reports={view.reports} display={display} />;
@@ -1,13 +0,0 @@
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;
}
+9 -22
View File
@@ -1,21 +1,15 @@
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 {
formatDisplayPct,
formatProviderUsageLabel,
formatResetLabel,
formatRunsOutLabel,
} from "./format";
import { usageCopy } from "./copy";
import { formatDisplayPct, formatResetLabel } 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.
@@ -45,24 +39,21 @@ 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) > now);
(!retained || Date.parse(window.runsOutAt) > Date.now());
const trailing = isAtRisk
? formatRunsOutLabel(window.runsOutAt, now, t)
: formatResetLabel(window.resetsAt, retained, now, t);
? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
: formatResetLabel(window.resetsAt, retained);
const value =
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
const content = {
label: formatProviderUsageLabel(window.id, window.label, t),
label: window.label,
value,
trailing,
isAtRisk,
@@ -133,7 +124,7 @@ function WindowRowContent({
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
<Text style={styles.value}>
{value}
{trailing ? (
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
@@ -151,7 +142,6 @@ 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,
@@ -170,9 +160,7 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
</View>
</TooltipTrigger>
<TooltipContent side="top">
<Text style={styles.tooltipText}>
{pinned ? t("providerUsage.unpin") : t("providerUsage.pin")}
</Text>
<Text style={styles.tooltipText}>{pinned ? usageCopy.unpin : usageCopy.pin}</Text>
</TooltipContent>
</Tooltip>
);
@@ -221,7 +209,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
-40
View File
@@ -2,8 +2,6 @@ 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" });
@@ -18,43 +16,6 @@ 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/**"],
@@ -121,7 +82,6 @@ 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",
],
@@ -240,8 +240,6 @@ 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: Pick<PaseoToolCatalog, "tools"> | null): void;
setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void;
shutdown(): Promise<void>;
}
@@ -13,11 +13,6 @@ 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";
@@ -28,61 +23,6 @@ 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,18 +5,15 @@ 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,
@@ -75,144 +72,6 @@ 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);
@@ -837,7 +696,6 @@ 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: Pick<PaseoToolCatalog, "tools"> | null = null;
private manifestCatalog: PaseoToolCatalog | 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: Pick<PaseoToolCatalog, "tools"> | null): void {
setManifestCatalog(catalog: PaseoToolCatalog | null): void {
this.manifestCatalog = catalog;
}
@@ -637,20 +637,6 @@ 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,
@@ -1223,7 +1209,7 @@ function createToolCatalog(
.describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."),
};
const createAgentInputSchema = z
.object(scope === "agent" ? agentToAgentInputSchema : canonicalTopLevelInputSchema)
.object(callerAgentId ? agentToAgentInputSchema : canonicalTopLevelInputSchema)
.passthrough();
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
const legacyAgentToAgentCreateAgentArgsSchema = z.object(legacyAgentToAgentInputSchema).strict();
@@ -1259,8 +1245,9 @@ function createToolCatalog(
"Agent-scoped only: get notified when the prompted agent finishes, errors, or needs permission.",
),
};
const sendAgentPromptInputSchema =
scope === "agent" ? agentToAgentSendAgentPromptInputSchema : topLevelSendAgentPromptInputSchema;
const sendAgentPromptInputSchema = callerAgentId
? agentToAgentSendAgentPromptInputSchema
: topLevelSendAgentPromptInputSchema;
const inspectProviderInputSchema = {
provider: ProviderOrProviderModelInputSchema.describe(
"Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).",
@@ -2666,7 +2653,7 @@ function createToolCatalog(
.optional()
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
name: z.string().optional(),
provider: (scope === "agent" ? AgentProviderEnum.optional() : AgentProviderEnum).describe(
provider: (callerAgentId ? 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(),
+1 -4
View File
@@ -137,7 +137,6 @@ 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";
@@ -1458,9 +1457,7 @@ export async function createPaseoDaemon(
const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) =>
createPaseoToolCatalog(createAgentToolHostDependencies(runtime));
const setAgentProviderToolsEnabled = (enabled: boolean) => {
agentProviderRuntime.setPaseoToolCatalog(
enabled ? createPaseoAgentToolManifest(createAgentToolHostDependencies({})) : null,
);
agentProviderRuntime.setPaseoToolCatalog(enabled ? createAgentToolCatalog({}) : null);
};
agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog);
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
@@ -288,12 +288,6 @@ 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({
@@ -317,15 +317,3 @@ 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 {