@nextclaw/ui 0.6.10 → 0.6.12

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 (90) hide show
  1. package/.eslintrc.cjs +10 -0
  2. package/CHANGELOG.md +16 -0
  3. package/dist/assets/ChannelsList-DBDjwf-X.js +1 -0
  4. package/dist/assets/ChatPage-C18sGGk1.js +36 -0
  5. package/dist/assets/DocBrowser-ZOplDEMS.js +1 -0
  6. package/dist/assets/LogoBadge-2LMzEMwe.js +1 -0
  7. package/dist/assets/MarketplacePage-D4JHYcB5.js +49 -0
  8. package/dist/assets/ModelConfig-DZVvdLFq.js +1 -0
  9. package/dist/assets/ProvidersList-Dum31480.js +1 -0
  10. package/dist/assets/{RuntimeConfig-BO6s-ls-.js → RuntimeConfig-4sb3mpkd.js} +1 -1
  11. package/dist/assets/SearchConfig-B4u_MxRG.js +1 -0
  12. package/dist/assets/{SecretsConfig-mayFdxpM.js → SecretsConfig-BQXblZvb.js} +2 -2
  13. package/dist/assets/SessionsConfig-Jk29xjQU.js +2 -0
  14. package/dist/assets/{card-BP5YnL-G.js → card-BekAnCgX.js} +1 -1
  15. package/dist/assets/config-layout-BHnOoweL.js +1 -0
  16. package/dist/assets/index-BXwjfCEO.css +1 -0
  17. package/dist/assets/index-Dl6t70wA.js +8 -0
  18. package/dist/assets/{input-B1D2QX0O.js → input-MMn_Na9q.js} +1 -1
  19. package/dist/assets/{label-DW0j-fXA.js → label-Dg2ydpN0.js} +1 -1
  20. package/dist/assets/{page-layout-Ch-H9gD-.js → page-layout-7K0rcz0I.js} +1 -1
  21. package/dist/assets/session-run-status-CAdjSqeb.js +3 -0
  22. package/dist/assets/{switch-_cZHlGKB.js → switch-DnDMlDVu.js} +1 -1
  23. package/dist/assets/{tabs-custom-ARxqYYjG.js → tabs-custom-khLM8lWj.js} +1 -1
  24. package/dist/assets/{useConfirmDialog-BaU7nIat.js → useConfirmDialog-BYA1XnVU.js} +2 -2
  25. package/dist/assets/{vendor-C--HHaLf.js → vendor-d7E8OgNx.js} +84 -84
  26. package/dist/index.html +3 -3
  27. package/package.json +4 -2
  28. package/src/App.tsx +3 -2
  29. package/src/api/config.ts +212 -200
  30. package/src/api/types.ts +93 -24
  31. package/src/components/chat/ChatConversationPanel.tsx +102 -121
  32. package/src/components/chat/ChatPage.tsx +165 -437
  33. package/src/components/chat/ChatSidebar.tsx +30 -36
  34. package/src/components/chat/ChatThread.tsx +73 -131
  35. package/src/components/chat/chat-input/ChatInputBarView.tsx +82 -0
  36. package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +71 -0
  37. package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +39 -0
  38. package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +31 -0
  39. package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +112 -0
  40. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +24 -0
  41. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +58 -0
  42. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +56 -0
  43. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +40 -0
  44. package/src/components/chat/chat-input/useChatInputBarController.ts +313 -0
  45. package/src/components/chat/chat-input.types.ts +15 -0
  46. package/src/components/chat/chat-page-data.ts +121 -0
  47. package/src/components/chat/chat-page-runtime.ts +221 -0
  48. package/src/components/chat/chat-session-route.ts +59 -0
  49. package/src/components/chat/chat-stream/nextbot-parsers.ts +52 -0
  50. package/src/components/chat/chat-stream/nextbot-runtime-agent.ts +413 -0
  51. package/src/components/chat/chat-stream/stream-event-adapter.ts +98 -0
  52. package/src/components/chat/chat-stream/transport.ts +159 -0
  53. package/src/components/chat/chat-stream/types.ts +76 -0
  54. package/src/components/chat/managers/chat-input.manager.ts +142 -0
  55. package/src/components/chat/managers/chat-run-status.manager.ts +32 -0
  56. package/src/components/chat/managers/chat-session-list.manager.ts +77 -0
  57. package/src/components/chat/managers/chat-stream-actions.manager.ts +34 -0
  58. package/src/components/chat/managers/chat-thread.manager.ts +86 -0
  59. package/src/components/chat/managers/chat-ui.manager.ts +65 -0
  60. package/src/components/chat/presenter/chat-presenter-context.tsx +25 -0
  61. package/src/components/chat/presenter/chat.presenter.ts +32 -0
  62. package/src/components/chat/stores/chat-input.store.ts +62 -0
  63. package/src/components/chat/stores/chat-run-status.store.ts +30 -0
  64. package/src/components/chat/stores/chat-session-list.store.ts +34 -0
  65. package/src/components/chat/stores/chat-thread.store.ts +52 -0
  66. package/src/components/chat/useChatRuntimeController.ts +134 -0
  67. package/src/components/chat/useChatSessionTypeState.ts +148 -0
  68. package/src/components/common/MaskedInput.tsx +1 -1
  69. package/src/components/config/SearchConfig.tsx +297 -0
  70. package/src/components/layout/Sidebar.tsx +6 -1
  71. package/src/hooks/useConfig.ts +48 -1
  72. package/src/hooks/useObservable.ts +20 -0
  73. package/src/lib/chat-message.ts +2 -202
  74. package/src/lib/chat-runtime-utils.ts +250 -0
  75. package/src/lib/i18n.ts +31 -0
  76. package/tsconfig.json +2 -1
  77. package/vite.config.ts +2 -1
  78. package/dist/assets/ChannelsList-TyMb5Mgz.js +0 -1
  79. package/dist/assets/ChatPage-CQerYqvy.js +0 -34
  80. package/dist/assets/DocBrowser-CNtrA0ps.js +0 -1
  81. package/dist/assets/LogoBadge-BLqiOM5D.js +0 -1
  82. package/dist/assets/MarketplacePage-CotZxxNe.js +0 -49
  83. package/dist/assets/ModelConfig-CCsQ8KFq.js +0 -1
  84. package/dist/assets/ProvidersList-BYYX5K_g.js +0 -1
  85. package/dist/assets/SessionsConfig-DAIczdBj.js +0 -2
  86. package/dist/assets/index-BUiahmWm.css +0 -1
  87. package/dist/assets/index-D6_5HaDl.js +0 -7
  88. package/dist/assets/session-run-status-BUYsQeWs.js +0 -5
  89. package/src/components/chat/ChatInputBar.tsx +0 -590
  90. package/src/components/chat/useChatStreamController.ts +0 -591
