Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f
- Show omp's Claude usage-limit badge in the composer toolbar, matching the terminal status line during the wrap-up allowance or once `/slow` low priority takes over. Reset times use the browser's locale and timezone. Requires an omp version whose `get_state` reports structured `anthropicSlowMode` state.
- Add **Paste image** to the composer's **+** menu, with Japanese and Simplified Chinese translations. Requires HTTPS (or localhost), browser support for clipboard image reads, and clipboard permission; the item is hidden where the browser cannot read images from the clipboard. Attaching a file remains available.
- Create a workspace by cloning a Git repository: in the **Add workspace** dialog, enter an `https://` or ssh URL and choose **Clone here** to clone into a new folder under the selected directory, which then becomes the active workspace. Clone progress streams into the dialog; **Cancel clone** stops git and deletes the partial clone. git never prompts, so private repositories need a credential helper or ssh agent.
- Add a manual refresh button to the sidebar's Provider Usage panel, with the same success feedback as workspace refresh. Manual refresh bypasses omp-web's usage cache and invalidates omp's cached reports before fetching fresh limits; automatic five-minute refresh remains unchanged. Manual refresh requires an omp that supports `usage invalidate`; an unsupported or failed refresh shows as unavailable instead of reporting success.
- Add an off-by-default **Resume running sessions after a restart** setting in Settings → System & Updates. When omp-web restarts while agents are working, it restarts those sessions and prompts each with "Session interrupted and resumed. Continue as you would have done without the interruption." Work in progress at the moment of the restart, such as a running command, is lost. Do not also resume those sessions from a terminal while omp-web is down.
- Add **Copy** and **Copy as Markdown** below user messages and completed assistant replies, with keyboard access and touch-sized controls. Copy only message text, excluding thinking, tool output, and renderer controls; preserve full source for oversized raw-text messages.
- Scope Ctrl+A / Cmd+A to the selected message, currently loaded chat, or active file contents instead of the whole page. Message selection includes collapsed extension previews and expanded details without toolbar labels. Newer pane focus takes precedence over retained child selections. Text fields and IME composition retain native behavior; browser-menu commands and embedded viewers remain browser-controlled.
Expand Down
2 changes: 1 addition & 1 deletion app/api/provider-usage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function GET(request: Request) {
}

