perf(web): page session list per workspace on first load - #1084
Conversation
Load only the first page of sessions per workspace instead of draining every session up front, so the initial request count scales with the number of workspaces rather than the total number of sessions. The per-workspace "show more" button now fetches the next page on demand, and searching lazily loads the full list so results stay complete. To keep per-workspace paging working for sessions created with cwd only, the workspace registry now also surfaces directories that have sessions but were never explicitly registered.
🦋 Changeset detectedLatest commit: 052d5ad The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 75354e66f9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // the old full global walk: the sidebar now truncates by loading, not by | ||
| // hiding already-fetched rows. | ||
| await loadWorkspaces(); | ||
| const sessions = await loadInitialSessionsByWorkspace(); |
There was a problem hiding this comment.
Add a mobile path to fetch older sessions
On mobile, App.vue still passes client.workspaceGroups.value to MobileSwitcherSheet, and that sheet only expands the already supplied g.sessions list; it has no loadMore emit or search-triggered full drain. Switching initial load here to only the first workspace page means a workspace with more than 10 conversations exposes no way in the mobile switcher to reach older sessions, while desktop now has a load-more button.
Useful? React with 👍 / 👎.
| rawState.workspaces.find((w) => w.id === workspaceId)?.sessionCount ?? loadedCount; | ||
| rawState.sessionsHasMoreByWorkspace = { | ||
| ...rawState.sessionsHasMoreByWorkspace, | ||
| [workspaceId]: page.hasMore || loadedCount < total, |
There was a problem hiding this comment.
Stop deriving hasMore from stale workspace totals
If a session is archived after loadWorkspaces() has populated rawState.workspaces, the workspace sessionCount kept here is stale because archiveSession only removes the local session. After the final server page returns page.hasMore === false, loadedCount < total can still keep sessionsHasMoreByWorkspace true, leaving a "Show more" button that keeps fetching empty pages for that workspace.
Useful? React with 👍 / 👎.
Stop deriving per-workspace hasMore from the workspace session_count. After a local archive/delete the count is stale (archiveSession only removes the local session), so loadedCount < total stayed true after the server returned its final page and re-fetched empty pages forever. The server's page.hasMore is authoritative for whether more pages exist, so use it directly and keep session_count only as a label total.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 39b6cb8e89
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (workspaces.length === 0) { | ||
| rawState.sessionsHasMoreByWorkspace = {}; | ||
| rawState.sessionsFullyLoaded = false; | ||
| return []; |
There was a problem hiding this comment.
Preserve the legacy session fallback when no workspaces are listed
When /workspaces is unavailable or returns an empty/error result while /sessions still works (the daemon client still treats that as a supported fallback path), this early return makes initial load produce no sessions, so mergedWorkspaces has nothing to derive from and the sidebar is blank. The previous flow always drained /sessions first, so older/partially-failing daemons still showed history; this path should fall back to the global session walk instead of returning [].
Useful? React with 👍 / 👎.
| // Surface cwds that have sessions but were never registered (e.g. sessions | ||
| // created with cwd only) by registering them idempotently first, so the | ||
| // sidebar can page sessions per workspace without a full global walk. | ||
| await this.registerDerivedWorkspaces(); |
There was a problem hiding this comment.
Do not re-register workspaces after DELETE
When a registered workspace that still has sessions is removed, delete() only removes the registry entry and leaves the session bucket/index on disk; the next GET /workspaces now runs this derived-registration step and immediately creates the workspace again from that bucket. That makes deletion non-durable for any workspace with history (fresh clients will see it reappear), so derived registration needs to distinguish never-registered buckets from explicitly deleted roots.
Useful? React with 👍 / 👎.
When /workspaces is unavailable or empty on older or partially-failing daemons while /sessions still works, the per-workspace initial load produced no sessions and the sidebar rendered blank. Reuse the existing global walk as a fallback in that case so history still shows.
Record deleted workspace ids as tombstones in the registry and skip them during derived registration. Deleting a workspace only removes its registry entry (session buckets stay on disk by design), so without the tombstone the next derived-registration scan recreated the workspace, making deletion non-durable for any workspace with history. An explicit re-add clears the tombstone.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c0be6e8956
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| async function loadMoreSessions(workspaceId: string): Promise<void> { | ||
| if (rawState.sessionsLoadingMoreByWorkspace[workspaceId]) return; | ||
| if (rawState.sessionsHasMoreByWorkspace[workspaceId] === false) return; | ||
| const beforeId = oldestLoadedSessionId(workspaceId); |
There was a problem hiding this comment.
Keep the load-more cursor independent of deep links
When fetchSessionIntoList appends an older deep-linked session that sits outside the first per-workspace page, this cursor becomes that old session instead of the end of the contiguous page. The next GET /sessions?before_id=... then starts after the deep link, so sessions between the initial 10 rows and the deep-linked row are skipped and repeated “Show more” clicks can exhaust older pages without ever loading them. Track a per-workspace paging cursor from the last fetched page instead of recomputing it from every loaded session.
Useful? React with 👍 / 👎.
| const root = rootByKey.get(d.name); | ||
| if (root === undefined) continue; | ||
| try { | ||
| await this.createOrTouch(root); |
There was a problem hiding this comment.
Preserve bucket ids when registering derived workspaces
When a cwd-only session was created from a symlinked working directory, the session bucket and workspace_id are based on the resolved cwd string, but createOrTouch realpaths the recovered root and registers a different workspace id/root. In that case /workspaces returns the realpath workspace and the new per-workspace /sessions?workspace_id= call reads the realpath bucket, so the original symlink-bucket sessions disappear from the first-load sidebar. Register the recovered bucket/root without changing its key, or otherwise keep the workspace id aligned with the existing session bucket.
Useful? React with 👍 / 👎.
Compute the load-more cursor from the end of the last fetched page instead of the oldest loaded session. A deep-linked older session appended out of band would otherwise become the cursor, so the next page started after it and skipped every session between the first page and the deep link.
The mobile switcher still used the old local display-expansion logic, but each workspace now starts with only the first page of sessions. Wire its show-more button to loadMoreSessions (matching the desktop sidebar) so workspaces with more sessions can page beyond the first page on mobile.
Session buckets are keyed by normalizeWorkDir (resolve, not realpath), so registering a derived workspace via createOrTouch (which realpaths) produced a different workspace id for a symlinked cwd, and per-workspace session lookups then read the wrong bucket and returned empty. Register derived workspaces with the resolved bucket key instead.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 18880bbb0d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| for (const d of missing) { | ||
| const root = rootByKey.get(d.name); | ||
| if (root === undefined) continue; | ||
| try { | ||
| await this.createOrTouch(root); |
There was a problem hiding this comment.
Skip archived-only buckets when deriving workspaces
When a session bucket exists only because of archived sessions, this still calls createOrTouch(root), so a plain GET /workspaces permanently registers that cwd and returns it with session_count: 0. The old fallback derived workspaces from GET /sessions without include_archive, so those archived-only workspaces stayed hidden; after this change they reappear as empty sidebar groups whenever the root still exists. Check the active-session count for d.name before registering the bucket.
Useful? React with 👍 / 👎.
| const pages = await Promise.all( | ||
| workspaces.map((w) => | ||
| api | ||
| .listSessions({ workspaceId: w.id, pageSize: SESSIONS_INITIAL_PAGE_SIZE }) |
There was a problem hiding this comment.
Page top-level sessions instead of all sessions
For workspaces with recent side-chat child sessions, this first page can be filled by children because GET /sessions?workspace_id= returns all sessions and the sidebar later drops parentSessionId entries in workspaceGroups. That makes the main conversation list show fewer than 10 rows—or even none—until the user clicks Show more through child-only pages, whereas the previous full drain could still render older top-level sessions. Fetch until the page contains the requested number of non-child sessions or add a top-level-only query.
Useful? React with 👍 / 👎.
A bucket that only contains archived sessions would otherwise be registered as an empty workspace on a plain GET /workspaces, surfacing an empty sidebar group that the old session-derived fallback (which ignored archived sessions) kept hidden. Skip derived buckets with no active sessions.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ae9f5970e8
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| await fsp.stat(root); | ||
| } catch (err) { | ||
| const code = (err as NodeJS.ErrnoException).code; | ||
| if (code === 'ENOENT' || code === 'ENOTDIR') continue; |
There was a problem hiding this comment.
Keep stale session buckets visible
When a user has history for a cwd that has since been deleted or is on an unmounted drive, this ENOENT/ENOTDIR skip prevents that session bucket from being registered as a workspace. Because the web first-load path now only pages sessions for the workspaces returned by /workspaces whenever any workspace exists, those conversations disappear from the normal sidebar even though the old global session walk still showed them from their stored cwd.
Useful? React with 👍 / 👎.
| const pages = await Promise.all( | ||
| workspaces.map((w) => | ||
| api | ||
| .listSessions({ workspaceId: w.id, pageSize: SESSIONS_INITIAL_PAGE_SIZE }) |
There was a problem hiding this comment.
Page only top-level sessions for the sidebar
This request pages the raw /sessions result, which also contains child/side-chat sessions, while workspaceGroups later drops any session with parentSessionId. In a workspace whose newest 10 records are side chats, the initial page is consumed by rows the sidebar intentionally filters out, so normal parent conversations from that workspace are missing until the user clicks “Show more” or searches; the previous full drain did not lose them from the rendered list.
Useful? React with 👍 / 👎.
… fly Stop persisting derived workspaces into the registry. Persisting made the registry a second data source that drifted from the session store (symlinked cwds, archived-only buckets, deleted/unmounted roots), each producing a bug. Instead compute derived workspaces fresh in list() from the session index and resolve their ids in resolveRoot() via the same index, so the session store stays the single source of truth. The deletion tombstone is kept so explicitly removed workspaces are not re-derived.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa2c130952
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const pages = await Promise.all( | ||
| workspaces.map((w) => | ||
| api | ||
| .listSessions({ workspaceId: w.id, pageSize: SESSIONS_INITIAL_PAGE_SIZE }) |
There was a problem hiding this comment.
Filter child sessions before paging workspace sessions
This fetches only the first raw /sessions rows for each workspace, but the sidebar still drops child/side-chat sessions later (workspaceGroups skips parentSessionId in apps/kimi-web/src/composables/useKimiWebClient.ts:1802). In a workspace whose newest rows are child sessions, the cursor can be consumed by items that are never rendered, so the initial sidebar can show no or too few parent sessions and require repeated “Show more” clicks even though visible sessions exist. The page limit/cursor needs to apply to the same parent-session set that the sidebar renders, or the client needs to continue fetching until it has a visible page.
Useful? React with 👍 / 👎.
| if (!file.deleted_workspace_ids.includes(workspaceId)) { | ||
| file.deleted_workspace_ids.push(workspaceId); |
There was a problem hiding this comment.
Tombstone derived workspaces on delete
Now that list() returns derived workspaces from the session index, those IDs are valid list results but they are not present in file.workspaces; delete() throws before reaching this tombstone block for exactly those derived IDs. Calling DELETE /workspaces/{id} on a derived workspace returns not found and the server will keep returning it on the next list() because deleted_workspace_ids is never updated, so server-side removal and multi-client sync do not work for the new workspace type.
Useful? React with 👍 / 👎.
After deriving workspaces from the session index, derived ids are valid list results but absent from the registry file, so delete() threw before writing the tombstone and DELETE on a derived workspace 404ed and reappeared on the next list(). Resolve the derived id and write the tombstone for it too.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 02980eb9fa
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // total: sessions are now paged per workspace, so the local loaded count is | ||
| // a lower bound, not the total. The loaded count is kept as a floor so a | ||
| // workspace never reports fewer sessions than we actually hold in memory. | ||
| const count = Math.max(w.sessionCount, counts.get(w.id) ?? counts.get(w.root) ?? 0); |
There was a problem hiding this comment.
Use local counts after all workspace sessions are loaded
When a workspace has no more pages (for example a workspace with a single session), the loaded session count is exact, but this line keeps the initial sessionCount from /workspaces as a floor. archiveSession() removes the archived session only from rawState.sessions and does not refresh rawState.workspaces, so after archiving the last session the group has sessions.length === 0 and hasMore === false while the sidebar/mobile count still reports 1 until a full reload. Use the local count when the workspace has no more pages, or update the cached workspace count on session mutations.
Useful? React with 👍 / 👎.
mergedWorkspaces kept the server session_count as a floor even after a workspace had no more pages, so archiving the last session left the header showing 1 until a reload. Once a workspace is fully loaded (hasMore === false) the local count is exact, so prefer it; keep the server count as a floor only while pages remain.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 052d5addcd
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| result.push( | ||
| await this.hydrate( | ||
| id, | ||
| { root: workDir, name: basename(workDir), created_at: '', last_opened_at: '' }, |
There was a problem hiding this comment.
Return valid timestamps for derived workspaces
When /workspaces includes a cwd-only session bucket, this synthetic entry is returned with empty created_at and last_opened_at values. The public workspaceSchema requires both fields to be ISO datetimes, so any client or generated-contract check that validates the response for a derived workspace will reject it even though the endpoint advertises a valid Workspace; use a real timestamp from the session bucket/index or persist a proper registry entry instead.
Useful? React with 👍 / 👎.
Related Issue
No related issue — see Problem below.
Problem
The web sidebar's first load drained every session (100 per page, so the request count grew with the total session count) and then hid most of them behind a per-workspace "show more" button. Truncation happened only at the display layer — all the data had already been fetched — so users with many sessions saw a burst of requests and a slow first screen.
A naive switch to per-workspace paging would also drop "derived" workspaces: sessions created with
cwdonly were never registered as workspaces, so they only appeared because the full session walk re-derived them from each session'scwd.What changed
/workspaces): the workspace registry now also returns directories that have sessions but were never explicitly registered (registering them idempotently), each with a livesession_count. This keeps derived workspaces visible and gives the UI a per-workspace total for free.session_count.Checklist
gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update.