diff --git a/.claude/scheduled_tasks.json b/.claude/scheduled_tasks.json deleted file mode 100644 index b501d2ed..00000000 --- a/.claude/scheduled_tasks.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "tasks": [ - { - "id": "b4851079", - "cron": "0 9 * * *", - "prompt": "test prompt", - "createdAt": 1775497208158, - "recurring": true, - "lastFiredAt": 1775525400260 - } - ] -} diff --git a/.gitignore b/.gitignore index df49ecb4..93aa66b0 100644 --- a/.gitignore +++ b/.gitignore @@ -25,10 +25,11 @@ desktop/src-tauri/gen/ # Desktop brand asset candidates (keep only selected ones in public/) desktop/brand-assets/ -# Claude worktrees & auto-generated -.claude/worktrees/ -.claude/scheduled_tasks.lock -.claude/scheduled_tasks.json +# Claude local config & auto-generated +.claude/ + +# Superpowers plans & specs (local only) +docs/superpowers/ # Debug / temp screenshots (root dir only) /*.png diff --git a/active-session-test.png b/active-session-test.png deleted file mode 100644 index f32eb0fc..00000000 Binary files a/active-session-test.png and /dev/null differ diff --git a/active-session.png b/active-session.png deleted file mode 100644 index f13945b7..00000000 Binary files a/active-session.png and /dev/null differ diff --git a/adapters/README.md b/adapters/README.md index 3eb1ba9e..79e101fe 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -1,257 +1,60 @@ # Claude Code IM Adapters -通过 Telegram / 飞书与 Claude Code Desktop 对话。 +当前目录只放 IM Adapter 运行时代码。 -每个 Adapter 是一个轻量独立脚本,直连 Desktop 服务端的 WebSocket 接口,与桌面 UI 走完全相同的协议。 +用户文档已经迁移到 `docs/`,并且以 Desktop Webapp 配置流程为准: -## 架构 +- `docs/im/index.md` +- `docs/im/telegram.md` +- `docs/im/feishu.md` -``` -Telegram / 飞书 - ↕ (各平台 SDK) -IM Adapter (独立脚本) - ↕ ws://localhost:3456/ws/{sessionId} -Claude Code Desktop 服务端 (已有,零改动) - ↕ -CLI 子进程 (Claude Code Agent) +## 当前方案摘要 + +当前真实链路是: + +```text +Desktop Webapp Settings + -> /api/adapters + -> ~/.claude/adapters.json + -> adapters//index.ts + -> /api/sessions + /ws/:sessionId + -> Claude Code session ``` -## 前置条件 +注意两点: -- **Claude Code Desktop** 已运行(服务端默认在 `localhost:3456`) -- **Bun** 运行时 `>= 1.0` +- IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入` +- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run telegram` 或 `bun run feishu` -## 安装 +## 快速启动 ```bash cd adapters bun install -``` - ---- - -## Telegram - -### 1. 创建 Bot - -1. 在 Telegram 中找到 [@BotFather](https://t.me/BotFather) -2. 发送 `/newbot`,按提示创建 -3. 获取 Bot Token(格式:`123456:ABC-DEF...`) - -### 2. 配置 - -**方式一:环境变量(推荐)** - -```bash -export TELEGRAM_BOT_TOKEN="你的Bot Token" -``` - -**方式二:配置文件** - -编辑 `~/.claude/adapters.json`: - -```json -{ - "telegram": { - "botToken": "你的Bot Token", - "allowedUsers": [], - "defaultWorkDir": "/path/to/your/project" - } -} -``` - -- `allowedUsers`:Telegram User ID 白名单。留空 `[]` 允许所有人(适合个人使用) -- `defaultWorkDir`:Claude Code 的工作目录 - -### 3. 启动 - -```bash -cd adapters bun run telegram -``` - -### 4. 使用 - -在 Telegram 中找到你的 Bot,直接发消息即可。 - -**命令:** - -| 命令 | 说明 | -|------|------| -| `/start` | 显示帮助信息 | -| `/new` | 新建会话(重置上下文) | -| `/stop` | 停止当前生成 | - -**权限审批:** 当 Claude 需要执行敏感操作(如运行终端命令、写文件),Bot 会发送带按钮的消息,点击 **允许** 或 **拒绝** 即可。 - ---- - -## 飞书 (Feishu) - -### 1. 创建应用 - -1. 打开 [飞书开放平台](https://open.feishu.cn/app) -2. 创建「企业自建应用」 -3. 记下 **App ID** 和 **App Secret** - -### 2. 配置应用权限 - -在应用后台「权限管理」中开启: - -- `im:message` — 获取与发送消息 -- `im:message:send_as_bot` — 以机器人身份发送消息 -- `im:resource` — 获取消息中的资源文件 - -### 3. 配置事件订阅 - -在应用后台「事件订阅」中: - -1. 选择「使用长连接接收事件」(WebSocket 模式,无需公网地址) -2. 添加事件: - - `im.message.receive_v1` — 接收消息 - - `card.action.trigger` — 卡片按钮点击回调 - -### 4. 配置 Adapter - -**方式一:环境变量(推荐)** - -```bash -export FEISHU_APP_ID="cli_xxx" -export FEISHU_APP_SECRET="你的App Secret" -``` - -**方式二:配置文件** - -编辑 `~/.claude/adapters.json`: - -```json -{ - "feishu": { - "appId": "cli_xxx", - "appSecret": "你的App Secret", - "allowedUsers": [], - "defaultWorkDir": "/path/to/your/project", - "streamingCard": false - } -} -``` - -- `allowedUsers`:飞书 Open ID 白名单。留空 `[]` 允许所有人 -- `streamingCard`:启用流式卡片模式(实时更新消息内容,体验更好) - -### 5. 发布应用 - -在应用后台「版本管理与发布」中创建版本并发布(至少发布到「开发中」状态,机器人才能收发消息)。 - -### 6. 启动 - -```bash -cd adapters +# 或 bun run feishu ``` -### 7. 使用 - -- **私聊**:直接给机器人发消息 -- **群聊**:需要 @机器人 -- **新会话**:发送 `/new` 或 `新会话` -- **停止**:发送 `/stop` 或 `停止` - -**权限审批:** 当 Claude 需要执行敏感操作,Bot 会发送交互式卡片,点击 **允许** 或 **拒绝** 按钮。 - ---- - -## 配置文件完整示例 - -`~/.claude/adapters.json`: - -```json -{ - "serverUrl": "ws://127.0.0.1:3456", - "telegram": { - "botToken": "123456:ABC-DEF...", - "allowedUsers": [123456789, 987654321], - "defaultWorkDir": "/Users/me/workspace/my-project" - }, - "feishu": { - "appId": "cli_xxx", - "appSecret": "xxx", - "encryptKey": "", - "verificationToken": "", - "allowedUsers": ["ou_xxx"], - "defaultWorkDir": "/Users/me/workspace/my-project", - "streamingCard": false - } -} -``` - -环境变量优先级高于配置文件。 - ---- - -## 常见问题 - -### 连不上服务器 - -``` -[WsBridge] Error on tg-xxx: connect ECONNREFUSED 127.0.0.1:3456 -``` - -**原因**:Claude Code Desktop 没有运行。启动 Desktop App 后再启动 Adapter。 - -### Telegram Bot 无响应 - -1. 检查 Bot Token 是否正确 -2. 检查 Bot 是否被 Telegram 封禁(找 @BotFather 查看状态) -3. 如果配置了 `allowedUsers`,确认你的 User ID 在列表中 - -**获取你的 Telegram User ID**:给 [@userinfobot](https://t.me/userinfobot) 发条消息。 - -### 飞书收不到消息 - -1. 检查 App ID / App Secret 是否正确 -2. 检查应用是否已发布 -3. 检查事件订阅是否配置了 `im.message.receive_v1` -4. 检查应用权限是否已开启 `im:message` -5. 群聊中确认已 @机器人 - -### 权限按钮不响应 - -- **Telegram**:可能是 callback_data 格式问题,查看 Adapter 日志 -- **飞书**:检查事件订阅是否配置了 `card.action.trigger` - ---- - ## 开发 ### 运行测试 ```bash cd adapters -bun test # 全部测试 -bun test common/ # 公共模块测试 -bun test telegram/ # Telegram 测试 -bun test feishu/ # 飞书测试 +bun test +bun test common/ +bun test telegram/ +bun test feishu/ ``` ### 目录结构 -``` +```text adapters/ -├── common/ # 公共模块 -│ ├── ws-bridge.ts # WebSocket 桥接 -│ ├── message-buffer.ts # 流式消息缓冲 -│ ├── message-dedup.ts # 消息去重 -│ ├── chat-queue.ts # 会话串行队列 -│ ├── format.ts # 消息格式化 -│ ├── config.ts # 配置加载 -│ └── __tests__/ # 公共模块测试 +├── common/ ├── telegram/ -│ ├── index.ts # Telegram Adapter 入口 -│ └── __tests__/ # Telegram 测试 ├── feishu/ -│ ├── index.ts # 飞书 Adapter 入口 -│ └── __tests__/ # 飞书测试 ├── package.json ├── tsconfig.json └── README.md diff --git a/after-send-fixed.png b/after-send-fixed.png deleted file mode 100644 index 6439e1c2..00000000 Binary files a/after-send-fixed.png and /dev/null differ diff --git a/after-send.png b/after-send.png deleted file mode 100644 index a737d7b8..00000000 Binary files a/after-send.png and /dev/null differ diff --git a/desktop-after-fixes.png b/desktop-after-fixes.png deleted file mode 100644 index e80cd968..00000000 Binary files a/desktop-after-fixes.png and /dev/null differ diff --git a/desktop-app-initial.png b/desktop-app-initial.png deleted file mode 100644 index 5f216fc2..00000000 Binary files a/desktop-app-initial.png and /dev/null differ diff --git a/desktop-with-server.png b/desktop-with-server.png deleted file mode 100644 index a6566093..00000000 Binary files a/desktop-with-server.png and /dev/null differ diff --git a/desktop/public/logo-horizontal.jpg b/desktop/public/logo-horizontal.jpg deleted file mode 100644 index 3ea5a21a..00000000 Binary files a/desktop/public/logo-horizontal.jpg and /dev/null differ diff --git a/desktop/tsconfig.tsbuildinfo b/desktop/tsconfig.tsbuildinfo index 84799aa1..4454391a 100644 --- a/desktop/tsconfig.tsbuildinfo +++ b/desktop/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/__tests__/pages.test.tsx","./src/api/clitasks.ts","./src/api/client.ts","./src/api/filesystem.ts","./src/api/models.ts","./src/api/providers.ts","./src/api/search.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/tasks.ts","./src/api/teams.ts","./src/api/websocket.ts","./src/components/chat/askuserquestion.tsx","./src/components/chat/assistantmessage.tsx","./src/components/chat/attachmentgallery.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/codeviewer.tsx","./src/components/chat/diffviewer.tsx","./src/components/chat/imagegallerymodal.tsx","./src/components/chat/messagelist.tsx","./src/components/chat/permissiondialog.tsx","./src/components/chat/sessiontaskbar.tsx","./src/components/chat/streamingindicator.tsx","./src/components/chat/terminalchrome.tsx","./src/components/chat/thinkingblock.tsx","./src/components/chat/toolcallblock.tsx","./src/components/chat/toolcallgroup.tsx","./src/components/chat/toolresultblock.tsx","./src/components/chat/usermessage.tsx","./src/components/chat/chatblocks.test.tsx","./src/components/chat/clipboard.ts","./src/components/chat/composerutils.test.ts","./src/components/chat/composerutils.ts","./src/components/controls/modelselector.tsx","./src/components/controls/permissionmodeselector.tsx","./src/components/layout/appshell.tsx","./src/components/layout/contentrouter.tsx","./src/components/layout/projectfilter.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/statusbar.tsx","./src/components/layout/titlebar.tsx","./src/components/markdown/markdownrenderer.tsx","./src/components/shared/button.tsx","./src/components/shared/copybutton.tsx","./src/components/shared/directorypicker.tsx","./src/components/shared/dropdown.tsx","./src/components/shared/input.tsx","./src/components/shared/modal.tsx","./src/components/shared/projectcontextchip.tsx","./src/components/shared/spinner.tsx","./src/components/shared/textarea.tsx","./src/components/shared/toast.tsx","./src/components/tasks/newtaskmodal.tsx","./src/components/tasks/taskemptystate.tsx","./src/components/tasks/tasklist.tsx","./src/components/tasks/taskrow.tsx","./src/components/teams/backtoleader.tsx","./src/components/teams/membertag.tsx","./src/components/teams/teamstatusbar.tsx","./src/components/teams/transcriptview.tsx","./src/config/providerpresets.ts","./src/config/spinnerverbs.ts","./src/hooks/usekeyboardshortcuts.ts","./src/lib/desktopruntime.ts","./src/mocks/data.ts","./src/pages/activesession.tsx","./src/pages/agentteams.tsx","./src/pages/agenttranscript.tsx","./src/pages/emptysession.tsx","./src/pages/newtaskmodal.tsx","./src/pages/scheduledtasks.tsx","./src/pages/scheduledtasksempty.tsx","./src/pages/scheduledtaskslist.tsx","./src/pages/sessioncontrols.tsx","./src/pages/settings.tsx","./src/pages/toolinspection.tsx","./src/stores/chatstore.test.ts","./src/stores/chatstore.ts","./src/stores/clitaskstore.ts","./src/stores/providerstore.ts","./src/stores/sessionstore.ts","./src/stores/settingsstore.ts","./src/stores/taskstore.ts","./src/stores/teamstore.ts","./src/stores/uistore.ts","./src/types/chat.ts","./src/types/clitask.ts","./src/types/provider.ts","./src/types/session.ts","./src/types/settings.ts","./src/types/task.ts","./src/types/team.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/__tests__/pages.test.tsx","./src/api/adapters.ts","./src/api/clitasks.ts","./src/api/client.ts","./src/api/filesystem.ts","./src/api/models.ts","./src/api/providers.ts","./src/api/search.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/tasks.ts","./src/api/teams.ts","./src/api/websocket.ts","./src/components/chat/askuserquestion.tsx","./src/components/chat/assistantmessage.tsx","./src/components/chat/attachmentgallery.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/codeviewer.tsx","./src/components/chat/diffviewer.tsx","./src/components/chat/filesearchmenu.tsx","./src/components/chat/imagegallerymodal.tsx","./src/components/chat/inlinetasksummary.tsx","./src/components/chat/messagelist.tsx","./src/components/chat/permissiondialog.tsx","./src/components/chat/sessiontaskbar.tsx","./src/components/chat/streamingindicator.tsx","./src/components/chat/terminalchrome.tsx","./src/components/chat/thinkingblock.tsx","./src/components/chat/toolcallblock.tsx","./src/components/chat/toolcallgroup.tsx","./src/components/chat/toolresultblock.tsx","./src/components/chat/usermessage.tsx","./src/components/chat/chatblocks.test.tsx","./src/components/chat/clipboard.ts","./src/components/chat/composerutils.test.ts","./src/components/chat/composerutils.ts","./src/components/controls/modelselector.tsx","./src/components/controls/permissionmodeselector.tsx","./src/components/layout/appshell.tsx","./src/components/layout/contentrouter.tsx","./src/components/layout/projectfilter.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/statusbar.tsx","./src/components/layout/titlebar.tsx","./src/components/markdown/markdownrenderer.tsx","./src/components/shared/button.tsx","./src/components/shared/copybutton.tsx","./src/components/shared/directorypicker.tsx","./src/components/shared/dropdown.tsx","./src/components/shared/input.tsx","./src/components/shared/modal.tsx","./src/components/shared/projectcontextchip.tsx","./src/components/shared/spinner.tsx","./src/components/shared/textarea.tsx","./src/components/shared/toast.tsx","./src/components/tasks/dayofweekpicker.tsx","./src/components/tasks/newtaskmodal.tsx","./src/components/tasks/prompteditor.tsx","./src/components/tasks/taskemptystate.tsx","./src/components/tasks/tasklist.tsx","./src/components/tasks/taskrow.tsx","./src/components/tasks/taskrunspanel.tsx","./src/components/teams/backtoleader.tsx","./src/components/teams/membertag.tsx","./src/components/teams/teamstatusbar.tsx","./src/components/teams/transcriptview.tsx","./src/config/providerpresets.ts","./src/config/spinnerverbs.ts","./src/hooks/usekeyboardshortcuts.ts","./src/i18n/index.ts","./src/i18n/locales/en.ts","./src/i18n/locales/zh.ts","./src/lib/crondescribe.ts","./src/lib/desktopruntime.ts","./src/lib/parserunoutput.ts","./src/lib/__tests__/crondescribe.test.ts","./src/mocks/data.ts","./src/pages/activesession.tsx","./src/pages/adaptersettings.tsx","./src/pages/agentteams.tsx","./src/pages/agenttranscript.tsx","./src/pages/emptysession.tsx","./src/pages/newtaskmodal.tsx","./src/pages/scheduledtasks.tsx","./src/pages/scheduledtasksempty.tsx","./src/pages/scheduledtaskslist.tsx","./src/pages/sessioncontrols.tsx","./src/pages/settings.tsx","./src/pages/toolinspection.tsx","./src/stores/adapterstore.ts","./src/stores/chatstore.test.ts","./src/stores/chatstore.ts","./src/stores/clitaskstore.ts","./src/stores/providerstore.ts","./src/stores/sessionstore.ts","./src/stores/settingsstore.ts","./src/stores/taskstore.ts","./src/stores/teamstore.ts","./src/stores/uistore.ts","./src/types/adapter.ts","./src/types/chat.ts","./src/types/clitask.ts","./src/types/provider.ts","./src/types/session.ts","./src/types/settings.ts","./src/types/task.ts","./src/types/team.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index f1c5a149..ca2558aa 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -54,7 +54,16 @@ const zhSidebar = [ ], }, { - text: 'Channel 系统', + text: 'IM 接入', + collapsed: false, + items: [ + { text: '总览', link: '/im/' }, + { text: 'Telegram', link: '/im/telegram' }, + { text: '飞书', link: '/im/feishu' }, + ], + }, + { + text: 'Channel 源码研究', collapsed: false, items: [ { text: '概览', link: '/channel/' }, diff --git a/docs/channel/02-im-gateway-proposal.md b/docs/channel/02-im-gateway-proposal.md index e43b926c..f47db0d2 100644 --- a/docs/channel/02-im-gateway-proposal.md +++ b/docs/channel/02-im-gateway-proposal.md @@ -1,8 +1,9 @@ -# IM Gateway 方案设计 `[已采纳 — 轻量脚本方案]` +# IM Gateway 方案设计 `[历史设计稿]` > 像 OpenClaw 一样,让 Claude Code Desktop 快速接入任意 IM 平台 > -> **最终方案**:不使用 OpenClaw 框架,也不新增 Gateway 模块。每个 IM 平台一个 200-350 行的独立 Adapter 脚本,直连现有 `/ws/:sessionId` 接口,服务端零改动。详见 [`adapters/README.md`](../../adapters/README.md)。 +> 状态更新:当前实际可用的接入方式请看 [`docs/im/`](../im/)。 +> 本文保留为方案演进记录,不再作为接入说明。

