父级:frontend-spec.md · 对应后端:sessions-spec.md
覆盖组件:SessionList.tsx、App.tsx(sessionId 顶层协调部分)。
- 会话列表拉取与展示
- 会话标题 + 讨论内容搜索
- 新建 / 删除 / 切换会话
- App 启动时检测运行中会话集合,并仅在首次恢复时自动选中第一个运行中会话
- 切换会话时通知下游组件(
ChatV2重新加载,ModelSelector同步)
不职责:
- 会话内消息 →
ChatV2(见 frontend-chat-spec) - 会话重命名(标题由后端自动更新,前端仅在
onTitleUpdated触发时刷新)
const [currentSessionId, setCurrentSessionId] = useState<string>('');
const [refreshTrigger, setRefreshTrigger] = useState(0); // 触发 SessionList 重拉
const [executingSessionIds, setExecutingSessionIds] = useState<Set<string>>(() => new Set());
const [activeSlotSessionIds, setActiveSlotSessionIds] = useState<Set<string>>(() => new Set());
const [selectedModelId, setSelectedModelId] = useState<string>('');App 挂载
→ GET /api/sessions/running-sessions (reconcileRunningSessions, 立即一次 + 5s 周期)
→ App 标记全部运行中 session 与 active slot,首次 reconcile 返回运行态且当前无 session 时自动 setCurrentSessionId(first)
SessionList 挂载
→ GET /api/sessions/list (loadSessions)
用户输入搜索词
→ 300ms debounce
→ GET /api/sessions/list?q=... // 后端默认 keyword,保证侧栏即时搜索速度
→ stale guard 校验请求序号
→ SessionList 展示匹配 sessions / match_excerpt
用户点击会话
→ onSessionSelect(sid, {roundId?})
→ App 更新 currentSessionId + selectedModelId + scrollTarget
→ ChatV2 检测 sessionId 变化 → loadHistory
→ 若 search result 带 match_round_id,则滚动到对应 round 并短暂高亮
用户新建会话
→ onNewChat → App.setCurrentSessionId('')
→ ChatV2 显示欢迎页
→ 用户输入第一条消息 → onCreateSession(modelId) → POST /api/sessions
→ setCurrentSessionId(newSid) → sendMessage
禁止:点击"新建"立即创建空会话。
正确:点击"新建" → setCurrentSessionId('') → 欢迎页 → 用户输入第一条消息时才 POST /api/sessions。
原因:避免大量空会话污染列表。
SessionList 不得请求 /running-sessions。运行态集合统一由 App.reconcileRunningSessions 维护:挂载后立即执行一次,之后 5s 周期执行。首次 reconcile 响应若返回运行态且 currentSessionId 为空时,可以自动选择第一个运行中会话;该自动选择机会只在首次 reconcile 响应中消耗一次。若首次响应为空,后续周期只同步运行态集合,不再自动切换当前会话,避免用户已在欢迎页或当前会话操作时被后台跳转打断。
useEffect(() => {
void reconcileRunningSessions();
const timer = setInterval(() => {
void reconcileRunningSessions();
}, RUNNING_SESSIONS_RECONCILE_INTERVAL_MS);
return () => clearInterval(timer);
}, [reconcileRunningSessions]);if (currentSessionId === sessionId) {
onSessionSelect(''); // 清空,回到欢迎页
}onSessionSelect(session.id);
if (session.model_id && onModelChange) {
onModelChange(session.model_id); // ModelSelector 同步显示
}原因:不同会话可能用不同模型,顶部 ModelSelector 必须反映当前会话的模型。
executingSessionIds 清除路径:
RUN_STARTED/ 本地发送开始 → ChatV2 调用onExecutionStart(sessionId),将 sid 加入集合。RUN_FINISHED→ ChatV2 调用onExecutionEnd(sessionId)(传 sid,精确清除)。- 禁止在切换会话时无条件清除 → 会误清其他正在运行的会话标记。
const handleExecutionStart = (sessionId: string) => {
setExecutingSessionIds((prev) => new Set(prev).add(sessionId));
};
const handleExecutionEnd = (sessionId?: string) => {
if (sessionId) {
setExecutingSessionIds((prev) => {
const next = new Set(prev);
next.delete(sessionId);
return next;
});
} else {
setExecutingSessionIds(new Set());
}
};activeSlotSessionIds 保存后端 /running-sessions 返回或本地已启动的运行 slot。本地发送 / resume 在 HTTP 响应通过后、RUN_STARTED 到达前也必须先加入该集合;429 等请求级拒绝不得加入。若 ChatV2 加载当前 session 历史时尚未看到 running round,但该 session 仍在 activeSlotSessionIds 中,必须保留执行标记并禁用输入;这表示 Agent 初始化窗口,不能误判为空闲。进入该分支后 ChatV2 必须短间隔检查最新 activeSlotSessionIds prop/ref:slot 消失则清除执行态,slot 仍存在则重拉 history,直到看到 running round 并进入 subscribeToRound 订阅路径。ChatV2 不得为该探测额外请求 /running-sessions。
App 必须立即并周期性 reconcile /running-sessions,更新 executingSessionIds 与 activeSlotSessionIds。除首次恢复运行态外,不得自动切换当前会话。该收敛用于清理非当前会话后台完成后的侧栏执行标记。
| 轮询 | 间隔 | 实现 |
|---|---|---|
| init-window 补偿探测 | 1.5s | ChatV2 在 active slot 但无 running round 时触发,通过最新 activeSlotSessionIds 判断是否重拉 history 或清标记 |
| running-sessions 后台收敛 | 5s | App 立即并周期调用 /running-sessions,同步运行态集合;仅首次 reconcile 返回运行态且当前为空时可自动跳转 |
| 会话列表刷新 | 30s | SessionList 内部 setInterval |
| Cron 未读计数 | 60s | App.tsx 内部 setInterval,调用 getUnreadCount |
触发列表重拉的其他入口:
refreshTrigger变化(新建/删除/标题更新时 +1)currentSessionId变化(从 useEffect 依赖触发)debouncedSearchQuery变化(搜索词 300ms 防抖后触发)
- 搜索框位于 History 区域上方,使用
Search图标、清空按钮和加载状态。 - 搜索使用单一后端
q参数,不暴露也不保留搜索模式选择。 - 搜索输入只影响会话列表,不改变当前选中 session。
- 搜索为空时请求完整列表;搜索非空时调用
GET /api/sessions/list?q=...。 - 搜索结果由后端限制为最多 50 个 sessions,前端不做额外分页。
- 快速输入必须使用请求序号 stale guard,旧响应不得覆盖新结果。
- 搜索非空且无结果时显示"没有匹配的对话"。
match_type=user|assistant且有match_excerpt时,在 session 标题下展示一行摘要,前缀分别为"我的问题"、"Agent 回复"。- 搜索结果带
match_round_id时,点击 session 后ChatV2应定位到对应 round 并短暂高亮。
- 打开右侧配置抽屉(AgentConfig/Skills/Cron)时自动折叠左侧栏:
setIsSidebarCollapsed(true)。 - 关闭配置抽屉时恢复:
setIsSidebarCollapsed(false)。 - 聊天区的 ArtifactsPanel 打开不折叠左侧栏(用户可能需要对照会话)。
折叠动画:width: 260px ↔ 0 + opacity: 1 ↔ 0,transition-all duration-300 ease-in-out。
注意:这是侧边栏折叠,不是抽屉。右侧抽屉仍必须覆盖式(见 frontend-spec §5.6)。
| 错误 | 表现 |
|---|---|
getSessions 失败 |
console.error,显示"加载失败"空态 |
deleteSession 失败 |
console.error,不改变 UI |
getRunningSessions 失败 |
console.error,不影响正常流程 |
- 首次进入由 App 自动检测全部运行中会话,并切换到第一个运行中会话
- 切换会话时
ModelSelector同步到新会话的 model_id - 删除当前会话回到欢迎页
- 欢迎页输入第一条消息才创建会话
- A/B 多会话并行时,侧栏同时显示多个执行标记
- 非当前会话后台完成后,周期收敛会清理对应执行标记
- 本地 run 在
RUN_STARTED前的 init-window 会保留执行标记;429 不污染执行标记 - ChatV2 init-window 补偿探测不额外请求
/running-sessions - A 会话运行中切到 B 会话,A 的
executingSessionIds标记不被误清 - 30s 后列表自动刷新
- 搜索输入 300ms 后调用
getSessions(q) - 清空搜索恢复完整列表
- 用户消息 / Agent 回复命中展示
match_excerpt - 搜索无结果展示"没有匹配的对话"
- 快速输入时旧响应不覆盖新结果
- 点击带
match_round_id的搜索结果后定位到对应 round
useEffect(() => loadSessions(), [refreshTrigger, currentSessionId])—— 切换会话也会触发 reload,属于期望行为,不要误删currentSessionId依赖。- 在
onSessionSelect里做异步操作会阻塞 UI → 保持同步。 - 运行中检测写成每次挂载都执行 → 切会话时 UI 闪跳。