From 980ff9d48ae92d63ac58e82dc674bf10c5e8b6ff Mon Sep 17 00:00:00 2001 From: qer Date: Sat, 13 Jun 2026 12:03:24 +0800 Subject: [PATCH] fix(web): hide the sending moon once the first reply token streams sendingBySession only cleared on turn end / error, so the in-flight moon lingered beside the streaming reply for the whole turn. Clear it on the first assistantDelta (thinking/text token) or messageUpdated (a turn that opens with a tool use), matching the moon's intended 'before the reply starts' semantics. --- .../src/composables/useKimiWebClient.ts | 15 +++ apps/kimi-web/test/sending-moon.test.ts | 113 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 apps/kimi-web/test/sending-moon.test.ts diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 5c6d390d1..bf411f525 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -509,6 +509,21 @@ function connectEventsIfNeeded(): void { // is kept, only a marker line records the compaction). applyEvent(appEvent, meta.sessionId, meta.seq); + // The "sending" moon is an in-flight placeholder for the dead air BEFORE + // the reply streams. Clear it the instant the assistant produces anything + // — the first thinking/text token (assistantDelta) or a tool-use the turn + // opens with (messageUpdated) — so the moon yields to the live stream + // instead of lingering beside it until the turn ends. + if ( + (appEvent.type === 'assistantDelta' || appEvent.type === 'messageUpdated') && + rawState.sendingBySession[appEvent.sessionId] + ) { + rawState.sendingBySession = { + ...rawState.sendingBySession, + [appEvent.sessionId]: false, + }; + } + // Turn-end cleanup for the session the event belongs to — including // sessions running in the background (see onSessionIdle). if ( diff --git a/apps/kimi-web/test/sending-moon.test.ts b/apps/kimi-web/test/sending-moon.test.ts new file mode 100644 index 000000000..7950a87e5 --- /dev/null +++ b/apps/kimi-web/test/sending-moon.test.ts @@ -0,0 +1,113 @@ +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 session(id: string): 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, + }; +} + +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 = session('sess_1'); + const api = { + createSession: vi.fn(async () => created), + getSessionSnapshot: vi.fn(async () => ({ + asOfSeq: 0, + epoch: 'ep_test', + session: created, + messages: [], + hasMoreMessages: false, + inFlightTurn: null, + pendingApprovals: [], + pendingQuestions: [], + })), + submitPrompt: vi.fn(async () => ({ promptId: 'pr_1', userMessageId: 'msg_real' })), + listTasks: vi.fn(async () => []), + getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {} })), + getSessionStatus: vi.fn(async () => ({ + model: 'kimi-test', + thinkingLevel: 'high', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 128_000, + contextUsage: 0, + })), + connectEvents: vi.fn((next: KimiEventHandlers) => { + handlers = next; + 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 { + client: useKimiWebClient(), + getHandlers: () => { + if (!handlers) throw new Error('connectEvents not called'); + return handlers; + }, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + vi.clearAllMocks(); +}); + +describe('sending moon placeholder', () => { + it('clears on the first streamed token instead of lingering until turn end', async () => { + const { client, getHandlers } = await setup(); + await client.createSession('/repo'); + await client.sendPrompt('hello'); + expect(client.isSending.value).toBe(true); + + // First token of the reply arrives. + getHandlers().onEvent( + { + type: 'assistantDelta', + sessionId: 'sess_1', + messageId: 'm1', + contentIndex: 0, + delta: { text: 'Hi' }, + }, + { sessionId: 'sess_1', seq: 5 }, + ); + + expect(client.isSending.value).toBe(false); + }); +});