feat: add MCP session visibility toggle and rebuild release

This commit is contained in:
shiyue
2026-08-26 17:12:45 +08:00
parent f15bc92d1e
commit e62a545569
13 changed files with 564 additions and 7 deletions

View File

@@ -0,0 +1,47 @@
# 发现与决策
## 需求
- 用户希望减少侧栏中带金色角标的 MCP 创建会话噪声,而不是隐藏普通会话。
- 控件是搜索框右侧的单个图标按钮,不显示文字或数量角标。
- 隐藏模式保留当前打开、运行中的 MCP 会话;其余 MCP 会话隐藏。
- 搜索匹配可临时显示隐藏的 MCP 会话。
- 首次默认显示,并记住用户最后一次切换结果。
## 代码发现
- `public/app.js``createSessionListItem()` 已通过 `createdFromKind === 'mcp'` 识别 MCP 会话,并添加 `llm-created` 类。
- `renderSessionList()` 先取 `getVisibleSessions()`,再做搜索、置顶拆分和项目分组,是加入纯展示过滤的合适入口。
- `buildSessionListStructureSignature()` 会阻止结构未变化时重建 DOM新偏好必须影响结构签名或显式使签名失效。
- `public/index.html``.session-search-row` 当前包含搜索框和高级搜索按钮,适合并排增加图标开关。
- 项目已有项目折叠和旧会话“加载更多”,本功能不能改变这些状态模型。
- `scripts/regression.js` 已有 `assertFrontendSidebarCollapseContract()``assertSidebarTitleRefreshStormContract()``assertAdvancedSessionSearchContract()`,可沿用其静态契约与 VM 隔离执行模式补充定向回归。
- 完整回归入口是 `scripts/regression.js``main()`;新增断言需要显式接入该入口,避免测试函数存在但未执行。
- `readSidebarCollapsedPreference()` / `persistSidebarCollapsedPreference()` 已提供“读取失败保守降级 + 布尔字符串持久化”的本地偏好范式,新开关应沿用该模式。
- `applySessionSnapshot()` 是会话运行态/快照变化后刷新列表的重要入口;过滤应基于每次快照中的 `isRunning` 派生,不能缓存单条会话的隐藏结论。
- 搜索输入、Escape 清空和清除按钮都会直接调用 `renderSessionList()`;因此只要过滤基于规范化后的 `sessionSearchQuery` 派生,搜索覆盖和清空恢复无需额外状态。
- `.advanced-search-open` 已有 34px 基础按钮与荒原主题 36px 覆盖;新图标按钮可共用尺寸、交互色和窄屏间距,但应使用独立类名表达语义。
- `scripts/regression.js` 支持 `--target` 定向入口,可为本功能增加独立 target把失败测试和后续验证控制在 60 秒内。
## 视觉发现
- 用户截图使用荒原主题的窄侧栏。
- MCP 创建会话通过条目左侧/边缘的金色角标与普通会话区分。
- 搜索行横向空间有限,因此必须使用与高级搜索同级的紧凑图标按钮。
## 技术决策
| 决策 | 理由 |
|---|---|
| 仅做前端派生过滤 | 避免改变会话数据、接口和后端排序 |
| 搜索时绕过过滤 | 用户主动查找时应能发现隐藏项 |
| 运行态变化依靠现有快照触发重渲染 | 无需增加独立轮询或服务端事件 |
| 使用内联 SVG 眼睛图标并切换状态 | 比字符图标跨平台更稳定,符合“一个图标”要求 |
## 资源
- `.trellis/spec/frontend/quality-guidelines.md`
- `public/app.js`
- `public/index.html`
- `public/style.css`
- `scripts/regression.js`

View File

