diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx
index af6e8b63..7d7fcf15 100644
--- a/canvas/src/components/tabs/ChatTab.tsx
+++ b/canvas/src/components/tabs/ChatTab.tsx
@@ -291,18 +291,31 @@ function MyChatPanel({ workspaceId, data }: Props) {
// - topRef = sentinel above the messages list; IO observes it
// and triggers loadOlder() when it enters view
// - hasMore = false once a fetch returns < limit rows; stops IO
- // - loadingOlder = guards against duplicate loadOlder() calls while
- // one is already in flight (fast scroll-flick)
+ // - loadingOlder = drives the "Loading older messages…" UI label
+ // - inflightRef = synchronous guard against double-entry of loadOlder
+ // when the IO callback fires twice in the same
+ // microtask (state-based guard would be stale until
+ // the next React commit)
// - scrollAnchorRef = saves distance-from-bottom before a prepend
// so the useLayoutEffect below can restore the
// user's exact viewport position. Without this,
// prepending older messages would jump the scroll
// position by the height of the new content.
+ // - oldestMessageRef / hasMoreRef = let the loadOlder closure read
+ // the latest values without taking them as deps —
+ // every live agent push mutates `messages`, and
+ // having loadOlder depend on `messages` would tear
+ // down + re-arm the IntersectionObserver on every
+ // push. Refs decouple the observer lifecycle from
+ // message-list updates.
const containerRef = useRef(null);
const topRef = useRef(null);
const [hasMore, setHasMore] = useState(true);
const [loadingOlder, setLoadingOlder] = useState(false);
+ const inflightRef = useRef(false);
const scrollAnchorRef = useRef<{ savedDistanceFromBottom: number } | null>(null);
+ const oldestMessageRef = useRef(null);
+ const hasMoreRef = useRef(true);
// Files the user has picked but not yet sent. Cleared on send
// (upload success) or by the × on each pill.
const [pendingFiles, setPendingFiles] = useState([]);
@@ -341,13 +354,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
sendInFlightRef.current = false;
}, []);
- // Load chat history from database on mount.
- // Initial load is bounded to INITIAL_HISTORY_LIMIT (newest 10) — the
- // rest streams in as the user scrolls up via loadOlder() below. Pre-
- // 2026-05-05 this fetched the newest 50 in one shot; on a long-running
- // workspace that meant 50× message-bubble paint + DOM cost on every
- // tab-open even when the user only wanted to read the last few.
- useEffect(() => {
+ // Initial-load fetch — used by the mount effect and the "Retry"
+ // button below. Single source of truth so the two paths can't drift
+ // (e.g. INITIAL_HISTORY_LIMIT bumped in the effect but not the
+ // retry, leading to inconsistent first-paint sizes).
+ const loadInitial = useCallback(() => {
setLoading(true);
setLoadError(null);
setHasMore(true);
@@ -361,16 +372,41 @@ function MyChatPanel({ workspaceId, data }: Props) {
);
}, [workspaceId]);
- // Fetch the next-older batch and prepend. Caller responsibility:
- // already check loadingOlder + hasMore (we re-check defensively for
- // race-safety against the IO callback firing twice).
+ // Load chat history on mount / workspace switch.
+ // Initial load is bounded to INITIAL_HISTORY_LIMIT (newest 10) — the
+ // rest streams in as the user scrolls up via loadOlder() below. Pre-
+ // 2026-05-05 this fetched the newest 50 in one shot; on a long-running
+ // workspace that meant 50× message-bubble paint + DOM cost on every
+ // tab-open even when the user only wanted to read the last few.
+ useEffect(() => {
+ loadInitial();
+ }, [loadInitial]);
+
+ // Mirror the latest oldest-message + hasMore into refs so loadOlder
+ // can read them without taking `messages` as a dep. Every live push
+ // through agentMessages would otherwise recreate loadOlder and tear
+ // down the IO observer.
+ useEffect(() => {
+ oldestMessageRef.current = messages[0] ?? null;
+ }, [messages]);
+ useEffect(() => {
+ hasMoreRef.current = hasMore;
+ }, [hasMore]);
+
+ // Fetch the next-older batch and prepend. Stable identity (deps =
+ // [workspaceId]) so the IntersectionObserver effect below doesn't
+ // re-arm on every messages update.
const loadOlder = useCallback(async () => {
- if (loadingOlder || !hasMore) return;
- if (messages.length === 0) return;
- const oldest = messages[0];
+ // inflightRef is the load-bearing guard — synchronous, set BEFORE
+ // any await, so two IO callbacks dispatched in the same microtask
+ // can't both pass. The state checks are defensive secondary
+ // gates for the slow-scroll case.
+ if (inflightRef.current || !hasMoreRef.current) return;
+ const oldest = oldestMessageRef.current;
if (!oldest) return;
const container = containerRef.current;
if (!container) return;
+ inflightRef.current = true;
// Capture the user's distance-from-bottom BEFORE we prepend so the
// useLayoutEffect can restore it after the new DOM lands. Without
// this anchor, the user reading mid-history would get yanked
@@ -379,26 +415,45 @@ function MyChatPanel({ workspaceId, data }: Props) {
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
};
setLoadingOlder(true);
- const { messages: older, reachedEnd } = await loadMessagesFromDB(
- workspaceId,
- OLDER_HISTORY_BATCH,
- oldest.timestamp,
- );
- if (older.length > 0) {
- setMessages((prev) => [...older, ...prev]);
- } else {
- // Nothing came back — clear the anchor so the next paint doesn't
- // try to "restore" against a no-op prepend.
- scrollAnchorRef.current = null;
+ try {
+ const { messages: older, reachedEnd } = await loadMessagesFromDB(
+ workspaceId,
+ OLDER_HISTORY_BATCH,
+ oldest.timestamp,
+ );
+ if (older.length > 0) {
+ setMessages((prev) => [...older, ...prev]);
+ } else {
+ // Nothing came back — clear the anchor so the next paint doesn't
+ // try to "restore" against a no-op prepend.
+ scrollAnchorRef.current = null;
+ }
+ setHasMore(!reachedEnd);
+ } finally {
+ setLoadingOlder(false);
+ inflightRef.current = false;
}
- setHasMore(!reachedEnd);
- setLoadingOlder(false);
- }, [workspaceId, messages, loadingOlder, hasMore]);
+ }, [workspaceId]);
// IntersectionObserver on the top sentinel. Fires loadOlder() the
// moment the user scrolls within 200px of the top. AbortController
// unwires cleanly on workspace switch / unmount; root is the
// scrollable container so we observe only what's visible inside it.
+ //
+ // Dependencies:
+ // - loadOlder — stable per workspaceId (refs decouple it from
+ // message updates), so this dep is here for the
+ // workspace-switch case only
+ // - hasMore — re-run when older history runs out so we
+ // disconnect cleanly
+ // - hasMessages — load-bearing: the sentinel JSX is gated on
+ // `messages.length > 0`, so topRef.current is null
+ // on the empty-messages render. We re-arm exactly
+ // once when messages first land. NOT depending on
+ // `messages.length` (or `messages`) directly so
+ // each subsequent message append doesn't tear down
+ // + re-arm the observer.
+ const hasMessages = messages.length > 0;
useEffect(() => {
const top = topRef.current;
const container = containerRef.current;
@@ -415,7 +470,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
io.observe(top);
ac.signal.addEventListener("abort", () => io.disconnect());
return () => ac.abort();
- }, [loadOlder, hasMore]);
+ }, [loadOlder, hasMore, hasMessages]);
// Agent reachability
useEffect(() => {
@@ -873,19 +928,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
Failed to load chat history: {loadError}