From f870d93e2a22bc25992f47eb03550d38620e6f4d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 15 Aug 2026 18:11:53 -0700 Subject: [PATCH] fix(ui): no-op the second reconcile of the same sessions.changed event (#124326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every sessions.changed is reconciled twice — once by the capability's own subscribeEvents handler and once by the chat page's reconcileSessionEvent — and reconcileSessionHistory always built a fresh sessions array, so the second, content-identical pass defeated every downstream result === state.result publish gate and re-rendered the sidebar per event. The merge now returns the original result identity when the merged row is content-equal to the existing row (shallow compare with structural fallback; rows are wire scalars/plain objects). The isOlderSessionSnapshot strict-< guard could not catch this: the duplicate pass carries the same updatedAt. Production +31 (the equality gate); regression test proves second-pass identity and fails pre-fix. 9082 UI tests green. --- ui/src/lib/sessions/reconcile.test.ts | 27 +++++++++++++++++++++++ ui/src/lib/sessions/reconcile.ts | 31 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/ui/src/lib/sessions/reconcile.test.ts b/ui/src/lib/sessions/reconcile.test.ts index 9bf7166239c3..b28e369f8c32 100644 --- a/ui/src/lib/sessions/reconcile.test.ts +++ b/ui/src/lib/sessions/reconcile.test.ts @@ -89,6 +89,33 @@ test("sessions.changed removes a label when the event carries null", () => { expect(reconciled.result?.sessions[0]?.displayName).toBeUndefined(); }); +test("reconciling the same sessions.changed twice keeps result identity on the second pass", () => { + const result: SessionsListResult = { + ts: 1, + path: "", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [{ key: "agent:main:main", kind: "direct", updatedAt: 1 }], + }; + const payload = { + sessionKey: "agent:main:main", + reason: "patch", + updatedAt: 2, + label: "Renamed", + }; + + const first = reconcileSessionChanged(result, payload); + expect(first.applied).toBe(true); + expect(first.result).not.toBe(result); + expect(first.result?.sessions[0]?.label).toBe("Renamed"); + + // The capability handler and the chat page both drive the same event; the + // second reconcile must return the identical result object so downstream + // result === state.result publish gates skip the duplicate re-render. + const second = reconcileSessionChanged(first.result ?? null, payload); + expect(second.result).toBe(first.result); +}); + test("sessions.changed deletes every null-tombstoned field, not a hand-kept list", () => { // The gateway tombstones more fields than the old per-field cascade knew // about; these five leaked literal null into rows typed optional-not-null. diff --git a/ui/src/lib/sessions/reconcile.ts b/ui/src/lib/sessions/reconcile.ts index 113f4c68d5be..d49179d70825 100644 --- a/ui/src/lib/sessions/reconcile.ts +++ b/ui/src/lib/sessions/reconcile.ts @@ -195,6 +195,27 @@ function stripThinkingMetadata(value: T): T { return next; } +/** Same-content merge detection; row values are wire scalars/plain objects, so one level suffices. */ +function isShallowEqualSessionRow( + incoming: GatewaySessionRow, + existing: GatewaySessionRow, +): boolean { + const incomingKeys = Object.keys(incoming); + if (incomingKeys.length !== Object.keys(existing).length) { + return false; + } + return incomingKeys.every((key) => { + const a = (incoming as Record)[key]; + const b = (existing as Record)[key]; + return ( + a === b || + (a !== null && b !== null && typeof a === "object" && typeof b === "object" + ? JSON.stringify(a) === JSON.stringify(b) + : false) + ); + }); +} + function isOlderSessionSnapshot( incoming: GatewaySessionRow, existing: GatewaySessionRow | undefined, @@ -551,6 +572,16 @@ export function reconcileSessionHistory( // result === state.result publish gate can skip a spurious re-render. return defaults ? { ...result, defaults: nextDefaults } : result; } + if ( + existing && + isShallowEqualSessionRow(visibleSession, existing) && + sessionMatchesArchivedFilter(visibleSession, archivedFilter) + ) { + // The same event reconciled twice (capability handler + chat page) must + // no-op the second pass; a fresh array here defeats every downstream + // result === state.result publish gate and re-renders per event. + return defaults ? { ...result, defaults: nextDefaults } : result; + } const sessions = sessionMatchesArchivedFilter(visibleSession, archivedFilter) ? [ ...result.sessions.filter((candidate) => candidate.key !== visibleKey),