@nextclaw/ui 0.10.0 → 0.10.2

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 (75) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/dist/assets/{ChannelsList-VSRZzxx2.js → ChannelsList-DSMuOmMG.js} +4 -4
  3. package/dist/assets/ChatPage-do9TwNxj.js +38 -0
  4. package/dist/assets/{DocBrowser-C65Hbvnb.js → DocBrowser-BjoTblYl.js} +1 -1
  5. package/dist/assets/{LogoBadge-4qtguXEJ.js → LogoBadge-2yDaYdxw.js} +1 -1
  6. package/dist/assets/MarketplacePage-DVVk4dlH.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-CHLkD8yX.js → McpMarketplacePage-B4WUzuLw.js} +1 -1
  8. package/dist/assets/{ModelConfig-CjsGdmZa.js → ModelConfig-Dr0eI9nN.js} +1 -1
  9. package/dist/assets/ProvidersList-C7A-mIbe.js +1 -0
  10. package/dist/assets/{RemoteAccessPage-rOZCnH1x.js → RemoteAccessPage-CI3Am3w1.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-CmJh6g0R.js → RuntimeConfig-DvSNVSs8.js} +1 -1
  12. package/dist/assets/{SearchConfig-C_hUuzR4.js → SearchConfig-B6TGIZow.js} +1 -1
  13. package/dist/assets/{SecretsConfig-Bu_zIRlQ.js → SecretsConfig-CpxaKU1j.js} +1 -1
  14. package/dist/assets/{SessionsConfig-DA_nqkM_.js → SessionsConfig-B-VHnv4G.js} +1 -1
  15. package/dist/assets/{chat-message-BOdA4h43.js → chat-message-BMqngrjp.js} +1 -1
  16. package/dist/assets/index-C6MeoecJ.js +8 -0
  17. package/dist/assets/index-DdXzLuNG.css +1 -0
  18. package/dist/assets/{label-BYZ62ajO.js → label-s2ILtQeP.js} +1 -1
  19. package/dist/assets/{page-layout-UC-h92sU.js → page-layout-BX5Ro4Sj.js} +1 -1
  20. package/dist/assets/{popover-DASCEr3G.js → popover-qmNpQSIy.js} +1 -1
  21. package/dist/assets/{security-config-Cvujq4fH.js → security-config--F-f-nDl.js} +1 -1
  22. package/dist/assets/skeleton-DthPOKSc.js +1 -0
  23. package/dist/assets/{status-dot-C1AvPwDD.js → status-dot-DWj7aUy8.js} +1 -1
  24. package/dist/assets/{switch-D3wVuCSh.js → switch-62r7L4Lj.js} +1 -1
  25. package/dist/assets/tabs-custom-DEmoGMsc.js +1 -0
  26. package/dist/assets/useConfirmDialog-DzT94nC_.js +1 -0
  27. package/dist/assets/{vendor-DJt0Azq5.js → vendor-CNhxtHCf.js} +1 -1
  28. package/dist/index.html +3 -3
  29. package/package.json +5 -5
  30. package/src/App.test.tsx +41 -0
  31. package/src/App.tsx +37 -0
  32. package/src/api/client.test.ts +12 -0
  33. package/src/api/client.ts +4 -2
  34. package/src/api/config.ts +1 -1
  35. package/src/components/chat/ChatSidebar.tsx +41 -69
  36. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +32 -1
  37. package/src/components/chat/adapters/chat-input-bar.adapter.ts +6 -3
  38. package/src/components/chat/adapters/chat-message.adapter.test.ts +141 -163
  39. package/src/components/chat/adapters/chat-message.adapter.ts +35 -0
  40. package/src/components/chat/chat-composer-state.ts +38 -0
  41. package/src/components/chat/chat-stream/types.ts +2 -0
  42. package/src/components/chat/containers/chat-input-bar.container.tsx +116 -55
  43. package/src/components/chat/containers/chat-message-list.container.tsx +2 -0
  44. package/src/components/chat/managers/chat-session-list.manager.test.ts +16 -1
  45. package/src/components/chat/managers/chat-session-list.manager.ts +0 -2
  46. package/src/components/chat/managers/chat-thread.manager.ts +0 -1
  47. package/src/components/chat/ncp/NcpChatPage.tsx +18 -18
  48. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +50 -33
  49. package/src/components/chat/ncp/ncp-app-client-fetch.ts +5 -123
  50. package/src/components/chat/ncp/ncp-chat-input.manager.ts +56 -1
  51. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +8 -0
  52. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +0 -1
  53. package/src/components/chat/presenter/chat-presenter-context.tsx +6 -0
  54. package/src/components/chat/stores/chat-input.store.ts +3 -0
  55. package/src/components/config/ChannelsList.test.tsx +2 -1
  56. package/src/components/config/weixin-channel-auth-section.test.tsx +2 -1
  57. package/src/components/layout/Sidebar.tsx +62 -102
  58. package/src/components/layout/sidebar-items.tsx +172 -0
  59. package/src/components/layout/sidebar.layout.test.tsx +11 -4
  60. package/src/hooks/use-auth.ts +1 -2
  61. package/src/lib/i18n.chat.ts +117 -0
  62. package/src/lib/i18n.remote.ts +1 -1
  63. package/src/lib/i18n.ts +2 -112
  64. package/src/transport/local.transport.ts +28 -7
  65. package/src/transport/remote.transport.test.ts +135 -0
  66. package/src/transport/remote.transport.ts +14 -1
  67. package/src/transport/transport.types.ts +1 -0
  68. package/dist/assets/ChatPage-CX0ZKE5i.js +0 -41
  69. package/dist/assets/MarketplacePage-DPCYptfD.js +0 -49
  70. package/dist/assets/ProvidersList-aXp_mo4J.js +0 -1
  71. package/dist/assets/index-C63mHRbE.css +0 -1
  72. package/dist/assets/index-DS7D1-KS.js +0 -8
  73. package/dist/assets/skeleton-DlYEKkkj.js +0 -1
  74. package/dist/assets/tabs-custom-CbgS7tu0.js +0 -1
  75. package/dist/assets/useConfirmDialog-BYbFEIbQ.js +0 -1
