@springbrand/message-panel 0.1.3-alpha.8 → 0.2.0-alpha.37

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 (67) hide show
  1. package/cloud-os/README.md +4 -3
  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-star.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +1 -1
  9. package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
  10. package/cloud-os/chat/code-runner.tsx +106 -0
  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/range.ts +8 -0
  15. package/cloud-os/chat/rich-blocks.tsx +285 -227
  16. package/cloud-os/chat/surfaces.tsx +90 -0
  17. package/cloud-os/chat/tool-call.tsx +94 -0
  18. package/cloud-os/chat/tool-presentation.ts +31 -22
  19. package/cloud-os/chat/tool-rows.tsx +195 -143
  20. package/cloud-os/chat/tool-timeline.tsx +123 -0
  21. package/cloud-os/chat/transcript-model.ts +258 -20
  22. package/cloud-os/chat/web-search.tsx +135 -0
  23. package/cloud-os/composer/cloud-os-chat-input.tsx +38 -88
  24. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  25. package/cloud-os/file-view.tsx +195 -12
  26. package/cloud-os/index.ts +24 -3
  27. package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
  28. package/cloud-os/primitives/collapsible.tsx +21 -0
  29. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  30. package/cloud-os/styles/cloud-os.css +132 -1
  31. package/demo/camel-chat-showcase.tsx +21 -13
  32. package/demo/chat-scenarios.ts +214 -40
  33. package/demo/cloud-os-chat-showcase.tsx +37 -14
  34. package/demo/fixtures.ts +4 -5
  35. package/demo/message-panel-gallery.tsx +16 -2
  36. package/package.json +8 -4
  37. package/springbrand-pet/LICENSE.bloub +21 -0
  38. package/springbrand-pet/bot/cycles.test.ts +221 -0
  39. package/springbrand-pet/bot/cycles.ts +225 -0
  40. package/springbrand-pet/bot/decor.ts +285 -0
  41. package/springbrand-pet/bot/engine.test.ts +543 -0
  42. package/springbrand-pet/bot/engine.ts +558 -0
  43. package/springbrand-pet/bot/expressions.test.ts +135 -0
  44. package/springbrand-pet/bot/expressions.ts +192 -0
  45. package/springbrand-pet/bot/eyefit.ts +455 -0
  46. package/springbrand-pet/bot/face.test.ts +101 -0
  47. package/springbrand-pet/bot/face.ts +179 -0
  48. package/springbrand-pet/bot/math.ts +42 -0
  49. package/springbrand-pet/bot/profiles.ts +22 -0
  50. package/springbrand-pet/bot/repere.ts +31 -0
  51. package/springbrand-pet/bot/shape.test.ts +97 -0
  52. package/springbrand-pet/bot/shape.ts +310 -0
  53. package/springbrand-pet/bot/skins.test.ts +325 -0
  54. package/springbrand-pet/bot/skins.ts +161 -0
  55. package/springbrand-pet/bot/states.ts +616 -0
  56. package/springbrand-pet/drag.test.ts +19 -0
  57. package/springbrand-pet/index.tsx +569 -0
  58. package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  59. package/springbrand-pet/vitest.config.ts +9 -0
  60. package/src/camel/camel-chat-messages.tsx +78 -78
  61. package/src/camel/camel-tool-presentation.tsx +19 -15
  62. package/src/camel/camel-turn.ts +1 -17
  63. package/src/composer/composer-attachments.tsx +1 -1
  64. package/src/composer/composer.tsx +2 -2
  65. package/src/contracts.ts +0 -2
  66. package/src/message-panel.tsx +1 -24
  67. package/src/parts/index.tsx +103 -64
@@ -1,4 +1,5 @@
1
1
  import type { UIMessage } from "ai";
