Skip to content

feat(sidebar): 分组会话列表限高滚动 + 边缘淡出,并带上新会话入口、写穿修复与退出确认 - #75

Merged
Jaxton07 merged 8 commits into
mainfrom
polish/ui
Sep 21, 2026
Merged

Jaxton07 merged 8 commits into
mainfrom
polish/ui

Conversation

@Jaxton07

Copy link
Copy Markdown
Owner

What does this PR do?

主体:左栏分组会话列表的「限高 + 组内独立滚动 + 边缘淡出」(+ 本分支上此前未推送的 3 个提交一并提上来)。

1. 分组会话列表:限高 8 行(248px)+ 组内独立滚动

以前左栏只有一个外层滚动容器,一个 35 个会话的项目会把后面项目推到很远。现在日常/每个项目的会话列表抽成统一组件 SidebarSessionList:

  • 最多 8 行(SIDEBAR_SESSION_LIST_MAX_ROWS × SIDEBAR_SESSION_ROW_HEIGHT = 248px),超出部分在组内独立滚动;
  • 滚到边界不穿透外层:只有实际溢出(> 8 行)才挂 overscroll-y-contain —— 实测给不溢出的滚动容器挂 contain 会把滚轮整个吞掉(内层没内容、外层也不动),所以 contain 与 data-scrollable 用同一个布尔判据;
  • 项目/日常标题行渲染在滚动容器之外,标题上的滚轮照旧滚外层导航;
  • 纯 props 驱动,不读 store、不排序,不改 store / 搜索 / 排序 / SessionRow 视觉。

2. 边缘淡出(替代硬裁切)

  • 上边缘只在「上方还有内容」时渐隐、下边缘只在「下方还有内容」时渐隐;静止在顶时下边缘仍渐隐(顺带把「还能往下滚」变得可见),滚到底时底部恢复干净;
  • 用 alpha 遮罩(mask-image)而非盖一层颜色:不引入任何色值,深浅色主题共用一套,hover 底色也自然跟着淡出;
  • 高度只有一个旋钮:CSS 变量 --session-list-fade(12px);
  • 状态只由容器自己的滚动位置推出(passive scroll + ResizeObserver + 每次渲染后补算),只写 data-fade-top/bottom 两个属性,不拦 wheel、不改 scrollTop;
  • ≤8 行的列表没有「另一边」,不挂任何属性。

3. 可重复执行的验收脚本

scripts/check-sidebar-group-scroll.mjs:用 CDP 真实 wheel 验滚动归属(不是拿赋 scrollTop 冒充),只用 data-* 与 DOM 相邻关系定位(不按文案匹配),退出码 0 通过 / 1 断言失败 / 2 环境不满足。覆盖结构尺寸契约、内层中部/上下边界/标题行/不溢出列表的滚动归属、淡出状态机、以及「淡出条内仍可命中」。

4. 文档

docs/INDEX.md(新组件、验收属性、脚本入口)、docs/PITFALLS.md(不溢出容器挂 contain 会吞 wheel;验证滚动归属的三条纪律;CDP wheel 落点被浮层遮住 → 假失败;命令式改 JSX 已声明属性会被 React 重渲染冲掉)。

5. 一并提上来的既有本地提交(本分支早前完成、未推送)

  • feat(quit):Windows 点 ✕ 退出前确认弹窗(说明任务会终止,交用户决定;macOS 隐藏窗口语义不变);
  • fix(sidebar):会话目录写穿 —— 内存策略卸载不再让左栏行消失;
  • feat(sidebar):左栏新会话入口(顶栏「+」同语义)。

How was it tested?

  • npm run typecheck / npm run lint(448 files)/ npm run test(531 tests)/ npm run build 全绿(本地,与 CI 同套命令);
  • CDP 验收脚本 node scripts/check-sidebar-group-scroll.mjs:41/41 PASS(日常 14 行组、项目 35 行组、1 行小列表;含静态尺寸、真实 wheel 的四类归属、淡出状态机、复位);
  • 人工项里可自动化的部分 9/9 PASS:搜索把大列表缩到 ≤8 行后不再截断(清空恢复)、折叠卸载 / 重展开从顶部开始、右键菜单随组内滚动关闭、点行打开会话、浅深主题下列表宽度与行标题 x 不变、置顶生效并可取消;
  • 视觉:逐帧截图确认静止在顶 / 滚到中部 / 滚到底三态与「关掉遮罩」的对照(截图在本地 .local/,未入库);
  • 全程用 dev 隔离数据(~/.pi/agent-dev),正式 ~/.pi/agent 零写入。

Checklist

  • npm run lint passes
  • npm run typecheck passes
  • npm run test passes
  • New user-facing strings were added to both zh and en dictionaries(本次无新增文案;退出确认的 quitConfirm.* 已在两份字典里)
  • No API keys or sensitive data included

