mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
cf558cd742
commit
dfcfdfd9dd
19 changed files with 115 additions and 439 deletions
5
.changeset/add-session-list-exclude-empty.md
Normal file
5
.changeset/add-session-list-exclude-empty.md
Normal 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.
|
||||
5
.changeset/web-hide-empty-sessions.md
Normal file
5
.changeset/web-hide-empty-sessions.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Hide unused "New Session" entries from the web session list by default.
|
||||
5
.changeset/web-remove-sessions-command.md
Normal file
5
.changeset/web-remove-sessions-command.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
export default {
|
||||
help: { desc: '显示可用命令列表' },
|
||||
new: { desc: '创建新会话' },
|
||||
sessions: { desc: '浏览/切换会话' },
|
||||
clear: { desc: '清空并新建会话' },
|
||||
model: { desc: '切换模型' },
|
||||
provider: { desc: '管理提供商 (添加/删除/刷新)' },
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
export default {
|
||||
title: '会话',
|
||||
close: '关闭',
|
||||
searchPlaceholder: '按标题搜索会话…',
|
||||
noWorkspace: '无工作区',
|
||||
emptyNone: '暂无会话',
|
||||
emptyNoMatch: '没有匹配的会话',
|
||||
footerHint: '↑↓ 切换 · Enter 进入 · Esc 关闭',
|
||||
justNow: '刚刚',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue