mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
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>
This commit is contained in:
@@ -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 `<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`.
|
||||
- `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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<HTMLElement>(".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<HTMLButtonElement>(".context-notice__action")?.click();
|
||||
expect(onCompact).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
getContextNoticeViewModel(
|
||||
{
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
/** 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`
|
||||
<div
|
||||
class="context-notice"
|
||||
@@ -113,6 +128,30 @@ export function renderContextNotice(
|
||||
</svg>
|
||||
<span>${model.pct}% context used</span>
|
||||
<span class="context-notice__detail">${model.detail}</span>
|
||||
${canRenderCompact
|
||||
? html`
|
||||
<button
|
||||
class="context-notice__action ${options.compactBusy
|
||||
? "context-notice__action--busy"
|
||||
: ""}"
|
||||
type="button"
|
||||
title="Compact session context"
|
||||
aria-label="Compact recommended session context"
|
||||
?disabled=${compactDisabled}
|
||||
@click=${(event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (compactDisabled) {
|
||||
return;
|
||||
}
|
||||
void options.onCompact?.();
|
||||
}}
|
||||
>
|
||||
${options.compactBusy ? icons.loader : icons.minimize}
|
||||
<span>${options.compactBusy ? "Compacting" : "Compact"}</span>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
signalDeleteStarted = resolve;
|
||||
});
|
||||
const deleteBlocker = new Promise<void>((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<void>((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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
type LoadSessionsOverrides = {
|
||||
activeMinutes?: number;
|
||||
limit?: number;
|
||||
includeGlobal?: boolean;
|
||||
includeUnknown?: boolean;
|
||||
};
|
||||
|
||||
type SessionsLoadControl = {
|
||||
loading: boolean;
|
||||
pending: { overrides?: LoadSessionsOverrides } | null;
|
||||
ownsStateLoading: boolean;
|
||||
};
|
||||
|
||||
const sessionsLoadControls = new WeakMap<object, SessionsLoadControl>();
|
||||
|
||||
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<string, unknown> {
|
||||
return Boolean(value && typeof value === "object");
|
||||
}
|
||||
|
||||
function hasOwn(record: Record<string, unknown>, 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<void>) {
|
||||
async function withSessionsLoading(
|
||||
state: SessionsState,
|
||||
run: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
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<T>(
|
||||
@@ -125,6 +218,65 @@ async function runCompactionMutation<T>(
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<SessionsState["client"]>,
|
||||
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) {
|
||||
|
||||
@@ -101,6 +101,7 @@ export type ChatProps = {
|
||||
onDraftChange: (next: string) => void;
|
||||
onRequestUpdate?: () => void;
|
||||
onSend: () => void;
|
||||
onCompact?: () => void | Promise<void>;
|
||||
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`
|
||||
<button class="chat-new-messages" type="button" @click=${props.onScrollToBottom}>
|
||||
|
||||
Reference in New Issue
Block a user