Ctrl K
ComunidadGuías…

docs/plan/2026-08-05-side-task-inspector-auto-switch-design.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 右侧边栏随中心 Tab 自动切换(边路任务 / 子代理)技术方案

- 日期:2026-08-05
- 涉及端:桌面端 / Web / 安卓(共用 `desktop/` 前端)
- 涉及后端:无(零改动)
- 文档状态:已与需求方逐项确认决策,达成共识

---

## 1. 需求背景

桌面端会话页面(`TaskView`)采用「左列表 + 中 Tab 区 + 右侧边栏」三栏布局。中间 Tab 区支持打开 5 类标签:主会话(chat)、文件(file)、Diff(diff)、边路任务(side_task)、子代理(subagent)。

当前缺陷:当用户从主会话 Tab 切换到「边路任务」或「子代理」Tab 时,右侧边栏(`TaskInspector`)的 **标题、状态、上下文、进度** 四个区块仍然展示**主任务**的信息,与该 Tab 正在查看的子会话内容不一致,容易造成误判(例如主任务已完成、边路任务仍在执行时,边栏仍显示「已完成」)。

用户诉求:切换 Tab 时右侧边栏应自动切换为对应会话的信息。

## 2. 需求描述

在会话页面(`desktop/src/views/task/TaskView.vue`)中,右侧边栏的展示对象随中间 Tab 区的当前激活 Tab 联动:

| 激活 Tab | 右侧边栏展示对象 |
|---|---|
| 主会话(chat) | 主会话(现状,不变) |
| 边路任务(side_task) | 该边路任务会话 |
| 子代理(subagent) | 该子代理会话 |
| 文件(file)/ Diff(diff) | 主会话(文件仅是浏览工具,无独立会话) |

联动区块(4 项,全部切换):

1. **标题** —— 显示对应会话的标题;
2. **状态** —— 显示对应会话的 phase(执行中 / 待审批 / 已完成 / 失败 / 已取消等);
3. **上下文** —— 显示对应会话的 context_window(token 占比);
4. **进度** —— 显示对应会话的 todos 清单。

## 3. 现状分析(代码事实)

### 3.1 数据链路(已具备子会话粒度数据)

- 后端 WS 事件按各自 sessionId 推送:`session_status`(phase,`StreamingWsHandler`/`SubAgentVisibilityService`)、`todo_updated`(todos,`WsStreamingEventListener`)、`context_window`(`WsStreamingEventListener.onContextWindow`)。
- `useStreamWS` 在 `side_session_created` / `subagent_session_created` 事件中会对子会话执行 `subscribe(String(sideSessionId))` / `subscribe(String(childSessionId))`,即**子会话已订阅**,其 phase / todos / context 事件会实时到达前端。
- `desktop/src/stores/session.ts` 中,`sessionTodos`、`sessionContextWindow`、`sessionPhases` 三个 Map 均**以 sessionId 为 key** 存储:
  - `setTodos(sessionId, todos)`、`setContextWindow(sessionId, info)`、`updateSessionPhase(id, phase)` 已按子会话 ID 写入;
  - `getSessionPhase(id)` 已存在(phase 缓存 + 主列表兜底)。

### 3.2 缺口(本方案要补齐的)

1. **store 缺少按 sessionId 读取 todos / contextWindow 的 getter**:目前仅暴露主会话视角的 `activeTodos` / `activeContextWindow`(绑定 `activeSessionId`),无法按子会话 ID 读取。
2. **TaskView 的右侧边栏数据全部来自主会话**:
   - `title` ← `sessionStore.activeSession.summary/title`;
   - `phase` ← `currentPhase`(主会话,由主 ChatPanel 的 `syncChatState` 同步);
   - `todos` ← `chatTodos`(主会话,同步自主 ChatPanel);
   - `contextWindow` ← `chatContextWindow`(主会话,同步自主 ChatPanel);
   - `TaskInspector` 的 `sessionId` prop 恒为主会话,其内部 `currentModelId` 从 `sessionStore.sessions`(主列表)查找,子会话不在该列表中 → 找不到子会话的 modelId。
3. **标题编辑入口未分流**:`TaskInspector` 标题可点击重命名(`emit('rename')`),`TaskView` 的 `handleRename` 只处理主会话;边路任务已有独立的 `handleEditSideTaskTitle`(PATCH `/sessions/{id}` + 更新缓存与 Tab 标题),但未接入右侧边栏的编辑入口;子代理无任何编辑逻辑。
4. **无会话类型标识**:边栏无法让用户感知当前查看的是主会话还是子会话。

### 3.3 相关文件

