@nextclaw/ui 0.11.8 → 0.11.10

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 (57) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/assets/{ChannelsList-dsxeZk5v.js → ChannelsList-C63gOoYI.js} +3 -3
  3. package/dist/assets/ChatPage-Ci3Gz0qh.js +37 -0
  4. package/dist/assets/{DocBrowser-B9RfxWIh.js → DocBrowser-CI4jOzJY.js} +1 -1
  5. package/dist/assets/{LogoBadge-iVDzhkNu.js → LogoBadge-DImV63-L.js} +1 -1
  6. package/dist/assets/{MarketplacePage-4MZIcD0K.js → MarketplacePage-B360oSAV.js} +1 -1
  7. package/dist/assets/{McpMarketplacePage-cwLMty-D.js → McpMarketplacePage-KIQgx_7h.js} +2 -2
  8. package/dist/assets/{ModelConfig-CNICBWzw.js → ModelConfig-Ben3tQoX.js} +1 -1
  9. package/dist/assets/{ProvidersList-CEHGsRSL.js → ProvidersList-DE-S9mq0.js} +1 -1
  10. package/dist/assets/RemoteAccessPage-DxUia6R-.js +1 -0
  11. package/dist/assets/RuntimeConfig-CQcGfNZT.js +1 -0
  12. package/dist/assets/{SearchConfig-CaFAgBMN.js → SearchConfig-DeOa-M6j.js} +1 -1
  13. package/dist/assets/{SecretsConfig-DzWq8hGZ.js → SecretsConfig-Ci8pJmzd.js} +2 -2
  14. package/dist/assets/{SessionsConfig-CJJTcxyQ.js → SessionsConfig-B6zq55yu.js} +2 -2
  15. package/dist/assets/chat-session-display--oo5yuIw.js +1 -0
  16. package/dist/assets/{index-BlrweCCh.js → index-LhlkB00c.js} +4 -4
  17. package/dist/assets/{label-DtssWSI4.js → label-3TKt0PoZ.js} +1 -1
  18. package/dist/assets/{page-layout-DnRqSldv.js → page-layout-CopkIM3Q.js} +1 -1
  19. package/dist/assets/{popover-Un2VFGcS.js → popover-CUx8uRJw.js} +1 -1
  20. package/dist/assets/security-config-BL29kTzz.js +1 -0
  21. package/dist/assets/{skeleton-DTFzTqqO.js → skeleton-Bs4zvcql.js} +1 -1
  22. package/dist/assets/{status-dot-DOJX6vii.js → status-dot-D6vJMwD7.js} +1 -1
  23. package/dist/assets/{switch-utBdpBRv.js → switch-A3-ClT1P.js} +1 -1
  24. package/dist/assets/{tabs-custom-ExyfvfgG.js → tabs-custom-BVSd5urq.js} +1 -1
  25. package/dist/assets/{useConfirmDialog-DjNtKs4n.js → useConfirmDialog-ChPriea6.js} +1 -1
  26. package/dist/index.html +1 -1
  27. package/package.json +5 -5
  28. package/src/api/ncp-session-query-cache.test.ts +89 -0
  29. package/src/api/ncp-session-query-cache.ts +85 -0
  30. package/src/api/types.ts +2 -0
  31. package/src/components/chat/ChatConversationPanel.test.tsx +1 -1
  32. package/src/components/chat/ChatConversationPanel.tsx +62 -32
  33. package/src/components/chat/ChatSidebar.test.tsx +87 -92
  34. package/src/components/chat/ChatSidebar.tsx +21 -36
  35. package/src/components/chat/adapters/chat-message.adapter.ts +17 -5
  36. package/src/components/chat/chat-session-label.service.ts +3 -3
  37. package/src/components/chat/containers/chat-message-list.container.test.tsx +101 -0
  38. package/src/components/chat/containers/chat-message-list.container.tsx +93 -54
  39. package/src/components/chat/managers/chat-session-list.manager.ts +0 -18
  40. package/src/components/chat/ncp/NcpChatPage.tsx +4 -52
  41. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +4 -18
  42. package/src/components/chat/ncp/ncp-chat.presenter.ts +0 -2
  43. package/src/components/chat/ncp/ncp-session-adapter.test.ts +0 -23
  44. package/src/components/chat/ncp/ncp-session-adapter.ts +0 -19
  45. package/src/components/chat/ncp/use-ncp-session-list-view.ts +42 -0
  46. package/src/components/chat/presenter/chat-presenter-context.tsx +0 -3
  47. package/src/components/chat/stores/chat-session-list.store.ts +1 -7
  48. package/src/components/chat/stores/chat-thread.store.ts +3 -3
  49. package/src/hooks/use-realtime-query-bridge.ts +14 -19
  50. package/src/hooks/useConfig.ts +10 -11
  51. package/dist/assets/ChatPage-CWK4Bckz.js +0 -37
  52. package/dist/assets/RemoteAccessPage-uYxoaQ8V.js +0 -1
  53. package/dist/assets/RuntimeConfig-CYQq4S_m.js +0 -1
  54. package/dist/assets/ncp-session-adapter-C-jqQqcV.js +0 -1
  55. package/dist/assets/security-config-B7Bkebpm.js +0 -1
  56. package/src/components/chat/managers/chat-run-status.manager.ts +0 -32
  57. package/src/components/chat/stores/chat-run-status.store.ts +0 -30
