Files
Haocode/docs/agent-handoff/evidence/P1-01.md
T
sorrow404null bc0b92bcdc docs: add agent handoff docs, verification guide, and repair evidence
Add docs/agent-handoff (backlog, execution state, verification, platform plan, per-task evidence), repo AGENTS.md, and architecture notes updated for the dual-renderer design.
2026-09-17 16:40:06 +08:00

91 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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(<json>)")` / `rwInitWindow(<json>)` / `rwNoteLive(...)` / `rwBegin(...)` / `rwConfig(...)`
- JS 端只做"窗口游标 + DOM 搬移"Python 用既有逐消息 bridge 调用渲染 DOM
JS 状态机 `ui/web/render_window.js` 跟踪 `order`(消息 id 序列)+ `indexById`(链内下标),
不解析消息内容。
**关键参数**
- 窗口 = `render_window_size`10/40/200,非法静默回落 40mode `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 规模诊断(真实 viewportresize+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 msmin/avg/max);DOM 节点 10571080;页面高度 71277213 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)只作真机基准参考,非硬阈值(符合任务要求)。
- 帧耗时真机人工基准报告留待人工验收环节。