| 文件 | 角色 |
|---|---|
| `desktop/src/views/task/TaskView.vue` | 容器:持有主会话状态,向 TaskInspector 传 props |
| `desktop/src/components/task/TaskInspector.vue` | 右侧边栏:标题 / 状态 / 上下文 / 进度 / 工作区 / 边路列表 / 子代理列表 / 文件树 / Git |
| `desktop/src/composables/useCenterTabs.ts` | 中心 Tab 状态;已导出 `activeTab`(当前激活 Tab 对象) |
| `desktop/src/stores/session.ts` | 会话数据缓存(messages / todos / context / phases / sideTaskCache / subagentCache) |
| `desktop/src/components/center/CenterTabContainer.vue` | 按 activeTabId 渲染 ChatPanel / SideChatPanel / SubagentChatPanel |
| `desktop/src/composables/useModelContext.ts` | 按 modelId 拉取模型窗口大小(上下文占比分母) |
| 后端 `SessionController` | `GET /sessions/{id}`(phase、contextTokens、modelId)、`GET /sessions/{id}/todos`(均已存在) |

## 4. 技术选型

| 项 | 选择 | 说明 |
|---|---|---|
| 实现方式 | 纯前端 Vue 响应式改造 | 数据已按会话粒度就绪,无需后端改动、无需新接口 |
| 状态读取 | Pinia store 按 sessionId 读取 | 新增两个 getter,保持现有缓存模型 |
| 补拉数据 | REST 接口按需补拉 | `GET /sessions/{id}` + `GET /sessions/{id}/todos`,会话内幂等去重 |
| 标识与只读 | TaskInspector 新增 props | `viewType` 徽标 + `modelId` 覆盖,避免侵入 store 主链路 |
| 新依赖 | 无 | 不引入任何 npm / 后端依赖 |

## 5. 详细设计

### 5.1 store 扩展(`desktop/src/stores/session.ts`)

1. 新增导出 getter(与 `getMessages` / `getCompactionEvents` 同风格):
   - `getTodos(sessionId: string): TodoItem[]` → 返回 `sessionTodos` 中该会话的列表,缺省 `[]`;
   - `getContextWindow(sessionId: string): ContextWindowInfo | null` → 返回 `sessionContextWindow` 中该会话的值,缺省 `null`。
2. `SubagentItem` 类型增加可选字段 `modelId?: number`(用于上下文占比分母;`SideTaskItem` 已带 `modelId`)。
3. 新增 action `updateSubagentMeta(childSessionId: number, meta: { title?: string; phase?: TaskPhase; modelId?: number })`:在 `subagentCache` 全量中按 id 找到条目并合并更新(与 `updateSubagentPhase` 同遍历模式)。

### 5.2 TaskView.vue(核心联动)

复用 `useCenterTabs` 已导出的 `activeTab`,新增一组「当前展示会话」computed,替换传给 `TaskInspector` 的 props:

| 新 computed | 逻辑 |
|---|---|
| `inspectorViewType` | `activeTab.type === 'side_task'` 且 `sideSessionId > 0` → `'side_task'`;`activeTab.type === 'subagent'` 且 `sideSessionId > 0` → `'subagent'`;其余(chat / file / diff / 占位边路 Tab)→ `'chat'` |
| `inspectorSessionId` | `inspectorViewType` 为子会话 → 对应 `sideSessionId` 字符串;否则主会话 id(沿用现有 `sessionIdForTabs`) |
| `inspectorTitle` | 子会话:从 `sideTasks` / `subagents`(当前主会话作用域聚合列表)按 id 匹配 title;匹配不到 → 回退主会话 `sessionTitle` |
| `inspectorPhase` | 子会话:`sessionStore.getSessionPhase(sid)`;为 null 时再取 cache 条目 phase;仍缺失 → 回退主会话 `currentPhase`。chat → `currentPhase` |
| `inspectorTodos` | 子会话:`sessionStore.getTodos(sid)`;chat → 现有 `chatTodos` |
| `inspectorContextWindow` | 子会话:`sessionStore.getContextWindow(sid)`;chat → 现有 `chatContextWindow` |
| `inspectorModelId` | 子会话:从 `sideTasks`(`SideTaskItem.modelId`)/ `subagents`(`SubagentItem.modelId`)匹配;chat → `undefined` |

回退语义:**任何子会话数据缺失时回退主会话信息,保证右侧边栏永不空白**(与现有行为一致)。

### 5.3 补拉策略(TaskView.vue)

