perf(web): page session list per workspace on first load (#1084)

* perf(web): page session list per workspace on first load

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.

* fix(web): trust server hasMore for session pagination

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.

* fix(web): fall back to global session walk when no workspaces listed

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.

* fix(agent-core): keep workspace deletion durable

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.

* fix(web): track session paging cursor per workspace

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.

* fix(web): load more sessions in the mobile switcher

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.

* fix(agent-core): align derived workspace id with its session bucket

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.

* fix(agent-core): skip archived-only buckets in derived registration

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.

* refactor(agent-core): derive workspaces from the session index on the 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.

* fix(agent-core): tombstone derived workspaces on delete

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.

* fix(web): use local session count once a workspace is fully loaded

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.
This commit is contained in:
qer 2026-06-25 17:12:34 +08:00 committed by GitHub
parent 8fc6aa5f68
commit d6e524682d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 493 additions and 140 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Page the web session list per workspace so the first screen no longer fetches every session up front.

View file

@ -562,6 +562,8 @@ function openPr(url: string): void {
@rename-workspace="(id, name) => client.renameWorkspace(id, name)"
@delete-workspace="(id) => client.deleteWorkspace(id)"
@reorder-workspaces="client.reorderWorkspaces($event)"
@load-more-sessions="(id) => void client.loadMoreSessions(id)"
@load-all-sessions="void client.loadAllSessions()"
@select-workspaces="handleSelectWorkspaces"
@open-settings="showSettings = true"
@collapse="toggleSidebarCollapse"
@ -899,6 +901,7 @@ function openPr(url: string): void {
@rename="(id, title) => client.renameSession(id, title)"
@archive="(id) => client.archiveSession(id)"
@delete-workspace="(id) => client.deleteWorkspace(id)"
@load-more="(id) => void client.loadMoreSessions(id)"
/>
<!-- Mobile settings bottom-sheet: session controls + app prefs + auth -->

View file

@ -656,8 +656,8 @@ export interface KimiWebApi {
openInApp(sessionId: string, appId: string, path: string, line?: number): Promise<void>;
connectEvents(handlers: KimiEventHandlers): KimiEventConnection;
// Workspaces + daemon folder browser
// PRESUMED — falls back until the daemon ships /workspaces, /fs:browse, /fs:home.
// Workspaces + daemon folder browser. /workspaces now ships and includes
// derived workspaces (cwds with sessions that were never explicitly registered).
listWorkspaces(): Promise<AppWorkspace[]>;
addWorkspace(input: { root: string; name?: string }): Promise<AppWorkspace>;
deleteWorkspace(id: string): Promise<void>;

View file

@ -3,7 +3,7 @@
The old workspace rail and workspace tabs have been removed;
workspace switching, folding and renaming all live in the group header. -->
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { serverEndpointLabel } from '../api/config';
import { copyTextToClipboard } from '../lib/clipboard';
@ -58,6 +58,8 @@ const emit = defineEmits<{
renameWorkspace: [id: string, name: string];
deleteWorkspace: [id: string];
reorderWorkspaces: [ids: string[]];
loadMoreSessions: [workspaceId: string];
loadAllSessions: [];
openSettings: [];
collapse: [];
}>();
@ -89,6 +91,12 @@ function onSelectResult(sessionId: string): void {
onSelectSession(sessionId);
}
// Sessions are loaded per-workspace (first page only). The first time the user
// searches, lazily drain the rest so the client-side filter covers everything.
watch(isSearching, (active) => {
if (active) emit('loadAllSessions');
});
// ---------------------------------------------------------------------------
// Collapse groups
// ---------------------------------------------------------------------------
@ -100,15 +108,8 @@ function isCollapsed(id: string): boolean {
function toggleCollapse(id: string): void {
const next = new Set(collapsedIds.value);
if (next.has(id)) {
next.delete(id);
// Reset session expansion when workspace is expanded
const expandedNext = new Set(expandedWsIds.value);
expandedNext.delete(id);
expandedWsIds.value = expandedNext;
} else {
next.add(id);
}
if (next.has(id)) next.delete(id);
else next.add(id);
collapsedIds.value = next;
saveCollapsedWorkspaces(next);
}
@ -161,37 +162,6 @@ function onGroupDrop(targetId: string): void {
emit('reorderWorkspaces', next);
}
// ---------------------------------------------------------------------------
// Session list truncation per workspace
// ---------------------------------------------------------------------------
const DEFAULT_VISIBLE_COUNT = 10;
/** workspace id → true = show all sessions */
const expandedWsIds = ref<Set<string>>(new Set());
function isExpanded(wsId: string): boolean {
return expandedWsIds.value.has(wsId);
}
function toggleExpand(wsId: string): void {
const next = new Set(expandedWsIds.value);
if (next.has(wsId)) next.delete(wsId);
else next.add(wsId);
expandedWsIds.value = next;
}
/** Show the most recent N sessions. If the active session is older than N,
replace the last slot with it so the highlight never disappears. */
function visibleSessions(sessions: Session[], expanded: boolean, activeId?: string): Session[] {
if (expanded || sessions.length <= DEFAULT_VISIBLE_COUNT) return sessions;
const visible = sessions.slice(0, DEFAULT_VISIBLE_COUNT);
if (activeId && !visible.some((s) => s.id === activeId)) {
const active = sessions.find((s) => s.id === activeId);
if (active) visible[DEFAULT_VISIBLE_COUNT - 1] = active;
}
return visible;
}
// ---------------------------------------------------------------------------
// Shift-multi-select workspaces
// ---------------------------------------------------------------------------
@ -603,8 +573,6 @@ function blinkOnce(): void {
:ws-menu-open-id="wsMenuOpenId"
:dragging="draggingWsId === g.workspace.id"
:is-collapsed="isCollapsed"
:is-expanded="isExpanded"
:visible-sessions="visibleSessions"
@group-click="handleGhClick"
@group-contextmenu="openGhMenu"
@toggle-ws-menu="toggleWsMenu"
@ -613,7 +581,7 @@ function blinkOnce(): void {
@rename-session="(id, title) => emit('rename', id, title)"
@archive-session="(id) => emit('archive', id)"
@fork-session="(id) => emit('fork', id)"
@toggle-expand="toggleExpand"
@load-more="(id) => emit('loadMoreSessions', id)"
@confirm-rename="confirmRenameWorkspace"
@cancel-rename="cancelRenameWorkspace"
@update-rename-value="onUpdateRenameValue"

View file

@ -7,7 +7,7 @@
<script setup lang="ts">
import { computed, type ComponentPublicInstance, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Session, WorkspaceGroup, WorkspaceView } from '../types';
import type { WorkspaceGroup, WorkspaceView } from '../types';
import SessionRow from './SessionRow.vue';
const { t } = useI18n();
@ -26,8 +26,6 @@ const props = defineProps<{
/** True while this group is the active drag source (drag-to-reorder). */
dragging: boolean;
isCollapsed: (id: string) => boolean;
isExpanded: (id: string) => boolean;
visibleSessions: (sessions: Session[], expanded: boolean, activeId?: string) => Session[];
}>();
const emit = defineEmits<{
@ -39,7 +37,7 @@ const emit = defineEmits<{
renameSession: [id: string, title: string];
archiveSession: [id: string];
forkSession: [id: string];
toggleExpand: [workspaceId: string];
loadMore: [workspaceId: string];
confirmRename: [];
cancelRename: [];
updateRenameValue: [value: string];
@ -157,7 +155,7 @@ function onHeaderDragStart(event: DragEvent): void {
</div>
<div v-show="!isCollapsed(group.workspace.id)" class="group-sessions">
<SessionRow
v-for="s in visibleSessions(group.sessions, isExpanded(group.workspace.id), activeId)"
v-for="s in group.sessions"
:key="s.id"
:session="s"
:active="s.id === activeId"
@ -170,11 +168,16 @@ function onHeaderDragStart(event: DragEvent): void {
@fork="emit('forkSession', $event)"
/>
<button
v-if="!isExpanded(group.workspace.id) && visibleSessions(group.sessions, false, activeId).length < group.sessions.length"
v-if="group.hasMore || group.loadingMore"
class="show-more"
@click.stop="emit('toggleExpand', group.workspace.id)"
:disabled="group.loadingMore"
@click.stop="emit('loadMore', group.workspace.id)"
>
{{ t('sidebar.showMore', { count: group.sessions.length - visibleSessions(group.sessions, false, activeId).length }) }}
{{
group.loadingMore
? t('sidebar.loadingMore')
: t('sidebar.showMore', { count: Math.max(0, group.workspace.sessionCount - group.sessions.length) })
}}
</button>
<div v-if="group.sessions.length === 0" class="group-empty">{{ t('sidebar.noSessions') }}</div>
</div>

View file

@ -40,6 +40,7 @@ const emit = defineEmits<{
archive: [id: string];
/** NOTE: needs `@delete-workspace="client.deleteWorkspace($event)"` wiring in App.vue. */
deleteWorkspace: [workspaceId: string];
loadMore: [workspaceId: string];
}>();
function close(): void {
@ -77,15 +78,8 @@ function isCollapsed(id: string): boolean {
function toggleCollapse(id: string): void {
const next = new Set(collapsedIds.value);
if (next.has(id)) {
next.delete(id);
// Reset session expansion when the workspace is expanded (desktop parity)
const expandedNext = new Set(expandedWsIds.value);
expandedNext.delete(id);
expandedWsIds.value = expandedNext;
} else {
next.add(id);
}
if (next.has(id)) next.delete(id);
else next.add(id);
collapsedIds.value = next;
// Tapping a header also dismisses any open row/workspace menu.
menuFor.value = null;
@ -96,47 +90,6 @@ function wsAttention(id: string): number {
return props.attentionByWorkspace[id] ?? 0;
}
// ---------------------------------------------------------------------------
// Session list truncation per workspace (desktop sidebar parity):
// default visible = union of (first 5) and (updated within 5 days), and the
// active session is always kept visible.
// ---------------------------------------------------------------------------
const DEFAULT_VISIBLE_COUNT = 5;
const FIVE_DAYS_MS = 5 * 24 * 60 * 60 * 1000;
/** workspace id → true = show all sessions */
const expandedWsIds = ref<Set<string>>(new Set());
function isExpanded(wsId: string): boolean {
return expandedWsIds.value.has(wsId);
}
function toggleExpand(wsId: string): void {
const next = new Set(expandedWsIds.value);
if (next.has(wsId)) next.delete(wsId);
else next.add(wsId);
expandedWsIds.value = next;
}
function visibleSessions(sessions: Session[], expanded: boolean, activeId?: string): Session[] {
if (expanded || sessions.length <= DEFAULT_VISIBLE_COUNT) return sessions;
const now = Date.now();
const cutoff = now - FIVE_DAYS_MS;
const recent5 = sessions.slice(0, DEFAULT_VISIBLE_COUNT);
const recent5Ids = new Set(recent5.map((s) => s.id));
const within5Days = sessions.filter((s) => {
if (recent5Ids.has(s.id)) return false;
const ts = s.updatedAt ? Date.parse(s.updatedAt) : 0;
return ts > cutoff;
});
const visible = [...recent5, ...within5Days];
if (activeId && !visible.some((s) => s.id === activeId)) {
const active = sessions.find((s) => s.id === activeId);
if (active) visible.push(active);
}
return visible;
}
// ---------------------------------------------------------------------------
// Per-row kebab menu (rename / archive) opened from the button.
// Archiving is two-step: the first tap arms the item ("Archive session?"),
@ -312,7 +265,7 @@ onUnmounted(() => {
<div v-show="!isCollapsed(g.workspace.id)">
<div v-if="g.sessions.length === 0" class="mempty small">{{ t('sidebar.noSessions') }}</div>
<div
v-for="s in visibleSessions(g.sessions, isExpanded(g.workspace.id), activeId)"
v-for="s in g.sessions"
:key="s.id"
class="srow"
:class="{ cur: s.id === activeId }"
@ -345,12 +298,17 @@ onUnmounted(() => {
</div>
</div>
<button
v-if="!isExpanded(g.workspace.id) && visibleSessions(g.sessions, false, activeId).length < g.sessions.length"
v-if="g.hasMore || g.loadingMore"
type="button"
class="mshow-more"
@click.stop="toggleExpand(g.workspace.id)"
:disabled="g.loadingMore"
@click.stop="emit('loadMore', g.workspace.id)"
>
{{ t('sidebar.showMore', { count: g.sessions.length - visibleSessions(g.sessions, false, activeId).length }) }}
{{
g.loadingMore
? t('sidebar.loadingMore')
: t('sidebar.showMore', { count: Math.max(0, g.workspace.sessionCount - g.sessions.length) })
}}
</button>
</div>
</div>

View file

@ -285,6 +285,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// Backend max page size for GET /sessions. Bigger pages mean fewer round-trips
// when draining the full session list.
const SESSION_PAGE_SIZE = 100;
// Sessions fetched per workspace on first load — keeps the initial request
// count at (number of workspaces) and each response small.
const SESSIONS_INITIAL_PAGE_SIZE = 10;
// Sessions fetched per "load more" click within a workspace.
const SESSIONS_LOAD_MORE_SIZE = 30;
/** Drain every page of sessions, newest first. A single global walk (instead of
* per-workspace) so sessions whose cwd is not a registered workspace root are
@ -302,6 +307,114 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
return items;
}
/** Fetch the first page of sessions for every known workspace concurrently.
* Returns the merged, recency-sorted list and seeds per-workspace hasMore. */
async function loadInitialSessionsByWorkspace(): Promise<AppSession[]> {
const api = getKimiWebApi();
const workspaces = rawState.workspaces;
if (workspaces.length === 0) {
// /workspaces may be unavailable or empty on older / partially-failing
// daemons while /sessions still works. Fall back to the legacy global
// walk so history still shows and mergedWorkspaces can derive workspaces
// from session cwds, instead of rendering a blank sidebar.
const fallback = await listAllSessionsGlobal().catch(() => [] as AppSession[]);
rawState.sessionsHasMoreByWorkspace = {};
rawState.sessionsCursorByWorkspace = {};
rawState.sessionsFullyLoaded = true;
return fallback;
}
const pages = await Promise.all(
workspaces.map((w) =>
api
.listSessions({ workspaceId: w.id, pageSize: SESSIONS_INITIAL_PAGE_SIZE })
.then((page) => ({ workspaceId: w.id, page }))
.catch(() => ({
workspaceId: w.id,
page: { items: [] as AppSession[], hasMore: false },
})),
),
);
const loaded: AppSession[] = [];
const hasMore: Record<string, boolean> = {};
const cursors: Record<string, string | undefined> = {};
for (const { workspaceId, page } of pages) {
loaded.push(...page.items);
// Trust the server's hasMore — the per-workspace session_count is only a
// (possibly stale) label total, not an authority on whether more pages exist.
hasMore[workspaceId] = page.hasMore;
// Cursor = oldest session of this page (pages are newest-first). Tracked
// separately from the loaded set so a deep-linked older session appended
// out of band cannot shift the cursor and skip intervening sessions.
cursors[workspaceId] =
page.items.length > 0 ? page.items[page.items.length - 1]!.id : undefined;
}
rawState.sessionsHasMoreByWorkspace = hasMore;
rawState.sessionsCursorByWorkspace = cursors;
rawState.sessionsFullyLoaded = false;
// Keep rawState.sessions newest-first for readers that pick sessions[0]
// (e.g. auto-selecting the most recent session on first load).
loaded.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return loaded;
}
/** Fetch the next page of sessions for a workspace (the "load more" button). */
async function loadMoreSessions(workspaceId: string): Promise<void> {
if (rawState.sessionsLoadingMoreByWorkspace[workspaceId]) return;
if (rawState.sessionsHasMoreByWorkspace[workspaceId] === false) return;
const beforeId = rawState.sessionsCursorByWorkspace[workspaceId];
if (beforeId === undefined) return;
rawState.sessionsLoadingMoreByWorkspace = {
...rawState.sessionsLoadingMoreByWorkspace,
[workspaceId]: true,
};
try {
const page = await getKimiWebApi().listSessions({
workspaceId,
pageSize: SESSIONS_LOAD_MORE_SIZE,
beforeId,
});
// Append de-duped against the latest list so a concurrently added/removed
// session is respected.
const existing = new Set(rawState.sessions.map((s) => s.id));
const fresh = page.items.filter((s) => !existing.has(s.id));
if (fresh.length > 0) setSessions([...rawState.sessions, ...fresh]);
// Advance the cursor to the end of the page we just fetched.
rawState.sessionsCursorByWorkspace = {
...rawState.sessionsCursorByWorkspace,
[workspaceId]:
page.items.length > 0 ? page.items[page.items.length - 1]!.id : beforeId,
};
// Trust the server's hasMore. Deriving it from the workspace session_count
// is unsafe: archive/delete only removes the local session and leaves the
// count stale, which would keep hasMore true and re-fetch empty pages.
rawState.sessionsHasMoreByWorkspace = {
...rawState.sessionsHasMoreByWorkspace,
[workspaceId]: page.hasMore,
};
} catch (err) {
pushOperationFailure('loadMoreSessions', err);
} finally {
rawState.sessionsLoadingMoreByWorkspace = {
...rawState.sessionsLoadingMoreByWorkspace,
[workspaceId]: false,
};
}
}
/** Drain every session via a single global walk so client-side search covers
* all sessions, not just the first page per workspace. Triggered lazily on
* first search; a no-op once the full list is loaded. */
async function loadAllSessions(): Promise<void> {
if (rawState.sessionsFullyLoaded) return;
const sessions = await listAllSessionsGlobal().catch(() => null);
if (sessions === null) return;
setSessions(sessions);
rawState.sessionsFullyLoaded = true;
const cleared: Record<string, boolean> = {};
for (const w of rawState.workspaces) cleared[w.id] = false;
rawState.sessionsHasMoreByWorkspace = cleared;
}
async function load(): Promise<void> {
rawState.loading = true;
try {
@ -320,13 +433,13 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
await checkAuth();
await loadConfig();
// Drain every session via a single global walk so sessions whose cwd is not
// a registered workspace root are still reachable after a refresh.
const sessions = await listAllSessionsGlobal().catch(() => [] as AppSession[]);
setSessions(sessions);
// Load workspaces (real if available, else derived from session cwds).
// Load workspaces first (registered + derived, each with a session_count),
// then fetch only the first page of sessions per workspace. This replaces
// the old full global walk: the sidebar now truncates by loading, not by
// hiding already-fetched rows.
await loadWorkspaces();
const sessions = await loadInitialSessionsByWorkspace();
setSessions(sessions);
// First load: pick the workspace of the most-recent session, unless the
// user already has a persisted active workspace that still exists.
@ -1599,6 +1712,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
listAllSessionsGlobal,
load,
loadWorkspaces,
loadMoreSessions,
loadAllSessions,
selectWorkspace,
openWorkspace,
upsertWorkspacePreserveOrder,

View file

@ -316,6 +316,16 @@ export interface ExtendedState extends KimiClientState {
messagesHasMoreBySession: Record<string, boolean>;
/** True when the last older-message fetch failed for a session. */
messagesLoadMoreErrorBySession: Record<string, boolean>;
/** Whether the server has more sessions than currently loaded, per workspace. */
sessionsHasMoreByWorkspace: Record<string, boolean>;
/** True while the next page of sessions is being fetched for a workspace. */
sessionsLoadingMoreByWorkspace: Record<string, boolean>;
/** Paging cursor (`before_id`) for the next session page, per workspace. Tracks
* the end of the last fetched page so a deep-linked older session appended
* out of band does not shift the cursor and skip intervening sessions. */
sessionsCursorByWorkspace: Record<string, string | undefined>;
/** True once every session has been loaded (after a search-triggered full drain). */
sessionsFullyLoaded: boolean;
}
const rawState: ExtendedState = reactive({
@ -352,6 +362,10 @@ const rawState: ExtendedState = reactive({
messagesLoadingMoreBySession: {},
messagesHasMoreBySession: {},
messagesLoadMoreErrorBySession: {},
sessionsHasMoreByWorkspace: {},
sessionsLoadingMoreByWorkspace: {},
sessionsCursorByWorkspace: {},
sessionsFullyLoaded: false,
});
// ---------------------------------------------------------------------------
@ -1742,12 +1756,15 @@ const mergedWorkspaces = computed<AppWorkspace[]>(() => {
const result: AppWorkspace[] = [];
for (const root of [...realRoots, ...derivedRoots]) {
const w = byRoot.get(root)!;
// Match count by either id or root (derived id === root). Once sessions
// have loaded, trust the live local count (0 when no sessions remain) rather
// than the daemon's sessionCount, which historically counted archived
// sessions and would keep a workspace looking non-empty after its last
// session was archived.
const count = counts.get(w.id) ?? counts.get(w.root) ?? (rawState.loading ? w.sessionCount : 0);
// When a workspace's sessions are fully loaded (hasMore === false), the
// local count is exact — prefer it so archiving the last session drops the
// count to 0 immediately. While pages remain, the local count is only a
// lower bound, so keep the daemon session_count as a floor.
const localCount = counts.get(w.id) ?? counts.get(w.root) ?? 0;
const count =
rawState.sessionsHasMoreByWorkspace[w.id] === false
? localCount
: Math.max(w.sessionCount, localCount);
let branch = w.branch;
if (!branch && activeGit && activeRoot === w.root) branch = activeGit.branch;
result.push({ ...w, sessionCount: count, branch });
@ -1865,6 +1882,8 @@ const workspaceGroups = computed<WorkspaceGroup[]>(() => {
return workspacesView.value.map((w) => ({
workspace: w,
sessions: byId.get(w.id) ?? [],
hasMore: rawState.sessionsHasMoreByWorkspace[w.id] ?? false,
loadingMore: rawState.sessionsLoadingMoreByWorkspace[w.id] ?? false,
}));
});
@ -2171,6 +2190,8 @@ export function useKimiWebClient() {
// Workspace actions
loadWorkspaces: workspaceState.loadWorkspaces,
loadMoreSessions: workspaceState.loadMoreSessions,
loadAllSessions: workspaceState.loadAllSessions,
selectWorkspace: workspaceState.selectWorkspace,
openWorkspace: workspaceState.openWorkspace,
openWorkspaceDraft: workspaceState.openWorkspaceDraft,

View file

@ -27,6 +27,7 @@ export default {
daemon: 'Daemon',
noSessions: 'No conversations yet',
showMore: 'Show more ({count})',
loadingMore: 'Loading…',
collapseSidebar: 'Collapse sidebar',
expandSidebar: 'Expand sidebar',
searchPlaceholder: 'Search sessions',

View file

@ -27,6 +27,7 @@ export default {
daemon: '后台',
noSessions: '暂无对话',
showMore: '展开更多 ({count})',
loadingMore: '加载中…',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
searchPlaceholder: '搜索会话',

View file

@ -70,6 +70,10 @@ export interface WorkspaceView {
export interface WorkspaceGroup {
workspace: WorkspaceView;
sessions: Session[];
/** True when the server has more sessions in this workspace than are loaded. */
hasMore: boolean;
/** True while the next page of sessions is being fetched for this workspace. */
loadingMore: boolean;
}
/** Sidebar session-list scope: only the active workspace, or all workspaces. */

View file

@ -1,5 +1,3 @@
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import { basename, dirname, join } from 'node:path';
@ -7,6 +5,7 @@ import type { Stats } from 'node:fs';
import { Disposable, InstantiationType, registerSingleton } from '../../di';
import { encodeWorkDirKey } from '../../session/store';
import { readSessionIndex } from '../../session/store/session-index';
import { IEnvironmentService } from '../environment/environment';
import { IEventService } from '../event/event';
@ -33,6 +32,10 @@ interface WorkspaceRegistryEntry {
interface WorkspaceRegistryFile {
version: number;
workspaces: Record<string, WorkspaceRegistryEntry>;
/** Workspace ids the user explicitly removed. Their session buckets stay on
* disk, so derived workspaces (computed from the session index) must skip
* them to keep deletion durable. */
deleted_workspace_ids: string[];
}
type WorkspaceRegistryEvent =
@ -43,6 +46,7 @@ type WorkspaceRegistryEvent =
export class WorkspaceRegistryService extends Disposable implements IWorkspaceRegistry {
readonly _serviceBrand: undefined;
private readonly homeDir: string;
private readonly sessionsDir: string;
private readonly registryPath: string;
private opQueue: Promise<unknown> = Promise.resolve();
@ -53,18 +57,46 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
@IEventService private readonly eventService: IEventService,
) {
super();
this.homeDir = env.homeDir;
this.sessionsDir = join(env.homeDir, 'sessions');
this.registryPath = join(env.homeDir, WORKSPACE_REGISTRY_FILE);
}
async list(): Promise<Workspace[]> {
const file = await this.runExclusive(() => this.readRegistry());
const hydrated = await Promise.all(
Object.entries(file.workspaces).map(([workspaceId, entry]) =>
this.hydrate(workspaceId, entry),
),
);
return hydrated.sort((a, b) => (b.last_opened_at < a.last_opened_at ? -1 : 1));
const deleted = new Set(file.deleted_workspace_ids);
const result: Workspace[] = [];
// Registered workspaces (explicitly added by the user).
for (const [id, entry] of Object.entries(file.workspaces)) {
result.push(await this.hydrate(id, entry));
}
// Derived workspaces: cwds that own sessions but were never registered
// (e.g. sessions created with cwd only). Computed on the fly from the
// session index and never persisted, so the registry cannot drift from the
// session store.
const index = await readSessionIndex(this.homeDir, this.sessionsDir);
const derived = new Map<string, string>(); // workspace id -> workDir
for (const entry of index.values()) {
const id = encodeWorkDirKey(entry.workDir);
if (file.workspaces[id] !== undefined || deleted.has(id)) continue;
derived.set(id, entry.workDir);
}
for (const [id, workDir] of derived) {
// Skip archived-only buckets so they don't surface as empty groups.
const sessionCount = await countActiveSessions(join(this.sessionsDir, id));
if (sessionCount === 0) continue;
result.push(
await this.hydrate(
id,
{ root: workDir, name: basename(workDir), created_at: '', last_opened_at: '' },
sessionCount,
),
);
}
return result.sort((a, b) => (b.last_opened_at < a.last_opened_at ? -1 : 1));
}
async get(workspaceId: string): Promise<Workspace> {
@ -106,6 +138,8 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
last_opened_at: now,
};
file.workspaces[workspaceId] = next;
// An explicit add clears any prior deletion tombstone.
file.deleted_workspace_ids = file.deleted_workspace_ids.filter((id) => id !== workspaceId);
await this.writeRegistry(file);
return { entry: next, created: existing === undefined };
});
@ -140,12 +174,22 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
const root = await this.runExclusive(async () => {
const file = await this.readRegistry();
const existing = file.workspaces[workspaceId];
if (existing === undefined) {
throw new WorkspaceNotFoundError(workspaceId);
let root: string;
if (existing !== undefined) {
delete file.workspaces[workspaceId];
root = existing.root;
} else {
// Derived workspace: not in the file but a valid list result.
// Tombstone it so list() stops surfacing it.
const derived = await this.findDerivedWorkDir(workspaceId);
if (derived === undefined) throw new WorkspaceNotFoundError(workspaceId);
root = derived;
}
if (!file.deleted_workspace_ids.includes(workspaceId)) {
file.deleted_workspace_ids.push(workspaceId);
}
delete file.workspaces[workspaceId];
await this.writeRegistry(file);
return existing.root;
return root;
});
this.publishWorkspace({
type: 'event.workspace.deleted',
@ -159,19 +203,33 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
const file = await this.readRegistry();
return file.workspaces[workspaceId] ?? null;
});
if (entry === null) {
throw new WorkspaceNotFoundError(workspaceId);
if (entry !== null) return entry.root;
// Not registered — may be a derived workspace id, which is the session
// bucket key (encodeWorkDirKey(workDir)). Resolve it from the index.
const derived = await this.findDerivedWorkDir(workspaceId);
if (derived !== undefined) return derived;
throw new WorkspaceNotFoundError(workspaceId);
}
/** Look up a derived workspace's workDir from the session index, or undefined
* if the id is not a known derived bucket. */
private async findDerivedWorkDir(workspaceId: string): Promise<string | undefined> {
const index = await readSessionIndex(this.homeDir, this.sessionsDir);
for (const e of index.values()) {
if (encodeWorkDirKey(e.workDir) === workspaceId) return e.workDir;
}
return entry.root;
return undefined;
}
private async hydrate(
workspaceId: string,
entry: WorkspaceRegistryEntry,
sessionCount?: number,
): Promise<Workspace> {
const [{ is_git_repo, branch }, session_count] = await Promise.all([
detectGit(entry.root),
countActiveSessions(join(this.sessionsDir, workspaceId)),
sessionCount ?? countActiveSessions(join(this.sessionsDir, workspaceId)),
]);
return {
id: workspaceId,
@ -215,7 +273,7 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {} };
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] };
}
throw err;
}
@ -227,7 +285,7 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
{ path: this.registryPath, err: String(err) },
'workspaces.json malformed; treating as empty',
);
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {} };
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] };
}
if (
typeof parsed !== 'object' ||
@ -239,7 +297,7 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
{ path: this.registryPath },
'workspaces.json missing required keys; treating as empty',
);
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {} };
return { version: WORKSPACE_REGISTRY_VERSION, workspaces: {}, deleted_workspace_ids: [] };
}
const rawWorkspaces = (parsed as { workspaces: Record<string, unknown> }).workspaces;
const workspaces: Record<string, WorkspaceRegistryEntry> = {};
@ -253,7 +311,11 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
typeof (parsed as { version?: unknown }).version === 'number'
? (parsed as { version: number }).version
: WORKSPACE_REGISTRY_VERSION;
return { version, workspaces };
const rawDeleted = (parsed as { deleted_workspace_ids?: unknown }).deleted_workspace_ids;
const deleted_workspace_ids = Array.isArray(rawDeleted)
? rawDeleted.filter((id): id is string => typeof id === 'string')
: [];
return { version, workspaces, deleted_workspace_ids };
}
private sanitizeEntry(value: unknown): WorkspaceRegistryEntry | null {

View file

@ -0,0 +1,212 @@
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Emitter } from '../../src';
import type { Event } from '@moonshot-ai/protocol';
import type { IEnvironmentService } from '../../src/services/environment/environment';
import type { IEventService } from '../../src/services/event/event';
import type { ILogService } from '../../src/services/logger/logger';
import { WorkspaceRegistryService } from '../../src/services/workspace/workspaceRegistryService';
import { appendSessionIndexEntry } from '../../src/session/store/session-index';
import { encodeWorkDirKey } from '../../src/session/store/workdir-key';
function makeLogger(): ILogService {
const noop = (): void => {};
return {
_serviceBrand: undefined,
info: noop,
warn: noop,
error: noop,
debug: noop,
child: () => makeLogger(),
};
}
function makeEventService(): IEventService & { events: Event[] } {
const emitter = new Emitter<Event>();
const events: Event[] = [];
return {
_serviceBrand: undefined,
events,
onDidPublish: emitter.event,
publish: (event: Event) => {
events.push(event);
emitter.fire(event);
},
};
}
interface TestContext {
homeDir: string;
registry: WorkspaceRegistryService;
}
describe('WorkspaceRegistryService', () => {
let ctx: TestContext;
let tempRoots: string[] = [];
beforeEach(async () => {
const homeDir = await mkdtemp(join(tmpdir(), 'kimi-ws-home-'));
const env: IEnvironmentService = {
_serviceBrand: undefined,
homeDir,
configPath: join(homeDir, 'config.toml'),
};
ctx = {
homeDir,
registry: new WorkspaceRegistryService(env, makeLogger(), makeEventService()),
};
tempRoots = [];
});
afterEach(async () => {
await rm(ctx.homeDir, { recursive: true, force: true });
for (const root of tempRoots) {
await rm(root, { recursive: true, force: true });
}
});
async function makeProjectRoot(label: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), `kimi-ws-${label}-`));
tempRoots.push(root);
// realpath so resolve() and realpath() agree on the workDir key even when
// tmpdir() is symlinked (e.g. /tmp -> /private/tmp on macOS).
return realpath(root);
}
async function seedSessionBucket(
root: string,
sessionId: string,
opts?: { archived?: boolean },
): Promise<void> {
const key = encodeWorkDirKey(root);
const sessionDir = join(ctx.homeDir, 'sessions', key, sessionId);
await mkdir(sessionDir, { recursive: true });
await writeFile(
join(sessionDir, 'state.json'),
JSON.stringify({ archived: opts?.archived === true }),
'utf-8',
);
await appendSessionIndexEntry(ctx.homeDir, {
sessionId,
sessionDir,
workDir: root,
});
}
it('auto-registers a workspace for a session bucket missing from the registry', async () => {
const registeredRoot = await makeProjectRoot('reg');
const derivedRoot = await makeProjectRoot('derived');
await ctx.registry.createOrTouch(registeredRoot);
// derivedRoot has a session bucket + index entry but is NOT registered.
await seedSessionBucket(derivedRoot, 'sess-derived-1');
const list = await ctx.registry.list();
const roots = list.map((w) => w.root);
expect(roots).toContain(registeredRoot);
expect(roots).toContain(derivedRoot);
const derived = list.find((w) => w.root === derivedRoot);
expect(derived).toBeDefined();
expect(derived?.session_count).toBe(1);
});
it('does not duplicate an already-registered workspace', async () => {
const root = await makeProjectRoot('only');
await ctx.registry.createOrTouch(root);
// A bucket for the same root exists, but it is already registered.
await seedSessionBucket(root, 'sess-only-1');
const list = await ctx.registry.list();
const matches = list.filter((w) => w.root === root);
expect(matches).toHaveLength(1);
});
it('keeps a derived bucket visible even when its root no longer exists on disk', async () => {
const registeredRoot = await makeProjectRoot('live');
await ctx.registry.createOrTouch(registeredRoot);
// A session whose cwd has since been deleted: the bucket + index remain,
// so the conversation should still show (matches the old global walk).
const goneRoot = join(tmpdir(), 'kimi-ws-gone-never-created');
await seedSessionBucket(goneRoot, 'sess-gone-1');
const list = await ctx.registry.list();
const roots = list.map((w) => w.root);
expect(roots).toContain(registeredRoot);
expect(roots).toContain(goneRoot);
});
it('does not re-register a deleted workspace that still has sessions', async () => {
const root = await makeProjectRoot('deleted');
const ws = await ctx.registry.createOrTouch(root);
// Session bucket + index entry remain on disk after the registry entry is removed.
await seedSessionBucket(root, 'sess-del-1');
await ctx.registry.delete(ws.id);
const list = await ctx.registry.list();
expect(list.map((w) => w.root)).not.toContain(root);
});
it('re-adding a previously deleted workspace clears its tombstone', async () => {
const root = await makeProjectRoot('readd');
const ws = await ctx.registry.createOrTouch(root);
await seedSessionBucket(root, 'sess-readd-1');
await ctx.registry.delete(ws.id);
// Explicit re-add should bring it back (clears the tombstone).
await ctx.registry.createOrTouch(root);
const list = await ctx.registry.list();
expect(list.map((w) => w.root)).toContain(root);
});
it('registers a derived workspace under the symlink bucket key, not the realpath', async () => {
const realDir = await makeProjectRoot('real');
const linkParent = await mkdtemp(join(tmpdir(), 'kimi-ws-link-'));
tempRoots.push(linkParent);
const linkDir = join(linkParent, 'link');
await symlink(realDir, linkDir);
// Seed a session bucket keyed by the SYMLINK path (resolve, not realpath),
// matching how SessionStore keys cwd-only sessions created from a symlinked cwd.
await seedSessionBucket(linkDir, 'sess-symlink-1');
const list = await ctx.registry.list();
const symlinkId = encodeWorkDirKey(linkDir);
const derived = list.find((w) => w.id === symlinkId);
// The workspace must be registered with the bucket key so per-workspace
// session lookups read the same bucket the sessions live in.
expect(derived).toBeDefined();
expect(derived?.session_count).toBe(1);
});
it('does not register a derived bucket that only has archived sessions', async () => {
const root = await makeProjectRoot('archived');
await seedSessionBucket(root, 'sess-archived-1', { archived: true });
const list = await ctx.registry.list();
expect(list.map((w) => w.root)).not.toContain(root);
});
it('tombstones a derived workspace on delete so it stays removed', async () => {
const root = await makeProjectRoot('derived-del');
// Derived (cwd-only, never registered) workspace with an active session.
await seedSessionBucket(root, 'sess-ddel-1');
const derivedId = encodeWorkDirKey(root);
expect((await ctx.registry.list()).map((w) => w.id)).toContain(derivedId);
await ctx.registry.delete(derivedId);
expect((await ctx.registry.list()).map((w) => w.id)).not.toContain(derivedId);
});
});