Skip to content

feat(layout): 左栏宽度可拖 + 窗口位置/尺寸记忆(#73) - #91

Merged
Jaxton07 merged 8 commits into
mainfrom
feat/layout-freedom
Oct 7, 2026
Merged

Jaxton07 merged 8 commits into
mainfrom
feat/layout-freedom

Conversation

@Jaxton07

@Jaxton07 Jaxton07 commented Oct 7, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

实现 issue #73 收窄后的两条诉求(作者本人跟帖把第 3 条「顶栏/置顶」划掉了,已由「工作区」方案覆盖):

  1. 左侧栏宽度可拖(200–480px),松手写入 ui-state.json,重启保持;渲染宽再按 min(用户值, 容器宽 - 320px) 二次夹紧,保证窗口缩小时聊天列不被挤没。拖动中只改内存、松手才落盘(否则每帧一次 IPC + 原子写会打爆写盘队列),拖动期间关掉 width 过渡(否则手感是橡皮筋)。
  2. 窗口位置与尺寸记忆:resize/move → 400ms 防抖 → 用 getNormalBounds() 落盘(不用 getBounds(),最大化时会把最大化尺寸写脏);退出时同步兜底;启动时用纯函数 sanitizeWindowBounds 做屏幕校验(拔掉外接屏 / 从大屏换小屏都不会把窗口留在可视区外);moved/resized 事件不可依赖(@platform darwin,win32,macOS 上 moved 还只是 move 的别名),已实测确认。

Fixes #73

三轮真机验收修复(用户验收时发现的,都在本 PR 内)

  • X2:左栏拖宽后把手/右缘按钮点不到 —— 聊天页空态那个 748px 的 logo 块在窄聊天列里居中后向左溢出,透明但照吃 pointer 事件,压住了侧栏右缘(侧栏 > 316px 即触发)。修法:.sidebar 加 z-index: 1 把整条侧栏抬到聊天列之上(视觉零变化,溢出那片本来就看不见)。
  • X3:拖到边界继续拖(指针拖出窗口左边缘)后永久卡在拖动态 —— 整窗光标一直是 col-resize、之后再也拖不动,要刷新才恢复。原因是只挂了 pointerup/pointercancel:指针在窗口外松手时页面收不到 pointerup。补三条兜底(pointermove 的 buttons === 0 主路径 / lostpointercapture / blur+visibilitychange),全部走同一个幂等 finish()。
  • X1(macOS 平台行为 + 缓解):macOS 在窗口首次 show() 时会把 x < 221 DIP 的窗口抬到 221。已定性为系统行为(new BrowserWindow + show() 就能复现,与我们代码无关),缓解方式是 show() 之后补一次 setBounds 校准(已实测:同一 seed 从「读回 221」变为「精确保持 180」)。

How was it tested?

  • npm run lint / npm run typecheck / npm run test(desktop 704 + backend 626)/ npm run build 全绿。
  • 新增 scripts/verify-layout-freedom.mjs(CDP 端到端,44 条断言)覆盖:CSS 夹紧与折叠动画共存、拖动关过渡不跳帧、window.resizeTo → main 落盘、防抖计数、最大化不污染 normal bounds、退出同步兜底、「松手后再抓」、「把手中心最上层元素必须是把手自己」、「pointerup 丢失后能否自愈」。
  • 单测:window-bounds.test.ts(拔屏/换小屏/多屏/负 x/非有限数)+ ui-state.test.ts(两字段白名单与 clamp)+ ui-preferences.test.ts(拖动不落盘 / 松手落盘 / 同值短路)。
  • 关键对照:用真 commit(git show HEAD:<file>)切回修复前跑断言,确认新用例修复前会红(不拿手改的 disabled 版本当对照 —— CDP 会自己补发 pointerup,会让坏版本看起来正常)。
  • 真机(macOS)人工验收:拖到边界继续拖→松手→移回鼠标再拖、拖到 480 再抓回、侧栏右缘「⋯/+/搜索框右半」可点、重启保持、最大化退出后回到普通尺寸、离屏 bounds 不飞屏外。

已知限制:Windows 侧行为本机无法验证(issue 作者是 Windows 用户)—— getNormalBounds/screen 校验/null 回退都是平台无关路径,但 Windows 的 titleBarOverlay + DPI 跨屏表现未实测。docs/PITFALLS.md 已记录窗口 show 重定位、CDP 对照方法论、HMR 订阅冻结三条。

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(sidebar.resizeHandle)
  • No API keys or sensitive data included

…过渡 / V3 窗口 bounds 端到端)

