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>
@ -1,12 +0,0 @@
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": "b4851079",
|
||||
"cron": "0 9 * * *",
|
||||
"prompt": "test prompt",
|
||||
"createdAt": 1775497208158,
|
||||
"recurring": true,
|
||||
"lastFiredAt": 1775525400260
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.gitignore
vendored
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 108 KiB |
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB |
BIN
after-send.png
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 206 KiB |
@ -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"}
|
||||
@ -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/' },
|
||||
|
||||
@ -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> ·
|
||||
|
||||
@ -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
@ -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
@ -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
@ -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`
|
||||
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 256 KiB |
@ -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 实现)
|
||||
|
||||
@ -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 || ' '}</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"
|
||||
```
|
||||
@ -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).
|
||||
@ -1,954 +0,0 @@
|
||||
# IM Adapter Session 持久化改造
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 IM Adapter(Telegram/飞书)创建与 Desktop App 互通的持久化 Session,实现聊天记录互通、Session 恢复、项目目录选择。
|
||||
|
||||
**Architecture:** Adapter 在连接 WebSocket 之前,先通过 HTTP `POST /api/sessions` 创建正式 UUID Session。chatId→sessionId 映射持久化到本地 JSON 文件,adapter 重启后可恢复。用户通过 `/projects` 命令从最近项目列表选择工作目录,或使用 Settings 页面配置的默认目录。
|
||||
|
||||
**Tech Stack:** Bun (HTTP fetch + WebSocket), existing server REST API (`/api/sessions`, `/api/sessions/recent-projects`), JSON file persistence
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `adapters/common/session-store.ts` | Create | chatId→sessionId 持久化映射 |
|
||||
| `adapters/common/http-client.ts` | Create | 调用服务端 REST API(创建 session、列出项目) |
|
||||
| `adapters/common/ws-bridge.ts` | Modify | 移除自动 sessionId 生成,接受外部传入的 sessionId |
|
||||
| `adapters/telegram/index.ts` | Modify | 接入 session 管理 + `/projects` 命令 |
|
||||
| `adapters/feishu/index.ts` | Modify | 同 Telegram 的改造 |
|
||||
| `adapters/common/config.ts` | Modify | 新增 `defaultProjectDir` 顶层配置字段 |
|
||||
| `src/server/services/adapterService.ts` | Modify | AdapterFileConfig 新增 `defaultProjectDir` |
|
||||
| `desktop/src/types/adapter.ts` | Modify | 前端类型同步 |
|
||||
| `desktop/src/pages/AdapterSettings.tsx` | Modify | 新增"默认项目"字段 |
|
||||
| `desktop/src/i18n/locales/en.ts` | Modify | i18n |
|
||||
| `desktop/src/i18n/locales/zh.ts` | Modify | i18n |
|
||||
| `adapters/common/__tests__/session-store.test.ts` | Create | session-store 测试 |
|
||||
| `adapters/common/__tests__/http-client.test.ts` | Create | http-client 测试 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Session Store — 持久化 chatId→sessionId 映射
|
||||
|
||||
**Files:**
|
||||
- Create: `adapters/common/session-store.ts`
|
||||
- Create: `adapters/common/__tests__/session-store.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```typescript
|
||||
// adapters/common/__tests__/session-store.test.ts
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { SessionStore } from '../session-store.js'
|
||||
|
||||
describe('SessionStore', () => {
|
||||
let tmpDir: string
|
||||
let store: SessionStore
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-'))
|
||||
store = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns null for unknown chatId', () => {
|
||||
expect(store.get('unknown')).toBeNull()
|
||||
})
|
||||
|
||||
it('stores and retrieves a session', () => {
|
||||
store.set('chat-1', 'uuid-aaa', '/path/to/project')
|
||||
const entry = store.get('chat-1')
|
||||
expect(entry).not.toBeNull()
|
||||
expect(entry!.sessionId).toBe('uuid-aaa')
|
||||
expect(entry!.workDir).toBe('/path/to/project')
|
||||
})
|
||||
|
||||
it('overwrites existing entry on set', () => {
|
||||
store.set('chat-1', 'uuid-aaa', '/old')
|
||||
store.set('chat-1', 'uuid-bbb', '/new')
|
||||
expect(store.get('chat-1')!.sessionId).toBe('uuid-bbb')
|
||||
})
|
||||
|
||||
it('deletes an entry', () => {
|
||||
store.set('chat-1', 'uuid-aaa', '/path')
|
||||
store.delete('chat-1')
|
||||
expect(store.get('chat-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('persists to disk and reloads', () => {
|
||||
store.set('chat-1', 'uuid-aaa', '/path')
|
||||
|
||||
// Create a new store instance pointing to the same file
|
||||
const store2 = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
||||
expect(store2.get('chat-1')!.sessionId).toBe('uuid-aaa')
|
||||
})
|
||||
|
||||
it('handles missing file gracefully', () => {
|
||||
const store2 = new SessionStore(path.join(tmpDir, 'nonexistent.json'))
|
||||
expect(store2.get('anything')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists all entries', () => {
|
||||
store.set('chat-1', 'uuid-1', '/a')
|
||||
store.set('chat-2', 'uuid-2', '/b')
|
||||
const all = store.listAll()
|
||||
expect(all).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd adapters && bun test common/__tests__/session-store.test.ts`
|
||||
Expected: FAIL — module `../session-store.js` not found
|
||||
|
||||
- [ ] **Step 3: Implement SessionStore**
|
||||
|
||||
```typescript
|
||||
// adapters/common/session-store.ts
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
|
||||
export type SessionEntry = {
|
||||
sessionId: string
|
||||
workDir: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
type StoreData = Record<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"
|
||||
```
|
||||
@ -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
|
||||
@ -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
|
||||
@ -1,221 +0,0 @@
|
||||
# Multi-Tab Sessions Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Desktop webapp (Tauri + React + Zustand) 新增多 Tab 功能,允许用户同时打开多个 Session 并行工作。类似 VS Code 的编辑器 Tab 与文件浏览器的关系:Sidebar 浏览所有 Session,Tab 栏管理当前打开的 Session。
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Desktop App**: Tauri v2 + React 18 + Zustand 5 + Tailwind CSS 4 + Vite
|
||||
- **Backend Server**: `src/server/` — 已支持多 Session 并发 (`maxSessions` 配置)
|
||||
- **通信协议**: 每个 Session 独立 WebSocket (`/ws/${sessionId}`) + REST API
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| Tab 与 Sidebar 关系 | 独立共存 (VS Code 模式) | Sidebar 浏览全部历史, Tab 管理当前打开的 |
|
||||
| Tab 栏位置 | 内容区域顶部 (Sidebar 右侧上方) | 不影响 Sidebar 布局 |
|
||||
| 并发策略 | 每个 Tab 独立 WebSocket 连接 | 真正并行, 后端已支持, WS 连接轻量 |
|
||||
| Tab 数量限制 | 不限制 | 用户自由管理 |
|
||||
| Tab 溢出处理 | 固定宽度 + 左右滚动箭头 | VS Code 风格 |
|
||||
| 关闭运行中 Tab | 可配置 (默认弹确认框) | 配置项: ask / background / stop |
|
||||
| Tab 持久化 | 可配置 (默认恢复) | 存 localStorage, 启动时恢复 |
|
||||
| Sidebar 打开行为 | 去重 + 聚焦 | 已打开则跳转, 否则新 Tab |
|
||||
| 新建 Session 入口 | 仅 Sidebar | 保持单一入口 |
|
||||
|
||||
## Architecture Changes
|
||||
|
||||
### 1. New Tab Store (`tabStore.ts`)
|
||||
|
||||
新增 Zustand store 管理 Tab 状态:
|
||||
|
||||
```ts
|
||||
interface Tab {
|
||||
sessionId: string
|
||||
title: string
|
||||
status: 'idle' | 'running' | 'error'
|
||||
}
|
||||
|
||||
interface TabStore {
|
||||
tabs: Tab[]
|
||||
activeTabId: string | null // sessionId
|
||||
|
||||
openTab(sessionId: string, title: string): void
|
||||
closeTab(sessionId: string): void
|
||||
setActiveTab(sessionId: string): void
|
||||
updateTabTitle(sessionId: string, title: string): void
|
||||
updateTabStatus(sessionId: string, status: Tab['status']): void
|
||||
reorderTabs(fromIndex: number, toIndex: number): void
|
||||
|
||||
// Persistence
|
||||
saveTabs(): void
|
||||
restoreTabs(): Tab[]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. chatStore Refactor
|
||||
|
||||
从单 Session 改为多 Session 并发:
|
||||
|
||||
**Before**: 单一 `messages[]` + 单一 `connectedSessionId`
|
||||
**After**: 按 sessionId 隔离的 session map
|
||||
|
||||
```ts
|
||||
interface PerSessionState {
|
||||
messages: UIMessage[]
|
||||
connectionState: 'connecting' | 'connected' | 'disconnected'
|
||||
streamingState: StreamingState
|
||||
permissionRequests: PermissionRequest[]
|
||||
tasks: TaskState[]
|
||||
}
|
||||
|
||||
interface ChatStore {
|
||||
sessions: Record<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 滚动
|
||||
BIN
final-ui.png
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 95 KiB |
BIN
new-session.png
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 122 KiB |
@ -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
|
||||
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 105 KiB |