mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
b63a2e4231
commit
d75c2e913e
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user