fix(ui): remove context compact button (#130775)

* fix(ui): remove context compact button

* fix(ui): remove context compact button

OpenClaw-Publication: 37fcd8f7-5da2-4fe3-afcf-c77609e0ab33

* fix(ui): remove context compact button

OpenClaw-Publication: 8779879a-59ce-40a4-a6ea-26a4f468f1ca

---------

Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
This commit is contained in:
RoboClaw
2026-08-27 00:30:17 -07:00
committed by GitHub
parent 0c5fb0f7ac
commit fa16a4f80e
13 changed files with 123 additions and 215 deletions
+1 -1
View File
@@ -561,7 +561,7 @@ Chat error banners, including cloud runner failures, keep a compact preview. Ope
- If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model.
- Typing `/new` creates and switches to the same fresh dashboard session as New Chat, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; then it resets the main session in place. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session.
- The chat model picker requests the Gateway's configured model view. If `agents.defaults.modelPolicy.allow` is non-empty, that policy drives the picker, including `provider/*` entries that keep provider-scoped catalogs dynamic. Otherwise the picker shows configured entries plus providers with usable auth; aliases and settings under `agents.defaults.models` do not restrict it. The full catalog stays available through the debug `models.list` RPC with `view: "all"`.
- When fresh Gateway session usage reports include current context tokens, the chat composer toolbar shows a small context usage ring with the used percentage. Open the ring for the current context window, latest-run token counts and estimated total cost, provider/model identity, and the latest provider response's input/output/cache cost breakdown when reported. The ring switches to warning styling at high context pressure and, at recommended compaction levels, shows a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again.
- When fresh Gateway session usage reports include current context tokens, the chat composer toolbar shows a small context usage ring with the used percentage. Open the ring for the current context window, latest-run token counts and estimated total cost, provider/model identity, and the latest provider response's input/output/cache cost breakdown when reported. The ring switches to warning styling at high context pressure. Stale token snapshots are hidden until the Gateway reports fresh usage again.
</Accordion>
<Accordion title="Talk mode (browser realtime)">
@@ -554,7 +554,7 @@ suite.define(() => {
}
});
it("keeps stale context visible as approximate without warning or compaction", async () => {
it("keeps stale context visible as approximate without warning", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
@@ -598,7 +598,6 @@ suite.define(() => {
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 suite.closeBrowserContext(context);
}
@@ -52,6 +52,11 @@ const gatewayInjectedSessions = {
ts: Date.now(),
};
const highPressureSessions = {
...gatewayInjectedSessions,
sessions: [{ ...gatewayInjectedSessions.sessions[0], totalTokens: 190_000 }],
};
const claudeSubscriptionAuthStatus = {
ts: baseTime,
providers: [
@@ -144,6 +149,31 @@ async function closeChat(fixture: { context: BrowserContext; page: Page }): Prom
}
suite.define(() => {
it("shows high context pressure without a compact action", async () => {
const fixture = await openChat(authStatusWithUsage, {
"sessions.list": highPressureSessions,
});
const { page } = fixture;
try {
const contextRing = page.locator(".context-ring");
await contextRing.waitFor({ state: "visible" });
expect(await contextRing.getAttribute("aria-label")).toBe(
"Session context usage: 190k of 200k (95%)",
);
expect(
await contextRing.evaluate((element) =>
element.classList.contains("context-ring--warning"),
),
).toBe(true);
expect(await page.locator(".context-usage button").count()).toBe(0);
await page.screenshot({
path: path.join(artifactDir, "00-high-context-without-compact-action.png"),
});
} finally {
await closeChat(fixture);
}
});
it("renders provider usage inside the desktop context popover", async () => {
const fixture = await openChat(authStatusWithUsage, {
"sessions.list": gatewayInjectedSessions,
-3
View File
@@ -5997,8 +5997,6 @@ export const en: TranslationMap = {
runInterrupted: "Interrupted",
runStatus: "Run status: {status}",
compactingContext: "Compacting context...",
compacting: "Compacting",
compact: "Compact",
contextCompacted: "Context compacted",
fallbackActive: "Fallback active: {model}",
fallbackCleared: "Fallback cleared: {model}",
@@ -6017,7 +6015,6 @@ export const en: TranslationMap = {
browserAnnotationRemoved: "Browser annotation removed.",
browserAnnotationUndoUnavailable:
"Undo is unavailable because the browser annotation limit has been reached.",
compactRecommendedContext: "Compact recommended session context",
removeAttachment: "Remove attachment",
removeBrowserAnnotation: "Remove browser annotation: {name}",
addAttachment: "Add attachment",
@@ -354,10 +354,8 @@ describe("renderChatComposer context usage", () => {
expect(container.textContent).not.toContain("Model:");
});
it("warns on fresh high usage but keeps stale usage approximate and nonactionable", () => {
const onCompact = vi.fn();
it("warns on fresh high usage but keeps stale usage approximate", () => {
let container = renderComposer({
onCompact,
sessions: {
sessions: [
{
@@ -371,17 +369,14 @@ describe("renderChatComposer context usage", () => {
defaults: { contextTokens: 200_000 },
} as never,
});
const compact = container.querySelector<HTMLButtonElement>(".context-ring__compact");
expect(compact?.getAttribute("aria-label")).toContain(
const contextRing = container.querySelector(".context-ring");
expect(contextRing?.getAttribute("aria-label")).toBe(
"Session context usage: 190k of 200k (95%)",
);
expect(container.querySelector(".context-ring")).toBeNull();
expect(compact?.textContent).toContain("Compact");
compact?.click();
expect(onCompact).toHaveBeenCalledOnce();
expect(contextRing?.classList).toContain("context-ring--warning");
expect(container.textContent).not.toContain("Compact");
container = renderComposer({
onCompact,
sessions: {
sessions: [
{
@@ -402,6 +397,5 @@ describe("renderChatComposer context usage", () => {
expect(container.querySelector(".context-ring")?.classList).not.toContain(
"context-ring--warning",
);
expect(container.querySelector(".context-ring__compact")).toBeNull();
});
});
-2
View File
@@ -266,7 +266,6 @@ export class ChatPane extends ChatPaneLayoutRender {
hasLocalRun: () => Boolean(state.chatRunId),
sessionParticipationBlocked,
onDenied: (reason) => this.publishHeaderError(reason),
onCompact: () => void state.handleSendChat("/compact"),
onAbort: () => void state.handleAbortChat({ preserveDraft: true }),
onRewind: (entryId) => this.rewindToMessage(entryId),
onFork: (entryId) => this.forkFromMessage(entryId),
@@ -567,7 +566,6 @@ export class ChatPane extends ChatPaneLayoutRender {
followUpModeOverride ? { followUpMode: followUpModeOverride } : undefined,
submissionAction,
),
onCompact: sessionActionCallbacks.onCompact,
// Checkpoint deep-link carries the archived filter so the row stays findable.
onOpenSessionCheckpoints: () => {
const status = selectedSessionArchived ? "&status=archived" : "";
@@ -31,7 +31,7 @@ type SessionActionAccess = ReturnType<typeof readChatSessionActionAccess>;
type SessionAction = keyof SessionActionAccess;
type SessionActionCallbacks = Pick<
ChatProps,
"onAbort" | "onClearHistory" | "onCompact" | "onForkMessage" | "onRewindMessage"
"onAbort" | "onClearHistory" | "onForkMessage" | "onRewindMessage"
>;
export function readChatPaneMutationAccess(
@@ -219,7 +219,6 @@ export function createChatPaneSessionActionCallbacks(params: {
hasLocalRun: () => boolean;
sessionParticipationBlocked: boolean;
onDenied: (reason: string) => void;
onCompact: () => void;
onAbort: () => void;
onRewind: (entryId: string) => Promise<boolean>;
onFork: (entryId: string) => Promise<void>;
@@ -235,13 +234,6 @@ export function createChatPaneSessionActionCallbacks(params: {
return false;
};
return {
onCompact: access.compact.allowed
? () => {
if (requireCurrent("compact")) {
params.onCompact();
}
}
: undefined,
onAbort:
params.sessionParticipationBlocked || !access.abort.allowed
? undefined
-2
View File
@@ -701,7 +701,6 @@ function createChatProps(overrides: Partial<ChatProps> = {}): ChatProps {
onDraftChange: () => undefined,
onRequestUpdate: () => undefined,
onSend: () => undefined,
onCompact: () => undefined,
onToggleRealtimeTalk: () => undefined,
onToggleRealtimeCamera: () => undefined,
onDismissError: () => undefined,
@@ -3167,7 +3166,6 @@ describe("chat loading skeleton", () => {
},
],
},
onCompact: () => undefined,
});
expect(
-2
View File
@@ -227,7 +227,6 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
onSlashIntent?: () => void | Promise<void>;
onSlashCommand?: (command: string) => void;
onSend: ChatComposerProps["onSend"];
onCompact?: () => void | Promise<void>;
onOpenSessionCheckpoints?: () => void | Promise<void>;
onToggleRealtimeTalk?: () => void;
onToggleRealtimeCamera?: () => void;
@@ -485,7 +484,6 @@ export function renderChat(props: ChatProps) {
onSlashIntent: props.onSlashIntent,
onSlashCommand: props.onSlashCommand,
onSend: props.onSend,
onCompact: props.suggestionComposer ? undefined : props.onCompact,
onToggleRealtimeTalk: props.suggestionComposer ? undefined : props.onToggleRealtimeTalk,
onToggleRealtimeCamera: props.onToggleRealtimeCamera,
onSwitchRealtimeCamera: props.onSwitchRealtimeCamera,
@@ -19,13 +19,9 @@ import {
import { handleChatComposerDetailsToggle } from "./chat-picker-overlay.ts";
const CONTEXT_NOTICE_RATIO = 0.85;
const CONTEXT_COMPACT_RATIO = 0.9;
type ContextNoticeOptions = {
compactBusy?: boolean;
compactDisabled?: boolean;
messages?: unknown[];
onCompact?: () => void | Promise<void>;
providerUsage?: ProviderUsageDisplayProps;
};
@@ -125,7 +121,6 @@ function getContextNoticeViewModel(
color: string;
bg: string;
warning: boolean;
compactRecommended: boolean;
approximate: boolean;
} | null {
const used = session?.totalTokens;
@@ -163,7 +158,6 @@ function getContextNoticeViewModel(
color: "var(--muted)",
bg: "color-mix(in srgb, var(--muted) 8%, transparent)",
warning,
compactRecommended: false,
approximate,
};
}
@@ -184,7 +178,6 @@ function getContextNoticeViewModel(
color,
bg,
warning,
compactRecommended: ratio >= CONTEXT_COMPACT_RATIO,
approximate,
};
}
@@ -329,8 +322,6 @@ export function renderContextNotice(
if (!model && quotaGroups.length === 0) {
return nothing;
}
const canRenderCompact = Boolean(model?.compactRecommended && options.onCompact);
const compactDisabled = options.compactDisabled === true || options.compactBusy === true;
const summary = model
? t("chat.composer.contextUsage.summary", {
used: `${model.approximate ? "~" : ""}${formatCompactTokenCount(model.used)}`,
@@ -385,135 +376,92 @@ export function renderContextNotice(
class="context-usage"
style=${model ? `--ctx-color:${model.color};--ctx-bg:${model.bg}` : ""}
>
${canRenderCompact
? html`<button
class="context-ring__compact ${options.compactBusy
? "context-ring__compact--busy"
: ""}"
type="button"
aria-label=${`${summary}. ${t("chat.composer.compactRecommendedContext")}`}
title=${summary}
?disabled=${compactDisabled}
@click=${() => {
if (!compactDisabled) {
void options.onCompact?.();
}
}}
<details @toggle=${handleChatComposerDetailsToggle}>
<summary
class="context-ring ${model?.warning ? "context-ring--warning" : ""}"
aria-label=${summary}
title=${t("chat.composer.contextUsage.open")}
>
<svg
class="context-ring__dial"
viewBox="0 0 16 16"
width="16"
height="16"
aria-hidden="true"
>
<svg
class="context-ring__dial"
viewBox="0 0 16 16"
width="16"
height="16"
aria-hidden="true"
>
<circle class="context-ring__track" cx="8" cy="8" r=${RING_RADIUS} />
<circle
class="context-ring__fill"
cx="8"
cy="8"
r=${RING_RADIUS}
stroke-dasharray=${RING_CIRCUMFERENCE.toFixed(2)}
stroke-dashoffset=${dashOffset.toFixed(2)}
/>
</svg>
<span
>${options.compactBusy
? t("chat.composer.compacting")
: t("chat.composer.compact")}</span
>
</button>`
: html`<details @toggle=${handleChatComposerDetailsToggle}>
<summary
class="context-ring ${model?.warning ? "context-ring--warning" : ""}"
aria-label=${summary}
title=${t("chat.composer.contextUsage.open")}
>
<svg
class="context-ring__dial"
viewBox="0 0 16 16"
width="16"
height="16"
aria-hidden="true"
>
<circle class="context-ring__track" cx="8" cy="8" r=${RING_RADIUS} />
<circle
class="context-ring__fill"
cx="8"
cy="8"
r=${RING_RADIUS}
stroke-dasharray=${RING_CIRCUMFERENCE.toFixed(2)}
stroke-dashoffset=${dashOffset.toFixed(2)}
/>
</svg>
</summary>
<section
class="context-usage__popover"
aria-label=${t("chat.composer.contextUsage.title")}
>
${model
? html`
<div class="context-usage__header">
<span class="context-usage__title"
>${t("chat.composer.contextUsage.contextWindow")}</span
>
<strong class="context-usage__context-value"
>${model.detail} · ${percentage}</strong
>
</div>
<div
class="context-usage__bar"
role="progressbar"
aria-label=${summary}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow=${model.pct}
>
<span style="width: ${model.pct}%"></span>
</div>
`
: nothing}
${model
? html`
<div class="context-usage__section-label">
${t("chat.composer.contextUsage.latestRunTokens")}
</div>
<dl class="context-usage__stats">
<div>
<dt>${t("usage.breakdown.input")}</dt>
<dd>${formatStat(model.input)}</dd>
</div>
<div>
<dt>${t("usage.breakdown.output")}</dt>
<dd>${formatStat(model.output)}</dd>
</div>
${!showCosts || model.cost === null
? nothing
: html`
<div>
<dt>${t("chat.composer.contextUsage.estimatedCost")}</dt>
<dd>${formatCost(model.cost)}</dd>
</div>
`}
</dl>
`
: nothing}
${showCosts && providerCosts && hasProviderCosts
? html`
<div class="context-usage__section-label">
${t("usage.breakdown.costByType")}
</div>
<dl class="context-usage__stats">
${renderCostStat(t("usage.breakdown.input"), providerCosts.input)}
${renderCostStat(t("usage.breakdown.output"), providerCosts.output)}
${renderCostStat(t("usage.breakdown.cacheRead"), providerCosts.cacheRead)}
${renderCostStat(t("usage.breakdown.cacheWrite"), providerCosts.cacheWrite)}
</dl>
`
: nothing}
${planGroups.map((group) => renderQuotaGroup(group, usageHref))}
</section>
</details>`}
<circle class="context-ring__track" cx="8" cy="8" r=${RING_RADIUS} />
<circle
class="context-ring__fill"
cx="8"
cy="8"
r=${RING_RADIUS}
stroke-dasharray=${RING_CIRCUMFERENCE.toFixed(2)}
stroke-dashoffset=${dashOffset.toFixed(2)}
/>
</svg>
</summary>
<section class="context-usage__popover" aria-label=${t("chat.composer.contextUsage.title")}>
${model
? html`
<div class="context-usage__header">
<span class="context-usage__title"
>${t("chat.composer.contextUsage.contextWindow")}</span
>
<strong class="context-usage__context-value"
>${model.detail} · ${percentage}</strong
>
</div>
<div
class="context-usage__bar"
role="progressbar"
aria-label=${summary}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow=${model.pct}
>
<span style="width: ${model.pct}%"></span>
</div>
`
: nothing}
${model
? html`
<div class="context-usage__section-label">
${t("chat.composer.contextUsage.latestRunTokens")}
</div>
<dl class="context-usage__stats">
<div>
<dt>${t("usage.breakdown.input")}</dt>
<dd>${formatStat(model.input)}</dd>
</div>
<div>
<dt>${t("usage.breakdown.output")}</dt>
<dd>${formatStat(model.output)}</dd>
</div>
${!showCosts || model.cost === null
? nothing
: html`
<div>
<dt>${t("chat.composer.contextUsage.estimatedCost")}</dt>
<dd>${formatCost(model.cost)}</dd>
</div>
`}
</dl>
`
: nothing}
${showCosts && providerCosts && hasProviderCosts
? html`
<div class="context-usage__section-label">${t("usage.breakdown.costByType")}</div>
<dl class="context-usage__stats">
${renderCostStat(t("usage.breakdown.input"), providerCosts.input)}
${renderCostStat(t("usage.breakdown.output"), providerCosts.output)}
${renderCostStat(t("usage.breakdown.cacheRead"), providerCosts.cacheRead)}
${renderCostStat(t("usage.breakdown.cacheWrite"), providerCosts.cacheWrite)}
</dl>
`
: nothing}
${planGroups.map((group) => renderQuotaGroup(group, usageHref))}
</section>
</details>
</div>
`;
}
@@ -124,7 +124,6 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
onSlashIntent?: () => void | Promise<void>;
onSlashCommand?: (command: string) => void;
onSend: (followUpModeOverride?: "steer", submissionAction?: Event) => void;
onCompact?: () => void | Promise<void>;
onToggleRealtimeTalk?: () => void;
onToggleRealtimeCamera?: () => void;
onSwitchRealtimeCamera?: () => void;
@@ -97,8 +97,6 @@ export function renderChatComposer(props: ChatComposerProps) {
sendingForCurrentSession || showAbortableUi || Boolean(submittedProgress)
? { phase: "in-progress" as const }
: props.runStatus;
const compactBusy =
props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying";
const activeSession = props.sessions?.sessions?.find((row) =>
areUiSessionKeysEquivalent(row.key, props.sessionKey),
);
@@ -142,10 +140,7 @@ export function renderChatComposer(props: ChatComposerProps) {
activeSession,
props.sessions?.defaults?.contextTokens ?? null,
{
compactBusy,
compactDisabled: !props.connected || !canCompose || isBusy || showAbortableUi,
messages: props.messages,
onCompact: props.onCompact,
providerUsage: props.providerUsage,
},
);
-40
View File
@@ -1132,46 +1132,6 @@ openclaw-chat-page {
font-weight: 650;
}
.context-ring__compact {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 24px;
padding: 0 9px 0 5px;
border-radius: var(--radius-full);
border: 0;
background: color-mix(in srgb, var(--warn) 10%, transparent);
color: color-mix(in srgb, var(--warn) 88%, var(--text));
font: inherit;
font-size: 11px;
font-weight: 500;
line-height: 1;
cursor: var(--cursor-action);
transition:
background 150ms ease-out,
color 150ms ease-out,
opacity 150ms ease-out;
}
.context-ring__compact:hover:not(:disabled) {
background: color-mix(in srgb, var(--warn) 15%, transparent);
color: color-mix(in srgb, var(--warn) 94%, var(--text-strong));
}
.context-ring__compact:active:not(:disabled) {
background: color-mix(in srgb, var(--warn) 19%, transparent);
}
.context-ring__compact:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.context-ring__compact--busy .context-ring__dial {
animation: compaction-spin 1s linear infinite;
}
/* Message images (sent images displayed in chat) */
.chat-message-images {
display: flex;