背景 · diff --git a/docs/channel/index.md b/docs/channel/index.md index 3f0ff310..491ca71d 100644 --- a/docs/channel/index.md +++ b/docs/channel/index.md @@ -1,83 +1,47 @@ -# Claude Code Channel 系统文档 +# Channel 源码研究 -> 通过 IM 平台远程控制 Claude Code Agent 的完整技术解析 +> 这里讲的是 Claude Code 原生的 Channel / MCP 体系。 +> 如果你要配置当前桌面版的 Telegram / 飞书接入,请先看 [IM 接入文档](../im/)。 ---- +## 这组文档是干什么的 + +这个仓库当前实际可用的 IM 接入方案,是 `Desktop Webapp + adapters/* + /api/adapters + /ws/:sessionId`。 + +`docs/channel/` 保留的价值主要是: + +- 解释上游 Claude Code 的原生 Channel 机制 +- 记录历史上为什么没有直接沿用那套机制做当前 IM 接入 +- 作为后续架构演进时的参考资料 ## 文档目录 -### [01-channel-system.md](./01-channel-system.md) — Channel 系统架构解析 +### [01-channel-system.md](./01-channel-system.md) -从源码视角深度剖析 Claude Code Channel 系统的设计与实现,涵盖: +从源码视角分析 Claude Code 原始 Channel 系统,包括: -- **什么是 Channel**:IM 集成的核心概念、MCP 协议基础 -- **整体架构**:消息流转全链路、组件关系 -- **消息协议**:入站通知、XML 封装、出站工具调用 -- **六层访问控制**:能力声明 → 运行时开关 → OAuth 认证 → 组织策略 → 会话白名单 → 插件审批 -- **权限中继系统**:远程审批工具执行、5 字母请求 ID、多源竞争 -- **插件架构**:Channel 插件清单声明、用户配置流、作用域命名 -- **UI 组件**:终端消息渲染、状态通知、开发者警告对话框 -- **安全设计**:防 XML 注入、marketplace 验证、信任边界分析 +- Channel 的概念模型 +- MCP 通知和工具出入站协议 +- 六层门控与权限中继 +- Plugin Channel 的注册和安全边界 -**适合人群**:想了解 AI Agent IM 集成架构的开发者、架构师、插件作者 +### [02-im-gateway-proposal.md](./02-im-gateway-proposal.md) ---- +这是历史方案设计文档,记录了从 `IM Gateway` 设想演进到“独立 Adapter 直连 `/ws/:sessionId`”的过程。 -## 配图说明 +它适合回答: -所有配图采用深色背景(#1a1a2e)+ Anthropic 品牌橙铜色(#D97757)风格。 +- 为什么最后没有走完整 Gateway +- 为什么当前实现选择了 `adapters/*` +- 设计阶段曾经考虑过哪些替代方案 -| 图片 | 说明 | 所属文档 | -|------|------|----------| -| `01-channel-overview.png` | Channel 系统架构总览 — 组件关系全景 | 架构解析 | -| `02-message-flow.png` | 消息流转全链路 — IM → Agent → IM | 架构解析 | -| `03-access-control.png` | 六层访问控制 — 层层递进的安全门 | 架构解析 | -| `04-permission-relay.png` | 权限中继系统 — 远程审批流程 | 架构解析 | +## 相关入口 ---- +- [IM 接入总览](../im/) +- [Telegram 接入](../im/telegram) +- [飞书接入](../im/feishu) -## 快速开始 +## 适合谁看 -### 用户 - -1. 阅读 [Channel 系统架构解析](./01-channel-system.md) -2. 了解如何通过 `--channels` 启动 IM 集成 -3. 理解不同平台(Telegram、Feishu、Discord)的接入方式 - -### 插件开发者 - -1. 阅读架构解析中的 [插件架构](./01-channel-system.md#七插件-channel-架构) 章节 -2. 了解 `plugin.json` 中 Channel 声明格式 -3. 实现 MCP Server 的 `notifications/claude/channel` 协议 -4. 查看源码位置: - - `src/services/mcp/channelNotification.ts` — 核心门控与消息封装 - - `src/services/mcp/channelPermissions.ts` — 权限中继系统 - - `src/services/mcp/channelAllowlist.ts` — 白名单管理 - - `src/utils/plugins/mcpPluginIntegration.ts` — 插件 MCP 集成 - - `src/utils/plugins/schemas.ts` — 插件清单 Channel 声明 Schema - ---- - -## 核心概念速查 - -| 概念 | 说明 | -|------|------| -| **Channel** | 一个声明了 `claude/channel` 能力的 MCP Server,可推送 IM 消息到 Agent | -| **Channel Entry** | `--channels` 参数解析后的条目,分 plugin 和 server 两种 | -| **Channel Gate** | 六层访问控制门,决定是否注册通知处理器 | -| **Permission Relay** | 将工具执行审批提示转发到 IM 平台的机制 | -| **Channel Plugin** | 在 `plugin.json` 中声明 `channels` 字段的插件 | -| **Scoped Name** | 插件服务器的作用域名称,格式 `plugin:{pluginName}:{serverName}` | -| **Short Request ID** | 5 字母权限请求标识符,基于 FNV-1a 哈希生成 | -| **Channel Tag** | `` XML 标签,封装来自 IM 的消息内容和元数据 | -| **Dev Channels** | 通过 `--dangerously-load-development-channels` 加载的本地开发频道 | -| **tengu_harbor** | GrowthBook 运行时特性开关,控制 Channel 功能总开关 | - ---- - -## 相关资源 - -- [Claude Code Haha 主页](/) -- [Agent 框架解析](/agent/03-agent-framework) -- [Skills 系统文档](/skills/01-usage-guide) -- [GitHub Issues](https://github.com/NanmiCoder/cc-haha/issues) +- 想研究 Claude Code 原生 IM / Channel 思路的开发者 +- 想理解当前仓库 IM 实现为什么没有直接复用 Channel 的贡献者 +- 想做架构对比和二次设计的人 diff --git a/docs/im/feishu.md b/docs/im/feishu.md new file mode 100644 index 00000000..7ae31955 --- /dev/null +++ b/docs/im/feishu.md @@ -0,0 +1,197 @@ +# 飞书接入 + +> 飞书 Adapter 的实际接入说明,基于当前仓库代码。 + +## 适用场景 + +飞书方案适合在中国区环境下通过企业自建应用私聊 Claude Code。当前实现只处理 `p2p` 私聊,不处理群聊。 + +实现入口:`adapters/feishu/index.ts` + +## 启动前准备 + +### 1. 创建飞书应用 + +在飞书开放平台创建企业自建应用,拿到: + +- `App ID` +- `App Secret` + +### 2. 配权限 + +至少确认这些能力可用: + +- `im:message` +- `im:message:send_as_bot` +- `im:resource` + +### 3. 配事件订阅 + +当前实现使用飞书 WebSocket 长连接接事件,不需要公网回调地址。 + +需要注册的事件: + +- `im.message.receive_v1` +- `card.action.trigger` + +### 4. 发布应用 + +应用至少需要处于可用发布状态,否则 bot 无法正常收发消息。 + +## 在 Desktop Webapp 里配置 + +打开 `Settings -> IM 接入 -> 飞书`,填写: + +- `App ID` +- `App Secret` +- `Encrypt Key`,按你的事件配置填写 +- `Verification Token`,按你的事件配置填写 +- `Allowed Users`,可选 +- `Streaming Card` + +配置结构示例: + +```json +{ + "serverUrl": "ws://127.0.0.1:3456", + "defaultProjectDir": "/Users/me/workspace/my-project", + "feishu": { + "appId": "cli_xxx", + "appSecret": "xxx", + "encryptKey": "", + "verificationToken": "", + "allowedUsers": ["ou_xxx"], + "streamingCard": false + } +} +``` + +环境变量也可覆盖配置文件: + +```bash +export FEISHU_APP_ID="cli_xxx" +export FEISHU_APP_SECRET="xxx" +export FEISHU_ENCRYPT_KEY="" +export FEISHU_VERIFICATION_TOKEN="" +export ADAPTER_SERVER_URL="ws://127.0.0.1:3456" +``` + +## 启动 + +```bash +cd adapters +bun install +bun run feishu +``` + +## 首次配对 + +飞书侧的配对和 Telegram 一样,都是走 Webapp 生成的全局配对码。 + +流程: + +1. 在 Desktop Webapp 里生成 6 位配对码 +2. 飞书私聊 bot +3. 直接发送配对码 +4. `adapters/common/pairing.ts` 验证成功后,把当前 `open_id` 写进 `feishu.pairedUsers` + +注意: + +- 当前实现把飞书用户标识存成 `open_id` +- `allowedUsers` 也是按 `open_id` 比较 +- 码一次性使用,60 分钟过期 + +## 日常使用流程 + +### 首次消息 + +`adapters/feishu/index.ts` 处理逻辑: + +1. 通过长连接收到 `im.message.receive_v1` +2. 提取文本内容 +3. 只接受 `p2p` +4. 校验授权 +5. 没有 session 时: + - 先尝试恢复 `adapter-sessions.json` + - 否则按 `defaultProjectDir` 建 session + - 如果没默认项目,则列最近项目让用户回复编号 + +### 项目选择 + +没有默认项目时,adapter 会调用 `GET /api/sessions/recent-projects`,然后在飞书里发编号列表。 + +用户回复编号后: + +- adapter 调 `POST /api/sessions` +- 建立 `/ws/:sessionId` 连接 +- 把映射写入 `~/.claude/adapter-sessions.json` + +## 支持的命令 + +飞书当前支持文本命令和中文别名: + +- `/projects` 或 `项目列表` +- `/new` 或 `新会话` +- `/stop` 或 `停止` + +## 权限审批 + +飞书使用交互卡片,而不是 Telegram 的 inline button。 + +当 Claude 请求权限时: + +1. adapter 构造 interactive card +2. 用户点击允许/拒绝 +3. 飞书回调 `card.action.trigger` +4. adapter 调用 `bridge.sendPermissionResponse(...)` + +## 返回消息的表现 + +飞书侧也支持流式更新,但渲染方式和 Telegram 不同: + +- 普通文本通过 `post` 消息发送 +- 权限审批通过卡片发送 +- 流式内容优先 patch 同一条消息 +- 完成后按 30000 字左右分片 + +`streamingCard` 字段当前已经进入配置模型,但 `adapters/feishu/index.ts` 里实际消息渲染仍以文本 patch / card 为主,文档里不应把它写成一个已经成型的独立体验开关。 + +## 常见问题 + +### 收不到消息 + +优先检查: + +- App ID / App Secret 是否正确 +- 应用是否已发布 +- 是否启用了 `im.message.receive_v1` +- 是否启用了 `card.action.trigger` +- 是否真的是和 bot 的私聊,而不是群聊 + +### 权限按钮点了没反应 + +通常是 `card.action.trigger` 没配置好,或者事件订阅鉴权配置和本地 `encryptKey` / `verificationToken` 不匹配。 + +### 一直提示未授权 + +优先检查: + +- 配对码是否还在有效期 +- 发送的是不是当前有效码 +- `allowedUsers` 填的是不是 `open_id` +- `feishu.pairedUsers` 里是否已写入当前用户 + +### 会话没恢复 + +检查: + +- `~/.claude/adapter-sessions.json` 是否成功写入 +- Desktop server 里的 session 是否仍存在 + +## 源码入口 + +- `adapters/feishu/index.ts` +- `adapters/common/pairing.ts` +- `adapters/common/session-store.ts` +- `adapters/common/ws-bridge.ts` +- `adapters/common/http-client.ts` diff --git a/docs/im/index.md b/docs/im/index.md new file mode 100644 index 00000000..7e04f6bc --- /dev/null +++ b/docs/im/index.md @@ -0,0 +1,166 @@ +# IM 接入 + +> 当前可用的 IM 接入方案总览。 +> 如果你只是想把 Telegram 或飞书接进来,从这篇开始。 + +## 当前方案是什么 + +当前仓库里的 IM 接入,已经不是早期文档里设想的 “IM Gateway / MCP Channel 插件” 主路径。 + +现在真实在跑的是一条更轻的链路: + +```mermaid +flowchart TD + A["Desktop Webapp
Settings -> IM 接入"] --> B["GET / PUT /api/adapters"] + B --> C["Desktop Server
配置接口"] + C --> D["本地配置持久化"] + D --> E["~/.claude/adapters.json"] + + E --> F["Telegram / 飞书 Adapter 进程"] + F --> G["加载平台配置"] + F --> H["校验配对与授权"] + F --> I["读取 / 写入会话映射"] + I --> J["~/.claude/adapter-sessions.json"] + + F --> K{"当前 chatId
是否已有 session?"} + K -->|有历史映射| L["复用已有 sessionId"] + K -->|无历史映射| M{"是否配置
defaultProjectDir?"} + M -->|是| N["POST /api/sessions
创建新 session"] + M -->|否| O["GET /api/sessions/recent-projects
让用户选择项目"] + O --> P["用户回复编号"] + P --> N + + N --> Q["获得 sessionId"] + L --> R["连接 /ws/:sessionId"] + Q --> R + + R --> S["adapters/common/ws-bridge.ts"] + S --> T["Desktop Server"] + T --> U["Claude Code session / CLI 子进程"] + + U --> V["流式消息 / 权限请求 / 状态事件"] + V --> S + S --> F + F --> W["Telegram / 飞书用户"] +``` + +可以把这条链路理解成四层: + +- 配置层:桌面端 webapp 负责填写平台凭据、默认项目和配对码管理 +- 存储层:本地服务端把配置写入 `~/.claude/adapters.json` +- 适配层:Telegram / 飞书 adapter 进程负责接 IM 平台、做授权检查、恢复或创建会话 +- 会话层:adapter 通过 HTTP 创建 session,再通过 WebSocket 把 IM 消息桥接到 Claude Code 会话 + +## 用户怎么用 + +### 1. 在 Desktop Webapp 里配置 + +打开桌面端设置页的 `IM 接入` 标签,填写: + +- `serverUrl` +- `defaultProjectDir` +- Telegram 或飞书各自的凭据 +- 可选 `allowedUsers` + +这里的配置会通过 `GET /api/adapters` 和 `PUT /api/adapters` 读写到 `~/.claude/adapters.json`。 + +### 2. 生成配对码 + +配对也在同一个设置页完成: + +- 点击“生成配对码” +- 前端生成 6 位码并写入 `pairing.code / expiresAt / createdAt` +- 码有效期 60 分钟 +- 配对成功后立即失效 + +配对码是平台无关的,同一个码可以在 Telegram 或飞书私聊里使用一次。 + +### 3. 启动对应 Adapter 进程 + +Webapp 负责配置和配对,不负责代你启动 bot 进程。当前仍需要手动启动: + +```bash +cd adapters +bun install +bun run telegram +# 或 +bun run feishu +``` + +### 4. 在 IM 里私聊 Bot + +- 未配对用户:先把配对码发给 bot +- 已配对用户:直接发送自然语言消息 +- 没有默认项目时:bot 会先让你从最近项目里选一个 +- 后续消息会复用同一个 Claude session + +## 配置和状态分别存哪 + +### `~/.claude/adapters.json` + +保存平台配置和授权状态,包括: + +- `serverUrl` +- `defaultProjectDir` +- `pairing` +- `telegram` +- `feishu` + +其中: + +- 敏感字段会在 API 返回时被脱敏 +- 配对码也会被 `/api/adapters` 返回值掩码为 `******` + +### `~/.claude/adapter-sessions.json` + +保存 IM chat 到 Claude session 的映射: + +- `chatId` +- `sessionId` +- `workDir` +- `updatedAt` + +这让 bot 重启后仍然能接回原来的会话。 + +## 当前安全模型 + +授权规则是: + +- `allowedUsers` 和 `pairedUsers` 取并集 +- 两者都为空时,默认拒绝访问 +- 配对码为 6 位安全字符集 +- 配对码一次性使用 +- 同一用户 5 分钟内最多失败 5 次 + +这和旧 README 里“`allowedUsers` 为空就允许所有人”的说法已经不同,旧说法已过时。 + +## 会话行为 + +Adapter 不是直接把消息丢给一个全局 Claude 进程,而是: + +1. 先用 `POST /api/sessions` 创建 session +2. 再用 `ws://.../ws/:sessionId` 建立桥接 +3. 把 IM 消息转成: + - `user_message` + - `permission_response` + - `stop_generation` +4. 把服务端流式消息再格式化回 IM + +如果没有 `defaultProjectDir`,Adapter 会调用 `GET /api/sessions/recent-projects` 让用户先选项目。 + +## 平台差异 + +- Telegram:`grammy`,按钮审批,纯私聊模式 +- 飞书:`@larksuiteoapi/node-sdk`,长连接事件订阅,交互卡片审批,当前只处理 `p2p` + +分别看: + +- [Telegram 接入](./telegram.md) +- [飞书接入](./feishu.md) + +## 和 `docs/channel/` 的关系 + +`docs/channel/` 主要是 Claude Code 原生 Channel/MCP 体系的源码研究资料,不是这个仓库当前推荐的 IM 接入方式。 + +如果你是要“把 bot 真跑起来”,看本目录。 +如果你是要研究 Claude Code 原始 Channel 设计,再去看 `docs/channel/`。 diff --git a/docs/im/telegram.md b/docs/im/telegram.md new file mode 100644 index 00000000..8a61acb3 --- /dev/null +++ b/docs/im/telegram.md @@ -0,0 +1,172 @@ +# Telegram 接入 + +> Telegram Adapter 的实际接入说明,基于当前仓库代码,而不是旧 README。 + +## 适用场景 + +Telegram 方案适合个人私聊远程使用。当前实现只处理 `private chat`,不处理群聊。 + +实现入口:`adapters/telegram/index.ts` + +## 启动前准备 + +### 1. 创建 Bot + +在 Telegram 里找 `@BotFather`: + +1. 发送 `/newbot` +2. 按提示创建 bot +3. 拿到 `Bot Token` + +### 2. 在 Desktop Webapp 里填写配置 + +打开 `Settings -> IM 接入 -> Telegram`,填写: + +- `Bot Token` +- `Allowed Users`,可选 +- 全局 `Default Project` + +也可以直接写 `~/.claude/adapters.json`,但当前推荐从 Webapp 配。 + +配置结构示例: + +```json +{ + "serverUrl": "ws://127.0.0.1:3456", + "defaultProjectDir": "/Users/me/workspace/my-project", + "telegram": { + "botToken": "123456:ABC-DEF...", + "allowedUsers": [123456789] + } +} +``` + +环境变量优先级更高: + +```bash +export TELEGRAM_BOT_TOKEN="123456:ABC-DEF..." +export ADAPTER_SERVER_URL="ws://127.0.0.1:3456" +``` + +## 启动 + +```bash +cd adapters +bun install +bun run telegram +``` + +## 首次配对 + +Telegram 侧的授权入口不再只依赖 `allowedUsers`。 + +当前真实逻辑: + +1. 在 Desktop Webapp 里生成配对码 +2. 去 Telegram 私聊 bot +3. 把那 6 位码直接发给 bot +4. adapter 调用 `tryPair(...)` +5. 配对成功后,把当前 Telegram user 写进 `telegram.pairedUsers` + +特点: + +- 配对码 60 分钟过期 +- 配对成功后立即失效 +- 和飞书共用同一枚全局配对码 +- 连续输错有速率限制 + +## 日常使用流程 + +### 首次消息 + +`adapters/telegram/index.ts` 的流程是: + +1. 校验是否私聊 +2. 去重 +3. 校验是否已授权 +4. 没有 session 时: + - 优先恢复 `adapter-sessions.json` 里的旧 session + - 否则用 `defaultProjectDir` 创建新 session + - 如果没配默认项目,则拉最近项目列表让你选 + +### 项目选择 + +当没有默认项目时,bot 会调用 `GET /api/sessions/recent-projects`,返回最近项目列表,并要求你回复编号。 + +选中后: + +- adapter 用 `POST /api/sessions` 创建 session +- 把 `chatId -> sessionId -> workDir` 写进 `~/.claude/adapter-sessions.json` +- 后续消息继续复用 + +## 支持的命令 + +### `/start` + +显示帮助和可用命令。 + +### `/projects` + +切换项目,重新显示最近项目列表。 + +### `/new` + +清空当前 chat 绑定的 session,并重新选择项目。 + +### `/stop` + +向当前 session 发送 `stop_generation`。 + +## 权限审批 + +当 Claude 需要敏感权限时,Telegram adapter 会发带按钮的消息: + +- `✅ 允许` +- `❌ 拒绝` + +点击后,adapter 会把结果通过 `permission_response` 发回 Desktop server。 + +## 返回消息的表现 + +Telegram 侧有一层流式缓冲: + +- thinking 时先发占位消息 +- text delta 逐步累积 +- 完成时按 4000 字分片发送 + +对应公共模块: + +- `adapters/common/message-buffer.ts` +- `adapters/common/format.ts` +- `adapters/common/ws-bridge.ts` + +## 常见问题 + +### bot 启动时报缺少 token + +说明 `TELEGRAM_BOT_TOKEN` 和 `~/.claude/adapters.json` 里的 `telegram.botToken` 都没有生效。 + +### 能打开设置页但 bot 不工作 + +Webapp 只负责配置,不会自动拉起 `bun run telegram`。 + +### 发消息提示未授权 + +优先检查: + +- 是否已经在 Webapp 里生成配对码 +- 配对码是否过期 +- 是否把码发到了正确的 bot 私聊 +- `allowedUsers` / `pairedUsers` 是否真的包含当前账号 + +### 每次重启后会话丢失 + +检查 `~/.claude/adapter-sessions.json` 是否能正常写入,以及 Desktop server 的 session 是否仍存在。 + +## 源码入口 + +- `adapters/telegram/index.ts` +- `adapters/common/pairing.ts` +- `adapters/common/session-store.ts` +- `adapters/common/ws-bridge.ts` +- `adapters/common/http-client.ts` diff --git a/docs/images/logo-horizontal.jpg b/docs/images/logo-horizontal.jpg index 3ea5a21a..859b0eac 100644 Binary files a/docs/images/logo-horizontal.jpg and b/docs/images/logo-horizontal.jpg differ diff --git a/docs/index.md b/docs/index.md index aa5d7fde..8f53d862 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,6 +36,10 @@ features: title: 第三方模型支持 details: 接入 OpenAI、DeepSeek、Ollama 等任意兼容模型 link: /guide/third-party-models + - icon: "\U0001F4AC" + title: IM 接入 + details: 在桌面端 webapp 配置 Telegram / 飞书,并通过独立 adapter 进程远程对话 Claude Code + link: /im/ - icon: "\U0001F4BB" title: Computer Use details: 桌面控制功能 — 截屏、鼠标、键盘操作(Python Bridge 实现) diff --git a/docs/superpowers/plans/2026-04-06-chat-ui-overhaul.md b/docs/superpowers/plans/2026-04-06-chat-ui-overhaul.md deleted file mode 100644 index 837f622b..00000000 --- a/docs/superpowers/plans/2026-04-06-chat-ui-overhaul.md +++ /dev/null @@ -1,988 +0,0 @@ -# Chat UI Overhaul — Bug Fix, Feature Optimization, and UI Clone - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Overhaul the desktop chat UI to match the original Claude Code's clean, compact design — fix CSS bugs in code blocks, add tool-call grouping/collapsing, and streamline all message components. - -**Architecture:** Three-phase approach: (1) Fix CSS bugs in CodeViewer/DiffViewer/MarkdownRenderer for compact code blocks, (2) Implement ToolCallGroup component to collapse consecutive tool calls into summary lines like the original ("Read 7 files, ran a command"), (3) Streamline AssistantMessage, ThinkingBlock, and PermissionDialog to reduce visual noise and vertical space. - -**Tech Stack:** React 18, Tailwind CSS 4, TypeScript, highlight.js, marked, diff - ---- - -## File Map - -| Action | File | Responsibility | -|--------|------|----------------| -| Modify | `desktop/src/components/chat/CodeViewer.tsx` | Fix line height, padding, line number styling | -| Modify | `desktop/src/components/chat/DiffViewer.tsx` | Fix line height, add gutter indicators | -| Modify | `desktop/src/components/markdown/MarkdownRenderer.tsx` | Sync code block styles with CodeViewer fixes | -| Create | `desktop/src/components/chat/ToolCallGroup.tsx` | New component: collapsible group of tool calls | -| Modify | `desktop/src/components/chat/MessageList.tsx` | Group consecutive tool calls, use ToolCallGroup | -| Modify | `desktop/src/components/chat/ToolCallBlock.tsx` | Add compact mode, simplify badge display | -| Modify | `desktop/src/components/chat/AssistantMessage.tsx` | Remove Copy button reserved space, use absolute positioning | -| Modify | `desktop/src/components/chat/ThinkingBlock.tsx` | Reduce nesting, improve preview | -| Modify | `desktop/src/components/chat/StreamingIndicator.tsx` | Remove elapsed seconds display | -| Modify | `desktop/src/theme/globals.css` | Add code-block CSS variables | - ---- - -### Task 1: Fix CodeViewer Line Height and Padding - -**Files:** -- Modify: `desktop/src/components/chat/CodeViewer.tsx:57-78` - -- [ ] **Step 1: Fix line height from 1.45 to 1.3** - -In `CodeViewer.tsx`, line 57, change the container class: - -```tsx -

