@springbrand/message-panel 0.1.3-alpha.3 → 0.1.3-alpha.30

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.
Files changed (39) hide show
  1. package/cloud-os/README.md +6 -6
  2. package/cloud-os/assets/followup-arrow.svg +3 -0
  3. package/cloud-os/assets/loading-corner.svg +3 -0
  4. package/cloud-os/assets/loading-mark.svg +16 -0
  5. package/cloud-os/assets/loading-spark.svg +3 -0
  6. package/cloud-os/assets/model-selected.svg +5 -0
  7. package/cloud-os/assets/thinking-spark.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +35 -0
  9. package/cloud-os/chat/activity-indicator.tsx +2 -2
  10. package/cloud-os/chat/cloud-os-chat-messages.tsx +374 -63
  11. package/cloud-os/chat/image-generation.tsx +76 -0
  12. package/cloud-os/chat/loading-state.tsx +25 -0
  13. package/cloud-os/chat/markdown-message.tsx +144 -41
  14. package/cloud-os/chat/rich-blocks.tsx +548 -246
  15. package/cloud-os/chat/tool-presentation.ts +35 -16
  16. package/cloud-os/chat/tool-rows.tsx +330 -85
  17. package/cloud-os/chat/transcript-model.ts +428 -56
  18. package/cloud-os/composer/cloud-os-chat-input.tsx +228 -142
  19. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  20. package/cloud-os/file-view.tsx +441 -0
  21. package/cloud-os/index.ts +35 -3
  22. package/cloud-os/layout/cloud-os-workspace-split.tsx +58 -15
  23. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  24. package/cloud-os/styles/cloud-os.css +100 -20
  25. package/demo/camel-chat-showcase.tsx +21 -13
  26. package/demo/chat-scenarios.ts +223 -43
  27. package/demo/cloud-os-chat-showcase.tsx +47 -14
  28. package/demo/fixtures.ts +4 -5
  29. package/demo/message-panel-gallery.tsx +16 -2
  30. package/package.json +3 -4
  31. package/src/camel/camel-chat-messages.tsx +78 -78
  32. package/src/camel/camel-tool-presentation.tsx +19 -15
  33. package/src/camel/camel-turn.ts +1 -17
  34. package/src/composer/composer-attachments.tsx +1 -1
  35. package/src/composer/composer-trigger-popover.tsx +1 -1
  36. package/src/composer/composer.tsx +2 -2
  37. package/src/contracts.ts +0 -2
  38. package/src/message-panel.tsx +1 -24
  39. package/src/parts/index.tsx +103 -64
@@ -5,7 +5,6 @@ import {
5
5
  CaretRight,
6
6
  Check,
7
7
  Copy,
8
- File as FileIcon,
9
8
  Terminal,
10
9
  } from "@phosphor-icons/react";
