@nextclaw/ui 0.10.0 → 0.10.1

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 (67) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/assets/{ChannelsList-VSRZzxx2.js → ChannelsList-BX7KqEk7.js} +4 -4
  3. package/dist/assets/ChatPage-zXLBKIAY.js +38 -0
  4. package/dist/assets/{DocBrowser-C65Hbvnb.js → DocBrowser-Cdbh4cVD.js} +1 -1
  5. package/dist/assets/{LogoBadge-4qtguXEJ.js → LogoBadge-4801esOJ.js} +1 -1
  6. package/dist/assets/MarketplacePage-GZgus0Or.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-CHLkD8yX.js → McpMarketplacePage-CAGGvoMo.js} +1 -1
  8. package/dist/assets/{ModelConfig-CjsGdmZa.js → ModelConfig-CfLYjQM3.js} +1 -1
  9. package/dist/assets/ProvidersList-CEo1kdf-.js +1 -0
  10. package/dist/assets/{RemoteAccessPage-rOZCnH1x.js → RemoteAccessPage-6GYzD7cc.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-CmJh6g0R.js → RuntimeConfig-BZdbp8mH.js} +1 -1
  12. package/dist/assets/{SearchConfig-C_hUuzR4.js → SearchConfig-ifvYKix-.js} +1 -1
  13. package/dist/assets/{SecretsConfig-Bu_zIRlQ.js → SecretsConfig-tDPbhTeR.js} +1 -1
  14. package/dist/assets/{SessionsConfig-DA_nqkM_.js → SessionsConfig-DhkAIzGm.js} +1 -1
  15. package/dist/assets/{chat-message-BOdA4h43.js → chat-message-C5Gl-dCH.js} +1 -1
  16. package/dist/assets/index-BTt_JlNV.css +1 -0
  17. package/dist/assets/index-JN3V84h_.js +8 -0
  18. package/dist/assets/{label-BYZ62ajO.js → label-D8zWKdqp.js} +1 -1
  19. package/dist/assets/{page-layout-UC-h92sU.js → page-layout-qAJ47LNQ.js} +1 -1
  20. package/dist/assets/{popover-DASCEr3G.js → popover-hyBGxpxS.js} +1 -1
  21. package/dist/assets/{security-config-Cvujq4fH.js → security-config-BJYZSnCA.js} +1 -1
  22. package/dist/assets/skeleton-CUQLsNsM.js +1 -0
  23. package/dist/assets/{status-dot-C1AvPwDD.js → status-dot-DKcoD-iY.js} +1 -1
  24. package/dist/assets/{switch-D3wVuCSh.js → switch-DtUdQxr_.js} +1 -1
  25. package/dist/assets/tabs-custom-Dj1BWHGK.js +1 -0
  26. package/dist/assets/useConfirmDialog-nZdrtETU.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/components/chat/ChatSidebar.tsx +41 -69
  31. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +32 -1
  32. package/src/components/chat/adapters/chat-input-bar.adapter.ts +6 -3
  33. package/src/components/chat/adapters/chat-message.adapter.test.ts +141 -163
  34. package/src/components/chat/adapters/chat-message.adapter.ts +35 -0
  35. package/src/components/chat/chat-composer-state.ts +38 -0
  36. package/src/components/chat/chat-stream/types.ts +2 -0
  37. package/src/components/chat/containers/chat-input-bar.container.tsx +116 -55
  38. package/src/components/chat/containers/chat-message-list.container.tsx +2 -0
  39. package/src/components/chat/managers/chat-session-list.manager.test.ts +16 -1
  40. package/src/components/chat/managers/chat-session-list.manager.ts +0 -2
  41. package/src/components/chat/managers/chat-thread.manager.ts +0 -1
  42. package/src/components/chat/ncp/NcpChatPage.tsx +18 -18
  43. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +50 -33
  44. package/src/components/chat/ncp/ncp-app-client-fetch.ts +5 -123
  45. package/src/components/chat/ncp/ncp-chat-input.manager.ts +56 -1
  46. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +8 -0
  47. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +0 -1
  48. package/src/components/chat/presenter/chat-presenter-context.tsx +6 -0
  49. package/src/components/chat/stores/chat-input.store.ts +3 -0
  50. package/src/components/config/ChannelsList.test.tsx +2 -1
  51. package/src/components/config/weixin-channel-auth-section.test.tsx +2 -1
  52. package/src/components/layout/Sidebar.tsx +62 -102
  53. package/src/components/layout/sidebar-items.tsx +172 -0
  54. package/src/components/layout/sidebar.layout.test.tsx +11 -4
  55. package/src/lib/i18n.chat.ts +117 -0
  56. package/src/lib/i18n.remote.ts +1 -1
  57. package/src/lib/i18n.ts +2 -112
  58. package/src/transport/remote.transport.test.ts +135 -0
  59. package/src/transport/remote.transport.ts +11 -1
  60. package/dist/assets/ChatPage-CX0ZKE5i.js +0 -41
  61. package/dist/assets/MarketplacePage-DPCYptfD.js +0 -49
  62. package/dist/assets/ProvidersList-aXp_mo4J.js +0 -1
  63. package/dist/assets/index-C63mHRbE.css +0 -1
  64. package/dist/assets/index-DS7D1-KS.js +0 -8
  65. package/dist/assets/skeleton-DlYEKkkj.js +0 -1
  66. package/dist/assets/tabs-custom-CbgS7tu0.js +0 -1
  67. package/dist/assets/useConfirmDialog-BYbFEIbQ.js +0 -1
