# P1-01 双向消息渲染窗口 — 完成证据 **状态**: COMPLETE **完成时间**: 2026-07-21(会话时间) **环境**: Windows 11 x64, CPython 3.10.21 (.venv, uv), PyQt6 / Qt 6.10.0, Node ## 设计 **数据流(引擎无关)** - JS → Python:`bridge.onRequestWindowPage(sessionId, direction, boundaryId, generation)` (QtWebChannel slot 与 WebView2 postMessage 白名单同名,单一实现)。 - Python → JS:`run_js("rwPageResponse()")` / `rwInitWindow()` / `rwNoteLive(...)` / `rwBegin(...)` / `rwConfig(...)`。 - JS 端只做"窗口游标 + DOM 搬移":Python 用既有逐消息 bridge 调用渲染 DOM, JS 状态机 `ui/web/render_window.js` 跟踪 `order`(消息 id 序列)+ `indexById`(链内下标), 不解析消息内容。 **关键参数** - 窗口 = `render_window_size`(10/40/200,非法静默回落 40;mode `auto`/`manual` 回落 auto)。 - **页 = 半窗**(`max(1, size//2)`)。若页 = 整窗,"首个可见消息"锚点必然被裁出窗口, 需求中的锚点恢复(≤2px)永远不可达——诊断实测验证了这一点后才改为半窗。 - 活动流式消息受保护(`trimHead`/`trimTail` 跳过 `activeStreamId`),计入上限; `streamFinished` 解除保护。`rwNoteLive` 经 DOM `.streaming` 类自动接管流式保护, 并携带实时 `chainLen` 维持 hidden 计数新鲜。 **滚动语义** - 批次渲染期间 `window.__rwPageRendering = true`,抑制 `softScroll()` 与 `finishMessage` 的 `scrollIntoView`。 - **守卫在调度时刻捕获**(`var rwBatchSuppressed = !!window.__rwPageRendering` 后立即 rAF+50ms 延迟回调):延迟回调触发时批次已结束、标志已被 Python 复位,届时再读会漏放 `scrollIntoView(smooth)` → 平滑滚底 → `rwAutoCheck` 误判贴底 → 触发 'newer' 反向换页振荡。 这是诊断中 `scrollY 0→4367` 振荡的根因,已修复。 - 向上换页锚点恢复:`scrollTop = oldScroll + (anchor.newTop - anchor.docTop)`, 绝对顶部(`oldScroll <= 1`)例外:停在 0 露出新页;auto 模式沿顶部 60ms 链式补页。 - 向下换页两模式均自动恢复(`isNearBottom() && canRequest('newer')`)。 - 过期响应(会话/代次不匹配、pending 已被新请求替换、重复投递)整批丢弃,已渲染 DOM 回滚。 **生成代次(generation)** - Python `MainWindow._rw_generation` 为唯一权威源:每次 `load_messages_to_web` / 新建会话 +1。 - JS `clear()` 本地防御性 +1,Python 下次推送重新同步。 ## 文件变更 | 文件 | 变更 | |---|---| | `core/config_paths.py` | 新增 `render_window_settings()`(16 类配置归一化)、`DEFAULT_RENDER_WINDOW_SIZE`、`ALLOWED_RENDER_WINDOW_SIZES` | | `ui/web/render_window.js` | 新增:DOM 无关状态机(id+chainIndex 模型,双遍 recompute) | | `ui/web/app.js` | `rwState`/`rwInitWindow`/`rwPageResponse`/`rwApplyOlder`/`rwApplyNewer`/`rwNoteLive`/`rwRequestPage`/`rwAutoCheck`/`rwCaptureAnchor`/`rwRemoveMessageDom`/`rwEnsureLoadButtons`/`rwUpdateLoadButtons`;`clearChat` 联动状态机清空;`finishMessage` 步骤 C 调 `streamFinished`、步骤 F 守卫捕获式;`createMessage`/`createLongMessage`/`createUserMessageWithAttachments` 的 `softScroll` 守卫;scroll 监听挂 `rwAutoCheck` | | `ui/web/index.html` | 引入 `render_window.js`(先于 app.js);WebView2 shim 增加 `onRequestWindowPage` | | `ui/web/style.css` | `.load-window-btn` 样式(含 `[hidden]` 规则) | | `ui/views/chat_bridge.py` | 信号 `window_page_requested`、slot `onRequestWindowPage`、推送方法 `rw_config`/`rw_begin`/`rw_init_window`/`rw_note_live`/`rw_page_response` | | `ui/views/wv2_view.py` | `_BRIDGE_METHODS` 白名单加 `onRequestWindowPage` | | `ui/views/main_window.py` | `init_browser` 窗口状态初始化 + 信号连接;`_on_js_ready_checked` 一次性 `rwConfig` 推送;`load_messages_to_web` 窗口化重写(代次+1、`rwBegin`、最新 size 条窗口渲染、`rwInitWindow`、流式恢复保留);新增 `_rw_visible_chain`/`_rw_note_live`/`_render_history_one`/`_on_window_page_request`(边界缺失安全降级空页);`on_new_chat_clicked` 代次+1;发送/重答/完成 5 处 `_rw_note_live` 挂点 | | `tests/test_render_window.js` | 新增:状态机 Node 测试 | | `tests/diag_render_scale.py` | 新增:400 条链 offscreen 规模诊断(真实 viewport:resize+show+等待 innerHeight>0+显式重载) | | `tests/smoke_timeline.py`、`tests/smoke_midswitch.py` | 转换到 `tests/_test_env.isolate()`(临时 DB+临时配置,P0-01 铁律) | | `tests/_probe_rw.py` | 调试探针(保留,供后续排障) | ## 验证(全部 EXIT=0,全部带显式超时执行) | 套件 | 结果 | |---|---| | `node tests/test_render_window.js` | **424/424 PASS**(配置归一化 16 例、auto/manual×10/40/200 初始窗口、双向连续换页 39 页/向、短链、过期响应 4 类、活动流保护、clear 语义、锚点几何、原子性、noteLive) | | `python tests/diag_render_scale.py 400`(offscreen) | **6/6 PASS**:初始窗口=最新 40 条(链长 400);中部锚点保持(误差 ≤2px、无 newer 振荡);自顶部向上分页至头部(绝对顶部例外);头部状态+全链 400 条可达无重复;自顶部向下回翻 2 页;auto 模式顶部自动补页。**锚点误差 0.00px**;18 页 @ 169/183/204 ms(min/avg/max);DOM 节点 1057–1080;页面高度 7127–7213 px | | `python tests/smoke_offscreen.py` | 8/8 ALL PASS | | `python tests/smoke_timeline.py` | 11/11 ALL PASS(流式/历史路径,含 streaming 类收尾) | | `python tests/smoke_midswitch.py` | 7/7 ALL PASS(切走切回时间线完整) | | `python tests/test_file_attach.py` | 9 tests OK | | `node tests/test_math_extract.js` | 39/39 PASS | | 回归:`smoke_bash_panel` / `test_config_isolation` / `test_main_window_event_filter` / `smoke_mode` / `test_error_persist` | 全部 ALL PASS | | `python tests/run_tests.py`(agent core 规范入口) | **41/41 PASS** | ## 诊断过程记录(问题 → 根因 → 修复) 1. **`rw_init_window` 链下标偏移**:初始窗口传入局部下标 0..39 而非链下标 → `hiddenOlder` 恒 0。 修复:`offset = total - len(window_items)`,传 `offset + i`。 2. **offscreen 零视口**:未 `resize`+`show` 前 `innerHeight=0`,锚点几何全废。 修复(诊断侧):`window.resize(1400,950)` + `show()` + 等待 `innerHeight>0` + 显式 `load_messages_to_web` 重载。 3. **`runJavaScript` 不能返回 DOM 元素**:回调转换失败 → 用 `cond ? 1 : 0` / 原语返回值。 4. **分支兄弟偷叶**:链尾补兄弟消息使 `add_message` 自动改叶 → 链被截断。 修复(夹具):兄弟消息在循环内 `i==298` 处插入。 5. **页 = 整窗导致锚点必被裁**(设计缺陷):半窗页修复(见"关键参数")。 6. **`finishMessage` 守卫延迟求值 → 滚底 → 'newer' 振荡**(见"滚动语义"第 2 条)。 7. **中部换页落点贴底**:15% 视口位置向上换页后锚点落 65%(不贴底);底部半窗换页本身会 落向底部属半窗几何固有——真实入口(顶部"加载更早消息"按钮 / auto 顶部观察器)不触发该位置, 且落底后自动 'newer' 恢复符合"向下自动恢复"需求。 ## 已知观察项(不在本任务范围) - `diag_render_scale` 的每页耗时(~180ms)只作真机基准参考,非硬阈值(符合任务要求)。 - 帧耗时真机人工基准报告留待人工验收环节。