@@ -5,27 +5,17 @@ import { BrandHeader } from '@/components/common/BrandHeader';
5
5
  import { Input } from '@/components/ui/input';
6
6
  import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
7
7
  import { SessionRunBadge } from '@/components/common/SessionRunBadge';
8
+ import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
9
+ import { useChatRunStatusStore } from '@/components/chat/stores/chat-run-status.store';
10
+ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
8
11
  import { cn } from '@/lib/utils';
9
12
  import { LANGUAGE_OPTIONS, formatDateTime, t, type I18nLanguage } from '@/lib/i18n';
10
- import type { SessionRunStatus } from '@/lib/session-run-status';
11
13
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
12
14
  import { useI18n } from '@/components/providers/I18nProvider';
13
15
  import { useTheme } from '@/components/providers/ThemeProvider';
14
16
  import { NavLink } from 'react-router-dom';
15
17
  import { AlarmClock, BrainCircuit, Languages, MessageSquareText, Palette, Plus, Search, Settings } from 'lucide-react';
16
18
 
17
- type ChatSidebarProps = {
18
- sessions: SessionEntryView[];
19
- sessionRunStatusByKey: ReadonlyMap<string, SessionRunStatus>;
20
- selectedSessionKey: string | null;
21
- onSelectSession: (key: string) => void;
22
- onCreateSession: () => void;
23
- sessionTitle: (session: SessionEntryView) => string;
24
- isLoading: boolean;
25
- query: string;
26
- onQueryChange: (value: string) => void;
27
- };
28
-
29
19
  type DateGroup = {
30
20
  label: string;
31
21
  sessions: SessionEntryView[];
@@ -63,18 +53,29 @@ function groupSessionsByDate(sessions: SessionEntryView[]): DateGroup[] {
63
53
  return groups;
64
54
  }
65
55
 
56
+ function sessionTitle(session: SessionEntryView): string {
57
+ if (session.label && session.label.trim()) {
58
+ return session.label.trim();
59
+ }
60
+ const chunks = session.key.split(':');
61
+ return chunks[chunks.length - 1] || session.key;
62
+ }
63
+
66
64
  const navItems = [
67
65
  { target: '/cron', label: () => t('chatSidebarScheduledTasks'), icon: AlarmClock },
68
66
  { target: '/skills', label: () => t('chatSidebarSkills'), icon: BrainCircuit },
69
67
  ];
70
68
 
71
- export function ChatSidebar(props: ChatSidebarProps) {
69
+ export function ChatSidebar() {
70
+ const presenter = usePresenter();
71
+ const listSnapshot = useChatSessionListStore((state) => state.snapshot);
72
+ const runSnapshot = useChatRunStatusStore((state) => state.snapshot);
72
73
  const { language, setLanguage } = useI18n();
73
74
  const { theme, setTheme } = useTheme();
74
75
  const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
75
76
  const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
76
77
 
77
- const groups = useMemo(() => groupSessionsByDate(props.sessions), [props.sessions]);
78
+ const groups = useMemo(() => groupSessionsByDate(listSnapshot.sessions), [listSnapshot.sessions]);
78
79
 
79
80
  const handleLanguageSwitch = (nextLang: I18nLanguage) => {
80
81
  if (language === nextLang) return;
@@ -84,33 +85,29 @@ export function ChatSidebar(props: ChatSidebarProps) {
84
85
 
85
86
  return (
86
87
  <aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
87
- {/* Logo */}
88
88
  <div className="px-5 pt-5 pb-3">
89
89
  <BrandHeader />
90
90
  </div>
91
91
 
92
- {/* New Task button */}
93
92
  <div className="px-4 pb-3">
94
- <Button variant="primary" className="w-full rounded-xl" onClick={props.onCreateSession}>
93
+ <Button variant="primary" className="w-full rounded-xl" onClick={presenter.chatSessionListManager.createSession}>
95
94
  <Plus className="h-4 w-4 mr-2" />
96
95
  {t('chatSidebarNewTask')}
97
96
  </Button>
98
97
  </div>
99
98
 
100
- {/* Search */}
101
99
  <div className="px-4 pb-3">
102
100
  <div className="relative">
103
101
  <Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
104
102
  <Input
105
- value={props.query}
106
- onChange={(e) => props.onQueryChange(e.target.value)}
103
+ value={listSnapshot.query}
104
+ onChange={(event) => presenter.chatSessionListManager.setQuery(event.target.value)}
107
105
  placeholder={t('chatSidebarSearchPlaceholder')}
108
106
  className="pl-8 h-9 rounded-lg text-xs"
109
107
  />
110
108
  </div>
111
109
  </div>
112
110
 
113
- {/* Navigation shortcuts */}
114
111
  <div className="px-3 pb-2">
115
112
  <ul className="space-y-0.5">
116
113
  {navItems.map((item) => {
@@ -142,12 +139,10 @@ export function ChatSidebar(props: ChatSidebarProps) {
142
139
  </ul>
143
140
  </div>
144
141
 
145
- {/* Divider */}
146
142
  <div className="mx-4 border-t border-gray-200/60" />
147
143
 
148
- {/* Session history */}
149
144
  <div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3 py-2">
150
- {props.isLoading ? (
145
+ {listSnapshot.isLoading ? (
151
146
  <div className="text-xs text-gray-500 p-3">{t('sessionsLoading')}</div>
152
147
  ) : groups.length === 0 ? (
153
148
  <div className="p-4 text-center">
@@ -163,12 +158,12 @@ export function ChatSidebar(props: ChatSidebarProps) {
163
158
  </div>
164
159
  <div className="space-y-0.5">
165
160
  {group.sessions.map((session) => {
166
- const active = props.selectedSessionKey === session.key;
167
- const runStatus = props.sessionRunStatusByKey.get(session.key);
161
+ const active = listSnapshot.selectedSessionKey === session.key;
162
+ const runStatus = runSnapshot.sessionRunStatusByKey.get(session.key);
168
163
  return (
169
164
  <button
170
165
  key={session.key}
171
- onClick={() => props.onSelectSession(session.key)}
166
+ onClick={() => presenter.chatSessionListManager.selectSession(session.key)}
172
167
  className={cn(
173
168
  'w-full rounded-xl px-3 py-2 text-left transition-all text-[13px]',
174
169
  active
@@ -177,7 +172,7 @@ export function ChatSidebar(props: ChatSidebarProps) {
177
172
  )}
178
173
  >
179
174
  <div className="grid grid-cols-[minmax(0,1fr)_0.875rem] items-center gap-1.5">
180
- <span className="truncate font-medium">{props.sessionTitle(session)}</span>
175
+ <span className="truncate font-medium">{sessionTitle(session)}</span>
181
176
  <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
182
177
  {runStatus ? <SessionRunBadge status={runStatus} /> : null}
183
178
  </span>
@@ -195,7 +190,6 @@ export function ChatSidebar(props: ChatSidebarProps) {
195
190
  )}
196
191
  </div>
197
192
 
198
- {/* Settings footer */}
199
193
  <div className="px-3 py-3 border-t border-gray-200/60 space-y-0.5">
200
194
  <NavLink
201
195
  to="/settings"
@@ -213,7 +207,7 @@ export function ChatSidebar(props: ChatSidebarProps) {
213
207
  </>
214
208
  )}
215
209
  </NavLink>
216
- <Select value={theme} onValueChange={(v) => setTheme(v as UiTheme)}>
210
+ <Select value={theme} onValueChange={(value) => setTheme(value as UiTheme)}>
217
211
  <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2 text-[13px] font-medium text-gray-600 hover:bg-gray-200/60 focus:ring-0">
218
212
  <div className="flex items-center gap-2.5 min-w-0">
219
213
  <Palette className="h-4 w-4 text-gray-400" />
@@ -222,12 +216,12 @@ export function ChatSidebar(props: ChatSidebarProps) {
222
216
  <span className="ml-auto text-[11px] text-gray-500">{currentThemeLabel}</span>
223
217
  </SelectTrigger>
224
218
  <SelectContent>
225
- {THEME_OPTIONS.map((o) => (
226
- <SelectItem key={o.value} value={o.value} className="text-xs">{t(o.labelKey)}</SelectItem>
219
+ {THEME_OPTIONS.map((option) => (
220
+ <SelectItem key={option.value} value={option.value} className="text-xs">{t(option.labelKey)}</SelectItem>
227
221
  ))}
228
222
  </SelectContent>
229
223
  </Select>
230
- <Select value={language} onValueChange={(v) => handleLanguageSwitch(v as I18nLanguage)}>
224
+ <Select value={language} onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}>
231
225
  <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2 text-[13px] font-medium text-gray-600 hover:bg-gray-200/60 focus:ring-0">
232
226
  <div className="flex items-center gap-2.5 min-w-0">
233
227
  <Languages className="h-4 w-4 text-gray-400" />
@@ -236,8 +230,8 @@ export function ChatSidebar(props: ChatSidebarProps) {
236
230
  <span className="ml-auto text-[11px] text-gray-500">{currentLanguageLabel}</span>
237
231
  </SelectTrigger>
238
232
  <SelectContent>
239
- {LANGUAGE_OPTIONS.map((o) => (
240
- <SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
233
+ {LANGUAGE_OPTIONS.map((option) => (
234
+ <SelectItem key={option.value} value={option.value} className="text-xs">{option.label}</SelectItem>
241
235
  ))}
242
236
  </SelectContent>
243
237
  </Select>
@@ -1,13 +1,9 @@
1
1
  import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
2
- import type { SessionEventView, SessionMessageView } from '@/api/types';
2
+ import { type UiMessage, type UiMessageRole } from '@nextclaw/agent-chat';
3
3
  import { cn } from '@/lib/utils';
4
4
  import {
5
- buildChatTimeline,
6
- extractMessageText,
7
- extractToolCards,
8
- normalizeChatRole,
9
- type ChatRole,
10
- type ChatTimelineAssistantTurnItem,
5
+ stringifyUnknown,
6
+ summarizeToolArgs,
11
7
  type ToolCard
12
8
  } from '@/lib/chat-message';
13
9
  import { formatDateTime, t } from '@/lib/i18n';
@@ -16,7 +12,7 @@ import remarkGfm from 'remark-gfm';
16
12
  import { Bot, Check, Clock3, Copy, FileSearch, Globe, Search, SendHorizontal, Terminal, User, Wrench } from 'lucide-react';
17
13
 
18
14
  type ChatThreadProps = {
19
- events: SessionEventView[];
15
+ uiMessages: UiMessage[];
20
16
  isSending: boolean;
21
17
  className?: string;
22
18
  };
@@ -26,9 +22,7 @@ const TOOL_OUTPUT_PREVIEW_MAX = 220;
26
22
  const CODE_LANGUAGE_REGEX = /language-([a-z0-9-]+)/i;
27
23
  const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
28
24
 
29
- type WorkflowToolCard = ToolCard & {
30
- _workflowStep?: number;
31
- };
25
+ type WorkflowToolCard = ToolCard;
32
26
 
33
27
  function trimMarkdown(value: string): string {
34
28
  if (value.length <= MARKDOWN_MAX_CHARS) {
@@ -122,7 +116,7 @@ function MarkdownCodeBlock({ className, children }: { className?: string; childr
122
116
  );
123
117
  }
124
118
 
125
- function roleTitle(role: ChatRole): string {
119
+ function roleTitle(role: UiMessageRole): string {
126
120
  if (role === 'user') return t('chatRoleUser');
127
121
  if (role === 'assistant') return t('chatRoleAssistant');
128
122
  if (role === 'tool') return t('chatRoleTool');
@@ -153,7 +147,7 @@ function renderToolIcon(name: string) {
153
147
  return <Wrench className="h-3.5 w-3.5" />;
154
148
  }
155
149
 
156
- function RoleAvatar({ role }: { role: ChatRole }) {
150
+ function RoleAvatar({ role }: { role: UiMessageRole }) {
157
151
  if (role === 'user') {
158
152
  return (
159
153
  <div className="h-8 w-8 rounded-full bg-primary text-white flex items-center justify-center shadow-sm">
@@ -175,7 +169,7 @@ function RoleAvatar({ role }: { role: ChatRole }) {
175
169
  );
176
170
  }
177
171
 
178
- function MarkdownBlock({ text, role }: { text: string; role: ChatRole }) {
172
+ function MarkdownBlock({ text, role }: { text: string; role: UiMessageRole }) {
179
173
  const isUser = role === 'user';
180
174
  const markdownComponents = useMemo<Components>(() => ({
181
175
  a: ({ href, children, ...props }) => {
@@ -261,9 +255,6 @@ function ToolCardView({ card }: { card: WorkflowToolCard }) {
261
255
  <div className="flex flex-wrap items-center gap-2 text-xs text-amber-800 font-semibold">
262
256
  {renderToolIcon(card.name)}
263
257
  <span>{title}</span>
264
- {typeof card._workflowStep === 'number' && (
265
- <span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">#{card._workflowStep + 1}</span>
266
- )}
267
258
  <span className="font-mono text-[11px] text-amber-900/80">{card.name}</span>
268
259
  </div>
269
260
  {card.detail && (
@@ -291,35 +282,6 @@ function ToolCardView({ card }: { card: WorkflowToolCard }) {
291
282
  );
292
283
  }
293
284
 
294
- function ToolWorkflowCard({ cards }: { cards: WorkflowToolCard[] }) {
295
- const chain = cards
296
- .map((card) => card.name.trim())
297
- .filter((name) => name.length > 0)
298
- .join(' \u2192 ');
299
-
300
- return (
301
- <details className="rounded-xl border border-amber-200/80 bg-amber-50/50 p-3">
302
- <summary className="cursor-pointer list-none">
303
- <div className="flex flex-wrap items-center gap-2 text-xs text-amber-800 font-semibold">
304
- <Wrench className="h-3.5 w-3.5" />
305
- <span>{t('chatToolWorkflow')}</span>
306
- <span className="font-mono text-[11px] text-amber-900/90">{chain || 'tool'}</span>
307
- <span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">{cards.length}</span>
308
- <span className="text-[11px] font-normal text-amber-700/90">{t('chatToolWorkflowDetails')}</span>
309
- </div>
310
- </summary>
311
- <div className="mt-3 space-y-2">
312
- {cards.map((card, index) => (
313
- <ToolCardView
314
- key={`${card.kind}-${card.callId ?? card.name}-${index}`}
315
- card={{ ...card, _workflowStep: index }}
316
- />
317
- ))}
318
- </div>
319
- </details>
320
- );
321
- }
322
-
323
285
  function ReasoningBlock({ reasoning, isUser }: { reasoning: string; isUser: boolean }) {
324
286
  return (
325
287
  <details className="mt-3">
@@ -333,18 +295,65 @@ function ReasoningBlock({ reasoning, isUser }: { reasoning: string; isUser: bool
333
295
  );
334
296
  }
335
297
 
336
- function MessageCard({ message }: { message: SessionMessageView }) {
337
- const role = normalizeChatRole(message);
338
- const primaryText = extractMessageText(message.content).trim();
339
- const primaryReasoning = typeof message.reasoning_content === 'string' ? message.reasoning_content.trim() : '';
340
- const toolCards = extractToolCards(message);
298
+ function resolveUiMessageTimestamp(message: UiMessage): string {
299
+ const candidate = message.meta?.timestamp;
300
+ if (candidate && Number.isFinite(Date.parse(candidate))) {
301
+ return candidate;
302
+ }
303
+ return new Date().toISOString();
304
+ }
305
+
306
+ function MessageCard({ message }: { message: UiMessage }) {
307
+ const role = message.role;
341
308
  const isUser = role === 'user';
342
- const shouldRenderPrimaryText = Boolean(primaryText) && !(role === 'tool' && toolCards.length > 0);
309
+ const renderedParts = message.parts
310
+ .map((part, index) => {
311
+ if (part.type === 'text') {
312
+ const text = part.text.trim();
313
+ if (!text) {
314
+ return null;
315
+ }
316
+ return <MarkdownBlock key={`text-${index}`} text={text} role={role} />;
317
+ }
318
+ if (part.type === 'reasoning') {
319
+ const reasoning = part.reasoning.trim();
320
+ if (!reasoning) {
321
+ return null;
322
+ }
323
+ return <ReasoningBlock key={`reasoning-${index}`} reasoning={reasoning} isUser={isUser} />;
324
+ }
325
+ if (part.type === 'tool-invocation') {
326
+ const invocation = part.toolInvocation;
327
+ const detail = summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
328
+ const rawResult = typeof invocation.error === 'string' && invocation.error.trim()
329
+ ? invocation.error.trim()
330
+ : invocation.result != null
331
+ ? stringifyUnknown(invocation.result).trim()
332
+ : '';
333
+ const hasResult =
334
+ invocation.status === 'result' || invocation.status === 'error' || invocation.status === 'cancelled';
335
+ const card: ToolCard = {
336
+ kind: invocation.status === 'result' && !invocation.args ? 'result' : 'call',
337
+ name: invocation.toolName,
338
+ detail,
339
+ text: rawResult || undefined,
340
+ callId: invocation.toolCallId || undefined,
341
+ hasResult
342
+ };
343
+ return (
344
+ <div key={`tool-${invocation.toolCallId || index}`} className="mt-0.5">
345
+ <ToolCardView card={card} />
346
+ </div>
347
+ );
348
+ }
349
+ return null;
350
+ })
351
+ .filter((node) => node !== null);
343
352
 
344
353
  return (
345
354
  <div
346
355
  className={cn(
347
- 'rounded-2xl border px-4 py-3 shadow-sm',
356
+ 'inline-block w-fit max-w-full rounded-2xl border px-4 py-3 shadow-sm',
348
357
  isUser
349
358
  ? 'bg-primary text-white border-primary'
350
359
  : role === 'assistant'
@@ -352,94 +361,27 @@ function MessageCard({ message }: { message: SessionMessageView }) {
352
361
  : 'bg-orange-50/70 text-gray-900 border-orange-200/80'
353
362
  )}
354
363
  >
355
- {shouldRenderPrimaryText && <MarkdownBlock text={primaryText} role={role} />}
356
- {primaryReasoning && <ReasoningBlock reasoning={primaryReasoning} isUser={isUser} />}
357
- {toolCards.length > 0 && (
358
- <div className={cn('space-y-2', (shouldRenderPrimaryText || primaryReasoning) && 'mt-3')}>
359
- {toolCards.length > 1 ? (
360
- <ToolWorkflowCard cards={toolCards} />
361
- ) : (
362
- toolCards.map((card, index) => (
363
- <ToolCardView
364
- key={`${card.kind}-${card.name}-${card.callId ?? index}`}
365
- card={{ ...card }}
366
- />
367
- ))
368
- )}
369
- </div>
370
- )}
371
- </div>
372
- );
373
- }
374
-
375
- function AssistantTurnCard({ item }: { item: ChatTimelineAssistantTurnItem }) {
376
- const renderedSegments: ReactNode[] = [];
377
- let index = 0;
378
- while (index < item.segments.length) {
379
- const segment = item.segments[index];
380
- if (!segment) {
381
- index += 1;
382
- continue;
383
- }
384
- if (segment.kind === 'assistant_message') {
385
- const hasText = Boolean(segment.text);
386
- const hasReasoning = Boolean(segment.reasoning);
387
- if (hasText || hasReasoning) {
388
- renderedSegments.push(
389
- <div key={`${segment.key}-${index}`}>
390
- {hasText && <MarkdownBlock text={segment.text} role="assistant" />}
391
- {hasReasoning && <ReasoningBlock reasoning={segment.reasoning} isUser={false} />}
392
- </div>
393
- );
394
- }
395
- index += 1;
396
- continue;
397
- }
398
-
399
- const groupedCards: WorkflowToolCard[] = [];
400
- let cursor = index;
401
- while (cursor < item.segments.length) {
402
- const current = item.segments[cursor];
403
- if (!current || current.kind !== 'tool_card') {
404
- break;
405
- }
406
- groupedCards.push(current.card);
407
- cursor += 1;
408
- }
409
- if (groupedCards.length > 1) {
410
- renderedSegments.push(<ToolWorkflowCard key={`workflow-${segment.key}-${index}`} cards={groupedCards} />);
411
- } else if (groupedCards.length === 1) {
412
- renderedSegments.push(<ToolCardView key={`${segment.key}-${index}`} card={groupedCards[0]} />);
413
- }
414
- index = cursor;
415
- }
416
-
417
- return (
418
- <div className="rounded-2xl border px-4 py-3 shadow-sm bg-white text-gray-900 border-gray-200">
419
- <div className="space-y-3">{renderedSegments}</div>
364
+ <div className="space-y-2">{renderedParts}</div>
420
365
  </div>
421
366
  );
422
367
  }
423
368
 
424
- export function ChatThread({ events, isSending, className }: ChatThreadProps) {
425
- const timeline = useMemo(() => buildChatTimeline(events), [events]);
369
+ export function ChatThread({ uiMessages, isSending, className }: ChatThreadProps) {
370
+ const hasStreamingDraft = uiMessages.some((message) => message.meta?.status === 'streaming');
426
371
 
427
372
  return (
428
373
  <div className={cn('space-y-5', className)}>
429
- {timeline.map((item) => {
430
- const role = item.kind === 'assistant_turn' ? 'assistant' : item.role;
374
+ {uiMessages.map((message) => {
375
+ const {role} = message;
431
376
  const isUser = role === 'user';
377
+ const timestamp = resolveUiMessageTimestamp(message);
432
378
  return (
433
- <div key={item.key} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
379
+ <div key={message.id} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
434
380
  {!isUser && <RoleAvatar role={role} />}
435
- <div className={cn('max-w-[92%] min-w-[280px] space-y-2', isUser && 'flex flex-col items-end')}>
436
- {item.kind === 'assistant_turn' ? (
437
- <AssistantTurnCard item={item} />
438
- ) : (
439
- <MessageCard message={item.message} />
440
- )}
381
+ <div className={cn('max-w-[92%] w-fit space-y-2', isUser && 'flex flex-col items-end')}>
382
+ <MessageCard message={message} />
441
383
  <div className={cn('text-[11px] px-1', isUser ? 'text-primary-300' : 'text-gray-400')}>
442
- {roleTitle(role)} · {formatDateTime(item.timestamp)}
384
+ {roleTitle(role)} · {formatDateTime(timestamp)}
443
385
  </div>
444
386
  </div>
445
387
  {isUser && <RoleAvatar role={role} />}
@@ -447,7 +389,7 @@ export function ChatThread({ events, isSending, className }: ChatThreadProps) {
447
389
  );
448
390
  })}
449
391
 
450
- {isSending && (
392
+ {isSending && !hasStreamingDraft && (
451
393
  <div className="flex gap-3 justify-start">
452
394
  <RoleAvatar role="assistant" />
453
395
  <div className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-500 shadow-sm">
@@ -0,0 +1,82 @@
1
+ import { useChatInputBarController } from '@/components/chat/chat-input/useChatInputBarController';
2
+ import { ChatInputBottomToolbar } from '@/components/chat/chat-input/ChatInputBottomToolbar';
3
+ import { ChatInputModelStateHint } from '@/components/chat/chat-input/components/ChatInputModelStateHint';
4
+ import { ChatInputSelectedSkillsSection } from '@/components/chat/chat-input/components/ChatInputSelectedSkillsSection';
5
+ import { ChatInputSlashPanelSection } from '@/components/chat/chat-input/components/ChatInputSlashPanelSection';
6
+ import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
7
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
8
+ import { t } from '@/lib/i18n';
9
+
10
+ export function ChatInputBarView() {
11
+ const presenter = usePresenter();
12
+ const snapshot = useChatInputStore((state) => state.snapshot);
13
+
14
+ const hasModelOptions = snapshot.modelOptions.length > 0;
15
+ const isModelOptionsLoading = !snapshot.isProviderStateResolved && !hasModelOptions;
16
+ const isModelOptionsEmpty = snapshot.isProviderStateResolved && !hasModelOptions;
17
+ const inputDisabled = ((isModelOptionsLoading || isModelOptionsEmpty) && !snapshot.isSending) || snapshot.sessionTypeUnavailable;
18
+ const textareaPlaceholder = isModelOptionsLoading
19
+ ? ''
20
+ : hasModelOptions
21
+ ? t('chatInputPlaceholder')
22
+ : t('chatModelNoOptions');
23
+
24
+ const controller = useChatInputBarController({
25
+ draft: snapshot.draft,
26
+ onDraftChange: presenter.chatInputManager.setDraft,
27
+ onSend: presenter.chatInputManager.send,
28
+ onStop: presenter.chatInputManager.stop,
29
+ canStopGeneration: snapshot.canStopGeneration,
30
+ isSending: snapshot.isSending,
31
+ skillRecords: snapshot.skillRecords,
32
+ isSkillsLoading: snapshot.isSkillsLoading,
33
+ selectedSkills: snapshot.selectedSkills,
34
+ onSelectedSkillsChange: presenter.chatInputManager.selectSkills
35
+ });
36
+
37
+ return (
38
+ <div className="border-t border-gray-200/80 bg-white p-4">
39
+ <div className="mx-auto w-full max-w-[min(1120px,100%)]">
40
+ <div className="rounded-2xl border border-gray-200 bg-white shadow-card overflow-hidden">
41
+ <div className="relative">
42
+ <textarea
43
+ value={snapshot.draft}
44
+ onChange={(event) => presenter.chatInputManager.setDraft(event.target.value)}
45
+ disabled={inputDisabled}
46
+ onKeyDown={controller.onTextareaKeyDown}
47
+ placeholder={textareaPlaceholder}
48
+ className="w-full min-h-[68px] max-h-[220px] resize-y bg-transparent outline-none text-sm px-4 py-3 text-gray-800 placeholder:text-gray-400"
49
+ />
50
+ <ChatInputSlashPanelSection
51
+ slashAnchorRef={controller.slashAnchorRef}
52
+ slashListRef={controller.slashListRef}
53
+ isSlashPanelOpen={controller.isSlashPanelOpen}
54
+ isSlashPanelLoading={controller.isSlashPanelLoading}
55
+ resolvedSlashPanelWidth={controller.resolvedSlashPanelWidth}
56
+ skillSlashItems={controller.skillSlashItems}
57
+ activeSlashIndex={controller.activeSlashIndex}
58
+ activeSlashItem={controller.activeSlashItem}
59
+ onSelectSlashItem={controller.onSelectSlashItem}
60
+ onSlashPanelOpenChange={controller.onSlashPanelOpenChange}
61
+ onSetActiveSlashIndex={controller.onSetActiveSlashIndex}
62
+ />
63
+ </div>
64
+
65
+ <ChatInputModelStateHint
66
+ isModelOptionsLoading={isModelOptionsLoading}
67
+ isModelOptionsEmpty={isModelOptionsEmpty}
68
+ onGoToProviders={presenter.chatInputManager.goToProviders}
69
+ />
70
+
71
+ <ChatInputSelectedSkillsSection
72
+ records={controller.selectedSkillRecords}
73
+ selectedSkills={snapshot.selectedSkills}
74
+ onSelectedSkillsChange={presenter.chatInputManager.selectSkills}
75
+ />
76
+
77
+ <ChatInputBottomToolbar />
78
+ </div>
79
+ </div>
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,71 @@
1
+ import { SkillsPicker } from '@/components/chat/SkillsPicker';
2
+ import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
3
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
4
+ import { ChatInputAttachButton } from '@/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton';
5
+ import { ChatInputModelSelector } from '@/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector';
6
+ import { ChatInputSendControls } from '@/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls';
7
+ import { ChatInputSessionTypeSelector } from '@/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector';
8
+ import { t } from '@/lib/i18n';
9
+
10
+ export function ChatInputBottomToolbar() {
11
+ const presenter = usePresenter();
12
+ const snapshot = useChatInputStore((state) => state.snapshot);
13
+
14
+ const hasModelOptions = snapshot.modelOptions.length > 0;
15
+ const isModelOptionsLoading = !snapshot.isProviderStateResolved && !hasModelOptions;
16
+ const selectedModelOption = snapshot.modelOptions.find((option) => option.value === snapshot.selectedModel);
17
+ const shouldShowSessionTypeSelector =
18
+ snapshot.canEditSessionType &&
19
+ (snapshot.sessionTypeOptions.length > 1 ||
20
+ Boolean(snapshot.selectedSessionType && snapshot.selectedSessionType !== 'native'));
21
+ const selectedSessionTypeOption =
22
+ snapshot.sessionTypeOptions.find((option) => option.value === snapshot.selectedSessionType) ??
23
+ (snapshot.selectedSessionType
24
+ ? { value: snapshot.selectedSessionType, label: snapshot.selectedSessionType }
25
+ : null);
26
+ const resolvedStopHint =
27
+ snapshot.stopDisabledReason === '__preparing__'
28
+ ? t('chatStopPreparing')
29
+ : snapshot.stopDisabledReason?.trim() || t('chatStopUnavailable');
30
+
31
+ return (
32
+ <div className="flex items-center justify-between px-3 pb-3">
33
+ <div className="flex items-center gap-1">
34
+ <SkillsPicker
35
+ records={snapshot.skillRecords}
36
+ isLoading={snapshot.isSkillsLoading}
37
+ selectedSkills={snapshot.selectedSkills}
38
+ onSelectedSkillsChange={presenter.chatInputManager.selectSkills}
39
+ />
40
+ <ChatInputSessionTypeSelector
41
+ shouldShowSessionTypeSelector={shouldShowSessionTypeSelector}
42
+ selectedSessionType={snapshot.selectedSessionType}
43
+ selectedSessionTypeOption={selectedSessionTypeOption}
44
+ sessionTypeOptions={snapshot.sessionTypeOptions}
45
+ onSelectedSessionTypeChange={presenter.chatInputManager.selectSessionType}
46
+ canEditSessionType={snapshot.canEditSessionType}
47
+ />
48
+ <ChatInputModelSelector
49
+ modelOptions={snapshot.modelOptions}
50
+ selectedModel={snapshot.selectedModel}
51
+ selectedModelOption={selectedModelOption}
52
+ onSelectedModelChange={presenter.chatInputManager.selectModel}
53
+ isModelOptionsLoading={isModelOptionsLoading}
54
+ hasModelOptions={hasModelOptions}
55
+ />
56
+ <ChatInputAttachButton />
57
+ </div>
58
+ <ChatInputSendControls
59
+ sendError={snapshot.sendError}
60
+ draft={snapshot.draft}
61
+ hasModelOptions={hasModelOptions}
62
+ sessionTypeUnavailable={snapshot.sessionTypeUnavailable}
63
+ isSending={snapshot.isSending}
64
+ canStopGeneration={snapshot.canStopGeneration}
65
+ resolvedStopHint={resolvedStopHint}
66
+ onSend={presenter.chatInputManager.send}
67
+ onStop={presenter.chatInputManager.stop}
68
+ />
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,39 @@
1
+ import { t } from '@/lib/i18n';
2
+
3
+ type ChatInputModelStateHintProps = {
4
+ isModelOptionsLoading: boolean;
5
+ isModelOptionsEmpty: boolean;
6
+ onGoToProviders: () => void;
7
+ };
8
+
9
+ export function ChatInputModelStateHint(props: ChatInputModelStateHintProps) {
10
+ if (!props.isModelOptionsLoading && !props.isModelOptionsEmpty) {
11
+ return null;
12
+ }
13
+
14
+ if (props.isModelOptionsLoading) {
15
+ return (
16
+ <div className="px-4 pb-2">
17
+ <div className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
18
+ <span className="h-3 w-28 animate-pulse rounded bg-gray-200" />
19
+ <span className="h-3 w-16 animate-pulse rounded bg-gray-200" />
20
+ </div>
21
+ </div>
22
+ );
23
+ }
24
+
25
+ return (
26
+ <div className="px-4 pb-2">
27
+ <div className="inline-flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
28
+ <span>{t('chatModelNoOptions')}</span>
29
+ <button
30
+ type="button"
31
+ onClick={props.onGoToProviders}
32
+ className="font-semibold text-amber-900 underline-offset-2 hover:underline"
33
+ >
34
+ {t('chatGoConfigureProvider')}
35
+ </button>
36
+ </div>
37
+ </div>
38
+ );
39
+ }