@@ -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
  });
@@ -14,6 +14,12 @@ export type ChatMessagePartSource =
14
14
  type: 'text';
15
15
  text: string;
16
16
  }
17
+ | {
18
+ type: 'file';
19
+ mimeType: string;
20
+ data: string;
21
+ name?: string;
22
+ }
17
23
  | {
18
24
  type: 'reasoning';
19
25
  reasoning: string;
@@ -58,6 +64,8 @@ export type ChatMessageAdapterTexts = {
58
64
  toolResultLabel: string;
59
65
  toolNoOutputLabel: string;
60
66
  toolOutputLabel: string;
67
+ imageAttachmentLabel: string;
68
+ fileAttachmentLabel: string;
61
69
  unknownPartLabel: string;
62
70
  };
63
71
 
@@ -77,6 +85,16 @@ function isReasoningPart(
77
85
  return part.type === 'reasoning' && typeof part.reasoning === 'string';
78
86
  }
79
87
 
88
+ function isFilePart(
89
+ part: ChatMessagePartSource
90
+ ): part is Extract<ChatMessagePartSource, { type: 'file' }> {
91
+ return (
92
+ part.type === 'file' &&
93
+ typeof part.mimeType === 'string' &&
94
+ typeof part.data === 'string'
95
+ );
96
+ }
97
+
80
98
  function isToolInvocationPart(
81
99
  part: ChatMessagePartSource
82
100
  ): part is Extract<ChatMessagePartSource, { type: 'tool-invocation' }> {
@@ -182,6 +200,23 @@ export function adaptChatMessages(params: {
182
200
  label: params.texts.reasoningLabel
183
201
  };
184
202
  }
203
+ if (isFilePart(part)) {
204
+ const isImage = part.mimeType.startsWith('image/');
205
+ return {
206
+ type: 'file' as const,
207
+ file: {
208
+ label:
209
+ typeof part.name === 'string' && part.name.trim()
210
+ ? part.name.trim()
211
+ : isImage
212
+ ? params.texts.imageAttachmentLabel
213
+ : params.texts.fileAttachmentLabel,
214
+ mimeType: part.mimeType,
215
+ dataUrl: `data:${part.mimeType};base64,${part.data}`,
216
+ isImage
217
+ }
218
+ };
219
+ }
185
220
  if (isToolInvocationPart(part)) {
186
221
  const invocation = part.toolInvocation;
187
222
  const detail = summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
@@ -1,4 +1,5 @@
1
1
  import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
2
3
  import {
3
4
  createChatComposerTokenNode,
4
5
  createChatComposerNodesFromText,
@@ -25,6 +26,10 @@ export function deriveSelectedSkillsFromComposer(nodes: ChatComposerNode[]): str
25
26
  return extractChatComposerTokenKeys(nodes, 'skill');
26
27
  }
27
28
 
29
+ export function deriveSelectedAttachmentIdsFromComposer(nodes: ChatComposerNode[]): string[] {
30
+ return extractChatComposerTokenKeys(nodes, 'file');
31
+ }
32
+
28
33
  export function syncComposerSkills(
29
34
  nodes: ChatComposerNode[],
30
35
  nextSkills: string[],
@@ -51,3 +56,36 @@ export function syncComposerSkills(
51
56
  ? prunedNodes
52
57
  : normalizeChatComposerNodes([...prunedNodes, ...appendedNodes]);
53
58
  }
59
+
60
+ export function syncComposerAttachments(
61
+ nodes: ChatComposerNode[],
62
+ attachments: readonly NcpDraftAttachment[]
63
+ ): ChatComposerNode[] {
64
+ const nextAttachmentIds = new Set(attachments.map((attachment) => attachment.id));
65
+ const prunedNodes = removeChatComposerTokenNodes(
66
+ nodes,
67
+ (node) => node.tokenKind === 'file' && !nextAttachmentIds.has(node.tokenKey)
68
+ );
69
+ const existingAttachmentIds = extractChatComposerTokenKeys(prunedNodes, 'file');
70
+ const appendedNodes = attachments
71
+ .filter((attachment) => !existingAttachmentIds.includes(attachment.id))
72
+ .map((attachment) =>
73
+ createChatComposerTokenNode({
74
+ tokenKind: 'file',
75
+ tokenKey: attachment.id,
76
+ label: attachment.name
77
+ })
78
+ );
79
+
80
+ return appendedNodes.length === 0
81
+ ? prunedNodes
82
+ : normalizeChatComposerNodes([...prunedNodes, ...appendedNodes]);
83
+ }
84
+
85
+ export function pruneComposerAttachments(
86
+ nodes: ChatComposerNode[],
87
+ attachments: readonly NcpDraftAttachment[]
88
+ ): NcpDraftAttachment[] {
89
+ const selectedIds = new Set(deriveSelectedAttachmentIdsFromComposer(nodes));
90
+ return attachments.filter((attachment) => selectedIds.has(attachment.id));
91
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
2
3
  import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
3
4
  import type {
4
5
  ChatRunView,
@@ -18,6 +19,7 @@ export type SendMessageParams = {
18
19
  model?: string;
19
20
  thinkingLevel?: ThinkingLevel;
20
21
  requestedSkills?: string[];
22
+ attachments?: NcpDraftAttachment[];
21
23
  stopSupported?: boolean;
22
24
  stopReason?: string;
23
25
  restoreDraftOnError?: boolean;