diff --git a/CLAUDE.md b/CLAUDE.md index 6200c3ab0..e72722605 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,8 @@ The Superpowers plugin is disabled for this repository. Do not invoke or follow - **回复中的三种发送**(对标 Claude 桌面版 next / later / now):Enter = 软插话 `/turn/steer`,先画在对话末尾(`SteeredMessages`,排队状态 `lane: 'steer'`),气泡上「立即发送」走 `/turn/steer/send-now`(服务端把这条未读插话提成优先回合并打断,**不要**改成再调 `/turn/interrupt` 重发,会发两遍)、撤回走 `/turn/steer/withdraw` 且**消息退回输入框不删**(已有内容时放前面换行,`steerWithdrawal.js`);回合结束仍未读的插话转回排队卡片;Ctrl+Enter = 排到回合结束(原排队卡片),Ctrl+Alt+Enter = `fork` 带 `whole: true` 复制整个会话后在新会话发送。见 [docs/daemon-api.md](docs/daemon-api.md)。 - **侧边对话只放只读工具**(`side_chat.cpp` 小工具循环):白名单 `file_read` / `grep` / `glob` / `lsp` 与会话策略取交集,MCP 一律不放,**不要**改成按 `is_read_only` 筛;凡要弹确认的一律拒绝;在 `MtimeTracker::DetachedReadScope` 里执行;Web 侧边多一个 `send_to_main_session`,用户确认之前绝不送达,送达走 `send_side_chat_message`(有可插话回合就 steer,否则普通 send)。归档:侧边对话只放只读工具。 - **后台任务**:会话级登记表(`tool/background_tasks`,AgentLoop 持有)管后台命令与后台子代理,结束时经邮箱通知会话(在跑就模型边界插入,空闲就开新回合);bash 前台超时**转后台不杀**、`run_in_background` / `monitor`、开头长 sleep 拒绝;用户插话 / 「转到后台」/ TUI Ctrl+B 让前台工作让路;通知落盘为 user + `metadata.background_task`,按内部上下文处理(不计回合、不当标题)。Web 对话里任务状态挂在发起它的工具行上:工具行照常折在「已处理」里,任务没结束时包着它的每层折叠条只带炫光(不转圈、文案不变),会话空闲时末尾**不画**「N 个后台任务正在运行」(那行转圈让用户以为回合没结束,别加回去)。全文 [docs/background-tasks.md](docs/background-tasks.md)。 -- **子代理 / 蜂群**:`spawn_subagent` / `wait_subagent` 双端注册,子会话是普通 SessionRegistry 会话(父 cwd + 权限模式),不能再派子代理;子会话从会话列表隐藏、始终跟随主会话(无单独归档 / 删除,主会话永久删除才级联);蜂群 `off | star | mesh` 两套工具互斥,邮件进 AgentLoop 邮箱,信封落盘为 user 角色 + `metadata.inter_agent`,子 agent 完成不唤醒空闲父 agent,Web 芯片只在与服务端模式不同时才随消息提交 `swarm_mode`。Web 里每个子代理前面一个 16px 头像(绑定专家用专家的,否则按子会话 id 生成,对话 / 摘要面板 / 后台任务页签是同一张脸),右上角的状态图标与桌面像素办公室共用 `office_characters.js` 的 `miniSprite`(运行中三个点 + 点头,完成是星,中止是红色感叹号),只有运行中的头像订阅 `avatarStatus.js` 那只共用的钟;后台任务页签里子代理是单行无边框的行(标题、token 数、时间靠右,只有运行中的多一行状态,别的不显示),后台命令仍是卡片。全文 [docs/subagents.md](docs/subagents.md)。 +- **会话产物**(摘要面板「产物」分组,`GET /api/sessions/:id/outputs`):列表由会话记录推导、不另存状态,也**不改写文件的工具**(`file_write` 看 `tool_summary` 的 Created / Wrote,`apply_patch` 看 `metadata.files` 的 add,子代理跟 `subagent_session_id`);bash 跑脚本生成的文件由 `shell_output_files` 在命令结束后尽力找(提到的路径 + 扫工作目录里刚改过的文档类文件),记进 `metadata.output_files`,只读命令与纯构建 / 包管理 / git 命令不找,一条命令动了 12 个以上文档当批量操作作废;工作目录内只算文档类扩展名(代码归「变更」),`.acecode` 判定要先去掉会话文件夹自身那段(worktree 会话的工作目录就在它下面)。全文 [docs/session-outputs.md](docs/session-outputs.md)。 +- **子代理 / 蜂群**:`spawn_subagent` / `wait_subagent` 双端注册,子会话是普通 SessionRegistry 会话(父 cwd + 权限模式),不能再派子代理;子会话从会话列表隐藏、始终跟随主会话(无单独归档 / 删除,主会话永久删除才级联);蜂群 `off | star | mesh` 两套工具互斥,邮件进 AgentLoop 邮箱,信封落盘为 user 角色 + `metadata.inter_agent`,子 agent 完成不唤醒空闲父 agent,Web 芯片只在与服务端模式不同时才随消息提交 `swarm_mode`。Web 里每个子代理前面一个 16px 头像(绑定专家用专家的,否则按子会话 id 生成,对话 / 摘要面板 / 后台任务页签是同一张脸),右上角的状态图标与桌面像素办公室共用 `office_characters.js` 的 `miniSprite`(运行中三个点 + 点头,完成是星,中止是红色感叹号),只有运行中的头像订阅 `avatarStatus.js` 那只共用的钟;生成的头像默认是花瓣(`geometricAvatar.js`,按身份哈希生成的花形矢量图,状态靠转 / 停 / 变灰,不叠角标),设置 > 外观 > 子代理头像可换成像素小人(`web_ui.subagent_avatar_style` = geometric / pixel,走外观偏好那条 daemon 持久化的路,不能只存浏览器本地;用户明确不要单独的「子代理」设置栏);后台任务页签里子代理是单行无边框的行(标题、token 数、时间靠右,只有运行中的多一行状态,别的不显示),后台命令仍是卡片。全文 [docs/subagents.md](docs/subagents.md)。 - **写边界**:`AgentLoop::write_root()`(会话 worktree > LOOP 策略 > 继承)非空即有边界,Yolo 不免除,只有 dangerous 放行;子会话共享父会话 worktree 并继承写边界;bash 走 `shell_write_guard` 可证明写目标守卫(**守卫不是沙箱**,事后用 `workspace_touched` 快照兜底);附加文件夹不能打穿写边界。归档:写边界与子代理继承、多文件夹项目。 - **Worktree**:落在主仓根 `.acecode/worktrees/`,分支 `worktree-`;`EnterWorktree` / `ExitWorktree` 只在用户明确提到 worktree 时可调,会话存储位置不动;合回主干必须再调 `ExitWorktree`;`ExitWorktree remove` fail-closed;`--worktree [name]` 时 worktree 就是项目根。纯逻辑 [worktree_core](src/domain/worktree/worktree_core.hpp),git 层 [worktree_manager](src/domain/worktree/worktree_manager.hpp)。归档:Worktree 隔离。 - **Goal**:每会话至多一个,存项目级 `state.sqlite3`,只有 `active` 自动 continuation(Plan mode 不触发);unattended 下写工具自动放行、bash 仍取决策表沙盒;provider 终止错误把 goal 转 usage_limited(429)/ blocked,用户 abort 转 paused。归档:Thread Goals。 @@ -88,6 +89,7 @@ The Superpowers plugin is disabled for this repository. Do not invoke or follow - **流式渲染**:`renderMarkdownBlocks` 分块 + `useStreamingRenderThrottle` 100ms 降频,未闭合代码块不高亮;`.ace-chat-transcript-scroll` 关 overflow-anchor。归档:流式渲染防跳动。 - **对话内可视化与画图说明**:顶层 ```visual 代码块画在只有 `allow-scripts` 的 srcdoc iframe 里(CSP 只放行四个 CDN,本机 daemon 请求不到),同一组件从流式用到定稿,框内运行时被序列化进 iframe、**不能写中文字面量**;Chart.js 4 / d3 7 内置,由外层把本地代码递进框(不放宽 CSP);系统提示末尾的画图说明 mermaid 部分 TUI + Web 都有,visual 部分只在 daemon 且 `inline_visuals.enabled`,headless / 子会话 / 群聊代答不加,文本字节稳定;详细规范在内置技能 `inline-visuals`,改变量名、类名要与外壳一起改。见 [docs/inline-visuals.md](docs/inline-visuals.md)。 - **交互约定**:对话框键盘走 `components/Modal.jsx` + `lib/dialogKeyboard.js`(Esc / Tab 循环 / Enter 默认操作 / auto-repeat Enter 拦下),不要手写 `fixed inset-0` 遮罩;排队卡片在 transcript 外(`QueueCardList`),中断后队列暂停不自动出队;粘贴 ≥ 20 行 / 2000 字符折成卡片、≥ 128 KiB 落文件;`@` 会话引用有查询词必须走服务端 `q=`;侧栏拖进输入框 = @ 引用;Slate 选区在 compositionstart 前 `adoptDomSelection`。归档:对话框键盘约定、排队卡片栈、粘贴的文本块、`@` 会话引用、输入框光标。 +- **右侧文件树多选**(「文件」页签):Shift / Ctrl 选区规则在 `lib/fileTreeSelection.js`(只算画出来的行,折叠 / 刷新后看不见的行从选区裁掉;还没有选区时预览中的文件算已选),交互在 `lib/useFileTreeSelection.js`;多选右键走 `data-desktop-context-menu-delegate="file-tree-selection"` 委托(点在选区外就退回单行菜单),菜单每项后面的数字是它实际处理的条数;拖动是自绘的指针拖动,**不要**改成 HTML5 拖放(桌面壳会当成系统文件拖放拦走),松手派发 `FILE_TREE_DROP_EVENT`(`lib/fileTreeDrop.js`):输入框在光标处插 @路径(目录和它里面的行一起选中时只给最外层),控制台贴加好引号的绝对路径。 - **搜索与批量**:`Ctrl+K` 快捷键只有一个定义点 `lib/searchPaletteShortcut.js`(xterm 要放行);前端纯聚合所有 workspace,后端零路由变更;侧栏多选批量操作只发一次 `POST /api/session-batch`。 - **git 感知**:`/api/git/info` 必须走 `gitInfoCache.js`(一次 spawn 5~7 个 git),`GitSessionPill` 用 `pillCouldRender` 在请求前短路;`src/domain/gitinfo/` 的 ref 名进 prompt / REST / argv 前过 `is_safe_ref_name`;SidePanel「变更」tab 在 git 仓库走 git 级视图。归档:git 感知三件套。 - **HUD 主题**(荒坂,工坊专用、默认不启用,**不写进版本更新记录**):`themeHud.css` 每条选择器以 `:root[data-theme-chrome="hud"]` 开头(滚动条部件用 `html[...]`),`themeHud.test.js` 守着;音效 / 异形窗口 / 背景染色只影响声明它们的主题;动效不跟随系统「减少动态效果」(用户常在 RDP 里用)。归档:HUD 主题外观与多风格。 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 15aacda0b..3439f6b15 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -359,6 +359,7 @@ download when nothing changed. | GET | `/api/sessions/:id/background-tasks/:task_id/output` | tail of a background command's output | | POST | `/api/sessions/:id/background-tasks/:task_id/stop` | stop a running background task | | POST | `/api/sessions/:id/background` | move the running foreground command / subagent wait to the background | +| GET | `/api/sessions/:id/outputs` | files the conversation produced (documents, spreadsheets, images) | | GET | `/api/files` | list directory | | GET | `/api/files/content` | read text file | | GET | `/api/files/blob` | read previewable binary file | @@ -2580,6 +2581,45 @@ Asks the running foreground `bash` command or blocking subagent wait to hand its the background registry without interrupting it. `200 {"ok": true, "busy": bool}`; `busy` is false when no turn was running (the request has no effect then). +### Session outputs + +#### `GET /api/sessions/:id/outputs[?workspace=]` + +Files this conversation produced for the user to look at: documents, spreadsheets, +images, media, archives. Code changes are not outputs; they belong to the change views. +The summary panel's Outputs group is the consumer. Sources and filtering rules are in +[session-outputs.md](session-outputs.md). + +```json +{ + "session_id": "sid", + "outputs": [ + { + "path": "C:\\work\\docs\\plan.md", + "relative": "docs/plan.md", + "source": "write", + "size": 1820, + "modified_ms": 1794300000000 + } + ], + "has_more": false +} +``` + +- Derived from the session JSONL (plus the JSONL of its subagent sessions) and checked + against the disk on every request: files that no longer exist are omitted. The scan + is incremental per file, so polling is cheap. +- Ordered by file modification time, newest first, at most 50; `has_more` is true when + more exist. +- `path` is absolute. For files inside a session folder it is spelled with that folder's + own form (the one the client already knows as the workspace cwd) and `relative` is the + path below that folder with forward slashes; outside every session folder `relative` + is empty. +- `source`: `write` (`file_write`), `patch` (`apply_patch` added file), `shell` (found + after a `bash` command finished). +- Works for sessions that are not loaded: `workspace` is the same lookup hint as on the + history route. An unknown session returns an empty list, not `404`. + --- ## 6. Opencode Import and Pins @@ -3991,7 +4031,8 @@ Returns: "message_auto_collapse": true, "theme_variant": "", "theme_effects": true, - "theme_sounds": true + "theme_sounds": true, + "subagent_avatar_style": "geometric" } ``` @@ -4016,6 +4057,12 @@ off and does not follow the OS reduced-motion setting. `theme_sounds` (default All three are persisted, included in the Desktop appearance bootstrap, and rejected with `400` when malformed. +`subagent_avatar_style` is `geometric` (default, a generated petal rosette) or +`pixel` (the Agent Office character): the portrait drawn for sub-agents that +have no expert attached (Settings > Appearance). It is persisted, included in +the Desktop appearance bootstrap, and any other value is rejected with `400`; a +config file without the key or holding an unknown value loads as `geometric`. + ### `PUT /api/config/ui-preferences` Body: diff --git a/docs/session-outputs.md b/docs/session-outputs.md new file mode 100644 index 000000000..ddbe82bee --- /dev/null +++ b/docs/session-outputs.md @@ -0,0 +1,76 @@ +# 会话产物(摘要面板的「产物」分组) + +列出这次对话生成、给用户看的文件:报告、表格、图片、音视频、压缩包。代码改动不算 +产物,归「变更」。做法参照 Claude 桌面版的 Outputs 面板(界面包源码已核对):列表 +不是另存的状态,而是从会话记录推导出来的。Claude 只认 Write 工具和显式交付,这里 +多做了一步:bash 跑脚本生成的文件尽力找出来。 + +## 组成 + +| 位置 | 职责 | +|---|---| +| `src/domain/session/session_outputs.{hpp,cpp}` | 纯逻辑:从一条消息的元数据取候选、判扩展名、JSONL 行预筛、过滤去重 | +| `src/domain/session/session_outputs_index.{hpp,cpp}` | 读盘:增量扫描会话记录、跟到子代理会话、归一路径、核对文件还在不在、按修改时间排序 | +| `src/adapters/tool/shell_output_files.{hpp,cpp}` | bash 命令结束后找它生成的文件(下面「bash 探测」) | +| `src/adapters/tool/bash_tool.cpp` / `background_tasks.cpp` | 前台命令结束时、后台命令完成通知发出前各调一次探测,结果记进元数据 | +| `src/apps/web/routes/routes_session_outputs.cpp` | `GET /api/sessions/:id/outputs`(见 docs/daemon-api.md「Session outputs」) | +| `web/src/lib/sessionOutputs.js` | 前端纯整形:收起显示前 5 个、重名文件带所在目录、轮询结果没变不重渲染 | +| `web/src/components/SummaryPanel.jsx` | 「产物」分组:点一行在右侧预览打开,右键是「打开文件夹 / 复制文件路径」 | + +## 来源 + +全部取自会话记录里已有的工具结果元数据,**不改写文件的工具**,所以功能上线前的旧 +会话也有产物列表: + +| 工具 | 记录里的依据 | 算不算 | +|---|---|---| +| `file_write` | `metadata.tool_summary` 的动词 `Created` / `Wrote`,`object` 是路径 | 新建、覆写都算 | +| `apply_patch` | `metadata.files` 里 `type == "add"` 的条目 | 只算新建 | +| `file_edit`、`apply_patch` 的 update / move | — | 不算,相当于编辑 | +| `bash` | `metadata.output_files`;后台命令在完成通知的 `metadata.background_task.output_files` | 探测到的都算,命令失败也算 | +| 子代理 | 派生结果的 `metadata.subagent_session_id` | 跟到子会话的记录,它的产物并进主会话 | + +## 过滤 + +- 路径里有 `.acecode` / `.git` 这一级的不算。会话文件夹自身那一段除外:worktree 会话 + 的工作目录就在 `.acecode/worktrees` 下面,只按「路径里有 .acecode」判会让它的列表 + 永远是空的。 +- 会话文件夹(工作目录、worktree、附加文件夹)内:只留文档类扩展名(md、txt、pdf、 + docx、xlsx、pptx、csv、html、图片、音视频、zip 等,清单在 `session_outputs.cpp`)。 +- 会话文件夹外:系统临时目录里的不算,其余任何扩展名都算。 +- 已经不在磁盘上的不返回;同一路径只出现一次;按文件修改时间新 → 旧,最多 50 个。 + +路径比较前两侧都过 `weakly_canonical`(用户目录是目录联接时盘符会不同)。交给界面 +的路径再用会话文件夹的原始写法拼回去:界面的预览按它自己知道的工作目录定位文件。 + +## bash 探测 + +脚本写文件不经过文件工具,只能事后找。两条线索,都只认文档类扩展名,并且要求文件 +修改时间不早于命令开始(放宽 2 秒): + +1. **提到的路径**:命令行里的单词(`-o report.pdf`、`--out=a.xlsx`、重定向目标)和 + 输出末尾 32 KB 里的路径片段(`Saved to D:\x\a.xlsx`,认中文全角标点;路径带空格 + 时从同一行左边的绝对路径起点取整段)。工作目录外的文件只能靠这条。网址、UNC + 路径、带 `~` `$` `%` 的不查。 +2. **扫工作目录**:广度优先,点开头的目录和 `node_modules`、`__pycache__`、`venv` + 整个跳过;`build` / `dist` / `out` 这类构建目录只看第一层;不跟符号链接和目录联接; + 上限 6000 个目录项、120 毫秒、8 层。 + +不找的情况: + +- 只读命令(命令分类器判 KnownSafe),所以 `ls`、`cat`、`git status` 没有额外开销。 +- 整条命令只有构建 / 包管理 / 版本控制工具(`git pull`、`pnpm build`、`cmake --build`): + 它们一次改动一批文档类文件,不是产物。`uv run`、`cargo run` 这类是在跑用户的 + 程序,照常找;带输出重定向的命令一律找。 +- 一条命令改动的文档类文件超过 12 个:当作批量操作,扫描结果整个作废,明确提到的 + 路径仍保留。 + +这是尽力而为,会漏:脚本悄悄写到工作目录外、写进被跳过的目录、只是复制了一个旧 +文件(修改时间没变)、一次生成十几个以上的文件。也会多:命令运行期间别的会话或 +用户自己改了工作目录里的文档,会被算到这条命令头上。 + +## 界面刷新 + +界面收不到「bash 写了文件」的事件。摘要面板可见时:打开 / 换会话 / 回合开始和结束 +各取一次;回合进行中每 6 秒取一次。服务端每个记录文件只在第一次整读,之后只读新 +追加的部分,行先过子串预筛,绝大多数行不解析 JSON。 diff --git a/docs/subagents.md b/docs/subagents.md index 2aa7e57f0..b459a0c43 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -157,12 +157,13 @@ session_status 帧(未知 busy 会话) → refetch ← wait=true Message/ToolBlock 紧凑渲染,`useSessionTranscript` live:'auto' 实时跟尾,AskUserQuestion 工具行过滤不显示)。 - 入口:会话三点菜单「后台任务」、对话里的智能体分组卡片、摘要面板的运行中任务行与「N 已完成」。 -- **头像**:每个子代理前面一个像素小人(`PixelAvatar.jsx` 的 `SubagentAvatar`):绑定了专家用专家的 - 头像,否则按子会话 id 生成(`officeCharacters.js` 的 `subagentAvatarIdentity`),所以同一个子代理在 +- **头像**:每个子代理前面一个头像(`PixelAvatar.jsx` 的 `SubagentAvatar`):绑定了专家用专家的 + 头像,否则按子会话 id 生成(`officeCharacters.js` 的 `subagentAvatarIdentity`;默认画成花瓣, + 可在设置里换成像素小人,见下面两条),所以同一个子代理在 三处是同一张脸——后台任务页签的行、摘要面板的运行中任务行、对话里的「调用了 N 个智能体」 (行首排开这几个子代理的头像,最多 6 张;外面包着的工具汇总行同样,「已处理」和进行中的行不画; 展开后的每个智能体行也带)。后台命令没有头像,保持终端图标。四处都是 16px。 -- **头像上的状态图标**:右上角按任务状态画一个小图标,画法照桌面像素办公室(同一份像素模板, +- **像素小人和专家头像上的状态图标**:右上角按任务状态画一个小图标,画法照桌面像素办公室(同一份像素模板, `assets/desktop_pet/office_characters.js` 的 `miniSprite`):运行中是三个点轮流跳,小人每 1.4 秒 左右点一下头;已完成是一颗星,后台任务页签的已完成行只显示普通头像;已中止是红色感叹号; 任务列表里查不到这个子代理时不画 @@ -170,6 +171,18 @@ session_status 帧(未知 busy 会话) → refetch ← wait=true `avatarStatus.js`:所有运行中的头像共用一只 280ms 一拍的钟(三个点全场同步),有人订阅才开定时器、 最后一个退订就停;点头的位移取整到设备像素(头像是最近邻放大的像素画,挪半个设备像素脸会抖)。 等待授权 / 等待回答目前没有单独的图标(办公室里有),任务对象里没有这两种状态。 +- **头像风格**(设置 > 外观 > 子代理头像,`SubagentAvatarStyleCards.jsx`;设置里没有单独的「子代理」一栏): + 花瓣(默认,配置值 `geometric`)或像素小人(`pixel`),只影响没绑定专家的子代理。 + 存在 `config.json` 的 `web_ui.subagent_avatar_style`,走外观偏好那条路 + (`appearancePreferences.js` → `PUT /api/config/ui-preferences`,桌面版首帧注入)——桌面版每次启动 + 端口都变,浏览器本地存储留不住,不能只存本地。App 把当前值放进 `SubagentAvatarStyleProvider`, + `SubagentAvatar` 从那里读。 +- **花瓣**(`geometricAvatar.js` + `GeometricAvatar.jsx`):一片花瓣绕中心转一圈拼成一朵, + 每朵一个颜色、深浅两档,画成矢量图。五个参数都由身份字符串的哈希决定:花瓣形状 5 种 × 瓣数 + (3 / 4 / 5 / 6 / 8)× 颜色 8 个 × 有无花心 × 是否转半格,共 800 种;同一个子代理到哪都是同一朵, + 但不保证一个会话里不撞(十个子代理里大约有一对颜色和形状都相同)。16px 下靠得住的区分只有颜色和 + 轮廓,所以不画描边和渐变;颜色表里没有红色(留给出错)和灰色(留给已中止)。状态不叠像素角标: + 运行中整朵慢慢转(6 秒一圈,不跟随系统「减少动态效果」),结束后停住,被中止变灰。 ### 权限 / 提问冒泡 diff --git a/src/adapters/tool/background_tasks.cpp b/src/adapters/tool/background_tasks.cpp index 60a547538..dd1a862b6 100644 --- a/src/adapters/tool/background_tasks.cpp +++ b/src/adapters/tool/background_tasks.cpp @@ -1,4 +1,5 @@ #include "background_tasks.hpp" +#include "shell_output_files.hpp" #include "llm/tool_protocol_names.hpp" #include "utils/logger.hpp" @@ -511,9 +512,22 @@ bool any_running(const std::vector& tasks) { return false; } +// 后台命令结束时找一遍它生成的文件,记进完成通知的元数据(摘要面板「产物」 +// 用,shell_output_files.hpp)。在这里做而不在 finish_shell 里做:要扫工作目录, +// 不能放在登记表的锁里。 +void attach_output_files(PumpEvent& event) { + if (!event.notice || !event.changed) return; + const BackgroundTaskInfo& info = *event.changed; + if (info.kind != BackgroundTaskKind::Shell || info.state == BackgroundTaskState::Running) return; + const ShellOutputProbe probe = make_shell_output_probe(info.command, info.cwd, info.started_at_ms); + auto files = collect_shell_output_files(probe, info.command, event.notice->text); + if (!files.empty()) event.notice->metadata["output_files"] = std::move(files); +} + void deliver(const BackgroundTaskRegistry::Sink& sink, std::vector& events) { for (auto& event : events) { try { + attach_output_files(event); if (event.changed && sink.changed) sink.changed(*event.changed); if (event.notice && sink.notify) sink.notify(std::move(*event.notice)); } catch (const std::exception& error) { diff --git a/src/adapters/tool/bash_tool.cpp b/src/adapters/tool/bash_tool.cpp index f44ddf372..302a2208a 100644 --- a/src/adapters/tool/bash_tool.cpp +++ b/src/adapters/tool/bash_tool.cpp @@ -1,5 +1,6 @@ #include "bash_tool.hpp" #include "background_tasks.hpp" +#include "shell_output_files.hpp" #include "shell_process.hpp" #include "llm/tool_icons.hpp" #include "llm/tool_protocol_names.hpp" @@ -90,6 +91,14 @@ static void append_root_script_warning(ToolResult& result, result.output += "."; } +// 把命令生成的文件记进结果元数据,摘要面板的「产物」分组读它 +// (shell_output_files.hpp)。不进模型看到的正文。 +static void attach_output_files(ToolResult& result, const ShellOutputProbe& probe, + const std::string& command, const std::string& output) { + auto files = collect_shell_output_files(probe, command, output); + if (!files.empty()) result.metadata["output_files"] = std::move(files); +} + namespace { std::string trim_copy(const std::string& value) { @@ -398,6 +407,9 @@ static ToolResult execute_bash(const std::string& arguments_json, const ToolCont if (ctx.stream) ctx.stream(clean); }; + const ShellOutputProbe output_probe = + begin_shell_output_probe(command, path_to_utf8(root_for_script_warning)); + ShellLaunchFailure launch_failure; auto process = ShellProcess::launch(spec, launch_failure); if (!process) { @@ -548,6 +560,7 @@ static ToolResult execute_bash(const std::string& arguments_json, const ToolCont ToolResult r{full_output, false}; r.summary = make_summary(command, duration_ms, raw_bytes, -1, false, was_truncated, true, false); + attach_output_files(r, output_probe, command, full_output); append_root_script_warning(r, new_root_script_files(root_scripts_before, list_root_script_files(root_for_script_warning)), @@ -559,6 +572,7 @@ static ToolResult execute_bash(const std::string& arguments_json, const ToolCont std::to_string(timeout_ms / 1000) + " seconds.", false}; r.summary = make_summary(command, duration_ms, raw_bytes, -1, false, was_truncated, false, true); + attach_output_files(r, output_probe, command, full_output); append_root_script_warning(r, new_root_script_files(root_scripts_before, list_root_script_files(root_for_script_warning)), @@ -587,6 +601,7 @@ static ToolResult execute_bash(const std::string& arguments_json, const ToolCont r.summary = make_summary(command, duration_ms, raw_bytes, static_cast(exit_code), is_ok, was_truncated, false, false); + attach_output_files(r, output_probe, command, full_output); append_root_script_warning(r, new_root_script_files(root_scripts_before, list_root_script_files(root_for_script_warning)), diff --git a/src/adapters/tool/shell_output_files.cpp b/src/adapters/tool/shell_output_files.cpp new file mode 100644 index 000000000..7e4f24d98 --- /dev/null +++ b/src/adapters/tool/shell_output_files.cpp @@ -0,0 +1,384 @@ +#include "shell_output_files.hpp" + +#include "ignore_utils.hpp" +#include "sandbox/command_classifier.hpp" +#include "session/session_outputs.hpp" +#include "utils/utf8_path.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace acecode { + +namespace { + +namespace fs = std::filesystem; + +// 文件系统时间戳有粗有细(FAT 2 秒),开始时刻往前放宽这么多。 +constexpr std::int64_t kClockSlackMs = 2000; +constexpr std::size_t kOutputTailBytes = 32 * 1024; +constexpr std::size_t kMaxTextMentions = 40; +constexpr std::size_t kAbsoluteStartWindow = 300; + +// 构建 / 包管理 / 版本控制 / 解包:会一次改动一批文件,它们动过的文档不是产物。 +const std::unordered_set& bulk_tools() { + static const std::unordered_set names = { + "git", "svn", "hg", "npm", "pnpm", "yarn", "bun", "pip", "pip3", "uv", "conda", + "poetry", "cargo", "cmake", "ctest", "ninja", "make", "nmake", "msbuild", "dotnet", + "gradle", "gradlew", "mvn", "go", "docker", "apt", "apt-get", "brew", "winget", + "choco", "tar", "unzip", "7z", + }; + return names; +} + +// 这些工具的 `run` 子命令是在跑用户自己的程序(uv run report.py),照常找。 +const std::unordered_set& runner_tools() { + static const std::unordered_set names = { + "uv", "conda", "poetry", "cargo", "dotnet", "go", + }; + return names; +} + +// 不会生成文件的铺垫命令(cd x && python gen.py 里的 cd)。 +const std::unordered_set& inert_commands() { + static const std::unordered_set names = { + "cd", "pushd", "popd", "echo", "set", "export", "pwd", "ls", "dir", "cat", "type", + "sleep", "true", "exit", "chcp", "set-location", "write-host", "write-output", + }; + return names; +} + +// 整个跳过的目录(点开头的另算):依赖与缓存,不会是产物的落点。 +bool is_skipped_directory(const std::string& name) { + static const std::unordered_set names = { + "node_modules", "__pycache__", "venv", "site-packages", "bower_components", + }; + return names.count(name) > 0; +} + +bool is_ascii_alnum(unsigned char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +} + +bool is_ascii_alpha(unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +} + +std::string ascii_lower_copy(std::string value) { + for (char& c : value) { + if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + } + return value; +} + +bool is_env_assignment(const std::string& token) { + const std::size_t eq = token.find('='); + if (eq == std::string::npos || eq == 0) return false; + if (!is_ascii_alpha(static_cast(token[0])) && token[0] != '_') return false; + for (std::size_t i = 1; i < eq; ++i) { + const auto c = static_cast(token[i]); + if (!is_ascii_alnum(c) && c != '_') return false; + } + return true; +} + +bool segment_may_produce(const sandbox::CommandSegment& segment) { + std::size_t first = 0; + while (first < segment.tokens.size() && is_env_assignment(segment.tokens[first])) ++first; + if (first >= segment.tokens.size()) return false; + const std::string name = sandbox::command_basename(segment.tokens[first]); + if (name.empty() || inert_commands().count(name) > 0) return false; + if (bulk_tools().count(name) > 0) { + return runner_tools().count(name) > 0 && first + 1 < segment.tokens.size() && + ascii_lower_copy(segment.tokens[first + 1]) == "run"; + } + return true; +} + +// 能拿去查磁盘的「提到的路径」:是文档类扩展名,不是网址,不是 UNC(对一个 +// 不存在的主机做 stat 会卡在网络超时上),不靠 shell 展开(~、$VAR、%VAR%)。 +bool is_usable_mention(std::string_view mention) { + if (mention.empty() || mention.size() > 1024) return false; + if (mention.find("://") != std::string_view::npos) return false; + if (mention.find_first_of("\n\r$%") != std::string_view::npos) return false; + if (mention[0] == '~') return false; + if (mention.size() >= 2 && (mention[0] == '/' || mention[0] == '\\') && + (mention[1] == '/' || mention[1] == '\\')) return false; + return is_deliverable_file_name(mention); +} + +void push_unique(std::vector& out, std::string value) { + if (std::find(out.begin(), out.end(), value) == out.end()) out.push_back(std::move(value)); +} + +// 紧凑片段的左边界:空白、引号、括号、重定向与通配符、逗号分号等号。 +bool is_hard_stop(unsigned char c) { + if (c <= 0x20) return true; + switch (c) { + case '"': case '\'': case '`': case '<': case '>': case '|': case '*': case '?': + case '(': case ')': case '[': case ']': case '{': case '}': case ',': case ';': + case '=': + return true; + default: + return false; + } +} + +// 「从绝对路径起点到扩展名」这一整段里不允许出现的字符(空格允许)。 +bool blocks_absolute_span(unsigned char c) { + switch (c) { + case '"': case '\'': case '`': case '<': case '>': case '|': case '*': case '?': + return true; + default: + return false; + } +} + +// 中文输出里紧贴路径的全角标点:已保存到:report.xlsx。 +bool is_cjk_punctuation(std::string_view code_point) { + // 前五个是全角的冒号、逗号、左右括号、分号,用字节写,免得被编辑器换成半角。 + static constexpr std::array kMarks = { + "\xEF\xBC\x9A", "\xEF\xBC\x8C", "\xEF\xBC\x88", "\xEF\xBC\x89", "\xEF\xBC\x9B", + "。", "“", "”", "‘", "’", "「", "」", "、", "【", "】", "《", "》", + }; + return std::find(kMarks.begin(), kMarks.end(), code_point) != kMarks.end(); +} + +bool is_drive_start(std::string_view text, std::size_t pos) { + if (pos + 2 >= text.size()) return false; + if (!is_ascii_alpha(static_cast(text[pos])) || text[pos + 1] != ':') return false; + if (text[pos + 2] != '\\' && text[pos + 2] != '/') return false; + return pos == 0 || !is_ascii_alnum(static_cast(text[pos - 1])); +} + +bool is_posix_root_start(std::string_view text, std::size_t pos) { + if (text[pos] != '/' || pos + 1 >= text.size()) return false; + const auto next = static_cast(text[pos + 1]); + if (next == '/' || next <= 0x20) return false; + if (pos == 0) return true; + const auto before = static_cast(text[pos - 1]); + return before <= 0x20 || before == '=' || before == ':'; +} + +std::int64_t unix_now_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +} + +// Unix 毫秒时刻换成文件时钟上的同一时刻(C++17 没有 clock_cast,用两边的「现在」对齐)。 +fs::file_time_type file_time_at(std::int64_t unix_ms) { + const std::int64_t age_ms = std::max(0, unix_now_ms() - unix_ms); + return fs::file_time_type::clock::now() - + std::chrono::duration_cast(std::chrono::milliseconds(age_ms)); +} + +std::string dedupe_key(const std::string& path) { + std::string key = path; + std::replace(key.begin(), key.end(), '\\', '/'); +#ifdef _WIN32 + key = ascii_lower_copy(std::move(key)); +#endif + return key; +} + +std::vector scan_recent_deliverables(const std::string& cwd, + fs::file_time_type threshold, + const ShellOutputScanLimits& limits) { + struct Pending { + fs::path dir; + std::size_t depth = 0; + bool shallow = false; // 构建目录:只看这一层的文件,不往下走 + }; + std::vector found; + if (cwd.empty()) return found; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(limits.max_millis); + std::deque queue; + queue.push_back({path_from_utf8(cwd), 0, false}); + std::size_t visited = 0; + bool out_of_budget = false; + const fs::directory_iterator end; + // 广度优先:预算用完时,浅层(最可能放产物的地方)已经看过了。 + while (!queue.empty() && !out_of_budget) { + const Pending current = std::move(queue.front()); + queue.pop_front(); + std::error_code ec; + fs::directory_iterator it(current.dir, fs::directory_options::skip_permission_denied, ec); + for (; !ec && it != end; it.increment(ec)) { + if (++visited > limits.max_entries || + ((visited & 0xFF) == 0 && std::chrono::steady_clock::now() > deadline)) { + out_of_budget = true; + break; + } + std::error_code entry_ec; + // symlink_status:不跟符号链接和目录联接,免得绕圈或扫到工作目录外面。 + const fs::file_status status = it->symlink_status(entry_ec); + if (entry_ec) continue; + const std::string name = path_to_utf8(it->path().filename()); + if (fs::is_directory(status)) { + if (current.shallow || current.depth + 1 > limits.max_depth) continue; + if (name.empty() || name[0] == '.' || is_skipped_directory(name)) continue; + queue.push_back({it->path(), current.depth + 1, should_ignore_dir(name)}); + continue; + } + if (!fs::is_regular_file(status) || !is_deliverable_file_name(name)) continue; + const auto modified = it->last_write_time(entry_ec); + if (entry_ec || modified < threshold) continue; + found.push_back(path_to_utf8(it->path())); + // 一条命令动了这么多文档类文件:是构建 / 检出这类批量操作,不当产物。 + if (found.size() > limits.bulk_threshold) return {}; + } + } + std::sort(found.begin(), found.end()); + return found; +} + +} // namespace + +bool shell_command_may_produce_files(const std::string& command) { + if (command.find_first_not_of(" \t\r\n") == std::string::npos) return false; + const auto classification = sandbox::classify_command(command); + if (classification.kind == sandbox::CommandKind::KnownSafe) return false; + // 输出重定向本身就是在写文件(echo ... > notes.md),不管前面是什么命令。 + for (const auto& word : sandbox::tokenize_shell_words(command)) { + if (word.is_operator && !word.text.empty() && word.text[0] == '>') return true; + } + // 拆不出段(整条是脚本块之类)时按「可能生成」处理。 + if (classification.segments.empty()) return true; + return std::any_of(classification.segments.begin(), classification.segments.end(), + segment_may_produce); +} + +std::vector shell_command_path_mentions(const std::string& command) { + std::vector out; + for (const auto& word : sandbox::tokenize_shell_words(command)) { + if (word.is_operator || word.text.empty()) continue; + // --out=reports/q3.xlsx:路径是等号后面那段。 + const std::size_t eq = word.text.rfind('='); + const std::string value = eq == std::string::npos ? word.text : word.text.substr(eq + 1); + if (is_usable_mention(value)) push_unique(out, value); + if (out.size() >= kMaxTextMentions) break; + } + return out; +} + +std::vector shell_text_path_mentions(std::string_view text) { + std::vector out; + const std::size_t size = text.size(); + for (std::size_t dot = text.find('.'); dot != std::string_view::npos && out.size() < kMaxTextMentions; + dot = text.find('.', dot + 1)) { + // 扩展名:点后面的一串字母数字,后面必须是边界。 + std::size_t end = dot + 1; + while (end < size && is_ascii_alnum(static_cast(text[end]))) ++end; + if (end == dot + 1 || end - dot > 9) continue; + if (end < size) { + const auto next = static_cast(text[end]); + if (next == '_' || next == '-') continue; + // a.md.bak:后面还接着别的扩展名。 + if (next == '.' && end + 1 < size && is_ascii_alnum(static_cast(text[end + 1]))) continue; + } + if (!is_deliverable_file_name(std::string("x") + std::string(text.substr(dot, end - dot)))) continue; + + // 向左扩到文件名 / 路径的起点。 + std::size_t start = dot; + while (start > 0) { + const auto c = static_cast(text[start - 1]); + if (c < 0x80) { + if (is_hard_stop(c)) break; + if (c == ':') { + // 只有盘符后面的冒号属于路径(C:\x);其它冒号是分隔(saved:a.csv)。 + if (start >= 2 && is_drive_start(text, start - 2)) start -= 2; + break; + } + --start; + continue; + } + std::size_t lead = start - 1; + while (lead > 0 && start - lead < 4 && + (static_cast(text[lead]) & 0xC0) == 0x80) --lead; + if (is_cjk_punctuation(text.substr(lead, start - lead))) break; + start = lead; + } + if (start == dot) continue; // 只有扩展名,没有文件名 + const std::string_view tight = text.substr(start, end - start); + if (is_usable_mention(tight)) push_unique(out, std::string(tight)); + + // 路径里带空格时紧凑片段只截到最后一个空格。同一行左边若有绝对路径起点, + // 再给出从起点到扩展名的整段;对不对由调用方查磁盘决定。 + if (is_drive_start(text, start) || text[start] == '/') continue; + const std::size_t line_start = text.rfind('\n', dot) == std::string_view::npos + ? 0 : text.rfind('\n', dot) + 1; + const std::size_t window_start = std::max(line_start, + dot > kAbsoluteStartWindow ? dot - kAbsoluteStartWindow : 0); + for (std::size_t pos = start; pos > window_start; --pos) { + const std::size_t at = pos - 1; + if (blocks_absolute_span(static_cast(text[at]))) break; + if (is_drive_start(text, at) || is_posix_root_start(text, at)) { + const std::string_view span = text.substr(at, end - at); + if (is_usable_mention(span)) push_unique(out, std::string(span)); + break; + } + } + } + return out; +} + +ShellOutputProbe make_shell_output_probe(const std::string& command, const std::string& cwd, + std::int64_t started_ms) { + ShellOutputProbe probe; + probe.cwd = cwd; + probe.started_ms = started_ms; + probe.enabled = started_ms > 0 && shell_command_may_produce_files(command); + return probe; +} + +ShellOutputProbe begin_shell_output_probe(const std::string& command, const std::string& cwd) { + return make_shell_output_probe(command, cwd, unix_now_ms()); +} + +std::vector collect_shell_output_files(const ShellOutputProbe& probe, + const std::string& command, + std::string_view output, + const ShellOutputScanLimits& limits) { + std::vector files; + if (!probe.enabled) return files; + try { + const fs::file_time_type threshold = file_time_at(probe.started_ms - kClockSlackMs); + std::unordered_set seen; + + std::vector mentions = shell_command_path_mentions(command); + const std::string_view tail = output.size() > kOutputTailBytes + ? output.substr(output.size() - kOutputTailBytes) : output; + for (auto& mention : shell_text_path_mentions(tail)) mentions.push_back(std::move(mention)); + + for (const auto& mention : mentions) { + if (files.size() >= limits.max_mentions) break; + fs::path path = path_from_utf8(mention); + if (!path.is_absolute()) { + if (probe.cwd.empty()) continue; + path = path_from_utf8(probe.cwd) / path; + } + std::error_code ec; + if (!fs::is_regular_file(path, ec) || ec) continue; + const auto modified = fs::last_write_time(path, ec); + if (ec || modified < threshold) continue; // 提到了但不是这次生成的(只是读它) + std::string resolved = path_to_utf8(path.lexically_normal()); + if (seen.insert(dedupe_key(resolved)).second) files.push_back(std::move(resolved)); + } + + for (auto& found : scan_recent_deliverables(probe.cwd, threshold, limits)) { + std::string resolved = path_to_utf8(path_from_utf8(found).lexically_normal()); + if (seen.insert(dedupe_key(resolved)).second) files.push_back(std::move(resolved)); + } + } catch (const std::exception&) { + // 尽力而为:路径转换 / 文件系统异常都不该影响命令本身的结果。 + files.clear(); + } + return files; +} + +} // namespace acecode diff --git a/src/adapters/tool/shell_output_files.hpp b/src/adapters/tool/shell_output_files.hpp new file mode 100644 index 000000000..ca3dcf50f --- /dev/null +++ b/src/adapters/tool/shell_output_files.hpp @@ -0,0 +1,63 @@ +#pragma once + +// bash 命令结束后,尽力找出它生成的文件,供摘要面板的「产物」分组使用 +// (session/session_outputs.hpp)。脚本写文件不经过文件工具,会话记录里没有 +// 路径,只能事后找。找不到就算了:这是尽力而为,不保证不漏。 +// +// 两条线索,都只认文档 / 表格 / 图片这类扩展名,并且要求文件的修改时间 +// 不早于命令开始: +// 1. 命令本身和输出末尾提到的路径(`-o report.pdf`、`Saved to D:\x\a.xlsx`), +// 工作目录外的文件只能靠这条; +// 2. 扫一遍工作目录:点开头的目录、node_modules 之类整个跳过;build / dist / +// out 这类构建目录只看第一层;有条数和时间上限,先扫浅层。 +// +// 两种情况不找:只读命令(分类器判 KnownSafe);整条命令只有构建 / 包管理 / +// 版本控制工具(git checkout、pnpm build 会一次改动一批文档类文件,不是产物)。 +// 一条命令改动的文档类文件超过 bulk_threshold 个,也当作批量操作,扫描结果作废 +// (明确提到的路径仍保留)。 + +#include +#include +#include +#include +#include + +namespace acecode { + +struct ShellOutputProbe { + bool enabled = false; + std::string cwd; + std::int64_t started_ms = 0; // 命令开始时刻,Unix 毫秒 +}; + +struct ShellOutputScanLimits { + std::size_t max_entries = 6000; // 最多看这么多个目录项 + int max_millis = 120; // 扫描最多花这么久 + std::size_t max_depth = 8; + std::size_t bulk_threshold = 12; + std::size_t max_mentions = 8; // 「提到的路径」最多采纳几个 +}; + +// 纯判定:这条命令值不值得找产物。 +bool shell_command_may_produce_files(const std::string& command); + +// 纯函数:命令行里像产物路径的单词(去引号后;`--out=a.pdf` 取等号后面)。 +std::vector shell_command_path_mentions(const std::string& command); + +// 纯函数:一段输出文本里像产物路径的片段。每处扩展名给出紧凑的那一段, +// 同一行左边有绝对路径起点时再给出「从起点到扩展名」的整段(路径里带空格)。 +std::vector shell_text_path_mentions(std::string_view text); + +// 命令开始前调用,记下开始时刻。 +ShellOutputProbe begin_shell_output_probe(const std::string& command, const std::string& cwd); +// 后台命令:开始时刻由登记表给出。 +ShellOutputProbe make_shell_output_probe(const std::string& command, const std::string& cwd, + std::int64_t started_ms); + +// 命令结束后调用。返回绝对路径(UTF-8),提到的在前、扫到的在后(按路径排序)。 +std::vector collect_shell_output_files(const ShellOutputProbe& probe, + const std::string& command, + std::string_view output, + const ShellOutputScanLimits& limits = {}); + +} // namespace acecode diff --git a/src/apps/desktop/main.cpp b/src/apps/desktop/main.cpp index e8662873e..0044e5cd1 100644 --- a/src/apps/desktop/main.cpp +++ b/src/apps/desktop/main.cpp @@ -2246,6 +2246,7 @@ int main(int argc, char** argv) { {"theme_variant", desktop_cfg.web_ui.theme_variant}, {"theme_effects", desktop_cfg.web_ui.theme_effects}, {"theme_sounds", desktop_cfg.web_ui.theme_sounds}, + {"subagent_avatar_style", desktop_cfg.web_ui.subagent_avatar_style}, }.dump(); const std::string startup_bootstrap = startup_timeline.snapshot_json(); host.init_script(acecode::desktop::locale_bootstrap_script( diff --git a/src/apps/web/routes/routes_misc.cpp b/src/apps/web/routes/routes_misc.cpp index 3e65d1332..5891e93d1 100644 --- a/src/apps/web/routes/routes_misc.cpp +++ b/src/apps/web/routes/routes_misc.cpp @@ -2243,6 +2243,15 @@ void WebServer::Impl::register_ui_preferences() { "theme_sounds must be a boolean"); } } + if (body.contains("subagent_avatar_style")) { + has_supported_field = true; + if (!body["subagent_avatar_style"].is_string() || + !is_valid_web_ui_subagent_avatar_style( + body["subagent_avatar_style"].get())) { + return json_err(400, "BAD_REQUEST", + "subagent_avatar_style must be geometric or pixel"); + } + } if (!has_supported_field) { return json_err(400, "BAD_REQUEST", "no supported UI preference field was provided"); @@ -2295,6 +2304,10 @@ void WebServer::Impl::register_ui_preferences() { deps.app_config->web_ui.theme_sounds = body["theme_sounds"].get(); } + if (body.contains("subagent_avatar_style")) { + deps.app_config->web_ui.subagent_avatar_style = + body["subagent_avatar_style"].get(); + } try { if (!deps.config_path.empty()) { save_config(*deps.app_config, deps.config_path); diff --git a/src/apps/web/routes/routes_session_outputs.cpp b/src/apps/web/routes/routes_session_outputs.cpp new file mode 100644 index 000000000..6211810db --- /dev/null +++ b/src/apps/web/routes/routes_session_outputs.cpp @@ -0,0 +1,91 @@ +#include "web/server_impl.hpp" +#include "session_host/session_registry.hpp" +#include "web/handlers/session_history_handler.hpp" + +namespace acecode::web { +using nlohmann::json; + +// GET /api/sessions/:id/outputs[?workspace=] +// 会话的产物:这次对话生成的文档 / 表格 / 图片等文件,摘要面板的「产物」分组用。 +// 列表由会话记录推导(session/session_outputs.hpp),已经不在磁盘上的不返回。 +void WebServer::Impl::register_session_outputs() { + CROW_ROUTE(app, "/api/sessions//outputs").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req, const std::string&) { return cors_preflight(req); }); + + CROW_ROUTE(app, "/api/sessions//outputs").methods(crow::HTTPMethod::GET) + ([this](const crow::request& req, const std::string& id) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + + SessionOutputsQuery query; + query.session_id = id; + if (deps.session_registry) { + if (auto entry = deps.session_registry->acquire(id)) { + // entry->cwd 决定记录存在哪;进了 worktree 的会话实际在 loop->cwd() 干活。 + query.project_dir = SessionStorage::get_project_dir(entry->cwd); + query.cwd = entry->cwd; + query.folders.push_back(entry->cwd); + if (entry->loop) { + const std::string working_cwd = entry->loop->cwd(); + if (!working_cwd.empty()) { + query.cwd = working_cwd; + query.folders.push_back(working_cwd); + } + for (auto& folder : entry->loop->workspace_extra_folders()) { + query.folders.push_back(std::move(folder)); + } + } + } + } + if (query.project_dir.empty()) { + // 还没恢复到内存的会话:按「工作区提示 → daemon 自己的目录 → 无工作区 + // 会话」的顺序找记录文件,最后一步最贵,放最后(同读记录接口)。 + std::vector candidates; + if (const char* hint = req.url_params.get("workspace")) { + if (auto workspace = resolve_workspace(hint)) candidates.push_back(workspace->cwd); + } + candidates.push_back(deps.cwd); + for (const auto& cwd : candidates) { + if (cwd.empty()) continue; + const std::string project_dir = SessionStorage::get_project_dir(cwd); + if (session_history_file_in(project_dir, id).empty()) continue; + query.project_dir = project_dir; + query.cwd = cwd; + break; + } + if (query.project_dir.empty()) { + const auto meta = find_no_workspace_session_meta(id); + if (meta && !meta->cwd.empty()) { + query.project_dir = SessionStorage::get_project_dir(meta->cwd); + query.cwd = meta->cwd; + } + } + if (!query.cwd.empty()) query.folders.push_back(query.cwd); + } + + json outputs = json::array(); + bool has_more = false; + if (!query.project_dir.empty()) { + const SessionOutputsResult result = session_outputs_index.query(query); + has_more = result.has_more; + for (const auto& file : result.files) { + outputs.push_back({ + {"path", file.path}, + {"relative", file.relative}, + {"source", session_output_source_name(file.source)}, + {"size", file.size}, + {"modified_ms", file.modified_ms}, + }); + } + } + // 路径来自磁盘上的文件名,不保证是合法 UTF-8;dump 用 replace 兜底, + // 一个坏字节不该让整个接口变成 500。 + crow::response response(200); + response.add_header("Content-Type", "application/json"); + response.add_header("Cache-Control", "no-store"); + response.body = json{{"session_id", id}, {"outputs", std::move(outputs)}, {"has_more", has_more}} + .dump(-1, ' ', false, json::error_handler_t::replace); + return with_cors(req, std::move(response)); + }); +} + +} // namespace acecode::web diff --git a/src/apps/web/server.cpp b/src/apps/web/server.cpp index 19ba9c61c..73f39ed7a 100644 --- a/src/apps/web/server.cpp +++ b/src/apps/web/server.cpp @@ -140,6 +140,7 @@ void WebServer::Impl::register_routes() { register_session_batch(); register_sessions(); register_background_tasks(); + register_session_outputs(); register_desktop_office(); register_task_suggestions(); register_models(); diff --git a/src/apps/web/server_helpers.cpp b/src/apps/web/server_helpers.cpp index 21a095443..9be376db0 100644 --- a/src/apps/web/server_helpers.cpp +++ b/src/apps/web/server_helpers.cpp @@ -115,6 +115,7 @@ json ui_preferences_to_json(const WebUiPreferencesConfig& cfg) { {"theme_variant", cfg.theme_variant}, {"theme_effects", cfg.theme_effects}, {"theme_sounds", cfg.theme_sounds}, + {"subagent_avatar_style", cfg.subagent_avatar_style}, }; } diff --git a/src/apps/web/server_impl.hpp b/src/apps/web/server_impl.hpp index 31bb0dd4c..c15c99093 100644 --- a/src/apps/web/server_impl.hpp +++ b/src/apps/web/server_impl.hpp @@ -34,6 +34,7 @@ #include "session/session_rewind.hpp" #include "session/session_serializer.hpp" #include "session/session_markdown_export.hpp" +#include "session/session_outputs_index.hpp" #include "session/session_storage.hpp" #include "session/sidebar_tree.hpp" #include "session/todo_state.hpp" @@ -472,6 +473,8 @@ struct WebServer::Impl { mutable std::mutex sidebar_sessions_mu; mutable SidebarMetadataCache sidebar_metadata_cache; + // GET /api/sessions/:id/outputs 的增量扫描状态(自带锁)。 + SessionOutputsIndex session_outputs_index; nlohmann::json sidebar_session_page(const acecode::desktop::WorkspaceMeta& ws, bool no_workspace, int limit, std::set expanded, @@ -689,6 +692,7 @@ struct WebServer::Impl { crow::response handle_session_batch(const crow::request& req); void register_sessions(); void register_background_tasks(); + void register_session_outputs(); void register_desktop_office(); void register_task_suggestions(); void register_models(); diff --git a/src/base/config/config.cpp b/src/base/config/config.cpp index 5d85945a5..002e979dd 100644 --- a/src/base/config/config.cpp +++ b/src/base/config/config.cpp @@ -217,6 +217,10 @@ bool is_valid_web_ui_font_size(const std::string& font_size) { font_size == "large"; } +bool is_valid_web_ui_subagent_avatar_style(const std::string& style) { + return style == "pixel" || style == "geometric"; +} + bool is_valid_web_ui_theme_variant(const std::string& variant) { if (variant.empty()) return true; if (variant.size() > 24 || variant.front() == '-' || variant.back() == '-') return false; @@ -500,6 +504,9 @@ std::vector validate_config(const AppConfig& cfg) { if (!is_valid_web_ui_font_size(cfg.web_ui.font_size)) { errors.push_back("web_ui.font_size must be one of: small, medium, large"); } + if (!is_valid_web_ui_subagent_avatar_style(cfg.web_ui.subagent_avatar_style)) { + errors.push_back("web_ui.subagent_avatar_style must be one of: geometric, pixel"); + } if (!is_valid_web_ui_theme_variant(cfg.web_ui.theme_variant)) { errors.push_back("web_ui.theme_variant must be empty or a lowercase variant ID"); } @@ -1113,6 +1120,16 @@ static AppConfig load_config_from_path_once( LOG_WARN("[config] invalid 'web_ui.theme_sounds', using true"); } } + if (uij.contains("subagent_avatar_style")) { + if (uij["subagent_avatar_style"].is_string() && + is_valid_web_ui_subagent_avatar_style( + uij["subagent_avatar_style"].get())) { + cfg.web_ui.subagent_avatar_style = + uij["subagent_avatar_style"].get(); + } else { + LOG_WARN("[config] invalid 'web_ui.subagent_avatar_style', using 'geometric'"); + } + } } } if (j.contains("models_dev") && j["models_dev"].is_object()) { @@ -2378,6 +2395,8 @@ nlohmann::json build_config_json(const AppConfig& cfg) { web_uij["theme_effects"] = cfg.web_ui.theme_effects; if (cfg.web_ui.theme_sounds != web_ui_d.theme_sounds) web_uij["theme_sounds"] = cfg.web_ui.theme_sounds; + if (cfg.web_ui.subagent_avatar_style != web_ui_d.subagent_avatar_style) + web_uij["subagent_avatar_style"] = cfg.web_ui.subagent_avatar_style; if (!web_uij.empty()) j["web_ui"] = std::move(web_uij); if (auto memj = memory_config_to_json(cfg.memory); !memj.empty()) j["memory"] = std::move(memj); diff --git a/src/base/config/config.hpp b/src/base/config/config.hpp index c827e94e0..f81dae3f5 100644 --- a/src/base/config/config.hpp +++ b/src/base/config/config.hpp @@ -229,6 +229,9 @@ struct WebUiPreferencesConfig { // Sound effects for themes that declare a sound style; default on. The // title bar's mute button writes this. bool theme_sounds = true; + // Portrait drawn for sub-agents that have no expert attached: `geometric` + // is a generated rosette (the default), `pixel` the Agent Office character. + std::string subagent_avatar_style = "geometric"; // geometric | pixel }; struct ModelsDevConfig { @@ -722,6 +725,7 @@ bool is_valid_ui_locale(const std::string& locale); bool is_valid_web_ui_theme(const std::string& theme); bool is_valid_web_ui_color_theme(const std::string& color_theme); bool is_valid_web_ui_font_size(const std::string& font_size); +bool is_valid_web_ui_subagent_avatar_style(const std::string& style); // Empty, or 1-24 chars of lowercase ASCII words joined by single hyphens. bool is_valid_web_ui_theme_variant(const std::string& variant); diff --git a/src/domain/session/session_outputs.cpp b/src/domain/session/session_outputs.cpp new file mode 100644 index 000000000..62b77fa95 --- /dev/null +++ b/src/domain/session/session_outputs.cpp @@ -0,0 +1,223 @@ +#include "session_outputs.hpp" + +#include "llm/message_predicates.hpp" + +#include +#include +#include + +namespace acecode { + +namespace { + +// 与 Claude 桌面版 Outputs 面板同一批扩展名,另加 svg(脚本画的图表常用)。 +constexpr std::array kDeliverableExtensions = { + "md", "markdown", "txt", "rtf", "pdf", "doc", "docx", "xls", "xlsx", + "ppt", "pptx", "csv", "tsv", "html", "htm", "png", "jpg", "jpeg", "gif", + "webp", "svg", "mp4", "mov", "webm", "mp3", "wav", "zip", +}; + +char ascii_lower(char c) { + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; +} + +bool is_separator(char c) { return c == '/' || c == '\\'; } + +bool has_drive_prefix(std::string_view path) { + return path.size() >= 2 && path[1] == ':' && + ((path[0] >= 'a' && path[0] <= 'z') || (path[0] >= 'A' && path[0] <= 'Z')); +} + +// 比较用的键:分隔符统一成 /,去掉结尾分隔符;带盘符的路径不分大小写。 +// 只改 ASCII 字母,所以键与原路径逐字节等长,可以按同一个偏移切相对路径。 +std::string path_key(std::string_view path) { + std::string key(path); + const bool fold_case = has_drive_prefix(path); + for (char& c : key) { + if (c == '\\') c = '/'; + else if (fold_case) c = ascii_lower(c); + } + while (key.size() > 1 && key.back() == '/' && !(key.size() == 3 && key[1] == ':')) key.pop_back(); + return key; +} + +bool key_is_inside(const std::string& key, const std::string& folder_key) { + if (folder_key.empty() || key.size() <= folder_key.size()) return false; + if (key.compare(0, folder_key.size(), folder_key) != 0) return false; + return folder_key.back() == '/' || key[folder_key.size()] == '/'; +} + +bool has_hidden_state_segment(std::string_view rest) { + std::size_t start = 0; + while (start <= rest.size()) { + std::size_t end = rest.find('/', start); + if (end == std::string_view::npos) end = rest.size(); + const std::string_view segment = rest.substr(start, end - start); + if (segment == ".acecode" || segment == ".git") return true; + start = end + 1; + } + return false; +} + +void append_string_array(const nlohmann::json& value, SessionOutputSource source, + std::vector& out) { + if (!value.is_array()) return; + for (const auto& item : value) { + if (!item.is_string()) continue; + std::string path = item.get(); + if (!path.empty()) out.push_back({std::move(path), source}); + } +} + +} // namespace + +const char* session_output_source_name(SessionOutputSource source) { + switch (source) { + case SessionOutputSource::Write: return "write"; + case SessionOutputSource::Patch: return "patch"; + case SessionOutputSource::Shell: return "shell"; + } + return "write"; +} + +bool is_deliverable_file_name(std::string_view path) { + std::size_t name_start = path.size(); + while (name_start > 0 && !is_separator(path[name_start - 1])) --name_start; + const std::string_view name = path.substr(name_start); + const std::size_t dot = name.rfind('.'); + // 没有扩展名,或整个名字就是「.md」这种点文件,都不算。 + if (dot == std::string_view::npos || dot == 0 || dot + 1 >= name.size()) return false; + std::string extension(name.substr(dot + 1)); + std::transform(extension.begin(), extension.end(), extension.begin(), ascii_lower); + return std::find(kDeliverableExtensions.begin(), kDeliverableExtensions.end(), + std::string_view(extension)) != kDeliverableExtensions.end(); +} + +bool jsonl_line_may_carry_outputs(std::string_view line) { + // session_serializer 用 dump(-1) 落盘,键值之间没有空格;正文里出现的同样 + // 字样在 JSON 字符串里引号会带反斜杠,不会误中。 + static constexpr std::array kMarkers = { + "\"output_files\"", "\"subagent_session_id\"", "\"verb\":\"Created\"", + "\"verb\":\"Wrote\"", "\"type\":\"add\"", + }; + for (const auto marker : kMarkers) { + if (line.find(marker) != std::string_view::npos) return true; + } + return false; +} + +std::vector session_output_candidates(const ChatMessage& message) { + std::vector out; + if (!message.metadata.is_object()) return out; + const auto& meta = message.metadata; + + if (message.role == "user") { + // 后台命令的完成通知。 + const auto task = meta.find(kBackgroundTaskMetadataKey); + if (task != meta.end() && task->is_object() && task->contains("output_files")) { + append_string_array((*task)["output_files"], SessionOutputSource::Shell, out); + } + return out; + } + if (message.role != "tool") return out; + + // bash:命令失败(退出码非零)也可能已经生成了文件,不看成败。 + if (meta.contains("output_files")) { + append_string_array(meta["output_files"], SessionOutputSource::Shell, out); + } + + const auto success = meta.find("tool_success"); + if (success != meta.end() && success->is_boolean() && !success->get()) return out; + + // apply_patch:只算新建。改已有文件相当于编辑,归「变更」。 + const auto files = meta.find("files"); + if (files != meta.end() && files->is_array()) { + for (const auto& entry : *files) { + if (!entry.is_object()) continue; + const auto type = entry.find("type"); + const auto path = entry.find("path"); + if (type == entry.end() || !type->is_string() || type->get() != "add") continue; + if (path == entry.end() || !path->is_string() || path->get().empty()) continue; + out.push_back({path->get(), SessionOutputSource::Patch}); + } + return out; + } + + // file_write:新建是 Created,覆写是 Wrote,都算。 + const auto summary = meta.find("tool_summary"); + if (summary != meta.end() && summary->is_object()) { + const auto verb = summary->find("verb"); + const auto object = summary->find("object"); + if (verb != summary->end() && verb->is_string() && + object != summary->end() && object->is_string()) { + const std::string verb_text = verb->get(); + std::string path = object->get(); + if ((verb_text == "Created" || verb_text == "Wrote") && !path.empty()) { + out.push_back({std::move(path), SessionOutputSource::Write}); + } + } + } + return out; +} + +std::string session_output_child_session_id(const ChatMessage& message) { + if (message.role != "tool" || !message.metadata.is_object()) return {}; + const auto id = message.metadata.find("subagent_session_id"); + if (id == message.metadata.end() || !id->is_string()) return {}; + return id->get(); +} + +std::vector select_session_outputs( + const std::vector& candidates, + const SessionOutputRoots& roots) { + // 与 roots.folders 逐项对应(空串占位),下标要原样交回调用方。 + std::vector folder_keys; + for (const auto& folder : roots.folders) { + folder_keys.push_back(folder.empty() ? std::string{} : path_key(folder)); + } + std::vector temp_keys; + for (const auto& temp : roots.temp_roots) { + if (!temp.empty()) temp_keys.push_back(path_key(temp)); + } + + std::vector out; + std::unordered_set seen; + for (auto it = candidates.rbegin(); it != candidates.rend(); ++it) { + if (it->path.empty()) continue; + const std::string key = path_key(it->path); + if (!seen.insert(key).second) continue; + + // 嵌套的会话文件夹(仓库根 + 它下面的 worktree)取最长的那个。 + const std::string* folder = nullptr; + int folder_index = -1; + for (std::size_t i = 0; i < folder_keys.size(); ++i) { + const std::string& candidate = folder_keys[i]; + if (!key_is_inside(key, candidate)) continue; + if (!folder || candidate.size() > folder->size()) { + folder = &candidate; + folder_index = static_cast(i); + } + } + + SessionOutputEntry entry; + entry.path = it->path; + entry.source = it->source; + entry.folder_index = folder_index; + if (folder) { + const std::size_t offset = folder->size() + (folder->back() == '/' ? 0 : 1); + if (has_hidden_state_segment(std::string_view(key).substr(offset))) continue; + if (!is_deliverable_file_name(key)) continue; + entry.relative = it->path.substr(offset); + std::replace(entry.relative.begin(), entry.relative.end(), '\\', '/'); + } else { + if (has_hidden_state_segment(key)) continue; + const bool in_temp = std::any_of(temp_keys.begin(), temp_keys.end(), + [&key](const std::string& temp) { return key_is_inside(key, temp); }); + if (in_temp) continue; + } + out.push_back(std::move(entry)); + } + return out; +} + +} // namespace acecode diff --git a/src/domain/session/session_outputs.hpp b/src/domain/session/session_outputs.hpp new file mode 100644 index 000000000..fb0833d5f --- /dev/null +++ b/src/domain/session/session_outputs.hpp @@ -0,0 +1,72 @@ +#pragma once + +// 会话「产物」:这次对话生成、交付给用户看的文件(报告、表格、图片……), +// 摘要面板的「产物」分组显示它。代码改动不算产物,归「变更」。 +// +// 来源全部取自会话记录里已有的工具结果元数据,不改写文件的工具: +// - file_write:tool_summary 的动词是 Created / Wrote,object 是路径; +// - apply_patch:metadata.files 里 type == "add" 的条目; +// - bash:metadata.output_files(命令结束后按修改时间找到的新文件, +// 见 tool/shell_output_files.hpp;后台命令在完成通知的 +// metadata.background_task.output_files 里); +// - 子代理:派生结果的 metadata.subagent_session_id 指向子会话,它的产物 +// 并进主会话的列表(SessionOutputsIndex 负责跟过去)。 +// +// 这里只有纯逻辑(无 IO):取候选、判扩展名、过滤去重。读记录与查磁盘在 +// session_outputs_index.cpp。 + +#include "llm/llm_provider.hpp" + +#include +#include +#include + +namespace acecode { + +enum class SessionOutputSource { Write, Patch, Shell }; + +const char* session_output_source_name(SessionOutputSource source); + +struct SessionOutputCandidate { + std::string path; // 记录里的原始路径,可能是相对路径 + SessionOutputSource source = SessionOutputSource::Write; +}; + +// 文档 / 表格 / 图片 / 音视频 / 压缩包这类「拿来看或交付」的扩展名(不分大小写)。 +// 工作目录内只有这类文件算产物;bash 探测也只认这类文件。 +bool is_deliverable_file_name(std::string_view path); + +// 不解析 JSON 的预筛:这一行有没有可能带产物候选或子会话 id。 +bool jsonl_line_may_carry_outputs(std::string_view line); + +// 一条消息里的产物候选(按记录顺序)。无关消息、失败的写入返回空。 +std::vector session_output_candidates(const ChatMessage& message); + +// 派生子代理的工具结果里记的子会话 id;没有返回空串。 +std::string session_output_child_session_id(const ChatMessage& message); + +struct SessionOutputRoots { + // 会话文件夹(工作目录、worktree、附加文件夹),已归一成同一种路径形态。 + std::vector folders; + // 系统临时目录,同样已归一。 + std::vector temp_roots; +}; + +struct SessionOutputEntry { + std::string path; // 归一后的绝对路径 + std::string relative; // 落在会话文件夹内时的相对路径(正斜杠),否则为空 + int folder_index = -1; // 所在会话文件夹在 roots.folders 里的下标;不在任何文件夹内为 -1 + SessionOutputSource source = SessionOutputSource::Write; +}; + +// candidates 按旧 → 新排列,path 必须已是归一后的绝对路径。返回按新 → 旧排列、 +// 按路径去重(同一文件取最近一次)的结果。规则: +// - 路径里有 .acecode / .git 这一级的不算(会话文件夹自身那一段除外, +// worktree 会话的工作目录就在 .acecode/worktrees 下面); +// - 会话文件夹内只留 is_deliverable_file_name 的文件; +// - 会话文件夹外:系统临时目录里的不算,其余任何扩展名都算。 +std::vector select_session_outputs( + const std::vector& candidates, + const SessionOutputRoots& roots); + +} // namespace acecode diff --git a/src/domain/session/session_outputs_index.cpp b/src/domain/session/session_outputs_index.cpp new file mode 100644 index 000000000..9b20d1d49 --- /dev/null +++ b/src/domain/session/session_outputs_index.cpp @@ -0,0 +1,238 @@ +#include "session_outputs_index.hpp" + +#include "jsonl_scanner.hpp" +#include "session_file_reader.hpp" +#include "session_serializer.hpp" +#include "session_storage.hpp" +#include "utils/logger.hpp" +#include "utils/utf8_path.hpp" + +#include + +#include +#include +#include +#include + +#include +#include + +namespace acecode { + +namespace { + +// 一个记录文件最多留这么多条候选(留最新的);一次查询最多跟这么多个会话、 +// 到磁盘上核对这么多个不同路径。都是防极端会话的上限,正常用不到。 +constexpr std::size_t kMaxCandidatesPerSession = 4000; +constexpr std::size_t kMaxSessionsPerQuery = 64; +constexpr std::size_t kMaxResolvedPaths = 400; +constexpr std::size_t kMaxCachedScans = 128; +constexpr std::size_t kPrefixBytes = 4096; + +struct FileFacts { + bool regular = false; + std::uint64_t size = 0; + std::int64_t modified_s = 0; +}; + +FileFacts stat_file(const std::string& utf8_path) { + FileFacts facts; +#ifdef _WIN32 + struct _stat64 info{}; + if (::_wstat64(path_from_utf8(utf8_path).c_str(), &info) != 0) return facts; + facts.regular = (info.st_mode & _S_IFREG) != 0; +#else + struct stat info{}; + if (::stat(utf8_path.c_str(), &info) != 0) return facts; + facts.regular = S_ISREG(info.st_mode); +#endif + facts.size = info.st_size > 0 ? static_cast(info.st_size) : 0; + facts.modified_s = static_cast(info.st_mtime); + return facts; +} + +// 比较两侧必须是同一种形态:用户目录是 junction 时,记录里的盘符和工作目录的 +// 盘符可能不同,所以会话文件夹和候选路径都过一遍 weakly_canonical。 +std::string canonical_path(const std::string& utf8_path) { + if (utf8_path.empty()) return {}; + std::error_code ec; + const std::filesystem::path raw = path_from_utf8(utf8_path); + std::filesystem::path resolved = std::filesystem::weakly_canonical(raw, ec); + if (ec || resolved.empty()) { + ec.clear(); + resolved = std::filesystem::absolute(raw, ec); + if (ec || resolved.empty()) return utf8_path; + resolved = resolved.lexically_normal(); + } + return path_to_utf8(resolved); +} + +std::string absolute_candidate(const std::string& path, const std::string& cwd) { + const std::filesystem::path raw = path_from_utf8(path); + if (raw.is_absolute()) return path; + if (cwd.empty()) return {}; + return path_to_utf8(path_from_utf8(cwd) / raw); +} + +std::vector system_temp_roots() { + std::vector roots; + std::error_code ec; + const auto temp = std::filesystem::temp_directory_path(ec); + if (!ec && !temp.empty()) roots.push_back(canonical_path(path_to_utf8(temp))); +#ifndef _WIN32 + for (const char* fixed : {"/tmp", "/var/tmp"}) roots.push_back(canonical_path(fixed)); +#endif + return roots; +} + +// 会话 id 进文件名之前挡掉带路径成分的值(根 id 来自请求)。 +bool is_plain_session_id(const std::string& id) { + return !id.empty() && id.find_first_of("/\\:") == std::string::npos && + id.find("..") == std::string::npos; +} + +} // namespace + +void SessionOutputsIndex::advance(Scan& scan, const std::string& jsonl_path) { + SessionFileReader reader(jsonl_path); + if (!reader.valid()) { + scan = Scan{}; + return; + } + bool resume = !scan.identity.empty() && scan.identity == reader.identity() && + reader.size() >= scan.offset; + if (resume && !scan.prefix.empty()) { + resume = reader.read(0, scan.prefix.size()) == scan.prefix; + } + if (!resume) { + scan = Scan{}; + scan.identity = reader.identity(); + } + if (scan.prefix.size() < kPrefixBytes) scan.prefix = reader.prefix(); + + JsonlScanner scanner(reader, false, scan.offset); + while (auto record = scanner.next()) { + // 写入方还没写完这一行:留到下次。 + if (!record->terminated) break; + scan.offset = record->end; + if (!jsonl_line_may_carry_outputs(record->text)) continue; + const auto parsed = nlohmann::json::parse(record->text, nullptr, false); + if (parsed.is_discarded() || !parsed.is_object()) continue; + const ChatMessage message = deserialize_message_json(parsed); + for (auto& candidate : session_output_candidates(message)) { + scan.candidates.push_back(std::move(candidate)); + } + std::string child = session_output_child_session_id(message); + if (!child.empty() && + std::find(scan.children.begin(), scan.children.end(), child) == scan.children.end()) { + scan.children.push_back(std::move(child)); + } + } + if (scan.candidates.size() > kMaxCandidatesPerSession) { + scan.candidates.erase(scan.candidates.begin(), + scan.candidates.end() - static_cast(kMaxCandidatesPerSession)); + } +} + +SessionOutputsIndex::Scan SessionOutputsIndex::scan_session(const std::string& jsonl_path) { + Scan scan; + { + std::lock_guard lock(mu_); + const auto it = scans_.find(jsonl_path); + if (it != scans_.end()) scan = it->second; + } + // 读盘在锁外:同一会话的并发查询各读各的,结果一致,后写回的覆盖先写回的。 + try { + advance(scan, jsonl_path); + } catch (const std::exception& error) { + LOG_WARN(std::string("[outputs] scan failed, will rescan: ") + error.what()); + scan = Scan{}; + } + std::lock_guard lock(mu_); + if (scan.identity.empty()) { + scans_.erase(jsonl_path); + return scan; + } + scan.used = ++tick_; + scans_[jsonl_path] = scan; + if (scans_.size() > kMaxCachedScans) { + std::vector> by_age; + by_age.reserve(scans_.size()); + for (const auto& item : scans_) by_age.emplace_back(item.second.used, item.first); + std::sort(by_age.begin(), by_age.end()); + for (std::size_t i = 0; i + kMaxCachedScans / 2 < by_age.size(); ++i) { + scans_.erase(by_age[i].second); + } + } + return scan; +} + +SessionOutputsResult SessionOutputsIndex::query(const SessionOutputsQuery& query) { + SessionOutputsResult result; + if (!is_plain_session_id(query.session_id) || query.project_dir.empty()) return result; + + // 主会话在前,再按派生顺序跟到各个子代理会话(网状模式下子代理还会再派生)。 + std::vector raw; + std::vector sessions{query.session_id}; + std::unordered_set visited; + for (std::size_t i = 0; i < sessions.size() && visited.size() < kMaxSessionsPerQuery; ++i) { + const std::string id = sessions[i]; + if (!is_plain_session_id(id) || !visited.insert(id).second) continue; + Scan scan = scan_session(SessionStorage::session_path(query.project_dir, id)); + raw.insert(raw.end(), scan.candidates.begin(), scan.candidates.end()); + sessions.insert(sessions.end(), scan.children.begin(), scan.children.end()); + } + if (raw.empty()) return result; + + // 比较用归一后的形态,交给界面的路径用调用方给的原始形态拼回去:界面的预览、 + // 文件树都按它自己知道的工作目录写法定位文件,盘符换了就对不上。 + std::vector folders = query.folders; + if (!query.cwd.empty()) folders.push_back(query.cwd); + SessionOutputRoots roots; + for (const auto& folder : folders) { + roots.folders.push_back(folder.empty() ? std::string{} : canonical_path(folder)); + } + roots.temp_roots = system_temp_roots(); + + // 从最新的往回取,只对前 kMaxResolvedPaths 个不同路径做归一(要碰磁盘)。 + std::vector resolved; + std::unordered_set seen; + for (auto it = raw.rbegin(); it != raw.rend() && resolved.size() < kMaxResolvedPaths; ++it) { + if (!seen.insert(it->path).second) continue; + const std::string absolute = absolute_candidate(it->path, query.cwd); + if (absolute.empty()) continue; + resolved.push_back({canonical_path(absolute), it->source}); + } + std::reverse(resolved.begin(), resolved.end()); + + for (auto& entry : select_session_outputs(resolved, roots)) { + const FileFacts facts = stat_file(entry.path); + if (!facts.regular) continue; // 已经删掉 / 挪走的不显示 + SessionOutputFile file; + if (entry.folder_index >= 0 && !entry.relative.empty()) { + std::filesystem::path shown = path_from_utf8(folders[static_cast(entry.folder_index)]) / + path_from_utf8(entry.relative); + file.path = path_to_utf8(shown.make_preferred()); + } else { + file.path = std::move(entry.path); + } + file.relative = std::move(entry.relative); + file.source = entry.source; + file.size = facts.size; + file.modified_ms = facts.modified_s * 1000; + result.files.push_back(std::move(file)); + } + // 最近修改的在前;同一秒内保持「记录里更靠后的在前」。 + std::stable_sort(result.files.begin(), result.files.end(), + [](const SessionOutputFile& a, const SessionOutputFile& b) { + return a.modified_ms > b.modified_ms; + }); + const std::size_t limit = query.limit == 0 ? 50 : query.limit; + if (result.files.size() > limit) { + result.has_more = true; + result.files.resize(limit); + } + return result; +} + +} // namespace acecode diff --git a/src/domain/session/session_outputs_index.hpp b/src/domain/session/session_outputs_index.hpp new file mode 100644 index 000000000..bd9923a1a --- /dev/null +++ b/src/domain/session/session_outputs_index.hpp @@ -0,0 +1,66 @@ +#pragma once + +// 会话产物的读盘一侧:增量扫描会话记录取候选,跟到子代理会话,再到磁盘上 +// 核对文件还在不在。纯逻辑见 session_outputs.hpp。 +// +// 成本:每个记录文件只在第一次查询时整读一遍,之后只读新追加的部分(按文件 +// 身份 + 已读偏移 + 文件头指纹判断能否接着读;压缩 / 回退把文件换掉后从头再读)。 +// 行先过 jsonl_line_may_carry_outputs 预筛,绝大多数行不解析 JSON。 + +#include "session_outputs.hpp" + +#include +#include +#include +#include +#include + +namespace acecode { + +struct SessionOutputFile { + std::string path; // 归一后的绝对路径(UTF-8) + std::string relative; // 在会话文件夹内时的相对路径(正斜杠),否则为空 + SessionOutputSource source = SessionOutputSource::Write; + std::uint64_t size = 0; + std::int64_t modified_ms = 0; // Unix 毫秒(秒级精度) +}; + +struct SessionOutputsQuery { + std::string session_id; + // 会话记录所在目录(SessionStorage::get_project_dir);子代理会话与主会话同目录。 + std::string project_dir; + // 解析记录里相对路径用的工作目录。 + std::string cwd; + // 会话文件夹:工作目录、worktree、附加文件夹。不必归一,这里统一处理。 + std::vector folders; + std::size_t limit = 50; +}; + +struct SessionOutputsResult { + std::vector files; // 按修改时间新 → 旧 + bool has_more = false; +}; + +class SessionOutputsIndex { +public: + SessionOutputsResult query(const SessionOutputsQuery& query); + +private: + struct Scan { + std::string identity; + std::string prefix; + std::uint64_t offset = 0; + std::uint64_t used = 0; + std::vector candidates; // 旧 → 新 + std::vector children; + }; + + Scan scan_session(const std::string& jsonl_path); + static void advance(Scan& scan, const std::string& jsonl_path); + + std::mutex mu_; + std::uint64_t tick_ = 0; + std::unordered_map scans_; +}; + +} // namespace acecode diff --git a/tests/config/config_web_ui_preferences_test.cpp b/tests/config/config_web_ui_preferences_test.cpp index 0c047ece5..56ef48d7a 100644 --- a/tests/config/config_web_ui_preferences_test.cpp +++ b/tests/config/config_web_ui_preferences_test.cpp @@ -292,6 +292,54 @@ TEST(ConfigWebUiPreferencesMessageAutoCollapse, DisabledRoundTripsAndDefaultIsOm std::filesystem::remove(path, ec); } +// 场景:设置 > 外观 > 子代理头像(花瓣 / 像素小人)存在 config.json 的 web_ui 里。 +// 期望:默认是 geometric(花瓣);旧配置没有这个键、或者值不认识 / 类型不对时仍按 geometric 加载, +// 不影响同一段里的其他外观设置;校验函数只认这两个值。 +TEST(ConfigWebUiPreferencesSubagentAvatarStyle, DefaultsToGeometricAndIgnoresInvalidValues) { + EXPECT_EQ(WebUiPreferencesConfig{}.subagent_avatar_style, "geometric"); + EXPECT_TRUE(is_valid_web_ui_subagent_avatar_style("pixel")); + EXPECT_TRUE(is_valid_web_ui_subagent_avatar_style("geometric")); + EXPECT_FALSE(is_valid_web_ui_subagent_avatar_style("")); + EXPECT_FALSE(is_valid_web_ui_subagent_avatar_style("Geometric")); + EXPECT_FALSE(is_valid_web_ui_subagent_avatar_style("flower")); + + const auto path = temp_config_path("subagent-avatar-default"); + for (const auto& value : {nlohmann::json{{"font_size", "large"}}, + nlohmann::json{{"font_size", "large"}, {"subagent_avatar_style", "flower"}}, + nlohmann::json{{"font_size", "large"}, {"subagent_avatar_style", 1}}}) { + write_json(path, {{"web_ui", value}}); + const auto loaded = load_config_from_path(path.string()); + EXPECT_EQ(loaded.web_ui.subagent_avatar_style, "geometric") << value.dump(); + EXPECT_EQ(loaded.web_ui.font_size, "large") << value.dump(); + } + std::error_code ec; + std::filesystem::remove(path, ec); +} + +// 场景:用户把头像风格改成像素小人,之后又改回花瓣。 +// 期望:pixel 写进 config.json 并能读回;改回默认值(geometric)后这个键不落盘 +// (配置文件只记与默认不同的值)。 +TEST(ConfigWebUiPreferencesSubagentAvatarStyle, PixelRoundTripsAndDefaultIsOmitted) { + const auto path = temp_config_path("subagent-avatar-roundtrip"); + AppConfig cfg; + cfg.web_ui.subagent_avatar_style = "pixel"; + save_config(cfg, path.string()); + { + std::ifstream input(path); + const auto saved = nlohmann::json::parse(input); + EXPECT_EQ(saved["web_ui"]["subagent_avatar_style"], "pixel"); + } + EXPECT_EQ(load_config_from_path(path.string()).web_ui.subagent_avatar_style, "pixel"); + cfg.web_ui.subagent_avatar_style = "geometric"; + save_config(cfg, path.string()); + { + std::ifstream input(path); + EXPECT_FALSE(nlohmann::json::parse(input).contains("web_ui")); + } + std::error_code ec; + std::filesystem::remove(path, ec); +} + // 场景:主题风格 ID 的格式校验(配置文件、REST 与前端共用同一规则)。 // 期望:空串表示主题默认风格;只接受 1-24 位小写字母数字、单个连字符分隔的词。 // 大写、首尾或连续连字符、超长、非 ASCII 都拒绝——它会直接写进 HTML 属性。 diff --git a/tests/session/session_outputs_test.cpp b/tests/session/session_outputs_test.cpp new file mode 100644 index 000000000..eec67ba3e --- /dev/null +++ b/tests/session/session_outputs_test.cpp @@ -0,0 +1,346 @@ +// 会话「产物」(摘要面板的产物分组)的单测: +// - session_outputs.hpp 的纯逻辑:从工具结果元数据取候选、扩展名判定、 +// JSONL 行预筛、过滤去重; +// - session_outputs_index.hpp 的读盘一侧:增量扫描会话记录、跟到子代理会话、 +// 核对文件是否还在、按修改时间排序。 +// 背景:产物列表完全由会话记录推导,不改写文件的工具;这里的用例就是 +// 「记录里长什么样 → 列表里出什么」的契约。 + +#include "session/session_outputs.hpp" +#include "session/session_outputs_index.hpp" +#include "session/session_serializer.hpp" +#include "session/session_storage.hpp" +#include "utils/utf8_path.hpp" +#include "utils/uuid.hpp" + +#include +#include + +#include +#include +#include + +namespace { + +using acecode::ChatMessage; +using acecode::SessionOutputCandidate; +using acecode::SessionOutputRoots; +using acecode::SessionOutputSource; +using nlohmann::json; + +// file_write 的工具结果:tool_result_committer 把 ToolSummary 编码进 metadata.tool_summary。 +ChatMessage write_result(const std::string& path, const char* verb = "Created", bool success = true) { + ChatMessage message; + message.role = "tool"; + message.tool_call_id = "call-write"; + message.content = "Created file: " + path; + message.metadata = { + {"tool_success", success}, + {"tool_summary", {{"verb", verb}, {"object", path}, {"icon", ""}, {"metrics", json::array()}}}, + }; + return message; +} + +// bash 的工具结果:shell_output_files 找到的文件在 metadata.output_files。 +ChatMessage bash_result(const std::vector& files, bool success = true) { + ChatMessage message; + message.role = "tool"; + message.tool_call_id = "call-bash"; + message.content = "(no output)"; + message.metadata = {{"tool_success", success}, {"exit_code", success ? 0 : 1}, {"output_files", files}}; + return message; +} + +struct OutputsFixture { + std::filesystem::path root = + std::filesystem::temp_directory_path() / ("ace-outputs-" + acecode::generate_uuid()); + std::filesystem::path project = root / "project"; // 会话记录目录 + std::filesystem::path work = root / "work"; // 会话工作目录 + OutputsFixture() { + std::filesystem::create_directories(project); + std::filesystem::create_directories(work); + } + ~OutputsFixture() { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + std::string jsonl(const std::string& session_id) const { + return acecode::SessionStorage::session_path(acecode::path_to_utf8(project), session_id); + } + std::string touch(const std::string& relative, int age_seconds = 0) const { + const auto path = work / relative; + std::filesystem::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << "content"; + if (age_seconds > 0) { + std::filesystem::last_write_time( + path, std::filesystem::last_write_time(path) - std::chrono::seconds(age_seconds)); + } + return acecode::path_to_utf8(path); + } + acecode::SessionOutputsQuery query(const std::string& session_id) const { + acecode::SessionOutputsQuery q; + q.session_id = session_id; + q.project_dir = acecode::path_to_utf8(project); + q.cwd = acecode::path_to_utf8(work); + q.folders = {q.cwd}; + return q; + } +}; + +std::vector relatives(const acecode::SessionOutputsResult& result) { + std::vector out; + for (const auto& file : result.files) out.push_back(file.relative); + return out; +} + +} // namespace + +// 场景:判定哪些文件名算「拿来看 / 交付」的产物。 +// 期望:文档、表格、图片等扩展名(不分大小写)算;代码、无扩展名、点文件不算; +// 正反斜杠路径都只看最后一段。 +TEST(SessionOutputs, DeliverableExtensionsAreDocumentsNotCode) { + EXPECT_TRUE(acecode::is_deliverable_file_name("report.md")); + EXPECT_TRUE(acecode::is_deliverable_file_name("C:\\out\\Q3.XLSX")); + EXPECT_TRUE(acecode::is_deliverable_file_name("/tmp/chart.svg")); + EXPECT_FALSE(acecode::is_deliverable_file_name("src/main.cpp")); + EXPECT_FALSE(acecode::is_deliverable_file_name("Makefile")); + EXPECT_FALSE(acecode::is_deliverable_file_name(".md")); + // 目录名里有点、文件本身没有扩展名:不能把目录的「扩展名」当成文件的。 + EXPECT_FALSE(acecode::is_deliverable_file_name("docs.md/readme")); +} + +// 场景:file_write 成功的结果(新建 = Created,覆写 = Wrote)。 +// 期望:都产出一条 Write 候选;写入失败(tool_success=false)和编辑(Edited)不算。 +TEST(SessionOutputs, FileWriteSummaryYieldsCandidateOnlyOnSuccess) { + auto created = acecode::session_output_candidates(write_result("C:/w/a.md")); + ASSERT_EQ(created.size(), 1u); + EXPECT_EQ(created[0].path, "C:/w/a.md"); + EXPECT_EQ(created[0].source, SessionOutputSource::Write); + + EXPECT_EQ(acecode::session_output_candidates(write_result("C:/w/a.md", "Wrote")).size(), 1u); + EXPECT_TRUE(acecode::session_output_candidates(write_result("C:/w/a.md", "Created", false)).empty()); + EXPECT_TRUE(acecode::session_output_candidates(write_result("C:/w/a.md", "Edited")).empty()); +} + +// 场景:apply_patch 一次改多个文件,metadata.files 里有 add / update / delete。 +// 期望:只有新建(add)算产物;改已有文件相当于编辑,归「变更」。 +TEST(SessionOutputs, ApplyPatchCountsOnlyAddedFiles) { + ChatMessage message; + message.role = "tool"; + message.metadata = { + {"tool_success", true}, + {"tool_summary", {{"verb", "Patched"}, {"object", "3 files"}}}, + {"files", json::array({ + {{"path", "C:/w/new.md"}, {"type", "add"}}, + {{"path", "C:/w/old.md"}, {"type", "update"}}, + {{"path", "C:/w/gone.md"}, {"type", "delete"}}, + })}, + }; + const auto candidates = acecode::session_output_candidates(message); + ASSERT_EQ(candidates.size(), 1u); + EXPECT_EQ(candidates[0].path, "C:/w/new.md"); + EXPECT_EQ(candidates[0].source, SessionOutputSource::Patch); +} + +// 场景:bash 跑脚本生成了文件,但脚本最后以非零退出码结束。 +// 期望:文件已经在磁盘上了,仍然算产物(不看 tool_success)。 +TEST(SessionOutputs, BashOutputFilesCountEvenWhenCommandFailed) { + const auto candidates = acecode::session_output_candidates(bash_result({"C:/w/chart.png"}, false)); + ASSERT_EQ(candidates.size(), 1u); + EXPECT_EQ(candidates[0].source, SessionOutputSource::Shell); +} + +// 场景:后台命令结束后的完成通知(user 角色 + metadata.background_task)。 +// 期望:通知里的 output_files 算产物;普通 user 消息不产出候选。 +TEST(SessionOutputs, BackgroundCompletionNoticeCarriesOutputFiles) { + ChatMessage notice; + notice.role = "user"; + notice.metadata = {{"background_task", {{"id", "bg1"}, {"output_files", {"C:/w/long.csv"}}}}}; + const auto candidates = acecode::session_output_candidates(notice); + ASSERT_EQ(candidates.size(), 1u); + EXPECT_EQ(candidates[0].path, "C:/w/long.csv"); + + ChatMessage plain; + plain.role = "user"; + plain.content = "please write report.md"; + EXPECT_TRUE(acecode::session_output_candidates(plain).empty()); +} + +// 场景:扫描会话记录时先用子串预筛,只解析可能带产物的行。 +// 期望:四种来源落盘后的行都能通过预筛;正文里出现同样字样的普通消息不通过 +// (JSON 字符串里的引号带反斜杠),否则编辑 ACECode 自己的源码会让每一行都被解析。 +TEST(SessionOutputs, PrefilterMatchesSerializedSourcesButNotQuotedText) { + EXPECT_TRUE(acecode::jsonl_line_may_carry_outputs(acecode::serialize_message(write_result("C:/w/a.md")))); + EXPECT_TRUE(acecode::jsonl_line_may_carry_outputs(acecode::serialize_message(bash_result({"C:/w/a.png"})))); + + ChatMessage spawn; + spawn.role = "tool"; + spawn.metadata = {{"subagent_session_id", "child-1"}}; + EXPECT_TRUE(acecode::jsonl_line_may_carry_outputs(acecode::serialize_message(spawn))); + EXPECT_EQ(acecode::session_output_child_session_id(spawn), "child-1"); + + ChatMessage assistant; + assistant.role = "assistant"; + assistant.content = R"(the summary is {"verb":"Created"} and "output_files" in JSON)"; + EXPECT_FALSE(acecode::jsonl_line_may_carry_outputs(acecode::serialize_message(assistant))); +} + +// 场景:工作目录内既写了文档也写了代码,还写了 .acecode 下的内部文件。 +// 期望:只留文档;代码归「变更」;.acecode / .git 下的不算。 +TEST(SessionOutputs, InsideWorkspaceKeepsOnlyDeliverablesOutsideHiddenState) { + SessionOutputRoots roots; + roots.folders = {"C:/repo"}; + const std::vector candidates = { + {"C:/repo/docs/plan.md", SessionOutputSource::Write}, + {"C:/repo/src/main.cpp", SessionOutputSource::Write}, + {"C:/repo/.acecode/tmp/session-1/scratch.md", SessionOutputSource::Write}, + {"C:/repo/.git/description.txt", SessionOutputSource::Shell}, + }; + const auto entries = acecode::select_session_outputs(candidates, roots); + ASSERT_EQ(entries.size(), 1u); + EXPECT_EQ(entries[0].relative, "docs/plan.md"); + EXPECT_EQ(entries[0].folder_index, 0); +} + +// 场景:worktree 会话。工作目录是 <仓库>/.acecode/worktrees/<名字>,仓库根也在 +// 会话文件夹里。 +// 期望:worktree 里的文档算产物(相对路径按 worktree 算,不因路径里有 .acecode +// 被丢掉);仓库根的 .acecode/tmp 仍然不算。 +// 回归:只按「路径里有 .acecode」过滤时,worktree 会话的产物列表永远是空的。 +TEST(SessionOutputs, WorktreeFolderIsNotTreatedAsHiddenState) { + SessionOutputRoots roots; + roots.folders = {"C:/repo", "C:/repo/.acecode/worktrees/feat"}; + const std::vector candidates = { + {"C:/repo/.acecode/worktrees/feat/docs/spec.md", SessionOutputSource::Write}, + {"C:/repo/.acecode/tmp/session-1/note.md", SessionOutputSource::Write}, + }; + const auto entries = acecode::select_session_outputs(candidates, roots); + ASSERT_EQ(entries.size(), 1u); + EXPECT_EQ(entries[0].relative, "docs/spec.md"); + EXPECT_EQ(entries[0].folder_index, 1); +} + +// 场景:写到工作目录外面(桌面、别的盘)和系统临时目录。 +// 期望:工作目录外任何扩展名都算(用户明确要的导出位置);临时目录里的不算。 +TEST(SessionOutputs, OutsideWorkspaceKeepsAnyExtensionExceptTemp) { + SessionOutputRoots roots; + roots.folders = {"C:/repo"}; + roots.temp_roots = {"C:/Users/u/AppData/Local/Temp"}; + const std::vector candidates = { + {"D:/export/data.json", SessionOutputSource::Write}, + {"C:/Users/u/AppData/Local/Temp/x/report.md", SessionOutputSource::Write}, + // 名字只是以工作目录为前缀的兄弟目录,不在工作目录内。 + {"C:/repo-other/main.cpp", SessionOutputSource::Write}, + }; + const auto entries = acecode::select_session_outputs(candidates, roots); + ASSERT_EQ(entries.size(), 2u); + EXPECT_EQ(entries[0].path, "C:/repo-other/main.cpp"); + EXPECT_TRUE(entries[0].relative.empty()); + EXPECT_EQ(entries[0].folder_index, -1); + EXPECT_EQ(entries[1].path, "D:/export/data.json"); +} + +// 场景:同一个文件被写了多次,记录里的路径大小写、分隔符不一致(Windows)。 +// 期望:只出现一次,取最近一次;结果按新 → 旧排列。 +TEST(SessionOutputs, DedupesByPathNewestFirst) { + SessionOutputRoots roots; + roots.folders = {"C:/repo"}; + const std::vector candidates = { + {"C:/repo/a.md", SessionOutputSource::Write}, + {"C:/repo/b.md", SessionOutputSource::Write}, + {"c:\\repo\\A.md", SessionOutputSource::Shell}, + }; + const auto entries = acecode::select_session_outputs(candidates, roots); + ASSERT_EQ(entries.size(), 2u); + EXPECT_EQ(entries[0].relative, "A.md"); + EXPECT_EQ(entries[0].source, SessionOutputSource::Shell); + EXPECT_EQ(entries[1].relative, "b.md"); +} + +// 场景:会话进行中反复查询产物(面板可见时每隔几秒一次)。 +// 期望:第一次读到已有的写入;之后追加的工具结果下一次查询就能看到(只读 +// 新追加的部分);文件从磁盘上删掉后不再返回。 +TEST(SessionOutputsIndex, PicksUpAppendedResultsAndDropsDeletedFiles) { + OutputsFixture fixture; + const std::string first = fixture.touch("report.md", 60); + acecode::SessionStorage::write_messages(fixture.jsonl("s1"), {write_result(first)}); + + acecode::SessionOutputsIndex index; + auto result = index.query(fixture.query("s1")); + EXPECT_EQ(relatives(result), (std::vector{"report.md"})); + + const std::string second = fixture.touch("charts/sales.png"); + ASSERT_TRUE(acecode::SessionStorage::append_message(fixture.jsonl("s1"), bash_result({second}))); + result = index.query(fixture.query("s1")); + // sales.png 更新(report.md 的修改时间被拨到 60 秒前),排前面。 + EXPECT_EQ(relatives(result), (std::vector{"charts/sales.png", "report.md"})); + EXPECT_GT(result.files[0].size, 0u); + EXPECT_GT(result.files[0].modified_ms, 0); + + std::filesystem::remove(acecode::path_from_utf8(first)); + result = index.query(fixture.query("s1")); + EXPECT_EQ(relatives(result), (std::vector{"charts/sales.png"})); +} + +// 场景:压缩 / 回退把会话记录整个换成更短的内容(文件身份或长度变了)。 +// 期望:从头重扫,旧记录里的候选不残留。 +// 回归:只按「已读偏移」续读时,文件变短后 offset 越界,读盘抛异常。 +TEST(SessionOutputsIndex, RescansWhenHistoryFileIsReplaced) { + OutputsFixture fixture; + const std::string old_file = fixture.touch("old.md"); + const std::string new_file = fixture.touch("new.md"); + acecode::SessionStorage::write_messages(fixture.jsonl("s1"), + {write_result(old_file), write_result(fixture.touch("filler-with-a-long-name.md"))}); + + acecode::SessionOutputsIndex index; + EXPECT_EQ(index.query(fixture.query("s1")).files.size(), 2u); + + acecode::SessionStorage::write_messages(fixture.jsonl("s1"), {write_result(new_file)}); + EXPECT_EQ(relatives(index.query(fixture.query("s1"))), (std::vector{"new.md"})); +} + +// 场景:主会话派生了子代理,文件是子代理写的(记录在子会话自己的 JSONL 里)。 +// 期望:主会话的产物列表包含子代理写的文件;子会话记录不存在时不报错。 +TEST(SessionOutputsIndex, FollowsSubagentSessions) { + OutputsFixture fixture; + ChatMessage spawned; + spawned.role = "tool"; + spawned.metadata = {{"tool_success", true}, {"subagent_session_id", "child-1"}}; + ChatMessage missing = spawned; + missing.metadata["subagent_session_id"] = "child-missing"; + acecode::SessionStorage::write_messages(fixture.jsonl("root"), {spawned, missing}); + acecode::SessionStorage::write_messages(fixture.jsonl("child-1"), + {write_result(fixture.touch("from-child.docx"))}); + + acecode::SessionOutputsIndex index; + EXPECT_EQ(relatives(index.query(fixture.query("root"))), + (std::vector{"from-child.docx"})); +} + +// 场景:产物很多,面板只要最近的 N 个。 +// 期望:按文件修改时间新 → 旧截断到 limit,has_more 为 true。 +TEST(SessionOutputsIndex, OrdersByModifiedTimeAndReportsHasMore) { + OutputsFixture fixture; + std::vector messages; + // 记录顺序与修改时间顺序相反:a 最先写但最近被改过。 + messages.push_back(write_result(fixture.touch("a.md", 0))); + messages.push_back(write_result(fixture.touch("b.md", 120))); + messages.push_back(write_result(fixture.touch("c.md", 60))); + acecode::SessionStorage::write_messages(fixture.jsonl("s1"), messages); + + acecode::SessionOutputsIndex index; + auto query = fixture.query("s1"); + query.limit = 2; + const auto result = index.query(query); + EXPECT_TRUE(result.has_more); + EXPECT_EQ(relatives(result), (std::vector{"a.md", "c.md"})); +} + +// 场景:请求里的会话 id 带路径成分(../ 之类)。 +// 期望:直接返回空,不拿它去拼文件名读别处的 .jsonl。 +TEST(SessionOutputsIndex, RejectsSessionIdsWithPathComponents) { + OutputsFixture fixture; + acecode::SessionOutputsIndex index; + auto query = fixture.query("../project/s1"); + EXPECT_TRUE(index.query(query).files.empty()); +} diff --git a/tests/tool/background_tasks_test.cpp b/tests/tool/background_tasks_test.cpp index bb66f5ba0..a3c88d061 100644 --- a/tests/tool/background_tasks_test.cpp +++ b/tests/tool/background_tasks_test.cpp @@ -229,6 +229,31 @@ TEST(BackgroundTaskRegistryTest, BackgroundCommandNotifiesWithOutputWhenFinished std::filesystem::remove_all(dir); } +// 场景:后台命令(长时间运行的脚本)生成了一个文档类文件后结束。 +// 期望:完成通知的元数据里带 output_files(绝对路径),摘要面板的「产物」分组靠它 +// 列出后台命令生成的文件;日志文件(.log)不算产物。 +// 背景:前台 bash 在工具结果里记 output_files,转后台的命令没有工具结果可记, +// 只能记在完成通知上,否则跑得久的脚本(超时自动转后台)生成的文件永远列不出来。 +TEST(BackgroundTaskRegistryTest, FinishedNoticeCarriesFilesTheCommandProduced) { + const auto dir = unique_temp_dir("bg_outputs"); + NoticeCollector collector; + BackgroundTaskRegistry registry; + registry.connect(collector.sink()); + ShellLaunchFailure failure; + const std::string id = registry.start_shell(shell_start("echo hello > bg-report.txt", dir), failure); + ASSERT_FALSE(id.empty()) << failure.message; + + const auto notice = collector.wait_event("finished", 15s); + ASSERT_TRUE(notice.has_value()); + ASSERT_TRUE(notice->metadata.contains("output_files")) << notice->metadata.dump(); + const auto files = notice->metadata["output_files"].get>(); + ASSERT_EQ(files.size(), 1u) << notice->metadata.dump(); + EXPECT_EQ(path_from_utf8(files[0]).filename(), std::filesystem::path("bg-report.txt")); + EXPECT_TRUE(path_from_utf8(files[0]).is_absolute()); + registry.shutdown(); + std::filesystem::remove_all(dir); +} + // 场景:模型 / 用户停止一个还在跑的后台命令。期望:几秒内结束,通知状态 stopped; // 再停一次报「不在运行」。 TEST(BackgroundTaskRegistryTest, StopKillsRunningCommandAndNotifiesStopped) { diff --git a/tests/tool/shell_output_files_test.cpp b/tests/tool/shell_output_files_test.cpp new file mode 100644 index 000000000..79ad5c68b --- /dev/null +++ b/tests/tool/shell_output_files_test.cpp @@ -0,0 +1,205 @@ +// bash 命令产物探测(src/adapters/tool/shell_output_files.cpp)的单测。 +// 背景:脚本写文件不经过文件工具,会话记录里没有路径;bash 结束后按 +// 「命令 / 输出里提到的路径」+「扫一遍工作目录找刚改过的文档类文件」两条线索 +// 尽力找出来,记进工具结果的 metadata.output_files,摘要面板的「产物」读它。 +// 这里分三层测:哪些命令值得找(纯判定)、路径片段怎么提(纯函数)、 +// 真实目录上的收集结果(含 bash 工具端到端)。 + +#include "tool/bash_tool.hpp" +#include "tool/shell_output_files.hpp" +#include "tool/tool_executor.hpp" +#include "utils/utf8_path.hpp" +#include "utils/uuid.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace { + +using acecode::collect_shell_output_files; +using acecode::make_shell_output_probe; +using acecode::shell_command_may_produce_files; +using acecode::shell_command_path_mentions; +using acecode::shell_text_path_mentions; + +bool contains(const std::vector& values, const std::string& value) { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +std::int64_t now_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +} + +struct WorkDir { + std::filesystem::path root = + std::filesystem::temp_directory_path() / ("ace-shell-outputs-" + acecode::generate_uuid()); + WorkDir() { std::filesystem::create_directories(root); } + ~WorkDir() { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + std::string utf8() const { return acecode::path_to_utf8(root); } + // age_seconds > 0:把修改时间拨到命令开始之前,模拟「早就在那儿的文件」。 + std::string touch(const std::string& relative, int age_seconds = 0) const { + const auto path = root / relative; + std::filesystem::create_directories(path.parent_path()); + std::ofstream(path, std::ios::binary) << "content"; + if (age_seconds > 0) { + std::filesystem::last_write_time( + path, std::filesystem::last_write_time(path) - std::chrono::seconds(age_seconds)); + } + return acecode::path_to_utf8(path.lexically_normal()); + } +}; + +// 结果里的路径相对工作目录的写法(正斜杠),方便断言。 +std::vector relative_to(const WorkDir& dir, const std::vector& files) { + std::vector out; + for (const auto& file : files) { + out.push_back(acecode::path_to_utf8_generic( + acecode::path_from_utf8(file).lexically_relative(dir.root))); + } + return out; +} + +} // namespace + +// 场景:决定一条命令结束后要不要去找产物。 +// 期望:跑脚本、带输出重定向的要找;只读命令、纯构建 / 包管理 / 版本控制命令不找 +// (它们一次改动一批文档类文件,不是产物);cd 之类的铺垫不影响后面的脚本; +// `uv run` 这类是在跑用户的程序,照常找。 +TEST(ShellOutputFiles, DecidesWhichCommandsAreWorthScanning) { + EXPECT_TRUE(shell_command_may_produce_files("python gen_report.py")); + EXPECT_TRUE(shell_command_may_produce_files("cd reports && python gen.py")); + EXPECT_TRUE(shell_command_may_produce_files("OUT=1 python gen.py")); + EXPECT_TRUE(shell_command_may_produce_files("uv run report.py")); + // 回归:echo 被当成「不会生成文件的铺垫」,连 `echo x > notes.md` 也跳过了。 + EXPECT_TRUE(shell_command_may_produce_files("echo hello > notes.md")); + + EXPECT_FALSE(shell_command_may_produce_files("")); + EXPECT_FALSE(shell_command_may_produce_files("ls -la")); + EXPECT_FALSE(shell_command_may_produce_files("git pull")); + EXPECT_FALSE(shell_command_may_produce_files("cd web && pnpm build")); + EXPECT_FALSE(shell_command_may_produce_files("uv sync")); +} + +// 场景:命令行里写明了输出位置(位置参数、--out=、带空格的引号路径、重定向目标)。 +// 期望:文档类扩展名的单词都取出来(去引号);脚本本身(.py)和网址不取。 +TEST(ShellOutputFiles, CommandMentionsPickDeliverablePathsOnly) { + const auto mentions = shell_command_path_mentions( + "python gen.py --out=reports/q3.xlsx \"C:\\My Docs\\a b.pdf\" > log.txt"); + EXPECT_TRUE(contains(mentions, "reports/q3.xlsx")); + EXPECT_TRUE(contains(mentions, "C:\\My Docs\\a b.pdf")); + EXPECT_TRUE(contains(mentions, "log.txt")); + EXPECT_FALSE(contains(mentions, "gen.py")); + + const auto download = shell_command_path_mentions("curl https://example.com/a.pdf -o a.pdf"); + EXPECT_EQ(download, (std::vector{"a.pdf"})); +} + +// 场景:脚本在输出里打印了保存位置(英文、中文带全角标点、Python repr 的引号)。 +// 期望:取出紧凑片段;路径里带空格时,同一行左边有盘符起点就再给出整段; +// 网址、a.md.bak 这种后面还接着扩展名的不取。 +TEST(ShellOutputFiles, TextMentionsHandleSpacesPunctuationAndUrls) { + EXPECT_EQ(shell_text_path_mentions("Saved to out/report.xlsx"), + (std::vector{"out/report.xlsx"})); + EXPECT_EQ(shell_text_path_mentions("result: out.csv, rows=3\n"), + (std::vector{"out.csv"})); + EXPECT_EQ(shell_text_path_mentions("saved:out.csv"), (std::vector{"out.csv"})); + EXPECT_EQ(shell_text_path_mentions("wrote 'C:\\data\\a.csv' ok"), + (std::vector{"C:\\data\\a.csv"})); + + // 全角冒号(EF BC 9A)和句号紧贴路径,路径里还有空格。字节写法免得被换成半角。 + const std::string chinese = std::string("\xE5\xB7\xB2\xE4\xBF\x9D\xE5\xAD\x98\xE5\x88\xB0") + + "\xEF\xBC\x9A" + "D:\\data\\q3 report.xlsx" + "\xE3\x80\x82"; + const auto mentions = shell_text_path_mentions(chinese); + EXPECT_TRUE(contains(mentions, "report.xlsx")); + EXPECT_TRUE(contains(mentions, "D:\\data\\q3 report.xlsx")); + + EXPECT_TRUE(shell_text_path_mentions("see https://example.com/files/a.pdf").empty()); + EXPECT_TRUE(shell_text_path_mentions("backup at notes.md.bak").empty()); + EXPECT_TRUE(shell_text_path_mentions("version 1.2 of tool.exe").empty()); +} + +// 场景:python 脚本在工作目录里生成了几个文件,没有打印任何路径。 +// 期望:命令开始后改过的文档类文件被找到;早就存在的文档、代码文件、依赖 / +// 点目录里的文件不算;build / out 这类构建目录只看第一层。 +TEST(ShellOutputFiles, ScanFindsFreshDeliverablesInWorkspace) { + WorkDir dir; + dir.touch("existing.md", 3600); + const auto probe = make_shell_output_probe("python gen.py", dir.utf8(), now_ms()); + ASSERT_TRUE(probe.enabled); + dir.touch("charts/sales.png"); + dir.touch("out/report.xlsx"); + dir.touch("out/cache/deep.pdf"); // 构建目录的第二层:不看 + dir.touch("src/main.py"); // 代码 + dir.touch("node_modules/pkg/README.md"); // 依赖目录 + dir.touch(".cache/notes.md"); // 点目录 + + const auto files = relative_to(dir, collect_shell_output_files(probe, "python gen.py", "done")); + EXPECT_EQ(files, (std::vector{"charts/sales.png", "out/report.xlsx"})); +} + +// 场景:一条命令改动了一大批文档类文件(构建、批量转换)。 +// 期望:超过 bulk_threshold 就把扫描结果整个作废,不拿一堆构建产物刷屏; +// 但命令里明确写出来的那个输出文件仍然保留。 +TEST(ShellOutputFiles, BulkChangesAreDroppedButExplicitMentionSurvives) { + WorkDir dir; + const auto probe = make_shell_output_probe("python build_site.py -o summary.md", dir.utf8(), now_ms()); + for (int i = 0; i < 20; ++i) dir.touch("pages/page" + std::to_string(i) + ".html"); + dir.touch("summary.md"); + + const auto files = relative_to(dir, + collect_shell_output_files(probe, "python build_site.py -o summary.md", "")); + EXPECT_EQ(files, (std::vector{"summary.md"})); +} + +// 场景:脚本把结果写到工作目录外面,并在输出里打印了绝对路径;同时命令行里 +// 提到了一个只是被读取的旧数据文件。 +// 期望:工作目录外的新文件靠「输出里提到」被找到;旧文件(修改时间早于命令 +// 开始)不算 —— 提到不等于生成。 +TEST(ShellOutputFiles, MentionsReachOutsideWorkspaceAndIgnoreStaleFiles) { + WorkDir dir; + WorkDir elsewhere; + dir.touch("input.csv", 3600); + const auto probe = make_shell_output_probe("python analyze.py input.csv", dir.utf8(), now_ms()); + const std::string exported = elsewhere.touch("export/result.pdf"); + + const auto files = collect_shell_output_files( + probe, "python analyze.py input.csv", "Saved to " + exported + "\n"); + EXPECT_EQ(files, (std::vector{exported})); +} + +// 场景:git / 只读命令运行期间,工作目录里正好有文档被改了(别的会话、用户自己)。 +// 期望:这类命令的探测是关着的,不把无关改动算到它头上。 +TEST(ShellOutputFiles, DisabledProbeReportsNothing) { + WorkDir dir; + const auto probe = make_shell_output_probe("git status", dir.utf8(), now_ms()); + EXPECT_FALSE(probe.enabled); + dir.touch("notes.md"); + EXPECT_TRUE(collect_shell_output_files(probe, "git status", "").empty()); +} + +// 场景:端到端。bash 工具执行一条写文件的命令。 +// 期望:工具结果的 metadata.output_files 里有这个文件的绝对路径,模型看到的 +// 正文不变(不往输出里加东西)。 +TEST(ShellOutputFiles, BashToolRecordsOutputFilesInMetadata) { + WorkDir dir; + acecode::ToolImpl tool = acecode::create_bash_tool(); + acecode::ToolContext ctx; + ctx.cwd = dir.utf8(); + const nlohmann::json args = {{"command", "echo hello > report.txt"}}; + const acecode::ToolResult result = tool.execute(args.dump(), ctx); + + ASSERT_TRUE(result.success) << result.output; + ASSERT_TRUE(result.metadata.contains("output_files")) << result.metadata.dump(); + const auto files = relative_to(dir, result.metadata["output_files"].get>()); + EXPECT_EQ(files, (std::vector{"report.txt"})); + EXPECT_EQ(result.output.find("report.txt"), std::string::npos); +} diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index e2ac74331..a1d4962df 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -9574,6 +9574,37 @@ TEST(WebServerHttp, UiPreferencesThemeSoundsRoundTrip) { EXPECT_FALSE(fx.cfg.web_ui.theme_sounds); } +// 场景:设置 > 外观里切换子代理头像(花瓣 / 像素小人)。 +// 期望:GET 默认返回 geometric(花瓣);单字段 PUT pixel 只改这一项并落盘,之后改别的外观设置 +// 不会把它冲回默认;不认识的值或非字符串整笔 400,内存配置保持 pixel;还能改回 geometric。 +TEST(WebServerHttp, UiPreferencesSubagentAvatarStyleRoundTrips) { + WebServerFixture fx; + const auto url = cpr::Url{fx.url("/api/config/ui-preferences")}; + const cpr::Header headers{{"Content-Type", "application/json"}}; + EXPECT_EQ(json::parse(cpr::Get(url).text)["subagent_avatar_style"], "geometric"); + auto pixel = cpr::Put(url, headers, cpr::Body{R"({"subagent_avatar_style":"pixel"})"}); + ASSERT_EQ(pixel.status_code, 200) << pixel.text; + EXPECT_EQ(json::parse(pixel.text)["subagent_avatar_style"], "pixel"); + EXPECT_EQ(json::parse(pixel.text)["font_size"], "medium"); + EXPECT_EQ(fx.cfg.web_ui.subagent_avatar_style, "pixel"); + auto other = cpr::Put(url, headers, cpr::Body{R"({"font_size":"large"})"}); + ASSERT_EQ(other.status_code, 200) << other.text; + EXPECT_EQ(json::parse(other.text)["subagent_avatar_style"], "pixel"); + const auto saved = acecode::load_config_from_path((fx.tmp_dir / "config.json").u8string()); + EXPECT_EQ(saved.web_ui.subagent_avatar_style, "pixel"); + EXPECT_EQ(saved.web_ui.font_size, "large"); + for (const char* body : {R"({"subagent_avatar_style":"flower"})", R"({"subagent_avatar_style":true})", + R"({"subagent_avatar_style":"Pixel","font_size":"small"})"}) { + auto rejected = cpr::Put(url, headers, cpr::Body{body}); + EXPECT_EQ(rejected.status_code, 400) << body << " " << rejected.text; + } + EXPECT_EQ(fx.cfg.web_ui.subagent_avatar_style, "pixel"); + EXPECT_EQ(fx.cfg.web_ui.font_size, "large"); + auto geometric = cpr::Put(url, headers, cpr::Body{R"({"subagent_avatar_style":"geometric"})"}); + ASSERT_EQ(geometric.status_code, 200) << geometric.text; + EXPECT_EQ(fx.cfg.web_ui.subagent_avatar_style, "geometric"); +} + TEST(WebServerHttp, UiPreferencesMessageAutoCollapseRollsBackOnSaveFailure) { WebServerFixture fx; std::filesystem::create_directories(fx.tmp_dir / "config.json"); diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 1bd2f3ef2..2ee6bf933 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1019,6 +1019,9 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '默认权限': 'Default permissions', '完全访问权限': 'Full access', '变更': 'Changes', + '产物': 'Outputs', + '显示更多': 'Show more', + '显示更少': 'Show less', '双列': 'Side by side', '自动换行': 'Word wrap', '扁平列表': 'Flat list', @@ -2549,4 +2552,8 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '本次会话允许': 'Allow for this session', '允许一次': 'Allow once', '已允许一次': 'Allowed once', + '子代理头像': 'Sub-agent avatars', + '花瓣': 'Petals', + '像素小人': 'Pixel characters', + 'subagent sub-agent agent avatar portrait icon style petal flower geometric pixel 子代理 智能体 头像 图标 花瓣 几何图形 像素小人': 'subagent sub-agent agent avatar portrait icon style petal flower geometric pixel', }); diff --git a/web/src/App.jsx b/web/src/App.jsx index 042b95cdd..ec992f5bd 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -117,7 +117,9 @@ import { clampPopupWidth, popupWidthRange } from './lib/responsiveSidebars.js'; import { ResponsiveSidebar } from './components/ResponsiveSidebar.jsx'; import { Toaster, toast } from './components/Toast.jsx'; import { SlashCommandsProvider } from './components/SlashCommandsContext.jsx'; +import { SubagentAvatarStyleProvider } from './components/GeometricAvatar.jsx'; import { FramelessResizeHandles } from './components/FramelessResizeHandles.jsx'; +import { useFramelessWindowState } from './components/WindowControls.jsx'; import { GlobalFindOverlay } from './components/GlobalFindOverlay.jsx'; import { canOpenConversationFind } from './lib/globalFind.js'; // 控制台停靠区带着 xterm,新手引导带着 react-joyride,都只在用户第一次打开时才需要; @@ -247,6 +249,8 @@ function parseDesktopBridgeResult(raw) { export function App() { useEditingHistoryShortcuts(); + const windowState = useFramelessWindowState(); + const resizeHandles = ; // Static product copy is marked at build time. Subscribing the shell to the // i18n instance refreshes mounted descendants immediately, while compiled // module-scope metadata resolves translations lazily on the rerender. @@ -423,6 +427,8 @@ export function App() { const fontSize = effectiveFontSize(uiPrefs); const sidebarSessionTime = effectiveSidebarSessionTime(uiPrefs); const messageAutoCollapse = effectiveMessageAutoCollapse(uiPrefs); + // 设置 > 外观 > 子代理头像。只存在 daemon 配置里(桌面版每次启动端口都变,本地存储留不住)。 + const [subagentAvatarStyle, setSubagentAvatarStyle] = useState(initialAppearance.subagentAvatarStyle); const applyAppearance = useCallback((next) => { setTheme(next.theme); setColorTheme(next.colorTheme); @@ -434,6 +440,7 @@ export function App() { sidebarSessionTime: next.sidebarSessionTime, messageAutoCollapse: next.messageAutoCollapse, }); + setSubagentAvatarStyle(next.subagentAvatarStyle); }, [setColorTheme, setTheme, setThemeEffects, setThemeSounds, setThemeVariant, setUiPrefs]); // The persistence controller outlives individual renders. Keep its callback // current so a later appearance choice is compared against the latest UI @@ -452,6 +459,7 @@ export function App() { themeVariant: bootstrapAppearance?.themeVariant ?? themeVariant, themeEffects: bootstrapAppearance?.themeEffects ?? themeEffects, themeSounds: bootstrapAppearance?.themeSounds ?? themeSounds, + subagentAvatarStyle, }, apply: (next) => applyAppearanceRef.current(next), save: (payload) => api.setUiPreferences(payload), @@ -2130,7 +2138,7 @@ export function App() { {desktopStartupStatus?.message || t('desktop.startupConnecting')} - + {resizeHandles} @@ -2141,7 +2149,7 @@ export function App() {
连接 daemon…
- + {resizeHandles} @@ -2151,7 +2159,7 @@ export function App() { return ( <> - + {resizeHandles} @@ -2205,6 +2213,7 @@ export function App() { return ( +
setSearchOpen(true)} @@ -2433,6 +2443,8 @@ export function App() { onSidebarSessionTimeChange={(next) => changeAppearance({ sidebarSessionTime: next })} messageAutoCollapse={messageAutoCollapse} onMessageAutoCollapseChange={(next) => changeAppearance({ messageAutoCollapse: next })} + subagentAvatarStyle={subagentAvatarStyle} + onSubagentAvatarStyleChange={(next) => changeAppearance({ subagentAvatarStyle: next })} onThemeVariantChange={(next) => changeAppearance({ themeVariant: next })} onThemeEffectsChange={(next) => changeAppearance({ themeEffects: next })} /> @@ -2448,7 +2460,7 @@ export function App() { onSelectSetting={handleSelectSetting} />
- + {resizeHandles} +
); } diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index beb282b57..c17e81c35 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -6887,6 +6887,7 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses { + const onDrop = (event) => { + const detail = event.detail; + if (!detail || detail.handled || detail.kind !== 'console') return; + const tabId = detail.target?.dataset?.tabId; + if (!tabId || !bodyRef.current?.contains(detail.target)) return; + detail.handled = injectTextToTab(tabId, fileTreeTerminalText(detail.items, HOST_OS)); + }; + window.addEventListener(FILE_TREE_DROP_EVENT, onDrop); + return () => window.removeEventListener(FILE_TREE_DROP_EVENT, onDrop); + }, [injectTextToTab]); + // NATIVE_DROP:注册全局回调,native 拦到系统文件拖放后经 host.eval 调它回传路径。 useEffect(() => { if (!NATIVE_DROP) return undefined; diff --git a/web/src/components/FramelessResizeHandles.jsx b/web/src/components/FramelessResizeHandles.jsx index f3ae03225..77d2418d8 100644 --- a/web/src/components/FramelessResizeHandles.jsx +++ b/web/src/components/FramelessResizeHandles.jsx @@ -7,22 +7,14 @@ // chromium browser_desktop_window_tree_host_win.cc::GetClientAreaInsets 的 // 注释)。所以走 JS 自制 + 转给 native start_window_resize,镜像现有的 // aceDesktop_startWindowDrag 范式。 -// -// strip 永远 render(只要进 frameless),最大化时 native 端会拒绝调用。 import { nativePointerEvent } from './WindowControls.jsx'; -import { FRAMELESS_RESIZE_ACTION, framelessResizeMouseDownAction } from '../lib/framelessResize.js'; +import { FRAMELESS_RESIZE_ACTION, canResizeFramelessWindow, framelessResizeMouseDownAction } from '../lib/framelessResize.js'; const RESIZE_STRIP_HEIGHT_PX = 6; const RESIZE_STRIP_WIDTH_PX = 6; const RESIZE_CORNER_SIZE_PX = 10; -function isFrameless() { - return typeof window !== 'undefined' - && window.__ACECODE_FRAMELESS_WINDOW__ === true - && typeof window.aceDesktop_startWindowResize === 'function'; -} - function makeStartResize(direction) { return (event) => { const action = framelessResizeMouseDownAction({ @@ -45,8 +37,8 @@ function makeStartResize(direction) { }; } -export function FramelessResizeHandles() { - if (!isFrameless()) return null; +export function FramelessResizeHandles({ windowState }) { + if (!canResizeFramelessWindow(windowState)) return null; // pointer-events: none 让中间空白区落回到底层内容。各边/角单独打开 // pointer-events: auto 接 mousedown。 return ( diff --git a/web/src/components/GeometricAvatar.jsx b/web/src/components/GeometricAvatar.jsx new file mode 100644 index 000000000..ddb9ad433 --- /dev/null +++ b/web/src/components/GeometricAvatar.jsx @@ -0,0 +1,39 @@ +import { createContext, useContext } from 'react'; +import { clsx } from '../lib/format.js'; +import { + geometricAvatarCenterTone, + geometricAvatarPetals, + geometricAvatarSpec, +} from '../lib/geometricAvatar.js'; + +// 设置 > 外观 > 子代理头像。App 在最外层给值;没有 Provider 时(单独渲染的组件、测试)按默认的花瓣。 +const SubagentAvatarStyleContext = createContext('geometric'); +export const SubagentAvatarStyleProvider = SubagentAvatarStyleContext.Provider; +export function useSubagentAvatarStyle() { + return useContext(SubagentAvatarStyleContext); +} + +// 花瓣头像(配置值 geometric):矢量图,任何缩放比例都清楚。状态不用角标:运行中整朵慢慢转,结束后停住, +// 被中止的变灰。 +export function GeometricAvatar({ identity, size = 16, status = '' }) { + const spec = geometricAvatarSpec(identity); + const stopped = status === 'stopped'; + const fill = (tone) => (stopped ? 'currentColor' : spec[tone]); + const opacity = (tone) => (stopped && tone === 'soft' ? 0.5 : 0.9); + const centerTone = geometricAvatarCenterTone(spec); + return ( + + ); +} diff --git a/web/src/components/InputBar.jsx b/web/src/components/InputBar.jsx index f2264d7cf..54a8bbd29 100644 --- a/web/src/components/InputBar.jsx +++ b/web/src/components/InputBar.jsx @@ -87,6 +87,8 @@ import { SESSION_REFERENCE_DROP_EVENT, droppableSessionReferences, } from '../lib/sessionReferenceDrop.js'; +import { FILE_TREE_DROP_EVENT, insertFileTreeReferences } from '../lib/fileTreeDrop.js'; +import { useStableCallback } from '../lib/useStableCallback.js'; import { hasNativeContextPicker, parseNativeContextPickerResult, @@ -855,6 +857,28 @@ export const InputBar = forwardRef(function InputBar({ return () => window.removeEventListener(SESSION_REFERENCE_DROP_EVENT, onDrop); }, [currentSessionId, sessionReferenceDropEnabled, t]); + // 右侧文件树里的行拖到本输入框(或多选后右键「添加到会话」):在光标处插入 @路径 引用, + // 一次可以是多个;效果同逐个「添加到会话」。 + const acceptFileTreeDrop = useStableCallback((event) => { + const detail = event.detail; + if (disabled || !detail || detail.handled || detail.kind !== 'composer') return; + if (!rootRef.current?.contains(detail.target)) return; + const caret = composerSelection.end; + const insertion = insertFileTreeReferences(value, caret, detail.items, { + cwd: detail.cwd, + composerCwd: cwd, + }); + if (!insertion.count) return; + detail.handled = true; + updateValue(insertion.text, undefined, { begin: caret, end: caret }); + setEditedSinceHistory(true); + restorePathCaret(insertion.cursor); + }); + useEffect(() => { + window.addEventListener(FILE_TREE_DROP_EVENT, acceptFileTreeDrop); + return () => window.removeEventListener(FILE_TREE_DROP_EVENT, acceptFileTreeDrop); + }, [acceptFileTreeDrop]); + const handleFileIntake = useCallback((payload, transfer) => { if (disabled || !transfer?.isActive()) return Promise.resolve(true); setCapabilityOpen(false); @@ -1700,6 +1724,7 @@ export const InputBar = forwardRef(function InputBar({ data-main-composer={mainComposer ? 'true' : undefined} data-native-file-drop-disabled={disabled ? 'true' : undefined} data-session-reference-drop={sessionReferenceDropEnabled ? 'true' : undefined} + data-file-tree-drop={disabled ? undefined : 'true'} onPointerDownCapture={(event) => preserveComposerFocusOnPointerDown(event, rootRef.current)} onDragEnter={fileDropManagedExternally ? undefined : handleDragEnter} onDragOver={fileDropManagedExternally ? undefined : handleDragOver} diff --git a/web/src/components/PixelAvatar.jsx b/web/src/components/PixelAvatar.jsx index 64eaac8c9..840d67b8e 100644 --- a/web/src/components/PixelAvatar.jsx +++ b/web/src/components/PixelAvatar.jsx @@ -15,6 +15,7 @@ import { stableSeed, subagentAvatarIdentity, } from '../lib/officeCharacters.js'; +import { GeometricAvatar, useSubagentAvatarStyle } from './GeometricAvatar.jsx'; export function PixelAvatar({ identity, size = 36 }) { const ref = useRef(null); @@ -39,15 +40,18 @@ export function SubagentAvatars({ avatars, size = 16 }) { )); } -// 子代理头像:绑定了专家用专家的头像(有自定义图片就用图片),否则是按子会话 id 生成的像素小人。 -// status(subagentAvatarStatus)决定右上角的状态图标,和桌面像素办公室同一套:运行中三个点 -// 轮流跳、小人隔一会儿点一下头,结束是一颗星,被中止是红色感叹号;不给就只有头像。 +// 子代理头像:绑定了专家用专家的头像(有自定义图片就用图片),否则按子会话 id 生成—— +// 设置 > 外观 > 子代理头像决定生成的是花瓣(默认,GeometricAvatar,状态靠转 / 停 / 变灰)还是像素小人。 +// 像素小人和专家头像由 status(subagentAvatarStatus)决定右上角的状态图标,和桌面像素办公室 +// 同一套:运行中三个点轮流跳、小人隔一会儿点一下头,结束是一颗星,被中止是红色感叹号; +// 不给就只有头像。 export function SubagentAvatar({ sessionId = '', expert = null, status = '', size = 16, className = '' }) { const [failedImage, setFailedImage] = useState(''); const bodyRef = useRef(null); const markRef = useRef(null); const identity = subagentAvatarIdentity(sessionId, expert); - const mark = avatarStatusMark(status); + const geometric = useSubagentAvatarStyle() === 'geometric' && !expert?.avatar_identity; + const mark = geometric ? null : avatarStatusMark(status); useEffect(() => { const body = bodyRef.current; const canvas = markRef.current; @@ -66,8 +70,16 @@ export function SubagentAvatar({ sessionId = '', expert = null, status = '', siz paint(avatarStatusTick()); // 只有运行中的头像跟着钟走;结束后的图标画一次就不动了。 return status === 'running' ? subscribeAvatarStatusClock(paint) : undefined; - }, [identity, status, size]); + }, [identity, status, size, geometric]); if (!identity) return null; + if (geometric) { + return ( + + + + ); + } const image = expert?.avatar_identity ? expert.avatar_url : ''; return ( {}, messageAutoCollapse = true, onMessageAutoCollapseChange = () => {}, + subagentAvatarStyle = 'geometric', + onSubagentAvatarStyleChange = () => {}, onThemeVariantChange, onThemeEffectsChange, }) { @@ -513,6 +516,8 @@ export function SettingsPage({ onSidebarSessionTimeChange={onSidebarSessionTimeChange} messageAutoCollapse={messageAutoCollapse} onMessageAutoCollapseChange={onMessageAutoCollapseChange} + subagentAvatarStyle={subagentAvatarStyle} + onSubagentAvatarStyleChange={onSubagentAvatarStyleChange} themeVariantOptions={themeVariantOptions} activeThemeVariant={activeThemeVariant} onThemeVariantChange={setThemeVariant} @@ -1561,6 +1566,8 @@ function SectionAppearance({ onSidebarSessionTimeChange, messageAutoCollapse, onMessageAutoCollapseChange, + subagentAvatarStyle, + onSubagentAvatarStyleChange, themeVariantOptions = [], activeThemeVariant = null, onThemeVariantChange = () => {}, @@ -1679,6 +1686,8 @@ function SectionAppearance({ ariaLabel="消息自动折叠" /> +
+ ); } diff --git a/web/src/components/SidePanel.jsx b/web/src/components/SidePanel.jsx index 00c1063d3..d932c80ba 100644 --- a/web/src/components/SidePanel.jsx +++ b/web/src/components/SidePanel.jsx @@ -12,6 +12,7 @@ // 整面板不渲染。 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { ApiError, createApi } from '../lib/api.js'; import { aggregateHunksFromMessages, summarizeChangeGroups } from '../lib/sessionChanges.js'; import { @@ -42,6 +43,8 @@ import { finishFileTreeDirectoryRequest, reconcileFileTreeDirectory, } from '../lib/fileTreeRefresh.js'; +import { toggleFileTreeDirectories } from '../lib/fileTreeSelection.js'; +import { FILE_TREE_SELECTION_DELEGATE, useFileTreeSelection } from '../lib/useFileTreeSelection.js'; import { clsx } from '../lib/format.js'; import { SIDE_PANEL_CONTEXT_EFFECTS, @@ -49,6 +52,7 @@ import { } from '../lib/sidePanelContextActions.js'; import { FileTypeIcon, VsIcon } from './Icon.jsx'; import { ChangeCompactList } from './ChangeReview.jsx'; +import { openFileTreeBatchMenu } from './fileTreeBatchMenu.js'; import { GitChangesPanel } from './GitChangesPanel.jsx'; import { GIT_STATE_CHANGED_EVENT } from '../lib/gitSessionPill.js'; import { gitInfoCache } from '../lib/gitInfoCache.js'; @@ -238,6 +242,33 @@ function FileTree({ owner, api, cwd, treeCache, setTreeCache, expandedDirs, setE if (shouldExpand) loadDir(path, { force: true }); }, [expandedDirs, loadDir, setExpandedDirs]); + // 多选:Shift / Ctrl 点击、多选后的右键菜单、拖到输入框或控制台。 + const selection = useFileTreeSelection({ + owner, + cwd, + treeRef, + treeCache, + expandedDirs, + activePath: selectedPath, + onBatchMenu: ({ entries, x, y, element }) => openFileTreeBatchMenu({ + entries, + x, + y, + element, + cwd, + expandedDirs, + onPreviewFiles: async (files) => { + for (const file of files) { + if (await onPickFile(file) === false) break; + } + }, + onSetDirectoriesExpanded: (paths, expand) => { + if (expand) paths.forEach(path => loadDir(path, { force: true })); + setExpandedDirs(prev => toggleFileTreeDirectories(prev, paths, expand)); + }, + }), + }); + useEffect(() => { const handler = (event) => { const detail = event.detail || {}; @@ -356,6 +387,7 @@ function FileTree({ owner, api, cwd, treeCache, setTreeCache, expandedDirs, setE const locatePath = isDir ? '' : containingWorkspacePath(cwd, e.path); const reviewStatus = e.review_status || statusForTreeEntry(e, reviewStatusByPath); const statusTitle = fileChangeStatusTitle(reviewStatus, isDir); + const rowEntry = { path: e.path, name: e.name, directory: isDir }; const activeGuideIndex = selectedParentGuideIndex >= 0 && depth > selectedParentGuideIndex && isPathWithinTreeBranch(e.path, selectedParentPath) @@ -375,13 +407,27 @@ function FileTree({ owner, api, cwd, treeCache, setTreeCache, expandedDirs, setE data-desktop-file-expanded={isDir ? (isOpen ? 'true' : 'false') : undefined} data-desktop-file-preview={isDir ? undefined : 'true'} data-desktop-file-add-context="true" + data-desktop-context-menu-delegate={FILE_TREE_SELECTION_DELEGATE} + data-file-multi-selected={selection.keys.has(e.path) ? 'true' : undefined} data-review-status={reviewStatus || undefined} className={clsx( 'ace-file-row w-full flex items-center gap-1 text-left text-[12px] py-[3px] pr-2', 'hover:bg-surface-hi cursor-pointer', isActive && 'bg-accent-soft text-accent', )} - onClick={() => isDir ? toggleDir(e.path) : onPickFile(e)} + onClick={(event) => { + // Shift / Ctrl 点击只改选区,不展开目录、不打开文件。 + if (!selection.click(event, rowEntry)) return; + if (isDir) toggleDir(e.path); + else onPickFile(e); + }} + onPointerDown={(event) => selection.pointerDown(event, rowEntry)} + onMouseDown={(event) => { + if (!event.shiftKey) return; + // Shift 点击默认会把页面上已有的文字选区一路拉到这里;拦下后手动给焦点,键盘操作才落在文件树上。 + event.preventDefault(); + event.currentTarget.focus({ preventScroll: true }); + }} onAnimationEnd={(event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove('ace-file-locate-highlight'); }} @@ -422,9 +468,38 @@ function FileTree({ owner, api, cwd, treeCache, setTreeCache, expandedDirs, setE return
无 cwd,请先选择会话
; } return ( -
- {renderEntries('', 0)} -
+ <> +
{ + if (event.target === event.currentTarget) selection.clear(); + }} + > + {renderEntries('', 0)} +
+ {selection.ghost && createPortal( + , + document.body, + )} + ); } @@ -638,10 +713,12 @@ export function SidePanel({ setActiveTab('changes'); }, [selectedChangeFile, selectedChangeFileRevision]); + // 返回 false = 预览没有打开(被取消),批量预览据此停下。 const onPickFile = useCallback(async (entry) => { - if (!filesEnabled) return; - if (await onOpenFilePreview?.(entry.path) === false) return; + if (!filesEnabled) return false; + if (await onOpenFilePreview?.(entry.path) === false) return false; setSelectedPath(entry.path); + return true; }, [filesEnabled, onOpenFilePreview]); useEffect(() => { diff --git a/web/src/components/SubagentAvatarStyleCards.jsx b/web/src/components/SubagentAvatarStyleCards.jsx new file mode 100644 index 000000000..e8d88f3d7 --- /dev/null +++ b/web/src/components/SubagentAvatarStyleCards.jsx @@ -0,0 +1,55 @@ +import { clsx } from '../lib/format.js'; +import { GeometricAvatar } from './GeometricAvatar.jsx'; +import { PixelAvatar } from './PixelAvatar.jsx'; + +// 设置 > 外观 > 子代理头像。只影响没绑定专家的子代理(绑定了专家的一直用专家头像)。 +// 配置里的值是 geometric / pixel;花瓣是默认,排在前面。 +const AVATAR_STYLE_OPTIONS = [ + { key: 'geometric', label: '花瓣' }, + { key: 'pixel', label: '像素小人' }, +]; + +// 两张卡片画的是同一批身份:换风格时看到的是「同样几个子代理换了一种画法」。 +// 这五个是挑过的,花瓣风格下正好是五种花瓣、五个颜色。 +const PREVIEW_IDENTITIES = [ + 'subagent:preview-1', + 'subagent:preview-4', + 'subagent:preview-5', + 'subagent:preview-7', + 'subagent:preview-11', +]; + +export function SubagentAvatarStyleCards({ avatarStyle = 'geometric', onAvatarStyleChange = () => {} }) { + return ( + <> +

