graph: session deep links reliably jump to the source message
The mount-time jump never fired on real graph-link arrivals: the transcript scroller's ref is attached by a later sibling, and the queued rAF was cancelled outright whenever use-stick-to-bottom re-created its context while the initial scroll settled — every link landed at the bottom (owner report 2026-10-13). Rewrite the mount path as a synchronous 64ms retry watcher that keeps running past its first success (hydration/adoption can replace the jumped-to node), with a ~20s watchdog for cold-compile SSR streams (observed 6.8s). jump() now reads the stick-to-bottom context through a ref so effect identities stay stable, and the unmount cleanup no longer strips a live flash. Verified live on real data: full load, SPA link navigation, and in-session hashchange all land the anchor 16px from the scroller top with the 2s highlight. Also gives the node card's session/file rows matching horizontal and vertical padding (px-2 py-1.5).
This commit is contained in:
+1
-1
@@ -1313,7 +1313,7 @@ export function Chat({
|
||||
<SessionFind scopeRef={sessionFindScopeRef} scrollContextRef={scrollContextRef} />
|
||||
<div ref={sessionFindScopeRef} className="relative flex min-h-0 flex-1">
|
||||
<Conversation className="h-full min-w-0 flex-1" initial="instant" contextRef={scrollContextRef}>
|
||||
<SessionMessageNavigation messages={messages} />
|
||||
<SessionMessageNavigation />
|
||||
<ConversationContent className="min-h-full gap-0 p-0">
|
||||
<div data-session-find-root="" className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
|
||||
{messages.length === 0 && !busy && (
|
||||
|
||||
@@ -1658,7 +1658,7 @@ export function GraphView({
|
||||
return (
|
||||
<div key={sourceId} className="flex items-center gap-1.5">
|
||||
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate px-1 py-1 text-sm text-muted-foreground">
|
||||
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-sm text-muted-foreground">
|
||||
{snapshot.fileSources[sourceId]}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1670,7 +1670,7 @@ export function GraphView({
|
||||
<Link
|
||||
href={propositionSessionHref(selected.node, sourceId)}
|
||||
scroll={false}
|
||||
className="min-w-0 flex-1 truncate rounded-md px-1 py-1 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
className="min-w-0 flex-1 truncate rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{snapshot?.sessions[sourceId] ?? sourceId}
|
||||
</Link>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom";
|
||||
import { messageAnchorId, messageIdFromHash, type LhViewMessage } from "@/src/core";
|
||||
import { messageAnchorId, messageIdFromHash } from "@/src/core";
|
||||
|
||||
export type SessionScrollContext = Pick<StickToBottomContext, "scrollRef" | "stopScroll">;
|
||||
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
/** Scroll only the session's own containers (including tall question/code
|
||||
* areas), never sidebar/chrome. Inner containers move first, then the main
|
||||
@@ -25,13 +24,25 @@ export function scrollSessionTarget(target: Range | HTMLElement, scope: HTMLElem
|
||||
}
|
||||
|
||||
/** React owns message markup; this controller adds no messages or content.
|
||||
* Native hash/back-forward navigation retries after the view arrives, and
|
||||
* subsequent streaming updates must NOT repeat the source jump. */
|
||||
export function SessionMessageNavigation({ messages }: { messages: readonly LhViewMessage[] }) {
|
||||
* Arrival on a full load is the browser's own hash scroll; this watcher owns
|
||||
* SPA arrivals (graph links) and later hash changes. The mount path polls on
|
||||
* a short interval ON PURPOSE: the transcript scroller's ref is attached by a
|
||||
* LATER SIBLING (StickToBottom.Content), hydration orderings vary, and a
|
||||
* frame-chained rAF proved cancellable mid-flight in dev (the owner's
|
||||
* every-link-lands-at-bottom report, 2026-10-13) — a synchronous tick that
|
||||
* retries until both elements exist has no cancellation window. */
|
||||
export function SessionMessageNavigation() {
|
||||
const context = useStickToBottomContext();
|
||||
const { scrollRef, stopScroll } = context;
|
||||
// ABSORB IDENTITY CHURN (the 2026-10-13 heisenbug): use-stick-to-bottom
|
||||
// re-creates stopScroll (and thus this context) while its initial scroll
|
||||
// settles. Reading it through a ref keeps `jump`'s identity STABLE, so the
|
||||
// effects below run once — a dep-churned cleanup used to cancel the queued
|
||||
// jump / strip the flash mid-flight, which is exactly the owner's
|
||||
// every-link-lands-at-bottom report (it only reproduced WITHOUT debug
|
||||
// logging shifting the timing).
|
||||
const contextRef = useRef(context);
|
||||
contextRef.current = context;
|
||||
const lastJump = useRef<{ hash: string; target: HTMLElement } | null>(null);
|
||||
const frame = useRef<number | null>(null);
|
||||
const flash = useRef<{ target: HTMLElement; timer: number } | null>(null);
|
||||
const clearFlash = useCallback(() => {
|
||||
if (!flash.current) return;
|
||||
@@ -39,30 +50,47 @@ export function SessionMessageNavigation({ messages }: { messages: readonly LhVi
|
||||
flash.current.target.classList.remove("lh-source-highlight");
|
||||
flash.current = null;
|
||||
}, []);
|
||||
const jump = useCallback(() => {
|
||||
/** Synchronous arrival: release the bottom-follow lock, scroll every
|
||||
* session container so the passage sits near the top, flash it. Returns
|
||||
* true when this hash is (now) jumped-to. */
|
||||
const jump = useCallback((): boolean => {
|
||||
const { scrollRef, stopScroll } = contextRef.current;
|
||||
const hash = window.location.hash;
|
||||
const messageId = messageIdFromHash(hash);
|
||||
const main = scrollRef.current;
|
||||
const target = messageId ? document.getElementById(messageAnchorId(messageId)) : null;
|
||||
if (!main || !target || !main.contains(target)) return;
|
||||
if (lastJump.current?.hash === hash && lastJump.current.target === target) return;
|
||||
if (!main || !target || !main.contains(target)) return false;
|
||||
if (lastJump.current?.hash === hash && lastJump.current.target === target) return true;
|
||||
stopScroll();
|
||||
if (frame.current !== null) window.cancelAnimationFrame(frame.current);
|
||||
// Dock height and the initial ResizeObserver settle before the jump.
|
||||
// A second stop cancels any initial bottom-follow queued in that frame.
|
||||
frame.current = window.requestAnimationFrame(() => {
|
||||
frame.current = null;
|
||||
if (!target.isConnected || window.location.hash !== hash) return;
|
||||
stopScroll();
|
||||
scrollSessionTarget(target, main, { scrollRef, stopScroll });
|
||||
clearFlash();
|
||||
target.classList.add("lh-source-highlight");
|
||||
flash.current = { target, timer: window.setTimeout(clearFlash, 2_000) };
|
||||
lastJump.current = { hash, target };
|
||||
});
|
||||
}, [scrollRef, stopScroll, clearFlash]);
|
||||
scrollSessionTarget(target, main, { scrollRef, stopScroll });
|
||||
clearFlash();
|
||||
target.classList.add("lh-source-highlight");
|
||||
flash.current = { target, timer: window.setTimeout(clearFlash, 2_000) };
|
||||
lastJump.current = { hash, target };
|
||||
return true;
|
||||
}, [clearFlash]);
|
||||
|
||||
// Arrival on mount (a graph link opened this session, or a full load
|
||||
// carried the hash): retry on a short interval until the scroller and the
|
||||
// anchor both exist, then stop. The ~4s watchdog covers a never-arriving
|
||||
// anchor (a deleted message) — the session simply opens where it opens.
|
||||
useEffect(() => {
|
||||
if (!window.location.hash) return;
|
||||
let ticks = 0;
|
||||
const id = window.setInterval(() => {
|
||||
// Keep watching PAST the first success: hydration / record adoption
|
||||
// can REPLACE the node that was just jumped to (observed on cold
|
||||
// dev loads, 2026-10-13) — jump() re-targets the fresh node and
|
||||
// re-flashes it, while steady-state ticks are no-ops via the
|
||||
// lastJump guard. ~20s watchdog covers a cold compile (observed
|
||||
// 6.8s SSR flush) or slow first load; a deleted-message anchor
|
||||
// gives up and the session opens as-is.
|
||||
if (++ticks > 300) window.clearInterval(id);
|
||||
else jump();
|
||||
}, 64);
|
||||
return () => window.clearInterval(id);
|
||||
}, [jump]);
|
||||
|
||||
useIsoLayoutEffect(() => { jump(); }, [jump, messages]);
|
||||
useEffect(() => {
|
||||
const onHash = () => {
|
||||
lastJump.current = null;
|
||||
@@ -71,13 +99,11 @@ export function SessionMessageNavigation({ messages }: { messages: readonly LhVi
|
||||
};
|
||||
window.addEventListener("hashchange", onHash);
|
||||
window.addEventListener("popstate", onHash);
|
||||
// Cleanup removes LISTENERS ONLY: never clearFlash/lastJump here — a
|
||||
// dep-churned re-run must not strip a live flash (see the note above).
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", onHash);
|
||||
window.removeEventListener("popstate", onHash);
|
||||
if (frame.current !== null) window.cancelAnimationFrame(frame.current);
|
||||
frame.current = null;
|
||||
lastJump.current = null;
|
||||
clearFlash();
|
||||
};
|
||||
}, [jump, clearFlash]);
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user