Fixed header, sticky composer inside the transcript scrollport
固定标题栏,sticky 编辑器位于 transcript(文本记录)滚动容器内
The active conversation column split scrolling: the chat (and trajectory) view owned `overflow-y: auto`, while the composer stack sat as a sibling below that scrollport. A wheel gesture over the stats line or input therefore hit a non-scrolling region and did nothing — the transcript only moved when the pointer was over the message list. Long drafts made it worse: the textarea is itself a scrollport, so wheel over th
English
Problem
The active conversation column split scrolling: the chat (and trajectory) view owned overflow-y: auto, while the composer stack sat as a sibling below that scrollport. A wheel gesture over the stats line or input therefore hit a non-scrolling region and did nothing — the transcript only moved when the pointer was over the message list. Long drafts made it worse: the textarea is itself a scrollport, so wheel over the composer could be trapped there. The session header must occupy the top of the column as ordinary chrome (not position: sticky inside the scrollport), while the composer must stick to the bottom of the same scrollport as the transcript so wheel over the footer moves the flow.
Decision
ConversationRoot always owns one data-conversation-scroll body, with the strict conversation.session view outlet before a data-composer-seat around the whole 'conversation.composer' chain output (fallback + elected overlay siblings from overlay: true). The separate strict conversation.session.header outlet remains flex: none column chrome above that scrollport and hides while the Session is blank. This fixed parent tree keeps the scroll body and composer seat mounted from no session through the blank Hero and active conversation. Active CSS sticks that seat with position: sticky; bottom: 0 so Question/Approval takeovers stay visible when the user is not pinned to the floor; Hero CSS centers the fallback stack inside the scroll body. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set overflow: visible and resolve bottom-follow / prepend anchoring through closest('[data-conversation-scroll]').
Session stats live on 'conversation.composer.dock' (above 'conversation.input.dock'). The InputBar textarea, when inside the host, chains wheel with { passive: false }: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it preventDefault and apply deltaY to the host.
Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. ChatView records the first visible data-chat-anchor-key and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry; how reader input is recognized — device-agnostic deviation from the observed-top ledger of the last delivered or written scrollTop — is owned by the reader-scroll-attribution note. ChatView's single ResizeObserver follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write.
Alternatives considered
Sticky header and sticky composer inside one column scrollport. Rejected for the header: it must occupy the top as fixed layout chrome, not participate in the scrollport's sticky layer.
Fixed flex-none composer below the scrollport with wheel forwarding. Rejected: the product requires the composer to stick inside the transcript scrollport so the footer is part of that scroll hit-testing surface, not a sibling that only forwards deltas.
Portal the composer into ChatView's scroller. Rejected: the composer is shared across view tabs; its target is the root-owned scrollport in the resident shell.
Keep StatsLine inside ChatView below the message column. Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
Model every browser scroll input source. Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership were left outside the input-source model instead of adding a general input state machine. The reader-scroll-attribution note later closed this deferral by generalizing attribution through the observed-top ledger, still without an input state machine.
Consequences
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve reader scroll decisions, including Chromium's compositor-first delivery and stream-finalization shrink clamp. Follow ownership extends to every reader input under the reader-scroll-attribution note. No session → blank Hero and Hero → active both keep the same textarea DOM node and InputHub draft.
中文
问题
活跃会话列把滚动拆成两段:聊天(以及 trajectory)视图自有 overflow-y: auto,编辑器栈则作为该滚动容器的兄弟节点坐在下方。指针落在统计行或输入区上时,滚轮打在不可滚动区域上因而毫无效果——只有指针在消息列表上时 transcript 才会移动。草稿变长时更糟:textarea 本身也是滚动容器,编辑器上的滚轮可能被截在那里。会话标题栏必须以普通 chrome 占据列顶(不能在滚动容器内 position: sticky),而编辑器必须与 transcript 贴在同一滚动容器底部,使页脚上的滚轮能带动内容流动。
决策
ConversationRoot 始终拥有同一个 data-conversation-scroll 主体,其中严格 conversation.session view outlet 位于 data-composer-seat 之前;该 seat 包住整条 'conversation.composer' chain 输出(overlay: true 下的 fallback 与选举出的 overlay 兄弟节点)。独立的严格 conversation.session.header outlet 作为 flex: none 列 chrome 位于滚动容器上方,并在 Session 仍为 blank 时隐藏。固定的父级树让滚动主体与 composer seat 从无会话、blank Hero 到活跃对话始终保持挂载。活跃阶段 CSS 以 position: sticky; bottom: 0 钉住该 seat,使用户未贴底时 Question/Approval 接管仍可见;Hero CSS 在滚动主体内居中 fallback 栈。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 overflow: visible,并通过 closest('[data-conversation-scroll]') 解析贴底跟随与前置锚定。
会话统计挂在 'conversation.composer.dock'(位于 'conversation.input.dock' 之上)。InputBar 的 textarea 在宿主内以 { passive: false } 链式处理 wheel:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 preventDefault 并将 deltaY 施加到宿主。
Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,ChatView 记录第一个可见的 data-chat-anchor-key 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态;读者输入如何被识别——即以与设备无关的方式偏离由最近一次交付或写入的 scrollTop 构成的 observed-top ledger——由读者滚动归因笔记负责。ChatView 的单个 ResizeObserver 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个分片不会触发第二次滚动写入。
考虑过的替代方案
标题栏与编辑器都在同一列滚动容器内 sticky。 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。
滚动容器下方 flex-none 固定编辑器并转发滚轮。 否决:产品要求编辑器 sticky 在 transcript 滚动容器内,使页脚成为该滚动命中面的一部分,而不是仅转发增量的兄弟节点。
把编辑器 portal 进 ChatView 的 scroller。 否决:编辑器跨视图标签共享;其目标是常驻壳中由 root 持有的滚动容器。
把 StatsLine 留在 ChatView 消息列下方。 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。
为每一种浏览器滚动输入来源建模。 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权当时被留在输入来源模型之外,也未为此新增通用输入状态机。读者滚动归因笔记后来通过 observed-top ledger 泛化了归因,补上了这一延后事项,且仍未引入输入状态机。
后果
在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky 轮次标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留读者的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段的收缩钳制。贴底跟随所有权依据读者滚动归因笔记扩展到每一种读者输入。无会话 → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。