Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/feat-hide-acp-workspaces.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/daemon/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ function backToMain(): void {
const filteredArchived = computed<AppSession[]>(() => {
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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose the ACP-session toggle in mobile settings

On mobile, App.vue opens MobileSettingsSheet instead of the desktop SettingsDialog, and this newly added filter is controlled by client.hideAcpSessions, which defaults to true. Because the mobile sheet has no corresponding toggle/event, mobile users cannot turn the filter off to find or restore archived ACP sessions, or reveal hidden ACP workspaces, from the mobile UI; add the same setting row here or pass through the existing setter.

Useful? React with 👍 / 👎.

if (q) rows = rows.filter((s) => s.title.toLowerCase().includes(q));
rows = rows.slice();
if (archiveSort.value === 'archived-desc') {
Expand Down
15 changes: 15 additions & 0 deletions apps/kimi-web/src/components/settings/SettingsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -61,6 +63,7 @@ const emit = defineEmits<{
setNotifyApproval: [on: boolean];
setSound: [on: boolean];
setConversationToc: [on: boolean];
setHideAcpSessions: [on: boolean];
login: [];
logout: [];
openOnboarding: [];
Expand Down Expand Up @@ -280,6 +283,7 @@ const filteredArchived = computed<AppSession[]>(() => {
// 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);
}
Expand Down Expand Up @@ -397,6 +401,17 @@ function archiveTime(iso: string): string {
@update:model-value="emit('setConversationToc', $event)"
/>
</div>
<div class="row">
<span class="rlabel">
{{ t('settings.hideAcpSessions') }}
<span class="hint">{{ t('settings.hideAcpSessionsHint') }}</span>
</span>
<Switch
:model-value="hideAcpSessions ?? true"
:label="t('settings.hideAcpSessions')"
@update:model-value="emit('setHideAcpSessions', $event)"
/>
</div>
</section>

<section class="sec">
Expand Down
34 changes: 28 additions & 6 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,13 @@ export interface UseWorkspaceStateDeps {
mergedWorkspaces: ComputedRef<AppWorkspace[]>;
/** Sidebar-facing workspaces in the user's (dragged) display order. */
workspacesView: ComputedRef<WorkspaceView[]>;
/**
* 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<ConversationStatus>;
workspaceIdForSession: (s: { workspaceId?: string; cwd: string }) => string;
savePermissionToStorage: (mode: PermissionMode) => void;
Expand Down Expand Up @@ -295,6 +302,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
persistSessionProfile,
mergedWorkspaces,
workspacesView,
isSessionHiddenFromList = () => false,
status,
workspaceIdForSession,
savePermissionToStorage,
Expand Down Expand Up @@ -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);
Expand All @@ -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';
Expand Down Expand Up @@ -965,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);
Expand Down Expand Up @@ -1351,13 +1371,14 @@ 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' });
return;
}
const next = rawState.sessions[0];
const next = rawState.sessions.find((s) => !isSessionHiddenFromList(s));
if (next) {
await selectSession(next.id, { urlMode: 'replace' });
} else {
Expand Down Expand Up @@ -2370,8 +2391,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 {
Expand Down
54 changes: 50 additions & 4 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1386,7 +1387,9 @@ async function handleSessionNotFound(sessionId: string): Promise<void> {

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 {
Expand Down Expand Up @@ -2350,6 +2353,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<string[]>(() =>
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
Expand All @@ -2359,8 +2392,10 @@ function workspaceIdForSession(s: { workspaceId?: string; cwd: string }): string
const mergedWorkspaces = computed<AppWorkspace[]>(() =>
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,
}),
);
Expand Down Expand Up @@ -2483,7 +2518,12 @@ const sessionsForView = computed<Session[]>(() => {
// 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)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply ACP filtering to recent workspace ordering

When hideAcpSessions is enabled and a mixed workspace has a newer ACP session plus an older human session, this filter removes the ACP row, but workspacesView still builds its recent-sort lastEditedAt from all rawState.sessions above. The hidden ACP activity can move that workspace to the top of the sidebar, so the setting still leaks/sorts by sessions it claims to hide; apply the same hideAcpSessions && isAcpSession predicate before computing workspace recency.

Useful? React with 👍 / 👎.

)
.map((s) => {
const workspaceId = workspaceIdForSession(s);
return {
Expand All @@ -2508,6 +2548,7 @@ const workspaceGroups = computed<WorkspaceGroup[]>(() => {
(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,
Expand Down Expand Up @@ -2654,6 +2695,7 @@ const workspaceState = useWorkspaceState(rawState, {
persistSessionProfile,
mergedWorkspaces,
workspacesView,
isSessionHiddenFromList: (s: AppSession) => hideAcpSessions.value && isAcpSession(s),
status,
workspaceIdForSession,
savePermissionToStorage,
Expand Down Expand Up @@ -2889,6 +2931,10 @@ export function useKimiWebClient() {
conversationToc,
setConversationToc,

// Hide ACP-created sessions/workspaces from the sidebar
hideAcpSessions,
setHideAcpSessions,

// Color scheme
colorScheme: appearance.colorScheme,
setColorScheme: appearance.setColorScheme,
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/i18n/locales/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/i18n/locales/zh/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export default {
exportLogBtn: '导出日志',
conversationToc: '显示对话目录',
conversationTocHint: '在右侧显示可点击跳转的对话目录',
hideAcpSessions: '隐藏 ACP 会话',
hideAcpSessionsHint: '不在会话列表显示由 ACP 客户端(如自动化 bot)创建的工作区和会话',
archivedTitle: '已归档会话',
archivedDesc: '查看已归档会话,确认其所属工作区路径、会话名称和归档时间,并可恢复到会话列表。',
archivedSearch: '搜索已归档会话',
Expand Down
69 changes: 69 additions & 0 deletions apps/kimi-web/src/lib/acpSessions.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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([]);
});
});
Loading