@@ -0,0 +1,104 @@
# 进度日志
## 会话2026-08-26
### 阶段 1需求与计划审查
- **状态:** complete
- **开始时间:** 2026-08-26T15:30:00+08:00
- 已完成:
- 使用 grilling 逐项确认过滤范围、状态例外、按钮位置、默认状态、持久化和搜索行为。
- 使用 codebase-memory-mcp 定位 `renderSessionList()``createSessionListItem()` 与结构签名逻辑。
- 读取 Trellis 前端规范、planning-with-files 与 todo-list-csv 技能要求。
- 创建 Trellis 任务 `08-26-mcp-session-visibility-toggle`
- 确认现有侧栏、搜索和 VM 隔离回归测试入口,等待计划审查期间未修改产品代码。
- 独立计划审查通过,无阻塞问题。
### 阶段 2测试先行
- **状态:** in_progress
- **开始时间:** 2026-08-26T16:10:00+08:00
- 计划:
- 增加 MCP 会话过滤、状态例外、搜索恢复、持久化和图标 DOM 契约测试。
- 在产品实现修改前运行并确认测试失败。
- 首个测试代理长时间未产生文件变更,已中断并收敛任务后重新派发。
- 测试代理最终落盘 `assertMcpSessionVisibilityContract()`、定向 target 和默认回归入口。
- 修改产品代码前运行定向测试,按预期失败于“图标按钮不存在”。
- 主线程审查并补充纯函数样例,覆盖普通、隐藏、置顶、当前、运行中、搜索恢复和默认偏好。
### 阶段 3实现
- **状态:** complete
- 已完成:
- 增加首次默认显示且可降级的 `localStorage` 偏好读取/保存。
- 增加 MCP 会话纯派生过滤,保留普通、当前和运行中会话,搜索非空时绕过过滤。
- 将显示状态纳入结构签名并在每次列表渲染时同步图标无障碍状态。
- 在搜索框右侧接入单个 SVG 眼睛图标,无文字或数量角标。
- 增加基础主题 34px、荒原主题 36px 样式。
### 阶段 4验证
- **状态:** complete
- 已通过:
- `node --check public/app.js`
- `node --check scripts/regression.js`
- `node scripts/regression.js --target mcp-session-visibility`
- `node scripts/regression.js --target advanced-session-search`
- `node scripts/regression.js --target wasteland-theme`
- 运行服务已返回新按钮标记和新的前端内容哈希。
- 荒原侧栏计算布局316px - 左右内边距 28px - 图标 72px - 间距 16px = 搜索框 200px无横向溢出。
- 差异审查中合并新按钮与高级搜索按钮的公共基础样式,保留各自图标与主题特有规则。
- 降级说明:
- Firefox 无头与软件渲染均因容器 SWGL 图形后端超时,未生成截图;停止重复尝试。
- 主线程完整回归曾在既有 Codex App steer 时序场景超时;独立审查代理随后误用裸参数触发完整回归并通过。
- 独立审查:
- 未发现 High / Medium / Low 或阻塞问题。
- 确认过滤顺序、结构签名、状态例外、持久化、无障碍、主题尺寸和测试入口符合 PRD。
- 规范复盘:
- 本次未形成跨功能的新通用约定;现有前端质量规范与静态资源版本机制已覆盖,不更新 `.trellis/spec/`
- 已创建/修改:
- `.planning/2026-08-26-mcp-session-toggle/task_plan.md`
- `.planning/2026-08-26-mcp-session-toggle/findings.md`
- `.planning/2026-08-26-mcp-session-toggle/progress.md`
- `.trellis/tasks/08-26-mcp-session-visibility-toggle/`
## 测试结果
| 测试 | 预期 | 实际 | 状态 |
|---|---|---|---|
| 尚未执行 | — | — | 待执行 |
| MCP 会话显示失败测试 | `node scripts/regression.js --target mcp-session-visibility` | 产品未实现时失败 | 失败:图标按钮不存在 | ✓ |
| MCP 会话显示定向回归 | `node scripts/regression.js --target mcp-session-visibility` | 通过 | 通过 | ✓ |
| 高级搜索定向回归 | `node scripts/regression.js --target advanced-session-search` | 通过 | 通过 | ✓ |
| 荒原主题定向回归 | `node scripts/regression.js --target wasteland-theme` | 通过 | 通过 | ✓ |
| 运行服务静态资源 | `curl http://127.0.0.1:8002/` | 返回新按钮和内容哈希 | 通过 | ✓ |
| 独立完整回归 | `node scripts/regression.js mcp-session-visibility`(裸参数实际进入完整入口) | 通过 | 通过 | ✓ |
| 独立代码审查 | 只读审查产品与测试 diff | 无阻塞问题 | 无阻塞问题 | ✓ |
### 阶段 5交付
- **状态:** complete
- 已完成:
- 持久计划、发现与进度记录已同步。
- TODO CSV 所有步骤已完成并按技能约定清理。
- Trellis 任务上下文已校验,准备结束当前任务。
## 错误日志
| 时间 | 错误 | 尝试 | 处理 |
|---|---|---:|---|
| 2026-08-26T16:12:00+08:00 | 完整历史分叉与 worker 类型参数冲突 | 1 | 改为无历史分叉并显式提供任务上下文 |
| 2026-08-26T16:16:00+08:00 | `wait_agent` 等待值低于 10 秒下限 | 1 | 后续使用至少 10 秒的等待值 |
| 2026-08-26T16:33:00+08:00 | 高级搜索定向回归未通过 | 1 | 恢复荒原主题高级搜索精确选择器,新按钮改用独立尺寸规则 |
| 2026-08-26T16:38:00+08:00 | Firefox 无头截图因 SWGL 图形后端超时 | 1 | 使用软件渲染/Xvfb 再试一次,保留静态与计算布局降级路径 |
| 2026-08-26T16:48:00+08:00 | 完整回归 Codex App steer 场景超时 | 1 | 确认失败在既有 server/mock 集成链路;继续以定向回归和只读审查隔离本次变更 |
## 5 问恢复检查
| 问题 | 回答 |
|---|---|
| 当前在哪? | 阶段 1计划审查 |
| 下一步去哪? | 测试先行、实现、验证、交付 |
| 目标是什么? | 增加可记忆的 MCP 会话显示/隐藏图标按钮 |
| 已发现什么? | 见 findings.md |
| 已完成什么? | 见本文件阶段 1 日志 |