- 维护会话级去重集合 `fetchedMetaSet = new Set<string>()`(切换会话/任务时清空,与 `reset` 生命周期一致)。
- `watch(inspectorSessionId)`:当 `inspectorViewType !== 'chat'` 且该 sid 未拉取过时,并发发起:
  - `GET /sessions/{sid}` → `data.phase` 写入 `updateSessionPhase(sid, phase)`;`data.title` 在 cache 无 title 时写回(边路走 `updateSideTaskTitle`,子代理走 `updateSubagentMeta`);`data.modelId` 写回 `updateSubagentMeta`(子代理)或 `updateSideTaskTitle` 对应条目(边路列表已带 modelId,如缺失亦写回);
  - `GET /sessions/{sid}/todos` → `setTodos(sid, data)`。
- 完成后将该 sid 加入去重集合,**后续切换直接读缓存,不再重复请求**。
- 拉取失败静默降级(catch 后仍加入集合,避免死循环请求),界面自然回退主会话信息。
- WS 推送持续生效:子会话运行期间的 `session_status` / `todo_updated` / `context_window` 事件会实时更新对应缓存,边栏随之刷新,无需额外监听。

### 5.4 TaskInspector.vue(展示层)

1. 新增 props:
   - `viewType?: 'chat' | 'side_task' | 'subagent'`(默认 `'chat'`);
   - `modelId?: number`(当前展示会话的模型 id,覆盖内部主列表查找)。
2. `currentModelId` 计算改为:**优先 `props.modelId`**,为空再走原 `sessionStore.sessions` 查找(主会话场景)。上下文占比分母语义:子会话用其自身模型窗口,缺失时回退主会话模型(`useModelContext` 在 modelId 为空时返回 null,占比降级为仅显示 token 数,现有行为)。
3. 类型标识:`viewType` 为 `'side_task'` / `'subagent'` 时,在标题区显示小徽标「边路任务」/「子代理」(复用 `phase-badge` 视觉体系,中性色,置于标题旁)。`'chat'` 不显示。
4. 标题编辑控制:`viewType === 'subagent'` 时标题为纯文本(`startEdit` 直接返回,不可点击、不弹输入框);`'chat'` 与 `'side_task'` 保持可编辑。

### 5.5 标题重命名分流(TaskView.vue)

`TaskInspector` 的 `rename` 事件在 `TaskView` 中按 `inspectorViewType` 分发:

| viewType | 处理 |
|---|---|
| `'chat'` | 现有 `handleRename`(主会话) |
| `'side_task'` | 现有 `handleEditSideTaskTitle`(PATCH `/sessions/{id}` + `updateSideTaskTitle` + `updateSideTaskTab`),保持不变 |
| `'subagent'` | 不处理(子代理只读,入口已禁用) |

## 6. 实现步骤

按依赖顺序执行,均为 `desktop/` 前端改动:

1. **store 扩展**:`desktop/src/stores/session.ts`
   - 新增 `getTodos` / `getContextWindow` getter;
   - `SubagentItem` 增加 `modelId?`;
   - 新增 `updateSubagentMeta` action;
   - 在 store 导出对象中登记以上新成员。
2. **TaskView 联动**:`desktop/src/views/task/TaskView.vue`
   - 引入 `activeTab`;新增 5.2 节一组 computed;
   - 实现补拉 watch 与去重集合(5.3 节);
   - `rename` 事件分流(5.5 节);
   - 更新 `TaskInspector` 传参(`title`、`phase`、`todos`、`contextWindow`、`sessionId` 换为 inspector 系列,新增 `view-type` / `model-id`)。
3. **TaskInspector 展示**:`desktop/src/components/task/TaskInspector.vue`
   - 新增 `viewType` / `modelId` props;
   - `currentModelId` 优先取 `props.modelId`;
   - 标题区加类型徽标;子代理标题只读;
   - 补徽标样式(明暗主题均适配)。
4. **类型检查与构建**:
   - `cd desktop && npx vue-tsc --noEmit`(严格模式类型检查);
   - `cd desktop && npm run build`(构建产物,验证通过)。
5. **手测**:按第 8 节清单执行。
6. **CHANGELOG**:根 `CHANGELOG.md` 在当前版本(或新增版本)的 `### 前端(桌面 / Web / 安卓)` 下追加条目(见第 9 节)。
7. **部署**:`cd desktop && npm run build && cd /opt/mao/desktop && npm run build`(后端零改动,无需重启后端服务)。

## 7. 落地清单

### 要做(Do)

