diff --git a/docs/issues/sidebar-history-pagination-stuck/plan.md b/docs/issues/sidebar-history-pagination-stuck/plan.md new file mode 100644 index 000000000..53dd4a5ea --- /dev/null +++ b/docs/issues/sidebar-history-pagination-stuck/plan.md @@ -0,0 +1,80 @@ +# 实施计划:侧边栏历史会话分页加载卡死 + +## 目标 + +修复 #1762:让侧边栏能持续加载并展示全部 regular 历史会话。核心两改 + 一项次要增强。 + +## 方案概述 + +### 改动 1 — 分页不再携带子代理会话(页槽修正) + +**文件**:`src/renderer/src/stores/ui/session.ts` + +将 `loadSessionPage` 中两处分页请求的 `includeSubagents: true` 改为 `false` +(`:512` 首屏、`:551` 翻页)。 + +- 后端 `newSessions.ts:240` 在 `includeSubagents !== true` 时自动加 + `WHERE session_kind = 'regular'`,使一页 30 条全部为 regular 会话。 +- `nextCursor` / `hasMore`(`sessionManager.ts:107-113`、`newSessions.ts:262`)随之只基于 + regular 会话计算,语义与显示层一致。 +- 影响面已核实安全(见 spec「影响面评估」)。 + +### 改动 2 — 加载后自动填充视口(根治"无滚动条→不加载") + +**文件**:`src/renderer/src/components/WindowSideBar.vue` + +新增 `ensureSessionListFilled()`:在首屏加载完成、列表渲染更新(`nextTick`)后,检测 +`scrollHeight <= clientHeight && sessionStore.hasMore && !sessionStore.loadingMore`, +若成立则 `await sessionStore.loadNextPage()` 并循环复检,直到视口被填满或 `hasMore = false`。 + +触发时机: +- `onMounted` 首屏 `fetchSessions` 之后; +- `watch(filteredGroups / pinnedSessions)` 或 `watch(sessionStore.sessions.length)` 变化后 + (会话列表内容变化、agent 切换过滤后内容变矮时复检)。 + +防护: +- 用一个 `isFilling` 本地标志避免并发重入; +- 设置最大循环轮数上限(如 `hasMore` 为真但连续加载无新增时退出)防止异常死循环; +- 复用现有 `performSessionListScrollCheck` 的 96px 阈值常量,逻辑保持一致。 + +### 改动 3(次要)— 侧边栏搜索接入后端 FTS + +**文件**:`src/renderer/src/components/WindowSideBar.vue`(+ 可能 `session.ts` / `SessionClient`) + +当前 `matchesSessionSearch` 仅前端过滤。增强为:搜索关键词非空时,调用已有 +`sessionClient.searchHistory(query)`(FTS 直查 DB),将命中的历史会话合并进可显示集合。 + +- 优先复用 `spotlight.ts:305` 已验证的 `searchHistory` 调用方式。 +- 加 debounce,避免逐字符请求。 +- 若本增强工作量偏大,可拆为独立后续 PR,先合入改动 1+2 即可让"滚动加载"恢复正常。 + +## 涉及模块 + +| 层 | 文件 | 改动 | +|---|---|---| +| Renderer Store | `src/renderer/src/stores/ui/session.ts` | `includeSubagents: false` ×2 | +| Renderer 组件 | `src/renderer/src/components/WindowSideBar.vue` | 视口自动填充;(次要)搜索接 FTS | +| Renderer Client | `src/renderer/api/SessionClient.ts` | (次要)如需暴露 searchHistory | + +主进程 / DB / 契约层**无需改动**(透传逻辑已正确)。 + +## 测试策略 + +- `test/renderer/stores/sessionStore.test.ts` + - 断言 `loadSessionPage` 发出的请求 `includeSubagents === false`。 + - 断言翻页 cursor 推进、`hasMore` 收敛、`sessions` 累积去重。 +- `test/renderer/components/WindowSideBar.test.ts` + - 模拟 `scrollHeight <= clientHeight && hasMore` 场景,断言 `loadNextPage` 被自动调用 + 直到 `hasMore = false`。 + - 模拟首屏已填满视口(`scrollHeight > clientHeight`)时,不应额外自动加载。 +- 回归:`pnpm test:renderer` 全绿。 + +## 兼容性 / 风险 + +- 分页协议、存储数据、契约类型均不变,无数据迁移。 +- 风险点:自动填充循环若与 cursor 异常叠加可能多拉数据 → 用 `isFilling` 标志 + 轮数上限兜底。 +- 性能:改动 1 减少传输的无关子代理会话,整体更优。 + +## 质量门禁 + +实现后执行:`pnpm run format && pnpm run i18n && pnpm run lint && pnpm run typecheck && pnpm test:renderer` diff --git a/docs/issues/sidebar-history-pagination-stuck/spec.md b/docs/issues/sidebar-history-pagination-stuck/spec.md new file mode 100644 index 000000000..c6ea05abe --- /dev/null +++ b/docs/issues/sidebar-history-pagination-stuck/spec.md @@ -0,0 +1,71 @@ +# 侧边栏历史会话分页加载卡死 + +> Issue: [#1762](https://github.com/ThinkInAIXYZ/deepchat/issues/1762) `[BUG] 对话历史无法加载` +> 环境: Windows 11 / DeepChat v1.0.6-beta.5 + +## 背景 + +用户的数据库中存在 100+ 个 regular 会话(已用数据库修复功能确认数据完整、schema 正常), +但侧边栏只显示最近十几个会话,滚动到底部不触发"加载更多",侧边栏搜索也找不到未加载的旧会话。 +用户进一步反馈:在同一个 agent 下新建几个对话后,更早的旧对话也会从侧边栏消失。 + +## 根因 + +侧边栏分页存在两个相互叠加的缺陷: + +1. **页槽被子代理会话浪费** + 侧边栏首屏与翻页请求 `listLightweight` 时传入 `includeSubagents: true` + (`src/renderer/src/stores/ui/session.ts:512`、`:551`),使后端一页 30 条结果里混入 + `session_kind = 'subagent'` 的会话;但显示层只渲染 regular 会话 + (`isRegularSession`,`session.ts:918`/`:930`)。一页 30 条里被子代理占用的名额会被直接过滤掉, + 导致界面可见的 regular 会话远少于 30。 + +2. **首屏未填满视口 → 无滚动条 → 永不触发加载更多** + `performSessionListScrollCheck` 仅在 `scroll` 事件中调用 + (`src/renderer/src/components/WindowSideBar.vue:1102-1125`)。当首屏可见 regular 会话过少、 + 列表内容高度 < 容器高度时,不存在可滚动空间,`scroll` 事件永不触发, + `loadNextPage()` 永远不会被调用。缺少"加载后检测视口是否填满、未满则继续加载"的自动填充逻辑。 + +此外,侧边栏搜索 (`WindowSideBar.vue` `matchesSessionSearch`) 仅在已加载的 `sessions.value` +内存数组中按标题过滤,未接入后端 FTS 搜索,因此未加载的旧会话搜不到。 + +## 用户故事 + +- 作为用户,当我有上百个历史会话时,**滚动侧边栏应能持续加载更早的会话**,直到全部可见。 +- 作为用户,**首屏应尽量填满可视区域**,而不是只显示十几条后就停住、且没有滚动条。 +- 作为用户,在侧边栏搜索框输入关键词时,**应能命中未加载到前端的历史会话**(次要目标)。 + +## 验收标准 + +1. 数据库有 100+ regular 会话时,侧边栏首屏加载后内容高度应填满(或超过)列表容器高度, + 存在可滚动空间。 +2. 滚动到列表底部(距底 ≤ 96px)时触发 `loadNextPage`,可持续翻页直到 `hasMore = false`, + 最终能展示数据库中的全部 regular 会话。 +3. 当首屏返回的 regular 会话不足以填满视口、且 `hasMore = true` 时,应自动继续加载下一页, + 直到填满视口或无更多数据,无需用户手动滚动。 +4. 侧边栏分页请求不再因子代理会话占用页槽而减少可见 regular 会话数量。 +5. (次要)侧边栏搜索能命中数据库中未加载到前端的会话标题。 +6. 现有 Vitest 套件(`test/main/**`、`test/renderer/**`)全部通过;新增针对分页/自动填充的回归用例。 + +## 非目标 + +- 不改动子代理会话本身的存储、级联删除、agent 迁移等业务逻辑 + (主进程 4 处 `includeSubagents: true` 走 `.list()`,与本修复无关,保持不变)。 +- 不重构 cursor 分页协议(`updatedAt + id` 游标语义保持不变)。 +- 不改变会话分组(time / project)与置顶逻辑。 + +## 约束 + +- 遵循 typed route / `renderer/api/*Client` 现有路径,不引入 legacy presenter 调用。 +- 用户可见文案使用 i18n key。 +- 兼容性:分页协议与已存储数据均不变,纯前端/查询行为修正,无数据迁移。 + +## 影响面评估(已核实) + +- `isRegularSession`:仅 `session.ts` 3 处,全部用于侧边栏显示过滤,证明 store 无需子代理数据。 +- `includeSubagents: true`:侧边栏分页 2 处需改;主进程 4 处(agent 迁移/删除/级联删/子会话判断) + 走 `.list()`,独立且必须保留;DB/契约/类型层为透传,改前端传 `false` 后 DB 正确启用 + `WHERE session_kind = 'regular'`。 +- 其它 renderer 消费方(`spotlight.ts` 自身已 `.filter(sessionKind !== 'subagent')`)不依赖 + 侧边栏 store 包含子代理会话。 +- 现有测试无断言依赖侧边栏 `listLightweight` 携带子代理,改动安全。 diff --git a/docs/issues/sidebar-history-pagination-stuck/tasks.md b/docs/issues/sidebar-history-pagination-stuck/tasks.md new file mode 100644 index 000000000..c0660a53f --- /dev/null +++ b/docs/issues/sidebar-history-pagination-stuck/tasks.md @@ -0,0 +1,34 @@ +# 任务拆分:侧边栏历史会话分页加载卡死 + +按提交粒度排序,每项可独立 review。 + +## T1 — 分页停止携带子代理会话(核心,最小修复) +- [ ] `src/renderer/src/stores/ui/session.ts:512`、`:551` 将 `includeSubagents: true` 改为 `false` +- [ ] `test/renderer/stores/sessionStore.test.ts` 增断言:分页请求 `includeSubagents === false`, + 翻页 cursor 推进与 `hasMore` 收敛正确 +- 提交:`fix(session): exclude subagents from sidebar pagination` + +## T2 — 侧边栏列表加载后自动填充视口(核心) +- [ ] `WindowSideBar.vue` 新增 `ensureSessionListFilled()`: + `scrollHeight <= clientHeight && hasMore && !loadingMore` 时循环 `loadNextPage()` +- [ ] 加 `isFilling` 重入防护与轮数上限 +- [ ] 在 `onMounted` 首屏加载后、以及会话列表/agent 过滤变化的 `watch` 中触发复检 +- [ ] `test/renderer/components/WindowSideBar.test.ts` 增用例: + - 未填满视口 + `hasMore` → 自动持续加载至 `hasMore=false` + - 已填满视口 → 不额外自动加载 +- 提交:`fix(sidebar): auto-fill session list viewport to resume pagination` + +## T3 — (次要)侧边栏搜索接入后端 FTS +- [ ] 搜索关键词非空时调用 `sessionClient.searchHistory(query)`,合并命中会话 +- [ ] 加 debounce +- [ ] 如需要,`SessionClient.ts` 暴露 `searchHistory` +- [ ] 增搜索命中未加载会话的用例 +- 提交:`feat(sidebar): search history via backend FTS` +- 备注:可拆为独立后续 PR,不阻塞 T1+T2 合入 + +## T4 — 质量门禁与收尾 +- [ ] `pnpm run format && pnpm run i18n && pnpm run lint && pnpm run typecheck` +- [ ] `pnpm test:renderer`(必要时 `pnpm test`) +- [ ] PR 描述附 BEFORE/AFTER 行为说明,`Closes #1762`,base 分支 `dev` +- [ ] 实现完成后按 SDD 保留策略:删除本目录 `plan.md` / `tasks.md`, + `spec.md` 作为回归契约保留(两周后若无价值再清理) diff --git a/src/renderer/src/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index 5cb424062..323725bf3 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -1124,6 +1124,60 @@ const handleSessionListScroll = () => { }) } +// 当首屏返回的 regular 会话不足以填满列表容器时,不会产生滚动条, +// `@scroll` 永远不触发,`loadNextPage` 也就永远不会被调用(issue #1762)。 +// 这里在加载/过滤变化后主动检测视口是否被填满,未满且仍有更多数据时继续加载。 +let isFillingSessionList = false +const ensureSessionListFilled = async () => { + if (isFillingSessionList) { + return + } + isFillingSessionList = true + try { + // 轮数上限兜底,避免异常情况下(如 cursor 不推进)陷入死循环。 + const MAX_FILL_ROUNDS = 50 + for (let round = 0; round < MAX_FILL_ROUNDS; round += 1) { + await nextTick() + const listElement = sessionListRef.value + if ( + !listElement || + !sessionStore.hasMore || + sessionStore.loadingMore || + sessionStore.loading + ) { + return + } + // 内容高度已超过容器(存在可滚动空间),交还给滚动事件处理后续分页。 + if (listElement.scrollHeight > listElement.clientHeight + 1) { + return + } + const beforeCount = sessionStore.sessions.length + await sessionStore.loadNextPage() + // 没有新增会话说明已到底或加载失败,停止以防空转。 + if (sessionStore.sessions.length <= beforeCount) { + return + } + } + } finally { + isFillingSessionList = false + } +} + +// 会话列表内容或 agent 过滤变化后,若视口未被填满则继续加载, +// 保证「滚动加载更多」在首屏内容过少时也能启动(issue #1762)。 +watch( + [ + () => sessionStore.sessions.length, + () => sessionStore.hasMore, + () => sessionStore.loading, + sidebarSelectedAgentId + ], + () => { + void ensureSessionListFilled() + }, + { immediate: true } +) + const getSessionItemElement = (sessionId: string, region: SessionItemRegion) => document.querySelector( `.session-item[data-session-id="${sessionId}"][data-session-region="${region}"]` diff --git a/src/renderer/src/stores/ui/session.ts b/src/renderer/src/stores/ui/session.ts index 19e8419ba..aed644825 100644 --- a/src/renderer/src/stores/ui/session.ts +++ b/src/renderer/src/stores/ui/session.ts @@ -509,7 +509,9 @@ export const useSessionStore = defineStore('session', () => { const result = await sessionClient.listLightweight({ limit: DEFAULT_SESSION_PAGE_SIZE, cursor: null, - includeSubagents: true, + // 侧边栏只展示 regular 会话;携带子代理会话会占用分页名额, + // 导致一页 30 条里的可见 regular 会话被显示层过滤后所剩无几。 + includeSubagents: false, prioritizeSessionId: options.prioritizeSessionId ?? undefined }) @@ -548,7 +550,8 @@ export const useSessionStore = defineStore('session', () => { const result = await sessionClient.listLightweight({ limit: DEFAULT_SESSION_PAGE_SIZE, cursor: nextCursor.value, - includeSubagents: true + // 与首屏一致:仅分页 regular 会话,避免子代理会话占用页槽。 + includeSubagents: false }) if (requestId !== nextPageRequestId) { diff --git a/test/renderer/components/WindowSideBar.test.ts b/test/renderer/components/WindowSideBar.test.ts index a3c5e4411..56e246fbc 100644 --- a/test/renderer/components/WindowSideBar.test.ts +++ b/test/renderer/components/WindowSideBar.test.ts @@ -8,6 +8,11 @@ type SetupOptions = { enabledAgents?: Array<{ id: string; name: string; type?: 'deepchat' | 'acp'; enabled?: boolean }> activeSession?: { id: string; agentId: string } | null hasActiveSession?: boolean + sessions?: Array<{ id: string }> + hasMore?: boolean + loading?: boolean + loadingMore?: boolean + nextPages?: Array<{ items: Array<{ id: string }>; hasMore: boolean }> pinnedSessions?: Array<{ id: string; title: string; status: string; isPinned?: boolean }> groups?: Array<{ id: string @@ -124,6 +129,19 @@ const setup = async (options: SetupOptions = {}) => { activeSessionId: (options.activeSession?.id ?? 'session-1') as string | null, activeSession: options.activeSession ?? null, hasActiveSession: options.hasActiveSession ?? true, + sessions: (options.sessions ?? []) as Array<{ id: string }>, + hasMore: options.hasMore ?? false, + loading: options.loading ?? false, + loadingMore: options.loadingMore ?? false, + loadNextPage: vi.fn(async () => { + const nextPage = (options.nextPages ?? []).shift() + if (!nextPage) { + sessionStore.hasMore = false + return + } + sessionStore.sessions = [...sessionStore.sessions, ...nextPage.items] + sessionStore.hasMore = nextPage.hasMore + }), startNewConversation: vi.fn().mockResolvedValue(undefined), selectSession: vi.fn(async (id: string) => { operations.push(`select:${id}`) @@ -1251,3 +1269,51 @@ describe('WindowSideBar agent switch', () => { wrapper.unmount() }) }) + +describe('WindowSideBar viewport auto-fill', () => { + it( + 'keeps loading pages until the session list viewport is filled', + async () => { + const { wrapper, sessionStore } = await setup({ + sessions: [{ id: 'session-1' }], + hasMore: true, + nextPages: [ + { items: [{ id: 'session-2' }], hasMore: true }, + { items: [{ id: 'session-3' }], hasMore: false } + ] + }) + + await flushPromises() + + // jsdom 下 scrollHeight/clientHeight 均为 0(未填满视口), + // 自动填充应持续翻页直到 hasMore 收敛为 false。 + expect(sessionStore.loadNextPage).toHaveBeenCalledTimes(2) + expect(sessionStore.hasMore).toBe(false) + expect(sessionStore.sessions.map((session) => session.id)).toEqual([ + 'session-1', + 'session-2', + 'session-3' + ]) + + wrapper.unmount() + }, + TEST_TIMEOUT_MS + ) + + it( + 'does not auto-load additional pages when there is nothing more to fetch', + async () => { + const { wrapper, sessionStore } = await setup({ + sessions: [{ id: 'session-1' }], + hasMore: false + }) + + await flushPromises() + + expect(sessionStore.loadNextPage).not.toHaveBeenCalled() + + wrapper.unmount() + }, + TEST_TIMEOUT_MS + ) +}) diff --git a/test/renderer/stores/sessionStore.test.ts b/test/renderer/stores/sessionStore.test.ts index 0250c6ce5..b29a72149 100644 --- a/test/renderer/stores/sessionStore.test.ts +++ b/test/renderer/stores/sessionStore.test.ts @@ -999,3 +999,60 @@ describe('sessionStore streaming cleanup', () => { expect(store.activeSession.value?.status).toBe('none') }) }) + +describe('sessionStore pagination', () => { + it('excludes subagent sessions from the initial sidebar page request', async () => { + const { store, sessionClient } = await setupStore() + + await store.fetchSessions() + + expect(sessionClient.listLightweight).toHaveBeenCalledWith( + expect.objectContaining({ includeSubagents: false }) + ) + }) + + it('keeps excluding subagents when loading the next page', async () => { + const { store, sessionClient } = await setupStore() + + sessionClient.listLightweight.mockResolvedValueOnce({ + items: [createSession({ id: 'session-a', title: 'Alpha', updatedAt: 30 })], + hasMore: true, + nextCursor: { updatedAt: 30, id: 'session-a' } + }) + await store.fetchSessions() + + sessionClient.listLightweight.mockResolvedValueOnce({ + items: [createSession({ id: 'session-b', title: 'Bravo', updatedAt: 20 })], + hasMore: false, + nextCursor: null + }) + await store.loadNextPage() + + const lastCall = sessionClient.listLightweight.mock.calls.at(-1)?.[0] + expect(lastCall).toMatchObject({ + includeSubagents: false, + cursor: { updatedAt: 30, id: 'session-a' } + }) + expect(store.hasMore.value).toBe(false) + expect(store.sessions.value.map((session: { id: string }) => session.id)).toEqual([ + 'session-a', + 'session-b' + ]) + }) + + it('does not request more pages once hasMore is false', async () => { + const { store, sessionClient } = await setupStore() + + sessionClient.listLightweight.mockResolvedValueOnce({ + items: [createSession({ id: 'session-a', title: 'Alpha', updatedAt: 30 })], + hasMore: false, + nextCursor: null + }) + await store.fetchSessions() + + const callsAfterInitial = sessionClient.listLightweight.mock.calls.length + await store.loadNextPage() + + expect(sessionClient.listLightweight.mock.calls.length).toBe(callsAfterInitial) + }) +})