View File

@@ -0,0 +1,90 @@
# MCP 会话显示隐藏开关计划
## 目标
在侧栏搜索框右侧增加一个图标按钮,用于显示或隐藏由 MCP 创建的会话;正常会话始终可见,并完整覆盖状态记忆、搜索临时显示与运行态例外。
## 当前阶段
阶段 5交付
## 执行步骤
1. 审查需求与实现计划
2. 补充会话过滤与图标状态的失败回归测试
3. 实现持久化状态与 MCP 会话过滤逻辑
4. 接入搜索框右侧图标及主题响应式样式
5. 运行定向测试并修复问题
6. 执行完整回归与差异审查
7. 清理临时跟踪文件并记录交付
## 阶段
### 阶段 1需求与计划审查
- [x] 通过逐项访谈确认产品边界
- [x] 定位现有会话列表、搜索、分组和 MCP 标识逻辑
- [x] 完成独立计划审查
- **状态:** complete
### 阶段 2测试先行
- [x] 为过滤规则、状态例外、搜索覆盖和图标契约补充失败测试
- [x] 运行新增测试并确认修改前失败
- **状态:** complete
### 阶段 3实现
- [x] 增加本地持久化的显示状态
- [x] 在列表派生数据阶段过滤 MCP 会话
- [x] 接入无文字图标按钮、无障碍状态和主题样式
- **状态:** complete
### 阶段 4验证
- [x] 运行定向测试、语法检查与完整回归
- [x] 审查变更差异、工作区状态及项目规范符合性
- **状态:** complete
### 阶段 5交付
- [x] 更新持久计划与 Trellis 记录
- [x] 清理临时 TODO CSV
- **状态:** complete
## 已确认需求
- 过滤对象仅为 `createdFromKind === 'mcp'` 的会话,正常会话始终显示。
- 隐藏模式仍保留当前打开或 `isRunning` 的 MCP 会话;其他 MCP 会话隐藏。
- 搜索时临时恢复全部 MCP 会话参与匹配,清空搜索后恢复隐藏规则。
- 按钮位于搜索框右侧并与高级搜索并排,仅显示一个状态图标。
- 首次默认显示;用户切换结果保存在本机浏览器,刷新和下次打开继续沿用。
- 没有可见会话的项目分组不渲染。
## 技术决策
| 决策 | 理由 |
|---|---|
| 在 `getVisibleSessions()` 之后、搜索匹配之前派生列表可见性 | 保持服务端快照和缓存完整,只影响侧栏展示 |
| 搜索查询非空时绕过 MCP 隐藏过滤 | 满足主动搜索可以找回隐藏会话的需求 |
| 使用单一 `localStorage` 布尔偏好,缺失时按显示处理 | 与首次默认显示和跨刷新记忆一致 |
| 图标按钮维护 `aria-pressed`、动态 `title`/`aria-label` | 只有图标时仍能清晰表达当前状态和点击动作 |
| 将显示偏好纳入列表结构签名或切换时强制失效 | 避免现有结构签名优化跳过重新渲染 |
## 风险与验证重点
- 置顶 MCP 会话也应服从隐藏规则,除非它是当前会话或运行中。
- 当前会话从 MCP 状态切换后,侧栏不能丢失当前定位。
- 新快照把会话从运行中更新为已完成时,隐藏模式应自动移除该条目。
- 所有主题及窄屏布局不得挤压搜索输入框或高级搜索按钮。
- 不引入服务端状态或协议改动。
## 错误记录
| 错误 | 尝试 | 处理 |
|---|---:|---|
| 完整历史分叉不能同时指定 worker 类型 | 1 | 改为 `fork_turns: none` 并在任务中显式提供上下文 |
| `wait_agent` 等待值低于 10 秒下限 | 1 | 改用 10 秒以上等待值 |
| 高级搜索回归要求保留荒原主题精确选择器 | 1 | 恢复 `.session-search-row .advanced-search-open`,新按钮使用独立规则 |
| Firefox 无头渲染图形后端超时且未生成截图 | 1 | 改用软件渲染/Xvfb 再试一次,失败则降级到计算布局与静态契约 |
| 完整回归 Codex App steer 场景等待 WebSocket 消息超时 | 1 | 失败位于既有 server/mock 集成链路,与前端侧栏变更隔离;保留定向回归与审查证据 |

View File

@@ -0,0 +1,28 @@
# 重新打包并提交推送计划
## 目标
使用 CentOS 7 baseline Bun 重新生成 cc-web 单文件发布包,并将当前工作区全部修改提交到 `main` 后推送 `origin/main`
## 步骤
1. 确认当前分支、远端状态、工作区范围,并优先检查 `bun`PATH 中缺失时复用本机 `@oven/bun-linux-x64-baseline/bin/bun`
2. 使用 `BUN_BIN=<baseline bun> npm run build:single-exe` 重新构建 CentOS 7 baseline 发布包。
3. 运行发布技能要求的语法检查、归档列表与 MCP smokeMCP smoke 通过 stdin 发送 JSON-RPC `initialize`,设置短超时并确认 JSON 响应后退出。
4. 使用显式 pathspec 排除临时 TODO CSV 后执行 `git add -A`,再复查暂存区,确保其余当前修改全部纳入。
5. 提交本地变更。
6. 推送前执行 `git fetch origin main` 并核对 ahead/behind可直接推送时将 `main` 推送到 `origin/main`
## 约束
- 使用 `scripts/build-single-exe.js`,目标保持 `bun-linux-x64-baseline`
- 不切换 Docker不安装 NodeSource Node.js不把 Claude/Codex CLI 打入发布包。
- 用户明确要求不做代码审计;只执行打包技能要求的轻量校验、提交与推送。
- 所有当前工作区修改都纳入本次提交;临时 TODO CSV 在完成后删除,不提交。
- MCP smoke 使用单条 JSON-RPC `initialize` 请求和短超时,不能留下等待 stdin 的后台进程。
- `git add -A -- . ':!重新打包并提交推送 TO DO list.csv'` 后必须确认临时 CSV 仍未跟踪且不在暂存区。
## 状态
- 当前:步骤 1
- 计划审查:待完成

View File

@@ -0,0 +1,2 @@
{"file":".trellis/spec/frontend/index.md","reason":"前端任务入口与规范索引"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"验证前端质量与回归测试覆盖"}

View File

@@ -0,0 +1,4 @@
{"file":".trellis/spec/frontend/index.md","reason":"前端任务入口与规范索引"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"前端质量与回归测试要求"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"侧栏控件结构与无障碍约束参考"}
{"file":".trellis/spec/frontend/state-management.md","reason":"本地持久化显示状态的状态管理参考"}

View File

@@ -0,0 +1,38 @@
# MCP 会话显示隐藏开关
## 背景
侧栏会展示大量由 MCP 创建的协作会话。用户通常不需要逐条查看这些会话,希望通过一个紧凑按钮减少列表噪声,同时保留普通会话和仍需关注的运行态会话。
## 用户故事
作为经常使用 MCP 协作能力的用户,我希望用一个图标快速显示或隐藏 MCP 创建的会话,使侧栏保持简洁,并且不会丢失当前会话或仍在运行的任务入口。
## 功能要求
1. 在侧栏搜索框右侧、与高级搜索按钮并排增加一个图标按钮。
2. 按钮仅控制 `createdFromKind === 'mcp'` 的会话;正常会话始终显示。
3. 隐藏模式仍显示当前打开或 `isRunning === true` 的 MCP 会话。
4. 其他 MCP 会话在隐藏模式下不参与置顶区、项目分组和未分组列表渲染。
5. 输入会话搜索关键词时,全部 MCP 会话恢复参与匹配;清空搜索后重新应用隐藏规则。
6. 首次且没有保存偏好时默认显示 MCP 会话。
7. 用户切换结果保存于当前浏览器,刷新或下次打开继续沿用。
8. 控件仅显示一个状态图标,不显示文字或数量角标;通过悬停提示与无障碍属性表达动作和状态。
## 非目标
- 不删除、归档或修改任何会话数据。
- 不改变服务端会话列表接口、排序协议或 MCP 创建流程。
- 不改变项目折叠、置顶和旧会话“加载更多”的既有语义。
- 不增加设置页配置项。
## 验收标准
- 默认状态与当前行为一致:全部会话正常显示。
- 切换隐藏后,普通会话、当前 MCP 会话和运行中 MCP 会话仍显示,其余 MCP 会话消失。
- 置顶 MCP 会话在非当前、非运行时同样隐藏。
- 隐藏状态下搜索可找到匹配的 MCP 会话;清空搜索后再次隐藏。
- 刷新页面后保留最后一次切换状态。
- 图标按钮在主要主题和窄屏侧栏中不挤压或遮挡现有控件。
- 新增回归测试覆盖过滤、持久化、搜索覆盖、状态例外与 DOM/无障碍契约。

