chore: rebuild release package

This commit is contained in:
shiyue
2026-07-27 17:06:32 +08:00
parent 85f2f2d420
commit efe7c02ec3
12 changed files with 548 additions and 25 deletions

View File

@@ -0,0 +1,7 @@
# 会话卡片完整标题悬停提示:发现
- `createSessionListItem()` 创建侧边栏会话卡片。
- 当前实现仅在存在有效工作目录时执行 `item.title = sessionCwd`,因此浏览器原生提示只显示项目路径。
- 卡片正文的 `.session-item-title` 会因布局宽度省略,完整 `session.title` 没有进入可访问提示。
- `getSessionProjectName(session)` 已统一优先读取 `session.projectName`,否则从有效 cwd 取末级目录,适合作为提示中的项目名来源。
- 推荐提示格式:`项目:<项目名>\n标题<完整标题>`;若缺少项目名,则只显示标题行。

View 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 记录。

View 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 会话,按仓库约束不重启服务 |

View 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`,继续保持现有“改标题不改变会话排序”的契约。

View 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` 代码索引已确认 readyTrellis 相关规范目前未提供额外项目约束。
- 第二次独立计划审查已通过;阶段 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 定位前后端链路。

View 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 审查收口 |

View File

@@ -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();

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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,