You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Traced this on current main (8a8d56413). Your instinct in the "额外遇到" paragraph is right: it is a restore/measure race, not a design choice, and I think I can name the exact window.
Where the position lives
Reading positions are renderer-lifetime bookmarks keyed by session (lib/transcript-reading-position.ts:15-27), captured only while the pane has layout (captureTranscriptReadingPosition, :36-53) — the code already knows a hidden pane must not overwrite a bookmark. SessionPane keeps every visited session mounted with key={id} and toggles visible (ChatSurface.tsx:205-209), so switching sessions is a visibility flip, not a remount: the hooks stay alive and their effects re-run on paneVisible.
The race
Two independent layout effects react to the same paneVisible: false → true edge, and their order decides the outcome:
useTranscriptReadingPosition (hooks/use-transcript-reading-position.ts:41-46) sets pendingRef on reveal, then a second effect (:82-115) runs restoreTranscriptReadingPosition and keeps re-aligning for up to 1500ms while a ResizeObserver fires.
useTranscriptScroll (features/chat/transcript/hooks/useTranscriptScroll.ts:231-255) handles the same edge: on becameVisible it either scrollToBottom() when pinnedRef.current is true, or reapplies retainedScrollTopRef.
When the restore hook has claimed the edge first, this effect returns without touching the scroller — fine so far. But the two hooks do not share the pinnedRef read: the restore hook's align() calls onPosition(scroller.scrollTop) (use-transcript-reading-position.ts:100) which writes lastScrollTopRef and lastLaidOutScrollTopRef (useTranscriptScroll.ts:124-127) but notpinnedRef. If the reveal lands while pinnedRef is still true — which it is whenever the previous session state ended pinned, or whenever the reader's last action before switching was a send/retry that force-pinned (:434-441) — the scroll hook's branch at :250 wins the race and bottoms the transcript before the restore effect's first align() runs.
That matches your repro exactly: step 3's "让它产生新输出" is the force-repin path at :434-441, which is why the failure needs activity in the other session and is not deterministic.
Why it is intermittent rather than always
readingPositionPendingRef is only non-null when the bookmark has pinned === falseand a messageId (:83-84, and captureTranscriptReadingPosition returns no messageId when pinned, lib/transcript-reading-position.ts:44). If the bookmark was captured while pinned, or the row was unmounted so restoreTranscriptReadingPosition returns false (:60), the restore path bails and readingPositionMissing (useTranscriptScroll.ts:128-132) re-pins and bottoms the view. Two different failures land on the same symptom.
Suggested shape of a fix (not proposing a PR here)
The edge should be owned by exactly one effect. Either:
have the reveal branch consult the bookmark directly — const bookmark = getTranscriptReadingPosition(sessionId) and skip scrollToBottom() when bookmark && bookmark.pinned === false — so pinnedRef cannot override a recorded unpinned position; or
make the restore hook the sole owner of the reveal edge and delete the becameVisible branch from useTranscriptScroll, letting readingPositionMissing handle the genuinely-missing case.
The second is cleaner and matches the comment already in the code ("Revisits restore this pane's own position"), but it touches the send/retry re-pin path, so it needs a test that switches away mid-turn and back.
I have not reproduced it in a running build — this is a static trace. If you can attach logs/app/*.log from one failing switch I can check whether paneVisible or isRunning moved first, which would settle which of the two orderings is firing.
Thanks — the trace is convincing, and "activity in the other session" matches what I actually see, including why it is not deterministic.
On your log request: I checked what the build actually records before attaching anything. On my machine (%USERPROFILE%\.pi-desktop\logs) there is zero renderer-side instrumentation for this — grepping every *.log (including the rotated .1/.2 files) for scroll, paneVisible and readingPosition returns nothing, with a single unrelated hit in plugin.2.log. session.log only carries prompt.accepted / session.created / agent.turn.failed-style events, so it cannot say which of the two orderings at the becameVisible edge fired. Attaching it would not settle the question.
So if you want a definitive ruling, the cheapest path is a temporary log line in both reveal branches (readingPositionPendingRef.current and pinnedRef.current at that edge), or a DevTools snippet I can run to capture scrollTop + pinned across a failing switch. Happy to attach either — just tell me which you want and I will reproduce on demand.
Otherwise I lean toward your second option (restore hook as the sole owner of the reveal edge), with the caveat you already named about the send/retry re-pin path — that is the path in my step 3, so a test that switches away mid-turn is the one that matters.
Activity
muzimu217 commentedon Oct 20, 2026
Traced this on current
main(8a8d56413). Your instinct in the "额外遇到" paragraph is right: it is a restore/measure race, not a design choice, and I think I can name the exact window.Where the position lives
Reading positions are renderer-lifetime bookmarks keyed by session (
lib/transcript-reading-position.ts:15-27), captured only while the pane has layout (captureTranscriptReadingPosition,:36-53) — the code already knows a hidden pane must not overwrite a bookmark.SessionPanekeeps every visited session mounted withkey={id}and togglesvisible(ChatSurface.tsx:205-209), so switching sessions is a visibility flip, not a remount: the hooks stay alive and their effects re-run onpaneVisible.The race
Two independent layout effects react to the same
paneVisible: false → trueedge, and their order decides the outcome:useTranscriptReadingPosition(hooks/use-transcript-reading-position.ts:41-46) setspendingRefon reveal, then a second effect (:82-115) runsrestoreTranscriptReadingPositionand keeps re-aligning for up to 1500ms while aResizeObserverfires.useTranscriptScroll(features/chat/transcript/hooks/useTranscriptScroll.ts:231-255) handles the same edge: onbecameVisibleit eitherscrollToBottom()whenpinnedRef.currentis true, or reappliesretainedScrollTopRef.The bail-out at
:249is the problem:When the restore hook has claimed the edge first, this effect returns without touching the scroller — fine so far. But the two hooks do not share the
pinnedRefread: the restore hook'salign()callsonPosition(scroller.scrollTop)(use-transcript-reading-position.ts:100) which writeslastScrollTopRefandlastLaidOutScrollTopRef(useTranscriptScroll.ts:124-127) but notpinnedRef. If the reveal lands whilepinnedRefis stilltrue— which it is whenever the previous session state ended pinned, or whenever the reader's last action before switching was a send/retry that force-pinned (:434-441) — the scroll hook's branch at:250wins the race and bottoms the transcript before the restore effect's firstalign()runs.That matches your repro exactly: step 3's "让它产生新输出" is the force-repin path at
:434-441, which is why the failure needs activity in the other session and is not deterministic.Why it is intermittent rather than always
readingPositionPendingRefis only non-null when the bookmark haspinned === falseand amessageId(:83-84, andcaptureTranscriptReadingPositionreturns nomessageIdwhen pinned,lib/transcript-reading-position.ts:44). If the bookmark was captured while pinned, or the row was unmounted sorestoreTranscriptReadingPositionreturns false (:60), the restore path bails andreadingPositionMissing(useTranscriptScroll.ts:128-132) re-pins and bottoms the view. Two different failures land on the same symptom.Suggested shape of a fix (not proposing a PR here)
The edge should be owned by exactly one effect. Either:
const bookmark = getTranscriptReadingPosition(sessionId)and skipscrollToBottom()whenbookmark && bookmark.pinned === false— sopinnedRefcannot override a recorded unpinned position; orbecameVisiblebranch fromuseTranscriptScroll, lettingreadingPositionMissinghandle the genuinely-missing case.The second is cleaner and matches the comment already in the code ("Revisits restore this pane's own position"), but it touches the send/retry re-pin path, so it needs a test that switches away mid-turn and back.
I have not reproduced it in a running build — this is a static trace. If you can attach
logs/app/*.logfrom one failing switch I can check whetherpaneVisibleorisRunningmoved first, which would settle which of the two orderings is firing.LGlassySky commentedon Oct 21, 2026
Thanks — the trace is convincing, and "activity in the other session" matches what I actually see, including why it is not deterministic.
On your log request: I checked what the build actually records before attaching anything. On my machine (
%USERPROFILE%\.pi-desktop\logs) there is zero renderer-side instrumentation for this — grepping every*.log(including the rotated.1/.2files) forscroll,paneVisibleandreadingPositionreturns nothing, with a single unrelated hit inplugin.2.log.session.logonly carriesprompt.accepted/session.created/agent.turn.failed-style events, so it cannot say which of the two orderings at thebecameVisibleedge fired. Attaching it would not settle the question.So if you want a definitive ruling, the cheapest path is a temporary log line in both reveal branches (
readingPositionPendingRef.currentandpinnedRef.currentat that edge), or a DevTools snippet I can run to capturescrollTop+pinnedacross a failing switch. Happy to attach either — just tell me which you want and I will reproduce on demand.Otherwise I lean toward your second option (restore hook as the sole owner of the reveal edge), with the caveat you already named about the send/retry re-pin path — that is the path in my step 3, so a test that switches away mid-turn is the one that matters.
fix(transcript): let an unpinned bookmark own the pane reveal
perf(sessions): cache file-derived session summaries by file identity (…