-``` - -- [ ] **Step 2: Reduce line padding from py-1 to py-px** - -In `CodeViewer.tsx`, line 66, change the line number span padding: - -```tsx - -``` - -In `CodeViewer.tsx`, line 73, change the code content span padding: - -```tsx - -``` - -- [ ] **Step 3: Soften border colors and remove per-line borders** - -In `CodeViewer.tsx`, line 61-63, simplify the row div: - -```tsx -
-``` - -Remove the `border-b border-[#d8dee4]` from each line — the per-line border creates visual noise and adds height. - -- [ ] **Step 4: Soften the outer container and header** - -In `CodeViewer.tsx`, line 44, change `rounded-2xl` to `rounded-lg` and soften border: - -```tsx -
-``` - -In `CodeViewer.tsx`, line 45, reduce header padding from `py-2` to `py-1.5`: - -```tsx -
-``` - -- [ ] **Step 5: Fix the expand button colors (currently broken — white text on light bg)** - -In `CodeViewer.tsx`, line 84, fix the expand toggle button: - -```tsx - -
-
-
${body}
-
-
- ` -} -``` - -Key changes synced with CodeViewer: -- `leading-[1.45]` → `leading-[1.3]` -- `py-1` → `py-px` -- `border-b border-[#d8dee4]` removed from rows -- `bg-[#f6f8fa]` → `bg-[#fafbfc]` for line numbers -- `text-[#57606a]` → `text-[#8b949e]` for line numbers -- `rounded-2xl` → `rounded-lg` -- Added `hover:bg-[#f6f8fa]/50` to rows - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/components/markdown/MarkdownRenderer.tsx -git commit -m "fix: sync markdown code blocks with CodeViewer compact styles" -``` - ---- - -### Task 4: Create ToolCallGroup Component - -**Files:** -- Create: `desktop/src/components/chat/ToolCallGroup.tsx` - -- [ ] **Step 1: Create the ToolCallGroup component** - -Create `desktop/src/components/chat/ToolCallGroup.tsx`: - -```tsx -import { useState } from 'react' -import { ToolCallBlock } from './ToolCallBlock' -import type { UIMessage } from '../../types/chat' - -type ToolCall = Extract -type ToolResult = Extract - -type Props = { - toolCalls: ToolCall[] - resultMap: Map - /** When true, the last tool is still executing — show expanded */ - isStreaming?: boolean -} - -const TOOL_VERBS: Record string> = { - Read: (n) => `Read ${n} file${n > 1 ? 's' : ''}`, - Write: (n) => `created ${n > 1 ? `${n} files` : 'a file'}`, - Edit: (n) => `edited ${n > 1 ? `${n} files` : 'a file'}`, - Bash: (n) => `ran ${n > 1 ? `${n} commands` : 'a command'}`, - Glob: (n) => `found files`, - Grep: (n) => `searched ${n > 1 ? `${n} patterns` : 'code'}`, - Agent: (n) => `dispatched ${n > 1 ? `${n} agents` : 'an agent'}`, - WebSearch: (n) => `searched the web`, - WebFetch: (n) => `fetched ${n > 1 ? `${n} pages` : 'a page'}`, -} - -function generateSummary(toolCalls: ToolCall[]): string { - const counts = new Map() - for (const tc of toolCalls) { - counts.set(tc.toolName, (counts.get(tc.toolName) ?? 0) + 1) - } - - const parts: string[] = [] - for (const [name, count] of counts) { - const verbFn = TOOL_VERBS[name] - parts.push(verbFn ? verbFn(count) : `${name} (${count})`) - } - - return parts.join(', ') -} - -function hasErrors(toolCalls: ToolCall[], resultMap: Map): boolean { - return toolCalls.some((tc) => { - const result = resultMap.get(tc.toolUseId) - return result?.isError - }) -} - -export function ToolCallGroup({ toolCalls, resultMap, isStreaming }: Props) { - // Single tool call — render directly without group wrapper - if (toolCalls.length === 1) { - const tc = toolCalls[0] - const result = resultMap.get(tc.toolUseId) - return ( - - ) - } - - const [expanded, setExpanded] = useState(false) - const summary = generateSummary(toolCalls) - const errorPresent = hasErrors(toolCalls, resultMap) - const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId)) - - return ( -
- - - {expanded && ( -
- {toolCalls.map((tc) => { - const result = resultMap.get(tc.toolUseId) - return ( - - ) - })} -
- )} -
- ) -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/components/chat/ToolCallGroup.tsx -git commit -m "feat: add ToolCallGroup component for collapsible tool call summaries" -``` - ---- - -### Task 5: Add Compact Mode to ToolCallBlock - -**Files:** -- Modify: `desktop/src/components/chat/ToolCallBlock.tsx:7-114` - -- [ ] **Step 1: Add `compact` prop and adjust rendering** - -In `ToolCallBlock.tsx`, add `compact` to the Props type (line 7-11): - -```tsx -type Props = { - toolName: string - input: unknown - result?: { content: unknown; isError: boolean } | null - compact?: boolean -} -``` - -Update the component signature (line 34): - -```tsx -export function ToolCallBlock({ toolName, input, result, compact = false }: Props) { -``` - -- [ ] **Step 2: Adjust the outer container for compact mode** - -In `ToolCallBlock.tsx`, update the outer div (line 53): - -```tsx -
-``` - -Key changes: -- `rounded-xl` → `rounded-lg` -- `mb-2 ml-10` only when not compact (ToolCallGroup handles margins) -- Border opacity reduced - -- [ ] **Step 3: Simplify the summary row — reduce from 6 info layers to 3** - -In `ToolCallBlock.tsx`, replace lines 62-105 (the button contents) with a cleaner layout: - -```tsx - -``` - -Key changes: -- Removed the standalone SUCCESS/MODIFIED/READ/EXECUTED badges for non-error states -- Simplified to single-line: `icon + toolName + filePath/summary + outputSummary + expand` -- Only ERROR badge shown for errors -- Reduced padding from `py-2.5` to `py-2` -- Removed the nested divs and multi-line layout - -- [ ] **Step 4: Remove unused badge constant** - -In `ToolCallBlock.tsx`, remove the `STATUS_BADGE` constant (lines 27-32) since we no longer show per-tool status badges: - -```tsx -// DELETE these lines: -// const STATUS_BADGE: Record = { -// Edit: { ... }, -// Write: { ... }, -// Read: { ... }, -// Bash: { ... }, -// } -``` - -Also remove the `badge` variable computation (lines 42-46). - -- [ ] **Step 5: Commit** - -```bash -git add desktop/src/components/chat/ToolCallBlock.tsx -git commit -m "feat: add compact mode to ToolCallBlock, simplify to single-line layout" -``` - ---- - -### Task 6: Update MessageList to Group Tool Calls - -**Files:** -- Modify: `desktop/src/components/chat/MessageList.tsx` - -- [ ] **Step 1: Add grouping logic and import ToolCallGroup** - -Replace the entire `MessageList.tsx` with: - -```tsx -import { useRef, useEffect } from 'react' -import { useChatStore } from '../../stores/chatStore' -import { UserMessage } from './UserMessage' -import { AssistantMessage } from './AssistantMessage' -import { ThinkingBlock } from './ThinkingBlock' -import { ToolCallBlock } from './ToolCallBlock' -import { ToolCallGroup } from './ToolCallGroup' -import { ToolResultBlock } from './ToolResultBlock' -import { PermissionDialog } from './PermissionDialog' -import { AskUserQuestion } from './AskUserQuestion' -import { StreamingIndicator } from './StreamingIndicator' -import type { UIMessage } from '../../types/chat' - -type ToolCall = Extract -type ToolResult = Extract - -/** - * Group consecutive tool_use messages together. - * A group breaks when a non-tool message (assistant_text, thinking, etc.) appears. - * Returns an array of render items — either a group or a single message. - */ -type RenderItem = - | { kind: 'tool_group'; toolCalls: ToolCall[]; id: string } - | { kind: 'message'; message: UIMessage } - -function buildRenderItems(messages: UIMessage[], toolUseIds: Set): RenderItem[] { - const items: RenderItem[] = [] - let pendingToolCalls: ToolCall[] = [] - - const flushGroup = () => { - if (pendingToolCalls.length > 0) { - items.push({ - kind: 'tool_group', - toolCalls: [...pendingToolCalls], - id: `group-${pendingToolCalls[0].id}`, - }) - pendingToolCalls = [] - } - } - - for (const msg of messages) { - // Skip tool_results that will be rendered inside their ToolCallBlock/Group - if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) { - continue - } - - if (msg.type === 'tool_use') { - // Skip AskUserQuestion — it renders separately - if (msg.toolName === 'AskUserQuestion') { - flushGroup() - items.push({ kind: 'message', message: msg }) - } else { - pendingToolCalls.push(msg) - } - } else { - flushGroup() - items.push({ kind: 'message', message: msg }) - } - } - - flushGroup() - return items -} - -export function MessageList() { - const { messages, chatState, streamingText, activeThinkingId } = useChatStore() - const bottomRef = useRef(null) - - useEffect(() => { - bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' }) - }, [messages.length, streamingText]) - - // Build lookup maps - const toolUseIds = new Set() - const toolResultMap = new Map() - - for (const msg of messages) { - if (msg.type === 'tool_use') { - toolUseIds.add(msg.toolUseId) - } - if (msg.type === 'tool_result' && msg.toolUseId) { - toolResultMap.set(msg.toolUseId, msg) - } - } - - const renderItems = buildRenderItems(messages, toolUseIds) - - return ( -
-
- {renderItems.map((item) => { - if (item.kind === 'tool_group') { - return ( - !toolResultMap.has(tc.toolUseId)) - } - /> - ) - } - - const msg = item.message - return ( - - ) - })} - - {streamingText && chatState === 'streaming' && ( - - )} - - {chatState !== 'idle' && chatState !== 'streaming' && chatState !== 'permission_pending' && ( - - )} - -
-
-
- ) -} - -function MessageBlock({ - message, - activeThinkingId, - toolResult, -}: { - message: UIMessage - activeThinkingId: string | null - toolResult?: { content: unknown; isError: boolean } | null -}) { - switch (message.type) { - case 'user_text': - return - case 'assistant_text': - return - case 'thinking': - return - case 'tool_use': - if (message.toolName === 'AskUserQuestion') { - return ( - - ) - } - return ( - - ) - case 'tool_result': - return ( - - ) - case 'permission_request': - return ( - - ) - case 'error': - return ( -
- Error: {message.message} -
- ) - case 'system': - return ( -
- {message.content} -
- ) - } -} -``` - -Key changes: -- Added `buildRenderItems()` to group consecutive `tool_use` messages -- `ToolCallGroup` renders groups of 2+ tool calls as a collapsible summary -- Single tool calls still render as individual `ToolCallBlock` -- `tool_result` messages are passed via `toolResultMap` to groups -- Simplified error/system message margins to `mb-3` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/components/chat/MessageList.tsx -git commit -m "feat: group consecutive tool calls into collapsible summaries" -``` - ---- - -### Task 7: Streamline AssistantMessage - -**Files:** -- Modify: `desktop/src/components/chat/AssistantMessage.tsx` - -- [ ] **Step 1: Remove Copy button reserved space — use absolute positioning** - -Replace the entire `AssistantMessage.tsx`: - -```tsx -import { MarkdownRenderer } from '../markdown/MarkdownRenderer' -import { useState } from 'react' - -type Props = { - content: string - isStreaming?: boolean -} - -export function AssistantMessage({ content, isStreaming }: Props) { - const [copied, setCopied] = useState(false) - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(content) - setCopied(true) - window.setTimeout(() => setCopied(false), 1500) - } catch { - setCopied(false) - } - } - - return ( -
- {/* Copy button — absolute positioned, no reserved space */} - {!isStreaming && content.trim() && ( - - )} -
- - {isStreaming && ( - - )} -
-
- ) -} -``` - -Key changes: -- Removed the avatar (saves 28px + 12px gap = 40px horizontal space) -- `mb-5` → `mb-3` (consistent with other blocks) -- Copy button is now `absolute -right-1 -top-1` — zero reserved space -- Removed the flex layout with avatar, now uses `ml-10` to match tool call alignment -- Simplified structure from 3 nested divs to 2 - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/components/chat/AssistantMessage.tsx -git commit -m "fix: remove assistant avatar and copy-button reserved space" -``` - ---- - -### Task 8: Streamline ThinkingBlock - -**Files:** -- Modify: `desktop/src/components/chat/ThinkingBlock.tsx` - -- [ ] **Step 1: Reduce nesting and improve preview** - -Replace the entire `ThinkingBlock.tsx`: - -```tsx -import { useState, useEffect, useRef } from 'react' - -export function ThinkingBlock({ content, isActive = false }: { content: string; isActive?: boolean }) { - const [expanded, setExpanded] = useState(false) - const contentRef = useRef(null) - - useEffect(() => { - if (expanded && isActive && contentRef.current) { - contentRef.current.scrollTop = contentRef.current.scrollHeight - } - }, [content, expanded, isActive]) - - // Preview: take first meaningful line, not first 140 chars - const lines = content.split('\n').filter((l) => l.trim()) - const firstLine = lines[0]?.replace(/\s+/g, ' ').trim() || '' - const preview = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine - - return ( -
- - - {expanded && ( -
- {content} - {isActive && expanded && } -
- )} -
- ) -} - -const thinkingStyles = ` -@keyframes thinking-cursor-blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } -} -@keyframes thinking-dots { - 0%, 20% { content: ''; } - 40% { content: '.'; } - 60% { content: '..'; } - 80%, 100% { content: '...'; } -} -.thinking-cursor { - display: inline-block; - width: 2px; - height: 1em; - background: var(--color-text-tertiary); - vertical-align: middle; - margin-left: 1px; - animation: thinking-cursor-blink 1s step-end infinite; -} -.thinking-inline-cursor { - display: inline-block; - width: 1px; - height: 0.95em; - margin-left: 3px; - vertical-align: text-bottom; - background: var(--color-text-tertiary); - animation: thinking-cursor-blink 1s step-end infinite; -} -.thinking-dots::after { - content: ''; - animation: thinking-dots 1.4s steps(1, end) infinite; -} -` -``` - -Key changes: -- Removed the `rounded-full` expand icon — just use a simple triangle character -- Removed 1 layer of nesting in the expanded state (was: outer div → inner div → content; now: outer div → content div) -- `rounded-2xl` → `rounded-lg` -- `mb-2` → `mb-1` (more compact) -- `max-h-[220px]` → `max-h-[300px]` (give more room when expanded) -- Preview now takes the first line instead of first 140 chars (more meaningful) -- `leading-[1.45]` → `leading-[1.35]` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/components/chat/ThinkingBlock.tsx -git commit -m "fix: streamline ThinkingBlock — less nesting, better preview" -``` - ---- - -### Task 9: Simplify StreamingIndicator - -**Files:** -- Modify: `desktop/src/components/chat/StreamingIndicator.tsx` - -- [ ] **Step 1: Remove elapsed seconds, keep only essential info** - -Replace `StreamingIndicator.tsx`: - -```tsx -import { useChatStore } from '../../stores/chatStore' - -export function StreamingIndicator() { - const { chatState } = useChatStore() - - const verb = chatState === 'thinking' - ? 'Thinking' - : chatState === 'tool_executing' - ? 'Running' - : 'Working' - - return ( -
- - {verb}... -
- ) -} -``` - -Key changes: -- Removed `elapsedSeconds` display (users don't need per-second counts) -- Removed `tokenUsage` display (noise) -- `mb-4` → `mb-2`, `py-1.5` → `py-1` -- `text-sm` → `text-xs` (more compact) -- Simplified verb labels - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/components/chat/StreamingIndicator.tsx -git commit -m "fix: simplify StreamingIndicator — remove elapsed time and token count" -``` - ---- - -### Task 10: Final Visual Verification - -- [ ] **Step 1: Start the dev server** - -Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm run dev` - -- [ ] **Step 2: Run the TypeScript compiler to check for type errors** - -Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit` - -Expected: No type errors. - -- [ ] **Step 3: Run existing tests** - -Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm test -- --run` - -Expected: All tests pass. - -- [ ] **Step 4: Verify the chat UI visually** - -Check these items in the running app: -1. **Code blocks:** Lines are tight (~16px/line), no per-line borders, line numbers are subdued -2. **Diff blocks:** Compact rows with green/red gutter indicators -3. **Tool calls:** 2+ consecutive tool calls show as "Read 3 files, ran a command" summary line -4. **Single tool call:** Still shows as individual ToolCallBlock -5. **Thinking:** Single-line with first-line preview, click expands to scrollable content -6. **Assistant messages:** No avatar, copy button appears on hover without reserving space -7. **Streaming indicator:** Compact, no elapsed time counter - -- [ ] **Step 5: Final commit if any fixes needed** - -```bash -git add -A -git commit -m "fix: address visual polish issues found during verification" -``` diff --git a/docs/superpowers/plans/2026-04-06-prism-diff-migration.md b/docs/superpowers/plans/2026-04-06-prism-diff-migration.md deleted file mode 100644 index 9db15a10..00000000 --- a/docs/superpowers/plans/2026-04-06-prism-diff-migration.md +++ /dev/null @@ -1,601 +0,0 @@ -# Migrate to prism-react-renderer + react-diff-viewer-continued - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace highlight.js and hand-rolled DiffViewer with prism-react-renderer and react-diff-viewer-continued for GitHub-quality code blocks and diff rendering. - -**Architecture:** (1) Install new deps and remove old ones, (2) Rewrite CodeViewer using prism-react-renderer's Highlight component with render props, (3) Rewrite DiffViewer using react-diff-viewer-continued with syntax highlighting via prism, (4) Update MarkdownRenderer to use a React-based code block instead of HTML strings, (5) Delete highlightCode.ts and hljs CSS. - -**Tech Stack:** prism-react-renderer (Highlight + themes.github), react-diff-viewer-continued (ReactDiffViewer), React 18, Tailwind CSS 4 - ---- - -## File Map - -| Action | File | Responsibility | -|--------|------|----------------| -| Modify | `desktop/package.json` | Add prism-react-renderer + react-diff-viewer-continued, remove highlight.js + diff | -| Rewrite | `desktop/src/components/chat/CodeViewer.tsx` | Pure React code blocks via prism-react-renderer | -| Rewrite | `desktop/src/components/chat/DiffViewer.tsx` | GitHub PR-style diffs via react-diff-viewer-continued | -| Rewrite | `desktop/src/components/markdown/MarkdownRenderer.tsx` | Use CodeViewer React component instead of HTML strings for code blocks | -| Delete | `desktop/src/components/chat/highlightCode.ts` | No longer needed | -| Modify | `desktop/src/theme/globals.css` | Remove .hljs-* CSS rules | - -**Consumers that import these files (no changes needed to their code):** -- `ToolCallBlock.tsx` imports `CodeViewer` and `DiffViewer` — same Props interface, no changes -- `ToolResultBlock.tsx` imports `CodeViewer` — same Props interface, no changes -- `PermissionDialog.tsx` imports `DiffViewer` — same Props interface, no changes - ---- - -### Task 1: Install dependencies - -**Files:** -- Modify: `desktop/package.json` - -- [ ] **Step 1: Install new dependencies** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm install prism-react-renderer react-diff-viewer-continued -``` - -- [ ] **Step 2: Remove old dependencies** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm uninstall highlight.js @types/diff diff -``` - -Note: `diff` was only used by `DiffViewer.tsx`. `highlight.js` was only used by `CodeViewer.tsx` and `highlightCode.ts`. - -- [ ] **Step 3: Verify no broken imports** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit 2>&1 | head -20 -``` - -Expected: Errors about missing `highlight.js` and `diff` modules (since files still import them). This is fine — we fix those in subsequent tasks. - -- [ ] **Step 4: Commit** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha && git add desktop/package.json desktop/package-lock.json && git commit -m "chore: add prism-react-renderer + react-diff-viewer-continued, remove highlight.js + diff" -``` - ---- - -### Task 2: Rewrite CodeViewer with prism-react-renderer - -**Files:** -- Rewrite: `desktop/src/components/chat/CodeViewer.tsx` - -- [ ] **Step 1: Replace the entire CodeViewer.tsx** - -```tsx -import { useState } from 'react' -import { Highlight, themes } from 'prism-react-renderer' -import { CopyButton } from '../shared/CopyButton' - -type Props = { - code: string - language?: string - maxLines?: number - showLineNumbers?: boolean -} - -export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = true }: Props) { - const [expanded, setExpanded] = useState(false) - - const allLines = code.split('\n') - const isTruncated = !expanded && allLines.length > maxLines - const visibleCode = isTruncated ? allLines.slice(0, maxLines).join('\n') : code - - // Only show line numbers for known languages (actual code). - // Plain text, file trees, command output look better without them. - const effectiveShowLineNumbers = showLineNumbers && !!language && language !== 'text' - - const languageLabel = language || 'code' - const lineCountLabel = `${allLines.length} ${allLines.length === 1 ? 'line' : 'lines'}` - const showExpandToggle = allLines.length > maxLines - - return ( -
-
-
- {languageLabel} - {lineCountLabel} -
- -
- -
- - {({ tokens, getLineProps, getTokenProps }) => ( -
-              {tokens.map((line, i) => {
-                const lineProps = getLineProps({ line })
-                return effectiveShowLineNumbers ? (
-                  
- - {i + 1} - - - {line.map((token, key) => ( - - ))} - -
- ) : ( -
- - {line.map((token, key) => ( - - ))} - -
- ) - })} -
- )} -
-
- - {showExpandToggle && ( - - )} -
- ) -} -``` - -Key changes: -- `highlight.js` replaced with `prism-react-renderer`'s `Highlight` component -- Pure React element rendering via `getTokenProps` — no `dangerouslySetInnerHTML` -- `themes.github` provides GitHub Light theme -- `language="text"` for unknown languages (no auto-detection, no colors) -- `style={undefined}` on line divs to prevent prism-react-renderer's default inline styles from overriding our Tailwind classes -- Same Props interface — all consumers (ToolCallBlock, ToolResultBlock) work unchanged - -- [ ] **Step 2: Verify TypeScript compiles (may still fail on other files)** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit 2>&1 | grep -v "highlightCode\|from 'diff'" -``` - -Expected: CodeViewer.tsx compiles without errors. - -- [ ] **Step 3: Commit** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha && git add desktop/src/components/chat/CodeViewer.tsx && git commit -m "feat: rewrite CodeViewer with prism-react-renderer — pure React rendering, GitHub theme" -``` - ---- - -### Task 3: Rewrite DiffViewer with react-diff-viewer-continued - -**Files:** -- Rewrite: `desktop/src/components/chat/DiffViewer.tsx` - -- [ ] **Step 1: Replace the entire DiffViewer.tsx** - -```tsx -import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued' -import { Highlight, themes } from 'prism-react-renderer' -import { CopyButton } from '../shared/CopyButton' - -type Props = { - filePath: string - oldString: string - newString: string -} - -/** Infer language from file extension for syntax highlighting within diffs */ -function inferLanguage(filePath: string): string { - const ext = filePath.split('.').pop()?.toLowerCase() - const langMap: Record = { - ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', - py: 'python', rs: 'rust', go: 'go', rb: 'ruby', - json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', - md: 'markdown', css: 'css', html: 'markup', xml: 'markup', - sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', - } - return langMap[ext ?? ''] || 'text' -} - -/** Render a single line with prism syntax highlighting */ -function highlightSyntax(str: string, language: string) { - return ( - - {({ tokens, getTokenProps }) => ( - <> - {tokens.map((line, i) => ( - - {line.map((token, key) => ( - - ))} - - ))} - - )} - - ) -} - -/** Custom styles to match our design system */ -const diffStyles = { - variables: { - light: { - diffViewerBackground: '#ffffff', - diffViewerColor: '#24292f', - addedBackground: '#dafbe1', - addedColor: '#24292f', - removedBackground: '#ffebe9', - removedColor: '#24292f', - wordAddedBackground: '#abf2bc', - wordRemovedBackground: '#ff818266', - addedGutterBackground: '#ccffd8', - removedGutterBackground: '#ffd7d5', - gutterBackground: '#f6f8fa', - gutterBackgroundDark: '#f0f1f3', - highlightBackground: '#fffbdd', - highlightGutterBackground: '#fff5b1', - codeFoldGutterBackground: '#dbedff', - codeFoldBackground: '#f1f8ff', - emptyLineBackground: '#fafbfc', - gutterColor: '#8b949e', - addedGutterColor: '#1a7f37', - removedGutterColor: '#cf222e', - codeFoldContentColor: '#57606a', - diffViewerTitleBackground: '#fafbfc', - diffViewerTitleColor: '#57606a', - diffViewerTitleBorderColor: '#d0d7de', - }, - }, - diffContainer: { - borderRadius: '0', - fontSize: '12px', - lineHeight: '1.3', - fontFamily: 'var(--font-mono)', - }, - line: { - padding: '1px 0', - }, - gutter: { - padding: '1px 8px', - minWidth: '40px', - fontSize: '11px', - }, - wordDiff: { - padding: '1px 2px', - borderRadius: '2px', - }, -} - -export function DiffViewer({ filePath, oldString, newString }: Props) { - const language = inferLanguage(filePath) - - // Count additions/deletions for the header - const oldLines = oldString.split('\n') - const newLines = newString.split('\n') - const additions = newLines.filter((l, i) => l !== (oldLines[i] ?? null)).length - const deletions = oldLines.filter((l, i) => l !== (newLines[i] ?? null)).length - - return ( -
- {/* Header */} -
-
-
- {filePath} -
-
- +{additions} - -{deletions} -
-
- -
- - {/* Diff body */} -
- highlightSyntax(str, language)} - hideLineNumbers={false} - styles={diffStyles} - useDarkTheme={false} - /> -
-
- ) -} -``` - -Key changes: -- Removed hand-rolled `parsePatchLines()` and `createPatch` from `diff` library -- `react-diff-viewer-continued` handles all diffing, line numbering, and word-level highlighting -- `renderContent` prop integrates `prism-react-renderer` for syntax highlighting within diffs -- Same Props interface (`{ filePath, oldString, newString }`) — ToolCallBlock and PermissionDialog work unchanged -- Custom `diffStyles` matches our GitHub-light design tokens -- `inferLanguage()` extracts language from file extension for diff syntax coloring - -- [ ] **Step 2: Commit** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha && git add desktop/src/components/chat/DiffViewer.tsx && git commit -m "feat: rewrite DiffViewer with react-diff-viewer-continued — GitHub PR-style diffs with syntax highlighting" -``` - ---- - -### Task 4: Rewrite MarkdownRenderer code blocks - -**Files:** -- Rewrite: `desktop/src/components/markdown/MarkdownRenderer.tsx` - -The current MarkdownRenderer generates code blocks as HTML strings via `marked`'s `renderer.code`. This has two problems: (1) uses `dangerouslySetInnerHTML` for code, (2) cannot use React components like prism-react-renderer inside HTML strings. - -The solution: Use `marked`'s `renderer.code` to output a placeholder `
`, then post-process the HTML to replace those placeholders with React-rendered `CodeViewer` components. - -- [ ] **Step 1: Replace the entire MarkdownRenderer.tsx** - -```tsx -import { useMemo, useCallback } from 'react' -import { marked, type Tokens } from 'marked' -import { CodeViewer } from '../chat/CodeViewer' - -type Props = { - content: string -} - -type CodeBlock = { - id: string - code: string - language: string | undefined -} - -// Unique marker that won't appear in normal markdown content -const CODEBLOCK_MARKER = '___CODEBLOCK___' - -const renderer = new marked.Renderer() - -// Collected code blocks during a single parse() call -let pendingCodeBlocks: CodeBlock[] = [] - -renderer.code = function ({ text, lang }: Tokens.Code) { - const id = `cb-${pendingCodeBlocks.length}` - pendingCodeBlocks.push({ id, code: text, language: lang || undefined }) - // Return a placeholder div that we'll replace with React components - return `
` -} - -marked.setOptions({ - breaks: true, - gfm: true, -}) -marked.use({ renderer }) - -function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[] } { - pendingCodeBlocks = [] - const html = marked.parse(content) as string - const codeBlocks = [...pendingCodeBlocks] - pendingCodeBlocks = [] - return { html, codeBlocks } -} - -export function MarkdownRenderer({ content }: Props) { - const { html, codeBlocks } = useMemo(() => parseMarkdown(content), [content]) - - // Split HTML at codeblock placeholders and interleave React components - const parts = useMemo(() => { - if (codeBlocks.length === 0) { - return [{ type: 'html' as const, content: html }] - } - - const result: Array<{ type: 'html'; content: string } | { type: 'code'; block: CodeBlock }> = [] - let remaining = html - - for (const block of codeBlocks) { - const marker = `
` - const idx = remaining.indexOf(marker) - if (idx === -1) continue - - const before = remaining.slice(0, idx) - if (before) { - result.push({ type: 'html', content: before }) - } - result.push({ type: 'code', block }) - remaining = remaining.slice(idx + marker.length) - } - - if (remaining) { - result.push({ type: 'html', content: remaining }) - } - - return result - }, [html, codeBlocks]) - - const handleClick = useCallback(async (event: React.MouseEvent) => { - const target = event.target as HTMLElement | null - const button = target?.closest('[data-copy-code]') - if (!button) return - - const text = button.getAttribute('data-copy-code') - if (!text) return - - try { - await navigator.clipboard.writeText(text) - const original = button.textContent - button.textContent = 'Copied' - window.setTimeout(() => { - button.textContent = original - }, 1500) - } catch { - // Ignore clipboard errors - } - }, []) - - const proseClasses = `prose prose-sm max-w-none text-[var(--color-text-primary)] - prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold - prose-p:my-2 prose-p:leading-relaxed - prose-code:text-[13px] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-info)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded - prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none - prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline - prose-strong:text-[var(--color-text-primary)] - prose-ul:my-2 prose-ol:my-2 - prose-li:my-0.5 - prose-table:text-sm - prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 - prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)]` - - // If no code blocks, render the simple way (same as before) - if (codeBlocks.length === 0) { - return ( -
- ) - } - - // Mixed content: interleave HTML fragments with React CodeViewer components - return ( -
- {parts.map((part, i) => - part.type === 'html' ? ( -
- ) : ( -
- -
- ) - )} -
- ) -} -``` - -Key changes: -- Code blocks are now rendered as React `CodeViewer` components (with prism-react-renderer) -- `renderer.code` outputs a placeholder `
` marker, which is replaced post-parse with `CodeViewer` -- Non-code HTML still uses `dangerouslySetInnerHTML` (for paragraphs, headings, lists, etc.) -- No more `highlightCode.ts` import -- Copy button for code blocks is now handled by `CodeViewer`'s `CopyButton` (instead of `data-copy-code` attribute) -- Prose classes preserved exactly as before - -- [ ] **Step 2: Commit** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha && git add desktop/src/components/markdown/MarkdownRenderer.tsx && git commit -m "feat: rewrite MarkdownRenderer code blocks with React CodeViewer — no more HTML string templates" -``` - ---- - -### Task 5: Delete highlightCode.ts and clean up globals.css - -**Files:** -- Delete: `desktop/src/components/chat/highlightCode.ts` -- Modify: `desktop/src/theme/globals.css` (lines 4-42) - -- [ ] **Step 1: Delete highlightCode.ts** - -```bash -rm /Users/nanmi/workspace/myself_code/claude-code-haha/desktop/src/components/chat/highlightCode.ts -``` - -- [ ] **Step 2: Remove all .hljs-* CSS rules from globals.css** - -In `desktop/src/theme/globals.css`, delete lines 4-42 (the entire highlight.js theme section): - -```css -/* DELETE everything from line 4 to line 42: */ -/* ─── highlight.js dark theme (VS Code Dark+ inspired) ────────── */ -.hljs { ... } -.hljs-keyword { ... } -/* ... all .hljs-* rules ... */ -.hljs-punctuation { color: #57606a; } -``` - -Replace with a single comment: - -```css -/* Code highlighting is handled by prism-react-renderer (inline styles) */ -``` - -- [ ] **Step 3: Verify full TypeScript compilation** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit -``` - -Expected: Zero errors. No file should import `highlightCode.ts`, `highlight.js`, or `diff` anymore. - -- [ ] **Step 4: Run tests** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm test -- --run -``` - -Expected: All tests pass. - -- [ ] **Step 5: Commit** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha && git add -A && git commit -m "chore: delete highlightCode.ts and .hljs CSS — fully migrated to prism-react-renderer" -``` - ---- - -### Task 6: Final verification - -- [ ] **Step 1: Start the dev server** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm run dev -``` - -- [ ] **Step 2: Verify visually** - -Check in the running app: -1. **Code blocks with language** (e.g. ` ```python`): syntax highlighting + line numbers -2. **Code blocks without language** (e.g. ` ``` `): plain text, no line numbers, no colors -3. **Diff blocks** (Edit/Write tool calls): GitHub PR-style with green/red backgrounds, word-level diff highlighting, syntax coloring -4. **PermissionDialog** with Edit tool: shows diff preview correctly -5. **Markdown content**: headings, lists, tables, inline code all render correctly -6. **Copy buttons**: work in both code blocks and diff viewer header - -- [ ] **Step 3: Verify bundle size improvement** - -```bash -cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm run build 2>&1 | tail -20 -``` - -Expected: Bundle size should be similar or smaller (prism-react-renderer ~12KB + react-diff-viewer-continued ~50KB vs highlight.js ~74KB + diff ~20KB). diff --git a/docs/superpowers/plans/2026-04-07-provider-preset-redesign.md b/docs/superpowers/plans/2026-04-07-provider-preset-redesign.md deleted file mode 100644 index 9dd01122..00000000 --- a/docs/superpowers/plans/2026-04-07-provider-preset-redesign.md +++ /dev/null @@ -1,1333 +0,0 @@ -# Provider Preset Redesign Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace manual provider configuration with preset-based system — users pick a provider, enter API key, done. - -**Architecture:** Frontend preset definitions hardcoded in a shared config file. Backend stores saved providers in `~/.claude/cc-haha/providers.json` (lightweight index). On activation, backend writes 6 env keys to `~/.claude/settings.json`. Official preset clears those keys. - -**Tech Stack:** TypeScript, Zod, Zustand, React, Tailwind CSS - -**Spec:** `docs/superpowers/specs/2026-04-07-provider-preset-redesign.md` - ---- - -## File Structure - -| Action | File | Responsibility | -|--------|------|---------------| -| Create | `src/server/config/providerPresets.ts` | Preset definitions (shared backend) | -| Create | `desktop/src/config/providerPresets.ts` | Preset definitions (frontend copy) | -| Rewrite | `src/server/types/provider.ts` | Simplified schemas — models as record not array | -| Rewrite | `src/server/services/providerService.ts` | New storage path, full env write, official clear | -| Modify | `src/server/api/providers.ts` | Simplified activate (no modelId), add presets endpoint | -| Rewrite | `desktop/src/types/provider.ts` | Simplified frontend types | -| Rewrite | `desktop/src/api/providers.ts` | Updated API calls | -| Rewrite | `desktop/src/stores/providerStore.ts` | Aligned with new API shape | -| Rewrite | `desktop/src/pages/Settings.tsx` (ProviderSettings section) | Preset chips + simplified form | - ---- - -### Task 1: Create Preset Definitions - -**Files:** -- Create: `src/server/config/providerPresets.ts` -- Create: `desktop/src/config/providerPresets.ts` - -- [ ] **Step 1: Create backend preset file** - -```typescript -// src/server/config/providerPresets.ts - -// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch) -// Original work by Jason Young, MIT License - -export type ModelMapping = { - main: string - haiku: string - sonnet: string - opus: string -} - -export type ProviderPreset = { - id: string - name: string - baseUrl: string - defaultModels: ModelMapping - needsApiKey: boolean - websiteUrl: string -} - -export const PROVIDER_PRESETS: ProviderPreset[] = [ - { - id: 'official', - name: 'Claude Official', - baseUrl: '', - defaultModels: { main: '', haiku: '', sonnet: '', opus: '' }, - needsApiKey: false, - websiteUrl: 'https://www.anthropic.com/claude-code', - }, - { - id: 'deepseek', - name: 'DeepSeek', - baseUrl: 'https://api.deepseek.com/anthropic', - defaultModels: { main: 'DeepSeek-V3.2', haiku: 'DeepSeek-V3.2', sonnet: 'DeepSeek-V3.2', opus: 'DeepSeek-V3.2' }, - needsApiKey: true, - websiteUrl: 'https://platform.deepseek.com', - }, - { - id: 'zhipuglm', - name: 'Zhipu GLM', - baseUrl: 'https://open.bigmodel.cn/api/anthropic', - defaultModels: { main: 'glm-5', haiku: 'glm-5', sonnet: 'glm-5', opus: 'glm-5' }, - needsApiKey: true, - websiteUrl: 'https://open.bigmodel.cn', - }, - { - id: 'kimi', - name: 'Kimi', - baseUrl: 'https://api.moonshot.cn/anthropic', - defaultModels: { main: 'kimi-k2.5', haiku: 'kimi-k2.5', sonnet: 'kimi-k2.5', opus: 'kimi-k2.5' }, - needsApiKey: true, - websiteUrl: 'https://platform.moonshot.cn', - }, - { - id: 'minimax', - name: 'MiniMax', - baseUrl: 'https://api.minimaxi.com/anthropic', - defaultModels: { main: 'MiniMax-M2.7', haiku: 'MiniMax-M2.7', sonnet: 'MiniMax-M2.7', opus: 'MiniMax-M2.7' }, - needsApiKey: true, - websiteUrl: 'https://platform.minimaxi.com', - }, - { - id: 'custom', - name: 'Custom', - baseUrl: '', - defaultModels: { main: '', haiku: '', sonnet: '', opus: '' }, - needsApiKey: true, - websiteUrl: '', - }, -] -``` - -- [ ] **Step 2: Create frontend preset file (identical content)** - -Copy `src/server/config/providerPresets.ts` to `desktop/src/config/providerPresets.ts`. Same content — the type + array are plain data, no server dependencies. - -- [ ] **Step 3: Commit** - -```bash -git add src/server/config/providerPresets.ts desktop/src/config/providerPresets.ts -git commit -m "feat: add provider preset definitions (Official, DeepSeek, ZhipuGLM, Kimi, MiniMax, Custom)" -``` - ---- - -### Task 2: Rewrite Backend Types - -**Files:** -- Rewrite: `src/server/types/provider.ts` - -- [ ] **Step 1: Rewrite provider types with simplified model structure** - -Replace `src/server/types/provider.ts` entirely: - -```typescript -// src/server/types/provider.ts - -/** - * Provider types — preset-based provider configuration. - * - * Providers are stored in ~/.claude/cc-haha/providers.json as a lightweight index. - * The active provider's env vars are written to ~/.claude/settings.json. - */ - -import { z } from 'zod' - -export const ModelMappingSchema = z.object({ - main: z.string(), - haiku: z.string(), - sonnet: z.string(), - opus: z.string(), -}) - -export const SavedProviderSchema = z.object({ - id: z.string(), - presetId: z.string(), - name: z.string().min(1), - apiKey: z.string(), - baseUrl: z.string(), - models: ModelMappingSchema, - notes: z.string().optional(), -}) - -export const ProvidersIndexSchema = z.object({ - activeId: z.string().nullable(), - providers: z.array(SavedProviderSchema), -}) - -export const CreateProviderSchema = z.object({ - presetId: z.string().min(1), - name: z.string().min(1), - apiKey: z.string(), - baseUrl: z.string(), - models: ModelMappingSchema, - notes: z.string().optional(), -}) - -export const UpdateProviderSchema = z.object({ - name: z.string().min(1).optional(), - apiKey: z.string().optional(), - baseUrl: z.string().optional(), - models: ModelMappingSchema.optional(), - notes: z.string().optional(), -}) - -export const TestProviderSchema = z.object({ - baseUrl: z.string().url(), - apiKey: z.string().min(1), - modelId: z.string().min(1), -}) - -// TypeScript types -export type ModelMapping = z.infer -export type SavedProvider = z.infer -export type ProvidersIndex = z.infer -export type CreateProviderInput = z.infer -export type UpdateProviderInput = z.infer -export type TestProviderInput = z.infer - -export interface ProviderTestResult { - success: boolean - latencyMs: number - error?: string - modelUsed?: string - httpStatus?: number -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/server/types/provider.ts -git commit -m "refactor: rewrite provider types — simplified model mapping, preset-based structure" -``` - ---- - -### Task 3: Rewrite Backend Provider Service - -**Files:** -- Rewrite: `src/server/services/providerService.ts` - -- [ ] **Step 1: Rewrite providerService.ts** - -Replace `src/server/services/providerService.ts` entirely: - -```typescript -// src/server/services/providerService.ts - -/** - * Provider Service — preset-based provider configuration - * - * Storage: ~/.claude/cc-haha/providers.json (lightweight index) - * Active provider env vars written to ~/.claude/settings.json - */ - -import * as fs from 'fs/promises' -import * as path from 'path' -import * as os from 'os' -import { ApiError } from '../middleware/errorHandler.js' -import type { - SavedProvider, - ProvidersIndex, - CreateProviderInput, - UpdateProviderInput, - TestProviderInput, - ProviderTestResult, -} from '../types/provider.js' - -/** The 6 env keys we manage in settings.json */ -const MANAGED_ENV_KEYS = [ - 'ANTHROPIC_BASE_URL', - 'ANTHROPIC_AUTH_TOKEN', - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', -] as const - -const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] } - -export class ProviderService { - private getConfigDir(): string { - return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') - } - - private getCcHahaDir(): string { - return path.join(this.getConfigDir(), 'cc-haha') - } - - private getIndexPath(): string { - return path.join(this.getCcHahaDir(), 'providers.json') - } - - private getSettingsPath(): string { - return path.join(this.getConfigDir(), 'settings.json') - } - - // --- File I/O (atomic write) --- - - private async readIndex(): Promise { - try { - const raw = await fs.readFile(this.getIndexPath(), 'utf-8') - return JSON.parse(raw) as ProvidersIndex - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return { ...DEFAULT_INDEX, providers: [] } - } - throw ApiError.internal(`Failed to read providers index: ${err}`) - } - } - - private async writeIndex(index: ProvidersIndex): Promise { - const filePath = this.getIndexPath() - const dir = path.dirname(filePath) - await fs.mkdir(dir, { recursive: true }) - - const tmpFile = `${filePath}.tmp.${Date.now()}` - try { - await fs.writeFile(tmpFile, JSON.stringify(index, null, 2) + '\n', 'utf-8') - await fs.rename(tmpFile, filePath) - } catch (err) { - await fs.unlink(tmpFile).catch(() => {}) - throw ApiError.internal(`Failed to write providers index: ${err}`) - } - } - - private async readSettings(): Promise> { - try { - const raw = await fs.readFile(this.getSettingsPath(), 'utf-8') - return JSON.parse(raw) as Record - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {} - throw ApiError.internal(`Failed to read settings.json: ${err}`) - } - } - - private async writeSettings(settings: Record): Promise { - const filePath = this.getSettingsPath() - const dir = path.dirname(filePath) - await fs.mkdir(dir, { recursive: true }) - - const tmpFile = `${filePath}.tmp.${Date.now()}` - try { - await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8') - await fs.rename(tmpFile, filePath) - } catch (err) { - await fs.unlink(tmpFile).catch(() => {}) - throw ApiError.internal(`Failed to write settings.json: ${err}`) - } - } - - // --- CRUD --- - - async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> { - const index = await this.readIndex() - return { providers: index.providers, activeId: index.activeId } - } - - async getProvider(id: string): Promise { - const index = await this.readIndex() - const provider = index.providers.find((p) => p.id === id) - if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) - return provider - } - - async addProvider(input: CreateProviderInput): Promise { - const index = await this.readIndex() - - const provider: SavedProvider = { - id: crypto.randomUUID(), - presetId: input.presetId, - name: input.name, - apiKey: input.apiKey, - baseUrl: input.baseUrl, - models: input.models, - ...(input.notes !== undefined && { notes: input.notes }), - } - - index.providers.push(provider) - await this.writeIndex(index) - return provider - } - - async updateProvider(id: string, input: UpdateProviderInput): Promise { - const index = await this.readIndex() - const idx = index.providers.findIndex((p) => p.id === id) - if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`) - - const existing = index.providers[idx] - const updated: SavedProvider = { - ...existing, - ...(input.name !== undefined && { name: input.name }), - ...(input.apiKey !== undefined && { apiKey: input.apiKey }), - ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), - ...(input.models !== undefined && { models: input.models }), - ...(input.notes !== undefined && { notes: input.notes }), - } - - index.providers[idx] = updated - await this.writeIndex(index) - - // Re-sync if this is the active provider - if (index.activeId === id) { - await this.syncToSettings(updated) - } - - return updated - } - - async deleteProvider(id: string): Promise { - const index = await this.readIndex() - const idx = index.providers.findIndex((p) => p.id === id) - if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`) - - if (index.activeId === id) { - throw ApiError.conflict('Cannot delete the active provider. Switch to another provider first.') - } - - index.providers.splice(idx, 1) - await this.writeIndex(index) - } - - // --- Activation --- - - async activateProvider(id: string): Promise { - const index = await this.readIndex() - const provider = index.providers.find((p) => p.id === id) - if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) - - index.activeId = id - await this.writeIndex(index) - - if (provider.presetId === 'official') { - await this.clearProviderFromSettings() - } else { - await this.syncToSettings(provider) - } - } - - /** Activate official — clear all managed env keys */ - async activateOfficial(): Promise { - const index = await this.readIndex() - index.activeId = null - await this.writeIndex(index) - await this.clearProviderFromSettings() - } - - // --- Settings sync --- - - private async syncToSettings(provider: SavedProvider): Promise { - const settings = await this.readSettings() - const existingEnv = (settings.env as Record) || {} - - settings.env = { - ...existingEnv, - ANTHROPIC_BASE_URL: provider.baseUrl, - ANTHROPIC_AUTH_TOKEN: provider.apiKey, - ANTHROPIC_MODEL: provider.models.main, - ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku, - ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet, - ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus, - } - - await this.writeSettings(settings) - } - - private async clearProviderFromSettings(): Promise { - const settings = await this.readSettings() - const env = (settings.env as Record) || {} - - for (const key of MANAGED_ENV_KEYS) { - delete env[key] - } - - settings.env = env - // Clean up empty env object - if (Object.keys(env).length === 0) { - delete settings.env - } - - await this.writeSettings(settings) - } - - // --- Test --- - - async testProvider(id: string): Promise { - const provider = await this.getProvider(id) - if (!provider.baseUrl || !provider.apiKey) { - return { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } - } - return this.testProviderConfig({ - baseUrl: provider.baseUrl, - apiKey: provider.apiKey, - modelId: provider.models.main, - }) - } - - async testProviderConfig(input: TestProviderInput): Promise { - const url = `${input.baseUrl.replace(/\/+$/, '')}/v1/messages` - const start = Date.now() - - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': input.apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: input.modelId, - max_tokens: 1, - messages: [{ role: 'user', content: 'Hi' }], - }), - signal: AbortSignal.timeout(15000), - }) - - const latencyMs = Date.now() - start - - if (response.ok) { - return { success: true, latencyMs, modelUsed: input.modelId, httpStatus: response.status } - } - - let errorMessage = `HTTP ${response.status}` - try { - const body = (await response.json()) as Record - if (body.error && typeof body.error === 'object') { - errorMessage = ((body.error as Record).message as string) || errorMessage - } else if (typeof body.message === 'string') { - errorMessage = body.message - } - } catch { - errorMessage = `HTTP ${response.status} ${response.statusText}` - } - - return { success: false, latencyMs, error: errorMessage, modelUsed: input.modelId, httpStatus: response.status } - } catch (err: unknown) { - const latencyMs = Date.now() - start - if (err instanceof DOMException && err.name === 'TimeoutError') { - return { success: false, latencyMs, error: 'Request timed out after 15 seconds', modelUsed: input.modelId } - } - return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: input.modelId } - } - } -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/server/services/providerService.ts -git commit -m "refactor: rewrite providerService — cc-haha storage, full env sync, official clear" -``` - ---- - -### Task 4: Update Backend API Routes - -**Files:** -- Modify: `src/server/api/providers.ts` - -- [ ] **Step 1: Rewrite providers API** - -Replace `src/server/api/providers.ts` entirely: - -```typescript -// src/server/api/providers.ts - -/** - * Providers REST API - * - * GET /api/providers — list all saved providers + activeId - * GET /api/providers/presets — list available presets - * POST /api/providers — add a provider - * PUT /api/providers/:id — update a provider - * DELETE /api/providers/:id — delete a provider - * POST /api/providers/:id/activate — activate a saved provider - * POST /api/providers/official — activate official (clear env) - * POST /api/providers/:id/test — test a saved provider - * POST /api/providers/test — test unsaved config - */ - -import { z } from 'zod' -import { ProviderService } from '../services/providerService.js' -import { PROVIDER_PRESETS } from '../config/providerPresets.js' -import { - CreateProviderSchema, - UpdateProviderSchema, - TestProviderSchema, -} from '../types/provider.js' -import { ApiError, errorResponse } from '../middleware/errorHandler.js' - -const providerService = new ProviderService() - -function maskApiKey(key: string): string { - if (key.length <= 8) return '****' - return key.slice(0, 4) + '****' + key.slice(-4) -} - -function sanitizeProvider(provider: Record): Record { - if (typeof provider.apiKey === 'string') { - return { ...provider, apiKey: maskApiKey(provider.apiKey) } - } - return provider -} - -export async function handleProvidersApi( - req: Request, - _url: URL, - segments: string[], -): Promise { - try { - const id = segments[2] - const action = segments[3] - - // POST /api/providers/test - if (id === 'test' && req.method === 'POST') { - return await handleTestUnsaved(req) - } - - // GET /api/providers/presets - if (id === 'presets' && req.method === 'GET') { - return Response.json({ presets: PROVIDER_PRESETS }) - } - - // POST /api/providers/official - if (id === 'official' && req.method === 'POST') { - await providerService.activateOfficial() - return Response.json({ ok: true }) - } - - // /api/providers (no ID) - if (!id) { - if (req.method === 'GET') { - const { providers, activeId } = await providerService.listProviders() - return Response.json({ providers: providers.map(sanitizeProvider), activeId }) - } - if (req.method === 'POST') { - return await handleCreate(req) - } - throw methodNotAllowed(req.method) - } - - // /api/providers/:id/activate - if (action === 'activate') { - if (req.method !== 'POST') throw methodNotAllowed(req.method) - await providerService.activateProvider(id) - return Response.json({ ok: true }) - } - - // /api/providers/:id/test - if (action === 'test') { - if (req.method !== 'POST') throw methodNotAllowed(req.method) - const result = await providerService.testProvider(id) - return Response.json({ result }) - } - - // /api/providers/:id - if (req.method === 'GET') { - const provider = await providerService.getProvider(id) - return Response.json({ provider: sanitizeProvider(provider) }) - } - if (req.method === 'PUT') { - return await handleUpdate(req, id) - } - if (req.method === 'DELETE') { - await providerService.deleteProvider(id) - return Response.json({ ok: true }) - } - - throw methodNotAllowed(req.method) - } catch (error) { - return errorResponse(error) - } -} - -async function handleCreate(req: Request): Promise { - const body = await parseJsonBody(req) - try { - const input = CreateProviderSchema.parse(body) - const provider = await providerService.addProvider(input) - return Response.json({ provider }, { status: 201 }) - } catch (err) { - if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) - throw err - } -} - -async function handleUpdate(req: Request, id: string): Promise { - const body = await parseJsonBody(req) - try { - const input = UpdateProviderSchema.parse(body) - const provider = await providerService.updateProvider(id, input) - return Response.json({ provider }) - } catch (err) { - if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) - throw err - } -} - -async function handleTestUnsaved(req: Request): Promise { - const body = await parseJsonBody(req) - try { - const input = TestProviderSchema.parse(body) - const result = await providerService.testProviderConfig(input) - return Response.json({ result }) - } catch (err) { - if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) - throw err - } -} - -async function parseJsonBody(req: Request): Promise> { - try { - return (await req.json()) as Record - } catch { - throw ApiError.badRequest('Invalid JSON body') - } -} - -function methodNotAllowed(method: string): ApiError { - return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/server/api/providers.ts -git commit -m "refactor: update provider API — add presets endpoint, simplify activate, add official route" -``` - ---- - -### Task 5: Rewrite Frontend Types + API + Store - -**Files:** -- Rewrite: `desktop/src/types/provider.ts` -- Rewrite: `desktop/src/api/providers.ts` -- Rewrite: `desktop/src/stores/providerStore.ts` - -- [ ] **Step 1: Rewrite frontend types** - -Replace `desktop/src/types/provider.ts`: - -```typescript -// desktop/src/types/provider.ts - -export type ModelMapping = { - main: string - haiku: string - sonnet: string - opus: string -} - -export type SavedProvider = { - id: string - presetId: string - name: string - apiKey: string // masked from server - baseUrl: string - models: ModelMapping - notes?: string -} - -export type CreateProviderInput = { - presetId: string - name: string - apiKey: string - baseUrl: string - models: ModelMapping - notes?: string -} - -export type UpdateProviderInput = { - name?: string - apiKey?: string - baseUrl?: string - models?: ModelMapping - notes?: string -} - -export type TestProviderConfigInput = { - baseUrl: string - apiKey: string - modelId: string -} - -export type ProviderTestResult = { - success: boolean - latencyMs: number - error?: string - modelUsed?: string - httpStatus?: number -} -``` - -- [ ] **Step 2: Rewrite frontend API client** - -Replace `desktop/src/api/providers.ts`: - -```typescript -// desktop/src/api/providers.ts - -import { api } from './client' -import type { - SavedProvider, - CreateProviderInput, - UpdateProviderInput, - TestProviderConfigInput, - ProviderTestResult, -} from '../types/provider' -import type { ProviderPreset } from '../config/providerPresets' - -type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null } -type ProviderResponse = { provider: SavedProvider } -type PresetsResponse = { presets: ProviderPreset[] } -type TestResultResponse = { result: ProviderTestResult } - -export const providersApi = { - list() { - return api.get('/api/providers') - }, - - presets() { - return api.get('/api/providers/presets') - }, - - create(input: CreateProviderInput) { - return api.post('/api/providers', input) - }, - - update(id: string, input: UpdateProviderInput) { - return api.put(`/api/providers/${id}`, input) - }, - - delete(id: string) { - return api.delete<{ ok: true }>(`/api/providers/${id}`) - }, - - activate(id: string) { - return api.post<{ ok: true }>(`/api/providers/${id}/activate`) - }, - - activateOfficial() { - return api.post<{ ok: true }>('/api/providers/official') - }, - - test(id: string) { - return api.post(`/api/providers/${id}/test`) - }, - - testConfig(input: TestProviderConfigInput) { - return api.post('/api/providers/test', input) - }, -} -``` - -- [ ] **Step 3: Rewrite frontend store** - -Replace `desktop/src/stores/providerStore.ts`: - -```typescript -// desktop/src/stores/providerStore.ts - -import { create } from 'zustand' -import { providersApi } from '../api/providers' -import type { - SavedProvider, - CreateProviderInput, - UpdateProviderInput, - TestProviderConfigInput, - ProviderTestResult, -} from '../types/provider' - -type ProviderStore = { - providers: SavedProvider[] - activeId: string | null - isLoading: boolean - - fetchProviders: () => Promise - createProvider: (input: CreateProviderInput) => Promise - updateProvider: (id: string, input: UpdateProviderInput) => Promise - deleteProvider: (id: string) => Promise - activateProvider: (id: string) => Promise - activateOfficial: () => Promise - testProvider: (id: string) => Promise - testConfig: (input: TestProviderConfigInput) => Promise -} - -export const useProviderStore = create((set, get) => ({ - providers: [], - activeId: null, - isLoading: false, - - fetchProviders: async () => { - set({ isLoading: true }) - try { - const { providers, activeId } = await providersApi.list() - set({ providers, activeId, isLoading: false }) - } catch { - set({ isLoading: false }) - } - }, - - createProvider: async (input) => { - const { provider } = await providersApi.create(input) - await get().fetchProviders() - return provider - }, - - updateProvider: async (id, input) => { - const { provider } = await providersApi.update(id, input) - await get().fetchProviders() - return provider - }, - - deleteProvider: async (id) => { - await providersApi.delete(id) - await get().fetchProviders() - }, - - activateProvider: async (id) => { - await providersApi.activate(id) - await get().fetchProviders() - }, - - activateOfficial: async () => { - await providersApi.activateOfficial() - await get().fetchProviders() - }, - - testProvider: async (id) => { - const { result } = await providersApi.test(id) - return result - }, - - testConfig: async (input) => { - const { result } = await providersApi.testConfig(input) - return result - }, -})) -``` - -- [ ] **Step 4: Commit** - -```bash -git add desktop/src/types/provider.ts desktop/src/api/providers.ts desktop/src/stores/providerStore.ts -git commit -m "refactor: rewrite frontend provider types, API client, and store for preset system" -``` - ---- - -### Task 6: Rewrite ProviderSettings UI in Settings.tsx - -**Files:** -- Rewrite: `desktop/src/pages/Settings.tsx` (ProviderSettings + ProviderFormModal sections only, lines 65-396) - -- [ ] **Step 1: Rewrite ProviderSettings and ProviderFormModal** - -Replace the `ProviderSettings` function and `ProviderFormModal` function in `desktop/src/pages/Settings.tsx`. Keep the imports, `Settings` component, `TabButton`, `PermissionSettings`, and `GeneralSettings` unchanged. - -Update the imports at the top of the file: - -```typescript -import { useState, useEffect } from 'react' -import { useSettingsStore } from '../stores/settingsStore' -import { useProviderStore } from '../stores/providerStore' -import { useUIStore } from '../stores/uiStore' -import { Modal } from '../components/shared/Modal' -import { Input } from '../components/shared/Input' -import { Button } from '../components/shared/Button' -import { PROVIDER_PRESETS } from '../config/providerPresets' -import type { PermissionMode, EffortLevel } from '../types/settings' -import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider' -``` - -New `ProviderSettings`: - -```typescript -function ProviderSettings() { - const { providers, activeId, isLoading, fetchProviders, deleteProvider, activateProvider, activateOfficial, testProvider } = useProviderStore() - const fetchSettings = useSettingsStore((s) => s.fetchAll) - const [editingProvider, setEditingProvider] = useState(null) - const [showCreateModal, setShowCreateModal] = useState(false) - const [testResults, setTestResults] = useState>({}) - - useEffect(() => { fetchProviders() }, [fetchProviders]) - - const handleDelete = async (provider: SavedProvider) => { - if (activeId === provider.id) return - if (!window.confirm(`Delete provider "${provider.name}"? This cannot be undone.`)) return - await deleteProvider(provider.id).catch(console.error) - } - - const handleTest = async (provider: SavedProvider) => { - setTestResults((r) => ({ ...r, [provider.id]: { loading: true } })) - try { - const result = await testProvider(provider.id) - setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } })) - } catch { - setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { success: false, latencyMs: 0, error: 'Request failed' } } })) - } - } - - const handleActivate = async (id: string) => { - await activateProvider(id) - await fetchSettings() - } - - const handleActivateOfficial = async () => { - await activateOfficial() - await fetchSettings() - } - - const isOfficialActive = activeId === null - - return ( -
-
-
-

