mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 23:23:54 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user