@springbrand/message-panel 0.2.0-alpha.49 → 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,7 +21,7 @@ import {
21
21
  CollapsibleTrigger,
22
22
  } from "../primitives/collapsible";
23
23
  import { Tooltip } from "../primitives/tooltip";
24
- import { ImagePreview } from "../file-view";
24
+ import { fileMeta, ImagePreview } from "../file-view";
25
25
  import { WorkshopButton } from "../primitives/workshop-controls";
26
26
  import { MarkdownMessage } from "./markdown-message";
27
27
  import { collapsePanel } from "./surfaces";
@@ -145,9 +145,8 @@ export function PlanBlock({
145
145
  interface AskQuestion {
146
146
  prompt: string;
147
147
  options: string[];
148
- multi: boolean;
149
148
  allowCustom: boolean;
150
- kind: "ordinary" | "attachment";
149
+ responseType: "text" | "single_select" | "multi_select" | "attachment";
151
150
  }
152
151
 
153
152
  function questionsOf(input: Record<string, unknown>): AskQuestion[] {
@@ -159,14 +158,25 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
159
158
  typeof option === "string"
160
159
  )
161
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";
162
173
  return {
163
174
  prompt: typeof question.question === "string"
164
175
  ? question.question
165
176
  : "Choose an option",
166
177
  options,
167
- multi: question.multiSelect === true,
168
- allowCustom: question.allowCustom === true || options.length === 0,
169
- kind: question.kind === "attachment" ? "attachment" : "ordinary",
178
+ allowCustom: responseType === "text" || question.allowCustom === true,
179
+ responseType,
170
180
  };
171
181
  });
172
182
  }
@@ -199,13 +209,6 @@ export interface AskUserBlockProps {
199
209
  attachmentField?: AskUserAttachmentController;
200
210
  }
201
211
 
