fix(ui): refresh session windows after events (#103134)

* fix(ui): refresh session windows after events

* chore: defer session fix release note
This commit is contained in:
Peter Steinberger
2026-07-09 23:43:07 +01:00
committed by GitHub
parent e251bc4c2f
commit af3a76f320
3 changed files with 111 additions and 91 deletions
+22 -6
View File
@@ -76,19 +76,35 @@ describeControlUiE2e("Control UI session-list event scope", () => {
),
).toBe(true);
await gateway.deferNext("sessions.list");
await gateway.emitGatewayEvent("sessions.changed", {
session: {
key: "agent:local:hidden",
kind: "direct",
label: hiddenLabel,
updatedAt: 2,
},
sessionKey: "agent:local:hidden",
reason: "create",
key: "agent:local:hidden",
kind: "direct",
label: hiddenLabel,
updatedAt: 2,
});
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(requestsBeforeEvent.length);
expect(await currentPage.getByText(hiddenLabel, { exact: true }).count()).toBe(0);
await gateway.resolveDeferred("sessions.list", {
count: 1,
defaults: { contextTokens: null, model: null, modelProvider: null },
path: "",
sessions: [
{
key: "agent:main:visible",
kind: "direct",
label: visibleLabel,
updatedAt: 3,
},
],
ts: 3,
});
await visibleOverviewRow.waitFor();
expect(await currentPage.getByText(hiddenLabel, { exact: true }).count()).toBe(0);
});
});
+63 -16
View File
@@ -70,13 +70,13 @@ function sessionChangedEvent(key: string): GatewayEventFrame {
type: "event",
event: "sessions.changed",
payload: {
session: {
key,
kind: "direct",
updatedAt: 2,
sessionId: "hidden-session",
label: "Hidden",
},
sessionKey: key,
reason: "create",
key,
kind: "direct",
updatedAt: 2,
sessionId: "hidden-session",
label: "Hidden",
},
};
}
@@ -408,11 +408,14 @@ describe("createSessionCapability", () => {
it("refreshes instead of inserting hidden sessions after configured-only lists", async () => {
const visibleKey = "agent:main:main";
const hiddenKey = "agent:local:hidden";
const refreshed = deferred<SessionsListResult>();
let listCalls = 0;
const request = vi.fn(async (method: string) => {
if (method !== "sessions.list") {
throw new Error(`Unexpected request: ${method}`);
}
return sessionsResult(
listCalls += 1;
const result = sessionsResult(
[
{
key: visibleKey,
@@ -423,6 +426,7 @@ describe("createSessionCapability", () => {
],
1,
);
return listCalls === 1 ? result : await refreshed.promise;
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, emitEvent } = createGatewayHarness(client);
@@ -431,20 +435,62 @@ describe("createSessionCapability", () => {
await sessions.refresh({ force: true });
expect(request).toHaveBeenCalledWith(
"sessions.list",
expect.objectContaining({ configuredAgentsOnly: true }),
expect.objectContaining({ configuredAgentsOnly: true, limit: 50 }),
);
const publishedKeys: string[][] = [];
sessions.subscribe((next) => {
publishedKeys.push(next.result?.sessions.map((row) => row.key) ?? []);
});
emitEvent(sessionChangedEvent(hiddenKey));
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
expect(sessions.state.result?.sessions.map((row) => row.key)).toEqual([visibleKey]);
expect(publishedKeys.some((keys) => keys.includes(hiddenKey))).toBe(false);
refreshed.resolve(sessionsResult([{ key: visibleKey, kind: "direct", updatedAt: 1 }], 2));
await vi.waitFor(() => expect(sessions.state.loading).toBe(false));
sessions.dispose();
});
it("reconciles broad events when configured-agent filtering is explicitly disabled", async () => {
it("publishes remote deletion before refreshing the canonical list", async () => {
const visibleKey = "agent:main:main";
const refreshed = deferred<SessionsListResult>();
let listCalls = 0;
const request = vi.fn(async (method: string) => {
if (method !== "sessions.list") {
throw new Error(`Unexpected request: ${method}`);
}
listCalls += 1;
const result = sessionsResult([{ key: visibleKey, kind: "direct", updatedAt: 1 }], 1);
return listCalls === 1 ? result : await refreshed.promise;
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, emitEvent } = createGatewayHarness(client);
const sessions = createSessionCapability(gateway);
await sessions.refresh({ force: true });
const deletedSnapshots: string[][] = [];
sessions.subscribe((next) => {
deletedSnapshots.push(next.deletedSessions.map((target) => target.key));
});
emitEvent({
type: "event",
event: "sessions.changed",
payload: { sessionKey: visibleKey, reason: "delete" },
});
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
expect(deletedSnapshots.some((keys) => keys.includes(visibleKey))).toBe(true);
refreshed.resolve(sessionsResult([], 2));
await vi.waitFor(() => expect(sessions.state.loading).toBe(false));
sessions.dispose();
});
it("refreshes broad lists when the client omits the server-side window limit", async () => {
const visibleKey = "agent:main:main";
const hiddenKey = "agent:local:hidden";
const request = vi.fn(async (method: string) => {
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method !== "sessions.list") {
throw new Error(`Unexpected request: ${method}`);
}
@@ -454,20 +500,21 @@ describe("createSessionCapability", () => {
const { gateway, emitEvent } = createGatewayHarness(client);
const sessions = createSessionCapability(gateway);
await sessions.refresh({ configuredAgentsOnly: false, force: true });
expect(request).toHaveBeenCalledWith(
"sessions.list",
await sessions.refresh({ configuredAgentsOnly: false, force: true, limit: 0 });
const requestParams = request.mock.calls[0]?.[1];
expect(requestParams).toEqual(
expect.objectContaining({
configuredAgentsOnly: false,
includeGlobal: true,
includeUnknown: true,
}),
);
expect(requestParams).not.toHaveProperty("limit");
emitEvent(sessionChangedEvent(hiddenKey));
expect(request).toHaveBeenCalledTimes(1);
expect(sessions.state.result?.sessions.map((row) => row.key)).toContain(hiddenKey);
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
expect(sessions.state.result?.sessions.map((row) => row.key)).not.toContain(hiddenKey);
sessions.dispose();
});
+26 -69
View File
@@ -230,10 +230,7 @@ export type {
SessionScopeHostWithKey,
} from "./navigation.ts";
type EffectiveSessionListOptions = SessionListOptions &
Required<Pick<SessionListOptions, "includeGlobal" | "includeUnknown" | "configuredAgentsOnly">>;
const SESSION_LIST_DEFAULTS = {
const SESSION_LIST_PARAMS = {
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
@@ -251,29 +248,24 @@ function buildSessionRequestParams(
};
}
function resolveEffectiveSessionListOptions(
options: SessionListOptions = {},
): EffectiveSessionListOptions {
return {
...options,
includeGlobal: options.includeGlobal ?? SESSION_LIST_DEFAULTS.includeGlobal,
includeUnknown: options.includeUnknown ?? SESSION_LIST_DEFAULTS.includeUnknown,
configuredAgentsOnly:
options.configuredAgentsOnly ?? SESSION_LIST_DEFAULTS.configuredAgentsOnly,
};
}
function buildSessionListParams(options: EffectiveSessionListOptions): Record<string, unknown> {
function buildSessionListParams(options: SessionListOptions = {}): Record<string, unknown> {
const params: Record<string, unknown> = {
includeGlobal: options.includeGlobal,
includeUnknown: options.includeUnknown,
configuredAgentsOnly: options.configuredAgentsOnly,
...SESSION_LIST_PARAMS,
};
if (options.limit === undefined) {
params.limit = 50;
} else if (options.limit > 0) {
params.limit = Math.floor(options.limit);
}
if (options.includeGlobal !== undefined) {
params.includeGlobal = options.includeGlobal;
}
if (options.includeUnknown !== undefined) {
params.includeUnknown = options.includeUnknown;
}
if (options.configuredAgentsOnly !== undefined) {
params.configuredAgentsOnly = options.configuredAgentsOnly;
}
if (options.showArchived === true) {
params.archived = true;
}
@@ -302,7 +294,7 @@ function buildSessionListParams(options: EffectiveSessionListOptions): Record<st
async function requestSessionList(
client: SessionRequestClient,
options: EffectiveSessionListOptions,
options: SessionListOptions = {},
): Promise<SessionsListResult | null> {
const result = await client.request<SessionsListResult | undefined>(
"sessions.list",
@@ -495,18 +487,6 @@ function isSessionStateEvent(event: GatewayEventFrame): boolean {
return event.event === "sessions.changed" || event.event === "session.message";
}
function canReconcileSessionEvent(options: SessionListOptions): boolean {
return (
options.activeMinutes === undefined &&
options.search === undefined &&
options.offset === undefined &&
options.limit === undefined &&
options.includeGlobal !== false &&
options.includeUnknown !== false &&
options.configuredAgentsOnly !== true
);
}
export function reconcileSessionRunTerminal(
result: SessionsListResult | null,
terminal: SessionRunTerminal,
@@ -615,10 +595,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
if (!scope) {
return null;
}
const result = await requestSessionList(
scope.client,
resolveEffectiveSessionListOptions(options),
);
const result = await requestSessionList(scope.client, options);
return isCurrentConnection(scope) ? (result ?? null) : null;
};
@@ -666,10 +643,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
if (!scope) {
return;
}
const { append = false, force: _force, backgroundHydrate = false, ...listOptions } = options;
const requestOptions = resolveEffectiveSessionListOptions(listOptions);
// Event reconciliation must retain the boolean filters sent to sessions.list.
// Otherwise broad events can insert rows that the Gateway deliberately excluded.
const { append = false, force: _force, backgroundHydrate = false, ...requestOptions } = options;
lastListOptions = requestOptions;
if (!backgroundHydrate) {
publish({ ...state, loading: true, error: null, deletedSessions: [] });
@@ -1178,35 +1152,18 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
if (event.event === "session.message" && !runEnded) {
return;
}
if (!canReconcileSessionEvent(lastListOptions)) {
void refresh({ ...lastListOptions, force: true });
return;
}
const priorRow =
reconciled.row ??
(eventInfo
? state.result?.sessions.find((row) => areUiSessionKeysEquivalent(row.key, eventInfo.key))
: undefined);
const activeRunClearNeedsRefresh = runEnded && priorRow?.hasActiveRun === true;
if (activeRunClearNeedsRefresh) {
// Terminal lifecycle events can omit hasActiveRun. Re-list when the
// stale-row guard preserves an active row after the run has ended.
void refresh({ ...lastListOptions, force: true });
return;
}
if (reconciled.applied) {
if (reconciled.result !== state.result || reconciled.deletedKey) {
publish({
...state,
result: reconciled.result,
error: null,
deletedSessions: reconciled.deletedKey
? [{ key: reconciled.deletedKey, agentId: reconciled.agentId ?? undefined }]
: [],
});
}
return;
if (reconciled.deletedKey) {
// Preserve remote-deletion navigation before the canonical refresh
// clears transient event state.
publish({
...state,
deletedSessions: [
{ key: reconciled.deletedKey, agentId: reconciled.agentId ?? undefined },
],
});
}
// Gateway lists are filtered and windowed. Events cannot preserve server
// membership or ordering, so the coalesced refresh remains canonical.
void refresh({ ...lastListOptions, force: true });
}
});