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.
This commit is contained in:
qer 2026-06-13 12:03:24 +08:00
parent 176d3d874c
commit 980ff9d48a
2 changed files with 128 additions and 0 deletions

View file

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

View file

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