@nextclaw/ui 0.7.0 → 0.9.0

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 (80) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assets/ChannelsList-C7F_As4r.js +1 -0
  3. package/dist/assets/ChatPage-Oo7-OUsx.js +37 -0
  4. package/dist/assets/{DocBrowser-B9ws5JL7.js → DocBrowser-Dsd8Dlq8.js} +1 -1
  5. package/dist/assets/{LogoBadge-DvGAzkZ3.js → LogoBadge-2ChEc_oz.js} +1 -1
  6. package/dist/assets/MarketplacePage-BXck6-X3.js +49 -0
  7. package/dist/assets/{ModelConfig-BL_HsOsm.js → ModelConfig-CgHRSD0b.js} +1 -1
  8. package/dist/assets/ProvidersList-PPfZucvS.js +1 -0
  9. package/dist/assets/RuntimeConfig-ClLEKNTN.js +1 -0
  10. package/dist/assets/{SearchConfig-BhaI0fUf.js → SearchConfig-CuXVCbrf.js} +1 -1
  11. package/dist/assets/{SecretsConfig-CFoimOh9.js → SecretsConfig-udJz6Ake.js} +2 -2
  12. package/dist/assets/SessionsConfig-C1XnFfiC.js +2 -0
  13. package/dist/assets/{session-run-status-TkIuGbVw.js → chat-message-BETwXLD4.js} +3 -3
  14. package/dist/assets/{index-uMsNsQX6.js → index-COJdlL0e.js} +1 -1
  15. package/dist/assets/index-CsvP4CER.js +8 -0
  16. package/dist/assets/index-D-bXl7qL.css +1 -0
  17. package/dist/assets/{label-D8ly4a2P.js → label-BGL-ztxh.js} +1 -1
  18. package/dist/assets/{page-layout-BSYfvwbp.js → page-layout-aw88k7tG.js} +1 -1
  19. package/dist/assets/popover-DyEvzhmV.js +1 -0
  20. package/dist/assets/security-config-BuPAQn82.js +1 -0
  21. package/dist/assets/skeleton-drzO_tdU.js +1 -0
  22. package/dist/assets/{switch-Ce_g9lpN.js → switch-BK8jIzto.js} +1 -1
  23. package/dist/assets/{tabs-custom-Cf5azvT5.js → tabs-custom-Da3cEOji.js} +1 -1
  24. package/dist/assets/{useConfirmDialog-A8Ek8Wu7.js → useConfirmDialog-z0CE92iS.js} +2 -2
  25. package/dist/assets/{vendor-B7ozqnFC.js → vendor-CkJHmX1g.js} +65 -70
  26. package/dist/index.html +3 -3
  27. package/package.json +5 -2
  28. package/src/api/config.ts +9 -0
  29. package/src/api/ncp-session.ts +50 -0
  30. package/src/api/types.ts +20 -0
  31. package/src/components/chat/ChatConversationPanel.test.tsx +65 -0
  32. package/src/components/chat/ChatConversationPanel.tsx +21 -12
  33. package/src/components/chat/ChatPage.tsx +10 -324
  34. package/src/components/chat/ChatSidebar.test.tsx +203 -0
  35. package/src/components/chat/ChatSidebar.tsx +97 -7
  36. package/src/components/chat/adapters/chat-message.adapter.test.ts +132 -81
  37. package/src/components/chat/adapters/chat-message.adapter.ts +27 -9
  38. package/src/components/chat/chat-chain.test.ts +22 -0
  39. package/src/components/chat/chat-chain.ts +23 -0
  40. package/src/components/chat/chat-page-data.ts +30 -1
  41. package/src/components/chat/chat-page-runtime.test.ts +181 -0
  42. package/src/components/chat/chat-page-runtime.ts +101 -15
  43. package/src/components/chat/chat-page-shell.tsx +103 -0
  44. package/src/components/chat/chat-session-preference-sync.test.ts +62 -0
  45. package/src/components/chat/chat-session-preference-sync.ts +75 -0
  46. package/src/components/chat/containers/chat-input-bar.container.tsx +0 -22
  47. package/src/components/chat/containers/chat-message-list.container.tsx +34 -26
  48. package/src/components/chat/legacy/LegacyChatPage.tsx +252 -0
  49. package/src/components/chat/managers/chat-input.manager.ts +5 -0
  50. package/src/components/chat/managers/chat-session-list.manager.test.ts +39 -0
  51. package/src/components/chat/managers/chat-session-list.manager.ts +9 -3
  52. package/src/components/chat/ncp/NcpChatPage.tsx +381 -0
  53. package/src/components/chat/ncp/ncp-chat-input.manager.ts +179 -0
  54. package/src/components/chat/ncp/ncp-chat-page-data.ts +166 -0
  55. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +89 -0
  56. package/src/components/chat/ncp/ncp-chat.presenter.ts +33 -0
  57. package/src/components/chat/ncp/ncp-session-adapter.test.ts +75 -0
  58. package/src/components/chat/ncp/ncp-session-adapter.ts +214 -0
  59. package/src/components/chat/presenter/chat-presenter-context.tsx +43 -4
  60. package/src/components/chat/stores/chat-thread.store.ts +2 -0
  61. package/src/components/chat/useChatSessionTypeState.test.tsx +58 -0
  62. package/src/components/chat/useChatSessionTypeState.ts +25 -8
  63. package/src/hooks/use-ncp-chat-session-types.ts +11 -0
  64. package/src/hooks/useConfig.ts +41 -1
  65. package/src/hooks/useMarketplace.ts +7 -4
  66. package/src/hooks/useWebSocket.ts +23 -2
  67. package/src/lib/i18n.ts +1 -1
  68. package/tailwind.config.js +8 -3
  69. package/tsconfig.json +4 -1
  70. package/dist/assets/ChannelsList-DF2U-LY1.js +0 -1
  71. package/dist/assets/ChatPage-BX39y0U5.js +0 -36
  72. package/dist/assets/MarketplacePage-DG5mHWJ8.js +0 -49
  73. package/dist/assets/ProvidersList-CH5z00YT.js +0 -1
  74. package/dist/assets/RuntimeConfig-BplBgkwo.js +0 -1
  75. package/dist/assets/SessionsConfig-BHTAYn9T.js +0 -2
  76. package/dist/assets/index-BLeJkJ0o.css +0 -1
  77. package/dist/assets/index-DK4TS5ev.js +0 -8
  78. package/dist/assets/index-X5J6Mm--.js +0 -1
  79. package/dist/assets/security-config-DlKEYHNN.js +0 -1
  80. package/dist/assets/skeleton-CWbsNx2h.js +0 -1
