Skip to content

fix(transcript): reserve a stable slot for tail runtime status - #432

Merged
vastsa merged 3 commits into
mainfrom
fix/issue-323-transcript-runtime-status-slot
Sep 16, 2026
Merged

vastsa merged 3 commits into
mainfrom
fix/issue-323-transcript-runtime-status-slot

Conversation

@vastsa

@vastsa vastsa commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Summary

The transcript's tail status row ("waiting for model", planning, working) was conditionally rendered straight into .thread-content, so the row's line box, padding and margin entered and left the normal document flow with every phase change. While the user is pinned to the bottom, the content ResizeObserver in useTranscriptScroll.ts re-baselines follow synchronously, so the height change moved rows the user was already reading. Reported and measured at 46 CSS px of heading displacement on Windows/Electron 43.6.0 at DPR=1, with no manual disclosure and no user scrolling involved (that separate path is #324).

Changes

apps/desktop/src/features/chat/transcript/ChatTranscript.tsx

  • The three tail indicators are wrapped in an always-mounted .transcript-runtime-status lane, gated on runtimeStatusLane (== transcriptRunning). All three indicators already required transcriptRunning, so the gate cannot hide one: only the wrapper's lifetime changed.
  • The indicators keep their exact conditional expressions on a single line, and the lane's slot is reserved for the whole running turn.

apps/desktop/src/styles/chat-shell.css

  • .transcript-runtime-status reserves exactly one indicator box: calc(var(--text-sm-plus) * var(--leading-body) + 22px) — the --text-sm-plus line at --leading-body plus the indicator's own 8px/4px padding and 2px/8px margins, which is why display: flow-root is needed so an empty lane does not collapse its margins away. Measured reserve: 40.125 px, the indicator's own height.
  • An idle transcript (turn over, or reading window) renders no lane at all, so a finished transcript keeps its exact layout.

No scroll compensation, no timeout-based re-anchoring, and the ResizeObserver, overflow-anchor settings, pinned-follow rule and automatic disclosure rules are untouched.

Validation

On the integrated local main (merge commit b1bd0f2b, after merging this branch):

Command Result
pnpm build:js pass
pnpm --filter @pi-desktop/desktop typecheck pass
pnpm lint:biome pass
pnpm -r --if-present test pass — desktop 1979/1979, 0 fail
pnpm test:e2e:transcript pass
pnpm test:e2e:theme-surfaces pass, failures: []
pnpm test:e2e:layout pass, 37/37 checks
pnpm test:e2e:boot pass

The new scenario E2E-CHAT-runtime-status-keeps-row-position measures the real ChatTranscript in a real Electron viewport with the app's built stylesheet linked in (the previous fixture bundled components with loader: { ".css": "empty" }, so a CSS-reserved slot was not measurable — the harness now links the built CSS the same way test:e2e:theme-surfaces already does, and asserts the linked stylesheet list matches the built one so a stale build cannot pass).

Geometry is byte-identical across empty lane → waiting → cleared:

contentHeight 1119.9375  scrollTop 548  firstRowTop -502  lastRowBottom 287.8125
laneHeight    40.125     laneChildren 0 -> 1 -> 0     laneText "" -> "Waiting for model0s" -> ""
idleLaneMounted: false    failures: []

Removing the min-height and rebuilding makes the scenario fail with exactly the reported symptom (contentHeight moved by 40.125px), so the test is not vacuous.

Specs

  • docs/spec/04-ux/08-component-spec.md — the tail status lane contract.
  • docs/spec/06-delivery/04-e2e-test-plan.mdE2E-CHAT-runtime-status-keeps-row-position.
  • Renderer only: no protocol, storage, host, permission, migration or IPC change.

Known boundary

The lane's reserve starts and ends with the turn (transcriptRunning), and entering/leaving the reading window removes it. Both are real content changes the transcript already follows; the fix targets the intra-turn flicker the issue reports. A one-line reserve can wrap in an extremely narrow pane; the measured scenario is the issue's own width regime.

Fixes #323

The working / planning / run-activity row mounts and clears with the turn's
phase, and each of those rows is its own box at the tail of `.thread-content`.
A row appearing between two tool rows therefore grew the content by 40.125px
(46 CSS px on the reporter's Windows / DPR=1 build), and pinned follow
re-baselined to the new bottom, pushing the rows the user was already reading up
by the same amount. Clearing the status moved them back.

Reserve that row for the whole running turn instead: an always-mounted
`.transcript-runtime-status` lane, sized from the indicator's own box (8px/4px
padding, 2px/8px margins, one line of `--text-sm-plus`), holds the indicators and
stays reserved, empty and invisible while a concrete row, a permission card, or
the answer owns the tail. An idle transcript renders no lane at all, so a
finished transcript keeps its exact layout. Follow, the content ResizeObserver,
automatic disclosure and the live-region roles are unchanged.

The new React/Chromium scenario mounts the production ChatTranscript in a real
scrolling pane and asserts that the content height, scroll extent, scroll offset
and the first and last row positions stay within 0.01px while the waiting status
appears and after it clears, that the empty lane stays invisible in both themes,
that the live region survives, and that an idle transcript has no lane. Removing
the reserve fails it by 40.125px of content height and 40px of row movement.

Refs #323

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

The English E2E plan gained E2E-CHAT-runtime-status-keeps-row-position with
issue #323, but the Chinese mirror was left without the section. Every other
scenario added recently (the skill-market, queue and chat ones) carries a
zh-CN counterpart, so this restores the convention. Documentation only; no
code, behaviour, or assertion changes.
@vastsa
vastsa merged commit 02f7411 into main Sep 16, 2026
4 checks passed
@vastsa
vastsa deleted the fix/issue-323-transcript-runtime-status-slot branch September 16, 2026 00:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] “等待模型响应”等临时状态出现/消失时,会话内容上下跳动

2 participants