feat(web): hide empty sessions from the session list (#1166)

* chore(web): remove the /sessions slash command

* feat(web): hide empty sessions from the session list

Add an optional exclude_empty parameter to the session list API; the web client passes it so unused "New Session" entries are hidden by default, with pagination and has_more computed on the filtered set.

* fix(protocol): add exclude_empty to the session list query schema

Keep the shared protocol schema in sync with the server route so clients using the protocol type see the new parameter.

* fix(protocol): keep exclude_empty off the child session list schema

listSessionChildrenQuerySchema aliased the main list schema, so it inherited exclude_empty even though the /sessions/{id}/children route does not filter by it. Split it so generated clients are not misled.
This commit is contained in:
qer 2026-06-28 13:38:46 +08:00 committed by GitHub
parent cf558cd742
commit dfcfdfd9dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 115 additions and 439 deletions

View file

@ -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.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Hide unused "New Session" entries from the web session list by default.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Remove the /sessions slash command from the web UI; the sidebar already covers session browsing.

View file

@ -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<boolean>(() =>
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"
/>
<!-- Sessions browser overlay (/sessions) client-side list, click to switch -->
<SessionsDialog
v-if="showSessions"
:sessions="client.sessions.value"
:workspace-groups="client.workspaceGroups.value"
:attention-by-session="client.attentionBySession.value"
:active-id="client.activeSessionId.value"
@select="(id) => { void client.selectSession(id); showSessions = false; }"
@close="showSessions = false"
/>
<!-- Status panel overlay (/status) renders current client state, no daemon call -->
<StatusPanel
v-if="showStatusPanel"

View file

@ -286,7 +286,12 @@ export class DaemonKimiWebApi implements KimiWebApi {
// -------------------------------------------------------------------------
async listSessions(
input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean },
input?: PageRequest & {
status?: AppSessionStatus;
workspaceId?: string;
includeArchive?: boolean;
excludeEmpty?: boolean;
},
): Promise<Page<AppSession>> {
const query: Record<string, string | number | boolean | undefined> = {
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,

View file

@ -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<string, boolean>; openInApps: string[] }>;
listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean }): Promise<Page<AppSession>>;
listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; excludeEmpty?: boolean }): Promise<Page<AppSession>>;
createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise<AppSession>;
/** Fetch one session by id (deep links beyond the first listSessions page). */
getSession(sessionId: string): Promise<AppSession>;

View file

