;
+};
+
/** 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