From 00f5df24e51b98cb303306b6e0ebbbb8dc01981f Mon Sep 17 00:00:00 2001 From: heichl_xydigit Date: Mon, 6 Jul 2026 13:44:41 +0800 Subject: [PATCH] fix(webchat): keep context indicator visible with stale token data (#89772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + ui/src/e2e/chat-flow.e2e.test.ts | 50 +++++++++++++++++++ ui/src/pages/chat/chat-composer.test.ts | 36 ++++++++----- ui/src/pages/chat/components/chat-composer.ts | 22 ++++---- 4 files changed, 87 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca0fbbb28a7..cfd596140d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index dbadf558287c..c4d6b782a582 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -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", diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index 9a40209338e9..780c48408f8f 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -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(".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(); }); }); diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index da86f4bd3d63..c161310656b8 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -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)} /> - ${model.pct}% + ${percentage}
${t("chat.composer.contextUsage.contextWindow")} - ${model.detail} · ${model.pct}% + ${model.detail} · ${percentage}