2
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
2
3
  import {
3
4
  ArrowDown,
4
5
  Brain,
@@ -17,9 +18,15 @@ import {
17
18
  type ReactNode,
18
19
  } from "react";
19
20
  import { Tooltip } from "../primitives/tooltip";
21
+ import {
22
+ Collapsible,
23
+ CollapsibleContent,
24
+ CollapsibleTrigger,
25
+ } from "../primitives/collapsible";
20
26
  import { WorkshopIconButton } from "../primitives/workshop-controls";
21
27
  import {
22
28
  MarkdownMessage,
29
+ type CloudOsArtifactResolver,
23
30
  type CloudOsUrlResolver,
24
31
  } from "./markdown-message";
25
32
  import {
@@ -34,7 +41,10 @@ import {
34
41
  type ApprovalDecision,
35
42
  } from "./rich-blocks";
36
43
  import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
44
+ import { collapsePanel } from "./surfaces";
37
45
  import { CloudOsActivityIndicator } from "./activity-indicator";
46
+ import { ProductGeneration } from "./image-generation";
47
+ import { GenerationLoader } from "./loading-state";
38
48
  import { CapabilityChip } from "../capability-chip";
39
49
  import {
40
50
  renderFileView,
@@ -46,7 +56,9 @@ import {
46
56
  formatClockTime,
47
57
  formatFullTimestamp,
48
58
  rhythmTopClass,
59
+ type AssistantBlock,
49
60
  type CloudOsEntry,
61
+ type CloudOsActionPresentation,
50
62
  } from "./transcript-model";
51
63
 
52
64
  export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
@@ -56,8 +68,9 @@ export interface CloudOsChatMessagesProps {
56
68
  status: CloudOsChatStatus;
57
69
  /** 权威 Turn 生命周期;提供时替代本地 status 推断。 */
58
70
  turnActive?: boolean;
59
- isHydrating?: boolean;
60
71
  isRecovering?: boolean;
72
+ recoveryAttempt?: number;
73
+ recoveryMax?: number;
61
74
  recoveryStatusLabel?: string;
62
75
  awaitingApproval?: boolean;
63
76
  /** 有 pending steer 时,活动占位说明「正在等当前步骤收尾」。 */
@@ -85,6 +98,10 @@ export interface CloudOsChatMessagesProps {
85
98
  renderFile?: FileRenderer;
86
99
  /** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
87
100
  resolveUrl?: CloudOsUrlResolver;
101
+ /** 把图片 URL 关联到宿主的持久产物。 */
102
+ resolveArtifactId?: CloudOsArtifactResolver;
103
+ /** 打开宿主拥有的产物视图。 */
104
+ onOpenArtifact?: (artifactId: string) => void;
88
105
  /** 消息区宽度是否收敛到 920px 居中(GadgetEditor 里叫 constrainChatWidth)。 */
89
106
  constrainWidth?: boolean;
90
107
  emptyTitle?: string;
@@ -129,12 +146,16 @@ function UserBubble({
129
146
  copied,
130
147
  renderFile,
131
148
  resolveUrl,
149
+ resolveArtifactId,
150
+ onOpenArtifact,
132
151
  }: {
133
152
  entry: Extract<CloudOsEntry, { type: "user" }>;
134
153
  onCopy: (text: string) => void;
135
154
  copied: boolean;
136
155
  renderFile?: FileRenderer;
137
156
  resolveUrl?: CloudOsUrlResolver;
157
+ resolveArtifactId?: CloudOsArtifactResolver;
158
+ onOpenArtifact?: (artifactId: string) => void;
138
159
  }) {
139
160
  return (
140
161
  <div className="group/message relative flex flex-col items-end">
@@ -150,23 +171,39 @@ function UserBubble({
150
171
  </div>
151
172
  )}
152
173
  {entry.attachments.length > 0 && (
153
- <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-2">
154
- {entry.attachments.map((attachment, index) => (
155
- <Fragment key={`${attachment.url}:${index}`}>
156
- {renderFileView(renderFile, {
157
- file: attachment,
158
- placement: "user-message",
159
- })}
160
- </Fragment>
161
- ))}
174
+ <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap items-start justify-end gap-2">
175
+ {entry.attachments.map((attachment, index) => {
176
+ const view = renderFileView(renderFile, {
177
+ file: {
178
+ ...attachment,
179
+ url: resolveUrl?.(attachment.url) ?? attachment.url,
180
+ },
181
+ placement: "user-message",
182
+ });
183
+ return attachment.mediaType?.startsWith("image/") ? (
184
+ <Fragment key={`${attachment.url}:${index}`}>{view}</Fragment>
185
+ ) : (
186
+ <span
187
+ key={`${attachment.url}:${index}`}
188
+ className="flex basis-full justify-end"
189
+ >
190
+ {view}
191
+ </span>
192
+ );
193
+ })}
162
194
  </div>
163
195
  )}
164
196
  {entry.text && (
165
- <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">
166
- {/* pre-wrap 让用户消息里的单个换行渲染成硬换行 */}
167
- <div className="whitespace-pre-wrap">
168
- <MarkdownMessage message={entry.text} resolveUrl={resolveUrl} />
169
- </div>
197
+ <div
198
+ data-user-message-bubble=""
199
+ 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 animate-in fade-in slide-in-from-bottom-1 duration-300 motion-reduce:animate-none"
200
+ >
201
+ <MarkdownMessage
202
+ message={entry.text}
203
+ resolveUrl={resolveUrl}
204
+ resolveArtifactId={resolveArtifactId}
205
+ onOpenArtifact={onOpenArtifact}
206
+ />
170
207
  </div>
171
208
  )}
172
209
  <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">
@@ -193,12 +230,147 @@ function UserBubble({
193
230
  );
194
231
  }
195
232
 
233
+ function ActionPresentationBlock({
234
+ presentation,
235
+ failureCount = 1,
236
+ partial = false,
237
+ renderFile,
238
+ resolveUrl,
239
+ resolveArtifactId,
240
+ onOpenArtifact,
241
+ }: {
242
+ presentation: CloudOsActionPresentation;
243
+ failureCount?: number;
244
+ partial?: boolean;
245
+ renderFile?: FileRenderer;
246
+ resolveUrl?: CloudOsUrlResolver;
247
+ resolveArtifactId?: CloudOsArtifactResolver;
248
+ onOpenArtifact?: (artifactId: string) => void;
249
+ }) {
250
+ const { state } = presentation;
251
+ if (state === "ready") {
252
+ return (
253
+ <div
254
+ data-action-presentation={state}
255
+ role="status"
256
+ aria-label="Completed"
257
+ className="space-y-2"
258
+ >
259
+ {presentation.content.length === 0
260
+ ? <p className="m-0 text-[14px] leading-5 text-kumo-subtle">Completed</p>
261
+ : presentation.content.map((file, index) => {
262
+ const url = resolveUrl?.(file.url) ?? file.url;
263
+ const artifactId = resolveArtifactId?.(url);
264
+ return (
265
+ <Fragment key={`${file.url}:${index}`}>
266
+ {renderFileView(renderFile, {
267
+ file: {
268
+ ...file,
269
+ filename: "Generated file",
270
+ url,
271
+ },
272
+ placement: "assistant-message",
273
+ variant: "product",
274
+ onOpen: file.mediaType?.startsWith("image/") && artifactId && onOpenArtifact
275
+ ? () => onOpenArtifact(artifactId)
276
+ : undefined,
277
+ })}
278
+ </Fragment>
279
+ );
280
+ })}
281
+ </div>
282
+ );
283
+ }
284
+
285
+ const product = presentation.produces;
286
+ const productName = product?.type ?? "other";
287
+ const isFileProduct = productName !== "data" && productName !== "other";
288
+ const insufficientCredits = state === "failed" &&
289
+ presentation.failureCode === "insufficient_credits";
290
+ const body = state === "running"
291
+ ? isFileProduct
292
+ ? `Generating ${productName}${product?.mediaType ? ` (${product.mediaType})` : ""}`
293
+ : "Generating"
294
+ : state === "outcome_unknown"
295
+ ? "The provider may have completed this action. Check the status before trying again."
296
+ : insufficientCredits
297
+ ? `${failureCount} API call${failureCount === 1 ? " was" : "s were"} not started because there weren't enough Credits.`
298
+ : undefined;
299
+ if (state === "running") {
300
+ return (
301
+ <div
302
+ data-action-presentation={state}
303
+ data-product-type={productName}
304
+ role="status"
305
+ aria-label={body}
306
+ >
307
+ {isFileProduct
308
+ ? <ProductGeneration type={productName} mediaType={product?.mediaType} />
309
+ : <GenerationLoader />}
310
+ </div>
311
+ );
312
+ }
313
+ return (
314
+ <div
315
+ data-action-presentation={state}
316
+ role={state === "failed" ? "alert" : "status"}
317
+ 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"
318
+ >
319
+ <span>
320
+ <strong className="block font-medium text-kumo-default">
321
+ {insufficientCredits
322
+ ? partial ? "Partially completed" : "Not completed"
323
+ : state === "failed" ? "Action failed" : "Status unavailable"}
324
+ </strong>
325
+ {body && <span className="block text-kumo-subtle">{body}</span>}
326
+ </span>
327
+ </div>
328
+ );
329
+ }
330
+
331
+ function TurnWorkDisclosure({
332
+ running,
333
+ open,
334
+ onOpenChange,
335
+ children,
336
+ }: {
337
+ running: boolean;
338
+ open: boolean;
339
+ onOpenChange: (open: boolean) => void;
340
+ children: ReactNode;
341
+ }) {
342
+ return (
343
+ <Collapsible
344
+ data-slot="turn-work-disclosure"
345
+ open={open}
346
+ onOpenChange={onOpenChange}
347
+ >
348
+ <CollapsibleTrigger
349
+ aria-disabled={running}
350
+ className="group/trigger flex items-center gap-1.5 rounded-md py-1 text-[13.5px] text-kumo-inactive transition-colors outline-none hover:text-kumo-subtle"
351
+ >
352
+ <CaretRight
353
+ size={14}
354
+ className="shrink-0 transition-transform duration-200 group-data-open/trigger:rotate-90 group-data-panel-open/trigger:rotate-90 motion-reduce:transition-none"
355
+ />
356
+ <span>{running ? "Working…" : "Work"}</span>
357
+ </CollapsibleTrigger>
358
+ <CollapsibleContent className={collapsePanel}>
359
+ <div className="cos-trace-chain relative space-y-4 pt-2">
360
+ {children}
361
+ </div>
362
+ </CollapsibleContent>
363
+ </Collapsible>
364
+ );
365
+ }
366
+
196
367
  export function CloudOsChatMessages({
197
368
  messages,
198
369
  status,
199
370
  turnActive,
200
- isHydrating = false,
201
371
  isRecovering = false,
372
+ recoveryAttempt,
373
+ recoveryMax,
202
374
  recoveryStatusLabel,
203
375
  awaitingApproval = false,
204
376
  hasPendingSteer = false,
@@ -210,6 +382,7 @@ export function CloudOsChatMessages({
210
382
  renderApproval,
211
383
  renderFile,
212
384
  resolveUrl,
385
+ resolveArtifactId,
213
386
  constrainWidth = true,
214
387
  emptyTitle = "What are we working on?",
215
388
  onRetry,
@@ -217,14 +390,19 @@ export function CloudOsChatMessages({
217
390
  onSuggestion,
218
391
  onRespond,
219
392
  onOpenSchedule,
393
+ onOpenArtifact,
220
394
  onCopy,
221
395
  }: CloudOsChatMessagesProps) {
222
396
  const scrollRef = useRef<HTMLDivElement>(null);
223
397
  const endRef = useRef<HTMLDivElement>(null);
224
398
  const [showScrollButton, setShowScrollButton] = useState(false);
399
+ const reducedMotion = useReducedMotion();
225
400
  const [expandedToolCalls, setExpandedToolCalls] = useState<ReadonlySet<string>>(
226
401
  () => new Set(),
227
402
  );
403
+ const [expandedTurnWork, setExpandedTurnWork] = useState<ReadonlySet<string>>(
404
+ () => new Set(),
405
+ );
228
406
  const [expandedCompactions, setExpandedCompactions] = useState<
229
407
  ReadonlySet<string>
230
408
  >(() => new Set());
@@ -242,6 +420,20 @@ export function CloudOsChatMessages({
242
420
  }),
243
421
  [approvalIdOf, isActive, messages, showThinkingTraces],
244
422
  );
423
+ const latestUserIndex = entries.findLastIndex((entry) => entry.type === "user");
424
+ const activeTurnEntry = isActive
425
+ ? entries.findLast(
426
+ (entry, index): entry is Extract<CloudOsEntry, { type: "assistant" }> =>
427
+ index > latestUserIndex && entry.type === "assistant",
428
+ )
429
+ : undefined;
430
+ const currentPlan = activeTurnEntry?.blocks.findLast(
431
+ (block): block is Extract<AssistantBlock, { kind: "plan" }> =>
432
+ block.kind === "plan",
433
+ );
434
+ const completedPlanSteps = currentPlan?.steps.filter(
435
+ (step) => step.status === "done",
436
+ ).length ?? 0;
245
437
 
246
438
  const entryTopClasses = useMemo(
247
439
  () =>
@@ -260,6 +452,15 @@ export function CloudOsChatMessages({
260
452
  });
261
453
  }, []);
262
454
 
455
+ const setTurnWorkOpen = useCallback((key: string, open: boolean) => {
456
+ setExpandedTurnWork((current) => {
457
+ const next = new Set(current);
458
+ if (open) next.add(key);
459
+ else next.delete(key);
460
+ return next;
461
+ });
462
+ }, []);
463
+
263
464
  const toggleCompaction = useCallback((key: string) => {
264
465
  setExpandedCompactions((current) => {
265
466
  const next = new Set(current);
@@ -286,6 +487,8 @@ export function CloudOsChatMessages({
286
487
  const activity = deriveTurnActivity(entries, {
287
488
  isActive,
288
489
  isRecovering,
490
+ recoveryAttempt,
491
+ recoveryMax,
289
492
  recoveryStatusLabel,
290
493
  awaitingApproval,
291
494
  hasPendingSteer,
@@ -300,22 +503,23 @@ export function CloudOsChatMessages({
300
503
  className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-kumo-base"
301
504
  data-cloud-os-transcript=""
302
505
  data-status={status}
303
- aria-busy={isActive || isHydrating || isRecovering}
506
+ aria-busy={isActive || isRecovering}
304
507
  >
305
- <div
306
- ref={scrollRef}
307
- onScroll={syncScrollButton}
308
- tabIndex={0}
309
- role="region"
310
- aria-label="Chat messages"
311
- className="cos-chat-panel flex-1 overflow-y-auto"
312
- >
508
+ <div className="relative flex min-h-0 flex-1">
509
+ <div
510
+ ref={scrollRef}
511
+ onScroll={syncScrollButton}
512
+ tabIndex={0}
513
+ role="region"
514
+ aria-label="Chat messages"
515
+ className="cos-chat-panel flex-1 overflow-y-auto"
516
+ >
313
517
  <div
314
518
  className={`flex flex-col px-6 pb-8 pt-8 ${
315
- constrainWidth ? "mx-auto w-full max-w-[920px]" : ""
519
+ constrainWidth ? "mx-auto w-full max-w-[var(--cos-conversation-max-width)]" : ""
316
520
  }`}
317
521
  >
318
- {entries.length === 0 && !startedWithPending && !isHydrating && (
522
+ {entries.length === 0 && !startedWithPending && (
319
523
  <div className="flex min-h-56 items-center justify-center text-center text-[24px] leading-8 font-semibold tracking-[-0.4px] text-kumo-default">
320
524
  {emptyTitle}
321
525
  </div>
@@ -326,13 +530,19 @@ export function CloudOsChatMessages({
326
530
 
327
531
  if (entry.type === "user") {
328
532
  return (
329
- <div key={entry.key} className={topClass}>
533
+ <div
534
+ key={entry.key}
535
+ data-user-message-entry=""
536
+ className={topClass}
537
+ >
330
538
  <UserBubble
331
539
  entry={entry}
332
540
  onCopy={copyText}
333
541
  copied={copied === entry.text}
334
542
  renderFile={renderFile}
335
543
  resolveUrl={resolveUrl}
544
+ resolveArtifactId={resolveArtifactId}
545
+ onOpenArtifact={onOpenArtifact}
336
546
  />
337
547
  </div>
338
548
  );
@@ -341,7 +551,7 @@ export function CloudOsChatMessages({
341
551
  if (entry.type === "slashCommand") {
342
552
  return (
343
553
  <div key={entry.key} className={`${topClass} flex flex-col items-end`}>
344
- <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">
554
+ <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">
345
555
  <span className="whitespace-pre-wrap font-mono">{entry.text}</span>
346
556
  </div>
347
557
  </div>
@@ -411,6 +621,8 @@ export function CloudOsChatMessages({
411
621
  <MarkdownMessage
412
622
  message={entry.summary}
413
623
  resolveUrl={resolveUrl}
624
+ resolveArtifactId={resolveArtifactId}
625
+ onOpenArtifact={onOpenArtifact}
414
626
  />
415
627
  </div>
416
628
  )}
@@ -418,34 +630,95 @@ export function CloudOsChatMessages({
418
630
  );
419
631
  }
420
632
 
421
- const hasNarrative = entry.blocks.some(
422
- (block) => block.kind === "text",
633
+ const entryIsActive =
634
+ isActive && entryIndex === entries.length - 1;
635
+ const finalTextBlocks = entryIsActive
636
+ ? []
637
+ : entry.blocks.filter(
638
+ (block): block is Extract<AssistantBlock, { kind: "text" }> =>
639
+ block.kind === "text" && block.final,
640
+ );
641
+ const actionResultBlocks = entry.blocks.filter(
642
+ (block): block is Extract<AssistantBlock, { kind: "actionPresentation" }> =>
643
+ block.kind === "actionPresentation",
644
+ );
645
+ const finalMediaBlocks = entry.blocks.filter(
646
+ (block): block is Extract<AssistantBlock, { kind: "image" | "file" }> =>
647
+ block.kind === "image" || block.kind === "file",
648
+ );
649
+ const workBlocks = entry.blocks.filter(
650
+ (block) =>
651
+ block.kind !== "suggestions" &&
652
+ block.kind !== "image" &&
653
+ block.kind !== "file" &&
654
+ block.kind !== "actionPresentation" &&
655
+ !(block.kind === "text" && block.final && !entryIsActive),
656
+ );
657
+ const hasNarrative = finalTextBlocks.length > 0;
658
+ const insufficientFailures = entry.blocks.filter(
659
+ (block) => block.kind === "actionPresentation" &&
660
+ block.presentation.state === "failed" &&
661
+ block.presentation.failureCode === "insufficient_credits",
662
+ );
663
+ const firstInsufficientFailure = insufficientFailures[0];
664
+ const hasCompletedAction = entry.blocks.some(
665
+ (block) => block.kind === "actionPresentation" &&
666
+ block.presentation.state === "ready",
667
+ );
668
+ const followUpBlocks = entry.blocks.filter(
669
+ (block) => block.kind === "suggestions",
423
670
  );
671
+ const footerTimestamp =
672
+ hasNarrative &&
673
+ entry.timestamp !== undefined &&
674
+ !entryIsActive &&
675
+ !entry.blocks.some(
676
+ (block) => block.kind === "reasoning" && block.running,
677
+ )
678
+ ? entry.timestamp
679
+ : undefined;
424
680
 
425
681
  return (
426
682
  <div
427
683
  key={entry.key}
428
- className={`${topClass} min-w-0 w-full max-w-[860px] space-y-2`}
684
+ className={`${topClass} group/agentMessage min-w-0 w-full max-w-[860px] space-y-2`}
429
685
  >
430
- <div className="group/agentMessage relative space-y-1.5">
431
- {entry.blocks.map((block) => {
686
+ {workBlocks.length > 0 && (
687
+ <TurnWorkDisclosure
688
+ running={entryIsActive}
689
+ open={entryIsActive || expandedTurnWork.has(entry.key)}
690
+ onOpenChange={(open) => {
691
+ if (!entryIsActive) setTurnWorkOpen(entry.key, open);
692
+ }}
693
+ >
694
+ {workBlocks.map((block) => {
432
695
  switch (block.kind) {
433
696
  case "reasoning":
434
697
  return (
435
- <ThinkingTraceRow key={block.key} reasoning={block.text} />
698
+ <ThinkingTraceRow
699
+ key={block.key}
700
+ reasoning={block.text}
701
+ running={block.running}
702
+ />
436
703
  );
437
- case "text":
704
+ case "text": {
705
+ const streaming =
706
+ entryIsActive && block === entry.blocks.at(-1);
438
707
  return (
439
708
  <div
440
709
  key={block.key}
441
- className="cos-markdown text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default"
710
+ className="cos-markdown px-1.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default"
442
711
  >
443
712
  <MarkdownMessage
444
713
  message={block.text}
445
714
  resolveUrl={resolveUrl}
715
+ resolveArtifactId={resolveArtifactId}
716
+ onOpenArtifact={onOpenArtifact}
717
+ streaming={streaming}
446
718
  />
447
719
  </div>
448
720
  );
721
+ }
449
722
  case "toolGroup":
450
723
  return (
451
724
  <ToolGroupRow
@@ -457,6 +730,7 @@ export function CloudOsChatMessages({
457
730
  />
458
731
  );
459
732
  case "plan":
733
+ if (currentPlan?.key === block.key) return null;
460
734
  return (
461
735
  <PlanBlock
462
736
  key={block.key}
@@ -473,13 +747,7 @@ export function CloudOsChatMessages({
473
747
  />
474
748
  );
475
749
  case "suggestions":
476
- return (
477
- <SuggestionsBlock
478
- key={block.key}
479
- items={block.items}
480
- onSuggestion={(text) => onSuggestion?.(text)}
481
- />
482
- );
750
+ return null;
483
751
  case "schedule":
484
752
  return (
485
753
  <ScheduleBlock
@@ -522,44 +790,92 @@ export function CloudOsChatMessages({
522
790
  return (
523
791
  <SubAgentsBlock key={block.key} agents={block.agents} />
524
792
  );
525
- case "image":
793
+ case "actionPresentation":
794
+ return null;
795
+ case "error":
526
796
  return (
527
- <Fragment key={block.key}>
528
- {renderFileView(renderFile, {
529
- file: {
530
- url: resolveUrl?.(block.src) ?? block.src,
531
- filename: block.alt,
532
- mediaType: block.mediaType ?? "image/*",
533
- },
534
- placement: "assistant-message",
535
- })}
536
- </Fragment>
797
+ <ErrorBlock
798
+ key={block.key}
799
+ title={block.title}
800
+ body={block.body}
801
+ />
537
802
  );
538
- case "file":
803
+ }
804
+ })}
805
+ </TurnWorkDisclosure>
806
+ )}
807
+
808
+ {actionResultBlocks.length > 0 && (
809
+ <div data-action-results="" className="space-y-4">
810
+ {actionResultBlocks.map((block) => {
811
+ if (
812
+ block.presentation.failureCode === "insufficient_credits" &&
813
+ block !== firstInsufficientFailure
814
+ ) return null;
815
+ return (
816
+ <ActionPresentationBlock
817
+ key={block.key}
818
+ presentation={block.presentation}
819
+ failureCount={insufficientFailures.length}
820
+ partial={hasCompletedAction}
821
+ renderFile={renderFile}
822
+ resolveUrl={resolveUrl}
823
+ resolveArtifactId={resolveArtifactId}
824
+ onOpenArtifact={onOpenArtifact}
825
+ />
826
+ );
827
+ })}
828
+ </div>
829
+ )}
830
+
831
+ {(finalMediaBlocks.length > 0 || finalTextBlocks.length > 0) && (
832
+ <div data-final-message="" className="space-y-4">
833
+ {finalMediaBlocks.map((block) => {
834
+ if (block.kind === "image") {
835
+ const url = resolveUrl?.(block.src) ?? block.src;
836
+ const artifactId = resolveArtifactId?.(url);
539
837
  return (
540
838
  <Fragment key={block.key}>
541
839
  {renderFileView(renderFile, {
542
840
  file: {
543
- ...block.file,
544
- url:
545
- resolveUrl?.(block.file.url) ?? block.file.url,
841
+ url,
842
+ filename: block.alt,
843
+ mediaType: block.mediaType ?? "image/*",
546
844
  },
547
845
  placement: "assistant-message",
846
+ onOpen: artifactId && onOpenArtifact
847
+ ? () => onOpenArtifact(artifactId)
848
+ : undefined,
548
849
  })}
549
850
  </Fragment>
550
851
  );
551
- case "error":
552
- return (
553
- <ErrorBlock
554
- key={block.key}
555
- title={block.title}
556
- body={block.body}
557
- />
558
- );
559
- }
560
- })}
852
+ }
853
+ return (
854
+ <Fragment key={block.key}>
855
+ {renderFileView(renderFile, {
856
+ file: {
857
+ ...block.file,
858
+ url: resolveUrl?.(block.file.url) ?? block.file.url,
859
+ },
860
+ placement: "assistant-message",
861
+ })}
862
+ </Fragment>
863
+ );
864
+ })}
865
+ {finalTextBlocks.map((block) => (
866
+ <div key={block.key} className="cos-markdown px-1.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
867
+ <MarkdownMessage
868
+ message={block.text}
869
+ resolveUrl={resolveUrl}
870
+ resolveArtifactId={resolveArtifactId}
871
+ onOpenArtifact={onOpenArtifact}
872
+ />
873
+ </div>
874
+ ))}
875
+ </div>
876
+ )}
561
877
 
562
- {entry.terminal?.kind === "error" && (
878
+ {entry.terminal?.kind === "error" && (
563
879
  <ErrorBlock
564
880
  title={entry.terminal.title}
565
881
  body={entry.terminal.body}
@@ -567,7 +883,7 @@ export function CloudOsChatMessages({
567
883
  />
568
884
  )}
569
885
 
570
- {entry.terminal?.kind === "interrupted" && (
886
+ {entry.terminal?.kind === "interrupted" && (
571
887
  <div
572
888
  className="px-1.5 py-1 text-[13px] leading-[19px] text-kumo-inactive"
573
889
  role="status"
@@ -576,8 +892,11 @@ export function CloudOsChatMessages({
576
892
  </div>
577
893
  )}
578
894
 
579
- {hasNarrative && entry.timestamp !== undefined && (
580
- <div className="mt-0.5 -ml-1 flex items-center gap-1 opacity-0 transition-opacity duration-150 ease-out group-hover/agentMessage:opacity-100 group-focus-within/agentMessage:opacity-100">
895
+ {footerTimestamp !== undefined && (
896
+ <div
897
+ data-agent-message-footer=""
898
+ className="mt-0.5 -ml-1 flex items-center gap-1 opacity-0 transition-opacity duration-150 ease-out group-hover/agentMessage:opacity-100 group-focus-within/agentMessage:opacity-100"
899
+ >
581
900
  <Tooltip
582
901
  content={copied === entry.copyText ? "Copied!" : "Copy message"}
583
902
  asChild
@@ -595,14 +914,21 @@ export function CloudOsChatMessages({
595
914
  )}
596
915
  </button>
597
916
  </Tooltip>
598
- <Tooltip content={formatFullTimestamp(entry.timestamp)} asChild>
917
+ <Tooltip content={formatFullTimestamp(footerTimestamp)} asChild>
599
918
  <span className="px-1 font-mono text-[11px] leading-4 text-kumo-inactive">
600
- {formatClockTime(entry.timestamp)}
919
+ {formatClockTime(footerTimestamp)}
601
920
  </span>
602
921
  </Tooltip>
603
922
  </div>
604
923
  )}
605
- </div>
924
+
925
+ {followUpBlocks.map((block) => (
926
+ <SuggestionsBlock
927
+ key={block.key}
928
+ items={block.items}
929
+ onSuggestion={(text) => onSuggestion?.(text)}
930
+ />
931
+ ))}
606
932
  </div>
607
933
  );
608
934
  })}
@@ -631,30 +957,47 @@ export function CloudOsChatMessages({
631
957
 
632
958
  <div ref={endRef} />
633
959
  </div>
960
+ </div>
961
+
962
+ <WorkshopIconButton
963
+ aria-label="Scroll to bottom"
964
+ onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
965
+ className={`themed-floating-shadow absolute bottom-3 left-1/2 z-20 -translate-x-1/2 !rounded-full border border-kumo-line bg-kumo-base transition-all duration-200 ${
966
+ showScrollButton
967
+ ? "translate-y-0 opacity-100"
968
+ : "pointer-events-none translate-y-2 opacity-0"
969
+ }`}
970
+ >
971
+ <ArrowDown size={15} />
972
+ </WorkshopIconButton>
634
973
  </div>
635
974
 
636
- <WorkshopIconButton
637
- aria-label="Scroll to bottom"
638
- onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
639
- className={`themed-floating-shadow absolute bottom-3 left-1/2 z-20 -translate-x-1/2 !rounded-full border border-kumo-line bg-kumo-base transition-all duration-200 ${
640
- showScrollButton
641
- ? "translate-y-0 opacity-100"
642
- : "pointer-events-none translate-y-2 opacity-0"
643
- }`}
644
- >
645
- <ArrowDown size={15} />
646
- </WorkshopIconButton>
647
-
648
- {isHydrating && (
649
- <div className="absolute inset-0 z-30 grid place-items-center bg-kumo-base/90">
650
- <div className="flex flex-col items-center gap-3">
651
- <div className="h-6 w-6 animate-spin rounded-full border-2 border-kumo-brand border-t-transparent" />
652
- <p className="m-0 text-[13px] leading-[18px] text-kumo-subtle">
653
- Loading conversation…
654
- </p>
655
- </div>
656
- </div>
657
- )}
975
+ <AnimatePresence initial={false}>
976
+ {currentPlan && completedPlanSteps < currentPlan.steps.length && (
977
+ <motion.div
978
+ key="task-progress"
979
+ initial={reducedMotion ? { opacity: 1 } : { opacity: 0, y: 8, scale: 0.98 }}
980
+ animate={{ opacity: 1, y: 0, scale: 1 }}
981
+ exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98 }}
982
+ transition={{ duration: reducedMotion ? 0 : 0.2, ease: [0.32, 0.72, 0, 1] }}
983
+ className="relative z-10 mx-auto w-full max-w-3xl shrink-0 px-4"
984
+ >
985
+ <section
986
+ data-cloud-os-plan-progress=""
987
+ aria-label="Task progress"
988
+ className="mx-auto border border-b-0 border-kumo-line bg-kumo-base px-4 py-2.5"
989
+ style={{ width: "90%", borderRadius: "1rem 1rem 0 0" }}
990
+ >
991
+ <PlanBlock
992
+ steps={currentPlan.steps}
993
+ running={isActive}
994
+ defaultOpen={false}
995
+ />
996
+ </section>
997
+ </motion.div>
998
+ )}
999
+ </AnimatePresence>
1000
+
658
1001
  </div>
659
1002
  );
660
1003
  }