备注

  • 唯一无法脚本化的验收项:真触控板双指滑动/惯性体感(CDP wheel 与触控板走同一条输入→滚动链,但惯性由 macOS 从设备产生)。
  • 组内 scrollTop 不持久化、不自动把 active 会话滚入视口(按设计明确不做)。
  • 左栏外层滚动容器目前仍是硬裁切;如需同样淡出,同一套配方再加一份 CSS 即可(未在本 PR 范围内)。

不改「关窗 = 退出」的 Windows 语义,也不做托盘;只在退出前问一句。

- main/window.ts:win32 关窗仅在渲染端接管确认时拦下(app:setQuitGuard),发
  app:quit-requested 并等 app:quitDialogShown 上屏回执;1.5s 无回执 = 渲染进程
  卡死/崩溃 → 放行退出(实测 unresponsive 事件此时不触发,不能靠它判断)
- 程序化退出(更新重启 / 看门狗 / ⌘Q)经 before-quit 的 quitting 放行,不弹窗
- macOS 红点仍是隐藏窗口,Linux 行为不变
- 渲染端:stores/app-quit.ts + ui/QuitConfirmDialog.tsx(挂 AppErrorBoundary 外面);
  ConfirmDialog 新增 autoFocusConfirm(默认焦点给「退出」,Enter 直接退出)
- 设计稿 .local/design/ux/quit-confirm/;PITFALLS 记录卡死兜底与验证手法
2026-09-21 事故:新建的会话过一阵突然从左侧栏消失(点「+」进新会话页后又回来)。
根因是缓存一致性设计缺失,不是某处漏了判断——左栏行的「存在性」被拆成两个都不完整的来源:

- `projects.allSessions` 只是**上次全量对账的快照**(只在进新会话页/启动时整表重拉),
  必然早于它之后新建出来的会话;
- 内存 `sessions` 会被内存策略(GC)卸载。

于是:17:20 新建的会话只活在内存里、靠合并撑着那一行 → 18:11 用户切走 → GC 立刻卸它
(`lastUsedAt` 只在「打开/切到/新建」打点,发消息不打点)→ 内存里没了、旧快照里也没有 → 行消失。

修复:把 `allSessions` 定成**会话目录**(存在性的唯一来源)= 上次全量对账 ∪ 本进程经手过的会话

- `absorbOpenSessions`:会话一进内存就写穿进目录(**只补缺不覆盖**,磁盘权威的时间字段不被内存 meta 盖掉);
  接线只有一处(`useSessionsStore` 订阅),后续新增「会话进内存」的路径不会漏;
- `load()` 结尾再补一次(0 消息会话还没有会话文件,替换语义会把它挤出目录);
- 内存 `sessions` 自此只负责运行态叠加,GC 卸载 = 只撤运行态、不撤存在性;
- 顶栏置顶胶囊(tabs → 目录兜底)同源,一并修好。

连带两处:

- 拆掉 renderer 的模块环 `projects → sessions → ui-preferences → sidebar-groups → projects`
  (模块级订阅在「sessions 先进」的入口会拿到未初始化的绑定):环的成因只是 3 行纯函数
  `toggleInList` 挂在 `sidebar-groups` 上,移入叶模块 `lib/toggle-in-list.ts`;补
  `import-cycles.test.ts` 扫全图断言无环(本仓当时为 0);
- backend `deleteSession` 容忍 ENOENT:0 消息会话只有计划路径(文件要等首条 entry 才落盘),
  它现在会出现在目录里,删掉它应当照常成功。

验证:新增「会话目录写穿」四例单测(做过红检,注释掉写穿后 3 例失败)+ dev 实例 CDP 真机复现
(createSession → 切走 → unloadSession → `inMemory=false` 但目录与 DOM 行仍在);typecheck/lint/全量测试全绿。

文档:PITFALLS 章节四补完整复盘与复现手法 + 症状索引 3 条;INDEX 加硬约束「renderer 模块图必须无环」
并改 `stores/projects.ts` / `lib/sidebar-groups.ts` 两条目;spec session-memory-policy 修正被证伪的前提。
- 新增 SidebarSessionList:纯 props 驱动,maxHeight = 8 × 31px,overflow-y-auto
  + overflow-x-hidden + 隐藏滚动条;仅在实际溢出(> 8 行)时挂 overscroll-y-contain
  (实测给不溢出的滚动容器挂 contain 会吞掉 wheel、外层不滚),该布尔与
  data-scrollable 共用同一判据
- SidebarGroup 改为渲染它,项目标题行留在组内滚动容器之外(标题上滚轮交给外层)
- Sidebar 外层滚动容器补 data-sidebar-scroll-root(验收脚本定位用,纯属性)

