fix(webchat): keep context indicator visible with stale token data (#89772)

* fix(webchat): keep context indicator visible with stale token data

The context usage indicator was disappearing after sending a message because
totalTokensFresh was set to false during the run, even though we had valid
token count data from before the message was sent.

Changes:
- Modified getContextNoticeViewModel to show the indicator even when
  totalTokensFresh is false, as long as totalTokens is non-zero
- Added isStale flag to indicate when token data is not fresh
- Applied subtle visual styling for stale data (lighter colors, 5% opacity)
- Added "(updating)" suffix to the percentage text and title when stale
- Updated tests to verify the new behavior

Fixes #89662

* test(ui): cover stale context indicator behavior

* docs(changelog): note Control UI fix

---------

Co-authored-by: 黑承亮0668000844 <bladin@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
heichl_xydigit
2026-07-06 13:44:41 +08:00
committed by GitHub
parent b3ba030707
commit 00f5df24e5
4 changed files with 87 additions and 22 deletions

View File

@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
- **Prompt-release media delivery:** accept active-leaf-preserving side appends while an embedded run temporarily releases its session lock, so successive message-tool media replies merge without a false session-takeover failure. (#100033, #100490) Thanks @scotthuang.
- **Control UI Skills filters:** align agent and search controls, use translated labels, and preserve native checkbox and radio sizing. (#100526, #99996) Thanks @evan-YM.
- **Control UI completed-run state:** bind active and completed updates to run identities so stale completions keep Send available while newer runs remain active. (#100527, #91680) Thanks @tiffanychum.
- **Control UI context usage:** keep stale cached totals visible as approximate without triggering warning styling or Compact actions. (#89772) Thanks @bladin.
- **Control UI file previews:** remove the duplicate Escape header hint while retaining the Close-button shortcut hint and Escape behavior. (#100528, #99029) Thanks @xianshishan.
- **Control UI autonomous tool failures:** preserve an earlier Tool error outcome across later autonomous recovery turns. (#100514, #98888) Thanks @qingminglong.
- **Agent empty replies:** surface a visible failure when a completed interactive turn has no deliverable reply, including queued follow-ups, while preserving explicit silence, pending continuations, and committed side effects, honoring queued send policies, and treating compaction notices as progress. (#100456) Thanks @mushuiyu886.

View File

@@ -369,6 +369,56 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
}
});
it("keeps stale context visible as approximate without warning or compaction", async () => {
const context = await newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
methodResponses: {
"sessions.list": {
count: 1,
defaults: { contextTokens: 200_000, model: "gpt-5.5", modelProvider: "openai" },
path: "",
sessions: [
{
contextTokens: 200_000,
key: "main",
kind: "direct",
totalTokens: 190_000,
totalTokensFresh: false,
updatedAt: Date.now(),
},
],
ts: Date.now(),
},
},
});
try {
await page.goto(`${server.baseUrl}chat`);
const trigger = page.locator("summary.context-ring");
await trigger.waitFor({ timeout: 10_000 });
expect((await trigger.textContent())?.trim()).toBe("~95%");
expect(await trigger.getAttribute("aria-label")).toBe(
"Session context usage: ~190k of 200k (~95%)",
);
expect(
await trigger.evaluate((element) => element.classList.contains("context-ring--warning")),
).toBe(false);
await trigger.click();
await expect
.poll(() => page.locator(".context-usage__popover").textContent())
.toContain("~190k / 200k · ~95%");
expect(await page.locator(".context-ring__action").count()).toBe(0);
} finally {
await closeBrowserContext(context);
}
});
it("keeps a targetless message-tool source reply beside the automatic final reply", async () => {
const context = await newBrowserContext({
locale: "en-US",

View File

@@ -451,19 +451,29 @@ describe("context notice", () => {
200_000,
),
).toBeNull();
expect(
getContextNoticeViewModel(
{
key: "main",
kind: "direct",
updatedAt: null,
totalTokens: 190_000,
totalTokensFresh: false,
contextTokens: 200_000,
},
200_000,
),
).toBeNull();
const staleSession: GatewaySessionRow = {
key: "main",
kind: "direct",
updatedAt: null,
totalTokens: 190_000,
totalTokensFresh: false,
contextTokens: 200_000,
};
expect(getContextNoticeViewModel(staleSession, 200_000)).toMatchObject({
pct: 95,
detail: "~190k / 200k",
approximate: true,
warning: false,
compactRecommended: false,
});
render(renderContextNotice(staleSession, 200_000, { onCompact }), container);
const staleNotice = container.querySelector<HTMLElement>(".context-ring");
expect(staleNotice?.textContent?.trim()).toBe("~95%");
expect(staleNotice?.classList.contains("context-ring--warning")).toBe(false);
expect(staleNotice?.getAttribute("aria-label")).toBe(
"Session context usage: ~190k of 200k (~95%)",
);
expect(container.querySelector(".context-ring__action")).toBeNull();
});
});

View File

@@ -1290,18 +1290,19 @@ export function getContextNoticeViewModel(
bg: string;
warning: boolean;
compactRecommended: boolean;
approximate: boolean;
} | null {
if (session?.totalTokensFresh === false) {
return null;
}
const used = session?.totalTokens;
const limit = session?.contextTokens ?? defaultContextTokens ?? 0;
if (typeof used !== "number" || !Number.isFinite(used) || used < 0 || !limit) {
return null;
}
const approximate = session?.totalTokensFresh === false;
const ratio = used / limit;
const pct = Math.min(Math.round(ratio * 100), 100);
const warning = ratio >= CONTEXT_NOTICE_RATIO;
// A stale total is still useful orientation, but must not drive warning or
// compaction decisions because the session may already have compacted.
const warning = !approximate && ratio >= CONTEXT_NOTICE_RATIO;
// Session rows expose the latest run snapshot; totalTokens is the separate context snapshot.
const input = Number.isFinite(session?.inputTokens) ? (session?.inputTokens ?? null) : null;
const output = Number.isFinite(session?.outputTokens) ? (session?.outputTokens ?? null) : null;
@@ -1324,11 +1325,12 @@ export function getContextNoticeViewModel(
return {
pct,
...usage,
detail: `${formatCompactTokenCount(used)} / ${formatCompactTokenCount(limit)}`,
detail: `${approximate ? "~" : ""}${formatCompactTokenCount(used)} / ${formatCompactTokenCount(limit)}`,
color: "var(--muted)",
bg: "color-mix(in srgb, var(--muted) 8%, transparent)",
warning,
compactRecommended: false,
approximate,
};
}
const { warnRgb, dangerRgb } = getThemeNoticeColors();
@@ -1349,6 +1351,7 @@ export function getContextNoticeViewModel(
bg,
warning,
compactRecommended: ratio >= CONTEXT_COMPACT_RATIO,
approximate,
};
}
@@ -1367,10 +1370,11 @@ export function renderContextNotice(
const canRenderCompact = model.compactRecommended && options.onCompact;
const compactDisabled = options.compactDisabled === true || options.compactBusy === true;
const summary = t("chat.composer.contextUsage.summary", {
used: formatCompactTokenCount(model.used),
used: `${model.approximate ? "~" : ""}${formatCompactTokenCount(model.used)}`,
limit: formatCompactTokenCount(model.limit),
pct: String(model.pct),
pct: `${model.approximate ? "~" : ""}${model.pct}`,
});
const percentage = `${model.approximate ? "~" : ""}${model.pct}%`;
const dashOffset = RING_CIRCUMFERENCE * (1 - model.pct / 100);
const providerCosts = latestProviderCostStats(options.messages);
const provider = providerCosts?.provider ?? model.provider;
@@ -1411,14 +1415,14 @@ export function renderContextNotice(
stroke-dashoffset=${dashOffset.toFixed(2)}
/>
</svg>
<span class="context-ring__pct">${model.pct}%</span>
<span class="context-ring__pct">${percentage}</span>
</summary>
<section class="context-usage__popover" aria-label=${t("chat.composer.contextUsage.title")}>
<div class="context-usage__header">
<span class="context-usage__title"
>${t("chat.composer.contextUsage.contextWindow")}</span
>
<strong class="context-usage__context-value">${model.detail} · ${model.pct}%</strong>
<strong class="context-usage__context-value">${model.detail} · ${percentage}</strong>
</div>
<div
class="context-usage__bar"