diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 3dc6149e9..29bfd7dba 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -12,6 +12,7 @@ import { } from "@/features/agents/observerRelayStore"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll"; +import { useStableArrayShallow } from "@/shared/hooks/useStableReference"; import { cn } from "@/shared/lib/cn"; import { Dialog, @@ -48,6 +49,7 @@ import type { FileEditDiff } from "./agentSessionFileEditDiff"; import { buildTranscriptDisplayBlocks, formatTurnSetupLabel, + getDisplayBlockKey, turnSetupDetail, turnSetupTimestamp, type TranscriptDisplayBlock, @@ -154,13 +156,27 @@ export function AgentSessionTranscriptList({ () => buildTranscriptDisplayBlocks(items, latestLiveSessionId), [items, latestLiveSessionId], ); + // Derive the same block keys the DOM renders as `data-message-id` so + // useAnchoredScroll anchors on real DOM rows. Value-stabilized so the + // hook's restoration effect only fires when the ordered block sequence + // actually changes (same pattern as AgentSessionThreadPanel). + const blockKeys = React.useMemo( + () => (autoTail ? displayBlocks.map(getDisplayBlockKey) : []), + [autoTail, displayBlocks], + ); + const stableBlockKeys = useStableArrayShallow(blockKeys); + const scrollMessages = React.useMemo( + () => stableBlockKeys.map((id) => ({ id })), + [stableBlockKeys], + ); + const scrollContainerRef = React.useRef(null); const contentRef = React.useRef(null); const anchoredScroll = useAnchoredScroll({ channelId: autoTail ? (scrollScopeKey ?? agentPubkey) : null, contentRef, isLoading: false, - messages: items, + messages: scrollMessages, scrollContainerRef, }); @@ -333,18 +349,6 @@ function TranscriptAcpSourceBadge({ source }: { source: string }) { ); } -function getDisplayBlockKey(block: TranscriptDisplayBlock) { - if (block.kind === "single") { - return block.item.id; - } - if (block.kind === "session-boundary") { - // Use firstItemId (stable across prepend) rather than runIndex (shifts when - // older sessions are prepended, causing unnecessary boundary remounts). - return `session-boundary:${block.sessionId}:${block.firstItemId}`; - } - return `turn:${block.turnId}`; -} - function TranscriptDisplayBlockView({ agentAvatarUrl, agentName, diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 48db6f6f5..962290c6c 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -1156,7 +1156,9 @@ export function processTranscriptEvent( }; } -export function buildTranscriptState(events: ObserverEvent[]): TranscriptState { +export function buildTranscriptState( + events: readonly ObserverEvent[], +): TranscriptState { let state = createEmptyTranscriptState(); for (const event of events) { state = processTranscriptEvent(state, event); @@ -1164,6 +1166,8 @@ export function buildTranscriptState(events: ObserverEvent[]): TranscriptState { return state; } -export function buildTranscript(events: ObserverEvent[]): TranscriptItem[] { +export function buildTranscript( + events: readonly ObserverEvent[], +): TranscriptItem[] { return buildTranscriptState(events).items; } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 8e1968dc5..7fddb8a7f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -5,6 +5,7 @@ import { buildTranscriptDisplayBlocks, flattenDisplayBlocks, formatTurnSetupLabel, + getDisplayBlockKey, } from "./agentSessionTranscriptGrouping.ts"; import { isObserverEventAfter } from "../observerRelayStore.ts"; @@ -1709,3 +1710,166 @@ test("buildTranscriptDisplayBlocks_leadingSingleBeforeSessionNew_emittedInline", `sp (${spIdx}) must precede toolA (${toolAIdx}) — system-prompt must be hoisted even after a leading item`, ); }); + +// ── getDisplayBlockKey ────────────────────────────────────────────────────────── + +test("getDisplayBlockKey_single_returnsItemId", () => { + const block = { kind: "single", item: { id: "item-42" } }; + assert.equal(getDisplayBlockKey(block), "item-42"); +}); + +test("getDisplayBlockKey_turn_returnsPrefixedTurnId", () => { + const block = { kind: "turn", turnId: "t-99", segments: [] }; + assert.equal(getDisplayBlockKey(block), "turn:t-99"); +}); + +test("getDisplayBlockKey_sessionBoundary_usesFirstItemIdNotRunIndex", () => { + const block = { + kind: "session-boundary", + sessionId: "sess-2", + sessionStartTimestamp: "2026-07-08T00:00:00.000Z", + labelState: "most-recent", + runIndex: 3, + firstItemId: "first-item-abc", + }; + assert.equal( + getDisplayBlockKey(block), + "session-boundary:sess-2:first-item-abc", + "key must use firstItemId, not runIndex", + ); +}); + +test("getDisplayBlockKey_independentOfLatestLiveSessionId", () => { + // Build blocks for the same items with different latestLiveSessionId values. + // Keys must be identical — only labelState differs. + const items = [ + sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"), + sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"), + ]; + + const blocksLive = buildTranscriptDisplayBlocks(items, "sess-2"); + const blocksNoLive = buildTranscriptDisplayBlocks(items, null); + const blocksDiffLive = buildTranscriptDisplayBlocks(items, "sess-other"); + + const keysLive = blocksLive.map(getDisplayBlockKey); + const keysNoLive = blocksNoLive.map(getDisplayBlockKey); + const keysDiffLive = blocksDiffLive.map(getDisplayBlockKey); + + assert.deepEqual( + keysLive, + keysNoLive, + "keys must not vary with latestLiveSessionId=null", + ); + assert.deepEqual( + keysLive, + keysDiffLive, + "keys must not vary with latestLiveSessionId=other", + ); + + // Sanity check: labelState DID change (proves the test setup matters). + const boundaryLive = blocksLive.find((b) => b.kind === "session-boundary"); + const boundaryNoLive = blocksNoLive.find( + (b) => b.kind === "session-boundary", + ); + assert.notEqual( + boundaryLive.labelState, + boundaryNoLive.labelState, + "labelState must differ (test setup sanity check)", + ); +}); + +test("getDisplayBlockKey_parity_matchesBuildTranscriptDisplayBlocksOutput", () => { + // End-to-end: derive keys from buildTranscriptDisplayBlocks and assert they + // match the expected key for each block kind (the load-bearing invariant that + // outer-panel-derived ids match inner DOM data-message-id). + const items = [ + sessionItem("orphan", "sess-1", "2026-07-08T00:00:00.000Z"), + // Multi-session to get a boundary block. + sessionItem("tool-a", "sess-2", "2026-07-08T00:00:01.000Z"), + ]; + + // Give orphan no turnId so it becomes a "single" block. + items[0].turnId = undefined; + + const blocks = buildTranscriptDisplayBlocks(items, null); + const keys = blocks.map(getDisplayBlockKey); + + // Expect: [single:orphan, boundary:sess-2:tool-a, turn:turn-tool-a] + assert.ok(keys.includes("orphan"), "single block key = item.id"); + assert.ok( + keys.some((k) => k.startsWith("session-boundary:sess-2:")), + "boundary block key uses session-boundary: prefix", + ); + assert.ok( + keys.some((k) => k.startsWith("turn:")), + "turn block key uses turn: prefix", + ); + + // No duplicates. + assert.equal(new Set(keys).size, keys.length, "all keys must be unique"); +}); + +// ── Key-parity invariant: transient [turn, single] → [single, turn] reorder ── + +test("getDisplayBlockKey_firstTurnReorder_keysStableAcrossReorder", () => { + // Partial sequence: turn_started → session/new (two items, before session_resolved). + // At this point the key set is some combination of single + turn keys. + const ts = "2026-07-08T10:00:00.000Z"; + const partialItems = [ + { + id: "turn-started", + type: "lifecycle", + renderClass: "lifecycle", + title: "Turn started", + text: "", + timestamp: ts, + acpSource: "turn_started", + turnId: "turn-001", + sessionId: null, + channelId: "chan-1", + }, + { + id: "system-prompt:chan-1", + type: "metadata", + renderClass: "raw-rail", + title: "System prompt", + sections: [{ title: "Base", body: "You are a helpful AI assistant." }], + timestamp: ts, + acpSource: "session/new", + turnId: null, + sessionId: null, + channelId: "chan-1", + }, + ]; + + const partialBlocks = buildTranscriptDisplayBlocks(partialItems); + const partialKeys = new Set(partialBlocks.map(getDisplayBlockKey)); + + // Full sequence: add session_resolved (same turnId) — this seals the open + // batch and may reorder [turn, single] → [single, turn]. + const fullItems = [ + ...partialItems, + { + id: "session-resolved", + type: "lifecycle", + renderClass: "lifecycle", + title: "Session ready", + text: "", + timestamp: ts, + acpSource: "session_resolved", + turnId: "turn-001", + sessionId: "session-001", + channelId: "chan-1", + }, + ]; + + const fullBlocks = buildTranscriptDisplayBlocks(fullItems); + const fullKeys = new Set(fullBlocks.map(getDisplayBlockKey)); + + // The KEY IDENTITIES must be identical — only the ORDER may differ. + assert.deepEqual( + partialKeys, + fullKeys, + "block key identities must be identical before and after session_resolved seals the open batch (order may differ)", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 2914f9f5b..795f1fcd1 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -1,4 +1,5 @@ -import type { TranscriptItem } from "./agentSessionTypes"; +import { buildTranscriptState } from "./agentSessionTranscript"; +import type { ObserverEvent, TranscriptItem } from "./agentSessionTypes"; import { classifyToolItem } from "./agentSessionToolClassifier"; export type TranscriptTurnSegment = @@ -742,6 +743,46 @@ export function flattenDisplayBlocks( return result; } +/** + * Stable display key for a transcript block — used as the React list key and + * as the `data-message-id` attribute that `useAnchoredScroll` anchors on. + * + * Must be kept in sync with the `data-message-id` rendered by + * `AgentSessionTranscriptList` so outer scroll-anchor ids and inner DOM ids + * always agree 1:1. + */ +export function getDisplayBlockKey(block: TranscriptDisplayBlock): string { + if (block.kind === "single") { + return block.item.id; + } + if (block.kind === "session-boundary") { + // Use firstItemId (stable across prepend) rather than runIndex (shifts when + // older sessions are prepended, causing unnecessary boundary remounts). + return `session-boundary:${block.sessionId}:${block.firstItemId}`; + } + return `turn:${block.turnId}`; +} + +/** + * Derive the ordered display-block key sequence from raw observer events. + * + * Pure function: `buildTranscriptState(events).items` → + * `buildTranscriptDisplayBlocks` → `getDisplayBlockKey`. This is the exact + * chain `AgentSessionThreadPanel` uses to produce the id list fed to + * `useAnchoredScroll` — extracted so both production and tests share one + * code path. + * + * `latestLiveSessionId` is intentionally omitted: it only affects boundary + * `labelState`, never keys. + */ +export function deriveTranscriptBlockIds( + events: readonly ObserverEvent[], +): string[] { + const items = buildTranscriptState(events).items; + const blocks = buildTranscriptDisplayBlocks(items); + return blocks.map(getDisplayBlockKey); +} + /** Human-readable labels for a collapsed turn setup row. */ export function formatTurnSetupLabel( items: Extract[], diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 7aef7a2e7..380db8816 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -15,6 +15,7 @@ import { observerEventScrollId, scopeByChannel, } from "@/features/agents/ui/agentSessionPanelLayout"; +import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping"; import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; import { @@ -22,6 +23,7 @@ import { useObserverEvents, } from "@/features/agents/ui/useObserverEvents"; import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll"; +import { useStableArrayShallow } from "@/shared/hooks/useStableReference"; import { cancelManagedAgentTurn } from "@/shared/api/agentControl"; import type { Channel } from "@/shared/api/types"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; @@ -152,28 +154,64 @@ export function AgentSessionThreadPanel({ sentinelRef: topSentinelRef, }); const rawFeedScopeKey = `${agent.pubkey}:${sessionChannelId ?? "all"}`; - // Live+archived is what actually renders (an idle agent's feed is - // archived-only, where scopedEvents alone would be empty and the tail-glue - // never fires) — see combinedHeaderEvents above. `observerEventScrollId` - // keys on (seq, timestamp), not seq alone: seq resets to 1 after every - // agent process restart, so a bare seq id can collide across restarts - // within one channel's combined window. The raw event rail below uses the - // same helper so both id namespaces always agree. - const anchoredScrollMessages = React.useMemo( + const [rawFeedState, setRawFeedState] = React.useState(() => ({ + scopeKey: rawFeedScopeKey, + show: false, + })); + const showRawFeed = + rawFeedState.scopeKey === rawFeedScopeKey && rawFeedState.show; + const handleRawFeedChange = React.useCallback( + (checked: boolean) => { + setRawFeedState({ scopeKey: rawFeedScopeKey, show: checked }); + }, + [rawFeedScopeKey], + ); + + // --- Transcript block ids for default Activity mode --- + // Derive the same display-block keys the inner AgentSessionTranscriptList + // renders as `data-message-id` so useAnchoredScroll anchors on real DOM rows + // instead of raw event ids (which live in a disjoint namespace and cause + // per-event floor writes → flicker + jump-to-tail). + // + // latestLiveSessionId is omitted: it only affects boundary `labelState`, + // never keys (agentSessionTranscriptGrouping.ts:557-574), so we avoid + // subscribing to the observer store from the outer panel. + const transcriptBlockIds = React.useMemo( + () => (showRawFeed ? [] : deriveTranscriptBlockIds(combinedHeaderEvents)), + [combinedHeaderEvents, showRawFeed], + ); + + // Stabilize the id array by VALUE so the hook's restoration effect (keyed on + // the `messages` reference) does not fire on every raw event when the block + // id sequence is unchanged. useStableArrayShallow shallow-compares with + // Object.is on each string element. + const stableTranscriptBlockIds = useStableArrayShallow(transcriptBlockIds); + + // Map to {id} objects only when the stabilized array reference changes. + const transcriptScrollMessages = React.useMemo( + () => stableTranscriptBlockIds.map((id) => ({ id })), + [stableTranscriptBlockIds], + ); + + // Raw-mode ids: keyed on (seq, timestamp) — matches RawEventRail's + // data-message-id. seq resets on agent restart so bare seq can collide; + // observerEventScrollId disambiguates. + const rawScrollMessages = React.useMemo( () => combinedHeaderEvents.map((event) => ({ id: observerEventScrollId(event), })), [combinedHeaderEvents], ); + const { onScroll } = useAnchoredScroll({ - // Scoped to the same (agent, channel) pair as the rendered feed, so - // switching agents/channels resets the anchor to bottom instead of - // carrying over the previous feed's scroll state. - channelId: rawFeedScopeKey, + // Fold view mode into the reset key so toggling raw ↔ transcript + // re-initializes the anchor (clean re-pin) instead of carrying an anchor + // across disjoint id namespaces. + channelId: `${rawFeedScopeKey}:${showRawFeed ? "raw" : "transcript"}`, contentRef, isLoading: connectionState === "connecting", - messages: anchoredScrollMessages, + messages: showRawFeed ? rawScrollMessages : transcriptScrollMessages, scrollContainerRef: scrollRef, }); // Scope label input: prefer the passed channel's name; when the pane is @@ -199,18 +237,6 @@ export function AgentSessionThreadPanel({ ? `#${scopeChannelName}` : "1 channel" : "All channels"; - const [rawFeedState, setRawFeedState] = React.useState(() => ({ - scopeKey: rawFeedScopeKey, - show: false, - })); - const showRawFeed = - rawFeedState.scopeKey === rawFeedScopeKey && rawFeedState.show; - const handleRawFeedChange = React.useCallback( - (checked: boolean) => { - setRawFeedState({ scopeKey: rawFeedScopeKey, show: checked }); - }, - [rawFeedScopeKey], - ); const animateActivity = useTranscriptAnimationEnabled(); const showTimestamps = useTranscriptTimestampsEnabled(); async function handleInterruptTurn() { diff --git a/desktop/src/features/channels/ui/agentSessionScrollIds.test.mjs b/desktop/src/features/channels/ui/agentSessionScrollIds.test.mjs new file mode 100644 index 000000000..7c1df5003 --- /dev/null +++ b/desktop/src/features/channels/ui/agentSessionScrollIds.test.mjs @@ -0,0 +1,546 @@ +/** + * Behavior-level tests for the observer feed scroll-id wiring. + * + * Corrective actions addressed: + * 1. Production derivation chain (via `deriveTranscriptBlockIds` — the same + * exported helper AgentSessionThreadPanel calls) + reference stability + * via useStableArrayShallow. + * 4. Mode-toggle reset-key disjointness. + * + * Corrective action 3 (ordered DOM parity) — NAMED RESIDUAL: + * AgentSessionTranscriptList cannot render under node:test — the component + * tree transitively imports a .css file (BuzzLogoAnimation.tsx → + * buzz-logo-animation.css) and the test-loader has no CSS stub. The + * underlying invariant (outer-derived ids = inner data-message-id) is + * structurally guaranteed by both sides calling the same exported + * getDisplayBlockKey, but a full-component render test would additionally + * catch a block being filtered or the attribute being dropped. That + * coverage requires a CSS-stub addition to the test-loader (outside this + * PR's scope). See D1 in Paul's verification at event 8dfadd5f. + * + * Hook-level zero-write assertions (corrective action 2) and mode-toggle + * re-pin behavior (corrective action 4) live in + * useAnchoredScroll.observerScrollIds.test.mjs alongside the existing hook tests. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import React from "react"; + +import { + deriveTranscriptBlockIds, + getDisplayBlockKey, + buildTranscriptDisplayBlocks, +} from "@/features/agents/ui/agentSessionTranscriptGrouping.ts"; +import { observerEventScrollId } from "@/features/agents/ui/agentSessionPanelLayout.ts"; + +// ─── Helpers ──────────────────────────────────────────────────────────────────── + +const BASE_TS = "2026-07-08T00:00:00.000Z"; + +/** + * Build a minimal ObserverEvent that produces a tool-call TranscriptItem + * through `buildTranscriptState`. Uses the `session/update` → `tool_call_update` + * method path (the most common tool-producing event in production). + */ +function mkToolEvent( + seq, + { sessionId = "sess-1", turnId = "turn-1", ts } = {}, +) { + return { + seq, + timestamp: ts ?? `2026-07-08T00:00:${String(seq).padStart(2, "0")}.000Z`, + kind: "acp_read", + agentIndex: 0, + channelId: "chan-1", + sessionId, + turnId, + payload: { + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: `call-${seq}`, + toolName: `tool-${seq}`, + status: "completed", + args: "{}", + result: "ok", + }, + }, + }, + }; +} + +/** Build a minimal ObserverEvent for raw-mode id derivation. */ +function mkRawEvent(seq, ts = BASE_TS) { + return { seq, timestamp: ts }; +} + +// ── Corrective action 1: production derivation chain + reference stability ─── +// +// These tests derive ids through the REAL production helper +// `deriveTranscriptBlockIds(events)` — the exact function +// `AgentSessionThreadPanel` calls. No mirror code, no pre-built items. + +test("production chain: same-turn event append produces value-equal block id sequence", () => { + const events1 = [mkToolEvent(1)]; + const ids1 = deriveTranscriptBlockIds(events1); + + // A second event on the same turn — block key unchanged. + const events2 = [...events1, mkToolEvent(2)]; + const ids2 = deriveTranscriptBlockIds(events2); + + // Value-equal: useStableArrayShallow will preserve the prior reference. + assert.deepEqual(ids1, ids2); + // Verify element-wise Object.is equality (what useStableArrayShallow checks). + assert.equal(ids1.length, ids2.length); + for (let i = 0; i < ids1.length; i++) { + assert.ok( + Object.is(ids1[i], ids2[i]), + `element ${i}: ${ids1[i]} must be Object.is-equal to ${ids2[i]}`, + ); + } +}); + +test("production chain: streaming 10 same-turn events produces stable id sequence", () => { + const events5 = Array.from({ length: 5 }, (_, i) => mkToolEvent(i + 1)); + const ids5 = deriveTranscriptBlockIds(events5); + + const events10 = [ + ...events5, + ...Array.from({ length: 5 }, (_, i) => mkToolEvent(i + 6)), + ]; + const ids10 = deriveTranscriptBlockIds(events10); + + assert.deepEqual( + ids5, + ids10, + "same-turn streaming must not change block ids", + ); +}); + +test("production chain: new session produces value-different id sequence", () => { + const events1 = [ + mkToolEvent(1, { sessionId: "sess-1", ts: "2026-07-08T00:00:01.000Z" }), + ]; + const ids1 = deriveTranscriptBlockIds(events1); + + const events2 = [ + ...events1, + mkToolEvent(2, { + sessionId: "sess-2", + turnId: "turn-2", + ts: "2026-07-08T00:00:02.000Z", + }), + ]; + const ids2 = deriveTranscriptBlockIds(events2); + + assert.ok(ids2.length > ids1.length, "new session must grow the id list"); + assert.notDeepEqual(ids1, ids2); +}); + +test("production chain: new turn in same session produces value-different id sequence", () => { + const events1 = [mkToolEvent(1)]; + const ids1 = deriveTranscriptBlockIds(events1); + + const events2 = [...events1, mkToolEvent(2, { turnId: "turn-2" })]; + const ids2 = deriveTranscriptBlockIds(events2); + + assert.equal(ids2.length, ids1.length + 1, "new turn adds one block id"); + assert.ok( + ids2.some((id) => id.startsWith("turn:turn-2")), + "new turn block key must be present", + ); +}); + +// ── Corrective action 1 (cont.): reference stability via useStableArrayShallow ── +// +// Verify that useStableArrayShallow returns the SAME reference for value-equal +// string arrays and a DIFFERENT reference for value-different arrays. +// This is tested by importing the hook directly and calling it via React. + +function installDOMShimForStabilityTest() { + if (globalThis.document) return; // already installed by a prior test + + class EventTargetShim { + constructor() { + this.listeners = new Map(); + } + addEventListener(type, listener) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + removeEventListener(type, listener) { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter((l) => l !== listener), + ); + } + dispatchEvent(event) { + for (const l of this.listeners.get(event.type) ?? []) l(event); + return true; + } + } + class NodeShim extends EventTargetShim { + constructor(tagName) { + super(); + this.tagName = tagName; + this.nodeName = tagName.toUpperCase(); + this.nodeType = 1; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + this.children = []; + this.childNodes = []; + this.style = {}; + this.parentNode = null; + this.attributes = {}; + } + setAttribute(name, value) { + this.attributes[name] = value; + } + removeAttribute(name) { + delete this.attributes[name]; + } + getAttribute(name) { + return this.attributes[name] ?? null; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get lastChild() { + return this.children.at(-1) ?? null; + } + get nextSibling() { + return null; + } + get nodeValue() { + return null; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((c) => c !== child); + this.childNodes = this.childNodes.filter((c) => c !== child); + child.parentNode = null; + return child; + } + insertBefore(child, ref) { + if (!ref) return this.appendChild(child); + const idx = this.children.indexOf(ref); + if (idx < 0) return this.appendChild(child); + this.children.splice(idx, 0, child); + this.childNodes.splice(idx, 0, child); + child.parentNode = this; + return child; + } + contains(node) { + return this === node || this.children.some((c) => c.contains(node)); + } + } + class DocumentShim extends EventTargetShim { + constructor() { + super(); + this.nodeType = 9; + this.defaultView = globalThis; + } + createElement(tagName) { + return new NodeShim(tagName); + } + createTextNode(value) { + const node = new NodeShim("#text"); + node.nodeType = 3; + node.nodeValue = value; + return node; + } + createComment(value) { + const node = new NodeShim("#comment"); + node.nodeType = 8; + node.nodeValue = value; + return node; + } + get activeElement() { + return null; + } + } + globalThis.document = new DocumentShim(); + globalThis.HTMLIFrameElement = NodeShim; + globalThis.HTMLElement = NodeShim; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, + }); + globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + globalThis.CSS = { escape: (v) => v }; + globalThis.ResizeObserver = class { + observe() {} + disconnect() {} + }; +} + +installDOMShimForStabilityTest(); + +// Dynamic import AFTER DOM shim is installed — React checks for document at import time. +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { useStableArrayShallow } = await import( + "@/shared/hooks/useStableReference.ts" +); + +test("useStableArrayShallow: preserves reference for value-equal string arrays", async () => { + const captured = []; + function Harness({ ids }) { + const stable = useStableArrayShallow(ids); + captured.push(stable); + return null; + } + + const root = createRoot(document.createElement("div")); + + const ids1 = ["turn:t1", "turn:t2"]; + await act(async () => { + root.render(React.createElement(Harness, { ids: ids1 })); + }); + + // Re-render with a NEW array reference containing the SAME values. + const ids2 = ["turn:t1", "turn:t2"]; + assert.notEqual(ids1, ids2, "test setup: arrays must be distinct references"); + await act(async () => { + root.render(React.createElement(Harness, { ids: ids2 })); + }); + + assert.ok(captured.length >= 2, "harness must have rendered at least twice"); + assert.equal( + captured[0], + captured[1], + "useStableArrayShallow must return the SAME reference for value-equal arrays", + ); + + await act(async () => { + root.unmount(); + }); +}); + +test("useStableArrayShallow: returns new reference for value-different arrays", async () => { + const captured = []; + function Harness({ ids }) { + const stable = useStableArrayShallow(ids); + captured.push(stable); + return null; + } + + const root = createRoot(document.createElement("div")); + + await act(async () => { + root.render(React.createElement(Harness, { ids: ["turn:t1", "turn:t2"] })); + }); + + // Different values → must be a new reference. + await act(async () => { + root.render( + React.createElement(Harness, { + ids: ["turn:t1", "turn:t2", "turn:t3"], + }), + ); + }); + + assert.ok(captured.length >= 2); + assert.notEqual( + captured[0], + captured[1], + "useStableArrayShallow must return a NEW reference when values change", + ); + + await act(async () => { + root.unmount(); + }); +}); + +test("stabilization chain: production helper → useStableArrayShallow → same {id}[] reference", async () => { + // End-to-end: call the PRODUCTION deriveTranscriptBlockIds from raw events, + // stabilize, map to {id}[] — the exact chain in AgentSessionThreadPanel. + const messageArrays = []; + function Harness({ events }) { + const blockIds = React.useMemo( + () => deriveTranscriptBlockIds(events), + [events], + ); + const stableIds = useStableArrayShallow(blockIds); + const messages = React.useMemo( + () => stableIds.map((id) => ({ id })), + [stableIds], + ); + messageArrays.push(messages); + return null; + } + + const root = createRoot(document.createElement("div")); + + // First render: one tool event → one turn block. + const events1 = [mkToolEvent(1)]; + await act(async () => { + root.render(React.createElement(Harness, { events: events1 })); + }); + + // Second render: same turn, new event — block ids unchanged. + const events2 = [...events1, mkToolEvent(2)]; + await act(async () => { + root.render(React.createElement(Harness, { events: events2 })); + }); + + assert.ok(messageArrays.length >= 2); + assert.equal( + messageArrays[0], + messageArrays[1], + "messages reference must be preserved when block ids are value-equal " + + "(this is the load-bearing invariant that prevents per-raw-event scrollTo writes)", + ); + + // Third render: new turn — block ids change → new reference. + const events3 = [...events2, mkToolEvent(3, { turnId: "turn-2" })]; + await act(async () => { + root.render(React.createElement(Harness, { events: events3 })); + }); + + assert.ok(messageArrays.length >= 3); + assert.notEqual( + messageArrays[1], + messageArrays[2], + "messages reference must change when block ids change", + ); + + await act(async () => { + root.unmount(); + }); +}); + +// ── Corrective action 3: ordered DOM parity — structural guarantee ────────── +// +// AgentSessionTranscriptList cannot render under node:test (CSS import blocker: +// BuzzLogoAnimation.tsx → buzz-logo-animation.css). The test below verifies the +// structural guarantee: both sides (outer derivation + inner render) call the +// SAME getDisplayBlockKey function, and the outer uses the SAME +// buildTranscriptDisplayBlocks output. This cannot catch a block being filtered +// or the attribute being dropped by the component, but it does catch key-function +// drift. Full-component coverage requires a CSS-stub in the test-loader. + +test("structural parity: getDisplayBlockKey output is ordered and deterministic across reorder", () => { + const ts = "2026-07-08T10:00:00.000Z"; + + // Partial sequence: turn_started + session/new — before session_resolved. + const partialItems = [ + { + id: "turn-started", + type: "lifecycle", + renderClass: "lifecycle", + title: "Turn started", + text: "", + timestamp: ts, + acpSource: "turn_started", + turnId: "turn-001", + sessionId: null, + channelId: "chan-1", + }, + { + id: "system-prompt:chan-1", + type: "metadata", + renderClass: "raw-rail", + title: "System prompt", + sections: [{ title: "Base", body: "You are a helpful AI assistant." }], + timestamp: ts, + acpSource: "session/new", + turnId: null, + sessionId: null, + channelId: "chan-1", + }, + ]; + + const partialBlocks = buildTranscriptDisplayBlocks(partialItems); + const partialIds = partialBlocks.map(getDisplayBlockKey); + + // Full sequence: add session_resolved — may reorder blocks. + const fullItems = [ + ...partialItems, + { + id: "session-resolved", + type: "lifecycle", + renderClass: "lifecycle", + title: "Session ready", + text: "", + timestamp: ts, + acpSource: "session_resolved", + turnId: "turn-001", + sessionId: "session-001", + channelId: "chan-1", + }, + ]; + + const fullBlocks = buildTranscriptDisplayBlocks(fullItems); + const fullIds = fullBlocks.map(getDisplayBlockKey); + + // Both sequences have the same key identities. + assert.deepEqual( + [...partialIds].sort(), + [...fullIds].sort(), + "key identities must be stable across session_resolved", + ); + + // Each sequence is internally unique (no duplicates). + assert.equal( + new Set(partialIds).size, + partialIds.length, + "partial ids must be unique", + ); + assert.equal( + new Set(fullIds).size, + fullIds.length, + "full ids must be unique", + ); + + // Verify determinism: deriving again produces the same ordered sequence. + const fullIds2 = + buildTranscriptDisplayBlocks(fullItems).map(getDisplayBlockKey); + assert.deepEqual( + fullIds, + fullIds2, + "block id derivation must be deterministic (same input → same ordered output)", + ); +}); + +// ── Corrective action 4: mode-toggle reset-key disjointness ───────────────── + +test("mode toggle: raw and transcript ids are in disjoint namespaces", () => { + const events = [ + mkRawEvent(1, "2026-07-08T00:00:01.000Z"), + mkRawEvent(2, "2026-07-08T00:00:02.000Z"), + ]; + const rawIds = new Set(events.map((e) => observerEventScrollId(e))); + + // Derive transcript ids through the production helper. + const toolEvents = [ + mkToolEvent(1, { ts: "2026-07-08T00:00:01.000Z" }), + mkToolEvent(2, { + sessionId: "sess-2", + turnId: "turn-2", + ts: "2026-07-08T00:00:02.000Z", + }), + ]; + const blockIds = deriveTranscriptBlockIds(toolEvents); + + for (const blockId of blockIds) { + assert.ok( + !rawIds.has(blockId), + `block id "${blockId}" must not collide with any raw id — ` + + "carrying an anchor across a mode toggle must never produce a false hit", + ); + } +}); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.observerScrollIds.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.observerScrollIds.test.mjs new file mode 100644 index 000000000..6514f70f2 --- /dev/null +++ b/desktop/src/features/messages/ui/useAnchoredScroll.observerScrollIds.test.mjs @@ -0,0 +1,622 @@ +/** + * Hook-level regression tests for the observer feed scroll-id wiring. + * + * Corrective actions addressed: + * 2. Zero-write assertions: same ids at bottom/mid-history → no React-effect + * scrollTo; new id at bottom → one floor write; new id mid-history → no floor. + * 4. Mode-toggle reset: changing channelId (which encodes the mode) resets the + * hook and re-pins in both loaded and connecting→loaded states. + * + * Uses the same DOM shim and harness pattern as the adjacent + * useAnchoredScroll.observerFeedMountPin.test.mjs. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +function installDOMShim() { + class EventTargetShim { + constructor() { + this.listeners = new Map(); + } + addEventListener(type, listener) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + removeEventListener(type, listener) { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter((l) => l !== listener), + ); + } + dispatchEvent(event) { + for (const l of this.listeners.get(event.type) ?? []) l(event); + return true; + } + } + class NodeShim extends EventTargetShim { + constructor(tagName) { + super(); + this.tagName = tagName; + this.nodeName = tagName.toUpperCase(); + this.nodeType = 1; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + this.children = []; + this.childNodes = []; + this.style = {}; + this.parentNode = null; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get lastChild() { + return this.children.at(-1) ?? null; + } + get nextSibling() { + return null; + } + get nodeValue() { + return null; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((c) => c !== child); + this.childNodes = this.childNodes.filter((c) => c !== child); + child.parentNode = null; + return child; + } + insertBefore(child, ref) { + if (!ref) return this.appendChild(child); + const idx = this.children.indexOf(ref); + if (idx < 0) return this.appendChild(child); + this.children.splice(idx, 0, child); + this.childNodes.splice(idx, 0, child); + child.parentNode = this; + return child; + } + contains(node) { + return this === node || this.children.some((c) => c.contains(node)); + } + } + class DocumentShim extends EventTargetShim { + constructor() { + super(); + this.nodeType = 9; + this.defaultView = globalThis; + } + createElement(tagName) { + return new NodeShim(tagName); + } + createTextNode(value) { + const node = new NodeShim("#text"); + node.nodeType = 3; + node.nodeValue = value; + return node; + } + createComment(value) { + const node = new NodeShim("#comment"); + node.nodeType = 8; + node.nodeValue = value; + return node; + } + get activeElement() { + return null; + } + } + globalThis.document = new DocumentShim(); + globalThis.HTMLIFrameElement = NodeShim; + globalThis.HTMLElement = NodeShim; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, + }); + globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + globalThis.CSS = { escape: (v) => v }; + globalThis.ResizeObserver = class { + observe() {} + disconnect() {} + }; +} + +installDOMShim(); + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { useAnchoredScroll } from "./useAnchoredScroll.ts"; + +/** + * Scroll container shim that tracks scrollTo writes. + * The `scrollTo` spy is the critical probe: it captures every React-effect + * floor write the hook performs. + */ +function makeContainer({ clientHeight, scrollHeight, scrollTop = 0 }) { + const writes = []; + return { + clientHeight, + scrollHeight, + scrollTop, + writes, + getBoundingClientRect() { + return { top: 0 }; + }, + querySelector() { + return null; + }, + querySelectorAll() { + return []; + }, + scrollTo({ top, behavior }) { + writes.push({ top, behavior }); + this.scrollTop = top; + }, + }; +} + +/** + * Harness wiring: mirrors AgentSessionThreadPanel's exact hook call — + * bottom-tail only (no targetMessageId, pinTargetCentered omitted/false), + * isLoading derived from the observer store's connection state. + * + * Exposes `onScroll` via a ref so tests can simulate user scroll events + * (required to transition the anchor from at-bottom to mid-history). + */ +function ObserverFeedHarness({ + channelId, + isLoading, + messages, + onScrollRef, + refs, +}) { + const { onScroll } = useAnchoredScroll({ + channelId, + contentRef: refs.content, + isLoading, + messages, + scrollContainerRef: refs.container, + }); + if (onScrollRef) onScrollRef.current = onScroll; + return null; +} + +/** Flush pending rAF-deferred work. */ +async function flushRaf() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +// ── Corrective action 2: hook-level zero-write assertions ─────────────────── + +test("at-bottom: same message ids → zero scrollTo writes after initial pin", async () => { + const refs = { + container: { current: null }, + content: { current: {} }, + }; + const root = createRoot(document.createElement("div")); + + const container = makeContainer({ + clientHeight: 400, + scrollHeight: 1000, + scrollTop: 0, + }); + refs.container.current = container; + + const messages = [{ id: "turn:t1" }, { id: "turn:t2" }]; + + // Mount → initial pin to bottom. + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:transcript", + isLoading: false, + messages, + refs, + }), + ); + }); + await flushRaf(); + + // Record the write count after initial pin. + const writesAfterMount = container.writes.length; + assert.ok(writesAfterMount > 0, "initial mount must pin to bottom"); + assert.equal( + container.scrollTop, + container.scrollHeight, + "must be at the bottom after mount", + ); + + // Re-render with the SAME messages reference — simulates raw events + // arriving that do not change the stabilized block-id array. + // The hook should NOT write scrollTo again. + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:transcript", + isLoading: false, + messages, // same reference + refs, + }), + ); + }); + await flushRaf(); + + assert.equal( + container.writes.length, + writesAfterMount, + "same messages reference at bottom → zero additional scrollTo writes " + + "(this is the regression the fix prevents: without useStableArrayShallow, " + + "every raw event would produce a new reference and fire scrollTo)", + ); + + await act(async () => { + root.unmount(); + }); +}); + +test("mid-history: same message ids → zero scrollTo writes", async () => { + const refs = { + container: { current: null }, + content: { current: {} }, + }; + const onScrollRef = { current: null }; + const root = createRoot(document.createElement("div")); + + const container = makeContainer({ + clientHeight: 400, + scrollHeight: 2000, + scrollTop: 0, + }); + // Fake [data-message-id] row so computeAnchor can find a mid-history anchor. + const fakeRow = { + dataset: { messageId: "turn:t1" }, + getBoundingClientRect() { + return { top: 100, bottom: 140, height: 40 }; + }, + }; + container.querySelectorAll = () => [fakeRow]; + refs.container.current = container; + + const messages = [{ id: "turn:t1" }, { id: "turn:t2" }, { id: "turn:t3" }]; + + // Mount and pin to bottom. + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:transcript", + isLoading: false, + messages, + onScrollRef, + refs, + }), + ); + }); + await flushRaf(); + + // Latch mid-history: double onScroll to clear the settle guard + latch anchor. + container.scrollTop = 500; + await act(async () => { + onScrollRef.current?.(); + }); + container.scrollTop = 500; + await act(async () => { + onScrollRef.current?.(); + }); + await flushRaf(); + + const writesAfterLatch = container.writes.length; + + // Re-render with the SAME messages reference while genuinely mid-history. + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:transcript", + isLoading: false, + messages, // same reference + onScrollRef, + refs, + }), + ); + }); + await flushRaf(); + + assert.equal( + container.writes.length, + writesAfterLatch, + "same messages reference mid-history → zero scrollTo writes (no yank)", + ); + + await act(async () => { + root.unmount(); + }); +}); + +test("at-bottom: new message id → one floor write", async () => { + const refs = { + container: { current: null }, + content: { current: {} }, + }; + const root = createRoot(document.createElement("div")); + + const container = makeContainer({ + clientHeight: 400, + scrollHeight: 1000, + scrollTop: 0, + }); + refs.container.current = container; + + const messages1 = [{ id: "turn:t1" }]; + + // Mount → pin to bottom. + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:transcript", + isLoading: false, + messages: messages1, + refs, + }), + ); + }); + await flushRaf(); + + const writesAfterMount = container.writes.length; + assert.equal(container.scrollTop, container.scrollHeight); + + // New block arrives — new messages reference with an additional id. + container.scrollHeight = 1400; + const messages2 = [{ id: "turn:t1" }, { id: "turn:t2" }]; + + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:transcript", + isLoading: false, + messages: messages2, + refs, + }), + ); + }); + await flushRaf(); + + assert.equal( + container.writes.length, + writesAfterMount + 1, + "new block id at bottom → exactly one additional floor write", + ); + assert.equal(container.scrollTop, 1400, "must be pinned to the new floor"); + + await act(async () => { + root.unmount(); + }); +}); + +test("mid-history: new message id → no floor write (unread path)", async () => { + const refs = { + container: { current: null }, + content: { current: {} }, + }; + const onScrollRef = { current: null }; + const root = createRoot(document.createElement("div")); + + // Container with fake [data-message-id] rows so computeAnchor can find + // a mid-history anchor instead of falling through to at-bottom. + const container = makeContainer({ + clientHeight: 400, + scrollHeight: 2000, + scrollTop: 0, + }); + // Add querySelectorAll that returns rows with data-message-id. + const fakeRow = { + dataset: { messageId: "turn:t1" }, + getBoundingClientRect() { + // Position the row inside the visible area when scrollTop=500. + return { top: 100, bottom: 140, height: 40 }; + }, + }; + container.querySelectorAll = () => [fakeRow]; + refs.container.current = container; + + const messages1 = [{ id: "turn:t1" }]; + + // Mount → pin to bottom. + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:transcript", + isLoading: false, + messages: messages1, + onScrollRef, + refs, + }), + ); + }); + await flushRaf(); + + // Simulate user scrolling up: move scrollTop, then call the hook's onScroll. + // The first onScroll call after mount hits the settling guard (the mount pin + // arms settling). That guard calls scrollTo(scrollHeight) and clears settling, + // then returns early. We must call onScroll a second time after re-setting + // scrollTop so computeAnchor can run and latch a mid-history anchor. + container.scrollTop = 500; + await act(async () => { + onScrollRef.current?.(); // clears settling, but scrollTo moves us back to bottom + }); + container.scrollTop = 500; // user scrolls up again after settling + await act(async () => { + onScrollRef.current?.(); // now computeAnchor runs, finds fakeRow → mid-history + }); + await flushRaf(); + + const scrollTopBeforeNewBlock = container.scrollTop; + + // New block arrives while mid-history. + container.scrollHeight = 2400; + const messages2 = [{ id: "turn:t1" }, { id: "turn:t2" }]; + + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:transcript", + isLoading: false, + messages: messages2, + onScrollRef, + refs, + }), + ); + }); + await flushRaf(); + + // scrollTop must not have been changed by the hook — no yank to bottom. + assert.equal( + container.scrollTop, + scrollTopBeforeNewBlock, + "new block mid-history must NOT yank to bottom", + ); + + await act(async () => { + root.unmount(); + }); +}); + +// ── Corrective action 4: mode-toggle reset + re-pin ───────────────────────── + +test("mode toggle: changing channelId resets and re-pins to bottom (loaded state)", async () => { + const refs = { + container: { current: null }, + content: { current: {} }, + }; + const root = createRoot(document.createElement("div")); + + const container = makeContainer({ + clientHeight: 400, + scrollHeight: 1000, + scrollTop: 0, + }); + refs.container.current = container; + + // Mount in transcript mode. + const transcriptMessages = [{ id: "turn:t1" }, { id: "turn:t2" }]; + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:transcript", + isLoading: false, + messages: transcriptMessages, + refs, + }), + ); + }); + await flushRaf(); + assert.equal(container.scrollTop, container.scrollHeight, "initial pin"); + + // Simulate user scrolling up (mid-history in transcript mode). + container.scrollTop = 200; + + // Toggle to raw mode — channelId changes, forces hook reset. + container.scrollHeight = 2000; + const rawMessages = [{ id: "1:ts1" }, { id: "2:ts2" }, { id: "3:ts3" }]; + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:raw", + isLoading: false, + messages: rawMessages, + refs, + }), + ); + }); + await flushRaf(); + + assert.equal( + container.scrollTop, + container.scrollHeight, + "mode toggle must reset the anchor and re-pin to bottom", + ); + + await act(async () => { + root.unmount(); + }); +}); + +test("mode toggle: connecting → loaded re-pins to bottom after reset", async () => { + const refs = { + container: { current: null }, + content: { current: {} }, + }; + const root = createRoot(document.createElement("div")); + + const container = makeContainer({ + clientHeight: 400, + scrollHeight: 0, + scrollTop: 0, + }); + refs.container.current = container; + + // Mount in transcript mode, loading (connecting). + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:transcript", + isLoading: true, + messages: [], + refs, + }), + ); + }); + await flushRaf(); + + // Still connecting — no pin yet. + assert.equal(container.scrollTop, 0, "no pin while loading"); + + // Toggle mode while still loading. + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:raw", + isLoading: true, + messages: [], + refs, + }), + ); + }); + await flushRaf(); + + // Connection resolves: content arrives and loading clears. + container.scrollHeight = 1500; + container.clientHeight = 400; + const rawMessages = [{ id: "1:ts1" }, { id: "2:ts2" }]; + + await act(async () => { + root.render( + React.createElement(ObserverFeedHarness, { + channelId: "agent:chan:raw", + isLoading: false, + messages: rawMessages, + refs, + }), + ); + }); + await flushRaf(); + + assert.equal( + container.scrollTop, + container.scrollHeight, + "after mode toggle + loading clears, must pin to bottom", + ); + + await act(async () => { + root.unmount(); + }); +});