Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/issues/sidebar-history-pagination-stuck/plan.md
Original file line number Diff line number Diff line change
@@ -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`
71 changes: 71 additions & 0 deletions docs/issues/sidebar-history-pagination-stuck/spec.md
Original file line number Diff line number Diff line change
@@ -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` 携带子代理,改动安全。
34 changes: 34 additions & 0 deletions docs/issues/sidebar-history-pagination-stuck/tasks.md
Original file line number Diff line number Diff line change
@@ -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` 作为回归契约保留(两周后若无价值再清理)
54 changes: 54 additions & 0 deletions src/renderer/src/components/WindowSideBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>(
`.session-item[data-session-id="${sessionId}"][data-session-region="${region}"]`
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/src/stores/ui/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand Down Expand Up @@ -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) {
Expand Down
66 changes: 66 additions & 0 deletions test/renderer/components/WindowSideBar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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
)
})
Loading