Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
combinations in a separate key bar editor. Existing extra-key preferences keep their keys and order,
and Restore defaults returns the original bar. Native clipboard shortcuts on non-Latin keyboards
and existing Alt combinations remain intact. ([#508](https://github.com/devswha/herdr-web-ui/pull/508))
- The usage meters show each limit's pace: a mark on its bar where an even pace would be by now,
how fast it is being used (`15%/h` for a session, `18%/day` for a week), and **Runs out in 2d 1h**
when that pace reaches the limit before it resets. A chip whose limit runs out before its reset
turns its hairline red while its share is still low, and says when in its label; once that
time has gone by, both stay until the reset as **Runs out before reset**. The pace is
measured when the numbers were read, and shown only where the provider states the window's
start or length (Claude, Codex, Grok), from 5% of the window on.
([#513](https://github.com/devswha/herdr-web-ui/pull/513))

### Changed
- Every agent's chat uses one compact Send/Stop button on desktop and phones. Sending during
Expand Down
8 changes: 6 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,12 +516,16 @@ One set for both themes: the card is island black wherever it shows.
account in the user's order: provider mark, mono `--fs-2xs` percent of the limit chosen in
Settings, the plan's week or its session (used, or left when Settings says so), and a 2px bar on a `--border-strong` track
filled to that percent. From 80% used the percent and bar take `--status-blocked`; amber stays
chrome. A chip whose numbers are stale or missing dims. An account hidden in Settings is
chrome. A limit that runs out before its reset at its pace so far takes `--status-blocked` on
the bar alone. A chip whose numbers are stale or missing dims. An account hidden in Settings is
left out of the strip and the popover; with every account hidden, neither shows.
- The button opens a popover above the footer (`--shadow-pop`, `--radius-lg`), as wide as the
footer and scrolling when it outgrows the sidebar: per account its mark, name and plan pill with
the email or login right-aligned and ellipsized, then one row per limit (label, reset time, right-aligned percent) over a 4px bar. A problem
note is dim, red for an expired sign-in or a failed request.
note is dim, red for an expired sign-in or a failed request. Where the window's start is known, a
2px `--text-strong` mark on the bar sits where an even pace would be by now, and a dim `--fs-2xs`
line under it gives the rate, with **Runs out in …** in `--status-blocked` when it comes before
the reset; once that time has gone by it reads **Runs out before reset** until the reset.

### New workspace dialog
- Agent select comes from `GET /api/agents`; shell-only is always available. Directory defaults to
Expand Down
2 changes: 1 addition & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ names that account and the reset times. Details stay in the strip beside Setting
remote panes do not show this compact reference; failed or unavailable limits are omitted.


The strip beside **Settings** shows the plan limits of the AI tools signed in on the PC the app's server runs on: for each account its provider's logo and one limit, the plan's week or its 5-hour session as chosen in Settings (red from 80%). A plan with neither shows its limit closest to running out. Tap it for every limit (5-hour session, week, month, per model where a plan has them) and when each starts over. It is off until you turn it on in **Settings → Subscription usage**: turning it on sends the sign-ins on the server's PC to each provider's usage endpoint.
The strip beside **Settings** shows the plan limits of the AI tools signed in on the PC the app's server runs on: for each account its provider's logo and one limit, the plan's week or its 5-hour session as chosen in Settings (red from 80%). A plan with neither shows its limit closest to running out. Tap it for every limit (5-hour session, week, month, per model where a plan has them) and when each starts over. Where the provider states when a limit's window began, the limit also shows its pace: a mark on the bar where an even pace would be by now, the rate so far, and when it runs out at that rate if that comes before the reset; a chip whose limit runs out early turns its bar red. It is off until you turn it on in **Settings → Subscription usage**: turning it on sends the sign-ins on the server's PC to each provider's usage endpoint.

| Provider | Sign-in it reads |
| --- | --- |
Expand Down
51 changes: 33 additions & 18 deletions scripts/usage-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,35 @@ import type { Browser, BrowserContext } from "playwright-core";
import type { UsageReport } from "../shared/protocol.ts";

const at = (hours: number): string => new Date(Date.now() + hours * 3600_000).toISOString();
const REPORT: UsageReport = {
/** Built when it is asked for: its times, and the paces measured from them, hold however late in the run that is. */
const report = (): UsageReport => ({
providers: [
{ id: "claude", key: "claude:u1", account: "me@example.com", plan: "max", problem: null, checked_at: "2026-09-29T12:00:00Z", windows: [
{ kind: "session", scope: null, used_percent: 42, resets_at: at(2.2) },
{ kind: "week", scope: null, used_percent: 63.4, resets_at: at(82) },
{ kind: "week", scope: "Sonnet", used_percent: 5, resets_at: null },
{ id: "claude", key: "claude:u1", account: "me@example.com", plan: "max", problem: null, checked_at: at(0), windows: [
// 2.8 hours into the session at 15%/h: lasts to the reset
{ kind: "session", scope: null, used_percent: 42, resets_at: at(2.2), starts_at: at(2.2 - 5) },
// 86 hours into the week: runs out in about 2 days, 1.4 days before the reset
{ kind: "week", scope: null, used_percent: 63.4, resets_at: at(82), starts_at: at(82 - 168) },
{ kind: "week", scope: "Sonnet", used_percent: 5, resets_at: null, starts_at: null },
] },
{ id: "codex", key: "codex:work", account: "me@work.example", plan: "pro", problem: null, checked_at: "2026-09-29T12:00:00Z", windows: [
{ kind: "session", scope: null, used_percent: 12, resets_at: at(0.8) },
{ kind: "week", scope: null, used_percent: 91, resets_at: at(99) },
{ id: "codex", key: "codex:work", account: "me@work.example", plan: "pro", problem: null, checked_at: at(0), windows: [
{ kind: "session", scope: null, used_percent: 12, resets_at: at(0.8), starts_at: at(0.8 - 5) },
{ kind: "week", scope: null, used_percent: 91, resets_at: at(99), starts_at: at(99 - 168) },
] },
{ id: "codex", key: "codex:home", account: "me@example.com", plan: "plus", problem: null, checked_at: "2026-09-29T12:00:00Z", windows: [
{ kind: "week", scope: null, used_percent: 30, resets_at: at(120) },
{ kind: "week", scope: null, used_percent: 30, resets_at: at(120), starts_at: null },
] },
{ id: "cursor", key: "cursor:c1", account: null, plan: "pro", problem: null, checked_at: "2026-09-29T12:00:00Z", windows: [{ kind: "month", scope: null, used_percent: 20, resets_at: at(300) }] },
{ id: "copilot", key: "copilot:me", account: "me", plan: "free", problem: null, checked_at: "2026-09-29T12:00:00Z", windows: [{ kind: "month", scope: "Chat", used_percent: 0, resets_at: at(34) }] },
{ id: "grok", key: "grok:g1", account: null, plan: null, problem: "rate_limited", checked_at: "2026-09-29T11:40:00Z", windows: [{ kind: "week", scope: null, used_percent: 0, resets_at: null }] },
{ id: "cursor", key: "cursor:c1", account: null, plan: "pro", problem: null, checked_at: "2026-09-29T12:00:00Z", windows: [{ kind: "month", scope: null, used_percent: 20, resets_at: at(300), starts_at: null }] },
{ id: "copilot", key: "copilot:me", account: "me", plan: "free", problem: null, checked_at: "2026-09-29T12:00:00Z", windows: [{ kind: "month", scope: "Chat", used_percent: 0, resets_at: at(34), starts_at: null }] },
{ id: "grok", key: "grok:g1", account: null, plan: null, problem: "rate_limited", checked_at: "2026-09-29T11:40:00Z", windows: [{ kind: "week", scope: null, used_percent: 0, resets_at: null, starts_at: null }] },
{ id: "antigravity", key: "antigravity@keychain", account: null, plan: null, problem: "expired", checked_at: null, windows: [] },
],
};
});

async function staged(context: BrowserContext): Promise<string[]> {
const asked: string[] = [];
await context.route("**/api/usage*", (route) => {
asked.push(new URL(route.request().url()).search);
return route.fulfill({ json: REPORT });
return route.fulfill({ json: report() });
});
return asked;
}
Expand All @@ -58,12 +61,14 @@ export async function checkUsageMeters(browser: Browser, origin: string): Promis
await page.keyboard.press("Escape");
await strip.waitFor();

// three chips and "+4" past four accounts, in the server's order, each its plan's week; one near its limit is red; two Codex accounts apart
// three chips and "+4" past four accounts, in the server's order, each its plan's week; one near its limit is red; two Codex accounts apart;
// a week running out before its reset says when, and its hairline turns red while its share is still low
assert.equal(await page.locator(".usage-chip").count(), 3);
assert.equal(await page.locator(".usage-more").textContent(), "+4");
assert.equal(await strip.getAttribute("aria-label"),
"Subscription usage: Claude · me@example.com 63%, Codex · me@work.example 91%, Codex · me@example.com 30%, Cursor 20%, Copilot · me 0%, Grok 0%, Antigravity —");
assert.match(await strip.getAttribute("aria-label") ?? "",
/^Subscription usage: Claude · me@example\.com 63% \(Runs out in 2d 1h\), Codex · me@work\.example 91% \(Runs out in 6h \d+m\), Codex · me@example\.com 30%, Cursor 20%, Copilot · me 0%, Grok 0%, Antigravity —$/);
assert.equal(await page.locator(".usage-chip").nth(1).evaluate((chip) => chip.classList.contains("is-high")), true);
assert.deepEqual(await page.locator(".usage-chip").evaluateAll((chips) => chips.map((chip) => chip.classList.contains("is-fast"))), [true, true, false]);
assert.equal(await strip.evaluate((el) => el.scrollWidth <= el.clientWidth), true, "the chips fit beside Settings");
const [settings, meters] = await Promise.all([page.locator(".sidebar-footer-row .sidebar-footer-action").boundingBox(), strip.boundingBox()]);
assert.ok(settings && meters && settings.x + settings.width <= meters.x, "Settings and the meters do not overlap");
Expand All @@ -75,6 +80,14 @@ export async function checkUsageMeters(browser: Browser, origin: string): Promis
assert.equal(await popover.getByRole("meter").count(), 9);
assert.equal(await popover.locator(".usage-account").first().textContent(), "me@example.com");
assert.equal(await popover.locator(".usage-row.is-high").count(), 1);
// the pace of every window whose start is known: a marker where an even pace would be, the rate, and when it runs out if before the reset
assert.equal(await popover.locator(".usage-bar-pace").count(), 4);
const paces = await popover.locator(".usage-row-pace").allTextContents();
[/^15%\/h$/, /^18%\/day · Runs out in 2d 1h$/, /^2\.\d%\/h$/, /^32%\/day · Runs out in 6h \d+m$/].forEach((pattern, index) => assert.match(paces[index] ?? "", pattern));
assert.equal(paces.length, 4);
const marker = () => popover.locator(".usage-bar-pace").first().evaluate((el) => parseFloat((el as HTMLElement).style.left));
const usedMarker = await marker();
assert.ok(usedMarker > 55 && usedMarker < 58, `a session 2.8 of 5 hours in puts the marker 56% along, not ${usedMarker}%`);
assert.equal(await popover.locator(".usage-note.is-problem").count(), 1, "only the expired sign-in reads as an error");
if (process.env.UI_EVIDENCE_DIR) {
mkdirSync(process.env.UI_EVIDENCE_DIR, { recursive: true });
Expand All @@ -95,12 +108,14 @@ export async function checkUsageMeters(browser: Browser, origin: string): Promis
await page.getByRole("button", { name: "Move Cursor up", exact: true }).click();
await page.keyboard.press("Escape");
assert.equal(await strip.getAttribute("aria-label"),
"Subscription usage: Claude · me@example.com 37% left, Cursor 80% left, Codex · me@example.com 70% left, Copilot · me 100% left, Grok 100% left, Antigravity —");
"Subscription usage: Claude · me@example.com 37% left (Runs out in 2d 1h), Cursor 80% left, Codex · me@example.com 70% left, Copilot · me 100% left, Grok 100% left, Antigravity —");
assert.deepEqual(await page.locator(".usage-chip-value").allTextContents(), ["37%", "80%", "70%"]);
await strip.click();
assert.deepEqual((await names()).slice(0, 3), ["Claude · me@example.com", "Cursor", "Codex · me@example.com"], "a hidden account is left out of the popover too");
assert.equal(await popover.locator(".usage-provider").count(), 6);
assert.equal(await popover.locator(".usage-row-value").first().textContent(), "58% left");
const leftMarker = await marker();
assert.ok(leftMarker > 42 && leftMarker < 45, `counting what is left, the marker sits where an even pace leaves 44%, not ${leftMarker}%`);
if (process.env.UI_EVIDENCE_DIR) await page.locator(".sidebar-shell").screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "usage-popover-left.png") });
await page.keyboard.press("Escape");
await settingsButton.click();
Expand Down
30 changes: 15 additions & 15 deletions server/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ describe("providers", () => {
expect(report.providers).toEqual([{
id: "codex", key: "codex:user-1__acct-1", account: "me@example.com", plan: "pro", problem: null, checked_at: new Date(NOW).toISOString(),
windows: [
{ kind: "session", scope: null, used_percent: 12, resets_at: new Date(NOW + HOUR).toISOString() },
{ kind: "week", scope: null, used_percent: 77, resets_at: new Date(NOW + 2 * HOUR).toISOString() },
{ kind: "session", scope: null, used_percent: 12, resets_at: new Date(NOW + HOUR).toISOString(), starts_at: new Date(NOW - 4 * HOUR).toISOString() },
{ kind: "week", scope: null, used_percent: 77, resets_at: new Date(NOW + 2 * HOUR).toISOString(), starts_at: new Date(NOW + 2 * HOUR - 7 * 24 * HOUR).toISOString() },
],
}]);
const headers = requests[0]!.init.headers as Record<string, string>;
Expand All @@ -118,9 +118,9 @@ describe("providers", () => {
expect((requests[0]!.init.headers as Record<string, string>)["authorization"]).toBe("Bearer k");
expect(usage!.plan).toBe("max");
expect(usage!.windows).toEqual([
{ kind: "session", scope: null, used_percent: 42, resets_at: "2026-09-29T14:00:00.000Z" },
{ kind: "week", scope: null, used_percent: 63.4, resets_at: "2026-10-03T00:00:00.000Z" },
{ kind: "week", scope: "Sonnet", used_percent: 5, resets_at: null },
{ kind: "session", scope: null, used_percent: 42, resets_at: "2026-09-29T14:00:00.000Z", starts_at: "2026-09-29T09:00:00.000Z" },
{ kind: "week", scope: null, used_percent: 63.4, resets_at: "2026-10-03T00:00:00.000Z", starts_at: "2026-09-26T00:00:00.000Z" },
{ kind: "week", scope: "Sonnet", used_percent: 5, resets_at: null, starts_at: null },
]);
});

Expand All @@ -138,9 +138,9 @@ describe("providers", () => {
} });
const [usage] = (await new UsageService(context("darwin"), only("claude")).report()).providers;
expect(usage!.windows).toEqual([
{ kind: "session", scope: null, used_percent: 42, resets_at: "2026-09-29T14:00:00.000Z" },
{ kind: "week", scope: "Sonnet", used_percent: 5, resets_at: null },
{ kind: "week", scope: "Fable", used_percent: 30, resets_at: "2026-10-11T19:59:00.000Z" },
{ kind: "session", scope: null, used_percent: 42, resets_at: "2026-09-29T14:00:00.000Z", starts_at: "2026-09-29T09:00:00.000Z" },
{ kind: "week", scope: "Sonnet", used_percent: 5, resets_at: null, starts_at: null },
{ kind: "week", scope: "Fable", used_percent: 30, resets_at: "2026-10-11T19:59:00.000Z", starts_at: null },
]);
});

Expand Down Expand Up @@ -366,7 +366,7 @@ describe("providers", () => {
config: { currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start: "2026-09-25T00:00:00Z", end: "2026-10-02T00:00:00Z" } },
} });
const [usage] = (await new UsageService(context(), only("grok")).report()).providers;
expect(usage!.windows).toEqual([{ kind: "week", scope: null, used_percent: 0, resets_at: "2026-10-02T00:00:00.000Z" }]);
expect(usage!.windows).toEqual([{ kind: "week", scope: null, used_percent: 0, resets_at: "2026-10-02T00:00:00.000Z", starts_at: "2026-09-25T00:00:00.000Z" }]);
});

