文档会话列表

14 · 会话列表

会话列表(Sessions List)让用户在 Web UI 内浏览历史会话一键恢复任意会话继续对话——无需手动记忆或输入会话 id。会话历史一直由底层持久化(每个会话按其工作目录 cwd 分桶,含 id / cwd / 创建·修改时间 / 可选名称等头部元数据),此前却从未在界面暴露;本特性把这份历史以一个可重定位的只读面板嵌入聊天界面,不占用、不替换既有对话区。


1. 它解决什么 / 能力边界

In scope

  • 全局视图:列出本机全部工作目录下的会话,不按项目目录区分(列表项仍带 cwd,故「哪个项目的会话」在项上仍可见)。
  • 列表项展示足以区分会话的轻量元数据:名称或标识、时间(创建或最近修改)、所属工作目录。
  • 从列表整行点击直接恢复某历史会话进入对话,回放历史上下文。
  • 大规模会话集合下的分页(keyset 游标续取)与倒序排序
  • 展示位置由配置控制(默认左侧栏),并可重定位到其它界面区域。
  • 每个会话项的项级管理:删除(不可逆物理删除)、重命名(持久化为最新显示名)、收藏 / 置顶(独立偏好、顶部分区置顶)——详见 §9 会话项操作。三项写操作可由部署方经门控整体关闭。

Out of scope

  • 会话的归档(archived 状态)、分叉(fork)、导出(下载 jsonl / markdown)、搜索·全文检索(本期不做,留待后续)。
  • 批量选择 / 批量删除;收藏项的手动拖拽排序 / 分组 / 打标签
  • 列表项展示消息条数、首条消息摘要等需读取会话正文的重型字段——本期只用文件头部轻量元数据。
  • 跨机器 / 远端会话聚合与管理(仅限本机持久化的会话)。
  • 新建会话入口(已由现有界面提供,不在本特性内重做)。

设计上:只读列表链路里服务端只负责「读 + 排序 + 分页 + 门控」,前端只负责「展示 + 切换 + 触发恢复」,恢复本身复用既有的 resumeId 冷恢复链路,不改动会话运行 / 流式内核,不改动持久化存储 schema。项级写操作(删除 / 重命名 / 收藏,§9)在此之上叠加一组独立的写接缝,同样不触碰运行内核与冷恢复链路:删除复用会话存储既有的物理删除、重命名复用其 append 事件模型、收藏落一份独立的用户偏好文件。


2. 两类视图

会话列表恒为全局GET /api/sessions 返回本机全部工作目录下的会话,无视图参数、无部署门控。

★ 行为变更:早期版本提供「当前目录 / 系统(全机器)」两类视图(scope=cwd|all),系统视图默认关闭、需 NEXT_PUBLIC_PI_WEB_SESSIONS_GLOBAL 开启。现已彻底移除 scope 参数、globalEnabled 门控与面板上的视图切换 Tab —— 用户在多个项目间穿梭时,「当前目录」这个切面制造的是隔阂而非聚焦。列表项保留 cwd,项目归属仍可见。


3. 展示位置与重定位(slot)

面板经宿主 PiChatslots 注入,默认位于左侧栏(sidebar,以追加方式占用所在区域,不替换、不遮挡既有对话区;同一区域若存在扩展(webext)贡献的内容,遵循既定宿主优先级与其共存。

展示位置由 NEXT_PUBLIC_PI_WEB_SESSIONS_SLOT 控制,取值限 PiChatSlots 的可承载块级面板子集:

取值位置
sidebar(默认)左侧栏
header顶部
footer底部
empty空态插槽区

非法 / 缺省取值一律回退 sidebarcomponents/chat-app.tsx)。宿主据此把 <SessionListPanel> 放入对应 slot(sessionListSlots()components/chat-app.tsx)。

接线集中在宿主 chat-app.tsx,UI 包内不读 env——SessionListPanel 的数据源与回调均由宿主注入,组件本身不持 pi 接线。


4. 整行点击恢复

