diff --git a/.planning/session-item-title-tooltip/findings.md b/.planning/session-item-title-tooltip/findings.md new file mode 100644 index 0000000..84558b1 --- /dev/null +++ b/.planning/session-item-title-tooltip/findings.md @@ -0,0 +1,7 @@ +# 会话卡片完整标题悬停提示:发现 + +- `createSessionListItem()` 创建侧边栏会话卡片。 +- 当前实现仅在存在有效工作目录时执行 `item.title = sessionCwd`,因此浏览器原生提示只显示项目路径。 +- 卡片正文的 `.session-item-title` 会因布局宽度省略,完整 `session.title` 没有进入可访问提示。 +- `getSessionProjectName(session)` 已统一优先读取 `session.projectName`,否则从有效 cwd 取末级目录,适合作为提示中的项目名来源。 +- 推荐提示格式:`项目:<项目名>\n标题:<完整标题>`;若缺少项目名,则只显示标题行。 diff --git a/.planning/session-item-title-tooltip/progress.md b/.planning/session-item-title-tooltip/progress.md new file mode 100644 index 0000000..26a7170 --- /dev/null +++ b/.planning/session-item-title-tooltip/progress.md @@ -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 记录。 diff --git a/.planning/session-item-title-tooltip/task_plan.md b/.planning/session-item-title-tooltip/task_plan.md new file mode 100644 index 0000000..de0c42b --- /dev/null +++ b/.planning/session-item-title-tooltip/task_plan.md @@ -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 会话,按仓库约束不重启服务 | diff --git a/.planning/title-history-locator/findings.md b/.planning/title-history-locator/findings.md new file mode 100644 index 0000000..cb3c702 --- /dev/null +++ b/.planning/title-history-locator/findings.md @@ -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()` 当前把所有项渲染成可点击 ` - `).join(''); + userOutlinePanel.innerHTML = items.map((item) => { + if (item.type === 'date') { + return ``; + } + if (item.type === 'title') { + return ` +
+ 标题 + ${escapeHtml(item.label)} +
+ `; + } + if (item.type === 'message') { + return ` + + `; + } + 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 = `
${escapeHtml(session.title || 'Untitled')} @@ -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(); diff --git a/public/index.html b/public/index.html index c6e6514..e7e1db7 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -183,6 +183,6 @@ - + diff --git a/public/style.css b/public/style.css index cb8f319..d84e99b 100644 --- a/public/style.css +++ b/public/style.css @@ -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 { diff --git a/scripts/regression.js b/scripts/regression.js index 09231cd..430fead 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -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', diff --git a/server.js b/server.js index 2621c81..22682d5 100644 --- a/server.js +++ b/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,