Appearance
功能规格说明:会话管理
规格文件:docs/specs/ui/session-management.md创建日期:2026-01-21
平台边界:前三个用户故事及会话存储相关功能需求为 CLI/SDK 宿主;会话列表聚合与恢复类用户故事(「跨目录会话聚合列表」「同仓库 worktree 会话聚合」「跨目录/worktree 恢复」「会话内热切换(
/resume)」)为 CLI 宿主;「IDE 插件聊天头部」与「GUI 会话内热切换(/resume)」用户故事适用于 GUI 宿主(VSCE / JB / desktop);其中远程主机与分屏(pane)语义仅 desktop 适用。「会话自定义标题(重命名)」的存储与列表语义为 SDK 层(对所有读会话文件的宿主生效),改名入口三端都做:CLI 为/rename命令与选择器内就地改名,插件端(VSCE / JB)为头部标题就地编辑,桌面端为侧边栏会话行菜单(见 desktop-sessions.md)。
用户场景与测试 (必填)
用户故事:基于项目的组织(优先级:P1)
作为处理多个项目的开发者,我希望我的代理会话按项目的工作目录组织,以便我能轻松找到和管理与当前工作相关的会话。
优先级原因:面向开发者的代理的核心组织需求。
独立测试:在不同目录中创建会话并验证它们存储在 ~/.wave/projects 下的独立子目录中。
验收场景:
- 假设 有工作目录,当 创建会话时,则 它必须存储在以编码路径命名的子目录中。
- 假设 有较长或复杂的路径,当 编码时,则 它必须是文件系统安全的且限制为 200 个字符。
用户故事:高性能会话列表(优先级:P1)
作为拥有数百个历史会话的用户,我希望会话列表能快速加载,以便在启动或切换会话时不会体验到延迟。
优先级原因:随着会话数量增长,这对良好的用户体验至关重要。
独立测试:生成 100 个大会话文件并测量列出它们的时间;应该是近乎即时的,因为系统直接扫描会话目录、对每个文件只读取最后一条消息来提取时间戳与 token 数,不维护任何索引文件。
验收场景:
- 假设 有多个会话文件,当 列出会话时,则 系统直接扫描项目目录(
~/.wave/projects/<编码路径>/),按文件名解析会话 ID(跳过subagent-前缀的子代理会话),对每个文件只读取最后一条消息以提取lastActiveAt与 token 数,按最后活跃时间倒序返回——不维护或读取任何sessions-index.json索引。 - 假设 会话文件损坏或为空,当 列出会话时,则 损坏文件跳过(解析失败视为不存在);空文件以文件修改时间作为
lastActiveAt时间戳,不阻塞整体列表。
用户故事:会话延迟物化,元数据消息不落盘(优先级:P1)
作为 CLI 用户,我希望仅包含元数据消息(如 SessionStart hook 注入的 isMeta: true 上下文)的会话不创建 transcript 文件,以便 wave -r 会话列表不会出现「0 tokens / No content」的空壳会话(与 Claude Code 的 lazy materialization 行为一致)。
优先级原因:会话列表可见缺陷,且与 Claude Code 行为不一致;修复集中在 saveSession 单点,成本低。
独立测试:启动 Agent 后不发送任何消息直接 destroy(),验证 ~/.wave/projects/<编码路径>/ 下不产生该会话的 JSONL 文件;发送第一条真实消息后再次验证 meta 消息与真实消息一次性写入。
验收场景:
- 假设新会话仅包含 SessionStart hook 注入的 meta 消息(
isMeta: true),当saveSession(如 destroy 时)被调用时,则不创建会话文件、不写入任何内容,meta 消息仅保留在内存中 - 假设新会话先注入 meta 消息、随后用户发送第一条真实消息,当
saveSession被调用时,则 meta 消息与真实消息一次性写入同一文件(meta 消息不丢失、顺序保持) - 假设会话文件已物化(已有已保存消息)后运行时恢复会话并注入 meta 消息,当
saveSession被调用时,则照常追加写入(与 Claude Code 一致:文件物化后所有条目都持久化) - 假设
/clear后新会话仅注入 meta 消息,当saveSession被调用时,则不创建新会话文件 - 假设从未落盘的 meta-only 会话,当运行
wave -r时,则列表不显示该会话
用户故事:子代理会话分离(优先级:P2)
作为使用子代理的开发者,我希望子代理会话被清楚地标识并与主代理会话分离,以避免污染主会话历史。
优先级原因:在处理复杂的多代理工作流时提高清晰度。
独立测试:启动子代理并验证其会话文件以 subagent- 为前缀。
验收场景:
- 假设 子代理已创建,当 其会话被保存时,则 文件名必须以
subagent-开头。
用户故事:IDE 插件聊天头部(优先级:P2)
作为 IDE 插件用户,我希望聊天面板顶部显示当前会话标题和常用操作(新建对话、历史对话、更多菜单),并能在输入框用 /resume 打开同一份会话列表,以便快速切换会话和管理当前会话。
为什么是这个优先级:会话切换与新建是高频入口,但用户仍可通过清空输入重新开始,属于体验增强。
独立测试:发送一条消息后观察标题栏显示会话标题;点击"新建对话"验证当前会话被清空;点击"历史对话"选择一个会话验证消息恢复。
验收场景:
- 假设会话中已有消息,当标题栏渲染时,则显示由首条消息截断得到的会话标题(最多 30 字符);无消息时显示"新对话"。
- 假设用户点击"新建对话"按钮,当当前不在响应流式输出中且当前会话无正在运行的后台任务时,则当前会话被清空,标题恢复为"新对话";流式输出期间或存在正在运行的后台任务期间按钮禁用。
- 假设用户点击"历史对话"按钮,当操作发生时,则弹出历史会话列表;选择其中一个会话后其消息被恢复到聊天面板(当前会话无正在运行的后台任务时;否则选择被忽略)。
- 假设用户点击"更多"按钮,当操作发生时,则弹出更多菜单(设置、企业控制台、登录/退出登录,见 sso-auth.md 的更多菜单条目)。
- 假设标题栏右侧显示上下文用量指示,当会话进行中时,则实时反映当前 token 使用情况(见 context-usage-indicator.md)。
- 假设当前会话存在正在运行的后台任务(后台终端工具/后台子代理/后台工作流,类型
shell/subagent/workflow任一、状态running),当用户点击"新建对话"或从"历史对话"列表中选择会话时,则两操作均被禁止——"新建对话"按钮与流式输出期间同样处于禁用态,历史会话选择被静默忽略(与流式输出期间的既有守卫行为一致),当前会话内容与后台任务保持原样、不被清空或中断;后台任务全部结束(或停止)后操作恢复可用。 - 假设与"新建对话"按钮同语义的
clearChat其它触发路径(如手动输入/clear),当当前会话存在正在运行的后台任务时,则同样被忽略,守卫与按钮一致(均为原地清空当前会话的操作;桌面端/clear不在本故事范围)。 - 假设用户在输入框输入
/resume(宿主本地命令,与其它本地命令一样出现在/弹窗中),当命令执行时,则打开与点击"历史对话"按钮完全相同的会话选择器——同一范围(当前工作区目录)、同一份磁盘会话来源(含 CLI 在该目录创建的会话)、同一搜索与键盘操作、同一条恢复路径,不离开面板、不清空当前对话。 - 假设用户通过
/resume打开的列表选择会话,当当前会话正在流式输出或存在正在运行的后台任务时,则与点击"历史对话"列表选择的行为一致:选择被忽略(守卫同场景 6),当前会话与后台任务保持原样。 - 假设
/resume选择器已打开,当用户按 Esc 或点击选择器外部时,则关闭选择器,当前会话、消息与输入框草稿保持原样。(/resume不因"统一为全部项目"而扩大范围——插件宿主无法切换工作区,跨项目恢复属于桌面端与 CLI 的能力,见「GUI 会话内热切换」故事。)
用户故事:跨目录会话聚合列表与移除 7 天窗口(优先级:P1)
作为在多个项目间切换的开发者,我希望 wave -r 选择器能列出所有项目的历史会话(Ctrl+A 切换,行尾显示项目路径),并且不再把超过 7 天未活跃的会话从列表中过滤掉,以便从任意目录找回并恢复任意项目的旧对话。
为什么是这个优先级:这是跨目录恢复的地基——没有全项目列表与完整历史,跨目录恢复无从选择;7 天窗口会隐藏用户以为还在的旧会话,与 Claude Code 行为不一致。
独立测试:在项目 A 与项目 B 分别创建会话;从项目 A 运行 wave -r,验证默认只见项目 A 的会话;按 Ctrl+A 后两个项目的会话都出现且项目 B 的行尾显示其路径;构造一个 8 天前活跃的会话文件,验证它仍出现在列表中(移除 7 天过滤)。
验收场景:
- 假设当前目录存在历史会话,当用户运行
wave -r(无参数)时,则默认列出当前目录的全部会话,按最后活跃时间倒序,不再按 7 天窗口过滤(超过 7 天未活跃的会话仍显示)。 - 假设存在多个项目目录(
~/.wave/projects/下多个编码目录),当用户在wave -r选择器中按Ctrl+A时,则切换为全项目列表:遍历全部项目目录,行尾显示每个会话所属的项目路径(尽力解码回原始路径),按最后活跃时间倒序。 - 假设同一会话 ID 出现在多个项目目录(如多 worktree 分支),当聚合列表渲染时,则按 sessionId 去重,仅保留最后活跃时间最新的那条。
- 假设用户在
wave -r选择器中再次按Ctrl+A,当切换发生时,则列表回到"仅当前目录"范围,快捷键为双向切换。 - 假设项目目录名因超长路径被截断加哈希后缀、无法可靠解码回原始路径,当全项目模式展示项目路径时,则显示编码后的目录名作为兜底,不报错。
用户故事:同仓库 worktree 会话聚合(优先级:P1)
作为在 git 仓库多个 worktree 间工作的开发者,我希望 wave -r 选择器能发现同一仓库的其他 worktree 的会话(Ctrl+W 展开),以便在其中一个 worktree 里查看并恢复另一个 worktree 的对话。
为什么是这个优先级:同一仓库的多个 worktree 是高频并发工作形态,会话按 worktree 目录分散存储,跨 worktree 恢复是跨目录恢复中最常见的一类;与 Claude Code 的 worktree 感知行为对齐。
独立测试:在仓库的两个 worktree 中分别创建会话;在其中一个 worktree 里运行 wave -r,验证默认只显示当前 worktree 的会话;按 Ctrl+W 后两个 worktree 的会话都出现;单 worktree 或非 git 目录下 Ctrl+W 不生效。
验收场景:
- 假设当前目录位于 git 仓库内且该仓库有多个 worktree,当用户运行
wave -r时,则选择器默认显示当前 worktree 的会话。 - 假设仓库有多个 worktree,当用户在
wave -r选择器中按Ctrl+W时,则展开为同仓库全部 worktree 的会话(经git worktree list发现并匹配各 worktree 对应的会话目录),再次按Ctrl+W收回为仅当前 worktree。 - 假设仓库只有一个 worktree 或当前目录不在 git 仓库内,当用户按
Ctrl+W时,则该快捷键不产生任何效果(底部提示不出现该快捷键)。 - 假设worktree 路径与普通项目路径可能前缀相同(如
repo与repo-foo),当worktree 目录匹配时,则按编码前缀长度优先匹配,避免短前缀误吞长路径的会话目录。 - 假设用户按
Ctrl+A进入全项目模式后又按Ctrl+W,当两个切换叠加时,则各自独立生效:全项目模式决定"哪些目录"、worktree 模式决定"是否限定同仓库 worktree",两者同时开启时列出全部项目(全项目模式优先,不再受 worktree 过滤约束)。
用户故事:跨目录/worktree 恢复(优先级:P1)
作为在任意目录使用 wave 的开发者,我希望从选择器选中会话后能直接恢复:当前目录会话原地恢复;同仓库其他 worktree 的会话自动切换到目标 worktree 后恢复;其他项目的会话得到可执行的 cd 命令,以便无需记忆会话 ID 就能继续任何历史对话。
为什么是这个优先级:恢复是列表的终点——能列出但恢复不了等于功能缺失;worktree 自动切换与跨项目 cd 命令分别覆盖"同仓库高频场景"与"跨项目低频场景",与 Claude Code 行为一致。
独立测试:在仓库 worktree B 创建会话后,在 worktree A 中经 wave -r + Ctrl+W 选中该会话,验证进程工作目录切换到 worktree B 且历史消息恢复;在另一个项目目录选中其会话,验证输出 cd <路径> && wave --restore <sessionId> 并复制到剪贴板。
验收场景:
- 假设用户选中当前目录的会话,当确认选择时,则直接恢复该会话(现有行为不变,消息历史载入、不改变工作目录)。
- 假设用户选中同仓库其他 worktree 的会话,当确认选择时,则直接恢复该会话,进程工作目录切换为该 worktree 路径,历史消息完整载入。
- 假设用户选中其他项目(非同仓库 worktree)的会话,当确认选择时,则不在当前进程恢复,而是显示
cd <项目路径> && wave --restore <sessionId>命令并将该命令复制到剪贴板,随后退出选择器。 - 假设选中的 worktree 目录已被删除(
git worktree remove后),当用户确认选择时,则按"其他项目"处理:给出cd命令提示而不是报错崩溃。 - 假设用户直接运行
wave --restore <sessionId>且该会话不在当前目录,当查找时,则扫描全部项目目录(含同仓库 worktree 目录),找到即恢复;全部找不到时给出明确错误(会话不存在),不静默地新建空会话。 - 假设用户运行
wave -c(--continue),当执行时,则行为保持不变:恢复当前目录最近一次会话,不跨目录。
用户故事:会话内热切换(/resume)(优先级:P1)
作为已经在一次对话中的开发者,我希望输入 /resume 就能列出历史会话并切换到其中一条,不必退出进程重新启动,以便在不丢失进程状态的前提下继续别处的对话。
为什么是这个优先级:换会话是会话功能里唯一仍需"退出重开"的高频动作。列表聚合、范围切换、归属判定与恢复语义都已为 wave -r 建好,会话内入口是这条链路的最后一段;缺失会让 CLI 与已支持进程内切换的 IDE 插件 / 桌面端体验断层。
独立测试:在目录 A 创建两条会话并在第一条里对话若干轮,在同一进程内输入 /resume 选中目录 A 的第二条会话,验证消息、用量与任务列表整体替换为目标会话且进程未退出;再切换到同仓库另一 worktree 的会话,验证工作目录随之切换、后续新消息追加到目标会话文件;按 Esc 取消,验证无任何副作用。
验收场景:
- 假设用户在一次对话中输入
/resume,当命令执行时,则在输入框上方打开会话选择器覆盖视图(不退出进程、不清空当前对话),支持 ↑↓ 选择、Enter 确认、Esc 取消,且范围切换(Ctrl+W同仓库全部 worktree、Ctrl+A全部项目)与快捷键提示同wave -r的选择器完全一致。 - 假设选择器已打开,当用户按 Esc 时,则关闭选择器,当前会话、消息与输入框内容保持原样。
- 假设用户选中当前目录的会话,当确认选择时,则在进程内切换:显示的消息流整体替换为目标会话的完整历史(含压缩前的显示历史),用量按目标会话重算,任务 / 待办列表替换为目标会话的任务,工作目录不变。
- 假设用户选中同仓库其他 worktree 的会话,当确认选择时,则在进程内切换并同时把工作目录切换到该会话所属目录;切换后涉及工作目录的派生内容(项目记忆、规则、目录相关的系统提示段)必须按新目录重新计算,不得沿用旧目录的缓存。
- 假设上一轮
/resume已把会话切到 worktree B,当用户再次/resume并选中 worktree C(或切回原目录)的会话时,则切换同样成功——不允许出现"只能切换一次、之后再也切不动"的状态。 - 假设用户选中跨项目(非同仓库 worktree)的会话,当确认选择时,则不在当前进程恢复,而是在选择器内显示
cd <项目路径> && wave --restore <sessionId>提示(可复制到剪贴板),进程不退出;关闭提示后原会话及其内容不受影响。(与"跨目录/worktree 恢复"故事场景 3 的唯一差别是入口在会话内,因此不退出进程。) - 假设选中的 worktree 目录已被删除,当确认选择时,则按"跨项目"处理:给出
cd提示而不是报错崩溃(与wave -r场景 4 一致)。 - 假设正在生成回复(加载中),当用户输入
/resume时,则命令被忽略并提示,不中止当前回合、不切换会话(与/clear在运行中的既有口径一致)。 - 假设切换成功,当切换完成时,则会话 ID 与落盘项目目录同步变化:后续新消息必须追加写入目标会话原本所在的会话文件,不得在当前目录另建一份同名会话文件。
- 假设切换成功,当切换完成时,则输入框草稿被清空、加载态复位,并触发会话切换的生命周期钩子(上一会话的
SessionEnd、目标会话的SessionStart,来源标记为"恢复")。 - 假设目标会话文件在磁盘上不存在或已损坏,当用户确认选择时,则切换不发生、给出失败提示,当前会话保持原状且可继续使用。
用户故事:GUI 会话内热切换(/resume)(优先级:P1)
作为在 IDE 插件或桌面端界面里的开发者,我希望在输入框里输入 /resume 就能打开会话选择器并切换到其中一条,以便不必离开当前界面、也不必重启应用,就能继续另一段对话;其中桌面端还应能列出命令行与其它客户端创建的会话(含远程主机上的),插件端则与当前宿主既有的历史会话入口保持同一范围。
为什么是这个优先级:GUI 里切换会话已有入口,但桌面端的会话树只收录本应用创建的会话——命令行那边开的对话完全不可见;插件端的历史弹窗已按工作区目录从磁盘读取,缺的只是输入框里的入口。/resume 是 GUI 与 CLI 行为对齐的最后一段,也是"跨客户端接着同一段对话"的唯一入口。
为什么插件端不扩到全部项目:插件宿主由绑定的工作区决定一切(会话落盘目录、工具作用域、打开的文件),列出其它项目的会话后既无法切换工作区、也无从恢复,只会与头部「历史对话」按钮的范围产生不一致。跨项目恢复是桌面端(可切换工作目录与主机的宿主)与 CLI(跨项目给 cd 提示)的能力。
独立测试:用 CLI 在某个项目目录创建一段会话(界面从未创建过它),在桌面端输入 /resume,验证列表中同时出现该项目与该会话(每行带项目路径),选中后当前分屏切换到该会话且消息历史与 CLI 中所见一致;在插件端(VSCE / JB)输入 /resume,验证打开的列表与头部「历史对话」按钮完全一致(当前工作区目录,含 CLI 在该目录创建的会话);再选中一条所属目录已被删除的会话,验证给出目录不存在的提示、且当前会话不受影响。
验收场景:
- 假设用户在 GUI(插件端或桌面端)输入
/resume,当命令执行时,则打开可搜索的会话选择器——插件端为面板头部下方的下拉弹窗(与"历史对话"按钮同一个),桌面端为居中模态对话框(渲染在应用根层,分屏状态下同样可用)——不离开当前界面、不清空当前对话,支持 ↑↓ 选择、Enter 确认、Esc 取消。 - 假设选择器打开,当列表加载时,则插件端(VSCE / JB)与该宿主头部「历史对话」按钮的范围完全一致(当前工作区目录的会话,按最后活跃时间倒序);桌面端列出全部项目(
~/.wave/projects下全部项目目录,跨目录去重后按最后活跃时间倒序),每行显示会话标题与该会话所属项目路径。 - 假设某条会话不是当前界面创建的(由 CLI、另一客户端、或同一台机器上另一个 wave 进程写入),当选择器列表加载时,则该会话在其所属目录范围内同样出现并可选——列表来自磁盘上的会话文件,而不是界面自己的会话索引(插件端即"CLI 在同一工作区目录开的会话",桌面端即"任意目录的会话")。
- 假设当前对话属于某台远程主机(桌面端),当选择器列表加载时,则列表为该主机磁盘上的会话(每行标注项目路径),不混入本地或其它主机的会话——范围随"当前对话所属主机"变化,切换焦点分屏后再次打开即换成该主机的会话;当前主机不可达时给出可见的失败提示(不得静默显示空列表),当前会话不受影响。
- 假设用户选中一条会话,当确认选择时,则在当前位置切换:消息流整体替换为该会话的完整历史,界面不重开、进程不退出;桌面端复用既有分屏切换路径(该会话已显示在其它分屏时聚焦那个分屏,而不是再开一份)。
- 假设选中的会话所属目录在对应主机上已不存在(插件端即工作区目录已被删除),当确认选择时,则不切换,给出「该会话的目录已不存在」提示;当前会话及其内容保持原状、可继续使用。
- 假设目标会话记录在磁盘上不存在或已损坏,当确认选择时,则切换不发生、给出失败提示,当前会话保持原状(桌面端按既有规则同时从会话索引移除该条目)。
- 假设当前会话正在流式输出、或存在正在运行的后台任务,当用户输入
/resume时,则沿用该宿主对既有切换入口的守卫口径:插件端与「历史对话」按钮一致(忽略选择并提示、不切换),桌面端维持其多会话并行语义(可切走,后台会话不被中断)。 - 假设选择器已打开,当用户按 Esc 或点击选择器外部时,则关闭选择器,当前会话、消息与输入框草稿保持原样。
- 假设插件端(VSCE / JB)的「历史对话」按钮与
/resume命令,当任一入口打开列表时,则两者打开同一个选择器、同一份列表(同一范围、同一搜索与选中行为);/resume不得把列表范围扩大到工作区之外,也不允许出现两份范围不同的历史列表。(桌面端的选择器与侧边栏会话树采用两条刻意的来源,不受本条约束,见desktop/desktop-sessions.md。) - 假设磁盘上存在数百条会话,当选择器打开时,则列表在打开时按需读取并显示加载态,不阻塞输入框与当前对话;不因数量设隐藏上限(搜索用于缩小范围,而不是靠截断列表控制规模)。
用户故事:会话元数据头:真实创建时间与 git 分支(优先级:P1)
作为 wave -r 用户,我希望会话文件的 metadata header 持久化真实创建时间(createdAt)与创建时的 git 分支(gitBranch),以便列表能使用真实创建时间,并在多 worktree 场景区分同一仓库不同分支的会话。
为什么是这个优先级:SessionMetadata.createdAt 目前是列表扫描时即时生成的 new Date()(恒等于"当前时间",完全失真);多 worktree 场景的会话无法显示所属分支,只能靠目录名猜测。
独立测试:创建会话后检查 JSONL 首行为 metadata header 且含 workdir、createdAt、gitBranch 字段;列表返回的 createdAt 与 header 一致;非 git 目录创建会话时 header 不含 gitBranch。
验收场景:
- 假设创建新会话,当 session 文件写入时,则首行为 metadata header,含
workdir与createdAt(ISO 8601,会话创建时刻);目录为 git 仓库时含gitBranch(git branch --show-current结果),非 git 目录或执行失败时省略gitBranch。 - 假设会话文件带 metadata header,当会话列表(当前目录 / 全项目 / worktree 模式)返回
SessionMetadata时,则createdAt取 header 中的真实创建时间,而非每次扫描生成当前时间。 - 假设会话文件带
gitBranch,当wave -r选择器渲染会话行时,则行尾显示该分支标签(如[main]);无分支信息时不显示。 - 假设旧版会话文件(无 header 或 header 缺
createdAt),当列表返回时,则createdAt回退为列表生成时的当前时间(legacy 文件无创建时间记录,行为不劣化)。 - 假设git 命令执行失败或超时(非 git 目录、git 不可用、目录被删除),当创建会话时,则 header 省略
gitBranch,创建流程不中断、不报错。
用户故事:会话自定义标题(重命名)(优先级:P2)
作为用户,我希望给会话起一个自己的标题,而不是永远显示首条用户消息截断出的那 30 个字符,以便在会话列表里一眼认出某段对话。
为什么是这个优先级:标题目前完全不可改(全仓无重命名入口),想换个说法只能重开会话;而它是会话列表里唯一的辨识信息。属于体验增强而非阻塞性缺陷,故 P2。存储侧参照 Claude Code:标题作为保留条目追加进会话自己的 JSONL 文件(custom-title),而不是另建索引文件——这样标题随会话文件走,任何读该文件的宿主(CLI wave -r、GUI /resume、插件端头部)都能拿到,无需额外的同步通道。
独立测试:对一个已有会话重命名后,检查该会话 JSONL 末尾多出一条 {"type":"custom-title","customTitle":"…"} 保留条目,而 metadata header 与既有消息行逐字节不变;列表返回该会话的 customTitle;继续追加超过 64KB 的消息,分别验证「正常退出」「压缩」「重新恢复该会话」三条路径之后再次列表,标题仍为自定义标题而非首条消息;再对已有标题的会话执行 /rewind,验证重写后的文件末尾仍带 custom-title 条目。
验收场景:
假设用户对某会话设置自定义标题,当宿主持久化时,则必须向该会话自己的 JSONL 文件追加一条保留条目
{"type":"custom-title","customTitle":"<标题>","sessionId":"<会话 ID>"}——不得改写 metadata header(保持「创建时写入一次、永不重写」的既有契约)、不得改动任何既有消息行、不得为此新建旁路索引文件。假设会话文件带
custom-title条目,当listSessions/listAllSessions(当前目录 / 全项目 / worktree 三种模式)返回SessionMetadata时,则必须带上customTitle字段。假设会话既有自定义标题又有首条用户消息,当渲染会话标签时,则显示优先级必须为「自定义标题 > 首条用户消息截断 30 字符 >
新对话」;只有自定义标题缺失时才回退到截断规则。假设会话文件在末尾带
custom-title保留条目,当加载该会话(恢复 //resume/ 全量线程读取)时,则该条目不得作为消息出现在对话流中,也不得参与lastActiveAt(末条消息时间)、latestTotalTokens(尾窗 token 扫描)与首条消息提取——读取侧的每个入口都必须显式跳过全部保留条目类型(metadata、custom-title)。假设会话已重命名,当出现下列任一「会话收尾 / 会话接续」时机时,则若该会话存在自定义标题,必须把它重新追加到文件末尾(重追加前必须先做一次尾窗读刷新,吸收其它进程——CLI 选择器、SDK 宿主——刚写入的更新值,再落内存持有值):
- 正常退出:
Agent.destroy()收尾时,且必须在saveSession()之后独立成步(saveSession()无新消息会提前 return,挂在其内部会被跳过)。对应 Claude Code 的registerCleanup(sessionStorage.ts:458)。 - 压缩结束:
compactMessagesAndUpdateSession追加完压缩块之后。一个挂点同时覆盖手动/compact与请求前自动压缩两条入口(二者共用aiManager.compactConversation)。对应 Claude Code 的compact.ts:711与:1057两处。 - 接管恢复:
restoreSession成功加载目标会话之后。对应 Claude Code 的adoptResumedSessionFile(sessionStorage.ts:1533)——让标题在恢复的当下就回到 EOF,而不是等这次会话再退出;没有这一步,「恢复一个标题已滑出尾窗的旧会话」在列表里仍看不到标题。 - 切换会话(切走):
restoreSession保存并结束当前会话的那一步(interactionService.ts发SessionEnd("resume")的同点)——语义等同该会话的收尾。 /rewind之后:rewriteSessionFile是整文件重写、只序列化消息,会物理丢掉末尾的custom-title保留条目,重写后必须补回标题。wave 独有——Claude Code 的 rewind 是 checkpoint 式、不重写转录文件。(同一处还会丢掉 metadata header,那是与标题无关的既有缺陷,本轮只补标题,header 的丢失另行跟踪,见边界情况。)
不做「首次物化」时机(Claude Code 的
sessionStorage.ts:983):CC 在该点补写的是mode/agentSetting这类「启动期已确定、文件尚未创建」的字段,而 wave 的自定义标题在renameSession时就立即写盘,不存在「内存有、盘上无」的窗口;将来新增启动期字段时再补。也不做/clear:它生成新的会话 ID,标题属于旧会话、不应继承。函数命名对齐 Claude Code 的
reAppendSessionMetadata;字段本轮只含custom-title,形状按「进程持有的保留条目集合」预留扩展,不提前引入无生产者的字段。改名的即时可见由「改名即追加到当时 EOF」保证;活会话期间标题被消息推出尾窗的窗口期是刻意取舍,见边界情况。- 正常退出:
假设会话尚无 transcript 文件(仅有 meta 消息、尚未物化),当用户重命名时,则必须先物化该会话文件再写入标题(重命名是用户显式动作,不受「meta-only 会话不落盘」约束);后续消息写入同一文件。
假设用户提交的标题 trim 后为空,当保存时,则必须什么都不做——不发请求、不写条目、不改变已有标题(空串不是「清除标题」的信号)。
假设写盘失败(权限不足、磁盘错误、远端不可达),当重命名结束时,则必须让调用方感知失败,以便界面把已乐观上屏的新标题回滚为原标题——不得静默留在「看起来成功了」的状态。
假设旧会话文件不含
custom-title条目,当列出与渲染时,则行为必须与现状完全一致(回退首条用户消息截断),不报错、不补写。假设会话文件不存在或中段损坏,当重命名或列出时,则沿用既有容错口径(缺失视为不存在、损坏跳过),不得因自定义标题引入新的崩溃路径。
用户故事:会话重命名入口:CLI 与 IDE 插件(优先级:P2)
作为 CLI / IDE 插件用户,我希望就地给当前会话或列表里看中的那条会话改标题,以便不必重开会话就能把某段对话标成自己认得出的名字。
为什么是这个优先级:与桌面端侧边栏同时做(三端共用同一份存储与显示优先级)。CLI 侧照 Claude Code 的两条腿:/rename 命令 + 选择器内就地改名(Ctrl+R);插件端入口只能是头部标题本身(面板没有会话列表 UI,唯一的标题展示点就是头部)。桌面端的侧边栏行菜单见 desktop/desktop-sessions.md。
独立测试:CLI 内输入 /rename 我的标题 后检查会话文件末尾多出 custom-title 条目、头部与 wave -r 列表都显示新标题;在 wave -r 选择器里对某条会话按 Ctrl+R 改名后按 Enter,验证该行立即显示新标题;在插件端点击头部标题改成新名字,验证标题即时变化、重启 IDE 后仍是新标题。
验收场景:
- 假设用户在 CLI 输入
/rename <标题>,当命令执行时,则当前会话的自定义标题被写为<标题>(trim 后),并给出成功提示;命令不得要求先退出进程、不得改变会话 ID 或消息历史。 - 假设用户只输入
/rename(不带参数),当命令执行时,则给出一条用法提示(如Usage: /rename <title>)并保持原标题不变——本轮不做「无参数则用模型自动生成标题」(与 desktop/desktop-sessions.md 非目标「AI 自动生成标题」一致)。 - 假设当前会话正在生成回复(加载中),当用户输入
/rename <标题>,则命令照常执行(改名与生成互不影响),不中止当前回合。 - 假设
wave -r或会话内/resume的选择器已打开,当用户对聚焦的那条会话按Ctrl+R,则该行的预览区变为标题输入框(初值为该会话当前标题,见 desktop/desktop-sessions.md 的显示优先级),列表本身不关闭、不丢失范围与选中位置。 - 假设用户正在选择器内改名,当按 Enter,则保存新标题并退出编辑(回到普通列表模式,该行立即显示新标题);当按 Esc,则退出编辑并回滚为原标题(不提交、不关闭选择器)。
- 假设选择器内提交的标题 trim 后为空,当按 Enter 时,则什么都不做(不发请求、标题不变、退出编辑);重命名失败(文件缺失/不可写/远端不可达)时给出可见失败提示、标题保持原样。
- 假设选择器列出的是非当前会话(其它目录/其它 worktree/其它项目的会话),当用户对它改名,则写入该会话自己的会话文件(按其所属项目目录定位),并同步更新该行的显示——不得因为「不是当前会话」而拒绝,也不得误写到当前会话的文件。
- 假设插件端(VSCE / JB)聊天面板头部显示会话标题,当用户点击标题,则标题就地变为输入框(自动聚焦、内容为当前标题并全选),不弹出模态对话框、不改变面板布局、不清空对话。
- 假设用户正在头部行内编辑,当按 Enter 或点击输入框之外(blur),则保存新标题;当按 Esc,则回滚为原标题;当提交内容 trim 后为空,则什么都不做。行内形态不提供 Save 按钮,也不在保存期间禁用对话。
- 假设用户使用中文/日文等输入法,当处于组合输入中(
isComposing),则不得把 Enter / Esc 当作保存 / 取消处理;假设焦点在标题输入框,当用户按键,则按键不得冒泡触发宿主快捷键。 - 假设标题保存成功,当插件端面板重载或 IDE 重启,则头部必须仍显示自定义标题(标题随会话文件持久化,不依赖界面内存状态)。
- 假设同一条会话先由 CLI 改名、再在 IDE 插件或桌面端打开,当任一宿主显示其标题时,则必须显示同一个自定义标题——三个宿主共用同一份存储(会话文件里的
custom-title保留条目)与同一套显示优先级,不得各存一份。标题被消息推出尾窗的窗口期内允许列表侧暂时回退为首条消息(见边界情况的刻意取舍),该会话被恢复或其宿主正常退出后必须回到自定义标题。
边界情况
- 权限问题:如果无法创建或写入会话目录,系统应优雅地失败并给出清晰的错误提示。
- 损坏的文件:如果 JSONL 文件包含无效 JSON,在列出时应跳过,在加载时应视为不存在。
- 空会话:没有消息的文件应使用文件的修改时间作为
lastActiveAt时间戳。 - 元数据消息:当会话文件尚未物化(无已保存消息)且所有未保存消息均为
isMeta: true(如 SessionStart hook 上下文)时,跳过持久化;meta 消息保留在内存中,随第一条真实 user/assistant 消息一并写入。 - 路径编码冲突:使用哈希后缀确保长路径即使被截断也具有唯一性。
- 会话标题为空:IDE 插件会话首条消息缺失或为空时,标题回退为"新对话"。
- 流式期间新建对话:IDE 插件在 AI 响应流式输出期间禁用"新建对话"按钮,防止中断中的会话被意外清空。
- 后台任务运行期间新建对话/加载历史:IDE 插件(VSCE/JB)在当前会话存在正在运行的后台任务(类型
shell/subagent/workflow、状态running,消息实时推送至 webview)时,与流式输出期间同等对待——"新建对话"按钮禁用、历史会话选择被忽略,防止原地清空/替换会话使正在运行的后台终端工具、后台子代理或后台工作流被意外中断或与 UI 脱离;任务结束后守卫自动解除。桌面端为多会话并行模型("新建对话"创建独立会话、切换会话不中止后台会话),不适用本守卫。 - 会话 ID 重复:同一 sessionId 出现在多个项目目录时,聚合列表按最后活跃时间取最新(见"跨目录会话聚合列表"故事)。
- 非 git 目录:
git worktree list失败时按"无 worktree"处理,Ctrl+W不生效。 - 长路径解码兜底:项目目录名被哈希截断时,全项目模式展示编码名兜底;恢复仍按目录内文件定位,不受展示影响。
- 剪贴板不可用:跨项目提示的
cd命令复制失败时仍显示命令文本,用户可手动复制。 - worktree 目录已删除:选中的会话属于已删除的 worktree 时按"其他项目"处理,给出
cd命令提示而非报错。 - 选择器语义单一来源:同一份会话在
/resume与wave -r两处入口必须落在同一个恢复类别(原地 / 同仓库 worktree / 跨项目),范围切换后的列表内容与快捷键提示也必须一致;不允许两处各自实现导致行为漂移。 - 会话内切换的落盘目录:切换会话必须使会话 ID 与落盘项目目录同步变化。只换 ID 不换目录会把目标会话的后续消息写进当前目录,造成同一 sessionId 在两个项目目录各有一份文件。
- 连续切换:会话内
/resume可重复执行,含 worktree ↔ 原目录、worktree ↔ 另一 worktree;不得因为"当前已处于上一次切换后的目录"而拒绝再次切换。 - 切换后的目录派生缓存:项目记忆、规则与目录相关的系统提示段在切换后必须失效重建。沿用旧目录的缓存会让模型读到另一个项目的上下文,且不会有任何报错。
- worktree 会话状态不随会话恢复:会话记录只持久化创建时的工作目录,未持久化 worktree 会话状态(分支、是否由 hook 创建等)。因此切换后只对齐工作目录,worktree 会话状态不恢复——这是刻意取舍,不是缺陷。
- 读文件缓存不随会话恢复:切换后不重建"本会话已读文件"的缓存(既有 gap,本轮不做),模型可能重复读取已读文件。
- 无消息的当前会话:切换前若当前会话尚无任何消息,不因切换时的自动保存而产生空的会话文件。
- metadata header 向后兼容:
createSession不传 metadata 时仍写空文件(旧调用方不变);读取侧缺 header 的 legacy 文件走 decodeSync/当前时间回退。 - metadata header 只读:header 是 append-only 文件的首行,创建后不重写;
gitBranch为创建时刻快照,不随后续分支切换更新。唯一会整文件重写转录的是/rewind:它不修改 header 内容,但必须原样保留首行(连同custom-title条目),见ui/rewind-command.md。 - gitBranch 为 per-session 数据:同一 workdir 不同时间创建的会话可能在不同分支,
gitBranch按会话文件逐一读取,不做目录级缓存。 - GUI 列表单一来源:GUI 的
/resume(以及插件端「历史对话」)与 CLI 的wave -r必须读同一份磁盘会话来源——不允许 GUI 依赖界面自己的会话索引,否则 CLI 或其它客户端创建的会话不可见(桌面端现状即为此问题)。"同一来源"不等于"同一范围":范围为各宿主的既有语义(插件端=工作区目录、桌面端=全部项目与主机、CLI=当前目录及其Ctrl+W/Ctrl+A展开)。 - 插件端范围不扩大:插件宿主无法切换工作区,因此
/resume的列表范围必须与头部「历史对话」按钮一致——不得因"统一为全部项目"而列出工作区之外的会话(跨项目恢复属于桌面端与 CLI 的能力)。 - 桌面端侧边栏索引语义不变:
/resume的磁盘来源只服务选择器本身,不改动侧边栏会话树与状态看板沿用会话索引的既有语义(被/resume恢复的索引外会话登记进索引,见desktop/desktop-sessions.md);两条来源并存是刻意设计,不是待清理的重复。 - 选择器形态按宿主既有语言:插件端沿用面板头部下拉弹窗(与"历史对话"按钮同一组件、同一位置);桌面端用居中模态对话框,且必须渲染在应用根层——桌面端分屏时根 ChatApp 不挂载
chatContainer,挂在其中的浮层在分屏下会打不开(既有教训)。 - 桌面端会话范围随当前主机:桌面端
/resume只列"当前对话所属主机"磁盘上的会话(本地对话列本地、远程对话列那台主机),与登录态、最近目录、会话索引的既有"当前主机"语义一致;不做跨主机聚合。切换主机/焦点分屏后再次打开选择器即为该主机的列表。 - 桌面端主机的两种失败要区分:当前主机不可达时给出可见失败提示(不得静默显示空列表,否则用户会误以为没有历史会话);"目录确实不存在"(
ssh test -d判定)才走目录缺失提示,探测失败不得据此判定(见desktop/desktop-sessions.md的「远端主机不可达时不得删除会话」)。 - 不做会话存活检测(本轮不做):会话在 wave 中没有锁,同一段会话可能正被终端或另一个进程使用;GUI 选择器不检测「该会话仍在别处打开」,也不因此拒绝切换,多点同时追加写入的风险留待独立议题(
/resume只负责切换)。 - 不做外部会话的信任确认(本轮不做):从磁盘发现的会话不按"外部导入"要求二次确认,选中即切换。
- GUI 选择器不提供 fork:选中即继续该会话本身,不复制为新会话(
--fork-session类语义不在本轮)。 - 保留条目是 SDK 私有契约,读取侧必须逐个入口跳过:会话 JSONL 里除「带
timestamp的真实消息」之外的行都是保留条目。全量读取、末条消息、尾窗 token 扫描、首条消息提取每个入口都必须跳过全部保留类型——漏一处就会把条目当成消息(lastActiveAt变成 Invalid Date、列表排序错乱,且不会有任何报错)。 - 重追加是稀疏触发,活会话窗口期内列表可回退(刻意取舍):自定义标题落到文件末尾的时机只有五个——①改名时(追加到当时 EOF);②正常退出(
Agent.destroy());③压缩结束;④接管恢复(restoreSession加载完成后);⑤/rewind重写后(完整清单与理由见验收场景 5,对齐 Claude Code 的reAppendSessionMetadata)。其余时间继续追加消息会把标题行逐渐推出 64KB 尾窗(一轮超大工具输出即可直接推出):窗口期内其它宿主(另一终端wave -r、GUI/resume)列出该会话会回退为「首条用户消息截断」,持有该会话的宿主不受影响(标题在内存);进程被硬杀(未走退出收尾)同样落在这个窗口里。这是对齐 Claude Code 稀疏触发的刻意取舍,换取每次保存不再多付一次 64KB 尾窗读 + 一行标题追加;该会话下次正常退出 / 压缩 / 被恢复后重新追加,标题回到 EOF 自愈。 - 不做列表侧 head 兜底(刻意取舍):不照抄 Claude Code 的 head 64KB 扫描(
listSessionsImpl.ts:97-103的tail || head双窗)——head 兜底只对「改名发生在文件尚小于 64KB 时」的会话有效,却要给列表里每个长会话文件多一次头部读。代价:「早期改名 + 标题已滑出尾窗 + 尚未自愈」的会话在列表里也回退首条消息(Claude Code 靠 head 兜底能救这一部分)。 - 恢复会话必须读回标题并在当场追加回末尾(自愈的来源):恢复路径整读会话文件时必须收集
custom-title作为内存值,并在加载完成后立即重追加到 EOF(验收场景 5 的第 3 项,对齐 Claude Code 的adoptResumedSessionFile)——否则标题一旦滑出尾窗,退出时既没有内存值、尾窗也读不到,标题将永久退回首条消息;而且在本次会话退出之前,其它宿主在列表里也一直看不到标题。有这一环,任何「滑出」都只是窗口期而非数据丢失(文件里的条目 append-only 永不删;/rewind是唯一物理删除内容的路径,删的是被回退的消息、保留条目原样留下,它仍按验收场景 5 第 5 项把标题重追加到 EOF,见ui/rewind-command.md)。 /rewind重写保留文件级保留条目(含 metadata header):重写不重建文件,而是逐行筛选原文件(见ui/rewind-command.md),首行的{"type":"metadata",...}与既有custom-title原样保留、按RESERVED_ENTRY_TYPES注册表搬运,故重写后readMetadata()仍返回创建时的workdir/createdAt/gitBranch、readCustomTitle()仍读得到;标题另按验收场景 5 第 5 项重追加到 EOF,保证列表在其尾窗里看得到。- 重命名不改变会话身份:会话 ID、文件路径、创建时间、列表排序(按最后活跃时间)与 worktree 归属均不受重命名影响。
- 标题长度上限 200 字符:自定义标题在写入边界(UI 输入 + 宿主校验)截断/拒绝到 200 字符,与同仓其它对象(项目/头部会话名 200)一致——不照抄 Claude Code 的「会话标题不设限」。
- 不做 AI 自动标题(本轮不做):不引入
ai-title保留条目、不额外调用模型;没有自定义标题时继续用首条用户消息截断兜底。因此 CLI/rename不带参数时不生成名字,只提示用法。 - 不做全局快捷键:不照抄 Claude Code 的
⌘/Ctrl+⌥+R——CLI 只认选择器内Ctrl+R(与既有Ctrl+A/Ctrl+W同族),GUI 只认鼠标入口。 - 插件端历史弹窗列表不提供改名入口:插件端的改名入口只有聊天面板头部的标题本身(面板没有常驻会话列表 UI);桌面端的入口是侧边栏会话行菜单 + 头部标题。
/resume与「历史对话」列表内的改名只在 CLI 侧提供。 - 改名不引入派生能力:不做按标题搜索排序、批量改名、标题历史/撤销。