列表项整行可点击(无独立「恢复」按钮):

  • 每项显示 name ?? sessionId(主标题)+ 时间 · cwd(副标题,时间取 updatedAt ?? createdAtsession-list-panel.tsx)。
  • 点击经 onResume(sessionId) 上抛宿主(session-list-panel.tsx)。
  • 宿主以 window.location.assign('/session/:id') 导航至该会话路由(components/chat-app.tsx)。

冷恢复链路:/session/:id 路由把 resumeId 传入 chat-app,经 usePiSessionresumeId 重建会话——这条链路同时回溯 agent source(否则 create.source 会回退为 ".",扩展的 region slots / background 等失效),随后 GET /sessions/:id/messages 回放历史消息,使对话从中断处接续。恢复失败时不破坏当前正在进行的会话。

点击列表项
  → onResume(sessionId)                          [SessionListPanel]
  → window.location.assign('/session/:id')       [chat-app 宿主]
  → resumeId 进 chat-app → usePiSession 重建会话   [冷恢复 + 回溯 agent source]
  → GET /sessions/:id/messages 回放历史           [接续上下文]

4.1 会话名来源与持久化

列表项主标题的显示名(name ?? sessionId)读的是 store 的 SessionMeta.name,其读取口径统一为「创建时头部名 → 最新 session_info.name」。写入这个名字有两条来源,共用同一 session_info append 事件模型

  1. 用户重命名§9.2):POST /sessions/rename → 服务端 store.append 一条 session_info{ name },成为最新显示名。
  2. 自动会话标题扩展:扩展经 ctx.ui.setTitle(t) 设置的标题原本只发一帧驱动前端瞬态 ambient.title不写会话名(故不进历史列表)。wireSessionTitlePersistence 以 prototype-patch session.bindExtensionssetTitle 包装为「先调原 setTitle(保留 ambient 展示)→ 再 best-effort persistTitleappendSessionInfo」,落 sqlite/postgres + pi 原生 fs(packages/server/src/runner/session-title-wiring.ts:1-20)。

两条来源写的是同一个会话名字段,故自动标题与手动重命名互相覆盖、后写为准;且都经 appendSessionInfo 持久化,冷恢复后保留——恢复某会话时列表里显示的仍是最后一次写入的名字。本特性(重命名入口)只新增「写入新名」的用户路径,不改动读取口径。


5. HTTP 契约

