chore: v2.8.22 — Users today/7/30-day spend counts every tool, update chip follows a running update

- Users: today / 7 / 30-day spend read only token_usage_hourly, which holds
  Claude transcript hours only — omp/Codex/Antigravity/Cursor sessions,
  sub-agent files, baselines and long-context pricing were missing. Now
  booked by hourlyTokenRows + calculateCost like the cost routes.
- Update: a page adopts a plugin update the server reports as running
  (after a reload, another tab, or one started from an older bundle), and
  the progress chip shows whenever an update runs and the modal is not
  visible — also when the status stops offering it mid-run.
This commit is contained in:
2026-10-23 11:26:12 +07:00
parent 9dd97a369c
commit 572d615d4e
9 changed files with 122 additions and 62 deletions
+27 -9
View File
@@ -19,9 +19,12 @@
* background process can do for it. Escape, the backdrop and × close it for
* this page load only — also while an update applies: it keeps running in the
* background (the page still reloads itself once the new server is up), and a
* failure re-opens the modal with its error. While hidden, a small chip in the
* corner keeps showing the update — with the plugin chain's current step from
* `plugin_update_progress` — and a click on it shows the modal again; "Skip this version" is the one
* failure re-opens the modal with its error. While hidden — or once the status
* stops offering the update mid-run — a small chip in the corner keeps showing
* it, with the plugin chain's current step from `plugin_update_progress`, and a
* click on it shows the modal again. A chain the server reports as running
* (started before a reload or from another tab) is adopted the same way;
* "Skip this version" is the one
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder
* and starts a Claude Code session there with the error as its prompt — the
@@ -242,13 +245,14 @@ export function UpdateNotifier() {
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it.
// Updating replaces the host's install: only an admin is offered it. A
// running update stays shown (unless closed) even once the status stops
// offering it — the new version may already be on disk before it restarts.
const show = Boolean(
isAdmin &&
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
closedKey !== dismissKey
closedKey !== dismissKey &&
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
);
const busy = applying || restarting || fixing;
@@ -344,6 +348,19 @@ export function UpdateNotifier() {
[syncFromPayload, t]
);
// A chain already running on the server (started before a reload, in another
// tab, or from the old bundle) is adopted: this tab shows its steps, then
// reloads onto the new server like the tab that started it.
const running = status?.plugin_update_progress ?? null;
const adoptedRef = useRef(false);
useEffect(() => {
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
adoptedRef.current = true;
setProgress(running);
setRestarting(true);
pollUntilBack(true, status?.local_version ?? null);
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
@@ -406,8 +423,9 @@ export function UpdateNotifier() {
})
: null;
// Hidden while it runs: a chip keeps it in sight and brings the modal back.
if (!show && isAdmin && (applying || restarting) && dismissKey && closedKey === dismissKey) {
// Hidden while it runs — closed, or the status no longer offers the update
// (the new version is already on disk): a chip keeps it in sight.
if (!show && isAdmin && (applying || restarting)) {
return (
<button
type="button"
@@ -353,6 +353,39 @@ describe("UpdateNotifier — hidden while it runs", () => {
expect(screen.queryByTestId("update-chip")).toBeNull();
});
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
const running: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
// The new version is already installed: the status no longer offers it.
update_available: false,
plugin_update_progress: {
step: "bootstrap",
index: 3,
total: 3,
started_at: new Date().toISOString(),
},
};
statusMock.mockResolvedValue(running);
render(<UpdateNotifier />);
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
});
it("shows no chip when nothing is running", async () => {
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.21 (rebuild client to match)
UI build v2.8.22 (rebuild client to match)
</span>
</p>
</div>