mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-26 00:54:36 +00:00
feat(kimi-web): defer session creation until first message
Clicking '+' now clears activeSessionId instead of eagerly creating a backend session. The onboarding composer is shown immediately. Sending a message without an active session creates it and submits the prompt in one flow via startSessionAndSendPrompt. createSession and createSessionInWorkspace now upsert sessions to avoid duplicates from WebSocket broadcasts.
This commit is contained in:
parent
cef4664e35
commit
75aa63ae70
3 changed files with 302 additions and 15 deletions
|
|
@ -48,10 +48,6 @@ const activeWorkspaceSessionCount = computed<number>(
|
|||
() => client.visibleWorkspace.value?.sessionCount ?? 0,
|
||||
);
|
||||
|
||||
// True when the active workspace has no sessions — drives the centred input
|
||||
// placeholder in ChatPane so the user can start typing immediately.
|
||||
const workspaceEmpty = computed<boolean>(() => activeWorkspaceSessionCount.value === 0);
|
||||
|
||||
// Thinking is on/off (TUI parity — no effort-level cycling). The /thinking
|
||||
// command flips between off and the backend default effort ('high').
|
||||
function nextThinkingLevel(current: ThinkingLevel): ThinkingLevel {
|
||||
|
|
@ -450,24 +446,32 @@ function handleEditQueued(index: number): void {
|
|||
|
||||
async function handleSubmit(payload: { text: string; attachments: { fileId: string }[] }): Promise<void> {
|
||||
const wsId = client.activeWorkspaceId.value;
|
||||
if (wsId && workspaceEmpty.value) {
|
||||
const session = await client.createSessionInWorkspace(wsId);
|
||||
if (session === undefined) return;
|
||||
if (!client.activeSessionId.value && wsId) {
|
||||
await client.startSessionAndSendPrompt(wsId, payload.text, payload.attachments);
|
||||
return;
|
||||
}
|
||||
void client.sendPrompt(payload.text, payload.attachments);
|
||||
}
|
||||
|
||||
// Primary "+ New": one-click session in the active workspace. If there is no
|
||||
// active workspace yet (no sessions / no folder added), fall back to the cwd
|
||||
// dialog so the user can still start somewhere.
|
||||
// Primary "+ New": clear the active session so the right pane shows the
|
||||
// onboarding composer. The session is only created when the user sends the
|
||||
// first message.
|
||||
function handleCreateSession(): void {
|
||||
const wsId = client.activeWorkspaceId.value;
|
||||
if (wsId) {
|
||||
void client.createSessionInWorkspace(wsId);
|
||||
client.clearActiveSession();
|
||||
} else {
|
||||
showNewSession.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace-level "+ New" (sidebar group or mobile switcher): switch to the
|
||||
// workspace and show the onboarding composer. No backend session is created
|
||||
// until the user actually sends a message.
|
||||
function handleCreateSessionInWorkspace(workspaceId: string): void {
|
||||
client.selectWorkspace(workspaceId);
|
||||
client.clearActiveSession();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -493,7 +497,7 @@ function handleCreateSession(): void {
|
|||
:accent="client.accent.value"
|
||||
@select="client.selectSession($event)"
|
||||
@create="handleCreateSession"
|
||||
@create-in-workspace="client.createSessionInWorkspace($event)"
|
||||
@create-in-workspace="handleCreateSessionInWorkspace($event)"
|
||||
@select-workspace="client.openWorkspace($event)"
|
||||
@add-workspace="showAddWorkspace = true"
|
||||
@rename="(id, title) => client.renameSession(id, title)"
|
||||
|
|
@ -750,7 +754,7 @@ function handleCreateSession(): void {
|
|||
:attention-by-session="client.attentionBySession.value"
|
||||
:attention-by-workspace="client.attentionByWorkspace.value"
|
||||
@select="client.selectSession($event)"
|
||||
@create-in-workspace="client.createSessionInWorkspace($event)"
|
||||
@create-in-workspace="handleCreateSessionInWorkspace($event)"
|
||||
@add-workspace="showAddWorkspace = true"
|
||||
@rename="(id, title) => client.renameSession(id, title)"
|
||||
@delete="(id) => client.deleteSession(id)"
|
||||
|
|
|
|||
|
|
@ -1483,6 +1483,12 @@ function openWorkspace(id: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
/** Clear the active session without creating a new one — used by the "+" button. */
|
||||
function clearActiveSession(): void {
|
||||
rawState.activeSessionId = undefined;
|
||||
writeSessionUrl(undefined, 'push');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session in a workspace — the one-click path (no cwd typing).
|
||||
* Register/touch the workspace first when the daemon supports it; if that
|
||||
|
|
@ -1509,7 +1515,7 @@ async function createSessionInWorkspace(workspaceId: string): Promise<AppSession
|
|||
// metadata.cwd only.
|
||||
}
|
||||
const session = await api.createSession({ workspaceId: workspaceIdForCreate, cwd: cwdForCreate });
|
||||
rawState.sessions = [session, ...rawState.sessions];
|
||||
rawState.sessions = [session, ...rawState.sessions.filter((s) => s.id !== session.id)];
|
||||
selectWorkspace(session.workspaceId ?? workspaceIdForCreate ?? workspaceId);
|
||||
await selectSession(session.id);
|
||||
return session;
|
||||
|
|
@ -1519,6 +1525,43 @@ async function createSessionInWorkspace(workspaceId: string): Promise<AppSession
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session and immediately submit the first prompt.
|
||||
* This is the unified path when there is no active session (e.g. after
|
||||
* clicking "+" or in an empty workspace).
|
||||
*/
|
||||
async function startSessionAndSendPrompt(
|
||||
workspaceId: string,
|
||||
text: string,
|
||||
attachments?: { fileId: string }[],
|
||||
): Promise<void> {
|
||||
const ws = mergedWorkspaces.value.find((w) => w.id === workspaceId);
|
||||
if (!ws) return;
|
||||
try {
|
||||
const api = getKimiWebApi();
|
||||
let workspaceIdForCreate: string | undefined;
|
||||
let cwdForCreate = ws.root;
|
||||
try {
|
||||
const registered = await api.addWorkspace({ root: ws.root });
|
||||
workspaceIdForCreate = registered.id;
|
||||
cwdForCreate = registered.root;
|
||||
rawState.workspaces = [
|
||||
registered,
|
||||
...rawState.workspaces.filter((w) => w.root !== registered.root && w.root !== ws.root),
|
||||
];
|
||||
} catch {
|
||||
// Older daemons may not have /workspaces.
|
||||
}
|
||||
const session = await api.createSession({ workspaceId: workspaceIdForCreate, cwd: cwdForCreate });
|
||||
rawState.sessions = [session, ...rawState.sessions.filter((s) => s.id !== session.id)];
|
||||
selectWorkspace(session.workspaceId ?? workspaceIdForCreate ?? workspaceId);
|
||||
await selectSession(session.id);
|
||||
await submitPromptInternal(session.id, text, attachments);
|
||||
} catch (err) {
|
||||
rawState.warnings = [...rawState.warnings, `startSessionAndSendPrompt failed: ${String(err)}`];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a workspace by folder path. Tries the daemon registry; on failure (or in
|
||||
* fallback mode) creates a locally-derived workspace from the path and
|
||||
|
|
@ -1698,7 +1741,7 @@ async function createSession(cwd: string, opts?: { title?: string; model?: strin
|
|||
try {
|
||||
const api = getKimiWebApi();
|
||||
const session = await api.createSession({ cwd, title: opts?.title, model: opts?.model });
|
||||
rawState.sessions = [session, ...rawState.sessions];
|
||||
rawState.sessions = [session, ...rawState.sessions.filter((s) => s.id !== session.id)];
|
||||
await selectSession(session.id);
|
||||
} catch (err) {
|
||||
rawState.warnings = [...rawState.warnings, `createSession failed: ${String(err)}`];
|
||||
|
|
@ -2565,7 +2608,9 @@ export function useKimiWebClient() {
|
|||
loadWorkspaces,
|
||||
selectWorkspace,
|
||||
openWorkspace,
|
||||
clearActiveSession,
|
||||
createSessionInWorkspace,
|
||||
startSessionAndSendPrompt,
|
||||
addWorkspaceByPath,
|
||||
browseFs,
|
||||
getFsHome,
|
||||
|
|
|
|||
238
apps/kimi-web/test/start-session-and-send.test.ts
Normal file
238
apps/kimi-web/test/start-session-and-send.test.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
// apps/kimi-web/test/start-session-and-send.test.ts
|
||||
//
|
||||
// startSessionAndSendPrompt: when there is no active session (e.g. after clicking
|
||||
// "+"), sending a message should create the session first, then submit the prompt.
|
||||
// The session list must never contain duplicates regardless of whether the REST
|
||||
// create response or the WebSocket sessionCreated broadcast arrives first.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
AppSession,
|
||||
KimiEventHandlers,
|
||||
KimiWebApi,
|
||||
} from '../src/api/types';
|
||||
|
||||
const now = '2026-06-11T00:00:00.000Z';
|
||||
|
||||
function makeSession(id: string, overrides?: Partial<AppSession>): AppSession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'idle',
|
||||
cwd: '/repo',
|
||||
model: 'kimi-test',
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
totalCostUsd: 0,
|
||||
contextTokens: 0,
|
||||
contextLimit: 128_000,
|
||||
turnCount: 0,
|
||||
},
|
||||
messageCount: 0,
|
||||
lastSeq: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('WebSocket', class WebSocket {});
|
||||
|
||||
let handlers: KimiEventHandlers | undefined;
|
||||
const eventConn = {
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
bindNextPromptId: vi.fn(),
|
||||
seedSnapshot: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
const created = makeSession('sess_new');
|
||||
const api = {
|
||||
createSession: vi.fn(async () => created),
|
||||
submitPrompt: vi.fn(async () => ({ promptId: 'pr_1', userMessageId: 'msg_real' })),
|
||||
addWorkspace: vi.fn(async () => ({ id: 'ws_repo', root: '/repo', name: 'repo', isGitRepo: false, sessionCount: 0 })),
|
||||
listWorkspaces: vi.fn(async () => []),
|
||||
getFsHome: vi.fn(async () => ({ path: '/home/user' })),
|
||||
listSessions: vi.fn(async () => ({ items: [], hasMore: false })),
|
||||
getHealth: vi.fn(async () => ({ ok: true })),
|
||||
getMeta: vi.fn(async () => ({ daemonVersion: '0.0.1' })),
|
||||
getSessionStatus: vi.fn(async () => ({
|
||||
model: 'kimi-test',
|
||||
thinkingLevel: 'high',
|
||||
permission: 'manual',
|
||||
planMode: false,
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 128_000,
|
||||
contextUsage: 0,
|
||||
})),
|
||||
getSessionSnapshot: vi.fn(async () => ({
|
||||
asOfSeq: 0,
|
||||
epoch: 'ep_test',
|
||||
session: created,
|
||||
messages: [],
|
||||
hasMoreMessages: false,
|
||||
inFlightTurn: null,
|
||||
pendingApprovals: [],
|
||||
pendingQuestions: [],
|
||||
})),
|
||||
listTasks: vi.fn(async () => []),
|
||||
getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {} })),
|
||||
connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => {
|
||||
handlers = nextHandlers;
|
||||
return eventConn;
|
||||
}),
|
||||
getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`),
|
||||
} as unknown as KimiWebApi;
|
||||
|
||||
vi.doMock('../src/api', () => ({ getKimiWebApi: () => api }));
|
||||
const { useKimiWebClient } = await import('../src/composables/useKimiWebClient');
|
||||
|
||||
return {
|
||||
api,
|
||||
client: useKimiWebClient(),
|
||||
eventConn,
|
||||
getHandlers: () => {
|
||||
if (!handlers) throw new Error('connectEvents was not called');
|
||||
return handlers;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('startSessionAndSendPrompt', () => {
|
||||
it('creates a session then submits the prompt in one flow', async () => {
|
||||
const { api, client } = await setup();
|
||||
await client.addWorkspaceByPath('/repo');
|
||||
|
||||
await client.startSessionAndSendPrompt('ws_repo', 'hello world');
|
||||
|
||||
expect(api.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(api.createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspaceId: 'ws_repo', cwd: '/repo' }),
|
||||
);
|
||||
expect(api.submitPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(api.submitPrompt).toHaveBeenCalledWith(
|
||||
'sess_new',
|
||||
expect.objectContaining({ content: [{ type: 'text', text: 'hello world' }] }),
|
||||
);
|
||||
expect(client.activeSessionId.value).toBe('sess_new');
|
||||
expect(client.sessions.value).toHaveLength(1);
|
||||
expect(client.sessions.value[0]!.id).toBe('sess_new');
|
||||
});
|
||||
|
||||
it('does not duplicate the session when WebSocket broadcast arrives after REST', async () => {
|
||||
const { api, client, getHandlers } = await setup();
|
||||
await client.addWorkspaceByPath('/repo');
|
||||
|
||||
await client.startSessionAndSendPrompt('ws_repo', 'hello');
|
||||
|
||||
// Simulate the late WebSocket sessionCreated broadcast
|
||||
getHandlers().onEvent(
|
||||
{ type: 'sessionCreated', session: makeSession('sess_new') },
|
||||
{ sessionId: 'sess_new', seq: 1 },
|
||||
);
|
||||
|
||||
expect(client.sessions.value).toHaveLength(1);
|
||||
expect(client.sessions.value[0]!.id).toBe('sess_new');
|
||||
});
|
||||
|
||||
it('does not duplicate the session when WebSocket broadcast arrives before REST', async () => {
|
||||
const { client, getHandlers } = await setup();
|
||||
await client.addWorkspaceByPath('/repo');
|
||||
|
||||
// Establish the event connection first
|
||||
await client.startSessionAndSendPrompt('ws_repo', 'first');
|
||||
|
||||
// Broadcast the same session (simulating WS arriving before REST)
|
||||
getHandlers().onEvent(
|
||||
{ type: 'sessionCreated', session: makeSession('sess_new') },
|
||||
{ sessionId: 'sess_new', seq: 1 },
|
||||
);
|
||||
|
||||
// Now REST returns — calling startSessionAndSendPrompt again with the same id.
|
||||
// The upsert filter in the method removes the duplicate.
|
||||
await client.startSessionAndSendPrompt('ws_repo', 'hello');
|
||||
|
||||
expect(client.sessions.value.filter((s) => s.id === 'sess_new')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearActiveSession', () => {
|
||||
it('clears activeSessionId without removing sessions', async () => {
|
||||
const { client } = await setup();
|
||||
await client.addWorkspaceByPath('/repo');
|
||||
await client.createSession('/repo');
|
||||
|
||||
expect(client.activeSessionId.value).toBe('sess_new');
|
||||
expect(client.sessions.value).toHaveLength(1);
|
||||
|
||||
client.clearActiveSession();
|
||||
|
||||
expect(client.activeSessionId.value).toBe('');
|
||||
expect(client.sessions.value).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession dedup', () => {
|
||||
it('createSession does not duplicate when broadcast arrived first', async () => {
|
||||
const { api, client, getHandlers } = await setup();
|
||||
|
||||
// Establish the event connection first
|
||||
await client.createSession('/repo');
|
||||
|
||||
// Now hijack createSession for the race test
|
||||
let resolveCreate!: (s: AppSession) => void;
|
||||
(api.createSession as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => new Promise((r) => { resolveCreate = r; }),
|
||||
);
|
||||
|
||||
const promise = client.createSession('/repo');
|
||||
|
||||
// Broadcast arrives first
|
||||
getHandlers().onEvent(
|
||||
{ type: 'sessionCreated', session: makeSession('sess_new') },
|
||||
{ sessionId: 'sess_new', seq: 1 },
|
||||
);
|
||||
|
||||
resolveCreate(makeSession('sess_new'));
|
||||
await promise;
|
||||
|
||||
// Should still be just the original session (no duplicate)
|
||||
expect(client.sessions.value.filter((s) => s.id === 'sess_new')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSessionInWorkspace dedup', () => {
|
||||
it('createSessionInWorkspace does not duplicate when broadcast arrived first', async () => {
|
||||
const { api, client, getHandlers } = await setup();
|
||||
await client.addWorkspaceByPath('/repo');
|
||||
|
||||
// Establish the event connection first
|
||||
await client.createSessionInWorkspace('ws_repo');
|
||||
|
||||
// Broadcast the same session (simulating WS arriving before REST)
|
||||
getHandlers().onEvent(
|
||||
{ type: 'sessionCreated', session: makeSession('sess_new', { workspaceId: 'ws_repo' }) },
|
||||
{ sessionId: 'sess_new', seq: 1 },
|
||||
);
|
||||
|
||||
// Now REST returns — calling createSessionInWorkspace again with the same id.
|
||||
// The upsert filter in the method removes the duplicate.
|
||||
await client.createSessionInWorkspace('ws_repo');
|
||||
|
||||
// Should still be just the original session (no duplicate)
|
||||
expect(client.sessions.value.filter((s) => s.id === 'sess_new')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue