@nextclaw/ui 0.11.18 → 0.11.20
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.
- package/CHANGELOG.md +26 -0
- package/dist/assets/{ChannelsList-eZfHzvxb.js → ChannelsList-DAx7wv0_.js} +1 -1
- package/dist/assets/{ChatPage-DKD5hcD8.js → ChatPage-l2PYwCeB.js} +8 -8
- package/dist/assets/{MarketplacePage-D0iqC5o7.js → MarketplacePage-Dlp5BgCh.js} +1 -1
- package/dist/assets/MarketplacePage-TVeyVOuO.js +1 -0
- package/dist/assets/{McpMarketplacePage-CCmRjGwl.js → McpMarketplacePage-CwKtAil8.js} +1 -1
- package/dist/assets/{ModelConfig-BiWp8Ymp.js → ModelConfig-Dg6F3Ldb.js} +1 -1
- package/dist/assets/{ProvidersList-HaCAzF9F.js → ProvidersList-f7bQdRxA.js} +1 -1
- package/dist/assets/{RemoteAccessPage-DOF4oEHW.js → RemoteAccessPage-w_dY7P4T.js} +1 -1
- package/dist/assets/{RuntimeConfig-BnkWf6Eb.js → RuntimeConfig-M4OKjmgU.js} +1 -1
- package/dist/assets/{SearchConfig-3ofKM9W4.js → SearchConfig-v46R5a2U.js} +1 -1
- package/dist/assets/{SecretsConfig-BRbC2hfo.js → SecretsConfig-CXvUpbB_.js} +1 -1
- package/dist/assets/{SessionsConfig-BpoD_0WD.js → SessionsConfig-7vUHMtOh.js} +1 -1
- package/dist/assets/{index-CjPeKafH.js → index-B0DzQqwv.js} +2 -2
- package/dist/assets/index-BahpXJg8.css +1 -0
- package/dist/assets/{security-config-BcbOF17w.js → security-config-Xi5DYW7j.js} +1 -1
- package/dist/assets/{useConfirmDialog-Dk15Fj1n.js → useConfirmDialog-CXDAxtRL.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +6 -6
- package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +103 -1
- package/src/components/chat/adapters/chat-input-bar.adapter.ts +80 -2
- package/src/components/chat/adapters/chat-message-inline-content.adapter.ts +95 -0
- package/src/components/chat/adapters/chat-message-part.adapter.ts +384 -0
- package/src/components/chat/adapters/chat-message.adapter.test.ts +37 -0
- package/src/components/chat/adapters/chat-message.adapter.ts +18 -366
- package/src/components/chat/chat-composer-state.test.ts +2 -6
- package/src/components/chat/chat-composer-state.ts +27 -6
- package/src/components/chat/chat-inline-token.utils.test.ts +87 -0
- package/src/components/chat/chat-inline-token.utils.ts +146 -0
- package/src/components/chat/chat-input/chat-input-bar.controller.test.tsx +24 -0
- package/src/components/chat/chat-input/chat-input-bar.controller.ts +81 -44
- package/src/components/chat/chat-recent-skills.manager.ts +8 -0
- package/src/components/chat/containers/chat-input-bar.container.tsx +31 -4
- package/src/components/chat/containers/chat-message-list.container.test.tsx +45 -0
- package/src/components/chat/containers/chat-message-list.container.tsx +11 -5
- package/src/components/chat/ncp/NcpChatPage.tsx +10 -1
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +18 -4
- package/src/components/chat/presenter/chat-presenter-context.tsx +1 -0
- package/dist/assets/MarketplacePage-CMPjqEmN.js +0 -1
- package/dist/assets/index-DMy_fKKh.css +0 -1
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import {
|
|
2
|
+
stringifyUnknown,
|
|
3
|
+
summarizeToolArgs,
|
|
4
|
+
type ToolCard,
|
|
5
|
+
} from "@/lib/chat-message";
|
|
6
|
+
import {
|
|
7
|
+
type ChatInlineTokenSource,
|
|
8
|
+
} from "@/components/chat/chat-inline-token.utils";
|
|
9
|
+
import {
|
|
10
|
+
buildRenderableText,
|
|
11
|
+
buildTextPart,
|
|
12
|
+
} from "@/components/chat/adapters/chat-message-inline-content.adapter";
|
|
13
|
+
import { buildSubagentToolCard } from "@/components/chat/adapters/chat-message.subagent-tool-card";
|
|
14
|
+
import type {
|
|
15
|
+
ChatMessagePartViewModel,
|
|
16
|
+
ChatToolPartViewModel,
|
|
17
|
+
} from "@nextclaw/agent-chat-ui";
|
|
18
|
+
|
|
19
|
+
export type ChatMessageAdapterTexts = {
|
|
20
|
+
roleLabels: {
|
|
21
|
+
user: string;
|
|
22
|
+
assistant: string;
|
|
23
|
+
tool: string;
|
|
24
|
+
system: string;
|
|
25
|
+
fallback: string;
|
|
26
|
+
};
|
|
27
|
+
reasoningLabel: string;
|
|
28
|
+
toolCallLabel: string;
|
|
29
|
+
toolResultLabel: string;
|
|
30
|
+
toolNoOutputLabel: string;
|
|
31
|
+
toolOutputLabel: string;
|
|
32
|
+
toolStatusPreparingLabel: string;
|
|
33
|
+
toolStatusRunningLabel: string;
|
|
34
|
+
toolStatusCompletedLabel: string;
|
|
35
|
+
toolStatusFailedLabel: string;
|
|
36
|
+
toolStatusCancelledLabel: string;
|
|
37
|
+
imageAttachmentLabel: string;
|
|
38
|
+
fileAttachmentLabel: string;
|
|
39
|
+
unknownPartLabel: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ChatMessagePartSource =
|
|
43
|
+
| {
|
|
44
|
+
type: "text";
|
|
45
|
+
text: string;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
type: "file";
|
|
49
|
+
mimeType: string;
|
|
50
|
+
data: string;
|
|
51
|
+
url?: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
sizeBytes?: number;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
type: "reasoning";
|
|
57
|
+
reasoning: string;
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
type: "tool-invocation";
|
|
61
|
+
toolInvocation: {
|
|
62
|
+
status?: string;
|
|
63
|
+
toolName: string;
|
|
64
|
+
args?: unknown;
|
|
65
|
+
parsedArgs?: unknown;
|
|
66
|
+
result?: unknown;
|
|
67
|
+
error?: string;
|
|
68
|
+
cancelled?: boolean;
|
|
69
|
+
toolCallId?: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
type: string;
|
|
74
|
+
[key: string]: unknown;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
type ToolCardViewSource = ToolCard & {
|
|
78
|
+
statusTone: ChatToolPartViewModel["statusTone"];
|
|
79
|
+
statusLabel: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type ChatMessagePartAdapterParams = {
|
|
83
|
+
part: ChatMessagePartSource;
|
|
84
|
+
inlineTokens: readonly ChatInlineTokenSource[];
|
|
85
|
+
texts: ChatMessageAdapterTexts;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
89
|
+
return typeof value === "object" && value !== null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readOptionalString(value: unknown): string | null {
|
|
93
|
+
if (typeof value !== "string") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const trimmed = value.trim();
|
|
97
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readOptionalNumber(value: unknown): number | null {
|
|
101
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
if (typeof value !== "string") {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const trimmed = value.trim();
|
|
108
|
+
if (!trimmed) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const parsed = Number(trimmed);
|
|
112
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractAssetFileView(
|
|
116
|
+
value: unknown,
|
|
117
|
+
texts: ChatMessageAdapterTexts,
|
|
118
|
+
): Extract<ChatMessagePartViewModel, { type: "file" }> | null {
|
|
119
|
+
if (!isRecord(value)) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const assetCandidate = isRecord(value.asset)
|
|
123
|
+
? value.asset
|
|
124
|
+
: Array.isArray(value.assets) &&
|
|
125
|
+
value.assets.length > 0 &&
|
|
126
|
+
isRecord(value.assets[0])
|
|
127
|
+
? value.assets[0]
|
|
128
|
+
: null;
|
|
129
|
+
if (!assetCandidate) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const url = readOptionalString(assetCandidate.url);
|
|
133
|
+
const mimeType =
|
|
134
|
+
readOptionalString(assetCandidate.mimeType) ?? "application/octet-stream";
|
|
135
|
+
const sizeBytes = readOptionalNumber(assetCandidate.sizeBytes);
|
|
136
|
+
if (!url) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const label =
|
|
140
|
+
readOptionalString(assetCandidate.name) ??
|
|
141
|
+
(mimeType.startsWith("image/")
|
|
142
|
+
? texts.imageAttachmentLabel
|
|
143
|
+
: texts.fileAttachmentLabel);
|
|
144
|
+
return {
|
|
145
|
+
type: "file",
|
|
146
|
+
file: {
|
|
147
|
+
label,
|
|
148
|
+
mimeType,
|
|
149
|
+
dataUrl: url,
|
|
150
|
+
...(sizeBytes != null ? { sizeBytes } : {}),
|
|
151
|
+
isImage: mimeType.startsWith("image/"),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildToolCard(
|
|
157
|
+
toolCard: ToolCardViewSource,
|
|
158
|
+
texts: ChatMessageAdapterTexts,
|
|
159
|
+
): ChatToolPartViewModel {
|
|
160
|
+
return {
|
|
161
|
+
kind: toolCard.kind,
|
|
162
|
+
toolName: toolCard.name,
|
|
163
|
+
summary: toolCard.detail,
|
|
164
|
+
output: toolCard.text,
|
|
165
|
+
hasResult: Boolean(toolCard.hasResult),
|
|
166
|
+
statusTone: toolCard.statusTone,
|
|
167
|
+
statusLabel: toolCard.statusLabel,
|
|
168
|
+
titleLabel:
|
|
169
|
+
toolCard.kind === "call" ? texts.toolCallLabel : texts.toolResultLabel,
|
|
170
|
+
outputLabel: texts.toolOutputLabel,
|
|
171
|
+
emptyLabel: texts.toolNoOutputLabel,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolveToolCardStatus(params: {
|
|
176
|
+
status?: string;
|
|
177
|
+
error?: string;
|
|
178
|
+
cancelled?: boolean;
|
|
179
|
+
result?: unknown;
|
|
180
|
+
texts: ChatMessageAdapterTexts;
|
|
181
|
+
}): Pick<
|
|
182
|
+
ChatToolPartViewModel,
|
|
183
|
+
"kind" | "hasResult" | "statusTone" | "statusLabel"
|
|
184
|
+
> {
|
|
185
|
+
const rawStatus =
|
|
186
|
+
typeof params.status === "string" ? params.status.trim().toLowerCase() : "";
|
|
187
|
+
const hasError =
|
|
188
|
+
typeof params.error === "string" && params.error.trim().length > 0;
|
|
189
|
+
const isCancelled = params.cancelled === true || rawStatus === "cancelled";
|
|
190
|
+
if (isCancelled) {
|
|
191
|
+
return {
|
|
192
|
+
kind: "result",
|
|
193
|
+
hasResult: true,
|
|
194
|
+
statusTone: "cancelled",
|
|
195
|
+
statusLabel: params.texts.toolStatusCancelledLabel,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (hasError || rawStatus === "error") {
|
|
199
|
+
return {
|
|
200
|
+
kind: "result",
|
|
201
|
+
hasResult: true,
|
|
202
|
+
statusTone: "error",
|
|
203
|
+
statusLabel: params.texts.toolStatusFailedLabel,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (rawStatus === "result" || params.result != null) {
|
|
207
|
+
return {
|
|
208
|
+
kind: "result",
|
|
209
|
+
hasResult: true,
|
|
210
|
+
statusTone: "success",
|
|
211
|
+
statusLabel: params.texts.toolStatusCompletedLabel,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (rawStatus === "partial-call") {
|
|
215
|
+
return {
|
|
216
|
+
kind: "call",
|
|
217
|
+
hasResult: false,
|
|
218
|
+
statusTone: "running",
|
|
219
|
+
statusLabel: params.texts.toolStatusPreparingLabel,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
kind: "call",
|
|
224
|
+
hasResult: false,
|
|
225
|
+
statusTone: "running",
|
|
226
|
+
statusLabel: params.texts.toolStatusRunningLabel,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildReasoningPart(
|
|
231
|
+
part: Extract<ChatMessagePartSource, { type: "reasoning" }>,
|
|
232
|
+
texts: ChatMessageAdapterTexts,
|
|
233
|
+
): Extract<ChatMessagePartViewModel, { type: "reasoning" }> | null {
|
|
234
|
+
const text = buildRenderableText(part.reasoning);
|
|
235
|
+
if (!text) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
type: "reasoning",
|
|
240
|
+
text,
|
|
241
|
+
label: texts.reasoningLabel,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function buildFilePart(
|
|
246
|
+
part: Extract<ChatMessagePartSource, { type: "file" }>,
|
|
247
|
+
texts: ChatMessageAdapterTexts,
|
|
248
|
+
): Extract<ChatMessagePartViewModel, { type: "file" }> {
|
|
249
|
+
const isImage = part.mimeType.startsWith("image/");
|
|
250
|
+
const sizeBytes = readOptionalNumber(part.sizeBytes);
|
|
251
|
+
return {
|
|
252
|
+
type: "file",
|
|
253
|
+
file: {
|
|
254
|
+
label:
|
|
255
|
+
typeof part.name === "string" && part.name.trim()
|
|
256
|
+
? part.name.trim()
|
|
257
|
+
: isImage
|
|
258
|
+
? texts.imageAttachmentLabel
|
|
259
|
+
: texts.fileAttachmentLabel,
|
|
260
|
+
mimeType: part.mimeType,
|
|
261
|
+
dataUrl:
|
|
262
|
+
typeof part.url === "string" && part.url.trim().length > 0
|
|
263
|
+
? part.url.trim()
|
|
264
|
+
: `data:${part.mimeType};base64,${part.data}`,
|
|
265
|
+
...(sizeBytes != null ? { sizeBytes } : {}),
|
|
266
|
+
isImage,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildToolInvocationPart(
|
|
272
|
+
part: Extract<ChatMessagePartSource, { type: "tool-invocation" }>,
|
|
273
|
+
texts: ChatMessageAdapterTexts,
|
|
274
|
+
): Extract<ChatMessagePartViewModel, { type: "tool-card" | "file" }> {
|
|
275
|
+
const invocation = part.toolInvocation;
|
|
276
|
+
const assetFileView = extractAssetFileView(invocation.result, texts);
|
|
277
|
+
if (assetFileView) {
|
|
278
|
+
return assetFileView;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const subagentToolCard = buildSubagentToolCard({
|
|
282
|
+
invocation,
|
|
283
|
+
texts,
|
|
284
|
+
});
|
|
285
|
+
if (subagentToolCard) {
|
|
286
|
+
return {
|
|
287
|
+
type: "tool-card",
|
|
288
|
+
card: buildToolCard(subagentToolCard, texts),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const statusView = resolveToolCardStatus({
|
|
293
|
+
status: invocation.status,
|
|
294
|
+
error: invocation.error,
|
|
295
|
+
cancelled: invocation.cancelled,
|
|
296
|
+
result: invocation.result,
|
|
297
|
+
texts,
|
|
298
|
+
});
|
|
299
|
+
const detail = summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
|
|
300
|
+
const rawResult =
|
|
301
|
+
typeof invocation.error === "string" && invocation.error.trim()
|
|
302
|
+
? invocation.error.trim()
|
|
303
|
+
: invocation.result != null
|
|
304
|
+
? stringifyUnknown(invocation.result).trim()
|
|
305
|
+
: "";
|
|
306
|
+
const card: ToolCardViewSource = {
|
|
307
|
+
kind: statusView.kind,
|
|
308
|
+
name: invocation.toolName,
|
|
309
|
+
detail,
|
|
310
|
+
text: rawResult || undefined,
|
|
311
|
+
callId: invocation.toolCallId || undefined,
|
|
312
|
+
hasResult: statusView.hasResult,
|
|
313
|
+
statusTone: statusView.statusTone,
|
|
314
|
+
statusLabel: statusView.statusLabel,
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
type: "tool-card",
|
|
318
|
+
card: buildToolCard(card, texts),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildUnknownPart(
|
|
323
|
+
part: ChatMessagePartSource,
|
|
324
|
+
texts: ChatMessageAdapterTexts,
|
|
325
|
+
): Extract<ChatMessagePartViewModel, { type: "unknown" }> {
|
|
326
|
+
return {
|
|
327
|
+
type: "unknown",
|
|
328
|
+
label: texts.unknownPartLabel,
|
|
329
|
+
rawType: typeof part.type === "string" ? part.type : "unknown",
|
|
330
|
+
text: stringifyUnknown(part),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isTextPart(
|
|
335
|
+
part: ChatMessagePartSource,
|
|
336
|
+
): part is Extract<ChatMessagePartSource, { type: "text" }> {
|
|
337
|
+
return part.type === "text" && typeof part.text === "string";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isReasoningPart(
|
|
341
|
+
part: ChatMessagePartSource,
|
|
342
|
+
): part is Extract<ChatMessagePartSource, { type: "reasoning" }> {
|
|
343
|
+
return part.type === "reasoning" && typeof part.reasoning === "string";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isFilePart(
|
|
347
|
+
part: ChatMessagePartSource,
|
|
348
|
+
): part is Extract<ChatMessagePartSource, { type: "file" }> {
|
|
349
|
+
return (
|
|
350
|
+
part.type === "file" &&
|
|
351
|
+
typeof part.mimeType === "string" &&
|
|
352
|
+
typeof part.data === "string"
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isToolInvocationPart(
|
|
357
|
+
part: ChatMessagePartSource,
|
|
358
|
+
): part is Extract<ChatMessagePartSource, { type: "tool-invocation" }> {
|
|
359
|
+
if (part.type !== "tool-invocation") {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
if (!isRecord(part.toolInvocation)) {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
return typeof part.toolInvocation.toolName === "string";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function adaptChatMessagePart(
|
|
369
|
+
params: ChatMessagePartAdapterParams,
|
|
370
|
+
): ChatMessagePartViewModel | null {
|
|
371
|
+
if (isTextPart(params.part)) {
|
|
372
|
+
return buildTextPart(params.part, params.inlineTokens);
|
|
373
|
+
}
|
|
374
|
+
if (isReasoningPart(params.part)) {
|
|
375
|
+
return buildReasoningPart(params.part, params.texts);
|
|
376
|
+
}
|
|
377
|
+
if (isFilePart(params.part)) {
|
|
378
|
+
return buildFilePart(params.part, params.texts);
|
|
379
|
+
}
|
|
380
|
+
if (isToolInvocationPart(params.part)) {
|
|
381
|
+
return buildToolInvocationPart(params.part, params.texts);
|
|
382
|
+
}
|
|
383
|
+
return buildUnknownPart(params.part, params.texts);
|
|
384
|
+
}
|
|
@@ -270,6 +270,43 @@ it("maps file parts into previewable attachment view models", () => {
|
|
|
270
270
|
});
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
+
it("renders inline skill tokens as structured inline content parts", () => {
|
|
274
|
+
const adapted = adapt([
|
|
275
|
+
{
|
|
276
|
+
id: "user-inline-skill",
|
|
277
|
+
role: "user",
|
|
278
|
+
meta: {
|
|
279
|
+
inlineTokens: [
|
|
280
|
+
{
|
|
281
|
+
kind: "skill",
|
|
282
|
+
key: "weather",
|
|
283
|
+
label: "Weather",
|
|
284
|
+
rawText: "$weather",
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
parts: [{ type: "text", text: "please use $weather now" }],
|
|
289
|
+
},
|
|
290
|
+
] as unknown as ChatMessageSource[]);
|
|
291
|
+
|
|
292
|
+
expect(adapted[0]?.parts[0]).toEqual({
|
|
293
|
+
type: "inline-content",
|
|
294
|
+
segments: [
|
|
295
|
+
{ type: "markdown", text: "please use " },
|
|
296
|
+
{
|
|
297
|
+
type: "token",
|
|
298
|
+
token: {
|
|
299
|
+
kind: "skill",
|
|
300
|
+
key: "weather",
|
|
301
|
+
label: "Weather",
|
|
302
|
+
rawText: "$weather",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
{ type: "markdown", text: " now" },
|
|
306
|
+
],
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
273
310
|
it("keeps named non-image files as downloadable attachments", () => {
|
|
274
311
|
const adapted = adapt([
|
|
275
312
|
{
|