diff --git a/.changeset/add-session-list-exclude-empty.md b/.changeset/add-session-list-exclude-empty.md new file mode 100644 index 000000000..dff643011 --- /dev/null +++ b/.changeset/add-session-list-exclude-empty.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add an optional exclude_empty parameter to the session list API to omit sessions that have no messages. diff --git a/.changeset/web-hide-empty-sessions.md b/.changeset/web-hide-empty-sessions.md new file mode 100644 index 000000000..d259d35a1 --- /dev/null +++ b/.changeset/web-hide-empty-sessions.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Hide unused "New Session" entries from the web session list by default. diff --git a/.changeset/web-remove-sessions-command.md b/.changeset/web-remove-sessions-command.md new file mode 100644 index 000000000..ae052a266 --- /dev/null +++ b/.changeset/web-remove-sessions-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Remove the /sessions slash command from the web UI; the sidebar already covers session browsing. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 51a75ba5d..4a610657e 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -16,7 +16,6 @@ import ProviderManager from './components/settings/ProviderManager.vue'; import LoginDialog from './components/dialogs/LoginDialog.vue'; import NewSessionDialog from './components/dialogs/NewSessionDialog.vue'; import SettingsDialog from './components/settings/SettingsDialog.vue'; -import SessionsDialog from './components/dialogs/SessionsDialog.vue'; import AddWorkspaceDialog from './components/dialogs/AddWorkspaceDialog.vue'; import StatusPanel from './components/chat/StatusPanel.vue'; import WarningToasts from './components/WarningToasts.vue'; @@ -229,7 +228,6 @@ const showModelPicker = ref(false); const showProviders = ref(false); const showLogin = ref(false); const showNewSession = ref(false); -const showSessions = ref(false); const showAddWorkspace = ref(false); const showStatusPanel = ref(false); const showSettings = ref(false); @@ -249,7 +247,6 @@ const anyOverlayOpen = computed(() => showProviders.value || showLogin.value || showNewSession.value || - showSessions.value || showAddWorkspace.value || showStatusPanel.value || showSettings.value || @@ -400,9 +397,6 @@ function handleCommand(cmd: string): void { case '/clear': handleCreateSession(); break; - case '/sessions': - showSessions.value = true; - break; case '/fork': void client.forkSession(); break; @@ -868,17 +862,6 @@ function openPr(url: string): void { @close="showNewSession = false" /> - - - > { const query: Record = { before_id: input?.beforeId, @@ -294,6 +299,7 @@ export class DaemonKimiWebApi implements KimiWebApi { page_size: input?.pageSize, status: input?.status ? toWireSessionStatus(input.status) : undefined, include_archive: input?.includeArchive, + exclude_empty: input?.excludeEmpty, // PRESUMED — daemon supports ?workspace_id= once the registry ships; it // ignores unknown query params until then, so this is safe to always send. workspace_id: input?.workspaceId, diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 620cdad1a..fe12d91b1 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -605,7 +605,7 @@ export interface AppSessionWarning { export interface KimiWebApi { getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>; getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record; openInApps: string[] }>; - listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean }): Promise>; + listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; excludeEmpty?: boolean }): Promise>; createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; /** Fetch one session by id (deep links beyond the first listSessions page). */ getSession(sessionId: string): Promise; diff --git a/apps/kimi-web/src/components/dialogs/SessionsDialog.vue b/apps/kimi-web/src/components/dialogs/SessionsDialog.vue deleted file mode 100644 index 59e426473..000000000 --- a/apps/kimi-web/src/components/dialogs/SessionsDialog.vue +++ /dev/null @@ -1,394 +0,0 @@ - - - - - - - - - - diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 66cf6ae8d..967ef58f3 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -299,7 +299,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const items: AppSession[] = []; let beforeId: string | undefined; for (;;) { - const page = await api.listSessions({ pageSize: SESSION_PAGE_SIZE, beforeId }); + const page = await api.listSessions({ + pageSize: SESSION_PAGE_SIZE, + beforeId, + excludeEmpty: true, + }); items.push(...page.items); if (!page.hasMore || page.items.length === 0) break; beforeId = page.items[page.items.length - 1]!.id; @@ -326,7 +330,11 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const pages = await Promise.all( workspaces.map((w) => api - .listSessions({ workspaceId: w.id, pageSize: SESSIONS_INITIAL_PAGE_SIZE }) + .listSessions({ + workspaceId: w.id, + pageSize: SESSIONS_INITIAL_PAGE_SIZE, + excludeEmpty: true, + }) .then((page) => ({ workspaceId: w.id, page })) .catch(() => ({ workspaceId: w.id, @@ -372,6 +380,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta workspaceId, pageSize: SESSIONS_LOAD_MORE_SIZE, beforeId, + excludeEmpty: true, }); // Append de-duped against the latest list so a concurrently added/removed // session is respected. diff --git a/apps/kimi-web/src/i18n/locales/en/commands.ts b/apps/kimi-web/src/i18n/locales/en/commands.ts index 1027cb50c..691cf863f 100644 --- a/apps/kimi-web/src/i18n/locales/en/commands.ts +++ b/apps/kimi-web/src/i18n/locales/en/commands.ts @@ -1,7 +1,6 @@ export default { help: { desc: 'Show the list of available commands' }, new: { desc: 'Create a new session' }, - sessions: { desc: 'Browse & switch sessions' }, clear: { desc: 'Clear and start a new session' }, model: { desc: 'Switch model' }, provider: { desc: 'Manage providers (add / remove / refresh)' }, diff --git a/apps/kimi-web/src/i18n/locales/en/sessions.ts b/apps/kimi-web/src/i18n/locales/en/sessions.ts index 378168311..cdc86a56a 100644 --- a/apps/kimi-web/src/i18n/locales/en/sessions.ts +++ b/apps/kimi-web/src/i18n/locales/en/sessions.ts @@ -1,10 +1,3 @@ export default { - title: 'Sessions', - close: 'Close', - searchPlaceholder: 'Search sessions by title…', - noWorkspace: 'No workspace', - emptyNone: 'No sessions yet', - emptyNoMatch: 'No matching sessions', - footerHint: '↑↓ to navigate · Enter to switch · Esc to close', justNow: 'just now', } as const; diff --git a/apps/kimi-web/src/i18n/locales/zh/commands.ts b/apps/kimi-web/src/i18n/locales/zh/commands.ts index 9f0b6e323..1d0ddf709 100644 --- a/apps/kimi-web/src/i18n/locales/zh/commands.ts +++ b/apps/kimi-web/src/i18n/locales/zh/commands.ts @@ -1,7 +1,6 @@ export default { help: { desc: '显示可用命令列表' }, new: { desc: '创建新会话' }, - sessions: { desc: '浏览/切换会话' }, clear: { desc: '清空并新建会话' }, model: { desc: '切换模型' }, provider: { desc: '管理提供商 (添加/删除/刷新)' }, diff --git a/apps/kimi-web/src/i18n/locales/zh/sessions.ts b/apps/kimi-web/src/i18n/locales/zh/sessions.ts index 3d2f16aa7..9bec2efa6 100644 --- a/apps/kimi-web/src/i18n/locales/zh/sessions.ts +++ b/apps/kimi-web/src/i18n/locales/zh/sessions.ts @@ -1,10 +1,3 @@ export default { - title: '会话', - close: '关闭', - searchPlaceholder: '按标题搜索会话…', - noWorkspace: '无工作区', - emptyNone: '暂无会话', - emptyNoMatch: '没有匹配的会话', - footerHint: '↑↓ 切换 · Enter 进入 · Esc 关闭', justNow: '刚刚', }; diff --git a/apps/kimi-web/src/lib/slashCommands.ts b/apps/kimi-web/src/lib/slashCommands.ts index b9fa53d52..cca86e1b3 100644 --- a/apps/kimi-web/src/lib/slashCommands.ts +++ b/apps/kimi-web/src/lib/slashCommands.ts @@ -24,7 +24,6 @@ export interface SlashCommand { export const SLASH_COMMANDS: SlashCommand[] = [ { name: '/help', desc: 'commands.help.desc' }, { name: '/new', desc: 'commands.new.desc' }, - { name: '/sessions', desc: 'commands.sessions.desc' }, { name: '/clear', desc: 'commands.clear.desc' }, { name: '/model', desc: 'commands.model.desc' }, { name: '/provider', desc: 'commands.provider.desc' }, diff --git a/packages/agent-core/src/services/session/session.ts b/packages/agent-core/src/services/session/session.ts index 1d83feead..fde54ddcc 100644 --- a/packages/agent-core/src/services/session/session.ts +++ b/packages/agent-core/src/services/session/session.ts @@ -24,6 +24,8 @@ export interface SessionListQuery extends CursorQuery { status?: import('@moonshot-ai/protocol').SessionStatus; workDir?: string; includeArchive?: boolean; + /** When true, hide sessions the user has never interacted with (no prompt yet). */ + excludeEmpty?: boolean; } export interface SessionClientTelemetry { diff --git a/packages/agent-core/src/services/session/sessionService.ts b/packages/agent-core/src/services/session/sessionService.ts index 3af85e326..3b684a0dc 100644 --- a/packages/agent-core/src/services/session/sessionService.ts +++ b/packages/agent-core/src/services/session/sessionService.ts @@ -260,21 +260,26 @@ export class SessionService extends Disposable implements ISessionService { }; const all = await this.core.rpc.listSessions(corePayload); const sorted = all.toSorted((a, b) => b.updatedAt - a.updatedAt); + // Hide sessions the user has never interacted with: a session is "empty" when + // it has no lastPrompt (the first prompt has not been sent yet). Filtered + // before cursor pagination so each returned page is filled with non-empty + // sessions and has_more reflects the filtered set. + const visible = query.excludeEmpty ? sorted.filter((s) => s.lastPrompt) : sorted; let pivotIndex = -1; if (query.before_id !== undefined) { - pivotIndex = sorted.findIndex((s) => s.id === query.before_id); + pivotIndex = visible.findIndex((s) => s.id === query.before_id); } else if (query.after_id !== undefined) { - pivotIndex = sorted.findIndex((s) => s.id === query.after_id); + pivotIndex = visible.findIndex((s) => s.id === query.after_id); } - let slice: typeof sorted; + let slice: typeof visible; if (query.before_id !== undefined && pivotIndex >= 0) { - slice = sorted.slice(pivotIndex + 1); + slice = visible.slice(pivotIndex + 1); } else if (query.after_id !== undefined && pivotIndex >= 0) { - slice = sorted.slice(0, pivotIndex); + slice = visible.slice(0, pivotIndex); } else { - slice = sorted; + slice = visible; } const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; diff --git a/packages/agent-core/test/services/session-service.test.ts b/packages/agent-core/test/services/session-service.test.ts index 6d8ece138..b84de9286 100644 --- a/packages/agent-core/test/services/session-service.test.ts +++ b/packages/agent-core/test/services/session-service.test.ts @@ -608,6 +608,46 @@ describe('SessionService.list', () => { expect(page.items).toEqual([]); expect(page.has_more).toBe(false); }); + + it('excludeEmpty drops sessions without a lastPrompt before pagination', async () => { + const ts = (n: number) => 1_000_000 + n * 1_000; + const summary = ( + id: string, + updatedAt: number, + lastPrompt?: string, + ): SessionSummary => ({ + id, + workDir: '/tmp/a', + sessionDir: `/tmp/sessions/${id}`, + createdAt: updatedAt, + updatedAt, + metadata: { cwd: '/tmp/a' }, + title: undefined, + lastPrompt, + }); + state.sessions = [ + summary('e1', ts(3)), + summary('u1', ts(2), 'hi'), + summary('e2', ts(1)), + summary('u2', ts(0), 'yo'), + ]; + + const all = await svc.list({}); + expect(all.items.map((s) => s.id)).toEqual(['e1', 'u1', 'e2', 'u2']); + + const visible = await svc.list({ excludeEmpty: true }); + expect(visible.items.map((s) => s.id)).toEqual(['u1', 'u2']); + expect(visible.has_more).toBe(false); + + // Pagination + cursor operate on the filtered set. + const first = await svc.list({ excludeEmpty: true, page_size: 1 }); + expect(first.items.map((s) => s.id)).toEqual(['u1']); + expect(first.has_more).toBe(true); + + const next = await svc.list({ excludeEmpty: true, page_size: 1, before_id: 'u1' }); + expect(next.items.map((s) => s.id)).toEqual(['u2']); + expect(next.has_more).toBe(false); + }); }); describe('SessionService.get', () => { diff --git a/packages/protocol/src/__tests__/rest-session.test.ts b/packages/protocol/src/__tests__/rest-session.test.ts index db64818eb..d53c4e5e5 100644 --- a/packages/protocol/src/__tests__/rest-session.test.ts +++ b/packages/protocol/src/__tests__/rest-session.test.ts @@ -11,6 +11,7 @@ import { forkSessionRequestSchema, forkSessionResponseSchema, getSessionProfileResponseSchema, + listSessionChildrenQuerySchema, listSessionChildrenResponseSchema, listSessionsQuerySchema, sessionStatusResponseSchema, @@ -98,6 +99,22 @@ describe('listSessionsQuerySchema', () => { include_archive: false, }); }); + + it('parses exclude_empty to boolean', () => { + expect(listSessionsQuerySchema.parse({ exclude_empty: 'true' })).toEqual({ + exclude_empty: true, + }); + expect(listSessionsQuerySchema.parse({ exclude_empty: 'false' })).toEqual({ + exclude_empty: false, + }); + }); +}); + +describe('listSessionChildrenQuerySchema', () => { + it('does not advertise exclude_empty (child lists do not filter by it)', () => { + const parsed = listSessionChildrenQuerySchema.parse({ exclude_empty: true }); + expect(parsed).not.toHaveProperty('exclude_empty'); + }); }); describe('getSessionProfileResponseSchema', () => { diff --git a/packages/protocol/src/rest/session.ts b/packages/protocol/src/rest/session.ts index a6d7bacc4..f937de4dd 100644 --- a/packages/protocol/src/rest/session.ts +++ b/packages/protocol/src/rest/session.ts @@ -46,6 +46,7 @@ export const listSessionsQuerySchema = cursorQuerySchema.and( z.object({ status: sessionStatusSchema.optional(), include_archive: booleanQueryParam, + exclude_empty: booleanQueryParam, }), ); export type ListSessionsQuery = z.infer; @@ -85,7 +86,14 @@ export const startBtwSessionResponseSchema = z.object({ }); export type StartBtwSessionResponse = z.infer; -export const listSessionChildrenQuerySchema = listSessionsQuerySchema; +// Child lists intentionally omit exclude_empty: the /sessions/{id}/children route +// does not filter by it, so advertising it would mislead generated clients. +export const listSessionChildrenQuerySchema = cursorQuerySchema.and( + z.object({ + status: sessionStatusSchema.optional(), + include_archive: booleanQueryParam, + }), +); export type ListSessionChildrenQuery = z.infer; export const listSessionChildrenResponseSchema = pageResponseSchema(sessionSchema); diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 245817b80..2c69bdb80 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -83,6 +83,7 @@ const sessionsListQueryCoercion = z page_size: z.coerce.number().int().min(1).max(100).optional(), status: sessionStatusSchema.optional(), include_archive: booleanQueryParam, + exclude_empty: booleanQueryParam, workspace_id: workspaceIdSchema.optional(), }) @@ -272,6 +273,7 @@ export function registerSessionsRoutes( page_size: raw.page_size, status: raw.status, includeArchive: raw.include_archive, + excludeEmpty: raw.exclude_empty, }; let query; if (raw.workspace_id !== undefined) {