@nextclaw/ui 0.8.0 → 0.9.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 (72) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/ChannelsList-DhvjpZcs.js +1 -0
  3. package/dist/assets/ChatPage-B8VBaMQm.js +38 -0
  4. package/dist/assets/{DocBrowser-DDX2HMXW.js → DocBrowser-LpzGe8An.js} +1 -1
  5. package/dist/assets/{LogoBadge-J53F_3JA.js → LogoBadge-Be4lktJN.js} +1 -1
  6. package/dist/assets/{MarketplacePage-0BZ4bza0.js → MarketplacePage-Cx9AI3_h.js} +3 -3
  7. package/dist/assets/{ModelConfig-Wzq9wGHV.js → ModelConfig-DuImUHIX.js} +1 -1
  8. package/dist/assets/ProvidersList-Ccleg25k.js +1 -0
  9. package/dist/assets/{RuntimeConfig-N771_AM6.js → RuntimeConfig-C6iqpJR_.js} +1 -1
  10. package/dist/assets/{SearchConfig-DVt5QVa_.js → SearchConfig-Dvp1TAXu.js} +1 -1
  11. package/dist/assets/{SecretsConfig-CkwauPa8.js → SecretsConfig-D5Ymlvt9.js} +1 -1
  12. package/dist/assets/{SessionsConfig-C3mnHzkZ.js → SessionsConfig-CIA_jA1P.js} +2 -2
  13. package/dist/assets/{chat-message-pxr79GDs.js → chat-message-B60Fh9kI.js} +1 -1
  14. package/dist/assets/index-BiPDnzv0.js +8 -0
  15. package/dist/assets/index-C8GsgIUn.css +1 -0
  16. package/dist/assets/{index-GdpEEKnz.js → index-CPDASUXh.js} +1 -1
  17. package/dist/assets/{label-CmksBHgc.js → label-D4fGx6Wb.js} +1 -1
  18. package/dist/assets/{page-layout-Db0GbnhS.js → page-layout-twy8gmBE.js} +1 -1
  19. package/dist/assets/popover-DYbYpt1j.js +1 -0
  20. package/dist/assets/{security-config-CjLFME5Q.js → security-config-BcIZ4rpb.js} +1 -1
  21. package/dist/assets/skeleton-DypBy7jp.js +1 -0
  22. package/dist/assets/{switch-C24d-UJU.js → switch-DqA6r5XR.js} +1 -1
  23. package/dist/assets/tabs-custom-C6enKKs1.js +1 -0
  24. package/dist/assets/{useConfirmDialog-BeP35LcG.js → useConfirmDialog-CHBf5Of7.js} +1 -1
  25. package/dist/assets/{vendor-psXJBy9u.js → vendor-DKBNiC31.js} +1 -1
  26. package/dist/index.html +3 -3
  27. package/package.json +6 -6
  28. package/src/api/config.ts +9 -38
  29. package/src/api/ncp-session.ts +50 -0
  30. package/src/api/types.ts +1 -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/ChatSidebar.test.tsx +203 -0
  34. package/src/components/chat/ChatSidebar.tsx +97 -7
  35. package/src/components/chat/adapters/chat-message.adapter.test.ts +132 -82
  36. package/src/components/chat/adapters/chat-message.adapter.ts +27 -9
  37. package/src/components/chat/chat-composer-state.ts +53 -0
  38. package/src/components/chat/chat-page-data.ts +30 -1
  39. package/src/components/chat/chat-page-runtime.test.ts +181 -0
  40. package/src/components/chat/chat-page-runtime.ts +101 -15
  41. package/src/components/chat/chat-session-preference-sync.test.ts +62 -0
  42. package/src/components/chat/chat-session-preference-sync.ts +75 -0
  43. package/src/components/chat/chat-stream/types.ts +3 -0
  44. package/src/components/chat/containers/chat-input-bar.container.tsx +12 -63
  45. package/src/components/chat/containers/chat-message-list.container.tsx +31 -27
  46. package/src/components/chat/legacy/LegacyChatPage.tsx +25 -0
  47. package/src/components/chat/managers/chat-input.manager.ts +48 -13
  48. package/src/components/chat/managers/chat-session-list.manager.test.ts +39 -0
  49. package/src/components/chat/managers/chat-session-list.manager.ts +9 -3
  50. package/src/components/chat/ncp/NcpChatPage.tsx +53 -13
  51. package/src/components/chat/ncp/ncp-chat-input.manager.ts +48 -12
  52. package/src/components/chat/ncp/ncp-chat-page-data.ts +34 -2
  53. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +1 -1
  54. package/src/components/chat/ncp/ncp-session-adapter.test.ts +27 -1
  55. package/src/components/chat/ncp/ncp-session-adapter.ts +20 -0
  56. package/src/components/chat/presenter/chat-presenter-context.tsx +2 -0
  57. package/src/components/chat/stores/chat-input.store.ts +4 -0
  58. package/src/components/chat/stores/chat-thread.store.ts +2 -0
  59. package/src/components/chat/useChatSessionTypeState.test.tsx +58 -0
  60. package/src/components/chat/useChatSessionTypeState.ts +25 -8
  61. package/src/hooks/use-ncp-chat-session-types.ts +11 -0
  62. package/src/hooks/useConfig.ts +2 -4
  63. package/src/hooks/useMarketplace.ts +7 -4
  64. package/src/hooks/useWebSocket.ts +23 -2
  65. package/dist/assets/ChannelsList-DBcoVJRW.js +0 -1
  66. package/dist/assets/ChatPage-CD3cxyyM.js +0 -37
  67. package/dist/assets/ProvidersList-kwzRS8_M.js +0 -1
  68. package/dist/assets/index-BIvFMkN4.js +0 -1
  69. package/dist/assets/index-CzkY1reu.js +0 -8
  70. package/dist/assets/index-RZ0kHHRI.css +0 -1
  71. package/dist/assets/skeleton-CkpQeVWN.js +0 -1
  72. package/dist/assets/tabs-custom-D89bh-fc.js +0 -1
