@nextclaw/ui 0.12.8 → 0.12.9

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 (142) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assets/ChannelsList-Ita2Zm1_.js +8 -0
  3. package/dist/assets/{DocBrowser-BMxf9CIK.js → DocBrowser-6ReNjvzF.js} +1 -1
  4. package/dist/assets/DocBrowser-BNwbPHf4.js +1 -0
  5. package/dist/assets/{DocBrowserContext-Ce28gRXt.js → DocBrowserContext-B6SpA7Qs.js} +1 -1
  6. package/dist/assets/{LogoBadge-o92MOA2L.js → LogoBadge-ByNLYg65.js} +1 -1
  7. package/dist/assets/MarketplacePage-CjX2MWww.js +1 -0
  8. package/dist/assets/{MarketplacePage-BySqkYDh.js → MarketplacePage-D0sDlYX4.js} +1 -1
  9. package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +40 -0
  10. package/dist/assets/{ModelConfig-IrmzoslW.js → ModelConfig-BzZenCH-.js} +1 -1
  11. package/dist/assets/{ProviderScopedModelInput-CmTIzgI7.js → ProviderScopedModelInput-Da7khnBA.js} +1 -1
  12. package/dist/assets/{ProvidersList-8_Kalfwl.js → ProvidersList-BbVzRxjY.js} +1 -1
  13. package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +1 -0
  14. package/dist/assets/RuntimeConfig-F_XKGgLm.js +1 -0
  15. package/dist/assets/{SearchConfig-DNBR-UbE.js → SearchConfig-BGkzXQP-.js} +1 -1
  16. package/dist/assets/{SecretsConfig-Ba1RPJaG.js → SecretsConfig-D281Rotl.js} +2 -2
  17. package/dist/assets/{SessionsConfig-Doqp5ghH.js → SessionsConfig-ChHQ7M5c.js} +2 -2
  18. package/dist/assets/{app-query-client-DniXoIN5.js → app-query-client-VnFElj4E.js} +1 -1
  19. package/dist/assets/{book-open-DocgeQtR.js → book-open-BdcxxoQu.js} +1 -1
  20. package/dist/assets/chat-page-Doe0yTtB.js +58 -0
  21. package/dist/assets/chat-session-display-cw78aiI_.js +1 -0
  22. package/dist/assets/{chunk-JZWAC4HX-BvKvh1R8.js → chunk-JZWAC4HX-DK5HPmIK.js} +1 -1
  23. package/dist/assets/{client-CVqPF5ie.js → client-_i4MU2bB.js} +1 -1
  24. package/dist/assets/{config-Bop2oB18.js → config-DtIQwrHF.js} +1 -1
  25. package/dist/assets/{createLucideIcon-DVv8taGY.js → createLucideIcon-BSeTgkZW.js} +1 -1
  26. package/dist/assets/desktop-update-config-Dpcf4BKG.js +1 -0
  27. package/dist/assets/{dist-Da5Gm_pO.js → dist-6TrrnPCR.js} +1 -1
  28. package/dist/assets/{dist-DmAlInRu.js → dist-ccBFUi-o.js} +1 -1
  29. package/dist/assets/download-BhDxnyvU.js +1 -0
  30. package/dist/assets/{external-link-DFjw3x1B.js → external-link-BgErLCNT.js} +1 -1
  31. package/dist/assets/{hash-DJtaCejM.js → hash-Bl7dr_UG.js} +1 -1
  32. package/dist/assets/i18n-eDHeDY0n.js +1 -0
  33. package/dist/assets/index-CF9xve0E.js +6 -0
  34. package/dist/assets/index-FgA52VBt.css +1 -0
  35. package/dist/assets/{infiniteQueryBehavior-DHSEQ3OH.js → infiniteQueryBehavior-ZDS92Qpp.js} +1 -1
  36. package/dist/assets/loader-circle-ACM1s51e.js +1 -0
  37. package/dist/assets/{logos-DEFUIR12.js → logos-x89HbrZ4.js} +1 -1
  38. package/dist/assets/{page-layout-Da3i3r6G.js → page-layout-vZnghcFy.js} +1 -1
  39. package/dist/assets/play-CFUwCA2E.js +1 -0
  40. package/dist/assets/plus-rYsv72JG.js +1 -0
  41. package/dist/assets/{popover-C_mWOFzI.js → popover-Bg1VoTZ6.js} +1 -1
  42. package/dist/assets/{refresh-ccw-D6HkNtfz.js → refresh-ccw-DT98i__E.js} +1 -1
  43. package/dist/assets/{refresh-cw-DRcvRrnc.js → refresh-cw-C47QSEwg.js} +1 -1
  44. package/dist/assets/{rotate-cw-BmDKfXtH.js → rotate-cw-JtFzpNn6.js} +1 -1
  45. package/dist/assets/{save-DHGmi2e9.js → save-3S6-H3Xw.js} +1 -1
  46. package/dist/assets/search-3kFR_zh9.js +1 -0
  47. package/dist/assets/{security-config-CbXfPZzr.js → security-config-BWaiARNk.js} +1 -1
  48. package/dist/assets/{select-Caud8QvU.js → select-DJ2MUjBB.js} +1 -1
  49. package/dist/assets/skeleton-ByQepn0M.js +1 -0
  50. package/dist/assets/{status-dot-DurKKSwA.js → status-dot-vbanNPFU.js} +1 -1
  51. package/dist/assets/{switch-0rmPBRKI.js → switch-BsLtHOH-.js} +1 -1
  52. package/dist/assets/{tabs-custom-5JLVL6v8.js → tabs-custom-D3HYMt6k.js} +1 -1
  53. package/dist/assets/{trash-2-C6caKPoz.js → trash-2-G48scll7.js} +1 -1
  54. package/dist/assets/{use-infinite-scroll-loader-dwnaa_qi.js → use-infinite-scroll-loader-DkNhD-42.js} +1 -1
  55. package/dist/assets/{useConfirmDialog-mMeWD_yo.js → useConfirmDialog-BkvTN-vd.js} +1 -1
  56. package/dist/assets/{useMutation-BmxxvCNf.js → useMutation-CBWjE2uj.js} +1 -1
  57. package/dist/assets/x-ByDbItbq.js +1 -0
  58. package/dist/index.html +95 -21
  59. package/dist/manifest.webmanifest +30 -0
  60. package/dist/offline.html +102 -0
  61. package/dist/pwa-192.png +0 -0
  62. package/dist/pwa-512.png +0 -0
  63. package/dist/sw.js +80 -0
  64. package/index.html +73 -1
  65. package/package.json +6 -6
  66. package/public/manifest.webmanifest +30 -0
  67. package/public/offline.html +102 -0
  68. package/public/pwa-192.png +0 -0
  69. package/public/pwa-512.png +0 -0
  70. package/public/sw.js +80 -0
  71. package/src/api/server-path.ts +27 -4
  72. package/src/api/types.ts +17 -10
  73. package/src/app.tsx +9 -0
  74. package/src/components/chat/ChatSidebar.test.tsx +43 -1
  75. package/src/components/chat/ChatSidebar.tsx +24 -0
  76. package/src/components/chat/adapters/chat-message.summary-truncation.test.ts +66 -0
  77. package/src/components/chat/adapters/file-operation/card.ts +9 -0
  78. package/src/components/chat/adapters/file-operation/diff.ts +14 -0
  79. package/src/components/chat/{ChatConversationPanel.test.tsx → chat-conversation-panel.test.tsx} +107 -206
  80. package/src/components/chat/chat-conversation-panel.tsx +412 -0
  81. package/src/components/chat/chat-page-shell.tsx +1 -1
  82. package/src/components/chat/chat-session-workspace-file-preview.test.tsx +91 -0
  83. package/src/components/chat/chat-session-workspace-file-preview.tsx +307 -0
  84. package/src/components/chat/chat-session-workspace-panel-nav.tsx +197 -0
  85. package/src/components/chat/chat-session-workspace-panel.tsx +318 -0
  86. package/src/components/chat/chat-sidebar-session-item.tsx +32 -2
  87. package/src/components/chat/containers/chat-message-list.container.test.tsx +49 -0
  88. package/src/components/chat/containers/chat-message-list.container.tsx +4 -0
  89. package/src/components/chat/managers/chat-session-list.manager.test.ts +12 -0
  90. package/src/components/chat/managers/chat-session-list.manager.ts +7 -0
  91. package/src/components/chat/ncp/ncp-chat-page.tsx +7 -7
  92. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +179 -41
  93. package/src/components/chat/ncp/ncp-session-adapter.test.ts +35 -1
  94. package/src/components/chat/ncp/ncp-session-adapter.ts +17 -0
  95. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +54 -11
  96. package/src/components/chat/ncp/tests/ncp-chat-thread.manager.test.ts +189 -0
  97. package/src/components/chat/presenter/chat-presenter-context.tsx +13 -2
  98. package/src/components/chat/session-header/chat-session-header-actions.test.tsx +26 -0
  99. package/src/components/chat/session-header/chat-session-header-actions.tsx +19 -1
  100. package/src/components/chat/stores/chat-thread.store.ts +24 -0
  101. package/src/components/config/RuntimeConfig.tsx +141 -2
  102. package/src/components/layout/AppLayout.tsx +1 -1
  103. package/src/components/providers/ThemeProvider.tsx +5 -0
  104. package/src/hooks/server-path/use-server-path-read.ts +20 -0
  105. package/src/lib/chat-message.ts +14 -3
  106. package/src/lib/i18n.chat.ts +12 -1
  107. package/src/lib/i18n.pwa.ts +62 -0
  108. package/src/lib/i18n.ts +2 -2
  109. package/src/pwa/components/pwa-install-entry.test.tsx +110 -0
  110. package/src/pwa/components/pwa-install-entry.tsx +205 -0
  111. package/src/pwa/managers/pwa-install.manager.test.ts +160 -0
  112. package/src/pwa/managers/pwa-install.manager.ts +232 -0
  113. package/src/pwa/managers/pwa-runtime.manager.ts +196 -0
  114. package/src/pwa/managers/pwa-shell-theme.manager.test.ts +30 -0
  115. package/src/pwa/managers/pwa-shell-theme.manager.ts +46 -0
  116. package/src/pwa/pwa-install-banner.storage.ts +55 -0
  117. package/src/pwa/pwa.types.ts +22 -0
  118. package/src/pwa/register-pwa.ts +14 -0
  119. package/src/pwa/stores/pwa.store.ts +17 -0
  120. package/src/vite-env.d.ts +9 -0
  121. package/dist/assets/ChannelsList-KIQIxluX.js +0 -8
  122. package/dist/assets/DocBrowser-CyDgAtO9.js +0 -1
  123. package/dist/assets/MarketplacePage-C0olZaek.js +0 -1
  124. package/dist/assets/McpMarketplacePage-DqKaiXO9.js +0 -40
  125. package/dist/assets/RemoteAccessPage-CyQlSjPf.js +0 -1
  126. package/dist/assets/RuntimeConfig-Bk0uYBhf.js +0 -1
  127. package/dist/assets/chat-page-Bph8M5zo.js +0 -58
  128. package/dist/assets/chat-session-display-CoN3Wmn-.js +0 -1
  129. package/dist/assets/desktop-update-config-1KBrqLBC.js +0 -1
  130. package/dist/assets/i18n-CwHZ-9vt.js +0 -1
  131. package/dist/assets/index-DafCdM4F.css +0 -1
  132. package/dist/assets/index-DdksE6U3.js +0 -6
  133. package/dist/assets/loader-circle-PsSP0H9n.js +0 -1
  134. package/dist/assets/play-DBQbBxTA.js +0 -1
  135. package/dist/assets/plus-DUOVbsyQ.js +0 -1
  136. package/dist/assets/search-MChQRYR1.js +0 -1
  137. package/dist/assets/skeleton-B-4vRq_Z.js +0 -1
  138. package/dist/assets/x-DuMhMATD.js +0 -1
  139. package/src/components/chat/ChatConversationPanel.tsx +0 -256
  140. package/src/components/chat/chat-child-session-panel.tsx +0 -270
  141. /package/dist/assets/{config-hints-BZoDjXye.js → config-hints-BhTmc9P1.js} +0 -0
  142. /package/dist/assets/{config-layout-DmlGaay2.js → config-layout-CHs0mAaR.js} +0 -0
