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),