验收(dev + CDP 实测,隔离数据):大列表 ch=248/sh=434、contain;1 行列表 ch=sh=31、
overscroll=auto;行高 31/项目行 34 不变、无横向溢出;真实 wheel 下大列表只滚内层、
到底不穿透、小列表交给外层、标题行滚外层。
可重复执行的真实 wheel 验收(spec §7):
- 结构/尺寸:外层容器可滚且无横向滚动;每个列表上限 248px、overflow-x hidden 无宽度跳变、
  会话行 31px、标题行 34px 且在滚动容器之外、contain 与 data-scrollable 同源;
  溢出列表 clientHeight=248 且 scrollHeight>clientHeight,不溢出列表 scrollHeight=clientHeight
- 真实 wheel:内层中部只滚内层;内层到顶/到底继续滚都不穿透外层;标题行只滚外层;
  不溢出的列表内滚交给外层(≤8 行不挂 contain 的收益)
- 只用 data-sidebar-scroll-root / data-sidebar-session-list / [data-session-id] 与 DOM 相邻关系定位,
  不按标题文案匹配;赋 scrollTop 只用于预置中部/边界,不代替 wheel;结束复位 scrollTop
- 退出码:0 全通过 / 1 有断言失败 / 2 环境不满足(无法判定,附造数据的提示)

当前 dev 数据实测 30/30 PASS(重复执行同结果)。
阶段 2 review 阻塞项:CDP_PORT 直接拼进 execSync 的 shell 字符串,既有注入面又
不必要地依赖外部 curl。改为:
- 删除 node:child_process/execSync,target 列表走内置 fetch(带 3s 超时);
- CDP_PORT 必须匹配 1–65535 的纯数字,否则以环境错误退出(退出码 2),
  "9224; rm -rf /"、"1e4"、空串等一律拦下。

实测:正常端口 30/30 PASS(退出码 0);非法端口与未监听端口(9299)均退出码 2 并给出提示。
- INDEX:sidebar/ 行补 SidebarSessionList(248px 上限 + 条件 contain)与 Sidebar 的
  data-sidebar-scroll-root;速查表「左侧栏」行补组内限高滚动与验收脚本;scripts 表补
  check-sidebar-group-scroll.mjs
- PITFALLS:新增「嵌套滚动的归属验证 + overscroll-behavior: contain 会吞掉滚轮」
  (含四组实测对照表)与「验证滚动归属三条纪律」;「鼠标事件」条目补第 4 条
  (wheel 落点被浮层遮住 → 假失败,要同时断言容器 scrollTop 真的变了);症状索引补 2 行
- SidebarSessionList:SIDEBAR_SESSION_ROW_HEIGHT 注释按 review 备注改为
  「与 SessionRow 固定行高配套的布局常量」,不再声称唯一权威值
用户反馈组内列表被 248px 硬切、上下边界太生硬。改为给列表容器上 mask 渐变:
- 上边缘只在「上方还有内容」时渐隐、下边缘只在「下方还有内容」时渐隐,静止在顶时
  下边缘仍渐隐(顺带把"还能往下滚"这件事变得可见)
- 用 alpha 遮罩(mask-image)而不是盖一层颜色:不引入任何色值、深浅色主题共用一套,
  hover 底色也自然跟着淡出;淡出高度只有一处旋钮 = CSS 变量 --session-list-fade(12px)
- 状态来源是容器自己的滚动位置(passive scroll + ResizeObserver + 每次渲染后补算),
  只写 data-fade-top / data-fade-bottom 两个属性:不拦 wheel、不改 scrollTop,
  延续「原生嵌套滚动」的既有决策
- 不溢出的列表(≤8 行)没有"另一边",不挂任何属性,边缘保持干净
- 属性由 effect 独占、不写进 JSX,避免 React 重渲染把滚动中的值冲回初值

验收(dev + CDP,隔离数据):scripts/check-sidebar-group-scroll.mjs 扩到 41/41 PASS
(新增:静止在顶只下渐隐 / 中部两面 / 到底只上渐隐 / 不溢出列表 mask=none /
淡出条内 elementFromPoint 仍命中会话行 / 淡出高度来自 CSS 变量);
人工项里可自动化部分复跑 9/9 PASS(搜索、折叠展开、右键菜单随滚动关闭、点击打开、
浅深主题宽度不变、置顶恢复);typecheck / lint(448 files) / test(531) 全绿。
@Jaxton07
Jaxton07 merged commit 9cee2e0 into main Sep 21, 2026
1 check passed
@Jaxton07
Jaxton07 deleted the polish/ui branch September 21, 2026 16:02
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.

1 participant