try {
return NextResponse.json(await getProviderUsage({ provider, modelId }));
return NextResponse.json(await getProviderUsage({ provider, modelId }, searchParams.get("refresh") === "true"));
} catch {
return NextResponse.json(
{ error: "Provider usage is currently unavailable", code: "provider_usage_unavailable" },
Expand Down
69 changes: 69 additions & 0 deletions components/AppShell-provider-usage.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import "../tests/setup-dom.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { createJiti } from "jiti";
import { act, cleanup, renderHook } from "@testing-library/react/pure.js";

const jiti = createJiti(import.meta.url, { tryNative: false, tsconfigPaths: true });
const { useProviderUsage } = await jiti.import("./AppShell-provider-usage.ts");
const snapshot = (percent) => ({ reports: [{ provider: "provider", fiveHour: { percent } }] });

test("manual refresh gets fresh limits on older browsers while automatic polling stays silent", async () => {
const originalFetch = globalThis.fetch;
const originalInterval = window.setInterval;
const originalClear = window.clearInterval;
const sizeDescriptor = Object.getOwnPropertyDescriptor(URLSearchParams.prototype, "size");
let poll;
let finishPoll;
let freshPercent = 10;
let cachedPercent = 10;
try {
delete URLSearchParams.prototype.size;
window.setInterval = (callback) => { poll = callback; return 1; };
window.clearInterval = () => {};
globalThis.fetch = async (url) => {
if (new URL(url, "http://localhost").searchParams.get("refresh") === "true") cachedPercent = freshPercent;
if (finishPoll) await new Promise((resolve) => { finishPoll.resolve = resolve; });
return { ok: true, json: async () => snapshot(cachedPercent) };
};
const { result, unmount } = renderHook(() => useProviderUsage("", 300_000));
await act(async () => {});
assert.equal(result.current.snapshot.reports[0].fiveHour.percent, 10);
freshPercent = 80;
finishPoll = {};
act(() => poll());
assert.equal(result.current.loading, false, "background polling must not change the loading state");
assert.equal(result.current.snapshot.reports[0].fiveHour.percent, 10);
await act(async () => { finishPoll.resolve(); });
finishPoll = undefined;
assert.equal(result.current.snapshot.reports[0].fiveHour.percent, 10, "automatic polling must not force cache invalidation");
let refreshed;
await act(async () => { refreshed = await result.current.refresh(); });
assert.equal(refreshed, true);
assert.equal(result.current.snapshot.reports[0].fiveHour.percent, 80, "manual refresh must request fresh limits even without URLSearchParams.size");
freshPercent = 95;
finishPoll = {};
act(() => poll());
let queuedRefresh;
act(() => { queuedRefresh = result.current.refresh(); });
await act(async () => {
const resolve = finishPoll.resolve;
finishPoll = undefined;
resolve();
assert.equal(await queuedRefresh, true);
});
assert.equal(result.current.snapshot.reports[0].fiveHour.percent, 95, "manual refresh during a background poll must still fetch fresh limits");
finishPoll = {};
let cancelled;
act(() => { cancelled = result.current.refresh(); });
unmount();
await act(async () => { finishPoll.resolve(); });
assert.equal(await cancelled, false, "an unmounted refresh cannot report success");
} finally {
cleanup();
globalThis.fetch = originalFetch;
window.setInterval = originalInterval;
window.clearInterval = originalClear;
if (sizeDescriptor) Object.defineProperty(URLSearchParams.prototype, "size", sizeDescriptor);
}
});
45 changes: 34 additions & 11 deletions components/AppShell-provider-usage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import type { ProviderUsageReport, ProviderUsageSnapshot } from "@/lib/provider-usage-types";

export function formatUsageReset(value: number, unit: "minutes" | "hours"): string {
Expand Down Expand Up @@ -51,31 +51,54 @@ export type ProviderUsageState = {
error: boolean;
};

export function useProviderUsage(query: string | null, refreshMs?: number): ProviderUsageState {
export function useProviderUsage(query: string | null, refreshMs?: number): ProviderUsageState & { refresh: () => Promise<boolean> } {
const [state, setState] = useState<ProviderUsageState>({ snapshot: null, loading: false, error: false });
const refreshRef = useRef<() => Promise<boolean>>(async () => false);
const refresh = useCallback(() => refreshRef.current(), []);
useEffect(() => {
if (query === null) {
setState({ snapshot: null, loading: false, error: false });
return;
}
const controller = new AbortController();
setState({ snapshot: null, loading: true, error: false });
const load = async () => {
try {
const response = await fetch(`/api/provider-usage${query ? `?${query}` : ""}`, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const snapshot = await response.json() as ProviderUsageSnapshot;
if (!controller.signal.aborted) setState({ snapshot, loading: false, error: false });
} catch {
if (!controller.signal.aborted) setState({ snapshot: null, loading: false, error: true });
let inFlight: Promise<boolean> | undefined;
let inFlightForced = false;
const load = (force = false): Promise<boolean> => {
if (controller.signal.aborted) return Promise.resolve(false);
if (inFlight) {
return force && !inFlightForced ? inFlight.then(() => load(true)) : inFlight;
}
if (force) setState((previous) => ({ ...previous, loading: true, error: false }));
const params = new URLSearchParams(query);
if (force) params.set("refresh", "true");
const queryString = params.toString();
inFlightForced = force;
inFlight = (async () => {
try {
const response = await fetch(`/api/provider-usage${queryString ? `?${queryString}` : ""}`, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const snapshot = await response.json() as ProviderUsageSnapshot;
if (controller.signal.aborted) return false;
setState({ snapshot, loading: false, error: false });
return true;
} catch {
if (!controller.signal.aborted) setState({ snapshot: null, loading: false, error: true });
return false;
} finally {
inFlight = undefined;
}
})();
return inFlight;
};
refreshRef.current = () => load(true);
void load();
const interval = refreshMs ? window.setInterval(() => void load(), refreshMs) : undefined;
return () => {
refreshRef.current = async () => false;
controller.abort();
if (interval !== undefined) window.clearInterval(interval);
};
}, [query, refreshMs]);
return state;
return { ...state, refresh };
}
33 changes: 30 additions & 3 deletions components/ProviderUsageBar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"use client";

import { useEffect, useState } from "react";
import { ChevronRight, Gauge } from "lucide-react";
import { Check, ChevronRight, Gauge, RefreshCw } from "lucide-react";
import { useI18n } from "@/lib/i18n";
import { formatUsageReset, usageTone, useProviderUsage } from "./AppShell-provider-usage";
import type { ProviderUsageReport, ProviderUsageWindow } from "@/lib/provider-usage-types";
import { SidebarIconButton } from "./SessionSidebar-chrome";
import { Tooltip } from "./ui/primitives";

interface WindowDef {
short: string;
Expand Down Expand Up @@ -63,7 +65,13 @@ function DetailMeter({ short, window, t }: {
// slim row per account (worst window only), click to expand the full detail.
export function ProviderUsageBar() {
const { t } = useI18n();
const { snapshot, loading, error } = useProviderUsage("", 5 * 60_000);
const { snapshot, loading, error, refresh } = useProviderUsage("", 5 * 60_000);
const [refreshDone, setRefreshDone] = useState(false);
useEffect(() => {
if (!refreshDone) return;
const timer = window.setTimeout(() => setRefreshDone(false), 2000);
return () => window.clearTimeout(timer);
}, [refreshDone]);
const reports = snapshot?.reports ?? [];
const [expandedKey, setExpandedKey] = useState<string | null>(null);
// The first render must match the server, where localStorage is unavailable,
Expand Down Expand Up @@ -102,6 +110,7 @@ export function ProviderUsageBar() {
overflowY: "auto",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 2 }}>
<button
type="button"
onClick={() => setCollapsed((v) => {
Expand All @@ -119,7 +128,7 @@ export function ProviderUsageBar() {
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
flex: 1,
boxSizing: "border-box",
padding: "0 4px",
minWidth: 0,
Expand Down Expand Up @@ -156,6 +165,24 @@ export function ProviderUsageBar() {
: t("appShell.providerUsageNoData")}
</span>
</button>
<Tooltip content={t("sessionSidebar.refresh")} side="top">
<SidebarIconButton
label={t("sessionSidebar.refresh")}
active={refreshDone}
disabled={loading}
onClick={() => {
setRefreshDone(false);
void refresh().then(setRefreshDone);
}}
>
{refreshDone ? (
<Check size={14} strokeWidth={2.2} aria-hidden="true" />
) : (
<RefreshCw size={14} strokeWidth={1.9} aria-hidden="true" />
)}
</SidebarIconButton>
</Tooltip>
</div>
{!collapsed && reports.map((report, index) => {
const account = report.accountLabel ?? t("appShell.account", { number: report.accountIndex ?? index + 1 });
const key = `${report.provider}:${account}:${report.modelId ?? "all"}:${index}`;
Expand Down
54 changes: 53 additions & 1 deletion lib/provider-usage.test.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createJiti } from "jiti";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

const jiti = createJiti(import.meta.url);
const { parseProviderUsageOutput } = await jiti.import("./provider-usage.ts");
const { parseProviderUsageOutput, getProviderUsage } = await jiti.import("./provider-usage.ts");
test("parseProviderUsageOutput selects matching model windows", () => {
const now = Date.UTC(2026, 7, 27, 12, 0, 0);
const output = JSON.stringify({
Expand Down Expand Up @@ -152,3 +155,52 @@ test("parseProviderUsageOutput falls back to duration and ignores malformed limi
test("parseProviderUsageOutput rejects invalid JSON", () => {
assert.throws(() => parseProviderUsageOutput("not-json"), /invalid JSON/);
});

test("manual refresh replaces cached limits while automatic reads keep using the cache", { skip: process.platform === "win32" }, async () => {
const dir = await mkdtemp(join(tmpdir(), "ompweb-provider-usage-"));
const bin = join(dir, "omp");
const data = join(dir, "usage.json");
const cache = join(dir, "cached-usage.json");
const failOnce = join(dir, "fail-once");
const previousBin = process.env.OMP_WEB_OMP_BIN;
const usage = (fraction) => JSON.stringify({
reports: [{
provider: "provider",
limits: [{ scope: { windowId: "5h" }, amount: { usedFraction: fraction }, window: {} }],
}],
});
try {
await writeFile(bin, `#!/usr/bin/env node
const fs = require("node:fs");
const cache = ${JSON.stringify(cache)};
const failOnce = ${JSON.stringify(failOnce)};
if (process.argv[3] === "invalidate") {
fs.rmSync(cache, { force: true });
} else if (fs.existsSync(failOnce)) {
fs.rmSync(failOnce);
setTimeout(() => process.exit(1), 100);
} else {
if (!fs.existsSync(cache)) fs.copyFileSync(${JSON.stringify(data)}, cache);
process.stdout.write(fs.readFileSync(cache, "utf8"));
}
`, { mode: 0o755 });
process.env.OMP_WEB_OMP_BIN = bin;
await writeFile(data, usage(0.1));
await writeFile(failOnce, "");
const automatic = getProviderUsage();
const manual = getProviderUsage({}, true);
await Promise.all([
assert.rejects(automatic),
manual.then((report) => assert.equal(report.reports[0].fiveHour.percent, 10, "manual refresh must recover after an overlapping automatic fetch fails")),
]);
await writeFile(data, usage(0.8));
assert.equal((await getProviderUsage()).reports[0].fiveHour.percent, 10);
assert.equal((await getProviderUsage({}, true)).reports[0].fiveHour.percent, 80);
await writeFile(data, usage(0.4));
assert.equal((await getProviderUsage()).reports[0].fiveHour.percent, 80);
} finally {
if (previousBin === undefined) delete process.env.OMP_WEB_OMP_BIN;
else process.env.OMP_WEB_OMP_BIN = previousBin;
await rm(dir, { recursive: true, force: true });
}
});
27 changes: 20 additions & 7 deletions lib/provider-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type CachedUsage = { expiresAt: number; output: string };

let usageCache: CachedUsage | undefined;
let usageInFlight: Promise<string> | undefined;
let usageInFlightForced = false;

function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
Expand Down Expand Up @@ -159,9 +160,16 @@ export function parseProviderUsageOutput(output: string, query: UsageQuery = {},
return { generatedAt, reports };
}

async function fetchProviderUsage(): Promise<string> {
async function fetchProviderUsage(refresh = false): Promise<string> {
const bin = resolveOmpBin();
if (!bin) throw new Error("omp binary not found. Install oh-my-pi or set OMP_WEB_OMP_BIN.");
if (refresh) {
await execFileAsync(bin, ["usage", "invalidate"], {
timeout: USAGE_TIMEOUT_MS,
maxBuffer: USAGE_MAX_BUFFER,
windowsHide: true,
});
}
const { stdout } = await execFileAsync(bin, ["usage", "--json", "--redact"], {
timeout: USAGE_TIMEOUT_MS,
maxBuffer: USAGE_MAX_BUFFER,
Expand All @@ -170,10 +178,15 @@ async function fetchProviderUsage(): Promise<string> {
return stdout;
}

function getUsageOutput(): Promise<string> {
if (usageCache && usageCache.expiresAt > Date.now()) return Promise.resolve(usageCache.output);
if (usageInFlight) return usageInFlight;
usageInFlight = fetchProviderUsage()
function getUsageOutput(refresh = false): Promise<string> {
if (!refresh && usageCache && usageCache.expiresAt > Date.now()) return Promise.resolve(usageCache.output);
if (usageInFlight) {
return refresh && !usageInFlightForced
? usageInFlight.then(() => getUsageOutput(true), () => getUsageOutput(true))
: usageInFlight;
}
usageInFlightForced = refresh;
usageInFlight = fetchProviderUsage(refresh)
.then((output) => {
usageCache = { output, expiresAt: Date.now() + USAGE_CACHE_TTL_MS };
return output;
Expand All @@ -182,6 +195,6 @@ function getUsageOutput(): Promise<string> {
return usageInFlight;
}

export async function getProviderUsage(query: UsageQuery = {}): Promise<ProviderUsageSnapshot> {
return parseProviderUsageOutput(await getUsageOutput(), query);
export async function getProviderUsage(query: UsageQuery = {}, refresh = false): Promise<ProviderUsageSnapshot> {
return parseProviderUsageOutput(await getUsageOutput(refresh), query);
}
Loading