it("reads every Grok account signed in", async () => {
Expand Down Expand Up @@ -399,8 +399,8 @@ describe("providers", () => {
const [usage] = (await new UsageService(context("darwin"), only("antigravity")).report()).providers;
// a bucket without a fraction is left out, never shown as exhausted
expect(usage!.windows).toEqual([
{ kind: "session", scope: null, used_percent: 25, resets_at: "2026-09-29T16:00:00.000Z" },
{ kind: "session", scope: "Other models", used_percent: 100, resets_at: "2026-09-29T15:00:00.000Z" },
{ kind: "session", scope: null, used_percent: 25, resets_at: "2026-09-29T16:00:00.000Z", starts_at: null },
{ kind: "session", scope: "Other models", used_percent: 100, resets_at: "2026-09-29T15:00:00.000Z", starts_at: null },
]);
});

Expand Down Expand Up @@ -459,9 +459,9 @@ describe("providers", () => {
id: "opencode",
plan: "Go",
windows: [
{ kind: "session", scope: null, used_percent: 10, resets_at: "2026-09-29T16:00:00.000Z" },
{ kind: "week", scope: null, used_percent: 45, resets_at: "2026-10-05T00:00:00.000Z" },
{ kind: "month", scope: null, used_percent: 20, resets_at: "2026-10-27T00:00:00.000Z" },
{ kind: "session", scope: null, used_percent: 10, resets_at: "2026-09-29T16:00:00.000Z", starts_at: null },
{ kind: "week", scope: null, used_percent: 45, resets_at: "2026-10-05T00:00:00.000Z", starts_at: null },
{ kind: "month", scope: null, used_percent: 20, resets_at: "2026-10-27T00:00:00.000Z", starts_at: null },
],
});
expect((requests[0]!.init.headers as Record<string, string>)["authorization"]).toBe("Bearer oc_sk_test");
Expand Down Expand Up @@ -677,7 +677,7 @@ describe("accounts across reads (review fixes)", () => {
},
};
}
const window = (used: number) => ({ kind: "session" as const, scope: "5h", used_percent: used, resets_at: null });
const window = (used: number) => ({ kind: "session" as const, scope: "5h", used_percent: used, resets_at: null, starts_at: null });

it("does not show the previous account's numbers when the same place now holds another account's failing sign-in", async () => {
let signIn = { source: "/home/me/.codex", token: "alice-token", expiresAt: null, account: { id: "alice", label: "alice@example.com" } };
Expand Down
Loading
Loading