@springbrand/message-panel 0.1.3-alpha.33 → 0.1.3-alpha.36

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.
@@ -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,6 +18,11 @@ 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,
@@ -34,11 +40,8 @@ import {
34
40
  SuggestionsBlock,
35
41
  type ApprovalDecision,
36
42
  } from "./rich-blocks";
37
- import {
38
- ToolGroupRow,
39
- WorkDescriptionRow,
40
- WorkTraceDisclosure,
41
- } from "./tool-rows";
43
+ import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
44
+ import { collapsePanel } from "./surfaces";
42
45
  import { CloudOsActivityIndicator } from "./activity-indicator";
43
46
  import { ProductGeneration } from "./image-generation";
44
47
  import { GenerationLoader } from "./loading-state";
@@ -52,14 +55,11 @@ import {
52
55
  deriveTurnActivity,
53
56
  formatClockTime,
54
57
  formatFullTimestamp,
55
- partitionAssistantBlocks,
56
58
  rhythmTopClass,
59
+ type AssistantBlock,
57
60
  type CloudOsEntry,
58
61
  type CloudOsActionPresentation,
59
62
  } from "./transcript-model";
60
- import { useDisclosureExpandedKeys } from "./use-lifecycle-disclosure";
61
-
62
- const NO_RUNNING_DISCLOSURES: ReadonlySet<string> = new Set();
63
63
 
64
64
  export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
65
65
 
