Skip to content

Commit da77317

Browse files
authored
fix(control-ui): keep context usage fresh (#71297)
Patch live session usage metadata into the Control UI session list, coalesce overlapping refreshes, and add a compact action when fresh context usage is high. Keep session refresh loading separate from session mutation ownership so background refreshes cannot re-enable mutation UI or overwrite delete/restore state mid-flight. Co-authored-by: Val Alexander <[email protected]>
1 parent d399ac7 commit da77317

10 files changed

Lines changed: 496 additions & 21 deletions

File tree

docs/web/control-ui.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ Cron jobs panel notes:
156156
- `chat.history` also strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks), and leaked ASCII/full-width model control tokens, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply`.
157157
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
158158
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
159+
- When fresh Gateway session usage reports show high context pressure, the chat
160+
composer area shows a context notice and, at recommended compaction levels, a
161+
compact button that runs the normal session compaction path. Stale token
162+
snapshots are hidden until the Gateway reports fresh usage again.
159163
- Talk mode uses a registered realtime voice provider that supports browser
160164
WebRTC sessions. Configure OpenAI with `talk.provider: "openai"` plus
161165
`talk.providers.openai.apiKey`, or reuse the Voice Call realtime provider

ui/src/styles/chat/layout.css

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,16 +151,19 @@
151151
align-self: center;
152152
display: inline-flex;
153153
align-items: center;
154+
justify-content: center;
155+
flex-wrap: wrap;
154156
gap: 8px;
155157
padding: 7px 14px;
156158
margin: 0 auto 8px;
159+
max-width: calc(100% - 20px);
157160
border-radius: var(--radius-full);
158161
border: 1px solid color-mix(in srgb, var(--ctx-color, #d97706) 35%, transparent);
159162
background: var(--ctx-bg, rgba(217, 119, 6, 0.12));
160163
color: var(--ctx-color, #d97706);
161164
font-size: 13px;
162165
line-height: 1.2;
163-
white-space: nowrap;
166+
white-space: normal;
164167
user-select: none;
165168
animation: fade-in 0.2s var(--ease-out);
166169
}
@@ -177,6 +180,52 @@
177180
font-variant-numeric: tabular-nums;
178181
}
179182

183+
.context-notice__action {
184+
display: inline-flex;
185+
align-items: center;
186+
justify-content: center;
187+
gap: 5px;
188+
height: 24px;
189+
padding: 0 9px;
190+
border-radius: var(--radius-full);
191+
border: 1px solid color-mix(in srgb, currentColor 38%, transparent);
192+
background: color-mix(in srgb, currentColor 12%, transparent);
193+
color: currentColor;
194+
font: inherit;
195+
font-size: 12px;
196+
line-height: 1;
197+
cursor: pointer;
198+
transition:
199+
background 150ms ease-out,
200+
border-color 150ms ease-out,
201+
opacity 150ms ease-out;
202+
}
203+
204+
.context-notice__action:hover:not(:disabled) {
205+
background: color-mix(in srgb, currentColor 18%, transparent);
206+
border-color: color-mix(in srgb, currentColor 55%, transparent);
207+
}
208+
209+
.context-notice__action:disabled {
210+
cursor: not-allowed;
211+
opacity: 0.65;
212+
}
213+
214+
.context-notice__action svg {
215+
width: 13px;
216+
height: 13px;
217+
flex-shrink: 0;
218+
stroke: currentColor;
219+
fill: none;
220+
stroke-width: 1.7px;
221+
stroke-linecap: round;
222+
stroke-linejoin: round;
223+
}
224+
225+
.context-notice__action--busy svg {
226+
animation: compaction-spin 1s linear infinite;
227+
}
228+
180229
/* Chat compose - sticky at bottom */
181230
.chat-compose {
182231
position: sticky;

ui/src/ui/app-gateway.sessions.node.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ vi.mock("./controllers/nodes.ts", () => ({
4242
loadNodes: vi.fn(),
4343
}));
4444
vi.mock("./controllers/sessions.ts", () => ({
45+
applySessionsChangedEvent: vi.fn(),
4546
loadSessions: loadSessionsMock,
4647
subscribeSessions: vi.fn(),
4748
}));

ui/src/ui/app-gateway.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ import {
4343
} from "./controllers/exec-approval.ts";
4444
import { loadHealthState, type HealthState } from "./controllers/health.ts";
4545
import { loadNodes, type NodesState } from "./controllers/nodes.ts";
46-
import { loadSessions, subscribeSessions, type SessionsState } from "./controllers/sessions.ts";
46+
import {
47+
applySessionsChangedEvent,
48+
loadSessions,
49+
subscribeSessions,
50+
type SessionsState,
51+
} from "./controllers/sessions.ts";
4752
import {
4853
resolveGatewayErrorDetailCode,
4954
type GatewayEventFrame,
@@ -482,6 +487,7 @@ function handleSessionMessageGatewayEvent(
482487
host: GatewayHost,
483488
payload: { sessionKey?: string } | undefined,
484489
) {
490+
applySessionsChangedEvent(host as unknown as SessionsState, payload);
485491
const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload;
486492
const sessionKey = payload?.sessionKey?.trim();
487493
if (!sessionKey || sessionKey !== host.sessionKey) {
@@ -568,6 +574,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
568574
}
569575

570576
if (evt.event === "sessions.changed") {
577+
applySessionsChangedEvent(host as unknown as SessionsState, evt.payload);
571578
void loadSessions(host as unknown as SessionsState);
572579
return;
573580
}

ui/src/ui/app-render.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from "../../../src/routing/session-key.js";
88
import { t } from "../i18n/index.ts";
99
import { getSafeLocalStorage } from "../local-storage.ts";
10-
import { refreshChatAvatar } from "./app-chat.ts";
10+
import { refreshChat } from "./app-chat.ts";
1111
import { DEFAULT_CRON_FORM } from "./app-defaults.ts";
1212
import { renderUsageTab } from "./app-render-usage-tab.ts";
1313
import {
@@ -2242,7 +2242,7 @@ export function renderApp(state: AppViewState) {
22422242
onRefresh: () => {
22432243
state.chatSideResult = null;
22442244
state.resetToolStream();
2245-
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
2245+
return refreshChat(state, { scheduleScroll: false });
22462246
},
22472247
onToggleFocusMode: () => {
22482248
if (state.onboarding) {
@@ -2260,6 +2260,7 @@ export function renderApp(state: AppViewState) {
22602260
attachments: state.chatAttachments,
22612261
onAttachmentsChange: (next) => (state.chatAttachments = next),
22622262
onSend: () => state.handleSendChat(),
2263+
onCompact: () => state.handleSendChat("/compact", { restoreDraft: true }),
22632264
onToggleRealtimeTalk: () => state.toggleRealtimeTalk(),
22642265
canAbort: Boolean(state.chatRunId),
22652266
onAbort: () => void state.handleAbortChat(),

ui/src/ui/chat/context-notice.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ describe("context notice", () => {
5555

5656
expect(container.textContent).toContain("95% context used");
5757
expect(container.textContent).toContain("190k / 200k");
58+
expect(getContextNoticeViewModel(session, 200_000)?.compactRecommended).toBe(true);
5859
expect(container.textContent).not.toContain("757.3k / 200k");
5960
const notice = container.querySelector<HTMLElement>(".context-notice");
6061
expect(notice).not.toBeNull();
@@ -71,6 +72,12 @@ describe("context notice", () => {
7172
expect(icon?.getAttribute("height")).toBe("16");
7273
expect(icon?.querySelector("path")).not.toBeNull();
7374

75+
const onCompact = vi.fn();
76+
render(renderContextNotice(session, 200_000, { onCompact }), container);
77+
expect(container.textContent).toContain("Compact");
78+
container.querySelector<HTMLButtonElement>(".context-notice__action")?.click();
79+
expect(onCompact).toHaveBeenCalledTimes(1);
80+
7481
expect(
7582
getContextNoticeViewModel(
7683
{

ui/src/ui/chat/context-notice.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { html, nothing } from "lit";
2+
import { icons } from "../icons.ts";
23
import type { GatewaySessionRow } from "../types.ts";
34

5+
const CONTEXT_NOTICE_RATIO = 0.85;
6+
const CONTEXT_COMPACT_RATIO = 0.9;
7+
8+
export type ContextNoticeOptions = {
9+
compactBusy?: boolean;
10+
compactDisabled?: boolean;
11+
onCompact?: () => void | Promise<void>;
12+
};
13+
414
/** Parse a 6-digit CSS hex color string to [r, g, b] integer components. */
515
function parseHexRgb(hex: string): [number, number, number] | null {
616
const h = hex.trim().replace(/^#/, "");
@@ -49,6 +59,7 @@ export function getContextNoticeViewModel(
4959
detail: string;
5060
color: string;
5161
bg: string;
62+
compactRecommended: boolean;
5263
} | null {
5364
if (session?.totalTokensFresh === false) {
5465
return null;
@@ -59,7 +70,7 @@ export function getContextNoticeViewModel(
5970
return null;
6071
}
6172
const ratio = used / limit;
62-
if (ratio < 0.85) {
73+
if (ratio < CONTEXT_NOTICE_RATIO) {
6374
return null;
6475
}
6576
const pct = Math.min(Math.round(ratio * 100), 100);
@@ -79,17 +90,21 @@ export function getContextNoticeViewModel(
7990
detail: `${formatTokensCompact(used)} / ${formatTokensCompact(limit)}`,
8091
color,
8192
bg,
93+
compactRecommended: ratio >= CONTEXT_COMPACT_RATIO,
8294
};
8395
}
8496

8597
export function renderContextNotice(
8698
session: GatewaySessionRow | undefined,
8799
defaultContextTokens: number | null,
100+
options: ContextNoticeOptions = {},
88101
) {
89102
const model = getContextNoticeViewModel(session, defaultContextTokens);
90103
if (!model) {
91104
return nothing;
92105
}
106+
const canRenderCompact = model.compactRecommended && options.onCompact;
107+
const compactDisabled = options.compactDisabled === true || options.compactBusy === true;
93108
return html`
94109
<div
95110
class="context-notice"
@@ -113,6 +128,30 @@ export function renderContextNotice(
113128
</svg>
114129
<span>${model.pct}% context used</span>
115130
<span class="context-notice__detail">${model.detail}</span>
131+
${canRenderCompact
132+
? html`
133+
<button
134+
class="context-notice__action ${options.compactBusy
135+
? "context-notice__action--busy"
136+
: ""}"
137+
type="button"
138+
title="Compact session context"
139+
aria-label="Compact recommended session context"
140+
?disabled=${compactDisabled}
141+
@click=${(event: Event) => {
142+
event.preventDefault();
143+
event.stopPropagation();
144+
if (compactDisabled) {
145+
return;
146+
}
147+
void options.onCompact?.();
148+
}}
149+
>
150+
${options.compactBusy ? icons.loader : icons.minimize}
151+
<span>${options.compactBusy ? "Compacting" : "Compact"}</span>
152+
</button>
153+
`
154+
: nothing}
116155
</div>
117156
`;
118157
}

0 commit comments

Comments
 (0)