diff --git a/README.md b/README.md
index 146f4dc7..d1c452ee 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,10 @@
-
+
+
+
+
-percho
+Percho
Highly customizable desktop GUI for the Pi coding agent — the same engine as the Pi CLI, in a clean visual interface. Multi-session chat, visual tool approvals, built-in subagents, UI plugins, and custom themes.
@@ -22,7 +25,11 @@
## Demo
-
+
+
+
+
+

@@ -34,13 +41,9 @@

-**Settings — providers & models**
-
-
+## Why Percho?
-## Why percho?
-
-percho embeds the official Pi SDK (`@earendil-works/pi-coding-agent`) in the Electron main process. It is **not a fork and not a reimplementation** — it runs the same engine as the Pi CLI and inherits Pi's native strengths:
+Percho embeds the official Pi SDK (`@earendil-works/pi-coding-agent`) in the Electron main process. It is **not a fork and not a reimplementation** — it runs the same engine as the Pi CLI and inherits Pi's native strengths:
- **Extensibility** — TypeScript extensions, skills, and prompt templates installed for the Pi CLI work here too, including project-local ones (with a trust prompt before loading). Adapt Pi to your workflows, no forking required.
- **Shared configuration** — same `~/.pi/agent/` directory as the CLI: sessions, auth, and model settings carry over. Start a session in the terminal, continue it in the GUI.
@@ -50,15 +53,15 @@ And for those who prefer a GUI over a TUI:
- Highly customizable UI — swap tool-call cards, drop in desk-pet overlays (two whale-maid pets ship built in), or extend the settings panel via UI plugins
- Visual permission gates — approve or deny each tool call from a dock, backed by a per-tool rule engine
-- Draggable multi-session tabs (plus an optional session rail), per-session composer drafts, follow-up queue with undo
+- A collapsible project/session sidebar, draggable pinned-session pills, per-session composer drafts, and a follow-up queue with undo
- Built-in subagents — a scout plus your own agent definitions, parallel task fan-outs, and run cards you can click to inspect the sub-session read-only
- Context evaporation (on by default) — stale tool outputs age into compact stubs, keeping long sessions within budget
-- Unified error system — in-chat error cards with one-click retry, an auto-retry status line, and a crash-proof renderer
-- Solid session workspace — fork any message, recall your own message back into the composer, todo panel, per-turn diff sidebar, slash-command menu and @-file completion
+- Unified error system — in-chat error cards with one-click retry, an auto-retry status line, and full-page renderer crash recovery
+- Solid session workspace — fork from assistant turns or selected context, recall your own message back into the composer, todo panel, per-turn diff sidebar, slash-command menu and @-file completion
- Streaming markdown rendering, image previews, message copy
- Agent-initiated image display — a built-in `show_image` tool lets the agent deliberately show you images inline (single or grouped), without turning every tool result into noise
- Custom background image with adjustable overlay dimming, light/dark/system themes
-- LAN observer — watch a session read-only from a phone or tablet browser via QR code
+- LAN companion — monitor sessions from a phone or tablet browser via QR code; optionally enable remote prompts, stop generation, and allow-once/deny approval decisions
## Download
@@ -83,10 +86,6 @@ Prebuilt installers are published on the [Releases](https://github.com/Jaxton07/
>
> On Linux: make the AppImage executable before first launch (`chmod +x percho-linux-x86_64.AppImage`). On Ubuntu 22.04+/24.04+ and derivatives, install `libfuse2` first — AppImages mount via FUSE 2, which is no longer preinstalled. Linux builds download and install updates in-app.
-## Configuration
-
-API keys are never stored in this repo or written into the app bundle. `~/.pi/agent/models.json` references environment variables (e.g. `$AI_OPS_API_KEY`) and keys stay in your shell environment. If you already use the Pi CLI, your existing setup just works.
-
## Development
Prerequisites: **Node.js >= 22.19**.
@@ -102,7 +101,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. If you are in China a
## Disclaimer
-percho is a community project. It is **not** built by or affiliated with the Pi team (earendil-works).
+Percho is a community project. It is **not** built by or affiliated with the Pi team (earendil-works).
## License
diff --git a/README.zh.md b/README.zh.md
index 15dee594..8bb0bcf2 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -1,7 +1,10 @@
-
+
+
+
+
-percho
+Percho
高度自定义的 Pi coding agent 桌面端 GUI —— 与 Pi CLI 同源同引擎,干净清爽的视觉界面。多会话聊天、可视化工具审批、内置子代理、UI 插件、自定义主题。
@@ -22,7 +25,11 @@
## 演示
-
+
+
+
+
+

@@ -34,13 +41,9 @@

-**设置页 —— 模型与 Provider**
-
-
+## 为什么选择 Percho?
-## 为什么选择 percho?
-
-percho 把官方 Pi SDK(`@earendil-works/pi-coding-agent`)跑在 Electron 主进程里。**不是 fork,也不是重新实现** —— 它和 Pi CLI 用的是同一套引擎,完整继承 Pi 的原生优势:
+Percho 把官方 Pi SDK(`@earendil-works/pi-coding-agent`)跑在 Electron 主进程里。**不是 fork,也不是重新实现** —— 它和 Pi CLI 用的是同一套引擎,完整继承 Pi 的原生优势:
- **可扩展性** —— 为 Pi CLI 安装的 TypeScript 扩展、Skills、Prompt 模板在这里同样生效,包括项目级资源(加载前会有信任确认)。让 Pi 适应你的工作流,无需 fork。
- **配置共享** —— 与 CLI 共用 `~/.pi/agent/` 目录:会话、认证、模型配置全部互通。终端里开的会话,可以在 GUI 里继续。
@@ -50,16 +53,15 @@ percho 把官方 Pi SDK(`@earendil-works/pi-coding-agent`)跑在 Electron
- 高度自定义界面 —— UI 插件可替换工具调用卡、添加桌宠浮层(内置鲸鱼娘 + Q 版两只)、扩展设置面板
- 可视化权限审批 —— 在底部审批坞里逐个批准/拒绝工具调用,背后是逐工具的规则引擎
-- 多会话顶栏标签(可拖拽排序,可选左侧会话轨道)、逐会话输入草稿、可撤销的跟进消息队列
+- 可折叠的项目/会话侧栏、可拖拽排序的顶部置顶会话、逐会话输入草稿、可撤销的跟进消息队列
- 内置子代理 —— 自带 scout 与自定义 agent 定义、并行任务拆分,点开运行卡片即可只读检视子会话
- 上下文蒸发(默认开启)—— 到龄的工具输出自动蒸发为紧凑 stub,长会话不超预算
-- 视觉代理 —— 纯文本模型遇到图片时,由视觉模型先识别成描述再交给 LLM
-- 统一报错系统 —— 对话内错误卡一键重试、自动重试状态行、全屏崩坏兜底
-- 扎实的会话工作台 —— 任意消息分叉、撤回自己的消息回输入框、todo 面板、逐轮 diff 侧栏、斜杠命令面板、@ 文件补全
+- 统一报错系统 —— 对话内错误卡一键重试、自动重试状态行、渲染进程全屏崩溃恢复
+- 扎实的会话工作台 —— 从助手回复或选中的上下文分叉、撤回自己的消息回输入框、todo 面板、逐轮 diff 侧栏、斜杠命令面板、@ 文件补全
- 流式 Markdown 渲染、图片预览、消息复制
- Agent 主动发图 —— 内置 `show_image` 工具让 agent 在需要时把图片(单张或成组)直接显示到对话区,而不是把所有工具结果都变成噪音
- 自定义背景图与遮罩透明度,浅色/深色/跟随系统主题
-- 局域网观察 —— 手机/平板浏览器扫二维码即可只读查看会话进度
+- 局域网伴侣 —— 手机/平板浏览器扫码查看会话;可选开启远程发送、停止生成,以及允许一次/拒绝权限请求
## 下载
@@ -84,10 +86,6 @@ percho 把官方 Pi SDK(`@earendil-works/pi-coding-agent`)跑在 Electron
>
> Linux:首次运行前给 AppImage 加执行位(`chmod +x percho-linux-x86_64.AppImage`);Ubuntu 22.04+/24.04+ 及衍生发行版需先安装 `libfuse2`(AppImage 依赖 FUSE 2 挂载,新版系统默认未装)。Linux 版在应用内下载并安装更新。
-## 配置
-
-API key 不会存入本仓库,也不会打进安装包。`~/.pi/agent/models.json` 通过环境变量引用(如 `$AI_OPS_API_KEY`),key 只存在于你的 shell 环境中。如果你已经在用 Pi CLI,现有配置开箱即用。
-
## 开发
前置要求:**Node.js >= 22.19**。
@@ -103,7 +101,7 @@ npm workspaces monorepo,三个包:`packages/shared`(IPC 契约)、`packa
## 声明
-percho 是社区项目,**并非** Pi 团队(earendil-works)官方出品,也与其无任何隶属关系。
+Percho 是社区项目,**并非** Pi 团队(earendil-works)官方出品,也与其无任何隶属关系。
## License
diff --git a/docs/INDEX.md b/docs/INDEX.md
index ecb3e0db..ea6ef02a 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -80,7 +80,7 @@ src/
├── slash-commands.ts 斜杠命令清单(纯函数)
├── log.ts 结构化日志
├── lan/ 局域网观察:config / projector / server(+ audit / sanitize)
-├── session/ registry / naming / messages / trace+traces / event-slim+stream-guard / rates / ui-context / extension-dialog-host
+├── session/ registry / meta / single-flight / naming / messages / trace+traces / event-slim+stream-guard / rates / ui-context / extension-dialog-host
├── permissions/ index(barrel) / bash-chain / pattern / config / tmp-zone / gate / extension / audit
├── project/ trust / trust-loader / workspace-store / files
├── settings/ settings / model-prefs / login / quota
@@ -90,12 +90,14 @@ src/
| 文件 | 关键导出 | 职责 |
|---|---|---|
-| `src/pi-backend.ts` | `PiBackend` | **门面**:create/open/close/delete/prompt(followUp 排队,preflight 回执见 `use-composer-send`)/abort/fork/recall/compact/stats/listModels(附 `thinkingLevels`/`imageInput`)/会话权限模式(`registry entry.modeRef` 内存态 + get/setSessionPermissionMode,随 buildExtensionFactories 闭包注入扩展)/事件与权限·信任分发(emitter.ts 统一原语,9 emitter)(respondPermission **先 gate.respond 放行再持久化**,持久化失败只 log 不挂会话)。`buildCustomTools(gate, preferBuiltin)` 注册 webfetch+show_image+todo+subagent(preferBuiltin 优先级:构造参数 > model-prefs 实时值 > true);`buildExtensionFactories` 注册序 = context 钩子链序:权限门控 → 上下文蒸发 → channel-watch → todo-reminder(最后,注入不被折叠);subagent 子会话 `noExtensions`。**频道订阅快照**(`channelSubscriptionIds`:`reportChannelSubscriptions` 由 channel-watch 扩展回调维护、`getChannelSubscriptionSessionIds` 供 renderer GC 查询,只返回仍在 registry 的 ID;dispose 兜底清理)+ **`closeSession(id, intent)`**:缺省/`user` = 用户意图(仅 streaming 守卫);`gc`(renderer 自动卸载)额外拒绝有订阅的会话。`sessions-subagents/` 下会话记 readOnly,prompt/fork/recall/setModel 一律 throw |
+| `src/pi-backend.ts` | `PiBackend` | **门面**:create/open/close/delete/prompt(followUp 排队,preflight 回执见 `use-composer-send`)/abort/fork/recall/compact/stats/listModels(附 `thinkingLevels`/`imageInput`)/会话权限模式(`registry entry.modeRef` 内存态 + get/setSessionPermissionMode,随 buildExtensionFactories 闭包注入扩展)/事件与权限·信任分发(emitter.ts 统一原语,9 emitter)(respondPermission **先 gate.respond 放行再持久化**,持久化失败只 log 不挂会话)。**`openSession` 幂等**:`resolve()` 规范化路径 → `KeyedSingleFlight`(同文件并发只构造一次)+ **读 header 取 sessionId 命中 registry 即短路返回现有 meta**(在 `getModelRuntime`/资源加载/扩展构造之前,不重复 subscribe/trace);`wireSession` 里 registry.add 冲突(并发/别名路径最后防线)会把刚构造的 unsubscribe/gate/dialogs/session 全 dispose 后重抛。`buildCustomTools(gate, preferBuiltin)` 注册 webfetch+show_image+todo+subagent(preferBuiltin 优先级:构造参数 > model-prefs 实时值 > true);`buildExtensionFactories` 注册序 = context 钩子链序:权限门控 → 上下文蒸发 → channel-watch → todo-reminder(最后,注入不被折叠);subagent 子会话 `noExtensions`。**频道订阅快照**(`channelSubscriptionIds`:`reportChannelSubscriptions` 由 channel-watch 扩展回调维护、`getChannelSubscriptionSessionIds` 供 renderer GC 查询,只返回仍在 registry 的 ID;dispose 兜底清理)+ **`closeSession(id, intent)`**:缺省/`user` = 用户意图(仅 streaming 守卫);`gc`(renderer 自动卸载)额外拒绝有订阅的会话。`sessions-subagents/` 下会话记 readOnly,prompt/fork/recall/setModel 一律 throw |
| `src/json-store.ts` | `JsonStore`、`JsonStoreCorruptedError` | 统一 JSON 持久化:tmp+rename 原子写;read 损坏回退默认、update 损坏抛 CorruptedError 拒写;async 版 per-path 队列串行化、sync 变体热路径用;缓存/normalize 不进本层 |
| `src/slash-commands.ts` | `BUILTIN_SLASH_COMMANDS` | 内置静态表(compact/name/export/settings)+ 模板/skill/扩展命令映射(纯函数) |
| `src/log.ts` | `createLogger`、`initLogging` | 结构化日志:按天落盘 `main-<本地日期>.log`(`PI_LOG_LEVEL`/`PI_LOG_DIR`) |
| `src/emitter.ts` | `Emitter` | 泛型订阅/分发:subscribe 返回退订、emit per-handler try-catch、clear/size(canAsk 探测);PiBackend 9 套事件管线共用 |
-| `src/session/registry.ts` | `SessionRegistry`、`RegisteredSession` | sessionId → AgentSession 单条记录(session/unsubscribe/cwd/readOnly + **gate/dialogs/modeRef 会话级状态随 entry 生命周期**);delete/disposeAll 做 entry 级清理 |
+| `src/session/registry.ts` | `SessionRegistry`、`RegisteredSession` | sessionId → AgentSession 单条记录(session/unsubscribe/cwd/readOnly + **gate/dialogs/modeRef 会话级状态随 entry 生命周期**);`add` 同 entry 幂等、**不同 entry 同 sessionId 抛错(不静默覆盖,防旧实例订阅/gate/dialogs 泄漏)**;delete/disposeAll 做 entry 级清理;`toMeta` 时间字段走 `session/meta.ts` |
+| `src/session/meta.ts` | `deriveSessionTimes`、`fallbackSessionTimes` | **会话时间字段唯一口径**(与 SDK `buildSessionInfo` 对齐):`createdAt` = session header 时间(**不是** 文件 birthtime/mtime——复制/恢复文件会改它);`modifiedAt` = user/assistant 消息最大活动时间(message 数值 timestamp 优先、entry timestamp 兜底、无消息=created,custom/toolResult 一律不算);header 读不出来才 `fallbackSessionTimes`(文件 mtime → 当前时刻) |
+| `src/session/single-flight.ts` | `KeyedSingleFlight` | 按 key 的并发 single-flight:同 key 在途复用同一个 Promise,settle(成败都算)后**只清自己那条**(旧 Promise 不删后继请求);`PiBackend.openSession` 按**规范化绝对路径**用它,保证同一会话文件并发 open 只构造一次 |
| `src/settings/quota.ts` | `QuotaService`、`makeQuotaService` | opencode-go 套餐额度:官方 API + 5min TTL 缓存(无 key null / HTTP 失败带 error 空窗体) |
| `src/lan/projector.ts` | `SessionProjection`、`seedProjection`、`applyEvent`、`deriveView` | LAN 会话投影 = **sanitize 事件流驱动的 shared reducer 态**(单一事实源)+ 非 reducer 底座(name/cwd/stats/lastActivity/pendingPermission);agentActive/todos/currentTool/assistantTail/lastError 全派生,无第二状态机 |
| `src/lan/sanitize.ts` | `sanitizeSessionEvent/Message` | LAN 出网净化;事件白名单 = shared `REDUCED_EVENT_TYPES` − LAN 排除集(auto_retry_*/stream_guard_tripped,单一事实源派生) |
@@ -127,7 +129,7 @@ src/
| `src/settings/login.ts` | `LoginService` | provider 交互登录桥接:AuthInteraction → IPC 事件(prompt 挂起等 renderer 应答;浏览器先到则拒挂起 prompt)。支持 OAuth + api_key 交互登录(如 Google Vertex);`filterAuthSelectOptions` 对 google-vertex 剔除必败的 api-key 选项(Vertex 不接受 API key,见 PITFALLS) |
| `src/packages/admin.ts` | `PackageAdmin` | 社区包搜索/安装/卸载/已配置清单 + 装卸后对非流式会话热重载(对齐 CLI /reload);npm ENOENT 转带哨兵的可读错误 |
| `src/packages/catalog.ts` | `fetchPackageCatalog` | pi.dev 目录抓取:无 JSON API,解析 SSR HTML 的 `` |
-| `src/lan/server.ts` | `LanObserverServer` | 局域网只读观察:userData 配置 + token 轮换、GET-only HTTP+SSE(timingSafeEqual、5 客户端上限、delta 微批合帧);projectEvent = 投影更新与帧广播同点(快照带 in-flight 容器,重连无缝) |
+| `src/lan/server.ts` | `LanObserverServer` | 局域网观察与可选远程控制:userData 配置 + token 轮换、HTTP+SSE(timingSafeEqual、5 客户端上限、delta 微批合帧);远程发送/停止/允许一次或拒绝由独立 `remoteControl` 开关保护;projectEvent = 投影更新与帧广播同点(快照带 in-flight 容器,重连无缝) |
**可观测性**:每会话事件 trace + 关键操作日志(create/open/close/prompt/abort/compact);main 进程还监听 renderer 崩溃/unresponsive/console。排查 UI 状态问题:`npx tsx scripts/replay-trace.mts --last` 确定性复现。
@@ -175,17 +177,18 @@ 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` 去重、内存覆盖历史、历史保序;draft 与刚创建还没落盘的会话由此进左栏)+ `deriveSidebarGroups`(合并结果 + 项目表 → 日常/项目分组、置顶排序、搜索过滤、展开态推断)+ `toggleInList`(置顶切换)+ `toggleExpandedGroup`(第 4 参 `touched` 定起点:没操作过用派生层默认集,已操作过用用户记录——**空数组合法表示全部折叠**)。「项目」小标 v7 起不可折叠,不再有 `PROJECTS_GROUP_KEY` 这类专用 key |
-| `lib/session-gc.ts` | 会话内存驻留策略(**纯函数,有单测**):`pickUnloadCandidates`(保护/草稿/活跃/刚用过 → 剔除;剩余按最久未用排序,保留 K 个,超的与晾过 `idleTimeoutMs` 的一并作候选)+ `isProtected`(保护条件唯一事实源:draft / **有频道订阅(`hasChannelSubscriptions`,订阅 = 明确驻留语义,卸载会停 watcher)** / 0 消息 / agentActive / 待审批 / 待应答 / 未读绿点 / 压缩中 / 排队跟发;**权限模式曾在清单里,D7 改成按会话持久化+打开时恢复后已移除**)+ `GC_DEFAULTS`(K=3、fresh **3s**、idle 5min)。不 import React / 不调 IPC |
+| `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/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;draft 态纯 renderer,转正 ensureSession 应用到后端)。draft 会话(`draft:` 前缀,空 tab 重启消失;发首条消息 `ensureSession` 用 draft cwd 原地转正);信任前置 ensureProjectTrust(trustVersion 触发重拉);打开(含从历史按需打开)/fork/撤回统一 `loadSessionBundle` 四件套(history→queue→todos→permissionMode 对齐后端);**`selectBarSessions`(v8)算顶栏展示集 = 严格置顶表(meta 从 tabs → `allSessions` 兜底;draft 不再进顶栏,导航与丢弃都在左栏)**;**v10 起不持久化打开列表**——启动纯空(新会话页),会话只在你点开时才加载,退出即清(与 pi 原生 `AgentSessionRuntime` 的"单会话按需替换"对齐);切会话/打开/新建三处调 `rememberCwd` 记住**上次项目目录**(`lastCwd`,只记目录不恢复会话);**`loadSessionBundle` 末尾按 `sessionPermissionModes` 恢复档位(await IPC,保证「打开完即正确档位」)**;**会话内存驻留策略**:`lastUsedAt` LRU 打点 + `unloadSession`(自动卸载专用入口,走 `closeSession(id, intent:"gc")` 让后端区分自动 GC 与用户意图;受保护会话由纯策略层排除),接线在 `lib/use-session-gc.ts`(App 挂一次) |
+| `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/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)按会话持久 + `COMPOSER_FOCUS_EVENT`(撤回回填后聚焦输入框) |
+| `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` | 会话历史与项目表(`deriveProjects`:手动添加的按时间倒排;`deriveSessions`:置顶分区在左 + 其余按最后活动倒序;`deleteSession` / `deleteProject` / `openSession` / `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` | 会话历史与项目表(`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 溢出折叠) |
| `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()`(文案改这里,双字典) |
@@ -198,9 +201,9 @@ src/
| `chat/` | **MessageList**(底部跟随 + 脱离回底;行模型 `useMemo`,轮末行定位规则在 shared chat-rows;**挂载窗口**见 `mount-window.ts`)/ **SelectionToolbar**(对话区选中文字浮出菜单:添加到对话/新会话继续)/ **MessageItem**(纯分发壳)+ `message-actions.tsx`(复制/Fork/撤回按钮)/ **UserMessage** / **SystemMessage**(compaction 分割线 + mutex 通知)/ **AssistantMessage** / **Markdown**(markstream-react 流式丝滑渲染,fade 关闭避免合成层闪烁;代码块只读编辑器关闭自动 decoration,样式覆写在 globals.css `.markdown-body`,见 PITFALLS)+ **MermaidBlock**(接管 markstream 的 mermaid code_block:loose 模式 + 悬浮工具栏 + 渲染失败报错卡;见 PITFALLS)/ **ToolCallCard** / **SubagentRunCard**(独立行,有 sessionFile 可点开子会话)/ **TodoPanel**(呼吸灯 + 展开 morph 同一容器)/ **MetaGroup**(memo 折叠组 + 圆点行/分类统计行)+ `use-sweep-highlight`(统一扫光)+ `use-shown-working`(working→worked 滞后缓冲)/ **PreviewTicker** + `activity-ticker`(工作中预览行调度)/ **StreamingMarquee**(溢出 tail-follow,位移用 shared marquee-motion)/ **ImagePreview**(全屏多图,portal 到 body)/ **ErrorNote**(统一报错卡)+ **RetryNote**(自动重试瞬态行)/ **CenterOrb** + `center-orb-draw`(中央状态动画,绘制闭式函数)/ **TurnDiffChip**(轮末计时行 + 文件变更 chip;计时器运行中 1s 心跳跳动/定格;文件行右键 + hover「⋯」→ 路径菜单 `ui/FilePathMenu`)/ `meta-summary-label`(i18n 胶水) |
| `diff/` | **useDiffSidebarOverlay**(宽度账本纯计算 + rAF 合帧 resize 监听:可用宽不够就转浮层)/ **DiffSidebar**(右侧变更侧栏:按轮分组 unified diff + 内嵌 BranchRow git 分支行;开关在 SessionTabBar;`cwd` 下传给卡片做相对路径基准)+ DiffFileCard(卡片头右键 + hover「⋯」→ 路径菜单) |
| `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` 定形态(draft / 普通 / 无菜单)、`draftSessionMenuItems` = 左栏 draft 专属最小菜单(只「丢弃新会话」,纯本地 `closeSession`)、只读会话不给菜单、置顶只动 `pinnedSessions`、重命名落盘(**同步 tabs 与历史列表两份拷贝**)+失败 toast、复制诊断、删除会话;顶栏胶囊与左栏会话行共用)/ **ProjectBranchPicker**(从 `projects/` 移入的 draft 态项目/分支选择器 chip)/ **DockSlot**(底部交换槽仲裁:权限 > 扩展对话框 > Composer)/ **ApprovalDock**(权限审批:async respond 成功才移除面板,失败保留重试)/ **InteractionDock**(扩展对话框四卡 select/input/editor/confirm:键盘 ↑↓/Enter/数字/Esc + 倒计时显示,裁决在 backend)/ **TrustDialog**(项目信任两选项)/ **UpdateButton**(顶栏更新按钮) |
+| `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 数据(**历史 + 内存会话经 `mergeSidebarSessions` 合并**)→ 调 `lib/sidebar-groups` 派生 → 分发 props,240↔0 push 过渡 + 收起时 `inert`)/ **SidebarHeader**(只有搜索框——v9 顶栏常驻,不再需要「顶栏关掉时补拖拽带 + +」)/ **SidebarGroup**(日常与各项目共用的分组骨架:分组行 + 可折叠会话列表 + 空态)/ **ProjectRow**(图标表达展开态:项目 = folder ↔ folder-open、日常 = 恒咖啡 + 行末 hover 箭头;hover 出 «⋯»(锚在**指针点**)、置顶标记、移除项目二次确认)/ **SessionRow**(无头像文字 + 行尾状态点 + 右键菜单锚点;**draft 行标题固定「新会话」**(`sidebar.newSession`))/ **SessionMenu**(会话行右键三层状态机:菜单 → 改名气泡 → 删除确认;draft 走 `draftSessionMenuItems`)/ **ProjectSection**(「项目」小标:**v7 起固定标题不可折叠** + 常显「+」)/ **SidebarFooter**(设置;顶栏关闭时补「本轮改动」)/ **ProjectMenu**(项目 «⋯» 菜单项 builder)/ `useExpandedGroups`(展开态读写:起点由 `expandedGroupsTouched` 决定;写入时 store 同一次 set + 单个补丁置 touched) |
+| `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/✕ = 取消,文案与按钮由调用方给) |
| `icons/` | 内联 SVG 集中管理;`icons.test.ts` 校验所有 path `d` 语法(防残缺数据被 Chromium 丢弃) |
@@ -211,6 +214,8 @@ src/
| 开屏动画(粒子光团 → 散场) | `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 手稿构造) |
+| APP / LAN 图标 | SVG 母版 `packages/desktop/build/icon.svg` → 桌面打包入口 `build/icon.png`(1024px RGBA);LAN/PWA 同图缩至 512px、pngquant 后内联在 `main/lan-icon.ts`;方向稿 `.local/design/pi-logo/index.html` |
+| README 顶部横幅 | 正式浅/深 SVG:`docs/assets/img/readme-hero-{light,dark}.svg`,`README.md` / `README.zh.md` 用 `` 随系统主题切换;生成器 `.local/design/readme-header-v2/build.mjs` 直接读取空白页 `WordmarkConstruct.tsx` 的同一份字标 SVG |
| 报错卡 / 错误分类 | `chat/ErrorNote.tsx` + globals.css(`.error-note*`/`.retry-note`/`.send-error`/`.toast` 段)+ `shared/src/errors.ts`(classifyLlmError 模式表 + 信封构造)+ i18n `error.*`;severity 色改 `--color-err/warn/info`(深浅各一份) |
| 工作中预览行 / 状态动画 | `chat/PreviewTicker.tsx` + `activity-ticker.ts`(minDwellMs 350)+ `MetaGroup.tsx`(状态行 orb + liveItems)+ `use-sweep-highlight.ts`(统一扫光)+ `use-shown-working.ts`(1500ms 滞后缓冲)+ `CenterOrb.tsx`/`center-orb-draw.ts`(中央版,开关 ui-preferences)+ shared `transcript/meta-summary.ts`(圆点行/统计行) |
| 输入框 / 发送 / 停止 / 排队 | `composer/Composer.tsx`(装配层)+ `use-composer-send.ts`(发送/停止/取回排队);草稿持久在 `stores/drafts.ts`;排队事件 `queue_update` + getFollowUpMessages |
@@ -218,24 +223,24 @@ src/
| 思考深度切换 | `composer/ThinkingPicker.tsx` + `stores/sessions.ts` + shared `src/thinking.ts`(档位常量单一事实源)+ `lib/thinking.ts`(clamp 共用;后端 SDK 内再 clamp 兜底) |
| 图片附件(选图/粘贴/预览/门控) | `composer/Composer.tsx`(images state + Ctrl+V + imageInput 门控 fail-open)+ `chat/MessageItem.tsx`(历史缩略图)+ `chat/ImagePreview.tsx`(全屏预览三处共用);事件流提取在 shared reducer,历史回放在 backend `toSessionMessages` |
| show_image 发图 | 工具本体 `backend/src/tools/show-image.ts`;实时 = shared reducer(pendingImages 缓冲,turn_end 固化排 assistant 之后);历史 = `toSessionMessages` 的 `role:"image"`;渲染 = MessageItem image 分支(缩略图按数量分档) |
-| subagent 独立行 | 工具 `backend/src/tools/subagent/`;提取 shared `src/subagent.ts`(extractSubagentRuns,结构检测不依赖工具名);互斥通知 `subagent_mutex` → reducer system 消息(dedup by extensionPath);渲染 `chat/SubagentRunCard.tsx`(行内 11px ink-faint 展示实际思考档位) |
-| 斜杠命令 | 面板 `composer/SlashMenu.tsx` + `slash-filter.ts` + `use-slash-menu.ts`;命令表 backend `slash-commands.ts`(draft 态走 listSlashCommandsForCwd);**模板/skill/扩展命令 SDK 原生展开无需代码**;`/settings` 定位走 settings store `openWith()` |
+| subagent 独立行 | 工具 `backend/src/tools/subagent/`;提取 shared `src/subagent.ts`(extractSubagentRuns,结构检测不依赖工具名);互斥通知 `subagent_mutex` → reducer system 消息(dedup by extensionPath);渲染 `chat/SubagentRunCard.tsx`(行内 11px ink-faint 展示实际思考档位;有 `sessionFile` 的行可点 → `openFromHistory` 打开**只读**检视页)。**检视会话只从导航投影过滤**(`lib/session-visibility.ts`):它留在 store `sessions` 里,但不进左栏分组/计数/搜索与顶栏胶囊 |
+| 斜杠命令 | 面板 `composer/SlashMenu.tsx` + `slash-filter.ts` + `use-slash-menu.ts`;命令表 backend `slash-commands.ts`(**新会话页(无 active)按 `cwd` 走 `listSlashCommandsForCwd`,真实会话按 `sessionId`**);**模板/skill/扩展命令 SDK 原生展开无需代码**;`/settings` 定位走 settings store `openWith()` |
| 上下文压缩 UI | compaction_start/end → shared reducer 生成 system 消息 + compacting 位(**压缩期间 Composer 禁发**,SDK 拒绝压缩中的 prompt);渲染 `chat/SystemMessage.tsx`(分割线,done 可展开摘要);手动 `/compact [focus]`(focus 拼入摘要 prompt);**压缩后 UI 历史完整保留**(SDK 只裁 LLM 上下文,jsonl 完整,reducer 只追加分界线) |
| 上下文蒸发 | backend `tools/context-evaporation/`(见 backend 表);开关 = 设置 GeneralPanel 二态(默认蒸发,写 settings.json 单 key,2s 生效免重开);调参 `scripts/replay-evaporation.mts`;观测 = log `context-evaporation` 行 + trace_custom |
| 上下文用量圆环 | `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 没开也在顶栏,点击自动开;draft 不进顶栏)+ `stores/ui-preferences.ts` `reorderPinned`(拖拽改 `pinnedSessions` 顺序) |
+| 顶栏 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` |
| 顶栏内容 / 显隐 | 顶栏**常驻**(窗口拖动、左栏开合、变更侧栏入口都在上面,v9 起不再整条隐藏)→ `App.tsx` 无条件渲染 `SessionTabBar`;设置项「顶栏显示会话」(`settings/AppearancePanel.tsx` → `barSessionsVisible`)只控制**要不要出置顶会话胶囊**:关掉后顶栏不出胶囊、也不出空态提示,会话全在左栏(v9 已删旧版的三处兜底:左栏头 44px 拖拽带 + 「+」、聊天列顶 12px 隐形拖拽带、左栏底部「本轮改动」入口) |
| 右栏(push ↔ 窄窗浮层) | `components/diff/DiffSidebar.tsx` + `useDiffSidebarOverlay.ts`(宽度账本:可用宽 < 380 + min(420, 38vw) → 转浮层)+ `globals.css` 的 `.diff-sidebar.is-overlay` 段;开关在顶栏(顶栏关闭时在左栏底部) |
| 权限审批面板 | `backend/src/permissions/gate.ts`(队列)+ `session/ApprovalDock.tsx`(快捷键 Enter/A/D/Esc;await 成功才移除) |
| 扩展交互(issue #45) | 契约 `shared/src/extension-dialog.ts` → `backend/src/session/extension-dialog-host.ts`(队列+裁决)→ `main/ipc/` 转发 → `stores/transcript.ts` pendingDialogs → `session/InteractionDock.tsx`;notify→`stores/toasts.ts` pushExtension;setEditorText→草稿+预填提示(Composer) |
-| 会话权限模式(默认/完全访问) | 后端:`pi-backend.ts`(`permissionModes` map 内存态 + `get/setSessionPermissionMode`)+ `permissions/extension.ts`(fullAccess 审计分支)+ `permissions/audit.ts`;IPC:`PermissionGetMode/SetMode`;前端:`composer/PermissionPicker.tsx`(chip)+ `stores/sessions.ts`(`permissionModes` map,draft 态转正在 ensureSession 应用)+ `use-composer-send.ts`。**模式不写进 permissions.json、不被新会话继承(spec D1);D7:按会话持久化到 ui-state.json 的 sessionPermissionModes(只存非 default,切回默认即删键),打开会话时 await 恢复——重启/内存回收后重开同一会话保住原档位,新会话与 fork 仍从 default 起步** |
+| 会话权限模式(默认/完全访问) | 后端:`pi-backend.ts`(`permissionModes` map 内存态 + `get/setSessionPermissionMode`)+ `permissions/extension.ts`(fullAccess 审计分支)+ `permissions/audit.ts`;IPC:`PermissionGetMode/SetMode`;前端:`composer/PermissionPicker.tsx`(chip;新会话页读写 `newSessionDraft.permissionMode`)+ `stores/sessions.ts`(真实会话走 `permissionModes` map,promotion 成功后把 draft 档位应用到新 id)+ `use-composer-send.ts`。**模式不写进 permissions.json、不被新会话继承(spec D1);D7:按会话持久化到 ui-state.json 的 sessionPermissionModes(只存非 default,切回默认即删键),打开会话时 await 恢复——重启/内存回收后重开同一会话保住原档位,新会话与 fork 仍从 default 起步** |
| 逐工具权限规则 | `backend/src/permissions/`(求值链在 extension.ts:deny → 临时区 → 多根边界读写分离 → 项目记忆 → ask)+ `project/workspace-store.ts`;enabled=false 只能手改 permissions.json(UI 无入口的逃生舱);设置页工作区根管理 UI 未实现,手改 workspaces.json |
-| 项目信任 | backend `project/trust.ts` + `trust-loader.ts`;触发点 `stores/sessions.ts`(createDraftSession/setDraftCwd)与 `stores/projects.ts`(addProject);弹窗 `session/TrustDialog.tsx` |
-| 日常空间(非项目闲聊维度) | main `daily.ts`(目录)+ IPC `app:getDailyDir` → renderer `lib/daily.ts`(缓存/判定);侧栏钉顶分组 `components/sidebar/*`(`lib/sidebar-groups.ts` 里 `isDailyCwd` 的会话归「日常」组,label 取 i18n `projects.daily`);隔离 = `stores/projects.ts` deriveProjects 过滤 + deleteProject 守卫(有 projects.test.ts);空态 chip `session/ProjectBranchPicker.tsx`(下拉钉顶「日常」项,draft 日常 ↔ 项目双向切换);胶囊咖啡头像 `SessionTabBar.tsx`(余态白底黑字,状态色优先);设计稿 `.local/design/ux/daily-space/` |
+| 项目信任 | backend `project/trust.ts` + `trust-loader.ts`;触发点 `stores/sessions.ts`(activateNewSessionDraft/setDraftCwd)与 `stores/projects.ts`(addProject);弹窗 `session/TrustDialog.tsx` |
+| 日常空间(非项目闲聊维度) | main `daily.ts`(目录)+ IPC `app:getDailyDir` → renderer `lib/daily.ts`(缓存/判定);侧栏钉顶分组 `components/sidebar/*`(`lib/sidebar-groups.ts` 里 `isDailyCwd` 的会话归「日常」组,label 取 i18n `projects.daily`);隔离 = `stores/projects.ts` deriveProjects 过滤 + deleteProject 守卫(有 projects.test.ts);空态 chip `session/ProjectBranchPicker.tsx`(下拉钉顶「日常」项,新会话页可在 日常 ↔ 项目 双向切换);胶囊咖啡头像 `SessionTabBar.tsx`(余态白底黑字,状态色优先);设计稿 `.local/design/ux/daily-space/` |
| 会话分叉 / 撤回 | backend `forkSession`/`recallMessage`(目标解析在 session/messages.ts;**agent 运行或压缩期间均拒绝**);renderer `stores/sessions.ts`(fork 新 tab 打开并返回新 sessionId;recall 草稿回填 + COMPOSER_FOCUS_EVENT)+ `chat/message-actions.tsx`(ForkButton 挂轮次末段正文/RecallButton 挂用户气泡,运行/压缩期间禁用) |
-| 对话区选中引用 / 引用胶囊 | 弹出菜单 `chat/SelectionToolbar.tsx`(selectionchange 缓存 + mouseup 显示;菜单 onMouseDown preventDefault 保选区;readOnly 不弹,busy 禁 fork)→ 草稿 `quotes: string[]`;胶囊 `composer/QuoteChip.tsx`;发送拼接 `composer/quote.ts`(buildQuoteBlock 置最前 blockquote);「新会话继续」= forkSession 末条 assistant(entryId 优先/sourceText 兑底)→ 写新会话 draft |
+| 对话区选中引用 / 引用胶囊 | 弹出菜单 `chat/SelectionToolbar.tsx`(selectionchange 缓存 + mouseup 显示;菜单 onMouseDown preventDefault 保选区;readOnly 不弹,busy 禁 fork)→ 草稿 `quotes: string[]`;胶囊 `composer/QuoteChip.tsx`;发送拼接 `composer/quote.ts`(buildQuoteBlock 置最前 blockquote);「新会话继续」= forkSession 末条 assistant(entryId 优先/sourceText 兑底)→ 写新会话页输入区(`__new__` 草稿) |
| 长会话渲染性能(切会话卡顿 / 首屏挂载量) | 窗口策略 `chat/mount-window.ts`(纯函数 + 测试;常量在 `chat/MessageList.tsx`);**补挂的视口锚定交给浏览器滚动锚定**(容器 `overflow-anchor` 保持默认,别加 `none`),手写补偿只兜底 `scrollTop === 0` 时浏览器不锚定的情况(按旧首行视口位置漂移补差);工具卡溢出测量调度器在 `chat/ToolCallCard.tsx`(共享测量队列 + 共享 ResizeObserver)。背景与实测数字见 PITFALLS「长会话切会话卡顿」(含 0.5.7 上滑被拽回底部那次二次修复) |
| todo 面板 + compaction 恢复 | 工具 `tools/todo.ts` + 恢复注入 `tools/todo-reminder.ts` + 读取 `getTodos`;UI `chat/TodoPanel.tsx` + `stores/ui.ts` todoExpanded;reducer 提取 `tool_execution_end`;打开会话恢复 = loadSessionBundle 后 loadTodos |
| 会话自动命名 | `backend/src/session/naming.ts`;手动重命名 = 胶囊右键 → `session:setName`(活跃会话走 SDK `setSessionName` 发事件;历史会话离线写文件见 `backend/src/session/rename.ts`)+ `session/RenamePopover.tsx` |
diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md
index 86e8bd30..076c010a 100644
--- a/docs/PITFALLS.md
+++ b/docs/PITFALLS.md
@@ -38,6 +38,8 @@
| 右键菜单贴边溢出视口、滚动后浮层脱锚 | 四 · 右键菜单定位与脱锚(2026-09-17) |
| Google Vertex 填了 key 仍 401「API keys are not supported by this API」 | 二 · Vertex 只支持 ADC/服务账号(api_key 路径必败,桥接层已剔除 api-key 选项) |
| 跨会话频道里对方迟迟不查收、回复总晚一整轮(实施在改文件、review 却在跑回归) | 二 · sendUserMessage 默认 followUp = 等对方 turn 结束才投递(2026-09-17) |
+| 新会话页的 picker 改动「点了没反应」、promotion 用的项目/模型和页面显示的不是一回事 | 五 · 新会话页不变式:active=null ⇒ 必有 draft,且 cwd 严格镜像 draft.cwd(2026-09-20) |
+| CDP 验收脚本里想注入 IPC 失败/统计调用次数,改写 `window.pi` 却毫无反应 | 五 · contextBridge 暴露的 API 在页面里只读(2026-09-20) |
| 逐帧截图全是空白/同一张陈旧图、rAF 像停摆 | 四 · 合成器空帧与「暂停动画不出新帧」(2026-09-19 补) |
| 验证脚本读出「旋转没生效」(`transform: none`)但界面明明转了 | 四 · Tailwind 4 的 `rotate-*` 走 `rotate` 属性不是 `transform`(2026-09-19) |
| 脚本里手动删了 React 的节点,随后整页「界面出现异常」(removeChild 报错) | 四 · 别手拆 React 管理的 DOM(含 portal 浮层)(2026-09-19) |
@@ -54,6 +56,10 @@
| 打开模型选择器后整页向左偷跑、左栏与顶栏左侧按钮被挤/裁切 | 四 · absolute 弹层越界 + autoFocus = 整页横向偷跑(2026-09-20) |
| CDP 量测得出「弹层在视口内、也没滚动」但界面明明错位(量错元素) | 四 · 同章节「量测三纪律」(2026-09-20) |
| 量测脚本报「draft 没进左栏」,实际是我的选择器点到了分组头 | 四 · 同章节「量测三纪律」→ 侧栏行选择器(2026-09-20) |
+| 点一下历史会话行,它在左栏里跳到别处(卸载后又跳回) | 四 · 内存 meta 覆盖历史 meta = 排序键漂移到 createdAt(2026-09-20) |
+| 快速连点两行,界面停在先点的那一行(或过一会才被抢回) | 四 · 异步导航必须 latest-wins(令牌 + 共享 open pipeline)(2026-09-20) |
+| 同一会话文件并发 open 后订阅/扩展/trace 翻倍、旧实例泄漏 | 二 · openSession 幂等:registry 短路 + single-flight + add 不静默覆盖(2026-09-20) |
+| 复制/恢复过会话文件后,它在列表里的时间/位置全变了 | 二 · 同章节「birthtime 不是会话创建时间」 |
## 一、事故复盘(含可复用诊断手法)
@@ -149,6 +155,29 @@ LAN 页重连/中途进入时,快照种子经 `messagesToUIMessages` 重建—
**本期未覆盖的已知限制(缺口)**:轮询降级模式的删除检测(`readdir` 快照里不含被删文件)与 watcher root 目录被删后重建——这两种异常场景下 watcher 会**漏事件**(订阅会话仍在,但那一次变化不会投递),不影响本期主故障(会话被卸载导致订阅整个消失)的验收,但它们是真实缺口、本期明确不修,不要当成「不是 bug」。
+### openSession 幂等:registry 短路 + single-flight + `add` 不静默覆盖(2026-09-20,sidebar-session-switch-stability)
+
+症状:同一个会话文件被重复打开(renderer 双击同一行、sidebar 与「子代理跑卡」两条路同时开、别处再调一次 open)后,这个会话的**订阅/扩展/trace 全变成两份**;关闭它只 dispose 掉后注册的那一份,前一份变成没人能关的活会话(内存、watcher、事件转发都翻倍)。查日志能看到同一 sessionId 的 `session opened` 出现两次。
+
+根因有两层,都要堵:
+
+1. `SessionRegistry.add()` 原来是 `Map.set` —— **静默覆盖**旧 entry,旧实例没人再持有引用,永远不会被 dispose;
+2. `PiBackend.openSession()` 每次都从头构造 AgentSession,**从不回头看 registry**(哪怕这个 sessionId 已经在内存里)。
+
+对策(三层,缺一层都可能漏):
+
+- `openSession` 用 **`resolve(filePath)` 规范化绝对路径做 key** 跑 single-flight(同一文件并发 open 共享一次构造;settle 后必清 key → 失败可重试);
+- 取到 header 拿到 sessionId 后**先查 registry,命中就直接返回现有 entry 的 meta**(`toMeta`)——短路必须放在 `getModelRuntime()`/资源 loader/扩展构造**之前**,否则照样白构造一遍;
+- `registry.add()` 改成同 entry 幂等、**不同 entry 同 sessionId 抛错**;`wireSession` 里接住这个错,把刚构造的 `unsubscribe/gate.dispose/dialogs.dispose/session.dispose` 全做掉再重抛(这是并发/别名路径的最后防线,不能只抛错把资源漏出去)。
+
+### birthtime 不是会话创建时间(2026-09-20,sidebar-session-switch-stability)
+
+症状:复制/恢复/迁移过会话文件(或从别的机器拷回来)后,这个会话在列表里的“创建时间”变成今天、置底或置顶、排序也乱;另一个更隐蔽的版本是我们把“内存活跃会话”的 meta 直接覆盖磁盘 meta,**一打开某个会话它就从列表当前位置跳走**(详情见四 · 内存 meta 覆盖历史 meta)。
+
+根因:`statSync(file).birthtimeMs` 被当成 `createdAt` 用。birthtime 是**文件诞生时间**,copy/restore 就变,跟会话本身没关系;而 SDK 自己用的是 session header 的 `timestamp`(`buildSessionInfo()`:`created = header.timestamp`、`modified = user/assistant 消息最大活动时间`)。
+
+对策:活跃会话的时间字段只从**会话内容**取(header + entries,见 `backend/src/session/meta.ts`),`stat`/`Date.now()` 只在 header 读不出来时兜底,且**兜底也优先 mtime 而不是 birthtime**。自己实现枚举时,`custom`/`toolResult` 类 entry 一律不算活动——否则 channel cursor 之类的扩展写入会把会话顶到最前。
+
## 三、构建 · 打包 · 环境
### electron-vite dev 主进程 watcher 不可依赖:改 `src/main/` 后必须验产物(2026-09-17 实测)
@@ -421,6 +450,31 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b
- **滚动/改变窗口尺寸就关菜单**(而不是重定位):祖先滚动容器可能有很多层,跟踪成本远大于收益;不关会「菜单挂在原地、触发元素跑了」。
- `preventDefault()` 在 `contextmenu` 里必写(否则同时弹系统菜单);dnd-kit 的 `PointerSensor` 只认主键,右键不会误触发拖拽。
+### 内存 meta 覆盖历史 meta = 排序键漂移到 createdAt,行会“自己跳”(2026-09-20,sidebar-session-switch-stability)
+
+症状:点开左栏某条历史会话,那一行**当场移到别处**(项目里第 3 行 → 第 22 行);等它被自动卸载(或关掉再开另一个),又跳回原位。用户描述成“点一下行就乱跳”。
+
+根因:左栏数据来自两份来源合并且**同 ID 后者整体覆盖前者**——磁盘历史(有完整 `modifiedAt`)与内存活跃会话(当时只有 `createdAt`,且是文件 birthtime)。合并且丢掉 `modifiedAt` 后,排序键 `modifiedAt ?? createdAt` 就从「最后活动时间」掉到「当时才诞生的 birthtime」——于是行位置随“打没打开”变。
+
+对策:
+
+- 同 ID 合并要分字段:运行态字段(name/model/active/messageCount)以内存为准,但 **`createdAt` 取历史权威值、内存缺 `modifiedAt` 时保留历史值**(`lib/sidebar-groups.ts` 的 `mergeSessionMeta`);
+- 更根上的一步是**让内存 meta 自己带正确的 `modifiedAt`**(后端 `toMeta` 改从 header/entries 算,见二 · birthtime 那条)——两层都要,上游漏一个字段下游不该跟着错;
+- 回归验收不要只看“值对不对”:**记录点击前后这一行的 `top` 与序号**(CDP 读 `[data-session-id]` 顺序即可),比对比快照更能发现“位置漂移”。
+
+### 异步导航必须 latest-wins(令牌 + 共享 open pipeline)(2026-09-20,sidebar-session-switch-stability)
+
+症状:快速点两行(或点一行再切回已加载的会话),界面**停在先点的那一行**——第一次点击的 `openSession` 后到,把刚切走的 `activeSessionId` 又写了回去;极端情况下还会把 `cwd` 一起带回旧项目。
+
+根因:所有导航入口都是 `await IPC → set({activeSessionId, cwd, lastUsedAt})`,没有任何措施区分“这是不是我最后一次点击带来的结果”。更阴的一点:`createSession` 不返回新会话 id、调用方改写 `activeSessionId` 取回它(为了拿权限模式/发首条消息)——一旦 latest-wins 生效,调用方就会拿到**别人的** sessionId(消息和权限模式发错会话),所以「改导航语义」必须同步审计所有调用点。
+
+对策:
+
+- 导航动作**入口就领**一个进程内单调令牌(不能等 IPC 回来才领,否则表达不了点击顺序);异步返回时只有令牌仍是最新才写 `activeSessionId/cwd`,过期的只落数据(meta 进 tabs、bundle 装载);同步切换(`switchSession`)领号即可瞬间作废所有在飞的 open/create/fork;
+- **同一文件共享一个 open pipeline**(模块级 in-flight Map,key = 规范化路径):双击只发一次 IPC、失败只 toast 一次、settle 后必清 key(否则失败后永远重试不了);bundle 装载再按 sessionId 去重(open 完成时 meta 会先落进 tabs,用户再点那一行走的是懒加载,两条路会撞车);
+- **异步创建/分叉要把新 id 作为返回值交给调用方**,禁止“创建完读 active 拿 id”;
+- 自动卸载(GC)的“关完成”要**复检 active**:关的过程中用户可能刚好选中了这个会话(判定“可卸”时它还不是 active),此时应当**重新打开后端会话**而不是把它从 UI 抹掉——只重建 backend、不重载磁盘历史(否则 idle transcript 会被历史快照覆盖),不写 `activeSessionId`、不领新令牌。
+
## 五、工程纪律
### renderer 单测跑在 node 环境:测不了 i18n 与返回 JSX 的模块函数(2026-09-20)
@@ -429,7 +483,7 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b
原因:本仓 vitest 无 config(`electron.vite.config.ts` 不被 vitest 读取),环境是默认的 **node**:无 DOM/localStorage,且 esbuild 把 `.tsx` 编译成 `React.createElement`(classic)→ 一调就炸(上游:应用构建预期 automatic,两边 JSX runtime 不一致)。
-对策(本期采用):**把决策抽成不碰 JSX 的纯函数再测**(如 `sidebarMenuKind()`、`discardDraft()`),JSX 菜单项本身交给 CDP 手测(真跑一遍比单测更接近用户行为)。若真需要渲染测试,得单独引入 jsdom + `esbuild: { jsx: "automatic" }`(新增 `packages/desktop/vitest.config.ts`),**别为一个 builder 就改全局测试环境**。
+对策(本期采用):**把决策抽成不碰 JSX 的纯函数再测**(如 `sidebarMenuKind()`、`canOpenSessionMenu()`;当时举例的 `discardDraft()` 已随单例 draft 重构删除,见 2026-09-20 单例 draft 条目),JSX 菜单项本身交给 CDP 手测(真跑一遍比单测更接近用户行为)。若真需要渲染测试,得单独引入 jsdom + `esbuild: { jsx: "automatic" }`(新增 `packages/desktop/vitest.config.ts`),**别为一个 builder 就改全局测试环境**。
### 别用 `npm run lint | tail -2` 判断「lint 通过」(2026-09-20)
@@ -452,6 +506,33 @@ pgrep -f "electron-vite" | xargs -r kill -9
另外同时起两个 dev 实例时,只有**先启动**那个能绑上调试端口(后起的静默失败);排查前先 `ps -eo pid,lstart,command | grep MacOS/Electron` 数一下进程。
+### 新会话页不变式:`activeSessionId === null` ⇒ 必有 draft,且 cwd 严格镜像 draft.cwd(2026-09-20,单例 draft 重构)
+
+症状(本仓单例 draft 重构中连栽三轮 review,都是同一个根因的不同侧面):
+
+1. 新会话页(`activeSessionId === null`)却没有 draft —— 此时模型/思考/权限 picker 会把选择写进「不存在的东西」,用户看到**点了没反应**(静默丢失)。
+ 触发路径:关掉最后一个真实会话、promotion 迟到且用户此刻正停在新会话页。
+2. 有 draft 但 `store.cwd` 与 `draft.cwd` 分叉 —— 页面/左栏 `activeCwd` 按 A 项目展开,promotion 却按 B 项目建会话;`draft.cwd === null`(还没选项目)时更隐蔽:store.cwd 回退成了上个会话的项目。
+
+原因:把「当前页面的配置真相」和「当前会话」当成两份独立状态维护。`cwd` 尤其容易分叉 —— 它是 store 的全局字段,同时又由 draft 承载。
+
+对策(现已落地,改动 store 时守住):
+
+- **唯一来源**:新会话的 cwd/模型/思考/权限只存在 `newSessionDraft` 里;`store.cwd` 在新会话页**严格镜像 `draft.cwd`(含 null,不回退到别的会话)**。
+- **不变式要主动维护**:store 初始化即建一份 draft;`closeSession` 关掉最后一个会话、promotion 消费掉 draft 后若用户仍在新会话页,都必须**立刻补种**一份。
+- **写 `cwd` 的所有路径都得盘一遍**:本仓共 6 处(init / switchSession / openFromHistory / promotion 成功与迟到 / activateNewSessionDraft / setDraftCwd),当时只有 `closeSession` 漏了。
+- 单测里直接把这三者(`activeSessionId` / `draft.cwd` / `store.cwd`)**一起断言**,别只断言其中一个。
+
+### contextBridge 暴露的 API 在页面里只读:CDP 验收脚本注不进 IPC 失败(2026-09-20)
+
+症状:想在 CDP 验收里 `window.pi.createSession = ...` 注入失败/数调用次数 —— 赋值**静默无效**(`window.pi.createSession === orig` 仍成立),也 `Object.defineProperty` 不了(`{ writable: false, configurable: false }`)。同理 `getPi()` 所在的 ESM 模块命名空间也改不动。
+
+对策:
+
+- **失败路径**改用「受控失败路径」:例如把新会话 draft 的 `cwd` 置成 null(= 用户还没选项目)走 `createSession()` 的 null 返回分支,或直接在单测里 mock。**别指望在页面里拦 IPC**。
+- **成功路径的 IPC 计数**:数主进程日志(本仓 backend 每次建会话会打 `session created`,脚本按行数取差),或用应用的 store 状态断言(页面内 `await import("/src/stores/*.ts")` 拿到的就是应用在用的同一份实例)。
+- 需要「点真实子代理卡」这类依赖模型行为的场景,优先找**历史回放**入口:历史里已有子代理运行的父会话,打开它 → transcript 重放出卡片 → 点它,同样落到只读检视页,且完全确定性(子会话文件在 `/sessions-subagents/`,后端按路径判定 `readOnly`)。现场真跑一次 subagent 依赖凭证可用,dev 里常 401 拿不到可点卡片。
+
### 绝不打印/提交 API key
`models.json` 用环境变量引用(`$AI_OPS_API_KEY`),key 由用户自持。
diff --git a/docs/assets/img/chat_001.png b/docs/assets/img/chat_001.png
new file mode 100644
index 00000000..47eedd62
Binary files /dev/null and b/docs/assets/img/chat_001.png differ
diff --git a/docs/assets/img/chat_002.png b/docs/assets/img/chat_002.png
new file mode 100644
index 00000000..c4437707
Binary files /dev/null and b/docs/assets/img/chat_002.png differ
diff --git a/docs/assets/img/chat_003.png b/docs/assets/img/chat_003.png
new file mode 100644
index 00000000..a5abad39
Binary files /dev/null and b/docs/assets/img/chat_003.png differ
diff --git a/docs/assets/img/demo-chat.gif b/docs/assets/img/demo-chat.gif
index 784264b3..d0d492ea 100644
Binary files a/docs/assets/img/demo-chat.gif and b/docs/assets/img/demo-chat.gif differ
diff --git a/docs/assets/img/demo-settings.gif b/docs/assets/img/demo-settings.gif
deleted file mode 100644
index 84d9e05d..00000000
Binary files a/docs/assets/img/demo-settings.gif and /dev/null differ
diff --git a/docs/assets/img/percho_pet.png b/docs/assets/img/percho_pet.png
deleted file mode 100644
index e794e87a..00000000
Binary files a/docs/assets/img/percho_pet.png and /dev/null differ
diff --git a/docs/assets/img/percho_ui_plugins.png b/docs/assets/img/percho_ui_plugins.png
index 567b9c3f..a173c95a 100644
Binary files a/docs/assets/img/percho_ui_plugins.png and b/docs/assets/img/percho_ui_plugins.png differ
diff --git a/docs/assets/img/readme-hero-dark.svg b/docs/assets/img/readme-hero-dark.svg
new file mode 100644
index 00000000..8ac677b9
--- /dev/null
+++ b/docs/assets/img/readme-hero-dark.svg
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ cap x base φ Percho · costruzione geometrica — 1 : φ Percho
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0.382 0.618 φ 1 : φ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/assets/img/readme-hero-light.svg b/docs/assets/img/readme-hero-light.svg
new file mode 100644
index 00000000..2a4fb780
--- /dev/null
+++ b/docs/assets/img/readme-hero-light.svg
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ cap x base φ Percho · costruzione geometrica — 1 : φ Percho
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0.382 0.618 φ 1 : φ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/icon.svg b/docs/icon.svg
deleted file mode 100644
index 72c589bd..00000000
--- a/docs/icon.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/packages/backend/src/pi-backend.ts b/packages/backend/src/pi-backend.ts
index ec082bb5..20974188 100644
--- a/packages/backend/src/pi-backend.ts
+++ b/packages/backend/src/pi-backend.ts
@@ -1,6 +1,6 @@
import { existsSync } from "node:fs";
import { readFile, unlink } from "node:fs/promises";
-import { join } from "node:path";
+import { join, resolve } from "node:path";
import type { Model, ThinkingLevel } from "@earendil-works/pi-ai";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import {
@@ -84,6 +84,7 @@ import { autoNameSession } from "./session/naming";
import { EventRateTracker } from "./session/rates";
import { type EventForwarder, type RegisteredSession, SessionRegistry } from "./session/registry";
import { renameSessionFile } from "./session/rename";
+import { KeyedSingleFlight } from "./session/single-flight";
import { StreamGuard } from "./session/stream-guard";
import { TraceRecorder } from "./session/trace";
import { SessionTraces } from "./session/traces";
@@ -174,6 +175,8 @@ export interface PiBackendOptions {
*/
export class PiBackend {
private readonly registry = new SessionRegistry();
+ /** open 的 single-flight(key = 规范化绝对路径):同文件并发 open 共享一次构造(spec D4) */
+ private readonly openFlights = new KeyedSingleFlight();
/**
* 已加载会话的频道订阅快照(spec §6.1/§6.2):sessionId → 有效运行态 topic 集,
* 由 channel-watch 扩展经 `reportChannelSubscriptions` 维护(只存 topic 名)。
@@ -433,12 +436,30 @@ export class PiBackend {
return this.toMetaOrThrow(session.sessionId);
}
+ /**
+ * 打开会话(幂等 + single-flight,spec D4):
+ * - key = 规范化绝对路径 → 同一文件并发 open 共享一次构造;
+ * - 已在 registry 的 sessionId 直接返回现有 entry 的最新 meta,**不构造第二实例**
+ * (短路在资源加载/扩展构造之前,重复 open 不再重复 subscribe/trace);
+ * - 失败(含构造失败)必清 in-flight key → 可重试。
+ */
async openSession(filePath: string): Promise {
- const runtime = await this.getModelRuntime();
- const sessionManager = SessionManager.open(filePath);
- const cwd = sessionManager.getCwd() || process.cwd();
+ const key = resolve(filePath);
+ return this.openFlights.run(key, () => this.openSessionOnce(key));
+ }
+
+ private async openSessionOnce(filePath: string): Promise {
+ // 读 header 拿到 sessionId(只读文件头,不构造会话)后先查 registry:
// 子代理产物目录下的会话文件 = 只读检视(spec §8.1:防能力静默漂移/递归绕过)。
// 其运行 trace 由 runner 管理,检视页不可写,故不另建 recorder(避免覆盖运行中的 recorder)。
+ const sessionManager = SessionManager.open(filePath);
+ const existing = this.registry.get(sessionManager.getSessionId());
+ if (existing) {
+ log.info("session open short-circuited", sessionManager.getSessionId(), { file: filePath });
+ return this.registry.toMeta(existing);
+ }
+ const runtime = await this.getModelRuntime();
+ const cwd = sessionManager.getCwd() || process.cwd();
const readOnly = isSubagentSessionPath(filePath);
const session = await this.wireSession(cwd, readOnly, async (deps) => {
const { settingsManager, resourceLoader } = await deps.load();
@@ -503,15 +524,28 @@ export class PiBackend {
autoNameSession(session, event);
this.emitEvent(session.sessionId, event);
});
- this.registry.add({
- session,
- unsubscribe,
- cwd,
- gate,
- dialogs,
- modeRef,
- readOnly: readOnly || undefined,
- });
+ try {
+ this.registry.add({
+ session,
+ unsubscribe,
+ cwd,
+ gate,
+ dialogs,
+ modeRef,
+ readOnly: readOnly || undefined,
+ });
+ } catch (error) {
+ // registry 的最后防线(同 sessionId 已被别的 entry 占住,如并发/别名路径):
+ // 刚构造的这份必须当场拆干净(订阅/gate/dialogs/session),不能只抛错把资源泄漏出去
+ unsubscribe();
+ gate.dispose();
+ dialogs.dispose();
+ session.dispose();
+ log.error("session register conflicted; constructed instance disposed", {
+ sessionId: session.sessionId,
+ });
+ throw error;
+ }
if (!readOnly) await this.traces.start(session.sessionId, session.sessionManager.getSessionDir());
return session;
}
diff --git a/packages/backend/src/session/meta.ts b/packages/backend/src/session/meta.ts
new file mode 100644
index 00000000..f49a6742
--- /dev/null
+++ b/packages/backend/src/session/meta.ts
@@ -0,0 +1,69 @@
+import { statSync } from "node:fs";
+import type { SessionEntry, SessionHeader } from "@earendil-works/pi-coding-agent";
+
+/**
+ * 会话时间字段的**权威口径**(spec sidebar-session-switch-stability D1),
+ * 与 SDK `buildSessionInfo()`(`SessionManager.list` 的磁盘枚举)逐条对齐:
+ *
+ * - `createdAt` = session header 的 timestamp —— **不是**文件时间:复制/恢复/迁移会话文件会改
+ * birthtime(旧实现就是踩在这上面),header 时间才是「会话诞生时刻」;
+ * - `modifiedAt` = 所有 `message` entry 里 **role 为 user/assistant** 的最大活动时间;
+ * message 自带数值 `timestamp` 优先,退回 entry 的 ISO `timestamp`;
+ * 其它 entry(custom / label / compaction / session_info…)与非 user/assistant 消息(toolResult 等)
+ * 一律不算活动 —— 否则 channel cursor 之类的扩展写入会把会话顶到列表最前;
+ * - 没有任何这类消息时 `modifiedAt = createdAt`。
+ *
+ * header 读不出来(空文件/非会话文件/异常)时返回 `null`,由调用方走 `fileTimeFallback` 兜底。
+ */
+export interface SessionTimes {
+ createdAt: number;
+ modifiedAt: number;
+}
+
+export function deriveSessionTimes(
+ header: SessionHeader | null | undefined,
+ entries: readonly SessionEntry[],
+): SessionTimes | null {
+ const createdAt = parseTimestamp(header?.timestamp);
+ if (createdAt === undefined) return null;
+ let latest: number | undefined;
+ for (const entry of entries) {
+ const activity = messageActivityTime(entry);
+ if (activity === undefined || activity <= 0) continue;
+ latest = latest === undefined ? activity : Math.max(latest, activity);
+ }
+ return { createdAt, modifiedAt: latest ?? createdAt };
+}
+
+/** 单条 entry 的会话活动时间:只认 user/assistant 消息,其余一律 undefined */
+function messageActivityTime(entry: SessionEntry): number | undefined {
+ if (entry.type !== "message") return undefined;
+ const message = entry.message as { role?: unknown; timestamp?: unknown } | undefined;
+ if (message?.role !== "user" && message?.role !== "assistant") return undefined;
+ if (typeof message.timestamp === "number") return message.timestamp;
+ return parseTimestamp(entry.timestamp);
+}
+
+function parseTimestamp(value: unknown): number | undefined {
+ if (typeof value !== "string") return undefined;
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? undefined : parsed;
+}
+
+/**
+ * 异常路径兜底(header 读不出来):文件 mtime → 当前时刻,两个字段用同一基准保证自洽。
+ * 刻意不用 birthtime:它随复制/恢复而变,语义上不是「会话创建时间」。
+ */
+export function fallbackSessionTimes(sessionFile: string | undefined): SessionTimes {
+ const time = fileTimeFallback(sessionFile);
+ return { createdAt: time, modifiedAt: time };
+}
+
+function fileTimeFallback(sessionFile: string | undefined): number {
+ if (!sessionFile) return Date.now();
+ try {
+ return statSync(sessionFile).mtimeMs;
+ } catch {
+ return Date.now();
+ }
+}
diff --git a/packages/backend/src/session/registry.ts b/packages/backend/src/session/registry.ts
index 5270511a..fe6c84c0 100644
--- a/packages/backend/src/session/registry.ts
+++ b/packages/backend/src/session/registry.ts
@@ -1,9 +1,9 @@
-import { statSync } from "node:fs";
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import type { SessionMeta } from "@percho/shared";
import type { PermissionModeRef } from "../permissions/extension";
import type { PermissionGate } from "../permissions/gate";
import type { ExtensionDialogHost } from "./extension-dialog-host";
+import { deriveSessionTimes, fallbackSessionTimes } from "./meta";
export interface RegisteredSession {
session: AgentSession;
@@ -23,7 +23,17 @@ export interface RegisteredSession {
export class SessionRegistry {
private readonly sessions = new Map();
+ /**
+ * 注册会话。同 sessionId 重复 add **同一个 entry** = 幂等;
+ * 不同 entry = 抛错,**绝不静默覆盖**:覆盖会把第一份实例的订阅/gate/dialogs 全泄漏,
+ * 而且再没人能 dispose 它。正常主路径走不到这里(`PiBackend.openSession` 在构造前就按
+ * sessionId 短路),这是并发/别名路径的最后防线;调用方负责清理刚构造的实例(见 wireSession)。
+ */
add(entry: RegisteredSession): void {
+ const existing = this.sessions.get(entry.session.sessionId);
+ if (existing && existing !== entry) {
+ throw new Error(`Session already registered: ${entry.session.sessionId}`);
+ }
this.sessions.set(entry.session.sessionId, entry);
}
@@ -51,15 +61,12 @@ export class SessionRegistry {
toMeta(entry: RegisteredSession): SessionMeta {
const { session, cwd } = entry;
- // createdAt 用会话文件创建时刻(fork = fork 时刻,语义真实);SDK 无活跃会话 created 访问器
- let createdAt = Date.now();
- if (session.sessionFile) {
- try {
- createdAt = statSync(session.sessionFile).birthtimeMs;
- } catch {
- // 文件不存在/异常回退当前时刻
- }
- }
+ // 时间口径唯一出处见 meta.ts(spec D1):header + entries 权威(与 SDK 磁盘枚举同语义,
+ // 不能用文件 birthtime/mtime 替代——复制/恢复文件会改 birthtime,channel cursor 等
+ // custom entry 也不该影响排序);只有 header 读不出来时才退化到文件时间。
+ const times =
+ deriveSessionTimes(session.sessionManager.getHeader(), session.sessionManager.getEntries()) ??
+ fallbackSessionTimes(session.sessionFile);
return {
sessionId: session.sessionId,
sessionFile: session.sessionFile,
@@ -70,7 +77,8 @@ export class SessionRegistry {
thinkingLevel: session.thinkingLevel,
active: true,
messageCount: session.messages.length,
- createdAt,
+ createdAt: times.createdAt,
+ modifiedAt: times.modifiedAt,
readOnly: entry.readOnly || undefined,
};
}
diff --git a/packages/backend/src/session/single-flight.ts b/packages/backend/src/session/single-flight.ts
new file mode 100644
index 00000000..aaec6d74
--- /dev/null
+++ b/packages/backend/src/session/single-flight.ts
@@ -0,0 +1,30 @@
+/**
+ * 按 key 的并发 single-flight 原语(spec sidebar-session-switch-stability D4):
+ * 同一 key 在途时复用同一个 Promise(多个调用者拿到同一结果),settle 后**无论成败**都清 key,
+ * 故失败可以重试;不同 key 互不影响。
+ *
+ * 为什么单独成文件:PiBackend 的构造路径依赖真实 SDK/资源加载,无法在单测里无侵入地多次构造,
+ * 把「按 key 去重」这层纯逻辑抽出来才能确定性单测(见 test/session-single-flight.test.ts)。
+ *
+ * 契约:`task` 必须返回 Promise(调用方均为 async 函数);同 key 在途期间复用同一个 Promise;
+ * settle(无论成败)后清 key,且只清「自己那一条」(settle 期间可能有新请求占用同一个 key,
+ * 旧 Promise 不得把后继请求删掉);不同 key 互不影响。
+ */
+export class KeyedSingleFlight {
+ private readonly inFlight = new Map>();
+
+ /** 在途任务数(单测断言「settle 后必然清空」用) */
+ get size(): number {
+ return this.inFlight.size;
+ }
+
+ run(key: string, task: () => Promise): Promise {
+ const existing = this.inFlight.get(key);
+ if (existing) return existing;
+ const promise = task().finally(() => {
+ if (this.inFlight.get(key) === promise) this.inFlight.delete(key);
+ });
+ this.inFlight.set(key, promise);
+ return promise;
+ }
+}
diff --git a/packages/backend/test/open-session-idempotency.test.ts b/packages/backend/test/open-session-idempotency.test.ts
new file mode 100644
index 00000000..cfdb8ba8
--- /dev/null
+++ b/packages/backend/test/open-session-idempotency.test.ts
@@ -0,0 +1,168 @@
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
+import { SessionManager } from "@earendil-works/pi-coding-agent";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { PermissionGate } from "../src/permissions/gate";
+import { PiBackend } from "../src/pi-backend";
+import { ExtensionDialogHost } from "../src/session/extension-dialog-host";
+import type { RegisteredSession, SessionRegistry } from "../src/session/registry";
+
+/**
+ * 阶段 0 红测(spec D4 / §5 Backend 1-2):已加载的会话文件再次 open 必须**命中 registry 短路**,
+ * 返回现有 entry 的最新 meta,绝不再构造第二个 AgentSession(构造会重复 subscribe/trace,
+ * 并在 registry.add 时静默覆盖旧 entry,泄漏第一份实例)。
+ *
+ * 为什么把 createAgentSession 与资源加载都挡住:真走到构造就会读真实 `~/.pi/agent` 资源、
+ * 拉起用户扩展 —— 红测要「不该走到构造」这条路**可判定**(走进去就抛),而不是碰运气。
+ * 并发路径的去重契约由 KeyedSingleFlight 单测固定(见 session-single-flight.test.ts)。
+ */
+
+vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ createAgentSession: () => {
+ throw new Error("不该构造第二个 AgentSession(应命中 registry 短路)");
+ },
+ };
+});
+
+const PROJECT = "/tmp/project";
+
+function writeSessionFile(dir: string, id: string): string {
+ const file = join(dir, `${id}.jsonl`);
+ const lines = [
+ JSON.stringify({
+ type: "session",
+ version: 3,
+ id,
+ timestamp: "2020-01-01T00:00:00.000Z",
+ cwd: PROJECT,
+ }),
+ JSON.stringify({
+ type: "message",
+ id: "m1",
+ parentId: null,
+ timestamp: "2021-03-04T05:06:07.000Z",
+ message: { role: "user", content: "hi", timestamp: Date.parse("2021-03-04T05:06:07.000Z") },
+ }),
+ ];
+ writeFileSync(file, `${lines.join("\n")}\n`);
+ return file;
+}
+
+function entryFor(file: string, sessionId: string): RegisteredSession {
+ return {
+ session: {
+ sessionId,
+ sessionFile: file,
+ sessionName: "已加载会话",
+ model: null,
+ thinkingLevel: "medium",
+ messages: [],
+ sessionManager: SessionManager.open(file),
+ } as unknown as AgentSession,
+ unsubscribe: () => {},
+ cwd: PROJECT,
+ gate: { dispose: () => {} },
+ dialogs: { dispose: () => {} },
+ modeRef: { current: "default" },
+ } as unknown as RegisteredSession;
+}
+
+let dir: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "open-idempotency-"));
+});
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe("PiBackend.openSession 幂等(spec D4)", () => {
+ it("已在 registry 的会话再 open:返回现有 entry 的 meta,不替换 entry、不重复构造", async () => {
+ const file = writeSessionFile(dir, "sess-open");
+ const backend = new PiBackend({ projectTrust: false, permissionGates: false });
+ const registry = (backend as unknown as { registry: SessionRegistry }).registry;
+ vi.spyOn(
+ backend as unknown as { getModelRuntime: () => Promise },
+ "getModelRuntime",
+ ).mockResolvedValue({} as ModelRuntime);
+ // 任何构造尝试都要先过资源加载:这里直接抛,命中短路时不会被调用
+ const load = vi.fn(() => {
+ throw new Error("不该构造第二个 AgentSession(应命中 registry 短路)");
+ });
+ Object.defineProperty(backend, "projectLoader", { value: { load } });
+
+ const entry = entryFor(file, "sess-open");
+ registry.add(entry);
+
+ const meta = await backend.openSession(file);
+
+ expect(meta.sessionId).toBe("sess-open");
+ expect(meta).toEqual(registry.toMeta(entry));
+ expect(registry.list()).toHaveLength(1);
+ expect(registry.get("sess-open")).toBe(entry);
+ expect(load).not.toHaveBeenCalled();
+ });
+});
+
+describe("PiBackend.wireSession 注册冲突(D4 最后防线)", () => {
+ it("同 sessionId 已被占住:抛错,并把刚构造的实例拆干净(订阅/gate/dialogs/session)", async () => {
+ const file = writeSessionFile(dir, "sess-dup");
+ const backend = new PiBackend({
+ projectTrust: false,
+ permissionGates: false,
+ subagentPreferBuiltin: false,
+ });
+ const registry = (backend as unknown as { registry: SessionRegistry }).registry;
+ // 先占住 sessionId(模拟并发/别名路径:另一个 entry 已经在册)
+ const holder = entryFor(file, "sess-dup");
+ registry.add(holder);
+
+ const unsubscribe = vi.fn();
+ const dispose = vi.fn();
+ const gateDispose = vi.spyOn(PermissionGate.prototype, "dispose");
+ const dialogsDispose = vi.spyOn(ExtensionDialogHost.prototype, "dispose");
+ const session = {
+ sessionId: "sess-dup",
+ sessionFile: file,
+ sessionName: undefined,
+ model: null,
+ thinkingLevel: "medium",
+ messages: [],
+ sessionManager: SessionManager.open(file),
+ subscribe: () => unsubscribe,
+ bindExtensions: () => Promise.resolve(),
+ dispose,
+ };
+ const wire = (
+ backend as unknown as {
+ wireSession: (cwd: string, readOnly: undefined, make: () => Promise) => Promise;
+ }
+ ).wireSession.bind(backend);
+
+ await expect(
+ wire("/tmp/project", undefined, async () => ({
+ session,
+ extensionsResult: { extensions: [], errors: [] },
+ })),
+ ).rejects.toThrow(/already registered/);
+
+ try {
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
+ expect(gateDispose).toHaveBeenCalledTimes(1);
+ expect(dialogsDispose).toHaveBeenCalledTimes(1);
+ expect(dispose).toHaveBeenCalledTimes(1);
+ // 占位 entry 原样保留(没有被静默替换),registry 里也不会多出第二条
+ expect(registry.get("sess-dup")).toBe(holder);
+ expect(registry.list()).toHaveLength(1);
+ } finally {
+ gateDispose.mockRestore();
+ dialogsDispose.mockRestore();
+ }
+ });
+});
diff --git a/packages/backend/test/registry.test.ts b/packages/backend/test/registry.test.ts
index 6582bb55..34a992a7 100644
--- a/packages/backend/test/registry.test.ts
+++ b/packages/backend/test/registry.test.ts
@@ -1,6 +1,8 @@
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import type { SessionEntry, SessionHeader, SessionManager } from "@earendil-works/pi-coding-agent";
+import { SessionManager as RealSessionManager } from "@earendil-works/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type RegisteredSession, SessionRegistry } from "../src/session/registry";
@@ -91,25 +93,102 @@ describe("SessionRegistry disposeAll(B8:与 closeSession 对称)", () => {
});
});
-describe("SessionRegistry toMeta createdAt(D7:会话文件 birthtime)", () => {
- it("createdAt 取会话文件 birthtimeMs", () => {
- const file = join(dir, "session.jsonl");
+describe("SessionRegistry toMeta 时间字段(spec D1 取代 D7 的 birthtime 语义)", () => {
+ const CREATED_ISO = "2020-01-01T00:00:00.000Z";
+ const USER_TS = Date.parse("2021-03-04T05:06:07.000Z");
+
+ /** 把 makeEntry 的桩 session 换上一个真的/替身的 sessionManager(时间字段的唯一来源) */
+ function entryWithManager(sessionId: string, file: string, manager: unknown): RegisteredSession {
+ const { entry } = makeEntry(sessionId, file);
+ return {
+ ...entry,
+ session: { ...(entry.session as unknown as object), sessionManager: manager },
+ } as unknown as RegisteredSession;
+ }
+
+ /** 按真实 pi 会话文件格式写一份会话(header + 逐行 entry) */
+ function writeSession(id: string, entries: unknown[]): string {
+ const file = join(dir, `${id}.jsonl`);
+ const header = JSON.stringify({ type: "session", version: 3, id, timestamp: CREATED_ISO, cwd: "/tmp" });
+ const body = entries.map((entry) => JSON.stringify(entry));
+ writeFileSync(file, `${[header, ...body].join("\n")}\n`, "utf8");
+ return file;
+ }
+
+ it("createdAt 取 session header 时间(刻意不是文件 birthtime)", () => {
+ // 契约迁移:D7 旧口径用 birthtimeMs,复制/恢复文件就会改「创建时间」;D1 改为 header 权威
+ const file = writeSession("header-session", [
+ {
+ type: "message",
+ id: "m1",
+ parentId: null,
+ timestamp: "2021-03-04T05:06:07.000Z",
+ message: { role: "user", content: "hi", timestamp: USER_TS },
+ },
+ ]);
+ const registry = new SessionRegistry();
+ const entry = entryWithManager("s1", file, RealSessionManager.open(file));
+ registry.add(entry);
+
+ const meta = registry.toMeta(entry);
+
+ expect(meta.createdAt).toBe(Date.parse(CREATED_ISO));
+ expect(meta.createdAt).not.toBe(statSync(file).birthtimeMs);
+ expect(meta.modifiedAt).toBe(USER_TS);
+ });
+
+ it("header 读不出来(异常路径):回退文件 mtime(不再用 birthtime)", () => {
+ const file = join(dir, "no-header.jsonl");
writeFileSync(file, "{}\n", "utf8");
const registry = new SessionRegistry();
- const { entry } = makeEntry("s1", file);
+ const manager = { getHeader: (): SessionHeader | null => null, getEntries: (): SessionEntry[] => [] };
+ const entry = entryWithManager("s1", file, manager as unknown as SessionManager);
registry.add(entry);
const meta = registry.toMeta(entry);
- expect(meta.createdAt).toBe(statSync(file).birthtimeMs);
+
+ expect(meta.createdAt).toBe(statSync(file).mtimeMs);
+ expect(meta.modifiedAt).toBe(statSync(file).mtimeMs);
});
- it("会话文件不存在时回退当前时刻(不抛错)", () => {
+ it("header 与文件都不可用时回退当前时刻(不抛错)", () => {
const registry = new SessionRegistry();
- const { entry } = makeEntry("s1", join(dir, "missing.jsonl"));
+ const manager = { getHeader: (): SessionHeader | null => null, getEntries: (): SessionEntry[] => [] };
+ const entry = entryWithManager("s1", join(dir, "missing.jsonl"), manager as unknown as SessionManager);
const before = Date.now();
const meta = registry.toMeta(entry);
+
expect(meta.createdAt).toBeGreaterThanOrEqual(before);
expect(meta.createdAt).toBeLessThanOrEqual(Date.now());
});
});
+
+// ---------------------------------------------------------------------------
+// 阶段 0 红测(spec sidebar-session-switch-stability D4):add 不得静默覆盖同 sessionId 的旧 entry ——
+// 覆盖 = 第一份实例的订阅/gate/dialogs 全部泄漏(且没人再能 dispose 它)。
+// 防住这条才能让「open 幂等」有意义(构造前短路 + 注册处兜底)。实现见 plan 阶段 3.1。
+// ---------------------------------------------------------------------------
+
+describe("SessionRegistry.add 幂等兜底", () => {
+ it("同一 sessionId 再 add 不同 entry:抛错,不静默替换旧 entry", () => {
+ const registry = new SessionRegistry();
+ const first = makeEntry("dup", join(dir, "dup.jsonl"));
+ const second = makeEntry("dup", join(dir, "dup.jsonl"));
+ registry.add(first.entry);
+
+ expect(() => registry.add(second.entry)).toThrow();
+
+ expect(registry.get("dup")).toBe(first.entry);
+ expect(registry.list()).toHaveLength(1);
+ });
+
+ it("同 sessionId 重复 add 同一个 entry:幂等(不抛错)", () => {
+ const registry = new SessionRegistry();
+ const first = makeEntry("dup", join(dir, "dup.jsonl"));
+ registry.add(first.entry);
+
+ expect(() => registry.add(first.entry)).not.toThrow();
+ expect(registry.list()).toHaveLength(1);
+ });
+});
diff --git a/packages/backend/test/session-meta.test.ts b/packages/backend/test/session-meta.test.ts
new file mode 100644
index 00000000..1586e0b2
--- /dev/null
+++ b/packages/backend/test/session-meta.test.ts
@@ -0,0 +1,211 @@
+import { copyFileSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { AgentSession } from "@earendil-works/pi-coding-agent";
+import { SessionManager } from "@earendil-works/pi-coding-agent";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { type RegisteredSession, SessionRegistry } from "../src/session/registry";
+
+/**
+ * 阶段 0 红测(spec sidebar-session-switch-stability D1 / §5 排序 3-4):
+ * 活跃会话 meta 的时间字段必须与磁盘枚举同语义 —— createdAt = session header 时间(**不是**文件 birthtime,
+ * 复制/恢复文件会改 birthtime),modifiedAt = 所有 user/assistant 消息的最大活动时间
+ * (message.timestamp 数值优先,退回 entry timestamp;custom/tool 类 entry 不影响)。
+ *
+ * 现状(红):toMeta 用 statSync(file).birthtimeMs 当 createdAt,且完全没有 modifiedAt →
+ * 左栏排序键从 modifiedAt 掉到 createdAt,点击行下移。实现见 plan 阶段 1.1。
+ */
+
+const PROJECT = "/tmp/project";
+const CREATED_ISO = "2020-01-01T00:00:00.000Z";
+const CREATED_MS = Date.parse(CREATED_ISO);
+const USER_TS = Date.parse("2021-03-04T05:06:07.000Z");
+const ASSISTANT_TS = Date.parse("2022-07-08T09:10:11.000Z");
+/** 比所有消息都晚的 custom entry(channel cursor 同类):绝不能当成会话活动时间 */
+const CUSTOM_ISO = "2023-05-06T07:08:09.000Z";
+
+interface MessageSpec {
+ id: string;
+ role: string;
+ /** message 自带的数值时间戳(缺省则退回 entry timestamp) */
+ timestamp?: number;
+ /** entry 的 ISO 时间戳 */
+ entryTimestamp: string;
+}
+
+function messageEntry(spec: MessageSpec): unknown {
+ return {
+ type: "message",
+ id: spec.id,
+ parentId: null,
+ timestamp: spec.entryTimestamp,
+ message: {
+ role: spec.role,
+ content: "x",
+ ...(spec.timestamp === undefined ? {} : { timestamp: spec.timestamp }),
+ },
+ };
+}
+
+function customEntry(id: string, entryTimestamp: string): unknown {
+ return { type: "custom", id, parentId: null, timestamp: entryTimestamp, customType: "channel-cursor" };
+}
+
+/** 按真实 pi 会话文件格式手写一份会话(header + 逐行 entry),零 SDK 写入 */
+function writeSessionFile(dir: string, id: string, entries: unknown[]): string {
+ const file = join(dir, `${id}.jsonl`);
+ const lines = [
+ JSON.stringify({ type: "session", version: 3, id, timestamp: CREATED_ISO, cwd: PROJECT }),
+ ...entries.map((entry) => JSON.stringify(entry)),
+ ];
+ writeFileSync(file, `${lines.join("\n")}\n`);
+ return file;
+}
+
+/** 最小 RegisteredSession 桩:sessionManager 是真的(读 header/entries),不构造 AgentSession */
+function entryFor(file: string, sessionId: string): RegisteredSession {
+ return {
+ session: {
+ sessionId,
+ sessionFile: file,
+ sessionName: undefined,
+ model: null,
+ thinkingLevel: "medium",
+ messages: [],
+ sessionManager: SessionManager.open(file),
+ } as unknown as AgentSession,
+ unsubscribe: () => {},
+ cwd: PROJECT,
+ gate: { dispose: () => {} },
+ dialogs: { dispose: () => {} },
+ modeRef: { current: "default" },
+ } as unknown as RegisteredSession;
+}
+
+function metaOf(file: string, sessionId: string) {
+ return new SessionRegistry().toMeta(entryFor(file, sessionId));
+}
+
+let dir: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "session-meta-"));
+});
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe("SessionRegistry.toMeta · 时间语义(spec D1)", () => {
+ it("createdAt 取 header 时间(不是文件 birthtime);modifiedAt 取 user/assistant 最大活动时间", () => {
+ const file = writeSessionFile(dir, "sess-a", [
+ messageEntry({
+ id: "m1",
+ role: "user",
+ timestamp: USER_TS,
+ entryTimestamp: "2021-03-04T05:06:07.000Z",
+ }),
+ customEntry("c1", CUSTOM_ISO),
+ messageEntry({
+ id: "m2",
+ role: "assistant",
+ timestamp: ASSISTANT_TS,
+ entryTimestamp: "2022-07-08T09:10:11.000Z",
+ }),
+ ]);
+
+ const meta = metaOf(file, "sess-a");
+
+ expect(meta.createdAt).toBe(CREATED_MS);
+ expect(meta.modifiedAt).toBe(ASSISTANT_TS);
+ // 反证:文件刚写出来,birthtime 是「现在」,与 header 时间(2020)必然不同
+ expect(statSync(file).birthtimeMs).not.toBe(CREATED_MS);
+ });
+
+ it("custom / toolResult 类 entry 不影响 modifiedAt(channel cursor 不参与排序)", () => {
+ const file = writeSessionFile(dir, "sess-b", [
+ messageEntry({
+ id: "m1",
+ role: "user",
+ timestamp: USER_TS,
+ entryTimestamp: "2021-03-04T05:06:07.000Z",
+ }),
+ messageEntry({
+ id: "m2",
+ role: "toolResult",
+ timestamp: ASSISTANT_TS,
+ entryTimestamp: "2022-07-08T09:10:11.000Z",
+ }),
+ customEntry("c1", CUSTOM_ISO),
+ ]);
+
+ expect(metaOf(file, "sess-b").modifiedAt).toBe(USER_TS);
+ });
+
+ it("消息没有数值 timestamp 时退回 entry timestamp", () => {
+ const file = writeSessionFile(dir, "sess-c", [
+ messageEntry({ id: "m1", role: "user", entryTimestamp: "2021-03-04T05:06:07.000Z" }),
+ ]);
+
+ expect(metaOf(file, "sess-c").modifiedAt).toBe(USER_TS);
+ });
+
+ it("没有任何 user/assistant 消息:modifiedAt = createdAt", () => {
+ const file = writeSessionFile(dir, "sess-d", [customEntry("c1", CUSTOM_ISO)]);
+
+ const meta = metaOf(file, "sess-d");
+
+ expect(meta.createdAt).toBe(CREATED_MS);
+ expect(meta.modifiedAt).toBe(CREATED_MS);
+ });
+
+ it("与 SDK 磁盘枚举同语义:SessionManager.list 的 created/modified 与活跃 meta 一致", async () => {
+ const file = writeSessionFile(dir, "sess-e", [
+ messageEntry({
+ id: "m1",
+ role: "user",
+ timestamp: USER_TS,
+ entryTimestamp: "2021-03-04T05:06:07.000Z",
+ }),
+ customEntry("c1", CUSTOM_ISO),
+ messageEntry({
+ id: "m2",
+ role: "assistant",
+ timestamp: ASSISTANT_TS,
+ entryTimestamp: "2022-07-08T09:10:11.000Z",
+ }),
+ ]);
+ const infos = await SessionManager.list(PROJECT, dir);
+ const info = infos.find((item) => item.id === "sess-e");
+ if (!info) throw new Error("session file not enumerated");
+
+ const meta = metaOf(file, "sess-e");
+
+ expect(meta.createdAt).toBe(info.created.getTime());
+ expect(meta.modifiedAt).toBe(info.modified.getTime());
+ });
+
+ it("复制会话文件(birthtime 变新)不改变 createdAt", () => {
+ const file = writeSessionFile(dir, "sess-f", [
+ messageEntry({
+ id: "m1",
+ role: "user",
+ timestamp: USER_TS,
+ entryTimestamp: "2021-03-04T05:06:07.000Z",
+ }),
+ ]);
+ const copyDir = mkdtempSync(join(tmpdir(), "session-meta-copy-"));
+ const copy = join(copyDir, "sess-f-copy.jsonl");
+ copyFileSync(file, copy);
+
+ try {
+ const meta = metaOf(copy, "sess-f");
+ expect(meta.createdAt).toBe(CREATED_MS);
+ expect(meta.modifiedAt).toBe(USER_TS);
+ // 副本的 birthtime 是复制时刻(远比 2020 晚):证明没在用 birthtime
+ expect(statSync(copy).birthtimeMs).toBeGreaterThan(CREATED_MS + 86_400_000);
+ } finally {
+ rmSync(copyDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/backend/test/session-single-flight.test.ts b/packages/backend/test/session-single-flight.test.ts
new file mode 100644
index 00000000..e6f3f691
--- /dev/null
+++ b/packages/backend/test/session-single-flight.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "vitest";
+import { KeyedSingleFlight } from "../src/session/single-flight";
+
+/** 手写 deferred:single-flight 的契约全在「settle 前 / settle 后」两个时刻,必须能精确控制 */
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+/**
+ * 阶段 0 红测(spec D4 / §5 Backend 1):同一路径并发 open 只构造一次,
+ * 靠这层原语保证;失败可重试靠「settle 必清 key」。
+ */
+describe("KeyedSingleFlight", () => {
+ it("同一 key 在途时只执行一次,多个调用者拿到同一结果", async () => {
+ const flight = new KeyedSingleFlight();
+ const pending = deferred();
+ let calls = 0;
+ const task = () => {
+ calls += 1;
+ return pending.promise;
+ };
+
+ const first = flight.run("k", task);
+ const second = flight.run("k", task);
+
+ expect(calls).toBe(1);
+ expect(flight.size).toBe(1);
+ pending.resolve(7);
+ expect(await Promise.all([first, second])).toEqual([7, 7]);
+ });
+
+ it("settle 后清 key:同一个 key 再次调用会重新执行", async () => {
+ const flight = new KeyedSingleFlight();
+ let calls = 0;
+ const task = () => {
+ calls += 1;
+ return Promise.resolve(calls);
+ };
+
+ await flight.run("k", task);
+ expect(flight.size).toBe(0);
+ expect(await flight.run("k", task)).toBe(2);
+ expect(calls).toBe(2);
+ });
+
+ it("失败也清 key:可重试(失败 Promise 不留在表里)", async () => {
+ const flight = new KeyedSingleFlight();
+ const failing = deferred();
+ const first = flight.run("k", () => failing.promise);
+ failing.reject(new Error("boom"));
+ await expect(first).rejects.toThrow("boom");
+
+ expect(flight.size).toBe(0);
+ expect(await flight.run("k", () => Promise.resolve(1))).toBe(1);
+ });
+
+ it("不同 key 并发互不影响(各自独立执行)", async () => {
+ const flight = new KeyedSingleFlight();
+ const a = deferred();
+ const b = deferred();
+
+ const first = flight.run("a", () => a.promise);
+ const second = flight.run("b", () => b.promise);
+ expect(flight.size).toBe(2);
+ a.resolve("A");
+ b.resolve("B");
+ expect(await Promise.all([first, second])).toEqual(["A", "B"]);
+ });
+});
diff --git a/packages/desktop/build/icon.png b/packages/desktop/build/icon.png
index bd27f188..76495134 100644
Binary files a/packages/desktop/build/icon.png and b/packages/desktop/build/icon.png differ
diff --git a/packages/desktop/build/icon.svg b/packages/desktop/build/icon.svg
new file mode 100644
index 00000000..04936a2e
--- /dev/null
+++ b/packages/desktop/build/icon.svg
@@ -0,0 +1,39 @@
+
+
+ Percho
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0.382
+ 0.618
+ φ
+ 1 : φ
+
+
+
diff --git a/packages/desktop/src/main/ipc/app.ts b/packages/desktop/src/main/ipc/app.ts
index 6303d84b..04d4ceba 100644
--- a/packages/desktop/src/main/ipc/app.ts
+++ b/packages/desktop/src/main/ipc/app.ts
@@ -66,7 +66,7 @@ export function registerAppIpc(_backend: PiBackend): void {
arch: process.arch,
repoUrl: REPO_URL,
}),
- // 日常空间目录下发(懒创建;会话创建由 renderer 走既有 draft/createSession 流程)
+ // 日常空间目录下发(懒创建;会话创建由 renderer 走新会话页 → promotion 流程)
getDailyDir: () => ensureDailyDir(),
loadUiState: () => loadUiState(),
saveUiState: ({ state }) => {
diff --git a/packages/desktop/src/main/lan-icon.ts b/packages/desktop/src/main/lan-icon.ts
index cbed2767..af7c00cc 100644
--- a/packages/desktop/src/main/lan-icon.ts
+++ b/packages/desktop/src/main/lan-icon.ts
@@ -1,3 +1,3 @@
/** PWA 图标(应用图标 512px 缩小 + pngquant 压缩),base64 内联避免 extraResources/dev-packaged 路径分叉。 */
export const LAN_ICON_PNG_BASE64 =
- "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";
+ "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";
diff --git a/packages/desktop/src/renderer/src/App.tsx b/packages/desktop/src/renderer/src/App.tsx
index e31e69d9..ca2756fd 100644
--- a/packages/desktop/src/renderer/src/App.tsx
+++ b/packages/desktop/src/renderer/src/App.tsx
@@ -55,8 +55,10 @@ export default function App() {
useEffect(() => {
// 上次项目目录:启动即预填(用户不用重选项目)。**只带 cwd,不恢复任何会话**——
// v10 启动仍是纯空会话页,历史全在左栏。偏好已在 main.tsx render 前 init 完毕,这里同步可用。
+ // 单例 draft:开屏就建好唯一那份(cwd = lastCwd,模型等 loadModels 回填),
+ // 这样「空态输入框 / 项目选择器 / 发送」从一开始就有同一份配置真相
const lastCwd = useUiPreferencesStore.getState().lastCwd;
- if (lastCwd) useSessionsStore.setState({ cwd: lastCwd });
+ useSessionsStore.getState().activateNewSessionDraft(lastCwd ?? undefined);
// 开屏就绪信号:首批数据(模型列表)settle 后收场(finishSplash 幂等)。
// v10:**不再恢复上次打开的会话**(启动纯空 = 新会话页,与 pi 原生 / Codex 一致);
// 历史全在左栏,点一下才按需加载
diff --git a/packages/desktop/src/renderer/src/components/chat/SelectionToolbar.tsx b/packages/desktop/src/renderer/src/components/chat/SelectionToolbar.tsx
index 8ce139f3..b48a6f60 100644
--- a/packages/desktop/src/renderer/src/components/chat/SelectionToolbar.tsx
+++ b/packages/desktop/src/renderer/src/components/chat/SelectionToolbar.tsx
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useSessionBusy, useSessionReadOnly } from "../../hooks/use-session-state";
import { useT } from "../../i18n";
import { COMPOSER_FOCUS_EVENT, useDraftStore } from "../../stores/drafts";
-import { isDraftSessionId, useSessionsStore } from "../../stores/sessions";
+import { useSessionsStore } from "../../stores/sessions";
import { useTranscriptStore } from "../../stores/transcript";
/** 菜单与选区的间距 */
@@ -136,7 +136,7 @@ export function SelectionToolbar({ containerRef }: { containerRef: React.RefObje
};
const continueInNewChat = async () => {
- if (!pending || busy || forking || !activeSessionId || isDraftSessionId(activeSessionId)) return;
+ if (!pending || busy || forking || !activeSessionId) return;
const messages = useTranscriptStore.getState().bySession[activeSessionId]?.messages ?? [];
const lastAssistant = [...messages].reverse().find((m) => m.kind === "assistant");
if (!lastAssistant) return;
diff --git a/packages/desktop/src/renderer/src/components/composer/Composer.tsx b/packages/desktop/src/renderer/src/components/composer/Composer.tsx
index 98699d08..b84ed981 100644
--- a/packages/desktop/src/renderer/src/components/composer/Composer.tsx
+++ b/packages/desktop/src/renderer/src/components/composer/Composer.tsx
@@ -36,7 +36,7 @@ export function Composer({ centered = false }: { centered?: boolean }) {
/** 只读会话(subagent 产物检视):输入/发送/图片全禁,模型与思考档位选择器置灰 */
const readOnly = useSessionReadOnly();
const cwd = useSessionsStore((s) => s.cwd);
- /** 信任决策应答后递增:draft 斜杠菜单按新决策(信任与否)重拉命令 */
+ /** 信任决策应答后递增:新会话页的斜杠菜单按新决策(信任与否)重拉命令 */
const trustVersion = useSessionsStore((s) => s.trustVersion);
const transcript = useTranscriptStore((s) => selectTranscript(s, activeSessionId));
/** 扩展 setEditorText 预填来源(一次性提示;用户首次键入清除) */
@@ -45,7 +45,7 @@ export function Composer({ centered = false }: { centered?: boolean }) {
const activeModelInfo = useActiveModelInfo();
/** 当前模型是否支持图片输入;fail-open:模型未知/字段缺省一律按支持,只拦 imageInput === false */
const imagesSupported = activeModelInfo?.imageInput !== false;
- /** 草稿(文本/图片/命令胶囊)按会话持久:切换会话/空态↔列表态换 Composer 实例不丢、不串会话 */
+ /** 输入内容(文本/图片/命令胶囊)按会话持久:真实会话用 sessionId、新会话页用固定 key,不丢也不串 */
const draftKey = activeSessionId ?? NEW_SESSION_DRAFT_KEY;
const draft = useDraftStore((s) => s.bySession[draftKey] ?? EMPTY_DRAFT);
const { text, images, slashCommand, attachments, quotes } = draft;
diff --git a/packages/desktop/src/renderer/src/components/composer/PermissionPicker.tsx b/packages/desktop/src/renderer/src/components/composer/PermissionPicker.tsx
index 00f560ea..ebea65a6 100644
--- a/packages/desktop/src/renderer/src/components/composer/PermissionPicker.tsx
+++ b/packages/desktop/src/renderer/src/components/composer/PermissionPicker.tsx
@@ -17,8 +17,14 @@ const MODES: PermissionMode[] = ["default", "fullAccess"];
export function PermissionPicker() {
const t = useT();
const activeSessionId = useSessionsStore((s) => s.activeSessionId);
- const mode = useSessionsStore((s) => s.permissionModes[s.activeSessionId ?? ""] ?? "default");
+ // 档位按会话隔离;draft 页(无会话)读 draft 配置里的档位,promotion 时随快照生效
+ const mode = useSessionsStore((s) =>
+ s.activeSessionId === null
+ ? (s.newSessionDraft?.permissionMode ?? "default")
+ : (s.permissionModes[s.activeSessionId] ?? "default"),
+ );
const setSessionPermissionMode = useSessionsStore((s) => s.setSessionPermissionMode);
+ const setDraftPermissionMode = useSessionsStore((s) => s.setDraftPermissionMode);
const gateOff = useSettingsStore((s) => s.permissionGateOff);
const [open, setOpen] = useState(false);
const ref = useRef(null);
@@ -77,7 +83,9 @@ export function PermissionPicker() {
selected ? "text-ink" : "text-ink-2 hover:bg-hover"
}`}
onClick={() => {
+ // draft 页没有后端会话:先写 draft 配置,promotion 后由 store 应用到新会话
if (activeSessionId) void setSessionPermissionMode(activeSessionId, m);
+ else setDraftPermissionMode(m);
setOpen(false);
}}
>
diff --git a/packages/desktop/src/renderer/src/components/composer/ThinkingPicker.tsx b/packages/desktop/src/renderer/src/components/composer/ThinkingPicker.tsx
index e5e839ae..a4033159 100644
--- a/packages/desktop/src/renderer/src/components/composer/ThinkingPicker.tsx
+++ b/packages/desktop/src/renderer/src/components/composer/ThinkingPicker.tsx
@@ -18,6 +18,10 @@ export function ThinkingPicker() {
const t = useT();
const thinkingLevel = useSessionsStore((s) => s.lastUsedThinkingLevel);
const activeSession = useSessionsStore((s) => s.sessions.find((x) => x.sessionId === s.activeSessionId));
+ // draft 页没有会话条目:档位读 draft 配置(起步值已按「会话快照 ?? 最近使用」定好)
+ const draftThinkingLevel = useSessionsStore((s) =>
+ s.activeSessionId === null ? s.newSessionDraft?.thinkingLevel : undefined,
+ );
// 当前会话覆写 ?? 全局默认 → models 表解析(useActiveModelInfo 收拢点)
const model = useActiveModelInfo();
const setThinkingLevel = useSessionsStore((s) => s.setThinkingLevel);
@@ -40,7 +44,7 @@ export function ThinkingPicker() {
};
}, [open]);
- const effective = activeSession?.thinkingLevel ?? thinkingLevel;
+ const effective = draftThinkingLevel ?? activeSession?.thinkingLevel ?? thinkingLevel;
// 当前会话模型实际支持的思考深度(模型配置下发);缺省按全量显示
const supported =
model?.thinkingLevels && model.thinkingLevels.length > 0 ? model.thinkingLevels : [...THINKING_LEVELS];
diff --git a/packages/desktop/src/renderer/src/components/composer/send-guard.test.ts b/packages/desktop/src/renderer/src/components/composer/send-guard.test.ts
new file mode 100644
index 00000000..c197d69b
--- /dev/null
+++ b/packages/desktop/src/renderer/src/components/composer/send-guard.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import { createSendGuard } from "./send-guard";
+
+describe("createSendGuard(发送的同步锁)", () => {
+ it("第一轮抢到、第二轮在同 tick 被挡(这就是「两次 Enter 发两遍」的根因)", () => {
+ const guard = createSendGuard();
+ expect(guard.tryAcquire()).toBe(true);
+ expect(guard.tryAcquire()).toBe(false);
+ expect(guard.tryAcquire()).toBe(false);
+ });
+
+ it("release 后可再次发送(不在途时不能被永久锁死)", () => {
+ const guard = createSendGuard();
+ guard.tryAcquire();
+ guard.release();
+ expect(guard.tryAcquire()).toBe(true);
+ });
+
+ it("每个 guard 各自独立(多个 Composer 实例不互相锁)", () => {
+ const a = createSendGuard();
+ const b = createSendGuard();
+ expect(a.tryAcquire()).toBe(true);
+ expect(b.tryAcquire()).toBe(true);
+ });
+
+ it("模拟两轮并发发送:只有一轮穿过 await 窗口(promotion 只被驱动一次)", async () => {
+ const guard = createSendGuard();
+ let promotions = 0;
+ const send = async () => {
+ if (!guard.tryAcquire()) return "ignored";
+ try {
+ promotions += 1;
+ await Promise.resolve(); // 相当于 ensureSession → createSession 的 await
+ return "sent";
+ } finally {
+ guard.release();
+ }
+ };
+ expect(await Promise.all([send(), send()])).toEqual(["sent", "ignored"]);
+ expect(promotions).toBe(1);
+ });
+});
diff --git a/packages/desktop/src/renderer/src/components/composer/send-guard.ts b/packages/desktop/src/renderer/src/components/composer/send-guard.ts
new file mode 100644
index 00000000..ea4a49bb
--- /dev/null
+++ b/packages/desktop/src/renderer/src/components/composer/send-guard.ts
@@ -0,0 +1,30 @@
+/**
+ * 同步发送锁(Composer 的 handleSend 专用)。
+ *
+ * 为什么不能用 `sending` state:React state 要等下一次渲染才生效,而首条消息在 draft 页
+ * 需要先 `await createSession()` 转正 —— 这个 await 窗口里 `sending` 还是 false,
+ * 两次 Enter(或 Enter + 发命令)都会通过校验、共享同一次 createSession,
+ * 然后**各自 prompt 一遍**(同一句话发两次)。
+ *
+ * 所以用一个同步(ref 持有)的锁,从校验通过一直握到本次发送真正结束,
+ * 覆盖所有 early return(在调用方用 try/finally 释放)。
+ */
+export interface SendGuard {
+ /** 抢锁:true = 抢到(可以发),false = 已有一轮在途(直接忽略这次触发) */
+ tryAcquire: () => boolean;
+ release: () => void;
+}
+
+export function createSendGuard(): SendGuard {
+ let locked = false;
+ return {
+ tryAcquire: () => {
+ if (locked) return false;
+ locked = true;
+ return true;
+ },
+ release: () => {
+ locked = false;
+ },
+ };
+}
diff --git a/packages/desktop/src/renderer/src/components/composer/use-composer-send.ts b/packages/desktop/src/renderer/src/components/composer/use-composer-send.ts
index 54f77ad8..fc610646 100644
--- a/packages/desktop/src/renderer/src/components/composer/use-composer-send.ts
+++ b/packages/desktop/src/renderer/src/components/composer/use-composer-send.ts
@@ -2,12 +2,13 @@ import type { ImageInput, UiError } from "@percho/shared";
import { useEffect, useRef, useState } from "react";
import { getPi } from "../../api";
import { useT } from "../../i18n";
-import { isDraftSessionId, useSessionsStore } from "../../stores/sessions";
+import { useSessionsStore } from "../../stores/sessions";
import { useSettingsStore } from "../../stores/settings";
import { pushToast } from "../../stores/toasts";
import { useTranscriptStore } from "../../stores/transcript";
import { buildQuoteBlock } from "./quote";
import { buildSendUiError } from "./send-error";
+import { createSendGuard } from "./send-guard";
export interface UseComposerSendOptions {
activeSessionId: string | null;
@@ -40,6 +41,8 @@ export function useComposerSend(options: UseComposerSendOptions) {
const [error, setErrorState] = useState(null);
const [feedback, setFeedback] = useState<{ message: string; tone: "info" | "warn" } | null>(null);
const feedbackTimer = useRef | null>(null);
+ /** 发送的同步锁(见 send-guard.ts:state 挡不住 await 窗口里的重复触发) */
+ const sendGuard = useRef(createSendGuard());
/** 统一信封:发送失败也走 UiError(内联条 + 与消息流错误卡同语言);传 null 清除 */
const setError = (message: string | null) => setErrorState(message ? buildSendUiError(message) : null);
@@ -50,26 +53,18 @@ export function useComposerSend(options: UseComposerSendOptions) {
feedbackTimer.current = setTimeout(() => setFeedback(null), 2500);
};
- /** 确保有活跃会话(无会话或 draft 时用其 cwd 真正创建,draft tab 原地转正),返回 sessionId */
+ /** 确保有活跃会话(新会话页则把那唯一一份 draft 转正),返回 sessionId */
const ensureSession = async (): Promise => {
- const state = useSessionsStore.getState();
- const current = state.activeSessionId;
- if (current && !isDraftSessionId(current)) return current;
- const draftCwd = current ? state.sessions.find((s) => s.sessionId === current)?.cwd : undefined;
- // draft 态选过的权限模式:转正后应用到新会话(后端新会话一律 default 起步)
- const pendingMode = current ? state.permissionModes[current] : undefined;
- const targetCwd = draftCwd ?? state.cwd;
- if (!targetCwd) return null;
- await useSessionsStore.getState().createSession(targetCwd, current ?? undefined);
- const created = useSessionsStore.getState().activeSessionId;
- if (created && !isDraftSessionId(created)) {
- // 失败仅 toast(store 内已提示+回滚),不阻塞发送
- if (pendingMode && pendingMode !== "default") {
- await useSessionsStore.getState().setSessionPermissionMode(created, pendingMode);
- }
- return created;
+ const current = useSessionsStore.getState().activeSessionId;
+ if (current) return current;
+ // draft 页:promotion 用 draft 快照建真实会话(模型/思考/权限都从 `newSessionDraft` 取,
+ // 调用方不再自己拼 cwd/权限)。无 draft 的防御态(调用顺序异常)先建一份再转正。
+ if (!useSessionsStore.getState().newSessionDraft) {
+ useSessionsStore.getState().activateNewSessionDraft();
}
- return null;
+ // 用返回值定位新会话,不读 activeSessionId:创建期间用户可能已切走,
+ // 此时新会话仍在后台存在(latest-wins 不让它抢焦点),读 active 会把消息发到别人身上
+ return useSessionsStore.getState().createSession();
};
/** 执行内置命令(发送以 / 开头文本时的分发;未匹配则透传给 SDK 原生处理模板/skill/扩展命令) */
@@ -114,35 +109,19 @@ export function useComposerSend(options: UseComposerSendOptions) {
}
};
- const handleSend = async () => {
- const { text, images, attachments, quotes, slashCommand, followUpQueue, compacting, agentActive } =
- options;
- // 引用胶囊逐条转 blockquote 段落;@ 引用胶囊拼回文本。引用置最前(先给上下文),正文在后
- const quoteBlock = buildQuoteBlock(quotes);
- const atText = attachments.map((p) => `@${p}`).join(" ");
- const body = [atText, text.trim()].filter(Boolean).join("\n");
- const content = [quoteBlock, slashCommand ? `/${slashCommand}${body ? ` ${body}` : ""}` : body]
- .filter(Boolean)
- .join("\n\n");
- // 运行中(streaming)不拦截:prompt 走 followUp 排队;仅防双击重发(sending)
- if ((!content && images.length === 0) || sending) return;
- // 压缩中禁发:SDK 拒绝压缩中的 prompt,提前拦截保住草稿
- if (compacting) {
- showFeedback(t("composer.compacting"), "warn");
- return;
- }
- // 图片门控:草稿残留图 + 当前模型不支持(如多模态切纯文本后)→ toast 提醒不发送,保住草稿
- if (images.length > 0 && !options.imagesSupported) {
- pushToast("warning", "composer.imageUnsupported");
- return;
- }
- // 单条排队上限:已有一条且本次是普通文本则挡住(斜杠命令 streaming 中也可立即执行,不受限)
- if (followUpQueue.length >= 1 && !content.startsWith("/")) {
- showFeedback(t("composer.queueFull"), "warn");
- return;
- }
- const wasActive = agentActive;
-
+ /**
+ * 真正的一轮发送(同步锁内)。从「确保有会话」到 prompt 落地都可能 await,
+ * 调用方必须先抢 `sendGuard` 再进来(见 handleSend 尾部的注释)。
+ */
+ const sendNow = async (sent: {
+ content: string;
+ text: string;
+ images: ImageInput[];
+ attachments: string[];
+ quotes: string[];
+ wasActive: boolean;
+ }): Promise => {
+ const { content, text, images, attachments, quotes, wasActive } = sent;
let sessionId = options.activeSessionId;
if (content.startsWith("/") && images.length === 0) {
sessionId = await ensureSession();
@@ -161,8 +140,8 @@ export function useComposerSend(options: UseComposerSendOptions) {
return;
}
// 未匹配的内置命令:落回正常发送(SDK 原生处理模板/技能/扩展命令)
- } else if (!sessionId || isDraftSessionId(sessionId)) {
- // 无会话或 draft tab:用其 cwd 真正创建(draft 原地转正)
+ } else if (!sessionId) {
+ // 新会话页(没有 active):先把 draft 转正成真实会话再发
sessionId = await ensureSession();
if (!sessionId) {
// v10 启动纯空:开机就是新会话页,没选项目目录时 ensureSession 返回 null。
@@ -204,10 +183,47 @@ export function useComposerSend(options: UseComposerSendOptions) {
}
};
+ const handleSend = async () => {
+ const { text, images, attachments, quotes, slashCommand, followUpQueue, compacting, agentActive } =
+ options;
+ // 引用胶囊逐条转 blockquote 段落;@ 引用胶囊拼回文本。引用置最前(先给上下文),正文在后
+ const quoteBlock = buildQuoteBlock(quotes);
+ const atText = attachments.map((p) => `@${p}`).join(" ");
+ const body = [atText, text.trim()].filter(Boolean).join("\n");
+ const content = [quoteBlock, slashCommand ? `/${slashCommand}${body ? ` ${body}` : ""}` : body]
+ .filter(Boolean)
+ .join("\n\n");
+ // 运行中(streaming)不拦截:prompt 走 followUp 排队;仅防双击重发(sending)
+ if ((!content && images.length === 0) || sending) return;
+ // 压缩中禁发:SDK 拒绝压缩中的 prompt,提前拦截保住草稿
+ if (compacting) {
+ showFeedback(t("composer.compacting"), "warn");
+ return;
+ }
+ // 图片门控:草稿残留图 + 当前模型不支持(如多模态切纯文本后)→ toast 提醒不发送,保住草稿
+ if (images.length > 0 && !options.imagesSupported) {
+ pushToast("warning", "composer.imageUnsupported");
+ return;
+ }
+ // 单条排队上限:已有一条且本次是普通文本则挡住(斜杠命令 streaming 中也可立即执行,不受限)
+ if (followUpQueue.length >= 1 && !content.startsWith("/")) {
+ showFeedback(t("composer.queueFull"), "warn");
+ return;
+ }
+ // 同步锁:`sending` 下一帧才生效,挡不住「draft 页要先 await 转正」这个窗口里的第二次触发
+ // (两次 Enter 会共享同一次 createSession,然后各自 prompt 一遍 = 同一句话发两次)
+ if (!sendGuard.current.tryAcquire()) return;
+ try {
+ await sendNow({ content, text, images, attachments, quotes, wasActive: agentActive });
+ } finally {
+ sendGuard.current.release();
+ }
+ };
+
/** 停止:先清排队(避免 abort 后 SDK 把排队消息投递出去)并还原为草稿,再中止 */
const handleStop = async () => {
const { activeSessionId, setText } = options;
- if (!activeSessionId || isDraftSessionId(activeSessionId)) return;
+ if (!activeSessionId) return;
useTranscriptStore.getState().setFollowUpQueue(activeSessionId, []); // 乐观清面板
const cleared = await getPi().clearQueue({ sessionId: activeSessionId });
if (cleared.followUp.length > 0) {
@@ -220,7 +236,7 @@ export function useComposerSend(options: UseComposerSendOptions) {
/** 取回排队消息:清队列(SDK 侧 queue_update 随后对齐),内容放回输入框继续编辑 */
const handleRestoreQueue = async (focus: () => void) => {
const { activeSessionId, setText } = options;
- if (!activeSessionId || isDraftSessionId(activeSessionId)) return;
+ if (!activeSessionId) return;
useTranscriptStore.getState().setFollowUpQueue(activeSessionId, []); // 乐观清面板
const cleared = await getPi().clearQueue({ sessionId: activeSessionId });
const restored = cleared.followUp[0];
diff --git a/packages/desktop/src/renderer/src/components/composer/use-slash-menu.ts b/packages/desktop/src/renderer/src/components/composer/use-slash-menu.ts
index c7f82f19..5be959af 100644
--- a/packages/desktop/src/renderer/src/components/composer/use-slash-menu.ts
+++ b/packages/desktop/src/renderer/src/components/composer/use-slash-menu.ts
@@ -2,7 +2,6 @@ import type { SlashCommandInfo } from "@percho/shared";
import { type RefObject, useEffect, useState } from "react";
import { getPi } from "../../api";
import { useT } from "../../i18n";
-import { isDraftSessionId } from "../../stores/sessions";
import { extractSlashToken, filterCommands, removeSlashToken, type SlashToken } from "./slash-filter";
export interface UseSlashMenuOptions {
@@ -50,20 +49,17 @@ export function useSlashMenu(options: UseSlashMenuOptions) {
!slashDismissed;
const slashQuery = slashOpen ? slashToken.query : "";
- // 会话切换时重新拉取命令列表(模板/skill 随项目变化);draft 无后端会话,按 cwd 拉
+ // 会话切换时重新拉取命令列表(模板/skill 随项目变化);新会话页没有后端会话,按 cwd 拉
// (三类命令只依赖资源加载器;项目信任已在选目录时经 ensureProjectTrust 决策落盘,
// 应答后 trustVersion 递增触发重拉,把项目级资源补进菜单)
// biome-ignore lint/correctness/useExhaustiveDependencies: trustVersion 是刻意的触发依赖(信任应答后重拉),effect 体内不引用
useEffect(() => {
- if (!activeSessionId) {
- setSlashCommands([]);
- return;
- }
- const request = isDraftSessionId(activeSessionId)
- ? cwd
+ // 真实会话按 sessionId 拉(命令与项目的资源加载器绑定);新会话页按 draft 的 cwd 拉
+ const request = activeSessionId
+ ? getPi().listSlashCommands({ sessionId: activeSessionId })
+ : cwd
? getPi().listSlashCommandsForCwd({ cwd })
- : null
- : getPi().listSlashCommands({ sessionId: activeSessionId });
+ : null;
if (!request) {
setSlashCommands([]);
return;
diff --git a/packages/desktop/src/renderer/src/components/session/ProjectBranchPicker.tsx b/packages/desktop/src/renderer/src/components/session/ProjectBranchPicker.tsx
index 4d63f757..ecea3af1 100644
--- a/packages/desktop/src/renderer/src/components/session/ProjectBranchPicker.tsx
+++ b/packages/desktop/src/renderer/src/components/session/ProjectBranchPicker.tsx
@@ -3,7 +3,7 @@ import { getPi } from "../../api";
import { useT } from "../../i18n";
import { getDailyDirCached, isDailyCwd } from "../../lib/daily";
import { deriveProjects, useProjectsStore } from "../../stores/projects";
-import { isDraftSessionId, useSessionsStore } from "../../stores/sessions";
+import { useSessionsStore } from "../../stores/sessions";
import { CoffeeIcon } from "../icons";
import { Dropdown } from "../ui/Dropdown";
@@ -11,9 +11,9 @@ import { Dropdown } from "../ui/Dropdown";
export function ProjectBranchPicker() {
const cwd = useSessionsStore((s) => s.cwd);
const activeSessionId = useSessionsStore((s) => s.activeSessionId);
- // 真实会话的项目在创建时已绑定、不可更改:只在无会话 / draft(新会话)时提供切换,
+ // 真实会话的项目在创建时已绑定、不可更改:只在新会话页(无 active)提供切换,
// 避免选择器看起来能切、实际不生效的假交互
- if (activeSessionId && !isDraftSessionId(activeSessionId)) return null;
+ if (activeSessionId) return null;
// 日常空间目录不是 git 仓库:隐藏分支选择器,只留归属 chip(可下拉切回项目)
const daily = isDailyCwd(cwd);
return (
@@ -64,7 +64,7 @@ function ProjectPicker() {
>
{(close) => (
<>
- {/* 日常空间钉顶:draft 可在 日常 ↔ 项目 间双向切换(与侧栏同一空间语义) */}
+ {/* 日常空间钉顶:新会话页可在 日常 ↔ 项目 间双向切换(与侧栏同一空间语义) */}
{dailyDir && (
<>
{
- // draft 场景下同步更新 draft 条目的 cwd,发送首条消息时即用此目录创建
+ // 写进 draft 配置(同时记住 lastCwd):发送首条消息时即用这个目录创建
setDraftCwd(project.cwd);
close();
}}
diff --git a/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx b/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx
index 849ea33d..ab8f56bf 100644
--- a/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx
+++ b/packages/desktop/src/renderer/src/components/session/SessionTabBar.tsx
@@ -20,6 +20,7 @@ import type { ComponentProps } from "react";
import { useCallback, useEffect, useState } from "react";
import { getPi } from "../../api";
import { useT } from "../../i18n";
+import { COMPOSER_FOCUS_EVENT } from "../../stores/drafts";
import { useProjectsStore } from "../../stores/projects";
import { selectBarSessions, useSessionsStore } from "../../stores/sessions";
import { useTranscriptStore } from "../../stores/transcript";
@@ -91,8 +92,7 @@ function TabPill({
buttonProps?: ComponentProps<"button">;
}) {
const t = useT();
- // v9:叉叉 = 取消置顶 + 从顶栏清除(会话不删、tab 也不关)。顶栏严格只放置顶会话,
- // draft 已不再进顶栏(其名题与丢弃入口都在左栏,见 spec D1/D3)
+ // v9:叉叉 = 取消置顶 + 从顶栏清除(会话不删、tab 也不关)。顶栏严格只放置顶会话
const unpin = useUiPreferencesStore((s) => s.unpin);
// 置顶标记:顶栏会滚动、顺序会被拖动,必须有常显 glyph(不是只靠排序表达)
const pinned = useUiPreferencesStore((s) => s.pinnedSessions.includes(session.sessionId));
@@ -218,7 +218,7 @@ export function SessionTabBar() {
const platform = getPi().platform;
const sessions = useSessionsStore((s) => s.sessions);
const activeSessionId = useSessionsStore((s) => s.activeSessionId);
- const createDraftSession = useSessionsStore((s) => s.createDraftSession);
+ const activateNewSessionDraft = useSessionsStore((s) => s.activateNewSessionDraft);
const cwd = useSessionsStore((s) => s.cwd);
const diffSidebarOpen = useUiStore((s) => s.diffSidebarOpen);
@@ -252,10 +252,10 @@ export function SessionTabBar() {
const [menu, setMenu] = useState(null);
/** 重命名浮层:与菜单同锚点,菜单选中后菜单卸载、浮层同帧展开 */
const [renaming, setRenaming] = useState(null);
- // 展示集(v8):置顶表驱动(不看 tab 开没开)+ 未命名 draft;v9:设置里的开关只控制「显不显这些胶囊」
+ // 展示集(v8):置顶表驱动(不看 tab 开没开);v9:设置里的开关只控制「显不显这些胶囊」
const barSessions = barSessionsVisible ? selectBarSessions(sessions, pinnedSessions, allSessions) : [];
const closeMenu = useCallback(() => setMenu(null), []);
- /** 打开胶囊右键菜单:draft(纯前端 id,后端没有该会话)与只读子会话(后端拒绝写)上的动作全都会失败,
+ /** 打开胶囊右键菜单:只读子会话(后端拒绝写)上的动作全都会失败,
* 所以**干脆不给菜单**(review B1:宁可没有入口,也不给必然弹 toast 的入口) */
const openMenu = (sessionId: string, anchor: MenuAnchor) => {
if (!canOpenSessionMenu(sessions.find((s) => s.sessionId === sessionId))) return;
@@ -383,8 +383,11 @@ 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 tab(空 tab 重启自动消失);发送首条消息时才真正创建后端会话
- createDraftSession();
+ // 单例 draft:已有 draft 就回到它(内容与配置一律保留,并聚焦输入框);
+ // 没有 draft(转正刚消费掉、或启动首帧)才新建一份
+ const hadDraft = useSessionsStore.getState().newSessionDraft !== null;
+ activateNewSessionDraft();
+ if (hadDraft) window.dispatchEvent(new CustomEvent(COMPOSER_FOCUS_EVENT));
}}
aria-label={cwd ? t("tabbar.newSession") : t("tabbar.pickProjectFirst")}
>
diff --git a/packages/desktop/src/renderer/src/components/session/session-menu.test.ts b/packages/desktop/src/renderer/src/components/session/session-menu.test.ts
index 4c6292d6..264cd77f 100644
--- a/packages/desktop/src/renderer/src/components/session/session-menu.test.ts
+++ b/packages/desktop/src/renderer/src/components/session/session-menu.test.ts
@@ -1,7 +1,6 @@
import type { SessionMeta } from "@percho/shared";
-import { beforeEach, describe, expect, it } from "vitest";
-import { useSessionsStore } from "../../stores/sessions";
-import { canOpenSessionMenu, discardDraft, sidebarMenuKind } from "./session-menu";
+import { describe, expect, it } from "vitest";
+import { canOpenSessionMenu, sidebarMenuKind } from "./session-menu";
function meta(overrides: Partial): SessionMeta {
return { sessionId: "s1", cwd: "/proj/demo", active: true, messageCount: 0, createdAt: 1, ...overrides };
@@ -12,10 +11,6 @@ describe("canOpenSessionMenu", () => {
expect(canOpenSessionMenu(meta({}))).toBe(true);
});
- it("draft(纯前端 id,后端没有该会话)不给菜单", () => {
- expect(canOpenSessionMenu(meta({ sessionId: "draft:1" }))).toBe(false);
- });
-
it("只读子会话(后端拒绝写)不给菜单", () => {
expect(canOpenSessionMenu(meta({ readOnly: true }))).toBe(false);
});
@@ -26,27 +21,9 @@ describe("canOpenSessionMenu", () => {
});
describe("sidebarMenuKind(左栏行右键菜单的形态判定)", () => {
- it("draft → 最小菜单;普通会话 → 完整菜单;只读子会话/找不到 → 无菜单", () => {
- expect(sidebarMenuKind(meta({ sessionId: "draft:1" }))).toBe("draft");
+ it("普通会话 → 完整菜单;只读子会话 / 找不到 → 无菜单", () => {
expect(sidebarMenuKind(meta({}))).toBe("session");
expect(sidebarMenuKind(meta({ readOnly: true }))).toBe("none");
expect(sidebarMenuKind(undefined)).toBe("none");
});
});
-
-describe("discardDraft(draft 的丢弃动作)", () => {
- beforeEach(() => {
- useSessionsStore.setState({ sessions: [], activeSessionId: null, cwd: null, permissionModes: {} });
- });
-
- it("纯本地移除:draft 没有后端会话,不调 IPC,也不影响其它会话", () => {
- useSessionsStore.setState({
- sessions: [meta({ sessionId: "draft:1", cwd: "/p" }), meta({ sessionId: "r1", cwd: "/p" })],
- activeSessionId: "draft:1",
- });
- discardDraft("draft:1");
- const state = useSessionsStore.getState();
- expect(state.sessions.map((s) => s.sessionId)).toEqual(["r1"]);
- expect(state.activeSessionId).toBe("r1");
- });
-});
diff --git a/packages/desktop/src/renderer/src/components/session/session-menu.tsx b/packages/desktop/src/renderer/src/components/session/session-menu.tsx
index f59e80d9..d51daac9 100644
--- a/packages/desktop/src/renderer/src/components/session/session-menu.tsx
+++ b/packages/desktop/src/renderer/src/components/session/session-menu.tsx
@@ -3,7 +3,7 @@ import { getPi } from "../../api";
import type { Translate } from "../../i18n";
import { buildDiagnosticsText } from "../../lib/diagnostics";
import { useProjectsStore } from "../../stores/projects";
-import { isDraftSessionId, useSessionsStore } from "../../stores/sessions";
+import { useSessionsStore } from "../../stores/sessions";
import { useToastsStore } from "../../stores/toasts";
import { useUiPreferencesStore } from "../../stores/ui-preferences";
import { CopyIcon, PencilIcon, PinIcon, TrashIcon } from "../icons";
@@ -12,50 +12,26 @@ import type { ContextMenuItem } from "../ui/ContextMenu";
/**
* 会话菜单的共用规则(顶栏胶囊、悬浮会话列表、左侧栏会话行调同一份,行为必须一致):
* 能不能弹、菜单项、以及置顶 / 重命名 / 复制诊断 / 删除四个动作。
- * 为什么单独一个模块:draft(纯前端 id)与只读子会话(后端拒绝写)都要拦、置顶要顺带重排、
+ * 为什么单独一个模块:只读子会话(后端拒绝写)要拦、置顶要顺带重排、
* 重命名失败要弹 toast —— 复制三份迟早漂移。
+ *
+ * 新会话还没有后端对象、也不进左栏,所以这里不存在「新会话」形态的菜单。
*/
-/** draft(后端没有该会话)与只读子会话(后端拒绝写)上的动作全会失败 → 通用菜单不给。
- * draft 在左栏有自己的**最小菜单**(只有一项「丢弃新会话」,见 `draftSessionMenuItems`),
- * 不要为了左栏放开这里让 draft 进通用菜单(spec D3)。 */
+/** 只读子会话(后端拒绝写)上的动作全会失败 → 通用菜单不给;找不到会话同样不给。 */
export function canOpenSessionMenu(session: SessionMeta | undefined): boolean {
- return !!session && !session.readOnly && !isDraftSessionId(session.sessionId);
+ return !!session && !session.readOnly;
}
/**
* 左栏会话行的右键菜单形态(纯逻辑,便于单测;组件只负责按形态渲染):
- * - `draft`:draft 专属最小菜单(只有「丢弃新会话」);
* - `session`:普通真实会话的完整菜单;
* - `none`:找不到会话或只读子会话,不给菜单。
*/
-export function sidebarMenuKind(session: SessionMeta | undefined): "draft" | "session" | "none" {
- if (!session) return "none";
- if (isDraftSessionId(session.sessionId)) return "draft";
+export function sidebarMenuKind(session: SessionMeta | undefined): "session" | "none" {
return canOpenSessionMenu(session) ? "session" : "none";
}
-/**
- * draft 左栏行的右键菜单:**只有「丢弃新会话」这一项**(spec D3)。
- * 重命名/置顶/复制诊断/删后端会话对 draft 都无意义或必败,一律不给;只读子会话仍完全无菜单。
- */
-export function draftSessionMenuItems(t: Translate, options: { onDiscard: () => void }): ContextMenuItem[] {
- return [
- {
- key: "discardDraft",
- label: t("sessionMenu.discardDraft"),
- icon: ,
- danger: true,
- onSelect: options.onDiscard,
- },
- ];
-}
-
-/** 丢弃 draft:纯本地移除(draft 没有任何后端对象),不弹确认——与旧顶栏胶囊叉号同语义 */
-export function discardDraft(sessionId: string): void {
- void useSessionsStore.getState().closeSession(sessionId);
-}
-
/**
* 置顶 / 取消置顶(v8):只动 `pinnedSessions`(新置顶自动排最左)——顶栏胶囊内容 = 置顶表,
* 所以**不再**顺带重排 tabs(旧模型靠把会话挪到 tabs 最前才会进顶栏置顶区)。
diff --git a/packages/desktop/src/renderer/src/components/sidebar/SessionMenu.tsx b/packages/desktop/src/renderer/src/components/sidebar/SessionMenu.tsx
index 222b9a04..4226847d 100644
--- a/packages/desktop/src/renderer/src/components/sidebar/SessionMenu.tsx
+++ b/packages/desktop/src/renderer/src/components/sidebar/SessionMenu.tsx
@@ -6,8 +6,6 @@ import { useUiPreferencesStore } from "../../stores/ui-preferences";
import { RenamePopover } from "../session/RenamePopover";
import {
copySessionDiagnostics,
- discardDraft,
- draftSessionMenuItems,
renameSession,
sessionMenuItems,
sidebarMenuKind,
@@ -18,8 +16,7 @@ import type { MenuAnchor } from "../ui/place-menu";
/**
* 左侧栏会话行的右键菜单状态机(菜单 → 改名气泡 → 删除确认三层,都在这里,行组件只管文案与状态点)。
- * 「能不能弹」与「有哪些项」跟顶栏胶囊共用 `session-menu.tsx`,两处行为不会漂移;
- * 例外是 draft:它走左栏专属的 `draftSessionMenuItems`(只有「丢弃新会话」一项)。
+ * 「能不能弹」与「有哪些项」跟顶栏胶囊共用 `session-menu.tsx`,两处行为不会漂移。
* 返回的 `element` 由分组挂到自己的子树里(portal 到 body,放哪都不影响定位)。
*/
export function useSessionMenu() {
@@ -31,30 +28,24 @@ export function useSessionMenu() {
const [deleting, setDeleting] = useState(null);
const open = (session: SessionMeta, anchor: MenuAnchor) => {
- // draft / 只读子会话的判定在纯函数里(sidebarMenuKind,有单测);none 就不弹菜单
+ // 只读子会话 / 找不到会话的判定在纯函数里(sidebarMenuKind,有单测);none 就不弹菜单
if (sidebarMenuKind(session) === "none") return;
setMenu({ session, anchor });
};
- const kind = menu ? sidebarMenuKind(menu.session) : "none";
-
const element = (
<>
{menu && (
setMenu(null)}
- items={
- kind === "draft"
- ? draftSessionMenuItems(t, { onDiscard: () => discardDraft(menu.session.sessionId) })
- : sessionMenuItems(t, {
- sessionId: menu.session.sessionId,
- pinned: pinnedSessions.includes(menu.session.sessionId),
- onRename: () => setRenaming({ session: menu.session, anchor: menu.anchor }),
- onCopyDiagnostics: () => void copySessionDiagnostics(menu.session),
- onDelete: () => setDeleting(menu.session),
- })
- }
+ items={sessionMenuItems(t, {
+ sessionId: menu.session.sessionId,
+ pinned: pinnedSessions.includes(menu.session.sessionId),
+ onRename: () => setRenaming({ session: menu.session, anchor: menu.anchor }),
+ onCopyDiagnostics: () => void copySessionDiagnostics(menu.session),
+ onDelete: () => setDeleting(menu.session),
+ })}
/>
)}
{renaming && (
diff --git a/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx b/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx
index ae249b97..f57edf2e 100644
--- a/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx
+++ b/packages/desktop/src/renderer/src/components/sidebar/SessionRow.tsx
@@ -1,7 +1,6 @@
import type { SessionMeta } from "@percho/shared";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useT } from "../../i18n";
-import { isDraftSessionId } from "../../stores/sessions";
import { PinIcon } from "../icons";
import { sessionTitle, useSessionStatus } from "../session/session-status";
import type { MenuAnchor } from "../ui/place-menu";
@@ -36,13 +35,13 @@ export function SessionRow({
}) {
const t = useT();
const status = useSessionStatus(session.sessionId);
- // draft 还没落盘、也没有名字:固定显示「新会话」(后来真的有名字了才回落到 sessionTitle)
- const title = isDraftSessionId(session.sessionId)
- ? t("sidebar.newSession")
- : sessionTitle(session, t("projects.untitled"), t("projects.daily"));
+ const title = sessionTitle(session, t("projects.untitled"), t("projects.daily"));
return (
s.addedProjects);
const deleteProject = useProjectsStore((s) => s.deleteProject);
const activeSessionId = useSessionsStore((s) => s.activeSessionId);
- // 内存会话(含 draft、刚创建还没进历史的会话):与磁盘历史合并成左栏数据源(spec D2,纯函数有单测)
+ // 内存会话(含刚创建还没进历史的真实会话与只读子会话):与磁盘历史合并成左栏数据源(spec D2)
const memorySessions = useSessionsStore((s) => s.sessions);
+ // 默认展开组用「当前目录」:真实会话 = 它的项目;新会话页 = draft 的目录(store.cwd 两处都已镜像)
+ const activeCwd = useSessionsStore((s) => s.cwd);
const { toggleGroup } = useExpandedGroups();
- const mergedSessions = useMemo(
- () => mergeSidebarSessions(allSessions, memorySessions),
- [allSessions, memorySessions],
- );
-
+ // 装配全在纯函数层(含只读子会话过滤 + 项目表同源),Sidebar 只负责取数与分发
const data = useMemo(
() =>
- deriveSidebarGroups({
- sessions: mergedSessions,
- // 项目表也用合并结果:draft 的目录若还没历史会话,要靠它才能成组
- projects: deriveProjects({ allSessions: mergedSessions, addedProjects }),
+ deriveSidebarNavigation({
+ history: allSessions,
+ memory: memorySessions,
+ addedProjects,
search,
- activeSessionId,
+ activeCwd,
pinnedSessions,
pinnedProjects,
expandedGroups,
expandedGroupsTouched,
}),
[
- mergedSessions,
+ allSessions,
+ memorySessions,
addedProjects,
search,
- activeSessionId,
+ activeCwd,
pinnedSessions,
pinnedProjects,
expandedGroups,
diff --git a/packages/desktop/src/renderer/src/hooks/use-context-usage.ts b/packages/desktop/src/renderer/src/hooks/use-context-usage.ts
index d7c78272..4544ceea 100644
--- a/packages/desktop/src/renderer/src/hooks/use-context-usage.ts
+++ b/packages/desktop/src/renderer/src/hooks/use-context-usage.ts
@@ -1,7 +1,6 @@
import type { ContextUsageInfo } from "@percho/shared";
import { useCallback, useEffect, useRef, useState } from "react";
import { getPi } from "../api";
-import { isDraftSessionId } from "../stores/sessions";
/** 触发刷新的事件类型(流式 delta 类高频事件不刷新) */
const REFRESH_EVENTS = new Set([
@@ -20,7 +19,7 @@ function isRefreshEvent(type: string): boolean {
/**
* 上下文使用量 hook(事件驱动刷新):给定会话 id,返回 { tokens, contextWindow, percent }。
- * sessionId 为 null / draft(后端无此会话)时返回 null。ContextRing 与插件 host API 共用,
+ * sessionId 为 null(新会话页,后端还没有这个会话)时返回 null。ContextRing 与插件 host API 共用,
* 抽自 ContextRing(行为零变化)。
*/
export function useContextUsage(sessionId: string | null): ContextUsageInfo | null {
@@ -29,8 +28,8 @@ export function useContextUsage(sessionId: string | null): ContextUsageInfo | nu
const cancelledRef = useRef(false);
const refresh = useCallback(async () => {
- // draft 在后端不存在,无上下文用量可查
- if (!sessionId || isDraftSessionId(sessionId)) {
+ // 新会话页(null)在后端还不存在,无上下文用量可查
+ if (!sessionId) {
setUsage(null);
return;
}
diff --git a/packages/desktop/src/renderer/src/hooks/use-session-state.ts b/packages/desktop/src/renderer/src/hooks/use-session-state.ts
index d76c66e5..a0609dab 100644
--- a/packages/desktop/src/renderer/src/hooks/use-session-state.ts
+++ b/packages/desktop/src/renderer/src/hooks/use-session-state.ts
@@ -3,13 +3,15 @@ import { useSessionsStore } from "../stores/sessions";
import { useTranscriptStore } from "../stores/transcript";
/**
- * 当前生效模型的完整信息(D4 收拢点):会话覆写 ?? 全局默认 → models 表解析。
+ * 当前生效模型的完整信息(D4 收拢点):
+ * 真实会话 = 会话覆写 ?? 全局默认;draft 页(`activeSessionId === null`)= draft 配置里的起步模型。
* Composer(图片门控)/ ModelPicker / ThinkingPicker 三处共用,替代各自手写解析。
*/
export function useActiveModelInfo(): AvailableModel | undefined {
return useSessionsStore((s) => {
const sessionModel = s.sessions.find((x) => x.sessionId === s.activeSessionId)?.model;
- const effective = sessionModel ?? s.lastUsedModel;
+ const effective =
+ s.activeSessionId === null ? s.newSessionDraft?.model : (sessionModel ?? s.lastUsedModel);
if (!effective) return undefined;
return s.models.find((m) => m.provider === effective.provider && m.id === effective.modelId);
});
diff --git a/packages/desktop/src/renderer/src/i18n/en.ts b/packages/desktop/src/renderer/src/i18n/en.ts
index 15f2e1d6..78f268be 100644
--- a/packages/desktop/src/renderer/src/i18n/en.ts
+++ b/packages/desktop/src/renderer/src/i18n/en.ts
@@ -246,12 +246,10 @@ export const en: Messages = {
delete: "Delete session",
deleteTitle: "Delete this session?",
deleteDesc: "Its transcript and session file will be deleted permanently. This cannot be undone.",
- discardDraft: "Discard new session",
},
sidebar: {
title: "Navigation",
search: "Search sessions",
- newSession: "New session",
daily: "Daily",
projectsSection: "Projects",
addProject: "Add project",
diff --git a/packages/desktop/src/renderer/src/i18n/zh.ts b/packages/desktop/src/renderer/src/i18n/zh.ts
index 5ff2e538..3d991c79 100644
--- a/packages/desktop/src/renderer/src/i18n/zh.ts
+++ b/packages/desktop/src/renderer/src/i18n/zh.ts
@@ -243,14 +243,10 @@ export const zh = {
delete: "删除会话",
deleteTitle: "删除会话?",
deleteDesc: "该会话的记录与文件会被永久删除,无法恢复。",
- /** draft 专用:只丢内存里的占位会话,不删磁盘文件、不碰后端(走纯本地分支) */
- discardDraft: "丢弃新会话",
},
sidebar: {
title: "导航",
search: "搜索会话",
- /** 未落盘的 draft 在左栏的固定标题(它还没名字,也不该显示 uuid) */
- newSession: "新会话",
daily: "日常",
projectsSection: "项目",
addProject: "添加项目",
diff --git a/packages/desktop/src/renderer/src/lib/session-gc-run.test.ts b/packages/desktop/src/renderer/src/lib/session-gc-run.test.ts
index bc8548e7..dda6ab89 100644
--- a/packages/desktop/src/renderer/src/lib/session-gc-run.test.ts
+++ b/packages/desktop/src/renderer/src/lib/session-gc-run.test.ts
@@ -20,7 +20,7 @@ function openSession(
lastUsedAt: number,
extra: Partial = {},
): SessionGcOpen {
- return { sessionId, lastUsedAt, isDraft: false, messageCount: 4, ...extra };
+ return { sessionId, lastUsedAt, messageCount: 4, ...extra };
}
function makeDeps(
diff --git a/packages/desktop/src/renderer/src/lib/session-gc.test.ts b/packages/desktop/src/renderer/src/lib/session-gc.test.ts
index 46a5ada7..d091dadc 100644
--- a/packages/desktop/src/renderer/src/lib/session-gc.test.ts
+++ b/packages/desktop/src/renderer/src/lib/session-gc.test.ts
@@ -22,7 +22,7 @@ const IDLE_ENTRY: SessionGcEntry = {
const NOW = 1_000_000_000;
function session(sessionId: string, lastUsedAt: number, extra: Partial = {}): SessionGcOpen {
- return { sessionId, lastUsedAt, isDraft: false, messageCount: 4, ...extra };
+ return { sessionId, lastUsedAt, messageCount: 4, ...extra };
}
function input(over: Partial = {}): SessionGcInput {
@@ -90,10 +90,6 @@ describe("pickUnloadCandidates · 单条保护条件", () => {
).toEqual([]);
});
- it("draft 不卸(内存 tab,卸了就是「关掉」)", () => {
- expect(ids({ open: [session("draft:x", NOW - 10_000_000, { isDraft: true })] })).toEqual([]);
- });
-
it("0 消息会话不卸(还没有会话文件,磁盘历史里查不到,卸掉 = 条目消失)", () => {
expect(ids({ open: [session("empty", NOW - 10_000_000, { messageCount: 0 })] })).toEqual([]);
});
@@ -135,7 +131,6 @@ describe("pickUnloadCandidates · 单条保护条件", () => {
expect(isProtected(session("a", NOW), IDLE_ENTRY)).toBe(false);
expect(isProtected(session("a", NOW), undefined)).toBe(false); // 还没装载 transcript = 空闲
expect(isProtected(session("a", NOW), { ...IDLE_ENTRY, agentActive: true })).toBe(true);
- expect(isProtected(session("a", NOW, { isDraft: true }), IDLE_ENTRY)).toBe(true);
expect(isProtected(session("a", NOW, { messageCount: 0 }), IDLE_ENTRY)).toBe(true);
expect(isProtected(session("a", NOW, { messageCount: 0 }), { ...IDLE_ENTRY, messageCount: 2 })).toBe(
false,
diff --git a/packages/desktop/src/renderer/src/lib/session-gc.ts b/packages/desktop/src/renderer/src/lib/session-gc.ts
index d388f616..6c839fe3 100644
--- a/packages/desktop/src/renderer/src/lib/session-gc.ts
+++ b/packages/desktop/src/renderer/src/lib/session-gc.ts
@@ -8,7 +8,7 @@
* 这次进程里的」一律不能卸(跑了会中断、排队的消息在后端内存里、权限模式不落盘、0 消息会话
* 连会话文件都还没有)。依据见 spec `session-memory-policy.md` §3 决策 3 与阶段 0 审计。
*
- * 顺序语义(单测逐条钉住):① 先剔除受保护 / draft / 活跃 / `freshMs` 内用过的;
+ * 顺序语义(单测逐条钉住):① 先剔除受保护 / 活跃 / `freshMs` 内用过的;
* ② 剩余按 `lastUsedAt` **最久未用在前**排序;③ 保留前 `keep` 个最新的,其余是候选;
* ④ 某会话 `now - lastUsedAt > idleTimeoutMs` 时,即使还在 keep 名额内也照样候选(兜底超时)。
*/
@@ -40,8 +40,6 @@ export interface SessionGcOpen {
sessionId: string;
/** 最近使用时刻(切会话/打开/新建时打点;毫秒) */
lastUsedAt: number;
- /** draft(还没落盘的内存 tab) */
- isDraft: boolean;
/** 消息条数(磁盘元数据,打开时读一次):与 transcript 条数取大值判断「有没有会话文件」 */
messageCount: number;
/**
@@ -84,11 +82,10 @@ export const GC_DEFAULTS = {
/**
* 保护判定(受保护会话**不占 K 名额**、永不卸载)。
- * 会话侧三条:draft / 0 消息 / 权限模式非 default;transcript 侧六条见 `SessionGcEntry`。
- * 导出给单测与接线层复用(接线层算候选时无需重复这套判断)。
+ * 会话侧两条:0 消息 / 频道订阅(权限模式那条已随 D7 落盘退出保护清单);
+ * transcript 侧六条见 `SessionGcEntry`。导出给单测与接线层复用(接线层算候选时无需重复这套判断)。
*/
export function isProtected(item: SessionGcOpen, entry: SessionGcEntry | undefined): boolean {
- if (item.isDraft) return true;
// 有频道订阅 = 明确驻留语义(spec channel-watch retention §6.2):卸载会停 watcher,
// 会话从此收不到频道唤醒——用户显式订阅了就该留在内存里(退订即恢复普通 GC 资格)。
if (item.hasChannelSubscriptions) return true;
diff --git a/packages/desktop/src/renderer/src/lib/session-visibility.ts b/packages/desktop/src/renderer/src/lib/session-visibility.ts
new file mode 100644
index 00000000..9874dbce
--- /dev/null
+++ b/packages/desktop/src/renderer/src/lib/session-visibility.ts
@@ -0,0 +1,13 @@
+import type { SessionMeta } from "@percho/shared";
+
+/**
+ * 会话是否属于「导航可见集合」(左栏项目组 / 搜索 / 项目计数 / 顶栏胶囊)。
+ *
+ * 临时 subagent 的检视会话(`readOnly: true`)**只从导航投影里过滤**:它必须继续留在
+ * `sessions` store 里(本地事件、transcript、只读判定、关闭/GC 都靠它),只是不该出现在
+ * 左栏的分组与计数、也不该出现在顶栏。判据收在这一处,导航派生(`lib/sidebar-groups`)
+ * 与顶栏兜底(`stores/sessions.selectBarSessions`)共用,避免两处各写一份。
+ */
+export function isPrimaryNavigationSession(session: SessionMeta): boolean {
+ return session.readOnly !== true;
+}
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 0a861e26..b5a765d9 100644
--- a/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts
+++ b/packages/desktop/src/renderer/src/lib/sidebar-groups.test.ts
@@ -4,8 +4,12 @@ import type { ProjectEntry } from "../stores/projects";
import { setDailyDirForTest } from "./daily";
import {
deriveSidebarGroups,
+ deriveSidebarNavigation,
+ isPrimaryNavigationSession,
mergeSidebarSessions,
+ primaryNavigationSessions,
type SidebarGroupsInput,
+ type SidebarNavigationInput,
toggleExpandedGroup,
toggleInList,
} from "./sidebar-groups";
@@ -36,13 +40,14 @@ function derive(overrides: Partial = {}) {
/**
* 展开态「用户已操作」位:所有用例**显式给值**,不依赖实现里的缺省推断。
+ * `activeCwd` 也显式给(阶段 0 起契约就是「调用方传当前会话所在目录」)。
*/
function deriveWithTouched(overrides: Partial, expandedGroupsTouched: boolean) {
return deriveSidebarGroups({
sessions: [],
projects: [],
search: "",
- activeSessionId: null,
+ activeCwd: null,
pinnedSessions: [],
pinnedProjects: [],
expandedGroups: [],
@@ -93,7 +98,7 @@ describe("deriveSidebarGroups · 展开推断", () => {
const result = derive({
sessions: [session("a", P1, 100), session("b", P2, 200), session("c", DAILY, 300)],
projects: [project(P1), project(P2)],
- activeSessionId: "a",
+ activeCwd: P1,
});
expect(result.projects.map((p) => [p.cwd, p.expanded])).toEqual([
[P1, true],
@@ -114,7 +119,6 @@ describe("deriveSidebarGroups · 展开推断", () => {
{
sessions: [session("a", P1, 100), session("b", P2, 200)],
projects: [project(P1), project(P2)],
- activeSessionId: "a",
expandedGroups: [P2],
},
true,
@@ -131,7 +135,6 @@ describe("deriveSidebarGroups · 展开推断", () => {
{
sessions: [session("a", P1, 100)],
projects: [project(P1)],
- activeSessionId: "a",
expandedGroups: ["__projects__"],
},
true,
@@ -143,7 +146,7 @@ describe("deriveSidebarGroups · 展开推断", () => {
describe("deriveSidebarGroups · 展开状态 touched 位", () => {
it("touched=false + 空记录:按默认推断只展开当前会话所在组", () => {
const result = deriveWithTouched(
- { sessions: [session("a", P1, 100)], projects: [project(P1)], activeSessionId: "a" },
+ { sessions: [session("a", P1, 100)], projects: [project(P1)], activeCwd: P1 },
false,
);
expect(result.projects.map((p) => [p.cwd, p.expanded])).toEqual([[P1, true]]);
@@ -151,7 +154,7 @@ describe("deriveSidebarGroups · 展开状态 touched 位", () => {
it("touched=true + 空记录:空数组合法 = 全部折叠(不能再被当成「未操作」回退默认集)", () => {
const result = deriveWithTouched(
- { sessions: [session("a", P1, 100)], projects: [project(P1)], activeSessionId: "a" },
+ { sessions: [session("a", P1, 100)], projects: [project(P1)], activeCwd: P1 },
true,
);
expect(result.projects.map((p) => [p.cwd, p.expanded])).toEqual([[P1, false]]);
@@ -163,7 +166,7 @@ describe("deriveSidebarGroups · 展开状态 touched 位", () => {
const base = {
sessions: [session("a", P1, 100)],
projects: [project(P1)],
- activeSessionId: "a",
+ activeCwd: P1,
};
const first = deriveWithTouched(base, false);
expect(first.defaultExpandedKeys).toEqual([P1]);
@@ -179,7 +182,6 @@ describe("deriveSidebarGroups · 展开状态 touched 位", () => {
{
sessions: [session("a", P1, 100), session("b", P2, 200)],
projects: [project(P1), project(P2)],
- activeSessionId: "b",
expandedGroups: [],
},
true,
@@ -244,29 +246,177 @@ describe("mergeSidebarSessions(磁盘历史 + 当前内存会话)", () => {
expect(merged.find((s) => s.sessionId === "h2")?.name).toBe("改过的名字");
});
- it("内存独有项全部保留(draft、刚创建还没进历史的真实会话),顺序不被改写", () => {
- const merged = mergeSidebarSessions(hist, [session("draft:x", P1, 300), session("fresh", P1, 400)]);
- expect(merged.map((s) => s.sessionId)).toEqual(["h1", "h2", "draft:x", "fresh"]);
+ it("内存独有项全部保留(刚创建还没进历史的真实会话),顺序不被改写", () => {
+ const merged = mergeSidebarSessions(hist, [session("fresh-0", P1, 300), session("fresh", P1, 400)]);
+ expect(merged.map((s) => s.sessionId)).toEqual(["h1", "h2", "fresh-0", "fresh"]);
});
it("无内存会话时等于历史(顺序不变)", () => {
expect(mergeSidebarSessions(hist, []).map((s) => s.sessionId)).toEqual(["h1", "h2"]);
});
- it("合并结果直接驱动派生:draft 落在所属项目的分组里(draft 的 cwd 尚无历史时也成组)", () => {
- const merged = mergeSidebarSessions(hist, [session("draft:x", P2, 300)]);
+ it("合并结果直接驱动派生:内存独有会话落在所属项目的分组里(cwd 尚无历史时也成组)", () => {
+ const merged = mergeSidebarSessions(hist, [session("fresh-1", P2, 300)]);
const result = derive({
sessions: merged,
projects: [project(P1), project(P2)],
- activeSessionId: "draft:x",
+ activeCwd: P2,
});
expect(result.projects.find((p) => p.cwd === P2)?.sessions.map((s) => s.session.sessionId)).toEqual([
- "draft:x",
+ "fresh-1",
]);
expect(result.defaultExpandedKeys).toEqual([P2]);
});
});
+// ---------------------------------------------------------------------------
+// 阶段 0 红测(spec sidebar-session-switch-stability D1 / §5 排序):
+// 内存 meta 覆盖历史 meta 时不得丢掉稳定时间字段,否则左栏排序键从 modifiedAt 掉到 createdAt、行跳动。
+// 实现见 plan 阶段 1.2。
+// ---------------------------------------------------------------------------
+
+describe("mergeSidebarSessions · 稳定时间字段(打开/卸载不改变行位置)", () => {
+ it("内存同 ID 缺 modifiedAt:保留历史活动时间;createdAt 用历史权威值(不信内存的运行态时间)", () => {
+ const history: SessionMeta = { ...session("h1", P1, 900), createdAt: 100, messageCount: 3 };
+ // 活跃会话 meta:registry 目前只给 createdAt(且是文件 birthtime),没有 modifiedAt
+ const memory: SessionMeta = {
+ sessionId: "h1",
+ cwd: P1,
+ active: true,
+ messageCount: 5,
+ createdAt: 5_000,
+ };
+ const merged = mergeSidebarSessions([history], [memory]);
+
+ expect(merged).toHaveLength(1);
+ expect(merged[0]?.modifiedAt).toBe(900);
+ expect(merged[0]?.createdAt).toBe(100);
+ // 运行态字段仍以内存为准
+ expect(merged[0]?.messageCount).toBe(5);
+ expect(merged[0]?.active).toBe(true);
+ });
+
+ it("内存给了 modifiedAt 就以内存的为准(会话真的产生了新活动)", () => {
+ const merged = mergeSidebarSessions(
+ [{ ...session("h1", P1, 900), createdAt: 100 }],
+ [{ sessionId: "h1", cwd: P1, active: true, messageCount: 1, createdAt: 100, modifiedAt: 1_500 }],
+ );
+ expect(merged[0]?.modifiedAt).toBe(1_500);
+ });
+
+ it("打开前后派生顺序完全一致(点击历史行不再下移)", () => {
+ const hist = [session("a", P1, 900), session("b", P1, 800), session("c", P1, 700)];
+ const before = derive({ sessions: hist, projects: [project(P1)] });
+ const orderBefore = before.projects[0]?.sessions.map((s) => s.session.sessionId);
+
+ // 用户点了 b:b 变成「内存活跃会话」,内存 meta 无 modifiedAt(registry 现状)
+ const merged = mergeSidebarSessions(hist, [
+ { sessionId: "b", cwd: P1, active: true, messageCount: 2, createdAt: 5_000 },
+ ]);
+ const after = derive({ sessions: merged, projects: [project(P1)] });
+
+ expect(orderBefore).toEqual(["a", "b", "c"]);
+ expect(after.projects[0]?.sessions.map((s) => s.session.sessionId)).toEqual(orderBefore);
+ });
+
+ it("置顶分区顺序不受合并影响(置顶仍在最前,按 pinnedSessions 顺序)", () => {
+ const hist = [session("a", P1, 900), session("b", P1, 800), session("c", P1, 700)];
+ const merged = mergeSidebarSessions(hist, [
+ { sessionId: "c", cwd: P1, active: true, messageCount: 2, createdAt: 5_000 },
+ ]);
+ const result = derive({ sessions: merged, projects: [project(P1)], pinnedSessions: ["c"] });
+ const alpha = result.projects.find((p) => p.cwd === P1);
+ expect(alpha?.sessions.map((s) => s.session.sessionId)).toEqual(["c", "a", "b"]);
+ expect(alpha?.sessions.map((s) => s.pinned)).toEqual([true, false, false]);
+ });
+
+ it("无历史对应(同 ID 只出现在内存:刚创建的真实会话)时不做字段兜底,保持原样", () => {
+ const fresh = session("fresh-2", P1, 300);
+ expect(mergeSidebarSessions([session("h1", P1, 100)], [fresh])[1]).toBe(fresh);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 只读子会话(tmp subagent 检视态)只从**导航投影**过滤(内存 sessions / backend registry 不动),
+// 过滤后的集合同时喂给 deriveProjects 与 deriveSidebarGroups;默认展开用显式 activeCwd。
+// ---------------------------------------------------------------------------
+
+describe("导航投影:只读子会话不进左栏", () => {
+ /**
+ * 走**生产装配函数**(Sidebar 用的就是它):历史 + 内存合并 → 过滤只读子会话 →
+ * 同一份集合同时喂给项目表与分组派生。不在测试里手抄装配顺序。
+ */
+ function navigation(
+ history: SessionMeta[],
+ memory: SessionMeta[],
+ extra: Partial = {},
+ ) {
+ return {
+ sessions: primaryNavigationSessions(history, memory),
+ result: deriveSidebarNavigation({
+ history,
+ memory,
+ addedProjects: [],
+ search: "",
+ activeCwd: null,
+ pinnedSessions: [],
+ pinnedProjects: [],
+ expandedGroups: [],
+ expandedGroupsTouched: false,
+ ...extra,
+ }),
+ };
+ }
+
+ it("isPrimaryNavigationSession:readOnly 子会话被排除,主会话保留", () => {
+ expect(isPrimaryNavigationSession(session("main", P1, 100))).toBe(true);
+ expect(isPrimaryNavigationSession({ ...session("sub", P1, 100), readOnly: true })).toBe(false);
+ });
+
+ it("只读子会话不计项目数量、不参与搜索、不生成幽灵项目组", () => {
+ const sub = { ...session("sub-1", P2, 500, "子代理检视"), readOnly: true };
+ const { sessions, result } = navigation([session("main", P1, 100)], [sub]);
+
+ expect(sessions.map((s) => s.sessionId)).toEqual(["main"]);
+ // P2 只有一条只读子会话:不该因此凭空长出一个项目组
+ expect(result.projects.map((p) => p.cwd)).toEqual([P1]);
+ expect(result.projects[0]?.totalSessions).toBe(1);
+
+ const searched = navigation([session("main", P1, 100)], [sub], { search: "子代理" });
+ expect(searched.result.projects).toEqual([]);
+ });
+
+ it("activeCwd 显式传入:active 会话本身被过滤出导航集合时仍能展开它所在项目(subagent 检视页)", () => {
+ const sub = { ...session("sub-1", P2, 500), readOnly: true };
+ const { result } = navigation([session("main", P1, 100)], [sub], {
+ activeCwd: P2,
+ });
+ expect(result.defaultExpandedKeys).toEqual([P2]);
+ expect(result.projects.find((p) => p.cwd === P2)).toBeUndefined();
+ });
+
+ it("尚无当前目录(新会话 draft 还没选项目):默认展开集为空,不误展开任何组", () => {
+ const { result } = navigation([session("main", P1, 100)], [], {
+ activeCwd: null,
+ });
+ expect(result.defaultExpandedKeys).toEqual([]);
+ });
+
+ it("activeCwd 为 null:哪怕导航里已有别的项目的会话也不展开任何组(不能猜「当前是哪个」)", () => {
+ const { result } = navigation([session("main", P1, 100), session("a", P2, 200)], [], {
+ activeCwd: null,
+ });
+ expect(result.defaultExpandedKeys).toEqual([]);
+ });
+
+ it("刚创建的普通真实会话仍由 mergeSidebarSessions 补进左栏(历史还没刷新的窗口)", () => {
+ const fresh = { ...session("fresh-1", P2, 900), messageCount: 0 };
+ const { result } = navigation([session("main", P1, 100)], [fresh], {});
+ const beta = result.projects.find((p) => p.cwd === P2);
+ expect(beta?.sessions.map((s) => s.session.sessionId)).toEqual(["fresh-1"]);
+ });
+});
+
describe("toggleInList / toggleExpandedGroup", () => {
it("置顶切换:新置顶排最前,取消则移除", () => {
expect(toggleInList([], "a")).toEqual(["a"]);
diff --git a/packages/desktop/src/renderer/src/lib/sidebar-groups.ts b/packages/desktop/src/renderer/src/lib/sidebar-groups.ts
index ee86596b..f6df899f 100644
--- a/packages/desktop/src/renderer/src/lib/sidebar-groups.ts
+++ b/packages/desktop/src/renderer/src/lib/sidebar-groups.ts
@@ -1,7 +1,10 @@
import type { SessionMeta } from "@percho/shared";
-import type { ProjectEntry } from "../stores/projects";
+import { deriveProjects, type ProjectEntry } from "../stores/projects";
import { partitionSessionsByPin } from "../stores/sessions";
import { getDailyDirCached } from "./daily";
+import { isPrimaryNavigationSession } from "./session-visibility";
+
+export { isPrimaryNavigationSession };
/**
* 左侧栏的纯派生层:把「全量历史会话 + 项目表 + 偏好」算成可直接渲染的分组数组。
@@ -44,15 +47,32 @@ export type SidebarGroupsResult = {
defaultExpandedKeys: string[];
};
+/**
+ * 同 ID 合并(spec D1):运行态字段(name/model/active/messageCount/readOnly…)以**内存**为准,
+ * 但**稳定时间字段**不能被不完整的活跃 meta 抹掉:
+ *
+ * - `createdAt`:历史(磁盘 header 时间)是权威值。活跃 meta 曾经用文件 birthtime,复制/恢复会话文件就会变;
+ * - `modifiedAt`:内存 meta 缺活动时间(老 registry meta)时**保留历史值**。丢了它排序键会从
+ * `modifiedAt` 掉到 `createdAt`(`groupSessions` 的 `modifiedAt ?? createdAt`),
+ * 于是「点一下历史行,它就跳到别处」——同一个会话并未真的产生新活动。
+ */
+function mergeSessionMeta(history: SessionMeta, memory: SessionMeta): SessionMeta {
+ return {
+ ...memory,
+ createdAt: history.createdAt,
+ modifiedAt: memory.modifiedAt ?? history.modifiedAt,
+ };
+}
+
/**
* 左栏数据源合并:**磁盘历史(`projects.allSessions`)+ 当前内存会话(`sessions.sessions`)**。
- * 内存项按 `sessionId` 覆盖历史项(名称/模型/状态以当前实例为准),历史项保持原顺序,
- * 内存独有项(draft、刚创建还没落盘的真实会话)按内存顺序补在后面。**不负责排序**:
+ * 内存项按 `sessionId` 覆盖历史项(名称/模型/状态以当前实例为准,时间字段走 `mergeSessionMeta` 合并),
+ * 历史项保持原顺序,内存独有项(刚创建、还没进历史的真实会话)按内存顺序补在后面。**不负责排序**:
* 组内排序统一由 `groupSessions` 做(最后活动倒序 + 置顶分区)。
*
- * 为什么不只拼 draft:draft 发首条消息时会在 `sessions` 里**原地替换**成真实会话,
- * 而它此刻还没进 `allSessions`(历史要重新拉取)——只拼 draft 会让左栏行在这段缝隙里消失。
- * 合并全部内存会话就从根上消除了这个状态缺口(spec D2)。
+ * 为什么必须合并内存会话:新会话 promotion 成真实会话后,`sessions` 里立刻有了它,
+ * 而 `allSessions`(磁盘历史)要等下次重拉才更新——只吃历史会让左栏行在这段缝隙里消失。
+ * 合并全部内存会话从根上消除了这个状态缺口(spec D2)。新会话页没有会话条目,靠 `activeCwd` 展开当前组。
*/
export function mergeSidebarSessions(
history: readonly SessionMeta[],
@@ -61,18 +81,26 @@ export function mergeSidebarSessions(
// Map 保序:覆盖同 id 不会改变它原有的插入位置(历史顺序不被改写)
const byId = new Map();
for (const session of history) byId.set(session.sessionId, session);
- for (const session of memory) byId.set(session.sessionId, session);
+ for (const session of memory) {
+ const previous = byId.get(session.sessionId);
+ byId.set(session.sessionId, previous ? mergeSessionMeta(previous, session) : session);
+ }
return [...byId.values()];
}
export type SidebarGroupsInput = {
- /** 全量历史会话(含未打开的) */
+ /** 全量历史会话(含未打开的);**调用方应先过 `isPrimaryNavigationSession`**(见 `deriveSidebarNavigation`) */
sessions: readonly SessionMeta[];
/** 项目表:**复用 `deriveProjects(projectsStore)` 的输出**(已排除日常目录、已含「只有会话」与「手动添加」两类) */
projects: readonly ProjectEntry[];
/** 搜索词(与 `deriveSessions` 同规则:命中名称或 sessionId,空串不过滤) */
search: string;
- activeSessionId: string | null;
+ /**
+ * 当前会话所在目录(新会话页 = draft 的目录),只用来算默认展开组。
+ * **显式传入、不从 `sessions` 反查**:只读子会话会被导航投影过滤掉,反查会丢信息;
+ * 传 `null` 就是「没有当前目录」(如新会话页还没选项目),不得回退去反查。
+ */
+ activeCwd: string | null;
pinnedSessions: readonly string[];
pinnedProjects: readonly string[];
/** 已展开的分组 key(**含义由 `expandedGroupsTouched` 决定**) */
@@ -81,6 +109,22 @@ export type SidebarGroupsInput = {
expandedGroupsTouched: boolean;
};
+/** `deriveSidebarNavigation` 的输入:左栏需要的两个数据源(历史 + 内存)+ 项目表 + 偏好 */
+export type SidebarNavigationInput = {
+ /** 磁盘历史(`projects.allSessions`) */
+ history: readonly SessionMeta[];
+ /** 当前内存会话(`sessions.sessions`,含刚创建还没进历史的真实会话与只读子会话) */
+ memory: readonly SessionMeta[];
+ /** 手动添加过的项目目录(`projects.addedProjects`) */
+ addedProjects: string[];
+ search: string;
+ activeCwd: string | null;
+ pinnedSessions: readonly string[];
+ pinnedProjects: readonly string[];
+ expandedGroups: readonly string[];
+ expandedGroupsTouched: boolean;
+};
+
function matchesSearch(session: SessionMeta, query: string): boolean {
if (!query) return true;
return (session.name ?? "").toLowerCase().includes(query) || session.sessionId.includes(query);
@@ -122,10 +166,8 @@ export function deriveSidebarGroups(input: SidebarGroupsInput): SidebarGroupsRes
}
const pinnedSessions = new Set(input.pinnedSessions);
- const activeCwd = input.activeSessionId
- ? (input.sessions.find((session) => session.sessionId === input.activeSessionId)?.cwd ?? null)
- : null;
- const defaultExpandedKeys = activeCwd ? [activeCwd] : [];
+ // 默认展开组只能来自显式 activeCwd:从 sessions 里反查 active 会因只读子会话被过滤而丢信息
+ const defaultExpandedKeys = input.activeCwd ? [input.activeCwd] : [];
// 展开态的唯一判据是 touched 位,不是「数组空不空」:空数组合法表示「用户把最后一组也折了」
const expandedKeys = input.expandedGroupsTouched ? input.expandedGroups : defaultExpandedKeys;
const isExpanded = (key: string) => expandedKeys.includes(key);
@@ -173,6 +215,36 @@ export function deriveSidebarGroups(input: SidebarGroupsInput): SidebarGroupsRes
};
}
+/**
+ * 历史 + 内存合并后,只留进导航的主会话(只读子会话检视态不进左栏,spec §6)。
+ * **过滤只发生在投影层**:store 里那份会话仍然活着(事件/transcript/关闭都靠它)。
+ */
+export function primaryNavigationSessions(
+ history: readonly SessionMeta[],
+ memory: readonly SessionMeta[],
+): SessionMeta[] {
+ return mergeSidebarSessions(history, memory).filter(isPrimaryNavigationSession);
+}
+
+/**
+ * 左栏装配(Sidebar 与单测**共用同一条路径**,不在测试里手抄装配顺序):
+ * 历史 + 内存合并 → 统一过滤只读子会话 → **同一份集合**同时喂给项目表与分组派生。
+ * 项目表必须也用过滤后的集合:否则只读子会话所在目录会凭空长出一个(计数虚高的)项目组。
+ */
+export function deriveSidebarNavigation(input: SidebarNavigationInput): SidebarGroupsResult {
+ const sessions = primaryNavigationSessions(input.history, input.memory);
+ return deriveSidebarGroups({
+ sessions,
+ projects: deriveProjects({ allSessions: sessions, addedProjects: input.addedProjects }),
+ search: input.search,
+ activeCwd: input.activeCwd,
+ pinnedSessions: input.pinnedSessions,
+ pinnedProjects: input.pinnedProjects,
+ expandedGroups: input.expandedGroups,
+ expandedGroupsTouched: input.expandedGroupsTouched,
+ });
+}
+
/** 置顶 / 取消置顶(新置顶排最前):会话与项目共用,store 与组件都走它,别各写一套 */
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/lib/use-session-gc.ts b/packages/desktop/src/renderer/src/lib/use-session-gc.ts
index 3e6c952b..d5d681fe 100644
--- a/packages/desktop/src/renderer/src/lib/use-session-gc.ts
+++ b/packages/desktop/src/renderer/src/lib/use-session-gc.ts
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { getPi } from "../api";
-import { isDraftSessionId, useSessionsStore } from "../stores/sessions";
+import { useSessionsStore } from "../stores/sessions";
import { useTranscriptStore } from "../stores/transcript";
import type { SessionGcEntry } from "./session-gc";
import { runSessionGcRound } from "./session-gc-run";
@@ -53,7 +53,6 @@ export function useSessionGc(): void {
sessionId: session.sessionId,
// 缺打点理论不可达(每个进 sessions 的路径都打点);真缺了当「最久未用」处理
lastUsedAt: state.lastUsedAt[session.sessionId] ?? 0,
- isDraft: isDraftSessionId(session.sessionId),
// 磁盘元数据(打开时读一次;本次进程内新建的会话恒为 0):与 transcript 实时条数
// 一起交给纯层判「有没有会话文件」,见 lib/session-gc.ts 的 isProtected;
// hasChannelSubscriptions 由本轮编排从 backend 快照映射进来
diff --git a/packages/desktop/src/renderer/src/stores/drafts.ts b/packages/desktop/src/renderer/src/stores/drafts.ts
index 00ae5e2d..7c9841e5 100644
--- a/packages/desktop/src/renderer/src/stores/drafts.ts
+++ b/packages/desktop/src/renderer/src/stores/drafts.ts
@@ -1,7 +1,10 @@
import type { ImageInput } from "@percho/shared";
import { create } from "zustand";
-/** 无活跃会话(未创建的新会话)时的草稿 key */
+/**
+ * 新会话页(`activeSessionId === null`)的草稿 key。
+ * 单例 draft:全局只有这一份新会话内容,新建/再点「+」/promotion 前后都不换 key。
+ */
export const NEW_SESSION_DRAFT_KEY = "__new__";
/** 请求聚焦输入框的窗口事件名(撤回回填草稿后由 store 派发,Composer 监听聚焦 textarea) */
diff --git a/packages/desktop/src/renderer/src/stores/projects.test.ts b/packages/desktop/src/renderer/src/stores/projects.test.ts
index 7a2093fe..a394152c 100644
--- a/packages/desktop/src/renderer/src/stores/projects.test.ts
+++ b/packages/desktop/src/renderer/src/stores/projects.test.ts
@@ -1,7 +1,15 @@
import type { SessionMeta } from "@percho/shared";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/** window.pi 的 mock:store 的 load 经 getPi() 访问(测试环境无 preload 注入) */
+const piMock = vi.hoisted(() => ({
+ listAllSessions: vi.fn(),
+ getDailyDir: vi.fn(() => Promise.resolve(null)),
+}));
+vi.mock("../api", () => ({ getPi: () => piMock }));
+
import { setDailyDirForTest } from "../lib/daily";
-import { deriveProjects } from "./projects";
+import { deriveProjects, useProjectsStore } from "./projects";
function session(cwd: string, modifiedAt: number): SessionMeta {
return {
@@ -61,3 +69,88 @@ describe("deriveProjects · 日常空间隔离", () => {
expect(out).toHaveLength(2);
});
});
+
+// ---------------------------------------------------------------------------
+// 阶段 0 红测(spec sidebar-session-switch-stability D6、§5 项目 load):
+// 列表刷新 latest-wins —— 旧 load 响应(成功或失败)不得覆盖更新响应,也不得提前把 loading 置 false。
+// 实现见 plan 阶段 3.3。
+// ---------------------------------------------------------------------------
+
+/** 手写 deferred:要精确控制两次 load 的返回顺序(旧后到 / 旧失败后到) */
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function ids(): string[] {
+ return useProjectsStore.getState().allSessions.map((s) => s.sessionId);
+}
+
+describe("projects.load latest-wins(spec D6)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ piMock.listAllSessions.mockReset();
+ useProjectsStore.setState({
+ allSessions: [],
+ selectedCwd: null,
+ search: "",
+ loading: false,
+ loaded: false,
+ });
+ });
+
+ it("load A 后 load B,B 先返回、A 后返回:最终 store 是 B,loading=false", async () => {
+ const a = deferred();
+ const b = deferred();
+ piMock.listAllSessions.mockImplementationOnce(() => a.promise).mockImplementationOnce(() => b.promise);
+
+ const loadA = useProjectsStore.getState().load();
+ const loadB = useProjectsStore.getState().load();
+ b.resolve([session("/p", 200)]);
+ await loadB;
+ a.resolve([session("/p", 100)]);
+ await loadA;
+
+ expect(ids()).toEqual(["/p-200"]);
+ expect(useProjectsStore.getState().loading).toBe(false);
+ expect(useProjectsStore.getState().loaded).toBe(true);
+ });
+
+ it("旧请求失败:不得覆盖新请求成功结果", async () => {
+ const a = deferred();
+ const b = deferred();
+ piMock.listAllSessions.mockImplementationOnce(() => a.promise).mockImplementationOnce(() => b.promise);
+
+ const loadA = useProjectsStore.getState().load();
+ const loadB = useProjectsStore.getState().load();
+ b.resolve([session("/p", 300)]);
+ await loadB;
+ a.reject(new Error("boom"));
+ await loadA;
+
+ expect(ids()).toEqual(["/p-300"]);
+ expect(useProjectsStore.getState().loading).toBe(false);
+ });
+
+ it("旧请求先失败:不得提前把 loading 置 false(新请求仍在途)", async () => {
+ const a = deferred();
+ const b = deferred();
+ piMock.listAllSessions.mockImplementationOnce(() => a.promise).mockImplementationOnce(() => b.promise);
+
+ const loadA = useProjectsStore.getState().load();
+ const loadB = useProjectsStore.getState().load();
+ a.reject(new Error("boom"));
+ await loadA;
+ expect(useProjectsStore.getState().loading).toBe(true);
+
+ b.resolve([session("/p", 400)]);
+ await loadB;
+ expect(ids()).toEqual(["/p-400"]);
+ expect(useProjectsStore.getState().loading).toBe(false);
+ });
+});
diff --git a/packages/desktop/src/renderer/src/stores/projects.ts b/packages/desktop/src/renderer/src/stores/projects.ts
index 673e9cc5..1d8a6e55 100644
--- a/packages/desktop/src/renderer/src/stores/projects.ts
+++ b/packages/desktop/src/renderer/src/stores/projects.ts
@@ -51,6 +51,12 @@ interface ProjectsStore {
openSession: (session: SessionMeta) => Promise;
}
+/**
+ * 列表刷新的单调序号(spec D6):旧请求(含比新请求早发却晚回、或晚失败的那次)不得覆盖新请求的
+ * `allSessions/loading/loaded`。模块级:不落盘、不进 store,重载归零也无意义。
+ */
+let loadSeq = 0;
+
export const useProjectsStore = create((set, get) => ({
allSessions: [],
addedProjects: loadAddedProjects(),
@@ -60,10 +66,13 @@ export const useProjectsStore = create((set, get) => ({
loaded: false,
load: async () => {
+ const seq = ++loadSeq;
set({ loading: true });
try {
// 日常目录与全量会话并行取;目录进 lib/daily 模块缓存(isDailyCwd 同步判定供各组件用)
const [allSessions] = await Promise.all([getPi().listAllSessions(), initDailyDir()]);
+ // latest-wins:更晚的 load 已经发起 → 这份旧快照直接丢弃(loading 由最新那次收尾)
+ if (seq !== loadSeq) return;
set({ allSessions, loading: false, loaded: true });
const { selectedCwd } = get();
if (!selectedCwd) {
@@ -71,6 +80,8 @@ export const useProjectsStore = create((set, get) => ({
if (projects[0]) set({ selectedCwd: projects[0].cwd });
}
} catch {
+ // 旧请求失败也不得插手:既不能覆盖新结果,也不能提前把新请求的 loading 置 false
+ if (seq !== loadSeq) return;
set({ loading: false, loaded: true });
}
},
@@ -82,7 +93,7 @@ export const useProjectsStore = create((set, get) => ({
addProject: async () => {
const cwd = await getPi().pickDirectory();
if (!cwd) return;
- // 信任前置:添加项目即决策(未决弹窗,结果落 trust.json),之后建 draft/会话不再弹
+ // 信任前置:添加项目即决策(未决弹窗,结果落 trust.json),之后在新会话页/建会话都不再弹
void getPi()
.ensureProjectTrust({ cwd })
.catch(() => {});
diff --git a/packages/desktop/src/renderer/src/stores/sessions.test.ts b/packages/desktop/src/renderer/src/stores/sessions.test.ts
index 443ffea7..d38a7a52 100644
--- a/packages/desktop/src/renderer/src/stores/sessions.test.ts
+++ b/packages/desktop/src/renderer/src/stores/sessions.test.ts
@@ -18,16 +18,14 @@ const piMock = vi.hoisted(() => ({
getTodos: vi.fn(() => Promise.resolve([])),
getPermissionMode: vi.fn(() => Promise.resolve("default" as const)),
setPermissionMode: vi.fn(() => Promise.resolve()),
+ // loadModels 的输入(阶段 0 的 draft 默认模型补齐用例):无类型 mock,用例内 mockImplementation 喂数据
+ listModels: vi.fn(),
+ loadUiState: vi.fn(),
}));
vi.mock("../api", () => ({ getPi: () => piMock }));
-import {
- DRAFT_SESSION_PREFIX,
- isDraftSessionId,
- partitionSessionsByPin,
- selectBarSessions,
- useSessionsStore,
-} from "./sessions";
+import { NEW_SESSION_DRAFT_KEY, useDraftStore } from "./drafts";
+import { partitionSessionsByPin, selectBarSessions, useSessionsStore } from "./sessions";
import { useToastsStore } from "./toasts";
import { useTranscriptStore } from "./transcript";
import { useUiPreferencesStore } from "./ui-preferences";
@@ -44,6 +42,7 @@ function realMeta(sessionId: string, cwd: string): SessionMeta {
}
function resetStore() {
+ useSessionsStore.setState({ newSessionDraft: null });
useSessionsStore.setState({
sessions: [],
activeSessionId: null,
@@ -54,23 +53,17 @@ function resetStore() {
trustVersion: 0,
permissionModes: {},
});
+ useDraftStore.setState({ bySession: {} });
}
beforeEach(() => {
vi.clearAllMocks();
+ // openSession 的 mockImplementation 是持久实现(clearAllMocks 不清),用例之间必须显式复位
+ piMock.openSession.mockReset();
resetStore();
useTranscriptStore.setState({ bySession: {} });
});
-describe("isDraftSessionId", () => {
- it("识别 draft 前缀 id", () => {
- expect(isDraftSessionId(`${DRAFT_SESSION_PREFIX}abc`)).toBe(true);
- expect(isDraftSessionId("real-1")).toBe(false);
- expect(isDraftSessionId(null)).toBe(false);
- expect(isDraftSessionId(undefined)).toBe(false);
- });
-});
-
describe("partitionSessionsByPin", () => {
const list = ["a", "b", "c", "d"].map((id) => realMeta(id, "/p"));
const ids = (sessions: SessionMeta[]) => sessions.map((s) => s.sessionId);
@@ -113,114 +106,24 @@ describe("selectBarSessions(顶栏 = 置顶表驱动)", () => {
expect(out[0]?.name).toBe("新名字");
});
- it("draft 不进顶栏:无置顶时即使存在 draft 也返回空(顶栏严格 = 置顶表)", () => {
- const draft = realMeta(`${DRAFT_SESSION_PREFIX}x`, "/p");
- expect(ids(selectBarSessions([...tabs, draft], [], history))).toEqual([]);
- expect(ids(selectBarSessions([...tabs, draft], ["c"], history))).toEqual(["c"]);
+ it("未置顶的已打开会话不进顶栏:顶栏严格 = 置顶表(新会话 draft 根本不在 sessions)", () => {
+ const fresh = realMeta("fresh-1", "/p");
+ expect(ids(selectBarSessions([...tabs, fresh], [], history))).toEqual([]);
+ expect(ids(selectBarSessions([...tabs, fresh], ["c"], history))).toEqual(["c"]);
});
it("置顶表里的未知 id(会话已删)直接跳过,不生成空胶囊", () => {
expect(ids(selectBarSessions(tabs, ["ghost", "b"], history))).toEqual(["b"]);
});
-});
-
-describe("createDraftSession", () => {
- it("只建内存 draft tab:不触后端、不落盘", () => {
- useSessionsStore.setState({ cwd: "/proj/a" });
- useSessionsStore.getState().createDraftSession();
- const state = useSessionsStore.getState();
- expect(state.sessions).toHaveLength(1);
- expect(isDraftSessionId(state.sessions[0]?.sessionId)).toBe(true);
- expect(state.sessions[0]?.cwd).toBe("/proj/a");
- expect(state.sessions[0]?.sessionFile).toBeUndefined();
- expect(state.activeSessionId).toBe(state.sessions[0]?.sessionId);
- expect(piMock.createSession).not.toHaveBeenCalled();
- });
-
- it("支持显式 cwd(项目页新会话入口)", () => {
- useSessionsStore.setState({ cwd: "/proj/a" });
- useSessionsStore.getState().createDraftSession("/proj/b");
- expect(useSessionsStore.getState().sessions[0]?.cwd).toBe("/proj/b");
- expect(useSessionsStore.getState().cwd).toBe("/proj/b");
- });
-
- it("无 cwd 时 no-op", () => {
- useSessionsStore.getState().createDraftSession();
- expect(useSessionsStore.getState().sessions).toHaveLength(0);
- });
-});
-
-describe("setDraftCwd", () => {
- it("活跃 tab 是 draft:同步更新 draft 条目与全局 cwd", () => {
- useSessionsStore.getState().createDraftSession("/proj/a");
- useSessionsStore.getState().setDraftCwd("/proj/b");
- const state = useSessionsStore.getState();
- expect(state.sessions[0]?.cwd).toBe("/proj/b");
- expect(state.cwd).toBe("/proj/b");
- });
-
- it("活跃 tab 是真实会话:只改全局默认,不动会话条目", () => {
- useSessionsStore.setState({ sessions: [realMeta("r1", "/proj/a")], activeSessionId: "r1" });
- useSessionsStore.getState().setDraftCwd("/proj/b");
- const state = useSessionsStore.getState();
- expect(state.sessions[0]?.cwd).toBe("/proj/a");
- expect(state.cwd).toBe("/proj/b");
- });
-});
-
-describe("draft 转正(createSession + replaceDraftId)", () => {
- it("用 draft 的 cwd 创建后端会话,原地替换保持 tab 位置", async () => {
- piMock.createSession.mockResolvedValue(realMeta("real-1", "/proj/b"));
- useSessionsStore.getState().createDraftSession("/proj/a");
- useSessionsStore.getState().createDraftSession("/proj/a");
- const draftId = useSessionsStore.getState().activeSessionId;
- if (!draftId) throw new Error("no draft");
- useSessionsStore.getState().setDraftCwd("/proj/b");
-
- await useSessionsStore.getState().createSession("/proj/b", draftId);
-
- const state = useSessionsStore.getState();
- expect(piMock.createSession).toHaveBeenCalledWith({
- options: { cwd: "/proj/b", thinkingLevel: "medium" },
- });
- expect(state.sessions).toHaveLength(2);
- expect(state.sessions[1]?.sessionId).toBe("real-1");
- expect(state.sessions[1]?.cwd).toBe("/proj/b");
- expect(state.activeSessionId).toBe("real-1");
- // v10:不再落盘打开列表(启动纯空,见 spec §12)
- expect(piMock.openSession).not.toHaveBeenCalled();
- });
- it("创建失败:draft tab 保留,toast 提示(不残留 store 错误态)", async () => {
- piMock.createSession.mockRejectedValue(new Error("boom"));
- useSessionsStore.getState().createDraftSession("/proj/a");
- const draftId = useSessionsStore.getState().activeSessionId;
- await useSessionsStore.getState().createSession("/proj/a", draftId ?? undefined);
- const state = useSessionsStore.getState();
- expect(state.sessions[0]?.sessionId).toBe(draftId);
- expect(
- useToastsStore
- .getState()
- .toasts.some(
- (t) =>
- t.severity === "warning" && t.titleKey === "toast.sessionCreateFailed" && t.detail === "boom",
- ),
- ).toBe(true);
+ // 阶段 0 红测(spec §6.3):tmp subagent 检视会话不该出现在顶栏。
+ it("只读子会话(脏置顶 / 手改 ui-state)被防御性过滤,不生成只读胶囊", () => {
+ const sub = { ...realMeta("sub-1", "/p"), readOnly: true };
+ expect(ids(selectBarSessions([sub], ["sub-1"], [sub]))).toEqual([]);
});
});
describe("closeSession", () => {
- it("关闭 draft:纯本地移除,不调后端、不落盘", async () => {
- useSessionsStore.getState().createDraftSession("/proj/a");
- const draftId = useSessionsStore.getState().activeSessionId;
- if (!draftId) throw new Error("no draft");
- await useSessionsStore.getState().closeSession(draftId);
- const state = useSessionsStore.getState();
- expect(piMock.closeSession).not.toHaveBeenCalled();
- expect(state.sessions).toHaveLength(0);
- expect(state.activeSessionId).toBeNull();
- });
-
it("关闭真实会话:正常走后端并落盘", async () => {
useSessionsStore.setState({ sessions: [realMeta("r1", "/proj/a")], activeSessionId: "r1" });
await useSessionsStore.getState().closeSession("r1");
@@ -280,22 +183,14 @@ describe("closeSession", () => {
});
describe("switchSession", () => {
- it("切到 draft:cwd 恢复为 draft 的目录,但不落盘;切回真实会话恢复并落盘", () => {
+ it("切到真实会话:cwd 跟随该会话的项目", () => {
useSessionsStore.setState({
sessions: [realMeta("r1", "/proj/a")],
activeSessionId: "r1",
cwd: "/proj/a",
});
- useSessionsStore.getState().createDraftSession("/proj/b");
- const draftId = useSessionsStore.getState().activeSessionId;
- if (!draftId) throw new Error("no draft");
-
- vi.clearAllMocks();
useSessionsStore.getState().switchSession("r1");
expect(useSessionsStore.getState().cwd).toBe("/proj/a");
-
- useSessionsStore.getState().switchSession(draftId);
- expect(useSessionsStore.getState().cwd).toBe("/proj/b");
});
});
@@ -371,18 +266,6 @@ describe("forkSession", () => {
});
});
-describe("模型/思考级别", () => {
- it("draft 下切换模型:只记跟随值与 draft 条目,不调后端 setModel", async () => {
- useSessionsStore.getState().createDraftSession("/proj/a");
- await useSessionsStore.getState().setCurrentModel("p", "m");
- const state = useSessionsStore.getState();
- expect(piMock.setModel).not.toHaveBeenCalled();
- expect(state.lastUsedModel).toEqual({ provider: "p", modelId: "m" });
- expect(state.sessions[0]?.model).toEqual({ provider: "p", modelId: "m" });
- expect(piMock.saveUiState).toHaveBeenCalled();
- });
-});
-
describe("乐观会话设置(optimisticSessionSetting 骨架)", () => {
it("切模型成功:跟随值 + 会话条目乐观更新,ui-state 持久化", async () => {
useSessionsStore.setState({
@@ -468,14 +351,6 @@ describe("permissionModes「缺 key = default」语义", () => {
await useSessionsStore.getState().setSessionPermissionMode("s1", "fullAccess");
expect(useUiPreferencesStore.getState().sessionPermissionModes).toEqual({});
});
-
- it("draft 会话模式纯 renderer:不调 IPC", async () => {
- const draftId = `${DRAFT_SESSION_PREFIX}x`;
- useSessionsStore.setState({ sessions: [realMeta(draftId, "/p")], activeSessionId: draftId });
- await useSessionsStore.getState().setSessionPermissionMode(draftId, "fullAccess");
- expect(useSessionsStore.getState().permissionModes).toEqual({ [draftId]: "fullAccess" });
- expect(piMock.setPermissionMode).not.toHaveBeenCalled();
- });
});
describe("switchSession 懒加载兑底", () => {
@@ -519,16 +394,19 @@ describe("记住上次项目目录(lastCwd)", () => {
expect(piMock.saveUiState).toHaveBeenLastCalledWith({ state: { lastCwd: "/work/beta" } });
});
- it("新建会话(draft 转正)后记住该项目", async () => {
- piMock.createSession.mockResolvedValue(realMeta("real-1", "/work/gamma"));
- await useSessionsStore.getState().createSession("/work/gamma");
+ it("首条消息 promotion 成功后记住该项目", async () => {
+ piMock.createSession.mockResolvedValue(realMeta("new-1", "/work/gamma"));
+ useSessionsStore.getState().activateNewSessionDraft("/work/gamma");
+ await useSessionsStore.getState().createSession();
+ expect(useUiPreferencesStore.getState().lastCwd).toBe("/work/gamma");
expect(piMock.saveUiState).toHaveBeenLastCalledWith({ state: { lastCwd: "/work/gamma" } });
});
it("在选择器里选项目(setDraftCwd)后立刻记住:首启选完项目没发消息就退出,下次也不用重选", () => {
- useSessionsStore.getState().createDraftSession("/work/alpha");
+ useSessionsStore.getState().activateNewSessionDraft("/work/alpha");
piMock.saveUiState.mockClear();
useSessionsStore.getState().setDraftCwd("/work/beta");
+ expect(useSessionsStore.getState().newSessionDraft?.cwd).toBe("/work/beta");
expect(useUiPreferencesStore.getState().lastCwd).toBe("/work/beta");
expect(piMock.saveUiState).toHaveBeenLastCalledWith({ state: { lastCwd: "/work/beta" } });
});
@@ -567,3 +445,881 @@ describe("unloadSession(自动 GC)的 intent 标记", () => {
expect(piMock.closeSession).toHaveBeenCalledWith({ sessionId: "r2" });
});
});
+
+// ---------------------------------------------------------------------------
+// 阶段 0 红测(spec sidebar-session-switch-stability D2/D3/D5、§5 导航 / GC):
+// ① 异步导航 latest-wins(旧 open/create/fork 迟到不得抢回 active)
+// ② 同 sessionFile 的 open single-flight(同一文件同时只发一次 IPC、bundle 不并发重复)
+// ③ GC close 在途时用户选中被卸载会话 → close 返回后 reopen 恢复,而不是把它抹掉
+// 实现见 plan 阶段 2 / 3。
+// ---------------------------------------------------------------------------
+
+/** 手写 deferred:要精确控制「异步动作什么时候返回」,才能复现点击与响应的交错 */
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function toastKeys(): string[] {
+ return useToastsStore
+ .getState()
+ .toasts.filter((t) => t.titleKey !== undefined)
+ .map((t) => `${t.titleKey}`);
+}
+
+describe("导航 latest-wins:最后一次点击获胜(spec D2)", () => {
+ beforeEach(() => useToastsStore.setState({ toasts: [] }));
+
+ it("旧 open 迟到不得抢回 active:点击未加载 A → 切到已加载 B → A 才返回", async () => {
+ const opened = deferred();
+ piMock.openSession.mockImplementationOnce(() => opened.promise);
+ useSessionsStore.setState({ sessions: [realMeta("b", "/p")], activeSessionId: "b", cwd: "/p" });
+
+ const opening = useSessionsStore.getState().openFromHistory("/p/a.jsonl");
+ useSessionsStore.getState().switchSession("b");
+ opened.resolve(realMeta("a", "/p"));
+ await opening;
+
+ const state = useSessionsStore.getState();
+ expect(state.activeSessionId).toBe("b");
+ expect(state.cwd).toBe("/p");
+ // 旧 open 成功仍可成为「已加载的后台会话」:latest-wins 只限制谁能激活
+ expect(state.sessions.map((s) => s.sessionId).sort()).toEqual(["a", "b"]);
+ });
+
+ it("连续 open A / C:响应正序或逆序返回,最终都停在最后一次点击的 C", async () => {
+ for (const order of ["a-then-c", "c-then-a"] as const) {
+ resetStore();
+ const a = deferred();
+ const c = deferred();
+ piMock.openSession.mockImplementation((args: { filePath: string }) =>
+ args.filePath.includes("/a.jsonl") ? a.promise : c.promise,
+ );
+
+ const openingA = useSessionsStore.getState().openFromHistory("/p/a.jsonl");
+ const openingC = useSessionsStore.getState().openFromHistory("/p/c.jsonl");
+ if (order === "a-then-c") {
+ a.resolve(realMeta("a", "/p"));
+ await openingA;
+ c.resolve(realMeta("c", "/p"));
+ await openingC;
+ } else {
+ c.resolve(realMeta("c", "/p"));
+ await openingC;
+ a.resolve(realMeta("a", "/p"));
+ await openingA;
+ }
+
+ expect(useSessionsStore.getState().activeSessionId).toBe("c");
+ }
+ });
+
+ it("无 draft 时 createSession 返回 null(调用方据此中止发送),不发 IPC、不动 active/cwd", async () => {
+ useSessionsStore.setState({ sessions: [realMeta("b", "/p")], activeSessionId: "b", cwd: "/p" });
+
+ expect(await useSessionsStore.getState().createSession()).toBeNull();
+
+ expect(piMock.createSession).not.toHaveBeenCalled();
+ expect(useSessionsStore.getState().activeSessionId).toBe("b");
+ expect(useSessionsStore.getState().cwd).toBe("/p");
+ });
+
+ it("fork 迟到不得覆盖后续 switch(新会话进 tabs,但不抢 active)", async () => {
+ useSessionsStore.setState({
+ sessions: [realMeta("r1", "/p"), realMeta("r2", "/p")],
+ activeSessionId: "r1",
+ cwd: "/p",
+ });
+ const forked = deferred();
+ piMock.forkSession.mockImplementationOnce(() => forked.promise);
+
+ const forking = useSessionsStore.getState().forkSession({ entryId: "e1" });
+ useSessionsStore.getState().switchSession("r2");
+ forked.resolve(realMeta("f1", "/p"));
+
+ // fork 事实上发生了:仍要返回新 id 供调用方使用
+ expect(await forking).toBe("f1");
+ const state = useSessionsStore.getState();
+ expect(state.activeSessionId).toBe("r2");
+ expect(state.sessions.map((s) => s.sessionId)).toContain("f1");
+ });
+
+ it("旧 open 失败不得回滚用户的新选择(新选择仍在,且只提示一次)", async () => {
+ const opened = deferred();
+ piMock.openSession.mockImplementationOnce(() => opened.promise);
+ useSessionsStore.setState({ sessions: [realMeta("b", "/p")], activeSessionId: "b", cwd: "/p" });
+
+ const opening = useSessionsStore.getState().openFromHistory("/p/a.jsonl");
+ useSessionsStore.getState().switchSession("b");
+ opened.reject(new Error("open boom"));
+ await opening;
+
+ expect(useSessionsStore.getState().activeSessionId).toBe("b");
+ expect(useSessionsStore.getState().cwd).toBe("/p");
+ });
+});
+
+describe("同 sessionFile 的 open single-flight(spec D3)", () => {
+ beforeEach(() => useToastsStore.setState({ toasts: [] }));
+
+ it("同一文件双击:只发一次 IPC,bundle 不并发重复装载,最终 active = 该会话", async () => {
+ const opened = deferred();
+ piMock.openSession.mockImplementation(() => opened.promise);
+
+ const first = useSessionsStore.getState().openFromHistory("/p/a.jsonl");
+ const second = useSessionsStore.getState().openFromHistory("/p/a.jsonl");
+ expect(piMock.openSession).toHaveBeenCalledTimes(1);
+ opened.resolve(realMeta("a", "/p"));
+ await Promise.all([first, second]);
+
+ expect(useSessionsStore.getState().activeSessionId).toBe("a");
+ expect(piMock.getSessionMessages).toHaveBeenCalledTimes(1);
+ expect(useSessionsStore.getState().sessions.filter((s) => s.sessionId === "a")).toHaveLength(1);
+ });
+
+ it("共享请求失败只 toast 一次(不因调用者数量重复刷屏),且 in-flight 清空可重试", async () => {
+ const opened = deferred();
+ piMock.openSession
+ .mockImplementationOnce(() => opened.promise)
+ .mockImplementationOnce(() => Promise.resolve(realMeta("a", "/p")));
+
+ const failing = useSessionsStore.getState().openFromHistory("/p/a.jsonl");
+ const failingToo = useSessionsStore.getState().openFromHistory("/p/a.jsonl");
+ opened.reject(new Error("boom"));
+ await Promise.all([failing, failingToo]);
+
+ expect(toastKeys().filter((key) => key === "toast.sessionOpenFailed")).toHaveLength(1);
+ expect(useSessionsStore.getState().activeSessionId).toBeNull();
+
+ // settle 后 key 已清:再次点击会重新 open
+ await useSessionsStore.getState().openFromHistory("/p/a.jsonl");
+ expect(piMock.openSession).toHaveBeenCalledTimes(2);
+ expect(useSessionsStore.getState().activeSessionId).toBe("a");
+ });
+
+ it("不同文件并发 open 各自独立(single-flight 只按文件去重)", async () => {
+ piMock.openSession.mockImplementation((args: { filePath: string }) =>
+ Promise.resolve(realMeta(args.filePath.includes("/a.jsonl") ? "a" : "c", "/p")),
+ );
+
+ await Promise.all([
+ useSessionsStore.getState().openFromHistory("/p/a.jsonl"),
+ useSessionsStore.getState().openFromHistory("/p/c.jsonl"),
+ ]);
+
+ expect(piMock.openSession).toHaveBeenCalledTimes(2);
+ expect(
+ useSessionsStore
+ .getState()
+ .sessions.map((s) => s.sessionId)
+ .sort(),
+ ).toEqual(["a", "c"]);
+ });
+});
+
+describe("GC close 在途的选择竞态(spec D5)", () => {
+ beforeEach(() => useToastsStore.setState({ toasts: [] }));
+
+ it("close 在途期间用户切到该会话:关闭成功后自动 reopen,保留 transcript/active,返回 closed:false", async () => {
+ useSessionsStore.setState({
+ sessions: [realMeta("a", "/p"), realMeta("b", "/p")],
+ activeSessionId: "a",
+ cwd: "/p",
+ });
+ // transcript 侧标记:reopen 恢复不能 reset 已有数据
+ useTranscriptStore.getState().setFollowUpQueue("b", ["kept"]);
+ const closing = deferred<{ closed: boolean }>();
+ piMock.closeSession.mockImplementationOnce(() => closing.promise);
+ piMock.openSession.mockResolvedValueOnce(realMeta("b", "/p"));
+
+ const unloading = useSessionsStore.getState().unloadSession("b");
+ useSessionsStore.getState().switchSession("b");
+ closing.resolve({ closed: true });
+ const result = await unloading;
+
+ // 后端已关又重建 → GC 这一轮视为「没卸成」,而不是让 renderer 删掉用户刚选的会话
+ expect(result).toEqual({ closed: false });
+ expect(piMock.openSession).toHaveBeenCalledWith({ filePath: "/tmp/b.jsonl" });
+ const state = useSessionsStore.getState();
+ expect(state.activeSessionId).toBe("b");
+ expect(state.sessions.map((s) => s.sessionId)).toEqual(["a", "b"]);
+ expect(useTranscriptStore.getState().bySession.b?.followUpQueue).toEqual(["kept"]);
+ });
+
+ it("close 在途期间用户切到别处:正常卸载(closed:true、条目移除,不 reopen)", async () => {
+ useSessionsStore.setState({
+ sessions: [realMeta("a", "/p"), realMeta("b", "/p")],
+ activeSessionId: "a",
+ cwd: "/p",
+ });
+ const closing = deferred<{ closed: boolean }>();
+ piMock.closeSession.mockImplementationOnce(() => closing.promise);
+
+ const unloading = useSessionsStore.getState().unloadSession("b");
+ useSessionsStore.getState().switchSession("a");
+ closing.resolve({ closed: true });
+
+ expect(await unloading).toEqual({ closed: true });
+ expect(piMock.openSession).not.toHaveBeenCalled();
+ expect(useSessionsStore.getState().sessions.map((s) => s.sessionId)).toEqual(["a"]);
+ });
+
+ it("reopen 成功后把 renderer 持有的权限档位拉回新 backend 会话(重开一律 default 起步)", async () => {
+ useSessionsStore.setState({
+ sessions: [realMeta("a", "/p"), realMeta("b", "/p")],
+ activeSessionId: "a",
+ cwd: "/p",
+ permissionModes: { b: "fullAccess" },
+ });
+ const closing = deferred<{ closed: boolean }>();
+ piMock.closeSession.mockImplementationOnce(() => closing.promise);
+ piMock.openSession.mockResolvedValueOnce(realMeta("b", "/p"));
+ // b 已有 transcript(真实场景:它是被 GC 判定可卸的已打开会话)→ 切换不触发懒加载
+ useTranscriptStore.getState().setFollowUpQueue("b", ["kept"]);
+
+ const unloading = useSessionsStore.getState().unloadSession("b");
+ useSessionsStore.getState().switchSession("b");
+ closing.resolve({ closed: true });
+ const result = await unloading;
+
+ expect(result).toEqual({ closed: false });
+ expect(piMock.setPermissionMode).toHaveBeenCalledWith({ sessionId: "b", mode: "fullAccess" });
+ expect(useSessionsStore.getState().permissionModes.b).toBe("fullAccess");
+ });
+
+ it("reopen 成功后权限档位恢复失败:返回 closed:false,但 renderer 回落 default(不能显示后端没执行的档位)", async () => {
+ useSessionsStore.setState({
+ sessions: [realMeta("a", "/p"), realMeta("b", "/p")],
+ activeSessionId: "a",
+ cwd: "/p",
+ permissionModes: { b: "fullAccess" },
+ });
+ const closing = deferred<{ closed: boolean }>();
+ piMock.closeSession.mockImplementationOnce(() => closing.promise);
+ piMock.openSession.mockResolvedValueOnce(realMeta("b", "/p"));
+ piMock.setPermissionMode.mockRejectedValueOnce(new Error("mode boom"));
+ useTranscriptStore.getState().setFollowUpQueue("b", ["kept"]);
+
+ const unloading = useSessionsStore.getState().unloadSession("b");
+ useSessionsStore.getState().switchSession("b");
+ closing.resolve({ closed: true });
+ const result = await unloading;
+
+ // 后端会话已重建成功 → GC 仍视为没卸成;但不能因此把后端真值(default)藏在 UI 后面
+ expect(result).toEqual({ closed: false });
+ expect(useSessionsStore.getState().permissionModes.b).toBeUndefined();
+ expect(toastKeys()).toContain("toast.permissionModeFailed");
+ expect(useSessionsStore.getState().sessions.map((s) => s.sessionId)).toEqual(["a", "b"]);
+ });
+
+ it("reopen 失败:不留幽灵 active(按正常关闭清理并显形提示)", async () => {
+ useSessionsStore.setState({
+ sessions: [realMeta("a", "/p"), realMeta("b", "/p")],
+ activeSessionId: "a",
+ cwd: "/p",
+ });
+ const closing = deferred<{ closed: boolean }>();
+ piMock.closeSession.mockImplementationOnce(() => closing.promise);
+ piMock.openSession.mockRejectedValueOnce(new Error("reopen boom"));
+
+ const unloading = useSessionsStore.getState().unloadSession("b");
+ useSessionsStore.getState().switchSession("b");
+ closing.resolve({ closed: true });
+ const result = await unloading;
+
+ expect(result).toEqual({ closed: true });
+ const state = useSessionsStore.getState();
+ expect(state.sessions.map((s) => s.sessionId)).toEqual(["a"]);
+ expect(state.activeSessionId).toBe("a");
+ expect(toastKeys()).toContain("toast.sessionOpenFailed");
+ });
+
+ it("reopen 在途期间用户又切走:不得抢回 active(恢复不是新的用户导航)", async () => {
+ useSessionsStore.setState({
+ sessions: [realMeta("a", "/p"), realMeta("b", "/p"), realMeta("c", "/p")],
+ activeSessionId: "a",
+ cwd: "/p",
+ });
+ const closing = deferred<{ closed: boolean }>();
+ const reopening = deferred();
+ piMock.closeSession.mockImplementationOnce(() => closing.promise);
+ piMock.openSession.mockImplementationOnce(() => reopening.promise);
+
+ const unloading = useSessionsStore.getState().unloadSession("b");
+ useSessionsStore.getState().switchSession("b");
+ closing.resolve({ closed: true });
+ await vi.waitFor(() => expect(piMock.openSession).toHaveBeenCalledTimes(1));
+ useSessionsStore.getState().switchSession("c");
+ reopening.resolve(realMeta("b", "/p"));
+
+ expect(await unloading).toEqual({ closed: false });
+ expect(useSessionsStore.getState().activeSessionId).toBe("c");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 阶段 0/1 契约(spec singleton-draft-subagent-nav §5):
+// ① 新会话 = renderer 全局唯一 draft(`activeSessionId === null`),不再有伪 SessionMeta 占位条目;
+// ② 点击「+」最多激活/创建一个 draft,已有 draft 必须保留内容与配置;
+// ③ 首条消息 promotion:吃 draft 入口快照 + single-flight + latest-wins + 失败保留。
+// ---------------------------------------------------------------------------
+
+describe("单例新会话 draft(newSessionDraft)", () => {
+ it("activateNewSessionDraft 不往 sessions 加条目:draft 只是 renderer 编辑态", () => {
+ useSessionsStore.setState({ cwd: "/proj/a" });
+ useSessionsStore.getState().activateNewSessionDraft();
+ const state = useSessionsStore.getState();
+ expect(state.sessions).toEqual([]);
+ expect(state.activeSessionId).toBeNull();
+ expect(state.newSessionDraft?.cwd).toBe("/proj/a");
+ expect(state.newSessionDraft?.permissionMode).toBe("default");
+ expect(piMock.createSession).not.toHaveBeenCalled();
+ });
+
+ it("激活 draft 即前置项目信任决策(结果落 trust.json,斜杠命令/需求转正直接命中缓存)", () => {
+ useSessionsStore.setState({ cwd: "/proj/a" });
+ useSessionsStore.getState().activateNewSessionDraft();
+ expect(piMock.ensureProjectTrust).toHaveBeenCalledWith({ cwd: "/proj/a" });
+ });
+
+ it("从当前真实会话快照 cwd/model/thinking(全局最近值是另一套,不得采用)", () => {
+ useSessionsStore.setState({
+ sessions: [
+ { ...realMeta("a", "/proj/alpha"), model: { provider: "pA", modelId: "mA" }, thinkingLevel: "high" },
+ ],
+ activeSessionId: "a",
+ cwd: "/proj/alpha",
+ lastUsedModel: { provider: "pB", modelId: "mB" },
+ lastUsedThinkingLevel: "low",
+ });
+ 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");
+ });
+
+ it("已在 draft 时再点「+」:返回同一 draft,配置与输入内容都不覆盖(离开 → 真实 switchSession → 回来)", () => {
+ 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({
+ sessions: [realMeta("r1", "/proj/a")],
+ activeSessionId: "r1",
+ cwd: "/proj/a",
+ });
+ useSessionsStore.getState().switchSession("r1");
+
+ useSessionsStore.getState().activateNewSessionDraft();
+
+ 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("半包需求");
+ });
+
+ it("draft 输入内容(文字/图片/slash/引用)挂在全局新会话草稿键上,再次激活不重置", () => {
+ useSessionsStore.setState({ cwd: "/proj/a" });
+ const draftStore = useDraftStore.getState();
+ draftStore.updateDraft(NEW_SESSION_DRAFT_KEY, () => ({
+ text: "接着上一句",
+ images: [{ data: "AAAA", mimeType: "image/png" }],
+ slashCommand: "compact",
+ attachments: ["src/a.ts"],
+ quotes: ["引用段落"],
+ }));
+
+ useSessionsStore.getState().activateNewSessionDraft();
+ expect(useDraftStore.getState().bySession[NEW_SESSION_DRAFT_KEY]).toEqual({
+ text: "接着上一句",
+ images: [{ data: "AAAA", mimeType: "image/png" }],
+ slashCommand: "compact",
+ attachments: ["src/a.ts"],
+ quotes: ["引用段落"],
+ });
+
+ useSessionsStore.getState().activateNewSessionDraft();
+ expect(useDraftStore.getState().bySession[NEW_SESSION_DRAFT_KEY]?.text).toBe("接着上一句");
+ });
+
+ it("无 cwd 也能进入 draft(newSessionDraft.cwd === null,不静默 no-op)", () => {
+ useSessionsStore.getState().activateNewSessionDraft();
+ expect(useSessionsStore.getState().newSessionDraft?.cwd).toBeNull();
+ expect(useSessionsStore.getState().activeSessionId).toBeNull();
+ });
+
+ it("draft 权限模式写在 draft 配置里:不占 permissionModes map、不调 IPC", () => {
+ useSessionsStore.getState().activateNewSessionDraft();
+ useSessionsStore.getState().setDraftPermissionMode("fullAccess");
+ expect(useSessionsStore.getState().newSessionDraft?.permissionMode).toBe("fullAccess");
+ expect(useSessionsStore.getState().permissionModes).toEqual({});
+ expect(piMock.setPermissionMode).not.toHaveBeenCalled();
+ });
+
+ it("真实会话里改模型/思考:不得覆盖后台那份 draft 快照(单例 draft 跨会话存活)", async () => {
+ // 先在 draft 页配好一套
+ useSessionsStore.setState({ cwd: "/proj/a" });
+ useSessionsStore.getState().activateNewSessionDraft();
+ useSessionsStore.getState().setDraftCwd("/proj/b");
+ await useSessionsStore.getState().setCurrentModel("pDraft", "mDraft");
+ await useSessionsStore.getState().setThinkingLevel("low");
+ const before = useSessionsStore.getState().newSessionDraft;
+ expect(before?.cwd).toBe("/proj/b");
+
+ // 切到真实会话,在那里改模型/思考
+ useSessionsStore.setState({
+ sessions: [
+ { ...realMeta("r1", "/proj/a"), model: { provider: "p1", modelId: "m1" }, thinkingLevel: "medium" },
+ ],
+ activeSessionId: "r1",
+ cwd: "/proj/a",
+ });
+ await useSessionsStore.getState().setCurrentModel("p2", "m2");
+ await useSessionsStore.getState().setThinkingLevel("high");
+
+ expect(useSessionsStore.getState().sessions[0]?.model).toEqual({ provider: "p2", modelId: "m2" });
+ expect(useSessionsStore.getState().sessions[0]?.thinkingLevel).toBe("high");
+ expect(useSessionsStore.getState().newSessionDraft).toEqual(before);
+ });
+
+ it("冷启动:无会话快照时 thinking 不算「已定」,loadModels 用持久化偏好回填(占位 medium 不得锁死)", async () => {
+ piMock.listModels.mockImplementation(() =>
+ Promise.resolve([
+ {
+ provider: "deepseek",
+ providerName: "DeepSeek",
+ id: "v4",
+ label: "V4",
+ authed: true,
+ thinkingLevels: ["low", "medium", "high"],
+ },
+ ]),
+ );
+ piMock.loadUiState.mockImplementation(() =>
+ Promise.resolve({
+ lastUsedModel: { provider: "deepseek", modelId: "v4" },
+ lastUsedThinkingLevel: "high",
+ }),
+ );
+ // 冷启动:模型列表还没回来,lastUsedThinkingLevel 只是占位 medium
+ useSessionsStore.setState({ lastUsedThinkingLevel: "medium" });
+ useSessionsStore.getState().activateNewSessionDraft();
+ expect(useSessionsStore.getState().newSessionDraft?.thinkingLevel).toBe("medium");
+
+ await useSessionsStore.getState().loadModels();
+
+ const draft = useSessionsStore.getState().newSessionDraft;
+ expect(draft?.thinkingLevel).toBe("high");
+ expect(draft?.model).toEqual({ provider: "deepseek", modelId: "v4" });
+ });
+
+ it("开机的空 draft(cwd === null)→ 先打开真实会话 → 点「+」:继承当前会话项目,不空着", () => {
+ // 开机那份:没有 lastCwd,cwd 为空
+ useSessionsStore.setState({ cwd: null });
+ useSessionsStore.getState().activateNewSessionDraft();
+ expect(useSessionsStore.getState().newSessionDraft?.cwd).toBeNull();
+
+ // 用户先从历史打开一个真实会话
+ useSessionsStore.setState({
+ sessions: [realMeta("h1", "/proj/from-history")],
+ activeSessionId: "h1",
+ cwd: "/proj/from-history",
+ });
+ useSessionsStore.getState().switchSession("h1");
+
+ useSessionsStore.getState().activateNewSessionDraft();
+
+ const state = useSessionsStore.getState();
+ expect(state.activeSessionId).toBeNull();
+ expect(state.newSessionDraft?.cwd).toBe("/proj/from-history");
+ expect(state.cwd).toBe("/proj/from-history");
+ expect(piMock.ensureProjectTrust).toHaveBeenCalledWith({ cwd: "/proj/from-history" });
+ });
+
+ it("未选项目的 draft(cwd === null)接受「+」/启动传进来的起点 cwd,已选的仍不覆盖", () => {
+ useSessionsStore.setState({ cwd: null });
+ useSessionsStore.getState().activateNewSessionDraft();
+ expect(useSessionsStore.getState().newSessionDraft?.cwd).toBeNull();
+
+ // 初始化/补种出来的 draft 还没选项目:接受起点(App 启动种 lastCwd 走的就是这条)
+ useSessionsStore.getState().activateNewSessionDraft("/proj/fromLastCwd");
+ expect(useSessionsStore.getState().newSessionDraft?.cwd).toBe("/proj/fromLastCwd");
+ expect(piMock.ensureProjectTrust).toHaveBeenCalledWith({ cwd: "/proj/fromLastCwd" });
+
+ // 已经选过的目录不被冲掉
+ useSessionsStore.getState().activateNewSessionDraft("/proj/other");
+ expect(useSessionsStore.getState().newSessionDraft?.cwd).toBe("/proj/fromLastCwd");
+ });
+
+ it("draft 态切换模型/思考深度:只改 draft 配置与最近使用偏好,不调后端", async () => {
+ useSessionsStore.getState().activateNewSessionDraft();
+ await useSessionsStore.getState().setCurrentModel("p", "m");
+ await useSessionsStore.getState().setThinkingLevel("high");
+ expect(piMock.setModel).not.toHaveBeenCalled();
+ expect(piMock.setThinkingLevel).not.toHaveBeenCalled();
+ expect(useSessionsStore.getState().newSessionDraft?.model).toEqual({ provider: "p", modelId: "m" });
+ expect(useSessionsStore.getState().newSessionDraft?.thinkingLevel).toBe("high");
+ expect(useSessionsStore.getState().lastUsedModel).toEqual({ provider: "p", modelId: "m" });
+ expect(useSessionsStore.getState().lastUsedThinkingLevel).toBe("high");
+ });
+
+ it("初始 draft 无可继承模型:loadModels 补最近(或首个可用)模型", async () => {
+ piMock.listModels.mockImplementation(() =>
+ Promise.resolve([
+ { provider: "deepseek", providerName: "DeepSeek", id: "v4", label: "V4", authed: true },
+ ]),
+ );
+ piMock.loadUiState.mockImplementation(() =>
+ Promise.resolve({ lastUsedModel: { provider: "deepseek", modelId: "v4" } }),
+ );
+
+ useSessionsStore.getState().activateNewSessionDraft();
+ expect(useSessionsStore.getState().newSessionDraft?.model).toBeNull();
+
+ await useSessionsStore.getState().loadModels();
+
+ expect(useSessionsStore.getState().newSessionDraft?.model).toEqual({
+ provider: "deepseek",
+ modelId: "v4",
+ });
+ });
+
+ it("draft 已从当前会话继承模型:loadModels 不得改成「全局最近」那一个", async () => {
+ // A = 当前会话的模型(draft 继承它),B = 全局最近值;两个都是列表里的合法模型,
+ // 这样才只验证「快照优先于全局最近」,不搅进「失效模型回退」语义。
+ piMock.listModels.mockImplementation(() =>
+ Promise.resolve([
+ { provider: "pA", providerName: "ProviderA", id: "mA", label: "Model A", authed: true },
+ { provider: "deepseek", providerName: "DeepSeek", id: "v4", label: "V4", authed: true },
+ ]),
+ );
+ piMock.loadUiState.mockImplementation(() =>
+ Promise.resolve({ lastUsedModel: { provider: "deepseek", modelId: "v4" } }),
+ );
+ useSessionsStore.setState({
+ sessions: [
+ { ...realMeta("a", "/proj/a"), model: { provider: "pA", modelId: "mA" }, thinkingLevel: "high" },
+ ],
+ activeSessionId: "a",
+ cwd: "/proj/a",
+ });
+
+ useSessionsStore.getState().activateNewSessionDraft();
+ await useSessionsStore.getState().loadModels();
+
+ expect(useSessionsStore.getState().newSessionDraft?.model).toEqual({ provider: "pA", modelId: "mA" });
+ });
+
+ it("用户已在 draft 上改过思考档位:loadModels 仍补空模型,但不得覆盖该档位", async () => {
+ piMock.listModels.mockImplementation(() =>
+ Promise.resolve([
+ {
+ provider: "deepseek",
+ providerName: "DeepSeek",
+ id: "v4",
+ label: "V4",
+ authed: true,
+ thinkingLevels: ["low", "medium", "high"],
+ },
+ ]),
+ );
+ piMock.loadUiState.mockImplementation(() =>
+ Promise.resolve({
+ lastUsedModel: { provider: "deepseek", modelId: "v4" },
+ lastUsedThinkingLevel: "low",
+ }),
+ );
+
+ useSessionsStore.getState().activateNewSessionDraft();
+ await useSessionsStore.getState().setThinkingLevel("high");
+
+ await useSessionsStore.getState().loadModels();
+
+ // dirty 必须分字段:改过思考不等于改过模型——模型仍要按默认补齐,思考选择不被回写
+ expect(useSessionsStore.getState().newSessionDraft?.model).toEqual({
+ provider: "deepseek",
+ modelId: "v4",
+ });
+ expect(useSessionsStore.getState().newSessionDraft?.thinkingLevel).toBe("high");
+ });
+});
+
+describe("promotion:首条消息把 draft 转成真实会话", () => {
+ beforeEach(() => useToastsStore.setState({ toasts: [] }));
+
+ it("用 draft 快照创建:当前会话模型 A 优先于全局最近 B", async () => {
+ piMock.createSession.mockResolvedValue(realMeta("new-1", "/proj/alpha"));
+ useSessionsStore.setState({
+ sessions: [
+ { ...realMeta("a", "/proj/alpha"), model: { provider: "pA", modelId: "mA" }, thinkingLevel: "high" },
+ ],
+ activeSessionId: "a",
+ cwd: "/proj/alpha",
+ lastUsedModel: { provider: "pB", modelId: "mB" },
+ lastUsedThinkingLevel: "low",
+ });
+ useSessionsStore.getState().activateNewSessionDraft();
+
+ expect(await useSessionsStore.getState().createSession()).toBe("new-1");
+
+ expect(piMock.createSession).toHaveBeenCalledTimes(1);
+ expect(piMock.createSession).toHaveBeenCalledWith({
+ options: { cwd: "/proj/alpha", provider: "pA", modelId: "mA", thinkingLevel: "high" },
+ });
+ const state = useSessionsStore.getState();
+ expect(state.newSessionDraft).toBeNull();
+ expect(state.sessions.map((s) => s.sessionId)).toEqual(["a", "new-1"]);
+ expect(state.activeSessionId).toBe("new-1");
+ expect(state.cwd).toBe("/proj/alpha");
+ });
+
+ it("并发两次(发送 + 命令)共享一次 pi.createSession,返回同一个 id", async () => {
+ const created = deferred();
+ piMock.createSession.mockImplementationOnce(() => created.promise);
+ useSessionsStore.setState({ cwd: "/proj/a" });
+ useSessionsStore.getState().activateNewSessionDraft();
+
+ const first = useSessionsStore.getState().createSession();
+ const second = useSessionsStore.getState().createSession();
+ expect(piMock.createSession).toHaveBeenCalledTimes(1);
+ created.resolve(realMeta("new-1", "/proj/a"));
+
+ expect(await first).toBe("new-1");
+ expect(await second).toBe("new-1");
+ expect(useSessionsStore.getState().sessions.filter((s) => s.sessionId === "new-1")).toHaveLength(1);
+ });
+
+ it("创建失败:draft 与配置、输入内容完整保留,重试会重新发 IPC", async () => {
+ piMock.createSession
+ .mockRejectedValueOnce(new Error("boom"))
+ .mockResolvedValueOnce(realMeta("new-1", "/proj/a"));
+ useSessionsStore.getState().activateNewSessionDraft("/proj/a");
+ useSessionsStore.getState().setDraftPermissionMode("fullAccess");
+ useDraftStore.getState().updateDraft(NEW_SESSION_DRAFT_KEY, (d) => ({ ...d, text: "别丢了我" }));
+
+ expect(await useSessionsStore.getState().createSession()).toBeNull();
+
+ const afterFail = useSessionsStore.getState();
+ expect(afterFail.newSessionDraft?.cwd).toBe("/proj/a");
+ expect(afterFail.newSessionDraft?.permissionMode).toBe("fullAccess");
+ expect(afterFail.activeSessionId).toBeNull();
+ expect(useDraftStore.getState().bySession[NEW_SESSION_DRAFT_KEY]?.text).toBe("别丢了我");
+ expect(toastKeys()).toContain("toast.sessionCreateFailed");
+
+ expect(await useSessionsStore.getState().createSession()).toBe("new-1");
+ expect(piMock.createSession).toHaveBeenCalledTimes(2);
+ });
+
+ it("创建在途时用户切走:真实 meta 仍进 sessions 且 draft 被消费,但不抢 active/cwd", async () => {
+ const created = deferred();
+ piMock.createSession.mockImplementationOnce(() => created.promise);
+ useSessionsStore.setState({ sessions: [realMeta("b", "/proj/b")], activeSessionId: "b", cwd: "/proj/b" });
+ useSessionsStore.getState().activateNewSessionDraft("/proj/a");
+
+ const promoting = useSessionsStore.getState().createSession();
+ useSessionsStore.getState().switchSession("b");
+ created.resolve(realMeta("new-1", "/proj/a"));
+
+ expect(await promoting).toBe("new-1");
+ const state = useSessionsStore.getState();
+ expect(state.activeSessionId).toBe("b");
+ expect(state.cwd).toBe("/proj/b");
+ expect(state.sessions.map((s) => s.sessionId)).toContain("new-1");
+ expect(state.newSessionDraft).toBeNull();
+ });
+
+ it("draft 非 default 权限模式:创建成功后应用到新会话", async () => {
+ piMock.createSession.mockResolvedValue(realMeta("new-1", "/proj/a"));
+ useSessionsStore.getState().activateNewSessionDraft("/proj/a");
+ useSessionsStore.getState().setDraftPermissionMode("fullAccess");
+
+ await useSessionsStore.getState().createSession();
+
+ expect(piMock.setPermissionMode).toHaveBeenCalledWith({ sessionId: "new-1", mode: "fullAccess" });
+ expect(useSessionsStore.getState().permissionModes["new-1"]).toBe("fullAccess");
+ });
+
+ it("draft.cwd === null:返回 null 且不发 IPC(等用户在项目选择器里选目录)", async () => {
+ useSessionsStore.getState().activateNewSessionDraft();
+
+ expect(await useSessionsStore.getState().createSession()).toBeNull();
+
+ expect(piMock.createSession).not.toHaveBeenCalled();
+ expect(useSessionsStore.getState().newSessionDraft?.cwd).toBeNull();
+ expect(useSessionsStore.getState().activeSessionId).toBeNull();
+ });
+
+ it("D1 已消费、还在等权限 IPC 时新建 D2:D2 必须另发一次 create 并返回 D2 的 id", async () => {
+ const first = deferred();
+ const second = deferred();
+ const permission = deferred();
+ piMock.createSession
+ .mockImplementationOnce(() => first.promise)
+ .mockImplementationOnce(() => second.promise);
+ piMock.setPermissionMode.mockImplementationOnce(() => permission.promise);
+
+ // D1:选 fullAccess,好让 promotion 在 meta 落地后还卡在权限 IPC 上
+ useSessionsStore.setState({ cwd: "/proj/a" });
+ useSessionsStore.getState().activateNewSessionDraft("/proj/a");
+ useSessionsStore.getState().setDraftPermissionMode("fullAccess");
+ const promoting1 = useSessionsStore.getState().createSession();
+
+ first.resolve(realMeta("new-1", "/proj/a"));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(useSessionsStore.getState().newSessionDraft).toBeNull(); // D1 已消费,仍卡在权限 IPC
+
+ // 用户点「+」建 D2 并发送
+ useSessionsStore.getState().activateNewSessionDraft("/proj/b");
+ expect(useSessionsStore.getState().newSessionDraft?.cwd).toBe("/proj/b");
+ const promoting2 = useSessionsStore.getState().createSession();
+
+ expect(piMock.createSession).toHaveBeenCalledTimes(2);
+ second.resolve(realMeta("new-2", "/proj/b"));
+ expect(await promoting2).toBe("new-2");
+
+ permission.resolve();
+ expect(await promoting1).toBe("new-1");
+ expect(useSessionsStore.getState().activeSessionId).toBe("new-2");
+ });
+
+ it("权限档位按**入口快照**冻结:创建在途时改 draft 档位,不影响已发出的这一次转正", async () => {
+ const created = deferred();
+ piMock.createSession.mockImplementationOnce(() => created.promise);
+ useSessionsStore.setState({ cwd: "/proj/a" });
+ useSessionsStore.getState().activateNewSessionDraft("/proj/a");
+ useSessionsStore.getState().setDraftPermissionMode("fullAccess");
+ const promoting = useSessionsStore.getState().createSession();
+
+ // 创建在途:用户把 draft 档位改回 default(入口快照已冻结,本次仍按 fullAccess)
+ useSessionsStore.getState().setDraftPermissionMode("default");
+ created.resolve(realMeta("new-1", "/proj/a"));
+
+ expect(await promoting).toBe("new-1");
+ expect(piMock.setPermissionMode).toHaveBeenCalledWith({ sessionId: "new-1", mode: "fullAccess" });
+ });
+
+ it("promotion 在途时再点「+」:复用同一 draft、不产生第二次 create、不覆盖配置与内容", async () => {
+ const created = deferred();
+ piMock.createSession.mockImplementationOnce(() => created.promise);
+ useSessionsStore.setState({ cwd: "/proj/a" });
+ useSessionsStore.getState().activateNewSessionDraft();
+ useSessionsStore.getState().setDraftPermissionMode("fullAccess");
+ useDraftStore.getState().updateDraft(NEW_SESSION_DRAFT_KEY, (d) => ({ ...d, text: "首条消息" }));
+
+ const promoting = useSessionsStore.getState().createSession();
+ useSessionsStore.getState().activateNewSessionDraft("/proj/other");
+
+ // 同一份 draft:cwd / 权限模式 / 内容都不被重置
+ expect(useSessionsStore.getState().newSessionDraft?.cwd).toBe("/proj/a");
+ expect(useSessionsStore.getState().newSessionDraft?.permissionMode).toBe("fullAccess");
+ expect(useDraftStore.getState().bySession[NEW_SESSION_DRAFT_KEY]?.text).toBe("首条消息");
+ expect(piMock.createSession).toHaveBeenCalledTimes(1);
+
+ created.resolve(realMeta("new-1", "/proj/a"));
+ expect(await promoting).toBe("new-1");
+ // 在途 re-activate 不是新的用户导航:转正成功后仍要落在新会话上
+ expect(useSessionsStore.getState().activeSessionId).toBe("new-1");
+ expect(useSessionsStore.getState().newSessionDraft).toBeNull();
+ });
+});
+
+describe("不变式:新会话页(active === null)必须有可用 draft", () => {
+ it("关闭最后一个会话 → 立刻补一份 draft(起步快照取刚关掉的那个会话)", async () => {
+ useSessionsStore.setState({
+ sessions: [
+ {
+ ...realMeta("only", "/proj/only"),
+ model: { provider: "pX", modelId: "mX" },
+ thinkingLevel: "high",
+ },
+ ],
+ activeSessionId: "only",
+ cwd: "/proj/only",
+ newSessionDraft: null,
+ });
+
+ await useSessionsStore.getState().closeSession("only");
+
+ const state = useSessionsStore.getState();
+ expect(state.activeSessionId).toBeNull();
+ expect(state.newSessionDraft?.cwd).toBe("/proj/only");
+ expect(state.newSessionDraft?.model).toEqual({ provider: "pX", modelId: "mX" });
+ expect(state.newSessionDraft?.thinkingLevel).toBe("high");
+ });
+
+ it("已有后台 draft 时关掉最后一个会话:保留原 draft,不拿被关会话覆盖", async () => {
+ useSessionsStore.setState({ cwd: "/proj/draft" });
+ useSessionsStore.getState().activateNewSessionDraft();
+ const draft = useSessionsStore.getState().newSessionDraft;
+ useSessionsStore.setState({
+ sessions: [realMeta("only", "/proj/only")],
+ activeSessionId: "only",
+ cwd: "/proj/only",
+ });
+
+ await useSessionsStore.getState().closeSession("only");
+
+ const state = useSessionsStore.getState();
+ expect(state.activeSessionId).toBeNull();
+ expect(state.newSessionDraft).toEqual(draft);
+ // cwd 必须镜像 draft.cwd:否则页面/activeCwd 显示 A 项目、promotion 却按 B 项目建会话
+ expect(state.cwd).toBe("/proj/draft");
+ expect(state.newSessionDraft?.cwd).toBe("/proj/draft");
+ });
+
+ it("已有 cwd=null 的 draft 时关掉最后一个真实会话:store.cwd 严格镜像 draft.cwd(含 null)", async () => {
+ // 开机(无 lastCwd)那份:cwd 为空,从未选过项目
+ useSessionsStore.setState({ cwd: null });
+ useSessionsStore.getState().activateNewSessionDraft();
+ expect(useSessionsStore.getState().newSessionDraft?.cwd).toBeNull();
+
+ // 用户打开一个真实会话(它的项目是 /proj/opened)再把它关掉
+ useSessionsStore.setState({
+ sessions: [realMeta("opened", "/proj/opened")],
+ activeSessionId: "opened",
+ cwd: "/proj/opened",
+ });
+ await useSessionsStore.getState().closeSession("opened");
+
+ const state = useSessionsStore.getState();
+ expect(state.activeSessionId).toBeNull();
+ // 关键:draft 还没选项目 → store.cwd 也得是 null,不能回退成被关闭会话的项目
+ expect(state.newSessionDraft?.cwd).toBeNull();
+ expect(state.cwd).toBeNull();
+ });
+
+ it("转正途中点「+」回新会话页 → 迟到的转正结果消费掉 draft 后必须立刻补一份", async () => {
+ const created = deferred();
+ piMock.createSession.mockImplementationOnce(() => created.promise);
+ useSessionsStore.setState({ cwd: "/proj/a" });
+ useSessionsStore.getState().activateNewSessionDraft();
+ const promoting = useSessionsStore.getState().createSession();
+
+ // 切真实会话再点「+」:领新号 → 在途那次转正变成「迟到」
+ useSessionsStore.setState({ sessions: [realMeta("b", "/proj/b")], activeSessionId: "b", cwd: "/proj/b" });
+ useSessionsStore.getState().switchSession("b");
+ useSessionsStore.getState().activateNewSessionDraft();
+ expect(useSessionsStore.getState().activeSessionId).toBeNull();
+
+ created.resolve(realMeta("new-1", "/proj/a"));
+ expect(await promoting).toBe("new-1");
+
+ const state = useSessionsStore.getState();
+ expect(state.activeSessionId).toBeNull(); // 迟到:不抢焦点
+ expect(state.sessions.map((s) => s.sessionId)).toContain("new-1");
+ // 关键:不能留下「空白新会话页 + 没有 draft」的破态(否则 picker 写入静默丢失)
+ expect(state.newSessionDraft).not.toBeNull();
+ expect(state.newSessionDraft?.cwd).toBe("/proj/a");
+ });
+});
diff --git a/packages/desktop/src/renderer/src/stores/sessions.ts b/packages/desktop/src/renderer/src/stores/sessions.ts
index 95fe9d1b..12fad92d 100644
--- a/packages/desktop/src/renderer/src/stores/sessions.ts
+++ b/packages/desktop/src/renderer/src/stores/sessions.ts
@@ -3,17 +3,63 @@ import { messagesToUIMessages } from "@percho/shared";
import { create } from "zustand";
import { getPi } from "../api";
import { errText } from "../lib/error-text";
+import { isPrimaryNavigationSession } from "../lib/session-visibility";
import { clampThinkingLevel } from "../lib/thinking";
import { COMPOSER_FOCUS_EVENT, useDraftStore } from "./drafts";
import { pushToast } from "./toasts";
import { useTranscriptStore } from "./transcript";
import { useUiPreferencesStore } from "./ui-preferences";
-/** 草稿会话 id 前缀:新会话 tab 的占位条目,只存在于 renderer 内存,后端永远不会看到 */
-export const DRAFT_SESSION_PREFIX = "draft:";
+/**
+ * 新会话 draft(renderer 全局唯一编辑态,spec §5.1):**不进 `sessions`、不落盘、不触后端**。
+ * 整个 renderer 最多一份,`activeSessionId === null` 时它就是当前页面;首条消息 promotion 时
+ * 用这份快照建真实会话(快照优先于全局「最近使用」值)。
+ */
+export interface NewSessionDraftConfig {
+ /** 目标项目目录;null = 还没选(可进入新会话页,但发不出去) */
+ cwd: string | null;
+ /** 起步模型;null = 还没定(等模型列表加载或用户选择补上) */
+ model: { provider: string; modelId: string } | null;
+ thinkingLevel: string;
+ permissionMode: PermissionMode;
+ /** 模型是否已定(会话快照或用户选择):false 时 loadModels 才用默认值补 */
+ modelResolved: boolean;
+ /** 思考档位是否已定。**与 modelResolved 分开**:只改过档位不得阻止模型补齐(反之亦然) */
+ thinkingResolved: boolean;
+ /**
+ * 内部代次(不给 UI 用):同一份 draft 的每次字段编辑共享它,新建一份就是新代次。
+ * promotion 的 single-flight 按它隔离 —— D1 已消费、还在等权限 IPC 时用户又建了 D2,
+ * D2 必须另发一次 createSession,不能复用 D1 的 Promise(否则 D2 的消息会发进 D1 的会话)。
+ */
+ generation: number;
+}
+
+let draftGenerationSeq = 0;
-export function isDraftSessionId(sessionId: string | null | undefined): boolean {
- return typeof sessionId === "string" && sessionId.startsWith(DRAFT_SESSION_PREFIX);
+/**
+ * 造一份 draft 配置。**有真实会话来源的字段才算「已定」**,没来源的一律留给 `loadModels`
+ * 用持久化偏好回填(冷启动时 lastUsedThinkingLevel 还只是占位 `medium`,不能当成用户选择)。
+ */
+function makeDraft(
+ state: Pick,
+ source: {
+ cwd?: string | null;
+ model?: { provider: string; modelId: string } | null;
+ thinkingLevel?: string | null;
+ } = {},
+): NewSessionDraftConfig {
+ const model = source.model ?? null;
+ draftGenerationSeq += 1;
+ return {
+ cwd: source.cwd ?? state.cwd,
+ model,
+ // 未定时先用「最近使用」占位(UI 立刻有值),但标成未定,等 loadModels 用持久化偏好回填
+ thinkingLevel: source.thinkingLevel ?? state.lastUsedThinkingLevel,
+ permissionMode: "default",
+ modelResolved: model != null,
+ thinkingResolved: source.thinkingLevel != null,
+ generation: draftGenerationSeq,
+ };
}
/**
@@ -33,9 +79,9 @@ export function partitionSessionsByPin(
}
/**
- * 顶栏展示集(v8 定稿):**严格 = 置顶表**——顶栏胶囊就是置顶会话(不管它的 tab 开没开,也不管它是 draft)。
+ * 顶栏展示集(v8 定稿):**严格 = 置顶表**——顶栏胶囊就是置顶会话(不管它的 tab 开没开)。
* 用户:顶栏之前是「打开的会话全进去」= 唯一的会话总表,胶囊越来越多;现在左栏承担总表,
- * 顶栏只放真正需要盯的会话(draft 的导航与丢弃都在左栏,见 spec D1/D3)。
+ * 顶栏只放真正需要盯的会话(新会话的导航在左栏与顶栏「+」,见 spec D1/D3)。
* - **不能只从 tabs 里筛**:会话被置顶、但 tab 已关(或本次启动没恢复)时,只筛 tabs 会把它藏掉,
* 用户会看到「已置顶却不在顶栏」——所以置顶会话的 meta 从 tabs → 历史两边找,点击时自动开。
* - 顺序 = `pinnedSessions` 自己的顺序(置顶即插队到最左,拖动排序改的也是它)。
@@ -50,15 +96,59 @@ export function selectBarSessions(
for (const s of history) byId.set(s.sessionId, s);
// tabs 覆盖历史同名项:名称/状态以当前打开实例为准
for (const s of tabs) byId.set(s.sessionId, s);
- return pinnedSessions.map((id) => byId.get(id)).filter((s): s is SessionMeta => s !== undefined);
+ return (
+ pinnedSessions
+ .map((id) => byId.get(id))
+ // 纵深防御:置顶表里可能残留只读子会话(旧 ui-state / 手改)——不生成只读胶囊。
+ // 与左栏同一道判据(lib/session-visibility),不要各写一份
+ .filter((s): s is SessionMeta => s !== undefined && isPrimaryNavigationSession(s))
+ );
}
+/**
+ * 进程内单调激活序号(spec D2):**用户导航动作一开始**就领号(不能等 IPC 返回才领,否则表达不了点击顺序)。
+ * 异步动作返回时只有「自己领的号仍是最新」才允许写 `activeSessionId/cwd/rememberCwd`;
+ * 过期的仍可落数据(sessions 条目、transcript 装载),但不得抄回焦点。
+ *
+ * 不落盘、不进 store:重启后一个在途动作都没有,序号归零无意义;测试间保留递增即可。
+ */
+let activationSeq = 0;
+
+function claimActivation(): number {
+ activationSeq += 1;
+ return activationSeq;
+}
+
+/** 领号后所有更早的在途导航立即失效(同步 `switchSession` 因此天然淘汰在飞的 open/create/fork) */
+function isLatestActivation(token: number): boolean {
+ return token === activationSeq;
+}
+
+/** 同会话在途的 bundle 装载(key = sessionId) */
+const bundleInFlight = new Map>();
+
/**
* 打开会话时同步四件套:消息历史(可选跳过 live 态)、排队队列、todo 面板、权限模式。
* 取数并行(各写 store 不同字段,无交叉读),应用顺序保持 history → queue → todos。
* 权限模式对齐后端真值:关 tab 重开后端已归零 default,拉回防 stale(spec permission-mode D1)。
+ *
+ * 同会话同时在途时复用同一个 Promise(spec D3,见 `ensureOpenSession`):open 完成时 meta 会先落进
+ * `sessions`,用户这时再点那一行就走 `switchSession` 的懒加载,两条路会撞车重复拉四件套。
*/
-async function loadSessionBundle(sessionId: string, opts?: { skipHistoryIfLive?: boolean }): Promise {
+function loadSessionBundle(sessionId: string, opts?: { skipHistoryIfLive?: boolean }): Promise {
+ const existing = bundleInFlight.get(sessionId);
+ if (existing) return existing;
+ const promise = loadSessionBundleInner(sessionId, opts).finally(() => {
+ if (bundleInFlight.get(sessionId) === promise) bundleInFlight.delete(sessionId);
+ });
+ bundleInFlight.set(sessionId, promise);
+ return promise;
+}
+
+async function loadSessionBundleInner(
+ sessionId: string,
+ opts?: { skipHistoryIfLive?: boolean },
+): Promise {
// skip 判定在入口一次性取值:并行发起 IPC 前先确定是否跳过历史(agent 运行中保流式态,见 openFromHistory 注释)
const skipHistory =
opts?.skipHistoryIfLive === true &&
@@ -97,6 +187,26 @@ function applyBackendPermissionMode(sessionId: string, mode: PermissionMode): vo
}));
}
+/**
+ * 后端会话被重建(GC 竞态恢复)后把权限档位拉回:新构造的会话一律 default 起步(D1:不落盘、不继承),
+ * 而 renderer 里还持有用户选过的档位——不拉回就会出现「UI 显示 fullAccess、后端按 default 走」的静默偏差。
+ *
+ * 拉回失败时必须把 renderer 拉回 default(而不是保留旧档):UI 永远反映 backend 真值,
+ * 否则用户看到 fullAccess、实际后端在按 default 索要确认——静默权限分叉比多一个 toast 危险得多。
+ * 持久偏好(uiPreferences.sessionPermissionModes)不动,下次打开/重启还能重试。
+ */
+async function restorePermissionMode(sessionId: string): Promise {
+ const mode = useSessionsStore.getState().permissionModes[sessionId];
+ if (!mode || mode === "default") return;
+ try {
+ await getPi().setPermissionMode({ sessionId, mode });
+ } catch (error) {
+ console.warn("恢复会话权限模式失败", error);
+ applyBackendPermissionMode(sessionId, "default");
+ pushToast("warning", "toast.permissionModeFailed", errText(error));
+ }
+}
+
/**
* 权限模式 map 变异 helper(D4):default = 删 key 的「缺 key = default」语义单点化,
* 应用/乐观写入/回滚/会话销毁四处共用。
@@ -115,7 +225,8 @@ function withPermissionMode(
/**
* 乐观会话设置骨架(D4,setCurrentModel/setThinkingLevel 共用):
* 快照 → 乐观写(跟随字段 + 当前会话条目 + ui-state 持久化)→ 真实会话 IPC 同步 →
- * 失败整体回滚(乐观态 + ui-state 重新持久化)+ toast。draft 无后端会话:只记跟随值,创建时生效。
+ * 失败整体回滚(乐观态 + ui-state 重新持久化)+ toast。新会话页(activeSessionId === null)没有
+ * 后端会话:只写 draft 配置 + 跟随值,promotion 时随入口快照生效。
*/
async function optimisticSessionSetting(
label: string,
@@ -125,6 +236,10 @@ async function optimisticSessionSetting(
global: { lastUsedModel?: { provider: string; modelId: string } | null; lastUsedThinkingLevel?: string };
/** 乐观写入当前会话条目的补丁(model / thinkingLevel) */
sessionPatch: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null };
+ /** draft 页(activeSessionId === null)写 newSessionDraft 的补丁;draft 不调 IPC */
+ draftPatch: Partial<
+ Pick
+ >;
/** saveUiState 载荷(乐观与回滚各一次) */
uiState: { lastUsedModel?: { provider: string; modelId: string } | null; lastUsedThinkingLevel?: string };
/** 真实会话的 SDK 同步 */
@@ -133,48 +248,111 @@ async function optimisticSessionSetting(
): Promise {
const s = useSessionsStore.getState();
const { activeSessionId } = s;
+ // 只有「本次动作发生在 draft 页」才能动 draft 配置:单例 draft 会跨会话存活,
+ // 在真实会话里改模型/思考不得覆盖后台那份 draft 快照
+ const onDraftPage = activeSessionId === null;
const previousGlobal = {
lastUsedModel: s.lastUsedModel,
lastUsedThinkingLevel: s.lastUsedThinkingLevel,
};
const previousSession = s.sessions.find((x) => x.sessionId === activeSessionId);
- const { global, sessionPatch, uiState, sync } = compute();
+ const { global, sessionPatch, draftPatch, uiState, sync } = compute();
const apply = (
g: { lastUsedModel?: { provider: string; modelId: string } | null; lastUsedThinkingLevel?: string },
patch: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null },
+ draft: Partial<
+ Pick
+ >,
) =>
useSessionsStore.setState((state) => ({
...g,
sessions: state.sessions.map((x) => (x.sessionId === activeSessionId ? { ...x, ...patch } : x)),
+ // draft 页没有会话条目可写:选择落在 draft 配置上(promotion 时随快照生效)
+ newSessionDraft: state.newSessionDraft ? { ...state.newSessionDraft, ...draft } : null,
}));
- apply(global, sessionPatch);
+ apply(global, sessionPatch, onDraftPage ? draftPatch : {});
getPi()
.saveUiState({ state: uiState })
.catch((error) => {
console.error("ui-state 持久化失败", error);
pushToast("warning", "toast.uiStateSaveFailed", errText(error));
});
- // draft 无后端会话:选择只记为跟随值,创建时随 createSession 生效
- if (activeSessionId && !isDraftSessionId(activeSessionId)) {
- try {
- await sync(activeSessionId);
- } catch (error) {
- // SDK 同步失败(如凭证缺失/模型不可用):回滚乐观态 + 重新持久化 + toast
- apply(
- previousGlobal,
- previousSession
- ? { model: previousSession.model, thinkingLevel: previousSession.thinkingLevel }
- : { model: null, thinkingLevel: null },
- );
- getPi()
- .saveUiState({ state: previousGlobal })
- .catch((e) => console.error("ui-state 回滚持久化失败", e));
- console.error(`${label}失败`, error);
- pushToast("warning", toastKey, errText(error));
- }
+ // 新会话页(activeSessionId === null)没有后端会话:选择只写 draft 配置 + 跟随值,不调 SDK
+ if (onDraftPage) return;
+ try {
+ await sync(activeSessionId);
+ } catch (error) {
+ // SDK 同步失败(如凭证缺失/模型不可用):回滚乐观态 + 重新持久化 + toast
+ apply(
+ previousGlobal,
+ previousSession
+ ? { model: previousSession.model, thinkingLevel: previousSession.thinkingLevel }
+ : { model: null, thinkingLevel: null },
+ // 不在 draft 页就没写过 draft,回滚也不能碰它(后台 draft 可能活着)
+ {},
+ );
+ getPi()
+ .saveUiState({ state: previousGlobal })
+ .catch((e) => console.error("ui-state 回滚持久化失败", e));
+ console.error(`${label}失败`, error);
+ pushToast("warning", toastKey, errText(error));
}
}
+/**
+ * 共享 open pipeline(spec D3):key = 规范化 sessionFile(trim)。同一文件在途时复用同一个 Promise,
+ * 因此「同一文件双击只发一次 IPC + 只装载一次 bundle + 失败只 toast 一次」;
+ * settle(无论成败)后立即清 key,失败可以重试;不同文件互不影响。
+ * 返回 null = 失败(提示已在共享链里报过,调用者不再重复报)。
+ *
+ * `loadBundle: false`(GC 竞态恢复专用):只重建 backend 会话、刷新 meta,**不**重载磁盘历史——
+ * idle transcript 必须原样保留(GC 不能把用户看到的对话内容重置成历史快照)。
+ * 已有在途请求时用先到者的选项(不会重复发 IPC,也不会多跑一次 bundle)。
+ *
+ * 模块级 Map 只存正在执行的 Promise,不落盘、不进 store:没有任何「仅测试用的 reset 入口」。
+ */
+function ensureOpenSession(filePath: string, opts?: { loadBundle?: boolean }): Promise {
+ const key = filePath.trim();
+ const existing = openInFlight.get(key);
+ if (existing) return existing;
+ const promise = ensureOpenSessionInner(key, opts?.loadBundle !== false).finally(() => {
+ if (openInFlight.get(key) === promise) openInFlight.delete(key);
+ });
+ openInFlight.set(key, promise);
+ return promise;
+}
+
+const openInFlight = new Map>();
+
+async function ensureOpenSessionInner(filePath: string, loadBundle: boolean): Promise {
+ let meta: SessionMeta;
+ try {
+ meta = await getPi().openSession({ filePath });
+ } catch (error) {
+ console.error("打开会话失败", error);
+ pushToast("warning", "toast.sessionOpenFailed", errText(error));
+ return null;
+ }
+ // 数据先落:meta 进 tabs + LRU 打点 —— 这些与「谁抢到焦点」无关(由调用者的 token 决定)。
+ // lastUsedAt 必须在数据路径打:用户确实点了这一行,不然 GC 会把它当成「从没用过」。
+ useSessionsStore.setState((state) => ({
+ sessions: [...state.sessions.filter((s) => s.sessionId !== meta.sessionId), meta],
+ lastUsedAt: { ...state.lastUsedAt, [meta.sessionId]: Date.now() },
+ }));
+ if (!loadBundle) return meta;
+ try {
+ // 运行中子会话的事件已按其 sessionId 实时转发;保留已有流式态,
+ // 否则会在点击卡片时把 agent_start 建立的进度视图重置为静态历史。
+ await loadSessionBundle(meta.sessionId, { skipHistoryIfLive: true });
+ } catch (error) {
+ // 会话已经打开了(后端在内存里、meta 在 tabs):数据装载失败只能提示,
+ // 不能当成「打开失败」——否则用户点了行却什么都没发生。
+ console.error("装载会话数据失败", error);
+ pushToast("warning", "toast.sessionOpenFailed", errText(error));
+ }
+ return meta;
+}
+
/**
* 记住「上次项目目录」(重启后启动页预填,用户不用重选项目)。
* 只写 cwd、**不恢复任何会话**(v10 启动纯空不变);同值短路,避免切会话时频繁写 ui-state。
@@ -186,29 +364,107 @@ function rememberCwd(cwd: string | null): void {
useUiPreferencesStore.getState().setLastCwd(cwd);
}
+/** 信任前置:未决项目弹窗(结果落 trust.json),draft 拉斜杠命令/promotion 建会话直接命中缓存 */
+function ensureTrust(cwd: string): void {
+ void getPi()
+ .ensureProjectTrust({ cwd })
+ .then(() => useSessionsStore.setState((s) => ({ trustVersion: s.trustVersion + 1 })))
+ .catch((error) => {
+ console.error("项目信任检查失败", error);
+ pushToast("warning", "toast.trustFailed", errText(error));
+ });
+}
+
+/** promotion 的 single-flight(按 draft generation 隔离;同一份 draft 的并发发送/命令共享一次 create) */
+let promotion: { generation: number; done: Promise } | null = null;
+
+/**
+ * draft → 真实会话:用 draft **入口快照**(cwd/model/thinking/permission 同一时点冻结)建会话,
+ * 成功才消费 draft(失败保留 draft 与输入内容,用户重试就行);promotion 会切到新会话 = 用户导航,
+ * 所以先领号:迟到结果只落 sessions、不抢焦点/cwd。
+ */
+async function promoteDraft(draft: NewSessionDraftConfig): Promise {
+ const targetCwd = draft.cwd;
+ if (!targetCwd) return null;
+ const token = claimActivation();
+ try {
+ const meta = await getPi().createSession({
+ options: {
+ cwd: targetCwd,
+ ...(draft.model ?? {}),
+ thinkingLevel: draft.thinkingLevel,
+ },
+ });
+ useSessionsStore.setState((state) => {
+ // 同 id 已存在就不再 append(与 open pipeline 同一套防御性去重)
+ const sessions = state.sessions.some((s) => s.sessionId === meta.sessionId)
+ ? state.sessions
+ : [...state.sessions, meta];
+ const landed = { sessions, lastUsedAt: { ...state.lastUsedAt, [meta.sessionId]: Date.now() } };
+ if (isLatestActivation(token)) {
+ // 消费 draft:转正成功后新会话页不再留草稿(下次「+」再按当时的会话快照新建一份)
+ return { ...landed, newSessionDraft: null, activeSessionId: meta.sessionId, cwd: targetCwd };
+ }
+ // 迟到(用户已切走):新会话仍进 sessions,但不抢焦点/cwd。
+ // 注意「转正途中点了『+』」:此刻用户就在新会话页,而本次已消费掉那份 draft ——
+ // 必须马上补一份,维持「新会话页必有 draft」不变式(否则 picker 的写入会静默丢失)
+ return {
+ ...landed,
+ newSessionDraft: state.activeSessionId === null ? makeDraft(state, { cwd: state.cwd }) : null,
+ };
+ });
+ useTranscriptStore.getState().resetSession(meta.sessionId);
+ if (isLatestActivation(token)) rememberCwd(targetCwd);
+ // draft 上选过的权限档位(入口快照):后端新会话一律 default 起步,这里补上
+ // (失败只 toast,不回滚转正)
+ if (draft.permissionMode !== "default") {
+ await useSessionsStore.getState().setSessionPermissionMode(meta.sessionId, draft.permissionMode);
+ }
+ return meta.sessionId;
+ } catch (error) {
+ // 失败:draft 与输入内容都保留,用户重试即可;toast 提示(非会话内容,不残留)
+ console.error("创建会话失败", error);
+ pushToast("warning", "toast.sessionCreateFailed", errText(error));
+ return null;
+ }
+}
+
/** 顶栏打开的会话持久化(重启恢复用);由主进程写 userData/tabs.json,不依赖 renderer localStorage */
interface SessionsStore {
sessions: SessionMeta[];
activeSessionId: string | null;
cwd: string | null;
models: AvailableModel[];
+ /** 全局唯一的新会话 draft;null = 当前已有真实会话(或还没初始化) */
+ newSessionDraft: NewSessionDraftConfig | null;
/** 上次使用的模型/思考深度(新会话与 draft 起步跟随;持久化 ui-state.json,语义 = 跟随最近选择,非独立默认设置) */
lastUsedModel: { provider: string; modelId: string } | null;
lastUsedThinkingLevel: string;
/** 项目信任决策完成计数:ensureProjectTrust 应答后 +1,驱动 draft 斜杠菜单按新决策重拉 */
trustVersion: number;
- /** 按会话权限模式(缺 key = default;draft id 为 key 的条目是 renderer 内存态,转正时由 ensureSession 应用到后端) */
+ /** 按会话权限模式(缺 key = default);draft 的档位在 `newSessionDraft.permissionMode` 里,不占本表 */
permissionModes: Record;
/**
* 最近使用时刻(毫秒,内存策略 LRU 打点):打开/新建/切走时更新,卸载/关闭时删表项。
* 只活在本次进程(不落盘):重启后一个会话都没打开,本来也没有 LRU 可言。
*/
lastUsedAt: Record;
- createSession: (cwd?: string, replaceDraftId?: string) => Promise;
- /** 新建草稿会话 tab:不触后端、不落盘(空 tab 重启后自动消失),发送首条消息时才用其 cwd 真正创建 */
- createDraftSession: (cwd?: string) => void;
- /** 设置新会话的目标项目目录;活跃 tab 是 draft 时同步更新其条目(切 tab 往返不丢选择) */
+ /**
+ * promotion:把当前 draft 变成真实会话(首条消息/斜杠命令触发)。返回新会话 id;
+ * 没 draft / draft 无 cwd → null(调用方据此中止发送);失败也返回 null 但**保留 draft**(可重试)。
+ * **调用方必须用返回值定位新会话**,不能读 `activeSessionId`:创建是异步的,
+ * 期间用户可能已切走(latest-wins 下新会话可能不抢焦点),读 active 会拿到别人的 id。
+ */
+ createSession: () => Promise;
+ /**
+ * 激活新会话页(顶栏「+」/ 启动初始化)。已有 draft 时**只激活、不覆盖任何配置**
+ * (`cwd` 参数只在新建一份 draft 时作为起点,避免把在选择器里选过的目录冲掉)。
+ */
+ activateNewSessionDraft: (cwd?: string) => void;
+ /** 设置新会话的目标项目目录(项目选择器);draft 没有就一直只记全局默认 */
setDraftCwd: (cwd: string) => void;
+ /** draft 的权限档位(纯 renderer,promotion 时才应用到后端会话) */
+ setDraftPermissionMode: (mode: PermissionMode) => void;
switchSession: (sessionId: string) => void;
closeSession: (sessionId: string, intent?: SessionCloseIntent) => Promise<{ closed: boolean }>;
/** 自动卸载(内存策略专用,见实现处注释);传 intent="gc" 让后端区分自动 GC 与用户意图 */
@@ -224,7 +480,7 @@ interface SessionsStore {
loadModels: () => Promise;
setCurrentModel: (provider: string, modelId: string) => Promise;
setThinkingLevel: (level: string) => Promise;
- /** 切换会话权限模式(draft 态仅本地;真实会话乐观更新 + 失败回滚 + toast) */
+ /** 切换会话权限模式(真实会话乐观更新 + 失败回滚 + toast;draft 走 setDraftPermissionMode) */
setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => Promise;
}
@@ -233,99 +489,89 @@ export const useSessionsStore = create((set, get) => ({
activeSessionId: null,
cwd: null,
models: [],
+ // 不变式:新会话页(`activeSessionId === null`)必须有一份 draft。开机就是新会话页,
+ // 所以初始化就建好(App 启动再把 lastCwd 种进来,见 App.tsx),不留「active=null 且 draft=null」的窗口
+ newSessionDraft: makeDraft({ cwd: null, lastUsedModel: null, lastUsedThinkingLevel: "medium" }),
lastUsedModel: null,
lastUsedThinkingLevel: "medium",
trustVersion: 0,
permissionModes: {},
lastUsedAt: {},
- createSession: async (cwd, replaceDraftId) => {
- const targetCwd = cwd ?? get().cwd;
- if (!targetCwd) return;
- try {
- const meta = await getPi().createSession({
- options: {
- cwd: targetCwd,
- ...get().lastUsedModel,
- thinkingLevel: get().lastUsedThinkingLevel,
- },
- });
- set((state) => ({
- // draft 转正式会话:原地替换保持 tab 位置;普通新建则追加
- sessions: replaceDraftId
- ? state.sessions.map((s) => (s.sessionId === replaceDraftId ? meta : s))
- : [...state.sessions, meta],
- activeSessionId: meta.sessionId,
- cwd: targetCwd,
- // draft 键下的权限模式由 ensureSession 在创建后应用到新 id,这里顺手满档防泄漏
- permissionModes: replaceDraftId
- ? withPermissionMode(state.permissionModes, replaceDraftId, "default")
- : state.permissionModes,
- lastUsedAt: { ...state.lastUsedAt, [meta.sessionId]: Date.now() },
- }));
- useTranscriptStore.getState().resetSession(meta.sessionId);
- rememberCwd(targetCwd);
- } catch (error) {
- // 失败时 draft tab 保留,用户重试即可;toast 提示(非会话内容,不残留)
- console.error("创建会话失败", error);
- pushToast("warning", "toast.sessionCreateFailed", errText(error));
- }
+ // promotion:draft → 真实会话。single-flight 按 draft generation 隔离(不落盘、不进 store)
+ createSession: () => {
+ const draft = get().newSessionDraft;
+ // 没 draft / 还没选项目 = 没什么可转正:不静默建会话,由调用方中止发送并提示
+ if (!draft?.cwd) return Promise.resolve(null);
+ // 同一份 draft 的并发发送/命令共享同一次 create;新 draft(新 generation)必须另起一次
+ if (promotion?.generation === draft.generation) return promotion.done;
+ const done = promoteDraft(draft);
+ const self = { generation: draft.generation, done };
+ promotion = self;
+ void done.finally(() => {
+ if (promotion === self) promotion = null;
+ });
+ return done;
},
- createDraftSession: (cwd) => {
- const targetCwd = cwd ?? get().cwd;
- if (!targetCwd) return;
- // 信任前置:未决项目立即弹窗(结果落 trust.json),draft 拉斜杠命令/转正建会话直接命中缓存
- void getPi()
- .ensureProjectTrust({ cwd: targetCwd })
- .then(() => set((s) => ({ trustVersion: s.trustVersion + 1 })))
- .catch((error) => {
- console.error("项目信任检查失败", error);
- pushToast("warning", "toast.trustFailed", errText(error));
+ activateNewSessionDraft: (cwd) => {
+ const state = get();
+ 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();
+ set({
+ activeSessionId: null,
+ cwd: targetCwd,
+ newSessionDraft: { ...existing, cwd: targetCwd },
});
- const now = Date.now();
- const draft: SessionMeta = {
- sessionId: `${DRAFT_SESSION_PREFIX}${crypto.randomUUID()}`,
- cwd: targetCwd,
- model: get().lastUsedModel,
- thinkingLevel: get().lastUsedThinkingLevel,
- active: true,
- messageCount: 0,
- createdAt: now,
- modifiedAt: now,
- };
- set((state) => ({
- sessions: [...state.sessions, draft],
- activeSessionId: draft.sessionId,
+ 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();
+ set({
+ activeSessionId: null,
cwd: targetCwd,
- lastUsedAt: { ...state.lastUsedAt, [draft.sessionId]: Date.now() },
- }));
+ newSessionDraft: makeDraft(state, {
+ cwd: targetCwd,
+ // 起步值优先当前会话快照(不是全局「最近使用」);没会话来源时两项都留「未定」,
+ // 由 loadModels 用持久化偏好回填(thinking 不能拿占位 medium 当用户选择)
+ model: activeSession?.model ?? state.lastUsedModel,
+ thinkingLevel: activeSession?.thinkingLevel,
+ }),
+ });
+ if (targetCwd) ensureTrust(targetCwd);
},
setDraftCwd: (cwd) => {
- // 同 createDraftSession:cwd 变化即前置信任决策
- void getPi()
- .ensureProjectTrust({ cwd })
- .then(() => set((s) => ({ trustVersion: s.trustVersion + 1 })))
- .catch((error) => {
- console.error("项目信任检查失败", error);
- pushToast("warning", "toast.trustFailed", errText(error));
- });
- set((state) => {
- const active = state.sessions.find((s) => s.sessionId === state.activeSessionId);
- if (active && isDraftSessionId(active.sessionId)) {
- return {
- cwd,
- sessions: state.sessions.map((s) => (s.sessionId === active.sessionId ? { ...s, cwd } : s)),
- };
- }
- return { cwd };
- });
+ // cwd 变化即前置信任决策
+ ensureTrust(cwd);
+ set((state) => ({
+ cwd,
+ newSessionDraft: state.newSessionDraft ? { ...state.newSessionDraft, cwd } : state.newSessionDraft,
+ }));
// 在选择器里选过项目 = 用户明确表态要用它:立刻记住(典型场景:首启选了项目、还没发消息就退出)
rememberCwd(cwd);
},
+ setDraftPermissionMode: (mode) => {
+ set((state) => ({
+ newSessionDraft: state.newSessionDraft ? { ...state.newSessionDraft, permissionMode: mode } : null,
+ }));
+ },
+
switchSession: (sessionId) => {
+ // 同步导航:先领号,在途的 open/create/fork 全部作废(latest-wins 的另一半)
+ claimActivation();
set((state) => {
const session = state.sessions.find((s) => s.sessionId === sessionId);
return {
@@ -336,14 +582,13 @@ export const useSessionsStore = create((set, get) => ({
});
// 懒加载兜底(D4):目标会话无 transcript 数据时补拉四件套(从历史打开、
// 事件桥断连期间的切换等都经此路径自愈;已有数据零成本短路)
- if (!isDraftSessionId(sessionId) && useTranscriptStore.getState().bySession[sessionId] === undefined) {
+ if (useTranscriptStore.getState().bySession[sessionId] === undefined) {
void loadSessionBundle(sessionId).catch((error) => {
console.error("切换会话时补拉数据失败", error);
pushToast("warning", "toast.sessionOpenFailed", errText(error));
});
}
rememberCwd(get().cwd);
- // 切到 draft 不落盘:tabs.json 保持指向最近的真实会话(draft 重启后本就会消失)
},
updateSessionName: (sessionId, name) =>
@@ -352,37 +597,62 @@ export const useSessionsStore = create((set, get) => ({
})),
closeSession: async (sessionId, intent) => {
- const isDraft = isDraftSessionId(sessionId);
- // draft 没有后端会话,纯本地移除
- if (!isDraft) {
- try {
- // intent 只在本轮是「内存策略自动卸载」时带上,用户主动关不传(保持既有语义)
- const { closed } = await getPi().closeSession(intent ? { sessionId, intent } : { sessionId });
- // 后端拒绝(agent 在跑 / 等审批):**渲染层状态必须原样保留** —— 事务语义,
- // 不能出现「后端会话还在、前端条目已消失」的半个动作(内存策略也会看走眼)
- if (!closed) return { closed: false };
- } catch (error) {
- // 会话关闭失败:UI 状态保留(用户可重试),显形不静默(曾「点了没反应」)
- console.error("关闭会话失败", error);
- pushToast("warning", "toast.closeFailed", errText(error));
- return { closed: false };
+ try {
+ // intent 只在本轮是「内存策略自动卸载」时带上,用户主动关不传(保持既有语义)
+ const { closed } = await getPi().closeSession(intent ? { sessionId, intent } : { sessionId });
+ // 后端拒绝(agent 在跑 / 等审批):**渲染层状态必须原样保留** —— 事务语义,
+ // 不能出现「后端会话还在、前端条目已消失」的半个动作(内存策略也会看走眼)
+ if (!closed) return { closed: false };
+ // 自动卸载的选择竞态(spec D5):策略判定「可卸」时它还不是 active,
+ // 但 close 在我们这个 await 里真的关掉了它 —— 此时用户刚好选中了它。
+ // 那样就把用户刚点的会话换成空白/消失 —— 所以重新打开后端会话,而不是把 UI 抹掉。
+ // 只重建 backend(loadBundle:false):**不**重载磁盘历史,idle transcript 原样保留。
+ if (intent === "gc" && get().activeSessionId === sessionId) {
+ const file = get().sessions.find((s) => s.sessionId === sessionId)?.sessionFile;
+ if (file && (await ensureOpenSession(file, { loadBundle: false }))) {
+ await restorePermissionMode(sessionId);
+ return { closed: false };
+ }
+ // 恢复失败(或没有会话文件):下面的常规清理照走;提示已由共享 open 链报过一次
}
+ } catch (error) {
+ // 会话关闭失败:UI 状态保留(用户可重试),显形不静默(曾「点了没反应」)
+ console.error("关闭会话失败", error);
+ pushToast("warning", "toast.closeFailed", errText(error));
+ return { closed: false };
}
useTranscriptStore.getState().resetSession(sessionId);
set((state) => {
+ const closing = state.sessions.find((s) => s.sessionId === sessionId);
const sessions = state.sessions.filter((s) => s.sessionId !== sessionId);
const activeSessionId =
state.activeSessionId === sessionId ? (sessions[0]?.sessionId ?? null) : state.activeSessionId;
// 切 active 后 cwd 同步到新活跃会话的项目(否则跨项目关会话后 cwd 残留旧项目,新建会话归属错)(B5)
- const cwd = activeSessionId
+ const fallbackCwd = activeSessionId
? (sessions.find((s) => s.sessionId === activeSessionId)?.cwd ?? state.cwd)
: state.cwd;
- // 权限模式随会话销毁归零(后端 holder 同点位清理;draft 态本就纯 renderer)
+ // 关掉最后一个会话 = 回到新会话页:不变式要求这里必须有 draft,否则新会话页的
+ // picker 写入会静默丢失。起步快照取刚关掉的那个会话(与「+」同一套语义);
+ // 已有后台 draft 则保留它(配置不拿被关会话覆盖)
+ const newSessionDraft =
+ activeSessionId === null
+ ? (state.newSessionDraft ??
+ makeDraft(state, {
+ cwd: fallbackCwd,
+ model: closing?.model,
+ thinkingLevel: closing?.thinkingLevel,
+ }))
+ : state.newSessionDraft;
+ // 新会话页的 cwd 真相是 draft.cwd(项目选择器 / Sidebar activeCwd / promotion 都读它):
+ // 严格镜像它(**含 null**)—— 不能回退到被关闭会话的项目,
+ // 否则又回到「页面/promotion 两处说法」(draft 还没选项目时 store.cwd 也得是 null)
+ const cwd = activeSessionId === null ? (newSessionDraft?.cwd ?? null) : fallbackCwd;
+ // 权限模式随会话销毁归零(后端 holder 同点位清理)
const permissionModes = withPermissionMode(state.permissionModes, sessionId, "default");
// LRU 打点随会话一起清(表项留着就是泄漏:只会积攒不再打开的 id)
const lastUsedAt = { ...state.lastUsedAt };
delete lastUsedAt[sessionId];
- return { sessions, activeSessionId, cwd, permissionModes, lastUsedAt };
+ return { sessions, activeSessionId, cwd, permissionModes, lastUsedAt, newSessionDraft };
});
return { closed: true };
},
@@ -395,36 +665,31 @@ export const useSessionsStore = create((set, get) => ({
unloadSession: (sessionId) => get().closeSession(sessionId, "gc"),
openFromHistory: async (filePath) => {
- try {
- const meta = await getPi().openSession({ filePath });
- set((state) => ({
- sessions: [...state.sessions.filter((s) => s.sessionId !== meta.sessionId), meta],
- activeSessionId: meta.sessionId,
- cwd: meta.cwd,
- lastUsedAt: { ...state.lastUsedAt, [meta.sessionId]: Date.now() },
- }));
- // 先记 cwd 再拉数据:即便随后装载失败(下面的 catch),用户「在用哪个项目」的事实也已经成立
- rememberCwd(meta.cwd);
- // 运行中子会话的事件已按其 sessionId 实时转发;保留已有流式态,
- // 否则会在点击卡片时把 agent_start 建立的进度视图重置为静态历史。
- await loadSessionBundle(meta.sessionId, { skipHistoryIfLive: true });
- } catch (error) {
- console.error("打开会话失败", error);
- pushToast("warning", "toast.sessionOpenFailed", errText(error));
- }
+ // 先领号再发请求:这次点击就是当前最新意图(同文件双击则第二次领号,自然胜出)
+ const token = claimActivation();
+ const meta = await ensureOpenSession(filePath);
+ // 失败已由共享链报过一次;这里的提前返回同时保证不对用户的新选择做任何回滚
+ if (!meta || !isLatestActivation(token)) return;
+ set({ activeSessionId: meta.sessionId, cwd: meta.cwd });
+ // 先记 cwd 再拉数据:即便随后装载失败,用户「在用哪个项目」的事实也已经成立
+ rememberCwd(meta.cwd);
},
forkSession: async (ref) => {
const { activeSessionId } = get();
- // draft 还没有消息,无可分叉(UI 上也到不了这里,防御性拦截)
- if (!activeSessionId || isDraftSessionId(activeSessionId)) return undefined;
+ // 新会话页还没有消息,无可分叉(UI 上也到不了这里,防御性拦截)
+ if (!activeSessionId) return undefined;
+ // 分叉成功会切到新会话 = 用户导航:先领号
+ const token = claimActivation();
try {
const meta = await getPi().forkSession({ sessionId: activeSessionId, ref });
- set((state) => ({
- sessions: [...state.sessions.filter((s) => s.sessionId !== meta.sessionId), meta],
- activeSessionId: meta.sessionId,
- }));
+ set((state) => {
+ const sessions = [...state.sessions.filter((s) => s.sessionId !== meta.sessionId), meta];
+ // 迟到:新会话仍进 tabs,但不抢焦点
+ return isLatestActivation(token) ? { sessions, activeSessionId: meta.sessionId } : { sessions };
+ });
await loadSessionBundle(meta.sessionId);
+ // fork 事实已发生:即便已不是最新意图也要把新 id 交给调用方
return meta.sessionId;
} catch (error) {
console.error("分叉会话失败", error);
@@ -435,8 +700,8 @@ export const useSessionsStore = create((set, get) => ({
recallMessage: async (ref) => {
const { activeSessionId } = get();
- // draft 还没有消息,无可撤回(UI 上也到不了这里,防御性拦截)
- if (!activeSessionId || isDraftSessionId(activeSessionId)) return;
+ // 新会话页还没有消息,无可撤回(UI 上也到不了这里,防御性拦截)
+ if (!activeSessionId) return;
try {
const recalled = await getPi().recallMessage({ sessionId: activeSessionId, ref });
// 内容回填草稿:已有草稿文本时换行拼接(与排队取回一致),图片追加在尾部
@@ -456,7 +721,7 @@ export const useSessionsStore = create((set, get) => ({
pickDirectory: async () => {
const cwd = await getPi().pickDirectory();
- // 走 setDraftCwd:活跃 tab 是 draft 时同步更新其条目
+ // 走 setDraftCwd:同时更新 draft 配置与全局 cwd(新会话归属哪个项目只看这一处)
if (cwd) get().setDraftCwd(cwd);
},
@@ -486,10 +751,28 @@ export const useSessionsStore = create((set, get) => ({
supportedLevels && supportedLevels.length > 0
? clampThinkingLevel(rawLevel, supportedLevels)
: rawLevel;
+ // draft 的起步模型:已定(会话快照/用户选过)就保留,没定就用刚解析出的默认值
+ const draft = get().newSessionDraft;
+ const draftModel = draft?.modelResolved ? draft.model : nextLastUsedModel;
+ const draftModelRecord = draftModel
+ ? models.find((m) => m.provider === draftModel.provider && m.id === draftModel.modelId)
+ : undefined;
set({
models,
lastUsedModel: nextLastUsedModel,
lastUsedThinkingLevel: clampedLevel,
+ // draft 起步值(spec §5.1):**分字段**判断已定/未定——
+ // 模型未定时用默认值补;档位已定的仍要按所选模型的能力夹紧(与全局/会话同一套规则)
+ newSessionDraft: draft
+ ? {
+ ...draft,
+ model: draftModel,
+ thinkingLevel: clampThinkingLevel(
+ draft.thinkingResolved ? draft.thinkingLevel : clampedLevel,
+ draftModelRecord?.thinkingLevels ?? [],
+ ),
+ }
+ : null,
});
} catch (error) {
console.error("加载模型列表失败", error);
@@ -513,6 +796,13 @@ export const useSessionsStore = create((set, get) => ({
await optimisticSessionSetting("切换模型", "toast.modelSwitchFailed", () => ({
global: { lastUsedModel: { provider, modelId }, lastUsedThinkingLevel: thinkingLevel },
sessionPatch: { model: { provider, modelId }, thinkingLevel },
+ // draft 页:选择写进 draft 配置(两个 resolved 位分字段置位,互不干扰)
+ draftPatch: {
+ model: { provider, modelId },
+ thinkingLevel,
+ modelResolved: true,
+ thinkingResolved: true,
+ },
uiState: { lastUsedModel: { provider, modelId }, lastUsedThinkingLevel: thinkingLevel },
sync: (sessionId) => getPi().setModel({ sessionId, provider, modelId }),
}));
@@ -523,21 +813,19 @@ export const useSessionsStore = create((set, get) => ({
await optimisticSessionSetting("切换思考深度", "toast.thinkingSwitchFailed", () => ({
global: { lastUsedThinkingLevel: level },
sessionPatch: { thinkingLevel: level },
+ // draft 页:只置「档位已定」,**不得**碰 modelResolved(改档位 ≠ 改模型,模型仍要能按默认补齐)
+ draftPatch: { thinkingLevel: level, thinkingResolved: true },
uiState: { lastUsedThinkingLevel: level },
sync: (sessionId) => getPi().setThinkingLevel({ sessionId, level }),
}));
},
/**
- * 切换会话权限模式:draft 态纯 renderer 内存(转正时 ensureSession 应用到后端);
- * 真实会话乐观更新 + IPC 同步,失败回滚 + toast(范式同 setContextManagerMode)。
+ * 切换会话权限模式:乐观更新 + IPC 同步,失败回滚 + toast(范式同 setContextManagerMode)。
+ * 新会话页的档位不走这里(它存在 `newSessionDraft.permissionMode`,promotion 后由 store 应用)。
* 后端为内存态即时生效(每次 tool_call 实时读),无需重升会话。
*/
setSessionPermissionMode: async (sessionId, mode) => {
- if (isDraftSessionId(sessionId)) {
- set((state) => ({ permissionModes: withPermissionMode(state.permissionModes, sessionId, mode) }));
- return;
- }
const previous = get().permissionModes[sessionId] ?? "default";
set((state) => ({ permissionModes: withPermissionMode(state.permissionModes, sessionId, mode) }));
try {
diff --git a/packages/desktop/src/renderer/src/stores/settings.ts b/packages/desktop/src/renderer/src/stores/settings.ts
index af771c00..0f5c6a41 100644
--- a/packages/desktop/src/renderer/src/stores/settings.ts
+++ b/packages/desktop/src/renderer/src/stores/settings.ts
@@ -13,7 +13,7 @@ import type {
} from "@percho/shared";
import { create } from "zustand";
import { getPi } from "../api";
-import { isDraftSessionId, useSessionsStore } from "./sessions";
+import { useSessionsStore } from "./sessions";
export type SettingsCategory =
| "general"
@@ -153,9 +153,9 @@ export const useSettingsStore = create((set, get) => {
getPi().listSubagents(),
]);
set({ providers, modelPrefs, subagents, loading: false, error: null });
- // 已加载资源按当前活跃会话(其项目)展示;无会话或 draft(未真正创建)时为 null(面板显示空态)
+ // 已加载资源按当前活跃会话(其项目)展示;新会话页(还没建后端会话)时为 null(面板显示空态)
const activeSessionId = useSessionsStore.getState().activeSessionId;
- if (activeSessionId && !isDraftSessionId(activeSessionId)) {
+ if (activeSessionId) {
const resources = await getPi().getLoadedResources({ sessionId: activeSessionId });
// 竞态守卫:await 期间活跃会话已切换则丢弃(防把 A 项目的资源写到 B 会话的面板)
if (useSessionsStore.getState().activeSessionId === activeSessionId) {