@ -1,394 +0,0 @@
<!-- apps/kimi-web/src/components/dialogs/SessionsDialog.vue -->
<!-- Session browser popup: lists ALL client-side sessions, searchable by title, -->
<!-- click to switch. Reuses the composable's sessions / workspaceGroups / -->
<!-- attentionBySession view data no daemon call. -->
<!-- Light only, monospace-forward, Kimi blue #1565C0, no emoji. -->
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Session, WorkspaceGroup } from '../../types';
const { t } = useI18n();
const props = defineProps<{
/** Every session the client knows about (flat, already view-mapped). */
sessions: Session[];
/** Workspace groups — used only to label each row with its workspace name. */
workspaceGroups: WorkspaceGroup[];
/** Per-session pending-attention count (approvals + questions). */
attentionBySession: Record<string, number>;
/** The currently-active session id, highlighted in the list. */
activeId?: string;
}>();
const emit = defineEmits<{
select: [id: string];
close: [];
}>();
// ---------------------------------------------------------------------------
// session id -> workspace name lookup, derived from the groups
// ---------------------------------------------------------------------------
const workspaceNameBySession = computed<Record<string, string>>(() => {
const out: Record<string, string> = {};
for (const group of props.workspaceGroups) {
for (const s of group.sessions) {
out[s.id] = group.workspace.name;
}
}
return out;
});
// ---------------------------------------------------------------------------
// Search (filters by title, case-insensitive)
// ---------------------------------------------------------------------------
const query = ref('');
const searchRef = ref<HTMLInputElement | null>(null);
const filtered = computed<Session[]>(() => {
const q = query.value.toLowerCase().trim();
if (!q) return props.sessions;
return props.sessions.filter((s) => s.title.toLowerCase().includes(q));
});
const selectedIdx = ref(0);
watch(query, () => { selectedIdx.value = 0; });
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
function choose(id: string): void {
emit('select', id);
}
function attentionFor(id: string): number {
return props.attentionBySession[id] ?? 0;
}
/** Status dot colour: amber when something needs the user (pending items or an
awaiting status), green while busy, red for an interrupted session, else grey. */
function dotClass(s: Session): 'run' | 'attn' | 'aborted' | 'idle' {
if (attentionFor(s.id) > 0 || s.status === 'awaitingApproval' || s.status === 'awaitingQuestion') return 'attn';
if (s.busy) return 'run';
if (s.status === 'aborted') return 'aborted';
return 'idle';
}
// ---------------------------------------------------------------------------
// Keyboard navigation
// ---------------------------------------------------------------------------
function handleKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
emit('close');
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIdx.value = Math.min(selectedIdx.value + 1, filtered.value.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIdx.value = Math.max(selectedIdx.value - 1, 0);
} else if (e.key === 'Enter') {
const s = filtered.value[selectedIdx.value];
if (s) choose(s.id);
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
nextTick(() => searchRef.value?.focus());
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
});
</script>
<template>
<!-- Backdrop -->
<div class="backdrop" @click.self="emit('close')">
<!-- Dialog -->
<div class="dialog" role="dialog" :aria-label="t('sessions.title')">
<!-- Header -->
<div class="dh">
<span class="dtitle">{{ t('sessions.title') }}</span>
<span class="count">{{ sessions.length }}</span>
<button class="close-btn" :aria-label="t('sessions.close')" :title="t('sessions.close')" @click="emit('close')">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5">
<line x1="1" y1="1" x2="9" y2="9"/><line x1="9" y1="1" x2="1" y2="9"/>
</svg>
</button>
</div>
<!-- Search -->
<div class="search-wrap">
<input
ref="searchRef"
v-model="query"
class="search-input"
type="text"
:placeholder="t('sessions.searchPlaceholder')"
autocomplete="off"
spellcheck="false"
/>
</div>
<!-- Session list -->
<div class="session-list">
<div
v-for="(s, i) in filtered"
:key="s.id"
class="session-row"
:class="{
'is-active': s.id === activeId,
'is-selected': i === selectedIdx,
}"
role="option"
:aria-selected="s.id === activeId"
@click="choose(s.id)"
@mouseenter="selectedIdx = i"
>
<!-- Status dot: busy=green, awaiting/attention=amber, aborted=red,
idle=grey. -->
<span class="dot" :class="dotClass(s)" />
<div class="meta">
<span class="title">{{ s.title }}</span>
<span class="sub">
<span class="ws">{{ workspaceNameBySession[s.id] ?? t('sessions.noWorkspace') }}</span>
<span class="sep">·</span>
<span class="time">{{ s.time }}</span>
</span>
</div>
<span v-if="attentionFor(s.id) > 0" class="attn-badge">{{ attentionFor(s.id) }}</span>
</div>
<div v-if="filtered.length === 0" class="empty">
{{ sessions.length === 0 ? t('sessions.emptyNone') : t('sessions.emptyNoMatch') }}
</div>
</div>
<!-- Footer hint -->
<div class="footer-hint">{{ t('sessions.footerHint') }}</div>
</div>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(20, 23, 28, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.dialog {
background: var(--bg);
border: 1px solid var(--line);
border-top: 2px solid var(--blue);
border-radius: 4px;
width: 540px;
max-width: calc(100vw - 32px);
height: 520px;
max-height: calc(100vh - 80px);
display: flex;
flex-direction: column;
font-family: var(--mono);
box-shadow: 0 8px 32px rgba(0,0,0,0.14);
}
/* Header */
.dh {
display: flex;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
background: var(--panel);
gap: 8px;
}
.dtitle {
font-size: calc(var(--ui-font-size) - 1.5px);
font-weight: 700;
color: var(--ink);
letter-spacing: 0.02em;
}
.count {
font-size: max(9px, calc(var(--ui-font-size) - 3.5px));
color: var(--muted);
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 9px;
padding: 0 7px;
line-height: 1.6;
flex: 1;
flex-grow: 0;
}
.close-btn {
background: none;
border: none;
color: var(--faint);
cursor: pointer;
padding: 4px;
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover { color: var(--ink); }
/* Search */
.search-wrap {
padding: 8px 12px;
border-bottom: 1px solid var(--line2);
}
.search-input {
width: 100%;
box-sizing: border-box;
font-family: var(--mono);
font-size: calc(var(--ui-font-size) - 1.5px);
padding: 5px 8px;
border: 1px solid var(--line);
border-radius: 3px;
background: var(--panel);
color: var(--ink);
outline: none;
}
.search-input:focus-visible {
border-color: var(--blue);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--blue) 25%, transparent);
}
/* Session list */
.session-list {
overflow-y: auto;
flex: 1;
padding: 4px 0;
min-height: 80px;
}
.session-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 14px;
cursor: pointer;
color: var(--text);
}
.session-row:hover, .session-row.is-selected {
background: var(--soft);
}
.session-row.is-active {
background: var(--bluebg);
}
/* Status dot */
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
background: var(--faint);
}
.dot.run { background: var(--ok); }
.dot.idle { background: var(--faint); }
.dot.attn { background: var(--warn); }
.dot.aborted { background: var(--err); }
.meta {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.title {
font-size: calc(var(--ui-font-size) - 1.5px);
font-weight: 500;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
display: flex;
align-items: center;
gap: 5px;
font-size: max(9px, calc(var(--ui-font-size) - 3.5px));
color: var(--muted);
min-width: 0;
}
.ws {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
}
.sep { color: var(--faint); flex: none; }
.time { flex: none; }
.attn-badge {
flex: none;
font-size: max(9px, calc(var(--ui-font-size) - 4px));
font-weight: 700;
color: var(--bg);
background: var(--warn);
border-radius: 9px;
min-width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
}
.empty {
padding: 24px 14px;
text-align: center;
color: var(--muted);
font-size: var(--ui-font-size);
}
/* Footer */
.footer-hint {
padding: 6px 14px;
font-size: max(9px, calc(var(--ui-font-size) - 3.5px));
color: var(--faint);
border-top: 1px solid var(--line2);
background: var(--panel);
border-radius: 0 0 4px 4px;
}
@media (max-width: 640px) {
.backdrop {
align-items: stretch;
padding:
max(12px, env(safe-area-inset-top))
max(12px, env(safe-area-inset-right))
max(12px, env(safe-area-inset-bottom))
max(12px, env(safe-area-inset-left));
}
.dialog {
width: 100%;
max-width: none;
height: auto;
max-height: calc(100dvh - 24px);
}
.dh {
min-height: 44px;
}
.session-row {
min-height: 48px;
padding: 8px 12px;
}
.sub {
flex-wrap: wrap;
row-gap: 2px;
}
.ws {
max-width: 100%;
flex: 1 1 100%;
}
}
</style>

View file

@ -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.

View file

@ -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)' },

View file

@ -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;

View file

@ -1,7 +1,6 @@
export default {
help: { desc: '显示可用命令列表' },
new: { desc: '创建新会话' },
sessions: { desc: '浏览/切换会话' },
clear: { desc: '清空并新建会话' },
model: { desc: '切换模型' },
provider: { desc: '管理提供商 (添加/删除/刷新)' },

View file

@ -1,10 +1,3 @@
export default {
title: '会话',
close: '关闭',
searchPlaceholder: '按标题搜索会话…',
noWorkspace: '无工作区',
emptyNone: '暂无会话',
emptyNoMatch: '没有匹配的会话',
footerHint: '↑↓ 切换 · Enter 进入 · Esc 关闭',
justNow: '刚刚',
};

View file

@ -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' },

View file

@ -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 {

View file

@ -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;

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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<typeof listSessionsQuerySchema>;
@ -85,7 +86,14 @@ export const startBtwSessionResponseSchema = z.object({
});
export type StartBtwSessionResponse = z.infer<typeof startBtwSessionResponseSchema>;
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<typeof listSessionChildrenQuerySchema>;
export const listSessionChildrenResponseSchema = pageResponseSchema(sessionSchema);

View file

@ -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) {