@nextclaw/ui 0.11.17 → 0.11.19

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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/assets/ChannelsList-DAx7wv0_.js +8 -0
  3. package/dist/assets/{ChatPage-C47h6sfA.js → ChatPage-l2PYwCeB.js} +9 -7
  4. package/dist/assets/DocBrowser-CIHLqoIm.js +1 -0
  5. package/dist/assets/{DocBrowser-C_C7daBv.js → DocBrowser-DKkE3Y4I.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-CJ-YKtWh.js → DocBrowserContext-BcZRBsCg.js} +1 -1
  7. package/dist/assets/{LogoBadge-DRDmIa7o.js → LogoBadge-BIPDLEwK.js} +1 -1
  8. package/dist/assets/{MarketplacePage-DaSRsFUA.js → MarketplacePage-Dlp5BgCh.js} +1 -1
  9. package/dist/assets/MarketplacePage-TVeyVOuO.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-B7HZn8zG.js → McpMarketplacePage-CwKtAil8.js} +1 -1
  11. package/dist/assets/{ModelConfig-MSi8VF9p.js → ModelConfig-Dg6F3Ldb.js} +1 -1
  12. package/dist/assets/{ProvidersList-_NBpSQWn.js → ProvidersList-f7bQdRxA.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-DSmdSsCJ.js → RemoteAccessPage-w_dY7P4T.js} +1 -1
  14. package/dist/assets/{RuntimeConfig-msA8NZOj.js → RuntimeConfig-M4OKjmgU.js} +1 -1
  15. package/dist/assets/{SearchConfig-BBtxHIN_.js → SearchConfig-v46R5a2U.js} +1 -1
  16. package/dist/assets/{SecretsConfig-BMAqj52o.js → SecretsConfig-CXvUpbB_.js} +1 -1
  17. package/dist/assets/{SessionsConfig-CEJqgz8F.js → SessionsConfig-7vUHMtOh.js} +1 -1
  18. package/dist/assets/{book-open-1agbn9dT.js → book-open-DzSduAaw.js} +1 -1
  19. package/dist/assets/{chat-session-display-DBBUJOYN.js → chat-session-display-CGfXhJoT.js} +1 -1
  20. package/dist/assets/{chunk-JZWAC4HX-BUooP92l.js → chunk-JZWAC4HX-C1vpvW4r.js} +1 -1
  21. package/dist/assets/{config-jOAXZWun.js → config-Df97LeLR.js} +1 -1
  22. package/dist/assets/{createLucideIcon-B8FV3fzy.js → createLucideIcon-CcR5wVoU.js} +1 -1
  23. package/dist/assets/{dist-D3OJg9V0.js → dist-BMlnBah3.js} +1 -1
  24. package/dist/assets/{dist-Cy668qFZ.js → dist-Dii9v3X9.js} +1 -1
  25. package/dist/assets/{external-link-DI4ZmR3r.js → external-link-CnSDrvJE.js} +1 -1
  26. package/dist/assets/{hash-DoXBhX9w.js → hash-CAnX6PNt.js} +1 -1
  27. package/dist/assets/i18n-CXBpwAwA.js +1 -0
  28. package/dist/assets/{index-bAeWRAyo.js → index-B0DzQqwv.js} +3 -3
  29. package/dist/assets/index-BahpXJg8.css +1 -0
  30. package/dist/assets/{label-Cz0q8fx4.js → label-CtIFj7_6.js} +1 -1
  31. package/dist/assets/loader-circle-qgU4zQDw.js +1 -0
  32. package/dist/assets/{logos-DjrINZ7P.js → logos-3KFNiOej.js} +1 -1
  33. package/dist/assets/{page-layout-Hr-Dvq4o.js → page-layout-BMwpn87D.js} +1 -1
  34. package/dist/assets/plus-C9cYVbL-.js +1 -0
  35. package/dist/assets/{popover-_nEUAtWY.js → popover-BIzq25oH.js} +1 -1
  36. package/dist/assets/{react-Bsr_GLhi.js → react-ji6GGP_j.js} +1 -1
  37. package/dist/assets/{save-Caodcm4q.js → save-CMgYkJ-y.js} +1 -1
  38. package/dist/assets/search-sl1OeJFl.js +1 -0
  39. package/dist/assets/{security-config-Zf1RBeS1.js → security-config-Xi5DYW7j.js} +1 -1
  40. package/dist/assets/{select-D60QRHg9.js → select-Cz82gl01.js} +1 -1
  41. package/dist/assets/skeleton-rgIt7a5q.js +1 -0
  42. package/dist/assets/{status-dot-D43lBF1a.js → status-dot-C7q1HvLH.js} +1 -1
  43. package/dist/assets/{switch-CcBS0F3U.js → switch-DYswvkYj.js} +1 -1
  44. package/dist/assets/{tabs-custom-UTbefkqB.js → tabs-custom-DKYQxrx1.js} +1 -1
  45. package/dist/assets/{trash-2-DvPrU1xO.js → trash-2-DfXI7-ap.js} +1 -1
  46. package/dist/assets/{useConfirmDialog-B89bxcd6.js → useConfirmDialog-CXDAxtRL.js} +1 -1
  47. package/dist/assets/{useMutation-BpXHE2OV.js → useMutation-s2sn2yzh.js} +1 -1
  48. package/dist/assets/x-MIimOGs6.js +1 -0
  49. package/dist/index.html +18 -18
  50. package/package.json +6 -6
  51. package/src/components/chat/ChatConversationPanel.tsx +1 -0
  52. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +103 -1
  53. package/src/components/chat/adapters/chat-input-bar.adapter.ts +80 -2
  54. package/src/components/chat/adapters/chat-message-inline-content.adapter.ts +95 -0
  55. package/src/components/chat/adapters/chat-message-part.adapter.ts +384 -0
  56. package/src/components/chat/adapters/chat-message.adapter.test.ts +49 -2
  57. package/src/components/chat/adapters/chat-message.adapter.ts +18 -366
  58. package/src/components/chat/adapters/chat-message.subagent-tool-card.ts +46 -17
  59. package/src/components/chat/chat-composer-state.test.ts +2 -6
  60. package/src/components/chat/chat-composer-state.ts +27 -6
  61. package/src/components/chat/chat-inline-token.utils.test.ts +87 -0
  62. package/src/components/chat/chat-inline-token.utils.ts +146 -0
  63. package/src/components/chat/chat-input/chat-input-bar.controller.test.tsx +24 -0
  64. package/src/components/chat/chat-input/chat-input-bar.controller.ts +81 -44
  65. package/src/components/chat/chat-recent-skills.manager.ts +8 -0
  66. package/src/components/chat/containers/chat-input-bar.container.tsx +31 -4
  67. package/src/components/chat/containers/chat-message-list.container.test.tsx +45 -0
  68. package/src/components/chat/containers/chat-message-list.container.tsx +11 -5
  69. package/src/components/chat/ncp/NcpChatPage.tsx +10 -1
  70. package/src/components/chat/ncp/ncp-chat-input.manager.ts +18 -4
  71. package/src/components/chat/presenter/chat-presenter-context.tsx +1 -0
  72. package/src/components/config/ChannelForm.tsx +71 -39
  73. package/src/components/config/channel-form-fields.test.ts +28 -0
  74. package/src/components/config/channel-form-fields.ts +95 -30
  75. package/src/components/config/weixin-channel-auth-section.test.tsx +26 -0
  76. package/src/components/config/weixin-channel-auth-section.tsx +6 -2
  77. package/src/lib/i18n.channel-auth.ts +5 -0
  78. package/dist/assets/ChannelsList-askIl_uW.js +0 -8
  79. package/dist/assets/DocBrowser-Cf7uSIoM.js +0 -1
  80. package/dist/assets/MarketplacePage-q12sRrvZ.js +0 -1
  81. package/dist/assets/i18n-Cn8SErDV.js +0 -1
  82. package/dist/assets/index-B2VeWxfm.css +0 -1
  83. package/dist/assets/loader-circle-d_mzMi2S.js +0 -1
  84. package/dist/assets/plus-BnGg0mB-.js +0 -1
  85. package/dist/assets/search-CQCQaN4Z.js +0 -1
  86. package/dist/assets/skeleton-BvV_2nf3.js +0 -1
  87. package/dist/assets/x-C8AWDn7c.js +0 -1
@@ -1,49 +1,18 @@
1
- import {
2
- stringifyUnknown,
3
- summarizeToolArgs,
4
- type ToolCard,
5
- } from "@/lib/chat-message";
6
- import { buildSubagentToolCard } from "@/components/chat/adapters/chat-message.subagent-tool-card";
1
+ import { adaptChatMessagePart } from "@/components/chat/adapters/chat-message-part.adapter";
2
+ import type { ChatInlineTokenSource } from "@/components/chat/chat-inline-token.utils";
7
3
  import type {
8
4
  ChatMessageRole,
9
5
  ChatMessageViewModel,
10
- ChatToolPartViewModel,
11
6
  } from "@nextclaw/agent-chat-ui";
7
+ import type {
8
+ ChatMessageAdapterTexts,
9
+ ChatMessagePartSource,
10
+ } from "@/components/chat/adapters/chat-message-part.adapter";
12
11
 
13
- export type ChatMessagePartSource =
14
- | {
15
- type: "text";
16
- text: string;
17
- }
18
- | {
19
- type: "file";
20
- mimeType: string;
21
- data: string;
22
- url?: string;
23
- name?: string;
24
- sizeBytes?: number;
25
- }
26
- | {
27
- type: "reasoning";
28
- reasoning: string;
29
- }
30
- | {
31
- type: "tool-invocation";
32
- toolInvocation: {
33
- status?: string;
34
- toolName: string;
35
- args?: unknown;
36
- parsedArgs?: unknown;
37
- result?: unknown;
38
- error?: string;
39
- cancelled?: boolean;
40
- toolCallId?: string;
41
- };
42
- }
43
- | {
44
- type: string;
45
- [key: string]: unknown;
46
- };
12
+ export type {
13
+ ChatMessageAdapterTexts,
14
+ ChatMessagePartSource,
15
+ } from "@/components/chat/adapters/chat-message-part.adapter";
47
16
 
