@springbrand/message-panel 0.2.0-alpha.49 → 0.2.0-alpha.51

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,9 @@ 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
+ required: boolean;
150
+ responseType: "text" | "single_select" | "multi_select" | "attachment";
151
151
  }
152
152
 
153
153
  function questionsOf(input: Record<string, unknown>): AskQuestion[] {
@@ -159,14 +159,26 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
159
159
  typeof option === "string"
160
160
  )
161
161
  : [];
162
+ const responseType = question.responseType === "text" ||
163
+ question.responseType === "single_select" ||
164
+ question.responseType === "multi_select" ||
165
+ question.responseType === "attachment"
166
+ ? question.responseType
167
+ : question.kind === "attachment"
168
+ ? "attachment"
169
+ : options.length === 0
170
+ ? "text"
171
+ : question.multiSelect === true
172
+ ? "multi_select"
173
+ : "single_select";
162
174
  return {
163
175
  prompt: typeof question.question === "string"
164
176
  ? question.question
165
177
  : "Choose an option",
166
178
  options,
167
- multi: question.multiSelect === true,
168
- allowCustom: question.allowCustom === true || options.length === 0,
169
- kind: question.kind === "attachment" ? "attachment" : "ordinary",
179
+ allowCustom: responseType === "text" || question.allowCustom === true,
180
+ required: question.required === true,
181
+ responseType,
170
182
  };
171
183
  });
172
184
  }
@@ -199,13 +211,6 @@ export interface AskUserBlockProps {
199
211
  attachmentField?: AskUserAttachmentController;
200
212
  }