@@ -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,97 +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']);
64
- expect(adapted[0]?.parts[1]).toMatchObject({ type: 'reasoning', label: 'Reasoning', text: 'internal reasoning' });
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
+ });
65
75
  expect(adapted[0]?.parts[2]).toMatchObject({
66
- type: 'tool-card',
76
+ type: "tool-card",
67
77
  card: {
68
- titleLabel: 'Tool Result',
69
- outputLabel: 'View Output'
70
- }
78
+ titleLabel: "Tool Result",
79
+ outputLabel: "View Output",
80
+ },
71
81
  });
72
82
  });
73
83
 
74
- it('maps non-standard roles back to the generic message role', () => {
84
+ it("maps non-standard roles back to the generic message role", () => {
75
85
  const adapted = adaptChatMessages({
76
86
  uiMessages: [
77
87
  {
78
- id: 'data-1',
79
- role: 'data',
80
- parts: [{ type: 'text', text: 'payload' }]
81
- }
88
+ id: "data-1",
89
+ role: "data",
90
+ parts: [{ type: "text", text: "payload" }],
91
+ },
82
92
  ] as unknown as ChatMessageSource[],
83
- formatTimestamp: () => 'formatted',
93
+ formatTimestamp: () => "formatted",
84
94
  texts: {
85
95
  roleLabels: {
86
- user: 'You',
87
- assistant: 'Assistant',
88
- tool: 'Tool',
89
- system: 'System',
90
- fallback: 'Message'
96
+ user: "You",
97
+ assistant: "Assistant",
98
+ tool: "Tool",
99
+ system: "System",
100
+ fallback: "Message",
91
101
  },
92
- reasoningLabel: 'Reasoning',
93
- toolCallLabel: 'Tool Call',
94
- toolResultLabel: 'Tool Result',
95
- toolNoOutputLabel: 'No output',
96
- toolOutputLabel: 'View Output',
97
- unknownPartLabel: 'Unknown Part'
98
- }
102
+ reasoningLabel: "Reasoning",
103
+ toolCallLabel: "Tool Call",
104
+ toolResultLabel: "Tool Result",
105
+ toolNoOutputLabel: "No output",
106
+ toolOutputLabel: "View Output",
107
+ unknownPartLabel: "Unknown Part",
108
+ },
99
109
  });
100
110
 
101
- expect(adapted[0]?.role).toBe('message');
102
- expect(adapted[0]?.roleLabel).toBe('Message');
111
+ expect(adapted[0]?.role).toBe("message");
112
+ expect(adapted[0]?.roleLabel).toBe("Message");
103
113
  });