48
17
  export type ChatMessageSource = {
49
18
  id: string;
@@ -51,146 +20,11 @@ export type ChatMessageSource = {
51
20
  meta?: {
52
21
  timestamp?: string;
53
22
  status?: string;
23
+ inlineTokens?: ChatInlineTokenSource[];
54
24
  };
55
25
  parts: ChatMessagePartSource[];
56
26
  };
57
27
 
58
- export type ChatMessageAdapterTexts = {
59
- roleLabels: {
60
- user: string;
61
- assistant: string;
62
- tool: string;
63
- system: string;
64
- fallback: string;
65
- };
66
- reasoningLabel: string;
67
- toolCallLabel: string;
68
- toolResultLabel: string;
69
- toolNoOutputLabel: string;
70
- toolOutputLabel: string;
71
- toolStatusPreparingLabel: string;
72
- toolStatusRunningLabel: string;
73
- toolStatusCompletedLabel: string;
74
- toolStatusFailedLabel: string;
75
- toolStatusCancelledLabel: string;
76
- imageAttachmentLabel: string;
77
- fileAttachmentLabel: string;
78
- unknownPartLabel: string;
79
- };
80
-
81
- const INVISIBLE_ONLY_TEXT_PATTERN = /\u200B|\u200C|\u200D|\u2060|\uFEFF/g;
82
-
83
- function isRecord(value: unknown): value is Record<string, unknown> {
84
- return typeof value === "object" && value !== null;
85
- }
86
-
87
- function readOptionalString(value: unknown): string | null {
88
- if (typeof value !== "string") {
89
- return null;
90
- }
91
- const trimmed = value.trim();
92
- return trimmed.length > 0 ? trimmed : null;
93
- }
94
-
95
- function readOptionalNumber(value: unknown): number | null {
96
- if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
97
- return value;
98
- }
99
- if (typeof value !== "string") {
100
- return null;
101
- }
102
- const trimmed = value.trim();
103
- if (!trimmed) {
104
- return null;
105
- }
106
- const parsed = Number(trimmed);
107
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
108
- }
109
-
110
- function extractAssetFileView(
111
- value: unknown,
112
- texts: ChatMessageAdapterTexts,
113
- ): {
114
- type: "file";
115
- file: {
116
- label: string;
117
- mimeType: string;
118
- dataUrl: string;
119
- sizeBytes?: number;
120
- isImage: boolean;
121
- };
122
- } | null {
123
- if (!isRecord(value)) {
124
- return null;
125
- }
126
- const assetCandidate = isRecord(value.asset)
127
- ? value.asset
128
- : Array.isArray(value.assets) &&
129
- value.assets.length > 0 &&
130
- isRecord(value.assets[0])
131
- ? value.assets[0]
132
- : null;
133
- if (!assetCandidate) {
134
- return null;
135
- }
136
- const url = readOptionalString(assetCandidate.url);
137
- const mimeType =
138
- readOptionalString(assetCandidate.mimeType) ?? "application/octet-stream";
139
- const sizeBytes = readOptionalNumber(assetCandidate.sizeBytes);
140
- if (!url) {
141
- return null;
142
- }
143
- const label =
144
- readOptionalString(assetCandidate.name) ??
145
- (mimeType.startsWith("image/")
146
- ? texts.imageAttachmentLabel
147
- : texts.fileAttachmentLabel);
148
- return {
149
- type: "file",
150
- file: {
151
- label,
152
- mimeType,
153
- dataUrl: url,
154
- ...(sizeBytes != null ? { sizeBytes } : {}),
155
- isImage: mimeType.startsWith("image/"),
156
- },
157
- };
158
- }
159
-
160
- function isTextPart(
161
- part: ChatMessagePartSource,
162
- ): part is Extract<ChatMessagePartSource, { type: "text" }> {
163
- return part.type === "text" && typeof part.text === "string";
164
- }
165
-
166
- function isReasoningPart(
167
- part: ChatMessagePartSource,
168
- ): part is Extract<ChatMessagePartSource, { type: "reasoning" }> {
169
- return part.type === "reasoning" && typeof part.reasoning === "string";
170
- }
171
-
172
- function isFilePart(
173
- part: ChatMessagePartSource,
174
- ): part is Extract<ChatMessagePartSource, { type: "file" }> {
175
- return (
176
- part.type === "file" &&
177
- typeof part.mimeType === "string" &&
178
- typeof part.data === "string"
179
- );
180
- }
181
-
182
- function isToolInvocationPart(
183
- part: ChatMessagePartSource,
184
- ): part is Extract<ChatMessagePartSource, { type: "tool-invocation" }> {
185
- if (part.type !== "tool-invocation") {
186
- return false;
187
- }
188
- if (!isRecord(part.toolInvocation)) {
189
- return false;
190
- }
191
- return typeof part.toolInvocation.toolName === "string";
192
- }
193
-
194
28
  function resolveMessageTimestamp(message: ChatMessageSource): string {
195
29
  const candidate = message.meta?.timestamp;
196
30
  if (candidate && Number.isFinite(Date.parse(candidate))) {
@@ -230,94 +64,6 @@ function resolveUiRole(role: string): ChatMessageRole {
230
64
  return "message";
231
65
  }
232
66
 
233
- function buildToolCard(
234
- toolCard: ToolCardViewSource,
235
- texts: ChatMessageAdapterTexts,
236
- ): ChatToolPartViewModel {
237
- return {
238
- kind: toolCard.kind,
239
- toolName: toolCard.name,
240
- summary: toolCard.detail,
241
- output: toolCard.text,
242
- hasResult: Boolean(toolCard.hasResult),
243
- statusTone: toolCard.statusTone,
244
- statusLabel: toolCard.statusLabel,
245
- titleLabel:
246
- toolCard.kind === "call" ? texts.toolCallLabel : texts.toolResultLabel,
247
- outputLabel: texts.toolOutputLabel,
248
- emptyLabel: texts.toolNoOutputLabel,
249
- };
250
- }
251
-
252
- type ToolCardViewSource = ToolCard & {
253
- statusTone: ChatToolPartViewModel["statusTone"];
254
- statusLabel: string;
255
- };
256
-
257
- function resolveToolCardStatus(params: {
258
- status?: string;
259
- error?: string;
260
- cancelled?: boolean;
261
- result?: unknown;
262
- texts: ChatMessageAdapterTexts;
263
- }): Pick<
264
- ChatToolPartViewModel,
265
- "kind" | "hasResult" | "statusTone" | "statusLabel"
266
- > {
267
- const rawStatus =
268
- typeof params.status === "string" ? params.status.trim().toLowerCase() : "";
269
- const hasError =
270
- typeof params.error === "string" && params.error.trim().length > 0;
271
- const isCancelled = params.cancelled === true || rawStatus === "cancelled";
272
- if (isCancelled) {
273
- return {
274
- kind: "result",
275
- hasResult: true,
276
- statusTone: "cancelled",
277
- statusLabel: params.texts.toolStatusCancelledLabel,
278
- };
279
- }
280
- if (hasError || rawStatus === "error") {
281
- return {
282
- kind: "result",
283
- hasResult: true,
284
- statusTone: "error",
285
- statusLabel: params.texts.toolStatusFailedLabel,
286
- };
287
- }
288
- if (rawStatus === "result" || params.result != null) {
289
- return {
290
- kind: "result",
291
- hasResult: true,
292
- statusTone: "success",
293
- statusLabel: params.texts.toolStatusCompletedLabel,
294
- };
295
- }
296
- if (rawStatus === "partial-call") {
297
- return {
298
- kind: "call",
299
- hasResult: false,
300
- statusTone: "running",
301
- statusLabel: params.texts.toolStatusPreparingLabel,
302
- };
303
- }
304
- return {
305
- kind: "call",
306
- hasResult: false,
307
- statusTone: "running",
308
- statusLabel: params.texts.toolStatusRunningLabel,
309
- };
310
- }
311
-
312
- function toRenderableText(value: string): string | null {
313
- const trimmed = value.trim();
314
- if (!trimmed) {
315
- return null;
316
- }
317
- const visible = trimmed.replace(INVISIBLE_ONLY_TEXT_PATTERN, "").trim();
318
- return visible ? trimmed : null;
319
- }
320
-
321
67
  type ChatMessageAdapterParams = {
322
68
  texts: ChatMessageAdapterTexts;
323
69
  formatTimestamp: (value: string) => string;
@@ -334,107 +80,13 @@ export function adaptChatMessage(
334
80
  timestampLabel: params.formatTimestamp(resolveMessageTimestamp(message)),
335
81
  status: message.meta?.status,
336
82
  parts: message.parts
337
- .map((part) => {
338
- if (isTextPart(part)) {
339
- const text = toRenderableText(part.text);
340
- if (!text) {
341
- return null;
342
- }
343
- return {
344
- type: "markdown" as const,
345
- text,
346
- };
347
- }
348
- if (isReasoningPart(part)) {
349
- const text = toRenderableText(part.reasoning);
350
- if (!text) {
351
- return null;
352
- }
353
- return {
354
- type: "reasoning" as const,
355
- text,
356
- label: params.texts.reasoningLabel,
357
- };
358
- }
359
- if (isFilePart(part)) {
360
- const isImage = part.mimeType.startsWith("image/");
361
- const sizeBytes = readOptionalNumber(part.sizeBytes);
362
- return {
363
- type: "file" as const,
364
- file: {
365
- label:
366
- typeof part.name === "string" && part.name.trim()
367
- ? part.name.trim()
368
- : isImage
369
- ? params.texts.imageAttachmentLabel
370
- : params.texts.fileAttachmentLabel,
371
- mimeType: part.mimeType,
372
- dataUrl:
373
- typeof part.url === "string" && part.url.trim().length > 0
374
- ? part.url.trim()
375
- : `data:${part.mimeType};base64,${part.data}`,
376
- ...(sizeBytes != null ? { sizeBytes } : {}),
377
- isImage,
378
- },
379
- };
380
- }
381
- if (isToolInvocationPart(part)) {
382
- const invocation = part.toolInvocation;
383
- const assetFileView = extractAssetFileView(
384
- invocation.result,
385
- params.texts,
386
- );
387
- if (assetFileView) {
388
- return assetFileView;
389
- }
390
- const subagentToolCard = buildSubagentToolCard({
391
- invocation,
392
- texts: params.texts,
393
- });
394
- if (subagentToolCard) {
395
- return {
396
- type: "tool-card" as const,
397
- card: buildToolCard(subagentToolCard, params.texts),
398
- };
399
- }
400
- const statusView = resolveToolCardStatus({
401
- status: invocation.status,
402
- error: invocation.error,
403
- cancelled: invocation.cancelled,
404
- result: invocation.result,
405
- texts: params.texts,
406
- });
407
- const detail = summarizeToolArgs(
408
- invocation.parsedArgs ?? invocation.args,
409
- );
410
- const rawResult =
411
- typeof invocation.error === "string" && invocation.error.trim()
412
- ? invocation.error.trim()
413
- : invocation.result != null
414
- ? stringifyUnknown(invocation.result).trim()
415
- : "";
416
- const card: ToolCardViewSource = {
417
- kind: statusView.kind,
418
- name: invocation.toolName,
419
- detail,
420
- text: rawResult || undefined,
421
- callId: invocation.toolCallId || undefined,
422
- hasResult: statusView.hasResult,
423
- statusTone: statusView.statusTone,
424
- statusLabel: statusView.statusLabel,
425
- };
426
- return {
427
- type: "tool-card" as const,
428
- card: buildToolCard(card, params.texts),
429
- };
430
- }
431
- return {
432
- type: "unknown" as const,
433
- label: params.texts.unknownPartLabel,
434
- rawType: typeof part.type === "string" ? part.type : "unknown",
435
- text: stringifyUnknown(part),
436
- };
437
- })
83
+ .map((part) =>
84
+ adaptChatMessagePart({
85
+ part,
86
+ inlineTokens: message.meta?.inlineTokens ?? [],
87
+ texts: params.texts,
88
+ }),
89
+ )
438
90
  .filter((part) => part !== null),
439
91
  };
440
92
  }
@@ -57,6 +57,47 @@ function readSubagentRunResult(value: unknown): SubagentRunResult | null {
57
57
  return null;
58
58
  }
59
59
 
60
+ function buildSubagentDetail(subagentRun: SubagentRunResult, fallbackArgs: unknown): string | undefined {
61
+ const detailParts = [
62
+ readOptionalString(subagentRun.label)
63
+ ? `label: ${subagentRun.label?.trim()}`
64
+ : null,
65
+ readOptionalString(subagentRun.runId)
66
+ ? `run: ${subagentRun.runId?.trim()}`
67
+ : null,
68
+ readOptionalString(subagentRun.task)
69
+ ? `task: ${subagentRun.task?.trim()}`
70
+ : null,
71
+ ].filter((value): value is string => Boolean(value));
72
+
73
+ return detailParts.join(" · ") || summarizeToolArgs(fallbackArgs);
74
+ }
75
+
76
+ function buildSubagentOutput(subagentRun: SubagentRunResult): string | undefined {
77
+ const runId = readOptionalString(subagentRun.runId);
78
+ const label = readOptionalString(subagentRun.label);
79
+ const task = readOptionalString(subagentRun.task);
80
+ const status = readOptionalString(subagentRun.status)?.toLowerCase();
81
+ const resultText =
82
+ typeof subagentRun.result !== "undefined"
83
+ ? stringifyUnknown(subagentRun.result).trim()
84
+ : "";
85
+ const messageText = readOptionalString(subagentRun.message);
86
+
87
+ const sections = [
88
+ runId ? `Run ID: ${runId}` : null,
89
+ label ? `Label: ${label}` : null,
90
+ task ? `Task:\n${task}` : null,
91
+ resultText
92
+ ? `${status === "failed" ? "Error" : "Result"}:\n${resultText}`
93
+ : messageText
94
+ ? `Status:\n${messageText}`
95
+ : null,
96
+ ].filter((value): value is string => Boolean(value));
97
+
98
+ return sections.length > 0 ? sections.join("\n\n") : undefined;
99
+ }
100
+
60
101
  export function buildSubagentToolCard(params: {
61
102
  invocation: SpawnToolInvocation;
62
103
  texts: SubagentToolCardTexts;
@@ -70,27 +111,15 @@ export function buildSubagentToolCard(params: {
70
111
  return null;
71
112
  }
72
113
 
73
- const detailParts = [
74
- readOptionalString(subagentRun.label)
75
- ? `label: ${subagentRun.label?.trim()}`
76
- : null,
77
- readOptionalString(subagentRun.task)
78
- ? `task: ${subagentRun.task?.trim()}`
79
- : null,
80
- ].filter((value): value is string => Boolean(value));
81
114
  const normalizedStatus = readOptionalString(subagentRun.status)?.toLowerCase();
82
- const output =
83
- (typeof subagentRun.result !== "undefined"
84
- ? stringifyUnknown(subagentRun.result).trim()
85
- : "") ||
86
- readOptionalString(subagentRun.message) ||
87
- undefined;
115
+ const detail = buildSubagentDetail(subagentRun, params.invocation.args);
116
+ const output = buildSubagentOutput(subagentRun);
88
117
 
89
118
  if (normalizedStatus === "failed") {
90
119
  return {
91
120
  kind: "result",
92
121
  name: params.invocation.toolName,
93
- detail: detailParts.join(" · ") || summarizeToolArgs(params.invocation.args),
122
+ detail,
94
123
  text: output,
95
124
  callId: params.invocation.toolCallId || undefined,
96
125
  hasResult: Boolean(output),
@@ -103,7 +132,7 @@ export function buildSubagentToolCard(params: {
103
132
  return {
104
133
  kind: "result",
105
134
  name: params.invocation.toolName,
106
- detail: detailParts.join(" · ") || summarizeToolArgs(params.invocation.args),
135
+ detail,
107
136
  text: output,
108
137
  callId: params.invocation.toolCallId || undefined,
109
138
  hasResult: Boolean(output),
@@ -115,7 +144,7 @@ export function buildSubagentToolCard(params: {
115
144
  return {
116
145
  kind: "result",
117
146
  name: params.invocation.toolName,
118
- detail: detailParts.join(" · ") || summarizeToolArgs(params.invocation.args),
147
+ detail,
119
148
  text: output,
120
149
  callId: params.invocation.toolCallId || undefined,
121
150
  hasResult: Boolean(output),
@@ -2,7 +2,7 @@ import { createChatComposerTextNode, createChatComposerTokenNode } from '@nextcl
2
2
  import { deriveNcpMessagePartsFromComposer } from '@/components/chat/chat-composer-state';
3
3
 
4
4
  describe('deriveNcpMessagePartsFromComposer', () => {
5
- it('preserves interleaved text and image token order while skipping skill tokens', () => {
5
+ it('preserves interleaved text and image token order while serializing skill tokens inline', () => {
6
6
  const parts = deriveNcpMessagePartsFromComposer(
7
7
  [
8
8
  createChatComposerTextNode('before '),
@@ -56,11 +56,7 @@ describe('deriveNcpMessagePartsFromComposer', () => {
56
56
  },
57
57
  {
58
58
  type: 'text',
59
- text: ' between '
60
- },
61
- {
62
- type: 'text',
63
- text: 'after'
59
+ text: ' between $web-searchafter'
64
60
  },
65
61
  {
66
62
  type: 'file',
@@ -11,6 +11,27 @@ import {
11
11
  serializeChatComposerPlainText
12
12
  } from '@nextclaw/agent-chat-ui';
13
13
 
14
+ const CHAT_SKILL_TOKEN_PREFIX = '$';
15
+
16
+ function serializeSkillTokenText(skillSpec: string): string {
17
+ return `${CHAT_SKILL_TOKEN_PREFIX}${skillSpec}`;
18
+ }
19
+
20
+ function appendTextPart(parts: NcpMessagePart[], text: string): void {
21
+ if (text.length === 0) {
22
+ return;
23
+ }
24
+ const previous = parts[parts.length - 1];
25
+ if (previous?.type === 'text') {
26
+ previous.text += text;
27
+ return;
28
+ }
29
+ parts.push({
30
+ type: 'text',
31
+ text
32
+ });
33
+ }
34
+
14
35
  export function createInitialChatComposerNodes(): ChatComposerNode[] {
15
36
  return createEmptyChatComposerNodes();
16
37
  }
@@ -86,12 +107,12 @@ export function deriveNcpMessagePartsFromComposer(
86
107
 
87
108
  for (const node of nodes) {
88
109
  if (node.type === 'text') {
89
- if (node.text.length > 0) {
90
- parts.push({
91
- type: 'text',
92
- text: node.text
93
- });
94
- }
110
+ appendTextPart(parts, node.text);
111
+ continue;
112
+ }
113
+
114
+ if (node.tokenKind === 'skill') {
115
+ appendTextPart(parts, serializeSkillTokenText(node.tokenKey));
95
116
  continue;
96
117
  }
97
118
 
@@ -0,0 +1,87 @@
1
+ import { createChatComposerTextNode, createChatComposerTokenNode } from "@nextclaw/agent-chat-ui";
2
+ import {
3
+ buildInlineSkillTokensFromComposer,
4
+ CHAT_UI_INLINE_TOKENS_METADATA_KEY,
5
+ readInlineTokensFromMetadata,
6
+ splitTextByInlineTokens,
7
+ } from "@/components/chat/chat-inline-token.utils";
8
+
9
+ describe("chat-inline-token utils", () => {
10
+ it("builds ordered inline skill tokens from composer nodes", () => {
11
+ expect(
12
+ buildInlineSkillTokensFromComposer([
13
+ createChatComposerTextNode("before "),
14
+ createChatComposerTokenNode({
15
+ tokenKind: "skill",
16
+ tokenKey: "weather",
17
+ label: "Weather",
18
+ }),
19
+ createChatComposerTokenNode({
20
+ tokenKind: "skill",
21
+ tokenKey: "docs",
22
+ label: "Docs",
23
+ }),
24
+ ]),
25
+ ).toEqual([
26
+ {
27
+ kind: "skill",
28
+ key: "weather",
29
+ label: "Weather",
30
+ rawText: "$weather",
31
+ },
32
+ {
33
+ kind: "skill",
34
+ key: "docs",
35
+ label: "Docs",
36
+ rawText: "$docs",
37
+ },
38
+ ]);
39
+ });
40
+
41
+ it("reads generic inline token metadata safely", () => {
42
+ expect(
43
+ readInlineTokensFromMetadata({
44
+ [CHAT_UI_INLINE_TOKENS_METADATA_KEY]: [
45
+ {
46
+ kind: "skill",
47
+ key: "weather",
48
+ label: "Weather",
49
+ rawText: "$weather",
50
+ },
51
+ ],
52
+ }),
53
+ ).toEqual([
54
+ {
55
+ kind: "skill",
56
+ key: "weather",
57
+ label: "Weather",
58
+ rawText: "$weather",
59
+ },
60
+ ]);
61
+ });
62
+
63
+ it("splits text into inline text and token fragments", () => {
64
+ expect(
65
+ splitTextByInlineTokens("please use $weather now", [
66
+ {
67
+ kind: "skill",
68
+ key: "weather",
69
+ label: "Weather",
70
+ rawText: "$weather",
71
+ },
72
+ ]),
73
+ ).toEqual([
74
+ { type: "text", text: "please use " },
75
+ {
76
+ type: "token",
77
+ token: {
78
+ kind: "skill",
79
+ key: "weather",
80
+ label: "Weather",
81
+ rawText: "$weather",
82
+ },
83
+ },
84
+ { type: "text", text: " now" },
85
+ ]);
86
+ });
87
+ });