@springbrand/message-panel 0.2.0-alpha.46 → 0.2.0-alpha.48

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.
@@ -32,6 +32,7 @@ import {
32
32
  import {
33
33
  ApprovalBlock,
34
34
  AskUserBlock,
35
+ type AskUserBlockProps,
35
36
  ErrorBlock,
36
37
  ParallelBlock,
37
38
  PlanBlock,
@@ -96,6 +97,8 @@ export interface CloudOsChatMessagesProps {
96
97
  input: Record<string, unknown>;
97
98
  disabled: boolean;
98
99
  }) => ReactNode;
100
+ /** Lets the Host add its upload lifecycle while retaining AskUserBlock presentation. */
101
+ renderAskUser?: (props: AskUserBlockProps) => ReactNode;
99
102
  /** 接管消息里的文件视图;未提供时使用包内 FileView。 */
100
103
  renderFile?: FileRenderer;
101
104
  /** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
@@ -382,6 +385,7 @@ export function CloudOsChatMessages({
382
385
  approvalIdOf,
383
386
  approvalsDisabled = false,
384
387
  renderApproval,
388
+ renderAskUser,
385
389
  renderFile,
386
390
  resolveUrl,
387
391
  resolveArtifactId,
@@ -822,11 +826,17 @@ export function CloudOsChatMessages({
822
826
  );
823
827
  case "askUser":
824
828
  return (
825
- <AskUserBlock
826
- key={block.key}
827
- call={block.call}
828
- onRespond={onRespond}
829
- />
829
+ <Fragment key={block.key}>
830
+ {renderAskUser?.({
831
+ call: block.call,
832
+ onRespond,
833
+ }) ?? (
834
+ <AskUserBlock
835
+ call={block.call}
836
+ onRespond={onRespond}
837
+ />
838
+ )}
839
+ </Fragment>
830
840
  );
831
841
  case "suggestions":
832
842
  return null;
@@ -7,10 +7,14 @@ import {
7
7
  Clock,
8
8
  Key,
9
9
  ListChecks,
10
+ File as FileIcon,
11
+ Image as ImageIcon,
12
+ UploadSimple,
10
13
  UsersThree,
11
14
  WarningCircle,
12
15
  } from "@phosphor-icons/react";
13
- import { useState } from "react";
16
+ import { useEffect, useRef, useState } from "react";
17
+ import type { FileUIPart } from "ai";
14
18
  import {
15
19
  Collapsible,
16
20
  CollapsibleContent,
@@ -30,6 +34,7 @@ import type {
30
34
  PlanStep,
31
35
  SubAgentView,
32
36
  } from "./transcript-model";
37
+ import type { CloudOsAttachmentView } from "../composer/cloud-os-chat-input";
33
38
  import {
34
39
  askUserOutcome,
35
40
  humanizeToolName,
@@ -141,6 +146,7 @@ interface AskQuestion {
141
146
  options: string[];
142
147
  multi: boolean;
143
148
  allowCustom: boolean;
149
+ kind: "ordinary" | "attachment";
144
150
  }
145
151
 
146
152
  function questionsOf(input: Record<string, unknown>): AskQuestion[] {
@@ -159,6 +165,7 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
159
165
  options,
160
166
  multi: question.multiSelect === true,
161
167
  allowCustom: question.allowCustom === true || options.length === 0,
168
+ kind: question.kind === "attachment" ? "attachment" : "ordinary",
162
169
  };
163
170
  });
164
171
  }
@@ -168,14 +175,199 @@ interface DraftAnswer {
168
175
  text: string;
169
176
  }
170
177
 
178
+ export interface AskUserAttachmentFieldProps {
179
+ attachments: readonly CloudOsAttachmentView[];
180
+ disabled: boolean;
181
+ busy: boolean;
182
+ hasFailure: boolean;
183
+ onFilesSelected: (files: readonly File[]) => void;
184
+ onRemove: (id: string) => void;
185
+ onRetry: (id: string) => void;
186
+ }
187
+
188
+ export interface AskUserAttachmentController
189
+ extends Omit<AskUserAttachmentFieldProps, "disabled"> {
190
+ getParts: () => readonly FileUIPart[];
191
+ }
192
+
193
+ export interface AskUserBlockProps {
194
+ call: CloudOsToolCall;
195
+ /** 缺省(宿主没接)时选项不可点。 */
196
+ onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
197
+ /** Host-owned upload lifecycle for the single Attachment question. */
198
+ attachmentField?: AskUserAttachmentController;
199
+ }
200
+
201
+ function formatAttachmentSize(size: number | undefined): string {
202
+ if (size === undefined) return "Ready";
203
+ if (size < 1_024) return `${size} B`;
204
+ if (size < 1_024 * 1_024) return `${Math.round(size / 1_024)} KB`;
205
+ return `${(size / 1_024 / 1_024).toFixed(1)} MB`;
206
+ }
207
+
208
+ function AttachmentImage({ src }: { src: string }) {
209
+ const [failed, setFailed] = useState(false);
210
+ useEffect(() => setFailed(false), [src]);
211
+ return failed
212
+ ? <ImageIcon size={18} aria-hidden="true" />
213
+ : (
214
+ <img
215
+ src={src}
216
+ alt=""
217
+ className="size-10 object-cover"
218
+ onError={() => setFailed(true)}
219
+ />
220
+ );
221
+ }
222
+
223
+ export function AskUserAttachmentField({
224
+ attachments,
225
+ disabled,
226
+ busy,
227
+ hasFailure,
228
+ onFilesSelected,
229
+ onRemove,
230
+ onRetry,
231
+ }: AskUserAttachmentFieldProps) {
232
+ const inputRef = useRef<HTMLInputElement>(null);
233
+ const addFiles = (files: FileList | readonly File[]) => {
234
+ if (!disabled && files.length > 0) onFilesSelected(Array.from(files));
235
+ };
236
+
237
+ return (
238
+ <div className="flex w-full flex-col gap-2.5">
239
+ <>
240
+ <input
241
+ ref={inputRef}
242
+ type="file"
243
+ multiple
244
+ disabled={disabled}
245
+ className="sr-only"
246
+ aria-label="Choose files"
247
+ onChange={(event) => {
248
+ if (event.currentTarget.files) addFiles(event.currentTarget.files);
249
+ event.currentTarget.value = "";
250
+ }}
251
+ />
252
+ <button
253
+ type="button"
254
+ aria-label="Choose files"
255
+ disabled={disabled}
256
+ onClick={() => inputRef.current?.click()}
257
+ onDragOver={(event) => {
258
+ event.preventDefault();
259
+ }}
260
+ onDrop={(event) => {
261
+ event.preventDefault();
262
+ addFiles(event.dataTransfer.files);
263
+ }}
264
+ onPaste={(event) => {
265
+ const files = Array.from(event.clipboardData.items)
266
+ .filter((item) => item.kind === "file")
267
+ .map((item) => item.getAsFile())
268
+ .filter((file): file is File => file !== null);
269
+ if (files.length > 0) {
270
+ event.preventDefault();
271
+ addFiles(files);
272
+ }
273
+ }}
274
+ className="flex min-h-16 w-full items-center justify-center gap-2 rounded-xl border border-dashed border-kumo-line bg-kumo-elevated/50 px-4 py-3 text-[13px] text-kumo-subtle transition-colors hover:border-kumo-brand hover:bg-kumo-tint/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40 disabled:cursor-default disabled:opacity-60"
275
+ >
276
+ <UploadSimple size={18} aria-hidden="true" />
277
+ <span>Drop files here or choose files</span>
278
+ </button>
279
+ </>
280
+ {attachments.length > 0 && (
281
+ <div className="flex flex-col gap-2">
282
+ {attachments.map((attachment) => {
283
+ const image = attachment.mediaType?.startsWith("image/") === true;
284
+ const label = attachment.filename ?? "Attachment";
285
+ const status = attachment.status === "uploading"
286
+ ? `Uploading ${Math.round((attachment.progress ?? 0) * 100)}%`
287
+ : attachment.status === "error"
288
+ ? "Could not upload this file. Try again."
289
+ : formatAttachmentSize(attachment.size);
290
+ const preview = (
291
+ <span
292
+ className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-kumo-control text-kumo-inactive themed-thumbnail-shadow"
293
+ data-attachment-kind={image ? "image" : "file"}
294
+ >
295
+ {image && attachment.previewUrl
296
+ ? <AttachmentImage src={attachment.previewUrl} />
297
+ : image
298
+ ? <ImageIcon size={18} aria-hidden="true" />
299
+ : <FileIcon size={18} aria-hidden="true" />}
300
+ </span>
301
+ );
302
+ return (
303
+ <div
304
+ key={attachment.id}
305
+ className="flex min-w-0 items-center gap-3 rounded-xl border border-kumo-line bg-kumo-elevated/55 p-2"
306
+ >
307
+ {attachment.status === "ready" && attachment.previewUrl
308
+ ? (
309
+ <a
310
+ href={attachment.previewUrl}
311
+ target="_blank"
312
+ rel="noopener noreferrer"
313
+ aria-label={`${image ? "Preview" : "Open"} ${label}`}
314
+ className="rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
315
+ >
316
+ {preview}
317
+ </a>
318
+ )
319
+ : preview}
320
+ <span className="flex min-w-0 flex-1 flex-col">
321
+ <span className="truncate text-[13px] font-medium text-kumo-default">
322
+ {label}
323
+ </span>
324
+ <span
325
+ className={attachment.status === "error"
326
+ ? "text-[11px] text-kumo-danger"
327
+ : "text-[11px] text-kumo-inactive"}
328
+ role={attachment.status === "error" ? "alert" : "status"}
329
+ >
330
+ {status}
331
+ </span>
332
+ </span>
333
+ {attachment.status === "error" && (
334
+ <button
335
+ type="button"
336
+ disabled={disabled}
337
+ onClick={() => onRetry(attachment.id)}
338
+ className="rounded-lg px-2 py-1 text-[12px] text-kumo-danger hover:bg-kumo-tint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40 disabled:opacity-50"
339
+ >
340
+ Retry
341
+ </button>
342
+ )}
343
+ <button
344
+ type="button"
345
+ disabled={disabled}
346
+ aria-label={`Remove ${label}`}
347
+ onClick={() => onRemove(attachment.id)}
348
+ className="rounded-lg px-2 py-1 text-[12px] text-kumo-inactive hover:bg-kumo-tint hover:text-kumo-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40 disabled:opacity-50"
349
+ >
350
+ Remove
351
+ </button>
352
+ </div>
353
+ );
354
+ })}
355
+ </div>
356
+ )}
357
+ {(busy || hasFailure) && attachments.length === 0 && (
358
+ <span role={hasFailure ? "alert" : "status"} className="text-[11px] text-kumo-inactive">
359
+ {hasFailure ? "Could not upload this file. Try again." : "Uploading"}
360
+ </span>
361
+ )}
362
+ </div>
363
+ );
364
+ }
365
+
171
366
  export function AskUserBlock({
172
367
  call,
173
368
  onRespond,
174
- }: {
175
- call: CloudOsToolCall;
176
- /** 缺省(宿主没接)时选项不可点 —— 形状对齐 ScheduleBlock 的 onOpen。 */
177
- onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
178
- }) {
369
+ attachmentField,
370
+ }: AskUserBlockProps) {
179
371
  const questions = questionsOf(call.input);
180
372
  // 不能用 `useState(() => questions.map(...))` 初始化:工具入参是**流式**到达的,
181
373
  // 首帧 questions 往往还是空数组,那之后每次 setAnswers 都在空数组上 map,
@@ -209,10 +401,27 @@ export function AskUserBlock({
209
401
  });
210
402
  // 在途只用于按钮转圈;权威状态永远是 part 的 state。
211
403
  const [inFlight, setInFlight] = useState(false);
404
+ const [submissionError, setSubmissionError] = useState(false);
212
405
  const outcome = askUserOutcome(call);
406
+ const responseTimerRef = useRef<number | null>(null);
407
+ const hasAttachmentQuestion = questions.some(
408
+ (question) => question.kind === "attachment",
409
+ );
410
+ const clearResponseTimer = () => {
411
+ if (responseTimerRef.current === null) return;
412
+ window.clearTimeout(responseTimerRef.current);
413
+ responseTimerRef.current = null;
414
+ };
415
+ useEffect(() => () => clearResponseTimer(), []);
416
+ useEffect(() => {
417
+ if (outcome.kind !== "pending") clearResponseTimer();
418
+ }, [outcome.kind]);
213
419
  const displayAnswerAt = (index: number): DraftAnswer => {
214
420
  if (outcome.kind !== "answered") return answerAt(index);
215
421
  const answer = outcome.answers[index];
422
+ if (!answer || !("selections" in answer)) {
423
+ return { selections: [], text: "" };
424
+ }
216
425
  return {
217
426
  selections: answer?.selections ?? [],
218
427
  text: answer?.text ?? "",
@@ -221,8 +430,14 @@ export function AskUserBlock({
221
430
  // 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
222
431
  const resolved = outcome.kind !== "pending" || inFlight;
223
432
  const valid = questions.length > 0 && questions.every(
224
- (_question, index) =>
225
- answerAt(index).selections.length > 0 || answerAt(index).text.trim(),
433
+ (question, index) => question.kind === "attachment"
434
+ ? Boolean(
435
+ attachmentField &&
436
+ attachmentField.attachments.length > 0 &&
437
+ !attachmentField.busy &&
438
+ !attachmentField.hasFailure
439
+ )
440
+ : answerAt(index).selections.length > 0 || Boolean(answerAt(index).text.trim()),
226
441
  );
227
442
  const buttonLabel = inFlight ? "Submitting…" : "Submit";
228
443
  const footnote = outcome.kind === "closed"
@@ -233,16 +448,42 @@ export function AskUserBlock({
233
448
 
234
449
  const submit = () => {
235
450
  if (!onRespond) return;
236
- setInFlight(true);
237
- // 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
238
- void onRespond(call.toolCallId, {
239
- answers: questions.map((_, index) => ({
240
- selections: answerAt(index).selections,
241
- text: answerAt(index).text.trim(),
242
- })),
243
- }).then((ok) => {
244
- if (!ok) setInFlight(false);
245
- }, () => setInFlight(false));
451
+ setSubmissionError(false);
452
+ try {
453
+ const response = {
454
+ answers: questions.map((question, index) =>
455
+ question.kind === "attachment"
456
+ ? { attachments: [...(attachmentField?.getParts() ?? [])] }
457
+ : {
458
+ selections: answerAt(index).selections,
459
+ text: answerAt(index).text.trim(),
460
+ }
461
+ ),
462
+ };
463
+ setInFlight(true);
464
+ clearResponseTimer();
465
+ responseTimerRef.current = window.setTimeout(() => {
466
+ responseTimerRef.current = null;
467
+ setInFlight(false);
468
+ setSubmissionError(hasAttachmentQuestion);
469
+ }, 30_000);
470
+ // 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
471
+ void onRespond(call.toolCallId, response).then((ok) => {
472
+ if (!ok) {
473
+ clearResponseTimer();
474
+ setInFlight(false);
475
+ setSubmissionError(hasAttachmentQuestion);
476
+ }
477
+ }, () => {
478
+ clearResponseTimer();
479
+ setInFlight(false);
480
+ setSubmissionError(hasAttachmentQuestion);
481
+ });
482
+ } catch {
483
+ clearResponseTimer();
484
+ setInFlight(false);
485
+ setSubmissionError(hasAttachmentQuestion);
486
+ }
246
487
  };
247
488
 
248
489
  return (
@@ -254,6 +495,9 @@ export function AskUserBlock({
254
495
  <div className="flex flex-col gap-4 py-2.5">
255
496
  {outcome.kind !== "closed" && questions.map((question, questionIndex) => {
256
497
  const answer = displayAnswerAt(questionIndex);
498
+ const settledAttachmentAnswer = outcome.kind === "answered"
499
+ ? outcome.answers[questionIndex]
500
+ : undefined;
257
501
  const options = outcome.kind === "answered"
258
502
  ? [...new Set([...question.options, ...answer.selections])]
259
503
  : question.options;
@@ -265,7 +509,29 @@ export function AskUserBlock({
265
509
  <p className="m-0 text-[14px] leading-[1.4] font-normal text-kumo-default">
266
510
  {question.prompt}
267
511
  </p>
268
- {options.length > 0 && (
512
+ {question.kind === "attachment" && (
513
+ <AskUserAttachmentField
514
+ attachments={
515
+ settledAttachmentAnswer && "attachments" in settledAttachmentAnswer
516
+ ? settledAttachmentAnswer.attachments.map((attachment) => ({
517
+ id: attachment.attachmentId,
518
+ filename: attachment.name,
519
+ mediaType: attachment.mediaType,
520
+ size: attachment.size,
521
+ previewUrl: attachment.cdnUrl,
522
+ status: "ready" as const,
523
+ }))
524
+ : attachmentField?.attachments ?? []
525
+ }
526
+ disabled={resolved || !onRespond || !attachmentField}
527
+ busy={attachmentField?.busy ?? false}
528
+ hasFailure={attachmentField?.hasFailure ?? false}
529
+ onFilesSelected={attachmentField?.onFilesSelected ?? (() => undefined)}
530
+ onRemove={attachmentField?.onRemove ?? (() => undefined)}
531
+ onRetry={attachmentField?.onRetry ?? (() => undefined)}
532
+ />
533
+ )}
534
+ {question.kind === "ordinary" && options.length > 0 && (
269
535
  <div className="flex flex-wrap items-center gap-2">
270
536
  {options.map((option) => {
271
537
  const selected = answer.selections.includes(option);
@@ -297,7 +563,7 @@ export function AskUserBlock({
297
563
  })}
298
564
  </div>
299
565
  )}
300
- {question.allowCustom && (
566
+ {question.kind === "ordinary" && question.allowCustom && (
301
567
  <label className="flex w-full flex-col gap-2 text-[14px] leading-[1.4] text-kumo-default">
302
568
  <span>{question.options.length > 0 ? "Other" : "Answer"}</span>
303
569
  <input
@@ -315,7 +581,7 @@ export function AskUserBlock({
315
581
  />
316
582
  </label>
317
583
  )}
318
- {outcome.kind === "answered" &&
584
+ {question.kind === "ordinary" && outcome.kind === "answered" &&
319
585
  !question.allowCustom && answer.text.trim() && (
320
586
  <p className="m-0 text-[14px] leading-[1.4] text-kumo-subtle">
321
587
  {answer.text.trim()}
@@ -325,6 +591,7 @@ export function AskUserBlock({
325
591
  );
326
592
  })}
327
593
  {outcome.kind === "pending" && (
594
+ <>
328
595
  <button
329
596
  type="button"
330
597
  disabled={resolved || !onRespond || !valid}
@@ -333,6 +600,12 @@ export function AskUserBlock({
333
600
  >
334
601
  {buttonLabel}
335
602
  </button>
603
+ {submissionError && (
604
+ <p className="m-0 text-[12px] leading-4 text-kumo-danger" role="alert">
605
+ Could not add these files to the Workspace. Try submitting again.
606
+ </p>
607
+ )}
608
+ </>
336
609
  )}
337
610
  {outcome.kind === "answered" && (
338
611
  <p className="m-0 text-[12px] leading-4 text-kumo-inactive">Submitted</p>
@@ -503,11 +503,25 @@ export type AskUserOutcome =
503
503
  | { kind: "pending" }
504
504
  | {
505
505
  kind: "answered";
506
- answers: Array<{
507
- question: string;
508
- selections: string[];
509
- text: string;
510
- }>;
506
+ answers: Array<
507
+ | {
508
+ question: string;
509
+ selections: string[];
510
+ text: string;
511
+ }
512
+ | {
513
+ question: string;
514
+ attachments: Array<{
515
+ name: string;
516
+ mediaType: string;
517
+ size: number;
518
+ cdnUrl: string;
519
+ workspacePath: string;
520
+ attachmentId: string;
521
+ contentVersion: string;
522
+ }>;
523
+ }
524
+ >;
511
525
  }
512
526
  | { kind: "closed"; reason: "user_replied_freeform" | "other" };
513
527
 
@@ -518,19 +532,51 @@ export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
518
532
  const record = recordOf(call.output);
519
533
  if (Array.isArray(record.answers) && record.answers.length > 0) {
520
534
  const answers = record.answers.map(recordOf);
521
- if (answers.every((answer) =>
522
- typeof answer.question === "string" &&
523
- Array.isArray(answer.selections) &&
524
- answer.selections.every((value) => typeof value === "string") &&
525
- (answer.text === undefined || typeof answer.text === "string")
526
- )) {
535
+ if (answers.every((answer) => {
536
+ if (typeof answer.question !== "string") return false;
537
+ if (Array.isArray(answer.attachments)) {
538
+ return answer.attachments.length > 0 &&
539
+ answer.attachments.every((value) => {
540
+ const attachment = recordOf(value);
541
+ return typeof attachment.name === "string" &&
542
+ typeof attachment.mediaType === "string" &&
543
+ typeof attachment.size === "number" &&
544
+ typeof attachment.cdnUrl === "string" &&
545
+ typeof attachment.workspacePath === "string" &&
546
+ typeof attachment.attachmentId === "string" &&
547
+ typeof attachment.contentVersion === "string";
548
+ });
549
+ }
550
+ return Array.isArray(answer.selections) &&
551
+ answer.selections.every((value) => typeof value === "string") &&
552
+ (answer.text === undefined || typeof answer.text === "string");
553
+ })) {
527
554
  return {
528
555
  kind: "answered",
529
- answers: answers.map((answer) => ({
530
- question: answer.question as string,
531
- selections: answer.selections as string[],
532
- text: typeof answer.text === "string" ? answer.text : "",
533
- })),
556
+ answers: answers.map((answer) => {
557
+ if (Array.isArray(answer.attachments)) {
558
+ return {
559
+ question: answer.question as string,
560
+ attachments: answer.attachments.map((value) => {
561
+ const attachment = recordOf(value);
562
+ return {
563
+ name: attachment.name as string,
564
+ mediaType: attachment.mediaType as string,
565
+ size: attachment.size as number,
566
+ cdnUrl: attachment.cdnUrl as string,
567
+ workspacePath: attachment.workspacePath as string,
568
+ attachmentId: attachment.attachmentId as string,
569
+ contentVersion: attachment.contentVersion as string,
570
+ };
571
+ }),
572
+ };
573
+ }
574
+ return {
575
+ question: answer.question as string,
576
+ selections: answer.selections as string[],
577
+ text: typeof answer.text === "string" ? answer.text : "",
578
+ };
579
+ }),
534
580
  };
535
581
  }
536
582
  }
package/cloud-os/index.ts CHANGED
@@ -84,6 +84,7 @@ export type {
84
84
  export {
85
85
  ApprovalBlock,
86
86
  AskUserBlock,
87
+ AskUserAttachmentField,
87
88
  ErrorBlock,
88
89
  ParallelBlock,
89
90
  PlanBlock,
@@ -94,6 +95,11 @@ export {
94
95
  SuggestionsBlock,
95
96
  } from "./chat/rich-blocks";
96
97
  export type { ApprovalDecision } from "./chat/rich-blocks";
98
+ export type {
99
+ AskUserAttachmentController,
100
+ AskUserAttachmentFieldProps,
101
+ AskUserBlockProps,
102
+ } from "./chat/rich-blocks";
97
103
 
98
104
  export { CloudOsActivityIndicator } from "./chat/activity-indicator";
99
105
 
@@ -126,6 +132,7 @@ export {
126
132
  humanizeToolName,
127
133
  toCloudOsToolCall,
128
134
  toolNameOfPart,
135
+ askUserOutcome,
129
136
  } from "./chat/tool-presentation";
130
137
  export type {
131
138
  CloudOsToolCall,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.2.0-alpha.46",
3
+ "version": "0.2.0-alpha.48",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -701,6 +701,7 @@ function CamelAskUser({
701
701
  >
702
702
  {questions.map((question, questionIndex) => {
703
703
  const prompt = String(question.question ?? "Choose an option");
704
+ const attachmentQuestion = question.kind === "attachment";
704
705
  const options = Array.isArray(question.options)
705
706
  ? question.options.filter(
706
707
  (option): option is string => typeof option === "string",
@@ -714,10 +715,41 @@ function CamelAskUser({
714
715
  (value): value is string => typeof value === "string",
715
716
  )
716
717
  : [];
718
+ const settledAttachments = Array.isArray(settledAnswer?.attachments)
719
+ ? settledAnswer.attachments.map(recordOf).filter((attachment) =>
720
+ typeof attachment.name === "string" &&
721
+ typeof attachment.cdnUrl === "string"
722
+ )
723
+ : [];
717
724
  return (
718
725
  <div className="space-y-2" key={`${prompt}:${questionIndex}`}>
719
726
  <p className="text-sm text-foreground">{prompt}</p>
720
- <div className="flex flex-wrap gap-2">
727
+ {attachmentQuestion && settledAttachments.length === 0 && (
728
+ <p className="text-xs text-muted-foreground">
729
+ File upload is unavailable in this preview.
730
+ </p>
731
+ )}
732
+ {attachmentQuestion && settledAttachments.length > 0 && (
733
+ <div className="flex flex-col gap-2">
734
+ {settledAttachments.map((attachment) => (
735
+ <a
736
+ key={String(attachment.attachmentId ?? attachment.cdnUrl)}
737
+ href={attachment.cdnUrl as string}
738
+ target="_blank"
739
+ rel="noopener noreferrer"
740
+ className="rounded-lg border border-border px-3 py-2 text-sm text-foreground"
741
+ data-attachment-kind={
742
+ String(attachment.mediaType ?? "").startsWith("image/")
743
+ ? "image"
744
+ : "file"
745
+ }
746
+ >
747
+ {attachment.name as string}
748
+ </a>
749
+ ))}
750
+ </div>
751
+ )}
752
+ {!attachmentQuestion && <div className="flex flex-wrap gap-2">
721
753
  {options.map((option) => {
722
754
  const selected = settledAnswer
723
755
  ? settledSelections.includes(option)
@@ -751,8 +783,8 @@ function CamelAskUser({
751
783
  </Button>
752
784
  );
753
785
  })}
754
- </div>
755
- {!resolvedSubmitted && allowCustom && (
786
+ </div>}
787
+ {!attachmentQuestion && !resolvedSubmitted && allowCustom && (
756
788
  <input
757
789
  value={answerAt(questionIndex).text}
758
790
  maxLength={2_000}
@@ -772,7 +804,7 @@ function CamelAskUser({
772
804
  className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
773
805
  />
774
806
  )}
775
- {settledAnswer && (
807
+ {!attachmentQuestion && settledAnswer && (
776
808
  <p className="text-xs text-muted-foreground">
777
809
  Answered: {[
778
810
  ...settledSelections,
@@ -788,7 +820,9 @@ function CamelAskUser({
788
820
  <Button
789
821
  type="button"
790
822
  size="sm"
791
- disabled={resolvedSubmitted || !valid}
823
+ disabled={resolvedSubmitted || !valid || questions.some((question) =>
824
+ question.kind === "attachment"
825
+ )}
792
826
  onClick={() => {
793
827
  onAnswer({
794
828
  answers: questions.map((_, index) => ({