chore: gitignore .claude and docs/superpowers, clean up temp files

Remove .claude/ and docs/superpowers/ from git tracking as they are
local-only config and planning files. Also remove stale screenshots
and update docs/channel content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 00:50:43 +08:00
parent db19491fc8
commit f2fa6fc56d
67 changed files with 617 additions and 6700 deletions

View File

@ -1,12 +0,0 @@
{
"tasks": [
{
"id": "b4851079",
"cron": "0 9 * * *",
"prompt": "test prompt",
"createdAt": 1775497208158,
"recurring": true,
"lastFiredAt": 1775525400260
}
]
}

9
.gitignore vendored
View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

View File

@ -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/<platform>/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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

View File

@ -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"}
{"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"}

View File

@ -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/' },

View File

@ -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/)。
> 本文保留为方案演进记录,不再作为接入说明。
<p align="center">
<a href="#一背景与动机">背景</a> ·

View File

@ -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** | `<channel>` 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 的贡献者
- 想做架构对比和二次设计的人

197
docs/im/feishu.md Normal file
View File

@ -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`

166
docs/im/index.md Normal file
View File

@ -0,0 +1,166 @@
# IM 接入
> 当前可用的 IM 接入方案总览。
> 如果你只是想把 Telegram 或飞书接进来,从这篇开始。
## 当前方案是什么
当前仓库里的 IM 接入,已经不是早期文档里设想的 “IM Gateway / MCP Channel 插件” 主路径。
现在真实在跑的是一条更轻的链路:
```mermaid
flowchart TD
A["Desktop Webapp<br/>Settings -> IM 接入"] --> B["GET / PUT /api/adapters"]
B --> C["Desktop Server<br/>配置接口"]
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<br/>是否已有 session?"}
K -->|有历史映射| L["复用已有 sessionId"]
K -->|无历史映射| M{"是否配置<br/>defaultProjectDir?"}
M -->|是| N["POST /api/sessions<br/>创建新 session"]
M -->|否| O["GET /api/sessions/recent-projects<br/>让用户选择项目"]
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/`

172
docs/im/telegram.md Normal file
View File

@ -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`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 256 KiB

View File

@ -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 实现)

View File