Providers

-

Manage API providers for model access.

-
- -
- - {/* Official provider card */} -
!isOfficialActive && handleActivateOfficial()} - > - -
-
- Claude Official - {isOfficialActive && ( - ACTIVE - )} -
-
Anthropic native — no API key required
-
-
- - {/* Saved providers */} - {isLoading && providers.length === 0 ? ( -
-
-
- ) : ( -
- {providers.map((provider) => { - const isActive = activeId === provider.id - const test = testResults[provider.id] - const preset = PROVIDER_PRESETS.find((p) => p.id === provider.presetId) - return ( -
- -
-
- {provider.name} - {preset && preset.id !== 'custom' && ( - {preset.name} - )} - {isActive && ( - ACTIVE - )} -
-
- {provider.baseUrl} · {provider.models.main} -
- {test && !test.loading && test.result && ( -
- {test.result.success ? `Connected (${test.result.latencyMs}ms)` : `Failed: ${test.result.error}`} -
- )} -
-
- {!isActive && ( - - )} - - - {!isActive && ( - - )} -
-
- ) - })} -
- )} - - {/* Create Modal */} - setShowCreateModal(false)} mode="create" /> - - {/* Edit Modal */} - {editingProvider && ( - setEditingProvider(null)} mode="edit" provider={editingProvider} /> - )} -
- ) -} -``` - -New `ProviderFormModal`: - -```typescript -type ProviderFormProps = { - open: boolean - onClose: () => void - mode: 'create' | 'edit' - provider?: SavedProvider -} - -function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) { - const { createProvider, updateProvider, testConfig } = useProviderStore() - const fetchSettings = useSettingsStore((s) => s.fetchAll) - - // Exclude 'official' from create presets — it has its own card - const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official') - const initialPreset = provider ? availablePresets.find((p) => p.id === provider.presetId) || availablePresets.at(-1)! : availablePresets[0] - - const [selectedPreset, setSelectedPreset] = useState(initialPreset) - const [name, setName] = useState(provider?.name ?? initialPreset.name) - const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) - const [apiKey, setApiKey] = useState('') - const [notes, setNotes] = useState(provider?.notes ?? '') - const [models, setModels] = useState(provider?.models ?? { ...initialPreset.defaultModels }) - const [showAdvanced, setShowAdvanced] = useState(false) - const [isSubmitting, setIsSubmitting] = useState(false) - const [testResult, setTestResult] = useState(null) - const [isTesting, setIsTesting] = useState(false) - - const handlePresetChange = (preset: typeof initialPreset) => { - setSelectedPreset(preset) - setName(preset.name) - setBaseUrl(preset.baseUrl) - setModels({ ...preset.defaultModels }) - setTestResult(null) - } - - const isCustom = selectedPreset.id === 'custom' - const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim() - - const handleSubmit = async () => { - if (!canSubmit) return - setIsSubmitting(true) - try { - if (mode === 'create') { - await createProvider({ - presetId: selectedPreset.id, - name: name.trim(), - apiKey: apiKey.trim(), - baseUrl: baseUrl.trim(), - models, - notes: notes.trim() || undefined, - }) - } else if (provider) { - const input: UpdateProviderInput = { - name: name.trim(), - baseUrl: baseUrl.trim(), - models, - notes: notes.trim() || undefined, - } - if (apiKey.trim()) input.apiKey = apiKey.trim() - await updateProvider(provider.id, input) - if (useProviderStore.getState().activeId === provider.id) await fetchSettings() - } - onClose() - } catch (err) { - console.error('Failed to save provider:', err) - } finally { - setIsSubmitting(false) - } - } - - const handleTest = async () => { - if (!baseUrl.trim() || !models.main.trim()) return - setIsTesting(true) - setTestResult(null) - try { - let result: ProviderTestResult - if (mode === 'edit' && provider && !apiKey.trim()) { - result = await useProviderStore.getState().testProvider(provider.id) - } else { - if (!apiKey.trim()) return - result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim() }) - } - setTestResult(result) - } catch { - setTestResult({ success: false, latencyMs: 0, error: 'Request failed' }) - } finally { - setIsTesting(false) - } - } - - return ( - - - - - } - > -
- {/* Preset chips (create mode only) */} - {mode === 'create' && ( -
- -
- {availablePresets.map((preset) => ( - - ))} -
-
- )} - - setName(e.target.value)} placeholder="Provider name" /> - - {/* Base URL — always visible for custom, read-only hint for presets */} - {isCustom || mode === 'edit' ? ( - setBaseUrl(e.target.value)} placeholder="https://api.example.com/anthropic" /> - ) : ( -
- -
- {baseUrl} -
-
- )} - - setApiKey(e.target.value)} - placeholder={mode === 'edit' ? '****' : 'sk-...'} - /> - - setNotes(e.target.value)} placeholder="Optional notes..." /> - - {/* Advanced: Model Mapping */} -
- - {showAdvanced && ( -
- setModels({ ...models, main: e.target.value })} placeholder="Model ID" /> - setModels({ ...models, haiku: e.target.value })} placeholder="Same as main" /> - setModels({ ...models, sonnet: e.target.value })} placeholder="Same as main" /> - setModels({ ...models, opus: e.target.value })} placeholder="Same as main" /> -
- )} -
- - {/* Test connection */} -
- - {testResult && ( - - {testResult.success ? `Connected (${testResult.latencyMs}ms)` : `Failed: ${testResult.error}`} - - )} -
-
-
- ) -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/pages/Settings.tsx -git commit -m "feat: rewrite ProviderSettings UI — preset chips, simplified form, official card" -``` - ---- - -### Task 7: Clean Up Unused Code - -**Files:** -- Modify: `desktop/src/pages/Settings.tsx` (remove unused imports) - -- [ ] **Step 1: Verify no unused imports** - -Check that the imports at the top of Settings.tsx match what's used. The old `Provider`, `ProviderModel` imports should be removed, replaced with `SavedProvider`, `ModelMapping`. `ProviderTestResult` and `UpdateProviderInput` should remain. - -Remove `ProviderModel` from type imports if still present (it no longer exists). - -- [ ] **Step 2: Remove old `~/.claude/providers.json`** - -No code change needed — the old file is just ignored. The new storage path is `~/.claude/cc-haha/providers.json`. - -- [ ] **Step 3: Commit** - -```bash -git add desktop/src/pages/Settings.tsx -git commit -m "chore: clean up unused imports after provider preset migration" -``` - ---- - -Plan complete and saved to `docs/superpowers/plans/2026-04-07-provider-preset-redesign.md`. Two execution options: - -**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration - -**2. Inline Execution** - Execute tasks in this session, batch execution with checkpoints - -Which approach? \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-08-im-session-persistence.md b/docs/superpowers/plans/2026-04-08-im-session-persistence.md deleted file mode 100644 index 0a6d8288..00000000 --- a/docs/superpowers/plans/2026-04-08-im-session-persistence.md +++ /dev/null @@ -1,954 +0,0 @@ -# IM Adapter Session 持久化改造 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 让 IM Adapter(Telegram/飞书)创建与 Desktop App 互通的持久化 Session,实现聊天记录互通、Session 恢复、项目目录选择。 - -**Architecture:** Adapter 在连接 WebSocket 之前,先通过 HTTP `POST /api/sessions` 创建正式 UUID Session。chatId→sessionId 映射持久化到本地 JSON 文件,adapter 重启后可恢复。用户通过 `/projects` 命令从最近项目列表选择工作目录,或使用 Settings 页面配置的默认目录。 - -**Tech Stack:** Bun (HTTP fetch + WebSocket), existing server REST API (`/api/sessions`, `/api/sessions/recent-projects`), JSON file persistence - ---- - -## File Structure - -| File | Action | Responsibility | -|------|--------|----------------| -| `adapters/common/session-store.ts` | Create | chatId→sessionId 持久化映射 | -| `adapters/common/http-client.ts` | Create | 调用服务端 REST API(创建 session、列出项目) | -| `adapters/common/ws-bridge.ts` | Modify | 移除自动 sessionId 生成,接受外部传入的 sessionId | -| `adapters/telegram/index.ts` | Modify | 接入 session 管理 + `/projects` 命令 | -| `adapters/feishu/index.ts` | Modify | 同 Telegram 的改造 | -| `adapters/common/config.ts` | Modify | 新增 `defaultProjectDir` 顶层配置字段 | -| `src/server/services/adapterService.ts` | Modify | AdapterFileConfig 新增 `defaultProjectDir` | -| `desktop/src/types/adapter.ts` | Modify | 前端类型同步 | -| `desktop/src/pages/AdapterSettings.tsx` | Modify | 新增"默认项目"字段 | -| `desktop/src/i18n/locales/en.ts` | Modify | i18n | -| `desktop/src/i18n/locales/zh.ts` | Modify | i18n | -| `adapters/common/__tests__/session-store.test.ts` | Create | session-store 测试 | -| `adapters/common/__tests__/http-client.test.ts` | Create | http-client 测试 | - ---- - -### Task 1: Session Store — 持久化 chatId→sessionId 映射 - -**Files:** -- Create: `adapters/common/session-store.ts` -- Create: `adapters/common/__tests__/session-store.test.ts` - -- [ ] **Step 1: Write the failing tests** - -```typescript -// adapters/common/__tests__/session-store.test.ts -import { describe, it, expect, beforeEach, afterEach } from 'bun:test' -import * as fs from 'node:fs' -import * as path from 'node:path' -import * as os from 'node:os' -import { SessionStore } from '../session-store.js' - -describe('SessionStore', () => { - let tmpDir: string - let store: SessionStore - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-')) - store = new SessionStore(path.join(tmpDir, 'sessions.json')) - }) - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }) - }) - - it('returns null for unknown chatId', () => { - expect(store.get('unknown')).toBeNull() - }) - - it('stores and retrieves a session', () => { - store.set('chat-1', 'uuid-aaa', '/path/to/project') - const entry = store.get('chat-1') - expect(entry).not.toBeNull() - expect(entry!.sessionId).toBe('uuid-aaa') - expect(entry!.workDir).toBe('/path/to/project') - }) - - it('overwrites existing entry on set', () => { - store.set('chat-1', 'uuid-aaa', '/old') - store.set('chat-1', 'uuid-bbb', '/new') - expect(store.get('chat-1')!.sessionId).toBe('uuid-bbb') - }) - - it('deletes an entry', () => { - store.set('chat-1', 'uuid-aaa', '/path') - store.delete('chat-1') - expect(store.get('chat-1')).toBeNull() - }) - - it('persists to disk and reloads', () => { - store.set('chat-1', 'uuid-aaa', '/path') - - // Create a new store instance pointing to the same file - const store2 = new SessionStore(path.join(tmpDir, 'sessions.json')) - expect(store2.get('chat-1')!.sessionId).toBe('uuid-aaa') - }) - - it('handles missing file gracefully', () => { - const store2 = new SessionStore(path.join(tmpDir, 'nonexistent.json')) - expect(store2.get('anything')).toBeNull() - }) - - it('lists all entries', () => { - store.set('chat-1', 'uuid-1', '/a') - store.set('chat-2', 'uuid-2', '/b') - const all = store.listAll() - expect(all).toHaveLength(2) - }) -}) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd adapters && bun test common/__tests__/session-store.test.ts` -Expected: FAIL — module `../session-store.js` not found - -- [ ] **Step 3: Implement SessionStore** - -```typescript -// adapters/common/session-store.ts -import * as fs from 'node:fs' -import * as path from 'node:path' -import * as os from 'node:os' - -export type SessionEntry = { - sessionId: string - workDir: string - updatedAt: number -} - -type StoreData = Record - -function getDefaultPath(): string { - const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') - return path.join(configDir, 'adapter-sessions.json') -} - -export class SessionStore { - private data: StoreData - private filePath: string - - constructor(filePath?: string) { - this.filePath = filePath ?? getDefaultPath() - this.data = this.load() - } - - get(chatId: string): SessionEntry | null { - return this.data[chatId] ?? null - } - - set(chatId: string, sessionId: string, workDir: string): void { - this.data[chatId] = { sessionId, workDir, updatedAt: Date.now() } - this.save() - } - - delete(chatId: string): void { - delete this.data[chatId] - this.save() - } - - listAll(): Array<{ chatId: string } & SessionEntry> { - return Object.entries(this.data).map(([chatId, entry]) => ({ chatId, ...entry })) - } - - private load(): StoreData { - try { - return JSON.parse(fs.readFileSync(this.filePath, 'utf-8')) - } catch { - return {} - } - } - - private save(): void { - const dir = path.dirname(this.filePath) - fs.mkdirSync(dir, { recursive: true }) - const tmp = `${this.filePath}.tmp.${Date.now()}` - fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + '\n') - fs.renameSync(tmp, this.filePath) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd adapters && bun test common/__tests__/session-store.test.ts` -Expected: 7 pass, 0 fail - -- [ ] **Step 5: Commit** - -```bash -git add adapters/common/session-store.ts adapters/common/__tests__/session-store.test.ts -git commit -m "feat(adapters): add persistent chatId→sessionId store" -``` - ---- - -### Task 2: HTTP Client — 调用服务端 REST API - -**Files:** -- Create: `adapters/common/http-client.ts` -- Create: `adapters/common/__tests__/http-client.test.ts` - -- [ ] **Step 1: Write the failing tests** - -```typescript -// adapters/common/__tests__/http-client.test.ts -import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test' -import { AdapterHttpClient } from '../http-client.js' - -describe('AdapterHttpClient', () => { - let client: AdapterHttpClient - const originalFetch = globalThis.fetch - - beforeEach(() => { - client = new AdapterHttpClient('ws://127.0.0.1:3456') - }) - - afterEach(() => { - globalThis.fetch = originalFetch - }) - - it('derives HTTP URL from WS URL', () => { - expect(client.httpBaseUrl).toBe('http://127.0.0.1:3456') - - const secure = new AdapterHttpClient('wss://example.com:443') - expect(secure.httpBaseUrl).toBe('https://example.com:443') - }) - - it('createSession calls POST /api/sessions', async () => { - const mockSessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' - globalThis.fetch = mock(() => - Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), { - status: 201, - headers: { 'Content-Type': 'application/json' }, - })) - ) as any - - const sessionId = await client.createSession('/path/to/project') - expect(sessionId).toBe(mockSessionId) - - const call = (globalThis.fetch as any).mock.calls[0] - expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions') - const body = JSON.parse(call[1].body) - expect(body.workDir).toBe('/path/to/project') - }) - - it('listRecentProjects calls GET /api/sessions/recent-projects', async () => { - const mockProjects = [ - { projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 }, - ] - globalThis.fetch = mock(() => - Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), { - headers: { 'Content-Type': 'application/json' }, - })) - ) as any - - const projects = await client.listRecentProjects() - expect(projects).toHaveLength(1) - expect(projects[0].projectName).toBe('my-app') - }) - - it('createSession throws on server error', async () => { - globalThis.fetch = mock(() => - Promise.resolve(new Response(JSON.stringify({ error: 'BAD_REQUEST', message: 'workDir required' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - })) - ) as any - - expect(client.createSession('')).rejects.toThrow() - }) -}) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd adapters && bun test common/__tests__/http-client.test.ts` -Expected: FAIL — module not found - -- [ ] **Step 3: Implement AdapterHttpClient** - -```typescript -// adapters/common/http-client.ts -export type RecentProject = { - projectPath: string - realPath: string - projectName: string - isGit: boolean - repoName: string | null - branch: string | null - modifiedAt: string - sessionCount: number -} - -export class AdapterHttpClient { - readonly httpBaseUrl: string - - constructor(wsUrl: string) { - this.httpBaseUrl = wsUrl - .replace(/^ws:/, 'http:') - .replace(/^wss:/, 'https:') - .replace(/\/$/, '') - } - - async createSession(workDir: string): Promise { - const res = await fetch(`${this.httpBaseUrl}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workDir }), - }) - if (!res.ok) { - const err = await res.json().catch(() => ({ message: res.statusText })) - throw new Error(`Failed to create session: ${(err as any).message}`) - } - const data = (await res.json()) as { sessionId: string } - return data.sessionId - } - - async listRecentProjects(): Promise { - const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`) - if (!res.ok) { - throw new Error(`Failed to list projects: ${res.statusText}`) - } - const data = (await res.json()) as { projects: RecentProject[] } - return data.projects - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd adapters && bun test common/__tests__/http-client.test.ts` -Expected: 4 pass, 0 fail - -- [ ] **Step 5: Commit** - -```bash -git add adapters/common/http-client.ts adapters/common/__tests__/http-client.test.ts -git commit -m "feat(adapters): add HTTP client for server session API" -``` - ---- - -### Task 3: WsBridge — 支持外部传入 sessionId - -**Files:** -- Modify: `adapters/common/ws-bridge.ts:46-56` -- Modify: `adapters/common/__tests__/ws-bridge.test.ts` - -- [ ] **Step 1: Update ws-bridge.test.ts — add test for connectSession** - -在现有测试文件末尾,`describe('WsBridge')` 块内添加: - -```typescript -it('connectSession connects with provided sessionId', () => { - bridge.connectSession('chat-1', 'my-uuid-session-id') - expect(bridge.hasSession('chat-1')).toBe(true) -}) - -it('connectSession reuses existing open connection', () => { - bridge.connectSession('chat-1', 'uuid-1') - // Simulate WS open - const ws1 = (bridge as any).sessions.get('chat-1')?.ws - bridge.connectSession('chat-1', 'uuid-2') - // Should not replace if first is still connecting/open - // (In practice, WS is in CONNECTING state, so it gets replaced) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd adapters && bun test common/__tests__/ws-bridge.test.ts` -Expected: FAIL — `bridge.connectSession is not a function` - -- [ ] **Step 3: Modify ws-bridge.ts** - -Replace `getOrCreateSession` with `connectSession`: - -```typescript -// In ws-bridge.ts, replace the getOrCreateSession method (lines 46-56) with: - -/** Connect to a session with a known sessionId. Returns false if already connected. */ -connectSession(chatId: string, sessionId: string): boolean { - const existing = this.sessions.get(chatId) - if (existing && existing.ws.readyState === WebSocket.OPEN) { - return false // already connected - } - this.connect(chatId, sessionId) - return true -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd adapters && bun test common/__tests__/ws-bridge.test.ts` -Expected: All pass (existing tests updated if they reference `getOrCreateSession`) - -Note: If existing tests call `getOrCreateSession`, update them to use `connectSession` with an explicit sessionId like `'test-session-id'`. - -- [ ] **Step 5: Commit** - -```bash -git add adapters/common/ws-bridge.ts adapters/common/__tests__/ws-bridge.test.ts -git commit -m "refactor(ws-bridge): replace getOrCreateSession with connectSession" -``` - ---- - -### Task 4: Config — 新增 defaultProjectDir - -**Files:** -- Modify: `adapters/common/config.ts:27-31` -- Modify: `src/server/services/adapterService.ts:13-29` -- Modify: `desktop/src/types/adapter.ts` -- Modify: `desktop/src/pages/AdapterSettings.tsx` -- Modify: `desktop/src/i18n/locales/en.ts` -- Modify: `desktop/src/i18n/locales/zh.ts` - -- [ ] **Step 1: Add defaultProjectDir to adapter config types** - -In `adapters/common/config.ts`, add to `AdapterConfig` type and `loadConfig`: - -```typescript -// adapters/common/config.ts — update AdapterConfig type (line 27-31) -export type AdapterConfig = { - serverUrl: string - defaultProjectDir: string // ← NEW - telegram: TelegramConfig - feishu: FeishuConfig -} - -// In loadConfig() return statement, add: - return { - serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456', - defaultProjectDir: file.defaultProjectDir || '', // ← NEW - telegram: { ... }, - feishu: { ... }, - } -``` - -- [ ] **Step 2: Add to server-side AdapterFileConfig** - -In `src/server/services/adapterService.ts`, add `defaultProjectDir` to the type (line 14): - -```typescript -export type AdapterFileConfig = { - serverUrl?: string - defaultProjectDir?: string // ← NEW - telegram?: { ... } - feishu?: { ... } -} -``` - -- [ ] **Step 3: Add to frontend type** - -In `desktop/src/types/adapter.ts`, add: - -```typescript -export type AdapterFileConfig = { - serverUrl?: string - defaultProjectDir?: string // ← NEW - telegram?: { ... } - feishu?: { ... } -} -``` - -- [ ] **Step 4: Add UI field in AdapterSettings.tsx** - -Add a `defaultProjectDir` state + `DirectoryPicker` between the server URL and the Telegram section: - -```tsx -// Add import at top -import { DirectoryPicker } from '../components/shared/DirectoryPicker' - -// Add state -const [defaultProjectDir, setDefaultProjectDir] = useState('') - -// In useEffect config sync, add: -setDefaultProjectDir(config.defaultProjectDir ?? '') - -// In handleSave, add to patch: -if (defaultProjectDir) patch.defaultProjectDir = defaultProjectDir - -// In JSX, after the Server URL Input and before the Telegram section: -
- - -