package/src/App.tsx CHANGED
@@ -3,8 +3,10 @@ import { QueryClientProvider } from '@tanstack/react-query';
3
3
  import { AccountPanel } from '@/account/components/account-panel';
4
4
  import { appQueryClient } from '@/app-query-client';
5
5
  import { LoginPage } from '@/components/auth/login-page';
6
+ import { Button } from '@/components/ui/button';
6
7
  import { AppLayout } from '@/components/layout/AppLayout';
7
8
  import { useAuthStatus } from '@/hooks/use-auth';
9
+ import { t } from '@/lib/i18n';
8
10
  import { useRealtimeQueryBridge } from '@/hooks/use-realtime-query-bridge';
9
11
  import { AppPresenterProvider } from '@/presenter/app-presenter-context';
10
12
  import { Toaster } from 'sonner';
@@ -31,6 +33,29 @@ function LazyRoute({ children }: { children: JSX.Element }) {
31
33
  return <Suspense fallback={<RouteFallback />}>{children}</Suspense>;
32
34
  }
33
35
 
36
+ function AuthBootstrapErrorState(props: {
37
+ message: string;
38
+ retrying: boolean;
39
+ onRetry: () => void;
40
+ }) {
41
+ return (
42
+ <main className="flex min-h-screen items-center justify-center bg-secondary px-6 py-10">
43
+ <div className="w-full max-w-lg rounded-3xl border border-gray-200 bg-white p-8 shadow-card">
44
+ <p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">{t('authBrand')}</p>
45
+ <h1 className="mt-3 text-2xl font-semibold text-gray-900">{t('authStatusLoadFailed')}</h1>
46
+ <p className="mt-3 text-sm leading-6 text-gray-600">
47
+ {props.message}
48
+ </p>
49
+ <div className="mt-6 flex gap-3">
50
+ <Button onClick={props.onRetry} disabled={props.retrying}>
51
+ {t('authRetryStatus')}
52
+ </Button>
53
+ </div>
54
+ </div>
55
+ </main>
56
+ );
57
+ }
58
+
34
59
  function ProtectedApp() {
35
60
  useRealtimeQueryBridge(appQueryClient);
36
61
 
@@ -75,6 +100,18 @@ function AuthGate() {
75
100
  return <RouteFallback />;
76
101
  }
77
102
 
103
+ if (authStatus.isError) {
104
+ return (
105
+ <AuthBootstrapErrorState
106
+ message={authStatus.error instanceof Error ? authStatus.error.message : t('authStatusLoadFailed')}
107
+ retrying={authStatus.isRefetching}
108
+ onRetry={() => {
109
+ void authStatus.refetch();
110
+ }}
111
+ />
112
+ );
113
+ }
114
+
78
115
  if (authStatus.data?.enabled && !authStatus.data.authenticated) {
79
116
  return <LoginPage username={authStatus.data.username} />;
80
117
  }
@@ -30,6 +30,18 @@ describe('api/client', () => {
30
30
  });
31
31
  });