@ -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
<div className="min-w-full font-[var(--font-mono)] text-[12px] leading-[1.3]">
```
- [ ] **Step 2: Reduce line padding from py-1 to py-px**
In `CodeViewer.tsx`, line 66, change the line number span padding:
```tsx
<span className="select-none border-r border-[#eaeef2] bg-[#fafbfc] px-2 py-px text-right text-[11px] text-[#8b949e]">
```
In `CodeViewer.tsx`, line 73, change the code content span padding:
```tsx
<span
className="overflow-hidden bg-white px-3 py-px whitespace-pre-wrap break-words text-[#24292f]"
dangerouslySetInnerHTML={{ __html: highlightedLines[index] ?? escapeHtml(line) }}
/>
```
- [ ] **Step 3: Soften border colors and remove per-line borders**
In `CodeViewer.tsx`, line 61-63, simplify the row div:
```tsx
<div
key={`${startLine + index}-${line}`}
className="grid grid-cols-[3rem,minmax(0,1fr)] gap-0 hover:bg-[#f6f8fa]/50"
>
```
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
<div className="overflow-hidden rounded-lg border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]">
```
In `CodeViewer.tsx`, line 45, reduce header padding from `py-2` to `py-1.5`:
```tsx
<div className="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-1.5 text-[11px] text-[#57606a]">
```
- [ ] **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
<button
onClick={() => setExpanded((value) => !value)}
className="w-full border-t border-[#d0d7de] bg-[#f6f8fa] py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#57606a] transition-colors hover:bg-[#eaeef2] hover:text-[#24292f]"
>
```
- [ ] **Step 6: Verify changes visually**
Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npm run dev`
Open the app and send a message that produces code blocks. Verify:
- Lines are visually tighter (~16px per line instead of ~21px)
- Line numbers are subdued (light gray, no strong background contrast)
- No per-line horizontal borders
- Expand/collapse button is readable
- [ ] **Step 7: Commit**
```bash
git add desktop/src/components/chat/CodeViewer.tsx
git commit -m "fix: compact code block rendering — tighter line height, softer borders"
```
---
### Task 2: Fix DiffViewer Line Height and Add Gutter
**Files:**
- Modify: `desktop/src/components/chat/DiffViewer.tsx:49-79`
- [ ] **Step 1: Fix line height and padding in DiffViewer rows**
In `DiffViewer.tsx`, line 67, change the row container:
```tsx
className={`grid grid-cols-[2.5rem,2.5rem,1.25rem,minmax(0,1fr)] gap-0 font-[var(--font-mono)] text-[12px] leading-[1.3] ${rowClass}`}
```
Key changes: `leading-[1.45]``leading-[1.3]`, column widths from `3rem` to `2.5rem`.
- [ ] **Step 2: Fix line number and content padding**
In `DiffViewer.tsx`, lines 69-76, change all `py-1` to `py-px` and soften border colors:
```tsx
<span className="select-none border-r border-[#eaeef2] px-2 py-px text-right text-[11px] text-[#8b949e]">
{line.oldLineNo ?? ''}
</span>
<span className="select-none border-r border-[#eaeef2] px-2 py-px text-right text-[11px] text-[#8b949e]">
{line.newLineNo ?? ''}
</span>
<span className={`border-r border-[#eaeef2] px-1 py-px text-center ${prefixColor}`}>{prefix}</span>
<span className="whitespace-pre-wrap break-words px-3 py-px text-[#24292f]">{line.content}</span>
```
- [ ] **Step 3: Add gutter color indicator for added/removed lines**
Add a left border indicator to each row. In `DiffViewer.tsx`, update the `rowClass` computation (around line 50-55):
```tsx
const rowClass =
line.type === 'added'
? 'bg-[#dafbe1]/60 border-l-2 border-l-[#1a7f37]'
: line.type === 'removed'
? 'bg-[#ffebe9]/60 border-l-2 border-l-[#cf222e]'
: 'bg-white border-l-2 border-l-transparent'
```
- [ ] **Step 4: Soften the outer container**
In `DiffViewer.tsx`, line 28, change `rounded-2xl` to `rounded-lg`:
```tsx
<div className="overflow-hidden rounded-lg border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]">
```
Also reduce header padding in line 29:
```tsx
<div className="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-1.5">
```
- [ ] **Step 5: Commit**
```bash
git add desktop/src/components/chat/DiffViewer.tsx
git commit -m "fix: compact diff viewer — tighter rows, gutter indicators, softer borders"
```
---
### Task 3: Sync MarkdownRenderer Code Blocks
**Files:**
- Modify: `desktop/src/components/markdown/MarkdownRenderer.tsx:11-41`
- [ ] **Step 1: Update the code block renderer to match CodeViewer fixes**
In `MarkdownRenderer.tsx`, replace the entire `renderer.code` function (lines 11-41):
```tsx
renderer.code = function ({ text, lang }: Tokens.Code) {
const languageLabel = escapeHtml(lang || 'code')
const lines = text.split('\n')
const highlightedLines = highlightCodeLines(text, lang)
const body = highlightedLines
.map((line, index) => `
<div class="grid grid-cols-[3rem,minmax(0,1fr)] gap-0 hover:bg-[#f6f8fa]/50">
<span class="select-none border-r border-[#eaeef2] bg-[#fafbfc] px-2 py-px text-right text-[11px] text-[#8b949e]">${index + 1}</span>
<span class="overflow-hidden bg-white px-3 py-px whitespace-pre-wrap break-words text-[#24292f]">${line || '&nbsp;'}</span>
</div>
`)
.join('')
return `
<div class="my-4 overflow-hidden rounded-lg border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]">
<div class="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-1.5 text-[11px] text-[#57606a]">
<div class="flex items-center gap-3">
<span class="font-semibold uppercase tracking-[0.14em] text-[#57606a]">${languageLabel}</span>
<span>${lines.length} ${lines.length === 1 ? 'line' : 'lines'}</span>
</div>
<button class="rounded-md border border-[#d0d7de] bg-white px-2 py-0.5 text-[11px] text-[#57606a] transition-colors hover:bg-[#f3f4f6] hover:text-[#24292f]" data-copy-code="${escapeHtml(text)}">
Copy
</button>
</div>
<div class="max-h-[420px] overflow-auto">
<div class="min-w-full font-[var(--font-mono)] text-[12px] leading-[1.3]">${body}</div>
</div>
</div>
`
}
```
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<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type Props = {
toolCalls: ToolCall[]
resultMap: Map<string, ToolResult>
/** When true, the last tool is still executing — show expanded */
isStreaming?: boolean
}
const TOOL_VERBS: Record<string, (count: number) => 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<string, number>()
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<string, ToolResult>): 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 (
<ToolCallBlock
toolName={tc.toolName}
input={tc.input}
result={result ? { content: result.content, isError: result.isError } : null}
/>
)
}
const [expanded, setExpanded] = useState(false)
const summary = generateSummary(toolCalls)
const errorPresent = hasErrors(toolCalls, resultMap)
const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId))
return (
<div className="mb-2 ml-10">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left transition-colors ${
errorPresent
? 'border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/30 hover:bg-[var(--color-error-container)]/50'
: 'border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-container-high)]'
}`}
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">
{expanded ? 'expand_less' : 'expand_more'}
</span>
<span className="flex-1 truncate text-[12px] text-[var(--color-text-secondary)]">
{summary}
</span>
{!isStreaming && allComplete && !errorPresent && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
)}
{errorPresent && (
<span className="rounded-full bg-[var(--color-error-container)] px-2 py-0.5 text-[9px] font-bold uppercase text-[var(--color-error)]">
ERROR
</span>
)}
{isStreaming && (
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-brand)] animate-pulse-dot" />
)}
</button>
{expanded && (
<div className="mt-1.5 space-y-1">
{toolCalls.map((tc) => {
const result = resultMap.get(tc.toolUseId)
return (
<ToolCallBlock
key={tc.id}
toolName={tc.toolName}
input={tc.input}
result={result ? { content: result.content, isError: result.isError } : null}
compact
/>
)
})}
</div>
)}
</div>
)
}
```
- [ ] **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
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2 ml-10'
}`}>
```
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
<button
type="button"
onClick={() => {
if (expandable) {
setExpanded((value) => !value)
}
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">{icon}</span>
<span className="text-[11px] font-semibold text-[var(--color-text-secondary)]">
{toolName}
</span>
{filePath ? (
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{filePath.split('/').pop()}
</span>
) : summary ? (
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{summary}
</span>
) : (
<span className="flex-1" />
)}
{result && outputSummary && (
<span className="shrink-0 text-[10px] text-[var(--color-outline)]">
{outputSummary}
</span>
)}
{result?.isError && (
<span className="shrink-0 rounded-full bg-[var(--color-error-container)] px-1.5 py-0.5 text-[9px] font-bold text-[var(--color-error)]">
ERROR
</span>
)}
{expandable && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">
{expanded ? 'expand_less' : 'expand_more'}
</span>
)}
</button>
```
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<string, { label: string; className: string }> = {
// 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<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
/**
* 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<string>): 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<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
}, [messages.length, streamingText])
// Build lookup maps
const toolUseIds = new Set<string>()
const toolResultMap = new Map<string, ToolResult>()
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 (
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="mx-auto max-w-[860px]">
{renderItems.map((item) => {
if (item.kind === 'tool_group') {
return (
<ToolCallGroup
key={item.id}
toolCalls={item.toolCalls}
resultMap={toolResultMap}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
/>
)
}
const msg = item.message
return (
<MessageBlock
key={msg.id}
message={msg}
activeThinkingId={activeThinkingId}
toolResult={
msg.type === 'tool_use'
? toolResultMap.get(msg.toolUseId) ?? null
: null
}
/>
)
})}
{streamingText && chatState === 'streaming' && (
<AssistantMessage content={streamingText} isStreaming />
)}
{chatState !== 'idle' && chatState !== 'streaming' && chatState !== 'permission_pending' && (
<StreamingIndicator />
)}
<div ref={bottomRef} />
</div>
</div>
)
}
function MessageBlock({
message,
activeThinkingId,
toolResult,
}: {
message: UIMessage
activeThinkingId: string | null
toolResult?: { content: unknown; isError: boolean } | null
}) {
switch (message.type) {
case 'user_text':
return <UserMessage content={message.content} attachments={message.attachments} />
case 'assistant_text':
return <AssistantMessage content={message.content} />
case 'thinking':
return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} />
case 'tool_use':
if (message.toolName === 'AskUserQuestion') {
return (
<AskUserQuestion
toolUseId={message.toolUseId}
input={message.input}
/>
)
}
return (
<ToolCallBlock
toolName={message.toolName}
input={message.input}
result={toolResult}
/>
)
case 'tool_result':
return (
<ToolResultBlock
content={message.content}
isError={message.isError}
standalone
/>
)
case 'permission_request':
return (
<PermissionDialog
requestId={message.requestId}
toolName={message.toolName}
input={message.input}
description={message.description}
/>
)
case 'error':
return (
<div className="mb-3 px-4 py-2.5 rounded-lg bg-red-50 border border-red-200 text-sm text-[var(--color-error)]">
<strong>Error:</strong> {message.message}
</div>
)
case 'system':
return (
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">
{message.content}
</div>
)
}
}
```
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 (
<div className="group relative mb-3 ml-10">
{/* Copy button — absolute positioned, no reserved space */}
{!isStreaming && content.trim() && (
<button
onClick={handleCopy}
className="absolute -right-1 -top-1 rounded-md border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-2 py-0.5 text-[10px] text-[var(--color-text-tertiary)] opacity-0 shadow-sm transition-opacity hover:text-[var(--color-text-primary)] group-hover:opacity-100"
>
{copied ? 'Copied' : 'Copy'}
</button>
)}
<div className="text-sm text-[var(--color-text-primary)]">
<MarkdownRenderer content={content} />
{isStreaming && (
<span className="inline-block w-0.5 h-4 bg-[var(--color-brand)] animate-shimmer ml-0.5 align-text-bottom" />
)}
</div>
</div>
)
}
```
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<HTMLDivElement>(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 (
<div className="mb-1 ml-10">
<style>{thinkingStyles}</style>
<button
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-1.5 rounded-md px-1 py-0.5 text-left text-[12px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-secondary)]"
>
<span className="text-[10px] text-[var(--color-outline)]">
{expanded ? '\u25BE' : '\u25B8'}
</span>
<span className="shrink-0 font-medium italic">
Thinking
{isActive && <span className="thinking-dots" />}
</span>
{!expanded && preview && (
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{preview}
{isActive && <span className="thinking-inline-cursor" />}
</span>
)}
</button>
{expanded && (
<div
ref={contentRef}
className="mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-lowest)] p-2.5 font-[var(--font-mono)] text-[11px] leading-[1.35] text-[var(--color-text-secondary)] whitespace-pre-wrap break-words"
>
{content}
{isActive && expanded && <span className="thinking-cursor" />}
</div>
)}
</div>
)
}
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 (
<div className="mb-2 ml-10 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1">
<span className="text-[var(--color-brand)] animate-shimmer text-xs"></span>
<span className="text-xs font-medium text-[var(--color-text-secondary)]">{verb}...</span>
</div>
)
}
```
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"
```

View File

@ -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 (
<div className="overflow-hidden rounded-lg border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]">
<div className="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-1.5 text-[11px] text-[#57606a]">
<div className="flex items-center gap-3">
<span className="font-semibold uppercase tracking-[0.14em] text-[#57606a]">{languageLabel}</span>
<span>{lineCountLabel}</span>
</div>
<CopyButton
text={code}
className="rounded-md border border-[#d0d7de] bg-white px-2 py-1 text-[11px] text-[#57606a] transition-colors hover:bg-[#f3f4f6] hover:text-[#24292f]"
/>
</div>
<div className="max-h-[420px] overflow-auto">
<Highlight theme={themes.github} code={visibleCode} language={language || 'text'}>
{({ tokens, getLineProps, getTokenProps }) => (
<pre className="m-0 bg-white p-0 font-[var(--font-mono)] text-[12px] leading-[1.3]">
{tokens.map((line, i) => {
const lineProps = getLineProps({ line })
return effectiveShowLineNumbers ? (
<div
key={i}
{...lineProps}
className="grid grid-cols-[3rem,minmax(0,1fr)] gap-0 hover:bg-[#f6f8fa]/50"
style={undefined}
>
<span className="select-none border-r border-[#eaeef2] bg-[#fafbfc] px-2 py-px text-right text-[11px] text-[#8b949e]">
{i + 1}
</span>
<span className="overflow-hidden px-3 py-px whitespace-pre-wrap break-words">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
</div>
) : (
<div
key={i}
{...lineProps}
className="hover:bg-[#f6f8fa]/50"
style={undefined}
>
<span className="block px-3 py-px whitespace-pre-wrap break-words">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
</div>
)
})}
</pre>
)}
</Highlight>
</div>
{showExpandToggle && (
<button
onClick={() => setExpanded((value) => !value)}
className="w-full border-t border-[#d0d7de] bg-[#f6f8fa] py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#57606a] transition-colors hover:bg-[#eaeef2] hover:text-[#24292f]"
>
{expanded ? 'Collapse' : `Show ${allLines.length - maxLines} more lines`}
</button>
)}
</div>
)
}
```
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<string, string> = {
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 (
<Highlight theme={themes.github} code={str} language={language}>
{({ tokens, getTokenProps }) => (
<>
{tokens.map((line, i) => (
<span key={i}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
))}
</>
)}
</Highlight>
)
}
/** 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 (
<div className="overflow-hidden rounded-lg border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-[var(--font-mono)] text-[11px] text-[#57606a]">
{filePath}
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] uppercase tracking-[0.14em]">
<span className="rounded-full bg-[#dafbe1] px-2 py-0.5 text-[#1a7f37]">+{additions}</span>
<span className="rounded-full bg-[#ffebe9] px-2 py-0.5 text-[#cf222e]">-{deletions}</span>
</div>
</div>
<CopyButton
text={`--- ${filePath}\n+++ ${filePath}`}
label="Copy path"
className="rounded-md border border-[#d0d7de] bg-white px-2 py-1 text-[11px] text-[#57606a] transition-colors hover:bg-[#f3f4f6] hover:text-[#24292f]"
/>
</div>
{/* Diff body */}
<div className="max-h-[400px] overflow-auto">
<ReactDiffViewer
oldValue={oldString}
newValue={newString}
splitView={false}
compareMethod={DiffMethod.WORDS}
renderContent={(str) => highlightSyntax(str, language)}
hideLineNumbers={false}
styles={diffStyles}
useDarkTheme={false}
/>
</div>
</div>
)
}
```
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 `<div data-codeblock="...">`, 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 `<div data-codeblock-id="${id}"></div>`
}
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 = `<div data-codeblock-id="${block.id}"></div>`
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<HTMLDivElement>) => {
const target = event.target as HTMLElement | null
const button = target?.closest<HTMLButtonElement>('[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 (
<div
className={proseClasses}
dangerouslySetInnerHTML={{ __html: html }}
onClick={handleClick}
/>
)
}
// Mixed content: interleave HTML fragments with React CodeViewer components
return (
<div className={proseClasses} onClick={handleClick}>
{parts.map((part, i) =>
part.type === 'html' ? (
<div key={i} dangerouslySetInnerHTML={{ __html: part.content }} />
) : (
<div key={part.block.id} className="my-4">
<CodeViewer
code={part.block.code}
language={part.block.language}
/>
</div>
)
)}
</div>
)
}
```
Key changes:
- Code blocks are now rendered as React `CodeViewer` components (with prism-react-renderer)
- `renderer.code` outputs a placeholder `<div>` 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).