- {t('settings.adapters.defaultProjectHint')} -

-
-``` - -- [ ] **Step 5: Add i18n keys** - -In `desktop/src/i18n/locales/en.ts`, in the adapters section: - -```typescript -'settings.adapters.defaultProject': 'Default Project', -'settings.adapters.defaultProjectHint': 'Default working directory for new IM sessions. If empty, the bot will ask you to choose.', -``` - -In `desktop/src/i18n/locales/zh.ts`: - -```typescript -'settings.adapters.defaultProject': '默认项目', -'settings.adapters.defaultProjectHint': '新 IM 会话的默认工作目录。留空则由 Bot 询问选择。', -``` - -- [ ] **Step 6: Update API validation whitelist** - -In `src/server/api/adapters.ts`, add `'defaultProjectDir'` to `ALLOWED_TOP_KEYS`: - -```typescript -const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu']) -``` - -- [ ] **Step 7: Run TypeScript check** - -Run: `cd desktop && npx tsc --noEmit` -Expected: No errors - -- [ ] **Step 8: Commit** - -```bash -git add adapters/common/config.ts src/server/services/adapterService.ts src/server/api/adapters.ts \ - desktop/src/types/adapter.ts desktop/src/pages/AdapterSettings.tsx \ - desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts -git commit -m "feat: add defaultProjectDir to adapter config and settings UI" -``` - ---- - -### Task 5: Telegram Adapter — Session 管理 + /projects 命令 - -**Files:** -- Modify: `adapters/telegram/index.ts` - -- [ ] **Step 1: Add imports and initialization** - -At the top of `adapters/telegram/index.ts`, add imports and initialize new modules: - -```typescript -// Add these imports after existing ones -import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient } from '../common/http-client.js' - -// After existing init section (after dedup initialization) -const sessionStore = new SessionStore() -const httpClient = new AdapterHttpClient(config.serverUrl) - -// Track chats waiting for project selection -const pendingProjectSelection = new Map() -``` - -- [ ] **Step 2: Replace setupMessageHandler with session-aware version** - -Replace the current `setupMessageHandler` function with: - -```typescript -async function ensureSession(chatId: string): Promise { - // Already connected? - if (bridge.hasSession(chatId)) return true - - // Has stored session? Reconnect. - const stored = sessionStore.get(chatId) - if (stored) { - bridge.connectSession(chatId, stored.sessionId) - bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) - return true - } - - // Need to create a new session — use default project or ask - const workDir = config.defaultProjectDir - if (workDir) { - return await createSessionForChat(chatId, workDir) - } - - // No default — ask user to pick - await showProjectPicker(chatId) - return false // message not sent yet, waiting for project selection -} - -async function createSessionForChat(chatId: string, workDir: string): Promise { - const numericChatId = Number(chatId) - try { - const sessionId = await httpClient.createSession(workDir) - sessionStore.set(chatId, sessionId, workDir) - bridge.connectSession(chatId, sessionId) - bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) - return true - } catch (err) { - await bot.api.sendMessage(numericChatId, - `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`) - return false - } -} - -async function showProjectPicker(chatId: string): Promise { - const numericChatId = Number(chatId) - try { - const projects = await httpClient.listRecentProjects() - if (projects.length === 0) { - await bot.api.sendMessage(numericChatId, - '没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在 Settings → IM 接入中配置默认项目。') - return - } - - const lines = projects.slice(0, 10).map((p, i) => - `${i + 1}. ${p.projectName}${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}` - ) - pendingProjectSelection.set(chatId, true) - await bot.api.sendMessage(numericChatId, - `选择项目(回复编号):\n\n${lines.join('\n\n')}`) - } catch (err) { - await bot.api.sendMessage(numericChatId, - `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`) - } -} -``` - -- [ ] **Step 3: Extract handleServerMessage from inline closure** - -Extract the server message handler (currently the inline `async (msg: ServerMessage) => { ... }` in the old `setupMessageHandler`) into a standalone function. The body is the same `switch(msg.type)` block, just named: - -```typescript -async function handleServerMessage(chatId: string, msg: ServerMessage): Promise { - const numericChatId = Number(chatId) - const buf = getBuffer(chatId) - - switch (msg.type) { - // ... exact same cases as before (status, content_start, content_delta, - // thinking, tool_use_complete, tool_result, permission_request, - // message_complete, error) - // No changes to the switch body. - } -} -``` - -- [ ] **Step 4: Update /new command** - -Replace the `/new` command handler: - -```typescript -bot.command('new', async (ctx) => { - const chatId = String(ctx.chat.id) - // Clean up current session state - bridge.resetSession(chatId) - sessionStore.delete(chatId) - placeholders.delete(chatId) - accumulatedText.delete(chatId) - buffers.get(chatId)?.reset() - buffers.delete(chatId) - pendingProjectSelection.delete(chatId) - // Show project picker for next session - await showProjectPicker(chatId) -}) -``` - -- [ ] **Step 5: Add /projects command** - -```typescript -bot.command('projects', async (ctx) => { - const chatId = String(ctx.chat.id) - await showProjectPicker(chatId) -}) -``` - -- [ ] **Step 6: Update message handler for project selection + normal messages** - -Replace the `bot.on('message:text')` handler: - -```typescript -bot.on('message:text', (ctx) => { - if (!ctx.from || !isAllowed(ctx.from.id)) return - if (!dedup.tryRecord(String(ctx.message.message_id))) return - - const chatId = String(ctx.chat.id) - const text = ctx.message.text - - enqueue(chatId, async () => { - // Check if user is responding to project selection - if (pendingProjectSelection.has(chatId)) { - const num = parseInt(text, 10) - if (num >= 1) { - try { - const projects = await httpClient.listRecentProjects() - const selected = projects[num - 1] - if (selected) { - pendingProjectSelection.delete(chatId) - await createSessionForChat(chatId, selected.realPath) - await bot.api.sendMessage(Number(chatId), - `✅ 已选择 ${selected.projectName}。现在可以开始对话了。`) - return - } - } catch { /* fall through to normal handling */ } - } - // Invalid selection — tell user - await bot.api.sendMessage(Number(chatId), '请输入有效的编号。') - return - } - - // Normal message flow - const ready = await ensureSession(chatId) - if (ready) { - bridge.sendUserMessage(chatId, text) - } - // If not ready, ensureSession already showed the project picker - }) -}) -``` - -- [ ] **Step 7: Update /start command help text** - -```typescript -bot.command('start', (ctx) => { - ctx.reply( - '👋 Claude Code Bot 已就绪。\n\n' + - '命令:\n' + - '/projects — 选择/切换项目\n' + - '/new — 新建会话\n' + - '/stop — 停止生成' - ) -}) -``` - -- [ ] **Step 8: Update SIGINT handler — add sessionStore** - -No change needed, sessionStore is sync-write so data is already persisted. - -- [ ] **Step 9: Run all adapter tests** - -Run: `cd adapters && bun test` -Expected: All pass (telegram mock tests may need updating if they reference `getOrCreateSession` — update mocks to use `connectSession`) - -- [ ] **Step 10: Commit** - -```bash -git add adapters/telegram/index.ts -git commit -m "feat(telegram): session persistence, /projects command, project selection" -``` - ---- - -### Task 6: Feishu Adapter — Session 管理 + /projects 命令 - -**Files:** -- Modify: `adapters/feishu/index.ts` - -- [ ] **Step 1: Add imports and initialization** - -Same pattern as Telegram — add at the top: - -```typescript -import { SessionStore } from '../common/session-store.js' -import { AdapterHttpClient } from '../common/http-client.js' - -// After existing init -const sessionStore = new SessionStore() -const httpClient = new AdapterHttpClient(config.serverUrl) -const pendingProjectSelection = new Map() -``` - -- [ ] **Step 2: Add ensureSession, createSessionForChat, showProjectPicker** - -Same functions as Telegram, but using `sendText(chatId, text)` instead of `bot.api.sendMessage(...)`: - -```typescript -async function ensureSession(chatId: string): Promise { - if (bridge.hasSession(chatId)) return true - - const stored = sessionStore.get(chatId) - if (stored) { - bridge.connectSession(chatId, stored.sessionId) - bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) - return true - } - - const workDir = config.defaultProjectDir - if (workDir) { - return await createSessionForChat(chatId, workDir) - } - - await showProjectPicker(chatId) - return false -} - -async function createSessionForChat(chatId: string, workDir: string): Promise { - try { - const sessionId = await httpClient.createSession(workDir) - sessionStore.set(chatId, sessionId, workDir) - bridge.connectSession(chatId, sessionId) - bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) - return true - } catch (err) { - await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`) - return false - } -} - -async function showProjectPicker(chatId: string): Promise { - try { - const projects = await httpClient.listRecentProjects() - if (projects.length === 0) { - await sendText(chatId, - '没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在设置中配置默认项目。') - return - } - const lines = projects.slice(0, 10).map((p, i) => - `${i + 1}. **${p.projectName}**${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}` - ) - pendingProjectSelection.set(chatId, true) - await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}`) - } catch (err) { - await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`) - } -} -``` - -- [ ] **Step 3: Extract handleServerMessage** - -Same as Telegram — extract the `switch(msg.type)` body from the inline closure in old `setupMessageHandler` into a standalone `handleServerMessage(chatId, msg)` function. - -- [ ] **Step 4: Update command handling in handleMessage** - -In the `handleMessage` function, update the command section (around line 364): - -```typescript - // Handle commands - if (text === '/new' || text === '新会话') { - bridge.resetSession(chatId) - sessionStore.delete(chatId) - chatStates.delete(chatId) - accumulatedText.delete(chatId) - buffers.get(chatId)?.reset() - buffers.delete(chatId) - pendingProjectSelection.delete(chatId) - await showProjectPicker(chatId) - return - } - if (text === '/stop' || text === '停止') { - bridge.sendStopGeneration(chatId) - await sendText(chatId, '⏹ 已发送停止信号。') - return - } - if (text === '/projects' || text === '项目列表') { - await showProjectPicker(chatId) - return - } - - // Check if user is responding to project selection - if (pendingProjectSelection.has(chatId)) { - const num = parseInt(text, 10) - if (num >= 1) { - try { - const projects = await httpClient.listRecentProjects() - const selected = projects[num - 1] - if (selected) { - pendingProjectSelection.delete(chatId) - await createSessionForChat(chatId, selected.realPath) - await sendText(chatId, `✅ 已选择 **${selected.projectName}**。现在可以开始对话了。`) - return - } - } catch { /* fall through */ } - } - await sendText(chatId, '请输入有效的编号。') - return - } - - // Normal message flow - enqueue(chatId, async () => { - const ready = await ensureSession(chatId) - if (ready) { - bridge.sendUserMessage(chatId, text!) - } - }) -``` - -- [ ] **Step 5: Run all adapter tests** - -Run: `cd adapters && bun test` -Expected: All pass - -- [ ] **Step 6: Commit** - -```bash -git add adapters/feishu/index.ts -git commit -m "feat(feishu): session persistence, /projects command, project selection" -``` - ---- - -### Task 7: Update existing tests for API changes - -**Files:** -- Modify: `adapters/telegram/__tests__/telegram.test.ts` -- Modify: `adapters/feishu/__tests__/feishu.test.ts` -- Modify: `adapters/common/__tests__/ws-bridge.test.ts` - -- [ ] **Step 1: Update ws-bridge tests** - -Replace any `getOrCreateSession` calls with `connectSession`: - -```typescript -// Find: bridge.getOrCreateSession('chat-1') -// Replace: bridge.connectSession('chat-1', 'test-session-id') -``` - -- [ ] **Step 2: Update telegram tests** - -Update the mocked bridge to use `connectSession` instead of `getOrCreateSession`. Update any mock setup that references the old API. - -- [ ] **Step 3: Update feishu tests** - -Same as telegram test updates. - -- [ ] **Step 4: Run all tests** - -Run: `cd adapters && bun test` -Expected: All pass, 0 fail - -- [ ] **Step 5: Commit** - -```bash -git add adapters/telegram/__tests__/telegram.test.ts adapters/feishu/__tests__/feishu.test.ts \ - adapters/common/__tests__/ws-bridge.test.ts -git commit -m "test: update adapter tests for connectSession API" -``` - ---- - -### Task 8: Final TypeScript check + verify Desktop build - -**Files:** None new — verification only. - -- [ ] **Step 1: TypeScript check for desktop** - -Run: `cd desktop && npx tsc --noEmit` -Expected: No errors - -- [ ] **Step 2: Run all adapter tests** - -Run: `cd adapters && bun test` -Expected: All pass - -- [ ] **Step 3: Verify adapters.json is properly written** - -Run: `cat ~/.claude/adapters.json` (if exists) -Verify `defaultProjectDir` field can be read. - -- [ ] **Step 4: Final commit (if any fixups needed)** - -```bash -git add -A -git commit -m "chore: fixup after session persistence integration" -``` diff --git a/docs/superpowers/plans/2026-04-09-multi-tab-sessions.md b/docs/superpowers/plans/2026-04-09-multi-tab-sessions.md deleted file mode 100644 index 0af6a9f3..00000000 --- a/docs/superpowers/plans/2026-04-09-multi-tab-sessions.md +++ /dev/null @@ -1,1814 +0,0 @@ -# Multi-Tab Sessions Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Desktop webapp 支持多 Tab 并行 Session,每个 Tab 独立 WebSocket 连接和消息状态。 - -**Architecture:** 新增 tabStore 管理 Tab 状态;将 chatStore 从单 session 改为 per-session Map;将 wsManager 从单例连接改为多连接管理器;新增 TabBar 组件放在内容区域顶部;修改 Sidebar 点击行为为 openTab 模式。 - -**Tech Stack:** React 18, Zustand 5, Tailwind CSS 4, TypeScript, Vite - ---- - -### Task 1: WebSocket Manager 多连接重构 - -**Files:** -- Modify: `desktop/src/api/websocket.ts` - -- [ ] **Step 1: 重构 WebSocketManager 为多连接管理器** - -将单例 WS 连接改为 `Map` 模式。每个方法都以 sessionId 为 key。 - -```ts -import type { ClientMessage, ServerMessage } from '../types/chat' -import { getBaseUrl } from './client' - -type MessageHandler = (msg: ServerMessage) => void - -type Connection = { - ws: WebSocket - handlers: Set - reconnectTimer: ReturnType | null - reconnectAttempt: number - pingInterval: ReturnType | null - intentionalClose: boolean - pendingMessages: ClientMessage[] -} - -class WebSocketManager { - private connections = new Map() - - isConnected(sessionId: string): boolean { - const conn = this.connections.get(sessionId) - return conn?.ws.readyState === WebSocket.OPEN - } - - getConnectedSessionIds(): string[] { - return [...this.connections.keys()] - } - - connect(sessionId: string) { - // Already connected or connecting - const existing = this.connections.get(sessionId) - if (existing && !existing.intentionalClose) return - - const conn: Connection = { - ws: null as unknown as WebSocket, - handlers: new Set(), - reconnectTimer: null, - reconnectAttempt: 0, - pingInterval: null, - intentionalClose: false, - pendingMessages: [], - } - this.connections.set(sessionId, conn) - - const wsUrl = getBaseUrl().replace(/^http/, 'ws') - conn.ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) - - conn.ws.onopen = () => { - conn.reconnectAttempt = 0 - this.startPingLoop(sessionId) - while (conn.pendingMessages.length > 0) { - const msg = conn.pendingMessages.shift()! - conn.ws.send(JSON.stringify(msg)) - } - } - - conn.ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data as string) as ServerMessage - for (const handler of conn.handlers) { - handler(msg) - } - } catch { - // Ignore malformed messages - } - } - - conn.ws.onclose = () => { - this.stopPingLoop(sessionId) - if (!conn.intentionalClose) { - this.scheduleReconnect(sessionId) - } - } - - conn.ws.onerror = () => { - // onclose will fire after onerror - } - } - - disconnect(sessionId: string) { - const conn = this.connections.get(sessionId) - if (!conn) return - - conn.intentionalClose = true - this.stopPingLoop(sessionId) - this.clearReconnect(sessionId) - conn.pendingMessages = [] - - if (conn.ws) { - conn.ws.close() - } - this.connections.delete(sessionId) - } - - disconnectAll() { - for (const sessionId of [...this.connections.keys()]) { - this.disconnect(sessionId) - } - } - - send(sessionId: string, message: ClientMessage) { - const conn = this.connections.get(sessionId) - if (!conn) return - - if (conn.ws.readyState === WebSocket.OPEN) { - conn.ws.send(JSON.stringify(message)) - } else if (conn.ws.readyState === WebSocket.CONNECTING) { - conn.pendingMessages.push(message) - } - } - - onMessage(sessionId: string, handler: MessageHandler): () => void { - const conn = this.connections.get(sessionId) - if (!conn) return () => {} - conn.handlers.add(handler) - return () => { conn.handlers.delete(handler) } - } - - clearHandlers(sessionId: string) { - const conn = this.connections.get(sessionId) - if (conn) conn.handlers.clear() - } - - private startPingLoop(sessionId: string) { - this.stopPingLoop(sessionId) - const conn = this.connections.get(sessionId) - if (!conn) return - conn.pingInterval = setInterval(() => { - this.send(sessionId, { type: 'ping' }) - }, 30_000) - } - - private stopPingLoop(sessionId: string) { - const conn = this.connections.get(sessionId) - if (conn?.pingInterval) { - clearInterval(conn.pingInterval) - conn.pingInterval = null - } - } - - private scheduleReconnect(sessionId: string) { - this.clearReconnect(sessionId) - const conn = this.connections.get(sessionId) - if (!conn) return - - const delay = Math.min(1000 * 2 ** conn.reconnectAttempt, 30_000) - conn.reconnectAttempt++ - - conn.reconnectTimer = setTimeout(() => { - if (this.connections.has(sessionId) && !conn.intentionalClose) { - // Re-create connection - this.connections.delete(sessionId) - this.connect(sessionId) - // Re-register handlers from old connection - const newConn = this.connections.get(sessionId) - if (newConn) { - for (const handler of conn.handlers) { - newConn.handlers.add(handler) - } - } - } - }, delay) - } - - private clearReconnect(sessionId: string) { - const conn = this.connections.get(sessionId) - if (conn?.reconnectTimer) { - clearTimeout(conn.reconnectTimer) - conn.reconnectTimer = null - } - } -} - -export const wsManager = new WebSocketManager() -``` - -- [ ] **Step 2: 验证编译通过** - -Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit 2>&1 | head -30` -Expected: chatStore.ts 会有类型错误(因为调用方式变了),这是预期的,Task 2 会修复。 - -- [ ] **Step 3: Commit** - -```bash -git add desktop/src/api/websocket.ts -git commit -m "refactor: websocket manager supports multiple concurrent connections" -``` - ---- - -### Task 2: chatStore 多 Session 状态隔离 - -**Files:** -- Modify: `desktop/src/stores/chatStore.ts` - -- [ ] **Step 1: 重构 chatStore 为 per-session 状态管理** - -将 `messages`, `chatState`, `streamingText` 等状态从顶层单一值改为按 sessionId 隔离的 Map 结构。 - -```ts -import { create } from 'zustand' -import { wsManager } from '../api/websocket' -import { sessionsApi } from '../api/sessions' -import { useTeamStore } from './teamStore' -import { useSessionStore } from './sessionStore' -import { useCLITaskStore } from './cliTaskStore' -import { randomSpinnerVerb } from '../config/spinnerVerbs' -import type { MessageEntry } from '../types/session' -import type { PermissionMode } from '../types/settings' -import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, TokenUsage } from '../types/chat' - -type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' - -export type PerSessionState = { - messages: UIMessage[] - chatState: ChatState - connectionState: ConnectionState - streamingText: string - streamingToolInput: string - activeToolUseId: string | null - activeToolName: string | null - activeThinkingId: string | null - pendingPermission: { - requestId: string - toolName: string - input: unknown - description?: string - } | null - tokenUsage: TokenUsage - elapsedSeconds: number - statusVerb: string - slashCommands: Array<{ name: string; description: string }> - elapsedTimer: ReturnType | null -} - -function createDefaultSessionState(): PerSessionState { - return { - messages: [], - chatState: 'idle', - connectionState: 'disconnected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - elapsedSeconds: 0, - statusVerb: '', - slashCommands: [], - elapsedTimer: null, - } -} - -type ChatStore = { - sessions: Record - - // Actions — all scoped by sessionId - connectToSession: (sessionId: string) => void - disconnectSession: (sessionId: string) => void - sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void - respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void - setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void - stopGeneration: (sessionId: string) => void - loadHistory: (sessionId: string) => Promise - clearMessages: (sessionId: string) => void - handleServerMessage: (sessionId: string, msg: ServerMessage) => void - - // Helpers - getSession: (sessionId: string) => PerSessionState -} - -const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) -const pendingTaskToolUseIds = new Set() - -let msgCounter = 0 -const nextId = () => `msg-${++msgCounter}-${Date.now()}` - -export const useChatStore = create((set, get) => ({ - sessions: {}, - - getSession: (sessionId: string): PerSessionState => { - return get().sessions[sessionId] ?? createDefaultSessionState() - }, - - connectToSession: (sessionId: string) => { - const existing = get().sessions[sessionId] - if (existing && existing.connectionState !== 'disconnected') return - - // Initialize session state - set((s) => ({ - sessions: { - ...s.sessions, - [sessionId]: { - ...createDefaultSessionState(), - connectionState: 'connecting', - // Preserve messages if reconnecting - messages: existing?.messages ?? [], - }, - }, - })) - - wsManager.clearHandlers(sessionId) - wsManager.connect(sessionId) - wsManager.onMessage(sessionId, (msg) => { - if (msg.type === 'connected') { - set((s) => ({ - sessions: { - ...s.sessions, - [sessionId]: { ...s.sessions[sessionId]!, connectionState: 'connected' }, - }, - })) - } - get().handleServerMessage(sessionId, msg) - }) - - // Load history and tasks - get().loadHistory(sessionId) - useCLITaskStore.getState().fetchSessionTasks(sessionId) - sessionsApi.getSlashCommands(sessionId) - .then(({ commands }) => { - const current = get().sessions[sessionId] - if (current) { - set((s) => ({ - sessions: { - ...s.sessions, - [sessionId]: { ...s.sessions[sessionId]!, slashCommands: commands }, - }, - })) - } - }) - .catch(() => { - const current = get().sessions[sessionId] - if (current) { - set((s) => ({ - sessions: { - ...s.sessions, - [sessionId]: { ...s.sessions[sessionId]!, slashCommands: [] }, - }, - })) - } - }) - }, - - disconnectSession: (sessionId: string) => { - const session = get().sessions[sessionId] - if (session?.elapsedTimer) clearInterval(session.elapsedTimer) - - wsManager.disconnect(sessionId) - - set((s) => { - const { [sessionId]: _, ...rest } = s.sessions - return { sessions: rest } - }) - }, - - sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => { - const userFacingContent = content.trim() - const uiAttachments: UIAttachment[] | undefined = - attachments && attachments.length > 0 - ? attachments.map((attachment) => ({ - type: attachment.type, - name: attachment.name || attachment.path || attachment.mimeType || attachment.type, - data: attachment.data, - mimeType: attachment.mimeType, - })) - : undefined - - const taskStore = useCLITaskStore.getState() - const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed') - - set((s) => { - const session = s.sessions[sessionId] - if (!session) return s - - const newMessages = [...session.messages] - if (allTasksDone) { - newMessages.push({ - id: nextId(), - type: 'task_summary', - tasks: taskStore.tasks.map((t) => ({ - id: t.id, - subject: t.subject, - status: t.status, - activeForm: t.activeForm, - })), - timestamp: Date.now(), - }) - taskStore.clearTasks() - } - newMessages.push({ - id: nextId(), - type: 'user_text', - content: userFacingContent, - attachments: uiAttachments, - timestamp: Date.now(), - }) - - // Clear old timer - if (session.elapsedTimer) clearInterval(session.elapsedTimer) - - // Start new elapsed timer - const timer = setInterval(() => { - set((st) => { - const sess = st.sessions[sessionId] - if (!sess) return st - return { - sessions: { - ...st.sessions, - [sessionId]: { ...sess, elapsedSeconds: sess.elapsedSeconds + 1 }, - }, - } - }) - }, 1000) - - return { - sessions: { - ...s.sessions, - [sessionId]: { - ...session, - messages: newMessages, - chatState: 'thinking', - elapsedSeconds: 0, - streamingText: '', - statusVerb: randomSpinnerVerb(), - elapsedTimer: timer, - }, - }, - } - }) - - wsManager.send(sessionId, { type: 'user_message', content, attachments }) - }, - - respondToPermission: (sessionId, requestId, allowed, rule?) => { - wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) }) - set((s) => { - const session = s.sessions[sessionId] - if (!session) return s - return { - sessions: { - ...s.sessions, - [sessionId]: { ...session, pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' }, - }, - } - }) - }, - - setSessionPermissionMode: (sessionId, mode) => { - if (!get().sessions[sessionId]) return - wsManager.send(sessionId, { type: 'set_permission_mode', mode }) - }, - - stopGeneration: (sessionId) => { - wsManager.send(sessionId, { type: 'stop_generation' }) - set((s) => { - const session = s.sessions[sessionId] - if (!session) return s - if (session.elapsedTimer) clearInterval(session.elapsedTimer) - return { - sessions: { - ...s.sessions, - [sessionId]: { ...session, chatState: 'idle', elapsedTimer: null }, - }, - } - }) - }, - - loadHistory: async (sessionId: string) => { - try { - const { messages } = await sessionsApi.getMessages(sessionId) - const uiMessages = mapHistoryMessagesToUiMessages(messages) - - set((state) => { - const session = state.sessions[sessionId] - if (!session || session.messages.length > 0) return state - return { - sessions: { - ...state.sessions, - [sessionId]: { ...session, messages: uiMessages }, - }, - } - }) - - const lastTodos = extractLastTodoWriteFromHistory(messages) - if (lastTodos && lastTodos.length > 0) { - const taskStore = useCLITaskStore.getState() - if (taskStore.tasks.length === 0) { - taskStore.setTasksFromTodos(lastTodos) - } - } - - if (hasUserMessagesAfterTaskCompletion(messages)) { - useCLITaskStore.getState().markCompletedAndDismissed() - } - } catch { - // Session may not have messages yet - } - }, - - clearMessages: (sessionId) => { - set((s) => { - const session = s.sessions[sessionId] - if (!session) return s - return { - sessions: { - ...s.sessions, - [sessionId]: { ...session, messages: [], streamingText: '', chatState: 'idle' }, - }, - } - }) - }, - - handleServerMessage: (sessionId: string, msg: ServerMessage) => { - const updateSession = (updater: (session: PerSessionState) => Partial) => { - set((s) => { - const session = s.sessions[sessionId] - if (!session) return s - return { - sessions: { - ...s.sessions, - [sessionId]: { ...session, ...updater(session) }, - }, - } - }) - } - - switch (msg.type) { - case 'connected': - break - - case 'status': - updateSession((session) => ({ - chatState: msg.state, - ...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}), - ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), - ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}), - })) - if (msg.state === 'idle') { - const session = get().sessions[sessionId] - if (session?.elapsedTimer) { - clearInterval(session.elapsedTimer) - updateSession(() => ({ elapsedTimer: null })) - } - } - break - - case 'content_start': { - const session = get().sessions[sessionId] - if (!session) break - - const pendingText = session.streamingText.trim() - if (pendingText) { - updateSession((s) => ({ - messages: [...s.messages, { - id: nextId(), - type: 'assistant_text' as const, - content: pendingText, - timestamp: Date.now(), - }], - streamingText: '', - })) - } - - if (msg.blockType === 'text') { - updateSession(() => ({ streamingText: '', chatState: 'streaming', activeThinkingId: null })) - } else if (msg.blockType === 'tool_use') { - updateSession(() => ({ - activeToolUseId: msg.toolUseId ?? null, - activeToolName: msg.toolName ?? null, - streamingToolInput: '', - chatState: 'tool_executing', - activeThinkingId: null, - })) - } - break - } - - case 'content_delta': - if (msg.text !== undefined) { - updateSession((s) => ({ streamingText: s.streamingText + msg.text })) - } - if (msg.toolInput !== undefined) { - updateSession((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput })) - } - break - - case 'thinking': - updateSession((s) => { - const pendingText = s.streamingText.trim() - const base = pendingText - ? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }] - : s.messages - - const last = base[base.length - 1] - if (last && last.type === 'thinking') { - const updated = [...base] - updated[updated.length - 1] = { ...last, content: last.content + msg.text } - return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' } - } - const id = nextId() - return { - messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }], - chatState: 'thinking', - activeThinkingId: id, - streamingText: '', - } - }) - break - - case 'tool_use_complete': { - const session = get().sessions[sessionId] - const toolName = msg.toolName || session?.activeToolName || 'unknown' - updateSession((s) => ({ - messages: [...s.messages, { - id: nextId(), - type: 'tool_use', - toolName, - toolUseId: msg.toolUseId || s.activeToolUseId || '', - input: msg.input, - timestamp: Date.now(), - parentToolUseId: msg.parentToolUseId, - }], - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - streamingToolInput: '', - })) - if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) { - useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos) - } else if (TASK_TOOL_NAMES.has(toolName)) { - const useId = msg.toolUseId || session?.activeToolUseId - if (useId) pendingTaskToolUseIds.add(useId) - } - break - } - - case 'tool_result': - updateSession((s) => ({ - messages: [...s.messages, { - id: nextId(), - type: 'tool_result', - toolUseId: msg.toolUseId, - content: msg.content, - isError: msg.isError, - timestamp: Date.now(), - parentToolUseId: msg.parentToolUseId, - }], - chatState: 'thinking', - activeThinkingId: null, - })) - if (pendingTaskToolUseIds.has(msg.toolUseId)) { - pendingTaskToolUseIds.delete(msg.toolUseId) - useCLITaskStore.getState().refreshTasks() - } - break - - case 'permission_request': - updateSession((s) => ({ - pendingPermission: { - requestId: msg.requestId, - toolName: msg.toolName, - input: msg.input, - description: msg.description, - }, - chatState: 'permission_pending', - activeThinkingId: null, - messages: [...s.messages, { - id: nextId(), - type: 'permission_request', - requestId: msg.requestId, - toolName: msg.toolName, - input: msg.input, - description: msg.description, - timestamp: Date.now(), - }], - })) - break - - case 'message_complete': { - const session = get().sessions[sessionId] - if (!session) break - const text = session.streamingText - if (text) { - updateSession((s) => ({ - messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }], - streamingText: '', - })) - } - if (session.elapsedTimer) { - clearInterval(session.elapsedTimer) - } - updateSession(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null })) - break - } - - case 'error': - updateSession((s) => ({ - messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }], - chatState: 'idle', - activeThinkingId: null, - })) - { - const session = get().sessions[sessionId] - if (session?.elapsedTimer) { - clearInterval(session.elapsedTimer) - updateSession(() => ({ elapsedTimer: null })) - } - } - break - - case 'team_created': - useTeamStore.getState().handleTeamCreated(msg.teamName) - break - case 'team_update': - useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members) - break - case 'team_deleted': - useTeamStore.getState().handleTeamDeleted(msg.teamName) - break - case 'task_update': - break - case 'session_title_updated': - useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title) - break - case 'system_notification': - if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) { - updateSession(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> })) - } - break - case 'pong': - break - } - }, -})) - -// ─── History mapping helpers (unchanged) ───────────────────── - -type AssistantHistoryBlock = { - type: string - text?: string - thinking?: string - name?: string - id?: string - input?: unknown -} - -type UserHistoryBlock = { - type: string - text?: string - tool_use_id?: string - content?: unknown - is_error?: boolean - source?: { data?: string } - mimeType?: string - media_type?: string - name?: string -} - -export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] { - const uiMessages: UIMessage[] = [] - for (const msg of messages) { - const timestamp = new Date(msg.timestamp).getTime() - if (msg.type === 'user' && typeof msg.content === 'string') { - uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp }) - continue - } - if (msg.type === 'assistant' && typeof msg.content === 'string') { - uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model }) - continue - } - if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { - for (const block of msg.content as AssistantHistoryBlock[]) { - if (block.type === 'thinking' && block.thinking) { - uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp }) - } else if (block.type === 'text' && block.text) { - uiMessages.push({ id: nextId(), type: 'assistant_text', content: block.text, timestamp, model: msg.model }) - } else if (block.type === 'tool_use') { - uiMessages.push({ - id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', - toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId, - }) - } - } - continue - } - if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) { - const textParts: string[] = [] - const attachments: UIAttachment[] = [] - for (const block of msg.content as UserHistoryBlock[]) { - if (block.type === 'text' && block.text) { - textParts.push(block.text) - } else if (block.type === 'image') { - attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type }) - } else if (block.type === 'file') { - attachments.push({ type: 'file', name: block.name || 'file' }) - } else if (block.type === 'tool_result') { - uiMessages.push({ - id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', - content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId, - }) - } - } - if (textParts.length > 0 || attachments.length > 0) { - uiMessages.push({ - id: nextId(), type: 'user_text', content: textParts.join('\n'), - attachments: attachments.length > 0 ? attachments : undefined, timestamp, - }) - } - } - } - return uiMessages -} - -function extractLastTodoWriteFromHistory( - messages: MessageEntry[], -): Array<{ content: string; status: string; activeForm?: string }> | null { - let foundIndex = -1 - let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]! - if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { - const blocks = msg.content as AssistantHistoryBlock[] - for (let j = blocks.length - 1; j >= 0; j--) { - const block = blocks[j]! - if (block.type === 'tool_use' && block.name === 'TodoWrite') { - const input = block.input as { todos?: unknown } | undefined - if (input && Array.isArray(input.todos)) { - todos = input.todos as Array<{ content: string; status: string; activeForm?: string }> - foundIndex = i - break - } - } - } - if (todos) break - } - } - if (!todos) return null - const allDone = todos.every((t) => t.status === 'completed') - if (allDone) { - for (let i = foundIndex + 1; i < messages.length; i++) { - const msg = messages[i]! - if (msg.type === 'user' && msg.content) return null - } - } - return todos -} - -const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']) - -function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean { - let lastTaskIndex = -1 - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]! - if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { - const blocks = msg.content as AssistantHistoryBlock[] - if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { - lastTaskIndex = i - break - } - } - } - if (lastTaskIndex < 0) return false - for (let i = lastTaskIndex + 1; i < messages.length; i++) { - if (messages[i]!.type === 'user') return true - } - return false -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/stores/chatStore.ts -git commit -m "refactor: chatStore supports per-session isolated state" -``` - ---- - -### Task 3: Tab Store - -**Files:** -- Create: `desktop/src/stores/tabStore.ts` - -- [ ] **Step 1: 创建 tabStore** - -```ts -import { create } from 'zustand' -import { sessionsApi } from '../api/sessions' - -const TAB_STORAGE_KEY = 'cc-haha-open-tabs' - -export type Tab = { - sessionId: string - title: string - status: 'idle' | 'running' | 'error' -} - -type TabPersistence = { - openTabs: Array<{ sessionId: string; title: string }> - activeTabId: string | null -} - -type TabStore = { - tabs: Tab[] - activeTabId: string | null - - openTab: (sessionId: string, title: string) => void - closeTab: (sessionId: string) => void - setActiveTab: (sessionId: string) => void - updateTabTitle: (sessionId: string, title: string) => void - updateTabStatus: (sessionId: string, status: Tab['status']) => void - - saveTabs: () => void - restoreTabs: () => Promise -} - -export const useTabStore = create((set, get) => ({ - tabs: [], - activeTabId: null, - - openTab: (sessionId, title) => { - const { tabs } = get() - const existing = tabs.find((t) => t.sessionId === sessionId) - if (existing) { - set({ activeTabId: sessionId }) - } else { - set({ - tabs: [...tabs, { sessionId, title, status: 'idle' }], - activeTabId: sessionId, - }) - } - get().saveTabs() - }, - - closeTab: (sessionId) => { - const { tabs, activeTabId } = get() - const index = tabs.findIndex((t) => t.sessionId === sessionId) - if (index < 0) return - - const newTabs = tabs.filter((t) => t.sessionId !== sessionId) - let newActiveId = activeTabId - - // If closing the active tab, activate the nearest neighbor - if (activeTabId === sessionId) { - if (newTabs.length === 0) { - newActiveId = null - } else if (index >= newTabs.length) { - newActiveId = newTabs[newTabs.length - 1]!.sessionId - } else { - newActiveId = newTabs[index]!.sessionId - } - } - - set({ tabs: newTabs, activeTabId: newActiveId }) - get().saveTabs() - }, - - setActiveTab: (sessionId) => { - set({ activeTabId: sessionId }) - get().saveTabs() - }, - - updateTabTitle: (sessionId, title) => { - set((s) => ({ - tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, title } : t)), - })) - get().saveTabs() - }, - - updateTabStatus: (sessionId, status) => { - set((s) => ({ - tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, status } : t)), - })) - }, - - saveTabs: () => { - const { tabs, activeTabId } = get() - const data: TabPersistence = { - openTabs: tabs.map((t) => ({ sessionId: t.sessionId, title: t.title })), - activeTabId, - } - try { - localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(data)) - } catch { /* noop */ } - }, - - restoreTabs: async () => { - try { - const raw = localStorage.getItem(TAB_STORAGE_KEY) - if (!raw) return - - const data = JSON.parse(raw) as TabPersistence - if (!data.openTabs || data.openTabs.length === 0) return - - // Validate sessions still exist - const { sessions } = await sessionsApi.list({ limit: 200 }) - const existingIds = new Set(sessions.map((s) => s.id)) - - const validTabs: Tab[] = data.openTabs - .filter((t) => existingIds.has(t.sessionId)) - .map((t) => ({ - sessionId: t.sessionId, - title: sessions.find((s) => s.id === t.sessionId)?.title || t.title, - status: 'idle' as const, - })) - - if (validTabs.length === 0) return - - const activeId = data.activeTabId && validTabs.some((t) => t.sessionId === data.activeTabId) - ? data.activeTabId - : validTabs[0]!.sessionId - - set({ tabs: validTabs, activeTabId: activeId }) - } catch { /* noop */ } - }, -})) -``` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/stores/tabStore.ts -git commit -m "feat: add tabStore for multi-tab state management" -``` - ---- - -### Task 4: TabBar 组件 - -**Files:** -- Create: `desktop/src/components/layout/TabBar.tsx` - -- [ ] **Step 1: 创建 TabBar 组件** - -固定宽度 Tab + 滚动箭头 + 关闭按钮 + 状态指示 + 右键上下文菜单。 - -```tsx -import { useRef, useState, useEffect, useCallback } from 'react' -import { useTabStore, type Tab } from '../../stores/tabStore' -import { useChatStore } from '../../stores/chatStore' -import { useTranslation } from '../../i18n' - -const TAB_WIDTH = 180 -const ARROW_WIDTH = 28 - -export function TabBar() { - const tabs = useTabStore((s) => s.tabs) - const activeTabId = useTabStore((s) => s.activeTabId) - const setActiveTab = useTabStore((s) => s.setActiveTab) - const closeTab = useTabStore((s) => s.closeTab) - const disconnectSession = useChatStore((s) => s.disconnectSession) - - const scrollRef = useRef(null) - const [canScrollLeft, setCanScrollLeft] = useState(false) - const [canScrollRight, setCanScrollRight] = useState(false) - const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null) - const t = useTranslation() - - const updateScrollState = useCallback(() => { - const el = scrollRef.current - if (!el) return - setCanScrollLeft(el.scrollLeft > 0) - setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1) - }, []) - - useEffect(() => { - updateScrollState() - const el = scrollRef.current - if (!el) return - el.addEventListener('scroll', updateScrollState) - const ro = new ResizeObserver(updateScrollState) - ro.observe(el) - return () => { - el.removeEventListener('scroll', updateScrollState) - ro.disconnect() - } - }, [updateScrollState, tabs.length]) - - useEffect(() => { - if (!contextMenu) return - const close = () => setContextMenu(null) - document.addEventListener('click', close) - return () => document.removeEventListener('click', close) - }, [contextMenu]) - - const scroll = (direction: 'left' | 'right') => { - const el = scrollRef.current - if (!el) return - el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' }) - } - - const handleClose = (sessionId: string) => { - disconnectSession(sessionId) - closeTab(sessionId) - } - - const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { - e.preventDefault() - setContextMenu({ sessionId, x: e.clientX, y: e.clientY }) - } - - const handleCloseOthers = (sessionId: string) => { - setContextMenu(null) - const otherIds = tabs.filter((t) => t.sessionId !== sessionId).map((t) => t.sessionId) - for (const id of otherIds) handleClose(id) - } - - const handleCloseRight = (sessionId: string) => { - setContextMenu(null) - const idx = tabs.findIndex((t) => t.sessionId === sessionId) - const rightIds = tabs.slice(idx + 1).map((t) => t.sessionId) - for (const id of rightIds) handleClose(id) - } - - if (tabs.length === 0) return null - - return ( -
- {/* Left arrow */} - {canScrollLeft && ( - - )} - - {/* Tabs scroll container */} -
- {tabs.map((tab) => ( - setActiveTab(tab.sessionId)} - onClose={() => handleClose(tab.sessionId)} - onContextMenu={(e) => handleContextMenu(e, tab.sessionId)} - /> - ))} -
- - {/* Right arrow */} - {canScrollRight && ( - - )} - - {/* Context menu */} - {contextMenu && ( -
- - - -
- )} -
- ) -} - -function TabItem({ tab, isActive, onClick, onClose, onContextMenu }: { - tab: Tab - isActive: boolean - onClick: () => void - onClose: () => void - onContextMenu: (e: React.MouseEvent) => void -}) { - return ( -
- {/* Status indicator */} - {tab.status === 'running' && ( - - )} - {tab.status === 'error' && ( - - )} - - {/* Title */} - - {tab.title || 'Untitled'} - - - {/* Close button */} - -
- ) -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/components/layout/TabBar.tsx -git commit -m "feat: add TabBar component with scroll overflow and context menu" -``` - ---- - -### Task 5: i18n 翻译 keys - -**Files:** -- Modify: `desktop/src/i18n/locales/en.ts` -- Modify: `desktop/src/i18n/locales/zh.ts` - -- [ ] **Step 1: 在 en.ts 末尾添加 Tab 相关翻译** - -在 `en.ts` 对象末尾(closing `}` 之前)添加: - -```ts - // ─── Tabs ────────────────────────────────────── - 'tabs.close': 'Close', - 'tabs.closeOthers': 'Close Others', - 'tabs.closeRight': 'Close to the Right', - 'tabs.closeConfirmTitle': 'Session Running', - 'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?', - 'tabs.closeConfirmKeep': 'Keep Running', - 'tabs.closeConfirmStop': 'Stop & Close', -``` - -- [ ] **Step 2: 在 zh.ts 末尾添加对应中文翻译** - -```ts - // ─── Tabs ────────────────────────────────────── - 'tabs.close': '关闭', - 'tabs.closeOthers': '关闭其他', - 'tabs.closeRight': '关闭右侧', - 'tabs.closeConfirmTitle': '会话运行中', - 'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?', - 'tabs.closeConfirmKeep': '保持运行', - 'tabs.closeConfirmStop': '停止并关闭', -``` - -- [ ] **Step 3: Commit** - -```bash -git add desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts -git commit -m "feat: add i18n translations for multi-tab feature" -``` - ---- - -### Task 6: 布局集成 — AppShell + ContentRouter - -**Files:** -- Modify: `desktop/src/components/layout/AppShell.tsx` -- Modify: `desktop/src/components/layout/ContentRouter.tsx` - -- [ ] **Step 1: AppShell 集成 TabBar 和 Tab 恢复** - -在 `AppShell.tsx` 中 import TabBar,在 bootstrap 后恢复 Tab 状态,并在 `` 之前渲染 ``。 - -修改 import 区域,添加: -```ts -import { TabBar } from './TabBar' -import { useTabStore } from '../../stores/tabStore' -import { useChatStore } from '../../stores/chatStore' -``` - -在 `bootstrap` async 函数中,`setReady(true)` 之前添加 Tab 恢复: -```ts - await useTabStore.getState().restoreTabs() - // Connect active tab's WS - const activeId = useTabStore.getState().activeTabId - if (activeId) { - useChatStore.getState().connectToSession(activeId) - } -``` - -修改 main 内容区 JSX,将 `` 替换为: -```tsx - - -``` - -- [ ] **Step 2: ContentRouter 从 tabStore 获取 activeTabId** - -修改 `ContentRouter.tsx`: - -```tsx -import { useUIStore } from '../../stores/uiStore' -import { useTabStore } from '../../stores/tabStore' -import { useTeamStore } from '../../stores/teamStore' -import { EmptySession } from '../../pages/EmptySession' -import { ActiveSession } from '../../pages/ActiveSession' -import { ScheduledTasks } from '../../pages/ScheduledTasks' -import { Settings } from '../../pages/Settings' -import { AgentTranscript } from '../../pages/AgentTranscript' - -export function ContentRouter() { - const activeView = useUIStore((s) => s.activeView) - const activeTabId = useTabStore((s) => s.activeTabId) - const viewingAgentId = useTeamStore((s) => s.viewingAgentId) - - if (activeView === 'settings') { - return - } - - if (activeView === 'scheduled') { - return - } - - if (activeView === 'terminal') { - return - } - - if (activeView === 'history') { - if (viewingAgentId) { - return - } - return - } - - // Code view - if (!activeTabId) { - return - } - - if (viewingAgentId) { - return - } - - return -} -``` - -(TerminalPlaceholder 和 HistoryPlaceholder 函数保持不变) - -- [ ] **Step 3: Commit** - -```bash -git add desktop/src/components/layout/AppShell.tsx desktop/src/components/layout/ContentRouter.tsx -git commit -m "feat: integrate TabBar into AppShell layout and route by activeTabId" -``` - ---- - -### Task 7: ActiveSession 适配多 Tab - -**Files:** -- Modify: `desktop/src/pages/ActiveSession.tsx` - -- [ ] **Step 1: 改为从 tabStore 和 chatStore.sessions 读取** - -```tsx -import { useEffect, useMemo } from 'react' -import { useTabStore } from '../stores/tabStore' -import { useSessionStore } from '../stores/sessionStore' -import { useChatStore } from '../stores/chatStore' -import { MessageList } from '../components/chat/MessageList' -import { ChatInput } from '../components/chat/ChatInput' -import { TeamStatusBar } from '../components/teams/TeamStatusBar' -import { SessionTaskBar } from '../components/chat/SessionTaskBar' - -export function ActiveSession() { - const activeTabId = useTabStore((s) => s.activeTabId) - const sessions = useSessionStore((s) => s.sessions) - const connectToSession = useChatStore((s) => s.connectToSession) - const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) - - const session = sessions.find((s) => s.id === activeTabId) - const chatState = sessionState?.chatState ?? 'idle' - const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } - - useEffect(() => { - if (activeTabId) { - connectToSession(activeTabId) - } - }, [activeTabId, connectToSession]) - - const isActive = chatState !== 'idle' - const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens - - const lastUpdated = useMemo(() => { - if (!session?.modifiedAt) return '' - const diff = Date.now() - new Date(session.modifiedAt).getTime() - if (diff < 60000) return 'just now' - if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago` - if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago` - return `${Math.floor(diff / 86400000)}d ago` - }, [session?.modifiedAt]) - - if (!activeTabId) return null - - return ( -
- {/* Session info header */} -
-
-