@@ -0,0 +1,203 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { MemoryRouter } from 'react-router-dom';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { ChatSidebar } from '@/components/chat/ChatSidebar';
5
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
6
+ import { useChatRunStatusStore } from '@/components/chat/stores/chat-run-status.store';
7
+ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
8
+
9
+ const mocks = vi.hoisted(() => ({
10
+ createSession: vi.fn(),
11
+ setQuery: vi.fn(),
12
+ selectSession: vi.fn(),
13
+ docOpen: vi.fn()
14
+ }));
15
+
16
+ vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
17
+ usePresenter: () => ({
18
+ chatSessionListManager: {
19
+ createSession: mocks.createSession,
20
+ setQuery: mocks.setQuery,
21
+ selectSession: mocks.selectSession
22
+ }
23
+ })
24
+ }));
25
+
26
+ vi.mock('@/components/doc-browser', () => ({
27
+ useDocBrowser: () => ({
28
+ open: mocks.docOpen
29
+ })
30
+ }));
31
+
32
+ vi.mock('@/components/common/BrandHeader', () => ({
33
+ BrandHeader: () => <div data-testid="brand-header" />
34
+ }));
35
+
36
+ vi.mock('@/components/common/StatusBadge', () => ({
37
+ StatusBadge: () => <div data-testid="status-badge" />
38
+ }));
39
+
40
+ vi.mock('@/components/providers/I18nProvider', () => ({
41
+ useI18n: () => ({
42
+ language: 'en',
43
+ setLanguage: vi.fn()
44
+ })
45
+ }));
46
+
47
+ vi.mock('@/components/providers/ThemeProvider', () => ({
48
+ useTheme: () => ({
49
+ theme: 'warm',
50
+ setTheme: vi.fn()
51
+ })
52
+ }));
53
+
54
+ vi.mock('@/stores/ui.store', () => ({
55
+ useUiStore: (selector: (state: { connectionStatus: string }) => unknown) =>
56
+ selector({ connectionStatus: 'connected' })
57
+ }));
58
+
59
+ describe('ChatSidebar', () => {
60
+ beforeEach(() => {
61
+ mocks.createSession.mockReset();
62
+ mocks.setQuery.mockReset();
63
+ mocks.selectSession.mockReset();
64
+ mocks.docOpen.mockReset();
65
+
66
+ useChatInputStore.setState({
67
+ snapshot: {
68
+ ...useChatInputStore.getState().snapshot,
69
+ defaultSessionType: 'native',
70
+ sessionTypeOptions: [
71
+ { value: 'native', label: 'Native' },
72
+ { value: 'codex', label: 'Codex' }
73
+ ]
74
+ }
75
+ });
76
+ useChatSessionListStore.setState({
77
+ snapshot: {
78
+ ...useChatSessionListStore.getState().snapshot,
79
+ sessions: [],
80
+ query: '',
81
+ isLoading: false
82
+ }
83
+ });
84
+ useChatRunStatusStore.setState({
85
+ snapshot: {
86
+ ...useChatRunStatusStore.getState().snapshot,
87
+ sessionRunStatusByKey: new Map()
88
+ }
89
+ });
90
+ });
91
+
92
+ it('closes the create-session menu after choosing a non-default session type', async () => {
93
+ render(
94
+ <MemoryRouter>
95
+ <ChatSidebar />
96
+ </MemoryRouter>
97
+ );
98
+
99
+ fireEvent.click(screen.getByLabelText('Session Type'));
100
+ fireEvent.click(screen.getByText('Codex'));
101
+
102
+ expect(mocks.createSession).toHaveBeenCalledWith('codex');
103
+ await waitFor(() => {
104
+ expect(screen.queryByText('Codex')).toBeNull();
105
+ });
106
+ });
107
+
108
+ it('shows a session type badge for non-native sessions in the list', () => {
109
+ useChatSessionListStore.setState({
110
+ snapshot: {
111
+ ...useChatSessionListStore.getState().snapshot,
112
+ sessions: [
113
+ {
114
+ key: 'session:codex-1',
115
+ createdAt: '2026-03-19T09:00:00.000Z',
116
+ updatedAt: '2026-03-19T09:05:00.000Z',
117
+ label: 'Codex Task',
118
+ sessionType: 'codex',
119
+ sessionTypeMutable: false,
120
+ messageCount: 2
121
+ }
122
+ ]
123
+ }
124
+ });
125
+
126
+ render(
127
+ <MemoryRouter>
128
+ <ChatSidebar />
129
+ </MemoryRouter>
130
+ );
131
+
132
+ expect(screen.getByText('Codex Task')).not.toBeNull();
133
+ expect(screen.getByText('Codex')).not.toBeNull();
134
+ });
135
+
136
+ it('formats non-native session badges generically when the type is no longer in the available options', () => {
137
+ useChatInputStore.setState({
138
+ snapshot: {
139
+ ...useChatInputStore.getState().snapshot,
140
+ sessionTypeOptions: [{ value: 'native', label: 'Native' }]
141
+ }
142
+ });
143
+ useChatSessionListStore.setState({
144
+ snapshot: {
145
+ ...useChatSessionListStore.getState().snapshot,
146
+ sessions: [
147
+ {
148
+ key: 'session:workspace-agent-1',
149
+ createdAt: '2026-03-19T09:00:00.000Z',
150
+ updatedAt: '2026-03-19T09:05:00.000Z',
151
+ label: 'Workspace Task',
152
+ sessionType: 'workspace-agent',
153
+ sessionTypeMutable: false,
154
+ messageCount: 2
155
+ }
156
+ ]
157
+ }
158
+ });
159
+
160
+ render(
161
+ <MemoryRouter>
162
+ <ChatSidebar />
163
+ </MemoryRouter>
164
+ );
165
+
166
+ expect(screen.getByText('Workspace Task')).not.toBeNull();
167
+ expect(screen.getByText('Workspace Agent')).not.toBeNull();
168
+ });
169
+
170
+ it('does not show a session type badge for native sessions in the list', () => {
171
+ useChatInputStore.setState({
172
+ snapshot: {
173
+ ...useChatInputStore.getState().snapshot,
174
+ sessionTypeOptions: [{ value: 'native', label: 'Native' }]
175
+ }
176
+ });
177
+ useChatSessionListStore.setState({
178
+ snapshot: {
179
+ ...useChatSessionListStore.getState().snapshot,
180
+ sessions: [
181
+ {
182
+ key: 'session:native-1',
183
+ createdAt: '2026-03-19T09:00:00.000Z',
184
+ updatedAt: '2026-03-19T09:05:00.000Z',
185
+ label: 'Native Task',
186
+ sessionType: 'native',
187
+ sessionTypeMutable: false,
188
+ messageCount: 1
189
+ }
190
+ ]
191
+ }
192
+ });
193
+
194
+ render(
195
+ <MemoryRouter>
196
+ <ChatSidebar />
197
+ </MemoryRouter>
198
+ );
199
+
200
+ expect(screen.getByText('Native Task')).not.toBeNull();
201
+ expect(screen.queryByText('Native')).toBeNull();
202
+ });
203
+ });
@@ -1,12 +1,14 @@
1
- import { useMemo } from 'react';
1
+ import { useMemo, useState } from 'react';
2
2
  import type { SessionEntryView } from '@/api/types';