@@ -1,14 +1,17 @@
1
1
  import { appQueryClient } from '@/app-query-client';
2
2
  import { deleteNcpSessionSummaryInQueryClient } from '@/api/ncp-session-query-cache';
3
3
  import { deleteNcpSession as deleteNcpSessionApi } from '@/api/ncp-session';
4
- import type { ChatToolActionViewModel } from '@nextclaw/agent-chat-ui';
4
+ import type {
5
+ ChatFileOpenActionViewModel,
6
+ ChatToolActionViewModel,
7
+ } from '@nextclaw/agent-chat-ui';
5
8
  import type { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
6
9
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
7
10
  import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
8
11
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
9
12
  import type {
10
- ChatChildSessionTab,
11
13
  ChatThreadSnapshot,
14
+ ChatWorkspaceFileTab,
12
15
  } from '@/components/chat/stores/chat-thread.store';
13
16
  import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
14
17
  import { t } from '@/lib/i18n';
@@ -37,6 +40,98 @@ export class NcpChatThreadManager {
37
40
  useChatThreadStore.getState().setSnapshot(patch);
38
41
  };
39
42
 
43
+ private clearDeletedSessionState = (sessionKey: string) => {
44
+ if (useChatSessionListStore.getState().snapshot.selectedSessionKey === sessionKey) {
45
+ this.sessionListManager.setSelectedSessionKey(null);
46
+ }
47
+ useChatThreadStore.getState().setSnapshot({
48
+ sessionKey: null,
49
+ sessionTypeLabel: null,
50
+ agentId: null,
51
+ agentDisplayName: null,
52
+ agentAvatarUrl: null,
53
+ sessionDisplayName: undefined,
54
+ sessionProjectRoot: null,
55
+ sessionProjectName: null,
56
+ canDeleteSession: false,
57
+ isHistoryLoading: false,
58
+ messages: [],
59
+ isSending: false,
60
+ isAwaitingAssistantOutput: false,
61
+ parentSessionKey: null,
62
+ parentSessionLabel: null,
63
+ workspacePanelParentKey: null,
64
+ childSessionTabs: [],
65
+ activeChildSessionKey: null,
66
+ workspaceFileTabs: [],
67
+ activeWorkspaceFileKey: null,
68
+ });
69
+ };
70
+
71
+ private resolveWorkspaceParentSessionKey = (): string | null => {
72
+ const threadSessionKey = useChatThreadStore.getState().snapshot.sessionKey?.trim();
73
+ if (threadSessionKey) {
74
+ return threadSessionKey;
75
+ }
76
+ return useChatSessionListStore.getState().snapshot.selectedSessionKey ?? null;
77
+ };
78
+
79
+ private buildWorkspaceFileTab = (
80
+ action: ChatFileOpenActionViewModel,
81
+ parentSessionKey: string | null,
82
+ ): ChatWorkspaceFileTab | null => {
83
+ const normalizedPath = action.path.trim();
84
+ if (!normalizedPath) {
85
+ return null;
86
+ }
87
+ const normalizedParentSessionKey = parentSessionKey?.trim() || null;
88
+ const key =
89
+ `${normalizedParentSessionKey ?? 'draft'}::${action.viewMode}::${normalizedPath}`;
90
+ return {
91
+ key,
92
+ parentSessionKey: normalizedParentSessionKey,
93
+ path: normalizedPath,
94
+ label: action.label?.trim() || null,
95
+ viewMode: action.viewMode,
96
+ line: action.line ?? null,
97
+ column: action.column ?? null,
98
+ rawText: action.rawText ?? null,
99
+ beforeText: action.beforeText ?? null,
100
+ afterText: action.afterText ?? null,
101
+ patchText: action.patchText ?? null,
102
+ oldStartLine: action.oldStartLine ?? null,
103
+ newStartLine: action.newStartLine ?? null,
104
+ fullLines: action.fullLines,
105
+ };
106
+ };
107
+
108
+ private upsertWorkspaceFileTab = (nextTab: ChatWorkspaceFileTab): ChatWorkspaceFileTab[] => {
109
+ const { workspaceFileTabs } = useChatThreadStore.getState().snapshot;
110
+ const existingIndex = workspaceFileTabs.findIndex((tab) => tab.key === nextTab.key);
111
+ if (existingIndex === -1) {
112
+ return [nextTab, ...workspaceFileTabs];
113
+ }
114
+ const nextTabs = [...workspaceFileTabs];
115
+ nextTabs.splice(existingIndex, 1);
116
+ nextTabs.unshift({
117
+ ...workspaceFileTabs[existingIndex],
118
+ ...nextTab,
119
+ });
120
+ return nextTabs;
121
+ };
122
+
123
+ private ensureWorkspaceParentRoute = (parentSessionKey: string | null) => {
124
+ if (!parentSessionKey) {
125
+ return;
126
+ }
127
+ const {
128
+ snapshot: { selectedSessionKey },
129
+ } = useChatSessionListStore.getState();
130
+ if (selectedSessionKey !== parentSessionKey) {
131
+ this.uiManager.goToSession(parentSessionKey);
132
+ }
133
+ };
134
+
40
135
  deleteSession = () => {
41
136
  void this.deleteCurrentSession();
42
137
  };
@@ -49,21 +144,36 @@ export class NcpChatThreadManager {
49
144
  this.uiManager.goToProviders();
50
145
  };
51
146
 
52
- private upsertChildSessionTab = (tab: ChatChildSessionTab) => {
53
- const { snapshot } = useChatThreadStore.getState();
54
- const existingIndex = snapshot.childSessionTabs.findIndex(
55
- (item) => item.sessionKey === tab.sessionKey,
56
- );
57
- const nextTabs =
58
- existingIndex >= 0
59
- ? snapshot.childSessionTabs.map((item, index) =>
60
- index === existingIndex ? { ...item, ...tab } : item,
61
- )
62
- : [...snapshot.childSessionTabs, tab];
147
+ openChildSessionPanel = (params: {
148
+ parentSessionKey: string;
149
+ activeChildSessionKey?: string | null;
150
+ }) => {
151
+ const parentSessionKey = params.parentSessionKey.trim();
152
+ if (!parentSessionKey) {
153
+ return;
154
+ }
155
+ const activeChildSessionKey = params.activeChildSessionKey?.trim() || null;
156
+ useChatThreadStore.getState().setSnapshot({
157
+ workspacePanelParentKey: parentSessionKey,
158
+ activeChildSessionKey,
159
+ activeWorkspaceFileKey: null,
160
+ });
161
+ this.ensureWorkspaceParentRoute(parentSessionKey);
162
+ };
163
+
164
+ openFilePreview = (action: ChatFileOpenActionViewModel) => {
165
+ const parentSessionKey = this.resolveWorkspaceParentSessionKey();
166
+ const nextTab = this.buildWorkspaceFileTab(action, parentSessionKey);
167
+ if (!nextTab) {
168
+ return;
169
+ }
63
170
  useChatThreadStore.getState().setSnapshot({
64
- childSessionTabs: nextTabs,
65
- activeChildSessionKey: tab.sessionKey,
171
+ workspacePanelParentKey: parentSessionKey,
172
+ workspaceFileTabs: this.upsertWorkspaceFileTab(nextTab),
173
+ activeWorkspaceFileKey: nextTab.key,
174
+ activeChildSessionKey: null,
66
175
  });
176
+ this.ensureWorkspaceParentRoute(parentSessionKey);
67
177
  };
68
178
 
69
179
  openSessionFromToolAction = (action: ChatToolActionViewModel) => {
@@ -75,14 +185,19 @@ export class NcpChatThreadManager {
75
185
  action.parentSessionId?.trim() ||
76
186
  useChatSessionListStore.getState().snapshot.selectedSessionKey ||
77
187
  null;
78
- this.upsertChildSessionTab({
79
- sessionKey: action.sessionId,
80
- parentSessionKey,
81
- label: action.label?.trim() || null,
82
- agentId: action.agentId?.trim() || null,
83
- });
84
- return;
188
+ if (parentSessionKey) {
189
+ this.openChildSessionPanel({
190
+ parentSessionKey,
191
+ activeChildSessionKey: action.sessionId,
192
+ });
193
+ return;
194
+ }
85
195
  }
196
+ useChatThreadStore.getState().setSnapshot({
197
+ workspacePanelParentKey: null,
198
+ activeChildSessionKey: null,
199
+ activeWorkspaceFileKey: null,
200
+ });
86
201
  this.uiManager.goToSession(action.sessionId);
87
202
  };
88
203
 
@@ -97,34 +212,56 @@ export class NcpChatThreadManager {
97
212
  }
98
213
  useChatThreadStore.getState().setSnapshot({
99
214
  activeChildSessionKey: normalizedSessionKey,
215
+ activeWorkspaceFileKey: null,
100
216
  });
101
217
  };
102
218
 
103
- closeChildSessionDetail = () => {
104
- const {
105
- sessionKey,
106
- childSessionTabs,
107
- activeChildSessionKey,
108
- } = useChatThreadStore.getState().snapshot;
109
- if (!sessionKey) {
110
- useChatThreadStore.getState().setSnapshot({
111
- childSessionTabs: [],
112
- activeChildSessionKey: null,
113
- });
219
+ selectWorkspaceFile = (fileKey: string) => {
220
+ const normalizedFileKey = fileKey.trim();
221
+ if (!normalizedFileKey) {
114
222
  return;
115
223
  }
116
- const nextTabs = childSessionTabs.filter(
117
- (tab) => tab.parentSessionKey !== sessionKey,
224
+ const { workspaceFileTabs } = useChatThreadStore.getState().snapshot;
225
+ if (!workspaceFileTabs.some((tab) => tab.key === normalizedFileKey)) {
226
+ return;
227
+ }
228
+ useChatThreadStore.getState().setSnapshot({
229
+ activeWorkspaceFileKey: normalizedFileKey,
230
+ activeChildSessionKey: null,
231
+ });
232
+ };
233
+
234
+ closeWorkspaceFile = (fileKey: string) => {
235
+ const normalizedFileKey = fileKey.trim();
236
+ if (!normalizedFileKey) {
237
+ return;
238
+ }
239
+ const { snapshot } = useChatThreadStore.getState();
240
+ const { activeWorkspaceFileKey, workspaceFileTabs } = snapshot;
241
+ const nextTabs = workspaceFileTabs.filter(
242
+ (tab) => tab.key !== normalizedFileKey,
118
243
  );
119
- const nextActiveKey = nextTabs.some((tab) => tab.sessionKey === activeChildSessionKey)
120
- ? activeChildSessionKey
121
- : null;
244
+ const nextPatch: Partial<ChatThreadSnapshot> = {
245
+ workspaceFileTabs: nextTabs,
246
+ };
247
+ if (activeWorkspaceFileKey === normalizedFileKey) {
248
+ nextPatch.activeWorkspaceFileKey = null;
249
+ }
250
+ useChatThreadStore.getState().setSnapshot(nextPatch);
251
+ };
252
+
253
+ closeWorkspacePanel = () => {
122
254
  useChatThreadStore.getState().setSnapshot({
123
- childSessionTabs: nextTabs,
124
- activeChildSessionKey: nextActiveKey,
255
+ workspacePanelParentKey: null,
256
+ activeChildSessionKey: null,
257
+ activeWorkspaceFileKey: null,
125
258
  });
126
259
  };
127
260
 
261
+ closeChildSessionDetail = () => {
262
+ this.closeWorkspacePanel();
263
+ };
264
+
128
265
  goToParentSession = () => {
129
266
  const {
130
267
  parentSessionKey,
@@ -139,7 +276,7 @@ export class NcpChatThreadManager {
139
276
  if (!resolvedParentSessionKey) {
140
277
  return;
141
278
  }
142
- this.closeChildSessionDetail();
279
+ this.closeWorkspacePanel();
143
280
  this.uiManager.goToSession(resolvedParentSessionKey);
144
281
  };
145
282
 
@@ -171,6 +308,7 @@ export class NcpChatThreadManager {
171
308
  deleteNcpSessionSummaryInQueryClient(appQueryClient, selectedSessionKey);
172
309
  appQueryClient.removeQueries({ queryKey: ['ncp-session-messages', selectedSessionKey] });
173
310
  this.streamActionsManager.resetStreamState();
311
+ this.clearDeletedSessionState(selectedSessionKey);
174
312
  this.uiManager.goToChatRoot({ replace: true });
175
313
  } finally {
176
314
  useChatThreadStore.getState().setSnapshot({ isDeletePending: false });
@@ -71,7 +71,7 @@ describe('adaptNcpSessionSummary', () => {
71
71
  });
72
72
  });
73
73
 
74
- describe('adaptNcpMessageToUiMessage', () => {
74
+ describe('adaptNcpMessageToUiMessage file rendering', () => {
75
75
  it('preserves mixed text and image part order for message rendering', () => {
76
76
  const adapted = adaptNcpMessageToUiMessage({
77
77
  id: 'ncp-message-1',
@@ -111,6 +111,40 @@ describe('adaptNcpMessageToUiMessage', () => {
111
111
  ]);
112
112
  });
113
113
 
114
+ it('maps assetUri file parts into asset content urls for rendering', () => {
115
+ const adapted = adaptNcpMessageToUiMessage({
116
+ id: 'ncp-message-asset-1',
117
+ sessionId: 'ncp-session-1',
118
+ role: 'assistant',
119
+ status: 'final',
120
+ timestamp: '2026-04-16T00:00:00.000Z',
121
+ parts: [
122
+ {
123
+ type: 'file',
124
+ name: 'diagram.png',
125
+ mimeType: 'image/png',
126
+ assetUri: 'asset://store/2026/04/16/asset_123',
127
+ sizeBytes: 42,
128
+ },
129
+ ],
130
+ });
131
+
132
+ expect(adapted.parts).toHaveLength(1);
133
+ expect(adapted.parts[0]).toMatchObject({
134
+ type: 'file',
135
+ name: 'diagram.png',
136
+ mimeType: 'image/png',
137
+ data: '',
138
+ sizeBytes: 42,
139
+ });
140
+ expect((adapted.parts[0] as { url?: string }).url).toMatch(
141
+ /\/api\/ncp\/assets\/content\?uri=asset%3A%2F%2Fstore%2F2026%2F04%2F16%2Fasset_123$/,
142
+ );
143
+ });
144
+
145
+ });
146
+
147
+ describe('adaptNcpMessageToUiMessage tool rendering', () => {
114
148
  it('keeps streamed native file tool args renderable as a preview before the tool result arrives', () => {
115
149
  const uiMessage = adaptNcpMessageToUiMessage({
116
150
  id: 'ncp-message-tool-1',
@@ -1,6 +1,7 @@
1
1
  import { ToolInvocationStatus, type UIMessage } from '@nextclaw/agent-chat';
2
2
  import type { NcpMessagePart } from '@nextclaw/ncp';
3
3
  import type { NcpMessageView, NcpSessionSummaryView, SessionEntryView, ThinkingLevel } from '@/api/types';
4
+ import { API_BASE } from '@/api/api-base';
4
5
  import {
5
6
  getSessionProjectName,
6
7
  normalizeSessionProjectRootValue,
@@ -19,6 +20,11 @@ function stringifyUnknown(value: unknown): string {
19
20
  }
20
21
  }
21
22
 
23
+ function buildNcpAssetContentUrl(assetUri: string): string {
24
+ const query = new URLSearchParams({ uri: assetUri });
25
+ return `${API_BASE}/api/ncp/assets/content?${query.toString()}`;
26
+ }
27
+
22
28
  function readOptionalString(value: unknown): string | null {
23
29
  if (typeof value !== 'string') {
24
30
  return null;
@@ -207,6 +213,17 @@ function toUiParts(parts: NcpMessagePart[]): UIMessage['parts'] {
207
213
  });
208
214
  continue;
209
215
  }
216
+ if (part.type === 'file' && part.assetUri) {
217
+ uiParts.push({
218
+ type: 'file',
219
+ ...(part.name ? { name: part.name } : {}),
220
+ mimeType: part.mimeType ?? 'application/octet-stream',
221
+ data: '',
222
+ url: buildNcpAssetContentUrl(part.assetUri),
223
+ ...(typeof part.sizeBytes === 'number' ? { sizeBytes: part.sizeBytes } : {})
224
+ });
225
+ continue;
226
+ }
210
227
  if (part.type === 'step-start') {
211
228
  uiParts.push({ type: 'step-start' });
212
229
  continue;
@@ -10,8 +10,30 @@ import { adaptNcpSessionSummary } from '@/components/chat/ncp/ncp-session-adapte
10
10
  import type { NcpChatPresenter } from '@/components/chat/ncp/ncp-chat.presenter';
11
11
  import type { UseHydratedNcpAgentResult } from '@nextclaw/ncp-react';
12
12
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
13
+ import type { ChatChildSessionTab } from '@/components/chat/stores/chat-thread.store';
13
14
  import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeState';
15
+
16
+ function buildChildSessionTabs(params: {
17
+ parentSessionKey: string | null;
18
+ sessionSummaries: NcpSessionSummaryView[];
19
+ }): ChatChildSessionTab[] {
20
+ if (!params.parentSessionKey) {
21
+ return [];
22
+ }
23
+ return params.sessionSummaries
24
+ .map(adaptNcpSessionSummary)
25
+ .filter((session) => session.parentSessionId === params.parentSessionKey)
26
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
27
+ .map((session) => ({
28
+ sessionKey: session.key,
29
+ parentSessionKey: session.parentSessionId ?? null,
30
+ label: session.label ?? null,
31
+ agentId: session.agentId ?? null,
32
+ }));
33
+ }
34
+
14
35
  export function useNcpChatDerivedState(params: {
36
+ sessionKey: string | null;
15
37
  selectedSession: SessionEntryView | null;
16
38
  selectedAgentId: string;
17
39
  availableAgents: AgentProfileView[];
@@ -20,32 +42,51 @@ export function useNcpChatDerivedState(params: {
20
42
  selectedSessionType: string;
21
43
  sessionTypeOptions: Array<{ value: string; label: string }>;
22
44
  }) {
23
- const currentSessionDisplayName = params.selectedSession
24
- ? sessionDisplayName(params.selectedSession)
45
+ const {
46
+ availableAgents,
47
+ parentSessionId,
48
+ selectedAgentId,
49
+ selectedSession,
50
+ selectedSessionType,
51
+ sessionKey,
52
+ sessionSummaries,
53
+ sessionTypeOptions,
54
+ } = params;
55
+ const currentSessionDisplayName = selectedSession
56
+ ? sessionDisplayName(selectedSession)
25
57
  : undefined;
26
- const currentAgentId = params.selectedSession?.agentId ?? params.selectedAgentId;
58
+ const currentAgentId = selectedSession?.agentId ?? selectedAgentId;
27
59
  const currentAgent =
28
- params.availableAgents.find((agent) => agent.id === currentAgentId) ?? null;
60
+ availableAgents.find((agent) => agent.id === currentAgentId) ?? null;
29
61
  const parentSession = useMemo(() => {
30
- if (!params.parentSessionId) {
62
+ if (!parentSessionId) {
31
63
  return null;
32
64
  }
33
65
  const parentSummary =
34
- params.sessionSummaries.find(
35
- (summary) => summary.sessionId === params.parentSessionId,
66
+ sessionSummaries.find(
67
+ (summary) => summary.sessionId === parentSessionId,
36
68
  ) ?? null;
37
69
  return parentSummary ? adaptNcpSessionSummary(parentSummary) : null;
38
- }, [params.parentSessionId, params.sessionSummaries]);
70
+ }, [parentSessionId, sessionSummaries]);
39
71
  const currentSessionTypeLabel =
40
- params.sessionTypeOptions.find((option) => option.value === params.selectedSessionType)
41
- ?.label ?? resolveSessionTypeLabel(params.selectedSessionType);
72
+ sessionTypeOptions.find((option) => option.value === selectedSessionType)
73
+ ?.label ?? resolveSessionTypeLabel(selectedSessionType);
74
+ const currentChildSessionTabs = useMemo(
75
+ () =>
76
+ buildChildSessionTabs({
77
+ parentSessionKey: sessionKey,
78
+ sessionSummaries,
79
+ }),
80
+ [sessionKey, sessionSummaries],
81
+ );
42
82
 
43
83
  return {
44
84
  currentSessionDisplayName,
45
85
  currentAgentId,
46
86
  currentAgent,
47
87
  parentSession,
48
- currentSessionTypeLabel
88
+ currentSessionTypeLabel,
89
+ currentChildSessionTabs,
49
90
  };
50
91
  }
51
92
 
@@ -78,6 +119,7 @@ export function useNcpChatSnapshotSync(params: {
78
119
  agent: Pick<UseHydratedNcpAgentResult, 'isHydrating' | 'visibleMessages'>;
79
120
  isAwaitingAssistantOutput: boolean;
80
121
  parentSession: SessionEntryView | null;
122
+ childSessionTabs: ChatChildSessionTab[];
81
123
  }) {
82
124
  useEffect(() => {
83
125
  params.presenter.chatInputManager.syncSnapshot({
@@ -121,6 +163,7 @@ export function useNcpChatSnapshotSync(params: {
121
163
  parentSessionLabel: params.parentSession
122
164
  ? sessionDisplayName(params.parentSession)
123
165
  : null,
166
+ childSessionTabs: params.childSessionTabs,
124
167
  });
125
168
  }, [
126
169
  params
@@ -0,0 +1,189 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { appQueryClient } from '@/app-query-client';
3
+ import { NcpChatThreadManager } from '@/components/chat/ncp/ncp-chat-thread.manager';
4
+ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
5
+ import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
6
+
7
+ const { deleteNcpSessionMock, deleteSummaryMock } = vi.hoisted(() => ({
8
+ deleteNcpSessionMock: vi.fn(async () => ({ deleted: true, sessionId: 'parent-session-1' })),
9
+ deleteSummaryMock: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('@/api/ncp-session', () => ({
13
+ deleteNcpSession: deleteNcpSessionMock,
14
+ }));
15
+
16
+ vi.mock('@/api/ncp-session-query-cache', () => ({
17
+ deleteNcpSessionSummaryInQueryClient: deleteSummaryMock,
18
+ }));
19
+
20
+ describe('NcpChatThreadManager', () => {
21
+ beforeEach(() => {
22
+ useChatSessionListStore.setState({
23
+ optimisticReadAtBySessionKey: {},
24
+ snapshot: {
25
+ ...useChatSessionListStore.getState().snapshot,
26
+ selectedSessionKey: 'parent-session-1',
27
+ },
28
+ });
29
+ useChatThreadStore.setState({
30
+ snapshot: {
31
+ ...useChatThreadStore.getState().snapshot,
32
+ sessionKey: 'parent-session-1',
33
+ workspacePanelParentKey: null,
34
+ childSessionTabs: [
35
+ {
36
+ sessionKey: 'child-session-1',
37
+ parentSessionKey: 'parent-session-1',
38
+ label: 'Child Session 1',
39
+ agentId: 'reviewer',
40
+ },
41
+ ],
42
+ activeChildSessionKey: null,
43
+ },
44
+ });
45
+ });
46
+
47
+ it('opens the child-session panel for the requested parent session and keeps focus on the chosen child', () => {
48
+ const uiManager = {
49
+ goToSession: vi.fn(),
50
+ goToChatRoot: vi.fn(),
51
+ goToProviders: vi.fn(),
52
+ confirm: vi.fn(),
53
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0];
54
+
55
+ const manager = new NcpChatThreadManager(
56
+ uiManager,
57
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[1],
58
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[2],
59
+ );
60
+
61
+ manager.openChildSessionPanel({
62
+ parentSessionKey: 'parent-session-1',
63
+ activeChildSessionKey: 'child-session-1',
64
+ });
65
+
66
+ expect(useChatThreadStore.getState().snapshot.workspacePanelParentKey).toBe('parent-session-1');
67
+ expect(useChatThreadStore.getState().snapshot.activeChildSessionKey).toBe('child-session-1');
68
+ expect(uiManager.goToSession).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it('routes to the parent session before opening the child-session panel when needed', () => {
72
+ useChatSessionListStore.setState({
73
+ snapshot: {
74
+ ...useChatSessionListStore.getState().snapshot,
75
+ selectedSessionKey: 'another-session',
76
+ },
77
+ });
78
+ const uiManager = {
79
+ goToSession: vi.fn(),
80
+ goToChatRoot: vi.fn(),
81
+ goToProviders: vi.fn(),
82
+ confirm: vi.fn(),
83
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0];
84
+
85
+ const manager = new NcpChatThreadManager(
86
+ uiManager,
87
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[1],
88
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[2],
89
+ );
90
+
91
+ manager.openChildSessionPanel({
92
+ parentSessionKey: 'parent-session-1',
93
+ activeChildSessionKey: 'child-session-1',
94
+ });
95
+
96
+ expect(uiManager.goToSession).toHaveBeenCalledWith('parent-session-1');
97
+ });
98
+
99
+ it('keeps preview and diff for the same file as separate workspace tabs', () => {
100
+ const uiManager = {
101
+ goToSession: vi.fn(),
102
+ goToChatRoot: vi.fn(),
103
+ goToProviders: vi.fn(),
104
+ confirm: vi.fn(),
105
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0];
106
+
107
+ const manager = new NcpChatThreadManager(
108
+ uiManager,
109
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[1],
110
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[2],
111
+ );
112
+
113
+ manager.openFilePreview({
114
+ path: 'README.md',
115
+ label: 'README.md',
116
+ viewMode: 'preview',
117
+ });
118
+ manager.openFilePreview({
119
+ path: 'README.md',
120
+ label: 'README.md',
121
+ viewMode: 'diff',
122
+ beforeText: 'old\n',
123
+ afterText: 'new\n',
124
+ });
125
+
126
+ expect(useChatThreadStore.getState().snapshot.workspaceFileTabs).toEqual(
127
+ expect.arrayContaining([
128
+ expect.objectContaining({
129
+ key: 'parent-session-1::preview::README.md',
130
+ path: 'README.md',
131
+ viewMode: 'preview',
132
+ }),
133
+ expect.objectContaining({
134
+ key: 'parent-session-1::diff::README.md',
135
+ path: 'README.md',
136
+ viewMode: 'diff',
137
+ }),
138
+ ]),
139
+ );
140
+ });
141
+
142
+ it('clears the selected thread state after deleting the current session', async () => {
143
+ const removeQueries = vi.spyOn(appQueryClient, 'removeQueries').mockImplementation(async () => undefined);
144
+ const uiManager = {
145
+ goToSession: vi.fn(),
146
+ goToChatRoot: vi.fn(),
147
+ goToProviders: vi.fn(),
148
+ confirm: vi.fn(async () => true),
149
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0];
150
+ const sessionListManager = {
151
+ setSelectedSessionKey: vi.fn((value: string | null) => {
152
+ useChatSessionListStore.getState().setSnapshot({
153
+ selectedSessionKey: value,
154
+ });
155
+ }),
156
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[1];
157
+ const streamActionsManager = {
158
+ resetStreamState: vi.fn(),
159
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[2];
160
+ const manager = new NcpChatThreadManager(
161
+ uiManager,
162
+ sessionListManager,
163
+ streamActionsManager,
164
+ );
165
+
166
+ await (manager as unknown as { deleteCurrentSession: () => Promise<void> }).deleteCurrentSession();
167
+
168
+ expect(sessionListManager.setSelectedSessionKey).toHaveBeenCalledWith(null);
169
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
170
+ expect(useChatThreadStore.getState().snapshot).toMatchObject({
171
+ sessionKey: null,
172
+ canDeleteSession: false,
173
+ messages: [],
174
+ workspacePanelParentKey: null,
175
+ childSessionTabs: [],
176
+ activeChildSessionKey: null,
177
+ workspaceFileTabs: [],
178
+ activeWorkspaceFileKey: null,
179
+ });
180
+ expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
181
+ expect(deleteSummaryMock).toHaveBeenCalledWith(appQueryClient, 'parent-session-1');
182
+ expect(removeQueries).toHaveBeenCalledWith({
183
+ queryKey: ['ncp-session-messages', 'parent-session-1'],
184
+ });
185
+ expect(uiManager.goToChatRoot).toHaveBeenCalledWith({ replace: true });
186
+
187
+ removeQueries.mockRestore();
188
+ });
189
+ });