mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44fdf72c8f | ||
|
|
c17e7c5b64 | ||
|
|
ab6cdb7c89 | ||
|
|
0d61cca50c | ||
|
|
0eee4fd9b0 | ||
|
|
69ef741a73 | ||
|
|
90f3cab585 | ||
|
|
fc775b6db5 | ||
|
|
71e7cf6ff8 | ||
|
|
eda3e4318f | ||
|
|
91154ba4e8 | ||
|
|
05920a236c |
@@ -50,6 +50,15 @@ older cancellation from settling a newer turn. If interruption is rejected or ti
|
||||
keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure.
|
||||
Accepting new work after an ambiguous interruption would create a split-brain session.
|
||||
|
||||
A forced settlement leaves two owners of "is a turn running": the manager has settled the run, while
|
||||
the provider session still holds the turn slot it never confirmed ending. `AgentSession.abandonForegroundTurn`
|
||||
is the optional seam for that case. The manager calls it after force-canceling, and only then. A
|
||||
provider that implements it must make the session usable again, not just clear a local flag: the ACP
|
||||
adapter kills the process that owned the turn and drops the slot, and its next `startTurn` resumes the
|
||||
durable session in a new process. Clearing the slot while reusing a runtime that may still be working
|
||||
would be the split-brain shape this section forbids. Providers that omit the hook keep today's
|
||||
behavior, and a rejected release is logged without undoing the settlement.
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous and always stamps `paseo.parent-agent-id`, pointing back at the caller. Omit `workspaceId` to use the caller's workspace, or pass an existing workspace ID returned by `create_workspace`. Placement never changes parentage.
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
|
||||
@@ -45,6 +45,21 @@
|
||||
}
|
||||
);
|
||||
|
||||
checks = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
packages = self.packages.${system};
|
||||
license = (builtins.fromJSON (builtins.readFile ./package.json)).license;
|
||||
in
|
||||
{
|
||||
license-metadata =
|
||||
assert packages.paseo.meta.license.spdxId == license;
|
||||
assert packages.desktop.meta.license.spdxId == license;
|
||||
pkgs.runCommand "paseo-license-metadata" { } "touch $out";
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
nixosModules.paseo =
|
||||
{ pkgs, lib, ... }:
|
||||
|
||||
@@ -257,7 +257,7 @@ buildNpmPackage {
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
license = lib.licenses.asl20;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ buildNpmPackage rec {
|
||||
meta = {
|
||||
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
license = lib.licenses.asl20;
|
||||
mainProgram = "paseo";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
|
||||
@@ -149,7 +149,7 @@ for (const layout of [
|
||||
await expect(details.getByText(/tokens$/)).toHaveCount(0);
|
||||
const account = usageCard(details, "login-journey:account");
|
||||
await expect(account.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31%", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31% used", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, `context-without-data-${layout.name}`);
|
||||
await layout.close(page);
|
||||
});
|
||||
@@ -161,7 +161,7 @@ for (const layout of [
|
||||
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
|
||||
usageCard(details, "login-journey:account").getByText("31% used", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -4,9 +4,10 @@ import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
@@ -131,7 +132,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
|
||||
await refreshAllUsage(page);
|
||||
await usage.waitForListRequests(requestCount + 1);
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText("41%", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("41% used", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
@@ -178,7 +179,7 @@ test.describe("usage modal", () => {
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(hostFilter(page)).toHaveCount(0);
|
||||
await qaScreenshot(page, "usage-modal-one-host");
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("31% used")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
|
||||
@@ -240,15 +241,15 @@ test.describe("usage modal", () => {
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromItem(page);
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
||||
await alphaRefresh.click();
|
||||
await expect
|
||||
.poll(() => forcedRefreshes(usage))
|
||||
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
|
||||
await expect(group.getByText("58%")).toBeVisible();
|
||||
await expect(group.getByText("12%")).toBeVisible();
|
||||
await expect(group.getByText("58% used")).toBeVisible();
|
||||
await expect(group.getByText("12% used")).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").nth(1).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
@@ -332,7 +333,7 @@ test.describe("usage modal", () => {
|
||||
await hostFilter(page).click();
|
||||
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await qaScreenshot(page, "usage-modal-picked-host");
|
||||
@@ -347,7 +348,7 @@ test.describe("usage modal", () => {
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await secondary.close().catch(() => undefined);
|
||||
@@ -513,7 +514,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expect(screen.getByText("69% left")).toBeVisible();
|
||||
await showUsageAs(page, "used");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, 31% · resets /,
|
||||
/^Pin Claude Session, 31% used · resets /,
|
||||
);
|
||||
await openUsageOptions(page);
|
||||
await closeUsageOptions(page);
|
||||
@@ -576,7 +577,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
|
||||
},
|
||||
]);
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
await expectLoginUsageOnly(page, "42% used");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
@@ -599,6 +600,7 @@ for (const compact of [false, true]) {
|
||||
}) => {
|
||||
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
|
||||
try {
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(
|
||||
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||
);
|
||||
@@ -615,7 +617,8 @@ for (const compact of [false, true]) {
|
||||
// Move only the browser clock; real daemon fetches continue using the host clock.
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
await expectLoginUsageOnly(page, "42% used");
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({ status: "error", error: "Rate limited" });
|
||||
@@ -640,8 +643,9 @@ for (const compact of [false, true]) {
|
||||
});
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43%");
|
||||
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
@@ -651,7 +655,88 @@ for (const compact of [false, true]) {
|
||||
|
||||
async function expectRetainedLoginUsage(page: Page, message: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
|
||||
await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
|
||||
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
|
||||
}
|
||||
|
||||
async function switchToJapanese(page: Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "general");
|
||||
await page.getByRole("button", { name: "System", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click();
|
||||
await page.getByRole("button", { name: "戻る", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openJapaneseUsage(page: Page) {
|
||||
await page.getByRole("button", { name: "使用状況", exact: true }).click();
|
||||
await expect(page.getByTestId("usage-modal").first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("usage percentages state their meaning and follow the selected language", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const login = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }],
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expect(page.getByText("70% used", { exact: true })).toBeVisible();
|
||||
await switchToJapanese(page);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
|
||||
await openJapaneseUsage(page);
|
||||
await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "設定", exact: true }).last().click();
|
||||
await expect(page.getByText("割合", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("使用済み", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("残り", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const now = new Date();
|
||||
const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
|
||||
const login = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 42, resetsAt: reset },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
|
||||
],
|
||||
balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
|
||||
});
|
||||
try {
|
||||
await openCompactUsageWithClock(page, now);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
||||
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
|
||||
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
||||
// After two browser minutes the latest displayed tick is one or two minutes old.
|
||||
await advanceUsageClock(page, 120_000);
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
||||
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
|
||||
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
|
||||
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
||||
/^resets (28|29)m$/,
|
||||
);
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openCompactUsageWithClock(page: Page, now: Date) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.clock.install({ time: now });
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
}
|
||||
|
||||
async function advanceUsageClock(page: Page, milliseconds: number) {
|
||||
await page.clock.fastForward(milliseconds);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("70% used")).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
@@ -131,11 +131,11 @@ test.describe("usage settings", () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
|
||||
await expect(card.getByText("64%")).toBeVisible();
|
||||
await expect(card.getByText("64% used")).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks to update a host without usage support and never calls it", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Native retained status-ring regression
|
||||
|
||||
This runs the production native ring and clock inside real retained panels with
|
||||
Reanimated on Android. A probe consumes the same public rotation hook and counts
|
||||
UI-runtime value changes. No Reanimated mocks or provider processes are used.
|
||||
|
||||
Use an Android development client compatible with the checkout's native dependencies.
|
||||
In a disposable checkout, temporarily replace `packages/app/index.ts` with:
|
||||
|
||||
```tsx
|
||||
import "./src/polyfills/install-early";
|
||||
import "./src/styles/unistyles";
|
||||
import { registerRootComponent } from "expo";
|
||||
import NativeRingRegression from "./e2e/mobile/status-ring/native";
|
||||
registerRootComponent(NativeRingRegression);
|
||||
```
|
||||
|
||||
Start Metro in `packages/app`:
|
||||
|
||||
```sh
|
||||
APP_VARIANT=development npx expo start --localhost --port 8096
|
||||
```
|
||||
|
||||
With one Android emulator running, open that bundle:
|
||||
|
||||
```sh
|
||||
agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096
|
||||
```
|
||||
|
||||
The test checks visible rotation, hidden-panel inactivity while another ring is
|
||||
visible, inactivity with both panels hidden, and synchronized resumption. Each
|
||||
sample reads counters on the UI runtime; elapsed time includes device-command
|
||||
latency. The baseline fails at the first hidden-panel assertion.
|
||||
|
||||
Restore the original `packages/app/index.ts`, close the agent-device session,
|
||||
and stop Metro afterward. The fixture substitutes running rings for a live agent
|
||||
and toggles retained panels directly; it does not exercise workspace navigation
|
||||
or measure whole-app CPU usage. It is excluded from routine unit tests because
|
||||
it requires a native runtime.
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, metroUrl] = process.argv;
|
||||
assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx");
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
function evaluate(expression) {
|
||||
const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim();
|
||||
return value === "undefined" ? undefined : JSON.parse(value);
|
||||
}
|
||||
let target;
|
||||
const deadline = Date.now() + 30_000;
|
||||
do {
|
||||
target = ad("cdp", "target", "list", "--url", metroUrl)
|
||||
.split("\n")
|
||||
.find((line) => line.includes("sh.paseo.debug"));
|
||||
if (!target) await delay(250);
|
||||
} while (!target && Date.now() < deadline);
|
||||
assert(target, "Open the Android dev client on this Metro bundle first");
|
||||
ad("cdp", "target", "select", target.split(/\s+/)[0]);
|
||||
let ready = false;
|
||||
do {
|
||||
ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2");
|
||||
if (!ready) await delay(250);
|
||||
} while (!ready && Date.now() < deadline);
|
||||
assert(ready, "The two native probes must mount before sampling");
|
||||
async function sample() {
|
||||
evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())");
|
||||
await delay(100);
|
||||
return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)"));
|
||||
}
|
||||
async function observe(label) {
|
||||
const before = await sample();
|
||||
await delay(600);
|
||||
const after = await sample();
|
||||
const deltas = Object.fromEntries(
|
||||
Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]),
|
||||
);
|
||||
console.log(JSON.stringify({ label, deltas, after }));
|
||||
return { deltas, after };
|
||||
}
|
||||
const visible = await observe("both visible");
|
||||
assert(visible.deltas.first > 0 && visible.deltas.second > 0);
|
||||
assert.equal(visible.after.first.rotation, visible.after.second.rotation);
|
||||
ad("press", 'label="HIDE FIRST"');
|
||||
await delay(200);
|
||||
const hidden = await observe("first hidden, second visible");
|
||||
assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates");
|
||||
assert(hidden.deltas.second > 0, "Visible rings must continue rotating");
|
||||
ad("press", 'label="HIDE SECOND"');
|
||||
await delay(200);
|
||||
const allHidden = await observe("both hidden");
|
||||
assert.equal(allHidden.deltas.first, 0);
|
||||
assert.equal(allHidden.deltas.second, 0);
|
||||
ad("press", 'label="SHOW FIRST"');
|
||||
ad("press", 'label="SHOW SECOND"');
|
||||
await delay(200);
|
||||
const restored = await observe("both shown again");
|
||||
assert(restored.deltas.first > 0 && restored.deltas.second > 0);
|
||||
assert.equal(
|
||||
restored.after.first.rotation,
|
||||
restored.after.second.rotation,
|
||||
"Restored rings must share the current phase",
|
||||
);
|
||||
console.log("PASS native retained-ring lifecycle");
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useLayoutEffect, useState } from "react";
|
||||
import { Button, Text, View } from "react-native";
|
||||
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { useStatusRingRotation } from "@/components/status-ring/clock";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
|
||||
interface Sample {
|
||||
updates: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __ringSamples: Record<string, Sample>;
|
||||
var __ringReaders: Record<string, () => void>;
|
||||
}
|
||||
|
||||
function recordSample(name: string, value: { updates: number; rotation: number }) {
|
||||
globalThis.__ringSamples[name] = value;
|
||||
}
|
||||
|
||||
function Probe({ name }: { name: string }) {
|
||||
const rotation = useStatusRingRotation();
|
||||
const updates = useSharedValue(0);
|
||||
useAnimatedReaction(
|
||||
() => rotation.value,
|
||||
() => {
|
||||
updates.value += 1;
|
||||
},
|
||||
);
|
||||
// Read the real UI runtime, without scheduling a continuous JS observer.
|
||||
useLayoutEffect(() => {
|
||||
globalThis.__ringSamples ??= {};
|
||||
globalThis.__ringReaders ??= {};
|
||||
globalThis.__ringReaders[name] = () =>
|
||||
scheduleOnUI(() => {
|
||||
"worklet";
|
||||
const sample = { updates: updates.value, rotation: rotation.value };
|
||||
scheduleOnRN(recordSample, name, sample);
|
||||
});
|
||||
return () => {
|
||||
delete globalThis.__ringReaders[name];
|
||||
};
|
||||
}, [name, rotation, updates]);
|
||||
return <StatusRing />;
|
||||
}
|
||||
|
||||
const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" };
|
||||
|
||||
export default function NativeRingRegression() {
|
||||
const [first, setFirst] = useState(true);
|
||||
const [second, setSecond] = useState(true);
|
||||
const toggleFirst = useCallback(() => setFirst((value) => !value), []);
|
||||
const toggleSecond = useCallback(() => setSecond((value) => !value), []);
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Text>Native retained status rings</Text>
|
||||
<Button title={first ? "Hide first" : "Show first"} onPress={toggleFirst} />
|
||||
<Button title={second ? "Hide second" : "Show second"} onPress={toggleSecond} />
|
||||
<RetainedPanel active={first}>
|
||||
<Probe name="first" />
|
||||
</RetainedPanel>
|
||||
<RetainedPanel active={second}>
|
||||
<Probe name="second" />
|
||||
</RetainedPanel>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ useDefaultAndroidSdkVersions()
|
||||
def skiaPackage = new File(["node", "--print", "require.resolve('@shopify/react-native-skia/package.json')"].execute(null, projectDir).text.trim()).parentFile
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
namespace "sh.paseo.diffprototype"
|
||||
defaultConfig {
|
||||
externalNativeBuild {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// After Expo prebuild, run from packages/app/android:
|
||||
// ./gradlew --init-script ../scripts/verify-android-ndk.gradle verifyAndroidNdk
|
||||
gradle.projectsEvaluated {
|
||||
rootProject.tasks.register('verifyAndroidNdk') {
|
||||
doLast {
|
||||
def expected = rootProject.ext.ndkVersion
|
||||
def nativeProjects = rootProject.subprojects.findAll { subproject ->
|
||||
def android = subproject.extensions.findByName('android')
|
||||
android != null && (android.externalNativeBuild.cmake.path != null ||
|
||||
android.externalNativeBuild.ndkBuild.path != null)
|
||||
}
|
||||
assert !nativeProjects.empty : 'No native Android modules found'
|
||||
println "Project NDK: ${expected}"
|
||||
def mismatches = nativeProjects.findAll { subproject ->
|
||||
def actual = subproject.android.ndkVersion
|
||||
println "${subproject.path} NDK: ${actual}"
|
||||
actual != expected
|
||||
}
|
||||
assert mismatches.empty : "Native Android modules must use project NDK ${expected}: ${mismatches*.path}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import {
|
||||
DaemonConnectionTestError,
|
||||
getConnectionAuthFailureReason,
|
||||
} from "@/utils/test-daemon-connection";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
@@ -195,92 +192,6 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTransportMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatTechnicalTransportDetails(
|
||||
details: (string | null)[],
|
||||
labels: DirectConnectionLabels,
|
||||
): string | null {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
details
|
||||
.map((value) => normalizeTransportMessage(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
if (unique.length === 0) return null;
|
||||
|
||||
const allGeneric = unique.every((value) => {
|
||||
const lower = value.toLowerCase();
|
||||
return lower === "transport error" || lower === "transport closed";
|
||||
});
|
||||
|
||||
if (allGeneric) {
|
||||
return labels.noAdditionalDetails(unique[0] ?? "");
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
function buildConnectionFailureCopy(input: {
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
labels: DirectConnectionLabels;
|
||||
}): { title: string; detail: string | null; raw: string | null } {
|
||||
const { endpoint, error, labels } = input;
|
||||
const title = labels.failedToConnect(endpoint);
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return normalizeTransportMessage(error.message);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
detail = error instanceof Error ? error.message : raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else {
|
||||
detail = labels.unableToConnect;
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
}
|
||||
|
||||
export interface AddHostModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
|
||||
const labels = {
|
||||
hostRequired: "host required",
|
||||
invalidPort: "invalid port",
|
||||
invalidConnection: "invalid connection",
|
||||
failedToConnect: (endpoint: string) => `Failed: ${endpoint}`,
|
||||
noAdditionalDetails: (detail: string) => `No details: ${detail}`,
|
||||
timedOut: "timeout",
|
||||
refused: "refused",
|
||||
hostNotFound: "DNS failure",
|
||||
hostUnreachable: "unreachable",
|
||||
tlsError: "TLS failure",
|
||||
unableToConnect: "connection failure",
|
||||
};
|
||||
|
||||
function failure(message: string) {
|
||||
return buildConnectionFailureCopy({
|
||||
endpoint: "tcp://example.test:443?ssl=true",
|
||||
error: new DaemonConnectionTestError(message, {
|
||||
reason: "transport error",
|
||||
lastError: message,
|
||||
}),
|
||||
labels,
|
||||
});
|
||||
}
|
||||
|
||||
describe("direct connection failure copy", () => {
|
||||
it("identifies Android certificate validation errors without calling them DNS failures", () => {
|
||||
const message =
|
||||
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
|
||||
expect(failure(message)).toEqual({
|
||||
title: "Failed: tcp://example.test:443?ssl=true",
|
||||
detail: labels.tlsError,
|
||||
raw: `transport error — ${message}`,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["certificate not found", labels.tlsError],
|
||||
["SSL handshake failed", labels.tlsError],
|
||||
["TLS handshake failed", labels.tlsError],
|
||||
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
|
||||
["Host not found", labels.hostNotFound],
|
||||
["connect ECONNREFUSED", labels.refused],
|
||||
["connection timed out", labels.timedOut],
|
||||
["EHOSTUNREACH", labels.hostUnreachable],
|
||||
["unexpected transport failure", labels.unableToConnect],
|
||||
])("classifies %s", (message, expected) => {
|
||||
expect(failure(message).detail).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves authentication failures and their technical details", () => {
|
||||
const message = "Invalid daemon password";
|
||||
expect(
|
||||
buildConnectionFailureCopy({
|
||||
endpoint: "example.test:6767",
|
||||
error: new DaemonConnectionTestError(message, {
|
||||
reason: message,
|
||||
lastError: message,
|
||||
authFailureReason: "incorrect_password",
|
||||
}),
|
||||
labels,
|
||||
}),
|
||||
).toMatchObject({ detail: message, raw: message });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
DaemonConnectionTestError,
|
||||
getConnectionAuthFailureReason,
|
||||
} from "@/utils/test-daemon-connection";
|
||||
|
||||
interface ConnectionFailureLabels {
|
||||
failedToConnect: (endpoint: string) => string;
|
||||
noAdditionalDetails: (detail: string) => string;
|
||||
timedOut: string;
|
||||
refused: string;
|
||||
hostNotFound: string;
|
||||
hostUnreachable: string;
|
||||
tlsError: string;
|
||||
unableToConnect: string;
|
||||
}
|
||||
|
||||
function normalizeTransportMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatTechnicalTransportDetails(
|
||||
details: (string | null)[],
|
||||
labels: ConnectionFailureLabels,
|
||||
): string | null {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
details
|
||||
.map((value) => normalizeTransportMessage(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
if (unique.length === 0) return null;
|
||||
|
||||
const allGeneric = unique.every((value) => {
|
||||
const lower = value.toLowerCase();
|
||||
return lower === "transport error" || lower === "transport closed";
|
||||
});
|
||||
|
||||
if (allGeneric) {
|
||||
return labels.noAdditionalDetails(unique[0] ?? "");
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
export function buildConnectionFailureCopy(input: {
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
labels: ConnectionFailureLabels;
|
||||
}): { title: string; detail: string | null; raw: string | null } {
|
||||
const { endpoint, error, labels } = input;
|
||||
const title = labels.failedToConnect(endpoint);
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return normalizeTransportMessage(error.message);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
detail = error instanceof Error ? error.message : raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("certpathvalidator") ||
|
||||
rawLower.includes("trust anchor") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
} else {
|
||||
detail = labels.unableToConnect;
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useHostReportsUsage } from "@/usage";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ContextWindowDetails } from "./context-window-details";
|
||||
import { ContextWindowSheet } from "./context-window-sheet";
|
||||
import { formatContextPercentage } from "./context-window-meter.utils";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
serverId: string;
|
||||
@@ -150,7 +151,7 @@ export function ContextWindowMeter({
|
||||
showPercentage = false,
|
||||
glyphSize,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
|
||||
const showsUsage = useHostReportsUsage(serverId);
|
||||
@@ -192,7 +193,9 @@ export function ContextWindowMeter({
|
||||
);
|
||||
const percentageLabel =
|
||||
showPercentage && context ? (
|
||||
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
|
||||
<Text style={styles.percentageLabel}>
|
||||
{formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
|
||||
</Text>
|
||||
) : null;
|
||||
const accessibilityLabel = context
|
||||
? t("contextWindow.accessibility", { percentage: context.percentage })
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatContextPercentage } from "./context-window-meter.utils";
|
||||
|
||||
describe("context window meter formatting", () => {
|
||||
it("localizes percentages without hiding context overflow", () => {
|
||||
expect(formatContextPercentage(125, "en")).toBe("125%");
|
||||
expect(formatContextPercentage(42, "fr")).toBe("42\u00a0%");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,10 @@
|
||||
export function formatContextPercentage(value: number, locale?: string): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value / 100);
|
||||
}
|
||||
|
||||
export function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnUI } from "react-native-worklets";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { getStatusRingRotation } from "@/components/status-ring/geometry";
|
||||
|
||||
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
|
||||
const activeRingCount = makeMutable(0);
|
||||
const clockRunning = makeMutable(false);
|
||||
let nextListenerId = 1;
|
||||
|
||||
function advanceSharedRotation(): void {
|
||||
"worklet";
|
||||
@@ -18,13 +20,21 @@ function advanceSharedRotation(): void {
|
||||
requestAnimationFrame(advanceSharedRotation);
|
||||
}
|
||||
|
||||
function registerStatusRing(registered: SharedValue<boolean>): void {
|
||||
function registerStatusRing(
|
||||
rotation: SharedValue<number>,
|
||||
registered: SharedValue<boolean>,
|
||||
listenerId: number,
|
||||
): void {
|
||||
"worklet";
|
||||
if (registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = true;
|
||||
rotation.value = getStatusRingRotation(Date.now());
|
||||
sharedRotation.addListener(listenerId, (value) => {
|
||||
rotation.value = value;
|
||||
});
|
||||
activeRingCount.value += 1;
|
||||
|
||||
if (!clockRunning.value) {
|
||||
@@ -34,25 +44,31 @@ function registerStatusRing(registered: SharedValue<boolean>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterStatusRing(registered: SharedValue<boolean>): void {
|
||||
function unregisterStatusRing(registered: SharedValue<boolean>, listenerId: number): void {
|
||||
"worklet";
|
||||
if (!registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = false;
|
||||
sharedRotation.removeListener(listenerId);
|
||||
activeRingCount.value -= 1;
|
||||
}
|
||||
|
||||
export function useStatusRingRotation(): SharedValue<number> {
|
||||
const active = useRetainedPanelActive();
|
||||
// Hidden retained views must not subscribe their animated styles to the shared clock.
|
||||
const rotation = useSharedValue(getStatusRingRotation(Date.now()));
|
||||
const registered = useSharedValue(false);
|
||||
const [listenerId] = useState(() => nextListenerId++);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scheduleOnUI(registerStatusRing, registered);
|
||||
if (!active) return;
|
||||
scheduleOnUI(registerStatusRing, rotation, registered, listenerId);
|
||||
return () => {
|
||||
scheduleOnUI(unregisterStatusRing, registered);
|
||||
scheduleOnUI(unregisterStatusRing, registered, listenerId);
|
||||
};
|
||||
}, [registered]);
|
||||
}, [active, listenerId, registered, rotation]);
|
||||
|
||||
return sharedRotation;
|
||||
return rotation;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
CheckoutPipelineJob,
|
||||
CheckoutPrStatusResponse,
|
||||
@@ -780,6 +780,11 @@ describe("formatAge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Fixed translators do not select a language, so request their resources first.
|
||||
beforeAll(async () => {
|
||||
await i18n.loadLanguages("fr");
|
||||
});
|
||||
|
||||
const english = i18n.getFixedT("en");
|
||||
const french = i18n.getFixedT("fr");
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { build } from "esbuild";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
let bundle: string;
|
||||
|
||||
// Exercise the bundled entry in fresh processes so the test runner's module cache
|
||||
// cannot hide eager evaluation. The probe only records resource module execution.
|
||||
beforeAll(async () => {
|
||||
const result = await build({
|
||||
entryPoints: [path.join(import.meta.dirname, "i18next.ts")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "cjs",
|
||||
external: ["i18next", "react-i18next"],
|
||||
write: false,
|
||||
plugins: [
|
||||
{
|
||||
name: "observe-translation-evaluation",
|
||||
setup(builder) {
|
||||
builder.onLoad(
|
||||
{ filter: /[/\\]i18n[/\\]resources[/\\][a-z]{2}(?:-[A-Z]{2})?\.ts$/ },
|
||||
async ({ path: file }) => ({
|
||||
contents: `globalThis.evaluatedLocales.push(${JSON.stringify(path.basename(file, ".ts"))});\n${await readFile(file, "utf8")}`,
|
||||
loader: "ts",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
bundle = result.outputFiles[0].text;
|
||||
});
|
||||
|
||||
function runStartupProbe(actions = "") {
|
||||
return JSON.parse(
|
||||
execFileSync(process.execPath, ["-"], {
|
||||
cwd: path.resolve(import.meta.dirname, "../.."),
|
||||
encoding: "utf8",
|
||||
input: `globalThis.evaluatedLocales = [];
|
||||
(function () { ${bundle} })();
|
||||
const { i18n } = module.exports;
|
||||
${actions}
|
||||
console.log(JSON.stringify({
|
||||
evaluated: globalThis.evaluatedLocales,
|
||||
registered: Object.keys(i18n.store.data),
|
||||
language: i18n.language,
|
||||
cancel: i18n.t("common.actions.cancel"),
|
||||
fallback: i18n.t("startupProbeOnlyInEnglish"),
|
||||
}));`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("bundled translation startup", () => {
|
||||
it("evaluates and registers only English before a language is selected", () => {
|
||||
expect(runStartupProbe()).toMatchObject({
|
||||
evaluated: ["en"],
|
||||
registered: ["en"],
|
||||
language: "en",
|
||||
cancel: "Cancel",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ar", "يلغي"],
|
||||
["es", "Cancelar"],
|
||||
["fr", "Annuler"],
|
||||
["ja", "キャンセル"],
|
||||
["ko", "취소"],
|
||||
["pt-BR", "Cancelar"],
|
||||
["ru", "Отмена"],
|
||||
["zh-CN", "取消"],
|
||||
])("loads %s synchronously on selection and reuses it", (locale, cancel) => {
|
||||
const result = runStartupProbe(`
|
||||
i18n.addResource("en", "translation", "startupProbeOnlyInEnglish", "English fallback");
|
||||
i18n.changeLanguage(${JSON.stringify(locale)});
|
||||
if (i18n.language !== ${JSON.stringify(locale)}) throw new Error("Language selection became asynchronous");
|
||||
i18n.changeLanguage("en");
|
||||
i18n.changeLanguage(${JSON.stringify(locale)});
|
||||
`);
|
||||
expect(result).toEqual({
|
||||
evaluated: ["en", locale],
|
||||
registered: ["en", locale],
|
||||
language: locale,
|
||||
cancel,
|
||||
fallback: "English fallback",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +1,70 @@
|
||||
import { createInstance } from "i18next";
|
||||
import { createInstance, type BackendModule } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { observeI18nInit } from "./init";
|
||||
import { ar } from "./resources/ar";
|
||||
import { en } from "./resources/en";
|
||||
import { es } from "./resources/es";
|
||||
import { fr } from "./resources/fr";
|
||||
import { ja } from "./resources/ja";
|
||||
import { ko } from "./resources/ko";
|
||||
import { ptBR } from "./resources/pt-BR";
|
||||
import { ru } from "./resources/ru";
|
||||
import { zhCN } from "./resources/zh-CN";
|
||||
|
||||
// Literal requires keep every locale bundled for offline use, while evaluating
|
||||
// its resource module only when selected. Synchronous reads preserve the language
|
||||
// provider's contract: translations are ready before it renders children.
|
||||
const bundledTranslations: BackendModule = {
|
||||
type: "backend",
|
||||
init() {},
|
||||
read(language, _namespace, callback) {
|
||||
switch (language) {
|
||||
case "en":
|
||||
callback(null, en);
|
||||
return;
|
||||
case "ar":
|
||||
callback(null, require("./resources/ar").ar);
|
||||
return;
|
||||
case "es":
|
||||
callback(null, require("./resources/es").es);
|
||||
return;
|
||||
case "fr":
|
||||
callback(null, require("./resources/fr").fr);
|
||||
return;
|
||||
case "ja":
|
||||
callback(null, require("./resources/ja").ja);
|
||||
return;
|
||||
case "ko":
|
||||
callback(null, require("./resources/ko").ko);
|
||||
return;
|
||||
case "pt-BR":
|
||||
callback(null, require("./resources/pt-BR").ptBR);
|
||||
return;
|
||||
case "ru":
|
||||
callback(null, require("./resources/ru").ru);
|
||||
return;
|
||||
case "zh-CN":
|
||||
callback(null, require("./resources/zh-CN").zhCN);
|
||||
return;
|
||||
default:
|
||||
callback(null, undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const i18n = createInstance();
|
||||
|
||||
observeI18nInit(
|
||||
i18n.use(initReactI18next).init({
|
||||
compatibilityJSON: "v4",
|
||||
fallbackLng: "en",
|
||||
lng: "en",
|
||||
resources: {
|
||||
ar: { translation: ar },
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
fr: { translation: fr },
|
||||
ja: { translation: ja },
|
||||
ko: { translation: ko },
|
||||
"pt-BR": { translation: ptBR },
|
||||
ru: { translation: ru },
|
||||
"zh-CN": { translation: zhCN },
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
}),
|
||||
i18n
|
||||
.use(bundledTranslations)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
compatibilityJSON: "v4",
|
||||
fallbackLng: "en",
|
||||
lng: "en",
|
||||
partialBundledLanguages: true,
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export { i18n };
|
||||
|
||||
@@ -20,6 +20,30 @@ function flattenKeys(value: unknown, prefix = ""): string[] {
|
||||
return entries.flatMap(([key, child]) => flattenKeys(child, prefix ? `${prefix}.${key}` : key));
|
||||
}
|
||||
|
||||
const pluralSuffixPattern = /_(zero|one|two|few|many|other)$/;
|
||||
const arabicPluralSuffixes = ["zero", "one", "two", "few", "many", "other"] as const;
|
||||
const arabicProviderUsagePluralKeys = [
|
||||
"providerUsage.duration.days",
|
||||
"providerUsage.duration.hours",
|
||||
"providerUsage.duration.minutes",
|
||||
"providerUsage.timing.daysAgo",
|
||||
"providerUsage.timing.hoursAgo",
|
||||
"providerUsage.timing.minutesAgo",
|
||||
] as const;
|
||||
|
||||
function canonicalKeys(value: unknown): string[] {
|
||||
const keys = flattenKeys(value);
|
||||
const keySet = new Set(keys);
|
||||
return [
|
||||
...new Set(
|
||||
keys.map((key) => {
|
||||
const baseKey = key.replace(pluralSuffixPattern, "");
|
||||
return baseKey !== key && keySet.has(baseKey) ? baseKey : key;
|
||||
}),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function flattenStrings(value: unknown, prefix = ""): Record<string, string> {
|
||||
if (typeof value === "string") {
|
||||
return { [prefix]: value };
|
||||
@@ -144,15 +168,24 @@ function findUntranslatedConnectionErrors(): string[] {
|
||||
|
||||
describe("translation resources", () => {
|
||||
it("keeps all supported language keys in sync with English", () => {
|
||||
const englishKeys = flattenKeys(en).sort();
|
||||
expect(flattenKeys(ar).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(es).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(fr).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(ja).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(ko).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(ptBR).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(ru).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(zhCN).sort()).toEqual(englishKeys);
|
||||
const englishKeys = canonicalKeys(en);
|
||||
expect(canonicalKeys(ar)).toEqual(englishKeys);
|
||||
expect(canonicalKeys(es)).toEqual(englishKeys);
|
||||
expect(canonicalKeys(fr)).toEqual(englishKeys);
|
||||
expect(canonicalKeys(ja)).toEqual(englishKeys);
|
||||
expect(canonicalKeys(ko)).toEqual(englishKeys);
|
||||
expect(canonicalKeys(ptBR)).toEqual(englishKeys);
|
||||
expect(canonicalKeys(ru)).toEqual(englishKeys);
|
||||
expect(canonicalKeys(zhCN)).toEqual(englishKeys);
|
||||
});
|
||||
|
||||
it("includes every Arabic form for provider-usage durations", () => {
|
||||
const arabicStrings = flattenStrings(ar);
|
||||
for (const key of arabicProviderUsagePluralKeys) {
|
||||
for (const suffix of arabicPluralSuffixes) {
|
||||
expect(arabicStrings[`${key}_${suffix}`]).toBeTypeOf("string");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps non-English supported languages translated beyond fallback labels", () => {
|
||||
|
||||
@@ -2046,6 +2046,106 @@ export const ar: TranslationResources = {
|
||||
saveAccessibility: "حفظ تعليق المراجعة",
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
options: "الإعدادات",
|
||||
refreshFailed: "تعذّر تحديث الاستخدام",
|
||||
noHosts: "لا توجد مضيفات متصلة",
|
||||
agentError: "تعذّر تحميل الاستخدام: {{reason}}",
|
||||
pin: "تثبيت",
|
||||
unpin: "إلغاء التثبيت",
|
||||
displayAs: "النسب المئوية",
|
||||
displayUsed: "المستخدم",
|
||||
displayRemaining: "المتبقي",
|
||||
showInSidebar: "ملخص في الشريط الجانبي",
|
||||
showInSidebarHint: "تظهر الفترات المثبتة في أسفل الشريط الجانبي",
|
||||
runToRefresh: "شغّل {{command}} للتحديث.",
|
||||
signInAgain: "سجّل الدخول مجددًا.",
|
||||
loginRejected: "تم رفض تسجيل الدخول (HTTP {{status}}). {{remedy}}",
|
||||
loginExpired: "انتهت صلاحية تسجيل الدخول {{relativeTime}}. {{remedy}}",
|
||||
title: "استخدام الخطة",
|
||||
refresh: "تحديث",
|
||||
refreshAll: "تحديث الكل",
|
||||
refreshing: "جارٍ التحديث...",
|
||||
loading: "جارٍ تحميل بيانات الاستخدام...",
|
||||
empty: "لا توجد بيانات استخدام",
|
||||
errorTitle: "تعذّر تحميل بيانات الاستخدام",
|
||||
hostUnavailable: "اتصل بـ{{host}} لعرض استخدام المزوّد",
|
||||
hostUpgradeRequired: "حدّث {{host}} لعرض استخدام المزوّد",
|
||||
clientUnavailable: "اتصال المضيف غير جاهز",
|
||||
retry: "حاول مرة أخرى",
|
||||
tooltipLoading: "جارٍ تحميل استخدام الخطة...",
|
||||
status: {
|
||||
error: "خطأ",
|
||||
unavailable: "غير متاح",
|
||||
},
|
||||
values: {
|
||||
used: "تم استخدام {{percentage}}",
|
||||
remaining: "متبقٍ {{amount}}",
|
||||
},
|
||||
labels: {
|
||||
session: "الجلسة",
|
||||
weekly: "أسبوعي",
|
||||
monthly: "شهري",
|
||||
codeReview: "مراجعة التعليمات البرمجية",
|
||||
credits: "الأرصدة",
|
||||
monthlyCredits: "أرصدة شهرية",
|
||||
},
|
||||
duration: {
|
||||
days: "{{value}} يوم",
|
||||
days_zero: "{{value}} يوم",
|
||||
days_one: "يوم واحد",
|
||||
days_two: "يومين",
|
||||
days_few: "{{value}} أيام",
|
||||
days_many: "{{value}} يومًا",
|
||||
days_other: "{{value}} يوم",
|
||||
hours: "{{value}} ساعة",
|
||||
hours_zero: "{{value}} ساعة",
|
||||
hours_one: "ساعة واحدة",
|
||||
hours_two: "ساعتين",
|
||||
hours_few: "{{value}} ساعات",
|
||||
hours_many: "{{value}} ساعة",
|
||||
hours_other: "{{value}} ساعة",
|
||||
minutes: "{{value}} دقيقة",
|
||||
minutes_zero: "{{value}} دقيقة",
|
||||
minutes_one: "دقيقة واحدة",
|
||||
minutes_two: "دقيقتين",
|
||||
minutes_few: "{{value}} دقائق",
|
||||
minutes_many: "{{value}} دقيقة",
|
||||
minutes_other: "{{value}} دقيقة",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "آخر تحديث {{relativeTime}}",
|
||||
noSuccessfulUpdate: "لا يوجد تحديث ناجح",
|
||||
resetTimePassed: "مضى وقت إعادة التعيين",
|
||||
resettingNow: "تتم إعادة التعيين الآن",
|
||||
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
|
||||
runsOutNow: "ينفد الآن",
|
||||
runsOutIn: "ينفد خلال {{duration}}",
|
||||
justNow: "الآن",
|
||||
daysAgo: "قبل {{value}} يومًا",
|
||||
daysAgo_zero: "قبل {{value}} يوم",
|
||||
daysAgo_one: "قبل يوم واحد",
|
||||
daysAgo_two: "قبل يومين",
|
||||
daysAgo_few: "قبل {{value}} أيام",
|
||||
daysAgo_many: "قبل {{value}} يومًا",
|
||||
daysAgo_other: "قبل {{value}} يوم",
|
||||
hoursAgo: "قبل {{value}} ساعة",
|
||||
hoursAgo_zero: "قبل {{value}} ساعة",
|
||||
hoursAgo_one: "قبل ساعة واحدة",
|
||||
hoursAgo_two: "قبل ساعتين",
|
||||
hoursAgo_few: "قبل {{value}} ساعات",
|
||||
hoursAgo_many: "قبل {{value}} ساعة",
|
||||
hoursAgo_other: "قبل {{value}} ساعة",
|
||||
minutesAgo: "قبل {{value}} دقيقة",
|
||||
minutesAgo_zero: "قبل {{value}} دقيقة",
|
||||
minutesAgo_one: "قبل دقيقة واحدة",
|
||||
minutesAgo_two: "قبل دقيقتين",
|
||||
minutesAgo_few: "قبل {{value}} دقائق",
|
||||
minutesAgo_many: "قبل {{value}} دقيقة",
|
||||
minutesAgo_other: "قبل {{value}} دقيقة",
|
||||
updated: "تم التحديث {{relativeTime}}",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "إعدادات",
|
||||
loading: "جارٍ تحميل الإعدادات...",
|
||||
|
||||
@@ -2071,6 +2071,70 @@ export const en = {
|
||||
saveAccessibility: "Save review comment",
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
options: "Settings",
|
||||
refreshFailed: "Unable to refresh usage",
|
||||
noHosts: "No connected hosts",
|
||||
agentError: "Unable to load usage: {{reason}}",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
displayAs: "Percentages",
|
||||
displayUsed: "Used",
|
||||
displayRemaining: "Remaining",
|
||||
showInSidebar: "Summary in sidebar",
|
||||
showInSidebarHint: "Pinned windows show in the sidebar footer",
|
||||
runToRefresh: "Run {{command}} to refresh it.",
|
||||
signInAgain: "Sign in again.",
|
||||
loginRejected: "Login rejected (HTTP {{status}}). {{remedy}}",
|
||||
loginExpired: "Login expired {{relativeTime}}. {{remedy}}",
|
||||
title: "Plan usage",
|
||||
refresh: "Refresh",
|
||||
refreshAll: "Refresh all",
|
||||
refreshing: "Refreshing...",
|
||||
loading: "Loading usage...",
|
||||
empty: "No usage data",
|
||||
errorTitle: "Unable to load usage",
|
||||
hostUnavailable: "Connect to {{host}} to see usage",
|
||||
hostUpgradeRequired: "Update {{host}} to see usage",
|
||||
clientUnavailable: "Host connection is not ready",
|
||||
retry: "Try again",
|
||||
tooltipLoading: "Loading plan usage...",
|
||||
status: {
|
||||
error: "Error",
|
||||
unavailable: "Unavailable",
|
||||
},
|
||||
values: {
|
||||
used: "{{percentage}} used",
|
||||
remaining: "{{amount}} left",
|
||||
},
|
||||
labels: {
|
||||
session: "Session",
|
||||
weekly: "Weekly",
|
||||
monthly: "Monthly",
|
||||
codeReview: "Code review",
|
||||
credits: "Credits",
|
||||
monthlyCredits: "Monthly credits",
|
||||
},
|
||||
duration: {
|
||||
days: "{{value}}d",
|
||||
hours: "{{value}}h",
|
||||
minutes: "{{value}}m",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Last updated {{relativeTime}}",
|
||||
noSuccessfulUpdate: "No successful update",
|
||||
resetTimePassed: "Reset time passed",
|
||||
resettingNow: "resetting now",
|
||||
resetsIn: "resets {{duration}}",
|
||||
runsOutNow: "runs out now",
|
||||
runsOutIn: "runs out {{duration}}",
|
||||
justNow: "just now",
|
||||
daysAgo: "{{value}}d ago",
|
||||
hoursAgo: "{{value}}h ago",
|
||||
minutesAgo: "{{value}}m ago",
|
||||
updated: "Updated {{relativeTime}}",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
loading: "Loading settings...",
|
||||
@@ -2973,4 +3037,18 @@ type WidenStringLeaves<T> = {
|
||||
[K in keyof T]: T[K] extends string ? string : WidenStringLeaves<T[K]>;
|
||||
};
|
||||
|
||||
export type TranslationResources = WidenStringLeaves<typeof en>;
|
||||
type TranslationResourcesBase = WidenStringLeaves<typeof en>;
|
||||
type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
|
||||
type DurationPluralKey = `${"days" | "hours" | "minutes"}_${PluralCategory}`;
|
||||
type AgoPluralKey = `${"daysAgo" | "hoursAgo" | "minutesAgo"}_${PluralCategory}`;
|
||||
|
||||
// i18next plural categories differ by locale, so locale catalogs may add the
|
||||
// explicit v4 suffixes they need while retaining the canonical fallback keys.
|
||||
export type TranslationResources = TranslationResourcesBase & {
|
||||
providerUsage: TranslationResourcesBase["providerUsage"] & {
|
||||
duration: TranslationResourcesBase["providerUsage"]["duration"] &
|
||||
Partial<Record<DurationPluralKey, string>>;
|
||||
timing: TranslationResourcesBase["providerUsage"]["timing"] &
|
||||
Partial<Record<AgoPluralKey, string>>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2094,6 +2094,70 @@ export const es: TranslationResources = {
|
||||
saveAccessibility: "Guardar comentario de revisión",
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
options: "Configuración",
|
||||
refreshFailed: "No se pudo actualizar el uso",
|
||||
noHosts: "No hay hosts conectados",
|
||||
agentError: "No se pudo cargar el uso: {{reason}}",
|
||||
pin: "Fijar",
|
||||
unpin: "Desfijar",
|
||||
displayAs: "Porcentajes",
|
||||
displayUsed: "Usado",
|
||||
displayRemaining: "Restante",
|
||||
showInSidebar: "Resumen en la barra lateral",
|
||||
showInSidebarHint: "Los períodos fijados aparecen al pie de la barra lateral",
|
||||
runToRefresh: "Ejecuta {{command}} para actualizar.",
|
||||
signInAgain: "Vuelve a iniciar sesión.",
|
||||
loginRejected: "Inicio de sesión rechazado (HTTP {{status}}). {{remedy}}",
|
||||
loginExpired: "La sesión caducó {{relativeTime}}. {{remedy}}",
|
||||
title: "Uso del plan",
|
||||
refresh: "Actualizar",
|
||||
refreshAll: "Actualizar todo",
|
||||
refreshing: "Actualizando...",
|
||||
loading: "Cargando uso...",
|
||||
empty: "No hay datos de uso",
|
||||
errorTitle: "No se pudo cargar el uso",
|
||||
hostUnavailable: "Conéctate a {{host}} para ver el uso del proveedor",
|
||||
hostUpgradeRequired: "Actualiza {{host}} para ver el uso del proveedor",
|
||||
clientUnavailable: "La conexión con el host no está lista",
|
||||
retry: "Intentar de nuevo",
|
||||
tooltipLoading: "Cargando uso del plan...",
|
||||
status: {
|
||||
error: "Error",
|
||||
unavailable: "No disponible",
|
||||
},
|
||||
values: {
|
||||
used: "Usado: {{percentage}}",
|
||||
remaining: "Quedan {{amount}}",
|
||||
},
|
||||
labels: {
|
||||
session: "Sesión",
|
||||
weekly: "Semanal",
|
||||
monthly: "Mensual",
|
||||
codeReview: "Revisión de código",
|
||||
credits: "Créditos",
|
||||
monthlyCredits: "Créditos mensuales",
|
||||
},
|
||||
duration: {
|
||||
days: "{{value}} d",
|
||||
hours: "{{value}} h",
|
||||
minutes: "{{value}} min",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Última actualización {{relativeTime}}",
|
||||
noSuccessfulUpdate: "No hay actualizaciones correctas",
|
||||
resetTimePassed: "Pasó la hora de restablecimiento",
|
||||
resettingNow: "se restablece ahora",
|
||||
resetsIn: "se restablece en {{duration}}",
|
||||
runsOutNow: "se agota ahora",
|
||||
runsOutIn: "se agota en {{duration}}",
|
||||
justNow: "ahora mismo",
|
||||
daysAgo: "hace {{value}} d",
|
||||
hoursAgo: "hace {{value}} h",
|
||||
minutesAgo: "hace {{value}} min",
|
||||
updated: "Actualizado {{relativeTime}}",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Ajustes",
|
||||
loading: "Cargando configuración...",
|
||||
|
||||
@@ -2094,6 +2094,70 @@ export const fr: TranslationResources = {
|
||||
saveAccessibility: "Enregistrer le commentaire de revue",
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
options: "Paramètres",
|
||||
refreshFailed: "Impossible d’actualiser l’utilisation",
|
||||
noHosts: "Aucun hôte connecté",
|
||||
agentError: "Impossible de charger l’utilisation : {{reason}}",
|
||||
pin: "Épingler",
|
||||
unpin: "Désépingler",
|
||||
displayAs: "Pourcentages",
|
||||
displayUsed: "Utilisé",
|
||||
displayRemaining: "Restant",
|
||||
showInSidebar: "Résumé dans la barre latérale",
|
||||
showInSidebarHint: "Les périodes épinglées apparaissent en bas de la barre latérale",
|
||||
runToRefresh: "Exécutez {{command}} pour actualiser.",
|
||||
signInAgain: "Reconnectez-vous.",
|
||||
loginRejected: "Connexion refusée (HTTP {{status}}). {{remedy}}",
|
||||
loginExpired: "Connexion expirée {{relativeTime}}. {{remedy}}",
|
||||
title: "Utilisation du forfait",
|
||||
refresh: "Actualiser",
|
||||
refreshAll: "Tout actualiser",
|
||||
refreshing: "Actualisation...",
|
||||
loading: "Chargement de l’utilisation...",
|
||||
empty: "Aucune donnée d’utilisation",
|
||||
errorTitle: "Impossible de charger l’utilisation",
|
||||
hostUnavailable: "Connectez-vous à {{host}} pour voir l’utilisation du fournisseur",
|
||||
hostUpgradeRequired: "Mettez à jour {{host}} pour voir l’utilisation du fournisseur",
|
||||
clientUnavailable: "La connexion à l’hôte n’est pas prête",
|
||||
retry: "Réessayer",
|
||||
tooltipLoading: "Chargement de l’utilisation du forfait...",
|
||||
status: {
|
||||
error: "Erreur",
|
||||
unavailable: "Indisponible",
|
||||
},
|
||||
values: {
|
||||
used: "Utilisé : {{percentage}}",
|
||||
remaining: "Il reste {{amount}}",
|
||||
},
|
||||
labels: {
|
||||
session: "Session",
|
||||
weekly: "Hebdomadaire",
|
||||
monthly: "Mensuel",
|
||||
codeReview: "Révision de code",
|
||||
credits: "Crédits",
|
||||
monthlyCredits: "Crédits mensuels",
|
||||
},
|
||||
duration: {
|
||||
days: "{{value}} j",
|
||||
hours: "{{value}} h",
|
||||
minutes: "{{value}} min",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Dernière mise à jour {{relativeTime}}",
|
||||
noSuccessfulUpdate: "Aucune mise à jour réussie",
|
||||
resetTimePassed: "Heure de réinitialisation dépassée",
|
||||
resettingNow: "réinitialisation en cours",
|
||||
resetsIn: "se réinitialise dans {{duration}}",
|
||||
runsOutNow: "s’épuise maintenant",
|
||||
runsOutIn: "s’épuise dans {{duration}}",
|
||||
justNow: "à l’instant",
|
||||
daysAgo: "il y a {{value}} j",
|
||||
hoursAgo: "il y a {{value}} h",
|
||||
minutesAgo: "il y a {{value}} min",
|
||||
updated: "Mis à jour {{relativeTime}}",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Paramètres",
|
||||
loading: "Chargement des paramètres…",
|
||||
|
||||
@@ -2067,6 +2067,70 @@ export const ja: TranslationResources = {
|
||||
saveAccessibility: "レビューコメントを保存",
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
options: "設定",
|
||||
refreshFailed: "使用状況を更新できません",
|
||||
noHosts: "接続中のホストがありません",
|
||||
agentError: "使用状況を読み込めません: {{reason}}",
|
||||
pin: "ピン留め",
|
||||
unpin: "ピン留めを解除",
|
||||
displayAs: "割合",
|
||||
displayUsed: "使用済み",
|
||||
displayRemaining: "残り",
|
||||
showInSidebar: "サイドバーに概要を表示",
|
||||
showInSidebarHint: "ピン留めした期間をサイドバーの下部に表示します",
|
||||
runToRefresh: "{{command}} を実行して更新してください。",
|
||||
signInAgain: "再度サインインしてください。",
|
||||
loginRejected: "ログインが拒否されました (HTTP {{status}})。{{remedy}}",
|
||||
loginExpired: "ログインは{{relativeTime}}に期限切れになりました。{{remedy}}",
|
||||
title: "プラン使用量",
|
||||
refresh: "更新",
|
||||
refreshAll: "すべて更新",
|
||||
refreshing: "更新中...",
|
||||
loading: "使用状況を読み込み中...",
|
||||
empty: "使用状況データがありません",
|
||||
errorTitle: "使用状況を読み込めません",
|
||||
hostUnavailable: "{{host}} に接続してプロバイダーの使用状況を確認してください",
|
||||
hostUpgradeRequired: "{{host}} を更新してプロバイダーの使用状況を確認してください",
|
||||
clientUnavailable: "ホスト接続の準備ができていません",
|
||||
retry: "再試行",
|
||||
tooltipLoading: "プラン使用状況を読み込み中...",
|
||||
status: {
|
||||
error: "エラー",
|
||||
unavailable: "利用不可",
|
||||
},
|
||||
values: {
|
||||
used: "{{percentage}} 使用済み",
|
||||
remaining: "残り {{amount}}",
|
||||
},
|
||||
labels: {
|
||||
session: "セッション",
|
||||
weekly: "週間",
|
||||
monthly: "月間",
|
||||
codeReview: "コードレビュー",
|
||||
credits: "クレジット",
|
||||
monthlyCredits: "月間クレジット",
|
||||
},
|
||||
duration: {
|
||||
days: "{{value}}日",
|
||||
hours: "{{value}}時間",
|
||||
minutes: "{{value}}分",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "最終更新: {{relativeTime}}",
|
||||
noSuccessfulUpdate: "正常な更新はまだありません",
|
||||
resetTimePassed: "リセット時刻を過ぎています",
|
||||
resettingNow: "まもなくリセット",
|
||||
resetsIn: "{{duration}}後にリセット",
|
||||
runsOutNow: "まもなく上限に到達",
|
||||
runsOutIn: "{{duration}}後に上限に到達",
|
||||
justNow: "たった今",
|
||||
daysAgo: "{{value}}日前",
|
||||
hoursAgo: "{{value}}時間前",
|
||||
minutesAgo: "{{value}}分前",
|
||||
updated: "更新: {{relativeTime}}",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "設定",
|
||||
loading: "設定を読み込み中...",
|
||||
|
||||
@@ -2056,6 +2056,70 @@ export const ko: TranslationResources = {
|
||||
saveAccessibility: "리뷰 댓글 저장",
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
options: "설정",
|
||||
refreshFailed: "사용량을 새로 고칠 수 없습니다",
|
||||
noHosts: "연결된 호스트가 없습니다",
|
||||
agentError: "사용량을 불러올 수 없습니다: {{reason}}",
|
||||
pin: "고정",
|
||||
unpin: "고정 해제",
|
||||
displayAs: "백분율",
|
||||
displayUsed: "사용됨",
|
||||
displayRemaining: "남음",
|
||||
showInSidebar: "사이드바에 요약 표시",
|
||||
showInSidebarHint: "고정된 기간이 사이드바 하단에 표시됩니다",
|
||||
runToRefresh: "{{command}} 명령을 실행하여 새로 고치세요.",
|
||||
signInAgain: "다시 로그인하세요.",
|
||||
loginRejected: "로그인이 거부되었습니다 (HTTP {{status}}). {{remedy}}",
|
||||
loginExpired: "로그인이 {{relativeTime}}에 만료되었습니다. {{remedy}}",
|
||||
title: "플랜 사용량",
|
||||
refresh: "새로 고침",
|
||||
refreshAll: "모두 새로 고침",
|
||||
refreshing: "새로 고치는 중...",
|
||||
loading: "사용량 불러오는 중...",
|
||||
empty: "사용량 데이터가 없습니다",
|
||||
errorTitle: "사용량을 불러올 수 없습니다",
|
||||
hostUnavailable: "{{host}}에 연결하여 제공업체 사용량을 확인하세요",
|
||||
hostUpgradeRequired: "{{host}}를 업데이트하여 제공업체 사용량을 확인하세요",
|
||||
clientUnavailable: "호스트 연결이 준비되지 않았습니다",
|
||||
retry: "다시 시도",
|
||||
tooltipLoading: "플랜 사용량 불러오는 중...",
|
||||
status: {
|
||||
error: "오류",
|
||||
unavailable: "사용할 수 없음",
|
||||
},
|
||||
values: {
|
||||
used: "{{percentage}} 사용됨",
|
||||
remaining: "{{amount}} 남음",
|
||||
},
|
||||
labels: {
|
||||
session: "세션",
|
||||
weekly: "주간",
|
||||
monthly: "월간",
|
||||
codeReview: "코드 검토",
|
||||
credits: "크레딧",
|
||||
monthlyCredits: "월간 크레딧",
|
||||
},
|
||||
duration: {
|
||||
days: "{{value}}일",
|
||||
hours: "{{value}}시간",
|
||||
minutes: "{{value}}분",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "마지막 업데이트: {{relativeTime}}",
|
||||
noSuccessfulUpdate: "성공한 업데이트가 없습니다",
|
||||
resetTimePassed: "초기화 시간이 지났습니다",
|
||||
resettingNow: "지금 재설정 중",
|
||||
resetsIn: "{{duration}} 후 재설정",
|
||||
runsOutNow: "지금 한도에 도달",
|
||||
runsOutIn: "{{duration}} 후 한도에 도달",
|
||||
justNow: "방금",
|
||||
daysAgo: "{{value}}일 전",
|
||||
hoursAgo: "{{value}}시간 전",
|
||||
minutesAgo: "{{value}}분 전",
|
||||
updated: "{{relativeTime}}에 업데이트됨",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "설정",
|
||||
loading: "설정 불러오는 중...",
|
||||
|
||||
@@ -2079,6 +2079,70 @@ export const ptBR: TranslationResources = {
|
||||
saveAccessibility: "Salvar comentário de revisão",
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
options: "Configurações",
|
||||
refreshFailed: "Não foi possível atualizar o uso",
|
||||
noHosts: "Nenhum host conectado",
|
||||
agentError: "Não foi possível carregar o uso: {{reason}}",
|
||||
pin: "Fixar",
|
||||
unpin: "Desafixar",
|
||||
displayAs: "Porcentagens",
|
||||
displayUsed: "Usado",
|
||||
displayRemaining: "Restante",
|
||||
showInSidebar: "Resumo na barra lateral",
|
||||
showInSidebarHint: "Os períodos fixados aparecem no rodapé da barra lateral",
|
||||
runToRefresh: "Execute {{command}} para atualizar.",
|
||||
signInAgain: "Entre novamente.",
|
||||
loginRejected: "Login rejeitado (HTTP {{status}}). {{remedy}}",
|
||||
loginExpired: "O login expirou {{relativeTime}}. {{remedy}}",
|
||||
title: "Uso do plano",
|
||||
refresh: "Atualizar",
|
||||
refreshAll: "Atualizar tudo",
|
||||
refreshing: "Atualizando...",
|
||||
loading: "Carregando uso...",
|
||||
empty: "Sem dados de uso",
|
||||
errorTitle: "Não foi possível carregar o uso",
|
||||
hostUnavailable: "Conecte-se a {{host}} para ver o uso do provedor",
|
||||
hostUpgradeRequired: "Atualize {{host}} para ver o uso do provedor",
|
||||
clientUnavailable: "A conexão com o host não está pronta",
|
||||
retry: "Tentar novamente",
|
||||
tooltipLoading: "Carregando uso do plano...",
|
||||
status: {
|
||||
error: "Erro",
|
||||
unavailable: "Indisponível",
|
||||
},
|
||||
values: {
|
||||
used: "{{percentage}} usado",
|
||||
remaining: "{{amount}} restantes",
|
||||
},
|
||||
labels: {
|
||||
session: "Sessão",
|
||||
weekly: "Semanal",
|
||||
monthly: "Mensal",
|
||||
codeReview: "Revisão de código",
|
||||
credits: "Créditos",
|
||||
monthlyCredits: "Créditos mensais",
|
||||
},
|
||||
duration: {
|
||||
days: "{{value}} d",
|
||||
hours: "{{value}} h",
|
||||
minutes: "{{value}} min",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Última atualização {{relativeTime}}",
|
||||
noSuccessfulUpdate: "Nenhuma atualização bem-sucedida",
|
||||
resetTimePassed: "O horário de redefinição passou",
|
||||
resettingNow: "redefinindo agora",
|
||||
resetsIn: "redefine em {{duration}}",
|
||||
runsOutNow: "esgota agora",
|
||||
runsOutIn: "esgota em {{duration}}",
|
||||
justNow: "agora mesmo",
|
||||
daysAgo: "há {{value}} d",
|
||||
hoursAgo: "há {{value}} h",
|
||||
minutesAgo: "há {{value}} min",
|
||||
updated: "Atualizado {{relativeTime}}",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Configurações",
|
||||
loading: "Carregando configurações...",
|
||||
|
||||
@@ -2079,6 +2079,70 @@ export const ru: TranslationResources = {
|
||||
saveAccessibility: "Сохранить комментарий к ревью",
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
options: "Настройки",
|
||||
refreshFailed: "Не удалось обновить использование",
|
||||
noHosts: "Нет подключённых хостов",
|
||||
agentError: "Не удалось загрузить использование: {{reason}}",
|
||||
pin: "Закрепить",
|
||||
unpin: "Открепить",
|
||||
displayAs: "Проценты",
|
||||
displayUsed: "Использовано",
|
||||
displayRemaining: "Осталось",
|
||||
showInSidebar: "Сводка на боковой панели",
|
||||
showInSidebarHint: "Закреплённые периоды отображаются внизу боковой панели",
|
||||
runToRefresh: "Выполните {{command}} для обновления.",
|
||||
signInAgain: "Войдите снова.",
|
||||
loginRejected: "Вход отклонён (HTTP {{status}}). {{remedy}}",
|
||||
loginExpired: "Срок входа истёк {{relativeTime}}. {{remedy}}",
|
||||
title: "Использование плана",
|
||||
refresh: "Обновить",
|
||||
refreshAll: "Обновить всё",
|
||||
refreshing: "Обновление...",
|
||||
loading: "Загрузка данных об использовании...",
|
||||
empty: "Нет данных об использовании",
|
||||
errorTitle: "Не удалось загрузить данные об использовании",
|
||||
hostUnavailable: "Подключитесь к {{host}}, чтобы увидеть использование провайдера",
|
||||
hostUpgradeRequired: "Обновите {{host}}, чтобы увидеть использование провайдера",
|
||||
clientUnavailable: "Соединение с хостом не готово",
|
||||
retry: "Повторить",
|
||||
tooltipLoading: "Загрузка использования плана...",
|
||||
status: {
|
||||
error: "Ошибка",
|
||||
unavailable: "Недоступно",
|
||||
},
|
||||
values: {
|
||||
used: "Использовано: {{percentage}}",
|
||||
remaining: "Осталось: {{amount}}",
|
||||
},
|
||||
labels: {
|
||||
session: "Сессия",
|
||||
weekly: "Еженедельно",
|
||||
monthly: "Ежемесячно",
|
||||
codeReview: "Проверка кода",
|
||||
credits: "Кредиты",
|
||||
monthlyCredits: "Ежемесячные кредиты",
|
||||
},
|
||||
duration: {
|
||||
days: "{{value}} дн.",
|
||||
hours: "{{value}} ч",
|
||||
minutes: "{{value}} мин",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "Последнее обновление {{relativeTime}}",
|
||||
noSuccessfulUpdate: "Успешных обновлений нет",
|
||||
resetTimePassed: "Время сброса прошло",
|
||||
resettingNow: "сброс сейчас",
|
||||
resetsIn: "сброс через {{duration}}",
|
||||
runsOutNow: "закончится сейчас",
|
||||
runsOutIn: "закончится через {{duration}}",
|
||||
justNow: "только что",
|
||||
daysAgo: "{{value}} дн. назад",
|
||||
hoursAgo: "{{value}} ч назад",
|
||||
minutesAgo: "{{value}} мин назад",
|
||||
updated: "Обновлено {{relativeTime}}",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "Настройки",
|
||||
loading: "Загрузка настроек...",
|
||||
|
||||
@@ -2024,6 +2024,70 @@ export const zhCN: TranslationResources = {
|
||||
saveAccessibility: "保存 review 评论",
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
options: "设置",
|
||||
refreshFailed: "无法刷新用量",
|
||||
noHosts: "没有已连接的主机",
|
||||
agentError: "无法加载用量:{{reason}}",
|
||||
pin: "固定",
|
||||
unpin: "取消固定",
|
||||
displayAs: "百分比",
|
||||
displayUsed: "已使用",
|
||||
displayRemaining: "剩余",
|
||||
showInSidebar: "在侧边栏显示摘要",
|
||||
showInSidebarHint: "固定的时间窗口显示在侧边栏底部",
|
||||
runToRefresh: "运行 {{command}} 以刷新。",
|
||||
signInAgain: "请重新登录。",
|
||||
loginRejected: "登录被拒绝(HTTP {{status}})。{{remedy}}",
|
||||
loginExpired: "登录已于{{relativeTime}}过期。{{remedy}}",
|
||||
title: "套餐用量",
|
||||
refresh: "刷新",
|
||||
refreshAll: "全部刷新",
|
||||
refreshing: "刷新中...",
|
||||
loading: "正在加载用量...",
|
||||
empty: "暂无用量数据",
|
||||
errorTitle: "无法加载用量",
|
||||
hostUnavailable: "连接到 {{host}} 以查看提供商用量",
|
||||
hostUpgradeRequired: "更新 {{host}} 以查看提供商用量",
|
||||
clientUnavailable: "主机连接尚未就绪",
|
||||
retry: "重试",
|
||||
tooltipLoading: "正在加载套餐用量...",
|
||||
status: {
|
||||
error: "错误",
|
||||
unavailable: "不可用",
|
||||
},
|
||||
values: {
|
||||
used: "已使用 {{percentage}}",
|
||||
remaining: "剩余 {{amount}}",
|
||||
},
|
||||
labels: {
|
||||
session: "会话",
|
||||
weekly: "每周",
|
||||
monthly: "每月",
|
||||
codeReview: "代码审查",
|
||||
credits: "积分",
|
||||
monthlyCredits: "每月积分",
|
||||
},
|
||||
duration: {
|
||||
days: "{{value}}天",
|
||||
hours: "{{value}}小时",
|
||||
minutes: "{{value}}分钟",
|
||||
},
|
||||
timing: {
|
||||
lastUpdated: "上次更新于 {{relativeTime}}",
|
||||
noSuccessfulUpdate: "尚无成功更新",
|
||||
resetTimePassed: "重置时间已过",
|
||||
resettingNow: "正在重置",
|
||||
resetsIn: "将在 {{duration}}后重置",
|
||||
runsOutNow: "即将用尽",
|
||||
runsOutIn: "将在 {{duration}}后用尽",
|
||||
justNow: "刚刚",
|
||||
daysAgo: "{{value}}天前",
|
||||
hoursAgo: "{{value}}小时前",
|
||||
minutesAgo: "{{value}}分钟前",
|
||||
updated: "更新于 {{relativeTime}}",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: "设置",
|
||||
loading: "正在加载设置...",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { UsageCard } from "./card";
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useAgentUsage } from "./queries";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
@@ -21,6 +21,7 @@ export function AgentUsage({
|
||||
/** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */
|
||||
refreshable: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const view = useAgentUsage(serverId, agentId);
|
||||
if (view.kind === "none") return null;
|
||||
return (
|
||||
@@ -35,7 +36,7 @@ export function AgentUsage({
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.message}>
|
||||
{view.kind === "loading" ? usageCopy.loading : view.message}
|
||||
{view.kind === "loading" ? t("providerUsage.loading") : view.message}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { useMemo } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { clampPct, formatAmount, formatResetLabel } from "./format";
|
||||
import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format";
|
||||
import type { UsageBalance, UsageTone } from "./types";
|
||||
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
||||
|
||||
interface ResolvedBalance {
|
||||
amountText: string;
|
||||
usedPct: number | null;
|
||||
}
|
||||
|
||||
function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance {
|
||||
function resolveBalance({
|
||||
balance,
|
||||
t,
|
||||
locale,
|
||||
}: {
|
||||
balance: UsageBalance;
|
||||
t: TFunction;
|
||||
locale?: string;
|
||||
}): ResolvedBalance {
|
||||
const { used, remaining, limit, unit } = balance;
|
||||
const format = (value: number) => formatAmount(value, unit, locale);
|
||||
if (limit != null && limit > 0) {
|
||||
@@ -20,7 +30,10 @@ function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance
|
||||
return { amountText: `${usedText} / ${format(limit)}`, usedPct };
|
||||
}
|
||||
if (remaining != null) {
|
||||
return { amountText: `${format(remaining)} left`, usedPct: null };
|
||||
return {
|
||||
amountText: t("providerUsage.values.remaining", { amount: format(remaining) }),
|
||||
usedPct: null,
|
||||
};
|
||||
}
|
||||
if (used != null) {
|
||||
return { amountText: format(used), usedPct: null };
|
||||
@@ -48,10 +61,11 @@ export function UsageBalanceBar({
|
||||
balance: UsageBalance;
|
||||
retained?: boolean;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const { amountText, usedPct } = resolveBalance(balance, i18n.language);
|
||||
const { t, i18n } = useTranslation();
|
||||
const now = useRelativeTimeTick({ enabled: balance.resetsAt != null });
|
||||
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
|
||||
const tone = balance.tone ?? "default";
|
||||
const resetLabel = formatResetLabel(balance.resetsAt, retained);
|
||||
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
|
||||
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
|
||||
@@ -62,12 +76,18 @@ export function UsageBalanceBar({
|
||||
<View style={styles.container}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{balance.label}
|
||||
</Text>
|
||||
<Text style={styles.value}>
|
||||
{amountText}
|
||||
{resetLabel ? <Text style={styles.reset}>{` · ${resetLabel}`}</Text> : null}
|
||||
{formatProviderUsageLabel(balance.id, balance.label, t)}
|
||||
</Text>
|
||||
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}>
|
||||
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail">
|
||||
{amountText}
|
||||
</Text>
|
||||
{resetLabel ? (
|
||||
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}>
|
||||
{resetLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{usedPct != null ? (
|
||||
<View style={styles.track}>
|
||||
@@ -94,13 +114,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
value: {
|
||||
flexShrink: 1,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
amount: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
reset: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
textAlign: "right",
|
||||
},
|
||||
track: {
|
||||
height: 4,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { UsageWindowBar } from "./window-bar";
|
||||
import type { UsageBalance, UsageWindow } from "./types";
|
||||
import { UsageBalanceBar } from "./balance-bar";
|
||||
|
||||
const window: UsageWindow = { id: "session", label: "Session", usedPct: 70 };
|
||||
const balance: UsageBalance = { id: "credits", label: "Credits", unit: "usd", remaining: 12 };
|
||||
const ignorePin = () => {};
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("React", React);
|
||||
await i18n.changeLanguage("en");
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
await i18n.changeLanguage("en");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("translates mounted usage rows when the language changes", async () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<>
|
||||
<UsageWindowBar
|
||||
window={window}
|
||||
displayAs="used"
|
||||
pinnable={false}
|
||||
pinned={false}
|
||||
onTogglePin={ignorePin}
|
||||
pinLabel=""
|
||||
pinTestID=""
|
||||
/>
|
||||
<UsageBalanceBar balance={balance} />
|
||||
</>,
|
||||
),
|
||||
);
|
||||
expect(container.textContent).toContain("Session70% used");
|
||||
expect(container.textContent).toContain("Credits");
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("ja");
|
||||
});
|
||||
expect(container.textContent).toContain("セッション70% 使用済み");
|
||||
expect(container.textContent).toContain("クレジット");
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type StyleProp, type TextStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -12,19 +13,22 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
import type { TFunction } from "i18next";
|
||||
import { UsageBalanceBar } from "./balance-bar";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model";
|
||||
import { formatAgo, formatProviderUsageLabel } from "./format";
|
||||
import { resolveUsageReport, type UsageRefresh } from "./model";
|
||||
import { useReportRefresh } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
import type { UsageReport, UsageReportEntry, UsageWindow } from "./types";
|
||||
import { UsageWindowBar } from "./window-bar";
|
||||
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
||||
|
||||
function statusText(report: UsageReport): string | null {
|
||||
function statusText(report: UsageReport, t: TFunction): string | null {
|
||||
if (report.status === "available") return null;
|
||||
return report.status === "error" ? "Error" : "Unavailable";
|
||||
return report.status === "error"
|
||||
? t("providerUsage.status.error")
|
||||
: t("providerUsage.status.unavailable");
|
||||
}
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
@@ -49,15 +53,16 @@ export function UsageCard({
|
||||
refreshable: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId);
|
||||
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
|
||||
const showsFreshnessInline = isNative || isCompact || !refreshable;
|
||||
const usage = entry.report;
|
||||
const status = statusText(usage);
|
||||
const status = statusText(usage, t);
|
||||
const footer = entry.account.label ?? null;
|
||||
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
|
||||
resolveUsageReport(entry);
|
||||
resolveUsageReport(entry, t);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
@@ -156,7 +161,7 @@ export function UsageCard({
|
||||
|
||||
{refreshState === "failed" ? (
|
||||
<Text style={styles.error} testID="usage-refresh-error">
|
||||
{usageCopy.refreshFailed}
|
||||
{t("providerUsage.refreshFailed")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -176,6 +181,7 @@ function CardWindowBar({
|
||||
display: UsageDisplay;
|
||||
pinnable: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pin = useMemo(
|
||||
() => ({ sourceId: entry.sourceId, windowId: window.id }),
|
||||
[entry.sourceId, window.id],
|
||||
@@ -190,7 +196,7 @@ function CardWindowBar({
|
||||
pinnable={pinnable}
|
||||
pinned={display.isPinned(pin)}
|
||||
onTogglePin={toggle}
|
||||
pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`}
|
||||
pinLabel={`${t("providerUsage.pin")} ${entry.sourceLabel} ${formatProviderUsageLabel(window.id, window.label, t)}`}
|
||||
pinTestID={`usage-pin-${entry.sourceId}-${window.id}`}
|
||||
/>
|
||||
);
|
||||
@@ -212,6 +218,7 @@ function UsageRefreshButton({
|
||||
onRefresh: () => void;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isPending = refreshState === "pending";
|
||||
const iconSize = paneContentToolbarIconSize(compact);
|
||||
const freshness = useMemo(
|
||||
@@ -227,7 +234,7 @@ function UsageRefreshButton({
|
||||
);
|
||||
return (
|
||||
<ToolbarButton
|
||||
label={`${usageCopy.refresh} ${sourceLabel}`}
|
||||
label={`${t("providerUsage.refresh")} ${sourceLabel}`}
|
||||
tooltip={freshness}
|
||||
tooltipSide="top"
|
||||
compact={compact}
|
||||
@@ -257,10 +264,17 @@ function UsageFreshness({
|
||||
style: StyleProp<TextStyle>;
|
||||
testID: string;
|
||||
}) {
|
||||
const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null);
|
||||
const { t } = useTranslation();
|
||||
const now = useRelativeTimeTick({ enabled: successfulAt != null });
|
||||
const relativeTime = formatAgo(successfulAt, now, t);
|
||||
const freshness = relativeTime
|
||||
? t(retained ? "providerUsage.timing.lastUpdated" : "providerUsage.timing.updated", {
|
||||
relativeTime,
|
||||
})
|
||||
: t("providerUsage.timing.noSuccessfulUpdate");
|
||||
return (
|
||||
<Text style={style} numberOfLines={1} testID={testID}>
|
||||
{successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"}
|
||||
{freshness}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,23 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { UsageProblem } from "@getpaseo/protocol/messages";
|
||||
import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { formatAgo } from "./format";
|
||||
|
||||
// User-facing copy for the usage surfaces, kept in one file so localization is a
|
||||
// single-file change.
|
||||
// Messages for usage failures outside the UI. Rendered copy uses useTranslation.
|
||||
export const usageCopy = {
|
||||
problem: (problem: UsageProblem, now: Date = new Date()): string => {
|
||||
problem: (problem: UsageProblem, now: Date = new Date(), t: TFunction = i18n.t): string => {
|
||||
if (problem.kind === "no_quota") return problem.detail;
|
||||
const remedy = problem.refreshedBy
|
||||
? `Run ${problem.refreshedBy} to refresh it.`
|
||||
: "Sign in again.";
|
||||
if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`;
|
||||
const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now));
|
||||
return `Login expired ${ago}. ${remedy}`;
|
||||
? t("providerUsage.runToRefresh", { command: problem.refreshedBy })
|
||||
: t("providerUsage.signInAgain");
|
||||
if (problem.kind === "rejected")
|
||||
return t("providerUsage.loginRejected", { status: problem.status, remedy });
|
||||
return t("providerUsage.loginExpired", {
|
||||
relativeTime: formatAgo(problem.expiresAt, now.getTime(), t),
|
||||
remedy,
|
||||
});
|
||||
},
|
||||
get clientUnavailable() {
|
||||
return i18n.t("providerUsage.clientUnavailable");
|
||||
},
|
||||
title: "Usage",
|
||||
planUsage: "Plan usage",
|
||||
options: "Settings",
|
||||
refresh: "Refresh",
|
||||
refreshAll: "Refresh all",
|
||||
refreshing: "Refreshing...",
|
||||
refreshFailed: "Unable to refresh usage",
|
||||
updated: "Updated",
|
||||
loading: "Loading usage...",
|
||||
empty: "No usage data",
|
||||
noHosts: "No connected hosts",
|
||||
errorTitle: "Unable to load usage",
|
||||
agentError: (reason: string) => `Unable to load usage: ${reason}`,
|
||||
hostUnavailable: (host: string) => `Connect to ${host} to see usage`,
|
||||
hostUpgradeRequired: (host: string) => `Update ${host} to see usage`,
|
||||
clientUnavailable: "Host connection is not ready",
|
||||
retry: "Try again",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
displayAs: "Percentages",
|
||||
displayUsed: "Used",
|
||||
displayRemaining: "Remaining",
|
||||
showInSidebar: "Summary in sidebar",
|
||||
showInSidebarHint: "Pinned windows show in the sidebar footer",
|
||||
} as const;
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAmount, formatResetLabel } from "./format";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
formatDisplayPct,
|
||||
formatAgo,
|
||||
formatAmount,
|
||||
formatPct,
|
||||
formatProviderUsageLabel,
|
||||
formatResetLabel,
|
||||
formatRunsOutLabel,
|
||||
} from "./format";
|
||||
|
||||
const NOW = Date.parse("2026-07-19T00:00:00.000Z");
|
||||
|
||||
describe("formatAmount", () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
it("groups thousands in the app's language", () => {
|
||||
expect(formatAmount(12345, "credits", "en")).toBe("12,345");
|
||||
expect(formatAmount(12345, "requests", "en")).toBe("12,345");
|
||||
@@ -12,6 +30,68 @@ describe("formatAmount", () => {
|
||||
expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50");
|
||||
expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US");
|
||||
});
|
||||
|
||||
it("localizes usage timing without patching the global clock", async () => {
|
||||
const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString();
|
||||
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
expect(formatPct(7, "en")).toBe("7%");
|
||||
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h");
|
||||
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h");
|
||||
|
||||
await i18n.changeLanguage("ja");
|
||||
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
|
||||
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("2時間後に上限に到達");
|
||||
expect(formatAgo(threeDaysAgo, NOW)).toBe("3日前");
|
||||
});
|
||||
|
||||
it("formats percentages with locale-specific spacing", () => {
|
||||
expect(formatPct(42, "fr")).toBe("42\u00a0%");
|
||||
});
|
||||
|
||||
it("formats relative-time counts with the active locale", async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
const days = 1_234;
|
||||
const daysFromNow = new Date(NOW + days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const daysAgo = new Date(NOW - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
expect(formatResetLabel(daysFromNow, false, NOW)).toBe("se réinitialise dans 1\u202f234 j");
|
||||
expect(formatRunsOutLabel(daysFromNow, NOW)).toBe("s’épuise dans 1\u202f234 j");
|
||||
expect(formatAgo(daysAgo, NOW)).toBe("il y a 1\u202f234 j");
|
||||
});
|
||||
|
||||
it("selects Arabic plural forms for relative durations", async () => {
|
||||
await i18n.changeLanguage("ar");
|
||||
const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString();
|
||||
const threeHoursFromNow = new Date(NOW + 3 * 60 * 60 * 1000).toISOString();
|
||||
const twoDaysAgo = new Date(NOW - 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("تتم إعادة التعيين خلال ساعتين");
|
||||
expect(formatRunsOutLabel(threeHoursFromNow, NOW)).toBe("ينفد خلال 3 ساعات");
|
||||
expect(formatAgo(twoDaysAgo, NOW)).toBe("قبل يومين");
|
||||
});
|
||||
|
||||
it("uses locale-aware compact notation for token balances", () => {
|
||||
expect(formatAmount(1_234, "tokens", "en")).toBe("1.2K");
|
||||
expect(formatAmount(1_234, "tokens", "ar")).toBe("1.2\u00a0ألف");
|
||||
});
|
||||
|
||||
it("localizes known provider-usage labels and preserves provider-specific labels", async () => {
|
||||
await i18n.changeLanguage("ko");
|
||||
|
||||
expect(formatProviderUsageLabel("session", "Session")).toBe("세션");
|
||||
expect(formatProviderUsageLabel("five_hour", "Session")).toBe("세션");
|
||||
expect(formatProviderUsageLabel("five_hour", "5-hour limit")).toBe("5-hour limit");
|
||||
expect(formatProviderUsageLabel("monthly", "Monthly")).toBe("월간");
|
||||
expect(formatProviderUsageLabel("code_review:weekly", "Code review · Weekly")).toBe(
|
||||
"코드 검토 · 주간",
|
||||
);
|
||||
expect(formatProviderUsageLabel("code_review:five_hour", "Code review · 5-hour")).toBe(
|
||||
"코드 검토 · 5-hour",
|
||||
);
|
||||
expect(formatProviderUsageLabel("credits", "Credits")).toBe("크레딧");
|
||||
expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit");
|
||||
});
|
||||
});
|
||||
|
||||
it("retained reset times that have passed do not claim the quota is resetting", () => {
|
||||
@@ -20,3 +100,33 @@ it("retained reset times that have passed do not claim the quota is resetting",
|
||||
expect(formatResetLabel(past)).toBe("resetting now");
|
||||
expect(formatResetLabel(null, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("says that a usage window percentage is consumed", () => {
|
||||
expect(formatDisplayPct(70, "used")).toBe("70% used");
|
||||
expect(formatDisplayPct(30, "remaining")).toBe("30% left");
|
||||
});
|
||||
it("translates the usage refresh control into Japanese", async () => {
|
||||
try {
|
||||
await i18n.changeLanguage("ja");
|
||||
expect(i18n.t("providerUsage.refresh")).toBe("更新");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
|
||||
it("translates the neighboring usage percentage settings", async () => {
|
||||
try {
|
||||
await i18n.changeLanguage("ja");
|
||||
expect(i18n.t("providerUsage.displayUsed")).toBe("使用済み");
|
||||
expect(i18n.t("providerUsage.displayRemaining")).toBe("残り");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves provider-defined labels even when their IDs match a standard period", () => {
|
||||
expect(formatProviderUsageLabel("monthly", "Extra allotment")).toBe("Extra allotment");
|
||||
expect(formatProviderUsageLabel("code_review:weekly", "Custom review allowance")).toBe(
|
||||
"Custom review allowance",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,47 +1,163 @@
|
||||
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type { UsageBalanceUnit } from "./types";
|
||||
|
||||
const providerUsageLabels = {
|
||||
session: { label: "Session", key: "providerUsage.labels.session" },
|
||||
weekly: { label: "Weekly", key: "providerUsage.labels.weekly" },
|
||||
monthly: { label: "Monthly", key: "providerUsage.labels.monthly" },
|
||||
code_review: { label: "Code review", key: "providerUsage.labels.codeReview" },
|
||||
credits: { label: "Credits", key: "providerUsage.labels.credits" },
|
||||
monthly_credits: { label: "Monthly credits", key: "providerUsage.labels.monthlyCredits" },
|
||||
} as const;
|
||||
|
||||
export function formatProviderUsageLabel(id: string, label: string, t: TFunction = i18n.t): string {
|
||||
const reviewPrefix = "Code review · ";
|
||||
if (id.startsWith("code_review:") && label.startsWith(reviewPrefix)) {
|
||||
const periodId = id.slice("code_review:".length);
|
||||
const periodLabel = label.slice(reviewPrefix.length);
|
||||
return `${t(providerUsageLabels.code_review.key)} · ${formatProviderUsageLabel(periodId, periodLabel, t)}`;
|
||||
}
|
||||
const standard =
|
||||
providerUsageLabels[id as keyof typeof providerUsageLabels] ??
|
||||
(id === "five_hour" ? providerUsageLabels.session : undefined);
|
||||
return standard?.label === label ? t(standard.key) : label;
|
||||
}
|
||||
|
||||
export function clampPct(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
export function formatPct(value: number): string {
|
||||
return `${Math.round(clampPct(value))}%`;
|
||||
export function formatPct(value: number, locale?: string): string {
|
||||
const resolvedLocale = locale ?? i18n.resolvedLanguage;
|
||||
return new Intl.NumberFormat(resolvedLocale, {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(clampPct(value) / 100);
|
||||
}
|
||||
|
||||
/** "31%" of the window used, or "69% left" of it. */
|
||||
export function formatDisplayPct(value: number, displayAs: UsageDisplayAs): string {
|
||||
return displayAs === "used" ? formatPct(value) : `${formatPct(value)} left`;
|
||||
/** A localized percentage that says whether it is used or remaining. */
|
||||
export function formatDisplayPct(
|
||||
value: number,
|
||||
displayAs: UsageDisplayAs,
|
||||
locale?: string,
|
||||
t: TFunction = i18n.t,
|
||||
): string {
|
||||
const percentage = formatPct(value, locale);
|
||||
return displayAs === "used"
|
||||
? t("providerUsage.values.used", { percentage })
|
||||
: t("providerUsage.values.remaining", { amount: percentage });
|
||||
}
|
||||
|
||||
function relativeDuration(iso: string): string | null {
|
||||
const diffMs = new Date(iso).getTime() - Date.now();
|
||||
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
|
||||
}
|
||||
|
||||
function relativeDuration(iso: string, now: number): RelativeDuration | null {
|
||||
const diffMs = new Date(iso).getTime() - now;
|
||||
if (!Number.isFinite(diffMs)) return null;
|
||||
if (diffMs <= 0) return "now";
|
||||
if (diffMs <= 0) return { unit: "now" };
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays > 0) return `${diffDays}d`;
|
||||
if (diffHours > 0) return `${diffHours}h`;
|
||||
return `${diffMinutes}m`;
|
||||
if (diffDays > 0) return { unit: "days", count: diffDays };
|
||||
if (diffHours > 0) return { unit: "hours", count: diffHours };
|
||||
return { unit: "minutes", count: diffMinutes };
|
||||
}
|
||||
|
||||
export function formatResetLabel(iso: string | null | undefined, retained = false): string | null {
|
||||
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
|
||||
switch (duration.unit) {
|
||||
case "now":
|
||||
return null;
|
||||
case "days":
|
||||
return t("providerUsage.duration.days", {
|
||||
count: duration.count,
|
||||
value: formatCount(duration.count),
|
||||
});
|
||||
case "hours":
|
||||
return t("providerUsage.duration.hours", {
|
||||
count: duration.count,
|
||||
value: formatCount(duration.count),
|
||||
});
|
||||
case "minutes":
|
||||
return t("providerUsage.duration.minutes", {
|
||||
count: duration.count,
|
||||
value: formatCount(duration.count),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function formatResetLabel(
|
||||
iso: string | null | undefined,
|
||||
retained = false,
|
||||
now = Date.now(),
|
||||
t: TFunction = i18n.t,
|
||||
): string | null {
|
||||
if (!iso) return null;
|
||||
const rel = relativeDuration(iso);
|
||||
const rel = relativeDuration(iso, now);
|
||||
if (!rel) return null;
|
||||
if (rel === "now" && retained) return "Reset time passed";
|
||||
return rel === "now" ? "resetting now" : `resets ${rel}`;
|
||||
if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed");
|
||||
if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
|
||||
const duration = formatRelativeDuration(rel, t);
|
||||
return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
|
||||
}
|
||||
|
||||
export function formatRunsOutLabel(
|
||||
iso: string | null | undefined,
|
||||
now = Date.now(),
|
||||
t: TFunction = i18n.t,
|
||||
): string | null {
|
||||
if (!iso) return null;
|
||||
const rel = relativeDuration(iso, now);
|
||||
if (!rel) return null;
|
||||
if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
|
||||
const duration = formatRelativeDuration(rel, t);
|
||||
return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
|
||||
}
|
||||
|
||||
export function formatAgo(
|
||||
iso: string | null | undefined,
|
||||
now = Date.now(),
|
||||
t: TFunction = i18n.t,
|
||||
): string | null {
|
||||
if (!iso) return null;
|
||||
const diffMs = now - new Date(iso).getTime();
|
||||
if (!Number.isFinite(diffMs)) return null;
|
||||
if (diffMs < 60_000) return t("providerUsage.timing.justNow");
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays > 0) {
|
||||
return t("providerUsage.timing.daysAgo", {
|
||||
count: diffDays,
|
||||
value: formatCount(diffDays),
|
||||
});
|
||||
}
|
||||
if (diffHours > 0) {
|
||||
return t("providerUsage.timing.hoursAgo", {
|
||||
count: diffHours,
|
||||
value: formatCount(diffHours),
|
||||
});
|
||||
}
|
||||
return t("providerUsage.timing.minutesAgo", {
|
||||
count: diffMinutes,
|
||||
value: formatCount(diffMinutes),
|
||||
});
|
||||
}
|
||||
|
||||
/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */
|
||||
export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string {
|
||||
export function formatAmount(value: number, unit: UsageBalanceUnit, locale?: string): string {
|
||||
switch (unit) {
|
||||
case "usd":
|
||||
return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value);
|
||||
case "tokens":
|
||||
return formatTokenCount(value);
|
||||
return new Intl.NumberFormat(locale, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
default:
|
||||
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { usageCopy } from "./copy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useHostUsage } from "./queries";
|
||||
import { UsageSection } from "./usage-section";
|
||||
|
||||
/** A host's usage reports, for its settings page. */
|
||||
export function HostUsageSection({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { view, refresh } = useHostUsage(serverId);
|
||||
const { display } = useUsagePreferences(serverId);
|
||||
return (
|
||||
<UsageSection
|
||||
serverId={serverId}
|
||||
title={usageCopy.planUsage}
|
||||
title={t("providerUsage.title")}
|
||||
view={view}
|
||||
display={display}
|
||||
onRefresh={refresh}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
resolveUsageReport,
|
||||
displayPercent,
|
||||
formatUsageFreshness,
|
||||
resolveAgentUsageView,
|
||||
replaceReport,
|
||||
resolveUsageRefresh,
|
||||
@@ -65,6 +65,18 @@ describe("resolveUsageView", () => {
|
||||
).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" });
|
||||
});
|
||||
|
||||
it("localizes unavailable host messages while retaining the host name", async () => {
|
||||
await i18n.changeLanguage("ko");
|
||||
expect(
|
||||
resolveUsageView({
|
||||
hostLabel: "Laptop",
|
||||
isConnected: false,
|
||||
supportsUsage: false,
|
||||
query: undefined,
|
||||
}),
|
||||
).toEqual({ kind: "unavailable", message: "Laptop에 연결하여 제공업체 사용량을 확인하세요" });
|
||||
});
|
||||
|
||||
it("moves from loading to ready to error", () => {
|
||||
const base = { hostLabel: "Laptop", isConnected: true, supportsUsage: true };
|
||||
expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" });
|
||||
@@ -80,6 +92,10 @@ describe("resolveUsageView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
const hosts: UsageHost[] = [
|
||||
{ serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true },
|
||||
{ serverId: "old", label: "Old", isConnected: true, supportsUsage: false },
|
||||
@@ -185,14 +201,6 @@ describe("displayPercent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUsageFreshness", () => {
|
||||
it("says when the report was fetched", () => {
|
||||
expect(formatUsageFreshness("now")).toBe("Updated just now");
|
||||
expect(formatUsageFreshness("3m")).toBe("Updated 3m ago");
|
||||
expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUsageRefresh", () => {
|
||||
it("is pending while a refresh runs, even after an earlier failure", () => {
|
||||
expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending");
|
||||
@@ -338,7 +346,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
|
||||
report: success.report as Extract<UsageReportEntry["report"], { status: "available" }>,
|
||||
},
|
||||
};
|
||||
expect(resolveUsageReport(failure)).toEqual({
|
||||
expect(resolveUsageReport(failure, i18n.t)).toEqual({
|
||||
windows: success.report.status === "available" ? success.report.windows : [],
|
||||
balances: [],
|
||||
details: [],
|
||||
@@ -347,7 +355,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
|
||||
retained: true,
|
||||
messages: ["Rate limited"],
|
||||
});
|
||||
expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({
|
||||
expect(resolveUsageReport({ ...failure, lastSuccessful: undefined }, i18n.t)).toEqual({
|
||||
windows: [],
|
||||
balances: [],
|
||||
details: [],
|
||||
@@ -356,7 +364,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
|
||||
retained: false,
|
||||
messages: ["Rate limited"],
|
||||
});
|
||||
expect(resolveUsageReport(success)).toEqual({
|
||||
expect(resolveUsageReport(success, i18n.t)).toEqual({
|
||||
windows: success.report.status === "available" ? success.report.windows : [],
|
||||
balances: [],
|
||||
details: [],
|
||||
@@ -384,7 +392,7 @@ it("orders failed polls by attempt time even when successful data is older", ()
|
||||
it("available and retained usage show the same successful completion time", () => {
|
||||
const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" };
|
||||
if (success.report.status !== "available") throw new Error("fixture must be available");
|
||||
expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z");
|
||||
expect(resolveUsageReport(success, i18n.t).successfulAt).toBe("2026-01-01T00:00:15.000Z");
|
||||
const failure: UsageReportEntry = {
|
||||
...success,
|
||||
successfulAt: undefined,
|
||||
@@ -392,6 +400,8 @@ it("available and retained usage show the same successful completion time", () =
|
||||
report: { status: "error", error: "Rate limited" },
|
||||
lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report },
|
||||
};
|
||||
expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt);
|
||||
expect(resolveUsageReport(failure, i18n.t).successfulAt).toBe(
|
||||
resolveUsageReport(success, i18n.t).successfulAt,
|
||||
);
|
||||
expect(upsertReport([failure], success)).toEqual([failure]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatCompactTimeAgoAsProse } from "@/utils/time";
|
||||
import type { TFunction } from "i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
|
||||
@@ -31,11 +32,6 @@ export function usageWindowRowLabel(input: {
|
||||
return `${input.pinLabel}, ${summary}`;
|
||||
}
|
||||
|
||||
/** When a report was fetched, from its compact relative time: "Updated 3m ago". */
|
||||
export function formatUsageFreshness(compactTimeAgo: string, retained = false): string {
|
||||
return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
|
||||
}
|
||||
|
||||
/** A user-requested refresh of one report. The previous report stays on screen throughout. */
|
||||
export type UsageRefresh = "idle" | "pending" | "failed";
|
||||
|
||||
@@ -108,16 +104,26 @@ export interface UsageQueryState {
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
export function resolveUsageView(input: {
|
||||
hostLabel: string;
|
||||
isConnected: boolean;
|
||||
supportsUsage: boolean;
|
||||
query: UsageQueryState | undefined;
|
||||
}): UsageView {
|
||||
export function resolveUsageView(
|
||||
input: {
|
||||
hostLabel: string;
|
||||
isConnected: boolean;
|
||||
supportsUsage: boolean;
|
||||
query: UsageQueryState | undefined;
|
||||
},
|
||||
t: TFunction = i18n.t,
|
||||
): UsageView {
|
||||
const { hostLabel, isConnected, supportsUsage, query } = input;
|
||||
if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) };
|
||||
if (!isConnected)
|
||||
return {
|
||||
kind: "unavailable",
|
||||
message: t("providerUsage.hostUnavailable", { host: hostLabel }),
|
||||
};
|
||||
if (!supportsUsage) {
|
||||
return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) };
|
||||
return {
|
||||
kind: "unavailable",
|
||||
message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
|
||||
};
|
||||
}
|
||||
if (query?.data) {
|
||||
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
|
||||
@@ -138,17 +144,20 @@ export type AgentUsageView =
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; reports: UsageReportEntry[] };
|
||||
|
||||
export function resolveAgentUsageView(input: {
|
||||
canReport: boolean;
|
||||
query: UsageQueryState;
|
||||
}): AgentUsageView {
|
||||
export function resolveAgentUsageView(
|
||||
input: {
|
||||
canReport: boolean;
|
||||
query: UsageQueryState;
|
||||
},
|
||||
t: TFunction = i18n.t,
|
||||
): AgentUsageView {
|
||||
const { canReport, query } = input;
|
||||
if (!canReport) return { kind: "none" };
|
||||
// A failed request keeps the reports from before it, or those that streamed in before it failed;
|
||||
// shown alone they would pass for the agent's complete, current usage.
|
||||
if (query.error) {
|
||||
const reason = query.error instanceof Error ? query.error.message : String(query.error);
|
||||
return { kind: "error", message: usageCopy.agentError(reason) };
|
||||
return { kind: "error", message: t("providerUsage.agentError", { reason }) };
|
||||
}
|
||||
if (query.data) {
|
||||
return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data };
|
||||
@@ -194,7 +203,7 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
|
||||
}
|
||||
|
||||
/** Current outcome owns messages; only a successful poll owns values and freshness. */
|
||||
export function resolveUsageReport(entry: UsageReportEntry) {
|
||||
export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
|
||||
const available = entry.report.status === "available";
|
||||
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Older daemons cannot provide history; a failed attempt is never a successful update.
|
||||
@@ -204,10 +213,12 @@ export function resolveUsageReport(entry: UsageReportEntry) {
|
||||
if (!available) {
|
||||
if (entry.loginErrors)
|
||||
messages.push(
|
||||
...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`),
|
||||
...entry.loginErrors.map(
|
||||
(login) => `${login.harness}: ${usageProblemText(login.report, t)}`,
|
||||
),
|
||||
);
|
||||
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
|
||||
else messages.push(usageProblemText(entry.report));
|
||||
else messages.push(usageProblemText(entry.report, t));
|
||||
}
|
||||
return {
|
||||
windows: content?.windows ?? [],
|
||||
@@ -224,8 +235,8 @@ export function resolveUsageReport(entry: UsageReportEntry) {
|
||||
};
|
||||
}
|
||||
|
||||
function usageProblemText(report: UsageReport): string {
|
||||
function usageProblemText(report: UsageReport, t: TFunction): string {
|
||||
if (report.status === "error") return report.error;
|
||||
if (report.status === "unavailable") return usageCopy.problem(report.problem);
|
||||
if (report.status === "unavailable") return usageCopy.problem(report.problem, new Date(), t);
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Settings } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
@@ -8,20 +9,23 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { SettingsRow, SettingsSwitch } from "@/components/settings";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageInSidebar } from "./in-sidebar";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
|
||||
const DISPLAY_AS_OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
|
||||
{ value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
|
||||
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
|
||||
];
|
||||
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
|
||||
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
|
||||
export function UsageOptions() {
|
||||
const { t } = useTranslation();
|
||||
const displayAsOptions: SegmentedControlOption<UsageDisplayAs>[] = [
|
||||
{ value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
|
||||
{
|
||||
value: "remaining",
|
||||
label: t("providerUsage.displayRemaining"),
|
||||
testID: "usage-display-remaining",
|
||||
},
|
||||
];
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { display } = useUsagePreferences();
|
||||
const { inSidebar, setInSidebar } = useUsageInSidebar();
|
||||
@@ -29,7 +33,7 @@ export function UsageOptions() {
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<ToolbarButton
|
||||
kind="menu"
|
||||
label={usageCopy.options}
|
||||
label={t("providerUsage.options")}
|
||||
compact={compact}
|
||||
testID="usage-options-menu"
|
||||
>
|
||||
@@ -41,21 +45,21 @@ export function UsageOptions() {
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
width={340}
|
||||
sheetTitle={usageCopy.options}
|
||||
sheetTitle={t("providerUsage.options")}
|
||||
testID="usage-options-surface"
|
||||
>
|
||||
<View testID="usage-options-fields">
|
||||
<SettingsSwitch
|
||||
label={usageCopy.showInSidebar}
|
||||
hint={usageCopy.showInSidebarHint}
|
||||
label={t("providerUsage.showInSidebar")}
|
||||
hint={t("providerUsage.showInSidebarHint")}
|
||||
value={inSidebar}
|
||||
onValueChange={setInSidebar}
|
||||
testID="usage-show-in-sidebar"
|
||||
/>
|
||||
<View style={settingsStyles.rowBorder}>
|
||||
<SettingsRow label={usageCopy.displayAs}>
|
||||
<SettingsRow label={t("providerUsage.displayAs")}>
|
||||
<SegmentedControl
|
||||
options={DISPLAY_AS_OPTIONS}
|
||||
options={displayAsOptions}
|
||||
value={display.displayAs}
|
||||
onValueChange={display.setDisplayAs}
|
||||
size="sm"
|
||||
|
||||
@@ -275,3 +275,29 @@ it("keeps unavailable and error reports out of the sidebar summary even with ret
|
||||
};
|
||||
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
|
||||
});
|
||||
|
||||
it("localizes pinned labels and meanings while preserving custom window names", async () => {
|
||||
const { i18n } = await import("@/i18n/i18next");
|
||||
await i18n.changeLanguage("ja");
|
||||
try {
|
||||
const entries = [
|
||||
report({
|
||||
sourceId: "test",
|
||||
sourceLabel: "Test",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", summary: true, usedPct: 70 },
|
||||
{ id: "weekly", label: "My allowance", summary: true, usedPct: 20 },
|
||||
],
|
||||
}),
|
||||
];
|
||||
expect(windows(entries, preferences(null)).map((w) => [w.label, w.shortLabel])).toEqual([
|
||||
["Test セッション 70% 使用済み", "セッション"],
|
||||
["Test My allowance 20% 使用済み", "My allowance"],
|
||||
]);
|
||||
expect(windows(entries, preferences(null, "remaining"))[0].label).toBe(
|
||||
"Test セッション 残り 30%",
|
||||
);
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { formatPct } from "./format";
|
||||
import type { TFunction } from "i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
|
||||
import { displayPercent } from "./model";
|
||||
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
|
||||
import { windowTone } from "./tone";
|
||||
@@ -68,9 +70,9 @@ function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[]
|
||||
export function resolvePinnedUsage(
|
||||
reports: readonly UsageReportEntry[],
|
||||
preferences: UsagePreferences,
|
||||
t: TFunction = i18n.t,
|
||||
): PinnedUsageSource[] {
|
||||
const pins = effectiveUsagePins(preferences, reports);
|
||||
const meaning = preferences.displayAs === "remaining" ? "left" : "used";
|
||||
return groupBySource(reports).flatMap((entry) => {
|
||||
if (entry.report.status !== "available") return [];
|
||||
const windows = entry.report.windows
|
||||
@@ -84,8 +86,8 @@ export function resolvePinnedUsage(
|
||||
const percentText = formatPct(percent);
|
||||
return {
|
||||
key: `${entry.id}/${window.id}`,
|
||||
label: `${describe(entry, window.label)} ${percentText} ${meaning}`,
|
||||
shortLabel: window.shortLabel ?? window.label,
|
||||
label: `${describe(entry, formatProviderUsageLabel(window.id, window.label, t))} ${formatDisplayPct(percent, preferences.displayAs, undefined, t)}`,
|
||||
shortLabel: window.shortLabel ?? formatProviderUsageLabel(window.id, window.label, t),
|
||||
percent,
|
||||
percentText,
|
||||
tone: windowTone(window),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
@@ -144,13 +145,17 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
|
||||
const refresh = useCallback(() => {
|
||||
void refreshReports(queryClient, serverId).catch(() => undefined);
|
||||
}, [queryClient, serverId]);
|
||||
const { t } = useTranslation();
|
||||
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const view = resolveUsageView({
|
||||
hostLabel,
|
||||
isConnected,
|
||||
supportsUsage: isSupported,
|
||||
query: toQueryState(query),
|
||||
});
|
||||
const view = resolveUsageView(
|
||||
{
|
||||
hostLabel,
|
||||
isConnected,
|
||||
supportsUsage: isSupported,
|
||||
query: toQueryState(query),
|
||||
},
|
||||
t,
|
||||
);
|
||||
return { view, refresh };
|
||||
}
|
||||
|
||||
@@ -196,7 +201,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView
|
||||
retry: false,
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
});
|
||||
return resolveAgentUsageView({ canReport, query: toQueryState(query) });
|
||||
const { t } = useTranslation();
|
||||
return resolveAgentUsageView({ canReport, query: toQueryState(query) }, t);
|
||||
}
|
||||
|
||||
/** Whether a host is connected and reports usage, read without fetching anything. */
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usageCopy } from "./copy";
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
@@ -14,11 +14,12 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
* header's island, so its highlight is round.
|
||||
*/
|
||||
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const iconSize = paneContentToolbarIconSize(compact);
|
||||
return (
|
||||
<ToolbarButton
|
||||
label={usageCopy.refreshAll}
|
||||
label={t("providerUsage.refreshAll")}
|
||||
compact={compact}
|
||||
disabled={busy}
|
||||
onPress={onRefresh}
|
||||
|
||||
@@ -34,9 +34,10 @@ import { UsageModal } from "./usage-modal";
|
||||
|
||||
/** Each summary window with data on the usage host, under its source; empty while none has. */
|
||||
function useUsageSummary(): readonly PinnedUsageSource[] {
|
||||
const { t } = useTranslation();
|
||||
const { preferences } = useUsagePreferences();
|
||||
const reports = useUsageHostReports(useUsageHostId());
|
||||
return useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
|
||||
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
|
||||
}
|
||||
|
||||
/** Whether the sidebar Usage item has anything to show. */
|
||||
|
||||
@@ -4,7 +4,6 @@ import { View } from "react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { useHostUsageWithControls } from "./controls";
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageHostSelection } from "./hosts";
|
||||
import type { UsageHost } from "./model";
|
||||
@@ -22,11 +21,12 @@ interface UsageModalProps {
|
||||
* title row.
|
||||
*/
|
||||
export function UsageModal(props: UsageModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, connectedHosts, select } = useUsageHostSelection();
|
||||
if (!serverId) {
|
||||
return (
|
||||
<UsageModalFrame {...props}>
|
||||
<UsageMessage text={usageCopy.noHosts} />
|
||||
<UsageMessage text={t("providerUsage.noHosts")} />
|
||||
</UsageModalFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -5,7 +6,6 @@ import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { UsageControls } from "./controls";
|
||||
import { UsageList } from "./list";
|
||||
@@ -50,26 +50,32 @@ export function UsageBody({
|
||||
display: UsageDisplay;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (view.kind === "unavailable") {
|
||||
return <UsageMessage text={view.message} />;
|
||||
}
|
||||
|
||||
if (view.kind === "loading") {
|
||||
return <UsageMessage text={usageCopy.loading} />;
|
||||
return <UsageMessage text={t("providerUsage.loading")} />;
|
||||
}
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<Alert size="sm" variant="error" title={usageCopy.errorTitle} description={view.message}>
|
||||
<Alert
|
||||
size="sm"
|
||||
variant="error"
|
||||
title={t("providerUsage.errorTitle")}
|
||||
description={view.message}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={onRefresh}>
|
||||
{usageCopy.retry}
|
||||
{t("providerUsage.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.reports.length === 0) {
|
||||
return <UsageMessage text={usageCopy.empty} />;
|
||||
return <UsageMessage text={t("providerUsage.empty")} />;
|
||||
}
|
||||
|
||||
return <UsageList serverId={serverId} reports={view.reports} display={display} />;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker";
|
||||
|
||||
/** The current time as a render input, updated while a relative-time label is mounted. */
|
||||
export function useRelativeTimeTick({ enabled }: { enabled: boolean }): number {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
return subscribeToRelativeTimeTick("minute", () => setNow(Date.now()));
|
||||
}, [enabled]);
|
||||
return now;
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import { Pin } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { usageCopy } from "./copy";
|
||||
import { formatDisplayPct, formatResetLabel } from "./format";
|
||||
import {
|
||||
formatDisplayPct,
|
||||
formatProviderUsageLabel,
|
||||
formatResetLabel,
|
||||
formatRunsOutLabel,
|
||||
} from "./format";
|
||||
import { UsageMeter } from "./meter";
|
||||
import { displayPercent, usageWindowRowLabel } from "./model";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import { windowTone } from "./tone";
|
||||
import type { UsageTone, UsageWindow } from "./types";
|
||||
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
||||
|
||||
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
|
||||
// so a hover never reads as the selection. Pinned rows do not react to hover.
|
||||
@@ -39,21 +45,24 @@ export function UsageWindowBar({
|
||||
pinLabel: string;
|
||||
pinTestID: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const shownPct = displayPercent(window, displayAs);
|
||||
const tone = windowTone(window);
|
||||
|
||||
const now = useRelativeTimeTick({ enabled: window.runsOutAt != null || window.resetsAt != null });
|
||||
const isAtRisk =
|
||||
window.runsOutAt != null &&
|
||||
window.shortfallPct != null &&
|
||||
(!retained || Date.parse(window.runsOutAt) > Date.now());
|
||||
(!retained || Date.parse(window.runsOutAt) > now);
|
||||
const trailing = isAtRisk
|
||||
? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
|
||||
: formatResetLabel(window.resetsAt, retained);
|
||||
? formatRunsOutLabel(window.runsOutAt, now, t)
|
||||
: formatResetLabel(window.resetsAt, retained, now, t);
|
||||
|
||||
const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
|
||||
const value =
|
||||
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
|
||||
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
|
||||
const content = {
|
||||
label: window.label,
|
||||
label: formatProviderUsageLabel(window.id, window.label, t),
|
||||
value,
|
||||
trailing,
|
||||
isAtRisk,
|
||||
@@ -124,7 +133,7 @@ function WindowRowContent({
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={styles.value}>
|
||||
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
|
||||
{value}
|
||||
{trailing ? (
|
||||
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
|
||||
@@ -142,6 +151,7 @@ function WindowRowContent({
|
||||
const ThemedPin = withUnistyles(Pin);
|
||||
|
||||
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const iconMapping = useMemo(
|
||||
() => (theme: { colors: { foregroundMuted: string } }) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -160,7 +170,9 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<Text style={styles.tooltipText}>{pinned ? usageCopy.unpin : usageCopy.pin}</Text>
|
||||
<Text style={styles.tooltipText}>
|
||||
{pinned ? t("providerUsage.unpin") : t("providerUsage.pin")}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -209,6 +221,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
value: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { defineConfig, configDefaults } from "vitest/config";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { transformSync } from "@babel/core";
|
||||
import { build } from "esbuild";
|
||||
|
||||
// Set this before Vite resolves dependencies and Vitest starts its workers.
|
||||
Object.assign(process.env, { NODE_ENV: "test" });
|
||||
@@ -16,6 +18,43 @@ const resolvePackageEntry = (packageName: string) => {
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: "compile-usage-components",
|
||||
enforce: "pre",
|
||||
transform(source, id) {
|
||||
// These labels must react to time and language in the compiled app too.
|
||||
if (!id.includes("/src/usage/") || !id.endsWith(".tsx") || id.includes(".test.")) return;
|
||||
const result = transformSync(source, {
|
||||
filename: id,
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
parserOpts: { plugins: ["typescript", "jsx"] },
|
||||
plugins: [["babel-plugin-react-compiler", { target: "19" }]],
|
||||
sourceMaps: true,
|
||||
});
|
||||
if (result?.code) return { code: result.code, map: result.map };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bundle-i18n-resources",
|
||||
// Metro resolves literal requires lazily. Vite's TS transform leaves them
|
||||
// to Node, which cannot resolve the resource modules. Bundle this boundary
|
||||
// so unit and browser consumers exercise the same deferred module loading.
|
||||
async transform(_source, id) {
|
||||
if (path.resolve(id) !== path.resolve(__dirname, "src/i18n/i18next.ts")) return;
|
||||
const result = await build({
|
||||
entryPoints: [id],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "neutral",
|
||||
external: ["i18next", "react-i18next"],
|
||||
write: false,
|
||||
});
|
||||
return { code: result.outputFiles[0].text, map: null };
|
||||
},
|
||||
},
|
||||
],
|
||||
test: {
|
||||
environment: "node",
|
||||
exclude: [...configDefaults.exclude, "e2e/**"],
|
||||
@@ -82,6 +121,7 @@ export default defineConfig({
|
||||
// Bundle the CJS dependencies of the excluded gesture-handler package for the browser.
|
||||
include: [
|
||||
"react/jsx-runtime",
|
||||
"react/compiler-runtime",
|
||||
"react-native-gesture-handler > hoist-non-react-statics",
|
||||
"react-native-gesture-handler > invariant",
|
||||
],
|
||||
|
||||
@@ -240,6 +240,8 @@ async function openTwoDraftTabs(page: Page, workspaceId: string) {
|
||||
|
||||
async function expectTabNumberNavigation(page: Page) {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
||||
await expect(page.getByTestId("settings-sidebar")).not.toBeVisible();
|
||||
await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true");
|
||||
await page.keyboard.press("Control+1");
|
||||
await expect(tabs.nth(0)).toHaveAttribute("aria-selected", "true");
|
||||
await page.keyboard.press("Control+2");
|
||||
|
||||
@@ -1364,6 +1364,7 @@ class McpCapableTestAgentClient extends TestAgentClient {
|
||||
|
||||
class ControlledInterruptSession extends TestAgentSession {
|
||||
interruptCalled = false;
|
||||
abandonCalls = 0;
|
||||
|
||||
constructor(
|
||||
config: AgentSessionConfig,
|
||||
@@ -1384,6 +1385,16 @@ class ControlledInterruptSession extends TestAgentSession {
|
||||
this.interruptCalled = true;
|
||||
await this.interruptBehavior(this);
|
||||
}
|
||||
|
||||
override async abandonForegroundTurn(): Promise<void> {
|
||||
this.abandonCalls += 1;
|
||||
this.pushEvent({
|
||||
type: "turn_canceled",
|
||||
provider: this.provider,
|
||||
reason: "Interrupted",
|
||||
turnId: this.turnId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface ControlledInterruptFixture {
|
||||
@@ -2785,6 +2796,91 @@ test("cancelAgentRun succeeds when the provider queues completion before rejecti
|
||||
}
|
||||
});
|
||||
|
||||
test("a force-canceled foreground turn releases the provider turn it never settled", async () => {
|
||||
const fixture = await createControlledInterruptFixture({
|
||||
name: "interrupt-acknowledged-no-settle",
|
||||
agentId: "00000000-0000-4000-8000-000000000307",
|
||||
turnId: "stuck-after-cancel-turn",
|
||||
// The provider answers cancel but never answers session/prompt, so the run stays unsettled
|
||||
// and the manager has to force-cancel its own side of the turn.
|
||||
interrupt: async () => {},
|
||||
});
|
||||
|
||||
try {
|
||||
await fixture.startForegroundRun();
|
||||
expect(fixture.manager.getAgent(fixture.agentId)?.activeForegroundTurnId).toBe(
|
||||
"stuck-after-cancel-turn",
|
||||
);
|
||||
|
||||
await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({
|
||||
status: "settled",
|
||||
});
|
||||
expect(fixture.session.abandonCalls).toBe(1);
|
||||
expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({
|
||||
lifecycle: "idle",
|
||||
activeForegroundTurnId: null,
|
||||
});
|
||||
|
||||
// The session must accept the next prompt: that is the bug this release exists for.
|
||||
await fixture.startForegroundRun();
|
||||
expect(fixture.manager.getAgent(fixture.agentId)?.lifecycle).toBe("running");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a settled cancel does not release the provider turn", async () => {
|
||||
let fixture!: ControlledInterruptFixture;
|
||||
fixture = await createControlledInterruptFixture({
|
||||
name: "interrupt-settled",
|
||||
agentId: "00000000-0000-4000-8000-000000000308",
|
||||
turnId: "settled-turn",
|
||||
interrupt: async (session) => {
|
||||
session.pushEvent({
|
||||
type: "turn_canceled",
|
||||
provider: session.provider,
|
||||
reason: "Interrupted",
|
||||
turnId: "settled-turn",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await fixture.startForegroundRun();
|
||||
await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({
|
||||
status: "settled",
|
||||
});
|
||||
expect(fixture.session.abandonCalls).toBe(0);
|
||||
expect(fixture.manager.getAgent(fixture.agentId)?.lifecycle).toBe("idle");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a provider that cannot release the turn still settles the cancel", async () => {
|
||||
const fixture = await createControlledInterruptFixture({
|
||||
name: "interrupt-release-rejected",
|
||||
agentId: "00000000-0000-4000-8000-000000000309",
|
||||
turnId: "release-rejected-turn",
|
||||
interrupt: async () => {},
|
||||
});
|
||||
let releaseAttempts = 0;
|
||||
fixture.session.abandonForegroundTurn = async () => {
|
||||
releaseAttempts += 1;
|
||||
throw new Error("The provider process did not exit");
|
||||
};
|
||||
|
||||
try {
|
||||
await fixture.startForegroundRun();
|
||||
await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({
|
||||
status: "settled",
|
||||
});
|
||||
expect(releaseAttempts).toBe(1);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("listProviderAvailability uses registered client keys, including custom providers", async () => {
|
||||
const customClient: AgentClient = {
|
||||
provider: "zai",
|
||||
|
||||
@@ -3156,6 +3156,7 @@ export class AgentManager {
|
||||
{ agentId, turnId: runTurnId, kind: run.kind },
|
||||
"cancelAgentRun: acknowledged turn still active after timeout, force-canceling",
|
||||
);
|
||||
await this.releaseAbandonedTurn(agent);
|
||||
await this.dispatchSessionEvent(agent, {
|
||||
type: "turn_canceled",
|
||||
provider: agent.provider,
|
||||
@@ -3168,6 +3169,7 @@ export class AgentManager {
|
||||
{ agentId, kind: run.kind },
|
||||
"cancelAgentRun: acknowledged pending turn still active after timeout, clearing it",
|
||||
);
|
||||
await this.releaseAbandonedTurn(agent);
|
||||
this.runs.settleForegroundRun(agentId, run.token);
|
||||
if (!agent.pendingReplacement) {
|
||||
agent.lifecycle = "idle";
|
||||
@@ -3194,6 +3196,26 @@ export class AgentManager {
|
||||
return { status: "settled" };
|
||||
}
|
||||
|
||||
/**
|
||||
* After a forced settlement, stop reusing a runtime whose foreground turn it never confirmed
|
||||
* ending. Providers that cannot reconcile omit the hook and keep today's behavior.
|
||||
*
|
||||
* A rejected release leaves the agent unusable, so log it and continue: the manager already
|
||||
* settled the run, and refusing here would report a failure whose only cause is the provider.
|
||||
*/
|
||||
private async releaseAbandonedTurn(agent: ActiveManagedAgent): Promise<void> {
|
||||
const abandon = agent.session.abandonForegroundTurn;
|
||||
if (!abandon) return;
|
||||
try {
|
||||
await abandon.call(agent.session);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId: agent.id, provider: agent.provider },
|
||||
"cancelAgentRun: provider could not release the abandoned foreground turn",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelAgentRunBefore(
|
||||
agentId: string,
|
||||
action: "reload" | "replace" | "rewind",
|
||||
|
||||
@@ -694,6 +694,17 @@ export interface AgentSession {
|
||||
* still uncertain.
|
||||
*/
|
||||
interrupt(): Promise<void>;
|
||||
/**
|
||||
* Drop ownership of a foreground turn the provider did not confirm as ended, after `interrupt()`
|
||||
* was acknowledged but the run never settled. The manager calls this only when it has already
|
||||
* force-canceled its own side of that run, so the daemon and the provider must not stay split.
|
||||
*
|
||||
* A provider that implements this must make the session usable again: release the turn slot and
|
||||
* stop trusting the runtime that owned it (kill, replace, or resume it). Reject when the runtime
|
||||
* cannot be released, which keeps the agent in its previous state. Omit it when the provider has
|
||||
* no safe way to reconcile; the manager keeps today's behavior.
|
||||
*/
|
||||
abandonForegroundTurn?(): Promise<void>;
|
||||
/** Release live runtime resources without archiving or deleting the durable native session. */
|
||||
close(): Promise<void>;
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
|
||||
@@ -22,6 +22,7 @@ type OptionalAgentSessionMethodName = {
|
||||
|
||||
const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
|
||||
"steerActiveTurn",
|
||||
"abandonForegroundTurn",
|
||||
"listCommands",
|
||||
"setModel",
|
||||
"setThinkingOption",
|
||||
@@ -168,6 +169,10 @@ class FakeSession implements AgentSession {
|
||||
this.recordedCalls.push("revertBoth");
|
||||
}
|
||||
|
||||
async abandonForegroundTurn() {
|
||||
this.recordedCalls.push("abandonForegroundTurn");
|
||||
}
|
||||
|
||||
tryHandleOutOfBand(_prompt: AgentPromptInput) {
|
||||
this.recordedCalls.push("tryHandleOutOfBand");
|
||||
return {
|
||||
@@ -190,6 +195,7 @@ describe("wrapSessionProvider", () => {
|
||||
const wrapped = wrapSessionProvider("custom-claude", session);
|
||||
|
||||
await wrapped.steerActiveTurn?.("follow-up", { expectedTurnId: "turn-1" });
|
||||
await wrapped.abandonForegroundTurn?.();
|
||||
await wrapped.listCommands?.();
|
||||
await wrapped.setModel?.("sonnet");
|
||||
await wrapped.setThinkingOption?.("high");
|
||||
@@ -205,6 +211,7 @@ describe("wrapSessionProvider", () => {
|
||||
]);
|
||||
expect(session.recordedCalls).toEqual([
|
||||
"steerActiveTurn",
|
||||
"abandonForegroundTurn",
|
||||
"listCommands",
|
||||
"setModel",
|
||||
"setThinkingOption",
|
||||
@@ -246,4 +253,10 @@ describe("wrapSessionProvider", () => {
|
||||
session.steerActiveTurn = undefined;
|
||||
expect(wrapSessionProvider("custom-claude", session).steerActiveTurn).toBeUndefined();
|
||||
});
|
||||
|
||||
test("leaves abandoned-turn release unavailable when the provider has no implementation", () => {
|
||||
const session: AgentSession = new FakeSession();
|
||||
session.abandonForegroundTurn = undefined;
|
||||
expect(wrapSessionProvider("custom-claude", session).abandonForegroundTurn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -468,6 +468,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
|
||||
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
|
||||
interrupt: () => inner.interrupt(),
|
||||
abandonForegroundTurn: inner.abandonForegroundTurn?.bind(inner),
|
||||
close: () => inner.close(),
|
||||
listCommands: inner.listCommands?.bind(inner),
|
||||
setModel: inner.setModel?.bind(inner),
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
export interface AgentProviderRuntime {
|
||||
snapshotManager: ProviderSnapshotManager;
|
||||
setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void;
|
||||
setPaseoToolCatalog(catalog: Pick<PaseoToolCatalog, "tools"> | null): void;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
import { ACPAgentSession } from "./acp-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
|
||||
const source = `
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL);
|
||||
appendFileSync(process.env.PROBE_PIDS, String(process.pid) + '\\n');
|
||||
new AgentSideConnection(() => ({
|
||||
async initialize() { return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true } }; },
|
||||
async newSession() { return { sessionId: 'held-session' }; },
|
||||
async loadSession() { return {}; },
|
||||
async authenticate() {},
|
||||
prompt() { return new Promise(() => {}); },
|
||||
async cancel() {},
|
||||
}), ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)));
|
||||
`;
|
||||
|
||||
test("a failed abandoned-runtime kill must block a second provider process", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "acp-abandon-timeout-"));
|
||||
const script = path.join(dir, "agent.mjs");
|
||||
const pidFile = path.join(dir, "pids");
|
||||
await writeFile(script, source);
|
||||
let allowTermination = false;
|
||||
const session = new ACPAgentSession(
|
||||
{ provider: "abandon-probe", cwd: dir },
|
||||
{
|
||||
provider: "abandon-probe",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: [process.execPath, script],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
launchEnv: {
|
||||
ACP_SDK_URL: pathToFileURL(
|
||||
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
|
||||
).href,
|
||||
PROBE_PIDS: pidFile,
|
||||
},
|
||||
// Inject the documented termination result; the ACP subprocess itself is real.
|
||||
terminateProcess: (child, options) =>
|
||||
allowTermination ? terminateWithTreeKill(child, options) : Promise.resolve("kill-timeout"),
|
||||
},
|
||||
);
|
||||
try {
|
||||
await session.initializeNewSession();
|
||||
await session.startTurn("first");
|
||||
await expect(session.abandonForegroundTurn()).rejects.toThrow("provider process did not exit");
|
||||
const firstPid = Number((await readFile(pidFile, "utf8")).trim());
|
||||
expect(() => process.kill(firstPid, 0)).not.toThrow();
|
||||
const next = await session.startTurn("second").then(
|
||||
() => "started",
|
||||
() => "refused",
|
||||
);
|
||||
const pids = (await readFile(pidFile, "utf8")).trim().split("\n").map(Number);
|
||||
for (const pid of pids) expect(() => process.kill(pid, 0)).not.toThrow();
|
||||
console.log({ next, liveProviderProcesses: pids.length });
|
||||
expect(next).toBe("refused");
|
||||
expect(pids).toHaveLength(1);
|
||||
} finally {
|
||||
allowTermination = true;
|
||||
await session.close();
|
||||
const pids = await readFile(pidFile, "utf8").catch(() => "");
|
||||
for (const pid of pids.trim().split("\n").filter(Boolean).map(Number)) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -4269,6 +4269,125 @@ describe("ACP session/load invariant — cwd and mcpServers always passed", () =
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP abandoned foreground turn", () => {
|
||||
/**
|
||||
* A session whose spawn is stubbed, so tests can hold a foreground turn that never settles
|
||||
* while asserting what the release path does to the runtime that owned it.
|
||||
*/
|
||||
function makeDownturnSession(options: { terminator?: ProcessTerminator } = {}) {
|
||||
const loadSession = vi.fn().mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
modes: null,
|
||||
models: null,
|
||||
configOptions: [],
|
||||
});
|
||||
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
|
||||
|
||||
class TestSession extends ACPAgentSession {
|
||||
spawnedChildren = 0;
|
||||
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
this.spawnedChildren += 1;
|
||||
return {
|
||||
child: createProbeChildStub(),
|
||||
connection: {
|
||||
prompt,
|
||||
loadSession,
|
||||
unstable_resumeSession: loadSession,
|
||||
} as unknown as ClientSideConnection,
|
||||
initialize: { agentCapabilities: { loadSession: true } },
|
||||
} as SpawnedACPProcess;
|
||||
}
|
||||
}
|
||||
|
||||
const session = new TestSession(
|
||||
{ provider: "claude-acp", cwd: "/tmp/paseo-acp-test" },
|
||||
{
|
||||
provider: "claude-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["claude", "--acp"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
handle: { sessionId: "session-1", provider: "claude-acp" },
|
||||
...(options.terminator ? { terminateProcess: options.terminator } : {}),
|
||||
},
|
||||
);
|
||||
return { session, prompt, loadSession };
|
||||
}
|
||||
|
||||
test("releases the turn slot and stops the runtime that never answered cancel", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const { session } = makeDownturnSession({ terminator: terminator.terminate });
|
||||
await session.initializeResumedSession();
|
||||
await session.startTurn("first");
|
||||
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeTruthy();
|
||||
|
||||
await session.abandonForegroundTurn();
|
||||
|
||||
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
|
||||
expect(terminator.terminated).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("the next prompt reconnects in a new process instead of failing on the held slot", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const { session } = makeDownturnSession({ terminator: terminator.terminate });
|
||||
await session.initializeResumedSession();
|
||||
await session.startTurn("first");
|
||||
await session.abandonForegroundTurn();
|
||||
|
||||
await expect(session.startTurn("second")).resolves.toEqual({ turnId: expect.any(String) });
|
||||
|
||||
expect(terminator.terminated).toHaveLength(1);
|
||||
// One spawn for the first turn, one for the resume the release forced.
|
||||
expect((session as unknown as { spawnedChildren: number }).spawnedChildren).toBe(2);
|
||||
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeTruthy();
|
||||
});
|
||||
|
||||
test("does not replay history a second time when it reconnects", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const { session } = makeDownturnSession({ terminator: terminator.terminate });
|
||||
await session.initializeResumedSession();
|
||||
const historyBefore = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
historyBefore.push(event);
|
||||
}
|
||||
await session.startTurn("first");
|
||||
await session.abandonForegroundTurn();
|
||||
await session.startTurn("second");
|
||||
|
||||
const replay = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
replay.push(event);
|
||||
}
|
||||
expect(replay).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects when the provider process cannot be stopped", async () => {
|
||||
const session = makeDownturnSession({
|
||||
terminator: async () => "kill-timeout",
|
||||
}).session;
|
||||
await session.initializeResumedSession();
|
||||
await session.startTurn("first");
|
||||
|
||||
await expect(session.abandonForegroundTurn()).rejects.toThrow(
|
||||
"the provider process did not exit",
|
||||
);
|
||||
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
|
||||
test("is a no-op on a session that has no id yet", async () => {
|
||||
const session = makeDownturnSession().session;
|
||||
await expect(session.abandonForegroundTurn()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const SILENT_CLOSE_ACP_AGENT = `
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
|
||||
@@ -1734,7 +1734,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private readonly toolCalls = new Map<string, ACPToolSnapshot>();
|
||||
private readonly terminalEntries = new Map<string, TerminalEntry>();
|
||||
private readonly persistedHistory: AgentTimelineItem[] = [];
|
||||
private readonly initialHandle?: AgentPersistenceHandle;
|
||||
private initialHandle?: AgentPersistenceHandle;
|
||||
private readonly resumePurpose: AgentResumePurpose;
|
||||
|
||||
private readonly config: AgentSessionConfig;
|
||||
@@ -1927,6 +1927,33 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.settleCommandsReady();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the durable session in a fresh process after `abandonForegroundTurn()` killed the
|
||||
* runtime that owned the abandoned turn.
|
||||
*
|
||||
* The replay that `initializeResumedSession()` drains is duplicate history: the daemon already
|
||||
* has every row up to the abandoned turn. Drop the replayed rows and keep the prior
|
||||
* `historyPending` flag so the timeline is not appended twice.
|
||||
*/
|
||||
private async reconnectAbandonedRuntime(): Promise<void> {
|
||||
if (this.connection || !this.sessionId || this.closed) {
|
||||
return;
|
||||
}
|
||||
// A session created in this process never received a resume handle; its durable id is the
|
||||
// handle, the same object `describePersistence()` publishes.
|
||||
this.initialHandle ??= {
|
||||
provider: this.provider,
|
||||
sessionId: this.sessionId,
|
||||
nativeHandle: this.sessionId,
|
||||
};
|
||||
const historyLength = this.persistedHistory.length;
|
||||
const historyPending = this.historyPending;
|
||||
await this.initializeResumedSession();
|
||||
this.persistedHistory.length = historyLength;
|
||||
this.historyPending = historyPending;
|
||||
this.bootstrapThreadEventPending = false;
|
||||
}
|
||||
|
||||
private async closeAfterInitializationFailure(error: unknown): Promise<never> {
|
||||
try {
|
||||
await this.close();
|
||||
@@ -1963,6 +1990,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (this.closed) {
|
||||
throw new Error(`${this.provider} session is closed`);
|
||||
}
|
||||
await this.reconnectAbandonedRuntime();
|
||||
if (!this.connection || !this.sessionId) {
|
||||
throw new Error(`${this.provider} session is not initialized`);
|
||||
}
|
||||
@@ -2554,6 +2582,46 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP turns end when `session/prompt` returns. The manager force-cancels after two seconds
|
||||
* without that answer, so an agent that keeps working after `session/cancel` leaves this
|
||||
* session's turn slot held. The next `startTurn` would then throw "A foreground turn is
|
||||
* already active" on every later prompt, because the daemon believes the agent is idle.
|
||||
*
|
||||
* The runtime that still owns that turn cannot be trusted, so this kills it and drops the
|
||||
* slot. The next prompt respawns through `initializeResumedSession()`; `startTurn` also
|
||||
* reconnects when it finds no connection, which covers the very next message.
|
||||
*/
|
||||
async abandonForegroundTurn(): Promise<void> {
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.synthesizeCanceledToolCalls();
|
||||
this.activeForegroundTurnId = null;
|
||||
this.fallbackAssistantMessageId = null;
|
||||
this.submittedUserMessageTurnId = null;
|
||||
|
||||
const child = this.child;
|
||||
this.connection = null;
|
||||
this.child = null;
|
||||
// A command from createTerminal is a peer of the agent process. The dead connection can no
|
||||
// longer release it, so stop those terminals here as well.
|
||||
const terminations = Array.from(this.terminalEntries.values(), (terminal) =>
|
||||
this.terminateProcess(terminal.child, { gracefulTimeoutMs: 200, forceTimeoutMs: 500 }),
|
||||
);
|
||||
this.terminalEntries.clear();
|
||||
if (child) {
|
||||
terminations.push(
|
||||
this.terminateProcess(child, { gracefulTimeoutMs: 200, forceTimeoutMs: 500 }),
|
||||
);
|
||||
}
|
||||
const results = await Promise.all(terminations);
|
||||
if (results.includes("kill-timeout")) {
|
||||
throw new Error("ACP abandoned turn: the provider process did not exit");
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { CanUseTool, PermissionMode, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentPermissionRequest } from "../../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
// The injected SDK adapter supplies requests through Claude's permission callback.
|
||||
async function createPlanApproval(mode: PermissionMode) {
|
||||
let requestPermission: CanUseTool | undefined;
|
||||
let sdkMode: PermissionMode = mode;
|
||||
const query = Object.assign((async function* () {})(), {
|
||||
async setPermissionMode(next: PermissionMode) {
|
||||
sdkMode = next;
|
||||
},
|
||||
close() {},
|
||||
});
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude",
|
||||
queryFactory: ({ options }) => {
|
||||
requestPermission = options.canUseTool;
|
||||
return query as Query;
|
||||
},
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: mode,
|
||||
});
|
||||
let request: AgentPermissionRequest | undefined;
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "permission_requested") request = event.request;
|
||||
});
|
||||
await session.setMode("plan");
|
||||
if (!requestPermission) throw new Error("SDK permission callback was not installed");
|
||||
const resolution = requestPermission(
|
||||
"ExitPlanMode",
|
||||
{ plan: "Implement the plan" },
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "plan-approval",
|
||||
},
|
||||
);
|
||||
if (!request) throw new Error("No plan approval request was emitted");
|
||||
return { session, request, resolution, sdkMode: () => sdkMode };
|
||||
}
|
||||
|
||||
test.each([
|
||||
["auto", "Implement with Auto mode"],
|
||||
["bypassPermissions", "Implement with Bypass"],
|
||||
] as const)("plan approval offers an explicit return to %s", async (mode, label) => {
|
||||
const plan = await createPlanApproval(mode);
|
||||
try {
|
||||
expect(plan.request.actions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "implement_resume",
|
||||
label,
|
||||
behavior: "allow",
|
||||
intent: "implement_resume",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await plan.session.respondToPermission(plan.request.id, { behavior: "deny" });
|
||||
await plan.resolution;
|
||||
await plan.session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
["auto", "implement_resume", "auto"],
|
||||
["bypassPermissions", "implement_resume", "bypassPermissions"],
|
||||
["auto", "implement", "acceptEdits"],
|
||||
["bypassPermissions", "implement", "acceptEdits"],
|
||||
["default", "implement_resume", "acceptEdits"],
|
||||
["acceptEdits", "implement_resume", "acceptEdits"],
|
||||
] as const)("approving from %s with %s enters %s", async (mode, action, expected) => {
|
||||
const plan = await createPlanApproval(mode);
|
||||
try {
|
||||
await plan.session.respondToPermission(plan.request.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: action,
|
||||
});
|
||||
await expect(plan.resolution).resolves.toMatchObject({ behavior: "allow" });
|
||||
expect(plan.sdkMode()).toBe(expected);
|
||||
expect(await plan.session.getCurrentMode()).toBe(expected);
|
||||
if (mode === "default" || mode === "acceptEdits") {
|
||||
expect(plan.request.actions?.some((entry) => entry.id === "implement_resume")).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
await plan.session.close();
|
||||
}
|
||||
});
|
||||
@@ -1172,7 +1172,7 @@ function buildClaudePlanPermissionActions(
|
||||
},
|
||||
];
|
||||
|
||||
if (resumeMode === "bypassPermissions") {
|
||||
if (resumeMode === "bypassPermissions" || resumeMode === "auto") {
|
||||
actions.push({
|
||||
id: "implement_resume",
|
||||
label: `Implement with ${getClaudeModeLabel(resumeMode)}`,
|
||||
@@ -2813,11 +2813,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (response.behavior === "allow") {
|
||||
if (pending.request.kind === "plan") {
|
||||
const selectedActionId = response.selectedActionId;
|
||||
const resumeMode = this.planResumeMode;
|
||||
const shouldResumePriorMode =
|
||||
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
|
||||
const targetMode: PermissionMode = shouldResumePriorMode
|
||||
? "bypassPermissions"
|
||||
: "acceptEdits";
|
||||
selectedActionId === "implement_resume" &&
|
||||
(resumeMode === "bypassPermissions" || resumeMode === "auto");
|
||||
const targetMode: PermissionMode = shouldResumePriorMode ? resumeMode : "acceptEdits";
|
||||
await this.setMode(targetMode);
|
||||
this.pushToolCall(
|
||||
mapClaudeCompletedToolCall({
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
getRealProviderRuntimeSettings,
|
||||
} from "../../daemon-e2e/real-provider-test-config.js";
|
||||
import type { PaseoToolCatalog } from "../tools/types.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
import { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
|
||||
import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../tools/paseo-tools.js";
|
||||
import { serializePaseoToolInputParameters } from "../tools/paseo-tool-serialization.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js";
|
||||
import { OpenCodeBridge } from "./opencode/bridge.js";
|
||||
@@ -23,6 +28,61 @@ import {
|
||||
requireSessionId,
|
||||
} from "./opencode/test-utils/v2-local-e2e-helpers.js";
|
||||
|
||||
test("real OpenCode server advertises agent-scoped notification descriptions", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-manifest-real-"));
|
||||
const logger = createTestLogger();
|
||||
const providerSnapshotManager = new ProviderSnapshotManager({ logger });
|
||||
const dependencies = {
|
||||
agentManager: new AgentManager({ logger }),
|
||||
agentStorage: new AgentStorage(path.join(root, "agents"), logger),
|
||||
providerSnapshotManager,
|
||||
logger,
|
||||
};
|
||||
const bridge = new OpenCodeBridge({ paseoHome: root, logger });
|
||||
bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies));
|
||||
const manager = new OpenCodeServerManager({
|
||||
logger,
|
||||
resolveHomeDir: () => root,
|
||||
runtimeSettings: {
|
||||
env: {
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
OPENCODE_DISABLE_AUTO_UPDATE: "1",
|
||||
},
|
||||
},
|
||||
decorateServerEnv: (env) => bridge.decorateServerEnv(env),
|
||||
});
|
||||
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
|
||||
try {
|
||||
await bridge.start();
|
||||
inspection = await manager.acquireCurrent();
|
||||
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: root });
|
||||
const response = await sdk.tool.list({ directory: root, provider: "openai", model: "gpt-5.4" });
|
||||
expect(response.error).toBeUndefined();
|
||||
const agentCatalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: "caller" });
|
||||
for (const name of ["create_agent", "send_agent_prompt"]) {
|
||||
const actual = response.data!.find((tool) => tool.id === `paseo_${name}`)!;
|
||||
expect(actual).toBeDefined();
|
||||
const expected = serializePaseoToolInputParameters(agentCatalog.getTool(name)!);
|
||||
expect(actual.parameters).toMatchObject({
|
||||
properties: {
|
||||
notifyOnFinish: {
|
||||
description: (expected.properties as Record<string, { description: string }>)
|
||||
.notifyOnFinish.description,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await inspection?.release();
|
||||
await manager.shutdown();
|
||||
await bridge.close();
|
||||
await providerSnapshotManager.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("real OpenCode server persists provider permissions across creation and resume", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-"));
|
||||
const cwd = path.join(root, "repo");
|
||||
|
||||
@@ -5,15 +5,18 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { z } from "zod";
|
||||
import Ajv from "ajv";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { PaseoToolCatalog } from "../../tools/types.js";
|
||||
import { PaseoToolInputError } from "../../tools/types.js";
|
||||
import { createPaseoToolCatalog } from "../../tools/paseo-tools.js";
|
||||
import { AgentManager } from "../../agent-manager.js";
|
||||
import { AgentStorage } from "../../agent-storage.js";
|
||||
import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js";
|
||||
import { createTestAgentClients } from "../../../test-utils/fake-agent-client.js";
|
||||
import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tools/paseo-tools.js";
|
||||
import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js";
|
||||
import {
|
||||
OpenCodeBridge,
|
||||
loadOpenCodeBridgePluginArtifact,
|
||||
@@ -72,6 +75,144 @@ function readPluginOptions(env: Record<string, string>): {
|
||||
}
|
||||
|
||||
describe("OpenCodeBridge", () => {
|
||||
test("advertises agent-scoped schemas that execute through both bridge plugins", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-manifest-"));
|
||||
temporaryDirectories.push(paseoHome);
|
||||
const logger = createTestLogger();
|
||||
const clients = createTestAgentClients();
|
||||
const agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
|
||||
const agentManager = new AgentManager({ clients, registry: agentStorage, logger });
|
||||
const providerSnapshotManager = new ProviderSnapshotManager({ logger, extraClients: clients });
|
||||
const dependencies = { agentManager, agentStorage, providerSnapshotManager, logger };
|
||||
const bridge = new OpenCodeBridge({ paseoHome, logger });
|
||||
await bridge.start();
|
||||
bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies));
|
||||
try {
|
||||
const parent = await agentManager.createAgent(
|
||||
{ provider: "codex", cwd: paseoHome },
|
||||
undefined,
|
||||
{ workspaceId: "manifest-workspace" },
|
||||
);
|
||||
const catalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: parent.id });
|
||||
bridge.bindSession({ sessionId: "bound", env: {}, tools: catalog });
|
||||
const plugin = readPluginOptions(bridge.decorateServerEnv({}));
|
||||
const response = await fetch(`${plugin.baseUrl}/_internal/opencode/tools`, {
|
||||
headers: { Authorization: `Bearer ${plugin.token}` },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const manifest = z
|
||||
.object({
|
||||
tools: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
inputSchema: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.parse(await response.json());
|
||||
const definitions = new Map(manifest.tools.map((tool) => [tool.name, tool]));
|
||||
const createDefinition = definitions.get("create_agent")!;
|
||||
const sendDefinition = definitions.get("send_agent_prompt")!;
|
||||
const topLevelCatalog = createPaseoToolCatalog(dependencies);
|
||||
for (const name of ["create_agent", "send_agent_prompt"]) {
|
||||
expect(serializePaseoToolInputParameters(topLevelCatalog.getTool(name)!)).toMatchObject({
|
||||
properties: { notifyOnFinish: { default: false } },
|
||||
});
|
||||
}
|
||||
const ajv = new Ajv({ useDefaults: true, strict: false });
|
||||
const createInput = {
|
||||
title: "Bridge child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Hello",
|
||||
notifyOnFinish: false,
|
||||
};
|
||||
expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true);
|
||||
|
||||
const v2Config = z
|
||||
.object({ plugins: z.array(z.object({ package: z.string() })) })
|
||||
.parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT));
|
||||
const v2Module: {
|
||||
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
|
||||
} = await import(
|
||||
pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href
|
||||
);
|
||||
const tools = new Map<string, V2TestTool>();
|
||||
const dispose = await v2Module.default.setup({
|
||||
options: plugin,
|
||||
tool: {
|
||||
transform: async (transform) => {
|
||||
transform({
|
||||
add: (tool) => {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
});
|
||||
return { dispose: async () => undefined };
|
||||
},
|
||||
},
|
||||
session: {
|
||||
context: async () => [],
|
||||
get: async () => ({ parentID: "bound" }),
|
||||
hook: async () => ({ dispose: async () => undefined }),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const created = await tools
|
||||
.get("paseo_create_agent")!
|
||||
.execute(createInput, { sessionID: "bound" });
|
||||
const children = agentManager.listAgents().filter((agent) => agent.id !== parent.id);
|
||||
expect(children).toHaveLength(1);
|
||||
const child = children[0]!;
|
||||
expect(created).toMatchObject({
|
||||
content: [{ type: "text", text: expect.stringContaining(child.id) }],
|
||||
});
|
||||
expect(child.workspaceId).toBe("manifest-workspace");
|
||||
expect([...definitions.keys()]).toEqual([...catalog.tools.keys()]);
|
||||
for (const definition of manifest.tools) {
|
||||
const accepted = catalog.getTool(definition.name)!;
|
||||
expect(definition.inputSchema).toEqual(serializePaseoToolInputParameters(accepted));
|
||||
expect(definition.description).toBe(accepted.description);
|
||||
expect(tools.get(`paseo_${definition.name}`)!.input).toEqual(definition.inputSchema);
|
||||
}
|
||||
expect(createDefinition.inputSchema).toMatchObject({
|
||||
properties: { notifyOnFinish: { default: true } },
|
||||
});
|
||||
expect(createDefinition.inputSchema.properties).not.toHaveProperty("background");
|
||||
expect(sendDefinition.inputSchema).toMatchObject({
|
||||
properties: { notifyOnFinish: { default: true } },
|
||||
});
|
||||
await expect(
|
||||
catalog.executeTool("create_agent", { ...createInput, background: true }),
|
||||
).rejects.toThrow("background");
|
||||
|
||||
const v1Module = await import(plugin.pluginUrl);
|
||||
const hooks = await v1Module.default({}, plugin);
|
||||
expect(Object.keys(hooks.tool.paseo_create_agent.args)).toEqual(
|
||||
Object.keys(
|
||||
z.record(z.string(), z.unknown()).parse(createDefinition.inputSchema.properties),
|
||||
),
|
||||
);
|
||||
const sendInput = { agentId: child.id, prompt: "Hello again", notifyOnFinish: false };
|
||||
expect(ajv.compile(sendDefinition.inputSchema)(sendInput)).toBe(true);
|
||||
await expect(
|
||||
hooks.tool.paseo_send_agent_prompt.execute(sendInput, {
|
||||
sessionID: "bound",
|
||||
ask: async () => undefined,
|
||||
}),
|
||||
).resolves.toMatchObject({ output: expect.stringContaining('"success": true') });
|
||||
} finally {
|
||||
await dispose();
|
||||
}
|
||||
} finally {
|
||||
await bridge.close();
|
||||
await Promise.all(
|
||||
agentManager.listAgents().map((agent) => agentManager.closeAgent(agent.id)),
|
||||
);
|
||||
await agentManager.flushForShutdown();
|
||||
await providerSnapshotManager.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads packaged bundle bytes without invoking source compilation", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-artifact-"));
|
||||
temporaryDirectories.push(root);
|
||||
@@ -696,6 +837,7 @@ describe("OpenCodeBridge", () => {
|
||||
|
||||
interface V2TestTool {
|
||||
name: string;
|
||||
input?: unknown;
|
||||
execute(input: unknown, call: { sessionID: string }): Promise<unknown>;
|
||||
}
|
||||
interface V2TestContext {
|
||||
|
||||
@@ -60,7 +60,7 @@ export class OpenCodeBridge {
|
||||
private baseUrl: string | null = null;
|
||||
private pluginUrl: string | null = null;
|
||||
private v2PluginUrl: string | null = null;
|
||||
private manifestCatalog: PaseoToolCatalog | null = null;
|
||||
private manifestCatalog: Pick<PaseoToolCatalog, "tools"> | null = null;
|
||||
|
||||
constructor(options: OpenCodeBridgeOptions) {
|
||||
this.paseoHome = options.paseoHome;
|
||||
@@ -89,7 +89,7 @@ export class OpenCodeBridge {
|
||||
this.baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
setManifestCatalog(catalog: PaseoToolCatalog | null): void {
|
||||
setManifestCatalog(catalog: Pick<PaseoToolCatalog, "tools"> | null): void {
|
||||
this.manifestCatalog = catalog;
|
||||
}
|
||||
|
||||
|
||||
@@ -637,6 +637,20 @@ function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInp
|
||||
}
|
||||
|
||||
export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog {
|
||||
return createToolCatalog(options, options.callerAgentId ? "agent" : "top-level");
|
||||
}
|
||||
|
||||
export function createPaseoAgentToolManifest(
|
||||
options: PaseoToolHostDependencies,
|
||||
): Pick<PaseoToolCatalog, "tools"> {
|
||||
// Plugins register before a caller exists, but every execution uses an agent-bound catalog.
|
||||
return { tools: createToolCatalog(options, "agent").tools };
|
||||
}
|
||||
|
||||
function createToolCatalog(
|
||||
options: PaseoToolHostDependencies,
|
||||
scope: "agent" | "top-level",
|
||||
): PaseoToolCatalog {
|
||||
const {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
@@ -1209,7 +1223,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
.describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."),
|
||||
};
|
||||
const createAgentInputSchema = z
|
||||
.object(callerAgentId ? agentToAgentInputSchema : canonicalTopLevelInputSchema)
|
||||
.object(scope === "agent" ? agentToAgentInputSchema : canonicalTopLevelInputSchema)
|
||||
.passthrough();
|
||||
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
|
||||
const legacyAgentToAgentCreateAgentArgsSchema = z.object(legacyAgentToAgentInputSchema).strict();
|
||||
@@ -1245,9 +1259,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
"Agent-scoped only: get notified when the prompted agent finishes, errors, or needs permission.",
|
||||
),
|
||||
};
|
||||
const sendAgentPromptInputSchema = callerAgentId
|
||||
? agentToAgentSendAgentPromptInputSchema
|
||||
: topLevelSendAgentPromptInputSchema;
|
||||
const sendAgentPromptInputSchema =
|
||||
scope === "agent" ? agentToAgentSendAgentPromptInputSchema : topLevelSendAgentPromptInputSchema;
|
||||
const inspectProviderInputSchema = {
|
||||
provider: ProviderOrProviderModelInputSchema.describe(
|
||||
"Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).",
|
||||
@@ -2653,7 +2666,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
.optional()
|
||||
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
|
||||
name: z.string().optional(),
|
||||
provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe(
|
||||
provider: (scope === "agent" ? AgentProviderEnum.optional() : AgentProviderEnum).describe(
|
||||
"Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.",
|
||||
),
|
||||
cwd: z.string().optional(),
|
||||
|
||||
@@ -137,6 +137,7 @@ import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import {
|
||||
createPaseoToolCatalog,
|
||||
createPaseoAgentToolManifest,
|
||||
type PaseoToolHostDependencies,
|
||||
} from "./agent/tools/paseo-tools.js";
|
||||
import type { PaseoToolRuntimeContext } from "./agent/tools/types.js";
|
||||
@@ -1457,7 +1458,9 @@ export async function createPaseoDaemon(
|
||||
const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) =>
|
||||
createPaseoToolCatalog(createAgentToolHostDependencies(runtime));
|
||||
const setAgentProviderToolsEnabled = (enabled: boolean) => {
|
||||
agentProviderRuntime.setPaseoToolCatalog(enabled ? createAgentToolCatalog({}) : null);
|
||||
agentProviderRuntime.setPaseoToolCatalog(
|
||||
enabled ? createPaseoAgentToolManifest(createAgentToolHostDependencies({})) : null,
|
||||
);
|
||||
};
|
||||
agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog);
|
||||
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
|
||||
|
||||
@@ -288,6 +288,12 @@ async function runScenario(modeId: "auto" | "auto-review") {
|
||||
try {
|
||||
writeMockCodexConfig(codexHome, mockServer.url);
|
||||
await client.connect();
|
||||
const permissionEvents = client.observeEvents([
|
||||
"agent_permission_request",
|
||||
"agent_permission_resolved",
|
||||
]);
|
||||
permissionEvents.subscribe({ snapshot: () => {}, update: () => {} });
|
||||
await permissionEvents.ready;
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
const agent = await client.createAgent({
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs");
|
||||
|
||||
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])(
|
||||
"%s makes schedule run retention discoverable",
|
||||
async (file) => {
|
||||
const markdown = await readFile(path.join(docsRoot, file), "utf8");
|
||||
expect(markdown).toContain("Archive on finish");
|
||||
if (file !== "schedules.md") {
|
||||
expect(markdown).toContain("/docs/schedules#keep-run-workspaces");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -317,3 +317,15 @@ index 39e0d9d..a21ba95 100644
|
||||
this.stylesCache.clear()
|
||||
this.dependenciesMap.clear()
|
||||
this.disposeListenersMap.clear()
|
||||
diff --git a/node_modules/react-native-unistyles/android/build.gradle b/node_modules/react-native-unistyles/android/build.gradle
|
||||
index 195954b..c4ac28c 100644
|
||||
--- a/node_modules/react-native-unistyles/android/build.gradle
|
||||
+++ b/node_modules/react-native-unistyles/android/build.gradle
|
||||
@@ -38,6 +38,7 @@ if (isNewArchitectureEnabled()) {
|
||||
|
||||
android {
|
||||
compileSdkVersion safeExtGet('compileSdkVersion', 35)
|
||||
+ ndkVersion safeExtGet('ndkVersion', '27.1.12297006')
|
||||
namespace "com.unistyles"
|
||||
|
||||
defaultConfig {
|
||||
|
||||
@@ -155,6 +155,8 @@ Both use the same cron engine, but they have deliberately different interfaces.
|
||||
|
||||
MCP heartbeats are ephemeral: create or delete them. To change one, delete it and create a replacement. Pause, resume, update, inspect, logs, and run-once apply to new-agent schedules only.
|
||||
|
||||
New-agent run workspaces are archived when each run ends by default. `create_schedule` and `update_schedule` do not expose **Archive on finish** (`archiveOnFinish`); [change the option in the app](/docs/schedules#keep-run-workspaces) to keep subsequent run workspaces active.
|
||||
|
||||
### Agent profiles
|
||||
|
||||
| Tool | Function |
|
||||
|
||||
@@ -10,6 +10,8 @@ category: Schedules
|
||||
|
||||
`paseo schedule` creates and manages new-agent [schedules](/docs/schedules) from your terminal, useful for headless boxes and scripts. Every run starts a fresh agent.
|
||||
|
||||
Run workspaces are archived when each run ends by default. The CLI has no **Archive on finish** flag; [change the option in the app](/docs/schedules#keep-run-workspaces) to keep subsequent run workspaces active.
|
||||
|
||||
## Create
|
||||
|
||||
Overnight refactor on Codex:
|
||||
|
||||
@@ -35,3 +35,11 @@ Both run on a cadence you set. To start an agent from an external event instead
|
||||
- **[From chat](/docs/schedules-chat)** — ask the agent in a chat and it sets the schedule up for you.
|
||||
- **[From the CLI](/docs/schedules-cli)** — `paseo schedule create`, for headless boxes and scripts.
|
||||
- **[Over MCP](/docs/mcp)** — agents create and manage schedules programmatically.
|
||||
|
||||
## Keep run workspaces
|
||||
|
||||
Each new-agent schedule run creates a workspace. **Archive on finish** is on by default: when the run ends, Paseo archives that workspace and its agent. With worktree isolation, archiving also removes the worktree after its last active workspace is archived.
|
||||
|
||||
To keep runs available, open the schedule in the app's Schedules view, turn off **Archive on finish**, and save. Paseo keeps each run's workspace and agent active until you archive them; runs that fail before creating an agent are still cleaned up. This option also applies to local isolation; archiving a local workspace leaves the project directory in place.
|
||||
|
||||
The stored option is `target.config.archiveOnFinish`, a boolean that defaults to `true` when omitted. The CLI and MCP schedule tools do not expose this option; use the app to change it. Heartbeats use an existing agent and do not have this option.
|
||||
|
||||
@@ -40,6 +40,8 @@ With a custom root, Paseo keeps the same hashed layout under that directory:
|
||||
3. Review the diff against the base branch
|
||||
4. Merge or archive the workspace; after the last workspace using it is archived, Paseo runs teardown and removes the worktree
|
||||
|
||||
Scheduled runs archive their workspaces when they finish by default. Turn off **Archive on finish** in the schedule's app form to [keep run workspaces](/docs/schedules#keep-run-workspaces) and their worktrees until you archive them.
|
||||
|
||||
## Create a worktree-backed workspace
|
||||
|
||||
The examples below use the current directory as the source checkout. Pass `--path ~/dev/my-app` to create the workspace from another checkout.
|
||||
|
||||
Reference in New Issue
Block a user