104
114
 
105
- it('maps unknown parts into a visible fallback part', () => {
115
+ it("maps unknown parts into a visible fallback part", () => {
106
116
  const adapted = adaptChatMessages({
107
117
  uiMessages: [
108
118
  {
109
- id: 'x-1',
110
- role: 'assistant',
111
- parts: [{ type: 'step-start', value: 'x' }]
112
- }
119
+ id: "x-1",
120
+ role: "assistant",
121
+ parts: [{ type: "step-start", value: "x" }],
122
+ },
113
123
  ] as unknown as ChatMessageSource[],
114
- formatTimestamp: () => 'formatted',
124
+ formatTimestamp: () => "formatted",
115
125
  texts: {
116
126
  roleLabels: {
117
- user: 'You',
118
- assistant: 'Assistant',
119
- tool: 'Tool',
120
- system: 'System',
121
- fallback: 'Message'
127
+ user: "You",
128
+ assistant: "Assistant",
129
+ tool: "Tool",
130
+ system: "System",
131
+ fallback: "Message",
122
132
  },
123
- reasoningLabel: 'Reasoning',
124
- toolCallLabel: 'Tool Call',
125
- toolResultLabel: 'Tool Result',
126
- toolNoOutputLabel: 'No output',
127
- toolOutputLabel: 'View Output',
128
- unknownPartLabel: 'Unknown Part'
129
- }
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",
130
146
  });
147
+ });
131
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);
132
183
  expect(adapted[0]?.parts[0]).toMatchObject({
133
- type: 'unknown',
134
- rawType: 'step-start',
135
- label: 'Unknown Part'
184
+ type: "markdown",
185
+ text: "\u200Bhello\u200B",
136
186
  });
137
187
  });
138
188
  });
@@ -61,6 +61,8 @@ export type ChatMessageAdapterTexts = {
61
61
  unknownPartLabel: string;
62
62
  };
63
63
 
64
+ const INVISIBLE_ONLY_TEXT_PATTERN = /\u200B|\u200C|\u200D|\u2060|\uFEFF/g;
65
+
64
66
  function isRecord(value: unknown): value is Record<string, unknown> {
65
67
  return typeof value === 'object' && value !== null;
66
68
  }
@@ -95,7 +97,10 @@ function resolveMessageTimestamp(message: ChatMessageSource): string {
95
97
  return new Date().toISOString();
96
98
  }
97
99
 