@@ -69,6 +69,8 @@ export interface CloudOsChatMessagesProps {
69
69
  /** 权威 Turn 生命周期;提供时替代本地 status 推断。 */
70
70
  turnActive?: boolean;
71
71
  isRecovering?: boolean;
72
+ recoveryAttempt?: number;
73
+ recoveryMax?: number;
72
74
  recoveryStatusLabel?: string;
73
75
  awaitingApproval?: boolean;
74
76
  /** 有 pending steer 时,活动占位说明「正在等当前步骤收尾」。 */
@@ -192,7 +194,10 @@ function UserBubble({
192
194
  </div>
193
195
  )}
194
196
  {entry.text && (
195
- <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">
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
+ >
196
201
  <MarkdownMessage
197
202
  message={entry.text}
198
203
  resolveUrl={resolveUrl}
@@ -323,11 +328,49 @@ function ActionPresentationBlock({
323
328
  );
324
329
  }
325
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
+
326
367
  export function CloudOsChatMessages({
327
368
  messages,
328
369
  status,
329
370
  turnActive,
330
371
  isRecovering = false,
372
+ recoveryAttempt,
373
+ recoveryMax,
331
374
  recoveryStatusLabel,
332
375
  awaitingApproval = false,
333
376
  hasPendingSteer = false,
@@ -353,6 +396,16 @@ export function CloudOsChatMessages({
353
396
  const scrollRef = useRef<HTMLDivElement>(null);
354
397
  const endRef = useRef<HTMLDivElement>(null);
355
398
  const [showScrollButton, setShowScrollButton] = useState(false);
399
+ const reducedMotion = useReducedMotion();
400
+ const [expandedToolCalls, setExpandedToolCalls] = useState<ReadonlySet<string>>(
401
+ () => new Set(),
402
+ );
403
+ const [expandedTurnWork, setExpandedTurnWork] = useState<ReadonlySet<string>>(
404
+ () => new Set(),
405
+ );
406
+ const [expandedCompactions, setExpandedCompactions] = useState<
407
+ ReadonlySet<string>
408
+ >(() => new Set());
356
409
  const { copied, copy } = useCopyAction();
357
410
  const isActive =
358
411
  turnActive ?? (status === "submitted" || status === "streaming");
@@ -367,74 +420,20 @@ export function CloudOsChatMessages({
367
420
  }),
368
421
  [approvalIdOf, isActive, messages, showThinkingTraces],
369
422
  );
370
- const workEntryKeys = useMemo(
371
- () =>
372
- new Set(
373
- entries.flatMap((entry) =>
374
- entry.type === "assistant" &&
375
- partitionAssistantBlocks(entry.blocks).work.length > 0
376
- ? [entry.key]
377
- : [],
378
- ),
379
- ),
380
- [entries],
381
- );
382
- const runningWorkEntryKeys = useMemo(() => {
383
- const keys = new Set<string>();
384
- if (!isActive) return keys;
385
- const entry = entries.findLast(
386
- (candidate) => candidate.type === "assistant",
387
- );
388
- if (entry?.type !== "assistant") return keys;
389
- const { work, content } = partitionAssistantBlocks(entry.blocks);
390
- if (work.length > 0 && !content.some((block) => block.kind === "text")) {
391
- keys.add(entry.key);
392
- }
393
- return keys;
394
- }, [entries, isActive]);
395
- const toolCallKeys = useMemo(
396
- () =>
397
- new Set(
398
- entries.flatMap((entry) =>
399
- entry.type === "assistant"
400
- ? entry.blocks.flatMap((block) =>
401
- block.kind === "toolGroup" ? [block.group.key] : [],
402
- )
403
- : [],
404
- ),
405
- ),
406
- [entries],
407
- );
408
- const runningToolCallKeys = useMemo(
409
- () =>
410
- new Set(
411
- entries.flatMap((entry) =>
412
- entry.type === "assistant" && runningWorkEntryKeys.has(entry.key)
413
- ? entry.blocks.flatMap((block) =>
414
- block.kind === "toolGroup" && block.group.hasRunning
415
- ? [block.group.key]
416
- : [],
417
- )
418
- : [],
419
- ),
420
- ),
421
- [entries, runningWorkEntryKeys],
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",
422
433
  );
423
- const compactionKeys = useMemo(
424
- () =>
425
- new Set(
426
- entries.flatMap((entry) =>
427
- entry.type === "compactionBoundary" ? [entry.key] : [],
428
- ),
429
- ),
430
- [entries],
431
- );
432
- const [expandedToolCalls, toggleToolCall] =
433
- useDisclosureExpandedKeys(toolCallKeys, runningToolCallKeys);
434
- const [expandedWorkEntries, toggleWorkEntry] =
435
- useDisclosureExpandedKeys(workEntryKeys, runningWorkEntryKeys);
436
- const [expandedCompactions, toggleCompaction] =
437
- useDisclosureExpandedKeys(compactionKeys, NO_RUNNING_DISCLOSURES);
434
+ const completedPlanSteps = currentPlan?.steps.filter(
435
+ (step) => step.status === "done",
436
+ ).length ?? 0;
438
437
 
439
438
  const entryTopClasses = useMemo(
440
439
  () =>
@@ -444,6 +443,33 @@ export function CloudOsChatMessages({
444
443
  [entries],
445
444
  );
446
445
 
446
+ const toggleToolCall = useCallback((key: string) => {
447
+ setExpandedToolCalls((current) => {
448
+ const next = new Set(current);
449
+ if (next.has(key)) next.delete(key);
450
+ else next.add(key);
451
+ return next;
452
+ });
453
+ }, []);
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
+
464
+ const toggleCompaction = useCallback((key: string) => {
465
+ setExpandedCompactions((current) => {
466
+ const next = new Set(current);
467
+ if (next.has(key)) next.delete(key);
468
+ else next.add(key);
469
+ return next;
470
+ });
471
+ }, []);
472
+
447
473
  const syncScrollButton = useCallback(() => {
448
474
  const element = scrollRef.current;
449
475
  if (!element) return;
@@ -461,6 +487,8 @@ export function CloudOsChatMessages({
461
487
  const activity = deriveTurnActivity(entries, {
462
488
  isActive,
463
489
  isRecovering,
490
+ recoveryAttempt,
491
+ recoveryMax,
464
492
  recoveryStatusLabel,
465
493
  awaitingApproval,
466
494
  hasPendingSteer,
@@ -477,14 +505,15 @@ export function CloudOsChatMessages({
477
505
  data-status={status}
478
506
  aria-busy={isActive || isRecovering}
479
507
  >
480
- <div
481
- ref={scrollRef}
482
- onScroll={syncScrollButton}
483
- tabIndex={0}
484
- role="region"
485
- aria-label="Chat messages"
486
- className="cos-chat-panel flex-1 overflow-y-auto"
487
- >
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
+ >
488
517
  <div
489
518
  className={`flex flex-col px-6 pb-8 pt-8 ${
490
519
  constrainWidth ? "mx-auto w-full max-w-[920px]" : ""
@@ -501,7 +530,11 @@ export function CloudOsChatMessages({
501
530
 
502
531
  if (entry.type === "user") {
503
532
  return (
504
- <div key={entry.key} className={topClass}>
533
+ <div
534
+ key={entry.key}
535
+ data-user-message-entry=""
536
+ className={topClass}
537
+ >
505
538
  <UserBubble
506
539
  entry={entry}
507
540
  onCopy={copyText}
@@ -584,7 +617,7 @@ export function CloudOsChatMessages({
584
617
  <span className="h-px flex-1 bg-kumo-line" aria-hidden="true" />
585
618
  </div>
586
619
  {expanded && (
587
- <div className="cos-markdown mt-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3.5 text-[13px] leading-[19px]">
620
+ <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]">
588
621
  <MarkdownMessage
589
622
  message={entry.summary}
590
623
  resolveUrl={resolveUrl}
@@ -597,116 +630,87 @@ export function CloudOsChatMessages({
597
630
  );
598
631
  }
599
632
 
600
- const { work: workBlocks, content: contentBlocks } =
601
- partitionAssistantBlocks(entry.blocks);
602
- const workGroups = workBlocks.flatMap((block) =>
603
- block.kind === "toolGroup" ? [block.group] : [],
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 finalActionBlocks = entryIsActive
642
+ ? []
643
+ : entry.blocks.filter(
644
+ (block): block is Extract<AssistantBlock, { kind: "actionPresentation" }> =>
645
+ block.kind === "actionPresentation" &&
646
+ block.presentation.state === "ready",
647
+ );
648
+ const finalMediaBlocks = entry.blocks.filter(
649
+ (block): block is Extract<AssistantBlock, { kind: "image" | "file" }> =>
650
+ block.kind === "image" || block.kind === "file",
604
651
  );
605
- const hasNarrative = contentBlocks.some(
606
- (block) => block.kind === "text",
652
+ const workBlocks = entry.blocks.filter(
653
+ (block) =>
654
+ block.kind !== "suggestions" &&
655
+ block.kind !== "image" &&
656
+ block.kind !== "file" &&
657
+ !(block.kind === "text" && block.final && !entryIsActive) &&
658
+ !(block.kind === "actionPresentation" && block.presentation.state === "ready"),
607
659
  );
608
- const insufficientFailures = contentBlocks.filter(
660
+ const hasNarrative = finalTextBlocks.length > 0;
661
+ const insufficientFailures = entry.blocks.filter(
609
662
  (block) => block.kind === "actionPresentation" &&
610
663
  block.presentation.state === "failed" &&
611
664
  block.presentation.failureCode === "insufficient_credits",
612
665
  );
613
666
  const firstInsufficientFailure = insufficientFailures[0];
614
- const hasCompletedAction = contentBlocks.some(
667
+ const hasCompletedAction = entry.blocks.some(
615
668
  (block) => block.kind === "actionPresentation" &&
616
669
  block.presentation.state === "ready",
617
670
  );
671
+ const followUpBlocks = entry.blocks.filter(
672
+ (block) => block.kind === "suggestions",
673
+ );
674
+ const footerTimestamp =
675
+ hasNarrative &&
676
+ entry.timestamp !== undefined &&
677
+ !entryIsActive &&
678
+ !entry.blocks.some(
679
+ (block) => block.kind === "reasoning" && block.running,
680
+ )
681
+ ? entry.timestamp
682
+ : undefined;
618
683
 
619
684
  return (
620
685
  <div
621
686
  key={entry.key}
622
- className={`${topClass} min-w-0 w-full max-w-[860px]`}
687
+ className={`${topClass} group/agentMessage min-w-0 w-full max-w-[860px] space-y-2`}
623
688
  >
624
- <div className="group/agentMessage relative space-y-4">
625
- {workBlocks.length > 0 && (
626
- <WorkTraceDisclosure
627
- durationMs={entry.durationMs}
628
- groups={workGroups}
629
- completed={!runningWorkEntryKeys.has(entry.key)}
630
- open={expandedWorkEntries.has(entry.key)}
631
- onToggle={() => toggleWorkEntry(entry.key)}
632
- >
633
- {workBlocks.map((block) => {
634
- switch (block.kind) {
635
- case "reasoning":
636
- case "text":
637
- return (
638
- <WorkDescriptionRow
639
- key={block.key}
640
- text={block.text}
641
- running={runningWorkEntryKeys.has(entry.key)}
642
- resolveUrl={resolveUrl}
643
- />
644
- );
645
- case "toolGroup":
646
- return (
647
- <ToolGroupRow
648
- key={block.key}
649
- group={block.group}
650
- open={expandedToolCalls.has(block.group.key)}
651
- expandedKeys={expandedToolCalls}
652
- onToggle={toggleToolCall}
653
- />
654
- );
655
- case "plan":
656
- return (
657
- <PlanBlock
658
- key={block.key}
659
- steps={block.steps}
660
- running={
661
- runningWorkEntryKeys.has(entry.key) && block.running
662
- }
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
- running={runningWorkEntryKeys.has(entry.key)}
673
- />
674
- );
675
- case "subagents":
676
- return (
677
- <SubAgentsBlock
678
- key={block.key}
679
- agents={block.agents}
680
- running={runningWorkEntryKeys.has(entry.key)}
681
- />
682
- );
683
- default:
684
- return null;
685
- }
686
- })}
687
- </WorkTraceDisclosure>
688
- )}
689
-
690
- {contentBlocks.map((block) => {
689
+ {workBlocks.length > 0 && (
690
+ <TurnWorkDisclosure
691
+ running={entryIsActive}
692
+ open={entryIsActive || expandedTurnWork.has(entry.key)}
693
+ onOpenChange={(open) => {
694
+ if (!entryIsActive) setTurnWorkOpen(entry.key, open);
695
+ }}
696
+ >
697
+ {workBlocks.map((block) => {
691
698
  switch (block.kind) {
692
699
  case "reasoning":
693
700
  return (
694
- <WorkDescriptionRow
701
+ <ThinkingTraceRow
695
702
  key={block.key}
696
- text={block.text}
703
+ reasoning={block.text}
697
704
  running={block.running}
698
- resolveUrl={resolveUrl}
699
705
  />
700
706
  );
701
707
  case "text": {
702
708
  const streaming =
703
- isActive &&
704
- entryIndex === entries.length - 1 &&
705
- block === entry.blocks.at(-1);
709
+ entryIsActive && block === entry.blocks.at(-1);
706
710
  return (
707
711
  <div
708
712
  key={block.key}
709
- className="cos-markdown text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default"
713
+ className="cos-markdown px-1.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default"
710
714
  >
711
715
  <MarkdownMessage
712
716
  message={block.text}
@@ -729,13 +733,12 @@ export function CloudOsChatMessages({
729
733
  />
730
734
  );
731
735
  case "plan":
736
+ if (currentPlan?.key === block.key) return null;
732
737
  return (
733
738
  <PlanBlock
734
739
  key={block.key}
735
740
  steps={block.steps}
736
- running={
737
- runningWorkEntryKeys.has(entry.key) && block.running
738
- }
741
+ running={block.running}
739
742
  />
740
743
  );
741
744
  case "askUser":
@@ -784,49 +787,11 @@ export function CloudOsChatMessages({
784
787
  key={block.key}
785
788
  label={block.label}
786
789
  tools={block.tools}
787
- running={runningWorkEntryKeys.has(entry.key)}
788
790
  />
789
791
  );
790
792
  case "subagents":
791
793
  return (
792
- <SubAgentsBlock
793
- key={block.key}
794
- agents={block.agents}
795
- running={runningWorkEntryKeys.has(entry.key)}
796
- />
797
- );
798
- case "image":
799
- {
800
- const url = resolveUrl?.(block.src) ?? block.src;
801
- const artifactId = resolveArtifactId?.(url);
802
- return (
803
- <Fragment key={block.key}>
804
- {renderFileView(renderFile, {
805
- file: {
806
- url,
807
- filename: block.alt,
808
- mediaType: block.mediaType ?? "image/*",
809
- },
810
- placement: "assistant-message",
811
- onOpen: artifactId && onOpenArtifact
812
- ? () => onOpenArtifact(artifactId)
813
- : undefined,
814
- })}
815
- </Fragment>
816
- );
817
- }
818
- case "file":
819
- return (
820
- <Fragment key={block.key}>
821
- {renderFileView(renderFile, {
822
- file: {
823
- ...block.file,
824
- url:
825
- resolveUrl?.(block.file.url) ?? block.file.url,
826
- },
827
- placement: "assistant-message",
828
- })}
829
- </Fragment>
794
+ <SubAgentsBlock key={block.key} agents={block.agents} />
830
795
  );
831
796
  case "actionPresentation":
832
797
  if (
@@ -855,8 +820,67 @@ export function CloudOsChatMessages({
855
820
  );
856
821
  }
857
822
  })}
823
+ </TurnWorkDisclosure>
824
+ )}
825
+
826
+ {(finalMediaBlocks.length > 0 || finalActionBlocks.length > 0 || finalTextBlocks.length > 0) && (
827
+ <div data-final-message="" className="space-y-4">
828
+ {finalMediaBlocks.map((block) => {
829
+ if (block.kind === "image") {
830
+ const url = resolveUrl?.(block.src) ?? block.src;
831
+ const artifactId = resolveArtifactId?.(url);
832
+ return (
833
+ <Fragment key={block.key}>
834
+ {renderFileView(renderFile, {
835
+ file: {
836
+ url,
837
+ filename: block.alt,
838
+ mediaType: block.mediaType ?? "image/*",
839
+ },
840
+ placement: "assistant-message",
841
+ onOpen: artifactId && onOpenArtifact
842
+ ? () => onOpenArtifact(artifactId)
843
+ : undefined,
844
+ })}
845
+ </Fragment>
846
+ );
847
+ }
848
+ return (
849
+ <Fragment key={block.key}>
850
+ {renderFileView(renderFile, {
851
+ file: {
852
+ ...block.file,
853
+ url: resolveUrl?.(block.file.url) ?? block.file.url,
854
+ },
855
+ placement: "assistant-message",
856
+ })}
857
+ </Fragment>
858
+ );
859
+ })}
860
+ {finalActionBlocks.map((block) => (
861
+ <ActionPresentationBlock
862
+ key={block.key}
863
+ presentation={block.presentation}
864
+ renderFile={renderFile}
865
+ resolveUrl={resolveUrl}
866
+ resolveArtifactId={resolveArtifactId}
867
+ onOpenArtifact={onOpenArtifact}
868
+ />
869
+ ))}
870
+ {finalTextBlocks.map((block) => (
871
+ <div key={block.key} className="cos-markdown px-1.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
872
+ <MarkdownMessage
873
+ message={block.text}
874
+ resolveUrl={resolveUrl}
875
+ resolveArtifactId={resolveArtifactId}
876
+ onOpenArtifact={onOpenArtifact}
877
+ />
878
+ </div>
879
+ ))}
880
+ </div>
881
+ )}
858
882
 
859
- {entry.terminal?.kind === "error" && (
883
+ {entry.terminal?.kind === "error" && (
860
884
  <ErrorBlock
861
885
  title={entry.terminal.title}
862
886
  body={entry.terminal.body}
@@ -864,7 +888,7 @@ export function CloudOsChatMessages({
864
888
  />
865
889
  )}
866
890
 
867
- {entry.terminal?.kind === "interrupted" && (
891
+ {entry.terminal?.kind === "interrupted" && (
868
892
  <div
869
893
  className="px-1.5 py-1 text-[13px] leading-[19px] text-kumo-inactive"
870
894
  role="status"
@@ -873,8 +897,11 @@ export function CloudOsChatMessages({
873
897
  </div>
874
898
  )}
875
899
 
876
- {hasNarrative && entry.timestamp !== undefined && (
877
- <div className="mt-0.5 -ml-1 flex items-center gap-1">
900
+ {footerTimestamp !== undefined && (
901
+ <div
902
+ data-agent-message-footer=""
903
+ 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"
904
+ >
878
905
  <Tooltip
879
906
  content={copied === entry.copyText ? "Copied!" : "Copy message"}
880
907
  asChild
@@ -892,24 +919,21 @@ export function CloudOsChatMessages({
892
919
  )}
893
920
  </button>
894
921
  </Tooltip>
895
- <Tooltip content={formatFullTimestamp(entry.timestamp)} asChild>
922
+ <Tooltip content={formatFullTimestamp(footerTimestamp)} asChild>
896
923
  <span className="px-1 font-mono text-[11px] leading-4 text-kumo-inactive">
897
- {formatClockTime(entry.timestamp)}
924
+ {formatClockTime(footerTimestamp)}
898
925
  </span>
899
926
  </Tooltip>
900
927
  </div>
901
928
  )}
902
929
 
903
- {contentBlocks.map((block) =>
904
- block.kind === "suggestions" ? (
905
- <SuggestionsBlock
906
- key={block.key}
907
- items={block.items}
908
- onSuggestion={(text) => onSuggestion?.(text)}
909
- />
910
- ) : null
911
- )}
912
- </div>
930
+ {followUpBlocks.map((block) => (
931
+ <SuggestionsBlock
932
+ key={block.key}
933
+ items={block.items}
934
+ onSuggestion={(text) => onSuggestion?.(text)}
935
+ />
936
+ ))}
913
937
  </div>
914
938
  );
915
939
  })}
@@ -938,19 +962,46 @@ export function CloudOsChatMessages({
938
962
 
939
963
  <div ref={endRef} />
940
964
  </div>
965
+ </div>
966
+
967
+ <WorkshopIconButton
968
+ aria-label="Scroll to bottom"
969
+ onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
970
+ 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 ${
971
+ showScrollButton
972
+ ? "translate-y-0 opacity-100"
973
+ : "pointer-events-none translate-y-2 opacity-0"
974
+ }`}
975
+ >
976
+ <ArrowDown size={15} />
977
+ </WorkshopIconButton>
941
978
  </div>
942
979
 
943
- <WorkshopIconButton
944
- aria-label="Scroll to bottom"
945
- onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
946
- 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 ${
947
- showScrollButton
948
- ? "translate-y-0 opacity-100"
949
- : "pointer-events-none translate-y-2 opacity-0"
950
- }`}
951
- >
952
- <ArrowDown size={15} />
953
- </WorkshopIconButton>
980
+ <AnimatePresence initial={false}>
981
+ {currentPlan && completedPlanSteps < currentPlan.steps.length && (
982
+ <motion.div
983
+ key="task-progress"
984
+ initial={reducedMotion ? { opacity: 1 } : { opacity: 0, y: 8, scale: 0.98 }}
985
+ animate={{ opacity: 1, y: 0, scale: 1 }}
986
+ exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98 }}
987
+ transition={{ duration: reducedMotion ? 0 : 0.2, ease: [0.32, 0.72, 0, 1] }}
988
+ className="relative z-10 mx-auto w-full max-w-3xl shrink-0 px-4"
989
+ >
990
+ <section
991
+ data-cloud-os-plan-progress=""
992
+ aria-label="Task progress"
993
+ className="mx-auto border border-b-0 border-kumo-line bg-kumo-base px-4 py-2.5"
994
+ style={{ width: "90%", borderRadius: "1rem 1rem 0 0" }}
995
+ >
996
+ <PlanBlock
997
+ steps={currentPlan.steps}
998
+ running={isActive}
999
+ defaultOpen={false}
1000
+ />
1001
+ </section>
1002
+ </motion.div>
1003
+ )}
1004
+ </AnimatePresence>
954
1005
 
955
1006
  </div>
956
1007
  );