@@ -2,8 +2,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
2
  import { MemoryRouter } from 'react-router-dom';
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { ChatSidebar } from '@/components/chat/ChatSidebar';
5
+ import type { NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
5
6
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
6
- import { useChatRunStatusStore } from '@/components/chat/stores/chat-run-status.store';
7
7
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
8
8
 
9
9
  const mocks = vi.hoisted(() => ({
@@ -11,9 +11,15 @@ const mocks = vi.hoisted(() => ({
11
11
  setQuery: vi.fn(),
12
12
  selectSession: vi.fn(),
13
13
  docOpen: vi.fn(),
14
- updateNcpSession: vi.fn()
14
+ updateNcpSession: vi.fn(),
15
+ sessionItems: [] as NcpSessionListItemView[],
16
+ isLoading: false
15
17
  }));
16
18
 
19
+ function createSessionItem(session: NcpSessionListItemView['session']): NcpSessionListItemView {
20
+ return { session };
21
+ }
22
+
17
23
  vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
18
24
  usePresenter: () => ({
19
25
  chatSessionListManager: {
@@ -34,7 +40,27 @@ vi.mock('@/components/chat/chat-session-label.service', () => ({
34
40
  useChatSessionLabelService: () => async (params: {
35
41
  sessionKey: string;
36
42
  label: string | null;
37
- }) => mocks.updateNcpSession(params.sessionKey, { label: params.label })
43
+ }) => {
44
+ mocks.sessionItems = mocks.sessionItems.map((item) =>
45
+ item.session.key === params.sessionKey
46
+ ? {
47
+ ...item,
48
+ session: {
49
+ ...item.session,
50
+ ...(params.label ? { label: params.label } : { label: undefined })
51
+ }
52
+ }
53
+ : item
54
+ );
55
+ return mocks.updateNcpSession(params.sessionKey, { label: params.label });
56
+ }
57
+ }));
58
+
59
+ vi.mock('@/components/chat/ncp/use-ncp-session-list-view', () => ({
60
+ useNcpSessionListView: () => ({
61
+ isLoading: mocks.isLoading,
62
+ items: mocks.sessionItems
63
+ })
38
64
  }));
39
65
 
40
66
  vi.mock('@/components/common/BrandHeader', () => ({
@@ -72,6 +98,8 @@ describe('ChatSidebar', () => {
72
98
  mocks.docOpen.mockReset();
73
99
  mocks.updateNcpSession.mockReset();
74
100
  mocks.updateNcpSession.mockResolvedValue({});
101
+ mocks.sessionItems = [];
102
+ mocks.isLoading = false;
75
103
 
76
104
  useChatInputStore.setState({
77
105
  snapshot: {
@@ -86,15 +114,7 @@ describe('ChatSidebar', () => {
86
114
  useChatSessionListStore.setState({
87
115
  snapshot: {
88
116
  ...useChatSessionListStore.getState().snapshot,
89
- sessions: [],
90
- query: '',
91
- isLoading: false
92
- }
93
- });
94
- useChatRunStatusStore.setState({
95
- snapshot: {
96
- ...useChatRunStatusStore.getState().snapshot,
97
- sessionRunStatusByKey: new Map()
117
+ query: ''
98
118
  }
99
119
  });
100
120
  });
@@ -145,22 +165,17 @@ describe('ChatSidebar', () => {
145
165
  });
146
166
 
147
167
  it('shows a session type badge for non-native sessions in the list', () => {
148
- useChatSessionListStore.setState({
149
- snapshot: {
150
- ...useChatSessionListStore.getState().snapshot,
151
- sessions: [
152
- {
153
- key: 'session:codex-1',
154
- createdAt: '2026-03-19T09:00:00.000Z',
155
- updatedAt: '2026-03-19T09:05:00.000Z',
156
- label: 'Codex Task',
157
- sessionType: 'codex',
158
- sessionTypeMutable: false,
159
- messageCount: 2
160
- }
161
- ]
162
- }
163
- });
168
+ mocks.sessionItems = [
169
+ createSessionItem({
170
+ key: 'session:codex-1',
171
+ createdAt: '2026-03-19T09:00:00.000Z',
172
+ updatedAt: '2026-03-19T09:05:00.000Z',
173
+ label: 'Codex Task',
174
+ sessionType: 'codex',
175
+ sessionTypeMutable: false,
176
+ messageCount: 2
177
+ })
178
+ ];
164
179
 
165
180
  render(
166
181
  <MemoryRouter>
@@ -180,22 +195,17 @@ describe('ChatSidebar', () => {
180
195
  sessionTypeOptions: [{ value: 'native', label: 'Native' }]
181
196
  }
182
197
  });
183
- useChatSessionListStore.setState({
184
- snapshot: {
185
- ...useChatSessionListStore.getState().snapshot,
186
- sessions: [
187
- {
188
- key: 'session:workspace-agent-1',
189
- createdAt: '2026-03-19T09:00:00.000Z',
190
- updatedAt: '2026-03-19T09:05:00.000Z',
191
- label: 'Workspace Task',
192
- sessionType: 'workspace-agent',
193
- sessionTypeMutable: false,
194
- messageCount: 2
195
- }
196
- ]
197
- }
198
- });
198
+ mocks.sessionItems = [
199
+ createSessionItem({
200
+ key: 'session:workspace-agent-1',
201
+ createdAt: '2026-03-19T09:00:00.000Z',
202
+ updatedAt: '2026-03-19T09:05:00.000Z',
203
+ label: 'Workspace Task',
204
+ sessionType: 'workspace-agent',
205
+ sessionTypeMutable: false,
206
+ messageCount: 2
207
+ })
208
+ ];
199
209
 
200
210
  render(
201
211
  <MemoryRouter>
@@ -214,22 +224,17 @@ describe('ChatSidebar', () => {
214
224
  sessionTypeOptions: [{ value: 'native', label: 'Native' }]
215
225
  }
216
226
  });
217
- useChatSessionListStore.setState({
218
- snapshot: {
219
- ...useChatSessionListStore.getState().snapshot,
220
- sessions: [
221
- {
222
- key: 'session:native-1',
223
- createdAt: '2026-03-19T09:00:00.000Z',
224
- updatedAt: '2026-03-19T09:05:00.000Z',
225
- label: 'Native Task',
226
- sessionType: 'native',
227
- sessionTypeMutable: false,
228
- messageCount: 1
229
- }
230
- ]
231
- }
232
- });
227
+ mocks.sessionItems = [
228
+ createSessionItem({
229
+ key: 'session:native-1',
230
+ createdAt: '2026-03-19T09:00:00.000Z',
231
+ updatedAt: '2026-03-19T09:05:00.000Z',
232
+ label: 'Native Task',
233
+ sessionType: 'native',
234
+ sessionTypeMutable: false,
235
+ messageCount: 1
236
+ })
237
+ ];
233
238
 
234
239
  render(
235
240
  <MemoryRouter>
@@ -242,22 +247,17 @@ describe('ChatSidebar', () => {
242
247
  });
243
248
 
244
249
  it('edits the session label inline and saves through the ncp session api by default', async () => {
245
- useChatSessionListStore.setState({
246
- snapshot: {
247
- ...useChatSessionListStore.getState().snapshot,
248
- sessions: [
249
- {
250
- key: 'session:ncp-1',
251
- createdAt: '2026-03-19T09:00:00.000Z',
252
- updatedAt: '2026-03-19T09:05:00.000Z',
253
- label: 'Initial Label',
254
- sessionType: 'native',
255
- sessionTypeMutable: false,
256
- messageCount: 1
257
- }
258
- ]
259
- }
260
- });
250
+ mocks.sessionItems = [
251
+ createSessionItem({
252
+ key: 'session:ncp-1',
253
+ createdAt: '2026-03-19T09:00:00.000Z',
254
+ updatedAt: '2026-03-19T09:05:00.000Z',
255
+ label: 'Initial Label',
256
+ sessionType: 'native',
257
+ sessionTypeMutable: false,
258
+ messageCount: 1
259
+ })
260
+ ];
261
261
 
262
262
  render(
263
263
  <MemoryRouter>
@@ -280,22 +280,17 @@ describe('ChatSidebar', () => {
280
280
  });
281
281
 
282
282
  it('cancels inline session label editing without saving', () => {
283
- useChatSessionListStore.setState({
284
- snapshot: {
285
- ...useChatSessionListStore.getState().snapshot,
286
- sessions: [
287
- {
288
- key: 'session:ncp-2',
289
- createdAt: '2026-03-19T09:00:00.000Z',
290
- updatedAt: '2026-03-19T09:05:00.000Z',
291
- label: 'Cancelable Label',
292
- sessionType: 'native',
293
- sessionTypeMutable: false,
294
- messageCount: 1
295
- }
296
- ]
297
- }
298
- });
283
+ mocks.sessionItems = [
284
+ createSessionItem({
285
+ key: 'session:ncp-2',
286
+ createdAt: '2026-03-19T09:00:00.000Z',
287
+ updatedAt: '2026-03-19T09:05:00.000Z',
288
+ label: 'Cancelable Label',
289
+ sessionType: 'native',
290
+ sessionTypeMutable: false,
291
+ messageCount: 1
292
+ })
293
+ ];
299
294
 
300
295
  render(
301
296
  <MemoryRouter>
@@ -8,9 +8,9 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
8
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
+ import { useNcpSessionListView, type NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
11
12
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
12
13
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
13
- import { useChatRunStatusStore } from '@/components/chat/stores/chat-run-status.store';
14
14
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
15
15
  import { cn } from '@/lib/utils';
16
16
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
@@ -35,38 +35,39 @@ import {
35
35
 
36
36
  type DateGroup = {
37
37
  label: string;
38
- sessions: SessionEntryView[];
38
+ items: NcpSessionListItemView[];
39
39
  };
40
40
 
41
- function groupSessionsByDate(sessions: SessionEntryView[]): DateGroup[] {
41
+ function groupSessionsByDate(items: NcpSessionListItemView[]): DateGroup[] {
42
42
  const now = new Date();
43
43
  const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
44
44
  const yesterdayStart = todayStart - 86_400_000;
45
45
  const sevenDaysStart = todayStart - 7 * 86_400_000;
46
46
 
47
- const today: SessionEntryView[] = [];
48
- const yesterday: SessionEntryView[] = [];
49
- const previous7: SessionEntryView[] = [];
50
- const older: SessionEntryView[] = [];
47
+ const today: NcpSessionListItemView[] = [];
48
+ const yesterday: NcpSessionListItemView[] = [];
49
+ const previous7: NcpSessionListItemView[] = [];
50
+ const older: NcpSessionListItemView[] = [];
51
51
 
52
- for (const session of sessions) {
52
+ for (const item of items) {
53
+ const { session } = item;
53
54
  const ts = new Date(session.updatedAt).getTime();
54
55
  if (ts >= todayStart) {
55
- today.push(session);
56
+ today.push(item);
56
57
  } else if (ts >= yesterdayStart) {
57
- yesterday.push(session);
58
+ yesterday.push(item);
58
59
  } else if (ts >= sevenDaysStart) {
59
- previous7.push(session);
60
+ previous7.push(item);
60
61
  } else {
61
- older.push(session);
62
+ older.push(item);
62
63
  }
63
64
  }
64
65
 
65
66
  const groups: DateGroup[] = [];
66
- if (today.length > 0) groups.push({ label: t('chatSidebarToday'), sessions: today });
67
- if (yesterday.length > 0) groups.push({ label: t('chatSidebarYesterday'), sessions: yesterday });
68
- if (previous7.length > 0) groups.push({ label: t('chatSidebarPrevious7Days'), sessions: previous7 });
69
- if (older.length > 0) groups.push({ label: t('chatSidebarOlder'), sessions: older });
67
+ if (today.length > 0) groups.push({ label: t('chatSidebarToday'), items: today });
68
+ if (yesterday.length > 0) groups.push({ label: t('chatSidebarYesterday'), items: yesterday });
69
+ if (previous7.length > 0) groups.push({ label: t('chatSidebarPrevious7Days'), items: previous7 });
70
+ if (older.length > 0) groups.push({ label: t('chatSidebarOlder'), items: older });
70
71
  return groups;
71
72
  }
72
73
 
@@ -121,15 +122,15 @@ export function ChatSidebar() {
121
122
  const [savingSessionKey, setSavingSessionKey] = useState<string | null>(null);
122
123
  const inputSnapshot = useChatInputStore((state) => state.snapshot);
123
124
  const listSnapshot = useChatSessionListStore((state) => state.snapshot);
124
- const runSnapshot = useChatRunStatusStore((state) => state.snapshot);
125
125
  const connectionStatus = useUiStore((state) => state.connectionStatus);
126
+ const { isLoading, items } = useNcpSessionListView();
126
127
  const { language, setLanguage } = useI18n();
127
128
  const { theme, setTheme } = useTheme();
128
129
  const updateSessionLabel = useChatSessionLabelService();
129
130
  const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
130
131
  const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
131
132
 
132
- const groups = useMemo(() => groupSessionsByDate(listSnapshot.sessions), [listSnapshot.sessions]);
133
+ const groups = useMemo(() => groupSessionsByDate(items), [items]);
133
134
  const defaultSessionType = inputSnapshot.defaultSessionType || 'native';
134
135
  const nonDefaultSessionTypeOptions = useMemo(
135
136
  () => inputSnapshot.sessionTypeOptions.filter((option) => option.value !== defaultSessionType),
@@ -142,20 +143,6 @@ export function ChatSidebar() {
142
143
  window.location.reload();
143
144
  };
144
145
 
145
- const patchSessionLabelInStore = (sessionKey: string, label: string | undefined) => {
146
- const { sessions } = useChatSessionListStore.getState().snapshot;
147
- useChatSessionListStore.getState().setSnapshot({
148
- sessions: sessions.map((session) =>
149
- session.key === sessionKey
150
- ? {
151
- ...session,
152
- ...(label ? { label } : { label: undefined })
153
- }
154
- : session
155
- )
156
- });
157
- };
158
-
159
146
  const startEditingSessionLabel = (session: SessionEntryView) => {
160
147
  setEditingSessionKey(session.key);
161
148
  setDraftLabel(session.label?.trim() ?? '');
@@ -181,7 +168,6 @@ export function ChatSidebar() {
181
168
  sessionKey: session.key,
182
169
  label: normalizedLabel || null
183
170
  });
184
- patchSessionLabelInStore(session.key, normalizedLabel || undefined);
185
171
  cancelEditingSessionLabel();
186
172
  } catch {
187
173
  setSavingSessionKey(null);
@@ -297,7 +283,7 @@ export function ChatSidebar() {
297
283
  <div className="mx-4 border-t border-gray-200/60" />
298
284
 
299
285
  <div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3 py-2">
300
- {listSnapshot.isLoading ? (
286
+ {isLoading ? (
301
287
  <div className="text-xs text-gray-500 p-3">{t('sessionsLoading')}</div>
302
288
  ) : groups.length === 0 ? (
303
289
  <div className="p-4 text-center">
@@ -312,9 +298,8 @@ export function ChatSidebar() {
312
298
  {group.label}
313
299
  </div>
314
300
  <div className="space-y-0.5">
315
- {group.sessions.map((session) => {
301
+ {group.items.map(({ session, runStatus }) => {
316
302
  const active = listSnapshot.selectedSessionKey === session.key;
317
- const runStatus = runSnapshot.sessionRunStatusByKey.get(session.key);
318
303
  const sessionTypeLabel = resolveSessionTypeLabel(session.sessionType, inputSnapshot.sessionTypeOptions);
319
304
  const isEditing = editingSessionKey === session.key;
320
305
  const isSaving = savingSessionKey === session.key;
@@ -317,12 +317,16 @@ function toRenderableText(value: string): string | null {
317
317
  return visible ? trimmed : null;
318
318
  }
319
319
 
320
- export function adaptChatMessages(params: {
321
- uiMessages: ChatMessageSource[];
320
+ type ChatMessageAdapterParams = {
322
321
  texts: ChatMessageAdapterTexts;
323
322
  formatTimestamp: (value: string) => string;
324
- }): ChatMessageViewModel[] {
325
- return params.uiMessages.map((message) => ({
323
+ };
324
+
325
+ export function adaptChatMessage(
326
+ message: ChatMessageSource,
327
+ params: ChatMessageAdapterParams,
328
+ ): ChatMessageViewModel {
329
+ return {
326
330
  id: message.id,
327
331
  role: resolveUiRole(message.role),
328
332
  roleLabel: resolveRoleLabel(message.role, params.texts.roleLabels),
@@ -421,5 +425,13 @@ export function adaptChatMessages(params: {
421
425
  };
422
426
  })
423
427
  .filter((part) => part !== null),
424
- }));
428
+ };
429
+ }
430
+
431
+ export function adaptChatMessages(params: {
432
+ uiMessages: ChatMessageSource[];
433
+ texts: ChatMessageAdapterTexts;
434
+ formatTimestamp: (value: string) => string;
435
+ }): ChatMessageViewModel[] {
436
+ return params.uiMessages.map((message) => adaptChatMessage(message, params));
425
437
  }
@@ -1,5 +1,6 @@
1
1
  import { useQueryClient } from '@tanstack/react-query';
2
2
  import { toast } from 'sonner';
3
+ import { upsertNcpSessionSummaryInQueryClient } from '@/api/ncp-session-query-cache';
3
4
  import { updateNcpSession } from '@/api/ncp-session';
4
5
  import { t } from '@/lib/i18n';
5
6
 
@@ -13,9 +14,8 @@ export function useChatSessionLabelService() {
13
14
 
14
15
  return async (params: UpdateChatSessionLabelParams): Promise<void> => {
15
16
  try {
16
- await updateNcpSession(params.sessionKey, { label: params.label });
17
- queryClient.invalidateQueries({ queryKey: ['ncp-sessions'] });
18
- queryClient.invalidateQueries({ queryKey: ['ncp-session-messages', params.sessionKey] });
17
+ const updated = await updateNcpSession(params.sessionKey, { label: params.label });
18
+ upsertNcpSessionSummaryInQueryClient(queryClient, updated);
19
19
  toast.success(t('configSavedApplied'));
20
20
  } catch (error) {
21
21
  toast.error(t('configSaveFailed') + ': ' + (error instanceof Error ? error.message : String(error)));
@@ -0,0 +1,101 @@
1
+ import { render } from "@testing-library/react";
2
+ import type { NcpMessage } from "@nextclaw/ncp";
3
+ import { beforeEach, expect, it, vi } from "vitest";
4
+ import { ChatMessageListContainer } from "./chat-message-list.container";
5
+
6
+ const captures = vi.hoisted(() => ({
7
+ renders: [] as Array<{ messages: unknown[] }>,
8
+ }));
9
+
10
+ vi.mock("@nextclaw/agent-chat-ui", () => ({
11
+ ChatMessageList: (props: { messages: unknown[] }) => {
12
+ captures.renders.push(props);
13
+ return <div data-testid="chat-message-list" />;
14
+ },
15
+ }));
16
+
17
+ vi.mock("@/components/providers/I18nProvider", () => ({
18
+ useI18n: () => ({ language: "en" }),
19
+ }));
20
+
21
+ vi.mock("@/lib/i18n", () => ({
22
+ formatDateTime: (value: string) => `formatted:${value}`,
23
+ t: (key: string) => key,
24
+ }));
25
+
26
+ beforeEach(() => {
27
+ captures.renders = [];
28
+ });
29
+
30
+ it("reuses adapted message references when the source message object is unchanged", () => {
31
+ const message = {
32
+ id: "assistant-1",
33
+ sessionId: "session-1",
34
+ role: "assistant",
35
+ status: "streaming",
36
+ timestamp: "2026-03-17T10:00:00.000Z",
37
+ parts: [{ type: "text", text: "hello" }],
38
+ } satisfies NcpMessage;
39
+
40
+ const { rerender } = render(
41
+ <ChatMessageListContainer messages={[message]} isSending={false} />,
42
+ );
43
+
44
+ const firstMessages =
45
+ captures.renders[captures.renders.length - 1]?.messages ?? [];
46
+
47
+ rerender(
48
+ <ChatMessageListContainer messages={[message]} isSending={false} />,
49
+ );
50
+
51
+ const secondMessages =
52
+ captures.renders[captures.renders.length - 1]?.messages ?? [];
53
+
54
+ expect(secondMessages[0]).toBe(firstMessages[0]);
55
+ });
56
+
57
+ it("keeps historical adapted message references stable when only the streaming message changes", () => {
58
+ const historicalMessage = {
59
+ id: "assistant-1",
60
+ sessionId: "session-1",
61
+ role: "assistant",
62
+ status: "final",
63
+ timestamp: "2026-03-17T10:00:00.000Z",
64
+ parts: [{ type: "text", text: "history" }],
65
+ } satisfies NcpMessage;
66
+ const firstStreamingMessage = {
67
+ id: "assistant-2",
68
+ sessionId: "session-1",
69
+ role: "assistant",
70
+ status: "streaming",
71
+ timestamp: "2026-03-17T10:00:01.000Z",
72
+ parts: [{ type: "text", text: "hello" }],
73
+ } satisfies NcpMessage;
74
+
75
+ const { rerender } = render(
76
+ <ChatMessageListContainer
77
+ messages={[historicalMessage, firstStreamingMessage]}
78
+ isSending={false}
79
+ />,
80
+ );
81
+
82
+ const firstMessages =
83
+ captures.renders[captures.renders.length - 1]?.messages ?? [];
84
+ const nextStreamingMessage = {
85
+ ...firstStreamingMessage,
86
+ parts: [{ type: "text", text: "hello world" }],
87
+ } satisfies NcpMessage;
88
+
89
+ rerender(
90
+ <ChatMessageListContainer
91
+ messages={[historicalMessage, nextStreamingMessage]}
92
+ isSending={false}
93
+ />,
94
+ );
95
+
96
+ const secondMessages =
97
+ captures.renders[captures.renders.length - 1]?.messages ?? [];
98
+
99
+ expect(secondMessages[0]).toBe(firstMessages[0]);
100
+ expect(secondMessages[1]).not.toBe(firstMessages[1]);
101
+ });