子代理头像

+
+ {AVATAR_STYLE_OPTIONS.map((option) => { + const active = avatarStyle === option.key; + return ( + + ); + })} +
+ + ); +} diff --git a/web/src/components/SummaryPanel.jsx b/web/src/components/SummaryPanel.jsx index 23186c679..ee8254f67 100644 --- a/web/src/components/SummaryPanel.jsx +++ b/web/src/components/SummaryPanel.jsx @@ -1,8 +1,10 @@ -// 摘要面板显示工作区、变更和后台任务。 +// 摘要面板显示工作区、产物、变更和后台任务。 // // 收起、停靠和悬浮由外层的 ResponsiveSidebar 负责,这里只渲染 // 内容并按需取数: // - 分支走共享的 gitInfoCache(30s TTL + 在途去重),与会话 git 胶囊同一份; +// - 产物(这次对话生成的文档 / 表格 / 图片)取 /api/sessions/:id/outputs, +// 面板可见时才取;回合进行中隔几秒看一眼,回合结束再取一次; // - 变更合计与右侧「变更」页共用 sessionChangesCache 和同一个基线(workbench // 状态 gitBase:),只在面板可见时拉取,回合结束 / checkout 后标脏重拉; // - 后台任务直接用 ChatView 已有的子代理任务列表,不另开订阅。 @@ -18,6 +20,11 @@ import { buildBaseCandidates } from '../lib/gitChanges.js'; import { sessionChangesCache } from '../lib/gitChangesCache.js'; import { gitInfoCache } from '../lib/gitInfoCache.js'; import { GIT_STATE_CHANGED_EVENT } from '../lib/gitSessionPill.js'; +import { + normalizeSessionOutputs, + sessionOutputRows, + sessionOutputsEqual, +} from '../lib/sessionOutputs.js'; import { folderLabel, formatChangeCount, @@ -28,11 +35,47 @@ import { } from '../lib/summaryPanel.js'; import { useWorkbenchState } from '../lib/useWorkbenchState.js'; import { subscribeWorkspaceDirectory, workspaceDirectoryEntry } from '../lib/workspaceDirectory.js'; -import { VsIcon } from './Icon.jsx'; +import { FileTypeIcon, VsIcon } from './Icon.jsx'; import { SubagentAvatar } from './PixelAvatar.jsx'; import { WorkspaceIcon } from './WorkspaceIcon.jsx'; const SUMMARY_FOLDER_CONTEXT_MENU_DELEGATE = 'summary-folder'; +const OUTPUTS_BUSY_POLL_MS = 6000; +const NO_OUTPUTS = []; + +// 产物列表。回合进行中脚本随时可能生成文件,而界面收不到「bash 写了文件」的 +// 事件,所以面板可见且回合在跑时隔几秒取一次;内容没变就留着旧数组不重渲染。 +function useSessionOutputs({ api, sessionId, workspaceHash, busy, enabled }) { + const [outputs, setOutputs] = useState(NO_OUTPUTS); + const sessionRef = useRef(sessionId); + sessionRef.current = sessionId; + const load = useCallback(() => { + const target = sessionId; + if (!target || typeof api?.sessionOutputs !== 'function') return; + api.sessionOutputs(target, workspaceHash) + .then((data) => { + if (sessionRef.current !== target) return; + const next = normalizeSessionOutputs(data); + setOutputs((previous) => (sessionOutputsEqual(previous, next) ? previous : next)); + }) + .catch(() => {}); + }, [api, sessionId, workspaceHash]); + + // 换会话先清空,别把上一个会话的文件挂在新会话下面。 + useEffect(() => { + setOutputs((previous) => (previous.length ? NO_OUTPUTS : previous)); + }, [sessionId]); + + useEffect(() => { + if (!enabled || !sessionId) return undefined; + load(); + if (!busy) return undefined; + const timer = window.setInterval(load, OUTPUTS_BUSY_POLL_MS); + return () => window.clearInterval(timer); + }, [busy, enabled, sessionId, load]); + + return sessionId ? outputs : NO_OUTPUTS; +} function useGitInfo(api, cwd, owner, enabled) { const [gitInfo, setGitInfo] = useState(() => gitInfoCache.peek(api, cwd, owner) ?? null); @@ -220,8 +263,9 @@ function useWorkspaceDirectoryEntry(hash) { } // 目录行菜单:左键、右键打开同一份下拉菜单,从行下方展开、与行同宽(学 Claude 桌面版)。 -// 走统一的 DesktopContextMenu,只给「打开文件夹」「复制文件路径」两项。 -function openFolderMenu(element, path) { +// 走统一的 DesktopContextMenu,只给「打开文件夹」「复制文件路径」两项。产物行的右键 +// 也用它(kind 传 'file',「打开文件夹」会在资源管理器里选中这个文件)。 +function openFolderMenu(element, path, kind = 'directory') { if (!element || !path) return; const rect = element.getBoundingClientRect(); openDesktopContextMenu({ @@ -237,7 +281,7 @@ function openFolderMenu(element, path) { { id: DESKTOP_CONTEXT_ACTIONS.OPEN_IN_EXPLORER, group: 'summary-folder-open', - target: { path, kind: 'directory' }, + target: { path, kind }, }, { id: DESKTOP_CONTEXT_ACTIONS.COPY_ABSOLUTE_PATH, @@ -261,7 +305,7 @@ function useFolderContextMenu(rootRef) { const path = element.getAttribute('data-summary-folder-path') || ''; if (!path) return; detail.handled = true; - openFolderMenu(element, path); + openFolderMenu(element, path, element.getAttribute('data-summary-path-kind') || 'directory'); }; window.addEventListener(CONTEXT_MENU_DELEGATE_EVENT, handler); return () => window.removeEventListener(CONTEXT_MENU_DELEGATE_EVENT, handler); @@ -333,9 +377,70 @@ function FolderRow({ path, primary }) { ); } +const SUMMARY_ROW_BUTTON_CLASS = 'flex-1 min-w-0 flex items-center gap-2 h-8 px-2 rounded-md text-left text-[13px] text-fg hover:bg-surface-hi transition'; + +// 产物行:左键在右侧预览里打开;右键是和目录行一样的「打开文件夹 / 复制文件路径」。 +function OutputRow({ row, onOpen }) { + const content = ( + <> + + + {row.name} + {row.hint && · {row.hint}} + + + ); + return ( +
+ {onOpen ? ( + + ) : ( +
+ {content} +
+ )} +
+ ); +} + +// 「产物」分组:先显示最近的几个,其余点「显示更多」;一个都没有时整段不画。 +function OutputsSection({ outputs, onOpenFile }) { + const [expanded, setExpanded] = useState(false); + const model = sessionOutputRows(outputs, { expanded }); + if (model.rows.length === 0) return null; + return ( + <> +
+
+ {model.rows.map((row) => ( + + ))} + {model.expandable && ( + + )} +
+ + ); +} + export function SummaryPanel({ owner, api, + sessionId = '', cwd = '', workspaceName = '', workspaceHash = '', @@ -347,12 +452,16 @@ export function SummaryPanel({ onClose, onEditWorkspace, onOpenChanges, + onOpenFile, onOpenTask, onOpenFinishedTasks, onStopTask, }) { const rootRef = useRef(null); useFolderContextMenu(rootRef); + const outputs = useSessionOutputs({ + api, sessionId, workspaceHash, busy, enabled: visible && !!sessionId, + }); const directory = useWorkspaceDirectoryEntry(noWorkspace ? '' : workspaceHash); const gitInfo = useGitInfo(api, cwd, owner, visible && !!cwd); const gitList = useGitChangeList({ api, owner, cwd, gitInfo, busy, enabled: visible && !!cwd }); @@ -394,6 +503,7 @@ export function SummaryPanel({ ))} {workspace.branch && {workspace.branch}} + {changes && ( <>
diff --git a/web/src/components/TopBar.jsx b/web/src/components/TopBar.jsx index 5e66214c0..319ee79eb 100644 --- a/web/src/components/TopBar.jsx +++ b/web/src/components/TopBar.jsx @@ -12,7 +12,6 @@ import { WindowControls, isInteractiveTarget, nativePointerEvent, - useFramelessWindowState, } from './WindowControls.jsx'; function QuickBtn({ @@ -49,6 +48,7 @@ function QuickBtn({ } export function TopBar({ + windowState: { framelessDesktop, isMaximized, isFullscreen }, onOpenSearch, onToggleConsole, consoleAvailable = false, @@ -68,7 +68,6 @@ export function TopBar({ themeSoundsOn = true, onToggleThemeSounds, }) { - const { framelessDesktop, isMaximized, isFullscreen } = useFramelessWindowState(); const topBarRef = useRef(null); useEffect(() => { diff --git a/web/src/components/WindowControls.jsx b/web/src/components/WindowControls.jsx index 27214624f..0f408eec6 100644 --- a/web/src/components/WindowControls.jsx +++ b/web/src/components/WindowControls.jsx @@ -1,12 +1,5 @@ // WindowControls:frameless desktop 模式下的标题栏窗口控制三连 -// (minimize / maximize ↔ restore / close)。从 TopBar 抽出,SettingsPage 等 -// 全屏覆盖 TopBar 的页面也复用,避免每处重写。 -// -// 用法: -// const { isMaximized, framelessDesktop } = useFramelessWindowState(); -// {framelessDesktop && ( -// -// )} +// (minimize / maximize ↔ restore / close)。 import { VsIcon } from './Icon.jsx'; import { useEffect, useState } from 'react'; @@ -32,47 +25,44 @@ export function nativePointerEvent(event) { }; } -// 监听 native 推送的 maximize 状态。多个组件可同时挂(WndProc 每次都遍历 webview -// 找全局回调,这里覆盖式注册 — 期望同一时刻只有一个 SettingsPage 或 TopBar 在 -// 监听;切走时 cleanup 还原 null)。 +// App 统一订阅窗口状态,供标题栏和缩放边缘共用。 export function useFramelessWindowState() { const framelessDesktop = isFramelessDesktop(); - const [isMaximized, setIsMaximized] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); + const [isMaximized, setIsMaximized] = useState(null); + const [isFullscreen, setIsFullscreen] = useState(null); useEffect(() => { if (!framelessDesktop) return undefined; let cancelled = false; const fetchInitial = async () => { - if (typeof window.aceDesktop_isWindowMaximized !== 'function') return; + let state; try { - const raw = await window.aceDesktop_isWindowMaximized(); - const r = typeof raw === 'string' ? JSON.parse(raw) : raw; - if (!cancelled) { - setIsMaximized(!!(r && r.maximized)); - setIsFullscreen(!!(r && r.fullscreen)); - } + const raw = await window.aceDesktop_isWindowMaximized?.(); + state = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { - // bind 偶发抛错 → 保留默认 false + // 兼容无法提供初始状态的桌面壳,按普通窗口处理。 + } + if (!cancelled) { + // 查询期间收到的原生推送比初始快照更新。 + setIsMaximized(current => current ?? !!state?.maximized); + setIsFullscreen(current => current ?? !!state?.fullscreen); } }; - fetchInitial(); const prev = window.aceDesktop_onMaximizeStateChanged; const prevFullscreen = window.aceDesktop_onFullscreenStateChanged; window.aceDesktop_onMaximizeStateChanged = (m) => { setIsMaximized(!!m); - // 也通知前一个监听者(链式),避免 TopBar 与 SettingsPage 共存时丢事件 if (typeof prev === 'function') prev(m); }; window.aceDesktop_onFullscreenStateChanged = (fullscreen) => { setIsFullscreen(!!fullscreen); if (typeof prevFullscreen === 'function') prevFullscreen(fullscreen); }; + fetchInitial(); return () => { cancelled = true; - // 还原前一个回调,而不是 delete — 防止 SettingsPage 关闭后 TopBar 失联 window.aceDesktop_onMaximizeStateChanged = prev || undefined; window.aceDesktop_onFullscreenStateChanged = prevFullscreen || undefined; }; diff --git a/web/src/components/fileTreeBatchMenu.js b/web/src/components/fileTreeBatchMenu.js new file mode 100644 index 000000000..7467f0ba2 --- /dev/null +++ b/web/src/components/fileTreeBatchMenu.js @@ -0,0 +1,73 @@ +// 右侧文件树多选后的右键菜单。每一项后面的数字是它实际会处理的条数。 +import { joinWorkspacePath, openDesktopContextMenu } from '../lib/desktopContextMenu.js'; +import { + dispatchFileTreeDrop, + fileTreeDropItems, + mainComposerFileTreeDropTarget, +} from '../lib/fileTreeDrop.js'; +import { + FILE_TREE_BATCH_ACTIONS, + fileTreeBatchMenu, + outermostFileTreeEntries, +} from '../lib/fileTreeSelection.js'; +import { copyTextToSystemClipboard } from '../lib/systemClipboard.js'; +import { toast } from './Toast.jsx'; + +const LABELS = { + [FILE_TREE_BATCH_ACTIONS.ADD_CONTEXT]: '添加到会话', + [FILE_TREE_BATCH_ACTIONS.PREVIEW]: '预览文件', + [FILE_TREE_BATCH_ACTIONS.COPY_RELATIVE_PATHS]: '复制相对路径', + [FILE_TREE_BATCH_ACTIONS.COPY_ABSOLUTE_PATHS]: '复制绝对路径', + [FILE_TREE_BATCH_ACTIONS.EXPAND]: '展开目录', + [FILE_TREE_BATCH_ACTIONS.COLLAPSE]: '折叠目录', +}; + +async function copyLines(lines) { + const result = await copyTextToSystemClipboard(lines.join('\n')); + if (result?.ok) toast({ kind: 'ok', text: '已复制' }); + else toast({ kind: 'err', text: '复制失败:' + (result?.error || '') }); +} + +// entries:选中的行 [{ path, name, directory }],按文件树里的先后排列。 +export function openFileTreeBatchMenu({ + entries, x, y, element, cwd, expandedDirs, onPreviewFiles, onSetDirectoriesExpanded, +}) { + const files = entries.filter(entry => !entry.directory); + const directories = entries.filter(entry => entry.directory); + const run = { + [FILE_TREE_BATCH_ACTIONS.ADD_CONTEXT]: () => { + const added = dispatchFileTreeDrop( + mainComposerFileTreeDropTarget(element?.ownerDocument), + fileTreeDropItems(outermostFileTreeEntries(entries), cwd), + { cwd }, + ); + toast(added ? { kind: 'ok', text: '已添加到会话' } : { kind: 'err', text: '输入框当前不可用' }); + }, + [FILE_TREE_BATCH_ACTIONS.PREVIEW]: () => onPreviewFiles?.(files), + [FILE_TREE_BATCH_ACTIONS.COPY_RELATIVE_PATHS]: () => copyLines(entries.map(entry => entry.path)), + [FILE_TREE_BATCH_ACTIONS.COPY_ABSOLUTE_PATHS]: () => copyLines( + entries.map(entry => joinWorkspacePath(cwd, entry.path)), + ), + [FILE_TREE_BATCH_ACTIONS.EXPAND]: () => onSetDirectoriesExpanded?.( + directories.filter(entry => !expandedDirs.has(entry.path)).map(entry => entry.path), true, + ), + [FILE_TREE_BATCH_ACTIONS.COLLAPSE]: () => onSetDirectoriesExpanded?.( + directories.filter(entry => expandedDirs.has(entry.path)).map(entry => entry.path), false, + ), + }; + openDesktopContextMenu({ + x, + y, + placement: 'pointer', + target: element, + trigger: element, + includeContextActions: false, + showIcons: false, + leadingItems: fileTreeBatchMenu(entries, expandedDirs).map(item => ({ + id: `file-tree-batch-${item.action}`, + label: `${LABELS[item.action]} (${item.count})`, + separatorBefore: !!item.separatorBefore, + onSelect: run[item.action], + })), + }); +} diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index ebe1087c6..33257c237 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -619,6 +619,7 @@ export const sourceCatalogs = { "s_327f00da9fc2ec36": "隐藏详情面板", "s_32a202d6fed3a1da": "在此工作区新建任务", "s_32c8d373afe08328": "执行", + "s_331af79f34e7a551": "显示更多", "s_33281591669db79f": "产品交付专家团", "s_33360315a592f985": "间隔单位", "s_333eda18f13dbaad": "语音识别启用后生效,停止后回填文字", @@ -1143,6 +1144,7 @@ export const sourceCatalogs = { "s_5ffdb67a4142cc22": "当前环境无法保存截图", "s_605a534da98e42ec": "Token 活动视图", "s_60b83f21d9d5d703": "群 {{p0}}", + "s_60e2a659efb4f9d7": "subagent sub-agent agent avatar portrait icon style petal flower geometric pixel 子代理 智能体 头像 图标 花瓣 几何图形 像素小人", "s_610a30de692b91b0": "请把你的公网地址转发到本机 http://127.0.0.1:{{p0}},连通后会自动继续。", "s_610e4e820d6881ea": "彻底删除会话“{{p0}}”?此操作不可撤销。", "s_6135d4159e892541": "移除", @@ -1579,6 +1581,7 @@ export const sourceCatalogs = { "s_85218e8da1a1ab77": "调整预览面板宽度", "s_8526267fcc158a52": "显示更早的消息", "s_85501294b3c40740": "内容超过 50 万字符,直接编辑会明显卡顿;可复制出去修改后用剪贴板内容替换", + "s_855633cd7c8b05c3": "子代理头像", "s_8569e005ca1f98f1": "已打开技能目录", "s_856dba844b90b484": "打开 LINE Official Account Manager", "s_8585b2747da94d76": "页面重定向失败", @@ -1768,6 +1771,7 @@ export const sourceCatalogs = { "s_94078a28716517ee": "记忆已删除", "s_940c5ebd7fe94a7a": "升级服务 URL", "s_94222dcc2d741467": "删除主题时恢复文件失败,请检查主题资源目录", + "s_9428536c690e31d4": "花瓣", "s_9441abccaad34739": "关闭后只注入离线环境(代理指向无效端口、pip / npm 离线、ssh 桩),不走代理的程序仍能联网", "s_9457d2d11b62d399": "{{p0}} 已{{p1}};重启 daemon 后生效", "s_9458d5ba961f45d5": "调用了 {{p0}} 个智能体", @@ -1894,6 +1898,7 @@ export const sourceCatalogs = { "s_9ef7b84761b55d09": "主题包已变化,请重新选择文件并确认预览", "s_9ef8c37085cbd49d": "最后去设置里添加模型。以后也可以在“常规”里重新查看这份新手指引。", "s_9f14a4c73e4f3334": "向下查看更晚的问题", + "s_9f19abc6a80d0ab1": "像素小人", "s_9f38ef9a265444d8": "旧工作空间", "s_9f3c756e11220434": "记忆作用域", "s_9f57c3620f0e89f6": "增加思维深度", @@ -2864,6 +2869,7 @@ export const sourceCatalogs = { "s_f2f491398295b1b9": "莲花", "s_f2f9fab7a3bb8411": "virtual office desktop pet 虚拟办公室 大眼回形针 Clippy rover 鲸鱼娘 whale 水晶球 crystal ball 显示 隐藏", "s_f323fc507d585565": "最小化到托盘", + "s_f32584a642dc943b": "显示更少", "s_f335420fcda22e54": "该文件格式暂不支持预览", "s_f33c31866a8c335e": "已拒绝 {{p0}}", "s_f36f1a90b64f69e5": "Node.js 工具", @@ -2962,6 +2968,7 @@ export const sourceCatalogs = { "s_fa8b002029e473c7": "macOS 已允许 ACECode 显示系统通知", "s_fa8d6a3ec8d7b375": "正在等待当前任务停止", "s_fa923de5f5dee1c1": "加载插件失败:", + "s_faa3e87e26be0348": "产物", "s_facdd36b9104ca64": "当前平台的沙箱只能拦截写入,可读白名单与黑名单在这里不拦截读取(读隔离仅 macOS / Linux)。", "s_fae3f5a6ef0d25e2": "验证码已复制", "s_fafb87bd1b75c9b0": "后台连接认证已失效,请重新连接 ACECode 后重试", @@ -3652,6 +3659,7 @@ export const sourceCatalogs = { "s_327f00da9fc2ec36": "Hide details panel", "s_32a202d6fed3a1da": "Create a new task in this workspace", "s_32c8d373afe08328": "Execution", + "s_331af79f34e7a551": "Show more", "s_33281591669db79f": "Product delivery expert team", "s_33360315a592f985": "interval unit", "s_333eda18f13dbaad": "Available when speech recognition is enabled; stopping inserts the transcript", @@ -4176,6 +4184,7 @@ export const sourceCatalogs = { "s_5ffdb67a4142cc22": "Screenshots cannot be saved in this environment", "s_605a534da98e42ec": "Token activity view", "s_60b83f21d9d5d703": "Group {{p0}}", + "s_60e2a659efb4f9d7": "subagent sub-agent agent avatar portrait icon style petal flower geometric pixel", "s_610a30de692b91b0": "Forward your public address to http://127.0.0.1:{{p0}} on this computer. Setup continues automatically once it is reachable.", "s_610e4e820d6881ea": "Completely delete session \"{{p0}}\"? This action cannot be undone.", "s_6135d4159e892541": "Remove", @@ -4612,6 +4621,7 @@ export const sourceCatalogs = { "s_85218e8da1a1ab77": "Adjust preview panel width", "s_8526267fcc158a52": "Show earlier messages", "s_85501294b3c40740": "The content exceeds 500,000 characters and editing it here would be slow; copy it out, edit it, then replace it with the clipboard content", + "s_855633cd7c8b05c3": "Sub-agent avatars", "s_8569e005ca1f98f1": "Skill catalog opened", "s_856dba844b90b484": "Open LINE Official Account Manager", "s_8585b2747da94d76": "Page redirect failed", @@ -4801,6 +4811,7 @@ export const sourceCatalogs = { "s_94078a28716517ee": "Memory deleted", "s_940c5ebd7fe94a7a": "Upgrade service URL", "s_94222dcc2d741467": "Files could not be restored after theme deletion failed. Check the theme directory.", + "s_9428536c690e31d4": "Petals", "s_9441abccaad34739": "When off, only an offline environment is injected (proxy pointed at a dead port, pip / npm offline, ssh stubs); programs that ignore the proxy can still reach the network", "s_9457d2d11b62d399": "{{p0}} has been replaced by {{p1}}; it will take effect after restarting the daemon.", "s_9458d5ba961f45d5": "Used {{p0}} agents", @@ -4927,6 +4938,7 @@ export const sourceCatalogs = { "s_9ef7b84761b55d09": "The theme package changed. Select it again and confirm the preview", "s_9ef8c37085cbd49d": "Finally, go to settings to add the model. You can also review this beginner's guide in \"General\" in the future.", "s_9f14a4c73e4f3334": "Scroll down for later issues", + "s_9f19abc6a80d0ab1": "Pixel characters", "s_9f38ef9a265444d8": "Previous workspace", "s_9f3c756e11220434": "Memory scope", "s_9f57c3620f0e89f6": "Increase reasoning depth", @@ -5897,6 +5909,7 @@ export const sourceCatalogs = { "s_f2f491398295b1b9": "Lotus", "s_f2f9fab7a3bb8411": "virtual office desktop pet Clippy rover whale girl crystal ball show hide", "s_f323fc507d585565": "Minimize to tray", + "s_f32584a642dc943b": "Show less", "s_f335420fcda22e54": "This file format does not currently support preview", "s_f33c31866a8c335e": "Declined {{p0}}", "s_f36f1a90b64f69e5": "Node.js Tools", @@ -5995,6 +6008,7 @@ export const sourceCatalogs = { "s_fa8b002029e473c7": "macOS has allowed ACECode to display system notifications", "s_fa8d6a3ec8d7b375": "Waiting for the current task to stop", "s_fa923de5f5dee1c1": "Failed to load plugins:", + "s_faa3e87e26be0348": "Outputs", "s_facdd36b9104ca64": "The sandbox on this platform only blocks writes; the readable allow list and the deny list do not block reads here (read isolation is macOS / Linux only).", "s_fae3f5a6ef0d25e2": "Verification code copied", "s_fafb87bd1b75c9b0": "Backend authentication has expired. Reconnect to ACECode and try again.", diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 5ed71fcd5..a2161efa5 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -527,6 +527,13 @@ export function createApi(base = null) { withdrawSteer: (id, clientMessageId) => request( 'POST', `/api/sessions/${encodeURIComponent(id)}/turn/steer/withdraw`, { client_message_id: clientMessageId }, base), + // 会话产物(这次对话生成的文档 / 表格 / 图片等文件),摘要面板用。 + sessionOutputs: (id, workspaceHash = '') => request( + 'GET', + `/api/sessions/${encodeURIComponent(id)}/outputs${workspaceHash ? `?workspace=${encodeURIComponent(workspaceHash)}` : ''}`, + undefined, + base, + ), // 后台任务(后台命令 / 后台子代理):列表、输出末尾、停止、把前台工作转到后台。 listBackgroundTasks: (id) => request( 'GET', `/api/sessions/${encodeURIComponent(id)}/background-tasks`, undefined, base), diff --git a/web/src/lib/appearancePersistenceArchitecture.test.js b/web/src/lib/appearancePersistenceArchitecture.test.js index 03a568625..9fe5d72e9 100644 --- a/web/src/lib/appearancePersistenceArchitecture.test.js +++ b/web/src/lib/appearancePersistenceArchitecture.test.js @@ -26,7 +26,7 @@ run('Desktop injects stable appearance before WebUI modules execute', () => { const navigation = desktop.indexOf('host.navigate(url);'); assert.ok(injection >= 0); assert.ok(navigation > injection); - for (const field of ['desktop_cfg.web_ui.theme', 'desktop_cfg.web_ui.color_theme', 'desktop_cfg.web_ui.font_size', 'desktop_cfg.web_ui.message_auto_collapse']) { + for (const field of ['desktop_cfg.web_ui.theme', 'desktop_cfg.web_ui.color_theme', 'desktop_cfg.web_ui.font_size', 'desktop_cfg.web_ui.message_auto_collapse', 'desktop_cfg.web_ui.subagent_avatar_style']) { assert.ok(desktop.includes(field), `bootstrap missing ${field}`); } }); @@ -46,6 +46,23 @@ run('App restores daemon appearance and owns durable mutations', () => { assert.match(app, /onFontSizeChange=\{\(nextFontSize\) => changeAppearance\(\{ fontSize: nextFontSize \}\)\}/); }); +// 场景:设置 > 外观 > 子代理头像。 +// 期望:这个选择只走 daemon 配置这一条路(和主题、字号一样由外观持久化控制器发出、由它回写), +// App 把当前值放进 Provider,头像组件从那里读;不落浏览器本地存储。 +// 回归防护:桌面版每次启动端口都变,本地存储留不住,只存本地的话重启就回到默认的花瓣。 +run('sub-agent avatar style is a daemon-backed appearance preference', () => { + const app = sourceFromSrc('App.jsx'); + const helper = sourceFromSrc('lib/appearancePreferences.js'); + const avatar = sourceFromSrc('components/PixelAvatar.jsx'); + assert.match(app, /const \[subagentAvatarStyle, setSubagentAvatarStyle\] = useState\(initialAppearance\.subagentAvatarStyle\);/); + assert.match(app, /setSubagentAvatarStyle\(next\.subagentAvatarStyle\);/); + assert.match(app, //); + assert.match(app, /onSubagentAvatarStyleChange=\{\(next\) => changeAppearance\(\{ subagentAvatarStyle: next \}\)\}/); + assert.doesNotMatch(app, /usePreference\([^)]*[aA]vatarStyle/); + assert.match(helper, /subagent_avatar_style: normalized\.subagentAvatarStyle,/); + assert.match(avatar, /const geometric = useSubagentAvatarStyle\(\) === 'geometric' && !expert\?\.avatar_identity;/); +}); + run('Settings retains durable theme controls after the top-bar shortcut is removed', () => { const topBar = sourceFromSrc('components/TopBar.jsx'); const settings = sourceFromSrc('components/SettingsPage.jsx'); diff --git a/web/src/lib/appearancePreferences.js b/web/src/lib/appearancePreferences.js index 78ef79a88..abf30b991 100644 --- a/web/src/lib/appearancePreferences.js +++ b/web/src/lib/appearancePreferences.js @@ -9,6 +9,8 @@ import { } from './uiPrefs.js'; export const APPEARANCE_THEME_VALUES = Object.freeze(['system', 'light', 'dark']); +// 设置 > 外观 > 子代理头像:花瓣(geometric,默认)/ 像素小人(pixel)。 +export const SUBAGENT_AVATAR_STYLE_VALUES = Object.freeze(['geometric', 'pixel']); export const DEFAULT_APPEARANCE_PREFERENCES = Object.freeze({ theme: 'system', colorTheme: DEFAULT_COLOR_THEME, @@ -18,6 +20,7 @@ export const DEFAULT_APPEARANCE_PREFERENCES = Object.freeze({ themeVariant: '', themeEffects: true, themeSounds: true, + subagentAvatarStyle: 'geometric', }); const FONT_SIZE_SET = new Set(FONT_SIZE_VALUES); @@ -75,6 +78,14 @@ function inputThemeSounds(value) { return (value?.theme_sounds ?? value?.themeSounds) !== false; } +// 不认识的值和旧 daemon 不返回这个键时都按默认(花瓣),不让它拖垮整份外观偏好。 +function inputSubagentAvatarStyle(value) { + const style = value?.subagent_avatar_style ?? value?.subagentAvatarStyle; + return SUBAGENT_AVATAR_STYLE_VALUES.includes(style) + ? style + : DEFAULT_APPEARANCE_PREFERENCES.subagentAvatarStyle; +} + export function effectiveAppearanceTheme(value, scope = globalThis) { return value === 'light' || value === 'dark' ? value @@ -100,6 +111,7 @@ export function normalizeAppearancePreferences(value, scope = globalThis) { themeVariant: inputThemeVariant(value), themeEffects: inputThemeEffects(value), themeSounds: inputThemeSounds(value), + subagentAvatarStyle: inputSubagentAvatarStyle(value), }; } @@ -121,6 +133,7 @@ export function parseAppearancePreferences(value, scope = globalThis) { themeVariant: inputThemeVariant(value), themeEffects: inputThemeEffects(value), themeSounds: inputThemeSounds(value), + subagentAvatarStyle: inputSubagentAvatarStyle(value), }; } @@ -153,6 +166,9 @@ export function mergeAppearancePreferences(current, patch, scope = globalThis) { themeSounds: patch?.themeSounds ?? patch?.theme_sounds ?? current?.themeSounds, + subagentAvatarStyle: patch?.subagentAvatarStyle + ?? patch?.subagent_avatar_style + ?? current?.subagentAvatarStyle, }, scope); } @@ -168,6 +184,7 @@ export function appearancePreferencesToApi(value, scope = globalThis) { theme_variant: normalized.themeVariant, theme_effects: normalized.themeEffects, theme_sounds: normalized.themeSounds, + subagent_avatar_style: normalized.subagentAvatarStyle, }; } diff --git a/web/src/lib/appearancePreferences.test.js b/web/src/lib/appearancePreferences.test.js index 186ed8656..d7b30d23b 100644 --- a/web/src/lib/appearancePreferences.test.js +++ b/web/src/lib/appearancePreferences.test.js @@ -10,8 +10,8 @@ import { systemThemeFallback, } from './appearancePreferences.js'; -// 主题风格与动效开关的默认值:空风格 = 主题的第一个风格,动效默认开。 -const THEME_STYLE_DEFAULTS = Object.freeze({ themeVariant: '', themeEffects: true, themeSounds: true }); +// 主题风格与动效开关的默认值:空风格 = 主题的第一个风格,动效默认开;子代理头像默认是花瓣。 +const THEME_STYLE_DEFAULTS = Object.freeze({ themeVariant: '', themeEffects: true, themeSounds: true, subagentAvatarStyle: 'geometric' }); async function run(name, fn) { try { @@ -114,6 +114,7 @@ await run('API serialization sends a complete compatibility snapshot', () => { theme_variant: '', theme_effects: true, theme_sounds: true, + subagent_avatar_style: 'geometric', }); }); @@ -248,6 +249,38 @@ await run('消息自动折叠缺省开启,关闭值经启动注入、API、其 assert.equal(saves.at(-1).message_auto_collapse, true); }); +// 场景:设置 > 外观 > 子代理头像(花瓣 / 像素小人),和主题、字号一样存在 daemon 配置里。 +// 期望:缺省是 geometric(花瓣);不认识的值按 geometric;pixel 经桌面版启动注入、接口返回都能读到; +// 之后改别的外观设置时随整份快照一起发给 daemon,不会被冲回默认;切换时立即生效。 +// 旧 daemon 的返回里没有这个键:按 geometric 处理,不能把整份外观偏好判成无效(那会连主题一起丢)。 +await run('子代理头像缺省是花瓣,像素小人经启动注入、API、其它偏好修改后保持', async () => { + const canonical = { theme: 'system', color_theme: 'blue', font_size: 'medium', subagent_avatar_style: 'pixel' }; + assert.equal(normalizeAppearancePreferences({}).subagentAvatarStyle, 'geometric'); + assert.equal(normalizeAppearancePreferences({ subagent_avatar_style: 'flower' }).subagentAvatarStyle, 'geometric'); + assert.equal(normalizeAppearancePreferences({ subagentAvatarStyle: 'pixel' }).subagentAvatarStyle, 'pixel'); + assert.equal(initialAppearancePreferences({ __ACECODE_APPEARANCE__: canonical }).subagentAvatarStyle, 'pixel'); + assert.equal(parseAppearancePreferences(canonical).subagentAvatarStyle, 'pixel'); + const legacy = parseAppearancePreferences({ theme: 'dark', color_theme: 'orange', font_size: 'small' }); + assert.equal(legacy.subagentAvatarStyle, 'geometric'); + assert.equal(legacy.theme, 'dark'); + + const saves = []; + const applied = []; + const controller = createAppearancePersistenceController({ + initial: {}, apply: (value) => applied.push(value.subagentAvatarStyle), + save: async (value) => { saves.push(value); return value; }, + }); + assert.equal(controller.restore(canonical), true); + await controller.change({ fontSize: 'large' }); + assert.equal(saves[0].subagent_avatar_style, 'pixel'); + assert.equal(controller.confirmed().subagentAvatarStyle, 'pixel'); + const switching = controller.change({ subagentAvatarStyle: 'geometric' }); + assert.equal(applied.at(-1), 'geometric'); + await switching; + assert.equal(saves.at(-1).subagent_avatar_style, 'geometric'); + assert.equal(saves.at(-1).font_size, 'large'); +}); + await run('消息折叠切换立即更新,保存失败恢复确认值,连续切换按最后选择落盘', async () => { const applied = []; let fail = true; diff --git a/web/src/lib/fileTreeDrop.js b/web/src/lib/fileTreeDrop.js new file mode 100644 index 000000000..2a7f2abb9 --- /dev/null +++ b/web/src/lib/fileTreeDrop.js @@ -0,0 +1,124 @@ +// 把右侧文件树里的行拖出去:落在输入框上 = 在光标处插入 @路径 引用(与右键「添加到会话」相同), +// 落在控制台终端上 = 贴入加好引号的绝对路径(与从系统拖文件进终端相同)。 +// 文件树的拖动是自绘的指针拖动(不是 HTML5 拖放,桌面壳会把后者当成系统文件拖放拦走), +// 目标收不到 drop 事件,所以由文件树在松手时判断落点、派发 FILE_TREE_DROP_EVENT,落点所在的一方认领。 +import { quoteShellPath } from './consoleDropPaths.js'; +import { joinWorkspacePath } from './desktopContextMenu.js'; +import { normalizeTreePath } from './fileTreeChangeStatus.js'; +import { insertPathReferenceAtCaret, normalizeReferencePath } from './pathReference.js'; + +export const FILE_TREE_DROP_EVENT = 'acecode:file-tree-drop'; +// 可接收的输入框根元素带这个属性(值为 "true");禁用时不带。 +export const FILE_TREE_DROP_ATTR = 'data-file-tree-drop'; +// 拖动经过时加在落点元素上的高亮 class。 +export const FILE_TREE_DROP_ACTIVE_CLASS = 'is-file-tree-drop'; +// 指针移动超过这个距离才算拖动,否则仍是一次点击。 +export const FILE_TREE_DRAG_START_PX = 5; +// 拖动预览最多列出的名字个数,其余只体现在数量角标上。 +export const FILE_TREE_DRAG_PREVIEW_LIMIT = 3; + +const COMPOSER_SELECTOR = `[${FILE_TREE_DROP_ATTR}="true"]`; +const CONSOLE_SELECTOR = '.ace-console-term[data-tab-id]'; + +function sameWorkspace(a, b) { + const left = normalizeTreePath(a).toLowerCase(); + return !!left && left === normalizeTreePath(b).toLowerCase(); +} + +// entries:[{ path, name, directory }](工作区相对路径)→ 拖放携带的条目。 +export function fileTreeDropItems(entries, cwd = '') { + return (Array.isArray(entries) ? entries : []) + .filter(entry => entry?.path) + .map(entry => ({ + path: entry.path, + absolutePath: joinWorkspacePath(cwd, entry.path), + name: entry.name || '', + directory: !!entry.directory, + })); +} + +export function fileTreeDropTargetFrom(element) { + if (!element?.closest) return null; + // 菜单、弹层盖在上面时不算落在它下面的输入框 / 终端上。 + if (element.closest('[data-ace-native-overlay]')) return null; + const composer = element.closest(COMPOSER_SELECTOR); + if (composer) return { kind: 'composer', element: composer }; + const terminal = element.closest(CONSOLE_SELECTOR); + if (terminal && terminal.style?.display !== 'none') return { kind: 'console', element: terminal }; + return null; +} + +export function fileTreeDropTargetAt(doc, clientX, clientY) { + if (!doc?.elementFromPoint || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; + return fileTreeDropTargetFrom(doc.elementFromPoint(clientX, clientY)); +} + +// 右键「添加到会话」没有落点:送给文件树所在会话的主输入框。 +export function mainComposerFileTreeDropTarget(doc = globalThis.document) { + const element = doc?.querySelector?.(`[data-main-composer="true"]${COMPOSER_SELECTOR}`); + return element ? { kind: 'composer', element } : null; +} + +// 返回 true 表示有一方认领了这次放下。 +export function dispatchFileTreeDrop(target, items, { cwd = '', clientX, clientY } = {}, win = globalThis.window) { + if (!target?.element || !items?.length || !win?.dispatchEvent) return false; + const detail = { kind: target.kind, target: target.element, items, cwd, clientX, clientY, handled: false }; + win.dispatchEvent(new win.CustomEvent(FILE_TREE_DROP_EVENT, { detail })); + return detail.handled; +} + +// 输入框与文件树同一个工作区时引用相对路径(与「添加到会话」一致),否则给绝对路径。 +export function insertFileTreeReferences(text, caret, items, { cwd = '', composerCwd = '' } = {}) { + const relative = !cwd || !composerCwd || sameWorkspace(cwd, composerCwd); + let next = { + text: String(text || ''), + cursor: Number.isFinite(caret) ? caret : String(text || '').length, + }; + let count = 0; + for (const item of Array.isArray(items) ? items : []) { + const path = normalizeReferencePath((relative ? item?.path : item?.absolutePath) || item?.path || ''); + if (!path) continue; + next = insertPathReferenceAtCaret(next.text, next.cursor, path, { directory: !!item.directory }); + count += 1; + } + return { ...next, count }; +} + +// 一段可直接贴进终端的输入:各路径按目标系统的 shell 规则加引号,空格分隔,末尾留一个空格。 +export function fileTreeTerminalText(items, os) { + const parts = (Array.isArray(items) ? items : []) + .map(item => String(item?.absolutePath || '')) + .filter(Boolean) + .map(path => quoteShellPath(os === 'windows' ? path.replace(/\//g, '\\') : path, os)); + return parts.length ? `${parts.join(' ')} ` : ''; +} + +// 拖动预览跟在指针右下方;主要落点(输入框、控制台)都贴着窗口底边, +// 放不下时翻到指针上方 / 收回窗口内,不让预览被窗口边缘切掉。 +export function fileTreeDragGhostPosition(point, { width = 0, height = 0, viewportWidth, viewportHeight } = {}) { + const x = Number(point?.x) || 0; + const y = Number(point?.y) || 0; + const margin = 4; + let left = x + 12; + let top = y + 14; + if (Number.isFinite(viewportWidth) && viewportWidth > 0) { + left = Math.max(margin, Math.min(left, viewportWidth - width - margin)); + } + if (Number.isFinite(viewportHeight) && viewportHeight > 0 && top + height > viewportHeight - margin) { + top = Math.max(margin, y - height - 10); + } + return { left: Math.round(left), top: Math.round(top) }; +} + +// 拖动预览:最多列出前几个名字,总数由角标显示。 +export function fileTreeDragPreview(entries) { + const list = Array.isArray(entries) ? entries : []; + return { + count: list.length, + rows: list.slice(0, FILE_TREE_DRAG_PREVIEW_LIMIT).map(entry => ({ + path: entry.path, + name: entry.name || entry.path, + directory: !!entry.directory, + })), + }; +} diff --git a/web/src/lib/fileTreeDrop.test.js b/web/src/lib/fileTreeDrop.test.js new file mode 100644 index 000000000..544b9f56b --- /dev/null +++ b/web/src/lib/fileTreeDrop.test.js @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + FILE_TREE_DRAG_PREVIEW_LIMIT, + FILE_TREE_DROP_EVENT, + dispatchFileTreeDrop, + fileTreeDragGhostPosition, + fileTreeDragPreview, + fileTreeDropItems, + fileTreeDropTargetFrom, + fileTreeTerminalText, + insertFileTreeReferences, + mainComposerFileTreeDropTarget, +} from './fileTreeDrop.js'; + +function test(name, fn) { + try { fn(); console.log('ok -', name); } + catch (error) { console.error('not ok -', name); throw error; } +} + +function source(path) { + return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8'); +} + +const entries = [ + { path: 'src/app', name: 'app', directory: true }, + { path: 'docs/设计 文档.md', name: '设计 文档.md', directory: false }, +]; +const items = fileTreeDropItems(entries, 'C:\\repo\\'); + +// 场景:把选中的行变成拖放携带的条目。 +// 期望:同时带工作区相对路径和绝对路径(输入框用前者,终端用后者);没有路径的条目丢掉。 +test('拖放条目带相对与绝对路径', () => { + assert.deepEqual(items, [ + { path: 'src/app', absolutePath: 'C:/repo/src/app', name: 'app', directory: true }, + { path: 'docs/设计 文档.md', absolutePath: 'C:/repo/docs/设计 文档.md', name: '设计 文档.md', directory: false }, + ]); + assert.deepEqual(fileTreeDropItems([{ path: '' }, null], 'C:/repo'), []); +}); + +// 场景:一次拖进两个条目(一个目录、一个带空格的文件),光标在「请看」后面。 +// 期望:依次插入、各带一个空格分隔;目录以 / 结尾,带空格的路径加引号;光标停在最后一个引用之后。 +test('在光标处依次插入多个 @路径 引用', () => { + const result = insertFileTreeReferences('请看 之后', 2, items, { cwd: 'C:/repo', composerCwd: 'c:\\repo' }); + assert.equal(result.text, '请看 @src/app/ @"docs/设计 文档.md" 之后'); + assert.equal(result.count, 2); + assert.equal(result.cursor, '请看 @src/app/ @"docs/设计 文档.md" '.length); + assert.equal(insertFileTreeReferences('abc', 3, [], {}).count, 0); +}); + +// 场景:落点的输入框属于另一个工作区(相对路径在那边解析不到同一个文件)。 +// 期望:改给绝对路径;同一个工作区(盘符大小写、斜杠方向不同也算同一个)仍给相对路径。 +test('输入框不在同一个工作区时引用绝对路径', () => { + const other = insertFileTreeReferences('', 0, items.slice(0, 1), { cwd: 'C:/repo', composerCwd: 'D:/elsewhere' }); + assert.equal(other.text, '@C:/repo/src/app/ '); + const unknown = insertFileTreeReferences('', 0, items.slice(0, 1), { cwd: 'C:/repo', composerCwd: '' }); + assert.equal(unknown.text, '@src/app/ '); +}); + +// 场景:拖到控制台终端上。 +// 期望:贴入绝对路径,空格分隔、末尾留一个空格;Windows 用反斜杠并给带空格的路径加双引号, +// 其它系统保持正斜杠、按 shell 规则加单引号。 +test('终端里贴入加好引号的绝对路径', () => { + assert.equal( + fileTreeTerminalText(items, 'windows'), + 'C:\\repo\\src\\app "C:\\repo\\docs\\设计 文档.md" ', + ); + const posix = fileTreeDropItems(entries, '/home/me/repo'); + assert.equal( + fileTreeTerminalText(posix, 'linux'), + "/home/me/repo/src/app '/home/me/repo/docs/设计 文档.md' ", + ); + assert.equal(fileTreeTerminalText([], 'linux'), ''); +}); + +// 场景:拖动途中判断指针下面是不是可以放下的地方。 +// 期望:输入框(带 data-file-tree-drop="true")和当前显示的终端页签算落点; +// 被隐藏的终端页签、盖在上面的菜单 / 弹层、普通区域都不算。 +test('落点只认输入框和当前显示的终端', () => { + const composer = { id: 'composer' }; + const terminal = { id: 'terminal', style: { display: 'block' } }; + const hidden = { id: 'hidden', style: { display: 'none' } }; + const within = found => ({ closest: selector => found[selector] || null }); + + assert.deepEqual( + fileTreeDropTargetFrom(within({ '[data-file-tree-drop="true"]': composer })), + { kind: 'composer', element: composer }, + ); + assert.deepEqual( + fileTreeDropTargetFrom(within({ '.ace-console-term[data-tab-id]': terminal })), + { kind: 'console', element: terminal }, + ); + assert.equal(fileTreeDropTargetFrom(within({ '.ace-console-term[data-tab-id]': hidden })), null); + assert.equal(fileTreeDropTargetFrom(within({ + '[data-ace-native-overlay]': { id: 'menu' }, + '[data-file-tree-drop="true"]': composer, + })), null); + assert.equal(fileTreeDropTargetFrom(within({})), null); + assert.equal(fileTreeDropTargetFrom(null), null); +}); + +// 场景:松手(或右键「添加到会话」)时派发事件,由落点所在的一方认领。 +// 期望:认领后返回 true;没人认领、没有落点、没有条目都返回 false,调用方据此提示「输入框当前不可用」。 +test('事件派发与认领', () => { + const composer = { id: 'composer' }; + const listeners = []; + const win = { + CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init.detail; } }, + dispatchEvent(event) { listeners.forEach(fn => fn(event)); return true; }, + }; + listeners.push((event) => { + assert.equal(event.type, FILE_TREE_DROP_EVENT); + assert.equal(event.detail.cwd, 'C:/repo'); + if (event.detail.kind === 'composer' && event.detail.target === composer) event.detail.handled = true; + }); + const target = { kind: 'composer', element: composer }; + assert.equal(dispatchFileTreeDrop(target, items, { cwd: 'C:/repo', clientX: 1, clientY: 2 }, win), true); + assert.equal(dispatchFileTreeDrop({ kind: 'console', element: {} }, items, { cwd: 'C:/repo' }, win), false); + assert.equal(dispatchFileTreeDrop(null, items, { cwd: 'C:/repo' }, win), false); + assert.equal(dispatchFileTreeDrop(target, [], { cwd: 'C:/repo' }, win), false); + + // 右键菜单没有落点:找主输入框;它被禁用(不带属性)时找不到。 + const doc = { querySelector: selector => (selector === '[data-main-composer="true"][data-file-tree-drop="true"]' ? composer : null) }; + assert.deepEqual(mainComposerFileTreeDropTarget(doc), target); + assert.equal(mainComposerFileTreeDropTarget({ querySelector: () => null }), null); +}); + +// 场景:一次拖几十个文件。期望:预览只列前几个名字,总数交给角标。 +test('拖动预览只列前几个名字', () => { + const many = Array.from({ length: 12 }, (_, index) => ({ path: `f${index}.txt`, name: `f${index}.txt`, directory: false })); + const preview = fileTreeDragPreview(many); + assert.equal(preview.count, 12); + assert.equal(preview.rows.length, FILE_TREE_DRAG_PREVIEW_LIMIT); + assert.equal(preview.rows[0].name, 'f0.txt'); +}); + +// 场景:把文件拖向贴着窗口底边的输入框 / 控制台,或拖到窗口右边缘。 +// 期望:平时预览在指针右下方;下方放不下就翻到指针上方,右边放不下就收回窗口内。 +// 回归点:最初固定放在右下方,拖到输入框上时预览有一半在窗口外面。 +test('拖动预览不会被窗口边缘切掉', () => { + const size = { width: 120, height: 60, viewportWidth: 800, viewportHeight: 600 }; + assert.deepEqual(fileTreeDragGhostPosition({ x: 100, y: 100 }, size), { left: 112, top: 114 }); + assert.deepEqual(fileTreeDragGhostPosition({ x: 100, y: 570 }, size), { left: 112, top: 500 }); + assert.deepEqual(fileTreeDragGhostPosition({ x: 790, y: 100 }, size), { left: 676, top: 114 }); + // 还没量到尺寸 / 窗口大小时退回右下方,不出 NaN。 + assert.deepEqual(fileTreeDragGhostPosition({ x: 10, y: 20 }), { left: 22, top: 34 }); +}); + +// 接线守卫:文件树的行要声明右键委托、选中状态并接上点击 / 按下;输入框与控制台要认领放下事件。 +// 任何一环断开,多选就会退化成「选得上、但右键和拖动没反应」。 +test('文件树、输入框与控制台三处接线', () => { + const sidePanel = source('../components/SidePanel.jsx'); + assert.match(sidePanel, /data-desktop-context-menu-delegate=\{FILE_TREE_SELECTION_DELEGATE\}/); + assert.match(sidePanel, /data-file-multi-selected=\{selection\.keys\.has\(e\.path\) \? 'true' : undefined\}/); + assert.match(sidePanel, /if \(!selection\.click\(event, rowEntry\)\) return;/); + assert.match(sidePanel, /onPointerDown=\{\(event\) => selection\.pointerDown\(event, rowEntry\)\}/); + assert.match(sidePanel, /onKeyDown=\{selection\.keyDown\}/); + + const hook = source('./useFileTreeSelection.js'); + assert.match(hook, /window\.addEventListener\(CONTEXT_MENU_DELEGATE_EVENT, handleContextMenuDelegate\)/); + assert.match(hook, /fileTreeDropTargetAt\(doc, moveEvent\.clientX, moveEvent\.clientY\)/); + assert.match(hook, /dispatchFileTreeDrop\(target, items,/); + + const inputBar = source('../components/InputBar.jsx'); + assert.match(inputBar, /data-file-tree-drop=\{disabled \? undefined : 'true'\}/); + assert.match(inputBar, /window\.addEventListener\(FILE_TREE_DROP_EVENT, acceptFileTreeDrop\)/); + assert.match(inputBar, /insertFileTreeReferences\(value, caret, detail\.items,/); + + const consoleDock = source('../components/ConsoleDock.jsx'); + assert.match(consoleDock, /window\.addEventListener\(FILE_TREE_DROP_EVENT, onDrop\)/); + assert.match(consoleDock, /injectTextToTab\(tabId, fileTreeTerminalText\(detail\.items, HOST_OS\)\)/); +}); + +console.log('fileTreeDrop tests passed'); diff --git a/web/src/lib/fileTreeSelection.js b/web/src/lib/fileTreeSelection.js new file mode 100644 index 000000000..7bbaec8d1 --- /dev/null +++ b/web/src/lib/fileTreeSelection.js @@ -0,0 +1,134 @@ +// 右侧文件树的多选:Shift 选一段、Ctrl / Cmd 逐个加减,规则与左侧会话栏、系统资源管理器一致。 +// 行的先后以「当前画出来的行」为准:展开的目录才把子项算进去,折叠后看不见的行不留在选区里, +// 批量操作因此永远只作用在用户看得见的行上。 +import { normalizeTreePath } from './fileTreeChangeStatus.js'; +import { pruneSidebarSelection, selectSidebarRange } from './sidebarSessionSelection.js'; + +export const FILE_TREE_BATCH_ACTIONS = Object.freeze({ + ADD_CONTEXT: 'add_context', + PREVIEW: 'preview', + COPY_RELATIVE_PATHS: 'copy_relative_paths', + COPY_ABSOLUTE_PATHS: 'copy_absolute_paths', + EXPAND: 'expand', + COLLAPSE: 'collapse', +}); + +export function emptyFileTreeSelection() { + return { anchor: '', keys: new Set() }; +} + +function entryName(path) { + return String(path || '').split('/').filter(Boolean).pop() || String(path || ''); +} + +// treeCache:目录路径 → /api/files 条目;expandedDirs:已展开的目录路径。 +// 返回与渲染顺序一致的行:[{ path, name, directory }]。 +export function visibleFileTreeEntries(treeCache, expandedDirs) { + const rows = []; + const walked = new Set(); + const walk = (parentPath) => { + if (walked.has(parentPath)) return; + walked.add(parentPath); + const entries = treeCache?.get?.(parentPath); + if (!Array.isArray(entries)) return; + for (const entry of entries) { + if (!entry?.path) continue; + const directory = entry.kind === 'dir'; + rows.push({ path: entry.path, name: entry.name || entryName(entry.path), directory }); + if (directory && expandedDirs?.has?.(entry.path)) walk(entry.path); + } + }; + walk(''); + return rows; +} + +// activePath:正在预览的文件所在的行(行上有高亮)。还没有选区就按住 Shift / Ctrl 点别的行时, +// 这一行看起来已经选中,所以算进选区 —— 否则用户看到两行高亮,操作却只对一行生效。 +export function selectFileTreeRow(state, visiblePaths, path, modifiers = {}, activePath = '') { + const modified = !!(modifiers.shiftKey || modifiers.ctrlKey || modifiers.metaKey); + const adoptActive = modified && state.keys.size === 0 && activePath && activePath !== path + && visiblePaths.includes(activePath); + const base = adoptActive + ? { + anchor: visiblePaths.includes(state.anchor) ? state.anchor : activePath, + keys: new Set([activePath]), + } + : state; + return selectSidebarRange(base, visiblePaths, path, modifiers); +} + +export function selectAllFileTreeRows(state, visiblePaths) { + if (!visiblePaths.length) return state; + const anchor = visiblePaths.includes(state.anchor) ? state.anchor : visiblePaths[0]; + return { anchor, keys: new Set(visiblePaths) }; +} + +export function pruneFileTreeSelection(state, visiblePaths) { + return pruneSidebarSelection(state, visiblePaths); +} + +// 预览中的文件换了(点页签、定位)时,没有多选就让选区跟过去,避免旧行还留着选中底色。 +export function followActiveFileTreeRow(state, visiblePaths, activePath) { + if (!activePath || state.keys.size > 1 || !visiblePaths.includes(activePath)) return state; + if (state.anchor === activePath && state.keys.size === 1 && state.keys.has(activePath)) return state; + return { anchor: activePath, keys: new Set([activePath]) }; +} + +export function selectedFileTreeEntries(visibleEntries, keys) { + return (Array.isArray(visibleEntries) ? visibleEntries : []).filter(entry => keys?.has?.(entry.path)); +} + +// 选中的目录已经带上了里面的全部内容:引用 / 拖动时只留最外层,不重复给子项。 +export function outermostFileTreeEntries(entries) { + const list = Array.isArray(entries) ? entries : []; + const directories = list.filter(entry => entry.directory).map(entry => normalizeTreePath(entry.path)); + return list.filter((entry) => { + const path = normalizeTreePath(entry.path); + return !directories.some(directory => directory !== path && path.startsWith(`${directory}/`)); + }); +} + +// 多选右键菜单的内容:[{ action, count, separatorBefore }]。数量是这一项实际会处理的条数。 +export function fileTreeBatchMenu(entries, expandedDirs) { + const list = Array.isArray(entries) ? entries : []; + if (list.length < 2) return []; + const files = list.filter(entry => !entry.directory); + const directories = list.filter(entry => entry.directory); + const expanded = directories.filter(entry => expandedDirs?.has?.(entry.path)); + const items = [ + { action: FILE_TREE_BATCH_ACTIONS.ADD_CONTEXT, count: outermostFileTreeEntries(list).length }, + ]; + if (files.length) items.push({ action: FILE_TREE_BATCH_ACTIONS.PREVIEW, count: files.length }); + items.push( + { action: FILE_TREE_BATCH_ACTIONS.COPY_RELATIVE_PATHS, count: list.length, separatorBefore: true }, + { action: FILE_TREE_BATCH_ACTIONS.COPY_ABSOLUTE_PATHS, count: list.length }, + ); + if (directories.length > expanded.length) { + items.push({ + action: FILE_TREE_BATCH_ACTIONS.EXPAND, + count: directories.length - expanded.length, + separatorBefore: true, + }); + } + if (expanded.length) { + items.push({ + action: FILE_TREE_BATCH_ACTIONS.COLLAPSE, + count: expanded.length, + separatorBefore: directories.length === expanded.length, + }); + } + return items; +} + +// 批量展开 / 折叠:只动选中的目录本身,与单个目录的右键菜单行为一致。 +export function toggleFileTreeDirectories(expandedDirs, directoryPaths, expand) { + const paths = Array.isArray(directoryPaths) ? directoryPaths.filter(Boolean) : []; + const current = expandedDirs instanceof Set ? expandedDirs : new Set(); + if (!paths.some(path => current.has(path) !== !!expand)) return expandedDirs; + const next = new Set(current); + for (const path of paths) { + if (expand) next.add(path); + else next.delete(path); + } + return next; +} diff --git a/web/src/lib/fileTreeSelection.test.js b/web/src/lib/fileTreeSelection.test.js new file mode 100644 index 000000000..8d04a05a2 --- /dev/null +++ b/web/src/lib/fileTreeSelection.test.js @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { + FILE_TREE_BATCH_ACTIONS, + emptyFileTreeSelection, + fileTreeBatchMenu, + followActiveFileTreeRow, + outermostFileTreeEntries, + pruneFileTreeSelection, + selectAllFileTreeRows, + selectFileTreeRow, + selectedFileTreeEntries, + toggleFileTreeDirectories, + visibleFileTreeEntries, +} from './fileTreeSelection.js'; + +function test(name, fn) { + try { fn(); console.log('ok -', name); } + catch (error) { console.error('not ok -', name); throw error; } +} + +// 一棵小树:根下 docs/(已展开,含 a.md、sub/)、src/(未展开)、README.md。 +// sub/ 没展开,但缓存里有它的子项 —— 用来确认「缓存里有」不等于「画出来了」。 +const treeCache = new Map([ + ['', [ + { path: 'docs', name: 'docs', kind: 'dir' }, + { path: 'src', name: 'src', kind: 'dir' }, + { path: 'README.md', name: 'README.md', kind: 'file' }, + ]], + ['docs', [ + { path: 'docs/sub', name: 'sub', kind: 'dir' }, + { path: 'docs/a.md', name: 'a.md', kind: 'file' }, + ]], + ['docs/sub', [{ path: 'docs/sub/deep.md', name: 'deep.md', kind: 'file' }]], + ['src', [{ path: 'src/main.js', name: 'main.js', kind: 'file' }]], +]); +const expanded = new Set(['docs']); +const rows = visibleFileTreeEntries(treeCache, expanded); +const paths = rows.map(row => row.path); + +// 场景:Shift 选范围要按「屏幕上从上到下」的顺序算。 +// 期望:只有展开的目录才把子项排进去,顺序与渲染一致;没展开的目录即使缓存过子项也不算。 +test('可见行的顺序与渲染一致', () => { + assert.deepEqual(paths, ['docs', 'docs/sub', 'docs/a.md', 'src', 'README.md']); + assert.deepEqual(rows[0], { path: 'docs', name: 'docs', directory: true }); + assert.deepEqual(rows[2], { path: 'docs/a.md', name: 'a.md', directory: false }); + assert.deepEqual(visibleFileTreeEntries(new Map(), expanded), []); + assert.deepEqual(visibleFileTreeEntries(null, null), []); +}); + +// 场景:依次普通点击、Shift 点击、Ctrl 点击。 +// 期望:普通点击只选这一行并成为范围起点;Shift 选中起点到这一行之间的全部可见行; +// Ctrl 逐个加减且不动其它行;Shift 之后起点不变,可以反方向重选。 +test('Shift 选范围、Ctrl 逐个加减', () => { + let state = selectFileTreeRow(emptyFileTreeSelection(), paths, 'docs/sub'); + assert.deepEqual([...state.keys], ['docs/sub']); + assert.equal(state.anchor, 'docs/sub'); + + state = selectFileTreeRow(state, paths, 'src', { shiftKey: true }); + assert.deepEqual([...state.keys], ['docs/sub', 'docs/a.md', 'src']); + assert.equal(state.anchor, 'docs/sub'); + + state = selectFileTreeRow(state, paths, 'docs', { shiftKey: true }); + assert.deepEqual([...state.keys], ['docs', 'docs/sub']); + + state = selectFileTreeRow(state, paths, 'README.md', { ctrlKey: true }); + assert.deepEqual([...state.keys], ['docs', 'docs/sub', 'README.md']); + state = selectFileTreeRow(state, paths, 'docs', { metaKey: true }); + assert.deepEqual([...state.keys], ['docs/sub', 'README.md']); + + // 不在可见行里的路径(已被折叠或删除)不改变选区。 + assert.equal(selectFileTreeRow(state, paths, 'docs/sub/deep.md', { ctrlKey: true }), state); +}); + +// 场景:README.md 正在预览(行上有高亮),用户还没在文件树里点过任何行,直接 Ctrl / Shift 点别的行。 +// 期望:看起来已经选中的预览文件算进选区 —— Ctrl 点 src 得到两行,Shift 点 docs/a.md 从预览文件拉范围。 +// 回归点:不这样处理的话,用户看到两行高亮,右键却只对一行生效。 +test('还没有选区时,预览中的文件算作已选', () => { + let state = selectFileTreeRow(emptyFileTreeSelection(), paths, 'src', { ctrlKey: true }, 'README.md'); + assert.deepEqual([...state.keys].sort(), ['README.md', 'src']); + + state = selectFileTreeRow(emptyFileTreeSelection(), paths, 'docs/a.md', { shiftKey: true }, 'README.md'); + assert.deepEqual([...state.keys], ['docs/a.md', 'src', 'README.md']); + assert.equal(state.anchor, 'README.md'); + + // Ctrl 点预览文件自己:就是选中它,而不是「先算已选再取消」。 + state = selectFileTreeRow(emptyFileTreeSelection(), paths, 'README.md', { ctrlKey: true }, 'README.md'); + assert.deepEqual([...state.keys], ['README.md']); + + // 已经有选区时不再把预览文件掺进来。 + state = selectFileTreeRow({ anchor: 'docs', keys: new Set(['docs']) }, paths, 'src', { ctrlKey: true }, 'README.md'); + assert.deepEqual([...state.keys], ['docs', 'src']); + + // 普通点击不受预览文件影响。 + state = selectFileTreeRow(emptyFileTreeSelection(), paths, 'src', {}, 'README.md'); + assert.deepEqual([...state.keys], ['src']); +}); + +// 场景:选中 docs 下面的行之后把 docs 折叠,或刷新后文件被删。 +// 期望:看不见的行从选区里去掉(批量操作不能落在看不见的行上);没有变化时返回原对象,不引起重渲染。 +test('折叠 / 刷新后裁掉看不见的行', () => { + const state = { anchor: 'docs/a.md', keys: new Set(['docs/a.md', 'src', 'README.md']) }; + const collapsed = visibleFileTreeEntries(treeCache, new Set()).map(row => row.path); + const pruned = pruneFileTreeSelection(state, collapsed); + assert.deepEqual([...pruned.keys], ['src', 'README.md']); + assert.equal(pruned.anchor, ''); + assert.equal(pruneFileTreeSelection(state, paths), state); +}); + +// 场景:用户点预览页签或「在文件列表展示」,预览中的文件换了。 +// 期望:只选了一行(或没选)时选区跟到新文件,旧行不再留着选中底色;已经多选时不打扰。 +test('单选时选区跟随预览中的文件', () => { + const single = { anchor: 'src', keys: new Set(['src']) }; + const followed = followActiveFileTreeRow(single, paths, 'README.md'); + assert.deepEqual([...followed.keys], ['README.md']); + assert.equal(followed.anchor, 'README.md'); + assert.equal(followActiveFileTreeRow(followed, paths, 'README.md'), followed); + + const multiple = { anchor: 'src', keys: new Set(['src', 'docs']) }; + assert.equal(followActiveFileTreeRow(multiple, paths, 'README.md'), multiple); + // 新文件在折叠的目录里(此刻看不见)或没有预览文件:不动。 + assert.equal(followActiveFileTreeRow(single, paths, 'docs/sub/deep.md'), single); + assert.equal(followActiveFileTreeRow(single, paths, ''), single); +}); + +// 场景:Ctrl+A。期望:选中全部可见行;原来的范围起点还在就保留,空树不动。 +test('全选可见行', () => { + const state = selectAllFileTreeRows({ anchor: 'src', keys: new Set(['src']) }, paths); + assert.deepEqual([...state.keys], paths); + assert.equal(state.anchor, 'src'); + assert.equal(selectAllFileTreeRows(emptyFileTreeSelection(), paths).anchor, 'docs'); + const empty = emptyFileTreeSelection(); + assert.equal(selectAllFileTreeRows(empty, []), empty); +}); + +// 场景:Shift 一路选下来,把展开的 docs 目录和它里面的行都选上了,然后拖进输入框。 +// 期望:引用 docs/ 已经包含里面的内容,只给最外层;名字只是前缀相同的兄弟(docs-old)不能被误删。 +test('目录和它里面的行一起选中时只留最外层', () => { + const selected = [ + { path: 'docs', name: 'docs', directory: true }, + { path: 'docs/sub', name: 'sub', directory: true }, + { path: 'docs/a.md', name: 'a.md', directory: false }, + { path: 'docs-old/b.md', name: 'b.md', directory: false }, + { path: 'README.md', name: 'README.md', directory: false }, + ]; + assert.deepEqual(outermostFileTreeEntries(selected).map(entry => entry.path), ['docs', 'docs-old/b.md', 'README.md']); + assert.deepEqual(outermostFileTreeEntries([]), []); +}); + +// 场景:多选后右键。菜单每一项后面的数字是它实际处理的条数,不是选中的总数。 +// 期望:添加到会话按最外层计数;预览只数文件;展开 / 折叠分别只数还没展开 / 已展开的目录; +// 没有文件就没有「预览」,没有目录就没有展开 / 折叠;只选一行不出批量菜单。 +test('多选右键菜单的项目与数量', () => { + const selected = selectedFileTreeEntries(rows, new Set(['docs', 'docs/a.md', 'src', 'README.md'])); + assert.deepEqual(selected.map(entry => entry.path), ['docs', 'docs/a.md', 'src', 'README.md']); + const menu = fileTreeBatchMenu(selected, expanded); + assert.deepEqual(menu.map(item => [item.action, item.count]), [ + [FILE_TREE_BATCH_ACTIONS.ADD_CONTEXT, 3], + [FILE_TREE_BATCH_ACTIONS.PREVIEW, 2], + [FILE_TREE_BATCH_ACTIONS.COPY_RELATIVE_PATHS, 4], + [FILE_TREE_BATCH_ACTIONS.COPY_ABSOLUTE_PATHS, 4], + [FILE_TREE_BATCH_ACTIONS.EXPAND, 1], + [FILE_TREE_BATCH_ACTIONS.COLLAPSE, 1], + ]); + // 展开与折叠同属一组,只在组的第一项前面画分隔线。 + assert.deepEqual(menu.filter(item => item.separatorBefore).map(item => item.action), [ + FILE_TREE_BATCH_ACTIONS.COPY_RELATIVE_PATHS, + FILE_TREE_BATCH_ACTIONS.EXPAND, + ]); + + const filesOnly = fileTreeBatchMenu(selected.filter(entry => !entry.directory), expanded); + assert.deepEqual(filesOnly.map(item => item.action), [ + FILE_TREE_BATCH_ACTIONS.ADD_CONTEXT, + FILE_TREE_BATCH_ACTIONS.PREVIEW, + FILE_TREE_BATCH_ACTIONS.COPY_RELATIVE_PATHS, + FILE_TREE_BATCH_ACTIONS.COPY_ABSOLUTE_PATHS, + ]); + + const expandedOnly = fileTreeBatchMenu(selected.filter(entry => entry.directory), new Set(['docs', 'src'])); + assert.deepEqual(expandedOnly.map(item => item.action), [ + FILE_TREE_BATCH_ACTIONS.ADD_CONTEXT, + FILE_TREE_BATCH_ACTIONS.COPY_RELATIVE_PATHS, + FILE_TREE_BATCH_ACTIONS.COPY_ABSOLUTE_PATHS, + FILE_TREE_BATCH_ACTIONS.COLLAPSE, + ]); + assert.equal(expandedOnly.at(-1).separatorBefore, true); + assert.deepEqual(fileTreeBatchMenu(selected.slice(0, 1), expanded), []); +}); + +// 场景:批量展开 / 折叠选中的目录。 +// 期望:只动选中的目录本身(别的已展开目录保留);什么都没变时返回原集合,不触发一次空更新。 +test('批量展开与折叠', () => { + const opened = toggleFileTreeDirectories(expanded, ['src', 'docs'], true); + assert.deepEqual([...opened].sort(), ['docs', 'src']); + assert.equal(toggleFileTreeDirectories(opened, ['src'], true), opened); + const closed = toggleFileTreeDirectories(new Set(['docs', 'docs/sub', 'src']), ['docs'], false); + assert.deepEqual([...closed].sort(), ['docs/sub', 'src']); + assert.equal(toggleFileTreeDirectories(expanded, ['src'], false), expanded); +}); + +console.log('fileTreeSelection tests passed'); diff --git a/web/src/lib/framelessResize.js b/web/src/lib/framelessResize.js index cb550a809..48de37123 100644 --- a/web/src/lib/framelessResize.js +++ b/web/src/lib/framelessResize.js @@ -4,6 +4,11 @@ export const FRAMELESS_RESIZE_ACTION = Object.freeze({ TOGGLE_MAXIMIZE: 'toggle-maximize', }); +export function canResizeFramelessWindow({ framelessDesktop, isMaximized, isFullscreen }, win = globalThis.window) { + return framelessDesktop && isMaximized === false && isFullscreen === false + && typeof win?.aceDesktop_startWindowResize === 'function'; +} + export function framelessResizeMouseDownAction({ direction, button = 0, diff --git a/web/src/lib/framelessResize.test.js b/web/src/lib/framelessResize.test.js index 701f699ae..f87d4e652 100644 --- a/web/src/lib/framelessResize.test.js +++ b/web/src/lib/framelessResize.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { FRAMELESS_RESIZE_ACTION, + canResizeFramelessWindow, framelessResizeMouseDownAction, } from './framelessResize.js'; @@ -14,6 +15,19 @@ function test(name, fn) { } } +test('resize handles are active only in a restored frameless window', () => { + const bridge = { aceDesktop_startWindowResize() {} }; + const restored = { framelessDesktop: true, isMaximized: false, isFullscreen: false }; + assert.equal(canResizeFramelessWindow(restored, bridge), true); + for (const state of [ + { isMaximized: true }, { isFullscreen: true }, + { isMaximized: null }, { isFullscreen: null }, { framelessDesktop: false }, + ]) { + assert.equal(canResizeFramelessWindow({ ...restored, ...state }, bridge), false); + } + assert.equal(canResizeFramelessWindow(restored, {}), false); +}); + test('non-left button ignores frameless resize handle', () => { assert.equal( framelessResizeMouseDownAction({ diff --git a/web/src/lib/geometricAvatar.js b/web/src/lib/geometricAvatar.js new file mode 100644 index 000000000..2efc7826c --- /dev/null +++ b/web/src/lib/geometricAvatar.js @@ -0,0 +1,73 @@ +// 子代理的花瓣头像(设置 > 外观 > 子代理头像的默认项,配置值 geometric):一片「花瓣」绕中心转一圈拼成一朵, +// 每朵一个颜色。长什么样完全由身份字符串(subagentAvatarIdentity)决定,所以同一个子代理在 +// 对话、摘要面板、后台任务页签里是同一朵。DOM 在 GeometricAvatar.jsx,这里只算形状。 +// +// 头像只有 16px,靠得住的区分只有颜色和轮廓(几瓣、粗瓣还是细条),所以只用纯色、深浅两档, +// 不画描边和渐变;颜色是手工挑的 8 个,没有红色(红色留给出错)和灰色(灰色留给已中止)。 + +export const GEOMETRIC_AVATAR_HUES = Object.freeze([ + Object.freeze({ key: 'purple', main: '#7F77DD', soft: '#AFA9EC' }), + Object.freeze({ key: 'blue', main: '#378ADD', soft: '#85B7EB' }), + Object.freeze({ key: 'cyan', main: '#1FA3C2', soft: '#7FD0E2' }), + Object.freeze({ key: 'teal', main: '#1D9E75', soft: '#5DCAA5' }), + Object.freeze({ key: 'green', main: '#6FA82A', soft: '#A9D46B' }), + Object.freeze({ key: 'amber', main: '#EF9F27', soft: '#FAC775' }), + Object.freeze({ key: 'coral', main: '#E0603A', soft: '#F2A083' }), + Object.freeze({ key: 'pink', main: '#D4537E', soft: '#ED93B1' }), +]); + +// 花瓣的画法:画在以中心为原点、边长 16 的格子里,尖端朝上;整朵由它旋转 count 次得到。 +export const GEOMETRIC_AVATAR_FORMS = Object.freeze([ + Object.freeze({ key: 'circle', path: 'M0,-6.9A2.7,2.7 0 1 1 0,-1.5A2.7,2.7 0 1 1 0,-6.9Z' }), + Object.freeze({ key: 'leaf', path: 'M0,-7.5Q2.9,-3.8 0,-0.6Q-2.9,-3.8 0,-7.5Z' }), + Object.freeze({ key: 'spoke', path: 'M-1,-6.3A1,1 0 0 1 1,-6.3V-2.1A1,1 0 0 1 -1,-2.1Z' }), + Object.freeze({ key: 'pinwheel', path: 'M0,0L0,-7.3L4.2,-3.4Z' }), + Object.freeze({ key: 'diamond', path: 'M0,-7.4L2.5,-4.7L0,-2L-2.5,-4.7Z' }), +]); + +export const GEOMETRIC_AVATAR_COUNTS = Object.freeze([3, 4, 5, 6, 8]); + +// FNV-1a 之后再搅一遍(murmur3 的收尾步骤):只差一个字符的 id 也会落到完全不同的花上。 +function hashIdentity(identity) { + let hash = 2166136261; + for (const char of String(identity)) hash = Math.imul(hash ^ char.codePointAt(0), 16777619); + hash ^= hash >>> 16; + hash = Math.imul(hash, 0x85ebca6b); + hash ^= hash >>> 13; + hash = Math.imul(hash, 0xc2b2ae35); + hash ^= hash >>> 16; + return hash >>> 0; +} + +// 身份 → 这朵花的五个参数:花瓣形状、瓣数、颜色、有没有花心、要不要转半格 +// (四根辐条是「+」还是「×」,三片叶子尖朝上还是朝下)。共 5 × 5 × 8 × 2 × 2 = 800 种。 +export function geometricAvatarSpec(identity) { + let rest = hashIdentity(identity); + const take = (size) => { + const value = rest % size; + rest = Math.floor(rest / size); + return value; + }; + const form = GEOMETRIC_AVATAR_FORMS[take(GEOMETRIC_AVATAR_FORMS.length)]; + const count = GEOMETRIC_AVATAR_COUNTS[take(GEOMETRIC_AVATAR_COUNTS.length)]; + const hue = GEOMETRIC_AVATAR_HUES[take(GEOMETRIC_AVATAR_HUES.length)]; + const center = take(2) === 1; + const turned = take(2) === 1; + return { form: form.key, path: form.path, count, hue: hue.key, main: hue.main, soft: hue.soft, center, turned }; +} + +// 每片花瓣转多少度、用深色还是浅色。偶数瓣深浅交替;奇数瓣交替不成一圈,全用深色。 +export function geometricAvatarPetals(spec) { + const count = Math.max(1, Number(spec?.count) || 0); + const step = 360 / count; + const offset = spec?.turned ? step / 2 : 0; + return Array.from({ length: count }, (_, index) => ({ + rotate: Math.round((offset + step * index) * 100) / 100, + tone: count % 2 === 0 && index % 2 === 1 ? 'soft' : 'main', + })); +} + +// 花心用和它压着的花瓣不同的那一档,否则看不出来。 +export function geometricAvatarCenterTone(spec) { + return Number(spec?.count) % 2 === 0 ? 'main' : 'soft'; +} diff --git a/web/src/lib/geometricAvatar.test.js b/web/src/lib/geometricAvatar.test.js new file mode 100644 index 000000000..e5a6df4bc --- /dev/null +++ b/web/src/lib/geometricAvatar.test.js @@ -0,0 +1,103 @@ +// 子代理几何头像的单测:同一个身份总是同一朵、参数落在允许的范围内、花瓣怎么排。 +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + GEOMETRIC_AVATAR_COUNTS, + GEOMETRIC_AVATAR_FORMS, + GEOMETRIC_AVATAR_HUES, + geometricAvatarCenterTone, + geometricAvatarPetals, + geometricAvatarSpec, +} from './geometricAvatar.js'; + +// 场景:同一个子代理的头像出现在对话、摘要面板、后台任务页签,刷新页面后还要是同一朵。 +// 期望:同一个身份每次算出完全相同的参数;参数都来自那几张表(5 种花瓣 / 5 种瓣数 / 8 个颜色), +// 深浅两档颜色和花瓣路径与表里一致。 +test('同一个身份总是同一朵,参数都来自固定的几张表', () => { + const first = geometricAvatarSpec('subagent:20261111_162252_a84f'); + assert.deepEqual(geometricAvatarSpec('subagent:20261111_162252_a84f'), first); + for (let index = 0; index < 500; index += 1) { + const spec = geometricAvatarSpec(`subagent:sample-${index}`); + const form = GEOMETRIC_AVATAR_FORMS.find((item) => item.key === spec.form); + const hue = GEOMETRIC_AVATAR_HUES.find((item) => item.key === spec.hue); + assert.ok(form, `sample ${index} form`); + assert.ok(hue, `sample ${index} hue`); + assert.equal(spec.path, form.path); + assert.equal(spec.main, hue.main); + assert.equal(spec.soft, hue.soft); + assert.ok(GEOMETRIC_AVATAR_COUNTS.includes(spec.count), `sample ${index} count`); + assert.equal(typeof spec.center, 'boolean'); + assert.equal(typeof spec.turned, 'boolean'); + } + // 没有身份(空串)也能算出一朵,不抛错。 + assert.ok(geometricAvatarSpec('').form); +}); + +// 场景:一个会话里派出十几个子代理,id 往往只差末尾几个字符(同一秒建的)。 +// 期望:只差一个字符的 id 也散到不同的花上——五个参数各自都能取到全部取值, +// 而且分布大致均匀(每个取值都不少于平均数的 7 成)。 +// 回归防护:直接拿哈希的低位取模时,相邻 id 会只在一个参数上变化,看起来像同一朵换了个颜色。 +// 阈值 7 成:4000 个样本、最多 8 个取值时,均匀分布下偏差远小于 3 成,低于它说明哈希没搅开。 +test('相邻的 id 散到不同的花上,各参数取值均匀', () => { + const tally = { form: new Map(), count: new Map(), hue: new Map(), center: new Map(), turned: new Map() }; + const seen = new Set(); + const total = 4000; + for (let index = 0; index < total; index += 1) { + const spec = geometricAvatarSpec(`subagent:20261111_162252_${index.toString(16).padStart(4, '0')}`); + seen.add([spec.form, spec.count, spec.hue, spec.center, spec.turned].join('|')); + for (const [key, counts] of Object.entries(tally)) counts.set(spec[key], (counts.get(spec[key]) || 0) + 1); + } + const sizes = { form: 5, count: 5, hue: 8, center: 2, turned: 2 }; + for (const [key, counts] of Object.entries(tally)) { + assert.equal(counts.size, sizes[key], key); + for (const [value, hits] of counts) { + assert.ok(hits >= (total / sizes[key]) * 0.7, `${key}=${value} 只出现 ${hits} 次`); + } + } + // 800 种组合里 4000 个样本应该几乎全部出现过。 + assert.ok(seen.size > 760, `只出现了 ${seen.size} 种组合`); + const a = geometricAvatarSpec('subagent:20261111_162252_a84f'); + const b = geometricAvatarSpec('subagent:20261111_162252_a84e'); + assert.notDeepEqual(a, b); +}); + +// 场景:把一片花瓣绕中心转一圈拼成一朵。 +// 期望:花瓣个数等于瓣数,角度均匀分布;偶数瓣深浅交替,奇数瓣交替不成一圈所以全用深色; +// 「转半格」时整朵多转半个间隔(四根辐条从「+」变成「×」);花心用和花瓣不同的那一档。 +test('花瓣均匀排一圈,偶数瓣深浅交替,奇数瓣全深色', () => { + const six = geometricAvatarPetals({ count: 6, turned: false }); + assert.deepEqual(six.map((petal) => petal.rotate), [0, 60, 120, 180, 240, 300]); + assert.deepEqual(six.map((petal) => petal.tone), ['main', 'soft', 'main', 'soft', 'main', 'soft']); + + const five = geometricAvatarPetals({ count: 5, turned: false }); + assert.deepEqual(five.map((petal) => petal.rotate), [0, 72, 144, 216, 288]); + assert.ok(five.every((petal) => petal.tone === 'main')); + + assert.deepEqual(geometricAvatarPetals({ count: 4, turned: true }).map((petal) => petal.rotate), [45, 135, 225, 315]); + assert.deepEqual(geometricAvatarPetals({ count: 3, turned: true }).map((petal) => petal.rotate), [60, 180, 300]); + // 角度互不相同:组件拿它当 React 的 key。 + for (const count of GEOMETRIC_AVATAR_COUNTS) { + const rotations = geometricAvatarPetals({ count, turned: true }).map((petal) => petal.rotate); + assert.equal(new Set(rotations).size, count); + } + + assert.equal(geometricAvatarCenterTone({ count: 6 }), 'main'); + assert.equal(geometricAvatarCenterTone({ count: 5 }), 'soft'); + // 参数缺失时不抛错,至少画一片。 + assert.equal(geometricAvatarPetals(null).length, 1); +}); + +// 场景:头像只有 16px,颜色是区分子代理的第一要素。 +// 期望:8 个颜色互不相同,深浅两档也不相同;没有红色和灰色——红色在界面里表示出错, +// 灰色留给「已中止」的头像。 +test('颜色表:八个互不相同的颜色,不占用红色和灰色', () => { + assert.equal(GEOMETRIC_AVATAR_HUES.length, 8); + assert.equal(new Set(GEOMETRIC_AVATAR_HUES.map((hue) => hue.key)).size, 8); + assert.equal(new Set(GEOMETRIC_AVATAR_HUES.map((hue) => hue.main)).size, 8); + for (const hue of GEOMETRIC_AVATAR_HUES) { + assert.match(hue.main, /^#[0-9A-F]{6}$/); + assert.match(hue.soft, /^#[0-9A-F]{6}$/); + assert.notEqual(hue.main, hue.soft); + assert.ok(!['red', 'gray', 'grey'].includes(hue.key), hue.key); + } +}); diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 7e9ceacc7..76710c21c 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -249,6 +249,7 @@ import './uiPrefs.test.js'; import './singleLayout.test.js'; import './responsiveSidebars.test.js'; import './summaryPanel.test.js'; +import './sessionOutputs.test.js'; import './backgroundTasks.test.js'; import './previewTabs.test.js'; import './agentBrowser.test.js'; @@ -361,6 +362,8 @@ import './fileTreeRefresh.test.js'; import './fileTreeChangeStatus.test.js'; import './changeFileTree.test.js'; import './sidePanelContextActions.test.js'; +import './fileTreeSelection.test.js'; +import './fileTreeDrop.test.js'; import './assistantRunDirectives.test.js'; import './turnFooterStabilityArchitecture.test.js'; import './assistantAvatarDisplay.test.js'; diff --git a/web/src/lib/sessionOutputs.js b/web/src/lib/sessionOutputs.js new file mode 100644 index 000000000..bc18efff3 --- /dev/null +++ b/web/src/lib/sessionOutputs.js @@ -0,0 +1,69 @@ +// 摘要面板「产物」分组的纯数据整形:把 GET /api/sessions/:id/outputs 的结果 +// 变成要画的行。组件 SummaryPanel.jsx 只渲染这里的结果。 + +// 收起时显示的条数(与 Claude 桌面版一致),其余点「显示更多」展开。 +export const SESSION_OUTPUTS_COLLAPSED_LIMIT = 5; + +const BACKSLASH = String.fromCharCode(92); + +function splitPath(path) { + return String(path || '').split(BACKSLASH).join('/').split('/').filter(Boolean); +} + +// 接口结果 → 干净的条目。没有路径的丢掉;顺序沿用服务端(最近修改的在前)。 +export function normalizeSessionOutputs(payload) { + const list = Array.isArray(payload?.outputs) ? payload.outputs : []; + const outputs = []; + for (const item of list) { + const path = typeof item?.path === 'string' ? item.path : ''; + const segments = splitPath(path); + if (segments.length === 0) continue; + const modifiedMs = Number(item.modified_ms); + outputs.push({ + path, + relative: typeof item.relative === 'string' ? item.relative : '', + name: segments[segments.length - 1], + modifiedMs: Number.isFinite(modifiedMs) ? modifiedMs : 0, + }); + } + return outputs; +} + +// 轮询拿到的列表多数时候没变:没变就让组件留着旧数组,不触发重渲染。 +export function sessionOutputsEqual(a, b) { + if (a === b) return true; + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((item, index) => item.path === b[index].path + && item.relative === b[index].relative + && item.modifiedMs === b[index].modifiedMs); +} + +// 所在目录的名字:工作目录里的文件用相对路径算(根目录下的文件没有), +// 工作目录外的用绝对路径算。 +function parentName(output) { + const segments = splitPath(output.relative || output.path); + return segments.length >= 2 ? segments[segments.length - 2] : ''; +} + +// 要画的行。重名的文件(openspec 的三个 spec.md)后面带上所在目录名, +// 不重名的只显示文件名;完整路径放 title。 +export function sessionOutputRows(outputs, { expanded = false, limit = SESSION_OUTPUTS_COLLAPSED_LIMIT } = {}) { + const list = Array.isArray(outputs) ? outputs : []; + const nameCounts = new Map(); + for (const output of list) { + const key = output.name.toLowerCase(); + nameCounts.set(key, (nameCounts.get(key) || 0) + 1); + } + const shown = expanded ? list : list.slice(0, limit); + return { + rows: shown.map((output) => ({ + key: output.path, + path: output.path, + name: output.name, + hint: nameCounts.get(output.name.toLowerCase()) > 1 ? parentName(output) : '', + title: output.path, + })), + hiddenCount: list.length - shown.length, + expandable: list.length > limit, + }; +} diff --git a/web/src/lib/sessionOutputs.test.js b/web/src/lib/sessionOutputs.test.js new file mode 100644 index 000000000..c87b76788 --- /dev/null +++ b/web/src/lib/sessionOutputs.test.js @@ -0,0 +1,114 @@ +// sessionOutputs.js 的单元测试(Node + node:assert)。 +// 背景:摘要面板的「产物」分组列出这次对话生成的文档 / 表格 / 图片。接口 +// (GET /api/sessions/:id/outputs)给出按修改时间排好的文件列表,这里的纯函数 +// 负责整理成要画的行:收起时只显示前几个、重名文件带上所在目录、轮询结果没变 +// 时不触发重渲染。 +import assert from 'node:assert/strict'; +import { + SESSION_OUTPUTS_COLLAPSED_LIMIT, + normalizeSessionOutputs, + sessionOutputRows, + sessionOutputsEqual, +} from './sessionOutputs.js'; + +function run(name, test) { test(); console.log(`[pass] ${name}`); } + +// Windows 路径用反斜杠拼接(源码里不直接写反斜杠字面量)。 +const winPath = (...parts) => parts.join(String.fromCharCode(92)); + +run('接口结果整理:取文件名、保留顺序,丢掉没有路径的条目', () => { + // 场景:接口返回里混着正反斜杠路径、缺字段的条目和非数组的异常返回。 + // 期望:文件名取路径最后一段;没有路径的条目丢掉;异常返回当作空列表而不是抛错 + // (面板每隔几秒取一次,一次坏返回不该让整个面板崩掉)。 + const outputs = normalizeSessionOutputs({ + outputs: [ + { path: winPath('C:', 'work', 'docs', 'plan.md'), relative: 'docs/plan.md', modified_ms: 2000 }, + { path: '/home/u/report.xlsx', relative: '', modified_ms: 'oops' }, + { relative: 'ghost.md' }, + null, + ], + }); + assert.deepEqual(outputs, [ + { path: winPath('C:', 'work', 'docs', 'plan.md'), relative: 'docs/plan.md', name: 'plan.md', modifiedMs: 2000 }, + { path: '/home/u/report.xlsx', relative: '', name: 'report.xlsx', modifiedMs: 0 }, + ]); + assert.deepEqual(normalizeSessionOutputs(null), []); + assert.deepEqual(normalizeSessionOutputs({ outputs: 'nope' }), []); +}); + +run('产物行:收起时只显示前几个,展开后全部显示', () => { + // 场景:一次对话生成了 7 个文件。 + // 期望:收起显示前 5 个(与 Claude 桌面版一致)并报告还有 2 个没显示;展开后 7 个 + // 全在;不超过 5 个时没有「显示更多」。 + const outputs = normalizeSessionOutputs({ + outputs: Array.from({ length: 7 }, (_, index) => ({ path: `/w/file${index}.md`, relative: `file${index}.md` })), + }); + const collapsed = sessionOutputRows(outputs); + assert.equal(collapsed.rows.length, SESSION_OUTPUTS_COLLAPSED_LIMIT); + assert.equal(collapsed.hiddenCount, 2); + assert.equal(collapsed.expandable, true); + assert.equal(collapsed.rows[0].name, 'file0.md'); + + const expanded = sessionOutputRows(outputs, { expanded: true }); + assert.equal(expanded.rows.length, 7); + assert.equal(expanded.hiddenCount, 0); + assert.equal(expanded.expandable, true); + + const few = sessionOutputRows(outputs.slice(0, 3)); + assert.equal(few.rows.length, 3); + assert.equal(few.expandable, false); + assert.deepEqual(sessionOutputRows(null), { rows: [], hiddenCount: 0, expandable: false }); +}); + +run('产物行:重名文件带上所在目录,不重名的只显示文件名', () => { + // 场景:openspec 一次生成 specs/auth/spec.md、specs/billing/spec.md,外加一个 tasks.md + // 和一个写到工作目录外的同名 spec.md。 + // 期望:三个 spec.md 各带所在目录名(否则列表里是三行一模一样的 spec.md,分不清); + // tasks.md 不带;大小写不同的同名文件(Windows)也算重名;完整路径放 title。 + const outputs = normalizeSessionOutputs({ + outputs: [ + { path: '/w/specs/auth/spec.md', relative: 'specs/auth/spec.md' }, + { path: '/w/specs/billing/Spec.md', relative: 'specs/billing/Spec.md' }, + { path: '/w/tasks.md', relative: 'tasks.md' }, + { path: winPath('D:', 'export', 'spec.md'), relative: '' }, + ], + }); + const { rows } = sessionOutputRows(outputs); + assert.deepEqual(rows.map((row) => [row.name, row.hint]), [ + ['spec.md', 'auth'], + ['Spec.md', 'billing'], + ['tasks.md', ''], + ['spec.md', 'export'], + ]); + assert.equal(rows[3].title, winPath('D:', 'export', 'spec.md')); + assert.equal(rows[0].key, '/w/specs/auth/spec.md'); +}); + +run('产物行:重名文件里放在工作目录根下的那个没有目录可带', () => { + // 场景:工作目录根下有 README.md,子目录 docs 里也有一个。 + // 期望:根下那个不带目录(它没有上级可显示),docs 里的带 docs,两行仍能区分。 + const outputs = normalizeSessionOutputs({ + outputs: [ + { path: '/w/README.md', relative: 'README.md' }, + { path: '/w/docs/README.md', relative: 'docs/README.md' }, + ], + }); + assert.deepEqual(sessionOutputRows(outputs).rows.map((row) => row.hint), ['', 'docs']); +}); + +run('轮询去重:内容没变判相等,路径或修改时间变了判不等', () => { + // 场景:回合进行中面板每隔几秒取一次产物列表,绝大多数时候结果和上次一样。 + // 期望:一样时判相等(组件留着旧数组,不重渲染);多了文件、文件被重新生成 + // (修改时间变了,排序可能也变了)判不等。 + const payload = { outputs: [{ path: '/w/a.md', relative: 'a.md', modified_ms: 1000 }] }; + const first = normalizeSessionOutputs(payload); + assert.equal(sessionOutputsEqual(first, normalizeSessionOutputs(payload)), true); + assert.equal(sessionOutputsEqual(first, first), true); + assert.equal(sessionOutputsEqual(first, []), false); + assert.equal(sessionOutputsEqual(first, normalizeSessionOutputs({ + outputs: [{ path: '/w/a.md', relative: 'a.md', modified_ms: 2000 }], + })), false); + assert.equal(sessionOutputsEqual(first, null), false); +}); + +console.log('sessionOutputs.test.js: all tests passed'); diff --git a/web/src/lib/settingsSearch.js b/web/src/lib/settingsSearch.js index 92f78e7bf..4b3fc7ee6 100644 --- a/web/src/lib/settingsSearch.js +++ b/web/src/lib/settingsSearch.js @@ -23,6 +23,7 @@ export function settingsSearchEntries(developerModeUnlocked = false) { ['appearance', '字体大小', 'font size'], ['appearance', '显示任务时间', 'sidebar task time timestamp'], ['appearance', '消息自动折叠', '会话 conversation messages auto collapse fold expand tools'], + ['appearance', '子代理头像', 'subagent sub-agent agent avatar portrait icon style petal flower geometric pixel 子代理 智能体 头像 图标 花瓣 几何图形 像素小人'], ['shortcuts', '搜索快捷键', 'keyboard shortcuts hotkeys 快捷键 键盘 搜索 前进 后退 思维深度 Ctrl Alt Cmd'], ['config', '升级服务 URL', 'upgrade update service url'], ['config', '自动更新', 'automatic update upgrade'], diff --git a/web/src/lib/subagentPanelSplitArchitecture.test.js b/web/src/lib/subagentPanelSplitArchitecture.test.js index 3445a46c2..a01c78066 100644 --- a/web/src/lib/subagentPanelSplitArchitecture.test.js +++ b/web/src/lib/subagentPanelSplitArchitecture.test.js @@ -192,4 +192,46 @@ run('sub-agent portraits share one size and carry the office status mark', () => assert.doesNotMatch(row, /rounded-full|agentRunning/); }); +// 场景:设置 > 外观 > 子代理头像,默认是花瓣(配置值 geometric),另一项是像素小人。 +// 期望:没绑定专家的子代理按这个设置画,绑定了专家的仍用专家头像;花瓣不叠像素角标, +// 状态靠它自己表达——运行中整朵转、被中止变灰;风格切换时要重新走一遍头像的副作用, +// 把像素风格订阅的钟退掉。选项是外观栏末尾的两张带预览的单选卡片,花瓣排在前面, +// 没有额外的说明文字;设置里没有单独的「子代理」一栏。 +// 回归防护:花瓣上叠像素画的三个点 / 星风格不搭(设计时否掉的方案);副作用的依赖里漏了风格, +// 切到花瓣后原来那只钟会一直往已经卸掉的画布上画;用户明确要求不要单独开一栏、默认用花瓣。 +run('petal sub-agent portraits are the default and are chosen in the appearance section', () => { + const avatar = source('components/PixelAvatar.jsx'); + const geometric = source('components/GeometricAvatar.jsx'); + const cards = source('components/SubagentAvatarStyleCards.jsx'); + const page = source('components/SettingsPage.jsx'); + const navigation = source('lib/settingsNavigation.js'); + const search = source('lib/settingsSearch.js'); + const preferences = source('lib/appearancePreferences.js'); + const css = source('styles/globals.css'); + + assert.match(avatar, /const mark = geometric \? null : avatarStatusMark\(status\);/); + assert.match(avatar, /\}, \[identity, status, size, geometric\]\);/); + assert.match(avatar, /if \(geometric\) \{[\s\S]*?/); + + assert.match(geometric, /const SubagentAvatarStyleContext = createContext\('geometric'\);/); + assert.match(preferences, /subagentAvatarStyle: 'geometric',\s*\}\);/); + assert.match(geometric, /status === 'running' && 'ace-geometric-avatar-running'/); + assert.match(geometric, /stopped && 'text-fg-mute'/); + assert.match(geometric, /stopped \? 'currentColor' : spec\[tone\]/); + assert.doesNotMatch(geometric, //); + assert.match(cards, />子代理头像<\/h3>/); + assert.match(cards, /\{ key: 'geometric', label: '花瓣' \},\s*\{ key: 'pixel', label: '像素小人' \},/); + assert.match(cards, /role="radio"\s*aria-checked=\{active\}/); + assert.match(cards, /onClick=\{\(\) => onAvatarStyleChange\(option\.key\)\}/); + // 界面文案约束:卡片只有预览和名字,不加说明行。 + assert.doesNotMatch(cards, /text-fg-mute/); +}); + console.log('subagentPanelSplitArchitecture.test.js: all tests passed'); diff --git a/web/src/lib/summaryPanel.test.js b/web/src/lib/summaryPanel.test.js index 8eff81204..fe7936386 100644 --- a/web/src/lib/summaryPanel.test.js +++ b/web/src/lib/summaryPanel.test.js @@ -15,6 +15,7 @@ import { summaryRunningTasks, summaryWorkspaceModel, } from './summaryPanel.js'; +import { normalizeSessionOutputs, sessionOutputRows } from './sessionOutputs.js'; import { publishWorkspaceDirectory, subscribeWorkspaceDirectory, @@ -115,7 +116,9 @@ run('后台任务已完成计数:结束一个加一,运行中的不算', () => { // 渲染生产组件,只替换取数和计时 hook,覆盖实际 JSX 的分组与完成行显示条件。 const panelSource = readFileSync(new URL('../components/SummaryPanel.jsx', import.meta.url), 'utf8'); const panelAst = parseSync(panelSource, { configFile: false, babelrc: false, parserOpts: { plugins: ['jsx'] } }); -const panelComponents = ['Section', 'Row', 'IconButton', 'SummaryPanel']; +const panelComponents = ['Section', 'Row', 'IconButton', 'OutputRow', 'OutputsSection', 'SummaryPanel']; +// 产物列表由 useSessionOutputs 取数,这里换成直接返回下面这个变量。 +let panelOutputs = []; const declarations = panelAst.program.body.map((node) => node.declaration || node) .filter((node) => node.type === 'FunctionDeclaration' && panelComponents.includes(node.id.name)); assert.equal(declarations.length, panelComponents.length); @@ -132,6 +135,11 @@ const SummaryPanel = vm.runInNewContext(`${compiledPanel.code}; SummaryPanel;`, useGitInfo: () => null, useGitChangeList: () => null, useNow: () => 100_000, + useSessionOutputs: () => panelOutputs, + sessionOutputRows, + SUMMARY_FOLDER_CONTEXT_MENU_DELEGATE: 'summary-folder', + SUMMARY_ROW_BUTTON_CLASS: 'row-button', + FileTypeIcon: ({ path }) => React.createElement('span', { 'data-file-icon': path }), VsIcon: ({ name }) => React.createElement('span', { 'data-icon': name }), SubagentAvatar: ({ sessionId }) => React.createElement('span', { 'data-avatar': sessionId }), }); @@ -190,6 +198,51 @@ run('后台任务摘要:有已结束任务时保留分组和正确完成数量', } }); +const withOutputs = (count, render) => { + panelOutputs = normalizeSessionOutputs({ + outputs: Array.from({ length: count }, (_, index) => ({ + path: `/w/docs/file${index}.md`, relative: `docs/file${index}.md`, modified_ms: 1000 - index, + })), + }); + try { return render(); } finally { panelOutputs = []; } +}; + +// 场景:这次对话还没有生成任何文件。 +// 期望:整个「产物」分组和它的分隔线都不画(不留一个空标题)。 +run('产物分组:没有产物时不显示分组和分隔线', () => { + const html = renderPanel([]); + assert.doesNotMatch(html, /产物|显示更多/); + assert.equal(dividerCount(html), 0); +}); + +// 场景:对话生成了 2 个文档。 +// 期望:「产物」分组排在工作区后面、变更前面;每个文件一行,带文件类型图标、 +// 完整路径在 title 里;行上有右键菜单的委托标记(kind=file);不到 6 个不出现「显示更多」。 +run('产物分组:每个文件一行,排在工作区和变更之间', () => { + const html = withOutputs(2, () => renderPanel([], { + sessionId: 's1', + onOpenFile() {}, + sessionChangeSummary: { hasChanges: true, totalAdditions: 3, totalDeletions: 1 }, + })); + assert.ok(html.indexOf('测试工作区') < html.indexOf('产物')); + assert.ok(html.indexOf('产物') < html.indexOf('变更')); + assert.match(html, /data-file-icon="file0\.md"/); + assert.match(html, /title="\/w\/docs\/file1\.md"/); + assert.match(html, /data-summary-path-kind="file"/); + assert.match(html, /data-summary-folder-path="\/w\/docs\/file0\.md"/); + assert.doesNotMatch(html, /显示更多/); + assert.equal(dividerCount(html), 2); +}); + +// 场景:对话生成了 8 个文件。 +// 期望:先只显示最近的 5 个,下面有「显示更多」(第 6 个起不在页面里)。 +run('产物分组:超过 5 个时先显示前 5 个并给出「显示更多」', () => { + const html = withOutputs(8, () => renderPanel([], { sessionId: 's1', onOpenFile() {} })); + assert.match(html, /file4\.md/); + assert.doesNotMatch(html, /file5\.md/); + assert.match(html, /显示更多/); +}); + // Windows 路径用反斜杠拼接(源码里不直接写反斜杠字面量)。 const winPath = (...parts) => parts.join(String.fromCharCode(92)); diff --git a/web/src/lib/useFileTreeSelection.js b/web/src/lib/useFileTreeSelection.js new file mode 100644 index 000000000..15126fa3f --- /dev/null +++ b/web/src/lib/useFileTreeSelection.js @@ -0,0 +1,257 @@ +// 右侧文件树多选的交互:点击(Shift / Ctrl)、键盘、多选右键菜单的委托、指针拖动。 +// 规则都在 fileTreeSelection.js / fileTreeDrop.js 的纯函数里,这里只接事件。 +import { useEffect, useMemo, useRef, useState } from 'react'; +import { CONTEXT_MENU_DELEGATE_EVENT } from './desktopContextMenu.js'; +import { + FILE_TREE_DRAG_START_PX, + FILE_TREE_DROP_ACTIVE_CLASS, + dispatchFileTreeDrop, + fileTreeDragGhostPosition, + fileTreeDragPreview, + fileTreeDropItems, + fileTreeDropTargetAt, +} from './fileTreeDrop.js'; +import { normalizeTreePath } from './fileTreeChangeStatus.js'; +import { + emptyFileTreeSelection, + followActiveFileTreeRow, + outermostFileTreeEntries, + pruneFileTreeSelection, + selectAllFileTreeRows, + selectFileTreeRow, + selectedFileTreeEntries, + visibleFileTreeEntries, +} from './fileTreeSelection.js'; +import { isDismissShortcut } from './shortcutBindings.js'; +import { useStableCallback } from './useStableCallback.js'; +import { useWorkbenchState } from './useWorkbenchState.js'; + +export const FILE_TREE_SELECTION_DELEGATE = 'file-tree-selection'; +// 拖动期间加在 body 上:换光标、禁止选中文字;后一个表示指针正停在可放下的位置。 +const DRAGGING_CLASS = 'ace-file-tree-dragging'; +const DROP_READY_CLASS = 'ace-file-tree-drop-ready'; + +export function useFileTreeSelection({ + owner, cwd, treeRef, treeCache, expandedDirs, activePath = '', onBatchMenu, +}) { + // $ 前缀:只在本窗口内按会话保留(切页签 / 切会话回来还在),不写进浏览器存储。 + const [state, setState] = useWorkbenchState(owner, `$treeSelection:${cwd}`, emptyFileTreeSelection); + const [ghost, setGhost] = useState(null); + const visibleEntries = useMemo( + () => visibleFileTreeEntries(treeCache, expandedDirs), + [treeCache, expandedDirs], + ); + const visiblePaths = useMemo(() => visibleEntries.map(entry => entry.path), [visibleEntries]); + const activeRowPath = useMemo(() => { + const active = normalizeTreePath(activePath); + return active ? visibleEntries.find(entry => normalizeTreePath(entry.path) === active)?.path || '' : ''; + }, [activePath, visibleEntries]); + + const latest = useRef(null); + latest.current = { state, visibleEntries, visiblePaths, activeRowPath, cwd }; + const dragRef = useRef(null); + const suppressClickRef = useRef(false); + const ghostElementRef = useRef(null); + const ghostPointRef = useRef({ x: 0, y: 0 }); + + const placeGhost = () => { + const element = ghostElementRef.current; + if (!element) return; + const view = element.ownerDocument.defaultView; + const position = fileTreeDragGhostPosition(ghostPointRef.current, { + width: element.offsetWidth, + height: element.offsetHeight, + viewportWidth: view?.innerWidth, + viewportHeight: view?.innerHeight, + }); + element.style.transform = `translate(${position.left}px, ${position.top}px)`; + }; + // 位置直接写到元素上:指针每动一下只改一个 transform,不让整棵文件树跟着重渲染。 + const ghostRef = useStableCallback((element) => { + ghostElementRef.current = element; + placeGhost(); + }); + + const rootLoaded = treeCache.has(''); + const followedActiveRef = useRef(activeRowPath); + useEffect(() => { + // 根目录还没拉回来时没有任何可见行,这时裁剪会把刚恢复的选区清空。 + if (!rootLoaded) return; + const activeChanged = followedActiveRef.current !== activeRowPath; + followedActiveRef.current = activeRowPath; + setState((previous) => { + const pruned = pruneFileTreeSelection(previous, visiblePaths); + return activeChanged ? followActiveFileTreeRow(pruned, visiblePaths, activeRowPath) : pruned; + }); + }, [activeRowPath, rootLoaded, setState, visiblePaths]); + + const selectedEntries = useStableCallback(() => ( + selectedFileTreeEntries(latest.current.visibleEntries, latest.current.state.keys) + )); + + const clear = useStableCallback(() => { + setState(previous => (previous.keys.size === 0 && !previous.anchor ? previous : emptyFileTreeSelection())); + }); + + // 返回 true = 这是一次普通点击,调用方照常展开目录 / 打开文件;false = 只改了选区。 + const click = useStableCallback((event, entry) => { + if (suppressClickRef.current) return false; + const current = latest.current; + setState(selectFileTreeRow(current.state, current.visiblePaths, entry.path, event, current.activeRowPath)); + return !(event.shiftKey || event.ctrlKey || event.metaKey); + }); + + const keyDown = useStableCallback((event) => { + const current = latest.current; + if (isDismissShortcut(event) && current.state.keys.size > 1) { + event.preventDefault(); + event.stopPropagation(); + clear(); + return; + } + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey + && String(event.key || '').toLowerCase() === 'a' && current.visiblePaths.length) { + event.preventDefault(); + event.stopPropagation(); + setState(selectAllFileTreeRows(current.state, current.visiblePaths)); + } + }); + + const handleContextMenuDelegate = useStableCallback((event) => { + const detail = event.detail; + if (detail?.name !== FILE_TREE_SELECTION_DELEGATE || !treeRef.current?.contains(detail.element)) return; + const path = detail.element.getAttribute('data-desktop-file-path') || ''; + const current = latest.current; + if (!current.state.keys.has(path)) { + // 右键点在选区外:选区换成这一行,由这一行自己的菜单接手。 + if (current.visiblePaths.includes(path)) setState({ anchor: path, keys: new Set([path]) }); + return; + } + const entries = selectedFileTreeEntries(current.visibleEntries, current.state.keys); + if (entries.length < 2) return; + detail.handled = true; + onBatchMenu?.({ entries, x: detail.x, y: detail.y, element: detail.element }); + }); + useEffect(() => { + window.addEventListener(CONTEXT_MENU_DELEGATE_EVENT, handleContextMenuDelegate); + return () => window.removeEventListener(CONTEXT_MENU_DELEGATE_EVENT, handleContextMenuDelegate); + }, [handleContextMenuDelegate]); + + const pointerDown = useStableCallback((event, entry) => { + // 带修饰键的按下是在改选区;触屏上按住滑动留给滚动。 + if (event.button !== 0 || event.shiftKey || event.ctrlKey || event.metaKey) return; + if (event.pointerType === 'touch' || dragRef.current) return; + const row = event.currentTarget; + const doc = row.ownerDocument; + const win = doc.defaultView; + const pointerId = event.pointerId; + const drag = { + startX: event.clientX, startY: event.clientY, dragging: false, target: null, items: [], cwd: '', + }; + + const setTarget = (target) => { + if (drag.target?.element === target?.element) return; + drag.target?.element.classList.remove(FILE_TREE_DROP_ACTIVE_CLASS); + drag.target = target; + target?.element.classList.add(FILE_TREE_DROP_ACTIVE_CLASS); + doc.body.classList.toggle(DROP_READY_CLASS, !!target); + }; + + const begin = () => { + const current = latest.current; + let entries = selectedFileTreeEntries(current.visibleEntries, current.state.keys); + if (!current.state.keys.has(entry.path)) { + // 从选区外的行拖起:只拖这一行,选区也换成它(与资源管理器一致)。 + entries = [entry]; + setState({ anchor: entry.path, keys: new Set([entry.path]) }); + } + const outermost = outermostFileTreeEntries(entries); + drag.dragging = true; + drag.cwd = current.cwd; + drag.items = fileTreeDropItems(outermost, current.cwd); + suppressClickRef.current = true; + doc.body.classList.add(DRAGGING_CLASS); + // 捕获指针:经过内嵌页面(预览、Agent Browser)时事件仍然送到这里。 + try { row.setPointerCapture?.(pointerId); } catch { /* 行可能已卸载 */ } + setGhost(fileTreeDragPreview(outermost)); + }; + + const finish = (released) => { + if (dragRef.current !== drag) return; + dragRef.current = null; + win.removeEventListener('pointermove', onMove); + win.removeEventListener('pointerup', onUp); + win.removeEventListener('pointercancel', onCancel); + win.removeEventListener('keydown', onKeyDown, true); + win.removeEventListener('blur', cancel); + row.removeEventListener('lostpointercapture', onCancel); + setTarget(null); + if (!drag.dragging) return; + try { row.releasePointerCapture?.(pointerId); } catch { /* 指针可能已释放 */ } + doc.body.classList.remove(DRAGGING_CLASS, DROP_READY_CLASS); + setGhost(null); + // 松手后浏览器还会给这一行派发一次 click,等它过去再放开点击; + // 按 Esc 取消时按键还没松,要等到真正松手之后。 + const allowClicks = () => win.setTimeout(() => { suppressClickRef.current = false; }, 0); + if (released) allowClicks(); + else win.addEventListener('pointerup', allowClicks, { once: true, capture: true }); + }; + const cancel = () => finish(false); + + function onMove(moveEvent) { + if (moveEvent.pointerId !== pointerId) return; + if (!drag.dragging) { + const distance = Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY); + if (distance < FILE_TREE_DRAG_START_PX) return; + begin(); + } + moveEvent.preventDefault(); + ghostPointRef.current = { x: moveEvent.clientX, y: moveEvent.clientY }; + placeGhost(); + setTarget(fileTreeDropTargetAt(doc, moveEvent.clientX, moveEvent.clientY)); + } + function onUp(upEvent) { + if (upEvent.pointerId !== pointerId) return; + const { dragging, target, items, cwd: dragCwd } = drag; + finish(true); + if (!dragging) return; + upEvent.preventDefault(); + if (target) { + dispatchFileTreeDrop(target, items, { + cwd: dragCwd, clientX: upEvent.clientX, clientY: upEvent.clientY, + }, win); + } + } + function onCancel(cancelEvent) { + if (cancelEvent.pointerId === pointerId) cancel(); + } + function onKeyDown(keyEvent) { + if (!drag.dragging || !isDismissShortcut(keyEvent)) return; + keyEvent.preventDefault(); + keyEvent.stopPropagation(); + cancel(); + } + + drag.cancel = cancel; + dragRef.current = drag; + win.addEventListener('pointermove', onMove); + win.addEventListener('pointerup', onUp); + win.addEventListener('pointercancel', onCancel); + win.addEventListener('keydown', onKeyDown, true); + win.addEventListener('blur', cancel); + row.addEventListener('lostpointercapture', onCancel); + }); + useEffect(() => () => dragRef.current?.cancel?.(), []); + + return { + keys: state.keys, + multiple: state.keys.size > 1, + ghost, + ghostRef, + click, + clear, + keyDown, + pointerDown, + selectedEntries, + }; +} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index d549be496..19a5624d4 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -1301,7 +1301,8 @@ body { /* The file drop target keeps the accent ring: it is a transient affordance, not a resting state. Same specificity as hover / focus, later, so it wins. */ .ace-composer-card.is-drag-active, -.ace-composer-card.is-session-reference-drop { +.ace-composer-card.is-session-reference-drop, +.ace-composer-card.is-file-tree-drop { border-color: var(--ace-accent); box-shadow: 0 0 0 2px rgba(var(--ace-accent-rgb), 0.15); } @@ -3369,7 +3370,8 @@ body { box-shadow: 0 18px 46px rgba(0, 0, 0, 0.08); } .ace-inputbar-hero-card.is-drag-active, -.ace-inputbar-hero-card.is-session-reference-drop { +.ace-inputbar-hero-card.is-session-reference-drop, +.ace-inputbar-hero-card.is-file-tree-drop { box-shadow: 0 0 0 2px rgba(var(--ace-accent-rgb), 0.15), 0 18px 46px rgba(0, 0, 0, 0.08); @@ -3387,7 +3389,8 @@ body { box-shadow: 0 18px 46px rgba(0, 0, 0, 0.32); } [data-theme="dark"] .ace-inputbar-hero-card.is-drag-active, -[data-theme="dark"] .ace-inputbar-hero-card.is-session-reference-drop { +[data-theme="dark"] .ace-inputbar-hero-card.is-session-reference-drop, +[data-theme="dark"] .ace-inputbar-hero-card.is-file-tree-drop { box-shadow: 0 0 0 2px rgba(var(--ace-accent-rgb), 0.15), 0 18px 46px rgba(0, 0, 0, 0.32); @@ -6555,6 +6558,14 @@ a.ace-thread-link:focus-visible { padding-left: 10px; } .ace-file-row:hover { background: var(--ace-surface-hi); } +/* 多选:Shift / Ctrl 选中的行。正在预览的文件另有一套更重的样式(见下),两者可以叠加。 */ +.ace-file-row { + user-select: none; + -webkit-user-select: none; +} +.ace-file-row[data-file-multi-selected="true"] { + background: rgba(var(--ace-accent-rgb), 0.12); +} .ace-file-tree-message { min-height: 24px; display: flex; @@ -6627,6 +6638,59 @@ a.ace-thread-link:focus-visible { .ace-file-row[data-desktop-file-selected="true"]:not([data-review-status]) { color: var(--ace-accent); } +/* 选了多行而预览中的文件不在其中:去掉它的底色,只留左侧色条,免得看成也被选中。 */ +.ace-file-tree[data-multi-select="true"] .ace-file-row[data-desktop-file-selected="true"]:not([data-file-multi-selected="true"]) { + background: transparent; +} +.ace-file-tree[data-multi-select="true"] .ace-file-row[data-desktop-file-selected="true"]:not([data-file-multi-selected="true"]):hover { + background: var(--ace-surface-hi); +} +/* 拖动文件树里的行:跟着指针走的预览,多于一行时右上角标数量。 */ +.ace-file-drag-ghost { + position: fixed; + left: 0; + top: 0; + z-index: 80; + display: flex; + flex-direction: column; + gap: 2px; + max-width: 240px; + padding: 4px 8px; + border: 1px solid rgba(var(--ace-border-rgb), 0.92); + border-radius: 6px; + background: rgba(var(--ace-bg-rgb), 0.96); + color: var(--ace-fg); + font-family: var(--font-sans); + font-size: 12px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.16); + pointer-events: none; +} +.ace-file-drag-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + min-height: 20px; +} +.ace-file-drag-count { + position: absolute; + top: -9px; + right: -7px; + min-width: 22px; + padding: 1px 6px; + border-radius: 9px; + background: var(--ace-fg); + color: var(--ace-bg); + text-align: center; + font-size: 12px; +} +.ace-file-tree-dragging { + user-select: none; + -webkit-user-select: none; +} +/* 指针被拖起的那一行捕获,光标形状由它决定。 */ +.ace-file-tree-dragging .ace-file-row { cursor: grabbing; } +.ace-file-tree-dragging.ace-file-tree-drop-ready .ace-file-row { cursor: copy; } .ace-file-row.ace-file-locate-highlight { overflow: hidden; animation: ace-file-locate-highlight 760ms ease-out; @@ -7197,6 +7261,16 @@ div.ace-markdown-opaque > div.ace-markdown-opaque-rendered { max-width: 100%; } +/* 花瓣风格的子代理头像:运行中整朵慢慢转(花是中心对称的,转起来就是「在忙」),结束后停住。 + 不跟随系统「减少动态效果」:远程桌面里那个设置总是开着,跟了就永远不转。 */ +@keyframes ace-geometric-avatar-spin { + to { transform: rotate(360deg); } +} + +.ace-geometric-avatar-running { + animation: ace-geometric-avatar-spin 6s linear infinite; +} + @keyframes ace-activity-line-shimmer-sweep { 0%, 50% { @@ -7745,7 +7819,8 @@ div.ace-markdown-opaque > div.ace-markdown-opaque-rendered { } /* 拖文件悬停时的浮层提示。用 ::after 伪元素而非 React 子节点,避免与 xterm 命令式插入的 DOM 冲突;pointer-events:none 确保拖放命中不被浮层吞掉。 */ -.ace-console-term[data-drop-active="true"]::after { +.ace-console-term[data-drop-active="true"]::after, +.ace-console-term.is-file-tree-drop::after { content: "松开插入文件路径"; position: absolute; inset: 0;