fix(desktop): unify observer feed scroll onto useAnchoredScroll (#1825)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-13 17:50:09 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent b63a2e4231
commit d75c2e913e
7 changed files with 512 additions and 81 deletions
@@ -1,5 +1,6 @@
import type { ObserverEvent } from "./agentSessionTypes";
import { describeRawEvent } from "./agentSessionTranscript";
import { observerEventScrollId } from "./agentSessionPanelLayout";
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
@@ -18,7 +19,8 @@ export function RawEventRail({ events }: { events: ObserverEvent[] }) {
{events.map((event) => (
<details
className="group rounded-md border border-border/55 bg-muted/25 px-2.5 py-1.5 transition-colors open:bg-muted/35"
key={event.seq}
data-message-id={observerEventScrollId(event)}
key={observerEventScrollId(event)}
>
<summary className="cursor-pointer select-none text-xs text-muted-foreground transition-colors group-open:text-foreground">
<span className="font-mono text-muted-foreground/70">
@@ -3,6 +3,7 @@ import test from "node:test";
import {
deriveLatestSessionId,
observerEventScrollId,
resolveDisplayEvents,
resolveRawRailLayout,
scopeByChannel,
@@ -100,3 +101,20 @@ test("resolveRawRailLayout renders the rail exclusively when toggled on in exclu
test("resolveRawRailLayout renders the rail beside the transcript in responsive layout", () => {
assert.deepEqual(resolveRawRailLayout(true, "responsive"), { mode: "side" });
});
// ---- observerEventScrollId ----
// seq is process-local (buzz-acp's ObserverHandle) and resets to 1 on every
// agent restart while timestamp keeps climbing, so seq alone is not unique
// across an agent's combined observer history — pair it with timestamp,
// the same identity mergeObserverEventWindows dedups on above.
test("observerEventScrollId combines seq and timestamp", () => {
const event = { seq: 1, timestamp: "2026-07-13T21:00:00.000Z" };
assert.equal(observerEventScrollId(event), "1:2026-07-13T21:00:00.000Z");
});
test("observerEventScrollId returns distinct ids for same seq across a restart", () => {
const before = { seq: 1, timestamp: "2026-07-13T20:00:00.000Z" };
const after = { seq: 1, timestamp: "2026-07-13T21:00:00.000Z" };
assert.notEqual(observerEventScrollId(before), observerEventScrollId(after));
});
@@ -53,6 +53,24 @@ export function mergeObserverEventWindows(
return merged;
}
/**
* Stable DOM scroll-anchor id for an observer event, shared by the outer
* `useAnchoredScroll` message list (`AgentSessionThreadPanel`) and the raw
* event rail's rows (`RawEventRail`) so both name the same row identically.
*
* `seq` alone is not unique across an agent's observer history: it's a
* monotonic counter local to one agent process (see buzz-acp's
* `ObserverHandle`), so it resets to 1 after every process restart while
* `timestamp` keeps climbing. Pairing them matches the exact `(seq,
* timestamp)` key `mergeObserverEventWindows` already dedups on above, and
* the collision guard the transcript uses for repeated `session/new` events
* across restarts (see `system-prompt:${channel}:${seq}:${timestamp}` in
* agentSessionTranscript.ts) — unique within one channel's combined window.
*/
export function observerEventScrollId(event: ObserverEvent): string {
return `${event.seq}:${event.timestamp}`;
}
/**
* Derive the most recent session id from a list of observer events by
* scanning from the end. Returns null when no event carries a sessionId.
@@ -1,7 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describeRawEvent } from "./agentSessionTranscriptHelpers.ts";
import { RawEventRail } from "./RawEventRail.tsx";
function rawEvent(overrides = {}) {
return {
@@ -9,6 +13,7 @@ function rawEvent(overrides = {}) {
kind: "acp",
sessionId: "sess-1",
channelId: "channel-1",
timestamp: "2026-07-13T00:00:00.000Z",
payload: {},
...overrides,
};
@@ -40,3 +45,40 @@ test("describeRawEvent falls back to the event kind when no method is present",
const event = rawEvent({ kind: "acp_parse_error", payload: {} });
assert.equal(describeRawEvent(event), "acp_parse_error");
});
test("RawEventRail render: each row exposes data-message-id keyed on (seq, timestamp) for scroll anchoring", () => {
const events = [rawEvent({ seq: 1 }), rawEvent({ seq: 2 })];
const html = renderToStaticMarkup(
React.createElement(RawEventRail, { events }),
);
assert.ok(
html.includes('data-message-id="1:2026-07-13T00:00:00.000Z"'),
"row for seq 1 should carry data-message-id",
);
assert.ok(
html.includes('data-message-id="2:2026-07-13T00:00:00.000Z"'),
"row for seq 2 should carry data-message-id",
);
});
test("RawEventRail render: rows sharing seq across an agent restart get distinct ids", () => {
// seq is process-local and resets to 1 on every agent restart, so two
// rows can share seq while their timestamps differ. Both must render a
// distinct data-message-id or the scroll anchor (and prop-id delta
// classification) collapses the two rows into one identity.
const events = [
rawEvent({ seq: 1, timestamp: "2026-07-13T00:00:00.000Z" }),
rawEvent({ seq: 1, timestamp: "2026-07-13T01:00:00.000Z" }),
];
const html = renderToStaticMarkup(
React.createElement(RawEventRail, { events }),
);
assert.ok(
html.includes('data-message-id="1:2026-07-13T00:00:00.000Z"'),
"pre-restart row should carry the pre-restart id",
);
assert.ok(
html.includes('data-message-id="1:2026-07-13T01:00:00.000Z"'),
"post-restart row should carry a distinct post-restart id",
);
});
@@ -12,6 +12,7 @@ import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import {
mergeObserverEventWindows,
observerEventScrollId,
scopeByChannel,
} from "@/features/agents/ui/agentSessionPanelLayout";
import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes";
@@ -20,11 +21,11 @@ import {
useArchivedChannelEvents,
useObserverEvents,
} from "@/features/agents/ui/useObserverEvents";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { cancelManagedAgentTurn } from "@/shared/api/agentControl";
import type { Channel } from "@/shared/api/types";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { useStickToBottom } from "@/shared/hooks/useStickToBottom";
import { useNow } from "@/shared/lib/useNow";
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
@@ -103,10 +104,11 @@ export function AgentSessionThreadPanel({
const canStopCurrentTurn = isWorking && canInterruptTurn;
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
const scrollRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const topSentinelRef = React.useRef<HTMLDivElement>(null);
const now = useNow(1000);
const { events } = useObserverEvents(isLive, agent.pubkey);
const { connectionState, events } = useObserverEvents(isLive, agent.pubkey);
const scopedEvents = React.useMemo(
() => scopeByChannel(events, sessionChannelId),
[events, sessionChannelId],
@@ -150,6 +152,30 @@ 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(
() =>
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,
contentRef,
isLoading: connectionState === "connecting",
messages: anchoredScrollMessages,
scrollContainerRef: scrollRef,
});
// Scope label input: prefer the passed channel's name; when the pane is
// channel-scoped without a full Channel object (#1380's channelId prop),
// resolve the name from the channels cache.
@@ -408,20 +434,22 @@ export function AgentSessionThreadPanel({
panelPadding
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
<ManagedAgentSessionPanel
agent={agent}
channelId={sessionChannelId}
className="border-0 bg-transparent px-0 py-2 shadow-none"
emptyDescription={
sessionChannelId
? `Mention ${agent.name} in the channel to see its work here.`
: `Mention ${agent.name} in any channel to see its work here.`
}
profiles={profiles}
rawLayout="exclusive"
showHeader={false}
showRaw={showRawFeed}
/>
<div ref={contentRef}>
<ManagedAgentSessionPanel
agent={agent}
channelId={sessionChannelId}
className="border-0 bg-transparent px-0 py-2 shadow-none"
emptyDescription={
sessionChannelId
? `Mention ${agent.name} in the channel to see its work here.`
: `Mention ${agent.name} in any channel to see its work here.`
}
profiles={profiles}
rawLayout="exclusive"
showHeader={false}
showRaw={showRawFeed}
/>
</div>
</AuxiliaryPanelBody>
</AuxiliaryPanel>
);
@@ -0,0 +1,386 @@
/**
* Regression test: the observer feed's bottom-tail auto-pin must fire once
* content has actually rendered, not merely once at mount.
*
* ── Bug this pins ────────────────────────────────────────────────────────────
* `AgentSessionThreadPanel`'s previous scroll owner, `useStickToBottom`,
* pinned to the bottom in an effect with an EMPTY dependency array — once, at
* mount (`el.scrollTop = el.scrollHeight`). Observer events render
* asynchronously (relay connect handshake, archive backfill); if they hadn't
* committed by the time that effect ran, `scrollHeight` still equalled
* `clientHeight` and the pin was a no-op with no keyed retry. Whether the
* panel visibly "snapped" depended entirely on a race between content
* arrival and the mount effect — the "sometimes it does, sometimes it
* doesn't" behavior reported against the live panel.
*
* `useAnchoredScroll` fixes this by gating its own mount-pin on `isLoading`
* (held while the connecting skeleton shows) and re-running its restoration
* effect on every `messages` change, so the pin fires against whatever
* `scrollHeight` is current when loading clears or content lands — never a
* frozen mount-time snapshot.
*
* ── CI surface ────────────────────────────────────────────────────────────────
* Runs under `pnpm test` (node:test with the React dev build).
*/
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(
(current) => current !== listener,
),
);
}
dispatchEvent(event) {
for (const listener of this.listeners.get(event.type) ?? [])
listener(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((current) => current !== child);
this.childNodes = this.childNodes.filter((current) => current !== child);
child.parentNode = null;
return child;
}
insertBefore(child, reference) {
if (!reference) return this.appendChild(child);
const index = this.children.indexOf(reference);
if (index < 0) return this.appendChild(child);
this.children.splice(index, 0, child);
this.childNodes.splice(index, 0, child);
child.parentNode = this;
return child;
}
contains(node) {
return (
this === node || this.children.some((child) => child.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 = (callback) => setTimeout(callback, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
globalThis.CSS = { escape: (value) => value };
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";
// Minimal scroll-container shim: only the geometry + `scrollTo` the hook's
// bottom-tail path touches. No `[data-message-id]` rows — this pins the
// at-bottom fast path, not the mid-history anchor walk.
function makeContainer({ clientHeight, scrollHeight, scrollTop = 0 }) {
return {
clientHeight,
scrollHeight,
scrollTop,
getBoundingClientRect() {
return { top: 0 };
},
querySelector() {
return null;
},
querySelectorAll() {
return [];
},
scrollTo({ top }) {
this.scrollTop = top;
},
};
}
// Mirrors AgentSessionThreadPanel's exact wiring: bottom-tail only (no
// targetMessageId, pinTargetCentered omitted/false), isLoading derived from
// the observer store's connection state.
function ObserverFeedHarness({ isLoading, messages, refs }) {
useAnchoredScroll({
channelId: "agent-pubkey:channel-id",
contentRef: refs.content,
isLoading,
messages,
scrollContainerRef: refs.container,
});
return null;
}
test("re-pins to the new floor once observer content commits after mount, even with no isLoading transition", async () => {
const refs = {
container: { current: null },
content: { current: {} },
};
const root = createRoot(document.createElement("div"));
// Mount with `isLoading: false` from the very first render (e.g. the
// agent was already "open" when the panel mounted) and no rows yet —
// observer events land a beat later over the relay. This is the exact
// race `useStickToBottom`'s empty-deps mount effect lost: nothing else
// ever re-fires the pin once the mount effect has run.
refs.container.current = makeContainer({
clientHeight: 0,
scrollHeight: 0,
scrollTop: 0,
});
await act(async () => {
root.render(
React.createElement(ObserverFeedHarness, {
isLoading: false,
messages: [],
refs,
}),
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Observer events commit into the DOM on the next render — `isLoading`
// never changes again, only `messages` grows. A mount-once effect has
// nothing left to key a retry on here.
refs.container.current.scrollHeight = 3_000;
refs.container.current.clientHeight = 400;
const messages = Array.from({ length: 40 }, (_, i) => ({ id: String(i) }));
await act(async () => {
root.render(
React.createElement(ObserverFeedHarness, {
isLoading: false,
messages,
refs,
}),
);
});
assert.equal(
refs.container.current.scrollTop,
refs.container.current.scrollHeight,
"re-pin must be keyed on message arrival, not just the mount commit",
);
await act(async () => {
root.unmount();
});
});
test("auto-pins to bottom once loading clears, against content that already committed", async () => {
const refs = {
container: { current: null },
content: { current: {} },
};
const root = createRoot(document.createElement("div"));
// Skeleton phase: container is attached but nothing has rendered yet.
refs.container.current = makeContainer({
clientHeight: 0,
scrollHeight: 0,
scrollTop: 0,
});
await act(async () => {
root.render(
React.createElement(ObserverFeedHarness, {
isLoading: true,
messages: [],
refs,
}),
);
});
// isLoading holds the mount-pin: no scroll write while the skeleton shows.
assert.equal(refs.container.current.scrollTop, 0);
// Observer events (relay + archive backfill) commit into the DOM WHILE
// `connectionState` is still resolving — the exact race that made
// `useStickToBottom`'s empty-deps mount effect a no-op. `scrollHeight`
// grows well past `clientHeight` before the loading flag flips.
refs.container.current.scrollHeight = 3_000;
refs.container.current.clientHeight = 400;
const messages = Array.from({ length: 40 }, (_, i) => ({ id: String(i) }));
// connectionState resolves out of "connecting" on the next render.
await act(async () => {
root.render(
React.createElement(ObserverFeedHarness, {
isLoading: false,
messages,
refs,
}),
);
});
// Flush the rAF-deferred scrollToBottomImperative.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(
refs.container.current.scrollTop,
refs.container.current.scrollHeight,
"pin fires against the DOM as it exists when loading clears, not a stale mount snapshot",
);
await act(async () => {
root.unmount();
});
});
test("stays glued to the floor as further messages stream in after the initial pin", async () => {
const refs = {
container: { current: null },
content: { current: {} },
};
const root = createRoot(document.createElement("div"));
refs.container.current = makeContainer({
clientHeight: 400,
scrollHeight: 1_000,
scrollTop: 0,
});
const initialMessages = Array.from({ length: 10 }, (_, i) => ({
id: String(i),
}));
await act(async () => {
root.render(
React.createElement(ObserverFeedHarness, {
isLoading: false,
messages: initialMessages,
refs,
}),
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(refs.container.current.scrollTop, 1_000);
// A streaming wave lands: content grows and the container's own
// `scrollHeight` grows with it. Still anchored at-bottom -> re-pin to the
// NEW floor on this commit — no mount race to lose this time.
refs.container.current.scrollHeight = 1_800;
const nextMessages = [
...initialMessages,
...Array.from({ length: 5 }, (_, i) => ({ id: `next-${i}` })),
];
await act(async () => {
root.render(
React.createElement(ObserverFeedHarness, {
isLoading: false,
messages: nextMessages,
refs,
}),
);
});
assert.equal(refs.container.current.scrollTop, 1_800);
await act(async () => {
root.unmount();
});
});
@@ -1,63 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
/**
* Keeps a scroll container pinned to the bottom as new content arrives,
* unless the user has scrolled up. Mirrors the "sticky scroll" pattern
* from goose's MessageTimeline.
*
* Attach `ref` to the scrollable container and `onScroll` as its scroll
* handler. The hook observes DOM mutations inside the container and
* auto-scrolls when the user is near the bottom (within 100 px).
*
* Scroll calls are batched via `requestAnimationFrame` so rapid streaming
* updates (e.g. token-by-token SSE) don't cause layout thrashing.
*/
export function useStickToBottom<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null);
const isNearBottomRef = useRef(true);
const onScroll = useCallback(() => {
const el = ref.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
isNearBottomRef.current = scrollHeight - scrollTop - clientHeight < 100;
}, []);
useEffect(() => {
const el = ref.current;
if (!el) return;
// Start at the bottom; the observer below only reacts to later changes.
el.scrollTop = el.scrollHeight;
let rafId: number | null = null;
const scrollIfSticky = () => {
// Coalesce to one scroll per animation frame.
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
if (isNearBottomRef.current && ref.current) {
ref.current.scrollTo({
top: ref.current.scrollHeight,
behavior: "smooth",
});
}
});
};
const observer = new MutationObserver(scrollIfSticky);
observer.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, []);
return { ref, onScroll, isNearBottomRef };
}