3
3
  import { Button } from '@/components/ui/button';
4
4
  import { BrandHeader } from '@/components/common/BrandHeader';
5
5
  import { StatusBadge } from '@/components/common/StatusBadge';
6
6
  import { Input } from '@/components/ui/input';
7
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
7
8
  import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
8
9
  import { SessionRunBadge } from '@/components/common/SessionRunBadge';
9
10
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
11
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
10
12
  import { useChatRunStatusStore } from '@/components/chat/stores/chat-run-status.store';
11
13
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
12
14
  import { cn } from '@/lib/utils';
@@ -17,7 +19,7 @@ import { useTheme } from '@/components/providers/ThemeProvider';
17
19
  import { useDocBrowser } from '@/components/doc-browser';
18
20
  import { useUiStore } from '@/stores/ui.store';
19
21
  import { NavLink } from 'react-router-dom';
20
- import { AlarmClock, BookOpen, BrainCircuit, Languages, MessageSquareText, Palette, Plus, Search, Settings } from 'lucide-react';
22
+ import { AlarmClock, BookOpen, BrainCircuit, ChevronDown, Languages, MessageSquareText, Palette, Plus, Search, Settings } from 'lucide-react';
21
23
 
22
24
  type DateGroup = {
23
25
  label: string;
@@ -64,6 +66,25 @@ function sessionTitle(session: SessionEntryView): string {
64
66
  return chunks[chunks.length - 1] || session.key;
65
67
  }
66
68
 
69
+ function resolveSessionTypeLabel(
70
+ sessionType: string,
71
+ options: Array<{ value: string; label: string }>
72
+ ): string | null {
73
+ const normalized = sessionType.trim().toLowerCase();
74
+ if (!normalized || normalized === 'native') {
75
+ return null;
76
+ }
77
+ const matchedOption = options.find((option) => option.value.trim().toLowerCase() === normalized);
78
+ if (matchedOption?.label.trim()) {
79
+ return matchedOption.label.trim();
80
+ }
81
+ return normalized
82
+ .split(/[-_]+/g)
83
+ .filter(Boolean)
84
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
85
+ .join(' ');
86
+ }
87
+
67
88
  const navItems = [
68
89
  { target: '/cron', label: () => t('chatSidebarScheduledTasks'), icon: AlarmClock },
69
90
  { target: '/skills', label: () => t('chatSidebarSkills'), icon: BrainCircuit },
@@ -72,6 +93,8 @@ const navItems = [
72
93
  export function ChatSidebar() {
73
94
  const presenter = usePresenter();
74
95
  const docBrowser = useDocBrowser();
96
+ const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
97
+ const inputSnapshot = useChatInputStore((state) => state.snapshot);
75
98
  const listSnapshot = useChatSessionListStore((state) => state.snapshot);
76
99
  const runSnapshot = useChatRunStatusStore((state) => state.snapshot);
77
100
  const connectionStatus = useUiStore((state) => state.connectionStatus);
@@ -81,6 +104,11 @@ export function ChatSidebar() {
81
104
  const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
82
105
 
83
106
  const groups = useMemo(() => groupSessionsByDate(listSnapshot.sessions), [listSnapshot.sessions]);
107
+ const defaultSessionType = inputSnapshot.defaultSessionType || 'native';
108
+ const nonDefaultSessionTypeOptions = useMemo(
109
+ () => inputSnapshot.sessionTypeOptions.filter((option) => option.value !== defaultSessionType),
110
+ [defaultSessionType, inputSnapshot.sessionTypeOptions]
111
+ );
84
112
 
85
113
  const handleLanguageSwitch = (nextLang: I18nLanguage) => {
86
114
  if (language === nextLang) return;
@@ -98,10 +126,57 @@ export function ChatSidebar() {
98
126
  </div>
99
127
 
100
128
  <div className="px-4 pb-3">
101
- <Button variant="primary" className="w-full rounded-xl" onClick={presenter.chatSessionListManager.createSession}>
102
- <Plus className="h-4 w-4 mr-2" />
103
- {t('chatSidebarNewTask')}
104
- </Button>
129
+ <div className="flex items-center gap-2">
130
+ <Button
131
+ variant="primary"
132
+ className={cn(
133
+ 'min-w-0 rounded-xl',
134
+ nonDefaultSessionTypeOptions.length > 0 ? 'flex-1 rounded-r-md' : 'w-full'
135
+ )}
136
+ onClick={() => {
137
+ setIsCreateMenuOpen(false);
138
+ presenter.chatSessionListManager.createSession(defaultSessionType);
139
+ }}
140
+ >
141
+ <Plus className="h-4 w-4 mr-2" />
142
+ {t('chatSidebarNewTask')}
143
+ </Button>
144
+ {nonDefaultSessionTypeOptions.length > 0 ? (
145
+ <Popover open={isCreateMenuOpen} onOpenChange={setIsCreateMenuOpen}>
146
+ <PopoverTrigger asChild>
147
+ <Button
148
+ variant="primary"
149
+ size="icon"
150
+ className="h-9 w-10 shrink-0 rounded-xl rounded-l-md"
151
+ aria-label={t('chatSessionTypeLabel')}
152
+ >
153
+ <ChevronDown className="h-4 w-4" />
154
+ </Button>
155
+ </PopoverTrigger>
156
+ <PopoverContent align="end" className="w-64 p-2">
157
+ <div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wider text-gray-400">
158
+ {t('chatSessionTypeLabel')}
159
+ </div>
160
+ <div className="mt-1 space-y-1">
161
+ {nonDefaultSessionTypeOptions.map((option) => (
162
+ <button
163
+ key={option.value}
164
+ type="button"
165
+ onClick={() => {
166
+ presenter.chatSessionListManager.createSession(option.value);
167
+ setIsCreateMenuOpen(false);
168
+ }}
169
+ className="w-full rounded-xl px-3 py-2 text-left transition-colors hover:bg-gray-100"
170
+ >
171
+ <div className="text-[13px] font-medium text-gray-900">{option.label}</div>
172
+ <div className="mt-0.5 text-[11px] text-gray-500">{t('chatSidebarNewTask')}</div>
173
+ </button>
174
+ ))}
175
+ </div>
176
+ </PopoverContent>
177
+ </Popover>
178
+ ) : null}
179
+ </div>
105
180
  </div>
106
181
 
107
182
  <div className="px-4 pb-3">
@@ -168,6 +243,7 @@ export function ChatSidebar() {
168
243
  {group.sessions.map((session) => {
169
244
  const active = listSnapshot.selectedSessionKey === session.key;
170
245
  const runStatus = runSnapshot.sessionRunStatusByKey.get(session.key);
246
+ const sessionTypeLabel = resolveSessionTypeLabel(session.sessionType, inputSnapshot.sessionTypeOptions);
171
247
  return (
172
248
  <button
173
249
  key={session.key}
@@ -180,7 +256,21 @@ export function ChatSidebar() {
180
256
  )}
181
257
  >
182
258
  <div className="grid grid-cols-[minmax(0,1fr)_0.875rem] items-center gap-1.5">
183
- <span className="truncate font-medium">{sessionTitle(session)}</span>
259
+ <span className="flex min-w-0 items-center gap-1.5">
260
+ <span className="truncate font-medium">{sessionTitle(session)}</span>
261
+ {sessionTypeLabel ? (
262
+ <span
263
+ className={cn(
264
+ 'shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-semibold leading-none',
265
+ active
266
+ ? 'border-gray-300 bg-white/80 text-gray-700'
267
+ : 'border-gray-200 bg-gray-100 text-gray-500'
268
+ )}
269
+ >
270
+ {sessionTypeLabel}
271
+ </span>
272
+ ) : null}
273
+ </span>
184
274
  <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
185
275
  {runStatus ? <SessionRunBadge status={runStatus} /> : null}
186
276
  </span>
@@ -1,40 +1,40 @@
1
- import { ToolInvocationStatus, type UiMessage } from '@nextclaw/agent-chat';
2
- import { adaptChatMessages } from '@/components/chat/adapters/chat-message.adapter';
3
- import type { ChatMessageSource } from '@/components/chat/adapters/chat-message.adapter';
1
+ import { ToolInvocationStatus, type UiMessage } from "@nextclaw/agent-chat";
2
+ import { adaptChatMessages } from "@/components/chat/adapters/chat-message.adapter";
3
+ import type { ChatMessageSource } from "@/components/chat/adapters/chat-message.adapter";
4
4
 
5
5
  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', () => {
9
+ describe("adaptChatMessages", () => {
10
+ it("maps markdown, reasoning, and tool parts into UI view models", () => {
11
11
  const messages: UiMessage[] = [
12
12
  {
13
- id: 'assistant-1',
14
- role: 'assistant',
13
+ id: "assistant-1",
14
+ role: "assistant",
15
15
  meta: {
16
- status: 'final',
17
- timestamp: '2026-03-17T10:00:00.000Z'
16
+ status: "final",
17
+ timestamp: "2026-03-17T10:00:00.000Z",
18
18
  },
19
19
  parts: [
20
- { type: 'text', text: 'hello world' },
20
+ { type: "text", text: "hello world" },
21
21
  {
22
- type: 'reasoning',
23
- reasoning: 'internal reasoning',
24
- details: []
22
+ type: "reasoning",
23
+ reasoning: "internal reasoning",
24
+ details: [],
25
25
  },
26
26
  {
27
- type: 'tool-invocation',
27
+ type: "tool-invocation",
28
28
  toolInvocation: {
29
29
  status: ToolInvocationStatus.RESULT,
30
- toolCallId: 'call-1',
31
- toolName: 'web_search',
30
+ toolCallId: "call-1",
31
+ toolName: "web_search",
32
32
  args: '{"q":"hello"}',
33
- result: { ok: true }
34
- }
35
- }
36
- ]
37
- }
33
+ result: { ok: true },
34
+ },
35
+ },
36
+ ],
37
+ },
38
38
  ];
39
39
 
40
40
  const adapted = adaptChatMessages({
@@ -42,96 +42,147 @@ describe('adaptChatMessages', () => {
42
42
  formatTimestamp: (value) => `formatted:${value}`,
43
43
  texts: {
44
44
  roleLabels: {
45
- user: 'You',
46
- assistant: 'Assistant',
47
- tool: 'Tool',
48
- system: 'System',
49
- fallback: 'Message'
45
+ user: "You",
46
+ assistant: "Assistant",
47
+ tool: "Tool",
48
+ system: "System",
49
+ fallback: "Message",
50
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
- }
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
58
  });
59
59
 
60
60
  expect(adapted).toHaveLength(1);
61
- expect(adapted[0]?.roleLabel).toBe('Assistant');
62
- expect(adapted[0]?.timestampLabel).toBe('formatted:2026-03-17T10:00:00.000Z');
63
- expect(adapted[0]?.parts.map((part) => part.type)).toEqual(['markdown', 'reasoning', 'tool-card']);
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
+ });
64
75
  expect(adapted[0]?.parts[2]).toMatchObject({
65
- type: 'tool-card',
76
+ type: "tool-card",
66
77
  card: {
67
- titleLabel: 'Tool Result',
68
- outputLabel: 'View Output'
69
- }
78
+ titleLabel: "Tool Result",
79
+ outputLabel: "View Output",
80
+ },
70
81
  });
71
82
  });
72
83
 
73
- it('maps non-standard roles back to the generic message role', () => {
84
+ it("maps non-standard roles back to the generic message role", () => {
74
85
  const adapted = adaptChatMessages({
75
86
  uiMessages: [
76
87
  {
77
- id: 'data-1',
78
- role: 'data',
79
- parts: [{ type: 'text', text: 'payload' }]
80
- }
88
+ id: "data-1",
89
+ role: "data",
90
+ parts: [{ type: "text", text: "payload" }],
91
+ },
81
92
  ] as unknown as ChatMessageSource[],
82
- formatTimestamp: () => 'formatted',
93
+ formatTimestamp: () => "formatted",
83
94
  texts: {
84
95
  roleLabels: {
85
- user: 'You',
86
- assistant: 'Assistant',
87
- tool: 'Tool',
88
- system: 'System',
89
- fallback: 'Message'
96
+ user: "You",
97
+ assistant: "Assistant",
98
+ tool: "Tool",
99
+ system: "System",
100
+ fallback: "Message",
90
101
  },
91
- reasoningLabel: 'Reasoning',
92
- toolCallLabel: 'Tool Call',
93
- toolResultLabel: 'Tool Result',
94
- toolNoOutputLabel: 'No output',
95
- toolOutputLabel: 'View Output',
96
- unknownPartLabel: 'Unknown Part'
97
- }
102
+ reasoningLabel: "Reasoning",
103
+ toolCallLabel: "Tool Call",
104
+ toolResultLabel: "Tool Result",
105
+ toolNoOutputLabel: "No output",
106
+ toolOutputLabel: "View Output",
107
+ unknownPartLabel: "Unknown Part",
108
+ },
98
109
  });
99
110
 
100
- expect(adapted[0]?.role).toBe('message');
101
- expect(adapted[0]?.roleLabel).toBe('Message');
111
+ expect(adapted[0]?.role).toBe("message");
112
+ expect(adapted[0]?.roleLabel).toBe("Message");
102
113
  });
103
114
 
104
- it('maps unknown parts into a visible fallback part', () => {
115
+ it("maps unknown parts into a visible fallback part", () => {
105
116
  const adapted = adaptChatMessages({
106
117
  uiMessages: [
107
118
  {
108
- id: 'x-1',
109
- role: 'assistant',
110
- parts: [{ type: 'step-start', value: 'x' }]
111
- }
119
+ id: "x-1",
120
+ role: "assistant",
121
+ parts: [{ type: "step-start", value: "x" }],
122
+ },
112
123
  ] as unknown as ChatMessageSource[],
113
- formatTimestamp: () => 'formatted',
124
+ formatTimestamp: () => "formatted",
114
125
  texts: {
115
126
  roleLabels: {
116
- user: 'You',
117
- assistant: 'Assistant',
118
- tool: 'Tool',
119
- system: 'System',
120
- fallback: 'Message'
127
+ user: "You",
128
+ assistant: "Assistant",
129
+ tool: "Tool",
130
+ system: "System",
131
+ fallback: "Message",
121
132
  },
122
- reasoningLabel: 'Reasoning',
123
- toolCallLabel: 'Tool Call',
124
- toolResultLabel: 'Tool Result',
125
- toolNoOutputLabel: 'No output',
126
- toolOutputLabel: 'View Output',
127
- unknownPartLabel: 'Unknown Part'
128
- }
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
+ });
141
+
142
+ expect(adapted[0]?.parts[0]).toMatchObject({
143
+ type: "unknown",
144
+ rawType: "step-start",
145
+ label: "Unknown Part",
129
146
  });
147
+ });
130
148
 
149
+ it("drops empty and zero-width text parts during adaptation", () => {
150
+ const adapted = adaptChatMessages({
151
+ uiMessages: [
152
+ {
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",
170
+ },
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
+ });
179
+
180
+ expect(adapted).toHaveLength(1);
181
+ expect(adapted[0]?.id).toBe("assistant-mixed");
182
+ expect(adapted[0]?.parts).toHaveLength(1);
131
183
  expect(adapted[0]?.parts[0]).toMatchObject({
132
- type: 'unknown',
133
- rawType: 'step-start',
134
- label: 'Unknown Part'
184
+ type: "markdown",
185
+ text: "\u200Bhello\u200B",
135
186
  });
136
187
  });
137
188
  });