fix: merge streamed tail text into the previous assistant bubble

Keep late-arriving streamed punctuation attached to the active assistant reply so desktop chat no longer renders the final character as its own bubble.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
hzchat 2026-04-22 17:06:58 +08:00
parent 5a86ab097f
commit 144182db63
2 changed files with 143 additions and 17 deletions

View File

@ -385,6 +385,95 @@ describe('chatStore history mapping', () => {
}) })
}) })
it('merges trailing streamed punctuation into the previous assistant bubble on completion', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [
{
id: 'assistant-1',
type: 'assistant_text',
content: '你好',
timestamp: 1,
},
],
chatState: 'streaming',
connectionState: 'connected',
streamingText: '。',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 1 },
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'assistant_text',
content: '你好。',
},
])
})
it('merges streamed tail text when status changes out of streaming', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [
{
id: 'assistant-1',
type: 'assistant_text',
content: '分析完成',
timestamp: 1,
},
],
chatState: 'streaming',
connectionState: 'connected',
streamingText: '。',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'status',
state: 'idle',
tokens: 3,
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'assistant_text',
content: '分析完成。',
},
])
})
it('flushes the previous assistant draft before starting a new user turn', () => { it('flushes the previous assistant draft before starting a new user turn', () => {
useChatStore.setState({ useChatStore.setState({
sessions: { sessions: {

View File

@ -108,6 +108,29 @@ const pendingTaskToolUseIds = new Set<string>()
let msgCounter = 0 let msgCounter = 0
const nextId = () => `msg-${++msgCounter}-${Date.now()}` const nextId = () => `msg-${++msgCounter}-${Date.now()}`
function appendAssistantTextMessage(messages: UIMessage[], content: string): UIMessage[] {
if (!content) return messages
const last = messages[messages.length - 1]
if (last?.type === 'assistant_text') {
return [
...messages.slice(0, -1),
{
...last,
content: last.content + content,
},
]
}
return [
...messages,
{
id: nextId(),
type: 'assistant_text',
content,
timestamp: Date.now(),
},
]
}
// Streaming throttle for content_delta // Streaming throttle for content_delta
let pendingDelta = '' let pendingDelta = ''
let flushTimer: ReturnType<typeof setTimeout> | null = null let flushTimer: ReturnType<typeof setTimeout> | null = null
@ -218,15 +241,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const pendingAssistantText = `${session.streamingText}${bufferedDelta}`.trim() const pendingAssistantText = `${session.streamingText}${bufferedDelta}`.trim()
const newMessages = pendingAssistantText const newMessages = pendingAssistantText
? [ ? appendAssistantTextMessage(session.messages, pendingAssistantText)
...session.messages,
{
id: nextId(),
type: 'assistant_text' as const,
content: pendingAssistantText,
timestamp: Date.now(),
},
]
: [...session.messages] : [...session.messages]
if (!isMemberSession && allTasksDone) { if (!isMemberSession && allTasksDone) {
newMessages.push({ newMessages.push({
@ -392,6 +407,16 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break break
case 'status': case 'status':
// Flush any pending delta before processing status change
if (flushTimer && msg.state !== 'streaming') {
clearTimeout(flushTimer)
flushTimer = null
}
if (pendingDelta && msg.state !== 'streaming') {
const deltaText = pendingDelta
pendingDelta = ''
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + deltaText })) }))
}
update((session) => { update((session) => {
const pendingText = session.streamingText.trim() const pendingText = session.streamingText.trim()
const shouldFlush = pendingText && session.chatState === 'streaming' && msg.state !== 'streaming' const shouldFlush = pendingText && session.chatState === 'streaming' && msg.state !== 'streaming'
@ -401,7 +426,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
...(shouldFlush ? { ...(shouldFlush ? {
messages: [...session.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }], messages: appendAssistantTextMessage(session.messages, pendingText),
streamingText: '', streamingText: '',
} : {}), } : {}),
} }
@ -423,7 +448,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const pendingText = session.streamingText.trim() const pendingText = session.streamingText.trim()
if (pendingText) { if (pendingText) {
update((s) => ({ update((s) => ({
messages: [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }], messages: appendAssistantTextMessage(s.messages, pendingText),
streamingText: '', streamingText: '',
})) }))
} }
@ -460,7 +485,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
update((s) => { update((s) => {
const pendingText = s.streamingText.trim() const pendingText = s.streamingText.trim()
const base = pendingText const base = pendingText
? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }] ? appendAssistantTextMessage(s.messages, pendingText)
: s.messages : s.messages
const last = base[base.length - 1] const last = base[base.length - 1]
if (last && last.type === 'thinking') { if (last && last.type === 'thinking') {
@ -553,13 +578,25 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break break
case 'message_complete': { case 'message_complete': {
// Flush any pending delta before processing complete
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
if (pendingDelta) {
const deltaText = pendingDelta
pendingDelta = ''
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + deltaText })) }))
}
const session = get().sessions[sessionId] const session = get().sessions[sessionId]
if (!session) break if (!session) break
const text = session.streamingText const text = session.streamingText.trim()
if (text) { if (text) {
update((s) => ({ set((s) => ({
messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }], sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({
streamingText: '', messages: appendAssistantTextMessage(sess.messages, text),
streamingText: '',
})),
})) }))
} }
if (session.elapsedTimer) clearInterval(session.elapsedTimer) if (session.elapsedTimer) clearInterval(session.elapsedTimer)
@ -579,7 +616,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const pendingText = s.streamingText.trim() const pendingText = s.streamingText.trim()
const newMessages = [...s.messages] const newMessages = [...s.messages]
if (pendingText) { if (pendingText) {
newMessages.push({ id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }) newMessages.splice(0, newMessages.length, ...appendAssistantTextMessage(newMessages, pendingText))
} }
newMessages.push({ id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }) newMessages.push({ id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() })
return { return {