From 70a559b97d67e900860324131f8f896200b2dfa7 Mon Sep 17 00:00:00 2001 From: zhiyuan1i Date: Sat, 18 Jul 2026 21:17:28 +0800 Subject: [PATCH 1/4] feat(web): hide ACP-created sessions and workspaces from the sidebar The ACP adapter now tags every session it creates with `source: 'acp'` (plus the client's `clientInfo.name` when advertised) in the persisted custom metadata, which kap-server already forwards onto the wire session. kimi-web reads that tag and, behind a new on-by-default setting, filters ACP sessions out of the sidebar lists and archived views; workspaces whose loaded sessions are all ACP-created are hidden as well, via a computed root set applied only at the mergeWorkspaces call site (never persisted, so WS broadcasts cannot resurrect them). The tag marks "created via ACP": forked sessions inherit it through the existing custom-metadata copy, and `session/resume` never tags. --- .changeset/feat-hide-acp-workspaces.md | 6 ++ apps/kimi-web/src/App.vue | 2 + apps/kimi-web/src/api/daemon/mappers.ts | 1 + apps/kimi-web/src/api/types.ts | 6 ++ .../components/mobile/MobileSettingsSheet.vue | 1 + .../components/settings/SettingsDialog.vue | 15 ++++ .../src/composables/useKimiWebClient.ts | 49 ++++++++++++- apps/kimi-web/src/i18n/locales/en/settings.ts | 2 + apps/kimi-web/src/i18n/locales/zh/settings.ts | 2 + apps/kimi-web/src/lib/acpSessions.test.ts | 69 +++++++++++++++++++ apps/kimi-web/src/lib/acpSessions.ts | 39 +++++++++++ apps/kimi-web/src/lib/storage.ts | 1 + packages/acp-adapter/src/server.ts | 13 ++++ packages/acp-adapter/test/session-new.test.ts | 43 +++++++++++- 14 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 .changeset/feat-hide-acp-workspaces.md create mode 100644 apps/kimi-web/src/lib/acpSessions.test.ts create mode 100644 apps/kimi-web/src/lib/acpSessions.ts diff --git a/.changeset/feat-hide-acp-workspaces.md b/.changeset/feat-hide-acp-workspaces.md new file mode 100644 index 0000000000..7504bffe95 --- /dev/null +++ b/.changeset/feat-hide-acp-workspaces.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/acp-adapter": patch +"@moonshot-ai/kimi-web": patch +--- + +Tag ACP-created sessions with `source: 'acp'` (plus the client's `clientInfo.name`) in their persisted custom metadata, and add a kimi-web setting (on by default) that hides ACP-created sessions and ACP-only workspaces from the sidebar and archived lists. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 5708bcc9bf..57e3bafeef 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -998,6 +998,7 @@ function openPr(url: string): void { :notify-permission="client.notifyPermission.value" :sound="client.soundOnComplete.value" :conversation-toc="client.conversationToc.value" + :hide-acp-sessions="client.hideAcpSessions.value" :config="client.config.value" :models="client.models.value" :config-saving="configSaving" @@ -1011,6 +1012,7 @@ function openPr(url: string): void { @set-notify-approval="client.setNotifyOnApproval($event)" @set-sound="client.setSoundOnComplete($event)" @set-conversation-toc="client.setConversationToc($event)" + @set-hide-acp-sessions="client.setHideAcpSessions($event)" @update-config="handleUpdateConfig($event)" @login="() => { showSettings = false; openLogin(); }" @logout="client.logout" diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index 5eeda29e08..fbcd4388ba 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -109,6 +109,7 @@ export function toAppSession(wire: WireSession): AppSession { typeof wire.metadata['parent_session_id'] === 'string' ? wire.metadata['parent_session_id'] : undefined, + source: typeof wire.metadata['source'] === 'string' ? wire.metadata['source'] : undefined, }; } diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 3f82db6a4b..f90ba663fb 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -90,6 +90,12 @@ export interface AppSession { * from. Used to keep child sessions out of the main session list. */ parentSessionId?: string; + /** + * Creation source of the session (e.g. 'acp' for sessions created through + * the ACP adapter), forwarded from the persisted session `custom` map. + * Used to hide ACP-created sessions/workspaces from the sidebar. + */ + source?: string; } /** diff --git a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue index 814ea8c398..f0bc74e752 100644 --- a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue @@ -198,6 +198,7 @@ function backToMain(): void { const filteredArchived = computed(() => { const q = archiveQuery.value.trim().toLowerCase(); let rows = archivedItems.value.filter((s) => s.archived === true); + if (client.hideAcpSessions.value) rows = rows.filter((s) => s.source !== 'acp'); if (q) rows = rows.filter((s) => s.title.toLowerCase().includes(q)); rows = rows.slice(); if (archiveSort.value === 'archived-desc') { diff --git a/apps/kimi-web/src/components/settings/SettingsDialog.vue b/apps/kimi-web/src/components/settings/SettingsDialog.vue index 181e4197eb..76680e51f8 100644 --- a/apps/kimi-web/src/components/settings/SettingsDialog.vue +++ b/apps/kimi-web/src/components/settings/SettingsDialog.vue @@ -40,6 +40,8 @@ const props = defineProps<{ sound: boolean; /** Conversation outline (proportional bubbles, viewport indicator, hover tooltip). */ conversationToc?: boolean; + /** Hide ACP-created sessions/workspaces from the sidebar preference. */ + hideAcpSessions?: boolean; /** Global daemon config from GET /api/v1/config. Secrets are redacted server-side. */ config?: AppConfig | null; /** Models from the daemon catalog, used to label default-model choices. */ @@ -61,6 +63,7 @@ const emit = defineEmits<{ setNotifyApproval: [on: boolean]; setSound: [on: boolean]; setConversationToc: [on: boolean]; + setHideAcpSessions: [on: boolean]; login: []; logout: []; openOnboarding: []; @@ -280,6 +283,7 @@ const filteredArchived = computed(() => { // even if an older server ignores `archived_only` and falls back to the // default (unarchived) list. Filter again on the client. let rows = archivedItems.value.filter((s) => s.archived === true); + if (client.hideAcpSessions.value) rows = rows.filter((s) => s.source !== 'acp'); if (archiveWsFilter.value !== 'all') { rows = rows.filter((s) => s.cwd === archiveWsFilter.value); } @@ -397,6 +401,17 @@ function archiveTime(iso: string): string { @update:model-value="emit('setConversationToc', $event)" /> +
+ + {{ t('settings.hideAcpSessions') }} + {{ t('settings.hideAcpSessionsHint') }} + + +
diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index db5281071a..ae6d188631 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -14,6 +14,7 @@ import { type WorkspaceSortMode, } from '../lib/workspaceOrder'; import { mergeWorkspaces } from '../lib/mergeWorkspaces'; +import { acpOnlyWorkspaceRoots, isAcpSession } from '../lib/acpSessions'; import { workspaceRootKey } from '../lib/rootKey'; import { mergeSnapshotMessages } from '../lib/snapshotMessages'; import { mergeSnapshotSubagents } from '../lib/taskMerge'; @@ -2350,6 +2351,36 @@ function workspaceIdForSession(s: { workspaceId?: string; cwd: string }): string ); } +/** + * Hide ACP-created sessions/workspaces from the sidebar. Default ON: sessions + * created via the ACP adapter (e.g. bot clients driving kimi-code headlessly) + * carry `source: 'acp'` in their persisted custom metadata, and their + * workspaces are usually machine-owned directories the user never edits in. + * Persisted as '1'/'0' like the notification prefs. + */ +function loadHideAcpSessions(): boolean { + const v = safeGetString(STORAGE_KEYS.hideAcpSessions); + return v === null ? true : v === '1'; +} + +const hideAcpSessions = ref(loadHideAcpSessions()); + +function setHideAcpSessions(on: boolean): void { + hideAcpSessions.value = on; + safeSetString(STORAGE_KEYS.hideAcpSessions, on ? '1' : '0'); +} + +/** + * Roots whose loaded sessions are ALL ACP-created (see `acpOnlyWorkspaceRoots` + * for the semantics and the pagination caveat). Deliberately NOT written into + * `rawState.hiddenWorkspaceRoots`: that set is user-owned, gets persisted, + * and is auto-cleared by workspace WS broadcasts. These roots only exist at + * the `mergeWorkspaces` call site below. + */ +const acpHiddenWorkspaceRoots = computed(() => + hideAcpSessions.value ? acpOnlyWorkspaceRoots(rawState.sessions) : [], +); + /** * Merge real (daemon) workspaces with workspaces DERIVED from the current * sessions' cwds. Each distinct cwd with no matching real workspace becomes one @@ -2359,8 +2390,10 @@ function workspaceIdForSession(s: { workspaceId?: string; cwd: string }): string const mergedWorkspaces = computed(() => mergeWorkspaces({ workspaces: rawState.workspaces, - sessions: rawState.sessions, - hiddenWorkspaceRoots: rawState.hiddenWorkspaceRoots, + sessions: hideAcpSessions.value + ? rawState.sessions.filter((s) => !isAcpSession(s)) + : rawState.sessions, + hiddenWorkspaceRoots: [...rawState.hiddenWorkspaceRoots, ...acpHiddenWorkspaceRoots.value], sessionsHasMoreByWorkspace: rawState.sessionsHasMoreByWorkspace, }), ); @@ -2483,7 +2516,12 @@ const sessionsForView = computed(() => { // excluded too, so this flat list matches what the grouped sidebar renders // and sidebar search can't resurrect sessions from a removed workspace. return rawState.sessions - .filter((s) => !s.parentSessionId && visibleWorkspaceIds.has(workspaceIdForSession(s))) + .filter( + (s) => + !s.parentSessionId && + visibleWorkspaceIds.has(workspaceIdForSession(s)) && + !(hideAcpSessions.value && isAcpSession(s)), + ) .map((s) => { const workspaceId = workspaceIdForSession(s); return { @@ -2508,6 +2546,7 @@ const workspaceGroups = computed(() => { (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), )) { if (s.parentSessionId) continue; // child sessions stay out of the list + if (hideAcpSessions.value && isAcpSession(s)) continue; // ACP sessions hidden by preference const wid = workspaceIdForSession(s); const view: Session = { id: s.id, @@ -2889,6 +2928,10 @@ export function useKimiWebClient() { conversationToc, setConversationToc, + // Hide ACP-created sessions/workspaces from the sidebar + hideAcpSessions, + setHideAcpSessions, + // Color scheme colorScheme: appearance.colorScheme, setColorScheme: appearance.setColorScheme, diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts index 733cb83e07..45ac799e0f 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -54,6 +54,8 @@ export default { exportLogBtn: 'Export log', conversationToc: 'Show conversation outline', conversationTocHint: 'Show a clickable outline in the right margin to jump between messages', + hideAcpSessions: 'Hide ACP sessions', + hideAcpSessionsHint: 'Hide workspaces and sessions created by ACP clients (e.g. automation bots) from the sidebar', archivedTitle: 'Archived sessions', archivedDesc: 'Browse archived sessions, see their workspace path, name, and archive time, and restore them to the session list.', archivedSearch: 'Search archived sessions', diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts index 3ebdbbe65f..567c2b8e0c 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -54,6 +54,8 @@ export default { exportLogBtn: '导出日志', conversationToc: '显示对话目录', conversationTocHint: '在右侧显示可点击跳转的对话目录', + hideAcpSessions: '隐藏 ACP 会话', + hideAcpSessionsHint: '不在会话列表显示由 ACP 客户端(如自动化 bot)创建的工作区和会话', archivedTitle: '已归档会话', archivedDesc: '查看已归档会话,确认其所属工作区路径、会话名称和归档时间,并可恢复到会话列表。', archivedSearch: '搜索已归档会话', diff --git a/apps/kimi-web/src/lib/acpSessions.test.ts b/apps/kimi-web/src/lib/acpSessions.test.ts new file mode 100644 index 0000000000..5f947152d8 --- /dev/null +++ b/apps/kimi-web/src/lib/acpSessions.test.ts @@ -0,0 +1,69 @@ +// apps/kimi-web/src/lib/acpSessions.test.ts +import { describe, expect, it } from 'vitest'; + +import type { AppSession } from '../api/types'; +import { acpOnlyWorkspaceRoots, isAcpSession } from './acpSessions'; +import { workspaceRootKey } from './rootKey'; + +function makeSession(overrides: Partial): AppSession { + return { + id: 'session_1', + title: 't', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + busy: false, + archived: false, + cwd: '/repo', + model: 'kimi', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + ...overrides, + }; +} + +describe('isAcpSession', () => { + it('matches exactly the acp source tag', () => { + expect(isAcpSession(makeSession({ source: 'acp' }))).toBe(true); + expect(isAcpSession(makeSession({ source: 'vscode' }))).toBe(false); + expect(isAcpSession(makeSession({}))).toBe(false); + }); +}); + +describe('acpOnlyWorkspaceRoots', () => { + it('reports roots whose sessions are all ACP-created', () => { + const roots = acpOnlyWorkspaceRoots([ + makeSession({ id: 'a', cwd: '/agent/s_1', source: 'acp' }), + makeSession({ id: 'b', cwd: '/agent/s_1', source: 'acp' }), + makeSession({ id: 'c', cwd: '/repo', source: undefined }), + ]); + expect(roots).toEqual([workspaceRootKey('/agent/s_1')]); + }); + + it('hides nothing when a workspace has any non-ACP session', () => { + const roots = acpOnlyWorkspaceRoots([ + makeSession({ id: 'a', cwd: '/mixed', source: 'acp' }), + makeSession({ id: 'b', cwd: '/mixed' }), + ]); + expect(roots).toEqual([]); + }); + + it('never reports a root with no ACP session (no vacuous truth on empty/ACP-free sets)', () => { + expect(acpOnlyWorkspaceRoots([])).toEqual([]); + expect(acpOnlyWorkspaceRoots([makeSession({ id: 'a', cwd: '/repo' })])).toEqual([]); + }); + + it('skips sessions without a cwd', () => { + const roots = acpOnlyWorkspaceRoots([makeSession({ id: 'a', cwd: '', source: 'acp' })]); + expect(roots).toEqual([]); + }); +}); diff --git a/apps/kimi-web/src/lib/acpSessions.ts b/apps/kimi-web/src/lib/acpSessions.ts new file mode 100644 index 0000000000..cdcb499681 --- /dev/null +++ b/apps/kimi-web/src/lib/acpSessions.ts @@ -0,0 +1,39 @@ +// apps/kimi-web/src/lib/acpSessions.ts +// ACP-session filtering helpers. Sessions created via the ACP adapter (bot +// clients driving kimi-code headlessly) carry `source: 'acp'` in their +// persisted custom metadata, forwarded onto the wire session's `metadata`. +// When the user preference is on, these sessions (and workspaces that contain +// nothing else) are hidden from the sidebar. + +import type { AppSession } from '../api/types'; +import { workspaceRootKey } from './rootKey'; + +export function isAcpSession(session: AppSession): boolean { + return session.source === 'acp'; +} + +/** + * Folded root keys whose loaded sessions are ALL ACP-created (with at least + * one such session). Keyed via `workspaceRootKey`, matching how + * `mergeWorkspaces` applies `hiddenWorkspaceRoots`. + * + * Known limitation: sessions load paginated, so a mixed workspace whose + * non-ACP sessions are all beyond the loaded pages is reported here until + * those sessions load. Accepted — the preference toggle restores everything. + */ +export function acpOnlyWorkspaceRoots(sessions: AppSession[]): string[] { + const stats = new Map(); + for (const s of sessions) { + if (!s.cwd) continue; + const key = workspaceRootKey(s.cwd); + const entry = stats.get(key) ?? { acp: 0, other: 0 }; + if (isAcpSession(s)) entry.acp += 1; + else entry.other += 1; + stats.set(key, entry); + } + const roots: string[] = []; + for (const [key, entry] of stats) { + if (entry.acp > 0 && entry.other === 0) roots.push(key); + } + return roots; +} diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index 5cd60760c6..12d5c23f34 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -25,6 +25,7 @@ export const STORAGE_KEYS = { workspaceOrder: 'kimi-web.workspace-order', workspaceNameOverrides: 'kimi-web.workspace-name-overrides', workspaceSort: 'kimi-web.workspace-sort', + hideAcpSessions: 'kimi-web.hide-acp-sessions', // Conversation outline (TOC). The value keeps the legacy `beta-toc` name so // users who explicitly turned it off while it was experimental keep their // preference after it became on-by-default. diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index d0286d18db..90c378a8b2 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -136,6 +136,7 @@ function thinkingEnabledFromEffort(effort: unknown): boolean | undefined { export class AcpServer implements Agent { private negotiated: AcpVersionSpec | undefined; private clientCapabilities: ClientCapabilities | undefined; + private clientInfo: Implementation | undefined; private readonly sessions = new Map(); private readonly agentInfo: Implementation | undefined; private readonly terminalAuthEnv: Readonly> | undefined; @@ -220,6 +221,7 @@ export class AcpServer implements Agent { async initialize(params: InitializeRequest): Promise { this.negotiated = negotiateVersion(params.protocolVersion); this.clientCapabilities = params.clientCapabilities; + this.clientInfo = params.clientInfo ?? undefined; const agentCapabilities: AgentCapabilities = { loadSession: true, @@ -295,6 +297,17 @@ export class AcpServer implements Agent { kaos: acpKaos, persistenceKaos, sessionStartedProperties: { mode: 'new' }, + // Tag the session as ACP-created so surfaces like kimi-web can + // filter them out of the workspace sidebar. `metadata` lands in the + // persisted session `custom` map (agent-core `CreateSessionPayload`) + // and is forwarded verbatim onto the wire session's `metadata` by + // kap-server. Forked sessions inherit `custom` (minus `goal`), so + // the tag propagates to forks — it marks "created via ACP", not + // "currently driven by an ACP client" (`session/resume` never tags). + metadata: { + source: 'acp', + ...(this.clientInfo ? { acp_client: this.clientInfo.name } : {}), + }, // @ts-expect-error — `mcpServers` is a kernel-side extension // (agent-core `CreateSessionPayload`) the SDK transparently // forwards via spread. See block comment above. diff --git a/packages/acp-adapter/test/session-new.test.ts b/packages/acp-adapter/test/session-new.test.ts index 61220b462b..56d2636ff4 100644 --- a/packages/acp-adapter/test/session-new.test.ts +++ b/packages/acp-adapter/test/session-new.test.ts @@ -46,7 +46,12 @@ function makeInMemoryStreamPair(): { } interface CapturedCall { - options: { id?: string; workDir: string; mcpServers?: Record }; + options: { + id?: string; + workDir: string; + mcpServers?: Record; + metadata?: Record; + }; } function makeHarness( @@ -157,6 +162,42 @@ describe('AcpServer session/new', () => { expect(captured[1]?.options.id).toBe(second.sessionId); }); + it('tags created sessions as ACP-sourced via metadata, carrying clientInfo name when advertised', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-tagged', captured); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await client.initialize({ + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, + clientInfo: { name: 'kitty', version: '1.2.3' }, + }); + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + // The kernel persists `metadata` into the session's `custom` map; + // kap-server forwards it onto the wire session so surfaces like + // kimi-web can hide ACP-created workspaces. + expect(captured).toHaveLength(1); + expect(captured[0]?.options.metadata).toEqual({ source: 'acp', acp_client: 'kitty' }); + }); + + it('tags metadata with source only when the client sent no clientInfo', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-tagged-no-info', captured); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.options.metadata).toEqual({ source: 'acp' }); + }); + it('advertises configOptions (PLAN D11 + Phase 15 thinking toggle) — model + thinking + mode under the unified SessionConfigOption surface', async () => { const captured: CapturedCall[] = []; const { harness } = makeHarness('sess-modes', captured); From 02b5b1794d8d3ed6dc0347ce6bc36e2e95c6c997 Mon Sep 17 00:00:00 2001 From: zhiyuan1i Date: Sat, 18 Jul 2026 21:31:11 +0800 Subject: [PATCH 2/4] fix(web): skip sidebar-hidden sessions during fresh-load auto-select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the ACP-hiding preference on, the most recent session is often a bot-driven ACP session that the sidebar does not render; fresh load still auto-selected it, leaving the user on an invisible session. useWorkspaceState now takes an optional isSessionHiddenFromList dep (the facade wires it to the hide-ACP preference) and both the workspace pick and the session auto-select skip hidden sessions. Explicit deep links and notification clicks are unaffected — only the passive landing changes. --- .../composables/client/useWorkspaceState.ts | 19 ++++- .../src/composables/useKimiWebClient.ts | 1 + apps/kimi-web/test/workspace-state.test.ts | 78 +++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 5f5cdbfa5b..442325a3d7 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -245,6 +245,13 @@ export interface UseWorkspaceStateDeps { mergedWorkspaces: ComputedRef; /** Sidebar-facing workspaces in the user's (dragged) display order. */ workspacesView: ComputedRef; + /** + * Sessions hidden from the sidebar list by preference (e.g. ACP-created + * sessions when `hideAcpSessions` is on). Fresh-load auto-selection skips + * these so the user never lands on a session the sidebar does not render. + * Defaults to "nothing hidden" when the facade does not wire a preference. + */ + isSessionHiddenFromList?: (session: AppSession) => boolean; status: ComputedRef; workspaceIdForSession: (s: { workspaceId?: string; cwd: string }) => string; savePermissionToStorage: (mode: PermissionMode) => void; @@ -295,6 +302,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta persistSessionProfile, mergedWorkspaces, workspacesView, + isSessionHiddenFromList = () => false, status, workspaceIdForSession, savePermissionToStorage, @@ -878,7 +886,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // First load: pick the workspace of the most-recent session, unless the // user already has a persisted active workspace that still exists. - const mostRecent = sessions[0]; + // Sessions hidden from the sidebar (e.g. ACP-created) are skipped — the + // user should not land on a session the sidebar does not render. + const mostRecent = sessions.find((s) => !isSessionHiddenFromList(s)); const persisted = rawState.activeWorkspaceId; const persistedStillExists = persisted !== null && mergedWorkspaces.value.some((w) => w.id === persisted); @@ -903,8 +913,13 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // Auto-select first session if none selected (also the fallback for a dead // deep link — 'replace' rewrites the URL to the session actually shown). + // Skip sessions hidden from the sidebar; when everything is hidden there + // is simply nothing to auto-select. if (!rawState.activeSessionId && sessions.length > 0) { - await selectSession(sessions[0]!.id, { urlMode: 'replace' }); + const firstVisible = sessions.find((s) => !isSessionHiddenFromList(s)); + if (firstVisible) { + await selectSession(firstVisible.id, { urlMode: 'replace' }); + } } } catch (err) { traceStatus = 'failed'; diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index ae6d188631..1acbd5e0a4 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -2693,6 +2693,7 @@ const workspaceState = useWorkspaceState(rawState, { persistSessionProfile, mergedWorkspaces, workspacesView, + isSessionHiddenFromList: (s: AppSession) => hideAcpSessions.value && isAcpSession(s), status, workspaceIdForSession, savePermissionToStorage, diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 5ea1a8615c..8ed14747b1 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -1258,6 +1258,84 @@ describe('useWorkspaceState — session list loading', () => { expect(deps.pushOperationFailure).toHaveBeenCalledWith('load', error); }); + function createAutoSelectRig( + sessions: AppSession[], + isSessionHiddenFromList?: (s: AppSession) => boolean, + ) { + const state = createState(); + state.sessions = []; + state.activeSessionId = null; + const deps = { + ...createDeps(), + isSessionHiddenFromList, + modelProvider: { loadModels: vi.fn().mockResolvedValue(undefined) }, + initialized: ref(false), + connectIssue: ref(null), + setActiveSessionId: vi.fn((id: string | undefined) => { + state.activeSessionId = id ?? null; + }), + setSessions: vi.fn((next: AppSession[]) => { + state.sessions = next; + }), + workspaceIdForSession: vi.fn( + (session: { workspaceId?: string; cwd: string }) => + state.workspaces.find((item) => item.root === session.cwd)?.id ?? + session.workspaceId ?? + session.cwd, + ), + } as unknown as UseWorkspaceStateDeps; + return { state, workspaceState: useWorkspaceState(state, deps) }; + } + + it('fresh-load auto-select skips sessions hidden from the sidebar list', async () => { + const acp = { + ...createSession(), + id: 'sess_acp', + source: 'acp', + updatedAt: '2026-01-03T00:00:00.000Z', + }; + const human = { + ...createSession(), + id: 'sess_human', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + apiMock.listWorkspaces.mockResolvedValue([workspace('wd_ws', '/workspace', 'Workspace')]); + apiMock.listSessions.mockResolvedValue({ items: [acp, human], hasMore: false }); + const { state, workspaceState } = createAutoSelectRig( + [acp, human], + (s) => s.source === 'acp', + ); + + await workspaceState.load(); + + // The ACP session is the most recent, but hidden — the user must land on + // the session the sidebar actually renders. + expect(state.activeSessionId).toBe('sess_human'); + }); + + it('fresh-load auto-selects nothing when every loaded session is hidden', async () => { + const acp = { ...createSession(), id: 'sess_acp', source: 'acp' }; + apiMock.listWorkspaces.mockResolvedValue([workspace('wd_ws', '/workspace', 'Workspace')]); + apiMock.listSessions.mockResolvedValue({ items: [acp], hasMore: false }); + const { state, workspaceState } = createAutoSelectRig([acp], (s) => s.source === 'acp'); + + await workspaceState.load(); + + expect(state.activeSessionId).toBeNull(); + }); + + it('fresh-load auto-select keeps picking the most recent session when nothing is hidden', async () => { + const first = { ...createSession(), id: 'sess_new', updatedAt: '2026-01-03T00:00:00.000Z' }; + const second = { ...createSession(), id: 'sess_old', updatedAt: '2026-01-02T00:00:00.000Z' }; + apiMock.listWorkspaces.mockResolvedValue([workspace('wd_ws', '/workspace', 'Workspace')]); + apiMock.listSessions.mockResolvedValue({ items: [first, second], hasMore: false }); + const { state, workspaceState } = createAutoSelectRig([first, second]); + + await workspaceState.load(); + + expect(state.activeSessionId).toBe('sess_new'); + }); + it('keeps failed workspace sessions while replacing a successful shared-root workspace', async () => { const error = new Error('legacy workspace unavailable'); const cached = { From cd4a8f0a5a58519c5f0c43a8f7b4475db7bb1ff1 Mon Sep 17 00:00:00 2001 From: zhiyuan1i Date: Sat, 18 Jul 2026 21:35:09 +0800 Subject: [PATCH 3/4] fix(web): skip sidebar-hidden sessions in the remaining pick-next paths Follow-up to the fresh-load fix: openWorkspace (clicking a mixed workspace), the popstate dead-link fallback, and the post-archive successor pick all used rawState.sessions[0] and could land on a session the sidebar hides. They now share the same isSessionHiddenFromList predicate; openWorkspace with only hidden sessions left falls back to the empty composer, matching the empty-workspace behavior. --- .../composables/client/useWorkspaceState.ts | 12 ++++-- apps/kimi-web/test/workspace-state.test.ts | 39 ++++++++++++++----- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 442325a3d7..5bb0fa82f2 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -980,10 +980,15 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta selectWorkspace(id); const sessionsInWs = rawState.sessions.filter((s) => workspaceIdForSession(s) === id); if (sessionsInWs.length > 0) { - const mostRecent = sessionsInWs[0]; + // Skip sessions hidden from the sidebar (e.g. ACP-created) — clicking a + // workspace must not land on a session the sidebar does not render. + const mostRecent = sessionsInWs.find((s) => !isSessionHiddenFromList(s)); if (mostRecent && mostRecent.id !== rawState.activeSessionId) { // One user action (clicking the workspace) = one history entry. void selectSession(mostRecent.id); + } else if (!mostRecent) { + setActiveSessionId(undefined); + writeSessionUrl(undefined, 'push'); } } else { setActiveSessionId(undefined); @@ -1372,7 +1377,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta await selectSession(id, { urlMode: 'none' }); return; } - const next = rawState.sessions[0]; + const next = rawState.sessions.find((s) => !isSessionHiddenFromList(s)); if (next) { await selectSession(next.id, { urlMode: 'replace' }); } else { @@ -2385,8 +2390,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // If archived session was active, pick another. 'replace' so the address // bar doesn't keep pointing at (and back doesn't return to) a dead session. + // Skip sessions hidden from the sidebar (e.g. ACP-created). if (rawState.activeSessionId === id) { - const next = rawState.sessions[0]; + const next = rawState.sessions.find((s) => !isSessionHiddenFromList(s)); if (next) { await selectSession(next.id, { urlMode: 'replace' }); } else { diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 8ed14747b1..9235ac7376 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -1258,10 +1258,7 @@ describe('useWorkspaceState — session list loading', () => { expect(deps.pushOperationFailure).toHaveBeenCalledWith('load', error); }); - function createAutoSelectRig( - sessions: AppSession[], - isSessionHiddenFromList?: (s: AppSession) => boolean, - ) { + function createAutoSelectRig(isSessionHiddenFromList?: (s: AppSession) => boolean) { const state = createState(); state.sessions = []; state.activeSessionId = null; @@ -1301,10 +1298,7 @@ describe('useWorkspaceState — session list loading', () => { }; apiMock.listWorkspaces.mockResolvedValue([workspace('wd_ws', '/workspace', 'Workspace')]); apiMock.listSessions.mockResolvedValue({ items: [acp, human], hasMore: false }); - const { state, workspaceState } = createAutoSelectRig( - [acp, human], - (s) => s.source === 'acp', - ); + const { state, workspaceState } = createAutoSelectRig((s) => s.source === 'acp'); await workspaceState.load(); @@ -1317,7 +1311,7 @@ describe('useWorkspaceState — session list loading', () => { const acp = { ...createSession(), id: 'sess_acp', source: 'acp' }; apiMock.listWorkspaces.mockResolvedValue([workspace('wd_ws', '/workspace', 'Workspace')]); apiMock.listSessions.mockResolvedValue({ items: [acp], hasMore: false }); - const { state, workspaceState } = createAutoSelectRig([acp], (s) => s.source === 'acp'); + const { state, workspaceState } = createAutoSelectRig((s) => s.source === 'acp'); await workspaceState.load(); @@ -1329,13 +1323,38 @@ describe('useWorkspaceState — session list loading', () => { const second = { ...createSession(), id: 'sess_old', updatedAt: '2026-01-02T00:00:00.000Z' }; apiMock.listWorkspaces.mockResolvedValue([workspace('wd_ws', '/workspace', 'Workspace')]); apiMock.listSessions.mockResolvedValue({ items: [first, second], hasMore: false }); - const { state, workspaceState } = createAutoSelectRig([first, second]); + const { state, workspaceState } = createAutoSelectRig(); await workspaceState.load(); expect(state.activeSessionId).toBe('sess_new'); }); + it('openWorkspace activates the most recent sidebar-visible session in a mixed workspace', () => { + const acp = { + ...createSession(), + id: 'sess_acp', + source: 'acp', + workspaceId: 'wd_ws', + updatedAt: '2026-01-03T00:00:00.000Z', + }; + const human = { + ...createSession(), + id: 'sess_human', + workspaceId: 'wd_ws', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + const { state, workspaceState } = createAutoSelectRig((s) => s.source === 'acp'); + state.sessions = [acp, human]; + + workspaceState.openWorkspace('wd_ws'); + + // The ACP session is more recent but hidden; selectSession runs its + // synchronous activation before its first await, so the human session + // must already be active here. + expect(state.activeSessionId).toBe('sess_human'); + }); + it('keeps failed workspace sessions while replacing a successful shared-root workspace', async () => { const error = new Error('legacy workspace unavailable'); const cached = { From 7d66cc0afd61d8987aeb87ac33ecc509dae02bb0 Mon Sep 17 00:00:00 2001 From: zhiyuan1i Date: Sat, 18 Jul 2026 21:38:36 +0800 Subject: [PATCH 4/4] fix(web): skip hidden sessions in handleSessionNotFound's successor pick Last rawState.sessions[0] pick-next path: when the daemon reports the active session as gone, the fallback could land on a sidebar-hidden (ACP-created) session. Now shares the same hide-ACP predicate as the other pick-next paths. --- apps/kimi-web/src/composables/client/useWorkspaceState.ts | 3 ++- apps/kimi-web/src/composables/useKimiWebClient.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 5bb0fa82f2..0607b3ee59 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1371,7 +1371,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } // A history entry can point at a session that has since been deleted (or one // outside the loaded page): try to fetch it; on failure fall back to the most - // recent session and FIX the URL so the bad entry doesn't stick around. + // recent sidebar-visible session (skipping hidden ones, e.g. ACP-created) + // and FIX the URL so the bad entry doesn't stick around. void (async () => { if (await fetchSessionIntoList(id)) { await selectSession(id, { urlMode: 'none' }); diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 1acbd5e0a4..f1f1528877 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -1387,7 +1387,9 @@ async function handleSessionNotFound(sessionId: string): Promise { if (rawState.activeSessionId !== sessionId) return; - const next = rawState.sessions[0]; + // Skip sessions hidden from the sidebar (e.g. ACP-created), matching the + // other pick-next paths in useWorkspaceState. + const next = rawState.sessions.find((s) => !(hideAcpSessions.value && isAcpSession(s))); if (next) { await workspaceState.selectSession(next.id, { urlMode: 'replace' }); } else {