11
10
  import {
@@ -21,6 +20,7 @@ import { Tooltip } from "../primitives/tooltip";
21
20
  import { WorkshopIconButton } from "../primitives/workshop-controls";
22
21
  import {
23
22
  MarkdownMessage,
23
+ type CloudOsArtifactResolver,
24
24
  type CloudOsUrlResolver,
25
25
  } from "./markdown-message";
26
26
  import {
@@ -34,15 +34,28 @@ import {
34
34
  SuggestionsBlock,
35
35
  type ApprovalDecision,
36
36
  } from "./rich-blocks";
37
- import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
37
+ import {
38
+ ToolGroupRow,
39
+ WorkDescriptionRow,
40
+ WorkTraceDisclosure,
41
+ } from "./tool-rows";
38
42
  import { CloudOsActivityIndicator } from "./activity-indicator";
43
+ import { ProductGeneration } from "./image-generation";
44
+ import { GenerationLoader } from "./loading-state";
45
+ import { CapabilityChip } from "../capability-chip";
46
+ import {
47
+ renderFileView,
48
+ type FileRenderer,
49
+ } from "../file-view";
39
50
  import {
40
51
  buildCloudOsEntries,
41
52
  deriveTurnActivity,
42
53
  formatClockTime,
43
54
  formatFullTimestamp,
55
+ partitionAssistantBlocks,
44
56
  rhythmTopClass,
45
57
  type CloudOsEntry,
58
+ type CloudOsActionPresentation,
46
59
  } from "./transcript-model";
47
60
 
48
61
  export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
@@ -50,9 +63,11 @@ export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
50
63
  export interface CloudOsChatMessagesProps {
51
64
  messages: readonly UIMessage[];
52
65
  status: CloudOsChatStatus;
53
- isHydrating?: boolean;
66
+ /** 权威 Turn 生命周期;提供时替代本地 status 推断。 */
67
+ turnActive?: boolean;
54
68
  isRecovering?: boolean;
55
69
  recoveryStatusLabel?: string;
70
+ awaitingApproval?: boolean;
56
71
  /** 有 pending steer 时,活动占位说明「正在等当前步骤收尾」。 */
57
72
  hasPendingSteer?: boolean;
58
73
  /** 首帧就带着待发消息:此时不显示空会话欢迎语。 */
@@ -74,8 +89,14 @@ export interface CloudOsChatMessagesProps {
74
89
  input: Record<string, unknown>;
75
90
  disabled: boolean;
76
91
  }) => ReactNode;
92
+ /** 接管消息里的文件视图;未提供时使用包内 FileView。 */
93
+ renderFile?: FileRenderer;
77
94
  /** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
78
95
  resolveUrl?: CloudOsUrlResolver;
96
+ /** 把图片 URL 关联到宿主的持久产物。 */
97
+ resolveArtifactId?: CloudOsArtifactResolver;
98
+ /** 打开宿主拥有的产物视图。 */
99
+ onOpenArtifact?: (artifactId: string) => void;
79
100
  /** 消息区宽度是否收敛到 920px 居中(GadgetEditor 里叫 constrainChatWidth)。 */
80
101
  constrainWidth?: boolean;
81
102
  emptyTitle?: string;
@@ -92,6 +113,18 @@ export interface CloudOsChatMessagesProps {
92
113
  onCopy?: (text: string) => void;
93
114
  }
94
115
 
116
+ export function collapseEntriesWithNewNarrative(
117
+ expanded: ReadonlySet<string>,
118
+ previousNarrativeKeys: ReadonlySet<string>,
119
+ narrativeKeys: ReadonlySet<string>,
120
+ ): ReadonlySet<string> {
121
+ const next = new Set(expanded);
122
+ for (const key of narrativeKeys) {
123
+ if (!previousNarrativeKeys.has(key)) next.delete(key);
124
+ }
125
+ return next.size === expanded.size ? expanded : next;
126
+ }
127
+
95
128
  function useCopyAction(): {
96
129
  copied: string | null;
97
130
  copy: (text: string) => void;
@@ -118,43 +151,63 @@ function UserBubble({
118
151
  entry,
119
152
  onCopy,
120
153
  copied,
154
+ renderFile,
121
155
  resolveUrl,
156
+ resolveArtifactId,
157
+ onOpenArtifact,
122
158
  }: {
123
159
  entry: Extract<CloudOsEntry, { type: "user" }>;
124
160
  onCopy: (text: string) => void;
125
161
  copied: boolean;
162
+ renderFile?: FileRenderer;
126
163
  resolveUrl?: CloudOsUrlResolver;
164
+ resolveArtifactId?: CloudOsArtifactResolver;
165
+ onOpenArtifact?: (artifactId: string) => void;
127
166
  }) {
128
167
  return (
129
168
  <div className="group/message relative flex flex-col items-end">
169
+ {entry.capabilities.length > 0 && (
170
+ <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-1.5">
171
+ {entry.capabilities.map((capability) => (
172
+ <CapabilityChip
173
+ key={`${capability.kind}:${capability.name}`}
174
+ kind={capability.kind}
175
+ label={capability.label}
176
+ />
177
+ ))}
178
+ </div>
179
+ )}
130
180
  {entry.attachments.length > 0 && (
131
- <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-2">
132
- {entry.attachments.map((attachment, index) =>
133
- attachment.mediaType?.startsWith("image/") ? (
134
- <img
135
- key={`${attachment.url}:${index}`}
136
- src={attachment.url}
137
- alt={attachment.filename ?? "Attachment"}
138
- className="themed-thumbnail-shadow max-h-52 max-w-64 rounded-xl border border-kumo-line object-cover"
139
- />
181
+ <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap items-start justify-end gap-2">
182
+ {entry.attachments.map((attachment, index) => {
183
+ const view = renderFileView(renderFile, {
184
+ file: {
185
+ ...attachment,
186
+ url: resolveUrl?.(attachment.url) ?? attachment.url,
187
+ },
188
+ placement: "user-message",
189
+ });
190
+ return attachment.mediaType?.startsWith("image/") ? (
191
+ <Fragment key={`${attachment.url}:${index}`}>{view}</Fragment>
140
192
  ) : (
141
193
  <span
142
194
  key={`${attachment.url}:${index}`}
143
- className="inline-flex items-center gap-1.5 rounded-lg border border-kumo-line bg-kumo-elevated px-2.5 py-1.5 text-[12px] leading-4 text-kumo-subtle"
195
+ className="flex basis-full justify-end"
144
196
  >
145
- <FileIcon size={13} className="text-kumo-inactive" />
146
- {attachment.filename ?? "Attachment"}
197
+ {view}
147
198
  </span>
148
- ),
149
- )}
199
+ );
200
+ })}
150
201
  </div>
151
202
  )}
152
203
  {entry.text && (
153
- <div className="themed-user-bubble-shadow cos-markdown w-fit max-w-[min(680px,78%)] rounded-[24px] rounded-br-lg border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
154
- {/* pre-wrap 让用户消息里的单个换行渲染成硬换行 */}
155
- <div className="whitespace-pre-wrap">
156
- <MarkdownMessage message={entry.text} resolveUrl={resolveUrl} />
157
- </div>
204
+ <div className="themed-user-bubble-shadow cos-markdown cos-user-markdown w-fit max-w-[min(680px,78%)] rounded-[12px] border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
205
+ <MarkdownMessage
206
+ message={entry.text}
207
+ resolveUrl={resolveUrl}
208
+ resolveArtifactId={resolveArtifactId}
209
+ onOpenArtifact={onOpenArtifact}
210
+ />
158
211
  </div>
159
212
  )}
160
213
  <div className="mt-0.5 flex items-center justify-end gap-2 pr-1 text-[11px] leading-4 text-kumo-inactive opacity-0 transition-opacity duration-150 ease-out group-hover/message:opacity-100 group-focus-within/message:opacity-100">
@@ -181,12 +234,111 @@ function UserBubble({
181
234
  );
182
235
  }
183
236
 
237
+ function ActionPresentationBlock({
238
+ presentation,
239
+ failureCount = 1,
240
+ partial = false,
241
+ renderFile,
242
+ resolveUrl,
243
+ resolveArtifactId,
244
+ onOpenArtifact,
245
+ }: {
246
+ presentation: CloudOsActionPresentation;
247
+ failureCount?: number;
248
+ partial?: boolean;
249
+ renderFile?: FileRenderer;
250
+ resolveUrl?: CloudOsUrlResolver;
251
+ resolveArtifactId?: CloudOsArtifactResolver;
252
+ onOpenArtifact?: (artifactId: string) => void;
253
+ }) {
254
+ const { state } = presentation;
255
+ if (state === "ready") {
256
+ return (
257
+ <div
258
+ data-action-presentation={state}
259
+ role="status"
260
+ aria-label="Completed"
261
+ className="space-y-2"
262
+ >
263
+ {presentation.content.length === 0
264
+ ? <p className="m-0 text-[14px] leading-5 text-kumo-subtle">Completed</p>
265
+ : presentation.content.map((file, index) => {
266
+ const url = resolveUrl?.(file.url) ?? file.url;
267
+ const artifactId = resolveArtifactId?.(url);
268
+ return (
269
+ <Fragment key={`${file.url}:${index}`}>
270
+ {renderFileView(renderFile, {
271
+ file: {
272
+ ...file,
273
+ filename: "Generated file",
274
+ url,
275
+ },
276
+ placement: "assistant-message",
277
+ variant: "product",
278
+ onOpen: file.mediaType?.startsWith("image/") && artifactId && onOpenArtifact
279
+ ? () => onOpenArtifact(artifactId)
280
+ : undefined,
281
+ })}
282
+ </Fragment>
283
+ );
284
+ })}
285
+ </div>
286
+ );
287
+ }
288
+
289
+ const product = presentation.produces;
290
+ const productName = product?.type ?? "other";
291
+ const isFileProduct = productName !== "data" && productName !== "other";
292
+ const insufficientCredits = state === "failed" &&
293
+ presentation.failureCode === "insufficient_credits";
294
+ const body = state === "running"
295
+ ? isFileProduct
296
+ ? `Generating ${productName}${product?.mediaType ? ` (${product.mediaType})` : ""}`
297
+ : "Generating"
298
+ : state === "outcome_unknown"
299
+ ? "The provider may have completed this action. Check the status before trying again."
300
+ : insufficientCredits
301
+ ? `${failureCount} API call${failureCount === 1 ? " was" : "s were"} not started because there weren't enough Credits.`
302
+ : undefined;
303
+ if (state === "running") {
304
+ return (
305
+ <div
306
+ data-action-presentation={state}
307
+ data-product-type={productName}
308
+ role="status"
309
+ aria-label={body}
310
+ >
311
+ {isFileProduct
312
+ ? <ProductGeneration type={productName} mediaType={product?.mediaType} />
313
+ : <GenerationLoader />}
314
+ </div>
315
+ );
316
+ }
317
+ return (
318
+ <div
319
+ data-action-presentation={state}
320
+ role={state === "failed" ? "alert" : "status"}
321
+ className="inline-flex max-w-72 items-start rounded-xl border border-kumo-line bg-kumo-elevated px-3 py-2 text-[13px] leading-5"
322
+ >
323
+ <span>
324
+ <strong className="block font-medium text-kumo-default">
325
+ {insufficientCredits
326
+ ? partial ? "Partially completed" : "Not completed"
327
+ : state === "failed" ? "Action failed" : "Status unavailable"}
328
+ </strong>
329
+ {body && <span className="block text-kumo-subtle">{body}</span>}
330
+ </span>
331
+ </div>
332
+ );
333
+ }
334
+
184
335
  export function CloudOsChatMessages({
185
336
  messages,
186
337
  status,
187
- isHydrating = false,
338
+ turnActive,
188
339
  isRecovering = false,
189
340
  recoveryStatusLabel,
341
+ awaitingApproval = false,
190
342
  hasPendingSteer = false,
191
343
  startedWithPending = false,
192
344
  error,
@@ -194,7 +346,9 @@ export function CloudOsChatMessages({
194
346
  approvalIdOf,
195
347
  approvalsDisabled = false,
196
348
  renderApproval,
349
+ renderFile,
197
350
  resolveUrl,
351
+ resolveArtifactId,
198
352
  constrainWidth = true,
199
353
  emptyTitle = "What are we working on?",
200
354
  onRetry,
@@ -202,6 +356,7 @@ export function CloudOsChatMessages({
202
356
  onSuggestion,
203
357
  onRespond,
204
358
  onOpenSchedule,
359
+ onOpenArtifact,
205
360
  onCopy,
206
361
  }: CloudOsChatMessagesProps) {
207
362
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -210,11 +365,15 @@ export function CloudOsChatMessages({
210
365
  const [expandedToolCalls, setExpandedToolCalls] = useState<ReadonlySet<string>>(
211
366
  () => new Set(),
212
367
  );
368
+ const [expandedWorkEntries, setExpandedWorkEntries] = useState<
369
+ ReadonlySet<string>
370
+ >(() => new Set());
213
371
  const [expandedCompactions, setExpandedCompactions] = useState<
214
372
  ReadonlySet<string>
215
373
  >(() => new Set());
216
374
  const { copied, copy } = useCopyAction();
217
- const isActive = status === "submitted" || status === "streaming";
375
+ const isActive =
376
+ turnActive ?? (status === "submitted" || status === "streaming");
218
377
 
219
378
  const entries = useMemo(
220
379
  () =>
@@ -226,6 +385,31 @@ export function CloudOsChatMessages({
226
385
  }),
227
386
  [approvalIdOf, isActive, messages, showThinkingTraces],
228
387
  );
388
+ const narrativeEntryKeys = useMemo(
389
+ () =>
390
+ new Set(
391
+ entries.flatMap((entry) =>
392
+ entry.type === "assistant" &&
393
+ partitionAssistantBlocks(entry.blocks).content.some(
394
+ (block) => block.kind === "text",
395
+ )
396
+ ? [entry.key]
397
+ : [],
398
+ ),
399
+ ),
400
+ [entries],
401
+ );
402
+ const previousNarrativeEntryKeys = useRef<ReadonlySet<string>>(
403
+ narrativeEntryKeys,
404
+ );
405
+
406
+ useEffect(() => {
407
+ const previous = previousNarrativeEntryKeys.current;
408
+ previousNarrativeEntryKeys.current = narrativeEntryKeys;
409
+ setExpandedWorkEntries((expanded) =>
410
+ collapseEntriesWithNewNarrative(expanded, previous, narrativeEntryKeys),
411
+ );
412
+ }, [narrativeEntryKeys]);
229
413
 
230
414
  const entryTopClasses = useMemo(
231
415
  () =>
@@ -244,6 +428,15 @@ export function CloudOsChatMessages({
244
428
  });
245
429
  }, []);
246
430
 
431
+ const toggleWorkEntry = useCallback((key: string) => {
432
+ setExpandedWorkEntries((current) => {
433
+ const next = new Set(current);
434
+ if (next.has(key)) next.delete(key);
435
+ else next.add(key);
436
+ return next;
437
+ });
438
+ }, []);
439
+
247
440
  const toggleCompaction = useCallback((key: string) => {
248
441
  setExpandedCompactions((current) => {
249
442
  const next = new Set(current);
@@ -267,10 +460,13 @@ export function CloudOsChatMessages({
267
460
  element.scrollTo({ top: element.scrollHeight });
268
461
  }, [isRecovering, messages, status, showScrollButton]);
269
462
 
270
- // 判据是「尾巴有没有活口」,不是原文件的「本回合有没有出过内容」——
271
- // 后者会让「答完 ask_user 之后到模型下一个 chunk」这段完全没有指示,
272
- // 而卡片此时写着 Submitted,读起来就是卡死了。见 deriveTurnActivity。
273
- const activity = deriveTurnActivity(entries, { isActive, hasPendingSteer });
463
+ const activity = deriveTurnActivity(entries, {
464
+ isActive,
465
+ isRecovering,
466
+ recoveryStatusLabel,
467
+ awaitingApproval,
468
+ hasPendingSteer,
469
+ });
274
470
  const copyText = (text: string) => {
275
471
  copy(text);
276
472
  onCopy?.(text);
@@ -281,7 +477,7 @@ export function CloudOsChatMessages({
281
477
  className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-kumo-base"
282
478
  data-cloud-os-transcript=""
283
479
  data-status={status}
284
- aria-busy={isActive || isHydrating}
480
+ aria-busy={isActive || isRecovering}
285
481
  >
286
482
  <div
287
483
  ref={scrollRef}
@@ -296,7 +492,7 @@ export function CloudOsChatMessages({
296
492
  constrainWidth ? "mx-auto w-full max-w-[920px]" : ""
297
493
  }`}
298
494
  >
299
- {entries.length === 0 && !startedWithPending && !isHydrating && (
495
+ {entries.length === 0 && !startedWithPending && (
300
496
  <div className="flex min-h-56 items-center justify-center text-center text-[24px] leading-8 font-semibold tracking-[-0.4px] text-kumo-default">
301
497
  {emptyTitle}
302
498
  </div>
@@ -312,7 +508,10 @@ export function CloudOsChatMessages({
312
508
  entry={entry}
313
509
  onCopy={copyText}
314
510
  copied={copied === entry.text}
511
+ renderFile={renderFile}
315
512
  resolveUrl={resolveUrl}
513
+ resolveArtifactId={resolveArtifactId}
514
+ onOpenArtifact={onOpenArtifact}
316
515
  />
317
516
  </div>
318
517
  );
@@ -321,7 +520,7 @@ export function CloudOsChatMessages({
321
520
  if (entry.type === "slashCommand") {
322
521
  return (
323
522
  <div key={entry.key} className={`${topClass} flex flex-col items-end`}>
324
- <div className="themed-user-bubble-shadow w-fit max-w-[min(680px,78%)] rounded-[24px] rounded-br-lg border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-brand">
523
+ <div className="themed-user-bubble-shadow w-fit max-w-[min(680px,78%)] rounded-[12px] border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-brand">
325
524
  <span className="whitespace-pre-wrap font-mono">{entry.text}</span>
326
525
  </div>
327
526
  </div>
@@ -387,10 +586,12 @@ export function CloudOsChatMessages({
387
586
  <span className="h-px flex-1 bg-kumo-line" aria-hidden="true" />
388
587
  </div>
389
588
  {expanded && (
390
- <div className="themed-surface-inset cos-markdown mt-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3.5 text-[13px] leading-[19px]">
589
+ <div className="cos-markdown mt-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3.5 text-[13px] leading-[19px]">
391
590
  <MarkdownMessage
392
591
  message={entry.summary}
393
592
  resolveUrl={resolveUrl}
593
+ resolveArtifactId={resolveArtifactId}
594
+ onOpenArtifact={onOpenArtifact}
394
595
  />
395
596
  </div>
396
597
  )}
@@ -398,23 +599,107 @@ export function CloudOsChatMessages({
398
599
  );
399
600
  }
400
601
 
401
- const hasNarrative = entry.blocks.some(
602
+ const { work: workBlocks, content: contentBlocks } =
603
+ partitionAssistantBlocks(entry.blocks);
604
+ const workGroups = workBlocks.flatMap((block) =>
605
+ block.kind === "toolGroup" ? [block.group] : [],
606
+ );
607
+ const hasNarrative = contentBlocks.some(
402
608
  (block) => block.kind === "text",
403
609
  );
610
+ const insufficientFailures = contentBlocks.filter(
611
+ (block) => block.kind === "actionPresentation" &&
612
+ block.presentation.state === "failed" &&
613
+ block.presentation.failureCode === "insufficient_credits",
614
+ );
615
+ const firstInsufficientFailure = insufficientFailures[0];
616
+ const hasCompletedAction = contentBlocks.some(
617
+ (block) => block.kind === "actionPresentation" &&
618
+ block.presentation.state === "ready",
619
+ );
404
620
 
405
621
  return (
406
622
  <div
407
623
  key={entry.key}
408
- className={`${topClass} min-w-0 w-full max-w-[860px] space-y-2`}
624
+ className={`${topClass} min-w-0 w-full max-w-[860px]`}
409
625
  >
410
- <div className="group/agentMessage relative space-y-1.5">
411
- {entry.blocks.map((block) => {
626
+ <div className="group/agentMessage relative space-y-4">
627
+ {workBlocks.length > 0 && (
628
+ <WorkTraceDisclosure
629
+ durationMs={entry.durationMs}
630
+ groups={workGroups}
631
+ completed={!isActive}
632
+ open={expandedWorkEntries.has(entry.key)}
633
+ onToggle={() => toggleWorkEntry(entry.key)}
634
+ >
635
+ {workBlocks.map((block) => {
636
+ switch (block.kind) {
637
+ case "reasoning":
638
+ case "text":
639
+ return (
640
+ <WorkDescriptionRow
641
+ key={block.key}
642
+ text={block.text}
643
+ resolveUrl={resolveUrl}
644
+ />
645
+ );
646
+ case "toolGroup":
647
+ return (
648
+ <ToolGroupRow
649
+ key={block.key}
650
+ group={block.group}
651
+ open={expandedToolCalls.has(block.group.key)}
652
+ expandedKeys={expandedToolCalls}
653
+ onToggle={toggleToolCall}
654
+ />
655
+ );
656
+ case "plan":
657
+ return (
658
+ <PlanBlock
659
+ key={block.key}
660
+ steps={block.steps}
661
+ running={block.running}
662
+ defaultOpen={false}
663
+ showCollapsedSummary={false}
664
+ />
665
+ );
666
+ case "parallel":
667
+ return (
668
+ <ParallelBlock
669
+ key={block.key}
670
+ label={block.label}
671
+ tools={block.tools}
672
+ />
673
+ );
674
+ case "subagents":
675
+ return (
676
+ <SubAgentsBlock
677
+ key={block.key}
678
+ agents={block.agents}
679
+ />
680
+ );
681
+ default:
682
+ return null;
683
+ }
684
+ })}
685
+ </WorkTraceDisclosure>
686
+ )}
687
+
688
+ {contentBlocks.map((block) => {
412
689
  switch (block.kind) {
413
690
  case "reasoning":
414
691
  return (
415
- <ThinkingTraceRow key={block.key} reasoning={block.text} />
692
+ <WorkDescriptionRow
693
+ key={block.key}
694
+ text={block.text}
695
+ resolveUrl={resolveUrl}
696
+ />
416
697
  );
417
- case "text":
698
+ case "text": {
699
+ const streaming =
700
+ isActive &&
701
+ entryIndex === entries.length - 1 &&
702
+ block === entry.blocks.at(-1);
418
703
  return (
419
704
  <div
420
705
  key={block.key}
@@ -423,9 +708,13 @@ export function CloudOsChatMessages({
423
708
  <MarkdownMessage
424
709
  message={block.text}
425
710
  resolveUrl={resolveUrl}
711
+ resolveArtifactId={resolveArtifactId}
712
+ onOpenArtifact={onOpenArtifact}
713
+ streaming={streaming}
426
714
  />
427
715
  </div>
428
716
  );
717
+ }
429
718
  case "toolGroup":
430
719
  return (
431
720
  <ToolGroupRow
@@ -503,14 +792,54 @@ export function CloudOsChatMessages({
503
792
  <SubAgentsBlock key={block.key} agents={block.agents} />
504
793
  );
505
794
  case "image":
795
+ {
796
+ const url = resolveUrl?.(block.src) ?? block.src;
797
+ const artifactId = resolveArtifactId?.(url);
798
+ return (
799
+ <Fragment key={block.key}>
800
+ {renderFileView(renderFile, {
801
+ file: {
802
+ url,
803
+ filename: block.alt,
804
+ mediaType: block.mediaType ?? "image/*",
805
+ },
806
+ placement: "assistant-message",
807
+ onOpen: artifactId && onOpenArtifact
808
+ ? () => onOpenArtifact(artifactId)
809
+ : undefined,
810
+ })}
811
+ </Fragment>
812
+ );
813
+ }
814
+ case "file":
506
815
  return (
507
- <figure key={block.key} className="my-1">
508
- <img
509
- src={resolveUrl?.(block.src) ?? block.src}
510
- alt={block.alt}
511
- className="max-h-[28rem] max-w-full rounded-xl border border-kumo-line object-contain"
512
- />
513
- </figure>
816
+ <Fragment key={block.key}>
817
+ {renderFileView(renderFile, {
818
+ file: {
819
+ ...block.file,
820
+ url:
821
+ resolveUrl?.(block.file.url) ?? block.file.url,
822
+ },
823
+ placement: "assistant-message",
824
+ })}
825
+ </Fragment>
826
+ );
827
+ case "actionPresentation":
828
+ if (
829
+ block.presentation.failureCode === "insufficient_credits" &&
830
+ block !== firstInsufficientFailure
831
+ ) return null;
832
+ return (
833
+ <ActionPresentationBlock
834
+ key={block.key}
835
+ presentation={block.presentation}
836
+ failureCount={insufficientFailures.length}
837
+ partial={hasCompletedAction}
838
+ renderFile={renderFile}
839
+ resolveUrl={resolveUrl}
840
+ resolveArtifactId={resolveArtifactId}
841
+ onOpenArtifact={onOpenArtifact}
842
+ />
514
843
  );
515
844
  case "error":
516
845
  return (
@@ -571,14 +900,6 @@ export function CloudOsChatMessages({
571
900
  );
572
901
  })}
573
902
 
574
- {isRecovering && (
575
- <div className="mt-4 flex max-w-[860px] items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
576
- <span className="cos-thinking-shimmer">
577
- {recoveryStatusLabel ?? "Reconnecting…"}
578
- </span>
579
- </div>
580
- )}
581
-
582
903
  {activity && (
583
904
  <div className="mt-5 inline-flex px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
584
905
  <CloudOsActivityIndicator {...activity} />
@@ -617,16 +938,6 @@ export function CloudOsChatMessages({
617
938
  <ArrowDown size={15} />
618
939
  </WorkshopIconButton>
619
940
 
620
- {isHydrating && (
621
- <div className="absolute inset-0 z-30 grid place-items-center bg-kumo-base/90">
622
- <div className="flex flex-col items-center gap-3">
623
- <div className="h-6 w-6 animate-spin rounded-full border-2 border-kumo-brand border-t-transparent" />
624
- <p className="m-0 text-[13px] leading-[18px] text-kumo-subtle">
625
- Loading conversation…
626
- </p>
627
- </div>
628
- </div>
629
- )}
630
941
  </div>
631
942
  );
632
943
  }