diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 0cba747262c8..cba734171e16 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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. diff --git a/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts index 6e8cb9f04976..d36ee551f8e7 100644 --- a/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.navigation-presentation.e2e.test.ts @@ -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); } diff --git a/ui/src/e2e/chat-quota-pill-93041.e2e.test.ts b/ui/src/e2e/chat-quota-pill-93041.e2e.test.ts index 3c1eda3ea856..ede7d2a50298 100644 --- a/ui/src/e2e/chat-quota-pill-93041.e2e.test.ts +++ b/ui/src/e2e/chat-quota-pill-93041.e2e.test.ts @@ -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, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 14e272fcc838..022414cf512f 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/pages/chat/chat-composer-context.test.ts b/ui/src/pages/chat/chat-composer-context.test.ts index 058ca8fd70ae..09566cb135f6 100644 --- a/ui/src/pages/chat/chat-composer-context.test.ts +++ b/ui/src/pages/chat/chat-composer-context.test.ts @@ -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(".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(); }); }); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 0c7a29231b35..0abd904218a3 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -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" : ""; diff --git a/ui/src/pages/chat/chat-pane-session-controls.ts b/ui/src/pages/chat/chat-pane-session-controls.ts index f80ea8ed2dc9..0df673048d23 100644 --- a/ui/src/pages/chat/chat-pane-session-controls.ts +++ b/ui/src/pages/chat/chat-pane-session-controls.ts @@ -31,7 +31,7 @@ type SessionActionAccess = ReturnType; 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; onFork: (entryId: string) => Promise; @@ -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 diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 3fe422cc53f5..f8baf1aaa6cf 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -701,7 +701,6 @@ function createChatProps(overrides: Partial = {}): 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( diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 9dabc6d8f5de..02164c695f4c 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -227,7 +227,6 @@ export type ChatProps = ChatTaskSuggestionTrayProps & onSlashIntent?: () => void | Promise; onSlashCommand?: (command: string) => void; onSend: ChatComposerProps["onSend"]; - onCompact?: () => void | Promise; onOpenSessionCheckpoints?: () => void | Promise; 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, diff --git a/ui/src/pages/chat/components/chat-composer-context.ts b/ui/src/pages/chat/components/chat-composer-context.ts index 705adb1821c8..415dbb8cd1df 100644 --- a/ui/src/pages/chat/components/chat-composer-context.ts +++ b/ui/src/pages/chat/components/chat-composer-context.ts @@ -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; 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`` - : html`
- - - -
- ${model - ? html` -
- ${t("chat.composer.contextUsage.contextWindow")} - ${model.detail} · ${percentage} -
-
- -
- ` - : nothing} - ${model - ? html` - -
-
-
${t("usage.breakdown.input")}
-
${formatStat(model.input)}
-
-
-
${t("usage.breakdown.output")}
-
${formatStat(model.output)}
-
- ${!showCosts || model.cost === null - ? nothing - : html` -
-
${t("chat.composer.contextUsage.estimatedCost")}
-
${formatCost(model.cost)}
-
- `} -
- ` - : nothing} - ${showCosts && providerCosts && hasProviderCosts - ? html` - -
- ${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)} -
- ` - : nothing} - ${planGroups.map((group) => renderQuotaGroup(group, usageHref))} -
-
`} + + + + +
+ ${model + ? html` +
+ ${t("chat.composer.contextUsage.contextWindow")} + ${model.detail} · ${percentage} +
+
+ +
+ ` + : nothing} + ${model + ? html` + +
+
+
${t("usage.breakdown.input")}
+
${formatStat(model.input)}
+
+
+
${t("usage.breakdown.output")}
+
${formatStat(model.output)}
+
+ ${!showCosts || model.cost === null + ? nothing + : html` +
+
${t("chat.composer.contextUsage.estimatedCost")}
+
${formatCost(model.cost)}
+
+ `} +
+ ` + : nothing} + ${showCosts && providerCosts && hasProviderCosts + ? html` + +
+ ${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)} +
+ ` + : nothing} + ${planGroups.map((group) => renderQuotaGroup(group, usageHref))} +
+ `; } diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index 47aceaf7013d..d88494c9fce8 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -124,7 +124,6 @@ export type ChatComposerProps = ChatAttachmentControlsProps & { onSlashIntent?: () => void | Promise; onSlashCommand?: (command: string) => void; onSend: (followUpModeOverride?: "steer", submissionAction?: Event) => void; - onCompact?: () => void | Promise; onToggleRealtimeTalk?: () => void; onToggleRealtimeCamera?: () => void; onSwitchRealtimeCamera?: () => void; diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index cc8c4f85a948..3269578216c7 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -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, }, ); diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 261ddc1d3efa..2e297558b23c 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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;