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

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.
@@ -21,6 +21,7 @@ import {
21
21
  CollapsibleTrigger,
22
22
  } from "../primitives/collapsible";
23
23
  import { Tooltip } from "../primitives/tooltip";
24
+ import { fileMeta, ImagePreview } from "../file-view";
24
25
  import { WorkshopButton } from "../primitives/workshop-controls";
25
26
  import { MarkdownMessage } from "./markdown-message";
26
27
  import { collapsePanel } from "./surfaces";
@@ -144,9 +145,8 @@ export function PlanBlock({
144
145
  interface AskQuestion {
145
146
  prompt: string;
146
147
  options: string[];
147
- multi: boolean;
148
148
  allowCustom: boolean;
149
- kind: "ordinary" | "attachment";
149
+ responseType: "text" | "single_select" | "multi_select" | "attachment";
150
150
  }
151
151
 
152
152
  function questionsOf(input: Record<string, unknown>): AskQuestion[] {
@@ -158,14 +158,25 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
158
158
  typeof option === "string"
159
159
  )
160
160
  : [];
161
+ const responseType = question.responseType === "text" ||
162
+ question.responseType === "single_select" ||
163
+ question.responseType === "multi_select" ||
164
+ question.responseType === "attachment"
165
+ ? question.responseType
166
+ : question.kind === "attachment"
167
+ ? "attachment"
168
+ : options.length === 0
169
+ ? "text"
170
+ : question.multiSelect === true
171
+ ? "multi_select"
172
+ : "single_select";
161
173
  return {
162
174
  prompt: typeof question.question === "string"
163
175
  ? question.question
164
176
  : "Choose an option",
165
177
  options,
166
- multi: question.multiSelect === true,
167
- allowCustom: question.allowCustom === true || options.length === 0,
168
- kind: question.kind === "attachment" ? "attachment" : "ordinary",
178
+ allowCustom: responseType === "text" || question.allowCustom === true,
179
+ responseType,
169
180
  };
170
181
  });
171
182
  }
@@ -198,13 +209,6 @@ export interface AskUserBlockProps {
198
209
  attachmentField?: AskUserAttachmentController;
199
210
  }
