@nextclaw/ui 0.12.7 → 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 (171) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/assets/ChannelsList-Ita2Zm1_.js +8 -0
  3. package/dist/assets/{DocBrowser-Cse_F8Nn.js → DocBrowser-6ReNjvzF.js} +1 -1
  4. package/dist/assets/DocBrowser-BNwbPHf4.js +1 -0
  5. package/dist/assets/{DocBrowserContext-Bai1WU2H.js → DocBrowserContext-B6SpA7Qs.js} +1 -1
  6. package/dist/assets/{LogoBadge-BdxMPc9v.js → LogoBadge-ByNLYg65.js} +1 -1
  7. package/dist/assets/MarketplacePage-CjX2MWww.js +1 -0
  8. package/dist/assets/{MarketplacePage-BbpAkllU.js → MarketplacePage-D0sDlYX4.js} +1 -1
  9. package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +40 -0
  10. package/dist/assets/{ModelConfig-3GLqQ5GY.js → ModelConfig-BzZenCH-.js} +1 -1
  11. package/dist/assets/{ProviderScopedModelInput-BYNouw-i.js → ProviderScopedModelInput-Da7khnBA.js} +1 -1
  12. package/dist/assets/{ProvidersList-BR1gJ4Dm.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-DTeJvp8m.js → SearchConfig-BGkzXQP-.js} +1 -1
  16. package/dist/assets/{SecretsConfig-CCYO6NcV.js → SecretsConfig-D281Rotl.js} +2 -2
  17. package/dist/assets/{SessionsConfig-Du39vDgt.js → SessionsConfig-ChHQ7M5c.js} +2 -2
  18. package/dist/assets/{app-query-client-Dr5d-K8d.js → app-query-client-VnFElj4E.js} +1 -1
  19. package/dist/assets/{book-open-Da4OEPqB.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-CoFVxHXV.js → chunk-JZWAC4HX-DK5HPmIK.js} +1 -1
  23. package/dist/assets/{client-CSk58DcF.js → client-_i4MU2bB.js} +1 -1
  24. package/dist/assets/{config-D8KzikVB.js → config-DtIQwrHF.js} +1 -1
  25. package/dist/assets/{createLucideIcon-83gaZMtv.js → createLucideIcon-BSeTgkZW.js} +1 -1
  26. package/dist/assets/desktop-update-config-Dpcf4BKG.js +1 -0
  27. package/dist/assets/{dist-toEYs-MZ.js → dist-6TrrnPCR.js} +1 -1
  28. package/dist/assets/{dist-aTmhMDVh.js → dist-ccBFUi-o.js} +1 -1
  29. package/dist/assets/download-BhDxnyvU.js +1 -0
  30. package/dist/assets/{external-link-QQ0TC6X4.js → external-link-BgErLCNT.js} +1 -1
  31. package/dist/assets/{hash-DaFBEkmi.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-BmHX_ayZ.js → infiniteQueryBehavior-ZDS92Qpp.js} +1 -1
  36. package/dist/assets/loader-circle-ACM1s51e.js +1 -0
  37. package/dist/assets/{logos-Dzlz30M3.js → logos-x89HbrZ4.js} +1 -1
  38. package/dist/assets/{page-layout-D2eRufRQ.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-BSXxm5bj.js → popover-Bg1VoTZ6.js} +1 -1
  42. package/dist/assets/{refresh-ccw-B3zMtN-_.js → refresh-ccw-DT98i__E.js} +1 -1
  43. package/dist/assets/{refresh-cw-DlZkIHnJ.js → refresh-cw-C47QSEwg.js} +1 -1
  44. package/dist/assets/rotate-cw-JtFzpNn6.js +1 -0
  45. package/dist/assets/{save-Us9fg4Sj.js → save-3S6-H3Xw.js} +1 -1
  46. package/dist/assets/search-3kFR_zh9.js +1 -0
  47. package/dist/assets/{security-config-BGWYwxNr.js → security-config-BWaiARNk.js} +1 -1
  48. package/dist/assets/{select-DLYqySQK.js → select-DJ2MUjBB.js} +1 -1
  49. package/dist/assets/skeleton-ByQepn0M.js +1 -0
  50. package/dist/assets/{status-dot-DGayudyB.js → status-dot-vbanNPFU.js} +1 -1
  51. package/dist/assets/{switch-Dz2ScsKx.js → switch-BsLtHOH-.js} +1 -1
  52. package/dist/assets/{tabs-custom-CdKyjiGk.js → tabs-custom-D3HYMt6k.js} +1 -1
  53. package/dist/assets/{trash-2-Db-mZOZs.js → trash-2-G48scll7.js} +1 -1
  54. package/dist/assets/{use-infinite-scroll-loader-DBJX5hj0.js → use-infinite-scroll-loader-DkNhD-42.js} +1 -1
  55. package/dist/assets/{useConfirmDialog-DL0a-oGC.js → useConfirmDialog-BkvTN-vd.js} +1 -1
  56. package/dist/assets/{useMutation-BdZm-9PL.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/runtime-control.ts +34 -0
  72. package/src/api/runtime-control.types.ts +58 -0
  73. package/src/api/server-path.ts +27 -4
  74. package/src/api/types.ts +30 -10
  75. package/src/{App.test.tsx → app.test.tsx} +1 -1
  76. package/src/{App.tsx → app.tsx} +10 -1
  77. package/src/components/chat/ChatSidebar.test.tsx +79 -8
  78. package/src/components/chat/ChatSidebar.tsx +43 -26
  79. package/src/components/chat/adapters/chat-message.summary-truncation.test.ts +66 -0
  80. package/src/components/chat/adapters/file-operation/card.ts +9 -0
  81. package/src/components/chat/adapters/file-operation/diff.ts +14 -0
  82. package/src/components/chat/{ChatConversationPanel.test.tsx → chat-conversation-panel.test.tsx} +118 -155
  83. package/src/components/chat/chat-conversation-panel.tsx +412 -0
  84. package/src/components/chat/chat-page-runtime.test.ts +1 -1
  85. package/src/components/chat/chat-page-shell.tsx +1 -1
  86. package/src/components/chat/{ChatPage.tsx → chat-page.tsx} +1 -1
  87. package/src/components/chat/chat-session-workspace-file-preview.test.tsx +91 -0
  88. package/src/components/chat/chat-session-workspace-file-preview.tsx +307 -0
  89. package/src/components/chat/chat-session-workspace-panel-nav.tsx +197 -0
  90. package/src/components/chat/chat-session-workspace-panel.tsx +318 -0
  91. package/src/components/chat/chat-sidebar-session-item.tsx +32 -2
  92. package/src/components/chat/containers/chat-message-list.container.test.tsx +49 -0
  93. package/src/components/chat/containers/chat-message-list.container.tsx +4 -0
  94. package/src/components/chat/managers/chat-session-list.manager.test.ts +94 -31
  95. package/src/components/chat/managers/chat-session-list.manager.ts +86 -14
  96. package/src/components/chat/managers/chat-ui.manager.ts +2 -0
  97. package/src/components/chat/ncp/README.md +1 -1
  98. package/src/components/chat/ncp/ncp-chat-input.manager.ts +7 -1
  99. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +1 -1
  100. package/src/components/chat/ncp/{NcpChatPage.tsx → ncp-chat-page.tsx} +7 -7
  101. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +179 -41
  102. package/src/components/chat/ncp/ncp-session-adapter.test.ts +40 -2
  103. package/src/components/chat/ncp/ncp-session-adapter.ts +29 -0
  104. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +54 -11
  105. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +4 -0
  106. package/src/components/chat/ncp/tests/ncp-chat-input.manager.test.ts +99 -0
  107. package/src/components/chat/ncp/tests/ncp-chat-thread.manager.test.ts +189 -0
  108. package/src/components/chat/presenter/chat-presenter-context.tsx +13 -2
  109. package/src/components/chat/session-header/chat-session-header-actions.test.tsx +26 -0
  110. package/src/components/chat/session-header/chat-session-header-actions.tsx +19 -1
  111. package/src/components/chat/stores/chat-session-list.store.ts +25 -54
  112. package/src/components/chat/stores/chat-thread.store.ts +24 -0
  113. package/src/components/common/ProviderScopedModelInput.tsx +12 -2
  114. package/src/components/config/ModelConfig.test.tsx +108 -2
  115. package/src/components/config/RuntimeConfig.tsx +154 -7
  116. package/src/components/config/desktop-update-config.test.tsx +85 -0
  117. package/src/components/config/desktop-update-config.tsx +44 -3
  118. package/src/components/config/runtime-control-card.test.tsx +255 -0
  119. package/src/components/config/runtime-control-card.tsx +301 -0
  120. package/src/components/config/runtime-presence-card.test.tsx +154 -0
  121. package/src/components/config/runtime-presence-card.tsx +163 -0
  122. package/src/components/layout/AppLayout.tsx +1 -1
  123. package/src/components/providers/ThemeProvider.tsx +5 -0
  124. package/src/desktop/desktop-update.types.ts +25 -0
  125. package/src/desktop/managers/desktop-presence.manager.ts +91 -0
  126. package/src/desktop/managers/desktop-update.manager.ts +37 -1
  127. package/src/desktop/stores/desktop-presence.store.ts +18 -0
  128. package/src/desktop/stores/desktop-update.store.ts +7 -1
  129. package/src/hooks/server-path/use-server-path-read.ts +20 -0
  130. package/src/hooks/use-runtime-control.ts +24 -0
  131. package/src/lib/chat-message.ts +14 -3
  132. package/src/lib/desktop-update-labels.utils.ts +28 -2
  133. package/src/lib/i18n.chat.ts +12 -1
  134. package/src/lib/i18n.pwa.ts +62 -0
  135. package/src/lib/i18n.runtime-control.ts +120 -0
  136. package/src/lib/i18n.ts +4 -6
  137. package/src/main.tsx +1 -1
  138. package/src/pwa/components/pwa-install-entry.test.tsx +110 -0
  139. package/src/pwa/components/pwa-install-entry.tsx +205 -0
  140. package/src/pwa/managers/pwa-install.manager.test.ts +160 -0
  141. package/src/pwa/managers/pwa-install.manager.ts +232 -0
  142. package/src/pwa/managers/pwa-runtime.manager.ts +196 -0
  143. package/src/pwa/managers/pwa-shell-theme.manager.test.ts +30 -0
  144. package/src/pwa/managers/pwa-shell-theme.manager.ts +46 -0
  145. package/src/pwa/pwa-install-banner.storage.ts +55 -0
  146. package/src/pwa/pwa.types.ts +22 -0
  147. package/src/pwa/register-pwa.ts +14 -0
  148. package/src/pwa/stores/pwa.store.ts +17 -0
  149. package/src/runtime-control/runtime-control.manager.ts +118 -0
  150. package/src/vite-env.d.ts +9 -0
  151. package/dist/assets/ChannelsList-D8p4OlM6.js +0 -8
  152. package/dist/assets/ChatPage-A45t1Rmf.js +0 -58
  153. package/dist/assets/DocBrowser-B2MpsnU9.js +0 -1
  154. package/dist/assets/MarketplacePage-BNZ3Jx5d.js +0 -1
  155. package/dist/assets/McpMarketplacePage-CxPFOgxv.js +0 -40
  156. package/dist/assets/RemoteAccessPage-DyYVWsyK.js +0 -1
  157. package/dist/assets/RuntimeConfig-ChdfK4Y_.js +0 -1
  158. package/dist/assets/chat-session-display-CAlPrnlV.js +0 -1
  159. package/dist/assets/desktop-update-config-CfoVwf-w.js +0 -1
  160. package/dist/assets/i18n-C3jb83S6.js +0 -1
  161. package/dist/assets/index-CE4N7ItL.css +0 -1
  162. package/dist/assets/index-riX7Sg0_.js +0 -6
  163. package/dist/assets/loader-circle-BjMg63eu.js +0 -1
  164. package/dist/assets/plus-CIXME2pD.js +0 -1
  165. package/dist/assets/search-B_Qr0f6C.js +0 -1
  166. package/dist/assets/skeleton-CYQJazv6.js +0 -1
  167. package/dist/assets/x-B8Tho_xC.js +0 -1
  168. package/src/components/chat/ChatConversationPanel.tsx +0 -256
  169. package/src/components/chat/chat-child-session-panel.tsx +0 -262
  170. /package/dist/assets/{config-hints-GSUMvmSo.js → config-hints-BhTmc9P1.js} +0 -0
  171. /package/dist/assets/{config-layout-CgBMG7OL.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 });
@@ -21,12 +21,14 @@ describe('adaptNcpSessionSummary', () => {
21
21
  const adapted = adaptNcpSessionSummary(
22
22
  createSummary({
23
23
  agentId: 'engineer',
24
+ lastMessageAt: '2026-03-18T00:00:00.000Z',
24
25
  metadata: {
25
26
  label: 'NCP Planning Thread',
26
27
  model: 'openai/gpt-5',
27
28
  preferred_thinking: 'medium',
28
29
  project_root: '/Users/demo/workspace/project-alpha',
29
- session_type: 'native'
30
+ session_type: 'native',
31
+ ui_last_read_at: '2026-03-17T23:59:00.000Z'
30
32
  }
31
33
  })
32
34
  );
@@ -39,6 +41,8 @@ describe('adaptNcpSessionSummary', () => {
39
41
  preferredThinking: 'medium',
40
42
  projectRoot: '/Users/demo/workspace/project-alpha',
41
43
  projectName: 'project-alpha',
44
+ lastMessageAt: '2026-03-18T00:00:00.000Z',
45
+ readAt: '2026-03-17T23:59:00.000Z',
42
46
  sessionType: 'native',
43
47
  sessionTypeMutable: false,
44
48
  isChildSession: false,
@@ -67,7 +71,7 @@ describe('adaptNcpSessionSummary', () => {
67
71
  });
68
72
  });
69
73
 
70
- describe('adaptNcpMessageToUiMessage', () => {
74
+ describe('adaptNcpMessageToUiMessage file rendering', () => {
71
75
  it('preserves mixed text and image part order for message rendering', () => {
72
76
  const adapted = adaptNcpMessageToUiMessage({
73
77
  id: 'ncp-message-1',
@@ -107,6 +111,40 @@ describe('adaptNcpMessageToUiMessage', () => {
107
111
  ]);
108
112
  });
109
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', () => {
110
148
  it('keeps streamed native file tool args renderable as a preview before the tool result arrives', () => {
111
149
  const uiMessage = adaptNcpMessageToUiMessage({
112
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;
@@ -80,6 +86,14 @@ function readNcpSessionProjectRoot(summary: NcpSessionSummaryView): string | nul
80
86
  return normalizeSessionProjectRootValue(metadata.project_root ?? metadata.projectRoot);
81
87
  }
82
88
 
89
+ function readNcpSessionReadAt(summary: NcpSessionSummaryView): string | null {
90
+ const metadata = readMetadata(summary);
91
+ if (!metadata) {
92
+ return null;
93
+ }
94
+ return readOptionalString(metadata.ui_last_read_at);
95
+ }
96
+
83
97
  function readNcpSessionType(summary: NcpSessionSummaryView): string {
84
98
  const metadata = readMetadata(summary);
85
99
  if (!metadata) {
@@ -199,6 +213,17 @@ function toUiParts(parts: NcpMessagePart[]): UIMessage['parts'] {
199
213
  });
200
214
  continue;
201
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
+ }
202
227
  if (part.type === 'step-start') {
203
228
  uiParts.push({ type: 'step-start' });
204
229
  continue;
@@ -249,6 +274,8 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
249
274
  const preferredModel = readNcpSessionPreferredModel(summary);
250
275
  const preferredThinking = readNcpSessionPreferredThinking(summary);
251
276
  const projectRoot = readNcpSessionProjectRoot(summary);
277
+ const readAt = readNcpSessionReadAt(summary);
278
+ const lastMessageAt = readOptionalString(summary.lastMessageAt);
252
279
  const projectName = getSessionProjectName(projectRoot);
253
280
  const context = parseSessionContext(summary.sessionId);
254
281
  const parentSessionId = readNcpParentSessionId(summary);
@@ -258,6 +285,8 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
258
285
  key: summary.sessionId,
259
286
  createdAt: summary.updatedAt,
260
287
  updatedAt: summary.updatedAt,
288
+ ...(lastMessageAt ? { lastMessageAt } : {}),
289
+ ...(readAt ? { readAt } : {}),
261
290
  ...(typeof summary.agentId === 'string' && summary.agentId.trim().length > 0 ? { agentId: summary.agentId.trim() } : {}),
262
291
  ...(label ? { label } : {}),
263
292
  ...context,
@@ -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
@@ -13,6 +13,8 @@ export type ResolvedChildSessionTab = {
13
13
  title: string;
14
14
  agentId: string | null;
15
15
  updatedAt: string | null;
16
+ lastMessageAt: string | null;
17
+ readAt: string | null;
16
18
  runStatus?: SessionRunStatus;
17
19
  sessionTypeLabel: string | null;
18
20
  preferredModel: string | null;
@@ -64,6 +66,8 @@ export function useNcpChildSessionTabsView(
64
66
  title: resolveChildSessionTitle(tab, session),
65
67
  agentId,
66
68
  updatedAt: session?.updatedAt ?? null,
69
+ lastMessageAt: session?.lastMessageAt ?? null,
70
+ readAt: session?.readAt ?? null,
67
71
  runStatus: summary?.status === "running" ? "running" : undefined,
68
72
  sessionTypeLabel: session?.sessionType
69
73
  ? resolveSessionTypeLabel(session.sessionType)
@@ -0,0 +1,99 @@
1
+ import { createChatComposerTextNode } from '@nextclaw/agent-chat-ui';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { NcpChatInputManager } from '@/components/chat/ncp/ncp-chat-input.manager';
4
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
5
+ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
6
+ import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
7
+
8
+ describe('NcpChatInputManager', () => {
9
+ beforeEach(() => {
10
+ useChatInputStore.setState({
11
+ snapshot: {
12
+ ...useChatInputStore.getState().snapshot,
13
+ draft: 'hello from current thread',
14
+ composerNodes: [createChatComposerTextNode('hello from current thread')],
15
+ attachments: [],
16
+ selectedSkills: [],
17
+ selectedSessionType: 'native',
18
+ selectedModel: '',
19
+ selectedThinkingLevel: null,
20
+ },
21
+ });
22
+ useChatSessionListStore.setState({
23
+ optimisticReadAtBySessionKey: {},
24
+ snapshot: {
25
+ ...useChatSessionListStore.getState().snapshot,
26
+ selectedSessionKey: 'stale-selected-session',
27
+ draftSessionKey: 'draft-root-session',
28
+ selectedAgentId: 'main',
29
+ },
30
+ });
31
+ useChatThreadStore.setState({
32
+ snapshot: {
33
+ ...useChatThreadStore.getState().snapshot,
34
+ sessionKey: 'current-route-session',
35
+ },
36
+ });
37
+ });
38
+
39
+ it('sends through the current thread session when selected session state is stale', async () => {
40
+ const streamActionsManager = {
41
+ sendMessage: vi.fn().mockResolvedValue(undefined),
42
+ stopCurrentRun: vi.fn().mockResolvedValue(undefined),
43
+ } as unknown as ConstructorParameters<typeof NcpChatInputManager>[1];
44
+ const sessionListManager = {
45
+ ensureDraftSession: vi.fn(() => 'draft-session'),
46
+ promoteRootDraftSessionRoute: vi.fn(),
47
+ } as unknown as ConstructorParameters<typeof NcpChatInputManager>[2];
48
+ const manager = new NcpChatInputManager(
49
+ {} as ConstructorParameters<typeof NcpChatInputManager>[0],
50
+ streamActionsManager,
51
+ sessionListManager,
52
+ );
53
+
54
+ await manager.send();
55
+
56
+ expect(streamActionsManager.sendMessage).toHaveBeenCalledTimes(1);
57
+ expect(streamActionsManager.sendMessage).toHaveBeenCalledWith(
58
+ expect.objectContaining({
59
+ sessionKey: 'current-route-session',
60
+ message: 'hello from current thread',
61
+ }),
62
+ );
63
+ expect(sessionListManager.ensureDraftSession).not.toHaveBeenCalled();
64
+ expect(sessionListManager.promoteRootDraftSessionRoute).toHaveBeenCalledWith('current-route-session');
65
+ });
66
+
67
+ it('keeps sending through the current root draft session while /chat is still in blank-draft mode', async () => {
68
+ useChatThreadStore.setState({
69
+ snapshot: {
70
+ ...useChatThreadStore.getState().snapshot,
71
+ sessionKey: 'draft-root-session',
72
+ },
73
+ });
74
+ const streamActionsManager = {
75
+ sendMessage: vi.fn().mockResolvedValue(undefined),
76
+ stopCurrentRun: vi.fn().mockResolvedValue(undefined),
77
+ } as unknown as ConstructorParameters<typeof NcpChatInputManager>[1];
78
+ const sessionListManager = {
79
+ ensureDraftSession: vi.fn(() => 'materialized-draft-session'),
80
+ promoteRootDraftSessionRoute: vi.fn(),
81
+ } as unknown as ConstructorParameters<typeof NcpChatInputManager>[2];
82
+ const manager = new NcpChatInputManager(
83
+ {} as ConstructorParameters<typeof NcpChatInputManager>[0],
84
+ streamActionsManager,
85
+ sessionListManager,
86
+ );
87
+
88
+ await manager.send();
89
+
90
+ expect(sessionListManager.ensureDraftSession).not.toHaveBeenCalled();
91
+ expect(streamActionsManager.sendMessage).toHaveBeenCalledWith(
92
+ expect.objectContaining({
93
+ sessionKey: 'draft-root-session',
94
+ message: 'hello from current thread',
95
+ }),
96
+ );
97
+ expect(sessionListManager.promoteRootDraftSessionRoute).toHaveBeenCalledWith('draft-root-session');
98
+ });
99
+ });