@nextclaw/ui 0.5.25 → 0.5.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/assets/{ChannelsList-C2h4dVWt.js → ChannelsList-DgkIu7_t.js} +1 -1
  3. package/dist/assets/ChatPage-CjzR-76f.js +32 -0
  4. package/dist/assets/{CronConfig-CtiTohyP.js → CronConfig-DREWLzKD.js} +1 -1
  5. package/dist/assets/{DocBrowser-BDOIcX3y.js → DocBrowser-fXdmzwRi.js} +1 -1
  6. package/dist/assets/{MarketplacePage-BPc8FmE5.js → MarketplacePage-zKC-b_O_.js} +1 -1
  7. package/dist/assets/{ModelConfig-pX1wV-UJ.js → ModelConfig-BdogzFBq.js} +1 -1
  8. package/dist/assets/{ProvidersList-BJTtF6S5.js → ProvidersList-BLScOe9j.js} +1 -1
  9. package/dist/assets/{RuntimeConfig-BqN1F3nP.js → RuntimeConfig-CGsoLtsV.js} +1 -1
  10. package/dist/assets/{SecretsConfig-CkqJq4s6.js → SecretsConfig-B8ZDpRgB.js} +1 -1
  11. package/dist/assets/{SessionsConfig-ti41bz9T.js → SessionsConfig-CcpfGazP.js} +1 -1
  12. package/dist/assets/{action-link-C4fhtxYl.js → action-link-RjYHzlQk.js} +1 -1
  13. package/dist/assets/{card-C0sHz144.js → card-Bt8T3JA3.js} +1 -1
  14. package/dist/assets/chat-message-D0s61C4e.js +5 -0
  15. package/dist/assets/{dialog-CJMXeCpK.js → dialog-BNi5ymWD.js} +1 -1
  16. package/dist/assets/index-B_OeEGic.css +1 -0
  17. package/dist/assets/{index-BTE5dHqH.js → index-CtNWUrVR.js} +2 -2
  18. package/dist/assets/{label-DeI2vCVA.js → label-BDCnjFl3.js} +1 -1
  19. package/dist/assets/{page-layout-C_pPVkYP.js → page-layout-DWVnm0X8.js} +1 -1
  20. package/dist/assets/{switch-_UxC2xoH.js → switch-CrmdAOBw.js} +1 -1
  21. package/dist/assets/{tabs-custom-DgryVfGt.js → tabs-custom-WJxQwDQy.js} +1 -1
  22. package/dist/assets/useConfig-COoN7EVf.js +6 -0
  23. package/dist/assets/{useConfirmDialog-DqTlJNqa.js → useConfirmDialog-C5hEiEeb.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/package.json +1 -1
  26. package/src/api/config.ts +14 -1
  27. package/src/api/types.ts +14 -0
  28. package/src/components/chat/ChatPage.tsx +134 -54
  29. package/src/components/chat/ChatThread.tsx +58 -32
  30. package/src/lib/chat-message.ts +169 -153
  31. package/dist/assets/ChatPage-B4xQTj78.js +0 -32
  32. package/dist/assets/chat-message-Jxa8JFA_.js +0 -9
  33. package/dist/assets/index-BsDasSXm.css +0 -1
  34. package/dist/assets/useConfig-DDPQwvDe.js +0 -6
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import type { SessionEntryView, SessionMessageView } from '@/api/types';
2
+ import type { SessionEntryView, SessionEventView } from '@/api/types';
3
3
  import { sendChatTurnStream } from '@/api/config';
4
4
  import { useConfig, useDeleteSession, useSessionHistory, useSessions } from '@/hooks/useConfig';
5
5
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
@@ -9,10 +9,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
9
9
  import { PageHeader, PageLayout } from '@/components/layout/page-layout';
10
10
  import { ChatThread } from '@/components/chat/ChatThread';
11
11
  import { cn } from '@/lib/utils';
12
+ import { buildFallbackEventsFromMessages } from '@/lib/chat-message';
12
13
  import { formatDateTime, t } from '@/lib/i18n';
13
14
  import { MessageSquareText, Plus, RefreshCw, Search, Send, Trash2 } from 'lucide-react';
14
15
 
15
16
  const CHAT_SESSION_STORAGE_KEY = 'nextclaw.ui.chat.activeSession';
17
+ const UNKNOWN_CHAT_CHANNEL_KEY = '__unknown_channel__';
16
18
 
17
19
  function readStoredSessionKey(): string | null {
18
20
  if (typeof window === 'undefined') {
@@ -63,6 +65,22 @@ function sessionDisplayName(session: SessionEntryView): string {
63
65
  return chunks[chunks.length - 1] || session.key;
64
66
  }
65
67
 
68
+ function resolveChannelFromSessionKey(key: string): string {
69
+ const separator = key.indexOf(':');
70
+ if (separator <= 0) {
71
+ return UNKNOWN_CHAT_CHANNEL_KEY;
72
+ }
73
+ const channel = key.slice(0, separator).trim();
74
+ return channel || UNKNOWN_CHAT_CHANNEL_KEY;
75
+ }
76
+
77
+ function displayChannelName(channel: string): string {
78
+ if (channel === UNKNOWN_CHAT_CHANNEL_KEY) {
79
+ return t('sessionsUnknownChannel');
80
+ }
81
+ return channel;
82
+ }
83
+
66
84
  type PendingChatMessage = {
67
85
  id: number;
68
86
  message: string;
@@ -72,11 +90,13 @@ type PendingChatMessage = {
72
90
 
73
91
  export function ChatPage() {
74
92
  const [query, setQuery] = useState('');
93
+ const [selectedChannel, setSelectedChannel] = useState('all');
75
94
  const [draft, setDraft] = useState('');
76
95
  const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(() => readStoredSessionKey());
77
96
  const [selectedAgentId, setSelectedAgentId] = useState('main');
78
- const [optimisticUserMessage, setOptimisticUserMessage] = useState<SessionMessageView | null>(null);
79
- const [streamingAssistantMessage, setStreamingAssistantMessage] = useState<SessionMessageView | null>(null);
97
+ const [streamingSessionEvents, setStreamingSessionEvents] = useState<SessionEventView[]>([]);
98
+ const [streamingAssistantText, setStreamingAssistantText] = useState('');
99
+ const [streamingAssistantTimestamp, setStreamingAssistantTimestamp] = useState<string | null>(null);
80
100
  const [isSending, setIsSending] = useState(false);
81
101
  const [queuedMessages, setQueuedMessages] = useState<PendingChatMessage[]>([]);
82
102
 
@@ -103,31 +123,60 @@ export function ChatPage() {
103
123
  }, [configQuery.data?.agents.list]);
104
124
 
105
125
  const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
126
+ const channelOptions = useMemo(() => {
127
+ const unique = new Set<string>();
128
+ for (const session of sessions) {
129
+ unique.add(resolveChannelFromSessionKey(session.key));
130
+ }
131
+ return Array.from(unique).sort((a, b) => {
132
+ if (a === UNKNOWN_CHAT_CHANNEL_KEY) return 1;
133
+ if (b === UNKNOWN_CHAT_CHANNEL_KEY) return -1;
134
+ return a.localeCompare(b);
135
+ });
136
+ }, [sessions]);
137
+ const filteredSessions = useMemo(() => {
138
+ if (selectedChannel === 'all') {
139
+ return sessions;
140
+ }
141
+ return sessions.filter((session) => resolveChannelFromSessionKey(session.key) === selectedChannel);
142
+ }, [selectedChannel, sessions]);
106
143
  const selectedSession = useMemo(
107
144
  () => sessions.find((session) => session.key === selectedSessionKey) ?? null,
108
145
  [selectedSessionKey, sessions]
109
146
  );
110
147
 
111
- const historyMessages = useMemo(() => historyQuery.data?.messages ?? [], [historyQuery.data?.messages]);
112
- const mergedMessages = useMemo(() => {
113
- if (!optimisticUserMessage && !streamingAssistantMessage) {
114
- return historyMessages;
115
- }
116
- const next = [...historyMessages];
117
- if (optimisticUserMessage) {
118
- next.push(optimisticUserMessage);
119
- }
120
- if (streamingAssistantMessage) {
121
- next.push(streamingAssistantMessage);
148
+ const historyData = historyQuery.data;
149
+ const historyMessages = historyData?.messages ?? [];
150
+ const historyEvents =
151
+ historyData?.events && historyData.events.length > 0
152
+ ? historyData.events
153
+ : buildFallbackEventsFromMessages(historyMessages);
154
+ const mergedEvents = useMemo(() => {
155
+ const next = [...historyEvents, ...streamingSessionEvents];
156
+ if (streamingAssistantText.trim()) {
157
+ const maxSeq = next.reduce((max, event) => {
158
+ const seq = Number.isFinite(event.seq) ? event.seq : 0;
159
+ return seq > max ? seq : max;
160
+ }, 0);
161
+ next.push({
162
+ seq: maxSeq + 1,
163
+ type: 'stream.assistant_delta',
164
+ timestamp: streamingAssistantTimestamp ?? new Date().toISOString(),
165
+ message: {
166
+ role: 'assistant',
167
+ content: streamingAssistantText,
168
+ timestamp: streamingAssistantTimestamp ?? new Date().toISOString()
169
+ }
170
+ });
122
171
  }
123
172
  return next;
124
- }, [historyMessages, optimisticUserMessage, streamingAssistantMessage]);
173
+ }, [historyEvents, streamingAssistantText, streamingAssistantTimestamp, streamingSessionEvents]);
125
174
 
126
175
  useEffect(() => {
127
- if (!selectedSessionKey && sessions.length > 0) {
128
- setSelectedSessionKey(sessions[0].key);
176
+ if (!selectedSessionKey && filteredSessions.length > 0) {
177
+ setSelectedSessionKey(filteredSessions[0].key);
129
178
  }
130
- }, [selectedSessionKey, sessions]);
179
+ }, [filteredSessions, selectedSessionKey]);
131
180
 
132
181
  useEffect(() => {
133
182
  writeStoredSessionKey(selectedSessionKey);
@@ -153,7 +202,7 @@ export function ChatPage() {
153
202
  return;
154
203
  }
155
204
  element.scrollTop = element.scrollHeight;
156
- }, [mergedMessages, isSending, selectedSessionKey]);
205
+ }, [mergedEvents, isSending, selectedSessionKey]);
157
206
 
158
207
  useEffect(() => {
159
208
  return () => {
@@ -165,10 +214,11 @@ export function ChatPage() {
165
214
  streamRunIdRef.current += 1;
166
215
  setIsSending(false);
167
216
  setQueuedMessages([]);
168
- setStreamingAssistantMessage(null);
217
+ setStreamingSessionEvents([]);
218
+ setStreamingAssistantText('');
219
+ setStreamingAssistantTimestamp(null);
169
220
  const next = buildNewSessionKey(selectedAgentId);
170
221
  setSelectedSessionKey(next);
171
- setOptimisticUserMessage(null);
172
222
  };
173
223
 
174
224
  const handleDeleteSession = async () => {
@@ -190,9 +240,10 @@ export function ChatPage() {
190
240
  streamRunIdRef.current += 1;
191
241
  setIsSending(false);
192
242
  setQueuedMessages([]);
193
- setStreamingAssistantMessage(null);
243
+ setStreamingSessionEvents([]);
244
+ setStreamingAssistantText('');
245
+ setStreamingAssistantTimestamp(null);
194
246
  setSelectedSessionKey(null);
195
- setOptimisticUserMessage(null);
196
247
  await sessionsQuery.refetch();
197
248
  }
198
249
  }
@@ -203,17 +254,15 @@ export function ChatPage() {
203
254
  streamRunIdRef.current += 1;
204
255
  const runId = streamRunIdRef.current;
205
256
 
206
- setStreamingAssistantMessage(null);
207
- setOptimisticUserMessage({
208
- role: 'user',
209
- content: item.message,
210
- timestamp: new Date().toISOString()
211
- });
257
+ setStreamingSessionEvents([]);
258
+ setStreamingAssistantText('');
259
+ setStreamingAssistantTimestamp(null);
212
260
  setIsSending(true);
213
261
 
214
262
  try {
215
263
  let streamText = '';
216
264
  const streamTimestamp = new Date().toISOString();
265
+ setStreamingAssistantTimestamp(streamTimestamp);
217
266
 
218
267
  const result = await sendChatTurnStream({
219
268
  message: item.message,
@@ -235,17 +284,30 @@ export function ChatPage() {
235
284
  return;
236
285
  }
237
286
  streamText += event.delta;
238
- setStreamingAssistantMessage({
239
- role: 'assistant',
240
- content: streamText,
241
- timestamp: streamTimestamp
287
+ setStreamingAssistantText(streamText);
288
+ },
289
+ onSessionEvent: (event) => {
290
+ if (runId !== streamRunIdRef.current) {
291
+ return;
292
+ }
293
+ setStreamingSessionEvents((prev) => {
294
+ const next = [...prev];
295
+ const hit = next.findIndex((item) => item.seq === event.data.seq);
296
+ if (hit >= 0) {
297
+ next[hit] = event.data;
298
+ } else {
299
+ next.push(event.data);
300
+ }
301
+ return next;
242
302
  });
303
+ if (event.data.message?.role === 'assistant') {
304
+ setStreamingAssistantText('');
305
+ }
243
306
  }
244
307
  });
245
308
  if (runId !== streamRunIdRef.current) {
246
309
  return;
247
310
  }
248
- setOptimisticUserMessage(null);
249
311
  if (result.sessionKey !== item.sessionKey) {
250
312
  setSelectedSessionKey(result.sessionKey);
251
313
  }
@@ -254,7 +316,9 @@ export function ChatPage() {
254
316
  if (!activeSessionKey || activeSessionKey === item.sessionKey || activeSessionKey === result.sessionKey) {
255
317
  await historyQuery.refetch();
256
318
  }
257
- setStreamingAssistantMessage(null);
319
+ setStreamingSessionEvents([]);
320
+ setStreamingAssistantText('');
321
+ setStreamingAssistantTimestamp(null);
258
322
  setIsSending(false);
259
323
  } catch {
260
324
  if (runId !== streamRunIdRef.current) {
@@ -262,8 +326,9 @@ export function ChatPage() {
262
326
  }
263
327
  streamRunIdRef.current += 1;
264
328
  setIsSending(false);
265
- setStreamingAssistantMessage(null);
266
- setOptimisticUserMessage(null);
329
+ setStreamingSessionEvents([]);
330
+ setStreamingAssistantText('');
331
+ setStreamingAssistantTimestamp(null);
267
332
  if (options?.restoreDraftOnError) {
268
333
  setDraft((prev) => prev.trim().length === 0 ? item.message : prev);
269
334
  }
@@ -338,6 +403,19 @@ export function ChatPage() {
338
403
  className="pl-8 h-9 rounded-lg text-xs"
339
404
  />
340
405
  </div>
406
+ <Select value={selectedChannel} onValueChange={setSelectedChannel}>
407
+ <SelectTrigger className="h-9 rounded-lg text-xs">
408
+ <SelectValue placeholder={t('sessionsAllChannels')} />
409
+ </SelectTrigger>
410
+ <SelectContent>
411
+ <SelectItem value="all">{t('sessionsAllChannels')}</SelectItem>
412
+ {channelOptions.map((channel) => (
413
+ <SelectItem key={channel} value={channel}>
414
+ {displayChannelName(channel)}
415
+ </SelectItem>
416
+ ))}
417
+ </SelectContent>
418
+ </Select>
341
419
  <div className="grid grid-cols-2 gap-2">
342
420
  <Button variant="outline" size="sm" className="rounded-lg" onClick={() => sessionsQuery.refetch()}>
343
421
  <RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', sessionsQuery.isFetching && 'animate-spin')} />
@@ -353,14 +431,14 @@ export function ChatPage() {
353
431
  <div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-2">
354
432
  {sessionsQuery.isLoading ? (
355
433
  <div className="text-sm text-gray-500 p-4">{t('sessionsLoading')}</div>
356
- ) : sessions.length === 0 ? (
434
+ ) : filteredSessions.length === 0 ? (
357
435
  <div className="p-5 m-2 rounded-xl border border-dashed border-gray-200 text-center text-sm text-gray-500">
358
436
  <MessageSquareText className="h-7 w-7 mx-auto mb-2 text-gray-300" />
359
437
  {t('sessionsEmpty')}
360
438
  </div>
361
439
  ) : (
362
440
  <div className="space-y-1">
363
- {sessions.map((session) => {
441
+ {filteredSessions.map((session) => {
364
442
  const active = selectedSessionKey === session.key;
365
443
  return (
366
444
  <button
@@ -387,8 +465,9 @@ export function ChatPage() {
387
465
  </aside>
388
466
 
389
467
  <section className="flex-1 min-h-0 rounded-2xl border border-gray-200 bg-gradient-to-b from-gray-50/60 to-white shadow-card flex flex-col overflow-hidden">
390
- <div className="px-5 py-4 border-b border-gray-200/80 bg-white/80 backdrop-blur-sm flex flex-wrap items-center gap-3">
391
- <div className="min-w-[220px] max-w-[320px]">
468
+ <div className="px-5 py-4 border-b border-gray-200/80 bg-white/80 backdrop-blur-sm">
469
+ <div className="grid gap-3 lg:grid-cols-[minmax(220px,300px)_minmax(0,1fr)_auto] items-end">
470
+ <div className="min-w-0">
392
471
  <div className="text-[11px] text-gray-500 mb-1">{t('chatAgentLabel')}</div>
393
472
  <Select value={selectedAgentId} onValueChange={setSelectedAgentId}>
394
473
  <SelectTrigger className="h-9 rounded-lg">
@@ -404,23 +483,24 @@ export function ChatPage() {
404
483
  </Select>
405
484
  </div>
406
485
 
407
- <div className="flex-1 min-w-[260px]">
486
+ <div className="min-w-0">
408
487
  <div className="text-[11px] text-gray-500 mb-1">{t('chatSessionLabel')}</div>
409
488
  <div className="h-9 rounded-lg border border-gray-200 bg-white px-3 text-xs text-gray-600 flex items-center truncate">
410
489
  {selectedSessionKey ?? t('chatNoSession')}
411
490
  </div>
412
491
  </div>
413
492
 
414
- <Button
415
- variant="outline"
416
- size="sm"
417
- className="rounded-lg self-end"
418
- onClick={handleDeleteSession}
419
- disabled={!selectedSession || deleteSession.isPending}
420
- >
421
- <Trash2 className="h-3.5 w-3.5 mr-1.5" />
422
- {t('chatDeleteSession')}
423
- </Button>
493
+ <Button
494
+ variant="outline"
495
+ size="sm"
496
+ className="rounded-lg"
497
+ onClick={handleDeleteSession}
498
+ disabled={!selectedSession || deleteSession.isPending}
499
+ >
500
+ <Trash2 className="h-3.5 w-3.5 mr-1.5" />
501
+ {t('chatDeleteSession')}
502
+ </Button>
503
+ </div>
424
504
  </div>
425
505
 
426
506
  <div ref={threadRef} className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-5 py-5">
@@ -436,10 +516,10 @@ export function ChatPage() {
436
516
  <div className="text-sm text-gray-500">{t('chatHistoryLoading')}</div>
437
517
  ) : (
438
518
  <>
439
- {mergedMessages.length === 0 ? (
519
+ {mergedEvents.length === 0 ? (
440
520
  <div className="text-sm text-gray-500">{t('chatNoMessages')}</div>
441
521
  ) : (
442
- <ChatThread messages={mergedMessages} isSending={isSending && !streamingAssistantMessage} />
522
+ <ChatThread events={mergedEvents} isSending={isSending && !streamingAssistantText.trim()} />
443
523
  )}
444
524
  </>
445
525
  )}
@@ -1,12 +1,13 @@
1
1
  import { useMemo } from 'react';
2
- import type { SessionMessageView } from '@/api/types';
2
+ import type { SessionEventView, SessionMessageView } from '@/api/types';
3
3
  import { cn } from '@/lib/utils';
4
4
  import {
5
- combineToolCallAndResults,
5
+ buildChatTimeline,
6
6
  extractMessageText,
7
7
  extractToolCards,
8
- groupChatMessages,
8
+ normalizeChatRole,
9
9
  type ChatRole,
10
+ type ChatTimelineAssistantFlowItem,
10
11
  type ToolCard
11
12
  } from '@/lib/chat-message';
12
13
  import { formatDateTime, t } from '@/lib/i18n';
@@ -16,7 +17,7 @@ import remarkGfm from 'remark-gfm';
16
17
  import { Bot, Clock3, FileSearch, Globe, Search, SendHorizontal, Terminal, User, Wrench } from 'lucide-react';
17
18
 
18
19
  type ChatThreadProps = {
19
- messages: SessionMessageView[];
20
+ events: SessionEventView[];
20
21
  isSending: boolean;
21
22
  className?: string;
22
23
  };
@@ -142,12 +143,26 @@ function ToolCardView({ card }: { card: ToolCard }) {
142
143
  );
143
144
  }
144
145
 
145
- function MessageCard({ message, role }: { message: SessionMessageView; role: ChatRole }) {
146
- const text = extractMessageText(message.content).trim();
146
+ function ReasoningBlock({ reasoning, isUser }: { reasoning: string; isUser: boolean }) {
147
+ return (
148
+ <details className="mt-3">
149
+ <summary className={cn('cursor-pointer text-xs', isUser ? 'text-primary-100' : 'text-gray-500')}>
150
+ {t('chatReasoning')}
151
+ </summary>
152
+ <pre className={cn('mt-2 text-[11px] whitespace-pre-wrap break-words rounded-lg p-2', isUser ? 'bg-primary-700/60' : 'bg-gray-100')}>
153
+ {reasoning}
154
+ </pre>
155
+ </details>
156
+ );
157
+ }
158
+
159
+ function MessageCard({ message }: { message: SessionMessageView }) {
160
+ const role = normalizeChatRole(message);
161
+ const primaryText = extractMessageText(message.content).trim();
162
+ const primaryReasoning = typeof message.reasoning_content === 'string' ? message.reasoning_content.trim() : '';
147
163
  const toolCards = extractToolCards(message);
148
- const reasoning = typeof message.reasoning_content === 'string' ? message.reasoning_content.trim() : '';
149
- const shouldRenderText = Boolean(text) && !(role === 'tool' && toolCards.length > 0);
150
164
  const isUser = role === 'user';
165
+ const shouldRenderPrimaryText = Boolean(primaryText) && !(role === 'tool' && toolCards.length > 0);
151
166
 
152
167
  return (
153
168
  <div
@@ -160,19 +175,10 @@ function MessageCard({ message, role }: { message: SessionMessageView; role: Cha
160
175
  : 'bg-orange-50/70 text-gray-900 border-orange-200/80'
161
176
  )}
162
177
  >
163
- {shouldRenderText && <MarkdownBlock text={text} role={role} />}
164
- {reasoning && (
165
- <details className="mt-3">
166
- <summary className={cn('cursor-pointer text-xs', isUser ? 'text-primary-100' : 'text-gray-500')}>
167
- {t('chatReasoning')}
168
- </summary>
169
- <pre className={cn('mt-2 text-[11px] whitespace-pre-wrap break-words rounded-lg p-2', isUser ? 'bg-primary-700/60' : 'bg-gray-100')}>
170
- {reasoning}
171
- </pre>
172
- </details>
173
- )}
178
+ {shouldRenderPrimaryText && <MarkdownBlock text={primaryText} role={role} />}
179
+ {primaryReasoning && <ReasoningBlock reasoning={primaryReasoning} isUser={isUser} />}
174
180
  {toolCards.length > 0 && (
175
- <div className="mt-3 space-y-2">
181
+ <div className={cn('space-y-2', (shouldRenderPrimaryText || primaryReasoning) && 'mt-3')}>
176
182
  {toolCards.map((card, index) => (
177
183
  <ToolCardView key={`${card.kind}-${card.name}-${card.callId ?? index}`} card={card} />
178
184
  ))}
@@ -182,26 +188,46 @@ function MessageCard({ message, role }: { message: SessionMessageView; role: Cha
182
188
  );
183
189
  }
184
190
 
185
- export function ChatThread({ messages, isSending, className }: ChatThreadProps) {
186
- const preparedMessages = useMemo(() => combineToolCallAndResults(messages), [messages]);
187
- const groups = useMemo(() => groupChatMessages(preparedMessages), [preparedMessages]);
191
+ function AssistantFlowCard({ item }: { item: ChatTimelineAssistantFlowItem }) {
192
+ return (
193
+ <div className="rounded-2xl border px-4 py-3 shadow-sm bg-white text-gray-900 border-gray-200">
194
+ {item.primaryText && <MarkdownBlock text={item.primaryText} role="assistant" />}
195
+ {item.primaryReasoning && <ReasoningBlock reasoning={item.primaryReasoning} isUser={false} />}
196
+ {item.toolCards.length > 0 && (
197
+ <div className={cn('space-y-2', (item.primaryText || item.primaryReasoning) && 'mt-3')}>
198
+ {item.toolCards.map((card, index) => (
199
+ <ToolCardView key={`${card.kind}-${card.name}-${card.callId ?? index}`} card={card} />
200
+ ))}
201
+ </div>
202
+ )}
203
+ {item.followupReasoning && <ReasoningBlock reasoning={item.followupReasoning} isUser={false} />}
204
+ {item.followupText && <div className="mt-3"><MarkdownBlock text={item.followupText} role="assistant" /></div>}
205
+ </div>
206
+ );
207
+ }
208
+
209
+ export function ChatThread({ events, isSending, className }: ChatThreadProps) {
210
+ const timeline = useMemo(() => buildChatTimeline(events), [events]);
188
211
 
189
212
  return (
190
213
  <div className={cn('space-y-5', className)}>
191
- {groups.map((group) => {
192
- const isUser = group.role === 'user';
214
+ {timeline.map((item) => {
215
+ const role = item.kind === 'assistant_flow' ? 'assistant' : item.role;
216
+ const isUser = role === 'user';
193
217
  return (
194
- <div key={group.key} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
195
- {!isUser && <RoleAvatar role={group.role} />}
218
+ <div key={item.key} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
219
+ {!isUser && <RoleAvatar role={role} />}
196
220
  <div className={cn('max-w-[88%] min-w-[280px] space-y-2', isUser && 'flex flex-col items-end')}>
197
- {group.messages.map((message, index) => (
198
- <MessageCard key={`${group.key}-${index}`} message={message} role={group.role} />
199
- ))}
221
+ {item.kind === 'assistant_flow' ? (
222
+ <AssistantFlowCard item={item} />
223
+ ) : (
224
+ <MessageCard message={item.message} />
225
+ )}
200
226
  <div className={cn('text-[11px] px-1', isUser ? 'text-primary-300' : 'text-gray-400')}>
201
- {roleTitle(group.role)} · {formatDateTime(group.timestamp)}
227
+ {roleTitle(role)} · {formatDateTime(item.timestamp)}
202
228
  </div>
203
229
  </div>
204
- {isUser && <RoleAvatar role={group.role} />}
230
+ {isUser && <RoleAvatar role={role} />}
205
231
  </div>
206
232
  );
207
233
  })}