@springbrand/message-panel 0.2.0-alpha.45 → 0.2.0-alpha.47

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,14 @@ 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);
213
406
  const displayAnswerAt = (index: number): DraftAnswer => {
214
407
  if (outcome.kind !== "answered") return answerAt(index);
215
408
  const answer = outcome.answers[index];
409
+ if (!answer || !("selections" in answer)) {
410
+ return { selections: [], text: "" };
411
+ }
216
412
  return {
217
413
  selections: answer?.selections ?? [],
218
414
  text: answer?.text ?? "",
@@ -221,8 +417,14 @@ export function AskUserBlock({
221
417
  // 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
222
418
  const resolved = outcome.kind !== "pending" || inFlight;
223
419
  const valid = questions.length > 0 && questions.every(
224
- (_question, index) =>
225
- answerAt(index).selections.length > 0 || answerAt(index).text.trim(),
420
+ (question, index) => question.kind === "attachment"
421
+ ? Boolean(
422
+ attachmentField &&
423
+ attachmentField.attachments.length > 0 &&
424
+ !attachmentField.busy &&
425
+ !attachmentField.hasFailure
426
+ )
427
+ : answerAt(index).selections.length > 0 || Boolean(answerAt(index).text.trim()),
226
428
  );
227
429
  const buttonLabel = inFlight ? "Submitting…" : "Submit";
228
430
  const footnote = outcome.kind === "closed"
@@ -233,16 +435,33 @@ export function AskUserBlock({
233
435
 
234
436
  const submit = () => {
235
437
  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));
438
+ setSubmissionError(false);
439
+ try {
440
+ const response = {
441
+ answers: questions.map((question, index) =>
442
+ question.kind === "attachment"
443
+ ? { attachments: [...(attachmentField?.getParts() ?? [])] }
444
+ : {
445
+ selections: answerAt(index).selections,
446
+ text: answerAt(index).text.trim(),
447
+ }
448
+ ),
449
+ };
450
+ setInFlight(true);
451
+ // 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
452
+ void onRespond(call.toolCallId, response).then((ok) => {
453
+ if (!ok) {
454
+ setInFlight(false);
455
+ setSubmissionError(true);
456
+ }
457
+ }, () => {
458
+ setInFlight(false);
459
+ setSubmissionError(true);
460
+ });
461
+ } catch {
462
+ setInFlight(false);
463
+ setSubmissionError(true);
464
+ }
246
465
  };
247
466
 
248
467
  return (
@@ -254,6 +473,9 @@ export function AskUserBlock({
254
473
  <div className="flex flex-col gap-4 py-2.5">
255
474
  {outcome.kind !== "closed" && questions.map((question, questionIndex) => {
256
475
  const answer = displayAnswerAt(questionIndex);
476
+ const settledAttachmentAnswer = outcome.kind === "answered"
477
+ ? outcome.answers[questionIndex]
478
+ : undefined;
257
479
  const options = outcome.kind === "answered"
258
480
  ? [...new Set([...question.options, ...answer.selections])]
259
481
  : question.options;
@@ -265,7 +487,29 @@ export function AskUserBlock({
265
487
  <p className="m-0 text-[14px] leading-[1.4] font-normal text-kumo-default">
266
488
  {question.prompt}
267
489
  </p>
268
- {options.length > 0 && (
490
+ {question.kind === "attachment" && (
491
+ <AskUserAttachmentField
492
+ attachments={
493
+ settledAttachmentAnswer && "attachments" in settledAttachmentAnswer
494
+ ? settledAttachmentAnswer.attachments.map((attachment) => ({
495
+ id: attachment.attachmentId,
496
+ filename: attachment.name,
497
+ mediaType: attachment.mediaType,
498
+ size: attachment.size,
499
+ previewUrl: attachment.cdnUrl,
500
+ status: "ready" as const,
501
+ }))
502
+ : attachmentField?.attachments ?? []
503
+ }
504
+ disabled={resolved || !onRespond || !attachmentField}
505
+ busy={attachmentField?.busy ?? false}
506
+ hasFailure={attachmentField?.hasFailure ?? false}
507
+ onFilesSelected={attachmentField?.onFilesSelected ?? (() => undefined)}
508
+ onRemove={attachmentField?.onRemove ?? (() => undefined)}
509
+ onRetry={attachmentField?.onRetry ?? (() => undefined)}
510
+ />
511
+ )}
512
+ {question.kind === "ordinary" && options.length > 0 && (
269
513
  <div className="flex flex-wrap items-center gap-2">
270
514
  {options.map((option) => {
271
515
  const selected = answer.selections.includes(option);
@@ -297,7 +541,7 @@ export function AskUserBlock({
297
541
  })}
298
542
  </div>
299
543
  )}
300
- {question.allowCustom && (
544
+ {question.kind === "ordinary" && question.allowCustom && (
301
545
  <label className="flex w-full flex-col gap-2 text-[14px] leading-[1.4] text-kumo-default">
302
546
  <span>{question.options.length > 0 ? "Other" : "Answer"}</span>
303
547
  <input
@@ -315,7 +559,7 @@ export function AskUserBlock({
315
559
  />
316
560
  </label>
317
561
  )}
318
- {outcome.kind === "answered" &&
562
+ {question.kind === "ordinary" && outcome.kind === "answered" &&
319
563
  !question.allowCustom && answer.text.trim() && (
320
564
  <p className="m-0 text-[14px] leading-[1.4] text-kumo-subtle">
321
565
  {answer.text.trim()}
@@ -325,6 +569,7 @@ export function AskUserBlock({
325
569
  );
326
570
  })}
327
571
  {outcome.kind === "pending" && (
572
+ <>
328
573
  <button
329
574
  type="button"
330
575
  disabled={resolved || !onRespond || !valid}
@@ -333,6 +578,12 @@ export function AskUserBlock({
333
578
  >
334
579
  {buttonLabel}
335
580
  </button>
581
+ {submissionError && (
582
+ <p className="m-0 text-[12px] leading-4 text-kumo-danger" role="alert">
583
+ Could not add these files to the Workspace. Try submitting again.
584
+ </p>
585
+ )}
586
+ </>
336
587
  )}
337
588
  {outcome.kind === "answered" && (
338
589
  <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
  }
@@ -22,7 +22,10 @@ import {
22
22
  useRef,
23
23
  useState,
24
24
  type ComponentType,
25
+ type ReactNode,
25
26
  } from "react";
27
+ import { PhotoProvider, PhotoView } from "react-photo-view";
28
+ import "react-photo-view/dist/react-photo-view.css";
26
29
  import {
27
30
  PRODUCT_PRESETS,
28
31
  productTypeForMediaType,
@@ -238,6 +241,10 @@ function ProductFileView({
238
241
  >
239
242
  {content}
240
243
  </button>
244
+ ) : image ? (
245
+ <ImagePreview src={file.url!} label={label} placement={placement}>
246
+ {content}
247
+ </ImagePreview>
241
248
  ) : file.url && !video ? (
242
249
  <a
243
250
  href={file.url}
@@ -263,6 +270,35 @@ function ProductFileView({
263
270
  );
264
271
  }
265
272
 
273
+ function ImagePreview({
274
+ src,
275
+ label,
276
+ placement,
277
+ children,
278
+ }: {
279
+ src: string;
280
+ label: string;
281
+ placement: Exclude<FileViewPlacement, "composer">;
282
+ children: ReactNode;
283
+ }) {
284
+ return (
285
+ <PhotoProvider>
286
+ <PhotoView src={src}>
287
+ <button
288
+ type="button"
289
+ aria-label={`Preview ${label}`}
290
+ data-image-preview=""
291
+ data-placement={placement}
292
+ data-file-variant="product"
293
+ className="inline-block max-w-full cursor-zoom-in rounded-2xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
294
+ >
295
+ {children}
296
+ </button>
297
+ </PhotoView>
298
+ </PhotoProvider>
299
+ );
300
+ }
301
+
266
302
  /**
267
303
  * Cloud OS 的默认文件视图。宿主可通过 `renderFile` 在同一 seam 替换它。
268
304
  */
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.45",
3
+ "version": "0.2.0-alpha.47",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -36,6 +36,7 @@
36
36
  "lucide-react": "^1.24.0",
37
37
  "motion": "^13.1.0",
38
38
  "radix-ui": "^1.6.7",
39
+ "react-photo-view": "^1.2.7",
39
40
  "streamdown": "^2.5.0",
40
41
  "thinking-orbs": "0.2.0",
41
42
  "use-stick-to-bottom": "^1.1.6"
@@ -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) => ({