From da773175f2eb0897b36dcae36c40ba9f0500259f Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:20:12 -0500 Subject: [PATCH] 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 <68980965+BunsDev@users.noreply.github.com> --- docs/web/control-ui.md | 4 + ui/src/styles/chat/layout.css | 51 ++++- ui/src/ui/app-gateway.sessions.node.test.ts | 1 + ui/src/ui/app-gateway.ts | 9 +- ui/src/ui/app-render.ts | 5 +- ui/src/ui/chat/context-notice.test.ts | 7 + ui/src/ui/chat/context-notice.ts | 41 +++- ui/src/ui/controllers/sessions.test.ts | 177 ++++++++++++++++ ui/src/ui/controllers/sessions.ts | 213 ++++++++++++++++++-- ui/src/ui/views/chat.ts | 9 +- 10 files changed, 496 insertions(+), 21 deletions(-) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 5aa4e101708..addde88a9fb 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -156,6 +156,10 @@ Cron jobs panel notes: - `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 `...`, `...`, `...`, `...`, 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`. - `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). - 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. +- When fresh Gateway session usage reports show high context pressure, the chat + composer area shows a context notice and, at recommended compaction levels, a + compact button that runs the normal session compaction path. Stale token + snapshots are hidden until the Gateway reports fresh usage again. - Talk mode uses a registered realtime voice provider that supports browser WebRTC sessions. Configure OpenAI with `talk.provider: "openai"` plus `talk.providers.openai.apiKey`, or reuse the Voice Call realtime provider diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index ca4e4230e13..aa272cc7579 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -151,16 +151,19 @@ align-self: center; display: inline-flex; align-items: center; + justify-content: center; + flex-wrap: wrap; gap: 8px; padding: 7px 14px; margin: 0 auto 8px; + max-width: calc(100% - 20px); border-radius: var(--radius-full); border: 1px solid color-mix(in srgb, var(--ctx-color, #d97706) 35%, transparent); background: var(--ctx-bg, rgba(217, 119, 6, 0.12)); color: var(--ctx-color, #d97706); font-size: 13px; line-height: 1.2; - white-space: nowrap; + white-space: normal; user-select: none; animation: fade-in 0.2s var(--ease-out); } @@ -177,6 +180,52 @@ font-variant-numeric: tabular-nums; } +.context-notice__action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 24px; + padding: 0 9px; + border-radius: var(--radius-full); + border: 1px solid color-mix(in srgb, currentColor 38%, transparent); + background: color-mix(in srgb, currentColor 12%, transparent); + color: currentColor; + font: inherit; + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: + background 150ms ease-out, + border-color 150ms ease-out, + opacity 150ms ease-out; +} + +.context-notice__action:hover:not(:disabled) { + background: color-mix(in srgb, currentColor 18%, transparent); + border-color: color-mix(in srgb, currentColor 55%, transparent); +} + +.context-notice__action:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.context-notice__action svg { + width: 13px; + height: 13px; + flex-shrink: 0; + stroke: currentColor; + fill: none; + stroke-width: 1.7px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.context-notice__action--busy svg { + animation: compaction-spin 1s linear infinite; +} + /* Chat compose - sticky at bottom */ .chat-compose { position: sticky; diff --git a/ui/src/ui/app-gateway.sessions.node.test.ts b/ui/src/ui/app-gateway.sessions.node.test.ts index d8e0b250f0b..29f163483bb 100644 --- a/ui/src/ui/app-gateway.sessions.node.test.ts +++ b/ui/src/ui/app-gateway.sessions.node.test.ts @@ -42,6 +42,7 @@ vi.mock("./controllers/nodes.ts", () => ({ loadNodes: vi.fn(), })); vi.mock("./controllers/sessions.ts", () => ({ + applySessionsChangedEvent: vi.fn(), loadSessions: loadSessionsMock, subscribeSessions: vi.fn(), })); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 494fd61cf5f..4455a523bb4 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -43,7 +43,12 @@ import { } from "./controllers/exec-approval.ts"; import { loadHealthState, type HealthState } from "./controllers/health.ts"; import { loadNodes, type NodesState } from "./controllers/nodes.ts"; -import { loadSessions, subscribeSessions, type SessionsState } from "./controllers/sessions.ts"; +import { + applySessionsChangedEvent, + loadSessions, + subscribeSessions, + type SessionsState, +} from "./controllers/sessions.ts"; import { resolveGatewayErrorDetailCode, type GatewayEventFrame, @@ -482,6 +487,7 @@ function handleSessionMessageGatewayEvent( host: GatewayHost, payload: { sessionKey?: string } | undefined, ) { + applySessionsChangedEvent(host as unknown as SessionsState, payload); const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload; const sessionKey = payload?.sessionKey?.trim(); if (!sessionKey || sessionKey !== host.sessionKey) { @@ -568,6 +574,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { } if (evt.event === "sessions.changed") { + applySessionsChangedEvent(host as unknown as SessionsState, evt.payload); void loadSessions(host as unknown as SessionsState); return; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c12675947e0..268173d0ab3 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -7,7 +7,7 @@ import { } from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; -import { refreshChatAvatar } from "./app-chat.ts"; +import { refreshChat } from "./app-chat.ts"; import { DEFAULT_CRON_FORM } from "./app-defaults.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; import { @@ -2242,7 +2242,7 @@ export function renderApp(state: AppViewState) { onRefresh: () => { state.chatSideResult = null; state.resetToolStream(); - return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); + return refreshChat(state, { scheduleScroll: false }); }, onToggleFocusMode: () => { if (state.onboarding) { @@ -2260,6 +2260,7 @@ export function renderApp(state: AppViewState) { attachments: state.chatAttachments, onAttachmentsChange: (next) => (state.chatAttachments = next), onSend: () => state.handleSendChat(), + onCompact: () => state.handleSendChat("/compact", { restoreDraft: true }), onToggleRealtimeTalk: () => state.toggleRealtimeTalk(), canAbort: Boolean(state.chatRunId), onAbort: () => void state.handleAbortChat(), diff --git a/ui/src/ui/chat/context-notice.test.ts b/ui/src/ui/chat/context-notice.test.ts index 20ae5a9e14c..42892dc4650 100644 --- a/ui/src/ui/chat/context-notice.test.ts +++ b/ui/src/ui/chat/context-notice.test.ts @@ -55,6 +55,7 @@ describe("context notice", () => { expect(container.textContent).toContain("95% context used"); expect(container.textContent).toContain("190k / 200k"); + expect(getContextNoticeViewModel(session, 200_000)?.compactRecommended).toBe(true); expect(container.textContent).not.toContain("757.3k / 200k"); const notice = container.querySelector(".context-notice"); expect(notice).not.toBeNull(); @@ -71,6 +72,12 @@ describe("context notice", () => { expect(icon?.getAttribute("height")).toBe("16"); expect(icon?.querySelector("path")).not.toBeNull(); + const onCompact = vi.fn(); + render(renderContextNotice(session, 200_000, { onCompact }), container); + expect(container.textContent).toContain("Compact"); + container.querySelector(".context-notice__action")?.click(); + expect(onCompact).toHaveBeenCalledTimes(1); + expect( getContextNoticeViewModel( { diff --git a/ui/src/ui/chat/context-notice.ts b/ui/src/ui/chat/context-notice.ts index 89a8528997a..42fed23018a 100644 --- a/ui/src/ui/chat/context-notice.ts +++ b/ui/src/ui/chat/context-notice.ts @@ -1,6 +1,16 @@ import { html, nothing } from "lit"; +import { icons } from "../icons.ts"; import type { GatewaySessionRow } from "../types.ts"; +const CONTEXT_NOTICE_RATIO = 0.85; +const CONTEXT_COMPACT_RATIO = 0.9; + +export type ContextNoticeOptions = { + compactBusy?: boolean; + compactDisabled?: boolean; + onCompact?: () => void | Promise; +}; + /** Parse a 6-digit CSS hex color string to [r, g, b] integer components. */ function parseHexRgb(hex: string): [number, number, number] | null { const h = hex.trim().replace(/^#/, ""); @@ -49,6 +59,7 @@ export function getContextNoticeViewModel( detail: string; color: string; bg: string; + compactRecommended: boolean; } | null { if (session?.totalTokensFresh === false) { return null; @@ -59,7 +70,7 @@ export function getContextNoticeViewModel( return null; } const ratio = used / limit; - if (ratio < 0.85) { + if (ratio < CONTEXT_NOTICE_RATIO) { return null; } const pct = Math.min(Math.round(ratio * 100), 100); @@ -79,17 +90,21 @@ export function getContextNoticeViewModel( detail: `${formatTokensCompact(used)} / ${formatTokensCompact(limit)}`, color, bg, + compactRecommended: ratio >= CONTEXT_COMPACT_RATIO, }; } export function renderContextNotice( session: GatewaySessionRow | undefined, defaultContextTokens: number | null, + options: ContextNoticeOptions = {}, ) { const model = getContextNoticeViewModel(session, defaultContextTokens); if (!model) { return nothing; } + const canRenderCompact = model.compactRecommended && options.onCompact; + const compactDisabled = options.compactDisabled === true || options.compactBusy === true; return html`
${model.pct}% context used ${model.detail} + ${canRenderCompact + ? html` + + ` + : nothing}
`; } diff --git a/ui/src/ui/controllers/sessions.test.ts b/ui/src/ui/controllers/sessions.test.ts index bfed20ce153..1d1412c4ac4 100644 --- a/ui/src/ui/controllers/sessions.test.ts +++ b/ui/src/ui/controllers/sessions.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + applySessionsChangedEvent, deleteSessionsAndRefresh, loadSessions, subscribeSessions, @@ -129,9 +130,112 @@ describe("deleteSessionsAndRefresh", () => { expect(deleted).toEqual([]); expect(request).not.toHaveBeenCalled(); }); + + it("queues refreshes requested during delete without releasing mutation loading", async () => { + let resolveDelete: () => void = () => undefined; + let signalDeleteStarted: () => void = () => undefined; + const deleteStarted = new Promise((resolve) => { + signalDeleteStarted = resolve; + }); + const deleteBlocker = new Promise((resolve) => { + resolveDelete = resolve; + }); + const request = vi.fn(async (method: string) => { + if (method === "sessions.delete") { + signalDeleteStarted(); + await deleteBlocker; + return { ok: true }; + } + if (method === "sessions.list") { + return { + ts: 2, + path: "(multiple)", + count: 0, + defaults: {}, + sessions: [], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + const state = createState(request); + vi.spyOn(window, "confirm").mockReturnValue(true); + + const deletePromise = deleteSessionsAndRefresh(state, ["key-a"]); + await deleteStarted; + expect(state.sessionsLoading).toBe(true); + + await loadSessions(state); + expect(request).toHaveBeenCalledTimes(1); + expect(state.sessionsLoading).toBe(true); + + resolveDelete(); + const deleted = await deletePromise; + + expect(deleted).toEqual(["key-a"]); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + }); + expect(state.sessionsLoading).toBe(false); + }); }); describe("loadSessions", () => { + it("coalesces overlapping refreshes instead of dropping the latest request", async () => { + let resolveFirst: () => void = () => undefined; + const firstBlocker = new Promise((resolve) => { + resolveFirst = resolve; + }); + const request = vi.fn(async (method: string) => { + if (method !== "sessions.list") { + throw new Error(`unexpected method: ${method}`); + } + if (request.mock.calls.length === 1) { + await firstBlocker; + return { + ts: 1, + path: "(multiple)", + count: 0, + defaults: {}, + sessions: [], + }; + } + return { + ts: 2, + path: "(multiple)", + count: 0, + defaults: {}, + sessions: [], + }; + }); + const state = createState(request, { + sessionsFilterActive: "30", + sessionsFilterLimit: "10", + }); + + const first = loadSessions(state); + const second = loadSessions(state, { activeMinutes: 0, limit: 0 }); + expect(request).toHaveBeenCalledTimes(1); + + resolveFirst(); + await Promise.all([first, second]); + + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", { + activeMinutes: 30, + limit: 10, + includeGlobal: true, + includeUnknown: true, + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + }); + expect(state.sessionsResult?.ts).toBe(2); + expect(state.sessionsLoading).toBe(false); + }); + it("refreshes expanded checkpoint cards when the row summary changes", async () => { const request = vi.fn(async (method: string) => { if (method === "sessions.list") { @@ -218,3 +322,76 @@ describe("loadSessions", () => { ).toEqual(["checkpoint-new"]); }); }); + +describe("applySessionsChangedEvent", () => { + it("updates fresh context usage from websocket event payloads", () => { + const state = createState(async () => undefined, { + sessionsResult: { + ts: 1, + path: "(multiple)", + count: 1, + defaults: { modelProvider: "openai", model: "gpt-5.4", contextTokens: 200_000 }, + sessions: [ + { + key: "agent:main:main", + kind: "direct", + updatedAt: 1, + totalTokens: 20_000, + totalTokensFresh: true, + contextTokens: 200_000, + }, + ], + }, + }); + + const applied = applySessionsChangedEvent(state, { + sessionKey: "agent:main:main", + ts: 2, + totalTokens: 190_000, + totalTokensFresh: true, + contextTokens: 200_000, + model: "gpt-5.4", + }); + + expect(applied).toBe(true); + expect(state.sessionsResult?.ts).toBe(2); + expect(state.sessionsResult?.sessions[0]).toMatchObject({ + key: "agent:main:main", + totalTokens: 190_000, + totalTokensFresh: true, + contextTokens: 200_000, + model: "gpt-5.4", + }); + }); + + it("clears old token totals when the gateway marks the measurement stale", () => { + const state = createState(async () => undefined, { + sessionsResult: { + ts: 1, + path: "(multiple)", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: 200_000 }, + sessions: [ + { + key: "agent:main:main", + kind: "direct", + updatedAt: 1, + totalTokens: 190_000, + totalTokensFresh: true, + contextTokens: 200_000, + }, + ], + }, + }); + + applySessionsChangedEvent(state, { + sessionKey: "agent:main:main", + totalTokensFresh: false, + contextTokens: 200_000, + }); + + expect(state.sessionsResult?.sessions[0]?.totalTokens).toBeUndefined(); + expect(state.sessionsResult?.sessions[0]?.totalTokensFresh).toBe(false); + expect(state.sessionsResult?.sessions[0]?.contextTokens).toBe(200_000); + }); +}); diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 0dc3cc46d48..233ba651c2b 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -1,6 +1,7 @@ import { toNumber } from "../format.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { + GatewaySessionRow, SessionCompactionCheckpoint, SessionsCompactionBranchResult, SessionsCompactionListResult, @@ -29,6 +30,87 @@ export type SessionsState = { sessionsCheckpointErrorByKey: Record; }; +type LoadSessionsOverrides = { + activeMinutes?: number; + limit?: number; + includeGlobal?: boolean; + includeUnknown?: boolean; +}; + +type SessionsLoadControl = { + loading: boolean; + pending: { overrides?: LoadSessionsOverrides } | null; + ownsStateLoading: boolean; +}; + +const sessionsLoadControls = new WeakMap(); + +const SESSION_EVENT_ROW_FIELDS = [ + "abortedLastRun", + "childSessions", + "compactionCheckpointCount", + "contextTokens", + "displayName", + "endedAt", + "elevatedLevel", + "fastMode", + "inputTokens", + "kind", + "label", + "latestCompactionCheckpoint", + "model", + "modelProvider", + "outputTokens", + "reasoningLevel", + "runtimeMs", + "sessionId", + "spawnedBy", + "startedAt", + "status", + "subject", + "surface", + "systemSent", + "thinkingDefault", + "thinkingLevel", + "thinkingOptions", + "totalTokens", + "totalTokensFresh", + "updatedAt", + "verboseLevel", +] as const satisfies readonly (keyof GatewaySessionRow)[]; + +function getSessionsLoadControl(state: SessionsState): SessionsLoadControl { + const key = state as object; + let control = sessionsLoadControls.get(key); + if (!control) { + control = { loading: false, ownsStateLoading: false, pending: null }; + sessionsLoadControls.set(key, control); + } + return control; +} + +function takePendingSessionsLoad( + control: SessionsLoadControl, +): { overrides?: LoadSessionsOverrides } | null { + const pending = control.pending; + control.pending = null; + return pending; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object"); +} + +function hasOwn(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function normalizeSessionKind(value: unknown): GatewaySessionRow["kind"] | undefined { + return value === "direct" || value === "group" || value === "global" || value === "unknown" + ? value + : undefined; +} + function checkpointSummarySignature( row: | { @@ -86,17 +168,28 @@ async function fetchSessionCompactionCheckpoints(state: SessionsState, key: stri } } -async function withSessionsLoading(state: SessionsState, run: () => Promise) { +async function withSessionsLoading( + state: SessionsState, + run: () => Promise, +): Promise { if (state.sessionsLoading) { - return; + return false; } + const control = getSessionsLoadControl(state); state.sessionsLoading = true; state.sessionsError = null; + let drainedPendingRefresh = false; try { await run(); } finally { state.sessionsLoading = false; + const pending = takePendingSessionsLoad(control); + if (pending && state.client && state.connected) { + await loadSessions(state, pending.overrides); + drainedPendingRefresh = true; + } } + return drainedPendingRefresh; } async function runCompactionMutation( @@ -125,6 +218,65 @@ async function runCompactionMutation( } } +export function applySessionsChangedEvent(state: SessionsState, payload: unknown): boolean { + if (!isRecord(payload) || !state.sessionsResult) { + return false; + } + const eventSession = isRecord(payload.session) ? payload.session : null; + const source = eventSession ?? payload; + const key = + (typeof source.key === "string" && source.key.trim()) || + (typeof payload.sessionKey === "string" && payload.sessionKey.trim()) || + (typeof payload.key === "string" && payload.key.trim()) || + ""; + if (!key) { + return false; + } + + const previousRows = state.sessionsResult.sessions; + const existingIndex = previousRows.findIndex((row) => row.key === key); + const existing = existingIndex >= 0 ? previousRows[existingIndex] : undefined; + const previousCheckpointSignature = checkpointSummarySignature(existing); + const fallbackKind = normalizeSessionKind(source.kind) ?? existing?.kind ?? "unknown"; + const nextRow: GatewaySessionRow = { + ...(existing ?? { key, kind: fallbackKind, updatedAt: null }), + key, + kind: fallbackKind, + }; + const mutableNext = nextRow as unknown as Record; + for (const field of SESSION_EVENT_ROW_FIELDS) { + if (!hasOwn(source, field)) { + continue; + } + const value = source[field]; + if (value === undefined) { + delete mutableNext[field]; + } else { + mutableNext[field] = value; + } + } + if (nextRow.totalTokensFresh === false && !hasOwn(source, "totalTokens")) { + delete nextRow.totalTokens; + } + + const sessions = + existingIndex >= 0 + ? previousRows.map((row, index) => (index === existingIndex ? nextRow : row)) + : [nextRow, ...previousRows]; + const eventTs = typeof payload.ts === "number" && Number.isFinite(payload.ts) ? payload.ts : null; + state.sessionsResult = { + ...state.sessionsResult, + ts: eventTs == null ? state.sessionsResult.ts : Math.max(state.sessionsResult.ts, eventTs), + count: existingIndex >= 0 ? state.sessionsResult.count : state.sessionsResult.count + 1, + sessions, + }; + + if (previousCheckpointSignature !== checkpointSummarySignature(nextRow)) { + invalidateCheckpointCacheForKey(state, key); + } + return true; +} + export async function subscribeSessions(state: SessionsState) { if (!state.client || !state.connected) { return; @@ -136,20 +288,51 @@ export async function subscribeSessions(state: SessionsState) { } } -export async function loadSessions( - state: SessionsState, - overrides?: { - activeMinutes?: number; - limit?: number; - includeGlobal?: boolean; - includeUnknown?: boolean; - }, -) { +export async function loadSessions(state: SessionsState, overrides?: LoadSessionsOverrides) { if (!state.client || !state.connected) { return; } + const control = getSessionsLoadControl(state); + if (control.loading) { + control.pending = { overrides }; + return; + } + if (state.sessionsLoading) { + control.pending = { overrides }; + return; + } const client = state.client; - await withSessionsLoading(state, async () => { + control.loading = true; + control.ownsStateLoading = true; + state.sessionsLoading = true; + state.sessionsError = null; + let currentOverrides: LoadSessionsOverrides | undefined = overrides; + try { + for (;;) { + control.pending = null; + await loadSessionsOnce(state, client, currentOverrides); + const pending = takePendingSessionsLoad(control); + if (!pending || !state.client || !state.connected) { + break; + } + currentOverrides = pending.overrides; + } + } finally { + control.loading = false; + control.pending = null; + if (control.ownsStateLoading) { + state.sessionsLoading = false; + control.ownsStateLoading = false; + } + } +} + +async function loadSessionsOnce( + state: SessionsState, + client: NonNullable, + overrides?: LoadSessionsOverrides, +) { + await (async () => { const previousRows = new Map( (state.sessionsResult?.sessions ?? []).map((row) => [row.key, row] as const), ); @@ -195,7 +378,7 @@ export async function loadSessions( await fetchSessionCompactionCheckpoints(state, expandedKey); } } - }).catch((err: unknown) => { + })().catch((err: unknown) => { if (!isMissingOperatorReadScopeError(err)) { state.sessionsError = String(err); return; @@ -258,7 +441,7 @@ export async function deleteSessionsAndRefresh( } const deleted: string[] = []; const deleteErrors: string[] = []; - await withSessionsLoading(state, async () => { + const refreshedDuringDelete = await withSessionsLoading(state, async () => { for (const key of keys) { try { await client.request("sessions.delete", { key, deleteTranscript: true }); @@ -268,7 +451,7 @@ export async function deleteSessionsAndRefresh( } } }); - if (deleted.length > 0) { + if (deleted.length > 0 && !refreshedDuringDelete) { await loadSessions(state); } if (deleteErrors.length > 0) { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 3be87867d90..cd21b098086 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -101,6 +101,7 @@ export type ChatProps = { onDraftChange: (next: string) => void; onRequestUpdate?: () => void; onSend: () => void; + onCompact?: () => void | Promise; onToggleRealtimeTalk?: () => void; onAbort?: () => void; onQueueRemove: (id: string) => void; @@ -768,6 +769,8 @@ export function renderChat(props: ChatProps) { const canCompose = props.connected; const isBusy = props.sending || props.stream !== null; const canAbort = Boolean(props.canAbort && props.onAbort); + const compactBusy = + props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying"; const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey); const reasoningLevel = activeSession?.reasoningLevel ?? "off"; const showReasoning = props.showThinking && reasoningLevel !== "off"; @@ -1201,7 +1204,11 @@ export function renderChat(props: ChatProps) { ${renderSideResult(props.sideResult, props.onDismissSideResult)} ${renderFallbackIndicator(props.fallbackStatus)} ${renderCompactionIndicator(props.compactionStatus)} - ${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null)} + ${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, { + compactBusy, + compactDisabled: !props.connected || isBusy || Boolean(props.canAbort), + onCompact: props.onCompact, + })} ${props.showNewMessages ? html`