只读列表端点经现有 routes: 注入接缝挂载(createSessionListRoutes(),与 createConfigRoutes 同构),与内置的 POST /sessionsGET /sessions/:id/* 共存。

GET /api/sessions?limit=&cursor=&q=
→ ListSessionsResponse

/api 前缀去哪了:服务端宿主是 Hono,整个 /api/* 面收敛为一条 app.all('/api/*') 转发到 createPiWebHandler 单例(server/index.ts);handler 内部路由不带 /api 前缀(注册为 /sessions/sessions/delete 等)。故本章面向客户端一律写 /api/sessions/...(浏览器实际请求的路径),若你对照 packages/server 源码会看到路由声明为 /sessions/...——两者指同一端点,差的只是 Hono 层剥掉的 /api basePath。列表本身是纯读链路,恢复则走 SPA 的 /session/:id 路由(src/app.tsx:24src/routes/session.tsx:21idresumeId)。

请求参数(query,packages/protocol/src/transport/rest-dto.ts:187

参数取值说明
limit正整数单页上限,默认 50,硬 clamp 到 200
cursorstring不透明 keyset 游标,续取下一页

响应rest-dto.ts:222

{
  "sessions": [
    { "sessionId": "...", "cwd": "...", "createdAt": "...", "updatedAt": "...", "name": "..." }
  ],
  "nextCursor": "...",     // 缺省表示无更多
}

试一下(dev 下 API 在 :3000,浏览器 UI 在 :5173;curl 直打 API 端口):

# 首页(全局,limit=50)
curl -s 'http://localhost:3000/api/sessions?sessionId=<活跃会话id>&limit=20' | jq
 
# 取下一页:把上一次响应里的 nextCursor 原样带回
curl -s 'http://localhost:3000/api/sessions?limit=20&cursor=<上页 nextCursor>' | jq '.sessions | length'
 

预期:返回 { "sessions": [...], "nextCursor": "..." }

分页(keyset):游标是 base64url(JSON.stringify({ ts, id }))ts = updatedAt ?? createdAtid = sessionId,取自上一页最后一项;服务端在排序序列中返回严格位于 {ts,id} 之后的项,保证续取不重复已返回会话,最终收敛(游标编解码与倒序比较 session-list-routes.ts:70-112,排序 + 切片 + nextCursor 生成 256-263)。分页在内存切片完成,store 仅提供 list(cwd) / listAll() 的轻量 header 元数据。

错误

状态code触发
400INVALID_REQUESTlimit / cursor 非法(响应含出错字段)
500INTERNAL存储读取异常(前端展示可重试错误)

store 惰性单例:首次请求时 await createSessionEntryStore(storeConfig) 构造并缓存,配置与冷恢复同源(sessionStoreConfigFromEnv()),保证列表与恢复读到同一后端(session-list-routes.ts:169-179)。

5.1 会话操作端点(删除 / 重命名 / 收藏)

项级写操作经另一组注入路由 createSessionActionsRoutes() 挂载(与 createSessionListRoutes 并列注入同一 routes: 接缝,packages/server/src/session-actions/session-actions-routes.ts),共四个端点,全部落在 /sessions/** 段:

MethodEndpoint请求体响应门控错误
POST/api/sessions/delete{ "sessionId": string }{ "ok": true }写门控400 / 403 / 500
POST/api/sessions/rename{ "sessionId": string, "name": string }{ "sessionId": string, "name": string }写门控400 / 403 / 404 / 500
GET/api/sessions/favorites{ "sessionIds": string[] }不受门控500
POST/api/sessions/favorites{ "sessionIds": string[] }{ "sessionIds": string[] }写门控400 / 403 / 500

为什么全是 POST 且路径无 :id:Router 对任何含 :id 的路由做内存会话存在性门控(router.ts:168),历史(非运行中)会话必然 404;故这些端点一律:id 路径参数sessionId 走请求体/查询,绕过门控作用于历史会话。写操作统一 POST(既有 /sessions/** 转发器只导出 GET/POST/DELETE),且刻意避开内置 DELETE /sessions/:id(停内存会话,语义完全不同)。

逐端点行为(均以 zod schema 校验请求体,packages/protocol/src/transport/rest-dto.ts:337-377):

  • POST /sessions/deleteDeleteSessionRequestSchemasessionId 非空)。命中 store.delete(sessionId) 物理删除(含头部与全部事件条目);目标已不存在(SessionStoreNotFoundError)视为幂等成功{ ok: true }),而非报错。
  • POST /sessions/renameRenameSessionRequestSchemasessionId 非空;name 原串 ≤ 200trim 后非空)。先 store.readHeader(sessionId) 探测存在性——不存在返回 404 SESSION_NOT_FOUND(不为不存在的会话命名);存在则 store.append 一条 session_info{ name, id: randomUUID(), parentId: null, timestamp },使其成为最新显示名(服务端以 trim 结果落库,响应回显该名)。
  • GET /sessions/favorites — 无请求体,返回已收藏的 sessionIds 集合(去重、无空串);不受写门控,只读部署下仍可读出收藏用于置顶展示(Req 4.9)。
  • POST /sessions/favoritesSetSessionFavoritesRequestSchemasessionIds 字符串数组)。全量替换收藏集合并原子落盘,回读落盘结果返回(经 store 去重容错),前端据此确认最新集合。

收藏是独立的用户偏好存储:收藏集合落 <agentDir>/session-favorites.json(形态 { "sessionIds": string[] }SessionFavoritesStorepackages/server/src/session-actions/session-favorites-store.ts),与只读的会话枚举完全独立——它记录的是「哪些 sessionId 被用户置顶」这一偏好,不属于会话事件持久化。文件缺失 / 坏 JSON 一律容错回退空集(list() 不使请求失败);set() 采用原子写(写 <file>.<pid>.<counter>.tmprename)避免半写被读到。它与启动导航区(LauncherRail)用的 agent-source 收藏(agent-source-favorites.json / listFavorites / setFavorites语义不同、文件独立、互不复用

错误码

状态code触发
400INVALID_REQUEST请求体不合 schema(缺 sessionId、空/超长 namesessionIds 非数组等)
403SESSIONS_MANAGE_DISABLED写门控关闭时命中删除 / 重命名 / 写收藏(不改动任何存储)
404SESSION_NOT_FOUND重命名的目标会话在存储中不存在
500INTERNAL存储读写异常(前端展示可见错误并回滚乐观更新)

对应 PiClient 方法(packages/react/src/client/pi-client.ts):deleteSessionHistory(sessionId)CommandAckrenameSession(sessionId, name)RenameSessionResponselistSessionFavorites() / setSessionFavorites({ sessionIds })ListSessionFavoritesResponse。命名刻意与既有 deleteSession(停内存会话)/ listFavorites(agent source 收藏)区分,避免混淆。


6. 前端状态与交互

SessionListPanelpackages/ui/src/elements/session-list-panel.tsx)的三态可见:

  • 加载中:首屏加载显示 loadingLabel(默认「加载中…」)。
  • 空态:当前范围无会话时显示 emptyLabel(默认「暂无会话」),而非报错或空白。
  • 错误:加载失败显示 errorLabel + 可点击的重试按钮,而非静默空白。

数据源变化会重置并重新加载首页(已无视图切换 Tab)。nextCursor 存在时显示「加载更多」按钮续取并追加。组件内有竞态守卫reqIdRef),快速续取 / 刷新时丢弃过期响应(session-list-panel.tsx:156177184)。

列表项、Tab、三态、加载更多均带 data-pi-session-list-* 属性,供 e2e 与宿主定位。


7. 配置与环境变量小结

变量默认作用读取处
NEXT_PUBLIC_PI_WEB_SESSIONS_SLOTsidebar面板展示位置(sidebar/header/footer/emptybootstrap 下发 → chat-app.tsxsessionsSlot()
NEXT_PUBLIC_PI_WEB_SESSIONS_MANAGE启用写门控:设为 false / 0 关闭项级删除 / 重命名 / 收藏(前端隐藏写入口 + 服务端写端点 403);其余取值(含未设)默认启用。读收藏(GET /sessions/favorites)不受此门控bootstrap 下发 → chat-app.tsx(前端 manageEnabled)+ pi-handler.ts:477(注入 createSessionActionsRoutes({ manageEnabled })

三者虽仍叫 NEXT_PUBLIC_*,但已不是 Next 时代的构建期内联值——现由服务端在 GET /api/bootstrap 请求时运行时读取并下发前端(见 §7.1)。改后重启服务端即生效,无需重新构建;对 CLI 用户(pi-web 二进制本无 build 步骤)尤为关键。会话存储后端由既有 sessionStoreConfigFromEnv() 决定,与冷恢复同源;会话收藏另落独立文件 <agentDir>/session-favorites.json(不改动会话存储 schema,见 §5.1),本特性不引入新的存储后端配置。

7.1 门控为何运行时生效(GET /api/bootstrap

Next 迁移到 Vite+SPA 后,这套门控的读取方式根本改变了,务必理解,否则会照着过时的「重新构建」指引白费力气:

  • Next 时代NEXT_PUBLIC_* 在客户端组件里被构建期内联成字面量——CLI 用户在运行时设置这些 env 其实不生效lib/app/runtime-features.ts:4-8 文件头明确记录了这个坑)。
  • 现在(SPA):服务端 buildBootstrap() 在每次 GET /api/bootstrap 请求时读 process.envserver/bootstrap.ts:58-102),把 sessionsGlobal / sessionsManage / sessionsSlot 等派生成 RuntimeFeatures 下发;SPA 启动时经 setRuntimeFeatures() 注入一次(src/bootstrap.tsx:140),此后 chat-app.tsx 全部门控经 getRuntimeFeatures() 惰性求值(components/chat-app.tsx:210-286)。于是 pi-web --canvas 这类运行时开关终于能工作

因此本章所有 NEXT_PUBLIC_PI_WEB_SESSIONS_* 门控的正确操作口径是:改 env → 重启服务端node dist/server.mjspnpm dev),不需要也无法靠「重新构建」。前后端读同一份 env,服务端门控(lib/app/pi-handler.ts:464-478)与前端下发的判定逐字段一致。


8. 故障排查 / 注意事项

  • 面板位置不对:检查 NEXT_PUBLIC_PI_WEB_SESSIONS_SLOT 取值是否落在 sidebar/header/footer/empty 之内;非法值静默回退 sidebar
  • 大量历史下首屏慢:全局视图走 listAll 全量扫桶 + 内存切片,开销随历史规模线性——分页(limit 默认 50、上限 200)与会话元数据索引(标题/来源快读,见 session-meta-index spec)是主要缓解手段。
  • 点恢复后扩展 UI(region slots / background)失效:恢复须经 /session/:id 冷恢复链路回溯 agent source;直接以 resumeId 之外的方式重挂会丢失 source。
  • ⋯ 操作菜单不出现 / 删除·重命名·收藏写入口消失NEXT_PUBLIC_PI_WEB_SESSIONS_MANAGE 被显式设为 false / 0(写门控关闭),或改后未重启服务端(该值经 GET /api/bootstrap 运行时下发,见 §7.1)。此时服务端也会对写请求返回 403 SESSIONS_MANAGE_DISABLED,属只读部署的预期行为;注意「收藏」分区仍会按已读收藏置顶展示(读收藏不受门控)。
  • 删的是当前正在查看的会话:删除成功后宿主会 window.location.assign("/") 导航至新会话空态,其余进行中的会话不受影响。
  • 重命名报 404:目标会话在存储中已不存在(如并发删除);重命名不会为不存在的会话创建记录。删除则相反——删一个已不在的会话按幂等成功处理。

9. 会话项操作:重命名 / 收藏 / 删除

在整行点击恢复(§4)之外,会话列表的每个会话项还带一个右侧 操作菜单,提供三项历史会话管理能力——重命名收藏 / 置顶删除——让用户在不离开聊天界面的前提下整理会话历史。菜单与项级交互由 SessionItemMenupackages/ui/src/elements/session-item-menu.tsx)承载,挂进 SessionListPanel 的两条渲染路径(普通列表与启动导航区 LauncherRail)。

9.1 操作菜单入口与「不误触恢复」

  • 每个会话项右侧渲染一个操作菜单触发入口( 按钮),悬停 / 键盘聚焦时显现,其余时候可隐藏以保持列表整洁。
  • 触发入口 stopPropagation,激活菜单不会触发整行的 onResume(恢复)——菜单交互与整行恢复互不误触。
  • 菜单展开后,点击菜单外区域或按 Esc 关闭且无副作用。
  • 写入口仅在写门控启用且相应回调在场时渲染;门控关闭时整组写入口隐藏(详见 §9.5)。
  • 菜单、各菜单项、内联编辑输入、删除确认等均带稳定 data-* 定位属性(data-pi-session-item-menu / -menu-content / -rename / -delete / -favorite / -rename-input / -delete-confirm / -delete-confirm-btn / -delete-cancel),供 e2e 与宿主定位(packages/ui/src/elements/session-item-menu.tsx)。

9.2 重命名(内联编辑 → 最新显示名)

  • 菜单选「重命名」进入该项内联编辑态,以当前显示名为初始值。
  • 提交一个 trim 后非空的名称 → 经 onRenameSession(id, name) 上抛宿主 → POST /sessions/rename → 服务端向该会话 append 一条 session_info,使其成为最新显示名。前端乐观改名后由宿主 bump 刷新拉权威态,名称跨刷新、跨视图一致
  • trim 后为空的提交不发写请求、直接退出编辑保留原名;Esc / 取消同样放弃编辑、不发请求。
  • 写失败(500 等)展示可见错误并回滚为原名。
  • 显示名的读取 / 派生口径沿用会话列表既有规则(创建时头部名 → 最新 session_info.name,与 auto-session-title 共用同一 session_info 事件模型与持久化路径,见 §4.1 会话名来源与持久化);本特性只新增「写入新名」入口,不改读取规则。

9.3 收藏 / 置顶(独立偏好存储)

  • 菜单选「收藏 / 取消收藏」→ 经 onToggleFavorite(id, favorite) 上抛宿主,宿主读→算→写:先 listSessionFavorites(),据目标态增删该 sessionId,再 setSessionFavorites({ sessionIds }) 全量替换落盘,回读结果更新界面。
  • 收藏以 sessionId 为键持久化在独立文件 <agentDir>/session-favorites.json(见 §5.1),与只读的会话枚举、与 agent-source 收藏均互不相干。收藏状态与列表视图无关。
  • 面板把 favoriteSessionIds ∩ 当前视图会话 求交,命中项在列表顶部以独立「收藏」分区置顶,并从普通列表中排除以免重复渲染;交集为空则不渲染该分区(不留空占位)。指向已删除会话的失效收藏 sessionId 因不在当前会话集合而被自然跳过,不报错、不渲染空条目。
  • 收藏项在收藏分区与普通列表中一致地展示名称、恢复入口与 菜单(同样可重命名 / 删除 / 取消收藏)。
  • 读收藏不受写门控:只读部署下已持久化的收藏仍会被拉取用于置顶展示,仅写入(收藏 / 取消收藏)被门控拒绝。

9.4 删除(二次确认 + 不可逆物理删除)

  • 菜单选「删除」→ 先弹二次确认dialog);确认前不发起删除,取消则列表不变。
  • 确认后经 onDeleteSession(id)POST /sessions/delete → 服务端 store.delete() 物理删除该会话的头部与全部事件条目。删除不可逆——之后该会话不再出现在任一视图、也无法再被恢复。
  • 删除成功后该项即时从列表移除(乐观移除 + 宿主 bump 刷新拉权威态),无需整页手动刷新
  • 删的是当前正在查看的会话时,宿主删除成功后 window.location.assign("/") 导航至新会话空态,其余进行中的会话不受影响。
  • 目标会话已不存在按幂等成功处理(仍从列表移除);删除因存储错误失败则展示可见错误并保留该项(不静默丢失、不误报成功)。

9.5 部署门控

三项操作(删除 / 重命名 / 收藏)由 NEXT_PUBLIC_PI_WEB_SESSIONS_MANAGE 整体门控,默认启用,面向只读 / 受限部署可整体关闭。门控是双重保险:

  • 前端:门控关闭 → 面板不渲染任何写入口( 写菜单项隐藏)。
  • 服务端:门控关闭 → 删除 / 重命名 / 写收藏端点一律返回 403 SESSIONS_MANAGE_DISABLED不改动任何存储;GET /sessions/favorites 例外,始终可读。

值经 GET /api/bootstrap 运行时下发,前端 chat-app.tsx 读取(manageEnabled = 值 !== "false" && 值 !== "0"),服务端 pi-handler.ts:477 以同一判定注入 createSessionActionsRoutes({ manageEnabled })——前后端读同一 env、语义一致,改后重启服务端即生效(见 §7.1)。

9.6 一致性与并发

  • 任一写操作成功 → 面板乐观更新(删除移除 / 重命名改名 / 收藏移动分区)+ 宿主 bump refreshSignal 拉权威态使显示与最新持久化一致,无需用户整页刷新(复用与新会话、auto_title 同一刷新通道)。
  • 某项有写请求在途时提供可感知的进行中反馈(如禁用重复触发),避免对同一项重复发起冲突请求。
  • 沿用面板既有 reqIdRef 竞态守卫:列表因其它原因刷新时,不因过期响应覆盖较新状态。
  • 菜单展开 / 内联编辑 / 二次确认等瞬态交互进行时,「当前会话高亮」「所在视图 Tab」等既有状态不被打断。

下一步 / 相关

  • 会话项操作端点(删除 / 重命名 / 收藏)与 /sessions/** 其余端点、GET /api/bootstrap 运行时门控下发 → 24 HTTP/SSE API 参考、本章 §5.1
  • 宿主 slots 与界面布局 → 12 Web UI 扩展
  • 会话管理写门控 NEXT_PUBLIC_PI_WEB_SESSIONS_MANAGEGET /api/bootstrap 运行时下发机制与环境变量总览 → 06 配置参考、本章 §7.1 / §9.5
  • 同属会话内建 UI 特性、随本章一起上提的排队 / 取回 → 15 消息队列