@nextclaw/ui 0.5.26 → 0.5.29

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 (33) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/assets/{ChannelsList-D0Wk08Ki.js → ChannelsList-DEr4kE7H.js} +1 -1
  3. package/dist/assets/ChatPage-DI2euxZy.js +32 -0
  4. package/dist/assets/{CronConfig-D-3Y8kWb.js → CronConfig-DAlt-x5i.js} +1 -1
  5. package/dist/assets/{DocBrowser-BSPKhqrK.js → DocBrowser-TrMsdXgx.js} +1 -1
  6. package/dist/assets/{MarketplacePage-Dkm2FTtN.js → MarketplacePage-Dwm527F7.js} +1 -1
  7. package/dist/assets/{ModelConfig-2cpAmvGq.js → ModelConfig-srggzgfA.js} +1 -1
  8. package/dist/assets/{ProvidersList-Dot21pAy.js → ProvidersList-8kFCDiqC.js} +1 -1
  9. package/dist/assets/{RuntimeConfig-BNw_Ms_Y.js → RuntimeConfig-CLbdKAlo.js} +1 -1
  10. package/dist/assets/{SecretsConfig-z8M3PDJP.js → SecretsConfig-DXCdR0Be.js} +1 -1
  11. package/dist/assets/{SessionsConfig-XVHZ-FG5.js → SessionsConfig-iKpz3Sts.js} +1 -1
  12. package/dist/assets/{action-link-CpPJJN-z.js → action-link-w4jS8X9q.js} +1 -1
  13. package/dist/assets/{card-DsZ2Am92.js → card-CVj65Dvi.js} +1 -1
  14. package/dist/assets/chat-message-D0s61C4e.js +5 -0
  15. package/dist/assets/{dialog-BysNu5hM.js → dialog-lK79rlAw.js} +1 -1
  16. package/dist/assets/{index-Bny21Br0.js → index-BXgULtdk.js} +2 -2
  17. package/dist/assets/{label-q6RASlER.js → label-l-fECYi3.js} +1 -1
  18. package/dist/assets/{page-layout-WiVrFc8t.js → page-layout-BghxFaNt.js} +1 -1
  19. package/dist/assets/{switch-DM_YYUgB.js → switch-B4yFzIbc.js} +1 -1
  20. package/dist/assets/{tabs-custom-mlgm-IGH.js → tabs-custom-B4q02QSV.js} +1 -1
  21. package/dist/assets/useConfig-C9k3TmQk.js +6 -0
  22. package/dist/assets/{useConfirmDialog-DamaA60g.js → useConfirmDialog-C20D5SYn.js} +1 -1
  23. package/dist/index.html +1 -1
  24. package/package.json +1 -1
  25. package/src/api/config.ts +14 -1
  26. package/src/api/types.ts +14 -0
  27. package/src/components/chat/ChatPage.tsx +95 -35
  28. package/src/components/chat/ChatThread.tsx +58 -32
  29. package/src/hooks/useConfig.ts +2 -1
  30. package/src/lib/chat-message.ts +169 -153
  31. package/dist/assets/ChatPage-Deg2lBH4.js +0 -32
  32. package/dist/assets/chat-message-Jxa8JFA_.js +0 -9
  33. package/dist/assets/useConfig-BOn-kp8G.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,6 +9,7 @@ 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
 