202
- function formatAttachmentSize(size: number | undefined): string {
203
- if (size === undefined) return "Ready";
204
- if (size < 1_024) return `${size} B`;
205
- if (size < 1_024 * 1_024) return `${Math.round(size / 1_024)} KB`;
206
- return `${(size / 1_024 / 1_024).toFixed(1)} MB`;
207
- }
208
-
209
212
  function AttachmentImage({ src }: { src: string }) {
210
213
  const [failed, setFailed] = useState(false);
211
214
  useEffect(() => setFailed(false), [src]);
@@ -224,8 +227,6 @@ function AttachmentImage({ src }: { src: string }) {
224
227
  export function AskUserAttachmentField({
225
228
  attachments,
226
229
  disabled,
227
- busy,
228
- hasFailure,
229
230
  onFilesSelected,
230
231
  onRemove,
231
232
  onRetry,
@@ -272,7 +273,7 @@ export function AskUserAttachmentField({
272
273
  addFiles(files);
273
274
  }
274
275
  }}
275
- 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"
276
277
  >
277
278
  <UploadSimple size={18} aria-hidden="true" />
278
279
  <span>Drop files here or choose files</span>
@@ -287,7 +288,7 @@ export function AskUserAttachmentField({
287
288
  ? `Uploading ${Math.round((attachment.progress ?? 0) * 100)}%`
288
289
  : attachment.status === "error"
289
290
  ? "Could not upload this file. Try again."
290
- : formatAttachmentSize(attachment.size);
291
+ : fileMeta({ size: attachment.size });
291
292
  const preview = (
292
293
  <span
293
294
  className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-kumo-control text-kumo-inactive themed-thumbnail-shadow"
@@ -365,11 +366,6 @@ export function AskUserAttachmentField({
365
366
  })}
366
367
  </div>
367
368
  )}
368
- {(busy || hasFailure) && attachments.length === 0 && (
369
- <span role={hasFailure ? "alert" : "status"} className="text-[11px] text-kumo-inactive">
370
- {hasFailure ? "Could not upload this file. Try again." : "Uploading"}
371
- </span>
372
- )}
373
369
  </div>
374
370
  );
375
371
  }
@@ -386,7 +382,7 @@ export function AskUserBlock({
386
382
  const [answers, setAnswers] = useState<DraftAnswer[]>([]);
387
383
  const answerAt = (index: number): DraftAnswer =>
388
384
  answers[index] ?? { selections: [], text: "" };
389
- const toggle = (questionIndex: number, option: string, multi: boolean) =>
385
+ const toggle = (questionIndex: number, option: string) =>
390
386
  setAnswers((current) => {
391
387
  const next = questions.map((_, index) =>
392
388
  current[index] ?? { selections: [], text: "" }
@@ -394,11 +390,11 @@ export function AskUserBlock({
394
390
  const answer = next[questionIndex]!;
395
391
  next[questionIndex] = {
396
392
  ...answer,
397
- selections: !multi
398
- ? [option]
399
- : answer.selections.includes(option)
393
+ selections: questions[questionIndex]?.responseType === "multi_select"
394
+ ? answer.selections.includes(option)
400
395
  ? answer.selections.filter((value) => value !== option)
401
- : [...answer.selections, option],
396
+ : [...answer.selections, option]
397
+ : [option],
402
398
  };
403
399
  return next;
404
400
  });
@@ -416,7 +412,7 @@ export function AskUserBlock({
416
412
  const outcome = askUserOutcome(call);
417
413
  const responseTimerRef = useRef<number | null>(null);
418
414
  const hasAttachmentQuestion = questions.some(
419
- (question) => question.kind === "attachment",
415
+ (question) => question.responseType === "attachment",
420
416
  );
421
417
  const clearResponseTimer = () => {
422
418
  if (responseTimerRef.current === null) return;
@@ -430,7 +426,7 @@ export function AskUserBlock({
430
426
  const displayAnswerAt = (index: number): DraftAnswer => {
431
427
  if (outcome.kind !== "answered") return answerAt(index);
432
428
  const answer = outcome.answers[index];
433
- if (!answer || !("selections" in answer)) {
429
+ if (!answer) {
434
430
  return { selections: [], text: "" };
435
431
  }
436
432
  return {
@@ -441,14 +437,17 @@ export function AskUserBlock({
441
437
  // 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
442
438
  const resolved = outcome.kind !== "pending" || inFlight;
443
439
  const valid = questions.length > 0 && questions.every(
444
- (question, index) => question.kind === "attachment"
440
+ (question, index) => question.responseType === "attachment"
445
441
  ? Boolean(
446
442
  attachmentField &&
447
443
  attachmentField.attachments.length > 0 &&
448
444
  !attachmentField.busy &&
449
445
  !attachmentField.hasFailure
450
446
  )
451
- : 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())),
452
451
  );
453
452
  const buttonLabel = inFlight ? "Submitting…" : "Submit";
454
453
  const footnote = outcome.kind === "closed"
@@ -463,12 +462,17 @@ export function AskUserBlock({
463
462
  try {
464
463
  const response = {
465
464
  answers: questions.map((question, index) =>
466
- question.kind === "attachment"
467
- ? { attachments: [...(attachmentField?.getParts() ?? [])] }
468
- : {
469
- selections: answerAt(index).selections,
470
- text: answerAt(index).text.trim(),
471
- }
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
+ })
472
476
  ),
473
477
  };
474
478
  setInFlight(true);
@@ -512,6 +516,8 @@ export function AskUserBlock({
512
516
  const options = outcome.kind === "answered"
513
517
  ? [...new Set([...question.options, ...answer.selections])]
514
518
  : question.options;
519
+ const choice = question.responseType === "single_select" ||
520
+ question.responseType === "multi_select";
515
521
  return (
516
522
  <div
517
523
  key={`${question.prompt}:${questionIndex}`}
@@ -520,10 +526,10 @@ export function AskUserBlock({
520
526
  <p className="m-0 text-[14px] leading-[1.4] font-normal text-kumo-default">
521
527
  {question.prompt}
522
528
  </p>
523
- {question.kind === "attachment" && (
529
+ {question.responseType === "attachment" && (
524
530
  <AskUserAttachmentField
525
531
  attachments={
526
- settledAttachmentAnswer && "attachments" in settledAttachmentAnswer
532
+ settledAttachmentAnswer
527
533
  ? settledAttachmentAnswer.attachments.map((attachment) => ({
528
534
  id: attachment.attachmentId,
529
535
  filename: attachment.name,
@@ -542,7 +548,7 @@ export function AskUserBlock({
542
548
  onRetry={attachmentField?.onRetry ?? (() => undefined)}
543
549
  />
544
550
  )}
545
- {question.kind === "ordinary" && options.length > 0 && (
551
+ {choice && options.length > 0 && (
546
552
  <div className="flex flex-wrap items-center gap-2">
547
553
  {options.map((option) => {
548
554
  const selected = answer.selections.includes(option);
@@ -552,8 +558,7 @@ export function AskUserBlock({
552
558
  type="button"
553
559
  aria-pressed={selected}
554
560
  disabled={resolved || !onRespond}
555
- onClick={() =>
556
- toggle(questionIndex, option, question.multi)}
561
+ onClick={() => toggle(questionIndex, option)}
557
562
  style={selected
558
563
  ? {
559
564
  background:
@@ -574,7 +579,8 @@ export function AskUserBlock({
574
579
  })}
575
580
  </div>
576
581
  )}
577
- {question.kind === "ordinary" && question.allowCustom && (
582
+ {(question.responseType === "text" ||
583
+ (choice && question.allowCustom)) && (
578
584
  <label className="flex w-full flex-col gap-2 text-[14px] leading-[1.4] text-kumo-default">
579
585
  <span>{question.options.length > 0 ? "Other" : "Answer"}</span>
580
586
  <input
@@ -592,7 +598,8 @@ export function AskUserBlock({
592
598
  />
593
599
  </label>
594
600
  )}
595
- {question.kind === "ordinary" && outcome.kind === "answered" &&
601
+ {question.responseType !== "attachment" &&
602
+ outcome.kind === "answered" &&
596
603
  !question.allowCustom && answer.text.trim() && (
597
604
  <p className="m-0 text-[14px] leading-[1.4] text-kumo-subtle">
598
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`;
@@ -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.49",
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
  : [];