- V1:候选 width:min(var(--sidebar-width), calc(100% - 320px)) 与 400ms 过渡/折叠态源序覆盖共存(11/11)
- V2:拖动期 is-resizing 关过渡后滞后 0px,对照组 94px(橡皮筋);松手同步摘类不跳帧
- V3:window.resizeTo 真改窗口、main 收到 resize ×N、getNormalBounds() 读到新值(主进程探针走 9229 inspector,零生产代码改动)
- 阶段 1/2 落地后脚本自动切到真实 CSS / 真实把手 / 落盘断言,不需改脚本
按 review 的 R1 先修夹紧方案再落把手:渲染宽由 renderer 派生,一个变量喂内外两层。
- shared/src/sidebar.ts(新):宽度常量 + clampSidebarWidth(main normalize 与 renderer 共用)
- lib/sidebar-width.ts(新,有单测):sidebarRenderWidth = max(200, min(user, 容器宽-320))
  —— 只夹外层会让内层右侧被 overflow:hidden 硬裁(⋯ / 添加项目 / 在项目中新建会话 点不到)
- useSidebarRenderWidth(新):ResizeObserver 跟容器宽,useLayoutEffect 保证首帧就对
- SidebarResizeHandle(新):贴右缘 8px 命中区;起点取 store 用户值;拖动中 preview(不落盘)
  + pointerup commit;visible 反馈复用同一条 .fade-rule-v 发丝线(hover/拖动两档加深,不新增竖线)
- store:sidebarWidth 字段 + previewSidebarWidth / commitSidebarWidth;main normalize clamp
- 单测:sidebarRenderWidth 5 例、ui-state 6 例、store 5 例(总计 686 tests 全绿)

验收(scripts/verify-layout-freedom.mjs 真实实现复跑,日志 .local/tmp/layout-freedom/verify-stage1-20261007.log)
33 条 PASS 31 / SKIP 2:V1-j/k 窄窗内层零溢出 + 无控件越界、V2-a 拖动滞后 0.0px(React 每帧 set
没放大延迟)、V2-j/k 两端夹紧、V2-l 松手落盘、V2-m 截图后拖动态可收尾。
- shared:Rect 类型 + UiState.windowBounds(normal 态 bounds)
- main/window-bounds.ts(新,纯函数、不 import electron):尺寸常量 + sanitizeWindowBounds
  (取交集最大的屏 → 交集不足最小可视 160×80 判 null → 夹到 workArea → 平移到屏内)
- main/ui-state.ts:normalize 增 windowBounds 白名单(不做屏幕校验);saveUiStateSync(updateSync,
  绝不上抛)供退出兜底
- main/window.ts:createWindow(theme, bounds);resize+move → 400ms 防抖 → getNormalBounds() 落盘
  (绝不用 getBounds:最大化时会写进 1512×859);close 开头先 flush(同步写,各分支都兜)
- main/index.ts:启动与 activate 都传 sanitize 后的 bounds(workArea 取自 screen)
- 单测 +16:window-bounds 10 例(拔屏/换小屏/多屏/左侧副屏/非有限数)+ ui-state 6 例

验收:verify-layout-freedom.mjs all(去掉 --stage0)33 条 PASS 32 / FAIL 0,V3-d 落盘真绿;
新增 v4-window(防抖 20→2 次、最大化不污染、退出兜底同步写保住最后值)6 条全绿;
跨重启手测 5 种情形(保持 / 最大化退出 / 离屏 / 超屏 / 删文件默认行为)逐条实测通过。
- main/window.ts:ready-to-show 后比对 x/y,被 macOS 首次 show() 抬到 ≥221 时 setBounds 校正
  (实测:不开校准从第一个采样(16ms)到 500ms 都是 221;开校准第一个采样就是目标值)
- main/ui-state.ts:saveUiStateSync 注释补「updateSync 不参与 async per-path 队列」的前提与边界
- scripts/shoot-sidebar.mjs:宽度两端读 --sidebar-render-width(不再硬编码 240);RESET 改成按属性
  removeProperty(cssText="" 会把 React 写的渲染宽变量一起抹掉,跑完界面宽度会错)
- docs/PITFALLS.md:新增「六、Electron 主进程 · 窗口」章节(getNormalBounds 语义 + 退出同步兜底 /
  macOS 首次 show() 的 x 约束 / 只来 resize+move)+ 三条脚本坑(CDP 拖拽两个卡死姿势、Tailwind
  preflight 的 hr height:0、别用 cssText="" 复位)+ 症状索引 8 行
- docs/INDEX.md:新增/更新 4 个新文件与 5 处职责(含「窗口位置/尺寸记忆」速查行、验收脚本)

最终验收:all 33 条 PASS 32 / FAIL 0;v4-window 6/6;v5-frames 两条件 0 帧 >25ms;
shoot-sidebar both 在 sidebarWidth=400 下逐帧正常;typecheck/lint/test(703)/build 全绿。
- 三章新增:`npm run test` 偶发失败 = `channel-watch-extension.test.ts:936` catch-up cursor 落盘竞态
  (连跑 6 失 2;与本任务无关、按 review 要求不修,只记录排查用法)+ 症状索引一行
- 更正:Y1 指出 DONE.md 与上一 commit message 里「npm run test(703 passed;backend + desktop)全绿」
  表述不准 —— 703 是 desktop 的数,backend 另有 626 且带该 flaky。实情已写入 DONE.md(uncommitted,
  在 .local/agent-work/channel/),commit message 历史不改写。
用户报的问题:侧栏拖宽后**再想拖回来就拖不动了**(把手抓不住),行末「+ / ⋯」也点不到 —— 截图完全正常。

根因:聊天列里比列宽更宽的内容(聊天页空态固定 748px 的 logo 块居中 → 窄于 748 时向两侧溢出)
在 DOM 里位于左栏之后,**溢出的透明矩形照样吃 pointer 事件**,把左栏右缘一整条(侧栏 480 时实测 82px)
变成点击死区。触发区不极端:窗口 < 748 + 侧栏宽 就中招(默认 1100 窗口下拖到约 388 即触发)。

修法:`.sidebar { z-index: 1 }`(它已是 position: relative;栏内无 fixed/portal、根级浮层在更外层节点,
视觉零变化 —— 前后全窗口对比截图仅 173 px、单通道 ≤5/255 的 wordmark 抗锯齿差异)。

补两条用例(review 指出的两个盲点):
- V2-n:把手中心最上层必须就是把手自己(240/360/480 三个宽度)
- V2-o:栏内**可见**可交互元素逐个 elementFromPoint 打到栏内(预筛掉被 `overflow != visible` 祖先裁掉的,
  避免把折叠分组/滚出可视区的行误判成遮挡 —— 第一版判据踩过,见 IMPL-NOTES)
- V2-p:拖到 480 松手后**重新抓**把手左拖 120px,宽度必须跟随(拖动中指针被 capture 锁定不做命中测试,
  只有重抓才暴露遮挡)
实测有效性:把 z-index 临时改回 auto → V2-n/o/p 三个宽度全红;修复后全绿。all 40 条 PASS 39 / SKIP 1。

另:dragBy 在 pointerdown 后多等 2 个 rAF 预热(首次在 HMR 后立刻跑 all 时 V2-a 误报过一次 40px 滞后;
修后连跑多次都是 0.0px,v5-frames 也显示 z-index 抬层没带来每帧开销:A 8.36ms vs B 8.35ms、0 帧 >25ms)。
2026-10-07 X2 复跑时踩到第二种形状:端口空着起,新实例仍可能绑失败,脚本静默连到旧实例跑完一轮,
旧实例一死就只剩一个不监听端口的僵尸进程。判据 = 起完立刻查 dev 日志有没有 'address already in use';
一行 grep 就能避免把「连错实例」误判成业务代码问题。
用户报的问题:拖一次左栏宽度后整窗光标卡在 col-resize、is-resizing 摘不掉、之后再也拖不动(刷新才恢复)。

根因:指针在**窗口外**松手时 mouseup 交给了光标下那个窗口/应用,页面收不到 pointerup;
只监听 pointerup/pointercancel 的实现永远等不到收尾信号。(review 给了精确机制 + 复现脚本。)

修法(三条兜底,finish() 幂等):
- onPointerMove 里 `event.buttons === 0` → 收尾(主路径:鼠标移回窗口内立刻自愈)
- onLostPointerCapture → 收尾(捕获真丢时的官方信号;实测 Chromium 里脚本调
  releasePointerCapture() 不触发该事件,故只是兜底之一)
- window blur / document visibilitychange → 收尾(切窗口/切应用时松手)
- 顺手:previewSidebarWidth 同值短路(拖动中每帧 set 同值会白白触发渲染)+ 单测

补用例(现有 V2-* 没有「pointerup 丢失」这一支):
- V2-q 忠实复现(按下后一路拖到视口外、**完全不发 release**)→ 卡住(无信号,预期)→ 鼠标移回自愈 → 之后还能拖
- V2-r 派发合成 lostpointercapture(窗口内操作,隔离处理器接线)
- V2-s 窗口 blur 收尾
- V2-t 拖动中收到 buttons=0 的 move 收尾

必要性证据:把 HEAD(修复前)的组件切回跑同一套断言 → V2-q/r/s/t **四条全红**,且
review 的 repro-lostup.mjs 显示「仍卡着 / 拖不动」;修复后四条全绿、repro 显示「自愈了 / 能拖」。
(教训:别用手改的 disabled 版本当对照,要用真正的上一个 commit —— CDP 的 buttons 会改
Chromium 按键状态,使修复前的代码也能「看起来正常」,我因此一度得到自相矛盾的结论。)

all 44 条 PASS 43 / SKIP 1;lint 503 文件;test desktop 704 + backend 626。
@Jaxton07
Jaxton07 merged commit 773a30f into main Oct 7, 2026
1 check passed
@Jaxton07
Jaxton07 deleted the feat/layout-freedom branch October 7, 2026 07:41
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.

[Feature] 界面布局自由度:侧栏宽度可调、窗口尺寸/位置记忆、顶栏与置顶的位置

1 participant