diff --git a/docs/INDEX.md b/docs/INDEX.md index 3d555bc2..92148201 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -12,6 +12,7 @@ - preload 必须保持 CJS(sandbox 限制,见 PITFALLS) - 新增 UI 文案:`i18n/zh.ts` + `en.ts` 双字典都要加 - zustand selector 必须返回稳定引用(模块级空对象/数组;#185 无限渲染,见 PITFALLS) +- **renderer 模块图必须无环**(改完用脚本自查):有环时「模块体在谁的 import 过程中执行」不确定,模块级副作用会读到尚未求值的 store 绑定(曾因此把 `toggleInList` 拆成叶模块 `lib/toggle-in-list.ts`)。纯函数/常量不要挂在会反向 import 回来的模块上 - 新增 renderer hook/store 要暴露给插件 = 源模块 + `plugins/host-api.ts` + `plugins/env.d.ts`(PerchoUiApi)+ `main/ui-plugins/build.ts` SHIM + `resources/percho-ui.d.ts`(必要时 SPEC.md 导出清单)五处同步 - JSON 持久化一律走 backend `JsonStore`(原子写 + 损坏语义),不自写 fs @@ -39,6 +40,7 @@ packages/ | `scripts/verify-vertex-login.mts` | google-vertex 完整登录桥验证:PiBackend.login 交互(select 剔除 api-key)→ ADC credential 落盘 auth.json → configured | | `scripts/repro-full.mjs` | 把 trace 事件序列直接注入 renderer 复现(#185/白屏类问题,手法见 PITFALLS 0.5.0) | | `scripts/shoot-sidebar.mjs` | UI 动画 CDP 逐帧截图模板(左栏 240↔0 开合演示,换场景照抄三步:触发状态 → **按 CSS 参数复现每一步**(关过渡写 inline style;别 pause 动画,见 PITFALLS)→ captureScreenshot) | +| `scripts/check-sidebar-group-scroll.mjs` | 左栏分组会话列表「限高 248px + 组内独立滚动 + 边缘淡出」CDP 验收(spec §7 + 淡出):结构/尺寸契约 + **真实 wheel** 的滚动归属(内层中部/上下边界/标题行/不溢出列表交给外层)+ 淡出状态机(静止在顶只下渐隐 / 中部两面 / 到底只上渐隐 / 不溢出列表无淡出)与「淡出条内仍可命中」;只用 `data-*` 属性与 DOM 相邻关系定位,退出码 0 通过 / 1 断言失败 / 2 环境不满足 | | `scripts/cdp-eval.mjs` / `cdp-shot.mjs` / `shoot-demo-gif.mjs` | CDP 页面单次求值 / 截图 / demo gif | 依赖补丁:`patches/` + patch-package(root devDep + postinstall)。目前一处 `thinking-orbs+0.3.1.patch`(移除 IntersectionObserver+visibilitychange 门控,Win11 恢复事件丢失会冻住 rAF)。改法:手改 `node_modules/thinking-orbs/dist/index.{es.js,cjs}` 两份 → `npx patch-package thinking-orbs`;升级该包前重评补丁是否仍需要。 @@ -151,7 +153,7 @@ src/ | `src/main/ipc/{sessions,settings,packages,app,ui-plugins,lan}.ts` + `invoke.ts` | 各域 handler(`registerInvokeHandlers(子表, {...})` 表驱动注册;ui-plugins 域 handler async await 落盘后才返回;17 非透传 handler 的逻辑体就在各域 map 内) | | `src/main/ui-state.ts` | ui-state.json 读写(JsonStore 原子写;补丁式合并 + normalize 补缺省)。**v10 起没有 tabs.json**:不再持久化「打开列表」 | | `src/main/background.ts` | 背景图选图(dialog → 拷贝 `userData/backgrounds/` 并清理旧图) | -| `src/main/window.ts` | BrowserWindow:sandbox + preload;启动底色跟随主题防白闪(已解析主题经 `?theme=` query 传 renderer);窗口框架按平台分流(mac hiddenInset / Win frameless+titleBarOverlay / Linux 原生);**macOS 关窗 = 隐藏窗口**(`close` 事件 `preventDefault + hide()`,`index.ts` 的 `before-quit` 调 `markQuitting()` 放行 ⌘Q;`activate` 恢复 show+focus);导出 `resolveTheme`/`applyChromeTheme`/`markQuitting` | +| `src/main/window.ts` | BrowserWindow:sandbox + preload;启动底色跟随主题防白闪(已解析主题经 `?theme=` query 传 renderer);窗口框架按平台分流(mac hiddenInset / Win frameless+titleBarOverlay / Linux 原生);**关窗语义按平台**:macOS 红点/⌘W = 隐藏窗口(`close` 事件 `preventDefault + hide()`,`index.ts` 的 `before-quit` 调 `markQuitting()` 放行 ⌘Q;`activate` 恢复 show+focus);Windows 点 ✕ = 退出(微软惯例不变),**仅当渲染端接管了退出确认(`app:setQuitGuard`)时拦下弹确认窗**,并起 1.5s 兑底计时等 `app:quitDialogShown` 回执(超时/未接管/页面已销毁一律放行——见 PITFALLS);`did-start-loading` 时清 guard(重载期不拦);导出 `resolveTheme`/`applyChromeTheme`/`markQuitting`/`setQuitGuard`/`ackQuitDialog` | | `src/main/path-target.ts` | 文件路径归一(纯函数,含 11 例单测):剥成对包裹符与 `:行:列`/`#L12` 锚点 → `file://` 与 `%` 解码 → `~` 展开 → 相对路径按会话 cwd 解析;`resolveExistingPath` 带存在性检查(不存在抛 `Path not found: <绝对路径>`)。消费方 = `app:resolvePath`/`openPath`/`revealPath` 三通道(文件行右键菜单) | | `src/main/ui-plugins/config.ts` | `ui-plugins.json` 读写(normalize 白名单;assignments 指向失效插件保留) | | `src/main/ui-plugins/build.ts` | 插件 esbuild 构建器:`loadEsbuild` 懒加载 + `ESBUILD_BINARY_PATH` 指向 asar.unpacked 真实二进制(打包态坑,升级 esbuild 前重评);react 系四个 specifier 重写到 `window.PerchoUI` shim(**与 renderer host-api.ts、resources/percho-ui.d.ts 逐名一致**);图片/音频资产 dataurl 内联(CSP img-src / media-src 均放行 data:) | @@ -177,18 +179,19 @@ src/ | `api.ts` | `getPi()`:window.pi 类型化访问 | | `lib/thinking.ts` | `THINKING_LEVELS`/`ThinkingLevel` re-export(单一事实源在 shared `src/thinking.ts`)+ `clampThinkingLevel` 就近向上收敛(仅 renderer UI 用;backend 不 import,交 SDK clamp) | | `lib/daily.ts` | 日常空间目录 renderer 缓存:`initDailyDir`(App 启动调一次,幂等)+ `isDailyCwd` 同步判定 + `setDailyDirForTest`;各处共享,不散落路径字符串 | -| `lib/sidebar-groups.ts` | 左栏纯派生层(有单测):`mergeSidebarSessions`(**历史 + 内存会话合并**:按 `sessionId` 去重、内存覆盖历史、历史保序;同 ID 走 `mergeSessionMeta`——运行态字段(name/model/active/messageCount)以内存为准,但 **`createdAt` 始终取历史权威值、内存 `modifiedAt` 缺失时保留历史值**,否则排序键会从 `modifiedAt` 掉到 `createdAt`、行在打开/卸载时伪移动;刚创建还没落盘的会话由此进左栏)+ `primaryNavigationSessions` / `deriveSidebarNavigation`(**左栏生产装配,Sidebar 与单测走同一条路**:合并 → 按 `isPrimaryNavigationSession` 过滤只读子会话 → **同一份集合**同时喂给 `deriveProjects` 与 `deriveSidebarGroups`,否则只读子会话会凭空长出一个项目组)+ `deriveSidebarGroups`(合并结果 + 项目表 → 日常/项目分组、置顶排序、搜索过滤、展开态推断;**默认展开组只看显式 `activeCwd`(新会话页 = draft.cwd),不从 sessions 反查 active**)+ `toggleInList`(置顶切换)+ `toggleExpandedGroup`(第 4 参 `touched` 定起点:没操作过用派生层默认集,已操作过用用户记录——**空数组合法表示全部折叠**)。「项目」小标 v7 起不可折叠,不再有 `PROJECTS_GROUP_KEY` 这类专用 key | +| `lib/sidebar-groups.ts` | 左栏纯派生层(有单测):`mergeSidebarSessions`(**会话目录 + 内存会话合并**:按 `sessionId` 去重、内存覆盖历史、历史保序;同 ID 走 `mergeSessionMeta`——运行态字段(name/model/active/messageCount)以内存为准,但 **`createdAt` 始终取历史权威值、内存 `modifiedAt` 缺失时保留历史值**,否则排序键会从 `modifiedAt` 掉到 `createdAt`、行在打开/卸载时伪移动;**存在性不靠它**:本进程经手过的会话在进内存时就已被写进目录,合并只补「还没落盘的新会话」与运行态)+ `primaryNavigationSessions` / `deriveSidebarNavigation`(**左栏生产装配,Sidebar 与单测走同一条路**:合并 → 按 `isPrimaryNavigationSession` 过滤只读子会话 → **同一份集合**同时喂给 `deriveProjects` 与 `deriveSidebarGroups`,否则只读子会话会凭空长出一个项目组)+ `deriveSidebarGroups`(合并结果 + 项目表 → 日常/项目分组、置顶排序、搜索过滤、展开态推断;**默认展开组只看显式 `activeCwd`(新会话页 = draft.cwd),不从 sessions 反查 active**)+ `toggleExpandedGroup`(第 4 参 `touched` 定起点:没操作过用派生层默认集,已操作过用用户记录——**空数组合法表示全部折叠**)。「项目」小标 v7 起不可折叠,不再有 `PROJECTS_GROUP_KEY` 这类专用 key | +| `lib/toggle-in-list.ts` | 置顶切换纯函数(叶模块,有单测):会话/项目/手动添加项目共用;**必须留在叶模块**,挂到 `sidebar-groups` 上会重新形成 store 环(见硬约束)。`toggleExpandedGroup` 仍在 `sidebar-groups` | | `lib/session-visibility.ts` | 导航可见性判据单一事实源:`isPrimaryNavigationSession(session)` = `readOnly !== true`。只读子会话(tmp subagent 检视)**只从导航投影过滤**——仍留在 `sessions` store(事件/transcript/关闭都靠它),但不进左栏分组/计数/搜索与顶栏胶囊。导航派生与 `selectBarSessions` 共用 | | `lib/session-gc.ts` | 会话内存驻留策略(**纯函数,有单测**):`pickUnloadCandidates`(保护/活跃/刚用过 → 剔除;剩余按最久未用排序,保留 K 个,超的与晾过 `idleTimeoutMs` 的一并作候选)+ `isProtected`(保护条件唯一事实源:**有频道订阅(`hasChannelSubscriptions`,订阅 = 明确驻留语义,卸载会停 watcher)** / 0 消息 / agentActive / 待审批 / 待应答 / 未读绿点 / 压缩中 / 排队跟发;**权限模式曾在清单里,D7 改成按会话持久化+打开时恢复后已移除**)+ `GC_DEFAULTS`(K=3、fresh **3s**、idle 5min)。不 import React / 不调 IPC | | `lib/session-gc-run.ts` | 一轮 GC 的**编排**(依赖全注入,有单测):先拉「有频道订阅的已加载会话 ID」快照 → 映射进 `hasChannelSubscriptions` → 纯策略层算候选 → 逐个再确认(活跃/已关跳过)后 `unload`;**fail-safe:快照拉取失败 = 保护范围未知 → 整轮跳过一个都不卸**(不退化成「让 backend 拒绝一次」,否则每轮每会话一次无效 IPC);后端 `closed=false` 记 `refused` | | `lib/use-session-gc.ts` | 内存策略接线层(薄):构建依赖调 `session-gc-run.ts` 的一轮编排 + 挂载跑一次 + **20s** 兜底 tick(实测:仅靠它时收敛延迟 ≤ 一个 tick) + 只订阅 `sessions`/`activeSessionId`(**不订阅 `transcript.bySession`**,它每 token 都变)+ 轮内不重入;K=3 常量在这里 | | `lib/diagnostics.ts` | `buildDiagnosticsText`(会话诊断纯文本,供「复制诊断信息」用;从 `components/projects/` 移入) | -| `stores/sessions.ts` | 会话列表/当前会话/cwd/模型/权限模式(`permissionModes` map,缺 key = default)。**新会话 = renderer 全局唯一 draft**(`newSessionDraft`:cwd/model/thinkingLevel/permissionMode + 两个**分字段** `modelResolved`/`thinkingResolved` 位 + 内部 `generation`;不进 `sessions`、不落盘、不触后端):`activateNewSessionDraft(cwd?)`(顶栏「+」/启动;已有 draft 只激活、不覆盖配置,**只有从真实会话回到新会话页时领号**——已在 draft 页不领号,否则在途 promotion 结果会被误判成「不是最新意图」)/ `setDraftCwd`(项目选择器;同时 `rememberCwd` + 信任前置)/ `setDraftPermissionMode`(纯 renderer)。**`createSession()` = promotion**(首条消息/斜杠命令):吃 **draft 入口快照**(cwd/model/thinking/permission 同一时点冻结)、single-flight **按 `generation` 隔离**(D1 已消费仍在等权限 IPC 时新建 D2 会另发一次)、成功消费 draft 并把 draft 权限档位应用到新会话、失败保留 draft 与内容(可重试)、迟到结果只落 `sessions` 不抢焦点。**不变式:`activeSessionId === null` ⇒ 必有 draft**(store 初始化即建一份;`closeSession` 关掉最后一个会话时补种;promotion 迟到且此刻在新会话页时补种),且此时 `cwd` **严格镜像 `draft.cwd`(含 null)**。**导航 latest-wins**:用户导航动作先领进程内单调 `activationSeq` 令牌,异步返回时只有仍是最新令牌才写 `activeSessionId/cwd/rememberCwd`(过期的只落数据:meta 进 tabs、bundle 装载);`switchSession`/`activateNewSessionDraft` 同步领号,`openFromHistory`/`createSession`(promotion)/`forkSession` 入口领号,`closeSession` 的 active 回退是关闭副作用**不领号**。**`openFromHistory` 走共享 open pipeline**(`openInFlight` 按规范化 filePath 去重:同文件双击只发一次 IPC、失败只 toast 一次、settle 必清 key;`bundleInFlight` 再按 sessionId 去重 bundle)。`createSession` 失败返回 null——**调用方必须用返回值定位新会话,不能读 `activeSessionId`**(创建期间用户可能已切走);信任前置 ensureProjectTrust(trustVersion 触发重拉);打开(含从历史按需打开)/fork/撤回统一 `loadSessionBundle` 四件套(history→queue→todos→permissionMode 对齐后端);**`selectBarSessions`(v8)算顶栏展示集 = 严格置顶表(meta 从 tabs → `allSessions` 兜底;**只读子会话被防御性过滤**,防脏置顶/手改 ui-state)**;**v10 起不持久化打开列表**——启动纯空(新会话页 = 那份唯一 draft),会话只在你点开时才加载,退出即清(与 pi 原生 `AgentSessionRuntime` 的"单会话按需替换"对齐);切会话/打开/选目录三处调 `rememberCwd` 记住**上次项目目录**(`lastCwd`,只记目录不恢复会话);**`loadSessionBundle` 末尾按 `sessionPermissionModes` 恢复档位(await IPC,保证「打开完即正确档位」)**;**`loadModels`**:模型列表加载后按 draft 的分字段 resolved 位补默认值(用户改过的字段不被覆盖)并按模型能力夹紧档位;**会话内存驻留策略**:`lastUsedAt` LRU 打点 + `unloadSession`(自动卸载专用入口,走 `closeSession(id, intent:"gc")` 让后端区分自动 GC 与用户意图;受保护会话由纯策略层排除),接线在 `lib/use-session-gc.ts`(App 挂一次)。**GC 选择竞态(D5)**:`closeSession` 在 `closed=true` 后、清本地状态前复检 `activeSessionId === sessionId`(仅 intent=`gc`)——命中则只重建 backend 会话(`loadBundle:false`,不重载磁盘历史、不 reset transcript、不领新令牌)、按 renderer 真值拉回权限档位(失败则回落 default + 提示),返回 `{closed:false}`(GC 视为本轮没卸成);reopen 失败才按常规关闭清理 | +| `stores/sessions.ts` | 会话列表/当前会话/cwd/模型/权限模式(`permissionModes` map,缺 key = default)。**新会话 = renderer 全局唯一 draft**(`newSessionDraft`:cwd/model/thinkingLevel/permissionMode + 两个**分字段** `modelResolved`/`thinkingResolved` 位 + 内部 `generation`;不进 `sessions`、不落盘、不触后端):`activateNewSessionDraft(cwd?)`(顶栏「+」/左栏顶部/启动;从真实会话进入时按当前会话 cwd/model/thinking/permission 重建 draft 配置但保留全局输入内容,已在 draft 页时只激活、不覆盖配置)/ `activateNewSessionDraftForCwd(cwd)`(左栏项目/日常行:复用全局 draft、保留内容与模型等配置但明确改投 cwd;换 generation + 领导航号,旧 promotion 迟到不覆盖)/ `setDraftCwd`(项目选择器;同时 `rememberCwd` + 信任前置)/ `setDraftPermissionMode`(纯 renderer)。**`createSession()` = promotion**(首条消息/斜杠命令):吃 **draft 入口快照**(cwd/model/thinking/permission 同一时点冻结)、single-flight **按 `generation` 隔离**(D1 已消费仍在等权限 IPC 时新建 D2 会另发一次)、成功消费 draft 并把 draft 权限档位应用到新会话、失败保留 draft 与内容(可重试)、迟到结果只落 `sessions` 不抢焦点。**不变式:`activeSessionId === null` ⇒ 必有 draft**(store 初始化即建一份;`closeSession` 关掉最后一个会话时补种;promotion 迟到且此刻在新会话页时补种),且此时 `cwd` **严格镜像 `draft.cwd`(含 null)**。**导航 latest-wins**:用户导航动作先领进程内单调 `activationSeq` 令牌,异步返回时只有仍是最新令牌才写 `activeSessionId/cwd/rememberCwd`(过期的只落数据:meta 进 tabs、bundle 装载);`switchSession`/`activateNewSessionDraft` 同步领号,`openFromHistory`/`createSession`(promotion)/`forkSession` 入口领号,`closeSession` 的 active 回退是关闭副作用**不领号**。**`openFromHistory` 走共享 open pipeline**(`openInFlight` 按规范化 filePath 去重:同文件双击只发一次 IPC、失败只 toast 一次、settle 必清 key;`bundleInFlight` 再按 sessionId 去重 bundle)。`createSession` 失败返回 null——**调用方必须用返回值定位新会话,不能读 `activeSessionId`**(创建期间用户可能已切走);信任前置 ensureProjectTrust(trustVersion 触发重拉);打开(含从历史按需打开)/fork/撤回统一 `loadSessionBundle` 四件套(history→queue→todos→permissionMode 对齐后端);**`selectBarSessions`(v8)算顶栏展示集 = 严格置顶表(meta 从 tabs → `allSessions` 兜底;**只读子会话被防御性过滤**,防脏置顶/手改 ui-state)**;**v10 起不持久化打开列表**——启动纯空(新会话页 = 那份唯一 draft),会话只在你点开时才加载,退出即清(与 pi 原生 `AgentSessionRuntime` 的"单会话按需替换"对齐);切会话/打开/选目录三处调 `rememberCwd` 记住**上次项目目录**(`lastCwd`,只记目录不恢复会话);**`loadSessionBundle` 末尾按 `sessionPermissionModes` 恢复档位(await IPC,保证「打开完即正确档位」)**;**`loadModels`**:模型列表加载后按 draft 的分字段 resolved 位补默认值(用户改过的字段不被覆盖)并按模型能力夹紧档位;**会话内存驻留策略**:`lastUsedAt` LRU 打点 + `unloadSession`(自动卸载专用入口,走 `closeSession(id, intent:"gc")` 让后端区分自动 GC 与用户意图;受保护会话由纯策略层排除),接线在 `lib/use-session-gc.ts`(App 挂一次)。**GC 选择竞态(D5)**:`closeSession` 在 `closed=true` 后、清本地状态前复检 `activeSessionId === sessionId`(仅 intent=`gc`)——命中则只重建 backend 会话(`loadBundle:false`,不重载磁盘历史、不 reset transcript、不领新令牌)、按 renderer 真值拉回权限档位(失败则回落 default + 提示),返回 `{closed:false}`(GC 视为本轮没卸成);reopen 失败才按常规关闭清理 | | `stores/transcript.ts` | re-export shared reducer + per-session 字段(agentActive/unseenCompletion/todos);reducer 细节见 shared `src/transcript/` | | `stores/event-conflator.ts` | 流式事件合流:纯追加型 delta 按会话/类型拼接 + rAF 每帧最多一次 flush,其余事件边界透传保序(可注入调度器,有测试) | | `stores/drafts.ts` | 输入内容(文本/图片/slash 胶囊/@ 引用 attachments/选中引用 quotes)按会话持久:真实会话用 sessionId、**新会话页固定用 `NEW_SESSION_DRAFT_KEY`(`__new__`,全 renderer 唯一一份,不持久化)** + `COMPOSER_FOCUS_EVENT`(撤回回填/点「+」后聚焦输入框) | | `stores/settings.ts` / `catalog.ts` / `provider-login.ts` | 设置域(providers + 上下文管理/channel-watch 开关,乐观更新回滚;permissionGateOff = 手改 permissions.json 卸载门控的逃生舱态只读感知)/ 社区包目录(300ms 防抖 + seq 防陈旧)/ OAuth 登录状态机(**取消时机 = LoginDialog 卸载 cleanup**;先订阅事件再 invoke) | -| `stores/projects.ts` / `theme.ts` / `ui.ts` / `ui-preferences.ts` / `update.ts` / `ui-plugins.ts` / `toasts.ts` | 会话历史与项目表(`load` 有 **latest-wins** 单调 `loadSeq` 守卫:更晚的 load 一旦发起,旧请求的成功/失败都不写 `allSessions/loading/loaded`;`deriveProjects`:手动添加的按时间倒排;`deriveSessions`:置顶分区在左 + 其余按最后活动倒序;`deleteSession` / `deleteProject` / `openSession`(已加载会话走 switchSession,否则 openFromHistory)/ `applySessionName`)/ 主题与背景(init 在 render 前 await 防闪烁)/ todo 面板展开 + diff 侧栏开关(内存态)/ **左栏偏好 `barSessionsVisible` / `sidebarCollapsed` / `expandedGroups` + `expandedGroupsTouched`(成对语义:false = 从没手动开合过 → 走默认推断,true = 完全以 `expandedGroups` 为准,**空数组 = 全部折叠**;旧文件缺字段时按记录是否非空推断)/ `pinnedProjects` + 中央动画开关 + 置顶会话 `pinnedSessions`(v8 起它同时就是顶栏胶囊内容与顺序,拖动排序走 `reorderPinned`)+ **`sessionPermissionModes`(D7:按会话记住的权限模式,只存非 default;`rememberPermissionMode` / `forgetPermissionMode`)** + 上次项目目录 `lastCwd`(App 启动预填 cwd,只带目录不恢复会话)**(均持久化 ui-state;删会话时 unpin 清理)/ 更新态 / UI 插件面板 / 全局 Toast(顶栏右侧,非阻塞自动消失;扩展 notify 走 pushExtension:同源同文 8s 去重 + 可见栈 3 溢出折叠) | +| `stores/projects.ts` / `theme.ts` / `ui.ts` / `ui-preferences.ts` / `update.ts` / `ui-plugins.ts` / `toasts.ts` / `app-quit.ts` | 会话目录与项目表(**`allSessions` = 会话目录 = 上次全量对账的磁盘历史 ∪ 本进程经手过的会话**,是「左栏/顶栏行存在不存在」的**唯一来源**:`load` 只在进新会话页/启动时整表对账,有 **latest-wins** 单调 `loadSeq` 守卫(更晚的 load 一旦发起,旧请求的成功/失败都不写 `allSessions/loading/loaded`);**写穿 `absorbOpenSessions`**:`useSessionsStore` 订阅(会话一进内存就补进目录,只补缺不覆盖)+ `load` 结尾再补一次(0 消息会话还没有会话文件,磁盘快照必然没有它)——内存策略(GC)卸载只撤运行态,行不会消失(2026-09-21 事故修复);`deriveProjects`:手动添加的按时间倒排;`deriveSessions`:置顶分区在左 + 其余按最后活动倒序;`deleteSession` / `deleteProject` / `openSession`(已加载会话走 switchSession,否则 openFromHistory)/ `applySessionName`)/ 主题与背景(init 在 render 前 await 防闪烁)/ todo 面板展开 + diff 侧栏开关(内存态)/ **左栏偏好 `barSessionsVisible` / `sidebarCollapsed` / `expandedGroups` + `expandedGroupsTouched`(成对语义:false = 从没手动开合过 → 走默认推断,true = 完全以 `expandedGroups` 为准,**空数组 = 全部折叠**;旧文件缺字段时按记录是否非空推断)/ `pinnedProjects` + 中央动画开关 + 置顶会话 `pinnedSessions`(v8 起它同时就是顶栏胶囊内容与顺序,拖动排序走 `reorderPinned`)+ **`sessionPermissionModes`(D7:按会话记住的权限模式,只存非 default;`rememberPermissionMode` / `forgetPermissionMode`)** + 上次项目目录 `lastCwd`(App 启动预填 cwd,只带目录不恢复会话)**(均持久化 ui-state;删会话时 unpin 清理)/ 更新态 / UI 插件面板 / 全局 Toast(顶栏右侧,非阻塞自动消失;扩展 notify 走 pushExtension:同源同文 8s 去重 + 可见栈 3 溢出折叠) | | `hooks/` | `use-context-usage`(上下文用量,事件驱动刷新)/ `use-language` / `use-session-state`(useSessionReadOnly/useSessionBusy 收敛)/ `use-session-event-bridge`(App 事件桥装配层专用) | | `plugins/` | UI 插件运行时:`slots.ts`(槽位名+props 契约单一来源)/ `registry.ts`(zustand:overrides + contributions 堆叠 + headless activate/cleanups + 崩溃计数/loadNonces)/ `Slot.tsx`(总开关门控 + PluginBoundary 包裹)/ `RegionHost.tsx`(区域挂载点,容器语义)/ `PluginBoundary.tsx`(class 错误边界,崩溃回退)/ `host-api.ts`(`window.PerchoUI` 挂载,main.tsx render 前 import)/ `loader.ts`(initUiPlugins/reloadAll/computeAssignedSlots) | | `i18n/` | zh/en 字典 + `useT()`(文案改这里,双字典) | @@ -203,14 +206,15 @@ src/ | `composer/` | **Composer**(装配层 ~330 行,键盘事件分发)+ 三 hook:`use-composer-send`(ensureSession 懒创建/followUp 排队/停止先 clearQueue/发送失败草稿回填)、`use-slash-menu`(命令拉取+胶囊回填+导航)、`use-at-completion`(@ token 探测/续钻/胶囊弹回)+ 展示件 QueueBar/ImageTray/AttachmentChip/**QuoteChip**(选中引用胶囊)/SendErrorBar + **ModelPicker** / **ThinkingPicker**(按模型 thinkingLevels 过滤+clamp)/ **PermissionPicker**(会话权限模式 chip:默认/完全访问)/ **SlashMenu** / **AtMenu** / **ContextRing** + 纯函数 `slash-filter`/`at-files`/`send-error`/`quote`/`model-filter`(各有测试) | | `session/` | **SessionTabBar** + SessionTab/TabPill(**v8:顶栏胶囊 = 置顶会话(不看 tab 开没开),严格只含置顶**,顺序就是 `pinnedSessions`;未置顶会话只在左栏;空态一行 12px `ink-faint` 提示;状态收拢到头像图标;dnd-kit 拖拽排序(改的也是 `pinnedSessions`),DragOverlay ghost + 轴锁定;拖拽期间退出 drag-region;**最左 = 左栏开合按钮 `PanelLeftIcon`**;胶囊右键 → `ui/ContextMenu`「重命名 / 置顶」;叉叉 = 取消置顶 + 从顶栏清除)/ **RenamePopover**(重命名浮层:锚在胶囊/指针锚点,160ms 进场 / 120ms 退场 + Enter/点外提交/Esc 取消)/ **SessionAvatar** + `session-status`(头像底色语义 + 状态/标题纯逻辑,顶栏与左栏共用;`SessionAvatar` 已无引用,留作备用)/ `session-menu`(菜单可用性与动作:`sidebarMenuKind` 定形态(普通 / 无菜单)、只读会话不给菜单、置顶只动 `pinnedSessions`、重命名落盘(**同步 tabs 与历史列表两份拷贝**)+失败 toast、复制诊断、删除会话;顶栏胶囊与左栏会话行共用)/ **ProjectBranchPicker**(从 `projects/` 移入的项目/分支选择器 chip;**只在无 active(新会话页)显示**,真实会话的项目创建时已绑定不可改)/ **DockSlot**(底部交换槽仲裁:权限 > 扩展对话框 > Composer)/ **ApprovalDock**(权限审批:async respond 成功才移除面板,失败保留重试)/ **InteractionDock**(扩展对话框四卡 select/input/editor/confirm:键盘 ↑↓/Enter/数字/Esc + 倒计时显示,裁决在 backend)/ **TrustDialog**(项目信任两选项)/ **UpdateButton**(顶栏更新按钮) | | `settings/` | **SettingsDialog**(PANELS 注册表;分类 = 静态 + 插件 settings.panel 贡献动态拼接)/ **GeneralPanel**(语言/上下文管理二态/channel-watch 开关)/ **AppearancePanel**(顶部 Tab 分栏:「基础」=主题三段/背景图/**显示顶栏开关**/中央动画,「UI 插件」= 原独立分类并入的 UiPluginsSection:总开关/插件卡/槽位指派,设计稿 .local/design/ux/appearance-ui-plugins)/ **SkillsPanel** / **McpPanel**(SDK 0.84 无 MCP,占位)/ **ExtensionsPanel** + `extensions/`(目录浏览/安装/卸载,subagent 包安装两段式确认)/ **UiPluginsSection**(挂在 AppearancePanel 的 UI 插件 Tab 下) / **LanObserverPanel** / **AboutPanel** / `providers/`(ProvidersPanel + ProviderRow 操作全图标化 / LoginDialog(交互登录对话框:OAuth + api_key 交互流)/ CustomProviderForm + ModelRowsEditor(逐模型行编辑器)/ BuiltinProviderEditForm(内置端点覆写)/ SubagentPanel(子代理执行器开关 + 逐代理模型/思考深度偏好)+ `model-rows` 纯函数) | -| `sidebar/` | **Sidebar**(常驻左栏容器:取 store 数据(历史 + 内存会话 + `activeCwd = store.cwd`)→ 调 `lib/sidebar-groups` 的 **`deriveSidebarNavigation`** 一条龙派生(含只读子会话过滤与项目表同源)→ 分发 props,240↔0 push 过渡 + 收起时 `inert`)/ **SidebarHeader**(只有搜索框——v9 顶栏常驻,不再需要「顶栏关掉时补拖拽带 + +」)/ **SidebarGroup**(日常与各项目共用的分组骨架:分组行 + 可折叠会话列表 + 空态)/ **ProjectRow**(图标表达展开态:项目 = folder ↔ folder-open、日常 = 恒咖啡 + 行末 hover 箭头;hover 出 «⋯»(锚在**指针点**)、置顶标记、移除项目二次确认)/ **SessionRow**(无头像文字 + 行尾状态点 + 右键菜单锚点;行上带 `data-session-id` / `data-session-active` 只读属性供 CDP 验收定位与判定,不影响视觉)/ **SessionMenu**(会话行右键三层状态机:菜单 → 改名气泡 → 删除确认;只读子会话不弹菜单)/ **ProjectSection**(「项目」小标:**v7 起固定标题不可折叠** + 常显「+」)/ **SidebarFooter**(设置;顶栏关闭时补「本轮改动」)/ **ProjectMenu**(项目 «⋯» 菜单项 builder)/ `useExpandedGroups`(展开态读写:起点由 `expandedGroupsTouched` 决定;写入时 store 同一次 set + 单个补丁置 touched) | -| `ui/` | Button(ghost/primary × sm/md × danger)/ Dropdown / Switch(统一受控开关,支持 indeterminate)/ **Tooltip**(自定义悬浮提示,新增提示一律用它不用原生 title;右缘元素传 `align="end"` 防幻影横向滚动条)/ **ContextMenu**(通用右键菜单:portal 到 body + `place-menu` 纯函数定位,点外/Esc/选中/滚动关闭;有边框卡片外观(`rounded-xl border border-border bg-surface shadow-pop`),项高 30px、支持 `danger` 红项与 `separatorBefore` 分隔线,不再有 veil 变体;会话胶囊 / 文件行 / 左栏会话行共用,文件行三项菜单见 **FilePathMenu**)/ `place-menu`(`placeMenu` 定位 + `anchorOfElement` 锚点转换,纯函数有测试;**左栏项目 «⋯» 与右键菜单一律传指针锚点** `{ left: clientX, top: clientY, width: 0, height: 0 }` —— 传元素矩形会把高度多算一遍,菜单掉到行下方)/ **ConfirmDialog**(破坏性操作二次确认:极淡蒙层 `bg-ink/6` + 392px 细边框卡片 + Portal 到 body 且 `z-[60]` 压过右键菜单;点卡片外/Esc/✕ = 取消,文案与按钮由调用方给) | +| `sidebar/` | **Sidebar**(常驻左栏容器:取 store 数据(历史 + 内存会话 + `activeCwd = store.cwd`)→ 调 `lib/sidebar-groups` 的 **`deriveSidebarNavigation`** 一条龙派生(含只读子会话过滤与项目表同源)→ 分发 props,240↔0 push 过渡 + 收起时 `inert`;主体是唯一外层滚动容器,带 `data-sidebar-scroll-root` 供 CDP 验收定位)/ **SidebarHeader**(滚动区外常驻的新会话入口 + 搜索框;新会话复用全局 draft)/ **SidebarGroup**(日常与各项目共用的分组骨架:分组行 + 可折叠的 `SidebarSessionList` + 空态,并把项目 cwd 接到定向 draft 入口;**标题行渲染在列表容器之外**,所以标题上的滚轮归外层导航)/ **SidebarSessionList**(会话列表单一实现:`SIDEBAR_SESSION_LIST_MAX_ROWS=8` × `SIDEBAR_SESSION_ROW_HEIGHT=31px` = 248px 上限 + `overflow-y-auto/overflow-x-hidden` + 隐藏滚动条;**仅在实际溢出(> 8 行)时挂 `overscroll-y-contain`** —— 给不溢出的滚动容器挂 contain 会吞掉 wheel、外层不滚,故 contain 与 `data-scrollable` 共用同一个布尔判据;**边缘淡出**:滚动位置写成 `data-fade-top/bottom`,`globals.css` 的 `.sidebar-session-list` 按这两个属性给 `mask-image` 渐变(alpha 遮罩、不引颜色,深浅主题共用;高度旋钮 = 变量 `--session-list-fade`),不溢出的列表两面都干净;淡出属性由 effect 独占、**不写进 JSX**(否则 React 重渲染会把滚动中的值冲回初值);纯 props 驱动,不读 store/不排序)/ **ProjectRow**(图标表达展开态:项目 = folder ↔ folder-open、日常 = 恒咖啡;行末 hover 出 Codex 式 «⋯» + 新会话按钮(日常仅新会话),菜单锚在**指针点**,另含置顶标记、移除项目二次确认)/ **SessionRow**(无头像文字 + 行尾状态点 + 右键菜单锚点;行上带 `data-session-id` / `data-session-active` 只读属性供 CDP 验收定位与判定,不影响视觉)/ **SessionMenu**(会话行右键三层状态机:菜单 → 改名气泡 → 删除确认;只读子会话不弹菜单)/ **ProjectSection**(「项目」小标:**v7 起固定标题不可折叠** + 常显「+」)/ **SidebarFooter**(设置;顶栏关闭时补「本轮改动」)/ **ProjectMenu**(项目 «⋯» 菜单项 builder)/ `useExpandedGroups`(展开态读写:起点由 `expandedGroupsTouched` 决定;写入时 store 同一次 set + 单个补丁置 touched) | +| `ui/` | Button(ghost/primary × sm/md × danger)/ Dropdown / Switch(统一受控开关,支持 indeterminate)/ **Tooltip**(自定义悬浮提示,新增提示一律用它不用原生 title;右缘元素传 `align="end"` 防幻影横向滚动条)/ **ContextMenu**(通用右键菜单:portal 到 body + `place-menu` 纯函数定位,点外/Esc/选中/滚动关闭;有边框卡片外观(`rounded-xl border border-border bg-surface shadow-pop`),项高 30px、支持 `danger` 红项与 `separatorBefore` 分隔线,不再有 veil 变体;会话胶囊 / 文件行 / 左栏会话行共用,文件行三项菜单见 **FilePathMenu**)/ `place-menu`(`placeMenu` 定位 + `anchorOfElement` 锚点转换,纯函数有测试;**左栏项目 «⋯» 与右键菜单一律传指针锚点** `{ left: clientX, top: clientY, width: 0, height: 0 }` —— 传元素矩形会把高度多算一遍,菜单掉到行下方)/ **ConfirmDialog**(破坏性操作二次确认:极淡蒙层 `bg-ink/6` + 392px 细边框卡片 + Portal 到 body 且 `z-[60]` 压过右键菜单;点卡片外/Esc/✕ = 取消,文案与按钮由调用方给;`autoFocusConfirm` 把默认焦点给确认按钮(不传则是键盘 Enter 不确认);非破坏性场景别默默加上)/ **QuitConfirmDialog**(Windows 退出前确认,文案走 i18n `quitConfirm.*`,并回 `app:quitDialogShown` 上屏回执) | | `icons/` | 内联 SVG 集中管理;`icons.test.ts` 校验所有 path `d` 语法(防残缺数据被 Chromium 丢弃) | ## 「想改 X 改哪里」速查 | 想改什么 | 改哪 | |---|---| +| 关窗 / 退出行为(mac 隐藏、Win 退出前确认) | `main/window.ts`(`close` 分支:darwin `hide()`、win32 拦下 + 等 `app:quitDialogShown` 回执,**1.5s 无回执 = 渲染进程卡死 → 放行退出**)+ `main/index.ts`(`before-quit` 置 `markQuitting` 放行程序化退出);renderer `stores/app-quit.ts`(挂载时置 guard + 订阅事件)+ `components/ui/QuitConfirmDialog.tsx`(挂在 AppErrorBoundary **外面**);设计稿 `.local/design/ux/quit-confirm/` | | 开屏动画(粒子光团 → 散场) | `renderer/src/styles/splash.css`(全部视觉与编排)+ `splash-dom.ts`(DOM/粒子参数)+ `splash.ts`(时长/单次标记);首帧主题链 = bootstrap-theme.ts + window.ts + main/index.ts(`?theme=` 传参) | | 消息气泡 / 代码块 / mermaid 图 / 高亮 | `components/chat/`(Markdown 样式覆写在 globals.css `.markdown-body`;mermaid 卡在 `chat/MermaidBlock.tsx` + globals.css 末尾两段 `mermaid` 段) | | 首页大字 logo(手稿构造字标) | `chat/WordmarkConstruct.tsx`(空心描边 + 制图构造线静态 SVG,canvas 度量字形墨盒后离线固化;改设计重新烘焙)+ globals.css `--lg-ol/c1/c2/lb`(深浅各一套);设计稿 `.local/design/pi-logo/wordmark.html`(H2 手稿构造) | @@ -230,7 +234,7 @@ src/ | 上下文用量圆环 | `composer/ContextRing.tsx` + `hooks/use-context-usage.ts`(事件驱动刷新,与插件 host API 共用)→ IPC getContextUsage → SDK `session.getContextUsage()`(percent null 或无消息不渲染;<60% 灰 / 60-85% 琥珀 / >85% 红) | | 每轮计时行 + 文件变更 chip + diff 侧栏 | 计时 shared `transcript/turn-timings.ts`(deriveTurnTimings;分量 = reducer 盖戳的 `UIToolCall.endedAt` + `runEndedAt` 定格 + 历史回放透传 toolResult timestamp);变更 shared `transcript/turn-files.ts`(deriveTurnChanges)+ `chat-rows.ts`(行定位:每轮必有计时行,lan-web 不传 timings 保持旧行为);渲染 `chat/TurnDiffChip.tsx`(timer 恒在首位)+ `diff/DiffSidebar.tsx`(含 BranchRow);开关 = SessionTabBar 的 DiffIcon 按钮 + `stores/ui.ts` diffSidebarOpen | | 顶栏 tab / 拖拽排序 / 置顶 | `session/SessionTabBar.tsx`(dnd-kit,DragOverlay ghost 拾起时实测宽度沿用原胶囊 + 轴锁定 + drag-region 退出)+ `stores/sessions.ts` `selectBarSessions`(**顶栏严格 = 置顶表**:会话被置顶就算 tab 没开也在顶栏,点击自动开;新会话不进顶栏)+ `stores/ui-preferences.ts` `reorderPinned`(拖拽改 `pinnedSessions` 顺序) | -| 左侧栏(分组 / 开合 / 置顶 / 搜索 / 移除项目 / 会话右键菜单) | 组件 `components/sidebar/*`(容器 → `Sidebar`;纯派生 → `lib/sidebar-groups.ts`,有单测)+ 持久化字段 `barSessionsVisible` / `sidebarCollapsed` / `expandedGroups` + `expandedGroupsTouched` / `pinnedProjects`(`ui-preferences.ts` → main `ui-state.ts` normalize + 旧文件缺字段时的 touched 迁移)+ 宽度过渡与状态点呼吸 `styles/globals.css` 的 `.sidebar*` 段;置顶会话的行首图钉在 `sidebar/SessionRow.tsx`(行首 16×16 图标槽,标题 x 恒 38);逐帧截图 `scripts/shoot-sidebar.mjs` | +| 左侧栏(分组 / 开合 / 置顶 / 搜索 / 移除项目 / 会话右键菜单 / 组内限高滚动) | 组件 `components/sidebar/*`(容器 → `Sidebar`;分组会话列表 → **`SidebarSessionList`**:8 行 × 31px = 248px 上限 + 原生嵌套滚动,`overscroll-y-contain` 只在溢出时挂,规则见 `docs/PITFALLS.md`「contain 吞 wheel」;纯派生 → `lib/sidebar-groups.ts`,有单测)+ 持久化字段 `barSessionsVisible` / `sidebarCollapsed` / `expandedGroups` + `expandedGroupsTouched` / `pinnedProjects`(`ui-preferences.ts` → main `ui-state.ts` normalize + 旧文件缺字段时的 touched 迁移)+ 宽度过渡与状态点呼吸 `styles/globals.css` 的 `.sidebar*` 段;置顶会话的行首图钉在 `sidebar/SessionRow.tsx`(行首 16×16 图标槽,标题 x 恒 38);验收/截图 `scripts/check-sidebar-group-scroll.mjs`(真实 wheel 滚动归属)、`scripts/shoot-sidebar.mjs`(逐帧) | | 顶栏内容 / 显隐 | 顶栏**常驻**(窗口拖动、左栏开合、变更侧栏入口都在上面,v9 起不再整条隐藏)→ `App.tsx` 无条件渲染 `SessionTabBar`;设置项「顶栏显示会话」(`settings/AppearancePanel.tsx` → `barSessionsVisible`)只控制**要不要出置顶会话胶囊**:关掉后顶栏不出胶囊、也不出空态提示,会话全在左栏(v9 已删旧版的三处兜底:左栏头 44px 拖拽带 + 「+」、聊天列顶 12px 隐形拖拽带、左栏底部「本轮改动」入口) | | 右侧变更浮层 | `components/diff/DiffSidebar.tsx` + `globals.css` 的 `.diff-sidebar`(固定 `min(420px, 38vw)`,transform 进退,不压缩聊天列);开关在顶栏(顶栏关闭时在左栏底部),关闭入口 = 开关 / 面板关闭按钮 / Esc | | 权限审批面板 | `backend/src/permissions/gate.ts`(队列)+ `session/ApprovalDock.tsx`(快捷键 Enter/A/D/Esc;await 成功才移除) | diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index 1deb317d..1e5d8c41 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -36,6 +36,7 @@ | 长会话里上滚,位置被反复重置/拽回底部(0.5.7 线上 bug) | 四 · 长会话切会话卡顿 → 二次修复(markstream 占位条缩水 + 手写滚动补偿)(2026-09-13 修复) | | 改了 `src/main/` 但 app 行为没变(dev 不重建主进程) | 三 · electron-vite dev 主进程 watcher 不可依赖(2026-09-17) | | 关窗后 renderer 还活着、`visibilityState` 仍是 visible;用 `window.close()` 测不出关窗拦截 | 四 · macOS 关窗 = 隐藏窗口(2026-09-17) | +| 拦下关窗后窗口再也关不掉;渲染进程死循环也收不到 `unresponsive` 事件 | 四 · 关窗拦截必须留「渲染进程卡死」兑底(2026-09-21) | | 浮层/菜单退场闪回(节点被提前卸载)、二级浮层输入框没聚焦 | 四 · 浮层退场时序与焦点接管(2026-09-17) | | 右键菜单贴边溢出视口、滚动后浮层脱锚 | 四 · 右键菜单定位与脱锚(2026-09-17) | | Google Vertex 填了 key 仍 401「API keys are not supported by this API」 | 二 · Vertex 只支持 ADC/服务账号(api_key 路径必败,桥接层已剔除 api-key 选项) | @@ -49,6 +50,9 @@ | 左栏有图钉、顶栏却没有胶囊(「置顶了但不显示」) | 四 · 顶栏内容要由置顶表驱动,别从 tabs 里筛(2026-09-19) | | 后端日志出现 `context-evaporation` / stale ctx 报错 | 二 · 删除正在跑的会话会留 stale ctx(既有现象,2026-09-20 记录) | | hover 才现的控件刚截完图就点不到、点击静默落空 | 四 · 鼠标事件 + `:hover` → 补「截图会清掉 hover」(2026-09-19) | +| 某个区域内滚轮完全失灵(内层没内容、外层也不滚);给不溢出的滚动容器挂了 `overscroll-behavior: contain` | 四 · 嵌套滚动的归属验证 + contain 吞 wheel(2026-09-21) | +| CDP wheel 验证“滚动该归谁”时假失败/假绿(落点被浮层盖住、或拿赋 `scrollTop` 冒充滚动) | 四 · 同章节「验证滚动归属的三条纪律」(2026-09-21) | +| 命令式写的 DOM 属性过一会儿变回旧值/初值(React 重渲染冲掉) | 四 · 别手拆 React 管理的 DOM → 补「命令式改 JSX 已声明属性」(2026-09-21) | | 改完自定义 hook 后整页报「Rendered fewer hooks than expected」 | 四 · HMR 改 hook 数量会假报错(2026-09-19) | | 清理 dev 进程后端口还占着、CDP 连上但页面全空 | 五 · `pkill -f` 杀 Electron 会留下孤儿 main(2026-09-19) | | 跨会话频道订阅后,会话被卸载/关闭期间的消息永久丢失(或反过来重复提醒) | 二 · 长生命周期订阅不能挂在可被自动 GC 的会话上(2026-09-20) | @@ -62,6 +66,9 @@ | 快速连点两行,界面停在先点的那一行(或过一会才被抢回) | 四 · 异步导航必须 latest-wins(令牌 + 共享 open pipeline)(2026-09-20) | | 同一会话文件并发 open 后订阅/扩展/trace 翻倍、旧实例泄漏 | 二 · openSession 幂等:registry 短路 + single-flight + add 不静默覆盖(2026-09-20) | | 复制/恢复过会话文件后,它在列表里的时间/位置全变了 | 二 · 同章节「birthtime 不是会话创建时间」 | +| 新建的会话过一阵突然从左侧栏消失(点「+」/重启后又回来) | 四 · 会话目录写穿:行存不存在不能依赖内存(2026-09-21) | +| 给 store 加模块级订阅后,某些入口报 `Cannot read properties of undefined (reading 'subscribe')` | 四 · 同章节「renderer 模块图不许有环」(2026-09-21) | +| 反复被 GC 卸载的已置顶会话,顶栏胶囊也一起消失了 | 四 · 同章节「写穿」:胶囊与左栏同源(tabs → 目录兜底) | ## 一、事故复盘(含可复用诊断手法) @@ -238,6 +245,28 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b 1. **`Page.captureScreenshot` 会把 hover 状态清掉**:截完图 `:hover` 链变空、目标元素的 `pointer-events` 回落 `none`(截图前读到的 `auto` 不再成立)。于是「hover → 截图 → 接着点它」的顺序会**静默落空**(点击落在 `pointer-events: none` 上,不报错也不生效)。 2. **对同一坐标的 `mouseMoved` 不会重算 hover**:截图后想恢复 hover,直接再发一次相同坐标无效 —— 必须**先挪开一点(如 −60px)再挪回来**。 3. `mousePressed` 与 `mouseReleased` 之间**贴太紧偶发不合成 `click`**,验证点击行为时中间留 ~70ms 更稳。 +4. **`mouseWheel` 的落点必须真的在目标容器上,且不能被刚弹出的浮层盖住**:右键菜单挂在指针处,紧接着朝“列表中心”派 wheel 很可能落在**菜单**上(菜单不可滚)→ 容器`scrollTop` 纹丝不动,看起来像“滚动了但菜单没关”的假失败。做法:先算出浮层矩形,再在目标容器里挑一个不被遮挡的点,并**同时断言容器 `scrollTop` 真的变了**(否则这条断言本来就不能判定)。实例:`scripts/check-sidebar-group-scroll.mjs` 的“滚动后菜单关闭”那一步。 + +### 嵌套滚动的归属验证 + `overscroll-behavior: contain` 会吞掉滚轮(2026-09-21,左栏分组列表限高) + +症状:给一个**不溢出**的滚动容器(`overflow-y:auto` 但 `scrollHeight === clientHeight`)挂 `overscroll-behavior-y: contain` 后,指针停在该区域时**滚轮完全失灵**——该容器滚不动(没内容),**外层祖先也不滚**(contain 把滚动链剪断了)。用户观感:会话列表里“滚不动”,而旁边的项目标题区一切正常。 + +实测(Electron dev 真实页 + CDP 真实 wheel,探针四组对照): + +| 元素 | 内容 | `overscroll-behavior-y` | 在内层 wheel | 结论 | +|---|---|---|---|---| +| A | 溢出 | contain | 内层滚 | 正常 | +| B | 溢出,已到底 | contain | 内层不动、**外层也不动** | 这就是我们要的不穿透 | +| E | **不溢出** | **contain** | **内外都不动(wheel 被吞)** | 坑 | +| F | 不溢出 | auto | 外层滚 | 对照,证明锅在 contain | + +做法:**`contain` 只在“确实会溢出”时才挂,且与该状态标记(如 `data-scrollable`)用同一个布尔值驱动**,别写两套判据(否则探针与真实行为会分叉)。溢出时需要 contain(边界不穿透),不溢出时必须让它为 `auto`(滚轮交给外层)。实测落地见 `components/sidebar/SidebarSessionList.tsx`。 + +**验证滚动归属的三条纪律**(都真踩过): + +1. **不能拿「直接赋 `scrollTop`」冒充滚动**:它绕过滚动链,结论必然假绿。真实 wheel 用 `Input.dispatchMouseEvent({ type: "mouseWheel", deltaY })`(先 `mouseMoved` 到目标上——命中区决定滚动链)。赋 `scrollTop` 只用来把容器**预置**到中部/底部。 +2. **落点要落在外层可视区内且不被浮层遮挡**(见上一条鼠标纪律 4)。 +3. **读数前等滚动落定**:轮播/平滑滚动是异步的,等“连续两次读数相同”再断言(别固定 sleep 猜时间)。 ### CDP 驱动 Electron dev 应用的能力边界(2026-09-19,左栏任务实测) @@ -261,6 +290,8 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b 做法:**只走组件自己的关闭路径**(`window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))`、或派发 `pointerdown` 到 body 让点外关闭生效)。同理:React portal(右键菜单 / 确认弹窗 / toast)里的节点一律不手动增删;要重置页面直接 `Page.reload`。 +**2026-09-21 补(反方向的同类坑:命令式改 JSX 已声明的属性)**:想在滚动中把状态写成 DOM 属性时,如果那属性**在 JSX 里声明过**(如 `data-fade-top={...}`),再用 `el.dataset.fadeTop = ...` 命令改,**React 下一次重渲染会把它冲回声明值**,而你的 effect 依赖没变、不会重跑——于是属性停在错误的旧值上(或初值上),肉眼与脚本都难归因。两条出路:① 属性**完全不在 JSX 出现**,由 effect 独占增删(`delete el.dataset.x` 清干净)——本次采用;② 属性交给 React 独占,resize/scroll 推进 state(会逐次 re-render,列表类组件别选)。注意:React 不会动它不认识的属性,所以“在 JSX 里没声明”的写法是安全的。 + ### 删除正在跑的会话会在后端留 stale ctx 报错(2026-09-20,既有现象) 症状:对一个**正在跑 agent** 的会话执行「删除会话」,后端日志会出现 `context-evaporation` / stale ctx 一类报错。原因:`deleteSession` = `closeSession()` + 删文件,**不先 `abort()`**(内存策略那轮核实过:`PiBackend.deleteSession` → `disposeSession` + unlink,没有 abort 步骤)。后果仅限日志噪音(会话确实被删掉了),但排查别的上下文蒸发问题时会误导。 @@ -445,6 +476,16 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b **修复**:右栏统一为绝对定位浮层抽屉,宽度固定,进退只动画 `transform/opacity`;聊天列完全不改宽。点击聊天区不自动关闭,保留顶栏开关、面板关闭按钮和 Esc,便于边看消息边核对变更。不要用 `max-width`、grid 列宽或另一种尺寸属性代替 `width`——它们仍然逐帧触发布局;要丝滑必须让运动留在合成层。 +### 关窗拦截必须留「渲染进程卡死」兑底:`unresponsive` 靠不住(2026-09-21,issue #71) + +背景:Windows 点 ✕ = 退出(微软惯例,不改),只在退出前弹一句「退出后正在跑的任务会终止」让用户自己决定。三条都是踩出来的: + +1. **`webContents.on("unresponsive")` 不能当「渲染进程卡死」的判据**:renderer 里投一个死循环(CDP `Runtime.evaluate` 跑 `while(true){}`,回包永不到)后等 20s,主进程**没收到任何 `unresponsive` 事件**(macOS 实测;`render-process-gone` 只管真崩溃)。只信它的话,卡死时 `close` 被拦下却没人弹窗——窗口从此关不掉,只能上任务管理器,**比误点退出更糟**。 +2. **正确做法 = 「上屏回执 + 超时放行」**:main 发 `app:quit-requested` 后起 1.5s 计时,renderer 的弹窗组件在 `useEffect` 里回一条 `app:quitDialogShown`(放在组件而非事件订阅处:提交后才有 DOM,才是真「上屏」);回执到了就撤计时交回用户,超时就 `quitting = true; app.quit()` 退回原行为。实测:卡死 → 1.5s 后自动退出,不会卡住。 +3. **还要一个「没人接管」的默认放行**:renderer 挂载时才置 guard(`app:setQuitGuard`),未置位(页面还没挂上 / 崩成错误页)就照旧关窗即退;弹窗组件必须挂在 `AppErrorBoundary` **外面**,否则 App 崩成错误页时它一起没了,又变成「拦下来但没人弹窗」。 + +验证手法(本机 macOS,靠两条临时改动模拟 Windows 分支):main 里 `process.platform` 判断临时放宽 + 加个临时 IPC `test:closeWindow` 走 **main 侧** `win.close()`—— **不要用 renderer 的 `window.close()`**(它会直接销毁窗口,不走 `close` 事件,见上一节)。脚本留档 `.local/verify/quit-confirm-check.mjs`。另外:`PERCHO_QUIT_TEST_CLOSE_MS` 那类「到点自动关窗」的临时计时器必须在 `ready-to-show` 之后才起,否则窗口还没显示就关了,看着像「拦截失效」。 + ### macOS 关窗 = 隐藏窗口:三个反直觉点(2026-09-17,issue #55) 行为:macOS 下点红点/⌘W → `preventDefault() + win.hide()`(app 继续跑,Dock 点回原窗口原状态);⌘Q / 菜单退出仍要真退。 @@ -493,6 +534,26 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b - **异步创建/分叉要把新 id 作为返回值交给调用方**,禁止“创建完读 active 拿 id”; - 自动卸载(GC)的“关完成”要**复检 active**:关的过程中用户可能刚好选中了这个会话(判定“可卸”时它还不是 active),此时应当**重新打开后端会话**而不是把它从 UI 抹掉——只重建 backend、不重载磁盘历史(否则 idle transcript 会被历史快照覆盖),不写 `activeSessionId`、不领新令牌。 +### 会话目录写穿:左栏「行存不存在」不能依赖内存(2026-09-21) + +症状:新建的会话先好好地待在左栏,过一阵(切走到别的会话后)**突然消失**;点「+」进新会话页后又出现。顶栏置顶胶囊如果指着它,也会一起没。 + +链路(`main-<日期>.log` 实证,含精确到秒的复现): + +1. 左栏行的数据源是「磁盘历史快照 + 内存会话」合并;而磁盘历史(`projects.allSessions`)**只在进新会话页(`EmptyState` 挂载)时整表重拉**。 +2. `17:19:05` 用户点「+」→ 拉快照(**必然早于它接下来要建的那个会话**);`17:20:35` 首条消息才建出会话 → 它只活在内存里,靠合并撑着左栏那一行。 +3. `18:11:26` 用户切走 → 它不再是 active → 内存策略(GC)下一轮立刻卸它(`lastUsedAt` 只在「打开/切到/新建」打点,发消息不打点,所以时间戳一直停在 17:20,一离开 active 就命中 5 分钟超时)→ 内存里没了、旧快照里也没有 → **行凭空消失**。日志里就一行 `session closed `,无任何删除语义。 +4. `18:18:43` 再点「+」→ 又拉一次快照 → 行回来。 + +对策(已落地):**目录是存在性的唯一来源,会话一进内存就写穿进去**。`stores/projects.ts` 底部一处 `useSessionsStore.subscribe`(会话一进内存就补进目录,**只补缺不覆盖**,磁盘权威的时间字段不被内存 meta 盖掉)+ `load()` 结尾再补一次(`0` 消息会话还没有会话文件,重拉的快照必然没有它)。内存 `sessions` 自此只负责运行态;GC 卸载 = 只撤运行态,不撤存在性。单测:`stores/projects.test.ts` 的「会话目录写穿」四例(含「卸载后左栏行仍在」)+ `lib/sidebar-groups.test.ts`。 + +连带两个教训: + +- **推导链条里任何一环「旧」,整条结论都不可信**:spec 里曾写着「左栏列的是磁盘历史,所以卸载不会让任何列表缺项」——这句话在快照新鲜时成立,而快照并不保证新鲜(`session-memory-policy.md` §1 已标注修正)。审查这类断言时要问「这份数据的**新鲜度**由谁保证」。 +- **renderer 模块图不许有环**:写穿用一个模块级订阅,立刻在「`sessions` 先进」的入口(三个 store 单测)报 `Cannot read properties of undefined (reading 'subscribe')`——环 `projects → sessions → ui-preferences → sidebar-groups → projects` 让 `projects` 的模块体在 `sessions` 未求值完时执行。环的成因只是 3 行纯函数 `toggleInList` 挂在 `sidebar-groups` 上(已拆成叶模块 `lib/toggle-in-list.ts`)。防线:`src/renderer/src/import-cycles.test.ts` 扫全图断言 0 环(本仓当时为 0,含 `import type`)。 + +复现手法(可复用):dev 实例 + CDP 直接驱 store 走真实入口(`.local/dev-logs/repro-session-catalog.mjs`):`createSession()`(真实 promotion)→ `activateNewSessionDraft()` 切走 → `unloadSession(id)`(GC 真实入口)→ 断言 `inMemory=false` 但 `inCatalog=true` 且 `document.querySelector('[data-session-id=…]')` 仍在。**侧栏按组渲染,断言前要先展开该会话所在项目的组**(`setExpandedGroups([...groups, cwd])`),否则“行不在 DOM 里”是假阴性。 + ## 五、工程纪律 ### renderer 单测跑在 node 环境:测不了 i18n 与返回 JSX 的模块函数(2026-09-20) diff --git a/packages/backend/src/pi-backend.ts b/packages/backend/src/pi-backend.ts index 20974188..15ccc594 100644 --- a/packages/backend/src/pi-backend.ts +++ b/packages/backend/src/pi-backend.ts @@ -646,7 +646,12 @@ export class PiBackend { // 否则会变成「拒绝 dispose 但磁盘文件照删」的跛脚状态(阶段 0 实测确认这条路径不先 abort) if (entry) await this.disposeSession(entry); if (!file) throw new Error(`Session file not found: ${sessionId}`); - await unlink(file); + // 文件可能还不存在:0 消息会话(首条 prompt 没过 preflight)只有**计划路径**,SDK 要等 + // 追加第一条 entry 才落盘。这类会话在左栏有行(目录也记着它,见 renderer stores/projects.ts + // 的写穿),删掉它应当照常成功——幂等:本来就没有文件 = 已经是「不存在」状态。 + await unlink(file).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); if (sessionDir) await TraceRecorder.removeAll(sessionDir, sessionId); log.info("session deleted", sessionId); } diff --git a/packages/desktop/src/main/ipc/app.ts b/packages/desktop/src/main/ipc/app.ts index 04d4ceba..72fee3b7 100644 --- a/packages/desktop/src/main/ipc/app.ts +++ b/packages/desktop/src/main/ipc/app.ts @@ -8,6 +8,7 @@ import { checkoutBranch, getGitBranch, listGitBranches } from "../git"; import { resolveExistingPath } from "../path-target"; import { loadUiState, saveUiState } from "../ui-state"; import { checkForUpdates, downloadUpdate, installUpdate } from "../updater"; +import { ackQuitDialog, setQuitGuard } from "../window"; import { registerInvokeHandlers } from "./invoke"; /** 项目仓库地址(帮助跳转 + 关于页) */ @@ -78,5 +79,12 @@ export function registerAppIpc(_backend: PiBackend): void { checkForUpdates: () => checkForUpdates(), downloadUpdate: () => downloadUpdate(), installUpdate: () => installUpdate(), + // 退出确认(Windows):渲染端挂载后接管 guard,点 ✕ 才会被拦下弹窗 + setQuitGuard: ({ enabled }) => setQuitGuard(enabled), + quitDialogShown: () => ackQuitDialog(), + confirmQuit: () => { + // 与 ⌘Q 同一条路:app.quit() 触发 before-quit → markQuitting() → close 不再被拦 + app.quit(); + }, }); } diff --git a/packages/desktop/src/main/window.ts b/packages/desktop/src/main/window.ts index bdd060aa..c285335a 100644 --- a/packages/desktop/src/main/window.ts +++ b/packages/desktop/src/main/window.ts @@ -1,15 +1,36 @@ import { join } from "node:path"; -import type { ThemeMode } from "@percho/shared"; -import { BrowserWindow, nativeTheme, shell } from "electron"; +import { IpcChannels, type ThemeMode } from "@percho/shared"; +import { app, BrowserWindow, nativeTheme, shell } from "electron"; const __dirname = import.meta.dirname; +/** 等「确认窗已上屏」回执的兑底时长:超过它没回执 = 渲染进程卡死/崩溃 */ +const QUIT_ACK_TIMEOUT_MS = 1500; + /** 应用是否正在退出(⌘Q / 菜单「退出」/ Dock 右键退出时由 before-quit 置位) */ let quitting = false; +/** 渲染端是否已接管退出确认(App 挂载时经 `app:setQuitGuard` 置位)。 + * 只有托管成功才拦关窗——没人弹窗时拦下来等于窗口关不掉 */ +let quitGuard = false; +/** 等「弹窗已上屏」回执的兑底计时(详见 close 分支) */ +let quitAckTimer: NodeJS.Timeout | undefined; + /** before-quit 调用:置位后 close 事件不再被拦截(macOS 关窗隐藏态也要能让 ⌘Q 真退出) */ export function markQuitting(): void { quitting = true; + ackQuitDialog(); +} + +export function setQuitGuard(enabled: boolean): void { + quitGuard = enabled; +} + +/** 渲染端回执「确认窗已上屏」→ 撤掉兑底计时,交回给用户决定 */ +export function ackQuitDialog(): void { + if (!quitAckTimer) return; + clearTimeout(quitAckTimer); + quitAckTimer = undefined; } /** 窗口启动底色跟随主题,避免深色模式下启动白闪(也是开屏动画的底色)。与 renderer bg-canvas 同色 */ @@ -72,11 +93,36 @@ export function createWindow(theme: "dark" | "light" = "light"): BrowserWindow { window.on("ready-to-show", () => window.show()); - // macOS:红点/⌘W = 收起窗口(应用继续跑,Dock/菜单栏可恢复);非 darwin 保持关窗即退出 + // 新文档开始加载(重载/HMR)→ 旧 guard 失效,等 renderer 重新挂载后再接管 + window.webContents.on("did-start-loading", () => { + quitGuard = false; + }); + + // 关窗语义按平台: + // - macOS:红点/⌘W = 收起窗口(应用继续跑,Dock/菜单栏可恢复) + // - Windows:✕ 就是退出(微软惯例),保留;只在渲染端接管了确认弹窗时拦一手, + // 让用户看到「退出后正在跑的任务会终止」再决定 + // - Linux:关窗即退,行为不变 window.on("close", (event) => { - if (process.platform !== "darwin" || quitting) return; + if (quitting) return; + if (process.platform === "darwin") { + event.preventDefault(); + window.hide(); + return; + } + if (process.platform !== "win32") return; + if (!quitGuard || window.webContents.isDestroyed()) return; event.preventDefault(); - window.hide(); + window.webContents.send(IpcChannels.QuitRequested); + // 兑底:渲染端弹窗上屏后会回执 `app:quitDialogShown`;超时没回执就说明渲染进程 + // 卡死/崩溃(实测 unresponsive 事件此时不一定来),弹窗永远不会出现—— + // 那就放行退出,退回「关窗即退」的原行为。关不掉的窗口比误点退出更糟 + ackQuitDialog(); + quitAckTimer = setTimeout(() => { + quitAckTimer = undefined; + quitting = true; + app.quit(); + }, QUIT_ACK_TIMEOUT_MS); }); window.webContents.setWindowOpenHandler((details) => { diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index a5604c07..b7d5d204 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -39,6 +39,7 @@ const api: PiApi = { ...invokeApi, onProviderLoginEvent: makeSubscription(IpcChannels.SettingsLoginEvent), onUiPluginsEvent: makeSubscription(IpcChannels.UiPluginsEvent), + onQuitRequested: makeSubscription(IpcChannels.QuitRequested), onUpdateEvent: makeSubscription(IpcChannels.UpdateEvent), onEvent: makeSubscription(IpcChannels.Event), onPermissionRequest: makeSubscription(IpcChannels.PermissionRequest), diff --git a/packages/desktop/src/renderer/src/App.tsx b/packages/desktop/src/renderer/src/App.tsx index 4f3c2886..dd4ec929 100644 --- a/packages/desktop/src/renderer/src/App.tsx +++ b/packages/desktop/src/renderer/src/App.tsx @@ -19,6 +19,7 @@ import { RegionHost } from "./plugins/RegionHost"; import { Slot } from "./plugins/Slot"; import { UI_REGIONS, UI_SLOTS } from "./plugins/slots"; import { finishSplash } from "./splash"; +import { initAppQuit } from "./stores/app-quit"; import { useSessionsStore } from "./stores/sessions"; import { backgroundImageUrl, useThemeStore } from "./stores/theme"; import { useTranscriptStore } from "./stores/transcript"; @@ -64,6 +65,8 @@ export default function App() { // 历史全在左栏,点一下才按需加载 void Promise.allSettled([useSessionsStore.getState().loadModels()]).then(() => finishSplash()); initUpdateStore(); + // 退出确认(Windows):挂载即向 main 声明接管,点 ✕ 才会拦下来弹窗 + initAppQuit(); // 日常空间目录缓存(pill/轨道/侧栏的空间归属判定依赖;失败静默,入口退化为不显示) void initDailyDir(); // UI 插件加载链路(总开关关时只订阅事件,零开销) diff --git a/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx b/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx index ab8f56bf..d839d4d7 100644 --- a/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx +++ b/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx @@ -383,8 +383,8 @@ export function SessionTabBar() { type="button" className="no-drag shrink-0 rounded-lg p-1.5 text-ink-dim transition-colors hover:bg-hover hover:text-ink" onClick={() => { - // 单例 draft:已有 draft 就回到它(内容与配置一律保留,并聚焦输入框); - // 没有 draft(转正刚消费掉、或启动首帧)才新建一份 + // 单例 draft:从真实会话进入时按当前会话项目/模型等配置起步; + // 已经在 draft 页再次点击则原样保留,并聚焦输入框 const hadDraft = useSessionsStore.getState().newSessionDraft !== null; activateNewSessionDraft(); if (hadDraft) window.dispatchEvent(new CustomEvent(COMPOSER_FOCUS_EVENT)); diff --git a/packages/desktop/src/renderer/src/components/sidebar/ProjectRow.tsx b/packages/desktop/src/renderer/src/components/sidebar/ProjectRow.tsx index b765e327..98a00fe0 100644 --- a/packages/desktop/src/renderer/src/components/sidebar/ProjectRow.tsx +++ b/packages/desktop/src/renderer/src/components/sidebar/ProjectRow.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useT } from "../../i18n"; -import { CoffeeIcon, ExpandArrowIcon, FolderIcon, FolderOpenIcon, MoreIcon, PinIcon } from "../icons"; +import { CoffeeIcon, EditIcon, FolderIcon, FolderOpenIcon, MoreIcon, PinIcon } from "../icons"; import { ConfirmDialog } from "../ui/ConfirmDialog"; import { ContextMenu } from "../ui/ContextMenu"; import type { MenuAnchor } from "../ui/place-menu"; @@ -9,10 +9,10 @@ import { projectMenuItems } from "./ProjectMenu"; /** * 分组行(日常 / 每个项目共用)。 * **图标表达状态(v7)**:项目 = 折叠 `FolderIcon` ↔ 展开 `FolderOpenIcon`(同源同风格,不再变 chevron → 不跳变); - * 日常 = 恒 `CoffeeIcon`(状态不由图标表达),行末 hover 出一个 `ExpandArrowIcon` 当折叠提示。 - * 行末槽位二选一:项目行只有 «⋯»(菜单)、日常行只有展开箭头(纯装饰,`pointer-events-none` 让点击落到行按钮)。 + * 日常 = 恒 `CoffeeIcon`(状态不由图标表达)。行末操作对齐 Codex:项目行 hover 出 «⋯» + 新会话, + * 日常行 hover 只出新会话;按钮同时支持 focus-visible,不能成为纯鼠标入口。 * - * «⋯» 是行的**兄弟节点**(绝对定位浮在行右端)而不是嵌套 button——避免 button 套 button; + * 行末按钮是行的**兄弟节点**(绝对定位浮在行右端)而不是嵌套 button——避免 button 套 button; * 菜单锚点用**指针点**(同 SessionRow 右键菜单),不然 placeMenu 会把按钮高度再算一遍、菜单掉到行下方左侧。 * 「移除项目」走二次确认(ConfirmDialog,z-60 压过菜单):菜单项 onSelect 先执行(开弹窗)、ContextMenu * 随后自己关闭,所以视觉上是「菜单消失 → 弹窗已在」;文案里的会话条数用 totalSessions(不受搜索影响)。 @@ -28,6 +28,7 @@ export function ProjectRow({ pinned = false, totalSessions = 0, onToggle, + onNewSession, onTogglePin, onRemove, }: { @@ -38,7 +39,9 @@ export function ProjectRow({ /** 会话总数(只有项目组传):移除确认文案里的真实条数 */ totalSessions?: number; onToggle: () => void; - /** 给了才渲染 «⋯»(阶段 2 只有项目行有:置顶;日常没有可做的事就不给死按钮) */ + /** 在该分组 cwd 下打开全局唯一 draft;日常与项目都有 */ + onNewSession: () => void; + /** 给了才渲染 «⋯»(项目行用于置顶/移除;日常没有项目管理菜单) */ onTogglePin?: () => void; /** 给了才在 «⋯» 里出「移除项目」(日常不可移除) */ onRemove?: () => void; @@ -62,7 +65,7 @@ export function ProjectRow({ type="button" aria-expanded={expanded} title={label} - className={`flex h-[34px] w-full items-center gap-2 rounded-[7px] px-1.5 text-[14px] text-ink-2 group-hover/row:bg-hover group-hover/row:text-ink ${ + className={`flex h-[34px] w-full items-center gap-2 rounded-[7px] px-1.5 pr-12 text-[14px] text-ink-2 group-hover/row:bg-hover group-hover/row:text-ink ${ expanded ? "text-ink" : "" }`} onClick={onToggle} @@ -77,28 +80,29 @@ export function ProjectRow({ )} - {kind === "daily" && ( - // 纯装饰(点击穿透到行按钮):折叠提示只在 hover 时现,项目行则用 folder-open 表达状态 - - )} - {togglePin && ( +
+ {togglePin && ( + + )} - )} +
{anchor && togglePin && (
-
+ {/* 左栏唯一的外层滚动容器:验收脚本用 `data-sidebar-scroll-root` 定位它(纯属性,无视觉影响) */} +
{data.daily && ( )} diff --git a/packages/desktop/src/renderer/src/components/sidebar/SidebarGroup.tsx b/packages/desktop/src/renderer/src/components/sidebar/SidebarGroup.tsx index 18408778..2822077a 100644 --- a/packages/desktop/src/renderer/src/components/sidebar/SidebarGroup.tsx +++ b/packages/desktop/src/renderer/src/components/sidebar/SidebarGroup.tsx @@ -1,15 +1,19 @@ import { useT } from "../../i18n"; import type { SidebarGroup as Group } from "../../lib/sidebar-groups"; +import { COMPOSER_FOCUS_EVENT } from "../../stores/drafts"; import { useProjectsStore } from "../../stores/projects"; +import { useSessionsStore } from "../../stores/sessions"; import { ProjectRow } from "./ProjectRow"; import { useSessionMenu } from "./SessionMenu"; -import { SessionRow } from "./SessionRow"; +import { SidebarSessionList } from "./SidebarSessionList"; /** * 分组的通用骨架(日常与每个项目共用一套,不写两套):分组行 + 可折叠的会话列表 + 展开空态。 * 会话行点击走 `openSession`(同一条路径同时覆盖「已打开 → 切换」与「未打开 → 从历史打开」); * 右键菜单是 `useSessionMenu()`(菜单 / 改名气泡 / 删除确认三层都在那边)。 * `pinned` / `onTogglePin` 只有项目组传(日常没有可置顶的语义,也就不给 «⋯»)。 + * **列表本身(限高 + 组内滚动)在 `SidebarSessionList` 里**:标题行由本组件渲染在它之外, + * 于是内层滚动容器只圈住会话行,标题上的滚轮自然滚外层项目导航(spec §4.2)。 */ export function SidebarGroup({ group, @@ -30,7 +34,12 @@ export function SidebarGroup({ }) { const t = useT(); const openSession = useProjectsStore((s) => s.openSession); + const activateNewSessionDraftForCwd = useSessionsStore((s) => s.activateNewSessionDraftForCwd); const sessionMenu = useSessionMenu(); + const openNewSession = () => { + activateNewSessionDraftForCwd(group.cwd); + requestAnimationFrame(() => window.dispatchEvent(new CustomEvent(COMPOSER_FOCUS_EVENT))); + }; return (
onToggle(group.key)} + onNewSession={openNewSession} onTogglePin={onTogglePin ? () => onTogglePin(group.cwd) : undefined} onRemove={onRemove} /> - {group.expanded && - (group.sessions.length > 0 ? ( - group.sessions.map(({ session, pinned }) => ( - void openSession(session)} - onContextMenu={(anchor) => sessionMenu.open(session, anchor)} - /> - )) - ) : ( -

{t("sidebar.noSessions")}

- ))} + {group.expanded && ( + void openSession(session)} + onContextMenu={(session, anchor) => sessionMenu.open(session, anchor)} + /> + )} {sessionMenu.element}
); diff --git a/packages/desktop/src/renderer/src/components/sidebar/SidebarHeader.tsx b/packages/desktop/src/renderer/src/components/sidebar/SidebarHeader.tsx index 75ffa7f0..2ae923b3 100644 --- a/packages/desktop/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/packages/desktop/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -1,19 +1,36 @@ import { useT } from "../../i18n"; +import { COMPOSER_FOCUS_EVENT } from "../../stores/drafts"; import { useProjectsStore } from "../../stores/projects"; -import { SearchIcon } from "../icons"; +import { useSessionsStore } from "../../stores/sessions"; +import { EditIcon, SearchIcon } from "../icons"; /** - * 左栏头区:搜索框(绑 `projects` store 的 `search`,与派生层同一份数据,无需各自过滤)。 - * v9:顶栏常驻,不再需要「顶栏关掉时补 44px 拖拽带 + + 兜底」那套(顶栏自带拖动区与新建按钮)。 + * 左栏固定头区:新会话入口 + 搜索框。它位于会话列表滚动容器之外,因此列表滚动时始终常驻。 + * 顶部入口与顶栏「+」同语义:从真实会话进入时复用当前项目/模型等配置;已在 draft 页则原样保留。 */ export function SidebarHeader() { const t = useT(); const search = useProjectsStore((s) => s.search); const setSearch = useProjectsStore((s) => s.setSearch); + const activateNewSessionDraft = useSessionsStore((s) => s.activateNewSessionDraft); + + const openNewSession = () => { + activateNewSessionDraft(); + // 等新会话页 Composer 挂载后再聚焦;同步派发会被刚卸载的真实会话 Composer 接走。 + requestAnimationFrame(() => window.dispatchEvent(new CustomEvent(COMPOSER_FOCUS_EVENT))); + }; return ( -
-
+
+ +
1 ? "true" : "false"; + el.dataset.fadeBottom = el.scrollHeight - el.clientHeight - el.scrollTop > 1 ? "true" : "false"; +} + +/** + * 分组的会话列表(日常与每个项目共用):限高 + 组内独立滚动。 + * + * 内外滚动归属靠**原生嵌套滚动**表达,不拦 wheel、不手改 scrollTop: + * - 列表用 `overflow-y-auto`,滚轮命中列表内容区时只滚它自己; + * - **只有真的会溢出(> 8 行)时才挂 `overscroll-y-contain`** —— 实测(2026-09-21,dev + CDP 真实 wheel) + * 给「不溢出的滚动容器」挂 contain 会把滚轮整个吞掉、外层一动不动(项目标题区之外的导航就此失灵), + * 所以 contain 与 `data-scrollable` 共用同一个判据,不给探针留分叉的可能; + * - 项目标题行由调用方渲染在本组件**之外**,标题上的滚轮自然交给外层侧栏。 + * + * 边缘淡出(`.sidebar-session-list` 的 mask 规则在 globals.css):上下两侧各自只在"那个方向还有内容"时淡出, + * 让截断处是渐变而不是硬切线;淡出是 alpha 逆罩、不引入颜色,所以深浅色主题共用一套。 + * + * 纯 props 驱动:不读 store、不排序、不管展开态与菜单状态(那些属于 `SidebarGroup`)。 + */ +export function SidebarSessionList({ + sessions, + activeSessionId, + emptyLabel, + onSelect, + onContextMenu, +}: { + sessions: readonly SidebarSession[]; + activeSessionId: string | null; + emptyLabel: string; + onSelect: (session: SessionMeta) => void; + onContextMenu: (session: SessionMeta, anchor: MenuAnchor) => void; +}) { + const scrollable = sessions.length > SIDEBAR_SESSION_LIST_MAX_ROWS; + const scrollRef = useRef(null); + + // 边缘淡出:只看当前滚动位置(纯读,不拦 wheel、不改 scrollTop)。 + // 两个属性由本组件独占——不写进 JSX,免得 React 重渲染把滚动中的状态冲回初值。 + // 1) 每次渲染后补算一次:行数变化会改 scrollHeight,而容器盒子可能不变(都溢出时恒 248px), + // 光靠 ResizeObserver 盖不住这种变化; + useEffect(() => { + const el = scrollRef.current; + if (el) syncFadeAttrs(el, scrollable); + }); + // 2) 滚动时增量更新(passive,不进 React 状态)+ 容器尺寸变化时重算。 + useEffect(() => { + const el = scrollRef.current; + if (!el || !scrollable) return; + const onScroll = () => syncFadeAttrs(el, true); + el.addEventListener("scroll", onScroll, { passive: true }); + const observer = new ResizeObserver(onScroll); + observer.observe(el); + return () => { + el.removeEventListener("scroll", onScroll); + observer.disconnect(); + }; + }, [scrollable]); + + if (sessions.length === 0) { + return

{emptyLabel}

; + } + return ( +
+ {sessions.map(({ session, pinned }) => ( + onSelect(session)} + onContextMenu={(anchor) => onContextMenu(session, anchor)} + /> + ))} +
+ ); +} diff --git a/packages/desktop/src/renderer/src/components/ui/ConfirmDialog.tsx b/packages/desktop/src/renderer/src/components/ui/ConfirmDialog.tsx index 2334bb59..25a2b043 100644 --- a/packages/desktop/src/renderer/src/components/ui/ConfirmDialog.tsx +++ b/packages/desktop/src/renderer/src/components/ui/ConfirmDialog.tsx @@ -14,6 +14,7 @@ export function ConfirmDialog({ cancelLabel, closeLabel, danger = false, + autoFocusConfirm = false, onConfirm, onCancel, }: { @@ -25,10 +26,19 @@ export function ConfirmDialog({ closeLabel?: string; /** 确认按钮走危险红(删除类操作) */ danger?: boolean; + /** 弹窗打开即把焦点给确认按钮(Enter 直接确认)。默认关:破坏性操作不该被一个回车顺手做掉 */ + autoFocusConfirm?: boolean; onConfirm: () => void; onCancel: () => void; }) { const ref = useRef(null); + const confirmRef = useRef(null); + + // 默认焦点(仅 autoFocusConfirm 时):走显式 focus() 而非 autoFocus 属性—— + // 属性版会被 lint/a11y/noAutofocus 拦,行为也不如这里可控(只在需要时焦点) + useEffect(() => { + if (autoFocusConfirm) confirmRef.current?.focus(); + }, [autoFocusConfirm]); useEffect(() => { const onPointerDown = (e: PointerEvent) => { @@ -79,6 +89,7 @@ export function ConfirmDialog({ className={`flex h-8 items-center rounded-[9px] px-3.5 text-[13px] font-medium transition-colors ${ danger ? "bg-red-600/12 text-red-600 hover:bg-red-600/20" : "bg-ink text-on-ink hover:bg-ink-2" }`} + ref={confirmRef} onClick={onConfirm} > {confirmLabel} diff --git a/packages/desktop/src/renderer/src/components/ui/QuitConfirmDialog.tsx b/packages/desktop/src/renderer/src/components/ui/QuitConfirmDialog.tsx new file mode 100644 index 00000000..0d47d5d4 --- /dev/null +++ b/packages/desktop/src/renderer/src/components/ui/QuitConfirmDialog.tsx @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { getPi } from "../../api"; +import { useT } from "../../i18n"; +import { useAppQuitStore } from "../../stores/app-quit"; +import { ConfirmDialog } from "./ConfirmDialog"; + +/** + * 退出前确认(Windows 点 ✕ 时 main 拦下 → 本弹窗;仅该平台会亮)。 + * 文案必须说清「退出后正在跑的任务会终止」——这是它存在的唯一理由; + * 想临时收起窗口继续跑任务,用标题栏的 – 最小化。 + */ +export function QuitConfirmDialog() { + const t = useT(); + const visible = useAppQuitStore((s) => s.visible); + + // 上屏回执:main 侧在发出 quit-requested 后等这个回执(超时 = 渲染进程卡死,直接放行退出)。 + // 放在本节里(而非事件订阅处)是因为 useEffect 跑在提交之后——DOM 真存在了才算数 + useEffect(() => { + if (visible) void getPi().quitDialogShown(); + }, [visible]); + + if (!visible) return null; + return ( + { + void getPi().confirmQuit(); + }} + onCancel={() => useAppQuitStore.setState({ visible: false })} + /> + ); +} diff --git a/packages/desktop/src/renderer/src/i18n/en.ts b/packages/desktop/src/renderer/src/i18n/en.ts index 78f268be..412aa578 100644 --- a/packages/desktop/src/renderer/src/i18n/en.ts +++ b/packages/desktop/src/renderer/src/i18n/en.ts @@ -14,6 +14,11 @@ export const en: Messages = { desc: "A rendering error occurred: {detail}", reload: "Reload", }, + quitConfirm: { + title: "Quit Percho", + desc: "Any task that is currently running will be terminated.", + confirm: "Quit", + }, tabbar: { newSession: "New session", pickProjectFirst: "Pick a project directory first", @@ -249,6 +254,8 @@ export const en: Messages = { }, sidebar: { title: "Navigation", + newSession: "New session", + newSessionInProject: "New session in {name}", search: "Search sessions", daily: "Daily", projectsSection: "Projects", diff --git a/packages/desktop/src/renderer/src/i18n/zh.ts b/packages/desktop/src/renderer/src/i18n/zh.ts index 3d991c79..50ff7b5c 100644 --- a/packages/desktop/src/renderer/src/i18n/zh.ts +++ b/packages/desktop/src/renderer/src/i18n/zh.ts @@ -12,6 +12,11 @@ export const zh = { desc: "渲染层发生错误:{detail}", reload: "重新加载", }, + quitConfirm: { + title: "退出 Percho", + desc: "退出后,当前正在进行的任务会终止。", + confirm: "退出", + }, tabbar: { newSession: "新建会话", pickProjectFirst: "请先选择项目目录", @@ -246,6 +251,8 @@ export const zh = { }, sidebar: { title: "导航", + newSession: "新会话", + newSessionInProject: "在 {name} 中新建会话", search: "搜索会话", daily: "日常", projectsSection: "项目", diff --git a/packages/desktop/src/renderer/src/import-cycles.test.ts b/packages/desktop/src/renderer/src/import-cycles.test.ts new file mode 100644 index 00000000..194d94a7 --- /dev/null +++ b/packages/desktop/src/renderer/src/import-cycles.test.ts @@ -0,0 +1,79 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * renderer 模块图不许有环(工程纪律,见 docs/INDEX.md 硬约束 / PITFALLS)。 + * + * 为什么值得一条测试:有环时「某个模块体在谁的 import 过程中执行」不确定,模块级副作用会读到 + * 尚未求值的绑定 —— 2026-09-21 的会话目录写穿订阅就因此炸过一次(`stores/projects.ts` 底部的 + * `useSessionsStore.subscribe` 在「sessions 先进」的入口下拿到 undefined);当时环 + * `stores/projects → stores/sessions → stores/ui-preferences → lib/sidebar-groups → stores/projects` + * 的成因只是一条 3 行纯函数(`toggleInList`)挂错了层。环本身没炸是因为入口顺序碰巧对了 —— 这类 + * 潜伏问题靠 reviewer 眼睛看不出来,交给它守。 + * + * 判定口径:只看 `src/renderer/src` 下的相对 import(含 `import type` 与 `import "x"` 副作用导入; + * 类型环同样说明分层乱了)。外部包(`@percho/shared` 等)不参与。 + */ +const ROOT = resolve(import.meta.dirname); +const SOURCE_RE = /\bfrom\s*["']([^"']+)["']|\bimport\s*["']([^"']+)["']/g; + +function listSources(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + if (statSync(path).isDirectory()) return listSources(path); + return /\.(ts|tsx)$/.test(path) && !path.endsWith(".d.ts") ? [path] : []; + }); +} + +function edgesOf(file: string): string[] { + const found = new Set(); + for (const match of readFileSync(file, "utf8").matchAll(SOURCE_RE)) { + const spec = match[1] ?? match[2]; + if (!spec?.startsWith(".")) continue; + const base = resolve(dirname(file), spec); + for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, "index.ts")]) { + try { + if (statSync(candidate).isFile()) found.add(candidate); + } catch { + // 不是文件(目录/不存在)→ 继续试下一个候选 + } + } + } + return [...found]; +} + +it("renderer 的 import 图无环(有环 = 模块体执行顺序不确定,模块级副作用会踩未初始化绑定)", () => { + const sources = listSources(ROOT); + const graph = new Map(sources.map((file) => [file, edgesOf(file)])); + const state = new Map(); + const stack: string[] = []; + const cycles: string[] = []; + + function visit(node: string): void { + state.set(node, "visiting"); + stack.push(node); + for (const next of graph.get(node) ?? []) { + if (!graph.has(next)) continue; + if (state.get(next) === "visiting") { + cycles.push([...stack.slice(stack.indexOf(next)), next].map((p) => relative(ROOT, p)).join(" → ")); + continue; + } + if (!state.has(next)) visit(next); + } + stack.pop(); + state.set(node, "done"); + } + + for (const file of sources) if (!state.has(file)) visit(file); + expect(cycles).toEqual([]); +}); + +describe("本测试自己的守卫(防它自己失效)", () => { + it("扫到的文件数与依赖解析都非空(否则「0 环」是假绿)", () => { + const sources = listSources(ROOT); + expect(sources.length).toBeGreaterThan(50); + const withEdges = sources.filter((file) => edgesOf(file).length > 0); + expect(withEdges.length).toBeGreaterThan(20); + }); +}); diff --git a/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts b/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts index b5a765d9..a369b667 100644 --- a/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts +++ b/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts @@ -11,7 +11,6 @@ import { type SidebarGroupsInput, type SidebarNavigationInput, toggleExpandedGroup, - toggleInList, } from "./sidebar-groups"; /** @@ -417,13 +416,7 @@ describe("导航投影:只读子会话不进左栏", () => { }); }); -describe("toggleInList / toggleExpandedGroup", () => { - it("置顶切换:新置顶排最前,取消则移除", () => { - expect(toggleInList([], "a")).toEqual(["a"]); - expect(toggleInList(["a"], "b")).toEqual(["b", "a"]); - expect(toggleInList(["b", "a"], "a")).toEqual(["b"]); - }); - +describe("toggleExpandedGroup", () => { it("展开切换:无记录时以默认集为起点翻转,不误伤其它组", () => { expect(toggleExpandedGroup([], P2, [P1], false)).toEqual([P1, P2]); expect(toggleExpandedGroup([P1], P1, [], true)).toEqual([]); diff --git a/packages/desktop/src/renderer/src/lib/sidebar-groups.ts b/packages/desktop/src/renderer/src/lib/sidebar-groups.ts index f6df899f..268b2003 100644 --- a/packages/desktop/src/renderer/src/lib/sidebar-groups.ts +++ b/packages/desktop/src/renderer/src/lib/sidebar-groups.ts @@ -7,9 +7,8 @@ import { isPrimaryNavigationSession } from "./session-visibility"; export { isPrimaryNavigationSession }; /** - * 左侧栏的纯派生层:把「全量历史会话 + 项目表 + 偏好」算成可直接渲染的分组数组。 + * 左侧栏的纯派生层:把「会话目录 + 项目表 + 偏好」算成可直接渲染的分组数组。 * 纪律:不 import React、不读写持久化、不调 IPC —— 组件只做展示,展开/置顶状态一律由调用方传入。 - * 两个共用纯助手也放这里:`toggleInList`(置顶切换,会话与项目共用)、`toggleExpandedGroup`(展开切换)。 * * v7:「项目」小标不再可折叠(只是固定标题分割区)——`PROJECTS_GROUP_KEY` / `projectsExpanded` 已删; * `expandedGroups` 里可能残留的历史值 `__projects__` 不会匹配任何 cwd,自然失效,无需数据迁移。 @@ -65,14 +64,16 @@ function mergeSessionMeta(history: SessionMeta, memory: SessionMeta): SessionMet } /** - * 左栏数据源合并:**磁盘历史(`projects.allSessions`)+ 当前内存会话(`sessions.sessions`)**。 - * 内存项按 `sessionId` 覆盖历史项(名称/模型/状态以当前实例为准,时间字段走 `mergeSessionMeta` 合并), - * 历史项保持原顺序,内存独有项(刚创建、还没进历史的真实会话)按内存顺序补在后面。**不负责排序**: - * 组内排序统一由 `groupSessions` 做(最后活动倒序 + 置顶分区)。 + * 左栏数据源合并:**会话目录(`projects.allSessions`)+ 当前内存会话(`sessions.sessions`)**。 + * 内存项按 `sessionId` 覆盖目录项(名称/模型/状态以当前实例为准,时间字段走 `mergeSessionMeta` 合并), + * 目录项保持原顺序,内存独有项按内存顺序补在后面。**不负责排序**:组内排序统一由 `groupSessions` 做 + * (最后活动倒序 + 置顶分区)。 * - * 为什么必须合并内存会话:新会话 promotion 成真实会话后,`sessions` 里立刻有了它, - * 而 `allSessions`(磁盘历史)要等下次重拉才更新——只吃历史会让左栏行在这段缝隙里消失。 - * 合并全部内存会话从根上消除了这个状态缺口(spec D2)。新会话页没有会话条目,靠 `activeCwd` 展开当前组。 + * ⚠️ **「行存不存在」的判断依据是目录,不是内存**:会话一进内存就被写穿进目录 + * (`stores/projects.ts` 的 `absorbOpenSessions`),所以 GC 卸载内存不会让行消失。下面的 + * 「内存独有项补在后面」只是兜时序的防御(模块加载早期订阅还没装、测试里直接 setState), + * **别再把它当存在性的依靠** —— 2026-09-21 的事故正是「新建的会话只活在内存里 + 被 GC 卸载」 + * = 行凭空消失。新会话页没有会话条目,靠 `activeCwd` 展开当前组。 */ export function mergeSidebarSessions( history: readonly SessionMeta[], @@ -89,7 +90,7 @@ export function mergeSidebarSessions( } export type SidebarGroupsInput = { - /** 全量历史会话(含未打开的);**调用方应先过 `isPrimaryNavigationSession`**(见 `deriveSidebarNavigation`) */ + /** 会话目录(`projects.allSessions`:磁盘历史 ∪ 本进程经手过的会话);**调用方应先过 `isPrimaryNavigationSession`**(见 `deriveSidebarNavigation`) */ sessions: readonly SessionMeta[]; /** 项目表:**复用 `deriveProjects(projectsStore)` 的输出**(已排除日常目录、已含「只有会话」与「手动添加」两类) */ projects: readonly ProjectEntry[]; @@ -111,9 +112,9 @@ export type SidebarGroupsInput = { /** `deriveSidebarNavigation` 的输入:左栏需要的两个数据源(历史 + 内存)+ 项目表 + 偏好 */ export type SidebarNavigationInput = { - /** 磁盘历史(`projects.allSessions`) */ + /** 会话目录(`projects.allSessions`):行存在性的唯一来源 */ history: readonly SessionMeta[]; - /** 当前内存会话(`sessions.sessions`,含刚创建还没进历史的真实会话与只读子会话) */ + /** 当前内存会话(`sessions.sessions`):只负责叠运行态与补还没落盘的新会话 */ memory: readonly SessionMeta[]; /** 手动添加过的项目目录(`projects.addedProjects`) */ addedProjects: string[]; @@ -245,11 +246,6 @@ export function deriveSidebarNavigation(input: SidebarNavigationInput): SidebarG }); } -/** 置顶 / 取消置顶(新置顶排最前):会话与项目共用,store 与组件都走它,别各写一套 */ -export function toggleInList(list: readonly string[], id: string): string[] { - return list.includes(id) ? list.filter((item) => item !== id) : [id, ...list]; -} - /** * 展开 / 折叠一个分组(纯逻辑):以 `touched` 判据——用户还没操作过就用 `defaults` * (派生层给的默认展开集)当起点再翻转,否则在用户的现有记录上翻转。 diff --git a/packages/desktop/src/renderer/src/lib/toggle-in-list.test.ts b/packages/desktop/src/renderer/src/lib/toggle-in-list.test.ts new file mode 100644 index 00000000..9a5dc4ff --- /dev/null +++ b/packages/desktop/src/renderer/src/lib/toggle-in-list.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { toggleInList } from "./toggle-in-list"; + +describe("toggleInList", () => { + it("置顶切换:新置顶排最前,取消则移除", () => { + expect(toggleInList([], "a")).toEqual(["a"]); + expect(toggleInList(["a"], "b")).toEqual(["b", "a"]); + expect(toggleInList(["b", "a"], "a")).toEqual(["b"]); + }); + + it("不动原数组(纯函数:调用方拿旧引用做比较)", () => { + const list = ["a"]; + expect(toggleInList(list, "b")).not.toBe(list); + expect(list).toEqual(["a"]); + }); +}); diff --git a/packages/desktop/src/renderer/src/lib/toggle-in-list.ts b/packages/desktop/src/renderer/src/lib/toggle-in-list.ts new file mode 100644 index 00000000..187653e8 --- /dev/null +++ b/packages/desktop/src/renderer/src/lib/toggle-in-list.ts @@ -0,0 +1,14 @@ +/** + * 置顶 / 取消置顶(新置顶排最前):会话、项目、手动添加的项目表共用,store 与组件都走它, + * 别各写一套。 + * + * **为什么单独一个叶模块**:这条 3 行纯函数被 `stores/ui-preferences.ts`(置顶表就是它持久化的) + * 与左栏派生层共用。原先它挂在 `lib/sidebar-groups.ts` 上,于是形成环 + * `stores/projects → stores/sessions → stores/ui-preferences → lib/sidebar-groups → stores/projects`: + * 有环的模块图里「模块体在谁的 import 过程中执行」不确定,模块作用域的副作用 + * (如 `stores/projects.ts` 底部的目录写穿订阅)会随机读到尚未求值的 store 绑定。 + * 拆成叶模块后 renderer 的 import 图无环,模块体执行顺序不再影响正确性。 + */ +export function toggleInList(list: readonly string[], id: string): string[] { + return list.includes(id) ? list.filter((item) => item !== id) : [id, ...list]; +} diff --git a/packages/desktop/src/renderer/src/main.tsx b/packages/desktop/src/renderer/src/main.tsx index 3adf0123..36802fea 100644 --- a/packages/desktop/src/renderer/src/main.tsx +++ b/packages/desktop/src/renderer/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import { AppErrorBoundary } from "./components/AppErrorBoundary"; +import { QuitConfirmDialog } from "./components/ui/QuitConfirmDialog"; // 宿主 API 挂载(副作用):必须在任何插件代码可能运行之前执行,因此置于 theme store init 之前 import "./plugins/host-api"; // monaco 缺失服务补注册(副作用,详见文件头):代码块编辑器 UNKNOWN service 报错修复 @@ -25,6 +26,9 @@ void Promise.all([useThemeStore.getState().init(), useUiPreferencesStore.getStat + {/* 退出确认弹窗挂在边界「外面」:App 崩成错误页时它仍要在场—— + 否则 Windows 上点 ✕ 会被拦下却没人弹窗,窗口从此关不掉(见 stores/app-quit.ts) */} + , ); }); diff --git a/packages/desktop/src/renderer/src/stores/app-quit.ts b/packages/desktop/src/renderer/src/stores/app-quit.ts new file mode 100644 index 00000000..9468d6c9 --- /dev/null +++ b/packages/desktop/src/renderer/src/stores/app-quit.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; +import { getPi } from "../api"; + +/** 退出确认弹窗可见性(main 拦下关窗后置位;用户取消时由弹窗自己关掉) */ +interface AppQuitStore { + visible: boolean; +} + +export const useAppQuitStore = create()(() => ({ visible: false })); + +let initialized = false; + +/** + * 接管退出确认(App 挂载时调用一次;模块级标志防 StrictMode 双执行)。 + * Windows 上 main 默认关窗即退,只有收到本 guard 才会拦下来让这里弹确认窗—— + * 所以「没挂载上」就等于退回原行为(直接退出),不会把窗口锁死。 + */ +export function initAppQuit(): void { + if (initialized) return; + initialized = true; + const pi = getPi(); + // 只有 Windows 的关窗语义是「退出」才需要拦下确认(macOS 红点=隐藏窗口,Linux 保持原样) + if (pi.platform !== "win32") return; + void pi.setQuitGuard({ enabled: true }); + // 重复触发(连点 ✕)只是把已为 true 的状态再置一次,不叠卡片 + pi.onQuitRequested(() => useAppQuitStore.setState({ visible: true })); +} diff --git a/packages/desktop/src/renderer/src/stores/projects.test.ts b/packages/desktop/src/renderer/src/stores/projects.test.ts index a394152c..220aa0fb 100644 --- a/packages/desktop/src/renderer/src/stores/projects.test.ts +++ b/packages/desktop/src/renderer/src/stores/projects.test.ts @@ -9,7 +9,9 @@ const piMock = vi.hoisted(() => ({ vi.mock("../api", () => ({ getPi: () => piMock })); import { setDailyDirForTest } from "../lib/daily"; +import { deriveSidebarNavigation } from "../lib/sidebar-groups"; import { deriveProjects, useProjectsStore } from "./projects"; +import { useSessionsStore } from "./sessions"; function session(cwd: string, modifiedAt: number): SessionMeta { return { @@ -91,6 +93,77 @@ function ids(): string[] { return useProjectsStore.getState().allSessions.map((s) => s.sessionId); } +/** + * 会话目录写穿(2026-09-21 事故修复): + * 「左栏/顶栏行存在不存在」必须由目录 `allSessions` 决定,内存 `sessions` 只管运行态。 + * 事故链路:磁盘历史只在进新会话页时整表重拉(是旧账,必然早于新建出来的会话)→ 新会话只活在内存里 → + * 内存策略(GC)卸载它 → 内存没了、旧账里也没有 → 行凭空消失;点「+」重拉一次才回来。 + */ +describe("会话目录写穿(存在性只依赖目录)", () => { + function freshMeta(id: string, cwd: string): SessionMeta { + // 后端 createSession 刚返回的 meta(0 消息:会话文件还没落盘,但路径已定) + return { + sessionId: id, + cwd, + name: "新会话", + sessionFile: `${cwd}/${id}.jsonl`, + active: true, + messageCount: 0, + createdAt: 500, + modifiedAt: 500, + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + useProjectsStore.setState({ allSessions: [], selectedCwd: null, search: "" }); + useSessionsStore.setState({ sessions: [], activeSessionId: null, cwd: null }); + }); + + it("会话一进内存(新建/打开)就写进目录 —— 哪怕磁盘快照比它更旧", () => { + useProjectsStore.setState({ allSessions: [session("/p", 100)] }); + useSessionsStore.setState({ sessions: [freshMeta("new-1", "/p")] }); + expect(ids()).toEqual(["/p-100", "new-1"]); + }); + + it("只补缺:磁盘已有的同 id 项不被内存 meta 覆盖(时间字段仍是磁盘权威值)", () => { + useProjectsStore.setState({ allSessions: [session("/p", 100)] }); + const sameId = { ...freshMeta("/p-100", "/p"), createdAt: 999, modifiedAt: 999, name: "内存名" }; + useSessionsStore.setState({ sessions: [sameId] }); + expect(useProjectsStore.getState().allSessions).toHaveLength(1); + expect(useProjectsStore.getState().allSessions[0]?.modifiedAt).toBe(100); + }); + + it("全量对账(load)不把内存里经手过的会话挤出目录", async () => { + // 磁盘上一个都没有:0 消息会话还没会话文件,重拉必然拿不到它 + piMock.listAllSessions.mockResolvedValue([]); + useSessionsStore.setState({ sessions: [freshMeta("new-1", "/p")] }); + await useProjectsStore.getState().load(); + expect(ids()).toEqual(["new-1"]); + }); + + it("被内存策略卸载后目录仍有它:左栏行不消失", () => { + // 1) 新建出来的会话进内存 → 写穿目录(此刻磁盘快照里没有它) + useSessionsStore.setState({ sessions: [freshMeta("new-1", "/p")] }); + // 2) GC 卸载 = 从内存移除(closeSession 的清理路径) + useSessionsStore.setState({ sessions: [] }); + // 3) 左栏派生照旧能渲染这一行(Sidebar 走的就是同一条装配路径) + const nav = deriveSidebarNavigation({ + history: useProjectsStore.getState().allSessions, + memory: [], + addedProjects: [], + search: "", + activeCwd: null, + pinnedSessions: ["new-1"], + pinnedProjects: [], + expandedGroups: ["/p"], + expandedGroupsTouched: true, + }); + const rows = nav.projects.flatMap((project) => project.sessions.map((row) => row.session.sessionId)); + expect(rows).toEqual(["new-1"]); + }); +}); + describe("projects.load latest-wins(spec D6)", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/desktop/src/renderer/src/stores/projects.ts b/packages/desktop/src/renderer/src/stores/projects.ts index 1d8a6e55..a49befc6 100644 --- a/packages/desktop/src/renderer/src/stores/projects.ts +++ b/packages/desktop/src/renderer/src/stores/projects.ts @@ -29,7 +29,11 @@ export interface ProjectEntry { } interface ProjectsStore { - /** 磁盘上的全部历史会话(跨项目) */ + /** + * **会话目录**(跨项目):存在性的唯一来源。内容 = 上次全量对账的磁盘历史 ∪ 本进程经手过的会话 + * (新建/打开/fork,见 `absorbOpenSessions` 的写穿)。左栏分组与顶栏置顶胶囊都只认它 + + * 内存运行态叠加,所以「内存被 GC 卸掉」不会让任何行消失。 + */ allSessions: SessionMeta[]; /** 手动添加过的项目目录(持久化 localStorage) */ addedProjects: string[]; @@ -74,6 +78,10 @@ export const useProjectsStore = create((set, get) => ({ // latest-wins:更晚的 load 已经发起 → 这份旧快照直接丢弃(loading 由最新那次收尾) if (seq !== loadSeq) return; set({ allSessions, loading: false, loaded: true }); + // 对账是**替换**语义(磁盘是存在性的权威),但内存里经手过的会话不能因此掉出目录: + // 0 消息会话此时还没有会话文件(SDK 追加首条 entry 才落盘),磁盘快照里必然没有它 —— + // 补回来才不会在随后被内存策略卸载时从 UI 消失 + absorbOpenSessions(useSessionsStore.getState().sessions); const { selectedCwd } = get(); if (!selectedCwd) { const projects = deriveProjects(get()); @@ -155,6 +163,31 @@ export const useProjectsStore = create((set, get) => ({ }, })); +/** + * 目录写穿:把内存里目录还没有的会话补进去(**只补缺、不覆盖**——磁盘权威的最小改动)。 + * + * 为什么必须有这一层:磁盘历史只在「进新会话页 / 启动」时整表重拉,是**旧账**;而新建出来的会话 + * 必然比那次快照晚(快照是在它诞生之前拉的)。这段时间里它只活在内存 `sessions` 里,靠 + * `mergeSidebarSessions` 的合并撑着左栏行。内存策略(GC)一卸载它,内存里没了、旧账里也没有 → + * 行凭空消失(2026-09-21 实测事故)。写穿把「存在性」提前落到目录,卸载就只是撤掉运行态。 + * + * 不覆盖的理由:`createdAt/modifiedAt` 等稳定字段以磁盘为准(内存 meta 可能缺字段,盖上去会让 + * 排序键掉到 createdAt、行在打开/卸载时伪移动);运行态字段本就在读取时由合并覆盖。 + */ +function absorbOpenSessions(open: readonly SessionMeta[]): void { + const state = useProjectsStore.getState(); + const known = new Set(state.allSessions.map((session) => session.sessionId)); + const missing = open.filter((session) => !known.has(session.sessionId)); + if (missing.length === 0) return; + useProjectsStore.setState({ allSessions: [...state.allSessions, ...missing] }); +} + +// 写穿接线:**只此一处** —— 任何「会话进入内存」的路径(新建/打开/fork/从历史打开)都经它落目录, +// 将来新增路径也不会漏(不依赖各调用点自觉)。放在这里是因为依赖方向就是 projects → sessions。 +useSessionsStore.subscribe((state, prev) => { + if (state.sessions !== prev.sessions) absorbOpenSessions(state.sessions); +}); + /** 项目列表 = 有历史会话的目录 ∪ 手动添加的目录(日常空间 cwd 除外,它在侧栏单独钉顶);手动添加的按添加时间倒排(最新在前),未添加过的按最后活动排后 */ export function deriveProjects(state: Pick): ProjectEntry[] { const byCwd = new Map(); diff --git a/packages/desktop/src/renderer/src/stores/sessions.test.ts b/packages/desktop/src/renderer/src/stores/sessions.test.ts index d38a7a52..a102476e 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.test.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.test.ts @@ -787,7 +787,7 @@ describe("单例新会话 draft(newSessionDraft)", () => { expect(piMock.ensureProjectTrust).toHaveBeenCalledWith({ cwd: "/proj/a" }); }); - it("从当前真实会话快照 cwd/model/thinking(全局最近值是另一套,不得采用)", () => { + it("从当前真实会话快照 cwd/model/thinking/permission(全局最近值是另一套,不得采用)", () => { useSessionsStore.setState({ sessions: [ { ...realMeta("a", "/proj/alpha"), model: { provider: "pA", modelId: "mA" }, thinkingLevel: "high" }, @@ -796,37 +796,128 @@ describe("单例新会话 draft(newSessionDraft)", () => { cwd: "/proj/alpha", lastUsedModel: { provider: "pB", modelId: "mB" }, lastUsedThinkingLevel: "low", + permissionModes: { a: "fullAccess" }, }); useSessionsStore.getState().activateNewSessionDraft(); const draft = useSessionsStore.getState().newSessionDraft; expect(draft?.cwd).toBe("/proj/alpha"); expect(draft?.model).toEqual({ provider: "pA", modelId: "mA" }); expect(draft?.thinkingLevel).toBe("high"); + expect(draft?.permissionMode).toBe("fullAccess"); }); - it("已在 draft 时再点「+」:返回同一 draft,配置与输入内容都不覆盖(离开 → 真实 switchSession → 回来)", () => { + it("已有后台 draft 时从真实会话点「+」:配置改用当前会话快照,输入内容仍保留", () => { + useSessionsStore.setState({ cwd: "/proj/old" }); + useSessionsStore.getState().activateNewSessionDraft(); + useSessionsStore.getState().setDraftCwd("/proj/old-draft"); + useDraftStore.getState().updateDraft(NEW_SESSION_DRAFT_KEY, (d) => ({ ...d, text: "半包需求" })); + useSessionsStore.setState({ + sessions: [ + { + ...realMeta("r1", "/proj/current"), + model: { provider: "current-provider", modelId: "current-model" }, + thinkingLevel: "high", + }, + ], + activeSessionId: "r1", + cwd: "/proj/current", + permissionModes: { r1: "fullAccess" }, + }); + + useSessionsStore.getState().activateNewSessionDraft(); + + const state = useSessionsStore.getState(); + expect(state.activeSessionId).toBeNull(); + expect(state.cwd).toBe("/proj/current"); + expect(state.newSessionDraft).toMatchObject({ + cwd: "/proj/current", + model: { provider: "current-provider", modelId: "current-model" }, + thinkingLevel: "high", + permissionMode: "fullAccess", + }); + expect(state.sessions.map((s) => s.sessionId)).toEqual(["r1"]); + expect(useDraftStore.getState().bySession[NEW_SESSION_DRAFT_KEY]?.text).toBe("半包需求"); + }); + + it("项目行新建:复用全局 draft,只改投 cwd,配置与输入内容都保留", () => { useSessionsStore.setState({ cwd: "/proj/a" }); useSessionsStore.getState().activateNewSessionDraft(); - useSessionsStore.getState().setDraftCwd("/proj/b"); useSessionsStore.getState().setDraftPermissionMode("fullAccess"); - useDraftStore.getState().updateDraft(NEW_SESSION_DRAFT_KEY, (d) => ({ ...d, text: "半包需求" })); - // 用户切到真实会话(真实动作,不用裸 setState 绕开要验证的路径) + useSessionsStore.setState((state) => ({ + newSessionDraft: state.newSessionDraft + ? { + ...state.newSessionDraft, + model: { provider: "p", modelId: "m" }, + thinkingLevel: "high", + } + : null, + })); + useDraftStore.getState().updateDraft(NEW_SESSION_DRAFT_KEY, (draft) => ({ + ...draft, + text: "保留这段需求", + attachments: ["src/a.ts"], + })); + + const beforeGeneration = useSessionsStore.getState().newSessionDraft?.generation; + useSessionsStore.getState().activateNewSessionDraftForCwd("/proj/b"); + + const state = useSessionsStore.getState(); + expect(state.activeSessionId).toBeNull(); + expect(state.cwd).toBe("/proj/b"); + expect(state.newSessionDraft).toMatchObject({ + cwd: "/proj/b", + model: { provider: "p", modelId: "m" }, + thinkingLevel: "high", + permissionMode: "fullAccess", + }); + expect(state.newSessionDraft?.generation).not.toBe(beforeGeneration); + expect(useDraftStore.getState().bySession[NEW_SESSION_DRAFT_KEY]).toMatchObject({ + text: "保留这段需求", + attachments: ["src/a.ts"], + }); + expect(piMock.ensureProjectTrust).toHaveBeenCalledWith({ cwd: "/proj/b" }); + expect(piMock.createSession).not.toHaveBeenCalled(); + }); + + it("项目行新建:当前没有 draft 时按指定 cwd 创建一份,并继承真实会话模型", () => { useSessionsStore.setState({ - sessions: [realMeta("r1", "/proj/a")], + sessions: [ + { ...realMeta("r1", "/proj/a"), model: { provider: "p", modelId: "m" }, thinkingLevel: "high" }, + ], activeSessionId: "r1", cwd: "/proj/a", + newSessionDraft: null, }); - useSessionsStore.getState().switchSession("r1"); + useSessionsStore.getState().activateNewSessionDraftForCwd("/proj/b"); + + const state = useSessionsStore.getState(); + expect(state.activeSessionId).toBeNull(); + expect(state.newSessionDraft).toMatchObject({ + cwd: "/proj/b", + model: { provider: "p", modelId: "m" }, + thinkingLevel: "high", + }); + }); + + it("项目行改投是最新导航:旧 cwd 的 promotion 迟到不得覆盖新 draft 或抢回焦点", async () => { + useSessionsStore.setState({ cwd: "/proj/a" }); useSessionsStore.getState().activateNewSessionDraft(); + const creating = deferred(); + piMock.createSession.mockImplementationOnce(() => creating.promise); + const oldPromotion = useSessionsStore.getState().createSession(); + + useSessionsStore.getState().activateNewSessionDraftForCwd("/proj/b"); + const newGeneration = useSessionsStore.getState().newSessionDraft?.generation; + creating.resolve(realMeta("old-created", "/proj/a")); + expect(await oldPromotion).toBe("old-created"); const state = useSessionsStore.getState(); expect(state.activeSessionId).toBeNull(); - expect(state.newSessionDraft?.cwd).toBe("/proj/b"); - expect(state.newSessionDraft?.permissionMode).toBe("fullAccess"); expect(state.cwd).toBe("/proj/b"); - expect(state.sessions.map((s) => s.sessionId)).toEqual(["r1"]); - expect(useDraftStore.getState().bySession[NEW_SESSION_DRAFT_KEY]?.text).toBe("半包需求"); + expect(state.newSessionDraft?.cwd).toBe("/proj/b"); + expect(state.newSessionDraft?.generation).toBe(newGeneration); + expect(state.sessions.map((session) => session.sessionId)).toContain("old-created"); }); it("draft 输入内容(文字/图片/slash/引用)挂在全局新会话草稿键上,再次激活不重置", () => { @@ -1320,6 +1411,7 @@ describe("不变式:新会话页(active === null)必须有可用 draft", ( expect(state.sessions.map((s) => s.sessionId)).toContain("new-1"); // 关键:不能留下「空白新会话页 + 没有 draft」的破态(否则 picker 写入静默丢失) expect(state.newSessionDraft).not.toBeNull(); - expect(state.newSessionDraft?.cwd).toBe("/proj/a"); + // 顶部新会话以点击时正在查看的 B 会话为模板。 + expect(state.newSessionDraft?.cwd).toBe("/proj/b"); }); }); diff --git a/packages/desktop/src/renderer/src/stores/sessions.ts b/packages/desktop/src/renderer/src/stores/sessions.ts index 12fad92d..6f9abb36 100644 --- a/packages/desktop/src/renderer/src/stores/sessions.ts +++ b/packages/desktop/src/renderer/src/stores/sessions.ts @@ -410,7 +410,12 @@ async function promoteDraft(draft: NewSessionDraftConfig): Promise Promise; /** - * 激活新会话页(顶栏「+」/ 启动初始化)。已有 draft 时**只激活、不覆盖任何配置** - * (`cwd` 参数只在新建一份 draft 时作为起点,避免把在选择器里选过的目录冲掉)。 + * 激活新会话页(顶栏「+」/ 启动初始化)。当前有真实会话时以它的项目/模型/思考/权限为起点; + * 已经在 draft 页时只激活、不覆盖用户选过的配置(`cwd` 参数只给未选项目的启动 draft 补起点)。 */ activateNewSessionDraft: (cwd?: string) => void; + /** + * 从项目行进入新会话:仍复用全局唯一 draft,但明确把它改投到指定 cwd。 + * 与无参数入口不同,这里 cwd 是用户显式选择,必须覆盖已有 draft 的项目。 + */ + activateNewSessionDraftForCwd: (cwd: string) => void; /** 设置新会话的目标项目目录(项目选择器);draft 没有就一直只记全局默认 */ setDraftCwd: (cwd: string) => void; /** draft 的权限档位(纯 renderer,promotion 时才应用到后端会话) */ @@ -516,17 +526,29 @@ export const useSessionsStore = create((set, get) => ({ activateNewSessionDraft: (cwd) => { const state = get(); + const activeSession = state.sessions.find((s) => s.sessionId === state.activeSessionId); + if (activeSession) { + // 从真实会话点顶部「新会话」:Codex 语义是以**当前对话**为模板,而不是回到后台 draft + // 之前记住的项目。输入内容仍挂在全局 NEW_SESSION_DRAFT_KEY 上,不会因此丢失。 + // 显式 cwd 只供启动/内部调用覆盖目录;UI 顶部入口不传,因此自然采用当前会话 cwd。 + const targetCwd = cwd ?? activeSession.cwd; + claimActivation(); + const nextDraft = makeDraft(state, { + cwd: targetCwd, + model: activeSession.model ?? state.lastUsedModel, + thinkingLevel: activeSession.thinkingLevel, + }); + nextDraft.permissionMode = state.permissionModes[activeSession.sessionId] ?? "default"; + set({ activeSessionId: null, cwd: targetCwd, newSessionDraft: nextDraft }); + ensureTrust(targetCwd); + return; + } + const existing = state.newSessionDraft; if (existing) { - // 已有 draft:只激活,不覆盖任何配置(否则会把选过的项目/模型冲掉)。 - // 只有「还没选项目」的那份(开机初始化/补种出来的,cwd === null)要继承当前上下文: - // 显式 cwd → 当前真实会话 cwd → 全局 cwd(与新建一份时的优先级一致) - const activeSession = state.sessions.find((s) => s.sessionId === state.activeSessionId); - const targetCwd = existing.cwd ?? cwd ?? activeSession?.cwd ?? state.cwd; - // 从真实会话回到新会话页 = 用户导航:先领号(在途 open/create/fork 作废)。 - // 已经在 draft 页时页面没变,**不领号**:否则 promotion 在途的结果会被误判成「用户不要它了」, - // 落地成「draft 已消费、页面还停在空白新会话页」 - if (state.activeSessionId !== null) claimActivation(); + // 已经在 draft 页:再次点击只激活,不覆盖用户在 draft 内选过的配置。 + // 只有「还没选项目」的那份(开机初始化/补种出来的,cwd === null)接受启动传入的 cwd。 + const targetCwd = existing.cwd ?? cwd ?? state.cwd; set({ activeSessionId: null, cwd: targetCwd, @@ -535,23 +557,36 @@ export const useSessionsStore = create((set, get) => ({ if (targetCwd) ensureTrust(targetCwd); return; } - const activeSession = state.sessions.find((s) => s.sessionId === state.activeSessionId); - const targetCwd = cwd ?? activeSession?.cwd ?? state.cwd; - if (state.activeSessionId !== null) claimActivation(); + + const targetCwd = cwd ?? state.cwd; set({ activeSessionId: null, cwd: targetCwd, - newSessionDraft: makeDraft(state, { - cwd: targetCwd, - // 起步值优先当前会话快照(不是全局「最近使用」);没会话来源时两项都留「未定」, - // 由 loadModels 用持久化偏好回填(thinking 不能拿占位 medium 当用户选择) - model: activeSession?.model ?? state.lastUsedModel, - thinkingLevel: activeSession?.thinkingLevel, - }), + newSessionDraft: makeDraft(state, { cwd: targetCwd }), }); if (targetCwd) ensureTrust(targetCwd); }, + activateNewSessionDraftForCwd: (cwd) => { + const state = get(); + const activeSession = state.sessions.find((s) => s.sessionId === state.activeSessionId); + // 与普通「+」不同,项目行点击即使已经在 draft 页也是一次新的导航意图: + // 淘汰在途 open/create/fork,且换 generation,避免旧 cwd 的 promotion single-flight 被复用。 + claimActivation(); + const existing = state.newSessionDraft; + draftGenerationSeq += existing ? 1 : 0; + const newSessionDraft = existing + ? { ...existing, cwd, generation: draftGenerationSeq } + : makeDraft(state, { + cwd, + model: activeSession?.model ?? state.lastUsedModel, + thinkingLevel: activeSession?.thinkingLevel, + }); + set({ activeSessionId: null, cwd, newSessionDraft }); + ensureTrust(cwd); + rememberCwd(cwd); + }, + setDraftCwd: (cwd) => { // cwd 变化即前置信任决策 ensureTrust(cwd); diff --git a/packages/desktop/src/renderer/src/stores/ui-preferences.ts b/packages/desktop/src/renderer/src/stores/ui-preferences.ts index a785ce4f..f78a7f49 100644 --- a/packages/desktop/src/renderer/src/stores/ui-preferences.ts +++ b/packages/desktop/src/renderer/src/stores/ui-preferences.ts @@ -1,7 +1,7 @@ import type { PermissionMode, UiState } from "@percho/shared"; import { create } from "zustand"; import { getPi } from "../api"; -import { toggleInList } from "../lib/sidebar-groups"; +import { toggleInList } from "../lib/toggle-in-list"; /** 应用级 UI 偏好(持久化在 ui-state.json,与主题/背景同源;主进程 normalize 负责旧文件缺省) */ interface UiPreferencesStore { diff --git a/packages/desktop/src/renderer/src/styles/globals.css b/packages/desktop/src/renderer/src/styles/globals.css index b085b845..1c55b8ca 100644 --- a/packages/desktop/src/renderer/src/styles/globals.css +++ b/packages/desktop/src/renderer/src/styles/globals.css @@ -964,6 +964,27 @@ code { .sidebar-dot-working { animation: avatar-breathe 3.2s ease-in-out infinite; } +/* 组内会话列表的边缘淡出:上下两侧各自按「那个方向还有内容」开关(属性由组件按滚动位置写), + 把硬裁切换成渐变。淡出高度是唯一旋钮;遮罩是 alpha 逆罩、不引入任何颜色,所以深浅色主题共用一套。 + #000 在这里是逆罩的「不透明」取值,不是设计色值。 */ +.sidebar-session-list { + --session-list-fade: 12px; +} +.sidebar-session-list[data-fade-top="true"] { + mask-image: linear-gradient(to bottom, transparent 0, #000 var(--session-list-fade)); +} +.sidebar-session-list[data-fade-bottom="true"] { + mask-image: linear-gradient(to top, transparent 0, #000 var(--session-list-fade)); +} +.sidebar-session-list[data-fade-top="true"][data-fade-bottom="true"] { + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--session-list-fade), + #000 calc(100% - var(--session-list-fade)), + transparent 100% + ); +} @media (prefers-reduced-motion: reduce) { .sidebar, .sidebar-inner { diff --git a/packages/shared/src/ipc-channels.ts b/packages/shared/src/ipc-channels.ts index 1ace05b9..285300bb 100644 --- a/packages/shared/src/ipc-channels.ts +++ b/packages/shared/src/ipc-channels.ts @@ -270,6 +270,12 @@ export const APP_CHANNELS = { downloadUpdate: ch("update:download")(), /** 重启并安装已下载的更新 */ installUpdate: ch("update:install")(), + /** 渲染端声明「退出确认由我接管」(App 挂载时置位)——未接管时 main 关窗直接放行 */ + setQuitGuard: ch("app:setQuitGuard")<{ enabled: boolean }, void>(), + /** 渲染端回执「退出确认窗已上屏」→ main 撤掉兑底计时(超时 = 渲染进程卡死,直接放行退出) */ + quitDialogShown: ch("app:quitDialogShown")(), + /** 用户在退出确认弹窗点了「退出」→ main 置 quitting 后真退 */ + confirmQuit: ch("app:confirmQuit")(), } as const; /** UI 插件域:配置读写 / 列表 / 构建 / 代码读取 / 目录打开(参数校验在 handler 内) */ @@ -350,6 +356,8 @@ const EVENT_CHANNELS = { UpdateEvent: "update:event", /** UI 插件事件(changed/config) */ UiPluginsEvent: "uiPlugins:event", + /** 用户点了关闭窗口且 main 已拦下(等渲染端弹退出确认);payload 为空 */ + QuitRequested: "app:quit-requested", } as const; /** 全通道字符串常量:main→renderer 事件通道 + 表化 invoke(key = PiApi 方法名) */ diff --git a/packages/shared/src/ipc.ts b/packages/shared/src/ipc.ts index d4ad1b5f..84350fbb 100644 --- a/packages/shared/src/ipc.ts +++ b/packages/shared/src/ipc.ts @@ -42,6 +42,8 @@ export interface PiApi extends InvokeApi { onUpdateEvent(cb: (state: UpdateState) => void): () => void; /** 订阅 UI 插件事件(changed/config);返回取消函数 */ onUiPluginsEvent(cb: (payload: UiPluginsEventPayload) => void): () => void; + /** 订阅「用户点了关闭窗口」(Windows 点 ✕,main 已拦下,等弹退出确认);返回取消函数 */ + onQuitRequested(cb: () => void): () => void; /** 订阅会话事件;返回取消函数 */ onEvent(cb: (payload: SessionEventEnvelope) => void): () => void; onPermissionRequest(cb: (req: PermissionRequest) => void): () => void; diff --git a/scripts/check-sidebar-group-scroll.mjs b/scripts/check-sidebar-group-scroll.mjs new file mode 100644 index 00000000..18cfee4e --- /dev/null +++ b/scripts/check-sidebar-group-scroll.mjs @@ -0,0 +1,483 @@ +/** + * 左栏分组会话列表「限高 + 组内独立滚动」CDP 验收脚本(spec §7) + * + * 前置:dev 应用带调试端口运行 + * cd packages/desktop && npx electron-vite dev -- --remote-debugging-port=9224 + * (`npm run dev -- --remote-debugging-port=9224` 不行:npm 会把参数吞掉) + * + * 用法:node scripts/check-sidebar-group-scroll.mjs(CDP_PORT 可换端口,必须是 1–65535 的纯数字) + * 退出码:0 全部断言通过 / 1 有断言失败 / 2 环境不满足(无法判定,见提示) + * + * 纪律: + * **DOM 相邻关系**定位元素,不按中文/英文标题文案匹配(分组头与会话行文字会相同); + * - 边缘淡出按 `data-fade-top` / `data-fade-bottom` 两个属性 + computed `mask-image` 判定; + * - 滚动归属用 **CDP 真实 wheel**(`Input.dispatchMouseEvent` type=mouseWheel)验证, + * 不用直接赋 scrollTop 冒充滚动(赋 scrollTop 只用于把容器预置到中部/边界); + * - 只读测量;结束时把外层与内层 scrollTop 复位为 0。 + * + * 环境前提(不满足则退出码 2,而不是给假绿): + * - 左栏外层容器本身可滚(需要足够的项目/会话); + * - 至少有一个溢出列表(> 8 行)与一个不溢出列表(≤ 8 行)。 + * 当前 dev 数据(`~/.pi/agent-dev`)默认满足;若没有,用隔离的 dev agent dir 造临时会话即可。 + */ +const EXIT_OK = 0; +const EXIT_FAIL = 1; +const EXIT_ENV = 2; + +/** 端口来自环境变量且会被拼进 URL:必须是 1–65535 的纯数字,非法值当环境错误退出 */ +const RAW_PORT = process.env.CDP_PORT ?? "9224"; +if (!/^\d+$/.test(RAW_PORT) || Number(RAW_PORT) < 1 || Number(RAW_PORT) > 65535) { + console.error(`CDP_PORT 非法(${JSON.stringify(RAW_PORT)}):应为 1–65535 的纯数字(退出码 ${EXIT_ENV})`); + process.exit(EXIT_ENV); +} +const PORT = Number(RAW_PORT); + +/** 与 SidebarSessionList 的常量一致;改那边必须同步这里(验收口径) */ +const MAX_ROWS = 8; +const ROW_HEIGHT = 31; +const PROJECT_ROW_HEIGHT = 34; +const LIST_MAX_HEIGHT = MAX_ROWS * ROW_HEIGHT; + +/* ---------- CDP 连接 ---------- */ +async function findPage(timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://127.0.0.1:${PORT}/json`, { signal: AbortSignal.timeout(3000) }); + const list = await res.json(); + const found = list.find((x) => x.type === "page" && x.webSocketDebuggerUrl); + if (found) return found; + } catch { + /* 端口未绑/非 JSON:下一轮 */ + } + await new Promise((r) => setTimeout(r, 400)); + } + throw new Error(`等不到 page target(${PORT}):dev 应用是否带 --remote-debugging-port=${PORT} 运行?`); +} + +const page = await findPage().catch((err) => { + console.error(`无法连接 CDP(退出码 ${EXIT_ENV}):${err.message}`); + process.exit(EXIT_ENV); +}); +const ws = new WebSocket(page.webSocketDebuggerUrl); +let msgId = 0; +const pending = new Map(); +ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result); + } +}; +await new Promise((r) => (ws.onopen = r)); +function send(method, params = {}) { + return new Promise((resolve, reject) => { + const id = ++msgId; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + }); +} +async function evalJs(expression) { + const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (result.exceptionDetails) throw new Error(`页面内执行失败:${JSON.stringify(result.exceptionDetails)}`); + return result.result?.value; +} + +// 窗口被遮挡时 rAF/合成器停摆:先把页面置为可见 +await send("Emulation.setFocusEmulationEnabled", { enabled: true }); + +/* ---------- 断言记录 ---------- */ +const results = []; +function check(name, ok, detail) { + results.push({ name, ok: ok === true }); + console.log(`${ok ? "PASS" : "FAIL"} · ${name} · ${detail}`); +} +function note(text) { + console.log(` ${text}`); +} + +/* ---------- 页面侧读取 ---------- */ +const READ_SNAPSHOT = `(() => { + const root = document.querySelector("[data-sidebar-scroll-root]"); + if (!root) return JSON.stringify({ error: "no-scroll-root" }); + const lists = [...root.querySelectorAll("[data-sidebar-session-list]")]; + return JSON.stringify({ + root: { + clientH: root.clientHeight, + scrollH: root.scrollHeight, + clientW: root.clientWidth, + scrollW: root.scrollWidth, + top: root.scrollTop, + }, + lists: lists.map((el, i) => { + const cs = getComputedStyle(el); + const rows = [...el.querySelectorAll("[data-session-id]")]; + // 分组标题行:用 DOM 相邻关系(分组根 div 里的 aria-expanded 按钮),不按文案匹配 + const title = el.parentElement ? el.parentElement.querySelector("button[aria-expanded]") : null; + return { + i, + scrollable: el.dataset.scrollable === "true", + maxHeight: cs.maxHeight, + overflowY: cs.overflowY, + overflowX: cs.overflowX, + overscrollY: cs.overscrollBehaviorY, + clientH: el.clientHeight, + scrollH: el.scrollHeight, + clientW: el.clientWidth, + scrollW: el.scrollWidth, + top: el.scrollTop, + rowCount: rows.length, + rowHeights: rows.map((r) => Math.round(r.getBoundingClientRect().height)), + titleHeight: title ? Math.round(title.getBoundingClientRect().height) : null, + titleInsideList: !!el.querySelector("button[aria-expanded]"), + }; + }), + }); +})()`; + +function snapshot() { + return evalJs(READ_SNAPSHOT).then(JSON.parse); +} + +/** 所有可滚容器(外层 + 各内层)的当前位置,用于判断某次手势影响了谁 */ +function positions() { + return evalJs(`(() => { + const root = document.querySelector("[data-sidebar-scroll-root]"); + const lists = [...root.querySelectorAll("[data-sidebar-session-list]")]; + return JSON.stringify({ outer: root.scrollTop, lists: lists.map((l) => l.scrollTop) }); +})()`).then(JSON.parse); +} + +function reset() { + return evalJs(`(() => { + const root = document.querySelector("[data-sidebar-scroll-root]"); + root.scrollTop = 0; + for (const l of root.querySelectorAll("[data-sidebar-session-list]")) l.scrollTop = 0; + return true; +})()`); +} + +/** 把某个内层预置到中部(用于「非边界位置」的手势;不是用它代替 wheel) */ +function presetListTop(index, cssExpr) { + return evalJs(`(() => { + const l = document.querySelectorAll("[data-sidebar-session-list]")[${index}]; + l.scrollTop = ${cssExpr}; + return l.scrollTop; +})()`); +} + +/** 目标中心点(必须落在外层容器可视区内,否则手势会落到别的元素上) */ +async function centerOf(selectorExpr) { + return JSON.parse( + await evalJs(`(() => { + const root = document.querySelector("[data-sidebar-scroll-root]"); + const el = ${selectorExpr}; + if (!el) return JSON.stringify(null); + const a = root.getBoundingClientRect(); + const r = el.getBoundingClientRect(); + const top = Math.max(r.top, a.top); + const bottom = Math.min(r.bottom, a.bottom); + return JSON.stringify({ + cx: r.left + r.width / 2, + cy: (top + bottom) / 2, + visibleHeight: bottom - top, + }); + })()`), + ); +} + +/** 等滚动停止(平滑滚动/惯性都要落定后再读数) */ +async function settle(timeoutMs = 900) { + const deadline = Date.now() + timeoutMs; + let prev = JSON.stringify(await positions()); + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 90)); + const cur = JSON.stringify(await positions()); + if (cur === prev) return; + prev = cur; + } +} + +/** 真实 wheel:指针先移到目标上(命中区决定滚动链),再派发 wheel */ +async function wheel(selectorExpr, deltaY, times = 1) { + const c = await centerOf(selectorExpr); + if (!c || c.visibleHeight < 8) { + throw new Error(`目标不可见,无法投递 wheel:${selectorExpr} → ${JSON.stringify(c)}`); + } + await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: c.cx, y: c.cy, button: "none" }); + for (let i = 0; i < times; i++) { + await send("Input.dispatchMouseEvent", { + type: "mouseWheel", + x: c.cx, + y: c.cy, + deltaX: 0, + deltaY, + pointerType: "mouse", + }); + await new Promise((r) => setTimeout(r, 70)); + } + await settle(); +} + +const listExpr = (i) => `document.querySelectorAll("[data-sidebar-session-list]")[${i}]`; +const titleExpr = (i) => + `document.querySelectorAll("[data-sidebar-session-list]")[${i}].parentElement.querySelector("button[aria-expanded]")`; + +/* ---------- 1. 结构与尺寸契约 ---------- */ +console.log("=== 1. 结构与尺寸 ==="); +const snap = await snapshot(); +if (snap.error === "no-scroll-root") { + console.error( + `FAIL · 左栏没有 [data-sidebar-scroll-root]:Sidebar 的改动没进包/没生效(退出码 ${EXIT_ENV})`, + ); + ws.close(); + process.exit(EXIT_ENV); +} +await reset(); +const lists = snap.lists; +note( + `外层容器 clientH=${snap.root.clientH} scrollH=${snap.root.scrollH};展开的组(有列表容器的)= ${lists.length} 个`, +); +check( + "外层滚动容器存在且自身可滚(环境前提)", + snap.root.scrollH > snap.root.clientH, + `clientH ${snap.root.clientH} / scrollH ${snap.root.scrollH}`, +); +check( + "外层无横向滚动", + snap.root.scrollW === snap.root.clientW, + `scrollW ${snap.root.scrollW} / clientW ${snap.root.clientW}`, +); + +for (const l of lists) { + const label = `列表#${l.i}(${l.rowCount} 行)`; + check( + `${label} 上限 248px、纵向可滚、横向隐藏`, + l.maxHeight === `${LIST_MAX_HEIGHT}px` && l.overflowY === "auto" && l.overflowX === "hidden", + `maxHeight ${l.maxHeight} / overflowY ${l.overflowY} / overflowX ${l.overflowX}`, + ); + check( + `${label} 无横向溢出(滚动条不造成宽度跳变)`, + l.scrollW === l.clientW, + `scrollW ${l.scrollW} / clientW ${l.clientW}`, + ); + check( + `${label} 会话行仍为 31px`, + l.rowHeights.length > 0 && l.rowHeights.every((h) => h === ROW_HEIGHT), + `行高 ${[...new Set(l.rowHeights)].join("/") || "无行"}`, + ); + check( + `${label} 项目/日常标题行 34px 且在滚动容器之外`, + l.titleInsideList === false && l.titleHeight === PROJECT_ROW_HEIGHT, + `标题行 ${l.titleHeight}px / 在列表容器内=${l.titleInsideList}`, + ); + check( + `${label} contain 与 data-scrollable 同源(${l.scrollable ? "溢出挂 contain" : "不溢出留 auto"})`, + l.scrollable ? l.overscrollY === "contain" : l.overscrollY === "auto", + `data-scrollable=${l.scrollable} / overscroll-behavior-y=${l.overscrollY}`, + ); + if (l.scrollable) { + check( + `${label} clientHeight=248 且 scrollHeight>clientHeight`, + l.clientH === LIST_MAX_HEIGHT && l.scrollH > l.clientH, + `clientH ${l.clientH} / scrollH ${l.scrollH}`, + ); + } else { + check( + `${label} 无内部可滚范围(自然高度,不制造空白)`, + l.scrollH === l.clientH, + `clientH ${l.clientH} / scrollH ${l.scrollH}`, + ); + } +} + +/* ---------- 环境前提:溢出/不溢出两类列表都要有 ---------- */ +const overflow = lists.filter((l) => l.scrollable); +const fit = lists.filter((l) => !l.scrollable); +if (overflow.length === 0 || fit.length === 0) { + console.error( + `FAIL · 环境不满足:需要至少一个 > ${MAX_ROWS} 行的组(现有 ${overflow.length} 个)与一个 ≤ ${MAX_ROWS} 行的组` + + `(现有 ${fit.length} 个)。请在 dev agent dir(~/.pi/agent-dev/sessions)造临时会话后重跑(退出码 ${EXIT_ENV})`, + ); + ws.close(); + process.exit(EXIT_ENV); +} +note( + `溢出列表 ${overflow.length} 个(#${overflow.map((l) => l.i).join("/")})、不溢出列表 ${fit.length} 个(#${fit.map((l) => l.i).join("/")})`, +); + +/* ---------- 2. 真实 wheel:内层中部 / 内层边界 / 标题行 ---------- */ +console.log("\n=== 2. 真实 wheel 的滚动归属 ==="); +for (const l of overflow) { + const label = `列表#${l.i}(${l.rowCount} 行)`; + + // a) 内层中部:只有内层动 + await reset(); + await presetListTop(l.i, "60"); + let before = await positions(); + await wheel(listExpr(l.i), 50); + let after = await positions(); + check( + `${label} 内层中部 wheel → 只滚内层`, + after.lists[l.i] > before.lists[l.i] && after.outer === before.outer, + `内层 ${before.lists[l.i]}→${after.lists[l.i]}(max ${l.scrollH - l.clientH})/ 外层 ${before.outer}→${after.outer}`, + ); + + // b) 内层到底继续滚:不穿透 + await reset(); + await presetListTop(l.i, "99999"); + before = await positions(); + await wheel(listExpr(l.i), 120, 2); + after = await positions(); + check( + `${label} 内层到底继续 wheel → 外层不动(不穿透)`, + after.lists[l.i] === before.lists[l.i] && after.outer === 0, + `内层 ${before.lists[l.i]}→${after.lists[l.i]} / 外层 ${before.outer}→${after.outer}`, + ); + + // c) 内层到顶继续上滚:不穿透 + await reset(); + await evalJs(`document.querySelector("[data-sidebar-scroll-root]").scrollTop = 40`); + await presetListTop(l.i, "0"); + before = await positions(); + await wheel(listExpr(l.i), -120, 2); + after = await positions(); + check( + `${label} 内层到顶继续上滚 → 外层不动(不穿透)`, + after.lists[l.i] === 0 && after.outer === before.outer, + `内层 ${before.lists[l.i]}→${after.lists[l.i]} / 外层 ${before.outer}→${after.outer}`, + ); + + // d) 组标题行(内层之外):只滚外层 + await reset(); + before = await positions(); + await wheel(titleExpr(l.i), 60); + after = await positions(); + check( + `${label} 标题行 wheel → 只滚外层`, + after.outer > before.outer && after.lists.every((t, i) => t === before.lists[i]), + `外层 ${before.outer}→${after.outer} / 各内层 ${before.lists.join(",")}→${after.lists.join(",")}`, + ); +} + +// e) 不溢出的列表:不挂 contain,滚轮自然交给外层 +for (const l of fit) { + await reset(); + const before = await positions(); + await wheel(listExpr(l.i), 60); + const after = await positions(); + check( + `列表#${l.i}(${l.rowCount} 行,不溢出)内 wheel → 交给外层`, + after.outer > before.outer, + `外层 ${before.outer}→${after.outer} / 内层 ${before.lists[l.i]}(max 0)`, + ); +} + +/* ---------- 3. 边缘淡出(上下渐隐)---------- */ +console.log("\n=== 3. 边缘淡出 ==="); + +/** 读取淡出状态:属性 + computed mask + 可调变量 + 淡出条内是否还能命中会话行 */ +function readFade(index) { + return evalJs(`(() => { + const root = document.querySelector("[data-sidebar-scroll-root]"); + const el = document.querySelectorAll("[data-sidebar-session-list]")[${index}]; + const cs = getComputedStyle(el); + const r = el.getBoundingClientRect(); + const a = root.getBoundingClientRect(); + // 在顶部淡出条里(盒子顶 +4px)取一点,看命中是不是还在列表里 + const y = Math.min(r.top + 4, a.bottom - 4); + const hit = document.elementFromPoint(r.left + 60, y); + return JSON.stringify({ + fadeTop: el.dataset.fadeTop, + fadeBottom: el.dataset.fadeBottom, + mask: cs.maskImage, + fadeVar: cs.getPropertyValue("--session-list-fade").trim(), + hitInsideList: !!hit && el.contains(hit), + }); + })()`).then(JSON.parse); +} + +/** 遮罩“有作用”的判据:非 none 且含一个完全透明的色站(不依赖算出的具体序列化顺序) */ +const maskActive = (mask) => mask !== "none" && mask.includes("rgba(0, 0, 0, 0)"); + +for (const l of overflow) { + const label = `列表#${l.i}(${l.rowCount} 行)`; + + let s = await (async () => { + await reset(); + return readFade(l.i); + })(); + check( + `${label} 静止在顶:只下边缘渐隐(还有内容在下面)`, + s.fadeTop === "false" && s.fadeBottom === "true" && maskActive(s.mask), + `fadeTop=${s.fadeTop} fadeBottom=${s.fadeBottom} mask=${s.mask.slice(0, 46)}…`, + ); + check( + `${label} 淡出高度来自 CSS 变量(非硬编码在类名里)`, + /^[\d.]+px$/.test(s.fadeVar) && Number.parseFloat(s.fadeVar) > 0, + `--session-list-fade=${s.fadeVar || "(未定义)"}`, + ); + + s = await (async () => { + await evalJs( + `(() => { const el = document.querySelectorAll("[data-sidebar-session-list]")[${l.i}]; el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2); return true; })()`, + ); + await settle(); + return readFade(l.i); + })(); + check( + `${label} 滚到中部:上下都渐隐`, + s.fadeTop === "true" && s.fadeBottom === "true" && maskActive(s.mask), + `fadeTop=${s.fadeTop} fadeBottom=${s.fadeBottom}`, + ); + check( + `${label} 淡出条内的会话行仍可命中(遮罩只画不挡)`, + s.hitInsideList === true, + `elementFromPoint(淡出条内) 落在列表内=${s.hitInsideList}`, + ); + + s = await (async () => { + await evalJs( + `(() => { const el = document.querySelectorAll("[data-sidebar-session-list]")[${l.i}]; el.scrollTop = el.scrollHeight; return true; })()`, + ); + await settle(); + return readFade(l.i); + })(); + check( + `${label} 滚到底:只上边缘渐隐`, + s.fadeTop === "true" && s.fadeBottom === "false" && maskActive(s.mask), + `fadeTop=${s.fadeTop} fadeBottom=${s.fadeBottom}`, + ); +} + +for (const l of fit) { + const s = await (async () => { + await reset(); + return readFade(l.i); + })(); + check( + `列表#${l.i}(${l.rowCount} 行,不溢出)无淡出:两面都干净`, + s.fadeTop === undefined && s.fadeBottom === undefined && s.mask === "none", + `fadeTop=${s.fadeTop} fadeBottom=${s.fadeBottom} mask=${s.mask}`, + ); +} + +/* ---------- 4. 收尾:复位 ---------- */ +await reset(); +const end = await positions(); +check( + "收尾:外层与各内层 scrollTop 已复位为 0", + end.outer === 0 && end.lists.every((t) => t === 0), + `外层 ${end.outer} / 内层 ${end.lists.join(",")}`, +); + +ws.close(); +const failed = results.filter((r) => !r.ok); +console.log(`\n${results.length - failed.length}/${results.length} PASS`); +if (failed.length > 0) { + console.log("失败项:"); + for (const f of failed) console.log(` - ${f.name}`); + process.exit(EXIT_FAIL); +} +process.exit(EXIT_OK);