98
- function resolveRoleLabel(role: string, texts: ChatMessageAdapterTexts['roleLabels']): string {
100
+ function resolveRoleLabel(
101
+ role: string,
102
+ texts: ChatMessageAdapterTexts['roleLabels']
103
+ ): string {
99
104
  if (role === 'user') {
100
105
  return texts.user;
101
106
  }
@@ -118,7 +123,10 @@ function resolveUiRole(role: string): ChatMessageRole {
118
123
  return 'message';
119
124
  }
120
125
 
121
- function buildToolCard(toolCard: ToolCard, texts: ChatMessageAdapterTexts): ChatToolPartViewModel {
126
+ function buildToolCard(
127
+ toolCard: ToolCard,
128
+ texts: ChatMessageAdapterTexts
129
+ ): ChatToolPartViewModel {
122
130
  return {
123
131
  kind: toolCard.kind,
124
132
  toolName: toolCard.name,
@@ -131,6 +139,15 @@ function buildToolCard(toolCard: ToolCard, texts: ChatMessageAdapterTexts): Chat
131
139
  };
132
140
  }
133
141
 
142
+ function toRenderableText(value: string): string | null {
143
+ const trimmed = value.trim();
144
+ if (!trimmed) {
145
+ return null;
146
+ }
147
+ const visible = trimmed.replace(INVISIBLE_ONLY_TEXT_PATTERN, "").trim();
148
+ return visible ? trimmed : null;
149
+ }
150
+
134
151
  export function adaptChatMessages(params: {
135
152
  uiMessages: ChatMessageSource[];
136
153
  texts: ChatMessageAdapterTexts;
@@ -145,7 +162,7 @@ export function adaptChatMessages(params: {
145
162
  parts: message.parts
146
163
  .map((part) => {
147
164
  if (isTextPart(part)) {
148
- const text = part.text.trim();
165
+ const text = toRenderableText(part.text);
149
166
  if (!text) {
150
167
  return null;
151
168
  }
@@ -155,7 +172,7 @@ export function adaptChatMessages(params: {
155
172
  };
156
173
  }
157
174
  if (isReasoningPart(part)) {
158
- const text = part.reasoning.trim();
175
+ const text = toRenderableText(part.reasoning);
159
176
  if (!text) {
160
177
  return null;
161
178
  }
@@ -168,11 +185,12 @@ export function adaptChatMessages(params: {
168
185
  if (isToolInvocationPart(part)) {
169
186
  const invocation = part.toolInvocation;
170
187
  const detail = summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
171
- const rawResult = typeof invocation.error === 'string' && invocation.error.trim()
172
- ? invocation.error.trim()
173
- : invocation.result != null
174
- ? stringifyUnknown(invocation.result).trim()
175
- : '';
188
+ const rawResult =
189
+ typeof invocation.error === 'string' && invocation.error.trim()
190
+ ? invocation.error.trim()
191
+ : invocation.result != null
192
+ ? stringifyUnknown(invocation.result).trim()
193
+ : '';
176
194
  const hasResult =
177
195
  invocation.status === 'result' || invocation.status === 'error' || invocation.status === 'cancelled';
178
196
  const card: ToolCard = {
@@ -0,0 +1,53 @@
1
+ import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import {
3
+ createChatComposerTokenNode,
4
+ createChatComposerNodesFromText,
5
+ createEmptyChatComposerNodes,
6
+ extractChatComposerTokenKeys,
7
+ normalizeChatComposerNodes,
8
+ removeChatComposerTokenNodes,
9
+ serializeChatComposerPlainText
10
+ } from '@nextclaw/agent-chat-ui';
11
+
12
+ export function createInitialChatComposerNodes(): ChatComposerNode[] {
13
+ return createEmptyChatComposerNodes();
14
+ }
15
+
16
+ export function createChatComposerNodesFromDraft(text: string): ChatComposerNode[] {
17
+ return createChatComposerNodesFromText(text);
18
+ }
19
+
20
+ export function deriveChatComposerDraft(nodes: ChatComposerNode[]): string {
21
+ return serializeChatComposerPlainText(nodes);
22
+ }
23
+
24
+ export function deriveSelectedSkillsFromComposer(nodes: ChatComposerNode[]): string[] {
25
+ return extractChatComposerTokenKeys(nodes, 'skill');
26
+ }
27
+
28
+ export function syncComposerSkills(
29
+ nodes: ChatComposerNode[],
30
+ nextSkills: string[],
31
+ skillRecords: Array<{ spec: string; label?: string }>
32
+ ): ChatComposerNode[] {
33
+ const nextSkillSet = new Set(nextSkills);
34
+ const prunedNodes = removeChatComposerTokenNodes(
35
+ nodes,
36
+ (node) => node.tokenKind === 'skill' && !nextSkillSet.has(node.tokenKey)
37
+ );
38
+ const existingSkills = extractChatComposerTokenKeys(prunedNodes, 'skill');
39
+ const recordMap = new Map(skillRecords.map((record) => [record.spec, record]));
40
+ const appendedNodes = nextSkills
41
+ .filter((skill) => !existingSkills.includes(skill))
42
+ .map((skill) =>
43
+ createChatComposerTokenNode({
44
+ tokenKind: 'skill',
45
+ tokenKey: skill,
46
+ label: recordMap.get(skill)?.label || skill
47
+ })
48
+ );
49
+
50
+ return appendedNodes.length === 0
51
+ ? prunedNodes
52
+ : normalizeChatComposerNodes([...prunedNodes, ...appendedNodes]);
53
+ }
@@ -3,7 +3,11 @@ import type { Dispatch, SetStateAction } from 'react';
3
3
  import type { SessionEntryView, ThinkingLevel } from '@/api/types';
4
4
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
5
5
  import { useChatSessionTypeState } from '@/components/chat/useChatSessionTypeState';
6
- import { useSyncSelectedModel } from '@/components/chat/chat-page-runtime';
6
+ import {
7
+ resolveSelectedModelValue,
8
+ resolveRecentSessionPreferredModel,
9
+ useSyncSelectedModel
10
+ } from '@/components/chat/chat-page-runtime';
7
11
  import {
8
12
  useChatCapabilities,
9
13
  useChatSessionTypes,
@@ -98,14 +102,38 @@ export function useChatPageData(params: UseChatPageDataParams) {
98
102
  setPendingSessionType: params.setPendingSessionType,
99
103
  sessionTypesData: sessionTypesQuery.data
100
104
  });
105
+ const recentSessionPreferredModel = useMemo(
106
+ () =>
107
+ resolveRecentSessionPreferredModel({
108
+ sessions,
109
+ selectedSessionKey: params.selectedSessionKey,
110
+ sessionType: sessionTypeState.selectedSessionType
111
+ }),
112
+ [params.selectedSessionKey, sessionTypeState.selectedSessionType, sessions]
113
+ );
101
114
 
102
115
  useSyncSelectedModel({
103
116
  modelOptions,
117
+ selectedSessionKey: params.selectedSessionKey,
104
118
  selectedSessionPreferredModel: selectedSession?.preferredModel,
119
+ fallbackPreferredModel: recentSessionPreferredModel,
105
120
  defaultModel: configQuery.data?.agents.defaults.model,
106
121
  setSelectedModel: params.setSelectedModel
107
122
  });
108
123
 
124
+ const hydratedSessionModel = useMemo(
125
+ () =>
126
+ resolveSelectedModelValue({
127
+ currentSelectedModel: '',
128
+ modelOptions,
129
+ selectedSessionPreferredModel: selectedSession?.preferredModel,
130
+ fallbackPreferredModel: recentSessionPreferredModel,
131
+ defaultModel: configQuery.data?.agents.defaults.model,
132
+ preferSessionPreferredModel: true
133
+ }),
134
+ [configQuery.data?.agents.defaults.model, modelOptions, recentSessionPreferredModel, selectedSession?.preferredModel]
135
+ );
136
+
109
137
  const historyMessages = useMemo(() => historyQuery.data?.messages ?? [], [historyQuery.data?.messages]);
110
138
  const selectedSessionThinkingLevel = useMemo(() => {
111
139
  if (!params.selectedSessionKey) {
@@ -143,6 +171,7 @@ export function useChatPageData(params: UseChatPageDataParams) {
143
171
  sessions,
144
172
  skillRecords,
145
173
  selectedSession,
174
+ hydratedSessionModel,
146
175
  historyMessages,
147
176
  selectedSessionThinkingLevel,
148
177
  ...sessionTypeState