Skip to content

[Bug] 切换会话后有时不恢复阅读位置,而是直接停在会话底部 #1771

Closed

Description

@LGlassySky

What happened? / 问题描述

在会话之间切换时,有时会话会直接定位到最新一轮(底部),而不是回到我离开时的位置。上翻很久在读旧内容,切去另一个任务再切回来,视图直接跳到底部,只能重新往上翻——几百轮的长会话里这个成本很高。

0.11.2 的更新说明里明确写过「回到之前上翻过的会话会停在原来的位置,而首次打开的会话仍然定位到最新一轮」,所以这是该行为偶发失效,不是设计如此。

Steps to reproduce / 复现步骤

不是每次必现,下面是能观察到失效的条件组合:

  1. 打开一个长会话,向上滚动到中间某处(离开 live 尾部,不要贴底)。
  2. 点侧边栏切到另一个会话。
  3. 在会话 2 里滚动一下,或让它产生新输出,然后切回会话 1。
  4. 有时视图直接落在会话底部(最新一轮),不是离开时的位置。

另外还遇到过:切到别的应用窗口再切回 PI-Desktop 时也发生过一次,所以怀疑「重新聚焦 + 转录重排」也走了同一条定位逻辑(比如重排后判断「是否贴底」时把尚未恢复的位置当成了 0)。

Expected behavior / 预期行为

切回会话时回到离开时的滚动位置;只有首次打开、没有任何已记录位置的会话才定位到最新一轮。

Actual behavior / 实际行为

偶发重新定位到最底部,且没有任何提示说明「视图被重置了」。

App version / 应用版本

0.20.1

Operating system / 操作系统

Windows

Extra environment / 其他环境信息

Windows 11 build 26300 · NSIS 安装包(%LOCALAPPDATA%\Programs\PI-Desktop)

Logs / 日志

这个现象没有对应报错。如果需要日志,请说明要看哪一类(logs\app\plugin.log / logs\host\runtime.log),我可以按要求开日志复现一次再贴上来。

Activity

  1. muzimu217 commented on Oct 20, 2026

    @muzimu217
    Contributor

    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:

    1. 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.
    2. 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.

    The bail-out at :249 is the problem:

    if (!becameVisible || readingPositionPendingRef.current) return;

    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 not pinnedRef. 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 === false and 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.

  2. LGlassySky commented on Oct 21, 2026

    @LGlassySky
    Author

    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.

  3. added a commit that references this issue on Oct 21, 2026
    549b67b
  4. added a commit that references this issue on Oct 25, 2026
    7a95fd0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions