chore: rebuild release package
This commit is contained in:
7
.planning/session-item-title-tooltip/findings.md
Normal file
7
.planning/session-item-title-tooltip/findings.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 会话卡片完整标题悬停提示:发现
|
||||
|
||||
- `createSessionListItem()` 创建侧边栏会话卡片。
|
||||
- 当前实现仅在存在有效工作目录时执行 `item.title = sessionCwd`,因此浏览器原生提示只显示项目路径。
|
||||
- 卡片正文的 `.session-item-title` 会因布局宽度省略,完整 `session.title` 没有进入可访问提示。
|
||||
- `getSessionProjectName(session)` 已统一优先读取 `session.projectName`,否则从有效 cwd 取末级目录,适合作为提示中的项目名来源。
|
||||
- 推荐提示格式:`项目:<项目名>\n标题:<完整标题>`;若缺少项目名,则只显示标题行。
|
||||
18
.planning/session-item-title-tooltip/progress.md
Normal file
18
.planning/session-item-title-tooltip/progress.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 会话卡片完整标题悬停提示:进度
|
||||
|
||||
## 2026-07-27
|
||||
|
||||
- 已恢复当前用户意图,排除根目录旧 CSV 与已完成标题历史任务的干扰。
|
||||
- 已使用 codebase-memory 确认索引 ready,并定位 `createSessionListItem()` 与 `getSessionProjectName()`。
|
||||
- 已确认根因是卡片 `title` 属性仅使用 cwd,完整会话标题未进入悬停提示。
|
||||
- 已新增 `session-item-tooltip` 专项回归,首次运行在缺少 `buildSessionItemTooltip` 处按预期失败。
|
||||
- 回归断言阶段完成,进入最小实现。
|
||||
- 已实现两行悬停提示:项目名复用统一解析逻辑,标题使用完整原文并保留无项目兜底。
|
||||
- 已更新前端缓存版本;`session-item-tooltip` 专项回归通过。
|
||||
- 进入全量验证与最终影响面审查。
|
||||
- `node --check`(app/regression/server)、`git diff --check`、标题历史专项和 tooltip 专项全部通过。
|
||||
- 完整 `npm run regression` 通过并输出 `Regression checks passed.`。
|
||||
- 独立只读审查确认根因、实现位置和回归覆盖一致,没有发现需要扩大的改动。
|
||||
- PM2 显示 ccweb online,但本地静态页请求超时;随后运行中会话查询也超时,无法满足安全重启前置条件,因此未重启。
|
||||
- 最终代码审查确认卡片提示固定为两行“项目 + 完整标题”,无项目时仍完整显示标题;缓存版本已同步。
|
||||
- 任务完成,删除临时 TODO CSV,保留 scoped planning 记录。
|
||||
29
.planning/session-item-title-tooltip/task_plan.md
Normal file
29
.planning/session-item-title-tooltip/task_plan.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 会话卡片完整标题悬停提示
|
||||
|
||||
## 目标
|
||||
|
||||
当侧边栏会话标题因宽度不足被省略时,悬停会话卡片应同时显示项目名和完整会话标题,不能只显示项目路径。
|
||||
|
||||
## 阶段
|
||||
|
||||
| 阶段 | 状态 | 验收标准 |
|
||||
|---|---|---|
|
||||
| 1. 定位悬停提示与项目名来源 | complete | 找到会话卡片 DOM 构建函数及现有 title 属性来源 |
|
||||
| 2. 补充回归断言 | complete | 测试明确要求提示包含项目名和完整标题 |
|
||||
| 3. 实现完整悬停提示 | complete | 卡片 title 使用两行“项目 + 标题”,标题不截断 |
|
||||
| 4. 验证与审查 | complete | 专项、全量回归、语法和 diff 检查通过 |
|
||||
|
||||
## 约束
|
||||
|
||||
- 不改变侧边栏会话卡片的现有布局和截断行为。
|
||||
- 使用会话已有的项目名解析逻辑,避免重复路径解析规则。
|
||||
- 保留工作区内其他任务的未提交改动。
|
||||
- 未确认没有其他 running 会话前不重启 ccweb。
|
||||
|
||||
## 错误记录
|
||||
|
||||
| 错误 | 尝试 | 处理 |
|
||||
|---|---|---|
|
||||
| 专项回归因缺少 `buildSessionItemTooltip` 失败 | 1 | 失败符合 TDD 预期,进入最小实现 |
|
||||
| `curl http://127.0.0.1:8002/` 3 秒无响应 | 1 | PM2 显示服务 online;不重复相同失败,转为检查重启安全条件 |
|
||||
| `ccweb_list_conversations(status=running)` 60 秒超时 | 1 | 无法证明当前之外没有其他 running 会话,按仓库约束不重启服务 |
|
||||
63
.planning/title-history-locator/findings.md
Normal file
63
.planning/title-history-locator/findings.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 标题历史定位时间线:发现记录
|
||||
|
||||
## 用户意图
|
||||
|
||||
- “定位”弹层当前只列可跳转的消息步骤。
|
||||
- 左侧会话标题由 LLM 通过标题 MCP 修改;每次实际修改都要保留历史。
|
||||
- 定位弹层需要插入标题变更节点,用于标识主题变化发生的位置。
|
||||
- 标题节点只读、不能选中或触发滚动定位,但需要在视觉上明显区别于消息项。
|
||||
- 定位弹层还要按消息时间显示日期分隔节点,参考横线中间承载日期的气泡样式。
|
||||
- 日期仅显示到天(`YYYY-MM-DD`),不显示截图示例中的时分秒;日期节点同样不可点击、不编号。
|
||||
- 计划审查确认日期时区必须明确:采用浏览器当前用户本地时区,与消息时间的前端展示语义保持一致。
|
||||
- 日期视觉明确为两侧横线、中间承载 `YYYY-MM-DD` 的气泡;测试覆盖本地 23:59/00:01 边界。
|
||||
|
||||
## 工作区与流程约束
|
||||
|
||||
- 当前 Trellis 指向另一个仍在进行的主题任务,不能擅自切换或覆盖。
|
||||
- 根目录已有旧的 `task_plan.md`、`findings.md`、`progress.md`,因此本任务使用 `.planning/title-history-locator/` 隔离。
|
||||
- 根目录已有他人未跟踪的 `修复会话切换表单不渲染 TO DO list.csv`,必须保留。
|
||||
- 项目约定:代码理解先用 `codebase-memory-mcp`,`rg/sed` 仅做行号与文本校验;禁止使用 graphify。
|
||||
- `codebase-memory-mcp` 的 `home-cc-web` 索引状态为 `ready`(4093 nodes / 8699 edges)。
|
||||
- Trellis 前后端规范入口及相关组件/数据库规范仍是占位模板,没有额外的项目专属实现约束。
|
||||
|
||||
## 待确认的技术事实
|
||||
|
||||
- `ccweb_set_title` 的后端实现、标题锁定与会话持久化结构。
|
||||
- “定位”按钮和弹层的前端构建函数、消息序号、消息时间和点击逻辑。
|
||||
- 会话详情接口是否已有适合承载时间线元数据的字段。
|
||||
- 现有回归脚本中标题 MCP 与定位列表相关 target。
|
||||
|
||||
## 已定位的实现链路
|
||||
|
||||
### 标题 MCP
|
||||
|
||||
- 入口是 `server.js` 的 `callInternalMcpTool()`,`ccweb_set_title` 路由到 `setCurrentConversationTitle()`。
|
||||
- `setCurrentConversationTitle()` 会加载会话、拒绝空标题、尊重 `titleSource === 'manual'` 锁定,并在实际处理后保存 `session.title/titleSource`、发送 `session_renamed`、广播列表。
|
||||
- 当前成功路径即使标题未变化也会保存并广播;历史事件必须只在 `changed === true` 时追加,避免重复标题伪事件。
|
||||
- 手动 UI 改名走 `handleRenameSession()`,写入 `titleSource = 'manual'`;本需求只要求记录 LLM 调用实际生效的标题,因此不把手动改名写入该事件历史。
|
||||
- 现有集成回归已经覆盖 MCP 成功、空标题、手动锁定和 `updated` 排序时间不变,适合直接扩展标题历史断言。
|
||||
|
||||
### 定位弹层
|
||||
|
||||
- `public/app.js` 的 `buildUserOutlineItems()` 当前从已渲染的 `.msg.user[data-message-id]` DOM 构建纯用户消息数组,通过 `userMessageIndex` 取完整内容。
|
||||
- `updateUserOutlinePanel()` 当前把所有项渲染成可点击 `<button class="user-outline-item">`,并以数组位置生成 1-based 编号。
|
||||
- 新实现需要把数据模型扩展为 `message/title/date` 三种 item;只有 `message` 渲染成按钮并计数,`title/date` 使用非交互 DOM。
|
||||
- 标题事件需要带能与对话顺序合并的锚点;仅有时间戳在同毫秒或历史回放时不够稳,宜在后端记录 `messageIndex`(标题调用发生时已有消息数量)并以时间戳作为辅助信息。
|
||||
- 日期可直接来自用户消息索引中的原始 `timestamp`,按浏览器本地年月日格式化;不需要后端新增日期字段。
|
||||
|
||||
### 会话传输与视觉复用
|
||||
|
||||
- 会话由 `normalizeSession()` 统一归一化、`sanitizeSessionForPersist()` 自动保留受限的顶层字段;新增标题历史需要在 `normalizeSession()` 做类型/长度清洗,避免旧文件或异常数据直接进入前端。
|
||||
- `handleLoadSession()` 的 `session_info` 当前显式列出返回字段,因此必须显式附带标题历史;前端 `normalizeSessionSnapshot()` 也要克隆该字段,才能进入 session cache。
|
||||
- 实时 `session_renamed` 事件目前只同步当前标题元数据;应同时携带本次标题事件,前端追加到当前/缓存 snapshot 并立即刷新已打开的定位弹层。
|
||||
- 每个消息 DOM 已由 `markSessionMessageElement()` 标注全局 session message index,可用于标题事件与用户消息的稳定顺序合并;分批加载旧历史后 `updateUserOutlinePanel()` 会重建列表。
|
||||
- 项目已有 `.agent-message-divider` 的“两侧横线 + 中间等宽时间”视觉,日期项可复用其构图语言,但使用定位弹层专属类,且不受“隐藏代理分隔时间”偏好影响。
|
||||
- 点击委托只匹配 `.user-outline-item`,因此标题/日期节点只要不使用该 class 和 `<button>`,天然不会触发定位;仍需回归锁定这一契约。
|
||||
- 现有 `assertSetTitleMcpContract()` 适合增加静态/纯函数契约,集成主流程可断言标题历史只在 changed 时追加、锁定时不追加、session_info 可恢复。
|
||||
|
||||
### 并行后端审计结论与取舍
|
||||
|
||||
- 独立审计确认顶层 `titleHistory` 是最小、兼容性最好的持久化位置;不应塞进 `messages`,也不应改造 `titleSource` 的手动锁语义。
|
||||
- 通用持久化 sanitizer 会保留顶层数组,但可能按通用上限从头截断,因此历史 helper 必须主动只保留最近 100 条。
|
||||
- 审计建议扩展记录 manual/system/derived、unchanged 和 ignored 尝试;本任务不采纳。用户要看的是“标题在哪一步变了”,因此只写入 `ccweb_set_title` 且 `changed === true` 的实际 LLM 标题变化,避免假主题节点与范围膨胀。
|
||||
- MCP 标题历史写入不得修改 `session.updated`,继续保持现有“改标题不改变会话排序”的契约。
|
||||
31
.planning/title-history-locator/progress.md
Normal file
31
.planning/title-history-locator/progress.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 标题历史定位时间线:进度
|
||||
|
||||
## 2026-07-27
|
||||
|
||||
- 已读取 `planning-with-files` 与 `todo-list-csv` 技能全文。
|
||||
- 已运行 session catchup;未发现需要接管的未同步计划。
|
||||
- 已检查 Trellis 工作流、当前任务与工作区状态;为避免干扰并行工作,采用独立 scoped planning 目录。
|
||||
- 已确认用户给出的核心交互:标题历史进入定位列表,但标题项不可点击。
|
||||
- 用户补充日期时间线:按天显示横线气泡,只显示日期,不显示时分秒;同样为只读节点。
|
||||
- 第一次独立计划审查要求明确时区、横线气泡形态与跨天边界;已按“浏览器用户本地时区 + 本地 23:59/00:01 回归”修订。
|
||||
- `home-cc-web` 代码索引已确认 ready;Trellis 相关规范目前未提供额外项目约束。
|
||||
- 第二次独立计划审查已通过;阶段 1 完成,进入前后端链路定位。
|
||||
- codebase-memory 已定位 `setCurrentConversationTitle()`、`handleRenameSession()`、`buildUserOutlineItems()`、`updateUserOutlinePanel()` 及现有 `assertSetTitleMcpContract()` 回归入口。
|
||||
- 初步数据设计:会话新增标题事件数组;事件包含标题、时间戳和调用时消息索引,前端与用户消息按索引合并;日期从用户消息时间派生。
|
||||
- 已确认 session_info、snapshot cache、实时 session_renamed 和分批历史渲染都需要同步标题历史;定位节点可复用既有 agent divider 的横线视觉。
|
||||
- 阶段 2 完成:确定使用 `titleHistory[{ title, changedAt, messageIndex }]`;`messageIndex` 表示事件发生前已持久化的消息数量,时间戳负责日期与异常回退。
|
||||
- 进入阶段 3:先补纯函数、静态契约与 MCP 集成失败回归,再写实现。
|
||||
- 并行后端审计完成并与本地结论一致;明确拒绝把未生效/手动/系统标题写入本次定位时间线。
|
||||
- 已新增 `title-history-outline` 专项回归和全量集成断言;首次运行在缺少 `normalizeTitleHistory` 处按预期失败(exit 1)。
|
||||
- 阶段 3 完成,进入后端标题事件持久化与 session_info/实时事件传输实现。
|
||||
- 后端已实现事件清洗、最近 100 条裁剪、仅 changed 追加、session_info 恢复与实时 `titleEvent` 推送;专项回归已越过后端断言并按预期停在缺少前端 helper。
|
||||
- 阶段 4 完成,进入定位列表标题/日期时间线与样式实现。
|
||||
- 前端已实现浏览器本地日期格式化、消息/标题稳定合并、只读 DOM、横线日期气泡、实时事件刷新与 snapshot cache 恢复。
|
||||
- `node --check`(server/app/regression)和 `title-history-outline` 专项回归通过;阶段 5 完成,进入全量集成验证。
|
||||
- 全量 `npm run regression` 通过并输出 `Regression checks passed.`;再次运行语法检查与 `git diff --check` 均为 exit 0。
|
||||
- 运行会话 MCP 查询超时;本机降级检查发现除当前 `91b7...` 外,另有 `745d...` 会话于 15:38 收到用户消息且常驻服务高负载,无法证明无其他 running 会话,因此未重启 ccweb。
|
||||
- codebase-memory 变更影响检测 transport closed;此前函数级检索结果和本地全量回归有效,已记录但不重复失败调用。
|
||||
- 阶段 6 完成,进入最终代码审查与临时跟踪清理。
|
||||
- 最终 diff 审查确认:标题/日期输出均转义,只有消息节点使用按钮与 `data-target`;旧会话默认空历史;MCP unchanged/invalid/manual-lock 均不产生伪事件;缓存版本已同步。
|
||||
- 最终状态:7 个阶段全部完成;保留 scoped planning 记录,删除本任务 TODO CSV。服务重启因另一条活跃会话被明确暂缓。
|
||||
- 下一步:审查计划,并通过 codebase-memory 定位前后端链路。
|
||||
36
.planning/title-history-locator/task_plan.md
Normal file
36
.planning/title-history-locator/task_plan.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 标题历史定位时间线
|
||||
|
||||
## 目标
|
||||
|
||||
当 LLM 通过 `ccweb_set_title` 修改会话标题时,持久化标题变更事件,并在“定位”列表中按对话顺序显示不可点击的标题分隔项;同时按消息时间插入精确到天的日期分隔项,让用户看出主题在哪一步变化以及对话发生在哪一天。
|
||||
|
||||
## 阶段
|
||||
|
||||
| 阶段 | 状态 | 验收标准 |
|
||||
|---|---|---|
|
||||
| 1. 建立计划并确认需求边界 | complete | 计划、发现、进度和 TODO CSV 已建立,且不覆盖现有任务文件 |
|
||||
| 2. 定位标题 MCP、消息存储与定位弹层链路 | complete | 找到精确入口、数据流、现有测试和兼容边界 |
|
||||
| 3. 补充标题历史及日期定位展示回归 | complete | 新断言覆盖持久化、接口返回、只读展示、历史兼容及浏览器本地 23:59/00:01 跨天边界 |
|
||||
| 4. 实现标题变更事件持久化与读取 | complete | 成功改名时写入事件,忽略/失败不写,旧数据可读取 |
|
||||
| 5. 实现定位列表标题与日期时间线展示 | complete | 标题项按步骤排序;日期按天去重并呈现横线中置日期气泡;两类节点均不可点击 |
|
||||
| 6. 运行专项与全量验证 | complete | 语法、专项回归、全量回归和 diff 检查通过 |
|
||||
| 7. 审查改动并清理临时跟踪文件 | complete | 代码审查通过,TODO CSV 删除,计划与进度收口 |
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 只记录由标题 MCP 实际生效的变更;用户手动锁定导致的 ignored 不产生伪历史。
|
||||
- 标题历史是时间线元数据,不伪装成普通对话消息,不参与消息定位选中。
|
||||
- 日期节点只显示 `YYYY-MM-DD`,按浏览器当前用户本地时区分组,与现有消息时间的前端展示语义一致,不显示时分秒。
|
||||
- 日期节点采用两侧横线、中间日期气泡的分隔样式;本地时间从 23:59 跨到 00:01 时必须产生新日期节点。
|
||||
- 兼容已有会话数据;历史中没有标题事件时维持当前行为。
|
||||
- 不覆盖工作区内与本任务无关的未提交文件和规划状态。
|
||||
- 测试命令最长 60 秒;未确认无其他 running 会话前不重启服务。
|
||||
|
||||
## 错误记录
|
||||
|
||||
| 错误 | 尝试 | 处理 |
|
||||
|---|---|---|
|
||||
| 标题历史专项回归因缺少 `normalizeTitleHistory` 失败 | 1 | 失败符合 TDD 预期,进入最小后端实现 |
|
||||
| 后端实现后专项回归因缺少前端 `normalizeOutlineTitleHistory` 失败 | 1 | 后端纯函数契约已通过,进入前端实现 |
|
||||
| `ccweb_list_conversations(status=running)` 60 秒超时 | 1 | 降级检查 PM2、端口、近期会话文件;发现另一条 15:38 收到用户消息的 Codex App 会话,按约束暂缓重启 |
|
||||
| `codebase-memory-mcp detect_changes` 返回 transport closed | 1 | 不重复同一失败,使用已完成的函数级检索、全量回归与本地 diff 审查收口 |
|
||||
Binary file not shown.
155
public/app.js
155
public/app.js
@@ -226,6 +226,7 @@
|
||||
let loginPasswordValue = ''; // store login password for force-change flow
|
||||
let currentCwd = null;
|
||||
let currentSessionMessageCount = 0;
|
||||
let currentOutlineTitleHistory = [];
|
||||
let currentSessionRunning = false;
|
||||
let fileBrowserState = null;
|
||||
let directoryPickerState = null;
|
||||
@@ -1452,15 +1453,97 @@
|
||||
userMessageIndex.clear();
|
||||
}
|
||||
|
||||
function registerUserMessage(messageId, element, content) {
|
||||
function registerUserMessage(messageId, element, content, timestamp = '') {
|
||||
if (!messageId || !element) return;
|
||||
const previous = userMessageIndex.get(messageId);
|
||||
userMessageIndex.set(messageId, {
|
||||
id: messageId,
|
||||
element,
|
||||
content: String(content || ''),
|
||||
timestamp: String(timestamp || previous?.timestamp || ''),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOutlineTitleHistory(history) {
|
||||
if (!Array.isArray(history)) return [];
|
||||
const normalized = [];
|
||||
history.forEach((event) => {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
const title = String(event.title || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
const changedAtMs = Date.parse(String(event.changedAt || ''));
|
||||
const messageIndex = Number(event.messageIndex);
|
||||
const source = String(event.source || '').trim().toLowerCase();
|
||||
if (!title || !Number.isFinite(changedAtMs) || !Number.isFinite(messageIndex) || messageIndex < 0 || source !== 'llm') return;
|
||||
normalized.push({
|
||||
title,
|
||||
changedAt: new Date(changedAtMs).toISOString(),
|
||||
messageIndex: Math.trunc(messageIndex),
|
||||
source: 'llm',
|
||||
});
|
||||
});
|
||||
return normalized.slice(-100);
|
||||
}
|
||||
|
||||
function formatUserOutlineDate(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function buildUserOutlineTimelineItems(messageItems, titleHistory = []) {
|
||||
const timeline = [];
|
||||
(Array.isArray(messageItems) ? messageItems : []).forEach((message, order) => {
|
||||
const messageIndex = Number(message.messageIndex);
|
||||
const timestampMs = Date.parse(String(message.timestamp || ''));
|
||||
timeline.push({
|
||||
...message,
|
||||
type: 'message',
|
||||
messageNumber: order + 1,
|
||||
timestamp: String(message.timestamp || ''),
|
||||
sortPosition: Number.isFinite(messageIndex) && messageIndex >= 0 ? messageIndex * 2 : null,
|
||||
sortTime: Number.isFinite(timestampMs) ? timestampMs : null,
|
||||
sortOrder: order,
|
||||
});
|
||||
});
|
||||
normalizeOutlineTitleHistory(titleHistory).forEach((event, order) => {
|
||||
const timestampMs = Date.parse(event.changedAt);
|
||||
timeline.push({
|
||||
type: 'title',
|
||||
label: event.title,
|
||||
timestamp: event.changedAt,
|
||||
messageIndex: event.messageIndex,
|
||||
source: event.source,
|
||||
sortPosition: event.messageIndex * 2 - 1,
|
||||
sortTime: Number.isFinite(timestampMs) ? timestampMs : null,
|
||||
sortOrder: order,
|
||||
});
|
||||
});
|
||||
timeline.sort((left, right) => {
|
||||
if (Number.isFinite(left.sortPosition) && Number.isFinite(right.sortPosition) && left.sortPosition !== right.sortPosition) {
|
||||
return left.sortPosition - right.sortPosition;
|
||||
}
|
||||
if (Number.isFinite(left.sortTime) && Number.isFinite(right.sortTime) && left.sortTime !== right.sortTime) {
|
||||
return left.sortTime - right.sortTime;
|
||||
}
|
||||
if (left.type !== right.type) return left.type === 'message' ? -1 : 1;
|
||||
return left.sortOrder - right.sortOrder;
|
||||
});
|
||||
|
||||
const result = [];
|
||||
let previousDate = '';
|
||||
timeline.forEach((entry) => {
|
||||
const dateLabel = formatUserOutlineDate(entry.timestamp);
|
||||
if (dateLabel && dateLabel !== previousDate) {
|
||||
result.push({ type: 'date', label: dateLabel });
|
||||
previousDate = dateLabel;
|
||||
}
|
||||
const { sortPosition, sortTime, sortOrder, ...item } = entry;
|
||||
result.push(item);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildUserOutlineItems() {
|
||||
const seen = new Set();
|
||||
return Array.from(messagesDiv.querySelectorAll('.msg.user[data-message-id]')).map((element) => {
|
||||
@@ -1469,27 +1552,47 @@
|
||||
seen.add(id);
|
||||
const indexed = userMessageIndex.get(id);
|
||||
const content = indexed?.content || element.querySelector('.msg-text')?.textContent || '';
|
||||
const rawMessageIndex = String(element.dataset.messageIndex || '').trim();
|
||||
const messageIndex = rawMessageIndex ? Number(rawMessageIndex) : Number.NaN;
|
||||
return {
|
||||
id,
|
||||
targetMessageId: element.id || '',
|
||||
label: shortMessagePreview(content, 64),
|
||||
timestamp: indexed?.timestamp || '',
|
||||
messageIndex: Number.isFinite(messageIndex) ? messageIndex : null,
|
||||
};
|
||||
}).filter((entry) => entry && entry.targetMessageId);
|
||||
}
|
||||
|
||||
function updateUserOutlinePanel() {
|
||||
if (!userOutlinePanel || !userOutlineBtn) return;
|
||||
const items = buildUserOutlineItems();
|
||||
const items = buildUserOutlineTimelineItems(buildUserOutlineItems(), currentOutlineTitleHistory);
|
||||
if (items.length === 0) {
|
||||
userOutlinePanel.innerHTML = '<div class="user-outline-empty">暂无用户消息</div>';
|
||||
userOutlineBtn.disabled = true;
|
||||
} else {
|
||||
userOutlinePanel.innerHTML = items.map((item, index) => `
|
||||
<button type="button" class="user-outline-item" data-target="${escapeHtml(item.targetMessageId)}">
|
||||
<span class="user-outline-index">${index + 1}</span>
|
||||
<span class="user-outline-text">${escapeHtml(item.label)}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
userOutlinePanel.innerHTML = items.map((item) => {
|
||||
if (item.type === 'date') {
|
||||
return `<div class="user-outline-date" role="separator" aria-label="${escapeHtml(item.label)}"><span>${escapeHtml(item.label)}</span></div>`;
|
||||
}
|
||||
if (item.type === 'title') {
|
||||
return `
|
||||
<div class="user-outline-title-event" role="note" aria-label="标题变更:${escapeHtml(item.label)}">
|
||||
<span class="user-outline-title-label">标题</span>
|
||||
<span class="user-outline-title-text">${escapeHtml(item.label)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (item.type === 'message') {
|
||||
return `
|
||||
<button type="button" class="user-outline-item" data-target="${escapeHtml(item.targetMessageId)}">
|
||||
<span class="user-outline-index">${item.messageNumber}</span>
|
||||
<span class="user-outline-text">${escapeHtml(item.label)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
}).join('');
|
||||
userOutlineBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -1848,6 +1951,7 @@
|
||||
agent: snapshot.agent || '',
|
||||
cwd: snapshot.cwd || '',
|
||||
updated: snapshot.updated || '',
|
||||
titleHistory: normalizeOutlineTitleHistory(snapshot.titleHistory),
|
||||
}).length;
|
||||
return base + (snapshot.messages || []).reduce((sum, message) => sum + estimateSessionMessageWeight(message), 0);
|
||||
}
|
||||
@@ -1866,6 +1970,7 @@
|
||||
id: sessionId,
|
||||
messages,
|
||||
title: payload.title || '新会话',
|
||||
titleHistory: normalizeOutlineTitleHistory(payload.titleHistory),
|
||||
mode: payload.mode || 'yolo',
|
||||
model: payload.model || '',
|
||||
agent: normalizeAgent(payload.agent),
|
||||
@@ -3903,6 +4008,15 @@
|
||||
return cwd ? (getPathLeaf(cwd) || cwd) : '';
|
||||
}
|
||||
|
||||
function buildSessionItemTooltip(projectName, title) {
|
||||
const normalizedProjectName = String(projectName || '').trim();
|
||||
const normalizedTitle = String(title || '').trim() || 'Untitled';
|
||||
return [
|
||||
normalizedProjectName ? `项目:${normalizedProjectName}` : '',
|
||||
`标题:${normalizedTitle}`,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function groupSessionsByProject(sessionItems) {
|
||||
const groups = [];
|
||||
const groupMap = new Map();
|
||||
@@ -4059,8 +4173,8 @@
|
||||
const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim();
|
||||
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
|
||||
item.dataset.id = session.id;
|
||||
const sessionCwd = getSessionEffectiveCwd(session);
|
||||
if (sessionCwd) item.title = sessionCwd;
|
||||
const sessionProjectName = getSessionProjectName(session);
|
||||
item.title = buildSessionItemTooltip(sessionProjectName, session.title);
|
||||
item.innerHTML = `
|
||||
<div class="session-item-main">
|
||||
<span class="session-item-title">${escapeHtml(session.title || 'Untitled')}</span>
|
||||
@@ -4254,6 +4368,7 @@
|
||||
currentSessionId = null;
|
||||
loadedHistorySessionId = null;
|
||||
currentSessionMessageCount = 0;
|
||||
currentOutlineTitleHistory = [];
|
||||
clearSessionLoading();
|
||||
setCurrentSessionRunningState(false);
|
||||
currentCwd = null;
|
||||
@@ -4311,6 +4426,7 @@
|
||||
}
|
||||
currentSessionId = snapshot.sessionId;
|
||||
loadedHistorySessionId = snapshot.sessionId;
|
||||
currentOutlineTitleHistory = normalizeOutlineTitleHistory(snapshot.titleHistory);
|
||||
currentSessionMessageCount = Math.max(
|
||||
snapshot.historyTotal || 0,
|
||||
snapshot.historyBaseIndex + (snapshot.messages || []).length,
|
||||
@@ -5462,9 +5578,16 @@
|
||||
snapshot.title = msg.title;
|
||||
if (msg.titleSource !== undefined) snapshot.titleSource = msg.titleSource || null;
|
||||
if (msg.createdFromKind !== undefined) snapshot.createdFromKind = msg.createdFromKind || null;
|
||||
if (msg.titleEvent) {
|
||||
snapshot.titleHistory = normalizeOutlineTitleHistory([...(snapshot.titleHistory || []), msg.titleEvent]);
|
||||
}
|
||||
});
|
||||
if (msg.sessionId === currentSessionId) {
|
||||
chatTitle.textContent = msg.title;
|
||||
if (msg.titleEvent) {
|
||||
currentOutlineTitleHistory = normalizeOutlineTitleHistory([...currentOutlineTitleHistory, msg.titleEvent]);
|
||||
updateUserOutlinePanel();
|
||||
}
|
||||
}
|
||||
renderSessionList();
|
||||
break;
|
||||
@@ -6315,7 +6438,7 @@
|
||||
setCodexAppSteerStatusElement(div, meta.codexAppSteerStatus, meta.codexAppSteerMessage);
|
||||
}
|
||||
if (role === 'user') {
|
||||
registerUserMessage(resolvedMessageId, div, content);
|
||||
registerUserMessage(resolvedMessageId, div, content, meta.timestamp);
|
||||
}
|
||||
return div;
|
||||
}
|
||||
@@ -9086,14 +9209,15 @@
|
||||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||||
if (welcome) welcome.remove();
|
||||
const messageId = createLocalId('user');
|
||||
const element = createMsgElement('user', text, attachments, { messageId });
|
||||
const timestamp = new Date().toISOString();
|
||||
const element = createMsgElement('user', text, attachments, { messageId, timestamp });
|
||||
messagesDiv.appendChild(element);
|
||||
if (currentSessionId) {
|
||||
const messageIndex = currentSessionMessageCount;
|
||||
currentSessionMessageCount += 1;
|
||||
markSessionMessageElement(element, messageIndex);
|
||||
}
|
||||
registerUserMessage(messageId, element, text);
|
||||
registerUserMessage(messageId, element, text, timestamp);
|
||||
updateUserOutlinePanel();
|
||||
scrollToBottom();
|
||||
|
||||
@@ -9131,7 +9255,8 @@
|
||||
}
|
||||
const attachments = pendingAttachments.map((attachment) => ({ ...attachment }));
|
||||
const messageId = createLocalId('user');
|
||||
const element = createMsgElement('user', text, attachments, { messageId, codexAppSteerStatus: 'pending' });
|
||||
const timestamp = new Date().toISOString();
|
||||
const element = createMsgElement('user', text, attachments, { messageId, timestamp, codexAppSteerStatus: 'pending' });
|
||||
const streamEl = document.getElementById('streaming-msg');
|
||||
const shouldFollow = isNearBottom();
|
||||
if (streamEl && streamEl.parentNode === messagesDiv) {
|
||||
@@ -9144,7 +9269,7 @@
|
||||
currentSessionMessageCount += 1;
|
||||
markSessionMessageElement(element, messageIndex);
|
||||
}
|
||||
registerUserMessage(messageId, element, text);
|
||||
registerUserMessage(messageId, element, text, timestamp);
|
||||
updateUserOutlinePanel();
|
||||
if (shouldFollow) {
|
||||
scrollToBottom();
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
document.documentElement.dataset.dividerTime = dividerTime;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=20260720-plan-progress-final-state">
|
||||
<link rel="stylesheet" href="style.css?v=20260727-session-item-tooltip">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -183,6 +183,6 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
|
||||
<script src="app.js?v=20260720-plan-progress-final-state"></script>
|
||||
<script src="app.js?v=20260727-session-item-tooltip"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1955,6 +1955,62 @@ body.session-loading-active {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-outline-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 4px 5px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-outline-date::before,
|
||||
.user-outline-date::after {
|
||||
content: '';
|
||||
flex: 1 1 auto;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
.user-outline-date span {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.user-outline-title-event {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 3px 2px;
|
||||
padding: 7px 8px;
|
||||
border-left: 2px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
background: var(--accent-light);
|
||||
color: var(--text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
.user-outline-title-label {
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.user-outline-title-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mode selector */
|
||||
.mode-select {
|
||||
|
||||
@@ -618,8 +618,8 @@ function assertFrontendSidebarCollapseContract() {
|
||||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||||
);
|
||||
assert(
|
||||
indexSource.includes('style.css?v=20260720-plan-progress-final-state')
|
||||
&& indexSource.includes('app.js?v=20260720-plan-progress-final-state'),
|
||||
indexSource.includes('style.css?v=20260727-session-item-tooltip')
|
||||
&& indexSource.includes('app.js?v=20260727-session-item-tooltip'),
|
||||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||||
);
|
||||
}
|
||||
@@ -942,8 +942,8 @@ function assertPlanListProgressContract() {
|
||||
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
|
||||
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260720-plan-progress-final-state'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=20260720-plan-progress-final-state'), 'Plan progress frontend logic should use the current cache-busted URL');
|
||||
assert(indexSource.includes('style.css?v=20260727-session-item-tooltip'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=20260727-session-item-tooltip'), 'Plan progress frontend logic should use the current cache-busted URL');
|
||||
}
|
||||
|
||||
function assertFrontendGildedThemeContract() {
|
||||
@@ -1058,8 +1058,8 @@ function assertFrontendGildedThemeContract() {
|
||||
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
|
||||
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
|
||||
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
|
||||
assert(indexSource.includes('style.css?v=20260720-plan-progress-final-state'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260720-plan-progress-final-state'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('style.css?v=20260727-session-item-tooltip'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260727-session-item-tooltip'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
}
|
||||
|
||||
function assertFrontendWastelandThemeContract() {
|
||||
@@ -1312,8 +1312,8 @@ function assertFrontendWastelandThemeContract() {
|
||||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||||
});
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260720-plan-progress-final-state'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260720-plan-progress-final-state'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('style.css?v=20260727-session-item-tooltip'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260727-session-item-tooltip'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
}
|
||||
|
||||
function assertFrontendCcwebPromptContract() {
|
||||
@@ -2247,6 +2247,86 @@ function assertSetTitleMcpContract() {
|
||||
assert(styleSource.includes('.session-item.llm-created:not(.pinned)::before'), 'LLM-created marker should be hidden when the session is pinned');
|
||||
}
|
||||
|
||||
function assertSessionItemTooltipContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const tooltipSource = extractFunctionSource(frontendSource, 'buildSessionItemTooltip');
|
||||
const buildSessionItemTooltip = new Function(`${tooltipSource}\nreturn buildSessionItemTooltip;`)();
|
||||
const fullTitle = '这个标题显示不下,需要在悬停时完整展示';
|
||||
|
||||
assert(
|
||||
buildSessionItemTooltip('cc-web', fullTitle) === `项目:cc-web\n标题:${fullTitle}`,
|
||||
'Session item tooltip should show the project and the complete conversation title on separate lines'
|
||||
);
|
||||
assert(
|
||||
buildSessionItemTooltip('', fullTitle) === `标题:${fullTitle}`,
|
||||
'Session item tooltip should still expose the complete title when no project is available'
|
||||
);
|
||||
|
||||
const createItemSource = extractFunctionSource(frontendSource, 'createSessionListItem');
|
||||
assert(createItemSource.includes('getSessionProjectName(session)'), 'Session item tooltip should reuse the canonical project-name resolver');
|
||||
assert(createItemSource.includes('item.title = buildSessionItemTooltip('), 'Session card should apply the combined project/title tooltip');
|
||||
assert(!createItemSource.includes('item.title = sessionCwd'), 'Session card should not fall back to a project-only tooltip');
|
||||
}
|
||||
|
||||
function assertTitleHistoryOutlineContract() {
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||
|
||||
const normalizeTitleHistorySource = extractFunctionSource(serverSource, 'normalizeTitleHistory');
|
||||
const normalizeServerHistory = new Function(`${normalizeTitleHistorySource}\nreturn normalizeTitleHistory;`)();
|
||||
const serverHistory = normalizeServerHistory([
|
||||
{ title: '无效日期', changedAt: 'not-a-date', messageIndex: 1, source: 'llm' },
|
||||
{ title: '无效索引', changedAt: '2026-07-27T00:00:00.000Z', messageIndex: -1, source: 'llm' },
|
||||
...Array.from({ length: 102 }, (_, index) => ({
|
||||
title: `主题 ${index}`,
|
||||
changedAt: new Date(Date.UTC(2026, 6, 27, 0, index)).toISOString(),
|
||||
messageIndex: index,
|
||||
source: 'llm',
|
||||
})),
|
||||
]);
|
||||
assert(serverHistory.length === 100, 'Server should retain only the latest 100 valid title history events');
|
||||
assert(serverHistory[0].title === '主题 2' && serverHistory.at(-1).title === '主题 101', 'Server title history should discard invalid events and trim from the oldest side');
|
||||
|
||||
const normalizeOutlineHistorySource = extractFunctionSource(frontendSource, 'normalizeOutlineTitleHistory');
|
||||
const formatOutlineDateSource = extractFunctionSource(frontendSource, 'formatUserOutlineDate');
|
||||
const buildOutlineTimelineSource = extractFunctionSource(frontendSource, 'buildUserOutlineTimelineItems');
|
||||
const outlineApi = new Function(`
|
||||
${normalizeOutlineHistorySource}
|
||||
${formatOutlineDateSource}
|
||||
${buildOutlineTimelineSource}
|
||||
return { normalizeOutlineTitleHistory, formatUserOutlineDate, buildUserOutlineTimelineItems };
|
||||
`)();
|
||||
const beforeMidnight = new Date(2026, 6, 27, 23, 59, 0).toISOString();
|
||||
const titleChangedAt = new Date(2026, 6, 27, 23, 59, 30).toISOString();
|
||||
const afterMidnight = new Date(2026, 6, 28, 0, 1, 0).toISOString();
|
||||
assert(outlineApi.formatUserOutlineDate(beforeMidnight) === '2026-07-27', 'Outline dates should use the browser local calendar day before midnight');
|
||||
assert(outlineApi.formatUserOutlineDate(afterMidnight) === '2026-07-28', 'Outline dates should roll over at browser-local midnight');
|
||||
|
||||
const timeline = outlineApi.buildUserOutlineTimelineItems([
|
||||
{ type: 'message', id: 'user-1', targetMessageId: 'hapi-message-user-1', label: '第一步', timestamp: beforeMidnight, messageIndex: 0 },
|
||||
{ type: 'message', id: 'user-2', targetMessageId: 'hapi-message-user-2', label: '第二步', timestamp: afterMidnight, messageIndex: 2 },
|
||||
], [
|
||||
{ title: 'SQL 排查主题', changedAt: titleChangedAt, messageIndex: 1, source: 'llm' },
|
||||
]);
|
||||
assert(
|
||||
JSON.stringify(timeline.map((item) => item.type)) === JSON.stringify(['date', 'message', 'title', 'date', 'message']),
|
||||
'Outline timeline should merge date, message and title nodes in conversation order'
|
||||
);
|
||||
assert(timeline[0].label === '2026-07-27' && timeline[3].label === '2026-07-28', 'Outline should deduplicate dates and show YYYY-MM-DD only');
|
||||
assert(timeline[1].messageNumber === 1 && timeline[4].messageNumber === 2, 'Only selectable message nodes should consume outline numbering');
|
||||
assert(timeline[2].label === 'SQL 排查主题' && !timeline[2].targetMessageId, 'Title history nodes should be read-only timeline metadata');
|
||||
|
||||
const updateOutlineSource = extractFunctionSource(frontendSource, 'updateUserOutlinePanel');
|
||||
assert(updateOutlineSource.includes('user-outline-title-event') && updateOutlineSource.includes('user-outline-date'), 'Outline renderer should include dedicated title and date nodes');
|
||||
assert(updateOutlineSource.includes("item.type === 'message'"), 'Outline renderer should reserve buttons for selectable message nodes');
|
||||
assert(/closest\('\.user-outline-item'\)/.test(frontendSource), 'Outline click delegation should only target selectable message buttons');
|
||||
assert(styleSource.includes('.user-outline-title-event') && styleSource.includes('.user-outline-date'), 'Outline title and date nodes should have dedicated styles');
|
||||
assert(/\.user-outline-date::before[\s\S]*\.user-outline-date::after/.test(styleSource), 'Outline dates should use the requested two-sided divider treatment');
|
||||
assert(serverSource.includes('titleHistory: refreshedSession.titleHistory'), 'session_info should expose persisted title history to the active conversation only');
|
||||
assert(frontendSource.includes('titleHistory: normalizeOutlineTitleHistory(payload.titleHistory)'), 'Frontend snapshots should preserve normalized title history');
|
||||
}
|
||||
|
||||
function assertSessionSwitchResilienceContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
@@ -3566,6 +3646,17 @@ async function main() {
|
||||
console.log('Session switch race regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'title-history-outline') {
|
||||
assertSetTitleMcpContract();
|
||||
assertTitleHistoryOutlineContract();
|
||||
console.log('Title history outline regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'session-item-tooltip') {
|
||||
assertSessionItemTooltipContract();
|
||||
console.log('Session item tooltip regression checks passed.');
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
||||
}
|
||||
|
||||
@@ -3585,6 +3676,8 @@ async function main() {
|
||||
assertCodexAppRuntimeSubAgentActivityContract();
|
||||
assertFrontendPrimaryCodexAppUiContract();
|
||||
assertSetTitleMcpContract();
|
||||
assertSessionItemTooltipContract();
|
||||
assertTitleHistoryOutlineContract();
|
||||
assertSessionSwitchResilienceContract();
|
||||
assertSessionSwitchRaceContract();
|
||||
assertCodexAppChildToolFallbackContract();
|
||||
@@ -3978,12 +4071,15 @@ async function main() {
|
||||
assert(mcpSetTitle.body.changed === true && mcpSetTitle.body.ignored === false, 'MCP set title should report a real title change');
|
||||
assert(mcpSetTitle.body.previousTitle === storedBeforeMcpTitle.title, 'MCP set title should return previousTitle');
|
||||
assert(mcpSetTitle.body.lockedByUser === false, 'MCP set title should not report a user lock before manual rename');
|
||||
assert(mcpSetTitle.body.titleEvent?.title === 'Concise MCP Title', 'MCP set title should return the persisted title event');
|
||||
assert(mcpSetTitle.body.titleEvent?.messageIndex === storedBeforeMcpTitle.messages.length, 'Title event should anchor after the messages persisted before the rename');
|
||||
const mcpTitleRenamed = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_renamed' &&
|
||||
msg.sessionId === codexSession.sessionId &&
|
||||
msg.title === 'Concise MCP Title'
|
||||
));
|
||||
assert(mcpTitleRenamed.titleSource === 'llm', 'MCP set title should push llm titleSource to current viewers');
|
||||
assert(mcpTitleRenamed.titleEvent?.title === 'Concise MCP Title', 'MCP set title should push the title event to current viewers');
|
||||
const mcpTitleList = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_list' &&
|
||||
msg.sessions.some((session) => (
|
||||
@@ -3997,6 +4093,27 @@ async function main() {
|
||||
assert(storedAfterMcpTitle.title === 'Concise MCP Title', 'MCP set title should persist title');
|
||||
assert(storedAfterMcpTitle.titleSource === 'llm', 'MCP set title should persist llm titleSource');
|
||||
assert(storedAfterMcpTitle.updated === storedBeforeMcpTitle.updated, 'MCP set title should not modify updated timestamp');
|
||||
assert(storedAfterMcpTitle.titleHistory?.length === 1, 'MCP set title should append exactly one title history event');
|
||||
assert(storedAfterMcpTitle.titleHistory[0].source === 'llm' && storedAfterMcpTitle.titleHistory[0].title === 'Concise MCP Title', 'Persisted title history should identify the LLM title change');
|
||||
assert(!Number.isNaN(Date.parse(storedAfterMcpTitle.titleHistory[0].changedAt)), 'Persisted title history should include a valid change timestamp');
|
||||
|
||||
const unchangedMcpTitle = await callInternalMcp(port, internalMcpToken, {
|
||||
tool: 'ccweb_set_title',
|
||||
sourceSessionId: codexSession.sessionId,
|
||||
args: { title: 'Concise MCP Title' },
|
||||
});
|
||||
assert(unchangedMcpTitle.status === 200 && unchangedMcpTitle.body?.changed === false, 'Repeated MCP title should report unchanged');
|
||||
assert(!unchangedMcpTitle.body.titleEvent, 'Repeated MCP title should not create a fake title event');
|
||||
const storedAfterUnchangedMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
|
||||
assert(storedAfterUnchangedMcpTitle.titleHistory?.length === 1, 'Repeated MCP title should not append title history');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'load_session', sessionId: codexSession.sessionId, requestId: 'reg-title-history-load' }));
|
||||
const titleHistorySessionInfo = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_info' &&
|
||||
msg.sessionId === codexSession.sessionId &&
|
||||
msg.requestId === 'reg-title-history-load'
|
||||
));
|
||||
assert(titleHistorySessionInfo.titleHistory?.length === 1, 'session_info should restore persisted title history');
|
||||
|
||||
const mcpEmptyTitle = await callInternalMcp(port, internalMcpToken, {
|
||||
tool: 'ccweb_set_title',
|
||||
@@ -4032,6 +4149,7 @@ async function main() {
|
||||
const storedAfterManualLock = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
|
||||
assert(storedAfterManualLock.title === 'Manual Locked Title', 'Manual locked title should remain persisted after ignored MCP set title');
|
||||
assert(storedAfterManualLock.titleSource === 'manual', 'Manual locked titleSource should remain manual after ignored MCP set title');
|
||||
assert(storedAfterManualLock.titleHistory?.length === 1, 'Ignored MCP title should not append a fake title history event');
|
||||
|
||||
const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, {
|
||||
tool: 'ccweb_create_conversation',
|
||||
|
||||
40
server.js
40
server.js
@@ -2895,6 +2895,26 @@ function isCodexLikeAgent(agent) {
|
||||
return normalized === 'codex' || normalized === 'codexapp';
|
||||
}
|
||||
|
||||
function normalizeTitleHistory(history) {
|
||||
if (!Array.isArray(history)) return [];
|
||||
const normalized = [];
|
||||
history.forEach((event) => {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
const title = String(event.title || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
const changedAtMs = Date.parse(String(event.changedAt || ''));
|
||||
const messageIndex = Number(event.messageIndex);
|
||||
const source = String(event.source || '').trim().toLowerCase();
|
||||
if (!title || !Number.isFinite(changedAtMs) || !Number.isFinite(messageIndex) || messageIndex < 0 || source !== 'llm') return;
|
||||
normalized.push({
|
||||
title,
|
||||
changedAt: new Date(changedAtMs).toISOString(),
|
||||
messageIndex: Math.trunc(messageIndex),
|
||||
source: 'llm',
|
||||
});
|
||||
});
|
||||
return normalized.slice(-100);
|
||||
}
|
||||
|
||||
function normalizeSession(session) {
|
||||
if (!session || typeof session !== 'object') return session;
|
||||
session.agent = normalizeAgent(session.agent);
|
||||
@@ -2906,6 +2926,7 @@ function normalizeSession(session) {
|
||||
} else if (Object.prototype.hasOwnProperty.call(session, 'titleSource')) {
|
||||
delete session.titleSource;
|
||||
}
|
||||
session.titleHistory = normalizeTitleHistory(session.titleHistory);
|
||||
if (!Object.prototype.hasOwnProperty.call(session, 'claudeSessionId')) session.claudeSessionId = null;
|
||||
if (!Object.prototype.hasOwnProperty.call(session, 'codexThreadId')) session.codexThreadId = null;
|
||||
if (!Object.prototype.hasOwnProperty.call(session, 'codexAppThreadId')) session.codexAppThreadId = null;
|
||||
@@ -7051,6 +7072,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
|
||||
cleanRunDir(sessionId);
|
||||
}
|
||||
session.messages = [];
|
||||
session.titleHistory = [];
|
||||
clearRuntimeSessionId(session);
|
||||
session.updated = new Date().toISOString();
|
||||
saveSession(session);
|
||||
@@ -7059,6 +7081,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
|
||||
sessionId: session.id,
|
||||
messages: [],
|
||||
title: session.title,
|
||||
titleHistory: session.titleHistory,
|
||||
pinnedAt: session.pinnedAt || null,
|
||||
...publicTitleMetadata(session),
|
||||
mode: session.permissionMode || 'yolo',
|
||||
@@ -7295,6 +7318,17 @@ function setCurrentConversationTitle(args = {}, sourceSessionId = '') {
|
||||
}
|
||||
|
||||
const changed = previousTitle !== normalizedTitle;
|
||||
let titleEvent = null;
|
||||
if (changed) {
|
||||
titleEvent = {
|
||||
title: normalizedTitle,
|
||||
changedAt: new Date().toISOString(),
|
||||
messageIndex: Array.isArray(session.messages) ? session.messages.length : 0,
|
||||
source: 'llm',
|
||||
};
|
||||
session.titleHistory = normalizeTitleHistory([...(session.titleHistory || []), titleEvent]);
|
||||
titleEvent = session.titleHistory[session.titleHistory.length - 1] || null;
|
||||
}
|
||||
session.title = normalizedTitle;
|
||||
session.titleSource = 'llm';
|
||||
saveSession(session);
|
||||
@@ -7302,6 +7336,7 @@ function setCurrentConversationTitle(args = {}, sourceSessionId = '') {
|
||||
type: 'session_renamed',
|
||||
sessionId: session.id,
|
||||
title: session.title,
|
||||
...(titleEvent ? { titleEvent } : {}),
|
||||
...publicTitleMetadata(session),
|
||||
});
|
||||
broadcastSessionList();
|
||||
@@ -7314,6 +7349,7 @@ function setCurrentConversationTitle(args = {}, sourceSessionId = '') {
|
||||
title: session.title,
|
||||
previousTitle,
|
||||
lockedByUser: false,
|
||||
...(titleEvent ? { titleEvent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7461,6 +7497,7 @@ function buildSessionInfoPayload(session) {
|
||||
sessionId: session.id,
|
||||
messages,
|
||||
title: session.title,
|
||||
titleHistory: session.titleHistory,
|
||||
pinnedAt: session.pinnedAt || null,
|
||||
...publicTitleMetadata(session),
|
||||
mode: session.permissionMode || 'yolo',
|
||||
@@ -7677,6 +7714,7 @@ function handleLoadSession(ws, msg) {
|
||||
sessionId: refreshedSession.id,
|
||||
messages: recentMessages,
|
||||
title: refreshedSession.title,
|
||||
titleHistory: refreshedSession.titleHistory,
|
||||
pinnedAt: refreshedSession.pinnedAt || null,
|
||||
...publicTitleMetadata(refreshedSession),
|
||||
mode: refreshedSession.permissionMode || 'yolo',
|
||||
@@ -10782,6 +10820,7 @@ function handleImportNativeSession(ws, msg) {
|
||||
sessionId: id,
|
||||
messages: transportMessages,
|
||||
title: session.title,
|
||||
titleHistory: session.titleHistory,
|
||||
pinnedAt: session.pinnedAt || null,
|
||||
...publicTitleMetadata(session),
|
||||
mode: session.permissionMode,
|
||||
@@ -10955,6 +10994,7 @@ function handleImportCodexSession(ws, msg) {
|
||||
sessionId: id,
|
||||
messages: transportMessages,
|
||||
title: session.title,
|
||||
titleHistory: session.titleHistory,
|
||||
pinnedAt: session.pinnedAt || null,
|
||||
...publicTitleMetadata(session),
|
||||
mode: session.permissionMode,
|
||||
|
||||
Reference in New Issue
Block a user