From 1cea9db02d11fa2b8f3d1b6356ab3ae46fe7b7a2 Mon Sep 17 00:00:00 2001 From: vastsa Date: Wed, 16 Sep 2026 02:20:36 +0800 Subject: [PATCH] fix(transcript): keep the reading position when a disclosure is toggled Expanding a tool, thinking or activity title re-bottomed a pinned transcript, so the clicked title jumped up by the height of the opened detail, and collapsing it moved back. The content ResizeObserver re-pins on every content resize and cannot tell a stream from a reader opening a row, and `overflow-anchor: none` on the scroller means the browser cannot do it either. A manual disclosure now hands the title element it was toggled from to the scroll container that owns it, synchronously, before the expansion state changes. That container leaves follow mode and restores the title's viewport offset from inside its own ResizeObserver, for every frame of a height transition, adopting the browser's own boundary clamping instead of fighting it. The transcript scroller and every nested follow scroller (D302) share one controller, and a nested owner passes its hold outward, because growing the dock grows the transcript's content. The hold is released by real scroll input, the jump-to-latest control, a new turn, a hidden pane and every navigation; there is no delayed grab-back and the observer is untouched. Scroll input is now attributed to the scroller that can consume it: a press on a row control or an editable field is an ordinary click, a keystroke in a text field belongs to that field, and input a nested scroller consumes is not the outer scroller's gesture. Follow records the scroll position the scroller actually reached rather than the one requested, and a sub-pixel tolerance applies only to scroll events no gesture produced, so a fractional device pixel ratio no longer reads as the reader scrolling up. Regression proof: `pnpm test:e2e:transcript-disclosure` mounts the real scroll hooks and a real tool row in a real 600 CSS px Electron viewport; without this change the same fixture reports the clicked title moving by 1270px, and with it the title's offset and the scroll offset are unchanged. Refs #324 --- .../components/workpanel/SubagentPanel.tsx | 5 + .../chat/transcript/ActivityGroup.tsx | 2 + .../chat/transcript/ChatTranscript.tsx | 5 + .../chat/transcript/SubagentDetail.tsx | 12 +- .../src/features/chat/transcript/ToolRow.tsx | 113 ++++---- .../transcript/hooks/useTranscriptScroll.ts | 154 ++++++---- .../src/features/chat/transcript/shared.tsx | 34 ++- .../src/hooks/use-disclosure-anchor.ts | 128 ++++++++ apps/desktop/src/hooks/use-follow-scroll.ts | 116 +++++--- .../src/lib/disclosure-anchor-context.ts | 21 ++ apps/desktop/src/lib/disclosure-anchor.ts | 78 +++++ apps/desktop/src/lib/scroll-input.ts | 68 +++++ apps/desktop/src/lib/transcript-scroll.ts | 98 ++++++- apps/desktop/test/disclosure-anchor.test.mjs | 219 ++++++++++++++ .../test/interaction-performance.test.mjs | 17 +- .../desktop/test/subagent-transcript.test.mjs | 2 +- .../transcript-disclosure-reading.test.mjs | 185 ++++++++++++ apps/desktop/test/transcript-settle.test.mjs | 5 +- docs/spec/04-ux/09-interaction-patterns.md | 18 ++ docs/spec/06-delivery/04-e2e-test-plan.md | 44 +++ docs/spec/08-meta/decisions-log.md | 40 +++ .../spec/06-delivery/04-e2e-test-plan.md | 18 +- package.json | 1 + scripts/e2e-transcript-disclosure-anchor.mjs | 117 ++++++++ scripts/e2e/transcript-disclosure-anchor.tsx | 273 ++++++++++++++++++ 25 files changed, 1600 insertions(+), 173 deletions(-) create mode 100644 apps/desktop/src/hooks/use-disclosure-anchor.ts create mode 100644 apps/desktop/src/lib/disclosure-anchor-context.ts create mode 100644 apps/desktop/src/lib/disclosure-anchor.ts create mode 100644 apps/desktop/src/lib/scroll-input.ts create mode 100644 apps/desktop/test/disclosure-anchor.test.mjs create mode 100644 apps/desktop/test/transcript-disclosure-reading.test.mjs create mode 100644 scripts/e2e-transcript-disclosure-anchor.mjs create mode 100644 scripts/e2e/transcript-disclosure-anchor.tsx diff --git a/apps/desktop/src/components/workpanel/SubagentPanel.tsx b/apps/desktop/src/components/workpanel/SubagentPanel.tsx index 5dbd3ffb5..8a13fdb71 100644 --- a/apps/desktop/src/components/workpanel/SubagentPanel.tsx +++ b/apps/desktop/src/components/workpanel/SubagentPanel.tsx @@ -21,6 +21,7 @@ import { useFollowScroll } from "../../hooks/use-follow-scroll"; import { useTranscriptView } from "../../hooks/use-transcript-view"; import { useTranscriptSearchFocus } from "../../hooks/use-transcript-search-focus"; import { IconArrowDown } from "../icons"; +import { DisclosureAnchorContext } from "../../lib/disclosure-anchor-context"; import { TooltipButton } from "../ui"; import { SubagentDetail } from "../ChatTranscript"; @@ -93,6 +94,7 @@ export function SubagentPanel({ selection }: { selection: SubagentPanelSelection jumpToLatest, scheduleFollowScroll, releaseFollow, + disclosureAnchorNotifier, } = useFollowScroll(); useLayoutEffect(() => { @@ -113,6 +115,7 @@ export function SubagentPanel({ selection }: { selection: SubagentPanelSelection }); return ( +
+
); } diff --git a/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx b/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx index 901809447..c21259096 100644 --- a/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx +++ b/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx @@ -237,6 +237,7 @@ export const ActivityGroup = memo(function ActivityGroup({ toggle: toggleDisclosure, collapse: collapseDisclosure, claim: claimDisclosure, + titleRef, } = useAutomaticDisclosure(live, revealRequest); const [now, setNow] = useState(Date.now); const [finishedAt, setFinishedAt] = useState(null); @@ -359,6 +360,7 @@ export const ActivityGroup = memo(function ActivityGroup({ }`} >