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:
qer 2026-06-11 15:29:11 +08:00
parent cef4664e35
commit 75aa63ae70
3 changed files with 302 additions and 15 deletions

View file

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

View file

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

View 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);
});
});