diff --git a/.changeset/web-sidebar-collapse-sessions.md b/.changeset/web-sidebar-collapse-sessions.md
new file mode 100644
index 000000000..0fc63b3c8
--- /dev/null
+++ b/.changeset/web-sidebar-collapse-sessions.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": patch
+---
+
+Let the web sidebar collapse an expanded workspace session list back to its first page.
diff --git a/apps/kimi-web/design/design-system.html b/apps/kimi-web/design/design-system.html
index 3694066d9..7f412eae9 100644
--- a/apps/kimi-web/design/design-system.html
+++ b/apps/kimi-web/design/design-system.html
@@ -2211,6 +2211,18 @@
Workspace group
The group is collapsible; when collapsed its session list is hidden.
+
Show more & collapse
+
The "load more / show less" control at the bottom of each workspace group is a session-row-shaped compact list control (same family as search, New chat, inline rename — not a Button). It doubles as the pagination trigger and the in-group expand / collapse toggle.
+
+
Part
Rule
+
+
Container
session-row pill: display:flex; gap:--sb-gap; min-height:26px, same padding as a session row, radius-md; hover = surface-sunken (no text recolor); :focus-visible uses --p-focus-ring
+
Lead slot
empty, --sb-gutter wide, so the label's start x aligns with the session titles (--sb-pad-x + --sb-gutter + --sb-gap)
+
Label
font-ui, text-xs, --color-text; flex:1, truncated
+
Behavior
"Load more" fetches the next page and auto-expands; once more than the first page is loaded, "Show less" appears and collapses back to the first page (view-layer trim — data is kept, no refetch); "Show all" re-expands
+
+
+
ResizeHandle
A 4px vertical drag bar, layered over the 1px column border (margin: 0 -2px makes the whole 4px grabbable), turning accent on hover / drag.
The group is collapsible; when collapsed its session list is hidden.
+
Show more & collapse
+
The "load more / show less" control at the bottom of each workspace group is a session-row-shaped compact list control (same family as search, New chat, inline rename — not a Button). It doubles as the pagination trigger and the in-group expand / collapse toggle.
+
+
Part
Rule
+
+
Container
session-row pill: display:flex; gap:--sb-gap; min-height:26px, same padding as a session row, radius-md; hover = surface-sunken (no text recolor); :focus-visible uses --p-focus-ring
+
Lead slot
empty, --sb-gutter wide, so the label's start x aligns with the session titles (--sb-pad-x + --sb-gutter + --sb-gap)
+
Label
font-ui, text-xs, --color-text; flex:1, truncated
+
Behavior
"Load more" fetches the next page and auto-expands; once more than the first page is loaded, "Show less" appears and collapses back to the first page (view-layer trim — data is kept, no refetch); "Show all" re-expands
+
+
+
ResizeHandle
A 4px vertical drag bar, layered over the 1px column border (margin: 0 -2px makes the whole 4px grabbable), turning accent on hover / drag.
diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue
index c8c92ef2b..b89cf4d95 100644
--- a/apps/kimi-web/src/components/Sidebar.vue
+++ b/apps/kimi-web/src/components/Sidebar.vue
@@ -146,6 +146,36 @@ const allCollapsed = computed(
props.groups.every((g) => collapsedIds.value.has(g.workspace.id)),
);
+// ---------------------------------------------------------------------------
+// In-group expand / collapse (show-more pagination)
+// ---------------------------------------------------------------------------
+// Tracks which workspace groups are "expanded" past their first page. Ephemeral
+// (not persisted): a refresh reloads only the first page, so everything starts
+// collapsed. Loading more expands automatically; the user can collapse back to
+// the first page without losing the already-loaded data.
+const expandedIds = ref>(new Set());
+
+function isExpanded(id: string): boolean {
+ return expandedIds.value.has(id);
+}
+
+function toggleExpand(id: string): void {
+ const next = new Set(expandedIds.value);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ expandedIds.value = next;
+}
+
+function onLoadMore(id: string): void {
+ // Loading more should reveal the new rows immediately.
+ if (!expandedIds.value.has(id)) {
+ const next = new Set(expandedIds.value);
+ next.add(id);
+ expandedIds.value = next;
+ }
+ emit('loadMoreSessions', id);
+}
+
// ---------------------------------------------------------------------------
// Workspace path display (toggle in the Workspaces section header)
// ---------------------------------------------------------------------------
@@ -646,6 +676,7 @@ onBeforeUnmount(() => {
:ws-menu-open-id="wsMenuOpenId"
:dragging="draggingWsId === g.workspace.id"
:is-collapsed="isCollapsed"
+ :is-expanded="isExpanded"
:show-path="showWorkspacePaths"
@group-click="handleGhClick"
@group-contextmenu="openGhMenu"
@@ -655,7 +686,8 @@ onBeforeUnmount(() => {
@rename-session="(id, title) => emit('rename', id, title)"
@archive-session="(id) => emit('archive', id)"
@fork-session="(id) => emit('fork', id)"
- @load-more="(id) => emit('loadMoreSessions', id)"
+ @load-more="onLoadMore"
+ @toggle-expand="toggleExpand"
@confirm-rename="confirmRenameWorkspace"
@cancel-rename="cancelRenameWorkspace"
@update-rename-value="onUpdateRenameValue"
diff --git a/apps/kimi-web/src/components/WorkspaceGroup.vue b/apps/kimi-web/src/components/WorkspaceGroup.vue
index eaff3dfa4..d29e7356b 100644
--- a/apps/kimi-web/src/components/WorkspaceGroup.vue
+++ b/apps/kimi-web/src/components/WorkspaceGroup.vue
@@ -30,6 +30,9 @@ const props = defineProps<{
/** When true, render the workspace root path as a stable subtitle line. */
showPath: boolean;
isCollapsed: (id: string) => boolean;
+ /** When true, render all loaded sessions; otherwise only the first page
+ * (`group.initialCount`). Drives the in-group show-more / show-less toggle. */
+ isExpanded: (id: string) => boolean;
}>();
const emit = defineEmits<{
@@ -42,6 +45,7 @@ const emit = defineEmits<{
archiveSession: [id: string];
forkSession: [id: string];
loadMore: [workspaceId: string];
+ toggleExpand: [workspaceId: string];
confirmRename: [];
cancelRename: [];
updateRenameValue: [value: string];
@@ -57,6 +61,32 @@ const renameValueModel = computed({
set: (value: string) => emit('updateRenameValue', value),
});
+// Sessions to render: all when expanded, otherwise only the first page. The
+// collapse is a pure view-layer trim — data, cursor and hasMore stay intact, so
+// re-expanding never refetches. When collapsed, the active session is always
+// kept visible: an older session selected via Cmd/Ctrl-K search or a URL deep
+// link would otherwise be hidden past the first page, so navigation would land
+// on a missing row. It appends in newest-first order (older than the head).
+const visibleSessions = computed(() => {
+ if (props.isExpanded(props.group.workspace.id)) return props.group.sessions;
+ const head = props.group.sessions.slice(0, props.group.initialCount);
+ if (props.activeId && !head.some((s) => s.id === props.activeId)) {
+ const active = props.group.sessions.find((s) => s.id === props.activeId);
+ if (active) return [...head, active];
+ }
+ return head;
+});
+// True once more than the first page is loaded — gates the show-less/show-all toggle.
+const canToggleExpand = computed(
+ () => props.group.sessions.length > props.group.initialCount,
+);
+function showMoreCount(): number {
+ return Math.max(0, props.group.workspace.sessionCount - props.group.sessions.length);
+}
+function showAllCount(): number {
+ return props.group.sessions.length - props.group.initialCount;
+}
+
// Hand the rename input element back to the parent's ref so Sidebar keeps
// owning focus (startRenameWorkspace focuses renameInputRef on nextTick). Only
// one group's input is mounted at a time, so sibling groups never collide.
@@ -140,7 +170,7 @@ function onHeaderDragStart(event: DragEvent): void {
:inert="isCollapsed(group.workspace.id)"
>
- {{
- group.loadingMore
- ? t('sidebar.loadingMore')
- : t('sidebar.showMore', { count: Math.max(0, group.workspace.sessionCount - group.sessions.length) })
- }}
+
+ {{
+ group.loadingMore ? t('sidebar.loadingMore') : t('sidebar.showMore', { count: showMoreCount() })
+ }}
+
+
{{ t('sidebar.noSessions') }}
@@ -281,21 +322,37 @@ function onHeaderDragStart(event: DragEvent): void {
color: var(--color-text-faint);
font-family: var(--font-mono);
}
+/* Show-more / show-less — a session-row-shaped compact list control (§07). The
+ empty lead slot mirrors a session row's status gutter, so the label text lands
+ at the exact same x as the session titles (--sb-pad-x + --sb-gutter + --sb-gap
+ from the sidebar edge). Hover washes the row in the sunken surface, matching
+ New chat / session rows; no text recolor. */
.show-more {
- display: block;
+ display: flex;
+ align-items: center;
+ gap: var(--sb-gap);
width: 100%;
- padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) + var(--sb-gutter) + var(--sb-gap));
- background: none;
+ min-height: 26px;
+ margin: 0;
+ padding: var(--space-1) calc(var(--sb-pad-x) - var(--space-2));
border: none;
+ border-radius: var(--radius-md);
+ background: transparent;
color: var(--color-text);
+ font-family: var(--font-ui);
font-size: var(--text-xs);
- font-family: var(--font-mono);
- cursor: pointer;
text-align: left;
+ cursor: pointer;
}
-.show-more:hover {
- color: var(--color-accent-hover);
- background: var(--color-surface-sunken);
+.show-more:hover { background: var(--color-surface-sunken); }
+.show-more:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
+.show-more-lead { width: var(--sb-gutter); flex: none; }
+.show-more-label {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
/* Inline workspace rename input */
diff --git a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue
index 8dcd40cb3..da5d73cbd 100644
--- a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue
+++ b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue
@@ -93,6 +93,45 @@ function toggleCollapse(id: string): void {
wsMenuFor.value = null;
}
+// ---------------------------------------------------------------------------
+// In-group expand / collapse (show-more pagination) — mirrors the desktop
+// sidebar. Local to the sheet; a refresh reloads only the first page.
+// ---------------------------------------------------------------------------
+const expandedIds = ref>(new Set());
+
+function isExpanded(id: string): boolean {
+ return expandedIds.value.has(id);
+}
+
+function toggleExpand(id: string): void {
+ const next = new Set(expandedIds.value);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ expandedIds.value = next;
+}
+
+function visibleSessions(g: WorkspaceGroup): Session[] {
+ if (isExpanded(g.workspace.id)) return g.sessions;
+ const head = g.sessions.slice(0, g.initialCount);
+ // Keep the active session visible when it's beyond the first page (e.g.
+ // selected via search or a deep link), mirroring the desktop sidebar.
+ if (props.activeId && !head.some((s) => s.id === props.activeId)) {
+ const active = g.sessions.find((s) => s.id === props.activeId);
+ if (active) return [...head, active];
+ }
+ return head;
+}
+
+function onLoadMore(id: string): void {
+ // Loading more should reveal the new rows immediately.
+ if (!expandedIds.value.has(id)) {
+ const next = new Set(expandedIds.value);
+ next.add(id);
+ expandedIds.value = next;
+ }
+ emit('loadMore', id);
+}
+
function wsAttention(id: string): number {
return props.attentionByWorkspace[id] ?? 0;
}
@@ -228,7 +267,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise {
diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts
index 033fe1f68..30d95d2ed 100644
--- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts
+++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts
@@ -45,6 +45,10 @@ import type { UseSideChat } from './useSideChat';
import type { UseTaskPoller } from './useTaskPoller';
const MESSAGES_PAGE_SIZE = 50;
+// Sessions fetched per workspace on first load — keeps the initial request
+// count at (number of workspaces) and each response small. Exported so the
+// sidebar can fall back to it when a workspace's first-page size is unknown.
+export const SESSIONS_INITIAL_PAGE_SIZE = 5;
const PROMPT_NOT_FOUND_CODE = 40402;
const WORKSPACE_NOT_FOUND_CODE = 40410;
// Shared "already resolved" conflict (40902). The daemon reuses it for both
@@ -327,9 +331,6 @@ 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 = 5;
// Sessions fetched per "load more" click within a workspace.
const SESSIONS_LOAD_MORE_SIZE = 30;
// On initial load, if the oldest session of the first page is still within
@@ -429,6 +430,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
const fallback = await listAllSessionsGlobal().catch(() => [] as AppSession[]);
rawState.sessionsHasMoreByWorkspace = {};
rawState.sessionsCursorByWorkspace = {};
+ rawState.sessionsInitialCountByWorkspace = {};
rawState.sessionsFullyLoaded = true;
return fallback;
}
@@ -443,6 +445,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
const loaded: AppSession[] = [];
const hasMore: Record = {};
const cursors: Record = {};
+ const counts: Record = {};
for (const { workspaceId, page } of pages) {
loaded.push(...page.items);
// Trust the server's hasMore — the per-workspace session_count is only a
@@ -453,9 +456,17 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// 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;
+ // Collapse target for the sidebar's in-group "show less" control: the
+ // first-page capacity, floored at a full page so a workspace that was
+ // empty or sparse on first paint does not hide sessions created later.
+ // If the initial load pulled more than a page (recent-window
+ // continuations), keep the larger count so collapse returns to what was
+ // first visible.
+ counts[workspaceId] = Math.max(page.items.length, SESSIONS_INITIAL_PAGE_SIZE);
}
rawState.sessionsHasMoreByWorkspace = hasMore;
rawState.sessionsCursorByWorkspace = cursors;
+ rawState.sessionsInitialCountByWorkspace = counts;
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).
diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts
index 5791758c4..522562f59 100644
--- a/apps/kimi-web/src/composables/useKimiWebClient.ts
+++ b/apps/kimi-web/src/composables/useKimiWebClient.ts
@@ -33,7 +33,7 @@ import { useSoundNotification } from './client/useSoundNotification';
import { useTaskPoller } from './client/useTaskPoller';
import { useModelProviderState } from './client/useModelProviderState';
import { useSideChat } from './client/useSideChat';
-import { useWorkspaceState } from './client/useWorkspaceState';
+import { SESSIONS_INITIAL_PAGE_SIZE, useWorkspaceState } from './client/useWorkspaceState';
const appearance = useAppearance();
const notification = useNotification();
@@ -334,6 +334,9 @@ export interface ExtendedState extends KimiClientState {
* 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;
+ /** First-page capacity per workspace (sessions loaded on first paint, floored
+ * at one full page). Drives the sidebar's in-group show-less collapse target. */
+ sessionsInitialCountByWorkspace: Record;
/** True once every session has been loaded (after a search-triggered full drain). */
sessionsFullyLoaded: boolean;
}
@@ -375,6 +378,7 @@ const rawState: ExtendedState = reactive({
sessionsHasMoreByWorkspace: {},
sessionsLoadingMoreByWorkspace: {},
sessionsCursorByWorkspace: {},
+ sessionsInitialCountByWorkspace: {},
sessionsFullyLoaded: false,
});
@@ -2002,6 +2006,7 @@ const workspaceGroups = computed(() => {
sessions: byId.get(w.id) ?? [],
hasMore: rawState.sessionsHasMoreByWorkspace[w.id] ?? false,
loadingMore: rawState.sessionsLoadingMoreByWorkspace[w.id] ?? false,
+ initialCount: rawState.sessionsInitialCountByWorkspace[w.id] ?? SESSIONS_INITIAL_PAGE_SIZE,
}));
});
diff --git a/apps/kimi-web/src/i18n/locales/en/sidebar.ts b/apps/kimi-web/src/i18n/locales/en/sidebar.ts
index e70846a48..bb53bc6c0 100644
--- a/apps/kimi-web/src/i18n/locales/en/sidebar.ts
+++ b/apps/kimi-web/src/i18n/locales/en/sidebar.ts
@@ -31,7 +31,9 @@ export default {
language: 'Language',
daemon: 'Daemon',
noSessions: 'No conversations yet',
- showMore: 'Show more ({count})',
+ showMore: 'Load more ({count})',
+ showLess: 'Show less',
+ showAll: 'Show all ({count})',
loadingMore: 'Loading…',
collapseSidebar: 'Collapse sidebar',
expandSidebar: 'Expand sidebar',
diff --git a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts
index b76bb0954..95698ae77 100644
--- a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts
+++ b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts
@@ -31,7 +31,9 @@ export default {
language: '语言',
daemon: '后台',
noSessions: '暂无对话',
- showMore: '展开更多 ({count})',
+ showMore: '加载更多 ({count})',
+ showLess: '收起',
+ showAll: '展开 ({count})',
loadingMore: '加载中…',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
diff --git a/apps/kimi-web/src/types.ts b/apps/kimi-web/src/types.ts
index 444e2eba8..52b1f4253 100644
--- a/apps/kimi-web/src/types.ts
+++ b/apps/kimi-web/src/types.ts
@@ -78,6 +78,10 @@ export interface WorkspaceGroup {
hasMore: boolean;
/** True while the next page of sessions is being fetched for this workspace. */
loadingMore: boolean;
+ /** First-page capacity for the in-group show-less collapse target: the number
+ * of sessions loaded on first paint, floored at one full page so a workspace
+ * that was empty or sparse does not hide sessions created later. */
+ initialCount: number;
}
/** Sidebar session-list scope: only the active workspace, or all workspaces. */