32
32
 
33
+ it('forwards timeout overrides to appClient.request', async () => {
34
+ mocks.request.mockResolvedValue({ ok: true });
35
+
36
+ await api.get<{ ok: boolean }>('/api/auth/status', { timeoutMs: 5000 });
37
+
38
+ expect(mocks.request).toHaveBeenCalledWith({
39
+ method: 'GET',
40
+ path: '/api/auth/status',
41
+ timeoutMs: 5000
42
+ });
43
+ });
44
+
33
45
  it('parses JSON request bodies before sending to appClient.request', async () => {
34
46
  mocks.request.mockResolvedValue({ success: true });
35
47
 
package/src/api/client.ts CHANGED
@@ -3,13 +3,14 @@ import type { ApiResponse } from './types';
3
3
 
4
4
  export async function requestApiResponse<T>(
5
5
  endpoint: string,
6
- options: RequestInit = {}
6
+ options: RequestInit & { timeoutMs?: number } = {}
7
7
  ): Promise<ApiResponse<T>> {
8
8
  const method = (options.method || 'GET').toUpperCase();
9
9
  try {
10
10
  const data = await appClient.request<T>({
11
11
  method: method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
12
12
  path: endpoint,
13
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
13
14
  ...(options.body !== undefined ? { body: parseRequestBody(options.body) } : {})
14
15
  });
15
16
  return {
@@ -32,7 +33,8 @@ export async function requestApiResponse<T>(
32
33
  }
33
34
 
34
35
  export const api = {
35
- get: <T>(path: string) => requestApiResponse<T>(path, { method: 'GET' }),
36
+ get: <T>(path: string, options: RequestInit & { timeoutMs?: number } = {}) =>
37
+ requestApiResponse<T>(path, { ...options, method: 'GET' }),
36
38
  put: <T>(path: string, body: unknown) =>
37
39
  requestApiResponse<T>(path, {
38
40
  method: 'PUT',
package/src/api/config.ts CHANGED
@@ -54,7 +54,7 @@ import type {
54
54
 
55
55
  // GET /api/auth/status
56
56
  export async function fetchAuthStatus(): Promise<AuthStatusView> {
57
- const response = await api.get<AuthStatusView>('/api/auth/status');
57
+ const response = await api.get<AuthStatusView>('/api/auth/status', { timeoutMs: 5_000 });
58
58
  if (!response.ok) {
59
59
  throw new Error(response.error.message);
60
60
  }
@@ -5,7 +5,7 @@ import { BrandHeader } from '@/components/common/BrandHeader';
5
5
  import { StatusBadge } from '@/components/common/StatusBadge';
6
6
  import { Input } from '@/components/ui/input';
7
7
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
8
- import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
8
+ import { SelectItem } from '@/components/ui/select';
9
9
  import { ChatSidebarSessionItem } from '@/components/chat/chat-sidebar-session-item';
10
10
  import { useChatSessionLabelService } from '@/components/chat/chat-session-label.service';
11
11
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
@@ -19,8 +19,9 @@ import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
19
19
  import { useI18n } from '@/components/providers/I18nProvider';
20
20
  import { useTheme } from '@/components/providers/ThemeProvider';
21
21
  import { useDocBrowser } from '@/components/doc-browser';
22
+ import { SidebarActionItem, SidebarNavLinkItem, SidebarSelectItem } from '@/components/layout/sidebar-items';
22
23
  import { useUiStore } from '@/stores/ui.store';
23
- import { NavLink, useLocation } from 'react-router-dom';
24
+ import { useLocation } from 'react-router-dom';
24
25
  import {
25
26
  AlarmClock,
26
27
  BookOpen,
@@ -284,28 +285,14 @@ export function ChatSidebar() {
284
285
  <div className="px-3 pb-2">
285
286
  <ul className="space-y-0.5">
286
287
  {navItems.map((item) => {
287
- const Icon = item.icon;
288
288
  return (
289
289
  <li key={item.target}>
290
- <NavLink
290
+ <SidebarNavLinkItem
291
291
  to={item.target}
292
- className={({ isActive }) => cn(
293
- 'group w-full flex items-center gap-3 px-3 py-2 rounded-xl text-[13px] font-medium transition-all duration-150',
294
- isActive
295
- ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
296
- : 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900'
297
- )}
298
- >
299
- {({ isActive }) => (
300
- <>
301
- <Icon className={cn(
302
- 'h-4 w-4 transition-colors',
303
- isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800'
304
- )} />
305
- <span>{item.label()}</span>
306
- </>
307
- )}
308
- </NavLink>
292
+ label={item.label()}
293
+ icon={item.icon}
294
+ density="compact"
295
+ />
309
296
  </li>
310
297
  );
311
298
  })}
@@ -363,57 +350,42 @@ export function ChatSidebar() {
363
350
  </div>
364
351
 
365
352
  <div className="px-3 py-3 border-t border-gray-200/60 space-y-0.5">
366
- <NavLink
353
+ <SidebarNavLinkItem
367
354
  to="/settings"
368
- className={({ isActive }) => cn(
369
- 'group w-full flex items-center gap-2.5 px-3 py-2 rounded-xl text-[13px] font-medium transition-all duration-150',
370
- isActive
371
- ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
372
- : 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900'
373
- )}
374
- >
375
- {({ isActive }) => (
376
- <>
377
- <Settings className={cn('h-4 w-4 transition-colors', isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800')} />
378
- <span>{t('settings')}</span>
379
- </>
380
- )}
381
- </NavLink>
382
- <button
355
+ label={t('settings')}
356
+ icon={Settings}
357
+ density="compact"
358
+ />
359
+ <SidebarActionItem
383
360
  onClick={() => docBrowser.open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })}
384
- className="w-full flex items-center gap-2.5 px-3 py-2 rounded-xl text-[13px] font-medium transition-all duration-150 text-gray-600 hover:bg-gray-200/60 hover:text-gray-800"
361
+ icon={BookOpen}
362
+ label={t('docBrowserHelp')}
363
+ density="compact"
364
+ />
365
+ <SidebarSelectItem
366
+ value={theme}
367
+ onValueChange={(value) => setTheme(value as UiTheme)}
368
+ icon={Palette}
369
+ label={t('theme')}
370
+ valueLabel={currentThemeLabel}
371
+ density="compact"
385
372
  >
386
- <BookOpen className="h-4 w-4 text-gray-400" />
387
- <span>{t('docBrowserHelp')}</span>
388
- </button>
389
- <Select value={theme} onValueChange={(value) => setTheme(value as UiTheme)}>
390
- <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">
391
- <div className="flex items-center gap-2.5 min-w-0">
392
- <Palette className="h-4 w-4 text-gray-400" />
393
- <span>{t('theme')}</span>
394
- </div>
395
- <span className="ml-auto text-[11px] text-gray-500">{currentThemeLabel}</span>
396
- </SelectTrigger>
397
- <SelectContent>
398
- {THEME_OPTIONS.map((option) => (
399
- <SelectItem key={option.value} value={option.value} className="text-xs">{t(option.labelKey)}</SelectItem>
400
- ))}
401
- </SelectContent>
402
- </Select>
403
- <Select value={language} onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}>
404
- <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">
405
- <div className="flex items-center gap-2.5 min-w-0">
406
- <Languages className="h-4 w-4 text-gray-400" />
407
- <span>{t('language')}</span>
408
- </div>
409
- <span className="ml-auto text-[11px] text-gray-500">{currentLanguageLabel}</span>
410
- </SelectTrigger>
411
- <SelectContent>
412
- {LANGUAGE_OPTIONS.map((option) => (
413
- <SelectItem key={option.value} value={option.value} className="text-xs">{option.label}</SelectItem>
414
- ))}
415
- </SelectContent>
416
- </Select>
373
+ {THEME_OPTIONS.map((option) => (
374
+ <SelectItem key={option.value} value={option.value} className="text-xs">{t(option.labelKey)}</SelectItem>
375
+ ))}
376
+ </SidebarSelectItem>
377
+ <SidebarSelectItem
378
+ value={language}
379
+ onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}
380
+ icon={Languages}
381
+ label={t('language')}
382
+ valueLabel={currentLanguageLabel}
383
+ density="compact"
384
+ >
385
+ {LANGUAGE_OPTIONS.map((option) => (
386
+ <SelectItem key={option.value} value={option.value} className="text-xs">{option.label}</SelectItem>
387
+ ))}
388
+ </SidebarSelectItem>
417
389
  </div>
418
390
  </aside>
419
391
  );
@@ -1,4 +1,9 @@
1
- import { buildChatSlashItems, buildSelectedSkillItems, buildSkillPickerModel } from '@/components/chat/adapters/chat-input-bar.adapter';
1
+ import {
2
+ buildChatSlashItems,
3
+ buildModelToolbarSelect,
4
+ buildSelectedSkillItems,
5
+ buildSkillPickerModel
6
+ } from '@/components/chat/adapters/chat-input-bar.adapter';
2
7
  import type { ChatSkillRecord } from '@/components/chat/adapters/chat-input-bar.adapter';
3
8
 
4
9
  function createSkillRecord(partial: Partial<ChatSkillRecord>): ChatSkillRecord {
@@ -78,3 +83,29 @@ describe('buildSkillPickerModel', () => {
78
83
  });
79
84
  });
80
85
  });
86
+
87
+ describe('buildModelToolbarSelect', () => {
88
+ it('falls back to the first available option when the selected model is missing', () => {
89
+ const onValueChange = vi.fn();
90
+ const select = buildModelToolbarSelect({
91
+ modelOptions: [
92
+ {
93
+ value: 'minimax/MiniMax-M2.7',
94
+ modelLabel: 'MiniMax-M2.7',
95
+ providerLabel: 'MiniMax'
96
+ }
97
+ ],
98
+ selectedModel: 'dashscope/qwen3-coder-next',
99
+ isModelOptionsLoading: false,
100
+ hasModelOptions: true,
101
+ onValueChange,
102
+ texts: {
103
+ modelSelectPlaceholder: 'Select model',
104
+ modelNoOptionsLabel: 'No models'
105
+ }
106
+ });
107
+
108
+ expect(select.value).toBe('minimax/MiniMax-M2.7');
109
+ expect(select.selectedLabel).toBe('MiniMax/MiniMax-M2.7');
110
+ });
111
+ });
@@ -238,13 +238,16 @@ export function buildModelToolbarSelect(params: {
238
238
  texts: Pick<ChatInputBarAdapterTexts, 'modelSelectPlaceholder' | 'modelNoOptionsLabel'>;
239
239
  }): ChatToolbarSelect {
240
240
  const selectedModelOption = params.modelOptions.find((option) => option.value === params.selectedModel);
241
+ const fallbackModelOption = params.modelOptions[0];
242
+ const resolvedModelOption = selectedModelOption ?? fallbackModelOption;
243
+ const resolvedValue = params.hasModelOptions ? resolvedModelOption?.value : undefined;
241
244
 
242
245
  return {
243
246
  key: 'model',
244
- value: params.hasModelOptions ? params.selectedModel : undefined,
247
+ value: resolvedValue,
245
248
  placeholder: params.texts.modelSelectPlaceholder,
246
- selectedLabel: selectedModelOption
247
- ? `${selectedModelOption.providerLabel}/${selectedModelOption.modelLabel}`
249
+ selectedLabel: resolvedModelOption
250
+ ? `${resolvedModelOption.providerLabel}/${resolvedModelOption.modelLabel}`
248
251
  : undefined,
249
252
  icon: 'sparkles',
250
253
  options: params.modelOptions.map((option) => ({
@@ -6,183 +6,161 @@ function toSource(uiMessages: UiMessage[]): ChatMessageSource[] {
6
6
  return uiMessages as unknown as ChatMessageSource[];
7
7
  }
8
8
 
9
- describe("adaptChatMessages", () => {
10
- it("maps markdown, reasoning, and tool parts into UI view models", () => {
11
- const messages: UiMessage[] = [
12
- {
13
- id: "assistant-1",
14
- role: "assistant",
15
- meta: {
16
- status: "final",
17
- timestamp: "2026-03-17T10:00:00.000Z",
18
- },
19
- parts: [
20
- { type: "text", text: "hello world" },
21
- {
22
- type: "reasoning",
23
- reasoning: "internal reasoning",
24
- details: [],
25
- },
26
- {
27
- type: "tool-invocation",
28
- toolInvocation: {
29
- status: ToolInvocationStatus.RESULT,
30
- toolCallId: "call-1",
31
- toolName: "web_search",
32
- args: '{"q":"hello"}',
33
- result: { ok: true },
34
- },
35
- },
36
- ],
37
- },
38
- ];
9
+ const defaultTexts = {
10
+ roleLabels: {
11
+ user: "You",
12
+ assistant: "Assistant",
13
+ tool: "Tool",
14
+ system: "System",
15
+ fallback: "Message",
16
+ },
17
+ reasoningLabel: "Reasoning",
18
+ toolCallLabel: "Tool Call",
19
+ toolResultLabel: "Tool Result",
20
+ toolNoOutputLabel: "No output",
21
+ toolOutputLabel: "View Output",
22
+ imageAttachmentLabel: "Image attachment",
23
+ fileAttachmentLabel: "File attachment",
24
+ unknownPartLabel: "Unknown Part",
25
+ };
39
26
 
40
- const adapted = adaptChatMessages({
41
- uiMessages: toSource(messages),
42
- formatTimestamp: (value) => `formatted:${value}`,
43
- texts: {
44
- roleLabels: {
45
- user: "You",
46
- assistant: "Assistant",
47
- tool: "Tool",
48
- system: "System",
49
- fallback: "Message",
50
- },
51
- reasoningLabel: "Reasoning",
52
- toolCallLabel: "Tool Call",
53
- toolResultLabel: "Tool Result",
54
- toolNoOutputLabel: "No output",
55
- toolOutputLabel: "View Output",
56
- unknownPartLabel: "Unknown Part",
57
- },
58
- });
59
-
60
- expect(adapted).toHaveLength(1);
61
- expect(adapted[0]?.roleLabel).toBe("Assistant");
62
- expect(adapted[0]?.timestampLabel).toBe(
63
- "formatted:2026-03-17T10:00:00.000Z",
64
- );
65
- expect(adapted[0]?.parts.map((part) => part.type)).toEqual([
66
- "markdown",
67
- "reasoning",
68
- "tool-card",
69
- ]);
70
- expect(adapted[0]?.parts[1]).toMatchObject({
71
- type: "reasoning",
72
- label: "Reasoning",
73
- text: "internal reasoning",
74
- });
75
- expect(adapted[0]?.parts[2]).toMatchObject({
76
- type: "tool-card",
77
- card: {
78
- titleLabel: "Tool Result",
79
- outputLabel: "View Output",
80
- },
81
- });
27
+ function adapt(uiMessages: ChatMessageSource[]) {
28
+ return adaptChatMessages({
29
+ uiMessages,
30
+ formatTimestamp: (value) => `formatted:${value}`,
31
+ texts: defaultTexts,
82
32
  });
33
+ }
83
34
 
84
- it("maps non-standard roles back to the generic message role", () => {
85
- const adapted = adaptChatMessages({
86
- uiMessages: [
35
+ it("maps markdown, reasoning, and tool parts into UI view models", () => {
36
+ const messages: UiMessage[] = [
37
+ {
38
+ id: "assistant-1",
39
+ role: "assistant",
40
+ meta: {
41
+ status: "final",
42
+ timestamp: "2026-03-17T10:00:00.000Z",
43
+ },
44
+ parts: [
45
+ { type: "text", text: "hello world" },
87
46
  {
88
- id: "data-1",
89
- role: "data",
90
- parts: [{ type: "text", text: "payload" }],
47
+ type: "reasoning",
48
+ reasoning: "internal reasoning",
49
+ details: [],
91
50
  },
92
- ] as unknown as ChatMessageSource[],
93
- formatTimestamp: () => "formatted",
94
- texts: {
95
- roleLabels: {
96
- user: "You",
97
- assistant: "Assistant",
98
- tool: "Tool",
99
- system: "System",
100
- fallback: "Message",
51
+ {
52
+ type: "tool-invocation",
53
+ toolInvocation: {
54
+ status: ToolInvocationStatus.RESULT,
55
+ toolCallId: "call-1",
56
+ toolName: "web_search",
57
+ args: '{"q":"hello"}',
58
+ result: { ok: true },
59
+ },
101
60
  },
102
- reasoningLabel: "Reasoning",
103
- toolCallLabel: "Tool Call",
104
- toolResultLabel: "Tool Result",
105
- toolNoOutputLabel: "No output",
106
- toolOutputLabel: "View Output",
107
- unknownPartLabel: "Unknown Part",
108
- },
109
- });
61
+ ],
62
+ },
63
+ ];
64
+
65
+ const adapted = adapt(toSource(messages));
66
+
67
+ expect(adapted).toHaveLength(1);
68
+ expect(adapted[0]?.roleLabel).toBe("Assistant");
69
+ expect(adapted[0]?.timestampLabel).toBe(
70
+ "formatted:2026-03-17T10:00:00.000Z",
71
+ );
72
+ expect(adapted[0]?.parts.map((part) => part.type)).toEqual([
73
+ "markdown",
74
+ "reasoning",
75
+ "tool-card",
76
+ ]);
77
+ expect(adapted[0]?.parts[1]).toMatchObject({
78
+ type: "reasoning",
79
+ label: "Reasoning",
80
+ text: "internal reasoning",
81
+ });
82
+ expect(adapted[0]?.parts[2]).toMatchObject({
83
+ type: "tool-card",
84
+ card: {
85
+ titleLabel: "Tool Result",
86
+ outputLabel: "View Output",
87
+ },
88
+ });
89
+ });
90
+
91
+ it("maps non-standard roles back to the generic message role", () => {
92
+ const adapted = adapt([
93
+ {
94
+ id: "data-1",
95
+ role: "data",
96
+ parts: [{ type: "text", text: "payload" }],
97
+ },
98
+ ] as unknown as ChatMessageSource[]);
110
99
 
111
- expect(adapted[0]?.role).toBe("message");
112
- expect(adapted[0]?.roleLabel).toBe("Message");
100
+ expect(adapted[0]?.role).toBe("message");
101
+ expect(adapted[0]?.roleLabel).toBe("Message");
102
+ });
103
+
104
+ it("maps unknown parts into a visible fallback part", () => {
105
+ const adapted = adapt([
106
+ {
107
+ id: "x-1",
108
+ role: "assistant",
109
+ parts: [{ type: "step-start", value: "x" }],
110
+ },
111
+ ] as unknown as ChatMessageSource[]);
112
+
113
+ expect(adapted[0]?.parts[0]).toMatchObject({
114
+ type: "unknown",
115
+ rawType: "step-start",
116
+ label: "Unknown Part",
113
117
  });
118
+ });
114
119
 
115
- it("maps unknown parts into a visible fallback part", () => {
116
- const adapted = adaptChatMessages({
117
- uiMessages: [
118
- {
119
- id: "x-1",
120
- role: "assistant",
121
- parts: [{ type: "step-start", value: "x" }],
122
- },
123
- ] as unknown as ChatMessageSource[],
124
- formatTimestamp: () => "formatted",
125
- texts: {
126
- roleLabels: {
127
- user: "You",
128
- assistant: "Assistant",
129
- tool: "Tool",
130
- system: "System",
131
- fallback: "Message",
132
- },
133
- reasoningLabel: "Reasoning",
134
- toolCallLabel: "Tool Call",
135
- toolResultLabel: "Tool Result",
136
- toolNoOutputLabel: "No output",
137
- toolOutputLabel: "View Output",
138
- unknownPartLabel: "Unknown Part",
139
- },
140
- });
120
+ it("drops empty and zero-width text parts during adaptation", () => {
121
+ const adapted = adapt([
122
+ {
123
+ id: "assistant-mixed",
124
+ role: "assistant",
125
+ parts: [
126
+ { type: "text", text: " " },
127
+ { type: "text", text: "\u200B\u200B" },
128
+ { type: "text", text: "\u200Bhello\u200B" },
129
+ ],
130
+ },
131
+ ] as unknown as ChatMessageSource[]);
141
132
 
142
- expect(adapted[0]?.parts[0]).toMatchObject({
143
- type: "unknown",
144
- rawType: "step-start",
145
- label: "Unknown Part",
146
- });
133
+ expect(adapted).toHaveLength(1);
134
+ expect(adapted[0]?.id).toBe("assistant-mixed");
135
+ expect(adapted[0]?.parts).toHaveLength(1);
136
+ expect(adapted[0]?.parts[0]).toMatchObject({
137
+ type: "markdown",
138
+ text: "\u200Bhello\u200B",
147
139
  });
140
+ });
148
141
 
149
- it("drops empty and zero-width text parts during adaptation", () => {
150
- const adapted = adaptChatMessages({
151
- uiMessages: [
142
+ it("maps file parts into previewable attachment view models", () => {
143
+ const adapted = adapt([
144
+ {
145
+ id: "assistant-file",
146
+ role: "assistant",
147
+ parts: [
152
148
  {
153
- id: "assistant-mixed",
154
- role: "assistant",
155
- parts: [
156
- { type: "text", text: " " },
157
- { type: "text", text: "\u200B\u200B" },
158
- { type: "text", text: "\u200Bhello\u200B" },
159
- ],
160
- },
161
- ] as unknown as ChatMessageSource[],
162
- formatTimestamp: () => "formatted",
163
- texts: {
164
- roleLabels: {
165
- user: "You",
166
- assistant: "Assistant",
167
- tool: "Tool",
168
- system: "System",
169
- fallback: "Message",
149
+ type: "file",
150
+ mimeType: "image/png",
151
+ data: "ZmFrZS1pbWFnZQ==",
170
152
  },
171
- reasoningLabel: "Reasoning",
172
- toolCallLabel: "Tool Call",
173
- toolResultLabel: "Tool Result",
174
- toolNoOutputLabel: "No output",
175
- toolOutputLabel: "View Output",
176
- unknownPartLabel: "Unknown Part",
177
- },
178
- });
153
+ ],
154
+ },
155
+ ] as unknown as ChatMessageSource[]);
179
156
 
180
- expect(adapted).toHaveLength(1);
181
- expect(adapted[0]?.id).toBe("assistant-mixed");
182
- expect(adapted[0]?.parts).toHaveLength(1);
183
- expect(adapted[0]?.parts[0]).toMatchObject({
184
- type: "markdown",
185
- text: "\u200Bhello\u200B",
186
- });
157
+ expect(adapted[0]?.parts[0]).toEqual({
158
+ type: "file",
159
+ file: {
160
+ label: "Image attachment",
161
+ mimeType: "image/png",
162
+ dataUrl: "data:image/png;base64,ZmFrZS1pbWFnZQ==",
163
+ isImage: true,
164
+ },
187
165
  });
188
166
  });