diff --git a/README.md b/README.md index 146f4dc7..d1c452ee 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@

- percho logo + + + Percho — geometric construction wordmark and pyramid +

-

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 -![percho welcome page with the whale maid desk pet](docs/assets/img/percho_pet.png) +![Percho new-session page](docs/assets/img/chat_001.png) + +![Percho project and session sidebar](docs/assets/img/chat_002.png) + +![Percho per-turn diff sidebar](docs/assets/img/chat_003.png) ![UI plugins settings — the whale maid desk pet ships built in](docs/assets/img/percho_ui_plugins.png) @@ -34,13 +41,9 @@ ![Chat with custom background image in dark theme](docs/assets/img/chat_img_bg_show_img.png) -**Settings — providers & models** - -![Settings page demo](docs/assets/img/demo-settings.gif) +## 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 logo + + + Percho 几何构造字标与立体金字塔 +

-

percho

+

Percho

高度自定义的 Pi coding agent 桌面端 GUI —— 与 Pi CLI 同源同引擎,干净清爽的视觉界面。多会话聊天、可视化工具审批、内置子代理、UI 插件、自定义主题。

@@ -22,7 +25,11 @@ ## 演示 -![percho 欢迎页与鲸鱼娘桌宠](docs/assets/img/percho_pet.png) +![Percho 新会话页](docs/assets/img/chat_001.png) + +![Percho 项目与会话侧栏](docs/assets/img/chat_002.png) + +![Percho 逐轮改动侧栏](docs/assets/img/chat_003.png) ![设置 —— UI 插件管理,内置鲸鱼娘桌宠](docs/assets/img/percho_ui_plugins.png) @@ -34,13 +41,9 @@ ![深色主题下带自定义背景的聊天页](docs/assets/img/chat_img_bg_show_img.png) -**设置页 —— 模型与 Provider** - -![设置页演示](docs/assets/img/demo-settings.gif) +## 为什么选择 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + capxbaseφPercho · costruzione geometrica — 1 : φPercho + + + + + + + + + + + + + + + + + 0.3820.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 @@ + + + + + + + + + + + + + + + + + + + + + + + + capxbaseφPercho · costruzione geometrica — 1 : φPercho + + + + + + + + + + + + + + + + + 0.3820.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 && ( <>