Skip to content

Commit c571a93

Browse files
authored
feat(web): improve streaming chat continuity readability (#1617)
* fix: coalesce transient chat status updates * feat(web): improve streaming thinking and tool readability
1 parent c903c79 commit c571a93

5 files changed

Lines changed: 120 additions & 20 deletions

File tree

packages/web/src/components/chat/ChatInterface.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
WorkflowDispatchEvent,
3030
} from '@/lib/types';
3131
import { applyOnText } from '@/lib/chat-message-reducer';
32+
import { applySystemStatus } from '@/lib/system-status-reducer';
3233
import {
3334
getCachedMessages,
3435
setCachedMessages,
@@ -563,15 +564,7 @@ export function ChatInterface({ conversationId }: ChatInterfaceProps): React.Rea
563564
);
564565

565566
const onSystemStatus = useCallback((content: string): void => {
566-
setMessages(prev => [
567-
...prev,
568-
{
569-
id: nextId(),
570-
role: 'system' as const,
571-
content,
572-
timestamp: Date.now(),
573-
},
574-
]);
567+
setMessages(prev => applySystemStatus(prev, content, nextId));
575568
}, []);
576569

577570
const { connected } = useSSE(isNewChat ? null : conversationId, {

packages/web/src/components/chat/MessageBubble.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -222,16 +222,20 @@ function MessageBubbleRaw({ message }: MessageBubbleProps): React.ReactElement {
222222
) : (
223223
<div className="chat-markdown max-w-none text-sm text-text-primary">
224224
{isThinking && (
225-
<div className="flex items-center gap-1.5 py-1">
226-
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary" />
227-
<span
228-
className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary"
229-
style={{ animationDelay: '0.2s' }}
230-
/>
231-
<span
232-
className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary"
233-
style={{ animationDelay: '0.4s' }}
234-
/>
225+
<div className="flex items-center gap-2 py-1 text-sm text-text-tertiary">
226+
<span className="sr-only">Thinking</span>
227+
<span className="font-medium">Thinking</span>
228+
<div className="flex items-center gap-1.5" aria-hidden="true">
229+
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary" />
230+
<span
231+
className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary"
232+
style={{ animationDelay: '0.2s' }}
233+
/>
234+
<span
235+
className="h-1.5 w-1.5 animate-pulse rounded-full bg-text-tertiary"
236+
style={{ animationDelay: '0.4s' }}
237+
/>
238+
</div>
235239
</div>
236240
)}
237241
{isJsonString(message.content) ? (

packages/web/src/components/chat/ToolCallCard.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export function ToolCallCard({ tool }: ToolCallCardProps): React.ReactElement {
3434
const outputLines = tool.output?.split('\n') ?? [];
3535
const isLongOutput = outputLines.length > 20;
3636
const displayOutput = showAllOutput ? tool.output : outputLines.slice(0, 20).join('\n');
37+
const outputPreview = outputLines
38+
.map(line => line.trim())
39+
.find(line => line.length > 0)
40+
?.slice(0, 80);
41+
const statusLabel = isRunning ? 'Running' : tool.output !== undefined ? 'Complete' : 'Done';
3742

3843
return (
3944
<div
@@ -61,7 +66,14 @@ export function ToolCallCard({ tool }: ToolCallCardProps): React.ReactElement {
6166
<Terminal className="h-3.5 w-3.5 shrink-0 text-text-secondary" />
6267
)}
6368
<span className="truncate font-mono text-xs text-text-secondary">{tool.name}</span>
64-
{summaryText && <span className="truncate text-xs text-text-tertiary">{summaryText}</span>}
69+
<span className="shrink-0 rounded-full bg-surface-elevated px-2 py-0.5 text-[10px] text-text-secondary">
70+
{statusLabel}
71+
</span>
72+
{summaryText ? (
73+
<span className="truncate text-xs text-text-tertiary">{summaryText}</span>
74+
) : outputPreview ? (
75+
<span className="truncate text-xs text-text-tertiary">{outputPreview}</span>
76+
) : null}
6577
<span className="ml-auto shrink-0">
6678
{isRunning && elapsed > 0 ? (
6779
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-[10px] text-primary">
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { applySystemStatus } from './system-status-reducer';
3+
import type { ChatMessage } from './types';
4+
5+
let idCounter = 0;
6+
function makeId(): string {
7+
idCounter++;
8+
return `msg-${String(idCounter)}`;
9+
}
10+
11+
const NOW = 1000;
12+
13+
describe('applySystemStatus', () => {
14+
test('appends a new system message when previous message is not system', () => {
15+
const prev: ChatMessage[] = [{ id: 'u1', role: 'user', content: 'hi', timestamp: NOW }];
16+
const result = applySystemStatus(prev, 'Connecting…', makeId, NOW + 1);
17+
18+
expect(result).toHaveLength(2);
19+
expect(result[1]).toEqual({
20+
id: 'msg-1',
21+
role: 'system',
22+
content: 'Connecting…',
23+
timestamp: NOW + 1,
24+
});
25+
});
26+
27+
test('coalesces consecutive system status updates into one row', () => {
28+
const prev: ChatMessage[] = [
29+
{ id: 'sys-1', role: 'system', content: 'Connecting…', timestamp: NOW },
30+
];
31+
const result = applySystemStatus(prev, 'Waiting for tools…', makeId, NOW + 2);
32+
33+
expect(result).toHaveLength(1);
34+
expect(result[0]).toEqual({
35+
id: 'sys-1',
36+
role: 'system',
37+
content: 'Waiting for tools…',
38+
timestamp: NOW + 2,
39+
});
40+
});
41+
42+
test('preserves earlier non-system history when coalescing', () => {
43+
const prev: ChatMessage[] = [
44+
{ id: 'u1', role: 'user', content: 'run it', timestamp: NOW },
45+
{ id: 'sys-1', role: 'system', content: 'Starting…', timestamp: NOW + 1 },
46+
];
47+
const result = applySystemStatus(prev, 'Streaming…', makeId, NOW + 3);
48+
49+
expect(result).toHaveLength(2);
50+
expect(result[0]).toBe(prev[0]);
51+
expect(result[1].content).toBe('Streaming…');
52+
expect(result[1].timestamp).toBe(NOW + 3);
53+
});
54+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { ChatMessage } from './types';
2+
3+
/**
4+
* Append a system-status line to the chat while coalescing consecutive status updates.
5+
*
6+
* Continuity goal: when multiple transient status updates arrive back-to-back,
7+
* keep a single evolving system row instead of stacking flickery one-line rows.
8+
*/
9+
export function applySystemStatus(
10+
prev: ChatMessage[],
11+
content: string,
12+
makeId: () => string = () => `msg-${String(Date.now())}`,
13+
now: number = Date.now()
14+
): ChatMessage[] {
15+
const last = prev[prev.length - 1];
16+
17+
if (last?.role === 'system') {
18+
return [
19+
...prev.slice(0, -1),
20+
{
21+
...last,
22+
content,
23+
timestamp: now,
24+
},
25+
];
26+
}
27+
28+
return [
29+
...prev,
30+
{
31+
id: makeId(),
32+
role: 'system',
33+
content,
34+
timestamp: now,
35+
},
36+
];
37+
}

0 commit comments

Comments
 (0)