201
213
 
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
214
  function AttachmentImage({ src }: { src: string }) {
210
215
  const [failed, setFailed] = useState(false);
211
216
  useEffect(() => setFailed(false), [src]);
@@ -224,8 +229,6 @@ function AttachmentImage({ src }: { src: string }) {
224
229
  export function AskUserAttachmentField({
225
230
  attachments,
226
231
  disabled,
227
- busy,
228
- hasFailure,
229
232
  onFilesSelected,
230
233
  onRemove,
231
234
  onRetry,
@@ -272,7 +275,7 @@ export function AskUserAttachmentField({
272
275
  addFiles(files);
273
276
  }
274
277
  }}
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"
278
+ 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
279
  >
277
280
  <UploadSimple size={18} aria-hidden="true" />
278
281
  <span>Drop files here or choose files</span>
@@ -287,7 +290,7 @@ export function AskUserAttachmentField({
287
290
  ? `Uploading ${Math.round((attachment.progress ?? 0) * 100)}%`
288
291
  : attachment.status === "error"
289
292
  ? "Could not upload this file. Try again."
290
- : formatAttachmentSize(attachment.size);
293
+ : fileMeta({ size: attachment.size });
291
294
  const preview = (
292
295
  <span
293
296
  className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-kumo-control text-kumo-inactive themed-thumbnail-shadow"
@@ -365,11 +368,6 @@ export function AskUserAttachmentField({
365
368
  })}
366
369
  </div>
367
370
  )}
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
371
  </div>
374
372
  );
375
373
  }
@@ -386,7 +384,7 @@ export function AskUserBlock({
386
384
  const [answers, setAnswers] = useState<DraftAnswer[]>([]);
387
385
  const answerAt = (index: number): DraftAnswer =>
388
386
  answers[index] ?? { selections: [], text: "" };
389
- const toggle = (questionIndex: number, option: string, multi: boolean) =>
387
+ const toggle = (questionIndex: number, option: string) =>
390
388
  setAnswers((current) => {
391
389
  const next = questions.map((_, index) =>
392
390
  current[index] ?? { selections: [], text: "" }
@@ -394,11 +392,11 @@ export function AskUserBlock({
394
392
  const answer = next[questionIndex]!;
395
393
  next[questionIndex] = {
396
394
  ...answer,
397
- selections: !multi
398
- ? [option]
399
- : answer.selections.includes(option)
395
+ selections: questions[questionIndex]?.responseType === "multi_select"
396
+ ? answer.selections.includes(option)
400
397
  ? answer.selections.filter((value) => value !== option)
401
- : [...answer.selections, option],
398
+ : [...answer.selections, option]
399
+ : [option],
402
400
  };
403
401
  return next;
404
402
  });
@@ -416,7 +414,7 @@ export function AskUserBlock({
416
414
  const outcome = askUserOutcome(call);
417
415
  const responseTimerRef = useRef<number | null>(null);
418
416
  const hasAttachmentQuestion = questions.some(
419
- (question) => question.kind === "attachment",
417
+ (question) => question.responseType === "attachment",
420
418
  );
421
419
  const clearResponseTimer = () => {
422
420
  if (responseTimerRef.current === null) return;
@@ -430,7 +428,7 @@ export function AskUserBlock({
430
428
  const displayAnswerAt = (index: number): DraftAnswer => {
431
429
  if (outcome.kind !== "answered") return answerAt(index);
432
430
  const answer = outcome.answers[index];
433
- if (!answer || !("selections" in answer)) {
431
+ if (!answer) {
434
432
  return { selections: [], text: "" };
435
433
  }
436
434
  return {
@@ -440,16 +438,25 @@ export function AskUserBlock({
440
438
  };
441
439
  // 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
442
440
  const resolved = outcome.kind !== "pending" || inFlight;
443
- const valid = questions.length > 0 && questions.every(
444
- (question, index) => question.kind === "attachment"
445
- ? Boolean(
446
- attachmentField &&
447
- attachmentField.attachments.length > 0 &&
448
- !attachmentField.busy &&
449
- !attachmentField.hasFailure
450
- )
451
- : answerAt(index).selections.length > 0 || Boolean(answerAt(index).text.trim()),
452
- );
441
+ const valid = questions.length > 0 && questions.every((question, index) => {
442
+ const answer = answerAt(index);
443
+ const attachmentCount = question.responseType === "attachment"
444
+ ? attachmentField?.attachments.length ?? 0
445
+ : 0;
446
+ if (
447
+ !question.required && attachmentCount === 0 &&
448
+ answer.selections.length === 0 && !answer.text.trim()
449
+ ) return true;
450
+ if (question.responseType === "attachment") {
451
+ return Boolean(
452
+ attachmentCount > 0 && attachmentField &&
453
+ !attachmentField.busy && !attachmentField.hasFailure,
454
+ );
455
+ }
456
+ if (question.responseType === "text") return Boolean(answer.text.trim());
457
+ return answer.selections.length > 0 ||
458
+ (question.allowCustom && Boolean(answer.text.trim()));
459
+ });
453
460
  const buttonLabel = inFlight ? "Submitting…" : "Submit";
454
461
  const footnote = outcome.kind === "closed"
455
462
  ? outcome.reason === "user_replied_freeform"
@@ -463,12 +470,17 @@ export function AskUserBlock({
463
470
  try {
464
471
  const response = {
465
472
  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
- }
473
+ ({
474
+ selections: question.responseType === "attachment"
475
+ ? []
476
+ : answerAt(index).selections,
477
+ text: question.responseType === "attachment"
478
+ ? ""
479
+ : answerAt(index).text.trim(),
480
+ attachments: question.responseType === "attachment"
481
+ ? [...(attachmentField?.getParts() ?? [])]
482
+ : [],
483
+ })
472
484
  ),
473
485
  };
474
486
  setInFlight(true);
@@ -512,6 +524,8 @@ export function AskUserBlock({
512
524
  const options = outcome.kind === "answered"
513
525
  ? [...new Set([...question.options, ...answer.selections])]
514
526
  : question.options;
527
+ const choice = question.responseType === "single_select" ||
528
+ question.responseType === "multi_select";
515
529
  return (
516
530
  <div
517
531
  key={`${question.prompt}:${questionIndex}`}
@@ -519,11 +533,14 @@ export function AskUserBlock({
519
533
  >
520
534
  <p className="m-0 text-[14px] leading-[1.4] font-normal text-kumo-default">
521
535
  {question.prompt}
536
+ {!question.required && (
537
+ <span className="ml-1 text-kumo-inactive">Optional</span>
538
+ )}
522
539
  </p>
523
- {question.kind === "attachment" && (
540
+ {question.responseType === "attachment" && (
524
541
  <AskUserAttachmentField
525
542
  attachments={
526
- settledAttachmentAnswer && "attachments" in settledAttachmentAnswer
543
+ settledAttachmentAnswer
527
544
  ? settledAttachmentAnswer.attachments.map((attachment) => ({
528
545
  id: attachment.attachmentId,
529
546
  filename: attachment.name,
@@ -542,7 +559,7 @@ export function AskUserBlock({
542
559
  onRetry={attachmentField?.onRetry ?? (() => undefined)}
543
560
  />
544
561
  )}
545
- {question.kind === "ordinary" && options.length > 0 && (
562
+ {choice && options.length > 0 && (
546
563
  <div className="flex flex-wrap items-center gap-2">
547
564
  {options.map((option) => {
548
565
  const selected = answer.selections.includes(option);
@@ -552,8 +569,7 @@ export function AskUserBlock({
552
569
  type="button"
553
570
  aria-pressed={selected}
554
571
  disabled={resolved || !onRespond}
555
- onClick={() =>
556
- toggle(questionIndex, option, question.multi)}
572
+ onClick={() => toggle(questionIndex, option)}
557
573
  style={selected
558
574
  ? {
559
575
  background:
@@ -574,7 +590,8 @@ export function AskUserBlock({
574
590
  })}
575
591
  </div>
576
592
  )}
577
- {question.kind === "ordinary" && question.allowCustom && (
593
+ {(question.responseType === "text" ||
594
+ (choice && question.allowCustom)) && (
578
595
  <label className="flex w-full flex-col gap-2 text-[14px] leading-[1.4] text-kumo-default">
579
596
  <span>{question.options.length > 0 ? "Other" : "Answer"}</span>
580
597
  <input
@@ -592,7 +609,8 @@ export function AskUserBlock({
592
609
  />
593
610
  </label>
594
611
  )}
595
- {question.kind === "ordinary" && outcome.kind === "answered" &&
612
+ {question.responseType !== "attachment" &&
613
+ outcome.kind === "answered" &&
596
614
  !question.allowCustom && answer.text.trim() && (
597
615
  <p className="m-0 text-[14px] leading-[1.4] text-kumo-subtle">
598
616
  {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.51",
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,17 @@ 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
+ const answer = answerAt(index);
709
+ if (
710
+ answer.selections.length === 0 && !answer.text.trim()
711
+ ) return question.required !== true;
712
+ if (responseType === "attachment") return false;
713
+ if (responseType === "text") return Boolean(answer.text.trim());
714
+ return answer.selections.length > 0 ||
715
+ (question.allowCustom === true && Boolean(answer.text.trim()));
716
+ });
695
717
 
696
718
  return (
697
719
  <section
@@ -701,14 +723,18 @@ function CamelAskUser({
701
723
  >
702
724
  {questions.map((question, questionIndex) => {
703
725
  const prompt = String(question.question ?? "Choose an option");
704
- const attachmentQuestion = question.kind === "attachment";
726
+ const responseType = askUserResponseType(question);
727
+ const attachmentQuestion = responseType === "attachment";
728
+ const choiceQuestion = responseType === "single_select" ||
729
+ responseType === "multi_select";
705
730
  const options = Array.isArray(question.options)
706
731
  ? question.options.filter(
707
732
  (option): option is string => typeof option === "string",
708
733
  )
709
734
  : [];
710
- const multi = question.multiSelect === true;
711
- const allowCustom = question.allowCustom === true || options.length === 0;
735
+ const multi = responseType === "multi_select";
736
+ const allowCustom = responseType === "text" ||
737
+ (choiceQuestion && question.allowCustom === true);
712
738
  const settledAnswer = settledAnswers[questionIndex];
713
739
  const settledSelections = Array.isArray(settledAnswer?.selections)
714
740
  ? settledAnswer.selections.filter(
@@ -723,7 +749,12 @@ function CamelAskUser({
723
749
  : [];
724
750
  return (
725
751
  <div className="space-y-2" key={`${prompt}:${questionIndex}`}>
726
- <p className="text-sm text-foreground">{prompt}</p>
752
+ <p className="text-sm text-foreground">
753
+ {prompt}
754
+ {question.required !== true && (
755
+ <span className="ml-1 text-muted-foreground">Optional</span>
756
+ )}
757
+ </p>
727
758
  {attachmentQuestion && settledAttachments.length === 0 && (
728
759
  <p className="text-xs text-muted-foreground">
729
760
  File upload is unavailable in this preview.
@@ -749,7 +780,7 @@ function CamelAskUser({
749
780
  ))}
750
781
  </div>
751
782
  )}
752
- {!attachmentQuestion && <div className="flex flex-wrap gap-2">
783
+ {choiceQuestion && <div className="flex flex-wrap gap-2">
753
784
  {options.map((option) => {
754
785
  const selected = settledAnswer
755
786
  ? settledSelections.includes(option)
@@ -820,14 +851,13 @@ function CamelAskUser({
820
851
  <Button
821
852
  type="button"
822
853
  size="sm"
823
- disabled={resolvedSubmitted || !valid || questions.some((question) =>
824
- question.kind === "attachment"
825
- )}
854
+ disabled={resolvedSubmitted || !valid}
826
855
  onClick={() => {
827
856
  onAnswer({
828
857
  answers: questions.map((_, index) => ({
829
858
  selections: answerAt(index).selections,
830
859
  text: answerAt(index).text.trim(),
860
+ attachments: [],
831
861
  })),
832
862
  });
833
863
  }}
@@ -834,9 +834,27 @@ 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
+ required?: boolean;
841
+ }
842
+
843
+ function askQuestionResponseType(question: AskQuestion) {
844
+ if (question.responseType) return question.responseType;
845
+ const legacy = question as AskQuestion & {
846
+ kind?: string;
847
+ multiSelect?: boolean;
848
+ };
849
+ if (legacy.kind === "attachment") return "attachment";
850
+ if (!question.options?.length) return "text";
851
+ return legacy.multiSelect ? "multi_select" : "single_select";
852
+ }
853
+
854
+ interface AskAnswer {
855
+ selections: string[];
856
+ text: string;
857
+ attachments: unknown[];
840
858
  }
841
859
 
842
860
  export function AskUserPart({
@@ -845,9 +863,9 @@ export function AskUserPart({
845
863
  onSubmit,
846
864
  }: {
847
865
  questions: readonly AskQuestion[];
848
- submittedAnswers?: ReadonlyArray<{ selections: string[]; text?: string }>;
866
+ submittedAnswers?: ReadonlyArray<AskAnswer>;
849
867
  onSubmit?: (payload: {
850
- answers: Array<{ selections: string[]; text: string }>;
868
+ answers: AskAnswer[];
851
869
  }) => void | Promise<void>;
852
870
  }) {
853
871
  const [answers, setAnswers] = useState<Array<{
@@ -859,8 +877,17 @@ export function AskUserPart({
859
877
  const [inFlight, setInFlight] = useState(false);
860
878
  const resolvedSubmitted = submittedAnswers.length > 0;
861
879
  const valid = questions.length > 0 && questions.every(
862
- (_question, index) =>
863
- answerAt(index).selections.length > 0 || answerAt(index).text.trim(),
880
+ (question, index) => {
881
+ const responseType = askQuestionResponseType(question);
882
+ const answer = answerAt(index);
883
+ if (
884
+ answer.selections.length === 0 && !answer.text.trim()
885
+ ) return question.required !== true;
886
+ if (responseType === "attachment") return false;
887
+ if (responseType === "text") return Boolean(answer.text.trim());
888
+ return answer.selections.length > 0 ||
889
+ (question.allowCustom === true && Boolean(answer.text.trim()));
890
+ },
864
891
  );
865
892
  const toggle = (questionIndex: number, option: string) => {
866
893
  if (resolvedSubmitted || inFlight || !onSubmit) return;
@@ -871,7 +898,8 @@ export function AskUserPart({
871
898
  const answer = next[questionIndex]!;
872
899
  next[questionIndex] = {
873
900
  ...answer,
874
- selections: questions[questionIndex]!.multiSelect
901
+ selections: askQuestionResponseType(questions[questionIndex]!) ===
902
+ "multi_select"
875
903
  ? answer.selections.includes(option)
876
904
  ? answer.selections.filter((item) => item !== option)
877
905
  : [...answer.selections, option]
@@ -888,6 +916,7 @@ export function AskUserPart({
888
916
  answers: questions.map((_, index) => ({
889
917
  selections: answerAt(index).selections,
890
918
  text: answerAt(index).text.trim(),
919
+ attachments: [],
891
920
  })),
892
921
  });
893
922
  } catch {
@@ -900,63 +929,77 @@ export function AskUserPart({
900
929
  data-part-type="ask_user"
901
930
  data-state={resolvedSubmitted ? "submitted" : "pending"}
902
931
  >
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
- />
932
+ {questions.map((question, questionIndex) => {
933
+ const responseType = askQuestionResponseType(question);
934
+ const choice = responseType === "single_select" ||
935
+ responseType === "multi_select";
936
+ return (
937
+ <fieldset
938
+ key={`${questionIndex}:${question.question}`}
939
+ disabled={resolvedSubmitted || inFlight || !onSubmit}
940
+ >
941
+ <legend>
942
+ {question.question}
943
+ {question.required !== true && <span> Optional</span>}
944
+ </legend>
945
+ <div>
946
+ {choice && (question.options ?? []).map((option) => {
947
+ const selected = submittedAnswers[questionIndex]
948
+ ? submittedAnswers[questionIndex]!.selections.includes(option)
949
+ : answerAt(questionIndex).selections.includes(option);
950
+ return (
951
+ <button
952
+ key={option}
953
+ type="button"
954
+ aria-pressed={selected}
955
+ onClick={() => toggle(questionIndex, option)}
956
+ >
957
+ {responseType === "multi_select" &&
958
+ <span>{selected && <CheckIcon size={12} />}</span>}
959
+ {selected && responseType === "single_select" &&
960
+ <CheckIcon size={14} />}
961
+ {option}
962
+ </button>
963
+ );
964
+ })}
965
+ </div>
966
+ {!resolvedSubmitted &&
967
+ (responseType === "text" || question.allowCustom === true) && (
968
+ <textarea
969
+ value={answerAt(questionIndex).text}
970
+ maxLength={2_000}
971
+ aria-label={`Custom answer: ${question.question}`}
972
+ onChange={(event) =>
973
+ setAnswers((current) => {
974
+ const next = questions.map((_, index) =>
975
+ current[index] ?? { selections: [], text: "" }
976
+ );
977
+ next[questionIndex] = {
978
+ ...next[questionIndex]!,
979
+ text: event.currentTarget.value,
980
+ };
981
+ return next;
982
+ })}
983
+ placeholder={question.options?.length
984
+ ? "Other"
985
+ : "Type your answer"}
986
+ />
987
+ )}
988
+ {submittedAnswers[questionIndex] && (
989
+ <small>
990
+ {[
991
+ ...submittedAnswers[questionIndex]!.selections,
992
+ ...(submittedAnswers[questionIndex]!.text?.trim()
993
+ ? [submittedAnswers[questionIndex]!.text!.trim()]
994
+ : []),
995
+ ].join(" / ")}
996
+ </small>
947
997
  )}
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
- ))}
998
+ {responseType === "attachment" && !resolvedSubmitted &&
999
+ <small>File upload is unavailable.</small>}
1000
+ </fieldset>
1001
+ );
1002
+ })}
960
1003
  {resolvedSubmitted ? (
961
1004
  <div className="sb-ask-user__submitted">
962
1005
  <CheckIcon size={15} />
@@ -1170,6 +1213,9 @@ export function DefaultPartRenderer({
1170
1213
  return {
1171
1214
  selections: strings(value.selections),
1172
1215
  text: typeof value.text === "string" ? value.text : "",
1216
+ attachments: Array.isArray(value.attachments)
1217
+ ? value.attachments
1218
+ : [],
1173
1219
  };
1174
1220
  })
1175
1221
  : [];