200
211
 
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
212
  function AttachmentImage({ src }: { src: string }) {
209
213
  const [failed, setFailed] = useState(false);
210
214
  useEffect(() => setFailed(false), [src]);
@@ -223,8 +227,6 @@ function AttachmentImage({ src }: { src: string }) {
223
227
  export function AskUserAttachmentField({
224
228
  attachments,
225
229
  disabled,
226
- busy,
227
- hasFailure,
228
230
  onFilesSelected,
229
231
  onRemove,
230
232
  onRetry,
@@ -271,7 +273,7 @@ export function AskUserAttachmentField({
271
273
  addFiles(files);
272
274
  }
273
275
  }}
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"
276
+ 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: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
277
  >
276
278
  <UploadSimple size={18} aria-hidden="true" />
277
279
  <span>Drop files here or choose files</span>
@@ -286,7 +288,7 @@ export function AskUserAttachmentField({
286
288
  ? `Uploading ${Math.round((attachment.progress ?? 0) * 100)}%`
287
289
  : attachment.status === "error"
288
290
  ? "Could not upload this file. Try again."
289
- : formatAttachmentSize(attachment.size);
291
+ : fileMeta({ size: attachment.size });
290
292
  const preview = (
291
293
  <span
292
294
  className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-kumo-control text-kumo-inactive themed-thumbnail-shadow"
@@ -305,17 +307,27 @@ export function AskUserAttachmentField({
305
307
  className="flex min-w-0 items-center gap-3 rounded-xl border border-kumo-line bg-kumo-elevated/55 p-2"
306
308
  >
307
309
  {attachment.status === "ready" && attachment.previewUrl
308
- ? (
310
+ ? image
311
+ ? (
312
+ <ImagePreview
313
+ src={attachment.previewUrl}
314
+ label={label}
315
+ placement="assistant-message"
316
+ >
317
+ {preview}
318
+ </ImagePreview>
319
+ )
320
+ : (
309
321
  <a
310
322
  href={attachment.previewUrl}
311
323
  target="_blank"
312
324
  rel="noopener noreferrer"
313
- aria-label={`${image ? "Preview" : "Open"} ${label}`}
325
+ aria-label={`Open ${label}`}
314
326
  className="rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
315
327
  >
316
328
  {preview}
317
329
  </a>
318
- )
330
+ )
319
331
  : preview}
320
332
  <span className="flex min-w-0 flex-1 flex-col">
321
333
  <span className="truncate text-[13px] font-medium text-kumo-default">
@@ -354,11 +366,6 @@ export function AskUserAttachmentField({
354
366
  })}
355
367
  </div>
356
368
  )}
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
369
  </div>
363
370
  );
364
371
  }
@@ -375,7 +382,7 @@ export function AskUserBlock({
375
382
  const [answers, setAnswers] = useState<DraftAnswer[]>([]);
376
383
  const answerAt = (index: number): DraftAnswer =>
377
384
  answers[index] ?? { selections: [], text: "" };
378
- const toggle = (questionIndex: number, option: string, multi: boolean) =>
385
+ const toggle = (questionIndex: number, option: string) =>
379
386
  setAnswers((current) => {
380
387
  const next = questions.map((_, index) =>
381
388
  current[index] ?? { selections: [], text: "" }
@@ -383,11 +390,11 @@ export function AskUserBlock({
383
390
  const answer = next[questionIndex]!;
384
391
  next[questionIndex] = {
385
392
  ...answer,
386
- selections: !multi
387
- ? [option]
388
- : answer.selections.includes(option)
393
+ selections: questions[questionIndex]?.responseType === "multi_select"
394
+ ? answer.selections.includes(option)
389
395
  ? answer.selections.filter((value) => value !== option)
390
- : [...answer.selections, option],
396
+ : [...answer.selections, option]
397
+ : [option],
391
398
  };
392
399
  return next;
393
400
  });
@@ -405,7 +412,7 @@ export function AskUserBlock({
405
412
  const outcome = askUserOutcome(call);
406
413
  const responseTimerRef = useRef<number | null>(null);
407
414
  const hasAttachmentQuestion = questions.some(
408
- (question) => question.kind === "attachment",
415
+ (question) => question.responseType === "attachment",
409
416
  );
410
417
  const clearResponseTimer = () => {
411
418
  if (responseTimerRef.current === null) return;
@@ -419,7 +426,7 @@ export function AskUserBlock({
419
426
  const displayAnswerAt = (index: number): DraftAnswer => {
420
427
  if (outcome.kind !== "answered") return answerAt(index);
421
428
  const answer = outcome.answers[index];
422
- if (!answer || !("selections" in answer)) {
429
+ if (!answer) {
423
430
  return { selections: [], text: "" };
424
431
  }
425
432
  return {
@@ -430,14 +437,17 @@ export function AskUserBlock({
430
437
  // 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
431
438
  const resolved = outcome.kind !== "pending" || inFlight;
432
439
  const valid = questions.length > 0 && questions.every(
433
- (question, index) => question.kind === "attachment"
440
+ (question, index) => question.responseType === "attachment"
434
441
  ? Boolean(
435
442
  attachmentField &&
436
443
  attachmentField.attachments.length > 0 &&
437
444
  !attachmentField.busy &&
438
445
  !attachmentField.hasFailure
439
446
  )
440
- : answerAt(index).selections.length > 0 || Boolean(answerAt(index).text.trim()),
447
+ : question.responseType === "text"
448
+ ? Boolean(answerAt(index).text.trim())
449
+ : answerAt(index).selections.length > 0 ||
450
+ (question.allowCustom && Boolean(answerAt(index).text.trim())),
441
451
  );
442
452
  const buttonLabel = inFlight ? "Submitting…" : "Submit";
443
453
  const footnote = outcome.kind === "closed"
@@ -452,12 +462,17 @@ export function AskUserBlock({
452
462
  try {
453
463
  const response = {
454
464
  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
- }
465
+ ({
466
+ selections: question.responseType === "attachment"
467
+ ? []
468
+ : answerAt(index).selections,
469
+ text: question.responseType === "attachment"
470
+ ? ""
471
+ : answerAt(index).text.trim(),
472
+ attachments: question.responseType === "attachment"
473
+ ? [...(attachmentField?.getParts() ?? [])]
474
+ : [],
475
+ })
461
476
  ),
462
477
  };
463
478
  setInFlight(true);
@@ -501,6 +516,8 @@ export function AskUserBlock({
501
516
  const options = outcome.kind === "answered"
502
517
  ? [...new Set([...question.options, ...answer.selections])]
503
518
  : question.options;
519
+ const choice = question.responseType === "single_select" ||
520
+ question.responseType === "multi_select";
504
521
  return (
505
522
  <div
506
523
  key={`${question.prompt}:${questionIndex}`}
@@ -509,10 +526,10 @@ export function AskUserBlock({
509
526
  <p className="m-0 text-[14px] leading-[1.4] font-normal text-kumo-default">
510
527
  {question.prompt}
511
528
  </p>
512
- {question.kind === "attachment" && (
529
+ {question.responseType === "attachment" && (
513
530
  <AskUserAttachmentField
514
531
  attachments={
515
- settledAttachmentAnswer && "attachments" in settledAttachmentAnswer
532
+ settledAttachmentAnswer
516
533
  ? settledAttachmentAnswer.attachments.map((attachment) => ({
517
534
  id: attachment.attachmentId,
518
535
  filename: attachment.name,
@@ -531,7 +548,7 @@ export function AskUserBlock({
531
548
  onRetry={attachmentField?.onRetry ?? (() => undefined)}
532
549
  />
533
550
  )}
534
- {question.kind === "ordinary" && options.length > 0 && (
551
+ {choice && options.length > 0 && (
535
552
  <div className="flex flex-wrap items-center gap-2">
536
553
  {options.map((option) => {
537
554
  const selected = answer.selections.includes(option);
@@ -541,8 +558,7 @@ export function AskUserBlock({
541
558
  type="button"
542
559
  aria-pressed={selected}
543
560
  disabled={resolved || !onRespond}
544
- onClick={() =>
545
- toggle(questionIndex, option, question.multi)}
561
+ onClick={() => toggle(questionIndex, option)}
546
562
  style={selected
547
563
  ? {
548
564
  background:
@@ -563,7 +579,8 @@ export function AskUserBlock({
563
579
  })}
564
580
  </div>
565
581
  )}
566
- {question.kind === "ordinary" && question.allowCustom && (
582
+ {(question.responseType === "text" ||
583
+ (choice && question.allowCustom)) && (
567
584
  <label className="flex w-full flex-col gap-2 text-[14px] leading-[1.4] text-kumo-default">
568
585
  <span>{question.options.length > 0 ? "Other" : "Answer"}</span>
569
586
  <input
@@ -581,7 +598,8 @@ export function AskUserBlock({
581
598
  />
582
599
  </label>
583
600
  )}
584
- {question.kind === "ordinary" && outcome.kind === "answered" &&
601
+ {question.responseType !== "attachment" &&
602
+ outcome.kind === "answered" &&
585
603
  !question.allowCustom && answer.text.trim() && (
586
604
  <p className="m-0 text-[14px] leading-[1.4] text-kumo-subtle">
587
605
  {answer.text.trim()}
@@ -503,25 +503,20 @@ export type AskUserOutcome =
503
503
  | { kind: "pending" }
504
504
  | {
505
505
  kind: "answered";
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
- >;
506
+ answers: Array<{
507
+ question: string;
508
+ selections: string[];
509
+ text: string;
510
+ attachments: Array<{
511
+ name: string;
512
+ mediaType: string;
513
+ size: number;
514
+ cdnUrl: string;
515
+ workspacePath: string;
516
+ attachmentId: string;
517
+ contentVersion: string;
518
+ }>;
519
+ }>;
525
520
  }
526
521
  | { kind: "closed"; reason: "user_replied_freeform" | "other" };
527
522
 
@@ -534,47 +529,52 @@ export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
534
529
  const answers = record.answers.map(recordOf);
535
530
  if (answers.every((answer) => {
536
531
  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
- });
532
+ const selections = answer.selections;
533
+ const attachments = answer.attachments;
534
+ if (!Array.isArray(selections) && !Array.isArray(attachments)) return false;
535
+ if (
536
+ Array.isArray(selections) &&
537
+ !selections.every((value) => typeof value === "string")
538
+ ) return false;
539
+ if (answer.text !== undefined && typeof answer.text !== "string") return false;
540
+ if (Array.isArray(attachments)) {
541
+ if (!Array.isArray(selections) && attachments.length === 0) return false;
542
+ return attachments.every((value) => {
543
+ const attachment = recordOf(value);
544
+ return typeof attachment.name === "string" &&
545
+ typeof attachment.mediaType === "string" &&
546
+ typeof attachment.size === "number" &&
547
+ typeof attachment.cdnUrl === "string" &&
548
+ typeof attachment.workspacePath === "string" &&
549
+ typeof attachment.attachmentId === "string" &&
550
+ typeof attachment.contentVersion === "string";
551
+ });
549
552
  }
550
- return Array.isArray(answer.selections) &&
551
- answer.selections.every((value) => typeof value === "string") &&
552
- (answer.text === undefined || typeof answer.text === "string");
553
+ return true;
553
554
  })) {
554
555
  return {
555
556
  kind: "answered",
556
557
  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
558
  return {
575
559
  question: answer.question as string,
576
- selections: answer.selections as string[],
560
+ selections: Array.isArray(answer.selections)
561
+ ? answer.selections as string[]
562
+ : [],
577
563
  text: typeof answer.text === "string" ? answer.text : "",
564
+ attachments: Array.isArray(answer.attachments)
565
+ ? answer.attachments.map((value) => {
566
+ const attachment = recordOf(value);
567
+ return {
568
+ name: attachment.name as string,
569
+ mediaType: attachment.mediaType as string,
570
+ size: attachment.size as number,
571
+ cdnUrl: attachment.cdnUrl as string,
572
+ workspacePath: attachment.workspacePath as string,
573
+ attachmentId: attachment.attachmentId as string,
574
+ contentVersion: attachment.contentVersion as string,
575
+ };
576
+ })
577
+ : [],
578
578
  };
579
579
  }),
580
580
  };
@@ -70,7 +70,7 @@ export interface FileViewProps {
70
70
 
71
71
  export type FileRenderer = ComponentType<FileViewProps>;
72
72
 
73
- function fileMeta(file: FileViewFile): string {
73
+ export function fileMeta(file: FileViewFile): string {
74
74
  if (file.size === undefined) return "Ready";
75
75
  if (file.size < 1024) return `${file.size} B`;
76
76
  if (file.size < 1024 * 1024) return `${Math.round(file.size / 1024)} KB`;
@@ -270,7 +270,7 @@ function ProductFileView({
270
270
  );
271
271
  }
272
272
 
273
- function ImagePreview({
273
+ export function ImagePreview({
274
274
  src,
275
275
  label,
276
276
  placement,
@@ -266,25 +266,26 @@ function actionUserMessage(prompt: string): UIMessage {
266
266
  }
267
267
 
268
268
  export interface DemoAskUserResponse {
269
- answers: Array<{ selections: string[]; text?: string }>;
269
+ answers: Array<{ selections: string[]; text: string; attachments: [] }>;
270
270
  }
271
271
 
272
272
  export const demoAskUserResponse: DemoAskUserResponse = {
273
273
  answers: [
274
- { selections: ["管理层"], text: "" },
275
- { selections: ["报告"], text: "" },
274
+ { selections: ["管理层"], text: "", attachments: [] },
275
+ { selections: ["报告"], text: "", attachments: [] },
276
276
  ],
277
277
  };
278
278
 
279
279
  const askUserQuestions = [
280
280
  {
281
281
  question: "这份建议优先面向哪个读者?",
282
+ responseType: "single_select",
282
283
  options: ["产品团队", "管理层", "设计团队"],
283
284
  },
284
285
  {
285
286
  question: "需要哪些交付物?",
287
+ responseType: "multi_select",
286
288
  options: ["报告", "演示文稿", "执行清单"],
287
- multiSelect: true,
288
289
  allowCustom: true,
289
290
  },
290
291
  ];
@@ -305,7 +306,8 @@ function askUserRequest(response?: DemoAskUserResponse): UIMessage {
305
306
  answers: response.answers.map((answer, index) => ({
306
307
  question: askUserQuestions[index]!.question,
307
308
  selections: answer.selections,
308
- text: answer.text?.trim() ?? "",
309
+ text: answer.text.trim(),
310
+ attachments: answer.attachments,
309
311
  })),
310
312
  },
311
313
  }
package/demo/fixtures.ts CHANGED
@@ -167,12 +167,13 @@ export const allPartsMessage: DemoMessage = {
167
167
  questions: [
168
168
  {
169
169
  question: "报告通过什么渠道发送?",
170
+ responseType: "single_select",
170
171
  options: ["Email", "Slack", "Both"],
171
172
  },
172
173
  {
173
174
  question: "需要包含哪些内容?",
175
+ responseType: "multi_select",
174
176
  options: ["定价", "功能", "动态"],
175
- multiSelect: true,
176
177
  allowCustom: true,
177
178
  },
178
179
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.2.0-alpha.48",
3
+ "version": "0.2.0-alpha.50",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -666,6 +666,20 @@ function CamelSchedule({
666
666
  );
667
667
  }
668
668
 
669
+ function askUserResponseType(question: Record<string, unknown>) {
670
+ if (
671
+ question.responseType === "text" ||
672
+ question.responseType === "single_select" ||
673
+ question.responseType === "multi_select" ||
674
+ question.responseType === "attachment"
675
+ ) return question.responseType;
676
+ if (question.kind === "attachment") return "attachment";
677
+ if (!Array.isArray(question.options) || question.options.length === 0) {
678
+ return "text";
679
+ }
680
+ return question.multiSelect === true ? "multi_select" : "single_select";
681
+ }
682
+
669
683
  function CamelAskUser({
670
684
  part,
671
685
  onAnswer,
@@ -689,9 +703,13 @@ function CamelAskUser({
689
703
  const answerAt = (index: number) =>
690
704
  answers[index] ?? { selections: [], text: "" };
691
705
  const resolvedSubmitted = settledAnswers.length > 0;
692
- const valid = questions.length > 0 && questions.every((_, index) =>
693
- answerAt(index).selections.length > 0 || answerAt(index).text.trim()
694
- );
706
+ const valid = questions.length > 0 && questions.every((question, index) => {
707
+ const responseType = askUserResponseType(question);
708
+ if (responseType === "attachment") return false;
709
+ if (responseType === "text") return Boolean(answerAt(index).text.trim());
710
+ return answerAt(index).selections.length > 0 ||
711
+ (question.allowCustom === true && Boolean(answerAt(index).text.trim()));
712
+ });
695
713
 
696
714
  return (
697
715
  <section
@@ -701,14 +719,18 @@ function CamelAskUser({
701
719
  >
702
720
  {questions.map((question, questionIndex) => {
703
721
  const prompt = String(question.question ?? "Choose an option");
704
- const attachmentQuestion = question.kind === "attachment";
722
+ const responseType = askUserResponseType(question);
723
+ const attachmentQuestion = responseType === "attachment";
724
+ const choiceQuestion = responseType === "single_select" ||
725
+ responseType === "multi_select";
705
726
  const options = Array.isArray(question.options)
706
727
  ? question.options.filter(
707
728
  (option): option is string => typeof option === "string",
708
729
  )
709
730
  : [];
710
- const multi = question.multiSelect === true;
711
- const allowCustom = question.allowCustom === true || options.length === 0;
731
+ const multi = responseType === "multi_select";
732
+ const allowCustom = responseType === "text" ||
733
+ (choiceQuestion && question.allowCustom === true);
712
734
  const settledAnswer = settledAnswers[questionIndex];
713
735
  const settledSelections = Array.isArray(settledAnswer?.selections)
714
736
  ? settledAnswer.selections.filter(
@@ -749,7 +771,7 @@ function CamelAskUser({
749
771
  ))}
750
772
  </div>
751
773
  )}
752
- {!attachmentQuestion && <div className="flex flex-wrap gap-2">
774
+ {choiceQuestion && <div className="flex flex-wrap gap-2">
753
775
  {options.map((option) => {
754
776
  const selected = settledAnswer
755
777
  ? settledSelections.includes(option)
@@ -821,13 +843,14 @@ function CamelAskUser({
821
843
  type="button"
822
844
  size="sm"
823
845
  disabled={resolvedSubmitted || !valid || questions.some((question) =>
824
- question.kind === "attachment"
846
+ askUserResponseType(question) === "attachment"
825
847
  )}
826
848
  onClick={() => {
827
849
  onAnswer({
828
850
  answers: questions.map((_, index) => ({
829
851
  selections: answerAt(index).selections,
830
852
  text: answerAt(index).text.trim(),
853
+ attachments: [],
831
854
  })),
832
855
  });
833
856
  }}
@@ -834,20 +834,37 @@ export function DashboardPart(data: DashboardData) {
834
834
 
835
835
  export interface AskQuestion {
836
836
  question: string;
837
+ responseType: "text" | "single_select" | "multi_select" | "attachment";
837
838
  options?: string[];
838
- multiSelect?: boolean;
839
839
  allowCustom?: boolean;
840
840
  }
841
841
 
842
+ function askQuestionResponseType(question: AskQuestion) {
843
+ if (question.responseType) return question.responseType;
844
+ const legacy = question as AskQuestion & {
845
+ kind?: string;
846
+ multiSelect?: boolean;
847
+ };
848
+ if (legacy.kind === "attachment") return "attachment";
849
+ if (!question.options?.length) return "text";
850
+ return legacy.multiSelect ? "multi_select" : "single_select";
851
+ }
852
+
853
+ interface AskAnswer {
854
+ selections: string[];
855
+ text: string;
856
+ attachments: unknown[];
857
+ }
858
+
842
859
  export function AskUserPart({
843
860
  questions,
844
861
  submittedAnswers = [],
845
862
  onSubmit,
846
863
  }: {
847
864
  questions: readonly AskQuestion[];
848
- submittedAnswers?: ReadonlyArray<{ selections: string[]; text?: string }>;
865
+ submittedAnswers?: ReadonlyArray<AskAnswer>;
849
866
  onSubmit?: (payload: {
850
- answers: Array<{ selections: string[]; text: string }>;
867
+ answers: AskAnswer[];
851
868
  }) => void | Promise<void>;
852
869
  }) {
853
870
  const [answers, setAnswers] = useState<Array<{
@@ -859,8 +876,13 @@ export function AskUserPart({
859
876
  const [inFlight, setInFlight] = useState(false);
860
877
  const resolvedSubmitted = submittedAnswers.length > 0;
861
878
  const valid = questions.length > 0 && questions.every(
862
- (_question, index) =>
863
- answerAt(index).selections.length > 0 || answerAt(index).text.trim(),
879
+ (question, index) => {
880
+ const responseType = askQuestionResponseType(question);
881
+ if (responseType === "attachment") return false;
882
+ if (responseType === "text") return Boolean(answerAt(index).text.trim());
883
+ return answerAt(index).selections.length > 0 ||
884
+ (question.allowCustom === true && Boolean(answerAt(index).text.trim()));
885
+ },
864
886
  );
865
887
  const toggle = (questionIndex: number, option: string) => {
866
888
  if (resolvedSubmitted || inFlight || !onSubmit) return;
@@ -871,7 +893,8 @@ export function AskUserPart({
871
893
  const answer = next[questionIndex]!;
872
894
  next[questionIndex] = {
873
895
  ...answer,
874
- selections: questions[questionIndex]!.multiSelect
896
+ selections: askQuestionResponseType(questions[questionIndex]!) ===
897
+ "multi_select"
875
898
  ? answer.selections.includes(option)
876
899
  ? answer.selections.filter((item) => item !== option)
877
900
  : [...answer.selections, option]
@@ -888,6 +911,7 @@ export function AskUserPart({
888
911
  answers: questions.map((_, index) => ({
889
912
  selections: answerAt(index).selections,
890
913
  text: answerAt(index).text.trim(),
914
+ attachments: [],
891
915
  })),
892
916
  });
893
917
  } catch {
@@ -900,63 +924,74 @@ export function AskUserPart({
900
924
  data-part-type="ask_user"
901
925
  data-state={resolvedSubmitted ? "submitted" : "pending"}
902
926
  >
903
- {questions.map((question, questionIndex) => (
904
- <fieldset
905
- key={`${questionIndex}:${question.question}`}
906
- disabled={resolvedSubmitted || inFlight || !onSubmit}
907
- >
908
- <legend>{question.question}</legend>
909
- <div>
910
- {(question.options ?? []).map((option) => {
911
- const selected = submittedAnswers[questionIndex]
912
- ? submittedAnswers[questionIndex]!.selections.includes(option)
913
- : answerAt(questionIndex).selections.includes(option);
914
- return (
915
- <button
916
- key={option}
917
- type="button"
918
- aria-pressed={selected}
919
- onClick={() => toggle(questionIndex, option)}
920
- >
921
- {question.multiSelect && <span>{selected && <CheckIcon size={12} />}</span>}
922
- {selected && !question.multiSelect && <CheckIcon size={14} />}
923
- {option}
924
- </button>
925
- );
926
- })}
927
- </div>
928
- {!resolvedSubmitted &&
929
- (question.allowCustom === true || !question.options?.length) && (
930
- <textarea
931
- value={answerAt(questionIndex).text}
932
- maxLength={2_000}
933
- aria-label={`Custom answer: ${question.question}`}
934
- onChange={(event) =>
935
- setAnswers((current) => {
936
- const next = questions.map((_, index) =>
937
- current[index] ?? { selections: [], text: "" }
938
- );
939
- next[questionIndex] = {
940
- ...next[questionIndex]!,
941
- text: event.currentTarget.value,
942
- };
943
- return next;
944
- })}
945
- placeholder={question.options?.length ? "Other" : "Type your answer"}
946
- />
927
+ {questions.map((question, questionIndex) => {
928
+ const responseType = askQuestionResponseType(question);
929
+ const choice = responseType === "single_select" ||
930
+ responseType === "multi_select";
931
+ return (
932
+ <fieldset
933
+ key={`${questionIndex}:${question.question}`}
934
+ disabled={resolvedSubmitted || inFlight || !onSubmit}
935
+ >
936
+ <legend>{question.question}</legend>
937
+ <div>
938
+ {choice && (question.options ?? []).map((option) => {
939
+ const selected = submittedAnswers[questionIndex]
940
+ ? submittedAnswers[questionIndex]!.selections.includes(option)
941
+ : answerAt(questionIndex).selections.includes(option);
942
+ return (
943
+ <button
944
+ key={option}
945
+ type="button"
946
+ aria-pressed={selected}
947
+ onClick={() => toggle(questionIndex, option)}
948
+ >
949
+ {responseType === "multi_select" &&
950
+ <span>{selected && <CheckIcon size={12} />}</span>}
951
+ {selected && responseType === "single_select" &&
952
+ <CheckIcon size={14} />}
953
+ {option}
954
+ </button>
955
+ );
956
+ })}
957
+ </div>
958
+ {!resolvedSubmitted &&
959
+ (responseType === "text" || question.allowCustom === true) && (
960
+ <textarea
961
+ value={answerAt(questionIndex).text}
962
+ maxLength={2_000}
963
+ aria-label={`Custom answer: ${question.question}`}
964
+ onChange={(event) =>
965
+ setAnswers((current) => {
966
+ const next = questions.map((_, index) =>
967
+ current[index] ?? { selections: [], text: "" }
968
+ );
969
+ next[questionIndex] = {
970
+ ...next[questionIndex]!,
971
+ text: event.currentTarget.value,
972
+ };
973
+ return next;
974
+ })}
975
+ placeholder={question.options?.length
976
+ ? "Other"
977
+ : "Type your answer"}
978
+ />
979
+ )}
980
+ {submittedAnswers[questionIndex] && (
981
+ <small>
982
+ {[
983
+ ...submittedAnswers[questionIndex]!.selections,
984
+ ...(submittedAnswers[questionIndex]!.text?.trim()
985
+ ? [submittedAnswers[questionIndex]!.text!.trim()]
986
+ : []),
987
+ ].join(" / ")}
988
+ </small>
947
989
  )}
948
- {submittedAnswers[questionIndex] && (
949
- <small>
950
- {[
951
- ...submittedAnswers[questionIndex]!.selections,
952
- ...(submittedAnswers[questionIndex]!.text?.trim()
953
- ? [submittedAnswers[questionIndex]!.text!.trim()]
954
- : []),
955
- ].join(" / ")}
956
- </small>
957
- )}
958
- </fieldset>
959
- ))}
990
+ {responseType === "attachment" && !resolvedSubmitted &&
991
+ <small>File upload is unavailable.</small>}
992
+ </fieldset>
993
+ );
994
+ })}
960
995
  {resolvedSubmitted ? (
961
996
  <div className="sb-ask-user__submitted">
962
997
  <CheckIcon size={15} />
@@ -1170,6 +1205,9 @@ export function DefaultPartRenderer({
1170
1205
  return {
1171
1206
  selections: strings(value.selections),
1172
1207
  text: typeof value.text === "string" ? value.text : "",
1208
+ attachments: Array.isArray(value.attachments)
1209
+ ? value.attachments
1210
+ : [],
1173
1211
  };
1174
1212
  })
1175
1213
  : [];