- 右侧边栏 标题 / 状态 / 上下文 / 进度 四项,随「边路任务」「子代理」Tab 自动切换为对应会话信息;
- 文件 / Diff Tab 激活时保持主会话信息(不切换);
- store 新增按 sessionId 读取 todos / contextWindow 的 getter;
- 切换子会话 Tab 时,缓存缺失则补拉 `GET /sessions/{id}` 与 `GET /sessions/{id}/todos` 一次(会话内幂等去重);
- 子会话运行期间,右侧边栏随 WS 推送(session_status / todo_updated / context_window)实时刷新;
- 右侧边栏标题旁增加类型徽标(「边路任务」「子代理」),chat 不显示;
- 子代理 Tab 下标题只读不可编辑;主会话与边路任务标题保持可编辑(复用现有逻辑);
- 上下文占比分母优先取子会话自身 modelId,缺失回退主会话模型;
- 子会话数据缺失/拉取失败时回退显示主会话信息(不空白);
- 更新根 `CHANGELOG.md` 前端小节;
- 通过 `vue-tsc` 类型检查与 `npm run build` 构建。

### 不做(Not Do)

- **后端零改动**:不新增/修改任何 REST 接口、WS 事件、数据库字段;
- 不改动右侧边栏的 工作区、Agent 名称、Git 摘要、文件树 Tab、Git Tab(保持主会话视角,子会话共享主会话工作区);
- 不改动「边路任务」「子代理」两个列表区块(始终以主任务为视角展示,不随 Tab 切换);
- 不为子代理开放标题编辑 / 追问 / 停止等只读限制之外的交互(与 `SubagentChatPanel` 现有只读语义一致);
- 不做待创建占位边路 Tab(`sideSessionId <= 0`)的切换,保持主会话信息;
- 不新增自动化 E2E 用例(需真实后端 + 完整 delegate 链路,可靠性差),以手测清单为准;
- 不引入功能开关 / 配置项,行为固定为本文档所述。

## 8. 测试与验证(手测清单)

1. 主会话运行中 → 新建/打开边路任务 Tab → 右侧标题、状态、上下文、进度切换为该边路任务的信息,标题旁出现「边路任务」徽标;
2. 边路任务运行 / 完成时 → 右侧状态与进度随 WS 实时变化(无需切 Tab);
3. 切回主会话 Tab → 右侧恢复主会话信息,徽标消失;
4. 运行中的主会话内委派子代理 → 打开子代理 Tab → 右侧切换为子代理信息 + 「子代理」徽标,且标题点击无编辑框;
5. 边路任务 Tab 下点击右侧标题 → 可编辑,保存后右侧标题、中心 Tab 标题、右侧列表条目同步更新;
6. 刷新页面 → 直接切到子会话 Tab → 短暂补拉后展示数据,不空白;
7. 打开文件 / Diff Tab → 右侧仍显示主会话信息;
8. 异常场景:缓存中被删除的子会话 Tab → 右侧回退主会话信息,不报错;
9. 明暗两种主题下徽标显示正常。

## 9. CHANGELOG 记录

在根 `CHANGELOG.md` 的 `### 前端(桌面 / Web / 安卓)` 追加(示例,最终文案以实际版本为准):

> - 会话页面右侧边栏随中心 Tab 联动:切换到边路任务 / 子代理 Tab 时,标题、状态、上下文、进度自动切换为对应会话的信息,并显示会话类型标识;子代理标题为只读。

## 10. 风险与注意

- **主会话链路不动**:`chatTodos` / `chatContextWindow` / `currentPhase` 及其 `syncChatState` 同步链路保持原样,仅新增 inspector 系列 computed 覆盖子会话场景,避免回归;
- **补拉去重**:以会话内 Set 去重,防止每次切换重复请求;失败静默降级并记录一次,避免请求风暴;
- **modelId 时序**:子代理 modelId 依赖补拉写回,首次渲染瞬间可能仍取主会话模型作分母,补拉完成后自动修正(可接受,属过渡态);
- **占位 Tab**:边路任务首次发送前的占位 Tab(`sideSessionId <= 0`)不触发切换,与 `SideChatPanel` 现有「待创建」语义一致;
- **KeepAlive**:Tab 切换不重建面板,数据全部经 store 读取,联动为纯 computed,无副作用。

## 11. 决策记录(已确认)

| # | 决策点 | 结论 |
|---|---|---|
| 1 | 切换范围 | 边路任务 + 子代理 Tab 切换;文件 / Diff Tab 保持主会话 |
| 2 | 切换内容 | 标题 / 状态 / 上下文 / 进度四项;工作区、Git 摘要、文件树 / Git Tab 保持主会话 |
| 3 | 标题编辑 | 主会话、边路任务可编辑;子代理只读 |
| 4 | 数据策略 | 缓存优先,缺失时补拉 `GET /sessions/{id}` + `/todos` 一次(幂等) |
| 5 | 可视标识 | 标题旁加「边路任务」「子代理」类型徽标 |
| 6 | 回退策略 | 数据缺失 / 异常时回退主会话信息,不空白 |
| 7 | 上下文分母 | 子会话自身 modelId 优先,缺失回退主会话模型 |