From 5c13553abb05bcbfc313090d560875f061c9082f Mon Sep 17 00:00:00 2001 From: CikeSeven Date: Sun, 11 Oct 2026 00:45:11 +0800 Subject: [PATCH 1/5] perf(sessions): prioritize current workspace session listing Publish a bounded first page from Claude's current directory and Codex's native state database before completing the existing catalog refresh. Resolve DSH workspace metadata ahead of other projects and publish short workspace batches immediately while preserving complete snapshots. Cover partial-list ordering, snapshot safety and Codex fallback paths, and sync the bilingual documentation and bundled guide. Refs #1462 --- README.md | 2 +- README_ZH.md | 2 +- docs/claude-backend.en.md | 6 ++- docs/claude-backend.md | 3 +- docs/codex-backend.en.md | 7 +++- docs/codex-backend.md | 4 +- guide/dsh-tui-guide/claude-backend.en.md | 6 ++- guide/dsh-tui-guide/claude-backend.md | 3 +- guide/dsh-tui-guide/codex-backend.en.md | 7 +++- guide/dsh-tui-guide/codex-backend.md | 4 +- scripts/verify-backend-list-snapshot.ts | 25 +++++++++++ scripts/verify-claude-catalog.ts | 38 ++++++++++++++++- scripts/verify-codex-catalog-history.ts | 46 +++++++++++++++++---- scripts/verify-session-list-snapshot.mjs | 34 ++++++++++++++- src/backends/claude/catalog.ts | 20 ++++++--- src/backends/codex/catalog.ts | 15 ++++++- src/dsh-adapter/channel/extensions.ts | 1 + src/dsh-adapter/channel/session-metadata.ts | 4 +- src/dsh-adapter/sessions/list.ts | 15 +++++-- 19 files changed, 207 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 446e207f0..69d169684 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ Shortcuts sit below the title and effort levels have their own section; DSH, Cla In `/provider`'s model list, focus a model and press `Tab` to edit its context window, max output tokens, reasoning efforts, and image input capability. -The session manager focuses the most recently used session in the current workspace; if there is no history, it focuses the new-session card. Press `←` to move to the workspace rail. It paints the last successful list immediately while it checks the persistence store for changes. This first-paint snapshot survives restarts on DSH, Claude and Codex and is isolated by backend and storage directory. On a cold Codex scan, pages appear as they arrive. Titles that require a deeper DSH log scan appear first with a fallback name and update in place when recovery finishes. +The session manager focuses the most recently used session in the current workspace; if there is no history, it focuses the new-session card. Press `←` to move to the workspace rail. It paints the last successful list immediately while it checks the persistence store for changes. This first-paint snapshot survives restarts on DSH, Claude and Codex and is isolated by backend and storage directory. Without a snapshot, all three backends prioritize the current workspace before filling in other workspaces. Claude and Codex first show up to 32 recent sessions from the current workspace; other Git worktrees appear as separate workspaces. DSH resolves the current workspace's metadata first and publishes it before reading other workspaces' logs, even when it has fewer than 32 sessions. Titles that require a deeper DSH log scan appear first with a fallback name and update in place when recovery finishes. With DSH's current JSONL backend, startup and `/new` keep initial permission events in memory until further session activity or an explicit durability flush saves the complete log. Restarting an unstored empty session starts fresh. Removing a workspace registration keeps its sessions accessible under a "History only" directory in the rail. History-only directories offer edit and new-session actions; rename and remove are available for registered workspaces. diff --git a/README_ZH.md b/README_ZH.md index e29c0f569..2bbee3d33 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -269,7 +269,7 @@ OpenAI 条款约束。完整操作与当前边界:[Codex 后端](docs/codex-ba 在 `/provider` 模型列表聚焦一项并按 `Tab`,可编辑上下文窗口、最大输出 token、推理档位和图片输入能力。 -会话管理界面默认聚焦当前工作区最近使用的会话;没有历史时聚焦“新建会话”卡片,按 `←` 可移到工作区栏。界面会立即显示上次成功读取的列表,同时核对持久化存储的变化。DSH、Claude 和 Codex 的首屏快照都可跨重启复用,并按内核与存储目录隔离。Codex 首次扫描时,分页到达就显示;DSH 中需要深度扫描日志的标题会先显示回退名称,恢复完成后在原行更新。 +会话管理界面默认聚焦当前工作区最近使用的会话;没有历史时聚焦“新建会话”卡片,按 `←` 可移到工作区栏。界面会立即显示上次成功读取的列表,同时核对持久化存储的变化。DSH、Claude 和 Codex 的首屏快照都可跨重启复用,并按内核与存储目录隔离。没有快照时,三种后端都优先加载当前工作区,再补齐其他工作区。Claude 和 Codex 先显示当前工作区最近的最多 32 条会话,其他 Git worktree 作为独立工作区加载。DSH 先解析当前工作区的元数据,即使不足 32 条,也会在读取其他工作区日志前显示;需要深度扫描日志的标题会先显示回退名称,恢复完成后在原行更新。 使用 DSH 当前的 JSONL 后端时,启动与 `/new` 的初始权限事件只保留在内存中;后续会话活动或显式持久化 flush 才会保存完整日志。尚未落盘的空会话重启时会重新创建。 移除工作区登记后,其历史会话仍可从侧栏的「仅历史」目录进入。 「仅历史」目录只提供编辑和新建会话操作;重命名与移除仅适用于已登记工作区。 diff --git a/docs/claude-backend.en.md b/docs/claude-backend.en.md index 59d98db36..10f2ab2f0 100644 --- a/docs/claude-backend.en.md +++ b/docs/claude-backend.en.md @@ -158,8 +158,10 @@ a notice, so a cloned repository cannot silently switch off every check. one another dsh-TUI terminal is using cannot be deleted. Pins are kept per backend. Reopening or restarting first paints the last complete list while the SDK refreshes it. - First-paint snapshots are isolated by Claude configuration directory; failed reads - preserve the last complete snapshot. + First-paint snapshots are isolated by Claude configuration directory. Without a snapshot, + up to 32 recent sessions from the current directory appear first, then the rest of that + workspace and other workspaces. Other Git worktrees in the same repository stay out of + the first batch. Failed reads preserve the last complete snapshot. - From a shell: `dsh-tui --backend claude --resume `; without an id it resumes the last Claude session this install used. dsh-TUI prints that command on exit. A session another dsh-TUI terminal has open is refused, and a `--resume` that diff --git a/docs/claude-backend.md b/docs/claude-backend.md index 7195e6b04..f5c3ec735 100644 --- a/docs/claude-backend.md +++ b/docs/claude-backend.md @@ -124,7 +124,8 @@ Claude 设置 > `default`。Claude 设置里的 `defaultMode: bypassPermissions` `claude --resume ` 打开)。`Enter` 恢复(先回放历史再继续),`Ctrl+R` 重命名, `Ctrl+D` 删除;当前会话和另一个 dsh-TUI 终端正在用的会话不能删。置顶按后端分开保存。 再次打开或重启后先显示上次完整列表,同时仍从 SDK 刷新。首屏快照按 Claude 配置 - 目录隔离;读取失败时保留上次完整快照。 + 目录隔离;没有快照时先显示当前工作目录最近的最多 32 条,再补齐该工作区及其他 + 工作区的列表。同一仓库的其他 Git worktree 不混入首批结果。读取失败时保留上次完整快照。 - 从 shell 恢复:`dsh-tui --backend claude --resume `;不带 id 时恢复本机最近一次 用过的 Claude 会话。退出时 dsh-TUI 会打印这条命令。另一个 dsh-TUI 终端已打开的会话会 被拒绝;`--resume` 打不开时直接报错,不会改开新会话。状态栏的短会话 id 就是 diff --git a/docs/codex-backend.en.md b/docs/codex-backend.en.md index d5e975431..2cb904f27 100644 --- a/docs/codex-backend.en.md +++ b/docs/codex-backend.en.md @@ -230,8 +230,11 @@ past tokens/cost. Fresh usage notifications update the readings. threads cannot be archived. The catalog lists up to 500 recent ordinary threads, excluding ephemeral forks and child threads from the main list. Reopening or restarting first paints the last complete list while the native catalog - refreshes; a cold scan shows pages as they arrive. First-paint snapshots are isolated - by Codex storage directory; failed or incomplete reads never replace a complete snapshot. + refreshes; a cold scan shows pages as they arrive. Without a snapshot, the native state + database supplies up to 32 recent threads from the current workspace first, then the + rollout scan repairs titles and fills in that workspace followed by other workspaces; + an unavailable database falls back to scanning. First-paint snapshots are isolated by + Codex storage directory; failed or incomplete reads never replace a complete snapshot. - `/rename` changes the official thread title; `/color` stores a session accent, not a global theme. - `/fork` creates a resumable copy without switching. Double `Esc` or `/rewind` conversation rewind creates a copy before the selected question, returns the question to the composer diff --git a/docs/codex-backend.md b/docs/codex-backend.md index d85884d43..a2f864fd3 100644 --- a/docs/codex-backend.md +++ b/docs/codex-backend.md @@ -193,7 +193,9 @@ used = max(0, T - 12000) 当前使用中、其他终端占用或内部 worker 活动的 thread 不能归档。目录最多列出 最近 500 条普通 thread,临时 fork 与子 thread 不混进主列表。 再次打开或重启后先显示上次完整列表,后台仍从官方目录刷新;首次读取时分页到达 - 就显示。首屏快照按 Codex 存储目录隔离,失败或未读完的分页不会覆盖完整快照。 + 就显示。没有快照时先从官方状态数据库显示当前工作区最近的最多 32 条,再由历史 + 扫描修正标题并补齐当前工作区及其他工作区;数据库不可用时照常扫描。首屏快照 + 按 Codex 存储目录隔离,失败或未读完的分页不会覆盖完整快照。 - `/rename` 改官方 thread 标题;`/color` 只保存会话强调色,不改全局主题。 - `/fork` 创建可恢复副本,不自动切换;双击 `Esc` 或 `/rewind` 的对话回退在 所选提问之前创建副本并继续,提问回到输入框、原 thread 保留。首条提问回退会 diff --git a/guide/dsh-tui-guide/claude-backend.en.md b/guide/dsh-tui-guide/claude-backend.en.md index 59d98db36..10f2ab2f0 100644 --- a/guide/dsh-tui-guide/claude-backend.en.md +++ b/guide/dsh-tui-guide/claude-backend.en.md @@ -158,8 +158,10 @@ a notice, so a cloned repository cannot silently switch off every check. one another dsh-TUI terminal is using cannot be deleted. Pins are kept per backend. Reopening or restarting first paints the last complete list while the SDK refreshes it. - First-paint snapshots are isolated by Claude configuration directory; failed reads - preserve the last complete snapshot. + First-paint snapshots are isolated by Claude configuration directory. Without a snapshot, + up to 32 recent sessions from the current directory appear first, then the rest of that + workspace and other workspaces. Other Git worktrees in the same repository stay out of + the first batch. Failed reads preserve the last complete snapshot. - From a shell: `dsh-tui --backend claude --resume `; without an id it resumes the last Claude session this install used. dsh-TUI prints that command on exit. A session another dsh-TUI terminal has open is refused, and a `--resume` that diff --git a/guide/dsh-tui-guide/claude-backend.md b/guide/dsh-tui-guide/claude-backend.md index 7195e6b04..f5c3ec735 100644 --- a/guide/dsh-tui-guide/claude-backend.md +++ b/guide/dsh-tui-guide/claude-backend.md @@ -124,7 +124,8 @@ Claude 设置 > `default`。Claude 设置里的 `defaultMode: bypassPermissions` `claude --resume ` 打开)。`Enter` 恢复(先回放历史再继续),`Ctrl+R` 重命名, `Ctrl+D` 删除;当前会话和另一个 dsh-TUI 终端正在用的会话不能删。置顶按后端分开保存。 再次打开或重启后先显示上次完整列表,同时仍从 SDK 刷新。首屏快照按 Claude 配置 - 目录隔离;读取失败时保留上次完整快照。 + 目录隔离;没有快照时先显示当前工作目录最近的最多 32 条,再补齐该工作区及其他 + 工作区的列表。同一仓库的其他 Git worktree 不混入首批结果。读取失败时保留上次完整快照。 - 从 shell 恢复:`dsh-tui --backend claude --resume `;不带 id 时恢复本机最近一次 用过的 Claude 会话。退出时 dsh-TUI 会打印这条命令。另一个 dsh-TUI 终端已打开的会话会 被拒绝;`--resume` 打不开时直接报错,不会改开新会话。状态栏的短会话 id 就是 diff --git a/guide/dsh-tui-guide/codex-backend.en.md b/guide/dsh-tui-guide/codex-backend.en.md index d5e975431..2cb904f27 100644 --- a/guide/dsh-tui-guide/codex-backend.en.md +++ b/guide/dsh-tui-guide/codex-backend.en.md @@ -230,8 +230,11 @@ past tokens/cost. Fresh usage notifications update the readings. threads cannot be archived. The catalog lists up to 500 recent ordinary threads, excluding ephemeral forks and child threads from the main list. Reopening or restarting first paints the last complete list while the native catalog - refreshes; a cold scan shows pages as they arrive. First-paint snapshots are isolated - by Codex storage directory; failed or incomplete reads never replace a complete snapshot. + refreshes; a cold scan shows pages as they arrive. Without a snapshot, the native state + database supplies up to 32 recent threads from the current workspace first, then the + rollout scan repairs titles and fills in that workspace followed by other workspaces; + an unavailable database falls back to scanning. First-paint snapshots are isolated by + Codex storage directory; failed or incomplete reads never replace a complete snapshot. - `/rename` changes the official thread title; `/color` stores a session accent, not a global theme. - `/fork` creates a resumable copy without switching. Double `Esc` or `/rewind` conversation rewind creates a copy before the selected question, returns the question to the composer diff --git a/guide/dsh-tui-guide/codex-backend.md b/guide/dsh-tui-guide/codex-backend.md index d85884d43..a2f864fd3 100644 --- a/guide/dsh-tui-guide/codex-backend.md +++ b/guide/dsh-tui-guide/codex-backend.md @@ -193,7 +193,9 @@ used = max(0, T - 12000) 当前使用中、其他终端占用或内部 worker 活动的 thread 不能归档。目录最多列出 最近 500 条普通 thread,临时 fork 与子 thread 不混进主列表。 再次打开或重启后先显示上次完整列表,后台仍从官方目录刷新;首次读取时分页到达 - 就显示。首屏快照按 Codex 存储目录隔离,失败或未读完的分页不会覆盖完整快照。 + 就显示。没有快照时先从官方状态数据库显示当前工作区最近的最多 32 条,再由历史 + 扫描修正标题并补齐当前工作区及其他工作区;数据库不可用时照常扫描。首屏快照 + 按 Codex 存储目录隔离,失败或未读完的分页不会覆盖完整快照。 - `/rename` 改官方 thread 标题;`/color` 只保存会话强调色,不改全局主题。 - `/fork` 创建可恢复副本,不自动切换;双击 `Esc` 或 `/rewind` 的对话回退在 所选提问之前创建副本并继续,提问回到输入框、原 thread 保留。首条提问回退会 diff --git a/scripts/verify-backend-list-snapshot.ts b/scripts/verify-backend-list-snapshot.ts index 9b75b70d4..52e1fdefa 100644 --- a/scripts/verify-backend-list-snapshot.ts +++ b/scripts/verify-backend-list-snapshot.ts @@ -88,6 +88,31 @@ try { check(backendId + ': an empty completed listing is cached distinctly from unknown', restarted.cachedSessions(), []) } + for (const backendId of ['claude', 'codex']) { + const local = row('current-workspace', backendId) + const foreign = { ...row('newer-elsewhere', backendId), cwd: join(home, 'elsewhere'), updatedAt: 999 } + const global = gate() + const began = gate() + const scopes: string[] = [] + const scoped = channel(backendId, { + list: async (scope, onPartial) => { + scopes.push(scope?.allProjects ? 'all' : scope?.cwd ?? '') + if (scope?.allProjects) { onPartial?.([foreign]); began.resolve(); return global.promise } + onPartial?.([local]) + return [local] + }, + }) + const partials: string[][] = [] + const listing = scoped.listSessions(undefined, rows => { partials.push(ids(rows)!) }) + await began.promise + check(backendId + ': the current workspace is queried before all projects', scopes, [home, 'all']) + check(backendId + ': the first rows are local even when another workspace is newer', partials[0], ['current-workspace']) + check(backendId + ': global partial pages keep the already-loaded workspace', partials.at(-1), ['newer-elsewhere', 'current-workspace']) + check(backendId + ': workspace progress is not a complete snapshot', scoped.cachedSessions(), undefined) + global.resolve([foreign, local]) + check(backendId + ': completion includes both workspaces', ids(await listing), ['newer-elsewhere', 'current-workspace']) + } + // A slow page is useful immediately, but cannot masquerade as a complete // listing or overwrite the last successful snapshot. const key = JSON.stringify(['codex', home, 'paging']) diff --git a/scripts/verify-claude-catalog.ts b/scripts/verify-claude-catalog.ts index ee245b1a4..a2b05ced8 100644 --- a/scripts/verify-claude-catalog.ts +++ b/scripts/verify-claude-catalog.ts @@ -103,7 +103,7 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s const catalog = createClaudeCatalog({ loadSdk: () => Promise.resolve(store as never), cwd: () => workdir, lastUsed: () => ({ a: NOW }) }) const local = await catalog.list({ cwd: workdir }) const listCall = calls.find(call => call.method === 'listSessions')!.args[0] as Record - check('the project listing asks the SDK for that directory, programmatic sessions included (P4-1)', listCall.dir === workdir && listCall.includeProgrammatic === true) + check('the project listing asks for only this workspace, programmatic sessions included (P4-1)', listCall.dir === workdir && listCall.includeProgrammatic === true && listCall.includeWorktrees === false) check('rows are the browser\'s shape, tagged with the backend', local.every(row => row.backendId === 'claude' && row.kind.kind === 'root' && row.hasPrompt && row.childCount === 0)) const a = local.find(row => row.id === 'a')! const b = local.find(row => row.id === 'b')! @@ -114,7 +114,7 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s check('bytes and branch come from the record', a.bytes === 1234 && a.branch === 'main' && a.cwd === workdir) const all = await catalog.list({ allProjects: true }) const allCall = calls.filter(call => call.method === 'listSessions').at(-1)!.args[0] as Record - check('the all-projects listing passes no directory', allCall.dir === undefined && allCall.includeProgrammatic === true && all.some(row => row.id === 'elsewhere' && row.cwd === '/other/dir')) + check('the all-projects listing includes other workspaces without a directory restriction', allCall.dir === undefined && allCall.includeWorktrees === undefined && allCall.includeProgrammatic === true && all.some(row => row.id === 'elsewhere' && row.cwd === '/other/dir')) check('no title and no prompt falls back to the directory name', claudeSessionSummary(info({ sessionId: 'x', summary: '' }) as never).title.source === 'fallback') const moved = await catalog.info!('moved', workdir) check('info looks in the directory first, then every project', moved?.cwd === '/moved/dir' && calls.filter(call => call.method === 'getSessionInfo').length === 2) @@ -123,6 +123,40 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s await catalog.rename!('a', 'New title', workdir) await catalog.delete!('b', workdir) check('rename and delete pass the session\'s directory', JSON.stringify(calls.find(call => call.method === 'renameSession')!.args) === JSON.stringify(['a', 'New title', { dir: workdir }]) && JSON.stringify(calls.find(call => call.method === 'deleteSession')!.args) === JSON.stringify(['b', { dir: workdir }])) + + const before = calls.length + const partials: (readonly SessionSummary[])[] = [] + const small = await catalog.list({}, rows => { partials.push(rows) }) + const smallOptions = calls.at(-1)!.args[0] as Record + check('a short workspace first page is complete without a second SDK scan', small.length === 2 && partials.length === 1 && calls.length === before + 1 && smallOptions.limit === 32 && smallOptions.dir === workdir && smallOptions.includeWorktrees === false) + + let finish!: (rows: ReturnType[]) => void + let started!: () => void + const pending = new Promise[]>(resolve => { finish = resolve }) + const scanning = new Promise(resolve => { started = resolve }) + const page = Array.from({ length: 32 }, (_, index) => info({ sessionId: `page-${index}`, lastModified: NOW - index })) + const pageCalls: Record[] = [] + let preferenceReads = 0 + const paged = createClaudeCatalog({ + loadSdk: async () => ({ ...store, listSessions: (options: Record) => { + pageCalls.push(options) + if (options.limit === 32) return Promise.resolve(page) + started() + return pending + } }) as never, + cwd: () => workdir, + lastUsed: () => { preferenceReads += 1; return { 'page-31': NOW + 1 } }, + }) + let first: readonly SessionSummary[] | undefined + let completed = false + const listing = paged.list({ allProjects: true }, rows => { first = rows }).then(rows => { completed = true; return rows }) + await scanning + check('cold listing publishes bounded, MRU-sorted rows while the full scan is pending', !completed && first?.length === 32 && first[0]?.id === 'page-31') + check('both scans preserve all-projects scope and programmatic sessions', pageCalls.length === 2 && pageCalls.every(options => options.dir === undefined && options.includeProgrammatic === true) && pageCalls[1]?.limit === undefined) + finish([...page.slice(1).map(row => ({ ...row, customTitle: 'Updated title' })), ...Array.from({ length: 20 }, (_, index) => info({ sessionId: `older-${index}` }))]) + const complete = await listing + check('the full listing retains older sessions and replaces deleted rows and stale titles', complete.length === 51 && !complete.some(row => row.id === 'page-0') && complete.find(row => row.id === 'page-1')?.title.text === 'Updated title' && complete.some(row => row.id === 'older-19')) + check('preferences are read once across the first page and full listing', preferenceReads === 1) } // ── resume: transcript → replay → `resume` ──────────────────────────── diff --git a/scripts/verify-codex-catalog-history.ts b/scripts/verify-codex-catalog-history.ts index 2b0c97a4a..0b994e199 100644 --- a/scripts/verify-codex-catalog-history.ts +++ b/scripts/verify-codex-catalog-history.ts @@ -9,6 +9,7 @@ import { createCodexTranscriptHistory } from '../src/backends/codex/session/hist import { createCodexHub, type HubSettings } from '../src/backends/codex/rpc/hub.js' import { createItemContext } from '../src/backends/codex/translate/items.js' import { createFakeAppServer, FakeRpcError, NO_REPLY } from './lib/codex-fake-app-server.js' +import { manualClock } from './lib/codex-session-harness.js' let passed = 0 const check = (label: string, ok: boolean): void => { assert.ok(ok, label); passed += 1; console.log('PASS ' + label) } @@ -28,7 +29,9 @@ const row = (id: string, extra: Record = {}) => ({ id, cwd: SET } { const fake = createFakeAppServer() - fake.on('thread/list', params => params.cursor === 'next' ? { data: [row('B', { updatedAt: 30 })], nextCursor: null } : { data: [row('A'), row('child', { parentThreadId: 'A' })], nextCursor: 'next' }) + fake.on('thread/list', params => params.useStateDbOnly === true + ? { data: [row('A')], nextCursor: null } + : params.cursor === 'next' ? { data: [row('B', { updatedAt: 30 })], nextCursor: null } : { data: [row('A'), row('child', { parentThreadId: 'A' })], nextCursor: 'next' }) fake.on('thread/read', params => { if (params.threadId === 'absent') throw new FakeRpcError(-32600, 'thread not found'); return { thread: row(String(params.threadId)) } }) fake.on('thread/turns/list', () => ({ data: [turn('newer'), turn('older')], nextCursor: null })) fake.on('thread/name/set', () => ({})) @@ -41,8 +44,8 @@ const row = (id: string, extra: Record = {}) => ({ id, cwd: SET const batches: string[][] = [] const rows = await catalog.list({}, rows => { batches.push(rows.map(row => row.id)) }) check('catalog list: preferences are read once across all pages', preferenceReads === 1) - check('catalog list: the first page publishes before the full result', JSON.stringify(batches) === JSON.stringify([['A'], ['B', 'A']])) - check('catalog list: native paging/scoped cwd/all provider filter excludes children', rows.map(row => row.id).join(',') === 'B,A' && fake.requests.filter(request => request.method === 'thread/list').length === 2 && fake.requests.find(request => request.method === 'thread/list')?.params.cwd === SETTINGS.cwd && JSON.stringify(fake.requests.find(request => request.method === 'thread/list')?.params.modelProviders) === '[]') + check('catalog list: the state DB and regular pages publish before the full result', JSON.stringify(batches) === JSON.stringify([['A'], ['A'], ['B', 'A']])) + check('catalog list: native paging/scoped cwd/all provider filter excludes children', rows.map(row => row.id).join(',') === 'B,A' && fake.requests.filter(request => request.method === 'thread/list').length === 3 && fake.requests.find(request => request.method === 'thread/list')?.params.cwd === SETTINGS.cwd && JSON.stringify(fake.requests.find(request => request.method === 'thread/list')?.params.modelProviders) === '[]') await catalog.list({ allProjects: true }) check('catalog list: all-projects omits cwd, archived threads stay excluded', fake.requests.filter(request => request.method === 'thread/list').at(-1)?.params.cwd === undefined && fake.requests.filter(request => request.method === 'thread/list').at(-1)?.params.archived === false) check('catalog info: uses thread/read metadata without full turns', (await catalog.info!('A'))?.id === 'A' && fake.requests.find(request => request.method === 'thread/read')?.params.includeTurns === false) @@ -60,7 +63,9 @@ const row = (id: string, extra: Record = {}) => ({ id, cwd: SET } { const fake = createFakeAppServer() - fake.on('thread/list', params => params.cursor === 'slow-page' ? NO_REPLY : { data: [row('first')], nextCursor: 'slow-page' }) + fake.on('thread/list', params => params.useStateDbOnly === true + ? { data: [row('first', { name: 'Old title' }), row('stale'), row('child', { parentThreadId: 'first' }), row('ephemeral', { ephemeral: true })], nextCursor: null } + : NO_REPLY) const hub = createCodexHub(SETTINGS, { transportFactory: fake.transportFactory }) await hub.ready const batches: string[][] = [] @@ -68,10 +73,37 @@ const row = (id: string, extra: Record = {}) => ({ id, cwd: SET const catalog = createCodexCatalog({ acquire: async () => ({ hub, release: () => {} }), cwd: () => SETTINGS.cwd }) const listing = catalog.list({}, rows => { batches.push(rows.map(row => row.id)) }).then(rows => { done = true; return rows }) const first = await fake.waitForRequest('thread/list') - const request = await fake.waitForRequest('thread/list', { after: fake.requests.indexOf(first) + 1 }) - check('catalog cold load: useful first-page rows are available while the next page is pending', !done && JSON.stringify(batches) === JSON.stringify([['first']])) + const regular = await fake.waitForRequest('thread/list', { after: fake.requests.indexOf(first) + 1 }) + check('catalog cold load: bounded DB rows arrive while the rollout repair scan is pending', !done && first.params.useStateDbOnly === true && first.params.limit === 32 && JSON.stringify(batches) === JSON.stringify([['first', 'stale']])) + fake.reply(regular.id, { data: [row('first', { name: 'Repaired title' })], nextCursor: 'slow-page' }) + const request = await fake.waitForRequest('thread/list', { after: fake.requests.indexOf(regular) + 1 }) + check('catalog cold load: regular pages replace stale DB rows while the next page is pending', !done && JSON.stringify(batches) === JSON.stringify([['first', 'stale'], ['first']]) && regular.params.useStateDbOnly === undefined && regular.params.cursor === undefined) fake.reply(request.id, { data: [row('second')], nextCursor: null }) - check('catalog cold load: completion retains both pages', (await listing).map(row => row.id).sort().join(',') === 'first,second') + const complete = await listing + check('catalog cold load: completion retains both repaired pages and drops DB-only rows', complete.map(row => row.id).sort().join(',') === 'first,second' && complete.find(row => row.id === 'first')?.title.text === 'Repaired title') + await hub.close() +} +for (const unavailable of ['empty', 'unsupported', 'timeout']) { + const fake = createFakeAppServer() + fake.on('thread/list', params => { + if (params.useStateDbOnly !== true) return { data: [row('repaired')], nextCursor: null } + if (unavailable === 'unsupported') throw new FakeRpcError(-32602, 'unknown field useStateDbOnly') + if (unavailable === 'timeout') return NO_REPLY + return { data: [], nextCursor: null } + }) + const clock = manualClock() + const hub = createCodexHub(SETTINGS, { transportFactory: fake.transportFactory, clock }) + await hub.ready + let released = false + const catalog = createCodexCatalog({ acquire: async () => ({ hub, release: () => { released = true } }), cwd: () => SETTINGS.cwd }) + const batches: string[][] = [] + const listing = catalog.list({ allProjects: true }, rows => { batches.push(rows.map(row => row.id)) }) + if (unavailable === 'timeout') { + await fake.waitForRequest('thread/list') + clock.advance(1000) + } + const complete = await listing + check(`catalog cold load: ${unavailable} DB falls back to repaired all-projects rows`, complete[0]?.id === 'repaired' && JSON.stringify(batches) === JSON.stringify([['repaired']]) && fake.requests.filter(request => request.method === 'thread/list').every(request => request.params.cwd === undefined) && released) await hub.close() } { diff --git a/scripts/verify-session-list-snapshot.mjs b/scripts/verify-session-list-snapshot.mjs index 75f2f06da..7c8168a0e 100644 --- a/scripts/verify-session-list-snapshot.mjs +++ b/scripts/verify-session-list-snapshot.mjs @@ -131,6 +131,34 @@ try { check('the finished listing carries every row', coldRows.length, 40) check('and only then reaches disk', diskRows([], coldConfig).length, 40) + // The current workspace can have fewer than one batch, and older sessions + // than every other project. Publish it before locating any foreign log. + const priorityConfig = { root: join(root, 'workspace-priority') } + const localHeaders = [header('workspace-old', 1), { ...header('workspace-new', 2), cwd: '/proj/' }] + const foreignHeaders = Array.from({ length: 40 }, (_, index) => ({ ...header(`foreign-${index}`, 100 + index), cwd: '/proj/other' })) + foreignHeaders[0] = { ...foreignHeaders[0], origin: 'subagent', parentSession: 'workspace-old' } + const located = [] + const batches = [] + let foreignSawLocal = false + const prioritized = await listSummaries({ + ...source([...foreignHeaders, ...localHeaders], priorityConfig), + locate(meta) { + located.push(meta.id) + if (meta.id.startsWith('foreign-') && located.length === 3) foreignSawLocal = batches[0]?.length === 2 + return undefined + }, + }, undefined, undefined, rows => { + batches.push(rows) + check('workspace partials never become a complete disk snapshot', diskRows([], priorityConfig), undefined) + }, '/proj') + check('current workspace logs resolve ahead of newer foreign logs', located.slice(0, 2), ['workspace-new', 'workspace-old']) + check('a workspace smaller than 32 paints before any foreign log is resolved', foreignSawLocal, true) + check('the first batch contains only the normalized current workspace', batches[0].map(row => row.id), ['workspace-new', 'workspace-old']) + check('workspace-first rows still count children from every project', batches[0].find(row => row.id === 'workspace-old').childCount, 1) + check('the complete result still includes every workspace in activity order', prioritized.length, 42) + check('final ordering is not changed by workspace processing priority', prioritized[0].id, 'foreign-39') + check('only the complete multi-workspace listing becomes the snapshot', diskRows([], priorityConfig).length, 42) + // ── 8. The real Channel forwards the cache without a backend call ─────────── let backendCalls = 0 const live = { name: provider.name, config: { root: provider.config.root }, list: async () => { backendCalls += 1; return [header('live', 5)] } } @@ -143,7 +171,7 @@ try { const metadata = createSessionMetadataActions({ get: name => name === 'sessionPersistence' ? currentProvider : undefined }, { owner, binding: { capture: () => ({ agent, generation: 1 }), isCurrent: () => owner.current() }, - provider: () => 'deepseek', model: () => 'model', emit: () => undefined, + provider: () => 'deepseek', model: () => 'model', cwd: () => '/proj', emit: () => undefined, sessionTitle: () => '', setSessionTitle: () => undefined, setSessionColor: () => undefined, forgetAgentView: () => undefined, setPersistedSessions: rows => pushed.push(rows), skillRegistryFor: () => undefined, skillViewOptions: () => ({ scope: agent, cwd: '/proj' }), @@ -161,6 +189,10 @@ try { let forwardedPartial await actions.listSessions(undefined, batch => { forwardedPartial = batch }) check('partial rows traverse the real readiness delegate', forwardedPartial.length, 32) + live.list = async () => [...foreignHeaders, ...localHeaders] + let firstWorkspace + await actions.listSessions(undefined, batch => { firstWorkspace ??= batch }) + check('the real metadata action forwards its current workspace to the listing', firstWorkspace.map(row => row.id), ['workspace-new', 'workspace-old']) live.list = liveList await actions.listSessions() diff --git a/src/backends/claude/catalog.ts b/src/backends/claude/catalog.ts index 133f5050a..f516a7d7d 100644 --- a/src/backends/claude/catalog.ts +++ b/src/backends/claude/catalog.ts @@ -38,6 +38,7 @@ export interface ClaudeCatalogDeps { /** Preview depth when the caller names none. */ const PREVIEW_LIMIT = 6 +const FIRST_PAGE = 32 /** One SDK record as a browser row. */ export function claudeSessionSummary(info: SDKSessionInfo, lastUsed: Readonly> = {}): SessionSummary { @@ -80,13 +81,22 @@ export function createClaudeCatalog(deps: ClaudeCatalogDeps): SessionCatalog { const dirOption = (cwd: string | undefined): { dir: string } | undefined => cwd === undefined || cwd === '' ? undefined : { dir: cwd } return { ...(deps.snapshotKey === undefined ? {} : { snapshotKey: deps.snapshotKey }), - async list(scope: SessionListScope = {}): Promise { + async list(scope: SessionListScope = {}, onPartial?: (rows: readonly SessionSummary[]) => void): Promise { const sdk = await deps.loadSdk() - const infos = scope.allProjects === true - ? await sdk.listSessions({ includeProgrammatic: true }) - : await sdk.listSessions({ dir: scope.cwd ?? deps.cwd(), includeProgrammatic: true }) + const options = { includeProgrammatic: true, ...(scope.allProjects === true ? {} : { dir: scope.cwd ?? deps.cwd(), includeWorktrees: false }) } const used = lastUsed() - return infos.map(info => claudeSessionSummary(info, used)).sort((a, b) => b.updatedAt - a.updatedAt) + const summaries = (infos: readonly SDKSessionInfo[]): SessionSummary[] => + infos.map(info => claudeSessionSummary(info, used)).sort((a, b) => b.updatedAt - a.updatedAt) + if (onPartial !== undefined) { + // A bounded SDK read extracts metadata only for the newest sessions. + // Keep the complete read below: offset paging would repeatedly scan + // earlier pages, and a short first page is already a complete listing. + const first = await sdk.listSessions({ ...options, limit: FIRST_PAGE }) + const rows = summaries(first) + onPartial(rows) + if (first.length < FIRST_PAGE) return rows + } + return summaries(await sdk.listSessions(options)) }, async info(sessionId: string, cwd?: string): Promise { const sdk = await deps.loadSdk() diff --git a/src/backends/codex/catalog.ts b/src/backends/codex/catalog.ts index 9ae391243..65b33a6e9 100644 --- a/src/backends/codex/catalog.ts +++ b/src/backends/codex/catalog.ts @@ -18,6 +18,7 @@ export interface CodexCatalogDeps { } const LIST_LIMIT = 500 const LIST_PAGE = 200 +const FIRST_PAGE = 32 const PREVIEW_LIMIT = 6 /** Metadata only. Absence of preview text is not proof of an empty thread. */ @@ -83,9 +84,21 @@ export function createCodexCatalog(deps: CodexCatalogDeps): SessionCatalog { // Read file-backed preferences once, rather than synchronously reading // and parsing them for every thread in every page. const used = deps.lastUsed?.() + const params = { sortKey: 'recency_at', sortDirection: 'desc', archived: false, modelProviders: [], ...(cwd === undefined ? {} : { cwd }) } + if (onPartial !== undefined) { + // The native state DB can paint rows before the default rollout scan + // repairs metadata. Its rows never enter the final listing or cache. + let first: Rec | undefined + try { + first = rec(await hub.call(CLIENT.threadList, { ...params, limit: FIRST_PAGE, useStateDbOnly: true }, { timeoutMs: 1000 })) + } catch { /* Older servers or unavailable DBs use the regular listing. */ } + const initial = arr(first?.data).map(raw => codexSessionSummary(raw, used)) + .filter((row): row is SessionSummary => row !== undefined && row.kind.kind !== 'subagent') + if (initial.length > 0) onPartial(initial.sort((a, b) => b.updatedAt - a.updatedAt)) + } // The row budget also bounds the number of empty/malformed pages. for (let page = 0; page < Math.ceil(LIST_LIMIT / LIST_PAGE) && scanned < LIST_LIMIT; page += 1) { - const answer = rec(await hub.call(CLIENT.threadList, { limit: Math.min(LIST_PAGE, LIST_LIMIT - scanned), sortKey: 'recency_at', sortDirection: 'desc', archived: false, modelProviders: [], ...(cwd === undefined ? {} : { cwd }), ...(cursor === undefined ? {} : { cursor }) })) + const answer = rec(await hub.call(CLIENT.threadList, { ...params, limit: Math.min(LIST_PAGE, LIST_LIMIT - scanned), ...(cursor === undefined ? {} : { cursor }) })) const data = arr(answer?.data) scanned += data.length for (const raw of data.slice(0, LIST_LIMIT - rows.size)) { diff --git a/src/dsh-adapter/channel/extensions.ts b/src/dsh-adapter/channel/extensions.ts index 115d1adc2..7ee1b5379 100644 --- a/src/dsh-adapter/channel/extensions.ts +++ b/src/dsh-adapter/channel/extensions.ts @@ -431,6 +431,7 @@ export function attachDshExtensions( const sessionMetadataActions = createSessionMetadataActions(ctx, { owner, binding, + cwd: () => state.cwd, provider: () => state.provider, model: () => state.model, emit: () => state.emit(), diff --git a/src/dsh-adapter/channel/session-metadata.ts b/src/dsh-adapter/channel/session-metadata.ts index de6a2454b..67c9550f8 100644 --- a/src/dsh-adapter/channel/session-metadata.ts +++ b/src/dsh-adapter/channel/session-metadata.ts @@ -34,6 +34,8 @@ export function createSessionMetadataActions(ctx: Context, deps: { binding: Binding provider(): string model(): string + /** The browser's current workspace, prioritized during a cold listing. */ + cwd?(): string emit(): void sessionTitle(): string setSessionTitle(title: string): void @@ -79,7 +81,7 @@ export function createSessionMetadataActions(ctx: Context, deps: { onEnriched?.(enriched) }, rows => { if (current(capture) && generation === listingGeneration) onPartial?.(rows) - }) + }, deps.cwd?.()) if (!current(capture) || generation !== listingGeneration) return [] remember(source, summaries) deps.setPersistedSessions(summaries) diff --git a/src/dsh-adapter/sessions/list.ts b/src/dsh-adapter/sessions/list.ts index 0f6d5344d..c0fe14c26 100644 --- a/src/dsh-adapter/sessions/list.ts +++ b/src/dsh-adapter/sessions/list.ts @@ -31,6 +31,7 @@ import { findSessionLogFile, resolveLocatedPath } from '../compat/sessionLog.js' import { indexFileStamp, readIndex, writeIndex, type DerivedEntry, type SessionIndex } from './store.js' import type { SessionSummary } from './types.js' import { readLastUsed } from '../../sessionHistory.js' +import { normalizeWorkspaceCwd } from '../../sessions/view.js' /** A late overlapping listing must not write an older index over a newer one. */ const listingVersions = new WeakMap() @@ -151,6 +152,8 @@ function locate(source: SessionSource, raw: unknown, sessionId: string): string * * @param source - The persistence service. * @param signal - Optional cancellation for the backend's own listing work. + * @param priorityCwd - Resolve this workspace before others for partial results; + * the final listing still includes every workspace, sorted by activity. * @returns One summary per stored session, most recently active first. No * filtering of any kind is applied — sub-agent runs and sessions with no * conversation are present and labelled as such. @@ -160,6 +163,7 @@ export async function listSummaries( signal?: AbortSignal, onEnriched?: (summary: SessionSummary) => void, onPartial?: (summaries: readonly SessionSummary[]) => void, + priorityCwd?: string, ): Promise { const identity = source.identity ?? source const version = (listingVersions.get(identity) ?? 0) + 1 @@ -184,10 +188,12 @@ export async function listSummaries( const index = readIndex() const next: SessionIndex = new Map() const lastUsed = readLastUsed() - // Recent conversations lead cold partial batches; backend directory order - // must not keep the useful rows behind thousands of old delegated runs. + // Resolve the current workspace first, even when other projects are newer. + // Activity determines processing order within each group, and final order. + const cwd = priorityCwd ? normalizeWorkspaceCwd(priorityCwd) : undefined + const workspace = new Set(cwd === undefined ? [] : listed.filter(entry => normalizeWorkspaceCwd(entry.header.cwd ?? '') === cwd)) const activity = (entry: Listed): number => Math.max(index.get(entry.header.id)?.derived?.modifiedAt ?? 0, lastUsed[entry.header.id] ?? 0, entry.header.createdAt ?? 0) - listed.sort((a, b) => activity(b) - activity(a)) + listed.sort((a, b) => Number(workspace.has(b)) - Number(workspace.has(a)) || activity(b) - activity(a)) let changed = false const records: Array<{ header: RawSessionHeader @@ -355,7 +361,8 @@ export async function listSummaries( recordsById.set(header.id, record) // Yield even for a cold index: scanning many individually bounded logs // must not freeze the renderer. Partial rows are never saved as a snapshot. - if (records.length % 32 === 0) { + // A small workspace must paint before we touch another project's logs. + if (records.length % 32 === 0 || (records.length === workspace.size && records.length < listed.length)) { if (listingVersions.get(identity) === version) onPartial?.(records.map(summaryOf).sort(compareSummaries)) await new Promise(resolve => setImmediate(resolve)) signal?.throwIfAborted() From bb34f60b6df2dfdcc92c801a2b4747140f2baf92 Mon Sep 17 00:00:00 2001 From: CikeSeven Date: Sun, 11 Oct 2026 01:09:08 +0800 Subject: [PATCH 2/5] fix(claude): preserve worktrees in complete session listings Exclude sibling worktrees only from the initial progress batch and retain the SDK's complete project scope, including after a short or empty first page. Keep the all-projects failure fallback from dropping sibling sessions. Cover direct catalog reads and the real channel fallback, and sync the bilingual documentation and bundled guide. --- docs/claude-backend.en.md | 3 +- docs/claude-backend.md | 3 +- guide/dsh-tui-guide/claude-backend.en.md | 3 +- guide/dsh-tui-guide/claude-backend.md | 3 +- scripts/verify-claude-catalog.ts | 43 +++++++++++++++++++++--- src/backends/claude/catalog.ts | 11 +++--- 6 files changed, 52 insertions(+), 14 deletions(-) diff --git a/docs/claude-backend.en.md b/docs/claude-backend.en.md index 10f2ab2f0..2bb696dec 100644 --- a/docs/claude-backend.en.md +++ b/docs/claude-backend.en.md @@ -161,7 +161,8 @@ a notice, so a cloned repository cannot silently switch off every check. First-paint snapshots are isolated by Claude configuration directory. Without a snapshot, up to 32 recent sessions from the current directory appear first, then the rest of that workspace and other workspaces. Other Git worktrees in the same repository stay out of - the first batch. Failed reads preserve the last complete snapshot. + the first batch but remain in the complete project listing, including the fallback when + the all-projects read fails. Failed reads preserve the last complete snapshot. - From a shell: `dsh-tui --backend claude --resume `; without an id it resumes the last Claude session this install used. dsh-TUI prints that command on exit. A session another dsh-TUI terminal has open is refused, and a `--resume` that diff --git a/docs/claude-backend.md b/docs/claude-backend.md index f5c3ec735..c16fbbaee 100644 --- a/docs/claude-backend.md +++ b/docs/claude-backend.md @@ -125,7 +125,8 @@ Claude 设置 > `default`。Claude 设置里的 `defaultMode: bypassPermissions` `Ctrl+D` 删除;当前会话和另一个 dsh-TUI 终端正在用的会话不能删。置顶按后端分开保存。 再次打开或重启后先显示上次完整列表,同时仍从 SDK 刷新。首屏快照按 Claude 配置 目录隔离;没有快照时先显示当前工作目录最近的最多 32 条,再补齐该工作区及其他 - 工作区的列表。同一仓库的其他 Git worktree 不混入首批结果。读取失败时保留上次完整快照。 + 工作区的列表。同一仓库的其他 Git worktree 不混入首批结果,但仍包含在完整项目目录 + 中;全部项目读取失败时也保留这些会话。读取失败时保留上次完整快照。 - 从 shell 恢复:`dsh-tui --backend claude --resume `;不带 id 时恢复本机最近一次 用过的 Claude 会话。退出时 dsh-TUI 会打印这条命令。另一个 dsh-TUI 终端已打开的会话会 被拒绝;`--resume` 打不开时直接报错,不会改开新会话。状态栏的短会话 id 就是 diff --git a/guide/dsh-tui-guide/claude-backend.en.md b/guide/dsh-tui-guide/claude-backend.en.md index 10f2ab2f0..2bb696dec 100644 --- a/guide/dsh-tui-guide/claude-backend.en.md +++ b/guide/dsh-tui-guide/claude-backend.en.md @@ -161,7 +161,8 @@ a notice, so a cloned repository cannot silently switch off every check. First-paint snapshots are isolated by Claude configuration directory. Without a snapshot, up to 32 recent sessions from the current directory appear first, then the rest of that workspace and other workspaces. Other Git worktrees in the same repository stay out of - the first batch. Failed reads preserve the last complete snapshot. + the first batch but remain in the complete project listing, including the fallback when + the all-projects read fails. Failed reads preserve the last complete snapshot. - From a shell: `dsh-tui --backend claude --resume `; without an id it resumes the last Claude session this install used. dsh-TUI prints that command on exit. A session another dsh-TUI terminal has open is refused, and a `--resume` that diff --git a/guide/dsh-tui-guide/claude-backend.md b/guide/dsh-tui-guide/claude-backend.md index f5c3ec735..c16fbbaee 100644 --- a/guide/dsh-tui-guide/claude-backend.md +++ b/guide/dsh-tui-guide/claude-backend.md @@ -125,7 +125,8 @@ Claude 设置 > `default`。Claude 设置里的 `defaultMode: bypassPermissions` `Ctrl+D` 删除;当前会话和另一个 dsh-TUI 终端正在用的会话不能删。置顶按后端分开保存。 再次打开或重启后先显示上次完整列表,同时仍从 SDK 刷新。首屏快照按 Claude 配置 目录隔离;没有快照时先显示当前工作目录最近的最多 32 条,再补齐该工作区及其他 - 工作区的列表。同一仓库的其他 Git worktree 不混入首批结果。读取失败时保留上次完整快照。 + 工作区的列表。同一仓库的其他 Git worktree 不混入首批结果,但仍包含在完整项目目录 + 中;全部项目读取失败时也保留这些会话。读取失败时保留上次完整快照。 - 从 shell 恢复:`dsh-tui --backend claude --resume `;不带 id 时恢复本机最近一次 用过的 Claude 会话。退出时 dsh-TUI 会打印这条命令。另一个 dsh-TUI 终端已打开的会话会 被拒绝;`--resume` 打不开时直接报错,不会改开新会话。状态栏的短会话 id 就是 diff --git a/scripts/verify-claude-catalog.ts b/scripts/verify-claude-catalog.ts index a2b05ced8..293b6124f 100644 --- a/scripts/verify-claude-catalog.ts +++ b/scripts/verify-claude-catalog.ts @@ -81,8 +81,8 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s listSessions: (options?: Record) => { calls.push({ method: 'listSessions', args: [options] }) return Promise.resolve(options?.dir === undefined - ? [info({ sessionId: 'a', customTitle: 'Custom or AI title' }), info({ sessionId: 'b', firstPrompt: 'first prompt', lastModified: NOW - 10 }), info({ sessionId: 'elsewhere', cwd: '/other/dir', summary: 'other' })] - : [info({ sessionId: 'a', customTitle: 'Custom or AI title' }), info({ sessionId: 'b', firstPrompt: 'first prompt', lastModified: NOW - 10 })]) + ? [info({ sessionId: 'a', customTitle: 'Custom or AI title' }), info({ sessionId: 'b', firstPrompt: 'first prompt', lastModified: NOW - 10 }), info({ sessionId: 'sibling', cwd: '/sibling-worktree', summary: 'worktree' }), info({ sessionId: 'elsewhere', cwd: '/other/dir', summary: 'other' })] + : [info({ sessionId: 'a', customTitle: 'Custom or AI title' }), info({ sessionId: 'b', firstPrompt: 'first prompt', lastModified: NOW - 10 }), ...(options?.includeWorktrees === false ? [] : [info({ sessionId: 'sibling', cwd: '/sibling-worktree', summary: 'worktree' })])]) }, getSessionInfo: (id: string, options?: Record) => { calls.push({ method: 'getSessionInfo', args: [id, options] }) @@ -103,7 +103,7 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s const catalog = createClaudeCatalog({ loadSdk: () => Promise.resolve(store as never), cwd: () => workdir, lastUsed: () => ({ a: NOW }) }) const local = await catalog.list({ cwd: workdir }) const listCall = calls.find(call => call.method === 'listSessions')!.args[0] as Record - check('the project listing asks for only this workspace, programmatic sessions included (P4-1)', listCall.dir === workdir && listCall.includeProgrammatic === true && listCall.includeWorktrees === false) + check('the complete scoped listing preserves SDK worktree coverage, programmatic sessions included (P4-1)', listCall.dir === workdir && listCall.includeProgrammatic === true && listCall.includeWorktrees === undefined && local.some(row => row.id === 'sibling')) check('rows are the browser\'s shape, tagged with the backend', local.every(row => row.backendId === 'claude' && row.kind.kind === 'root' && row.hasPrompt && row.childCount === 0)) const a = local.find(row => row.id === 'a')! const b = local.find(row => row.id === 'b')! @@ -127,8 +127,41 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s const before = calls.length const partials: (readonly SessionSummary[])[] = [] const small = await catalog.list({}, rows => { partials.push(rows) }) - const smallOptions = calls.at(-1)!.args[0] as Record - check('a short workspace first page is complete without a second SDK scan', small.length === 2 && partials.length === 1 && calls.length === before + 1 && smallOptions.limit === 32 && smallOptions.dir === workdir && smallOptions.includeWorktrees === false) + const smallOptions = calls[before]!.args[0] as Record + const completeOptions = calls.at(-1)!.args[0] as Record + check('a short first page stays in the current directory while the complete result includes siblings', partials[0]?.length === 2 && partials[0].every(row => row.cwd === workdir) && small.some(row => row.id === 'sibling') && calls.length === before + 2 && smallOptions.limit === 32 && smallOptions.dir === workdir && smallOptions.includeWorktrees === false && completeOptions.limit === undefined && completeOptions.includeWorktrees === undefined) + const beforeAll = calls.length + const smallAll = await catalog.list({ allProjects: true }, () => {}) + check('a short all-projects page is already complete without a second SDK scan', smallAll.length === 4 && calls.length === beforeAll + 1) + + const sibling = info({ sessionId: 'sibling', cwd: '/sibling-worktree', summary: 'worktree' }) + const emptyCatalog = createClaudeCatalog({ + cwd: () => workdir, + loadSdk: async () => ({ ...store, listSessions: async (options: Record) => options.includeWorktrees === false ? [] : [sibling] }) as never, + }) + const emptyPartials: (readonly SessionSummary[])[] = [] + const siblingsOnly = await emptyCatalog.list({ cwd: workdir }, rows => { emptyPartials.push(rows) }) + check('an empty current-directory page still loads sessions from sibling worktrees', emptyPartials[0]?.length === 0 && siblingsOnly[0]?.id === 'sibling') + + const fallbackCatalog = createClaudeCatalog({ + cwd: () => workdir, + loadSdk: async () => ({ ...store, listSessions: (options: Record) => { + if (options.dir === undefined) return Promise.reject(new Error('all-projects unavailable')) + return store.listSessions(options) + } }) as never, + }) + const fallbackSession: AgentSession = { + ref: { backendId: 'claude', sessionId: 'worktree-fallback' }, cwd: workdir, status: 'idle', capabilities: { native: {} }, + history: async () => [], subscribe: () => () => {}, submit: async () => ({ accepted: true }), + cancel: async () => ({ stillQueued: [] }), dispose: async () => {}, + } + const fallbackChannel = createChannel({ on: () => () => {}, get: () => undefined, logger: { warn() {}, info() {}, debug() {} } } as never, fallbackSession, { cwd: workdir, model: 'Claude Agent', provider: 'claude', sessionCatalog: fallbackCatalog }) + try { + const batches: (readonly SessionSummary[])[] = [] + const fallback = await fallbackChannel.listSessions(undefined, rows => { batches.push(rows) }) + check('a failed all-projects refresh retains the complete scoped worktree listing', batches[0]?.length === 2 && batches[0].every(row => row.cwd === workdir) && fallback.some(row => row.id === 'sibling') && fallback.length === 3) + check('the failed all-projects fallback never becomes a complete snapshot', fallbackChannel.cachedSessions() === undefined) + } finally { fallbackChannel.releaseContributions() } let finish!: (rows: ReturnType[]) => void let started!: () => void diff --git a/src/backends/claude/catalog.ts b/src/backends/claude/catalog.ts index f516a7d7d..fdf5e9041 100644 --- a/src/backends/claude/catalog.ts +++ b/src/backends/claude/catalog.ts @@ -83,18 +83,19 @@ export function createClaudeCatalog(deps: ClaudeCatalogDeps): SessionCatalog { ...(deps.snapshotKey === undefined ? {} : { snapshotKey: deps.snapshotKey }), async list(scope: SessionListScope = {}, onPartial?: (rows: readonly SessionSummary[]) => void): Promise { const sdk = await deps.loadSdk() - const options = { includeProgrammatic: true, ...(scope.allProjects === true ? {} : { dir: scope.cwd ?? deps.cwd(), includeWorktrees: false }) } + const options = { includeProgrammatic: true, ...(scope.allProjects === true ? {} : { dir: scope.cwd ?? deps.cwd() }) } const used = lastUsed() const summaries = (infos: readonly SDKSessionInfo[]): SessionSummary[] => infos.map(info => claudeSessionSummary(info, used)).sort((a, b) => b.updatedAt - a.updatedAt) if (onPartial !== undefined) { // A bounded SDK read extracts metadata only for the newest sessions. - // Keep the complete read below: offset paging would repeatedly scan - // earlier pages, and a short first page is already a complete listing. - const first = await sdk.listSessions({ ...options, limit: FIRST_PAGE }) + // Scoped reads still need the complete project's worktrees, even when + // the current directory's first page is short or empty. Offset paging + // would repeatedly scan earlier pages, so read the full scope once. + const first = await sdk.listSessions({ ...options, limit: FIRST_PAGE, ...(scope.allProjects === true ? {} : { includeWorktrees: false }) }) const rows = summaries(first) onPartial(rows) - if (first.length < FIRST_PAGE) return rows + if (scope.allProjects === true && first.length < FIRST_PAGE) return rows } return summaries(await sdk.listSessions(options)) }, From 905a8a15c046368ebfc8150fa8777fbdbc5fb36d Mon Sep 17 00:00:00 2001 From: CikeSeven Date: Sun, 11 Oct 2026 01:52:00 +0800 Subject: [PATCH 3/5] fix(sessions): preserve progress and confirm complete listings Keep Codex's initial state-database IDs in every progress batch until the complete rollout result resolves, while allowing repaired metadata to win. Always perform Claude's unlimited listing before publishing a complete result, including when the bounded first page is short. Cover cumulative progress, metadata repair and authoritative memory/disk snapshots with focused catalog regressions. --- scripts/verify-claude-catalog.ts | 32 ++++++++++++++++++++++++- scripts/verify-codex-catalog-history.ts | 10 ++++++-- src/backends/claude/catalog.ts | 7 +++--- src/backends/codex/catalog.ts | 13 ++++++---- 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/scripts/verify-claude-catalog.ts b/scripts/verify-claude-catalog.ts index 293b6124f..5d0018f32 100644 --- a/scripts/verify-claude-catalog.ts +++ b/scripts/verify-claude-catalog.ts @@ -132,7 +132,7 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s check('a short first page stays in the current directory while the complete result includes siblings', partials[0]?.length === 2 && partials[0].every(row => row.cwd === workdir) && small.some(row => row.id === 'sibling') && calls.length === before + 2 && smallOptions.limit === 32 && smallOptions.dir === workdir && smallOptions.includeWorktrees === false && completeOptions.limit === undefined && completeOptions.includeWorktrees === undefined) const beforeAll = calls.length const smallAll = await catalog.list({ allProjects: true }, () => {}) - check('a short all-projects page is already complete without a second SDK scan', smallAll.length === 4 && calls.length === beforeAll + 1) + check('a short all-projects page is followed by an unlimited SDK read', smallAll.length === 4 && calls.length === beforeAll + 2 && (calls.at(-1)!.args[0] as Record).limit === undefined) const sibling = info({ sessionId: 'sibling', cwd: '/sibling-worktree', summary: 'worktree' }) const emptyCatalog = createClaudeCatalog({ @@ -163,6 +163,36 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s check('the failed all-projects fallback never becomes a complete snapshot', fallbackChannel.cachedSessions() === undefined) } finally { fallbackChannel.releaseContributions() } + const { readListingSnapshot } = await import('../src/sessions/listSnapshot.js') + const canonicalKey = JSON.stringify(['claude', home, 'limited-global']) + const early = info({ sessionId: 'early-global' }) + const omitted = info({ sessionId: 'omitted-by-limited-read', cwd: '/other-project' }) + let finishGlobal!: (rows: ReturnType[]) => void + let fullStarted = false + const fullGlobal = new Promise[]>(resolve => { finishGlobal = resolve }) + const canonicalCatalog = createClaudeCatalog({ + cwd: () => workdir, + snapshotKey: () => canonicalKey, + loadSdk: async () => ({ ...store, listSessions: (options: Record) => { + if (options.dir !== undefined) return Promise.resolve([]) + if (options.limit === 32) return Promise.resolve([early]) + fullStarted = true + return fullGlobal + } }) as never, + }) + const canonicalChannel = createChannel({ on: () => () => {}, get: () => undefined, logger: { warn() {}, info() {}, debug() {} } } as never, { ...fallbackSession, ref: { backendId: 'claude', sessionId: 'canonical-global' } }, { cwd: workdir, model: 'Claude Agent', provider: 'claude', sessionCatalog: canonicalCatalog }) + try { + const batches: (readonly SessionSummary[])[] = [] + let completed = false + const listing = canonicalChannel.listSessions(undefined, rows => { batches.push(rows) }).then(rows => { completed = true; return rows }) + await settled(() => fullStarted || completed) + check('a short global page is progress while the unlimited SDK read is pending', fullStarted && !completed && batches.at(-1)?.[0]?.id === 'early-global') + check('a short limited global page cannot become a memory or disk snapshot', canonicalChannel.cachedSessions() === undefined && readListingSnapshot(canonicalKey) === undefined) + finishGlobal([early, omitted]) + const complete = await listing + check('only the unlimited result populates complete snapshots, including omitted global sessions', complete.length === 2 && complete.some(row => row.id === omitted.sessionId) && canonicalChannel.cachedSessions()?.length === 2 && readListingSnapshot(canonicalKey)?.some(row => row.id === omitted.sessionId)) + } finally { canonicalChannel.releaseContributions() } + let finish!: (rows: ReturnType[]) => void let started!: () => void const pending = new Promise[]>(resolve => { finish = resolve }) diff --git a/scripts/verify-codex-catalog-history.ts b/scripts/verify-codex-catalog-history.ts index 0b994e199..9b5644675 100644 --- a/scripts/verify-codex-catalog-history.ts +++ b/scripts/verify-codex-catalog-history.ts @@ -69,17 +69,23 @@ const row = (id: string, extra: Record = {}) => ({ id, cwd: SET const hub = createCodexHub(SETTINGS, { transportFactory: fake.transportFactory }) await hub.ready const batches: string[][] = [] + const titles: (string | undefined)[] = [] let done = false const catalog = createCodexCatalog({ acquire: async () => ({ hub, release: () => {} }), cwd: () => SETTINGS.cwd }) - const listing = catalog.list({}, rows => { batches.push(rows.map(row => row.id)) }).then(rows => { done = true; return rows }) + const listing = catalog.list({}, rows => { + batches.push(rows.map(row => row.id)) + titles.push(rows.find(row => row.id === 'first')?.title.text) + }).then(rows => { done = true; return rows }) const first = await fake.waitForRequest('thread/list') const regular = await fake.waitForRequest('thread/list', { after: fake.requests.indexOf(first) + 1 }) check('catalog cold load: bounded DB rows arrive while the rollout repair scan is pending', !done && first.params.useStateDbOnly === true && first.params.limit === 32 && JSON.stringify(batches) === JSON.stringify([['first', 'stale']])) fake.reply(regular.id, { data: [row('first', { name: 'Repaired title' })], nextCursor: 'slow-page' }) const request = await fake.waitForRequest('thread/list', { after: fake.requests.indexOf(regular) + 1 }) - check('catalog cold load: regular pages replace stale DB rows while the next page is pending', !done && JSON.stringify(batches) === JSON.stringify([['first', 'stale'], ['first']]) && regular.params.useStateDbOnly === undefined && regular.params.cursor === undefined) + check('catalog cold load: progress keeps all announced DB rows while the next page is pending', !done && JSON.stringify(batches) === JSON.stringify([['first', 'stale'], ['first', 'stale']]) && regular.params.useStateDbOnly === undefined && regular.params.cursor === undefined) + check('catalog cold load: repair updates metadata without withdrawing other announced rows', titles[0] === 'Old title' && titles[1] === 'Repaired title') fake.reply(request.id, { data: [row('second')], nextCursor: null }) const complete = await listing + check('catalog cold load: every progress batch retains initial IDs through the last page', batches.every(batch => batch.includes('first') && batch.includes('stale')) && batches.at(-1)?.includes('second') === true) check('catalog cold load: completion retains both repaired pages and drops DB-only rows', complete.map(row => row.id).sort().join(',') === 'first,second' && complete.find(row => row.id === 'first')?.title.text === 'Repaired title') await hub.close() } diff --git a/src/backends/claude/catalog.ts b/src/backends/claude/catalog.ts index fdf5e9041..e09535a2c 100644 --- a/src/backends/claude/catalog.ts +++ b/src/backends/claude/catalog.ts @@ -89,13 +89,12 @@ export function createClaudeCatalog(deps: ClaudeCatalogDeps): SessionCatalog { infos.map(info => claudeSessionSummary(info, used)).sort((a, b) => b.updatedAt - a.updatedAt) if (onPartial !== undefined) { // A bounded SDK read extracts metadata only for the newest sessions. - // Scoped reads still need the complete project's worktrees, even when - // the current directory's first page is short or empty. Offset paging - // would repeatedly scan earlier pages, so read the full scope once. + // An unlimited read confirms completeness for every scope: a short + // limited page does not promise exhaustion, and scoped progress also + // excludes sibling worktrees. Avoid repeatedly scanning offset pages. const first = await sdk.listSessions({ ...options, limit: FIRST_PAGE, ...(scope.allProjects === true ? {} : { includeWorktrees: false }) }) const rows = summaries(first) onPartial(rows) - if (scope.allProjects === true && first.length < FIRST_PAGE) return rows } return summaries(await sdk.listSessions(options)) }, diff --git a/src/backends/codex/catalog.ts b/src/backends/codex/catalog.ts index 65b33a6e9..1af675dff 100644 --- a/src/backends/codex/catalog.ts +++ b/src/backends/codex/catalog.ts @@ -78,6 +78,7 @@ export function createCodexCatalog(deps: CodexCatalogDeps): SessionCatalog { const cwd = scope.allProjects === true ? undefined : scope.cwd ?? deps.cwd() return borrow(cwd, async hub => { const rows = new Map() + const initial = new Map() let cursor: string | undefined let scanned = 0 const seen = new Set() @@ -92,9 +93,11 @@ export function createCodexCatalog(deps: CodexCatalogDeps): SessionCatalog { try { first = rec(await hub.call(CLIENT.threadList, { ...params, limit: FIRST_PAGE, useStateDbOnly: true }, { timeoutMs: 1000 })) } catch { /* Older servers or unavailable DBs use the regular listing. */ } - const initial = arr(first?.data).map(raw => codexSessionSummary(raw, used)) - .filter((row): row is SessionSummary => row !== undefined && row.kind.kind !== 'subagent') - if (initial.length > 0) onPartial(initial.sort((a, b) => b.updatedAt - a.updatedAt)) + for (const raw of arr(first?.data)) { + const row = codexSessionSummary(raw, used) + if (row !== undefined && row.kind.kind !== 'subagent') initial.set(row.id, row) + } + if (initial.size > 0) onPartial([...initial.values()].sort((a, b) => b.updatedAt - a.updatedAt)) } // The row budget also bounds the number of empty/malformed pages. for (let page = 0; page < Math.ceil(LIST_LIMIT / LIST_PAGE) && scanned < LIST_LIMIT; page += 1) { @@ -105,7 +108,9 @@ export function createCodexCatalog(deps: CodexCatalogDeps): SessionCatalog { const row = codexSessionSummary(raw, used) if (row !== undefined && row.kind.kind !== 'subagent') rows.set(row.id, row) } - onPartial?.([...rows.values()].sort((a, b) => b.updatedAt - a.updatedAt)) + // Progress is cumulative; repaired rows supersede the DB metadata, + // and only the resolved result may drop unconfirmed initial IDs. + onPartial?.([...new Map([...initial, ...rows]).values()].sort((a, b) => b.updatedAt - a.updatedAt)) cursor = text(answer?.nextCursor) if (cursor === undefined || seen.has(cursor)) break seen.add(cursor) From 0b2e15c69730a62c00ec8f58eff78a864f22abb6 Mon Sep 17 00:00:00 2001 From: CikeSeven Date: Sun, 11 Oct 2026 02:12:04 +0800 Subject: [PATCH 4/5] test(effort): match modal titles instead of welcome tips Observe the same rendered Help heading for open and close waits, and match standalone effort titles and hint text. Random welcome tips can mention scrolling or reasoning effort and otherwise trigger premature input or false close failures. --- scripts/verify-effort-slider-ui.mjs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/verify-effort-slider-ui.mjs b/scripts/verify-effort-slider-ui.mjs index 98ef97489..4784c3c71 100644 --- a/scripts/verify-effort-slider-ui.mjs +++ b/scripts/verify-effort-slider-ui.mjs @@ -210,6 +210,9 @@ await sleep(700) // inline 模式下有 scrollback 时 getLine(0..rows) 直扫读的是缓冲区开头; // 改用公共辅助按 baseY 读可见视口(issue #532)。 const screen = () => viewportLines(term).join('\n') +// Welcome tips can mention scrolling or effort; observe the modal chrome. +const helpVisible = title => viewportLines(term).some(line => line.trimEnd().endsWith(title)) +const sliderVisible = title => viewportLines(term).some(line => line.trim() === title) // Pin the UI language so the assertions below don't depend on the host's // persisted /lang choice or OS locale (the slider chrome is localized). @@ -221,15 +224,15 @@ stdin.write('/help') // 输入已落屏(补全菜单描述可见)再回车——等待后只操作不断言,用 settle。 await settle(() => screen().includes('Show shortcuts and commands')) stdin.write('\r') -check('en: Help opens before slider', await settled(() => /scroll|commands:/.test(screen())), '') +check('en: Help opens before slider', await settled(() => helpVisible('commands:')), '') stdin.write('\x1b') -check('en: Esc closes Help only', await settled(() => !/commands:/.test(screen())), '') +check('en: Esc closes Help only', await settled(() => !helpVisible('commands:')), '') // 1. /effort bare → slider opens with the current level (High) checked. stdin.write('/effort') await settle(() => screen().includes('Adjust the reasoning effort')) stdin.write('\r') -check('slider opens with Reasoning effort title', await settled(() => /Reasoning effort/.test(screen())), '') +check('slider opens with Reasoning effort title', await settled(() => sliderVisible('Reasoning effort')), '') check('slider lists all three levels', await settled(() => /Off/.test(screen()) && /High/.test(screen()) && /Max/.test(screen())), '') check('current level marked', await settled(() => /High\s*✓/.test(screen()) || /✓/.test(screen())), '') @@ -240,7 +243,7 @@ check('statusline effort shows max', await settled(() => /max/.test(screen())), // 3. Esc closes. stdin.write('\x1b') -check('Esc closed the slider', await settled(() => !/Reasoning effort/.test(screen().slice(-4000))), '') +check('Esc closed the slider', await settled(() => !sliderVisible('Reasoning effort')), '') // 4. /effort off → direct set + notify. stdin.write('/effort off') @@ -268,18 +271,18 @@ stdin.write('/help') // zh 下 /help 的补全描述是本地化文案(i18n cmd-desc-help),等英文永远不成立。 await settle(() => screen().includes('查看快捷键与命令')) stdin.write('\r') -check('zh: Help opens before slider', await settled(() => /命令:/.test(screen())), '') +check('zh: Help opens before slider', await settled(() => helpVisible('命令:')), '') stdin.write('\x1b') -check('zh: Esc closes Help only', await settled(() => !/命令:/.test(screen())), '') +check('zh: Esc closes Help only', await settled(() => !helpVisible('命令:')), '') stdin.write('/effort') // /effort 暂无 cmd-desc-effort 键,zh 下补全描述回退英文;若日后补键需同步改这里。 await settle(() => screen().includes('Adjust the reasoning effort')) stdin.write('\r') -check('zh: slider title 推理强度', await settled(() => screen().includes('推理强度')), '') -check('zh: hint line localized', await settled(() => screen().includes('调整') && screen().includes('完成')), '') +check('zh: slider title 推理强度', await settled(() => sliderVisible('推理强度')), '') +check('zh: hint line localized', await settled(() => viewportLines(term).some(line => line.trim() === '←/→ 调整 · Enter/Esc 完成')), '') // Read the xterm visible screen after the repaint, not the raw output backlog. stdin.write('\x1b') -check('zh: Esc closed the slider', await settled(() => !screen().includes('推理强度')), '') +check('zh: Esc closed the slider', await settled(() => !sliderVisible('推理强度')), '') setLang('en') instance.unmount() From 6600a7bfaff7e6e23701a882859bdfc82ff87b7d Mon Sep 17 00:00:00 2001 From: CikeSeven Date: Sun, 11 Oct 2026 02:28:43 +0800 Subject: [PATCH 5/5] fix(claude): preserve native worktree scope in first batch Use the SDK default project scope for bounded progress and complete session queries. Include sibling worktrees from the first batch, retain unlimited completeness checks, and synchronize the regression coverage and bilingual documentation. --- README.md | 2 +- README_ZH.md | 2 +- docs/claude-backend.en.md | 8 +++---- docs/claude-backend.md | 6 ++--- guide/dsh-tui-guide/claude-backend.en.md | 8 +++---- guide/dsh-tui-guide/claude-backend.md | 6 ++--- scripts/verify-claude-catalog.ts | 28 ++++++++++++++++++++---- src/backends/claude/catalog.ts | 8 +++---- 8 files changed, 44 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 69d169684..ca26c7c51 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ Shortcuts sit below the title and effort levels have their own section; DSH, Cla In `/provider`'s model list, focus a model and press `Tab` to edit its context window, max output tokens, reasoning efforts, and image input capability. -The session manager focuses the most recently used session in the current workspace; if there is no history, it focuses the new-session card. Press `←` to move to the workspace rail. It paints the last successful list immediately while it checks the persistence store for changes. This first-paint snapshot survives restarts on DSH, Claude and Codex and is isolated by backend and storage directory. Without a snapshot, all three backends prioritize the current workspace before filling in other workspaces. Claude and Codex first show up to 32 recent sessions from the current workspace; other Git worktrees appear as separate workspaces. DSH resolves the current workspace's metadata first and publishes it before reading other workspaces' logs, even when it has fewer than 32 sessions. Titles that require a deeper DSH log scan appear first with a fallback name and update in place when recovery finishes. +The session manager focuses the most recently used session in the current workspace; if there is no history, it focuses the new-session card. Press `←` to move to the workspace rail. It paints the last successful list immediately while it checks the persistence store for changes. This first-paint snapshot survives restarts on DSH, Claude and Codex and is isolated by backend and storage directory. Without a snapshot, all three backends prioritize the current workspace before filling in other workspaces. Claude and Codex first request up to 32 recent sessions within their native cwd scope: Claude's SDK includes Git worktrees in the same repository, while Codex matches the session cwd exactly. The browser groups sessions by their own cwd. DSH resolves the current workspace's metadata first and publishes it before reading other workspaces' logs, even when it has fewer than 32 sessions. Titles that require a deeper DSH log scan appear first with a fallback name and update in place when recovery finishes. With DSH's current JSONL backend, startup and `/new` keep initial permission events in memory until further session activity or an explicit durability flush saves the complete log. Restarting an unstored empty session starts fresh. Removing a workspace registration keeps its sessions accessible under a "History only" directory in the rail. History-only directories offer edit and new-session actions; rename and remove are available for registered workspaces. diff --git a/README_ZH.md b/README_ZH.md index 2bbee3d33..0afc6ff17 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -269,7 +269,7 @@ OpenAI 条款约束。完整操作与当前边界:[Codex 后端](docs/codex-ba 在 `/provider` 模型列表聚焦一项并按 `Tab`,可编辑上下文窗口、最大输出 token、推理档位和图片输入能力。 -会话管理界面默认聚焦当前工作区最近使用的会话;没有历史时聚焦“新建会话”卡片,按 `←` 可移到工作区栏。界面会立即显示上次成功读取的列表,同时核对持久化存储的变化。DSH、Claude 和 Codex 的首屏快照都可跨重启复用,并按内核与存储目录隔离。没有快照时,三种后端都优先加载当前工作区,再补齐其他工作区。Claude 和 Codex 先显示当前工作区最近的最多 32 条会话,其他 Git worktree 作为独立工作区加载。DSH 先解析当前工作区的元数据,即使不足 32 条,也会在读取其他工作区日志前显示;需要深度扫描日志的标题会先显示回退名称,恢复完成后在原行更新。 +会话管理界面默认聚焦当前工作区最近使用的会话;没有历史时聚焦“新建会话”卡片,按 `←` 可移到工作区栏。界面会立即显示上次成功读取的列表,同时核对持久化存储的变化。DSH、Claude 和 Codex 的首屏快照都可跨重启复用,并按内核与存储目录隔离。没有快照时,三种后端都优先加载当前工作区,再补齐其他工作区。Claude 和 Codex 按各自原生的 cwd 范围先请求最近的最多 32 条会话:Claude 的 SDK 默认包含同仓库 Git worktree,Codex 则精确匹配会话 cwd。界面仍按会话自身 cwd 分组。DSH 先解析当前工作区的元数据,即使不足 32 条,也会在读取其他工作区日志前显示;需要深度扫描日志的标题会先显示回退名称,恢复完成后在原行更新。 使用 DSH 当前的 JSONL 后端时,启动与 `/new` 的初始权限事件只保留在内存中;后续会话活动或显式持久化 flush 才会保存完整日志。尚未落盘的空会话重启时会重新创建。 移除工作区登记后,其历史会话仍可从侧栏的「仅历史」目录进入。 「仅历史」目录只提供编辑和新建会话操作;重命名与移除仅适用于已登记工作区。 diff --git a/docs/claude-backend.en.md b/docs/claude-backend.en.md index 2bb696dec..b4c5ba481 100644 --- a/docs/claude-backend.en.md +++ b/docs/claude-backend.en.md @@ -159,10 +159,10 @@ a notice, so a cloned repository cannot silently switch off every check. backend. Reopening or restarting first paints the last complete list while the SDK refreshes it. First-paint snapshots are isolated by Claude configuration directory. Without a snapshot, - up to 32 recent sessions from the current directory appear first, then the rest of that - workspace and other workspaces. Other Git worktrees in the same repository stay out of - the first batch but remain in the complete project listing, including the fallback when - the all-projects read fails. Failed reads preserve the last complete snapshot. + up to 32 recent sessions from the SDK's default project scope appear first, including + the current directory and Git worktrees in the same repository, then the complete + project and other projects. A failed all-projects read retains the project's worktree + sessions. Failed reads preserve the last complete snapshot. - From a shell: `dsh-tui --backend claude --resume `; without an id it resumes the last Claude session this install used. dsh-TUI prints that command on exit. A session another dsh-TUI terminal has open is refused, and a `--resume` that diff --git a/docs/claude-backend.md b/docs/claude-backend.md index c16fbbaee..3200b1205 100644 --- a/docs/claude-backend.md +++ b/docs/claude-backend.md @@ -124,9 +124,9 @@ Claude 设置 > `default`。Claude 设置里的 `defaultMode: bypassPermissions` `claude --resume ` 打开)。`Enter` 恢复(先回放历史再继续),`Ctrl+R` 重命名, `Ctrl+D` 删除;当前会话和另一个 dsh-TUI 终端正在用的会话不能删。置顶按后端分开保存。 再次打开或重启后先显示上次完整列表,同时仍从 SDK 刷新。首屏快照按 Claude 配置 - 目录隔离;没有快照时先显示当前工作目录最近的最多 32 条,再补齐该工作区及其他 - 工作区的列表。同一仓库的其他 Git worktree 不混入首批结果,但仍包含在完整项目目录 - 中;全部项目读取失败时也保留这些会话。读取失败时保留上次完整快照。 + 目录隔离;没有快照时按 SDK 默认项目范围(当前工作目录及同仓库的 Git worktree) + 先显示最近的最多 32 条,再补齐该项目及其他项目的完整列表;全部项目读取失败时 + 也保留该项目的 worktree 会话。读取失败时保留上次完整快照。 - 从 shell 恢复:`dsh-tui --backend claude --resume `;不带 id 时恢复本机最近一次 用过的 Claude 会话。退出时 dsh-TUI 会打印这条命令。另一个 dsh-TUI 终端已打开的会话会 被拒绝;`--resume` 打不开时直接报错,不会改开新会话。状态栏的短会话 id 就是 diff --git a/guide/dsh-tui-guide/claude-backend.en.md b/guide/dsh-tui-guide/claude-backend.en.md index 2bb696dec..b4c5ba481 100644 --- a/guide/dsh-tui-guide/claude-backend.en.md +++ b/guide/dsh-tui-guide/claude-backend.en.md @@ -159,10 +159,10 @@ a notice, so a cloned repository cannot silently switch off every check. backend. Reopening or restarting first paints the last complete list while the SDK refreshes it. First-paint snapshots are isolated by Claude configuration directory. Without a snapshot, - up to 32 recent sessions from the current directory appear first, then the rest of that - workspace and other workspaces. Other Git worktrees in the same repository stay out of - the first batch but remain in the complete project listing, including the fallback when - the all-projects read fails. Failed reads preserve the last complete snapshot. + up to 32 recent sessions from the SDK's default project scope appear first, including + the current directory and Git worktrees in the same repository, then the complete + project and other projects. A failed all-projects read retains the project's worktree + sessions. Failed reads preserve the last complete snapshot. - From a shell: `dsh-tui --backend claude --resume `; without an id it resumes the last Claude session this install used. dsh-TUI prints that command on exit. A session another dsh-TUI terminal has open is refused, and a `--resume` that diff --git a/guide/dsh-tui-guide/claude-backend.md b/guide/dsh-tui-guide/claude-backend.md index c16fbbaee..3200b1205 100644 --- a/guide/dsh-tui-guide/claude-backend.md +++ b/guide/dsh-tui-guide/claude-backend.md @@ -124,9 +124,9 @@ Claude 设置 > `default`。Claude 设置里的 `defaultMode: bypassPermissions` `claude --resume ` 打开)。`Enter` 恢复(先回放历史再继续),`Ctrl+R` 重命名, `Ctrl+D` 删除;当前会话和另一个 dsh-TUI 终端正在用的会话不能删。置顶按后端分开保存。 再次打开或重启后先显示上次完整列表,同时仍从 SDK 刷新。首屏快照按 Claude 配置 - 目录隔离;没有快照时先显示当前工作目录最近的最多 32 条,再补齐该工作区及其他 - 工作区的列表。同一仓库的其他 Git worktree 不混入首批结果,但仍包含在完整项目目录 - 中;全部项目读取失败时也保留这些会话。读取失败时保留上次完整快照。 + 目录隔离;没有快照时按 SDK 默认项目范围(当前工作目录及同仓库的 Git worktree) + 先显示最近的最多 32 条,再补齐该项目及其他项目的完整列表;全部项目读取失败时 + 也保留该项目的 worktree 会话。读取失败时保留上次完整快照。 - 从 shell 恢复:`dsh-tui --backend claude --resume `;不带 id 时恢复本机最近一次 用过的 Claude 会话。退出时 dsh-TUI 会打印这条命令。另一个 dsh-TUI 终端已打开的会话会 被拒绝;`--resume` 打不开时直接报错,不会改开新会话。状态栏的短会话 id 就是 diff --git a/scripts/verify-claude-catalog.ts b/scripts/verify-claude-catalog.ts index 5d0018f32..bf39f0c9c 100644 --- a/scripts/verify-claude-catalog.ts +++ b/scripts/verify-claude-catalog.ts @@ -129,7 +129,7 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s const small = await catalog.list({}, rows => { partials.push(rows) }) const smallOptions = calls[before]!.args[0] as Record const completeOptions = calls.at(-1)!.args[0] as Record - check('a short first page stays in the current directory while the complete result includes siblings', partials[0]?.length === 2 && partials[0].every(row => row.cwd === workdir) && small.some(row => row.id === 'sibling') && calls.length === before + 2 && smallOptions.limit === 32 && smallOptions.dir === workdir && smallOptions.includeWorktrees === false && completeOptions.limit === undefined && completeOptions.includeWorktrees === undefined) + check('a short first page and the complete result both preserve SDK worktree coverage', partials[0]?.length === 3 && partials[0].some(row => row.id === 'sibling') && small.some(row => row.id === 'sibling') && calls.length === before + 2 && smallOptions.limit === 32 && smallOptions.dir === workdir && smallOptions.includeWorktrees === undefined && completeOptions.limit === undefined && completeOptions.includeWorktrees === undefined) const beforeAll = calls.length const smallAll = await catalog.list({ allProjects: true }, () => {}) check('a short all-projects page is followed by an unlimited SDK read', smallAll.length === 4 && calls.length === beforeAll + 2 && (calls.at(-1)!.args[0] as Record).limit === undefined) @@ -137,11 +137,31 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s const sibling = info({ sessionId: 'sibling', cwd: '/sibling-worktree', summary: 'worktree' }) const emptyCatalog = createClaudeCatalog({ cwd: () => workdir, - loadSdk: async () => ({ ...store, listSessions: async (options: Record) => options.includeWorktrees === false ? [] : [sibling] }) as never, + loadSdk: async () => ({ ...store, listSessions: async (options: Record) => options.limit === 32 ? [] : [sibling] }) as never, }) const emptyPartials: (readonly SessionSummary[])[] = [] const siblingsOnly = await emptyCatalog.list({ cwd: workdir }, rows => { emptyPartials.push(rows) }) - check('an empty current-directory page still loads sessions from sibling worktrees', emptyPartials[0]?.length === 0 && siblingsOnly[0]?.id === 'sibling') + check('an empty limited page still loads the complete project including sibling worktrees', emptyPartials[0]?.length === 0 && siblingsOnly[0]?.id === 'sibling') + + let finishProject!: (rows: ReturnType[]) => void + let projectStarted!: () => void + const pendingProject = new Promise[]>(resolve => { finishProject = resolve }) + const projectScanning = new Promise(resolve => { projectStarted = resolve }) + const siblingCatalog = createClaudeCatalog({ + cwd: () => workdir, + loadSdk: async () => ({ ...store, listSessions: (options: Record) => { + if (options.limit === 32) return Promise.resolve(options.includeWorktrees === false ? [] : [sibling]) + projectStarted() + return pendingProject + } }) as never, + }) + let projectCompleted = false + let projectFirst: readonly SessionSummary[] | undefined + const siblingListing = siblingCatalog.list({ cwd: workdir }, rows => { projectFirst = rows }).then(rows => { projectCompleted = true; return rows }) + await projectScanning + check('a project with only sibling-worktree sessions publishes them before the full scan', !projectCompleted && projectFirst?.length === 1 && projectFirst[0]?.id === 'sibling') + finishProject([sibling]) + check('the complete project retains the sibling-worktree first batch', (await siblingListing)[0]?.id === 'sibling') const fallbackCatalog = createClaudeCatalog({ cwd: () => workdir, @@ -159,7 +179,7 @@ const info = (over: Record) => ({ sessionId: 's', summary: 'a s try { const batches: (readonly SessionSummary[])[] = [] const fallback = await fallbackChannel.listSessions(undefined, rows => { batches.push(rows) }) - check('a failed all-projects refresh retains the complete scoped worktree listing', batches[0]?.length === 2 && batches[0].every(row => row.cwd === workdir) && fallback.some(row => row.id === 'sibling') && fallback.length === 3) + check('a failed all-projects refresh retains worktrees from scoped progress and the complete listing', batches[0]?.length === 3 && batches[0].some(row => row.id === 'sibling') && fallback.some(row => row.id === 'sibling') && fallback.length === 3) check('the failed all-projects fallback never becomes a complete snapshot', fallbackChannel.cachedSessions() === undefined) } finally { fallbackChannel.releaseContributions() } diff --git a/src/backends/claude/catalog.ts b/src/backends/claude/catalog.ts index e09535a2c..942af8285 100644 --- a/src/backends/claude/catalog.ts +++ b/src/backends/claude/catalog.ts @@ -89,10 +89,10 @@ export function createClaudeCatalog(deps: ClaudeCatalogDeps): SessionCatalog { infos.map(info => claudeSessionSummary(info, used)).sort((a, b) => b.updatedAt - a.updatedAt) if (onPartial !== undefined) { // A bounded SDK read extracts metadata only for the newest sessions. - // An unlimited read confirms completeness for every scope: a short - // limited page does not promise exhaustion, and scoped progress also - // excludes sibling worktrees. Avoid repeatedly scanning offset pages. - const first = await sdk.listSessions({ ...options, limit: FIRST_PAGE, ...(scope.allProjects === true ? {} : { includeWorktrees: false }) }) + // Both reads keep the SDK's project scope, including git worktrees. + // An unlimited read confirms completeness: a short limited page does + // not promise exhaustion. Avoid repeatedly scanning offset pages. + const first = await sdk.listSessions({ ...options, limit: FIRST_PAGE }) const rows = summaries(first) onPartial(rows) }