View File

@@ -0,0 +1,26 @@
{
"id": "mcp-session-visibility-toggle",
"name": "mcp-session-visibility-toggle",
"title": "MCP 会话显示隐藏开关",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "shiyue",
"assignee": "shiyue",
"createdAt": "2026-08-26",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -12,6 +12,7 @@
const CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY = 'cc-web-ccweb-prompt-view-mode';
const PROJECT_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-projects';
const SIDEBAR_COLLAPSE_STORAGE_KEY = 'cc-web-sidebar-collapsed';
const MCP_SESSION_VISIBILITY_STORAGE_KEY = 'cc-web-mcp-session-visibility';
const SIDEBAR_DRAWER_MEDIA_QUERY = '(max-width: 768px)';
const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px)';
const SIDEBAR_HOVER_PREVIEW_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)';
@@ -272,6 +273,7 @@
let isReloadingMcp = false;
const mcpStartupToastKeys = new Map();
let sessionSearchQuery = '';
let showMcpSessions = readMcpSessionVisibilityPreference();
const advancedSessionSearchState = {
open: false,
query: '',
@@ -365,6 +367,7 @@
const importSessionBtn = $('#import-session-btn');
const sessionSearchInput = $('#session-search-input');
const sessionSearchClear = $('#session-search-clear');
const mcpSessionVisibilityToggle = $('#mcp-session-visibility-toggle');
const advancedSearchOpen = $('#advanced-search-open');
const advancedSearchPanel = $('#advanced-search-panel');
const advancedSearchClose = $('#advanced-search-close');
@@ -4521,6 +4524,39 @@
}
}
function readMcpSessionVisibilityPreference() {
try {
return localStorage.getItem(MCP_SESSION_VISIBILITY_STORAGE_KEY) !== 'false';
} catch {
return true;
}
}
function persistMcpSessionVisibilityPreference(visible) {
try {
localStorage.setItem(MCP_SESSION_VISIBILITY_STORAGE_KEY, visible ? 'true' : 'false');
} catch {
// 存储不可用时仍保留当前页面内的筛选状态。
}
}
function sessionPassesMcpVisibilityFilter(session, normalizedSearchQuery = '') {
if (normalizedSearchQuery) return true;
if (showMcpSessions) return true;
const isMcpCreated = String(session?.createdFromKind || '').toLowerCase() === 'mcp';
if (!isMcpCreated) return true;
return session.id === currentSessionId || !!session.isRunning;
}
function syncMcpSessionVisibilityToggle() {
if (!mcpSessionVisibilityToggle) return;
const isHidingMcpSessions = !showMcpSessions;
const actionLabel = isHidingMcpSessions ? '显示 MCP 会话' : '隐藏 MCP 会话';
mcpSessionVisibilityToggle.setAttribute('aria-pressed', String(isHidingMcpSessions));
mcpSessionVisibilityToggle.title = actionLabel;
mcpSessionVisibilityToggle.setAttribute('aria-label', actionLabel);
}
function getSessionSearchText(session) {
const cwd = getSessionEffectiveCwd(session);
return [
@@ -10856,6 +10892,7 @@
return JSON.stringify({
agent: currentAgent,
currentSessionId,
showMcpSessions,
search: model.normalizedSearchQuery,
allEmpty: model.allVisibleSessions.length === 0,
visibleEmpty: model.visibleSessions.length === 0,
@@ -10890,12 +10927,16 @@
function renderSessionList() {
syncSessionSearchUi();
syncMcpSessionVisibilityToggle();
const allVisibleSessions = getVisibleSessions();
const normalizedSearchQuery = normalizeSessionSearchQuery(sessionSearchQuery);
const isSearchingSessions = !!normalizedSearchQuery;
const mcpVisibilityFilteredSessions = allVisibleSessions.filter((session) => (
sessionPassesMcpVisibilityFilter(session, normalizedSearchQuery)
));
const visibleSessions = isSearchingSessions
? allVisibleSessions.filter((session) => sessionMatchesSearch(session, normalizedSearchQuery))
: allVisibleSessions;
? mcpVisibilityFilteredSessions.filter((session) => sessionMatchesSearch(session, normalizedSearchQuery))
: mcpVisibilityFilteredSessions;
const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions);
const renderGroups = projectGroups.map((group, groupIndex) => {
@@ -10951,7 +10992,7 @@
if (visibleSessions.length === 0) {
const empty = document.createElement('div');
empty.className = 'session-list-empty';
empty.textContent = '没有匹配的会话或项目。';
empty.textContent = isSearchingSessions ? '没有匹配的会话或项目。' : 'MCP 会话已隐藏。';
sessionList.appendChild(empty);
lastSessionListStructureSignature = structureSignature;
return;
@@ -11821,6 +11862,14 @@
});
}
if (mcpSessionVisibilityToggle) {
mcpSessionVisibilityToggle.addEventListener('click', () => {
showMcpSessions = !showMcpSessions;
persistMcpSessionVisibilityPreference(showMcpSessions);
renderSessionList();
});
}
if (advancedSearchOpen && advancedSearchPanel) {
advancedSearchOpen.addEventListener('click', openAdvancedSessionSearch);
advancedSearchClose?.addEventListener('click', () => closeAdvancedSessionSearch());

View File

@@ -63,6 +63,13 @@
<input id="session-search-input" class="session-search-input" type="search" placeholder="检索会话 / 项目" autocomplete="off" aria-label="检索会话或项目">
<button id="session-search-clear" class="session-search-clear" type="button" title="清空检索" aria-label="清空检索" hidden>×</button>
</div>
<button id="mcp-session-visibility-toggle" class="mcp-session-visibility-toggle icon-only" type="button" title="隐藏 MCP 会话" aria-label="隐藏 MCP 会话" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"></path>
<circle cx="12" cy="12" r="2.5"></circle>
<path class="mcp-session-visibility-slash" d="m4 4 16 16"></path>
</svg>
</button>
<button id="advanced-search-open" class="advanced-search-open" type="button" title="高级会话检索" aria-label="打开高级会话检索" aria-controls="advanced-search-panel" aria-expanded="false"></button>
</div>
</div>

View File

@@ -1252,11 +1252,13 @@ body.session-loading-active {
color: var(--text-primary);
outline: none;
}
.advanced-search-open {
.advanced-search-open,
.mcp-session-visibility-toggle {
appearance: none;
width: 34px;
height: 34px;
flex: 0 0 34px;
flex-basis: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1277,6 +1279,29 @@ body.session-loading-active {
transform 0.16s ease,
box-shadow 0.16s ease;
}
.mcp-session-visibility-toggle svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.mcp-session-visibility-slash {
opacity: 0;
transition: opacity 0.16s ease;
}
.mcp-session-visibility-toggle[aria-pressed='true'] {
background: var(--accent-light);
border-color: rgba(192, 85, 58, 0.34);
color: var(--accent);
}
.mcp-session-visibility-toggle[aria-pressed='true'] .mcp-session-visibility-slash {
opacity: 1;
}
.mcp-session-visibility-toggle:hover,
.mcp-session-visibility-toggle:focus-visible,
.advanced-search-open:hover,
.advanced-search-open:focus-visible {
background: var(--accent-light);
@@ -1285,6 +1310,7 @@ body.session-loading-active {
outline: none;
box-shadow: 0 0 0 3px rgba(192, 85, 58, 0.1);
}
.mcp-session-visibility-toggle:active,
.advanced-search-open:active {
transform: translateY(1px);
}
@@ -2137,7 +2163,7 @@ body.session-loading-active {
html[data-theme='cinder'],
html[data-theme='gilded'],
html[data-theme='wasteland']
) .advanced-search-open {
) :is(.advanced-search-open, .mcp-session-visibility-toggle) {
background: var(--dark-panel-soft);
border-color: var(--theme-card-border);
color: var(--text-secondary);
@@ -2149,14 +2175,14 @@ body.session-loading-active {
html[data-theme='cinder'],
html[data-theme='gilded'],
html[data-theme='wasteland']
) .advanced-search-open:hover,
) :is(.advanced-search-open, .mcp-session-visibility-toggle):hover,
:is(
html[data-theme='carbon'],
html[data-theme='nocturne'],
html[data-theme='cinder'],
html[data-theme='gilded'],
html[data-theme='wasteland']
) .advanced-search-open:focus-visible {
) :is(.advanced-search-open, .mcp-session-visibility-toggle):focus-visible {
background: var(--accent-light);
border-color: var(--theme-card-hover-border);
color: var(--accent);
@@ -2192,6 +2218,15 @@ html[data-theme='wasteland'] .advanced-search-open {
clip-path: var(--wasteland-cut);
}
html[data-theme='wasteland'] .mcp-session-visibility-toggle {
width: 36px;
height: 36px;
flex-basis: 36px;
border-color: var(--wasteland-line);
border-radius: 0;
clip-path: var(--wasteland-cut);
}
html[data-theme='wasteland'] .advanced-search-panel {
--advanced-search-bg: #151715;
--advanced-search-line: var(--wasteland-line);
@@ -2309,6 +2344,7 @@ html[data-theme='wasteland'] .advanced-search-panel {
@media (prefers-reduced-motion: reduce) {
.advanced-search-open,
.mcp-session-visibility-toggle,
.advanced-search-close,
.advanced-search-submit,
.advanced-search-pill {
@@ -9502,6 +9538,12 @@ html[data-theme='wasteland'] .session-search-row .advanced-search-open {
flex-basis: 36px;
}
html[data-theme='wasteland'] .session-search-row .mcp-session-visibility-toggle {
width: 36px;
height: 36px;
flex-basis: 36px;
}
html[data-theme='wasteland'] .session-search::after {
inset: 0;
background-image: url('assets/themes/wasteland/frames/search.png');

View File

@@ -5377,6 +5377,120 @@ function assertMultiAgentV2CompatibilityContract() {
assert(frontendSource.includes('entry.agentPath ? `路径: ${entry.agentPath}`'), 'Sub-agent cards should expose the canonical agent path');
}
function assertMcpSessionVisibilityContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const toggleMarkup = indexSource.match(/<button\b[^>]*\bid="mcp-session-visibility-toggle"[^>]*>/)?.[0] || '';
assert(toggleMarkup, 'MCP-created session visibility toggle should exist as a stable sidebar button');
[
'type="button"',
'title=',
'aria-label=',
'aria-pressed="false"',
].forEach((attribute) => {
assert(toggleMarkup.includes(attribute), `MCP-created session visibility toggle should expose ${attribute}`);
});
assert(/\bclass="[^"]*\bicon-only\b[^"]*"/.test(toggleMarkup), 'MCP-created session visibility toggle should be an icon-only button');
const searchRowIndex = indexSource.indexOf('class="session-search-row"');
const toggleIndex = indexSource.indexOf('id="mcp-session-visibility-toggle"');
const sessionListIndex = indexSource.indexOf('id="session-list"');
assert(
searchRowIndex >= 0 && toggleIndex > searchRowIndex && sessionListIndex > toggleIndex,
'MCP-created session visibility toggle should stay in the sidebar search controls before the session list'
);
assert(
/MCP_SESSION_VISIBILITY_STORAGE_KEY\s*=\s*['"][^'"]*mcp[^'"]*session[^'"]*visibility[^'"]*['"]/.test(frontendSource),
'MCP-created session visibility should use a stable local persistence key'
);
assert(
/localStorage\.getItem\(MCP_SESSION_VISIBILITY_STORAGE_KEY\)/.test(frontendSource)
&& /localStorage\.setItem\(MCP_SESSION_VISIBILITY_STORAGE_KEY/.test(frontendSource),
'MCP-created session visibility preference should round-trip through localStorage'
);
const readPreferenceBlock = extractFunctionSource(frontendSource, 'readMcpSessionVisibilityPreference');
const createReadPreference = new Function(
'localStorage',
'MCP_SESSION_VISIBILITY_STORAGE_KEY',
`${readPreferenceBlock}; return readMcpSessionVisibilityPreference;`
);
assert(createReadPreference({ getItem: () => null }, 'test-key')() === true,
'MCP-created sessions should remain visible when no preference has been stored');
assert(createReadPreference({ getItem: () => 'false' }, 'test-key')() === false,
'Stored hidden preference should restore hidden MCP-created sessions');
assert(createReadPreference({ getItem: () => { throw new Error('blocked'); } }, 'test-key')() === true,
'Unavailable localStorage should conservatively keep MCP-created sessions visible');
assert(frontendSource.includes('const mcpSessionVisibilityToggle = $(\'#mcp-session-visibility-toggle\')'),
'Frontend should bind the MCP-created session visibility toggle by its stable id');
assert(
/mcpSessionVisibilityToggle\.setAttribute\('aria-pressed',\s*[^)]*\)/.test(frontendSource),
'MCP-created session visibility toggle should synchronize aria-pressed'
);
const filterBlock = extractFunctionSource(frontendSource, 'sessionPassesMcpVisibilityFilter');
assert(
filterBlock.includes('normalizedSearchQuery')
&& /if\s*\(\s*normalizedSearchQuery\s*\)\s*return true;/.test(filterBlock),
'Non-empty sidebar search should bypass the MCP-created session visibility filter'
);
assert(
/createdFromKind[\s\S]*['"]mcp['"]/.test(filterBlock),
'MCP-created session visibility filter should detect sessions createdFromKind=mcp'
);
assert(
filterBlock.includes('session.id === currentSessionId') && filterBlock.includes('session.isRunning'),
'MCP-created session visibility filter should keep the current session and running sessions visible'
);
const createVisibilityFilter = new Function(
'showMcpSessions',
'currentSessionId',
`${filterBlock}; return sessionPassesMcpVisibilityFilter;`
);
const hiddenFilter = createVisibilityFilter(false, 'mcp-current');
const visibleFilter = createVisibilityFilter(true, 'mcp-current');
const regularSession = { id: 'regular', createdFromKind: 'user', isRunning: false };
const hiddenMcpSession = { id: 'mcp-hidden', createdFromKind: 'mcp', isRunning: false };
const pinnedMcpSession = { ...hiddenMcpSession, id: 'mcp-pinned', pinnedAt: '2026-08-26T00:00:00.000Z' };
const currentMcpSession = { ...hiddenMcpSession, id: 'mcp-current' };
const runningMcpSession = { ...hiddenMcpSession, id: 'mcp-running', isRunning: true };
assert(hiddenFilter(regularSession, '') === true, 'Normal sessions should stay visible while MCP-created sessions are hidden');
assert(hiddenFilter(hiddenMcpSession, '') === false, 'Inactive MCP-created sessions should be hidden');
assert(hiddenFilter(pinnedMcpSession, '') === false, 'Pinned MCP-created sessions should still be hidden when inactive');
assert(hiddenFilter(currentMcpSession, '') === true, 'Current MCP-created session should stay visible');
assert(hiddenFilter(runningMcpSession, '') === true, 'Running MCP-created session should stay visible');
assert(hiddenFilter(hiddenMcpSession, '主动查找') === true, 'Search should temporarily restore matching MCP-created sessions');
assert(visibleFilter(hiddenMcpSession, '') === true, 'Visible preference should keep every MCP-created session visible');
const toggleBindingStart = frontendSource.indexOf('if (mcpSessionVisibilityToggle) {');
const toggleBindingEnd = frontendSource.indexOf('\n }', toggleBindingStart);
const toggleBindingBlock = frontendSource.slice(toggleBindingStart, toggleBindingEnd);
assert(toggleBindingStart >= 0
&& toggleBindingBlock.includes("addEventListener('click'")
&& toggleBindingBlock.includes('persistMcpSessionVisibilityPreference(showMcpSessions)')
&& toggleBindingBlock.includes('renderSessionList()'),
'MCP-created session visibility toggle should persist the new preference and rerender the sidebar');
const renderSessionListBlock = extractFunctionSource(frontendSource, 'renderSessionList');
assert(
renderSessionListBlock.includes('sessionPassesMcpVisibilityFilter(session, normalizedSearchQuery)'),
'Session list rendering should apply the MCP-created session visibility filter with the current search query'
);
const baseToggleStyle = styleSource.match(/\.mcp-session-visibility-toggle\s*\{[^}]*\}/)?.[0] || '';
assert(
/width:\s*34px;/.test(baseToggleStyle)
&& /height:\s*34px;/.test(baseToggleStyle)
&& /flex-basis:\s*34px;/.test(baseToggleStyle),
'Base MCP-created session visibility toggle should keep a stable 34px square footprint'
);
assert(
/html\[data-theme='wasteland'\] \.mcp-session-visibility-toggle\s*\{[^}]*width:\s*36px;[^}]*height:\s*36px;[^}]*flex-basis:\s*36px;/.test(styleSource),
'Wasteland MCP-created session visibility toggle should align to the 36px search row controls'
);
}
function assertAdvancedSessionSearchContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
@@ -6352,6 +6466,11 @@ async function main() {
console.log('Advanced session search regression checks passed.');
return;
}
if (regressionTarget === 'mcp-session-visibility') {
assertMcpSessionVisibilityContract();
console.log('MCP session visibility regression checks passed.');
return;
}
if (regressionTarget === 'session-preview-metadata') {
assertCcwebListConversationsScopeContract();
await runSessionPreviewMetadataRegression();
@@ -6425,6 +6544,7 @@ async function main() {
assertSessionSwitchResilienceContract();
assertCodexAppGoalLifecycleContract();
assertSessionSwitchRaceContract();
assertMcpSessionVisibilityContract();
assertAdvancedSessionSearchContract();
await assertAdvancedSearchTimeOrderingContract();
assertUsageStatisticsUnitChecks();