- {session?.title || 'Untitled Session'} -

-
- {isActive && ( - - - session active - - )} - {totalTokens > 0 && ( - <> - · - {totalTokens.toLocaleString()} t - - )} - {lastUpdated && ( - <> - · - last updated {lastUpdated} - - )} - {session?.messageCount !== undefined && session.messageCount > 0 && ( - <> - · - {session.messageCount} messages - - )} -
- {session?.workDirExists === false && ( -
- warning - - Workspace unavailable: {session.workDir || 'directory no longer exists'} - -
- )} -
-
- - {/* Message stream */} - - - {/* Session task bar — sticky at bottom */} - - - {/* Team status bar */} - - - {/* Chat input */} - -
- ) -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/pages/ActiveSession.tsx -git commit -m "feat: ActiveSession reads from tabStore and per-session chatStore" -``` - ---- - -### Task 8: Sidebar 集成 — 点击打开 Tab - -**Files:** -- Modify: `desktop/src/components/layout/Sidebar.tsx` - -- [ ] **Step 1: 修改 Sidebar session 点击行为** - -在 Sidebar.tsx 中 import tabStore 和 chatStore: -```ts -import { useTabStore } from '../../stores/tabStore' -import { useChatStore } from '../../stores/chatStore' -``` - -修改 session 按钮的 `onClick`,从: -```ts -onClick={() => { setActiveView('code'); setActiveSession(session.id) }} -``` -改为: -```ts -onClick={() => { - setActiveView('code') - const { openTab } = useTabStore.getState() - openTab(session.id, session.title) - useChatStore.getState().connectToSession(session.id) -}} -``` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/components/layout/Sidebar.tsx -git commit -m "feat: sidebar click opens session in tab instead of replacing" -``` - ---- - -### Task 9: EmptySession 集成 — 新建 Session 自动开 Tab - -**Files:** -- Modify: `desktop/src/pages/EmptySession.tsx` - -- [ ] **Step 1: 修改 handleSubmit 创建 Session 后打开 Tab** - -在 EmptySession.tsx 中添加 import: -```ts -import { useTabStore } from '../stores/tabStore' -``` - -在 `handleSubmit` 中,`createSession` 成功后,添加 openTab 调用。修改 `try` 块: -```ts - const sessionId = await createSession(workDir || undefined) - setActiveView('code') - // Open as tab - useTabStore.getState().openTab(sessionId, 'New Session') - connectToSession(sessionId) - const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ - type: attachment.type, - name: attachment.name, - data: attachment.data, - mimeType: attachment.mimeType, - })) - sendMessage(text, attachmentPayload) - setInput('') - setAttachments([]) -``` - -注意:这里 `sendMessage` 现在需要传 sessionId 作为第一个参数: -```ts - sendMessage(sessionId, text, attachmentPayload) -``` - -同时更新 sendMessage 的引用获取: -```ts -const sendMessage = useChatStore((state) => state.sendMessage) -``` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/pages/EmptySession.tsx -git commit -m "feat: new session creation automatically opens as tab" -``` - ---- - -### Task 10: MessageList 和 ChatInput 适配 per-session 数据 - -**Files:** -- Modify: `desktop/src/components/chat/MessageList.tsx` -- Modify: `desktop/src/components/chat/ChatInput.tsx` - -- [ ] **Step 1: 修改 MessageList 从 per-session state 读取** - -在 MessageList.tsx 中,找到从 `useChatStore` 读取 `messages`, `chatState`, `streamingText` 等的地方,改为通过 `activeTabId` 从 `sessions[activeTabId]` 读取。 - -添加 import: -```ts -import { useTabStore } from '../../stores/tabStore' -``` - -将所有从 chatStore 直接读取的字段改为通过 helper: -```ts -const activeTabId = useTabStore((s) => s.activeTabId) -const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) -const messages = sessionState?.messages ?? [] -const chatState = sessionState?.chatState ?? 'idle' -const streamingText = sessionState?.streamingText ?? '' -// ... 等等 -``` - -- [ ] **Step 2: 修改 ChatInput 发送消息时传 sessionId** - -在 ChatInput.tsx 中,找到调用 `sendMessage(content, attachments)` 的地方,改为 `sendMessage(activeTabId, content, attachments)`。 - -同样对 `respondToPermission`, `stopGeneration`, `setSessionPermissionMode` 等调用添加 sessionId 参数。 - -- [ ] **Step 3: Commit** - -```bash -git add desktop/src/components/chat/MessageList.tsx desktop/src/components/chat/ChatInput.tsx -git commit -m "feat: MessageList and ChatInput read from per-session state" -``` - ---- - -### Task 11: Tab 状态同步 — session_title_updated + chatState - -**Files:** -- Modify: `desktop/src/stores/chatStore.ts` - -- [ ] **Step 1: 在 handleServerMessage 中同步 Tab 状态** - -在 `session_title_updated` case 中添加: -```ts - case 'session_title_updated': - useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title) - // Sync to tab title - import('../stores/tabStore').then(({ useTabStore }) => { - useTabStore.getState().updateTabTitle(msg.sessionId, msg.title) - }) - break -``` - -更简洁的方式:在文件顶部 import tabStore: -```ts -import { useTabStore } from './tabStore' -``` - -然后在 `status` case 中同步 tab status: -```ts - case 'status': - updateSession((session) => ({ ... })) - // Sync tab status - if (msg.state === 'idle') { - useTabStore.getState().updateTabStatus(sessionId, 'idle') - } else { - useTabStore.getState().updateTabStatus(sessionId, 'running') - } - break -``` - -在 `error` case 中: -```ts - useTabStore.getState().updateTabStatus(sessionId, 'error') -``` - -在 `session_title_updated` case 中: -```ts - useTabStore.getState().updateTabTitle(msg.sessionId, msg.title) -``` - -- [ ] **Step 2: Commit** - -```bash -git add desktop/src/stores/chatStore.ts -git commit -m "feat: sync tab status and title from server messages" -``` - ---- - -### Task 12: Tab 关闭确认对话框 - -**Files:** -- Create: `desktop/src/components/layout/TabCloseDialog.tsx` -- Modify: `desktop/src/components/layout/TabBar.tsx` - -- [ ] **Step 1: 创建 TabCloseDialog 组件** - -```tsx -import { Modal } from '../shared/Modal' -import { Button } from '../shared/Button' -import { useTranslation } from '../../i18n' - -type Props = { - open: boolean - onKeepRunning: () => void - onStopAndClose: () => void - onCancel: () => void -} - -export function TabCloseDialog({ open, onKeepRunning, onStopAndClose, onCancel }: Props) { - const t = useTranslation() - - return ( - - - - - - }> -

- {t('tabs.closeConfirmMessage')} -

-
- ) -} -``` - -- [ ] **Step 2: 在 TabBar 中集成关闭确认逻辑** - -在 TabBar.tsx 中 import 对话框: -```ts -import { TabCloseDialog } from './TabCloseDialog' -``` - -添加 state: -```ts - const [closingTabId, setClosingTabId] = useState(null) -``` - -修改 `handleClose` 函数,检查 session 是否正在运行: -```ts - const handleClose = (sessionId: string) => { - const sessionState = useChatStore.getState().sessions[sessionId] - const isRunning = sessionState && sessionState.chatState !== 'idle' - - if (isRunning) { - // TODO: check settings for tabCloseWithRunningSession - setClosingTabId(sessionId) - return - } - - disconnectSession(sessionId) - closeTab(sessionId) - } -``` - -在 JSX 末尾(context menu 后面)添加对话框: -```tsx - {closingTabId && ( - { - closeTab(closingTabId) - setClosingTabId(null) - }} - onStopAndClose={() => { - useChatStore.getState().stopGeneration(closingTabId) - disconnectSession(closingTabId) - closeTab(closingTabId) - setClosingTabId(null) - }} - onCancel={() => setClosingTabId(null)} - /> - )} -``` - -- [ ] **Step 3: Commit** - -```bash -git add desktop/src/components/layout/TabCloseDialog.tsx desktop/src/components/layout/TabBar.tsx -git commit -m "feat: add confirmation dialog when closing running session tab" -``` - ---- - -### Task 13: 编译验证和集成测试 - -**Files:** -- All modified files - -- [ ] **Step 1: TypeScript 编译检查** - -Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit` -Expected: 零错误。如有错误,逐一修复类型问题。 - -- [ ] **Step 2: Vite 开发服务器启动测试** - -Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx vite build 2>&1 | tail -20` -Expected: Build 成功,无错误。 - -- [ ] **Step 3: 运行现有测试** - -Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx vitest run 2>&1` -Expected: 测试通过(可能需要更新 mock)。 - -- [ ] **Step 4: 修复所有发现的问题** - -根据编译/构建/测试结果修复问题。 - -- [ ] **Step 5: Final commit** - -```bash -git add -A -git commit -m "fix: resolve compilation and integration issues for multi-tab" -``` diff --git a/docs/superpowers/specs/2026-04-07-provider-preset-redesign.md b/docs/superpowers/specs/2026-04-07-provider-preset-redesign.md deleted file mode 100644 index b5900c6f..00000000 --- a/docs/superpowers/specs/2026-04-07-provider-preset-redesign.md +++ /dev/null @@ -1,184 +0,0 @@ -# Provider Preset Redesign - -## Overview - -Replace the manual provider configuration UI with a preset-based system inspired by [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, Copyright 2025 Jason Young). Users select a preset provider, enter an API key, and the system writes the correct env vars to `~/.claude/settings.json`. - -## Presets - -Six presets, each with pre-configured baseUrl and model mapping: - -| ID | Name | baseUrl | Main Model | API Key Required | -|---|---|---|---|---| -| `official` | Claude Official | _(clear env)_ | _(default)_ | No | -| `deepseek` | DeepSeek | `https://api.deepseek.com/anthropic` | `DeepSeek-V3.2` | Yes | -| `zhipuglm` | Zhipu GLM | `https://open.bigmodel.cn/api/anthropic` | `glm-5` | Yes | -| `kimi` | Kimi | `https://api.moonshot.cn/anthropic` | `kimi-k2.5` | Yes | -| `minimax` | MiniMax | `https://api.minimaxi.com/anthropic` | `MiniMax-M2.7` | Yes | -| `custom` | Custom | User-provided | User-provided | Yes | - -Each preset defaults Haiku/Sonnet/Opus to the same as main model. Users can override via advanced settings. - -## Data Flow - -``` -Preset Provider List (frontend hardcoded) - | -User selects preset -> enters API Key -> optionally customizes model mapping - | -Save -> write ~/.claude/cc-haha/providers.json (index of saved providers) - -> write ~/.claude/settings.json env block (active provider only) - | -Claude Code reads settings.json on next session -``` - -## Storage - -### Index File: `~/.claude/cc-haha/providers.json` - -Stores all saved provider profiles for switching. Lightweight — no full settingsConfig, just what's needed to reconstruct the env block. - -```json -{ - "activeId": "deepseek-1", - "providers": [ - { - "id": "deepseek-1", - "presetId": "deepseek", - "name": "DeepSeek", - "apiKey": "sk-xxx", - "baseUrl": "https://api.deepseek.com/anthropic", - "models": { - "main": "DeepSeek-V3.2", - "haiku": "DeepSeek-V3.2", - "sonnet": "DeepSeek-V3.2", - "opus": "DeepSeek-V3.2" - }, - "notes": "" - } - ] -} -``` - -For `custom` preset, `baseUrl` is user-provided. For other presets, `baseUrl` comes from the preset default but can be overridden. - -### settings.json env block (written on activation) - -Third-party provider activation writes 6 keys: - -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", - "ANTHROPIC_AUTH_TOKEN": "sk-xxx", - "ANTHROPIC_MODEL": "DeepSeek-V3.2", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "DeepSeek-V3.2", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "DeepSeek-V3.2", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "DeepSeek-V3.2" - } -} -``` - -Official provider activation removes these 6 keys from env, preserving all other settings.json fields. - -## Frontend UI - -### ProviderSettings Component - -Replaces current provider list + modal with: - -1. **Preset chip bar** — horizontal row: `官方` | `DeepSeek` | `ZhipuGLM` | `Kimi` | `MiniMax` | `自定义` -2. **Saved provider list** — cards showing each saved provider with active badge, switch/edit/delete actions -3. **Add/Edit form** (inline or modal): - - Preset selector (chips, pre-fills form) - - API Key input (not shown for official) - - Base URL input (only shown for custom preset, or editable in advanced) - - Model mapping (collapsible advanced section): Main / Haiku / Sonnet / Opus inputs, pre-filled from preset - - Notes (optional) - - Test Connection button - - Save / Cancel buttons - -### Activation Flow - -- Click "Activate" on a saved provider card -- Backend writes env to settings.json -- UI updates active badge -- Official preset clears env keys - -### Delete Provider - -- Cannot delete the active provider (must switch first, or switch to official) -- Confirmation dialog before delete - -## Backend Changes - -### New: `src/server/config/providerPresets.ts` - -Shared preset definitions used by both API validation and settings generation. - -```typescript -export type ProviderPreset = { - id: string - name: string - baseUrl: string - models: { main: string; haiku: string; sonnet: string; opus: string } - needsApiKey: boolean - websiteUrl: string -} -``` - -### Modified: `src/server/services/providerService.ts` - -- Change storage path from `~/.claude/providers.json` to `~/.claude/cc-haha/providers.json` -- Simplify provider data structure (remove ProviderModel array, use models record) -- `syncToSettings()` writes all 6 env keys -- New `clearProviderFromSettings()` for official preset — removes the 6 env keys -- Remove `activateProvider(id, modelId)` modelId param — models stored in provider config -- Ensure `~/.claude/cc-haha/` directory is created on first write - -### Modified: `src/server/api/providers.ts` - -- Update activate endpoint: `POST /api/providers/:id/activate` (no body needed) -- Add preset list endpoint: `GET /api/providers/presets` (returns preset definitions) - -### Deleted after migration - -- Old `~/.claude/providers.json` is not migrated — users reconfigure (acceptable since this is pre-release) - -## Frontend File Changes - -| Action | File | Description | -|--------|------|-------------| -| New | `desktop/src/config/providerPresets.ts` | Frontend preset definitions | -| Rewrite | `desktop/src/pages/Settings.tsx` ProviderSettings section | Preset-based UI | -| Modify | `desktop/src/types/provider.ts` | Simplify types (remove ProviderModel, add models record) | -| Modify | `desktop/src/stores/providerStore.ts` | Align with new API shape | -| Modify | `desktop/src/api/providers.ts` | Update activate call (remove modelId), add presets endpoint | - -## Backend File Changes - -| Action | File | Description | -|--------|------|-------------| -| New | `src/server/config/providerPresets.ts` | Preset definitions | -| Modify | `src/server/services/providerService.ts` | New storage path, simplified model structure, full env write | -| Modify | `src/server/types/provider.ts` | Simplify schema | -| Modify | `src/server/api/providers.ts` | Update routes | - -## Attribution - -```typescript -// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch) -// Original work by Jason Young, MIT License -``` - -Added as comment header in preset configuration files. - -## Testing - -1. Select DeepSeek preset, enter API key, save — verify `~/.claude/cc-haha/providers.json` and `~/.claude/settings.json` written correctly -2. Switch to Kimi — verify settings.json updated, index updated -3. Switch to Official — verify env keys removed from settings.json -4. Edit a provider's model mapping — verify changes persisted -5. Test Connection — verify connectivity check works -6. Delete non-active provider — verify removed from index -7. Custom preset — verify baseUrl input appears, full flow works diff --git a/docs/superpowers/specs/2026-04-07-sidebar-redesign-design.md b/docs/superpowers/specs/2026-04-07-sidebar-redesign-design.md deleted file mode 100644 index 9a6279bf..00000000 --- a/docs/superpowers/specs/2026-04-07-sidebar-redesign-design.md +++ /dev/null @@ -1,145 +0,0 @@ -# Sidebar Redesign — Project Filter + Time-Grouped Session List - -## Summary - -Redesign the desktop app sidebar to match the original Claude Code desktop app: -- Replace project-grouped session list with a **flat, time-grouped list** (Today / Yesterday / Last 7 days / Last 30 days / Older) -- Add a **ProjectFilter dropdown** at the top for multi-select project filtering -- Keep existing features: search, right-click menu (rename + delete), active session indicator, missing dir warning - -## Approach - -**Plan A: Frontend-only refactor** — modify `Sidebar.tsx`, add `ProjectFilter.tsx`, update `sessionStore.ts`. No backend API changes. - -## Components - -### 1. ProjectFilter (`desktop/src/components/layout/ProjectFilter.tsx`) - -New component providing a multi-select project dropdown. - -**UI structure:** -- **Trigger button**: displays current filter state - - All selected → "All projects" - - N selected → "N projects" - - Dropdown arrow (chevron) on the right -- **Dropdown panel** (absolute positioned, below trigger): - - "All projects" option (toggles select-all / deselect-all) - - Separator line - - Per-project items: folder icon + project name + checkmark when selected - - Project name extracted from sanitized projectPath (last segment) - -**Behavior:** -- Default state: "All projects" (empty `selectedProjects` array = show all) -- Click a project → toggle its selection -- Click "All projects" → select all (clear the filter) -- If all projects are individually selected → treat as "All projects" -- Clicking outside the dropdown closes it - -**Data source:** Extract unique `projectPath` values from the full session list (`sessions.map(s => s.projectPath)`), deduplicated and sorted alphabetically. - -### 2. Sidebar Layout (`desktop/src/components/layout/Sidebar.tsx`) - -**Layout (top to bottom):** -1. **+ New session** button (existing) -2. **Scheduled** button (existing) -3. **ProjectFilter** dropdown + settings icon (right side) -4. **Search sessions** input (existing) -5. **Time-grouped session list** (scrollable, main content area) -6. **Settings** button (bottom, existing) - -**Time grouping logic — `groupByTime(sessions)`:** - -```typescript -type TimeGroup = 'Today' | 'Yesterday' | 'Last 7 days' | 'Last 30 days' | 'Older' - -function groupByTime(sessions: SessionListItem[]): Map { - const now = new Date() - const startOfToday = startOfDay(now) - const startOfYesterday = startOfDay(subDays(now, 1)) - const sevenDaysAgo = startOfDay(subDays(now, 7)) - const thirtyDaysAgo = startOfDay(subDays(now, 30)) - - // Classify each session by modifiedAt into time buckets - // Within each bucket, sessions are already sorted by modifiedAt desc (from API) -} -``` - -No external date library needed — use native `Date` arithmetic. - -**Removed logic:** -- `getProjectDisplayName()` function -- `groupedSessions` useMemo that groups by projectPath -- Per-project group headers and collapsible groups - -**Preserved logic:** -- Search filtering (by title, case-insensitive) -- Active session highlighting (background color) -- Status dot (left side of each session item) -- Right-click context menu (rename + delete) -- "missing dir" warning badge -- Relative timestamp on hover - -### 3. Session Store Changes (`desktop/src/stores/sessionStore.ts`) - -**New state fields:** -```typescript -selectedProjects: string[] // empty = all projects (no filter) -availableProjects: string[] // unique project paths from all sessions -``` - -**New actions:** -```typescript -setSelectedProjects: (projects: string[]) => void -``` - -**Modified behavior:** -- `fetchSessions()` called without `project` parameter (fetch all sessions) -- `availableProjects` computed after fetching: `[...new Set(sessions.map(s => s.projectPath))]` -- Filtering by selectedProjects happens in `Sidebar.tsx` via useMemo, not in the store - -### 4. Session List Item Rendering - -Each session item renders as: - -``` -[status dot] Session title text... [4h] -``` - -- **Status dot**: small circle, brand color when active (connected), tertiary when inactive -- **Title**: single line, text-ellipsis overflow -- **Timestamp**: relative format (now / 5m / 2h / 3d / 1mo), shown on hover (matching current behavior) -- **Selected state**: `surface-selected` background color -- **Hover state**: `surface-hover` background color -- **Missing dir**: orange "missing dir" badge after title - -## What Does NOT Change - -- Backend API (`/api/sessions`, `/api/sessions/recent-projects`) — no changes -- Session CRUD operations (create, delete, rename, activate) -- WebSocket connection logic -- Right-click context menu items -- Search functionality (filter by title) -- Settings button at bottom -- Overall sidebar width and theme variables - -## File Change Summary - -| File | Change Type | Description | -|------|------------|-------------| -| `desktop/src/components/layout/ProjectFilter.tsx` | **New** | Multi-select project dropdown | -| `desktop/src/components/layout/Sidebar.tsx` | **Modify** | Replace project grouping with time grouping, integrate ProjectFilter | -| `desktop/src/stores/sessionStore.ts` | **Modify** | Add selectedProjects/availableProjects state | - -## Error Handling - -- Empty state: if no sessions match filter + search, show "No sessions found" message -- API failure: existing error handling in sessionStore remains unchanged -- Missing projects: if a session's projectPath doesn't match any known project, it still appears in "All projects" view - -## Testing - -- Verify time grouping correctly classifies sessions into Today/Yesterday/Last 7 days/Last 30 days/Older -- Verify ProjectFilter multi-select toggles work correctly -- Verify search + project filter work together (intersection) -- Verify right-click menu still works -- Verify active session highlighting persists across filter changes diff --git a/docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md b/docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md deleted file mode 100644 index 9bd3c908..00000000 --- a/docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md +++ /dev/null @@ -1,221 +0,0 @@ -# Multi-Tab Sessions Design Spec - -## Overview - -Desktop webapp (Tauri + React + Zustand) 新增多 Tab 功能,允许用户同时打开多个 Session 并行工作。类似 VS Code 的编辑器 Tab 与文件浏览器的关系:Sidebar 浏览所有 Session,Tab 栏管理当前打开的 Session。 - -## Tech Stack - -- **Desktop App**: Tauri v2 + React 18 + Zustand 5 + Tailwind CSS 4 + Vite -- **Backend Server**: `src/server/` — 已支持多 Session 并发 (`maxSessions` 配置) -- **通信协议**: 每个 Session 独立 WebSocket (`/ws/${sessionId}`) + REST API - -## Key Design Decisions - -| 决策 | 选择 | 理由 | -|------|------|------| -| Tab 与 Sidebar 关系 | 独立共存 (VS Code 模式) | Sidebar 浏览全部历史, Tab 管理当前打开的 | -| Tab 栏位置 | 内容区域顶部 (Sidebar 右侧上方) | 不影响 Sidebar 布局 | -| 并发策略 | 每个 Tab 独立 WebSocket 连接 | 真正并行, 后端已支持, WS 连接轻量 | -| Tab 数量限制 | 不限制 | 用户自由管理 | -| Tab 溢出处理 | 固定宽度 + 左右滚动箭头 | VS Code 风格 | -| 关闭运行中 Tab | 可配置 (默认弹确认框) | 配置项: ask / background / stop | -| Tab 持久化 | 可配置 (默认恢复) | 存 localStorage, 启动时恢复 | -| Sidebar 打开行为 | 去重 + 聚焦 | 已打开则跳转, 否则新 Tab | -| 新建 Session 入口 | 仅 Sidebar | 保持单一入口 | - -## Architecture Changes - -### 1. New Tab Store (`tabStore.ts`) - -新增 Zustand store 管理 Tab 状态: - -```ts -interface Tab { - sessionId: string - title: string - status: 'idle' | 'running' | 'error' -} - -interface TabStore { - tabs: Tab[] - activeTabId: string | null // sessionId - - openTab(sessionId: string, title: string): void - closeTab(sessionId: string): void - setActiveTab(sessionId: string): void - updateTabTitle(sessionId: string, title: string): void - updateTabStatus(sessionId: string, status: Tab['status']): void - reorderTabs(fromIndex: number, toIndex: number): void - - // Persistence - saveTabs(): void - restoreTabs(): Tab[] -} -``` - -### 2. chatStore Refactor - -从单 Session 改为多 Session 并发: - -**Before**: 单一 `messages[]` + 单一 `connectedSessionId` -**After**: 按 sessionId 隔离的 session map - -```ts -interface PerSessionState { - messages: UIMessage[] - connectionState: 'connecting' | 'connected' | 'disconnected' - streamingState: StreamingState - permissionRequests: PermissionRequest[] - tasks: TaskState[] -} - -interface ChatStore { - sessions: Record - connectToSession(sessionId: string): void - disconnectSession(sessionId: string): void - sendMessage(sessionId: string, content: string, attachments?: AttachmentRef[]): void - // ... per-session methods all accept sessionId -} -``` - -### 3. WebSocket Manager Refactor - -从单例连接改为多连接管理: - -**Before**: 全局一个 WS 连接 -**After**: 按 sessionId 管理多个独立 WS 连接 - -```ts -class WebSocketManager { - connections: Map - connect(sessionId: string): void - disconnect(sessionId: string): void - disconnectAll(): void - getConnection(sessionId: string): WebSocketConnection | undefined - send(sessionId: string, message: ClientMessage): void -} -``` - -### 4. UI Components - -#### 4.1 TabBar Component (New) - -位置: 内容区域顶部, Sidebar 右侧上方。 - -- 固定 Tab 宽度, 显示 Session 标题 (截断) + 状态指示 + 关闭按钮 -- 超出容器宽度时显示左右滚动箭头 -- 当前活跃 Tab 高亮 -- Tab 运行中显示状态指示 (spinner/dot) -- 右键上下文菜单: 关闭、关闭其他、关闭右侧 - -#### 4.2 Layout Changes (AppShell / ContentRouter) - -``` -Before: -┌────────────────────────────────────────┐ -│ Tauri Drag Region │ -├──────────┬─────────────────────────────┤ -│ Sidebar │ ContentRouter │ -│ │ (single active session) │ -└──────────┴─────────────────────────────┘ - -After: -┌────────────────────────────────────────┐ -│ Tauri Drag Region │ -├──────────┬─────────────────────────────┤ -│ Sidebar │ TabBar │ -│ ├─────────────────────────────┤ -│ │ ContentRouter │ -│ │ (active tab's session) │ -└──────────┴─────────────────────────────┘ -``` - -#### 4.3 ActiveSession Changes - -当前 ActiveSession 组件从 `sessionStore.activeSessionId` 获取 session。 -改为从 `tabStore.activeTabId` 获取, 并从 `chatStore.sessions[activeTabId]` 读取消息。 - -### 5. Settings Additions - -新增两个配置项到 Settings 页面: - -- **关闭运行中 Tab 的行为**: `ask` | `background` | `stop` (默认 `ask`) -- **启动时恢复 Tab**: `boolean` (默认 `true`) - -### 6. Sidebar Integration - -修改 Sidebar 点击行为: -- 点击 Session → 检查 tabStore 是否已打开该 Session - - 已打开: `setActiveTab(sessionId)` 聚焦 - - 未打开: `openTab(sessionId, title)` 创建新 Tab + 连接 WS -- 新建 Session → 创建后自动 `openTab` - -### 7. Tab Close Flow - -``` -User clicks X on tab - → Is session running? - → No: close tab, disconnect WS, cleanup chatStore session state - → Yes: check setting `tabCloseWithRunningSession` - → 'ask': show confirm dialog (3 options: keep running / stop / cancel) - → 'background': close tab, keep WS + session running - → 'stop': send stop_generation, close tab, disconnect WS -``` - -### 8. Tab Persistence - -存储到 `localStorage`: -```ts -{ - openTabs: Array<{ sessionId: string; title: string }> - activeTabId: string | null -} -``` - -启动时: -1. 读取 localStorage -2. 检查 `restoreTabsOnStartup` 配置 -3. 校验每个 sessionId 是否仍存在 (API check) -4. 恢复有效 Tab, 连接活跃 Tab 的 WS - -## Backend Impact - -**无需后端改动**。后端已支持: -- 多 Session 并发 (`maxSessions` 配置) -- 独立 WebSocket 端点 (`/ws/${sessionId}`) -- Session CRUD REST API -- 独立的消息历史存储 - -前端并行打开多个 WS 连接即可。 - -## Files to Modify - -### New Files -- `desktop/src/stores/tabStore.ts` -- `desktop/src/components/layout/TabBar.tsx` -- `desktop/src/components/layout/TabCloseDialog.tsx` - -### Modified Files -- `desktop/src/stores/chatStore.ts` — 多 session 状态隔离 -- `desktop/src/api/websocket.ts` — 多连接管理 -- `desktop/src/components/layout/AppShell.tsx` — 集成 TabBar -- `desktop/src/components/layout/ContentRouter.tsx` — 从 tabStore 获取 activeTabId -- `desktop/src/components/layout/Sidebar.tsx` — 点击行为改为 openTab -- `desktop/src/pages/ActiveSession.tsx` — 从 chatStore.sessions[id] 读取 -- `desktop/src/pages/Settings.tsx` — 新增 Tab 相关设置项 -- `desktop/src/stores/uiStore.ts` — 可能需要调整 activeView 逻辑 -- `desktop/src/stores/cliTaskStore.ts` — 按 sessionId 隔离 task 状态 - -## Performance Considerations - -- 每个 WS 连接占用极少资源, 10 个以内无需担心 -- 消息缓存在内存中, 极端情况 (大量 Tab + 长对话) 可能占用较多内存 -- 非活跃 Tab 不渲染 DOM (React 条件渲染), 无 UI 性能开销 -- Tab 切换无需重新加载消息 (已缓存), 切换体验流畅 - -## Testing Strategy - -- Unit tests: tabStore CRUD, persistence, close flow logic -- Integration tests: Sidebar → Tab → WS 连接全链路 -- Edge cases: 关闭最后一个 Tab、恢复已删除的 Session、大量 Tab 滚动 diff --git a/final-ui.png b/final-ui.png deleted file mode 100644 index b9079936..00000000 Binary files a/final-ui.png and /dev/null differ diff --git a/new-session-response.png b/new-session-response.png deleted file mode 100644 index 7a554fd3..00000000 Binary files a/new-session-response.png and /dev/null differ diff --git a/new-session-sent.png b/new-session-sent.png deleted file mode 100644 index a6566093..00000000 Binary files a/new-session-sent.png and /dev/null differ diff --git a/new-session-state.png b/new-session-state.png deleted file mode 100644 index 5eeafe6f..00000000 Binary files a/new-session-state.png and /dev/null differ diff --git a/new-session.png b/new-session.png deleted file mode 100644 index 6d06823d..00000000 Binary files a/new-session.png and /dev/null differ diff --git a/project-picker-open.png b/project-picker-open.png deleted file mode 100644 index ef45455d..00000000 Binary files a/project-picker-open.png and /dev/null differ diff --git a/project-selected.png b/project-selected.png deleted file mode 100644 index 6310fe60..00000000 Binary files a/project-selected.png and /dev/null differ diff --git a/response-complete.png b/response-complete.png deleted file mode 100644 index 04164e16..00000000 Binary files a/response-complete.png and /dev/null differ diff --git a/scheduled-tasks.png b/scheduled-tasks.png deleted file mode 100644 index d0a035b7..00000000 Binary files a/scheduled-tasks.png and /dev/null differ diff --git a/session-with-messages.png b/session-with-messages.png deleted file mode 100644 index 4ff3d8a4..00000000 Binary files a/session-with-messages.png and /dev/null differ diff --git a/settings-page.png b/settings-page.png deleted file mode 100644 index d749777d..00000000 Binary files a/settings-page.png and /dev/null differ diff --git a/settings-view.png b/settings-view.png deleted file mode 100644 index 5dc73bab..00000000 Binary files a/settings-view.png and /dev/null differ diff --git a/sidebar-snapshot.md b/sidebar-snapshot.md deleted file mode 100644 index 50d594d5..00000000 --- a/sidebar-snapshot.md +++ /dev/null @@ -1,148 +0,0 @@ -- generic [ref=e3]: - - generic [ref=e4]: - - generic [ref=e5]: - - button [ref=e6]: - - img [ref=e7] - - button [ref=e9]: - - img [ref=e10] - - button "Code" [ref=e13] - - generic [ref=e14]: - - complementary [ref=e15]: - - generic [ref=e16]: - - button "New session" [ref=e17]: - - img [ref=e18] - - text: New session - - button "Scheduled" [ref=e19]: - - img [ref=e20] - - text: Scheduled - - button "All projects ▾" [ref=e24]: - - generic [ref=e25]: All projects - - generic [ref=e26]: ▾ - - textbox "Search sessions..." [ref=e28] - - generic [ref=e29]: - - generic [ref=e90]: - - generic [ref=e91]: Today - - button "Say hello in exactly one word. Nothing else." [ref=e110] - - button "claude-code-ui-clone-docs" [ref=e294] - - button "这是一个非常棒的设计需求。要实现“苹果风格(Apple Style)”的H5应用,核心在于:**大量的留白、圆角(Large border radius)、细腻..." [ref=e296] - - button "opencut /opencut..." [ref=e298] - - button "Say \"hello world\" and nothing else. Keep it short." [ref=e300] - - button "Say \"hello world\" and nothing else. Keep it short." [ref=e302] - - button "Say \"hello world\" and nothing else. Keep it short." [ref=e304] - - button "Say \"hello world\" and nothing else. Keep it short." [ref=e306] - - button "Say hi" [ref=e308] - - button "Say hi in one word" [ref=e310] - - button "Say hello in exactly one word. Nothing else." [ref=e312] - - button "Untitled Session" [ref=e314] - - button "分析下这个项目" [ref=e316] - - button "/clear clear/effort effortreview /review <..." [ref=e382] - - button "帮我打开网易云音乐,搜索一首《喜欢你的歌》" [ref=e384] - - button "帮我研究一下 Google 新推出的这个推理框架。如果我在 macOS 电脑上,能跑 Google Gemma4 新模型,并为它提供一个服务,从而让我的一些C..." [ref=e386] - - 'button "要实现这种“苹果健康(Apple Health)”风格的圆形进度图,核心在于使用 **SVG** 来绘制环形,并通过 `stroke-dasharray` 和 ..." [ref=e388]' - - button "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-f..." [ref=e390] - - button "/Users/nanmi/workspace/myself_code/MediaCrawler/.github 参考这里面的模板 提问题issue,替换不属于..." [ref=e392] - - button "add-third-party-models-guide" [ref=e394] - - button "今天深圳的天气怎么样" [ref=e396] - - button "你是什么模型" [ref=e398] - - button "给我调研一下这个泄露的源代码。它在使用 web search 工具时,为什么搜索不到,但用它自家的模型可以? 如果第三方模型做不到,是为什么?是因为这个 we..." [ref=e400] - - button "搜索一下深圳今天的天气" [ref=e402] - - button "搜索一下MediaCrawler" [ref=e404] - - button "深圳今天的天气怎么样" [ref=e406] - - button "开启多个SubAgent去调研一下这个超大的repo,如果我们要加其他协议的模型支持方便吗,比如OpenAI的,现在是Anthropic的,改动大不大" [ref=e408] - - button "bilibili-chapter-generator /bil..." [ref=e410] - - button "@千问AI_3分钟视频脚本.docx @【程序员阿江-Relakkes 】-视频大纲-4.5.docx @千问AI:硬核技术 AI office - 传播bri..." [ref=e412] - - button "@千问AI_3分钟视频脚本.docx 帮我把这个脚本写到飞书里,不要写时间线,按分类写好,顶部就是视频的标题" [ref=e414] - - button "opencut /opencut..." [ref=e416] - - button "124.223.163.13 root ganlu520.@ 帮我去这台服务器看一下,我的v2rayn代理好像挂了 看看那个能不能切换一个节点试试" [ref=e418] - - button "https://relakkes.feishu.cn/docx/GjgxdIuMLoKSTfxhPjLcN6Hxnyg ..." [ref=e420] - - button "https://relakkes.feishu.cn/docx/GjgxdIuMLoKSTfxhPjLcN6Hxnyg 读取飞书文档,然后帮我生成一个PPT网站..." [ref=e422] - - button "fix-video-segment-display" [ref=e424] - - button "@SKILL.md @references/prompt-template.md 我们这个skill写的太复杂了,我觉得就用模板里的就能很好的完成任务,另外只是..." [ref=e426] - - button "https://relakkes.feishu.cn/docx/GjgxdIuMLoKSTfxhPjLcN6Hxnyg '/Users/nanmi/个人自媒..." [ref=e428] - - button "opencut /opencut..." [ref=e430] - - button "/resume resume/exit exit/exit exit/exit exit/exit exitbilibili-chapter-generator /bil..." [ref=e474] - - button "把前端学习项目启动起来" [ref=e476] - - button "improve-template-ui" [ref=e478] - - button "@.github @PicTacticAgent 这样子的我有一个图片生成的A I A G的项目,现在我也准备把它放到Pro项目里面去了,作为订阅Pro赠送..." [ref=e480] - - button "hello" [ref=e482] - - button "hello" [ref=e484] - - button "解读一下当前仓库代码" [ref=e486] - - button "opencut /opencut..." [ref=e488] - - button "@qwen-omni-slides 第 5 页 PPT 有问题,我们根本就不是这一个什么瀑布流的需求 @2026-03-30-qwen35-omni-.md @..." [ref=e490] - - button "@README.md @docs 在我们的 ReadMe 中,在顶部合适的位置,帮我们把运行时的图片放过去 同时还有 8 张整个源码的架构图,你把它放到两行,..." [ref=e492] - - main [ref=e30]: - - generic [ref=e31]: - - generic [ref=e33]: - - img [ref=e35] - - heading "New session" [level=1] [ref=e52] - - paragraph [ref=e53]: Start a fresh coding session. Claude is ready to help you build, debug, and architect your project. - - generic [ref=e55]: - - generic [ref=e56] - - generic [ref=e63] - - generic [ref=e73]: - - generic [ref=e74]: - - generic [ref=e75]: "N" - - generic [ref=e76]: - - generic [ref=e77]: nanmi - - generic [ref=e78]: Max plan - - generic [ref=e80]: - - img [ref=e81] - - generic [ref=e83]: claude-code-haha - - img [ref=e84] - - generic [ref=e88]: main - - generic [ref=e89]: Haiku 4.5 \ No newline at end of file diff --git a/slash-command-chatinput.png b/slash-command-chatinput.png deleted file mode 100644 index 3c38c4d1..00000000 Binary files a/slash-command-chatinput.png and /dev/null differ diff --git a/slash-commands-test.png b/slash-commands-test.png deleted file mode 100644 index bef4faa5..00000000 Binary files a/slash-commands-test.png and /dev/null differ diff --git a/ui-active-session.png b/ui-active-session.png deleted file mode 100644 index 92e4adf0..00000000 Binary files a/ui-active-session.png and /dev/null differ diff --git a/ui-after-response.png b/ui-after-response.png deleted file mode 100644 index 58d0f1f2..00000000 Binary files a/ui-after-response.png and /dev/null differ diff --git a/ui-bypass-mode-test.png b/ui-bypass-mode-test.png deleted file mode 100644 index 5b426a6e..00000000 Binary files a/ui-bypass-mode-test.png and /dev/null differ diff --git a/ui-complete-session.png b/ui-complete-session.png deleted file mode 100644 index aee88dec..00000000 Binary files a/ui-complete-session.png and /dev/null differ diff --git a/ui-dir-picker-working.png b/ui-dir-picker-working.png deleted file mode 100644 index 20c48746..00000000 Binary files a/ui-dir-picker-working.png and /dev/null differ diff --git a/ui-directory-picker.png b/ui-directory-picker.png deleted file mode 100644 index b39b2e76..00000000 Binary files a/ui-directory-picker.png and /dev/null differ diff --git a/ui-fibonacci-test.png b/ui-fibonacci-test.png deleted file mode 100644 index 6d9f068d..00000000 Binary files a/ui-fibonacci-test.png and /dev/null differ diff --git a/ui-final-composer.png b/ui-final-composer.png deleted file mode 100644 index 656db3c4..00000000 Binary files a/ui-final-composer.png and /dev/null differ diff --git a/ui-final-result.png b/ui-final-result.png deleted file mode 100644 index 1bddf912..00000000 Binary files a/ui-final-result.png and /dev/null differ diff --git a/ui-final-test.png b/ui-final-test.png deleted file mode 100644 index ceeab12c..00000000 Binary files a/ui-final-test.png and /dev/null differ diff --git a/ui-message-queued.png b/ui-message-queued.png deleted file mode 100644 index 78aab501..00000000 Binary files a/ui-message-queued.png and /dev/null differ diff --git a/ui-new-empty-session.png b/ui-new-empty-session.png deleted file mode 100644 index 19d2b0e8..00000000 Binary files a/ui-new-empty-session.png and /dev/null differ diff --git a/ui-no-duplicates.png b/ui-no-duplicates.png deleted file mode 100644 index eb625124..00000000 Binary files a/ui-no-duplicates.png and /dev/null differ diff --git a/ui-no-duplication-final.png b/ui-no-duplication-final.png deleted file mode 100644 index a504820b..00000000 Binary files a/ui-no-duplication-final.png and /dev/null differ diff --git a/ui-project-selected.png b/ui-project-selected.png deleted file mode 100644 index f349e360..00000000 Binary files a/ui-project-selected.png and /dev/null differ diff --git a/ui-recent-projects.png b/ui-recent-projects.png deleted file mode 100644 index 181db6e8..00000000 Binary files a/ui-recent-projects.png and /dev/null differ diff --git a/ui-response-complete.png b/ui-response-complete.png deleted file mode 100644 index 4970a69c..00000000 Binary files a/ui-response-complete.png and /dev/null differ diff --git a/ui-session-response.png b/ui-session-response.png deleted file mode 100644 index 06c86753..00000000 Binary files a/ui-session-response.png and /dev/null differ diff --git a/ui-tool-calls-live.png b/ui-tool-calls-live.png deleted file mode 100644 index e0740b92..00000000 Binary files a/ui-tool-calls-live.png and /dev/null differ diff --git a/ui-tool-calls-result.png b/ui-tool-calls-result.png deleted file mode 100644 index 6b48c6eb..00000000 Binary files a/ui-tool-calls-result.png and /dev/null differ diff --git a/ui-tool-calls-test.png b/ui-tool-calls-test.png deleted file mode 100644 index b2ab4113..00000000 Binary files a/ui-tool-calls-test.png and /dev/null differ diff --git a/ui-tool-calls-working.png b/ui-tool-calls-working.png deleted file mode 100644 index ff9fee42..00000000 Binary files a/ui-tool-calls-working.png and /dev/null differ diff --git a/ui-tool-display-final.png b/ui-tool-display-final.png deleted file mode 100644 index fb9029f9..00000000 Binary files a/ui-tool-display-final.png and /dev/null differ diff --git a/ui-upgrade-initial.png b/ui-upgrade-initial.png deleted file mode 100644 index 377d4956..00000000 Binary files a/ui-upgrade-initial.png and /dev/null differ