@@ -93,9 +94,12 @@ export function ChatPage() {
93
94
  const [draft, setDraft] = useState('');
94
95
  const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(() => readStoredSessionKey());
95
96
  const [selectedAgentId, setSelectedAgentId] = useState('main');
96
- const [optimisticUserMessage, setOptimisticUserMessage] = useState<SessionMessageView | null>(null);
97
- const [streamingAssistantMessage, setStreamingAssistantMessage] = useState<SessionMessageView | null>(null);
97
+ const [optimisticUserEvent, setOptimisticUserEvent] = useState<SessionEventView | null>(null);
98
+ const [streamingSessionEvents, setStreamingSessionEvents] = useState<SessionEventView[]>([]);
99
+ const [streamingAssistantText, setStreamingAssistantText] = useState('');
100
+ const [streamingAssistantTimestamp, setStreamingAssistantTimestamp] = useState<string | null>(null);
98
101
  const [isSending, setIsSending] = useState(false);
102
+ const [isAwaitingAssistantOutput, setIsAwaitingAssistantOutput] = useState(false);
99
103
  const [queuedMessages, setQueuedMessages] = useState<PendingChatMessage[]>([]);
100
104
 
101
105
  const { confirm, ConfirmDialog } = useConfirmDialog();
@@ -143,20 +147,36 @@ export function ChatPage() {
143
147
  [selectedSessionKey, sessions]
144
148
  );
145
149
 
146
- const historyMessages = useMemo(() => historyQuery.data?.messages ?? [], [historyQuery.data?.messages]);
147
- const mergedMessages = useMemo(() => {
148
- if (!optimisticUserMessage && !streamingAssistantMessage) {
149
- return historyMessages;
150
+ const historyData = historyQuery.data;
151
+ const historyMessages = historyData?.messages ?? [];
152
+ const historyEvents =
153
+ historyData?.events && historyData.events.length > 0
154
+ ? historyData.events
155
+ : buildFallbackEventsFromMessages(historyMessages);
156
+ const mergedEvents = useMemo(() => {
157
+ const next = [...historyEvents];
158
+ if (optimisticUserEvent) {
159
+ next.push(optimisticUserEvent);
150
160
  }
151
- const next = [...historyMessages];
152
- if (optimisticUserMessage) {
153
- next.push(optimisticUserMessage);
154
- }
155
- if (streamingAssistantMessage) {
156
- next.push(streamingAssistantMessage);
161
+ next.push(...streamingSessionEvents);
162
+ if (streamingAssistantText.trim()) {
163
+ const maxSeq = next.reduce((max, event) => {
164
+ const seq = Number.isFinite(event.seq) ? event.seq : 0;
165
+ return seq > max ? seq : max;
166
+ }, 0);
167
+ next.push({
168
+ seq: maxSeq + 1,
169
+ type: 'stream.assistant_delta',
170
+ timestamp: streamingAssistantTimestamp ?? new Date().toISOString(),
171
+ message: {
172
+ role: 'assistant',
173
+ content: streamingAssistantText,
174
+ timestamp: streamingAssistantTimestamp ?? new Date().toISOString()
175
+ }
176
+ });
157
177
  }
158
178
  return next;
159
- }, [historyMessages, optimisticUserMessage, streamingAssistantMessage]);
179
+ }, [historyEvents, optimisticUserEvent, streamingAssistantText, streamingAssistantTimestamp, streamingSessionEvents]);
160
180
 
161
181
  useEffect(() => {
162
182
  if (!selectedSessionKey && filteredSessions.length > 0) {
@@ -188,7 +208,7 @@ export function ChatPage() {
188
208
  return;
189
209
  }
190
210
  element.scrollTop = element.scrollHeight;
191
- }, [mergedMessages, isSending, selectedSessionKey]);
211
+ }, [mergedEvents, isSending, selectedSessionKey]);
192
212
 
193
213
  useEffect(() => {
194
214
  return () => {
@@ -200,10 +220,13 @@ export function ChatPage() {
200
220
  streamRunIdRef.current += 1;
201
221
  setIsSending(false);
202
222
  setQueuedMessages([]);
203
- setStreamingAssistantMessage(null);
223
+ setOptimisticUserEvent(null);
224
+ setStreamingSessionEvents([]);
225
+ setStreamingAssistantText('');
226
+ setStreamingAssistantTimestamp(null);
227
+ setIsAwaitingAssistantOutput(false);
204
228
  const next = buildNewSessionKey(selectedAgentId);
205
229
  setSelectedSessionKey(next);
206
- setOptimisticUserMessage(null);
207
230
  };
208
231
 
209
232
  const handleDeleteSession = async () => {
@@ -225,9 +248,12 @@ export function ChatPage() {
225
248
  streamRunIdRef.current += 1;
226
249
  setIsSending(false);
227
250
  setQueuedMessages([]);
228
- setStreamingAssistantMessage(null);
251
+ setOptimisticUserEvent(null);
252
+ setStreamingSessionEvents([]);
253
+ setStreamingAssistantText('');
254
+ setStreamingAssistantTimestamp(null);
255
+ setIsAwaitingAssistantOutput(false);
229
256
  setSelectedSessionKey(null);
230
- setOptimisticUserMessage(null);
231
257
  await sessionsQuery.refetch();
232
258
  }
233
259
  }
@@ -238,17 +264,26 @@ export function ChatPage() {
238
264
  streamRunIdRef.current += 1;
239
265
  const runId = streamRunIdRef.current;
240
266
 
241
- setStreamingAssistantMessage(null);
242
- setOptimisticUserMessage({
243
- role: 'user',
244
- content: item.message,
245
- timestamp: new Date().toISOString()
267
+ setStreamingSessionEvents([]);
268
+ setStreamingAssistantText('');
269
+ setStreamingAssistantTimestamp(null);
270
+ setOptimisticUserEvent({
271
+ seq: 0,
272
+ type: 'message.user.optimistic',
273
+ timestamp: new Date().toISOString(),
274
+ message: {
275
+ role: 'user',
276
+ content: item.message,
277
+ timestamp: new Date().toISOString()
278
+ }
246
279
  });
247
280
  setIsSending(true);
281
+ setIsAwaitingAssistantOutput(true);
248
282
 
249
283
  try {
250
284
  let streamText = '';
251
285
  const streamTimestamp = new Date().toISOString();
286
+ setStreamingAssistantTimestamp(streamTimestamp);
252
287
 
253
288
  const result = await sendChatTurnStream({
254
289
  message: item.message,
@@ -270,17 +305,36 @@ export function ChatPage() {
270
305
  return;
271
306
  }
272
307
  streamText += event.delta;
273
- setStreamingAssistantMessage({
274
- role: 'assistant',
275
- content: streamText,
276
- timestamp: streamTimestamp
308
+ setStreamingAssistantText(streamText);
309
+ setIsAwaitingAssistantOutput(false);
310
+ },
311
+ onSessionEvent: (event) => {
312
+ if (runId !== streamRunIdRef.current) {
313
+ return;
314
+ }
315
+ if (event.data.message?.role === 'user') {
316
+ setOptimisticUserEvent(null);
317
+ }
318
+ setStreamingSessionEvents((prev) => {
319
+ const next = [...prev];
320
+ const hit = next.findIndex((item) => item.seq === event.data.seq);
321
+ if (hit >= 0) {
322
+ next[hit] = event.data;
323
+ } else {
324
+ next.push(event.data);
325
+ }
326
+ return next;
277
327
  });
328
+ if (event.data.message?.role === 'assistant') {
329
+ setStreamingAssistantText('');
330
+ setIsAwaitingAssistantOutput(false);
331
+ }
278
332
  }
279
333
  });
280
334
  if (runId !== streamRunIdRef.current) {
281
335
  return;
282
336
  }
283
- setOptimisticUserMessage(null);
337
+ setOptimisticUserEvent(null);
284
338
  if (result.sessionKey !== item.sessionKey) {
285
339
  setSelectedSessionKey(result.sessionKey);
286
340
  }
@@ -289,7 +343,10 @@ export function ChatPage() {
289
343
  if (!activeSessionKey || activeSessionKey === item.sessionKey || activeSessionKey === result.sessionKey) {
290
344
  await historyQuery.refetch();
291
345
  }
292
- setStreamingAssistantMessage(null);
346
+ setStreamingSessionEvents([]);
347
+ setStreamingAssistantText('');
348
+ setStreamingAssistantTimestamp(null);
349
+ setIsAwaitingAssistantOutput(false);
293
350
  setIsSending(false);
294
351
  } catch {
295
352
  if (runId !== streamRunIdRef.current) {
@@ -297,8 +354,11 @@ export function ChatPage() {
297
354
  }
298
355
  streamRunIdRef.current += 1;
299
356
  setIsSending(false);
300
- setStreamingAssistantMessage(null);
301
- setOptimisticUserMessage(null);
357
+ setOptimisticUserEvent(null);
358
+ setStreamingSessionEvents([]);
359
+ setStreamingAssistantText('');
360
+ setStreamingAssistantTimestamp(null);
361
+ setIsAwaitingAssistantOutput(false);
302
362
  if (options?.restoreDraftOnError) {
303
363
  setDraft((prev) => prev.trim().length === 0 ? item.message : prev);
304
364
  }
@@ -482,14 +542,14 @@ export function ChatPage() {
482
542
  <div className="text-xs mt-1">{t('chatNoSessionHint')}</div>
483
543
  </div>
484
544
  </div>
485
- ) : historyQuery.isLoading ? (
545
+ ) : historyQuery.isLoading && mergedEvents.length === 0 && !isSending && !isAwaitingAssistantOutput && !streamingAssistantText.trim() ? (
486
546
  <div className="text-sm text-gray-500">{t('chatHistoryLoading')}</div>
487
547
  ) : (
488
548
  <>
489
- {mergedMessages.length === 0 ? (
549
+ {mergedEvents.length === 0 ? (
490
550
  <div className="text-sm text-gray-500">{t('chatNoMessages')}</div>
491
551
  ) : (
492
- <ChatThread messages={mergedMessages} isSending={isSending && !streamingAssistantMessage} />
552
+ <ChatThread events={mergedEvents} isSending={isSending && isAwaitingAssistantOutput} />
493
553
  )}
494
554
  </>
495
555
  )}
@@ -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
  })}
@@ -150,7 +150,8 @@ export function useSessionHistory(key: string | null, limit = 200) {
150
150
  queryKey: ['session-history', key, limit],
151
151
  queryFn: () => fetchSessionHistory(key as string, limit),
152
152
  enabled: Boolean(key),
153
- staleTime: 5_000
153
+ staleTime: 5_000,
154
+ retry: false
154
155
  });
155
156
  }
156
157