File diff suppressed because it is too large Load Diff

View File

@ -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 AdapterTelegram/飞书)创建与 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<string, SessionEntry>
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<string> {
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<RecentProject[]> {
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:
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.adapters.defaultProject')}
</label>
<DirectoryPicker value={defaultProjectDir} onChange={setDefaultProjectDir} />
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.defaultProjectHint')}
</p>
</div>
```
- [ ] **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<string, boolean>()
```
- [ ] **Step 2: Replace setupMessageHandler with session-aware version**
Replace the current `setupMessageHandler` function with:
```typescript
async function ensureSession(chatId: string): Promise<boolean> {
// 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<boolean> {
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<void> {
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<void> {
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<string, boolean>()
```
- [ ] **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<boolean> {
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<boolean> {
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<void> {
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"
```

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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<TimeGroup, SessionListItem[]> {
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

View File

@ -1,221 +0,0 @@
# Multi-Tab Sessions Design Spec
## Overview
Desktop webapp (Tauri + React + Zustand) 新增多 Tab 功能,允许用户同时打开多个 Session 并行工作。类似 VS Code 的编辑器 Tab 与文件浏览器的关系Sidebar 浏览所有 SessionTab 栏管理当前打开的 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<string, PerSessionState>
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<string, WebSocketConnection>
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 滚动

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

View File

@ -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 "<command-message>opencut</command-message> <command-name>/opencut</command-name>..." [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 "<command-name>/clear</command-name> <command-message>clear</command-..." [ref=e318]
- button "multimodal-video-analysis" [ref=e320]
- 'button "[Image #1] 现在是 64GB 内存 MacBook Pro M5 Pro 的芯片,我应该选哪一个版本" [ref=e322]'
- button "@docs @docs/features/computer-use.md 我们的 Computer Use我建议我们再补一些文档同时补一些配图。底层是怎么..." [ref=e324]
- button "'/Users/nanmi/Screen Studio Projects/glm5v_副本2.screenstudio' 剪辑" [ref=e326]
- button "vitepress-docs-site-setup" [ref=e328]
- 'button "[Image #1] 检查一下我马上这个mcp链接不上了" [ref=e330]'
- button "分析下这个claude code的源代码它里面的记忆模式代码有吗你开启多个subgent探索一下" [ref=e332]
- button "add-seedance-video-provider" [ref=e334]
- button "readme-slimdown-refactor" [ref=e336]
- button "利用websearch工具搜索一下深圳今天的天气" [ref=e338]
- button "hello" [ref=e340]
- button "你现在是什么工作目录" [ref=e342]
- button "现在是什么目录" [ref=e344]
- button "现在是什么目录" [ref=e346]
- generic [ref=e146]:
- generic [ref=e147]: Previous 7 Days
- button "<command-name>/effort</command-name> <command-message>effort</comman..." [ref=e348]
- button "使用 pictactic 异步生成一张图片测试一下生图功能" [ref=e350]
- button "hello" [ref=e352]
- button "打开网易云音乐搜索 喜欢你 选择邓紫棋版本的并播放,然后开启歌词" [ref=e354]
- button "use computer use open my 网易云音乐,并搜索 喜欢你 这首歌,点击播放,然后开启歌词功能" [ref=e356]
- button "enable-computer-use-docs" [ref=e358]
- button "hello 帮我打开我电脑里的网易云音乐 搜索 喜欢你 然后点击播放,再开启歌词预览" [ref=e360]
- button "帮我打开我电脑里的网易云音乐 搜索 喜欢你 然后点击播放,再开启歌词预览" [ref=e362]
- button "帮我打开我电脑上的网易云音乐,然后搜索一首歌" [ref=e364]
- button "帮我截屏" [ref=e366]
- button "test" [ref=e368]
- button "hi" [ref=e370]
- button "\"截个屏\"测试 Computer Use 是否加载。如果有报错,把错误截图发给我。" [ref=e372]
- button "hi" [ref=e374]
- button "hi" [ref=e376]
- button "test" [ref=e378]
- button "帮我打开我电脑上的网易云音乐,然后搜索一首我喜欢你的歌" [ref=e380]
- button "<command-message>review</command-message> <command-name>/review</command-name> <..." [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 "<command-message>bilibili-chapter-generator</command-message> <command-name>/bil..." [ref=e410]
- button "@千问AI_3分钟视频脚本.docx @【程序员阿江-Relakkes 】-视频大纲-4.5.docx @千问AI:硬核技术 AI office - 传播bri..." [ref=e412]
- button "@千问AI_3分钟视频脚本.docx 帮我把这个脚本写到飞书里,不要写时间线,按分类写好,顶部就是视频的标题" [ref=e414]
- button "<command-message>opencut</command-message> <command-name>/opencut</command-name>..." [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 "<command-message>opencut</command-message> <command-name>/opencut</command-name>..." [ref=e430]
- button "<command-name>/resume</command-name> <command-message>resume</comman..." [ref=e432]
- button "hello" [ref=e434]
- 'button "[Image #1] [Image #3] 帮我还原一下这个健身 App。配色也给你了你给我写一下它整体的页面一共 4 个页面" [ref=e436]'
- button "hello" [ref=e438]
- button "hello" [ref=e440]
- button "hello" [ref=e442]
- button "hello" [ref=e444]
- button "hello Whats your model" [ref=e446]
- button "<command-name>/exit</command-name> <command-message>exit</command-me..." [ref=e448]
- button "<command-name>/exit</command-name> <command-message>exit</command-me..." [ref=e450]
- button "<command-name>/exit</command-name> <command-message>exit</command-me..." [ref=e452]
- 'button "我们的整个配色交互这些太丑陋了 [Image #2] [Image #3] 看下人家的设计 这是我的原型图 [Image #4]" [ref=e454]'
- button "<command-name>/exit</command-name> <command-message>exit</command-me..." [ref=e456]
- button "fittrack-app-implementation" [ref=e458]
- button "无参数和 --debug-file 这两条都还在跑,我把输出拉完。如果它们什么都不打印就退出,下一步我会直接看交互路径里有没有“检测到非 TTY 就早退”的逻辑..." [ref=e460]
- 'button "Unknown skill: eit" [ref=e462]'
- button "hello" [ref=e464]
- button "hello" [ref=e466]
- button "hello 你是什么模型" [ref=e468]
- button "@slides @2026-03-31-claude-code-leaked-architecture.md @/Users/nanmi/workspace/..." [ref=e470]
- button "@2026-04-01-claude-code-run-locally.md /Users/nanmi/个人自媒体/326-MiniMaxM2.7-评测/视频..." [ref=e472]
- button "<command-message>bilibili-chapter-generator</command-message> <command-name>/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 "<command-message>opencut</command-message> <command-name>/opencut</command-name>..." [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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB