@nextclaw/ui 0.12.3 → 0.12.5

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 (123) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/assets/{ChannelsList-DZWam3Ob.js → ChannelsList-C6-lh55g.js} +2 -2
  3. package/dist/assets/ChatPage-DOW0gPc2.js +45 -0
  4. package/dist/assets/DocBrowser-CGyeswYP.js +1 -0
  5. package/dist/assets/{DocBrowser-C7-1sXqo.js → DocBrowser-QUZ3nfmH.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-DN5tjUoS.js → DocBrowserContext-CpiIfhJO.js} +1 -1
  7. package/dist/assets/{LogoBadge-DDS1sU_U.js → LogoBadge-BUK13xK5.js} +1 -1
  8. package/dist/assets/MarketplacePage-BDVwhIYE.js +1 -0
  9. package/dist/assets/MarketplacePage-LnKKL3xK.js +49 -0
  10. package/dist/assets/McpMarketplacePage-BG4T_Pcx.js +40 -0
  11. package/dist/assets/ModelConfig-LtWuogIw.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-DGn6sFEN.js +1 -0
  13. package/dist/assets/ProvidersList-ma-_MlLo.js +1 -0
  14. package/dist/assets/{RemoteAccessPage-COnjm8_x.js → RemoteAccessPage-ff15qO-c.js} +1 -1
  15. package/dist/assets/RuntimeConfig-TgPandXF.js +1 -0
  16. package/dist/assets/SearchConfig-C9iBt7pl.js +1 -0
  17. package/dist/assets/{SecretsConfig-Cefg1LFJ.js → SecretsConfig-Bew4EF2A.js} +2 -2
  18. package/dist/assets/{SessionsConfig-BZnmVTIu.js → SessionsConfig-2r2yAGZg.js} +2 -2
  19. package/dist/assets/{book-open-DvWqOode.js → book-open-CJG8Yz3U.js} +1 -1
  20. package/dist/assets/{chat-session-display-D4bYa0b8.js → chat-session-display-DkAC5OMC.js} +1 -1
  21. package/dist/assets/{chunk-JZWAC4HX-CxfKRD7X.js → chunk-JZWAC4HX-D5b3Iyas.js} +1 -1
  22. package/dist/assets/{config-BeGwf2Ao.js → config-zvnxSXSP.js} +1 -1
  23. package/dist/assets/{createLucideIcon-C7MmdIX3.js → createLucideIcon-_FMJqZw2.js} +1 -1
  24. package/dist/assets/{dist-B6VMuIQN.js → dist-B1fpOuON.js} +1 -1
  25. package/dist/assets/{dist-RWNFhxvR.js → dist-BCXX7FD-.js} +2 -2
  26. package/dist/assets/{external-link-U86Acd1t.js → external-link-b7gAJWYY.js} +1 -1
  27. package/dist/assets/{hash-D-OVfV3Z.js → hash-Bhy4TwfZ.js} +1 -1
  28. package/dist/assets/i18n-DJg9BPYk.js +1 -0
  29. package/dist/assets/index-BoJbxdvZ.css +1 -0
  30. package/dist/assets/index-CtlT4E9Y.js +6 -0
  31. package/dist/assets/infiniteQueryBehavior-CTcVlD9s.js +1 -0
  32. package/dist/assets/loader-circle-B60I0hEk.js +1 -0
  33. package/dist/assets/{logos-U1_qDA3U.js → logos-GMeYU9vc.js} +1 -1
  34. package/dist/assets/{page-layout-Z1klaUFW.js → page-layout-C8UbWuMt.js} +1 -1
  35. package/dist/assets/plus-CR7RfK3H.js +1 -0
  36. package/dist/assets/{popover-xWbqMnIN.js → popover-8HSx9wQj.js} +1 -1
  37. package/dist/assets/react-BB4jko2M.js +1 -0
  38. package/dist/assets/{refresh-ccw-JQh1lwq-.js → refresh-ccw-CA4_C7Zg.js} +1 -1
  39. package/dist/assets/{save-4VRlzkii.js → save-BtvMy4lk.js} +1 -1
  40. package/dist/assets/search-C60UA27E.js +1 -0
  41. package/dist/assets/security-config-BkFDYZ6j.js +1 -0
  42. package/dist/assets/{select-DF-AUoie.js → select-xp_Ac8ip.js} +1 -1
  43. package/dist/assets/skeleton-uxz_5h3A.js +1 -0
  44. package/dist/assets/{status-dot-Bq_8Ojvv.js → status-dot-Cn4Pp7DZ.js} +1 -1
  45. package/dist/assets/{switch-D7JF_RZ-.js → switch-BTi6UOij.js} +1 -1
  46. package/dist/assets/{tabs-custom-CLksZ2bO.js → tabs-custom-BiiN8DME.js} +1 -1
  47. package/dist/assets/{trash-2-VV8jvziy.js → trash-2-BpsF0N-r.js} +1 -1
  48. package/dist/assets/use-infinite-scroll-loader-C8jBv11-.js +1 -0
  49. package/dist/assets/{useConfirmDialog-CuQqiPx7.js → useConfirmDialog-BJIwUZjH.js} +1 -1
  50. package/dist/assets/{useMutation-DBTWPbTg.js → useMutation-BjBOKHj_.js} +1 -1
  51. package/dist/assets/x-BfTu-g7D.js +1 -0
  52. package/dist/index.html +19 -18
  53. package/package.json +5 -5
  54. package/src/account/components/account-panel.tsx +46 -4
  55. package/src/account/managers/account.manager.ts +19 -4
  56. package/src/api/remote.ts +9 -0
  57. package/src/api/remote.types.ts +5 -0
  58. package/src/components/chat/ChatConversationPanel.test.tsx +183 -141
  59. package/src/components/chat/ChatSidebar.test.tsx +168 -28
  60. package/src/components/chat/ChatSidebar.tsx +103 -28
  61. package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +11 -11
  62. package/src/components/chat/adapters/chat-message.adapter.test.ts +43 -6
  63. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +182 -44
  64. package/src/components/chat/adapters/chat-message.session-spawn-tool-card.test.ts +104 -0
  65. package/src/components/chat/chat-child-session-panel.tsx +103 -45
  66. package/src/components/chat/chat-page-runtime.test.ts +16 -19
  67. package/src/components/chat/chat-session-preference-sync.test.ts +13 -0
  68. package/src/components/chat/chat-session-preference-sync.ts +9 -7
  69. package/src/components/chat/chat-sidebar-list-mode-switch.tsx +43 -0
  70. package/src/components/chat/chat-sidebar-project-groups.tsx +152 -0
  71. package/src/components/chat/hooks/use-chat-session-project.test.tsx +5 -5
  72. package/src/components/chat/hooks/use-chat-session-project.ts +0 -5
  73. package/src/components/chat/hooks/use-chat-session-update.test.tsx +75 -0
  74. package/src/components/chat/hooks/use-chat-session-update.ts +4 -2
  75. package/src/components/chat/managers/chat-session-list.manager.test.ts +46 -6
  76. package/src/components/chat/managers/chat-session-list.manager.ts +19 -6
  77. package/src/components/chat/ncp/NcpChatPage.tsx +33 -38
  78. package/src/components/chat/ncp/ncp-chat-input.manager.ts +3 -5
  79. package/src/components/chat/ncp/ncp-chat-page-data.ts +0 -1
  80. package/src/components/chat/ncp/ncp-chat.presenter.ts +2 -16
  81. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +20 -7
  82. package/src/components/chat/session-header/chat-session-project-badge.test.tsx +16 -0
  83. package/src/components/chat/session-header/chat-session-project-badge.tsx +2 -2
  84. package/src/components/chat/stores/chat-session-list.store.ts +3 -0
  85. package/src/components/chat/useChatSessionTypeState.test.tsx +0 -3
  86. package/src/components/chat/useChatSessionTypeState.ts +3 -5
  87. package/src/components/config/ChannelsList.test.tsx +68 -0
  88. package/src/components/config/ChannelsList.tsx +22 -4
  89. package/src/components/config/ProvidersList.tsx +17 -3
  90. package/src/components/config/providers-list.test.tsx +68 -0
  91. package/src/components/layout/Sidebar.tsx +13 -13
  92. package/src/components/layout/sidebar.layout.test.tsx +32 -1
  93. package/src/components/marketplace/MarketplacePage.tsx +30 -30
  94. package/src/components/marketplace/marketplace-page-parts.tsx +16 -24
  95. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +28 -26
  96. package/src/hooks/marketplace-list-pages.ts +27 -0
  97. package/src/hooks/use-infinite-scroll-loader.ts +88 -0
  98. package/src/hooks/useMarketplace.ts +14 -3
  99. package/src/hooks/useMcpMarketplace.ts +14 -3
  100. package/src/lib/i18n.chat.ts +3 -0
  101. package/src/lib/i18n.remote.ts +15 -0
  102. package/dist/assets/ChatPage-YBL7iJ1X.js +0 -43
  103. package/dist/assets/DocBrowser-DQjtSsY3.js +0 -1
  104. package/dist/assets/MarketplacePage-2tWWgwAb.js +0 -49
  105. package/dist/assets/MarketplacePage-BorWJftJ.js +0 -1
  106. package/dist/assets/McpMarketplacePage-N-fB4HID.js +0 -40
  107. package/dist/assets/ModelConfig-DvsBTUiE.js +0 -1
  108. package/dist/assets/ProviderScopedModelInput-D9woCARc.js +0 -1
  109. package/dist/assets/ProvidersList-D-qPGgC4.js +0 -1
  110. package/dist/assets/RuntimeConfig-BHpqcaHm.js +0 -1
  111. package/dist/assets/SearchConfig-DIT6M65Q.js +0 -1
  112. package/dist/assets/i18n-hM3v-3YG.js +0 -1
  113. package/dist/assets/index-CpxuJa9o.css +0 -1
  114. package/dist/assets/index-DHmCjcxq.js +0 -6
  115. package/dist/assets/label-CHJ1ATds.js +0 -1
  116. package/dist/assets/loader-circle-C8cpaL0w.js +0 -1
  117. package/dist/assets/marketplace-localization-CxSTG9wr.js +0 -1
  118. package/dist/assets/plus-CrkO1kob.js +0 -1
  119. package/dist/assets/react-3YE87-lE.js +0 -1
  120. package/dist/assets/search-EX-Papzl.js +0 -1
  121. package/dist/assets/security-config-DEgOD4VX.js +0 -1
  122. package/dist/assets/skeleton-B0mmt1vo.js +0 -1
  123. package/dist/assets/x-B4sxJkGY.js +0 -1
@@ -9,6 +9,7 @@ import type { ChatToolPartViewModel } from "@nextclaw/agent-chat-ui";
9
9
  type ToolCardViewSource = ToolCard & {
10
10
  statusTone: ChatToolPartViewModel["statusTone"];
11
11
  statusLabel: string;
12
+ input?: string;
12
13
  action?: ChatToolPartViewModel["action"];
13
14
  };
14
15
 
@@ -31,15 +32,30 @@ type SessionRequestResult = {
31
32
  sessionId?: string;
32
33
  agentId?: string;
33
34
  isChildSession?: boolean;
35
+ lifecycle?: string;
34
36
  title?: string;
35
37
  task?: string;
36
38
  status?: string;
39
+ notify?: string;
40
+ spawnedByRequestId?: string;
37
41
  message?: unknown;
38
42
  finalResponseText?: unknown;
39
43
  error?: unknown;
40
44
  parentSessionId?: string;
41
45
  };
42
46
 
47
+ type SessionSpawnResult = {
48
+ kind: string;
49
+ sessionId?: string;
50
+ agentId?: string;
51
+ isChildSession?: boolean;
52
+ title?: string;
53
+ sessionType?: string;
54
+ lifecycle?: string;
55
+ createdAt?: string;
56
+ parentSessionId?: string;
57
+ };
58
+
43
59
  function isRecord(value: unknown): value is Record<string, unknown> {
44
60
  return typeof value === "object" && value !== null;
45
61
  }
@@ -59,6 +75,33 @@ function readSessionRequestResult(value: unknown): SessionRequestResult | null {
59
75
  return value as SessionRequestResult;
60
76
  }
61
77
 
78
+ function readSessionSpawnResult(value: unknown): SessionSpawnResult | null {
79
+ if (!isRecord(value) || value.kind !== "nextclaw.session") {
80
+ return null;
81
+ }
82
+ return value as SessionSpawnResult;
83
+ }
84
+
85
+ function parseStructuredValue(value: unknown): unknown {
86
+ if (typeof value !== "string") {
87
+ return value;
88
+ }
89
+ const trimmed = value.trim();
90
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
91
+ return value;
92
+ }
93
+ try {
94
+ return JSON.parse(trimmed) as unknown;
95
+ } catch {
96
+ return value;
97
+ }
98
+ }
99
+
100
+ function buildStructuredInput(value: unknown): string | undefined {
101
+ const text = stringifyUnknown(parseStructuredValue(value)).trim();
102
+ return text || undefined;
103
+ }
104
+
62
105
  function buildSessionRequestDetail(
63
106
  result: SessionRequestResult,
64
107
  fallbackArgs: unknown,
@@ -78,11 +121,32 @@ function buildSessionRequestDetail(
78
121
  return detailParts.join(" · ") || summarizeToolArgs(fallbackArgs);
79
122
  }
80
123
 
124
+ function buildSessionSpawnDetail(
125
+ result: SessionSpawnResult,
126
+ fallbackArgs: unknown,
127
+ ): string | undefined {
128
+ const detailParts = [
129
+ readOptionalString(result.title)
130
+ ? `title: ${result.title?.trim()}`
131
+ : null,
132
+ readOptionalString(result.sessionId)
133
+ ? `session: ${result.sessionId?.trim()}`
134
+ : null,
135
+ ].filter((value): value is string => Boolean(value));
136
+
137
+ return detailParts.join(" · ") || summarizeToolArgs(fallbackArgs);
138
+ }
139
+
81
140
  function buildSessionRequestOutput(result: SessionRequestResult): string | undefined {
82
141
  const requestId = readOptionalString(result.requestId);
83
142
  const sessionId = readOptionalString(result.sessionId);
84
143
  const title = readOptionalString(result.title);
85
144
  const task = readOptionalString(result.task);
145
+ const status = readOptionalString(result.status);
146
+ const notify = readOptionalString(result.notify);
147
+ const lifecycle = readOptionalString(result.lifecycle);
148
+ const parentSessionId = readOptionalString(result.parentSessionId);
149
+ const spawnedByRequestId = readOptionalString(result.spawnedByRequestId);
86
150
  const messageText =
87
151
  typeof result.message !== "undefined"
88
152
  ? stringifyUnknown(result.message).trim()
@@ -102,6 +166,11 @@ function buildSessionRequestOutput(result: SessionRequestResult): string | undef
102
166
  typeof result.isChildSession === "boolean"
103
167
  ? `Target: ${result.isChildSession ? "child" : "session"}`
104
168
  : null,
169
+ status ? `Status: ${status}` : null,
170
+ notify ? `Notify: ${notify}` : null,
171
+ lifecycle ? `Lifecycle: ${lifecycle}` : null,
172
+ parentSessionId ? `Parent Session ID: ${parentSessionId}` : null,
173
+ spawnedByRequestId ? `Spawned By Request ID: ${spawnedByRequestId}` : null,
105
174
  title ? `Title: ${title}` : null,
106
175
  task ? `Task:\n${task}` : null,
107
176
  finalResponseText
@@ -116,6 +185,29 @@ function buildSessionRequestOutput(result: SessionRequestResult): string | undef
116
185
  return sections.length > 0 ? sections.join("\n\n") : undefined;
117
186
  }
118
187
 
188
+ function buildSessionSpawnOutput(result: SessionSpawnResult): string | undefined {
189
+ const sessionId = readOptionalString(result.sessionId);
190
+ const title = readOptionalString(result.title);
191
+ const sessionType = readOptionalString(result.sessionType);
192
+ const lifecycle = readOptionalString(result.lifecycle);
193
+ const parentSessionId = readOptionalString(result.parentSessionId);
194
+ const createdAt = readOptionalString(result.createdAt);
195
+
196
+ const sections = [
197
+ sessionId ? `Session ID: ${sessionId}` : null,
198
+ typeof result.isChildSession === "boolean"
199
+ ? `Target: ${result.isChildSession ? "child" : "session"}`
200
+ : null,
201
+ title ? `Title: ${title}` : null,
202
+ sessionType ? `Session Type: ${sessionType}` : null,
203
+ lifecycle ? `Lifecycle: ${lifecycle}` : null,
204
+ parentSessionId ? `Parent Session ID: ${parentSessionId}` : null,
205
+ createdAt ? `Created At: ${createdAt}` : null,
206
+ ].filter((value): value is string => Boolean(value));
207
+
208
+ return sections.length > 0 ? sections.join("\n\n") : undefined;
209
+ }
210
+
119
211
  export function buildSessionRequestToolCard(params: {
120
212
  invocation: SessionRequestInvocation;
121
213
  texts: SessionRequestToolCardTexts;
@@ -123,77 +215,123 @@ export function buildSessionRequestToolCard(params: {
123
215
  const { invocation, texts } = params;
124
216
  const { toolName, toolCallId, args, result } = invocation;
125
217
 
126
- if (toolName !== "spawn" && toolName !== "sessions_request") {
218
+ if (
219
+ toolName !== "spawn" &&
220
+ toolName !== "sessions_request" &&
221
+ toolName !== "sessions_spawn"
222
+ ) {
127
223
  return null;
128
224
  }
129
225
 
130
226
  const sessionRequest = readSessionRequestResult(result);
131
- if (!sessionRequest) {
132
- return null;
133
- }
227
+ if (sessionRequest) {
228
+ const normalizedStatus = readOptionalString(sessionRequest.status)?.toLowerCase();
229
+ const detail = buildSessionRequestDetail(sessionRequest, args);
230
+ const output = buildSessionRequestOutput(sessionRequest);
231
+ const targetSessionId = readOptionalString(sessionRequest.sessionId);
232
+ const agentId = resolveToolInvocationAgentId({ args, result: sessionRequest });
233
+ const action =
234
+ targetSessionId
235
+ ? {
236
+ kind: "open-session" as const,
237
+ sessionId: targetSessionId,
238
+ sessionKind: sessionRequest.isChildSession === true ? ("child" as const) : ("session" as const),
239
+ ...(agentId
240
+ ? { agentId }
241
+ : {}),
242
+ ...(readOptionalString(sessionRequest.title)
243
+ ? { label: sessionRequest.title!.trim() }
244
+ : {}),
245
+ ...(readOptionalString(sessionRequest.parentSessionId)
246
+ ? { parentSessionId: sessionRequest.parentSessionId!.trim() }
247
+ : {}),
248
+ }
249
+ : undefined;
134
250
 
135
- const normalizedStatus = readOptionalString(sessionRequest.status)?.toLowerCase();
136
- const detail = buildSessionRequestDetail(sessionRequest, args);
137
- const output = buildSessionRequestOutput(sessionRequest);
138
- const targetSessionId = readOptionalString(sessionRequest.sessionId);
139
- const agentId = resolveToolInvocationAgentId({ args, result: sessionRequest });
140
- const action =
141
- targetSessionId
142
- ? {
143
- kind: "open-session" as const,
144
- sessionId: targetSessionId,
145
- sessionKind: sessionRequest.isChildSession === true ? ("child" as const) : ("session" as const),
146
- ...(agentId
147
- ? { agentId }
148
- : {}),
149
- ...(readOptionalString(sessionRequest.title)
150
- ? { label: sessionRequest.title!.trim() }
151
- : {}),
152
- ...(readOptionalString(sessionRequest.parentSessionId)
153
- ? { parentSessionId: sessionRequest.parentSessionId!.trim() }
154
- : {}),
155
- }
156
- : undefined;
251
+ if (normalizedStatus === "failed") {
252
+ return {
253
+ kind: "result",
254
+ name: toolName,
255
+ detail,
256
+ input: buildStructuredInput(args),
257
+ text: output,
258
+ callId: toolCallId || undefined,
259
+ hasResult: Boolean(output),
260
+ statusTone: "error",
261
+ statusLabel: texts.toolStatusFailedLabel,
262
+ ...(agentId ? { agentId } : {}),
263
+ ...(action ? { action } : {}),
264
+ };
265
+ }
266
+
267
+ if (normalizedStatus === "completed") {
268
+ return {
269
+ kind: "result",
270
+ name: toolName,
271
+ detail,
272
+ input: buildStructuredInput(args),
273
+ text: output,
274
+ callId: toolCallId || undefined,
275
+ hasResult: Boolean(output),
276
+ statusTone: "success",
277
+ statusLabel: texts.toolStatusCompletedLabel,
278
+ ...(agentId ? { agentId } : {}),
279
+ ...(action ? { action } : {}),
280
+ };
281
+ }
157
282
 
158
- if (normalizedStatus === "failed") {
159
283
  return {
160
284
  kind: "result",
161
285
  name: toolName,
162
286
  detail,
287
+ input: buildStructuredInput(args),
163
288
  text: output,
164
289
  callId: toolCallId || undefined,
165
290
  hasResult: Boolean(output),
166
- statusTone: "error",
167
- statusLabel: texts.toolStatusFailedLabel,
291
+ statusTone: "running",
292
+ statusLabel: texts.toolStatusRunningLabel,
168
293
  ...(agentId ? { agentId } : {}),
169
294
  ...(action ? { action } : {}),
170
295
  };
171
296
  }
172
297
 
173
- if (normalizedStatus === "completed") {
174
- return {
175
- kind: "result",
176
- name: toolName,
177
- detail,
178
- text: output,
179
- callId: toolCallId || undefined,
180
- hasResult: Boolean(output),
181
- statusTone: "success",
182
- statusLabel: texts.toolStatusCompletedLabel,
183
- ...(agentId ? { agentId } : {}),
184
- ...(action ? { action } : {}),
185
- };
298
+ const sessionSpawn = readSessionSpawnResult(result);
299
+ if (!sessionSpawn) {
300
+ return null;
186
301
  }
187
302
 
303
+ const detail = buildSessionSpawnDetail(sessionSpawn, args);
304
+ const output = buildSessionSpawnOutput(sessionSpawn);
305
+ const targetSessionId = readOptionalString(sessionSpawn.sessionId);
306
+ const agentId = resolveToolInvocationAgentId({ args, result: sessionSpawn });
307
+ const action =
308
+ targetSessionId
309
+ ? {
310
+ kind: "open-session" as const,
311
+ sessionId: targetSessionId,
312
+ sessionKind: sessionSpawn.isChildSession === true ? ("child" as const) : ("session" as const),
313
+ ...(agentId
314
+ ? { agentId }
315
+ : {}),
316
+ ...(readOptionalString(sessionSpawn.title)
317
+ ? { label: sessionSpawn.title!.trim() }
318
+ : {}),
319
+ ...(readOptionalString(sessionSpawn.parentSessionId)
320
+ ? { parentSessionId: sessionSpawn.parentSessionId!.trim() }
321
+ : {}),
322
+ }
323
+ : undefined;
324
+
188
325
  return {
189
326
  kind: "result",
190
327
  name: toolName,
191
328
  detail,
329
+ input: buildStructuredInput(args),
192
330
  text: output,
193
331
  callId: toolCallId || undefined,
194
332
  hasResult: Boolean(output),
195
- statusTone: "running",
196
- statusLabel: texts.toolStatusRunningLabel,
333
+ statusTone: "success",
334
+ statusLabel: texts.toolStatusCompletedLabel,
197
335
  ...(agentId ? { agentId } : {}),
198
336
  ...(action ? { action } : {}),
199
337
  };
@@ -0,0 +1,104 @@
1
+ import { ToolInvocationStatus, type UiMessage } from "@nextclaw/agent-chat";
2
+ import { adaptChatMessages } from "@/components/chat/adapters/chat-message.adapter";
3
+ import type { ChatMessageSource } from "@/components/chat/adapters/chat-message.adapter";
4
+
5
+ const defaultTexts = {
6
+ roleLabels: {
7
+ user: "You",
8
+ assistant: "Assistant",
9
+ tool: "Tool",
10
+ system: "System",
11
+ fallback: "Message",
12
+ },
13
+ reasoningLabel: "Reasoning",
14
+ toolCallLabel: "Tool Call",
15
+ toolResultLabel: "Tool Result",
16
+ toolInputLabel: "Input",
17
+ toolNoOutputLabel: "No output",
18
+ toolOutputLabel: "Output",
19
+ toolStatusPreparingLabel: "Preparing",
20
+ toolStatusRunningLabel: "Running",
21
+ toolStatusCompletedLabel: "Completed",
22
+ toolStatusFailedLabel: "Failed",
23
+ toolStatusCancelledLabel: "Cancelled",
24
+ imageAttachmentLabel: "Image attachment",
25
+ fileAttachmentLabel: "File attachment",
26
+ unknownPartLabel: "Unknown Part",
27
+ };
28
+
29
+ function adapt(uiMessages: UiMessage[]) {
30
+ return adaptChatMessages({
31
+ uiMessages: uiMessages as unknown as ChatMessageSource[],
32
+ formatTimestamp: (value) => `formatted:${value}`,
33
+ texts: defaultTexts,
34
+ });
35
+ }
36
+
37
+ it("renders child-session creation cards for sessions_spawn and keeps child-panel navigation", () => {
38
+ const adapted = adapt([
39
+ {
40
+ id: "assistant-child-session-create",
41
+ role: "assistant",
42
+ parts: [
43
+ {
44
+ type: "tool-invocation",
45
+ toolInvocation: {
46
+ status: ToolInvocationStatus.RESULT,
47
+ toolCallId: "sessions-spawn-child-only-1",
48
+ toolName: "sessions_spawn",
49
+ args: '{"scope":"child","title":"Verifier","task":"Prepare a child workspace"}',
50
+ result: {
51
+ kind: "nextclaw.session",
52
+ sessionId: "child-session-2",
53
+ agentId: "verifier-agent",
54
+ isChildSession: true,
55
+ title: "Verifier",
56
+ sessionType: "native",
57
+ lifecycle: "persistent",
58
+ parentSessionId: "parent-session-2",
59
+ createdAt: "2026-04-09T09:00:00.000Z",
60
+ },
61
+ },
62
+ },
63
+ ],
64
+ },
65
+ ]);
66
+
67
+ expect(adapted[0]?.parts[0]).toMatchObject({
68
+ type: "tool-card",
69
+ card: {
70
+ toolName: "sessions_spawn",
71
+ agentId: "verifier-agent",
72
+ summary: "title: Verifier · session: child-session-2",
73
+ input: `{
74
+ "scope": "child",
75
+ "title": "Verifier",
76
+ "task": "Prepare a child workspace"
77
+ }`,
78
+ output: [
79
+ "Session ID: child-session-2",
80
+ "",
81
+ "Target: child",
82
+ "",
83
+ "Title: Verifier",
84
+ "",
85
+ "Session Type: native",
86
+ "",
87
+ "Lifecycle: persistent",
88
+ "",
89
+ "Parent Session ID: parent-session-2",
90
+ "",
91
+ "Created At: 2026-04-09T09:00:00.000Z",
92
+ ].join("\n"),
93
+ statusTone: "success",
94
+ action: {
95
+ kind: "open-session",
96
+ sessionId: "child-session-2",
97
+ sessionKind: "child",
98
+ agentId: "verifier-agent",
99
+ label: "Verifier",
100
+ parentSessionId: "parent-session-2",
101
+ },
102
+ },
103
+ });
104
+ });
@@ -1,13 +1,18 @@
1
- import { ArrowLeft, Loader2, X } from 'lucide-react';
2
- import { ChatMessageListContainer } from '@/components/chat/containers/chat-message-list.container';
3
- import { useNcpChildSessionTabsView } from '@/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view';
4
- import { useNcpSessionConversation } from '@/components/chat/ncp/session-conversation/use-ncp-session-conversation';
5
- import type { ChatChildSessionTab } from '@/components/chat/stores/chat-thread.store';
6
- import { AgentIdentityAvatar } from '@/components/common/agent-identity';
7
- import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
8
- import { t } from '@/lib/i18n';
9
- import { cn } from '@/lib/utils';
10
- import type { ChatToolActionViewModel } from '@nextclaw/agent-chat-ui';
1
+ import { useRef } from "react";
2
+ import { ArrowLeft, Loader2, X } from "lucide-react";
3
+ import { useStickyBottomScroll } from "@nextclaw/agent-chat-ui";
4
+ import { ChatMessageListContainer } from "@/components/chat/containers/chat-message-list.container";
5
+ import {
6
+ useNcpChildSessionTabsView,
7
+ type ResolvedChildSessionTab,
8
+ } from "@/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view";
9
+ import { useNcpSessionConversation } from "@/components/chat/ncp/session-conversation/use-ncp-session-conversation";
10
+ import type { ChatChildSessionTab } from "@/components/chat/stores/chat-thread.store";
11
+ import { AgentIdentityAvatar } from "@/components/common/agent-identity";
12
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
13
+ import { t } from "@/lib/i18n";
14
+ import { cn } from "@/lib/utils";
15
+ import type { ChatToolActionViewModel } from "@nextclaw/agent-chat-ui";
11
16
 
12
17
  type ChatChildSessionPanelProps = {
13
18
  tabs: readonly ChatChildSessionTab[];
@@ -27,39 +32,84 @@ function ChildSessionPanelConversation({
27
32
  }) {
28
33
  const agent = useNcpSessionConversation(sessionKey);
29
34
  const messages = agent.visibleMessages;
35
+ const scrollRef = useRef<HTMLDivElement>(null);
36
+ const { onScroll } = useStickyBottomScroll({
37
+ scrollRef,
38
+ resetKey: sessionKey,
39
+ isLoading: agent.isHydrating,
40
+ hasContent: messages.length > 0,
41
+ contentVersion: messages[messages.length - 1] ?? null,
42
+ stickyThresholdPx: 20,
43
+ });
30
44
 
31
- if (agent.isHydrating) {
32
- return (
33
- <div className="flex h-full items-center justify-center text-sm text-gray-500">
34
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
35
- {t('chatChildSessionLoading')}
36
- </div>
37
- );
38
- }
45
+ return (
46
+ <div
47
+ ref={scrollRef}
48
+ onScroll={onScroll}
49
+ className="h-full overflow-y-auto custom-scrollbar"
50
+ >
51
+ {agent.isHydrating ? (
52
+ <div className="flex h-full items-center justify-center text-sm text-gray-500">
53
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
54
+ {t("chatChildSessionLoading")}
55
+ </div>
56
+ ) : agent.hydrateError ? (
57
+ <div className="px-4 py-5 text-sm text-rose-600">
58
+ {agent.hydrateError.message}
59
+ </div>
60
+ ) : messages.length === 0 && !agent.isRunning ? (
61
+ <div className="px-4 py-5 text-sm text-gray-500">
62
+ {t("chatChildSessionEmpty")}
63
+ </div>
64
+ ) : (
65
+ <div className="px-4 py-5">
66
+ <ChatMessageListContainer
67
+ messages={messages}
68
+ isSending={agent.isRunning}
69
+ onToolAction={onToolAction}
70
+ />
71
+ </div>
72
+ )}
73
+ </div>
74
+ );
75
+ }
39
76
 
40
- if (agent.hydrateError) {
41
- return (
42
- <div className="px-4 py-5 text-sm text-rose-600">
43
- {agent.hydrateError.message}
44
- </div>
45
- );
46
- }
77
+ function ChildSessionPanelMetaChip({ value }: { value: string }) {
78
+ return (
79
+ <span className="inline-flex max-w-full items-center rounded-full border border-gray-200/80 bg-gray-50/90 px-2.5 py-1 text-[11px] font-medium text-gray-600">
80
+ <span className="truncate">{value}</span>
81
+ </span>
82
+ );
83
+ }
47
84
 
48
- if (messages.length === 0 && !agent.isRunning) {
49
- return (
50
- <div className="px-4 py-5 text-sm text-gray-500">
51
- {t('chatChildSessionEmpty')}
52
- </div>
53
- );
85
+ function ChildSessionPanelMetaStrip({ tab }: { tab: ResolvedChildSessionTab }) {
86
+ const metaItems = [
87
+ tab.sessionTypeLabel,
88
+ tab.preferredModel,
89
+ tab.projectName,
90
+ ].filter((value): value is string => Boolean(value?.trim()));
91
+
92
+ if (metaItems.length === 0 && !tab.projectRoot) {
93
+ return null;
54
94
  }
55
95
 
56
96
  return (
57
- <div className="px-4 py-5">
58
- <ChatMessageListContainer
59
- messages={messages}
60
- isSending={agent.isRunning}
61
- onToolAction={onToolAction}
62
- />
97
+ <div className="mt-3 space-y-2">
98
+ {metaItems.length > 0 ? (
99
+ <div className="flex flex-wrap gap-1.5">
100
+ {metaItems.map((item) => (
101
+ <ChildSessionPanelMetaChip key={item} value={item} />
102
+ ))}
103
+ </div>
104
+ ) : null}
105
+ {tab.projectRoot ? (
106
+ <div
107
+ title={tab.projectRoot}
108
+ className="truncate rounded-xl border border-gray-200/70 bg-gray-50/80 px-2.5 py-2 font-mono text-[11px] text-gray-500"
109
+ >
110
+ {tab.projectRoot}
111
+ </div>
112
+ ) : null}
63
113
  </div>
64
114
  );
65
115
  }
@@ -77,7 +127,9 @@ export function ChatChildSessionPanel({
77
127
  resolvedTabs.find((tab) => tab.sessionKey === activeSessionKey) ??
78
128
  resolvedTabs[0] ??
79
129
  null;
80
- const hasParentSession = resolvedTabs.some((tab) => Boolean(tab.parentSessionKey));
130
+ const hasParentSession = resolvedTabs.some((tab) =>
131
+ Boolean(tab.parentSessionKey),
132
+ );
81
133
  const shouldShowTabs = resolvedTabs.length > 1;
82
134
 
83
135
  if (!activeTab) {
@@ -93,18 +145,18 @@ export function ChatChildSessionPanel({
93
145
  type="button"
94
146
  onClick={onBackToParent}
95
147
  className={cn(
96
- 'inline-flex items-center gap-1 text-xs font-medium text-gray-600 transition-colors hover:text-gray-900',
97
- !hasParentSession && 'pointer-events-none opacity-0',
148
+ "inline-flex items-center gap-1 text-xs font-medium text-gray-600 transition-colors hover:text-gray-900",
149
+ !hasParentSession && "pointer-events-none opacity-0",
98
150
  )}
99
151
  >
100
152
  <ArrowLeft className="h-3.5 w-3.5" />
101
- <span>{t('chatBackToParent')}</span>
153
+ <span>{t("chatBackToParent")}</span>
102
154
  </button>
103
155
  <button
104
156
  type="button"
105
157
  onClick={onClose}
106
158
  className="rounded-full border border-gray-200/80 p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
107
- aria-label={t('chatChildSessionClosePanel')}
159
+ aria-label={t("chatChildSessionClosePanel")}
108
160
  >
109
161
  <X className="h-4 w-4" />
110
162
  </button>
@@ -138,20 +190,26 @@ export function ChatChildSessionPanel({
138
190
  className="h-4 w-4 shrink-0"
139
191
  />
140
192
  ) : null}
141
- <span className="max-w-[132px] truncate">{tab.title}</span>
193
+ <span className="max-w-[132px] truncate">
194
+ {tab.title}
195
+ </span>
142
196
  </TabsTrigger>
143
197
  ))}
144
198
  </TabsList>
145
199
  </Tabs>
146
200
  </div>
147
201
  ) : null}
202
+ <ChildSessionPanelMetaStrip tab={activeTab} />
148
203
  </div>
149
204
 
150
- <div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar">
205
+ <div className="flex-1 min-h-0">
151
206
  {resolvedTabs.map((tab) => (
152
207
  <div
153
208
  key={tab.sessionKey}
154
- className={cn(tab.sessionKey === activeSessionKey ? 'block' : 'hidden')}
209
+ className={cn(
210
+ "h-full",
211
+ tab.sessionKey === activeSessionKey ? "block" : "hidden",
212
+ )}
155
213
  >
156
214
  <ChildSessionPanelConversation
157
215
  sessionKey={tab.sessionKey}
@@ -6,7 +6,9 @@ import {
6
6
  resolveSelectedModelValue,
7
7
  resolveSelectedThinkingLevelValue
8
8
  } from '@/components/chat/chat-session-preference-governance';
9
- import { shouldRefreshDraftSessionId } from '@/components/chat/ncp/NcpChatPage';
9
+ import {
10
+ shouldClearPendingProjectRootOverride
11
+ } from '@/components/chat/ncp/NcpChatPage';
10
12
 
11
13
  const modelOptions = [
12
14
  {
@@ -155,33 +157,28 @@ describe('resolveSelectedModelValue', () => {
155
157
  });
156
158
  });
157
159
 
158
- describe('shouldRefreshDraftSessionId', () => {
159
- it('does not replace the initial draft session id on first mount', () => {
160
+ describe('shouldClearPendingProjectRootOverride', () => {
161
+ it('does not clear an unrelated session project override', () => {
160
162
  expect(
161
- shouldRefreshDraftSessionId({
162
- previousSelectedSessionKey: undefined,
163
- nextSelectedSessionKey: null
163
+ shouldClearPendingProjectRootOverride({
164
+ pendingProjectRoot: '/tmp/project-alpha',
165
+ pendingProjectRootSessionKey: 'draft-project-alpha',
166
+ sessionKey: 'session-existing',
167
+ selectedSessionProjectRoot: '/tmp/project-alpha'
164
168
  })
165
169
  ).toBe(false);
166
170
  });
167
171
 
168
- it('replaces the draft session id after leaving an existing session', () => {
172
+ it('clears the override only after the bound session reflects the same project root', () => {
169
173
  expect(
170
- shouldRefreshDraftSessionId({
171
- previousSelectedSessionKey: 'session-1',
172
- nextSelectedSessionKey: null
174
+ shouldClearPendingProjectRootOverride({
175
+ pendingProjectRoot: '/tmp/project-alpha',
176
+ pendingProjectRootSessionKey: 'draft-after-refresh',
177
+ sessionKey: 'draft-after-refresh',
178
+ selectedSessionProjectRoot: '/tmp/project-alpha'
173
179
  })
174
180
  ).toBe(true);
175
181
  });
176
-
177
- it('does not replace the draft session id while staying on the same session', () => {
178
- expect(
179
- shouldRefreshDraftSessionId({
180
- previousSelectedSessionKey: 'session-1',
181
- nextSelectedSessionKey: 'session-1'
182
- })
183
- ).toBe(false);
184
- });
185
182
  });
186
183
 
187
184
  describe('resolveRecentSessionPreferredModel', () => {