@springbrand/message-panel 0.1.3-alpha.24 → 0.1.3-alpha.26

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.
@@ -0,0 +1,9 @@
1
+ <svg preserveAspectRatio="none" overflow="visible" width="12.2369" height="12.2369" viewBox="0 0 12.2369 12.2369" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M5.18061 0.652963C5.50276 -0.217655 6.73415 -0.217654 7.05631 0.652964L7.64089 2.23277C8.04603 3.32764 8.90927 4.19089 10.0042 4.59603L11.584 5.18061C12.4546 5.50276 12.4546 6.73415 11.584 7.05631L10.0041 7.64089C8.90927 8.04603 8.04603 8.90927 7.64089 10.0042L7.05631 11.584C6.73415 12.4546 5.50276 12.4546 5.18061 11.584L4.59603 10.0041C4.19089 8.90927 3.32764 8.04603 2.23277 7.64089L0.652963 7.05631C-0.217655 6.73415 -0.217654 5.50276 0.652964 5.18061L2.23277 4.59603C3.32764 4.19089 4.19089 3.32764 4.59603 2.23277L5.18061 0.652963Z" fill="url(#thinking-spark-gradient)"/>
3
+ <defs>
4
+ <radialGradient id="thinking-spark-gradient" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(6.11846 2.11846) rotate(90) scale(12)">
5
+ <stop stop-color="#FF9938"/>
6
+ <stop offset="1" stop-color="#FFE46C"/>
7
+ </radialGradient>
8
+ </defs>
9
+ </svg>
@@ -33,7 +33,11 @@ import {
33
33
  SuggestionsBlock,
34
34
  type ApprovalDecision,
35
35
  } from "./rich-blocks";
36
- import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
36
+ import {
37
+ ToolGroupRow,
38
+ WorkDescriptionRow,
39
+ WorkTraceDisclosure,
40
+ } from "./tool-rows";
37
41
  import { CloudOsActivityIndicator } from "./activity-indicator";
38
42
  import { ProductGeneration } from "./image-generation";
39
43
  import { GenerationLoader } from "./loading-state";
@@ -47,6 +51,7 @@ import {
47
51
  deriveTurnActivity,
48
52
  formatClockTime,
49
53
  formatFullTimestamp,
54
+ partitionAssistantBlocks,
50
55
  rhythmTopClass,
51
56
  type CloudOsEntry,
52
57
  type CloudOsActionPresentation,
@@ -103,6 +108,18 @@ export interface CloudOsChatMessagesProps {
103
108
  onCopy?: (text: string) => void;
104
109
  }
105
110
 
111
+ export function collapseEntriesWithNewNarrative(
112
+ expanded: ReadonlySet<string>,
113
+ previousNarrativeKeys: ReadonlySet<string>,
114
+ narrativeKeys: ReadonlySet<string>,
115
+ ): ReadonlySet<string> {
116
+ const next = new Set(expanded);
117
+ for (const key of narrativeKeys) {
118
+ if (!previousNarrativeKeys.has(key)) next.delete(key);
119
+ }
120
+ return next.size === expanded.size ? expanded : next;
121
+ }
122
+
106
123
  function useCopyAction(): {
107
124
  copied: string | null;
108
125
  copy: (text: string) => void;
@@ -311,6 +328,9 @@ export function CloudOsChatMessages({
311
328
  const [expandedToolCalls, setExpandedToolCalls] = useState<ReadonlySet<string>>(
312
329
  () => new Set(),
313
330
  );
331
+ const [expandedWorkEntries, setExpandedWorkEntries] = useState<
332
+ ReadonlySet<string>
333
+ >(() => new Set());
314
334
  const [expandedCompactions, setExpandedCompactions] = useState<
315
335
  ReadonlySet<string>
316
336
  >(() => new Set());
@@ -328,6 +348,31 @@ export function CloudOsChatMessages({
328
348
  }),
329
349
  [approvalIdOf, isActive, messages, showThinkingTraces],
330
350
  );
351
+ const narrativeEntryKeys = useMemo(
352
+ () =>
353
+ new Set(
354
+ entries.flatMap((entry) =>
355
+ entry.type === "assistant" &&
356
+ partitionAssistantBlocks(entry.blocks).content.some(
357
+ (block) => block.kind === "text",
358
+ )
359
+ ? [entry.key]
360
+ : [],
361
+ ),
362
+ ),
363
+ [entries],
364
+ );
365
+ const previousNarrativeEntryKeys = useRef<ReadonlySet<string>>(
366
+ narrativeEntryKeys,
367
+ );
368
+
369
+ useEffect(() => {
370
+ const previous = previousNarrativeEntryKeys.current;
371
+ previousNarrativeEntryKeys.current = narrativeEntryKeys;
372
+ setExpandedWorkEntries((expanded) =>
373
+ collapseEntriesWithNewNarrative(expanded, previous, narrativeEntryKeys),
374
+ );
375
+ }, [narrativeEntryKeys]);
331
376
 
332
377
  const entryTopClasses = useMemo(
333
378
  () =>
@@ -346,6 +391,15 @@ export function CloudOsChatMessages({
346
391
  });
347
392
  }, []);
348
393
 
394
+ const toggleWorkEntry = useCallback((key: string) => {
395
+ setExpandedWorkEntries((current) => {
396
+ const next = new Set(current);
397
+ if (next.has(key)) next.delete(key);
398
+ else next.add(key);
399
+ return next;
400
+ });
401
+ }, []);
402
+
349
403
  const toggleCompaction = useCallback((key: string) => {
350
404
  setExpandedCompactions((current) => {
351
405
  const next = new Set(current);
@@ -493,7 +547,7 @@ export function CloudOsChatMessages({
493
547
  <span className="h-px flex-1 bg-kumo-line" aria-hidden="true" />
494
548
  </div>
495
549
  {expanded && (
496
- <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]">
550
+ <div className="cos-markdown mt-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3.5 text-[13px] leading-[19px]">
497
551
  <MarkdownMessage
498
552
  message={entry.summary}
499
553
  resolveUrl={resolveUrl}
@@ -504,24 +558,90 @@ export function CloudOsChatMessages({
504
558
  );
505
559
  }
506
560
 
507
- const hasNarrative = entry.blocks.some(
561
+ const { work: workBlocks, content: contentBlocks } =
562
+ partitionAssistantBlocks(entry.blocks);
563
+ const workGroups = workBlocks.flatMap((block) =>
564
+ block.kind === "toolGroup" ? [block.group] : [],
565
+ );
566
+ const hasNarrative = contentBlocks.some(
508
567
  (block) => block.kind === "text",
509
568
  );
510
569
 
511
570
  return (
512
571
  <div
513
572
  key={entry.key}
514
- className={`${topClass} min-w-0 w-full max-w-[860px] space-y-2`}
573
+ className={`${topClass} min-w-0 w-full max-w-[860px]`}
515
574
  >
516
- <div className="group/agentMessage relative space-y-1.5">
517
- {entry.blocks.map((block) => {
575
+ <div className="group/agentMessage relative space-y-4">
576
+ {workBlocks.length > 0 && (
577
+ <WorkTraceDisclosure
578
+ durationMs={entry.durationMs}
579
+ groups={workGroups}
580
+ completed={!isActive}
581
+ open={expandedWorkEntries.has(entry.key)}
582
+ onToggle={() => toggleWorkEntry(entry.key)}
583
+ >
584
+ {workBlocks.map((block) => {
585
+ switch (block.kind) {
586
+ case "reasoning":
587
+ case "text":
588
+ return (
589
+ <WorkDescriptionRow
590
+ key={block.key}
591
+ text={block.text}
592
+ resolveUrl={resolveUrl}
593
+ />
594
+ );
595
+ case "toolGroup":
596
+ return (
597
+ <ToolGroupRow
598
+ key={block.key}
599
+ group={block.group}
600
+ open={expandedToolCalls.has(block.group.key)}
601
+ expandedKeys={expandedToolCalls}
602
+ onToggle={toggleToolCall}
603
+ />
604
+ );
605
+ case "plan":
606
+ return (
607
+ <PlanBlock
608
+ key={block.key}
609
+ steps={block.steps}
610
+ running={block.running}
611
+ defaultOpen={false}
612
+ showCollapsedSummary={false}
613
+ />
614
+ );
615
+ case "parallel":
616
+ return (
617
+ <ParallelBlock
618
+ key={block.key}
619
+ label={block.label}
620
+ tools={block.tools}
621
+ />
622
+ );
623
+ case "subagents":
624
+ return (
625
+ <SubAgentsBlock
626
+ key={block.key}
627
+ agents={block.agents}
628
+ />
629
+ );
630
+ default:
631
+ return null;
632
+ }
633
+ })}
634
+ </WorkTraceDisclosure>
635
+ )}
636
+
637
+ {contentBlocks.map((block) => {
518
638
  switch (block.kind) {
519
639
  case "reasoning":
520
640
  return (
521
- <ThinkingTraceRow
641
+ <WorkDescriptionRow
522
642
  key={block.key}
523
- reasoning={block.text}
524
- running={block.running}
643
+ text={block.text}
644
+ resolveUrl={resolveUrl}
525
645
  />
526
646
  );
527
647
  case "text": {
@@ -1,3 +1,9 @@
1
+ import {
2
+ TextMessagePartProvider,
3
+ useMessagePartText,
4
+ useSmooth,
5
+ type SmoothOptions,
6
+ } from "@assistant-ui/react";
1
7
  import { memo, useMemo, type ReactNode } from "react";
2
8
  import {
3
9
  defaultRehypePlugins,
@@ -14,7 +20,10 @@ import {
14
20
  export type CloudOsUrlResolver = (url: string) => string | null | undefined;
15
21
 
16
22
  const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
17
- const CJK_TEXT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
23
+ const SMOOTH_STREAMING = {
24
+ maxCharsPerFrame: 12,
25
+ minCommitMs: 32,
26
+ } satisfies SmoothOptions;
18
27
 
19
28
  export function safeExternalUrl(href: string | undefined): string | undefined {
20
29
  if (!href) return undefined;
@@ -101,6 +110,20 @@ export const MarkdownMessage = memo(function MarkdownMessage({
101
110
  resolveUrl?: CloudOsUrlResolver;
102
111
  streaming?: boolean;
103
112
  }): ReactNode {
113
+ return (
114
+ <TextMessagePartProvider text={message} isRunning={streaming}>
115
+ <MarkdownMessageContent resolveUrl={resolveUrl} />
116
+ </TextMessagePartProvider>
117
+ );
118
+ });
119
+
120
+ function MarkdownMessageContent({
121
+ resolveUrl,
122
+ }: {
123
+ resolveUrl?: CloudOsUrlResolver;
124
+ }) {
125
+ const part = useSmooth(useMessagePartText(), SMOOTH_STREAMING);
126
+ const streaming = part.status.type === "running";
104
127
  const rehypePlugins = useMemo(
105
128
  () =>
106
129
  resolveUrl
@@ -110,13 +133,7 @@ export const MarkdownMessage = memo(function MarkdownMessage({
110
133
  );
111
134
  return (
112
135
  <Streamdown
113
- animated={
114
- streaming
115
- ? CJK_TEXT.test(message)
116
- ? { sep: "char", duration: 100, stagger: 5 }
117
- : true
118
- : false
119
- }
136
+ animated={false}
120
137
  className="space-y-0"
121
138
  controls={false}
122
139
  mode={streaming ? "streaming" : "static"}
@@ -125,7 +142,7 @@ export const MarkdownMessage = memo(function MarkdownMessage({
125
142
  skipHtml
126
143
  components={DEFAULT_COMPONENTS}
127
144
  >
128
- {message}
145
+ {part.text}
129
146
  </Streamdown>
130
147
  );
131
- });
148
+ }
@@ -4,6 +4,7 @@ import {
4
4
  Bell,
5
5
  CaretRight,
6
6
  Check,
7
+ CheckCircle,
7
8
  CircleNotch,
8
9
  Clock,
9
10
  Key,
@@ -32,7 +33,7 @@ import {
32
33
  * UIMessage 协议里存在、gadgets 那套没有的几类 part —— 计划快照、原位提问、
33
34
  * 后续建议、定时任务、并行/子 Agent、原位授权。视觉语言全部沿用 cloud-os:
34
35
  * 折叠工作行用 `px-1.5 py-1` 的行式布局,卡片用
35
- * `themed-surface-inset rounded-2xl border-kumo-line/70 bg-kumo-elevated/45 p-3`,
36
+ * `rounded-2xl border-kumo-line/70 bg-kumo-elevated/45 p-3`,
36
37
  * 授权卡沿用 renderActionCard 的 blocking callout(brand/40 边 + brand/10 底)。
37
38
  */
38
39
 
@@ -41,13 +42,17 @@ import {
41
42
  export function PlanBlock({
42
43
  steps,
43
44
  running,
45
+ defaultOpen = true,
46
+ showCollapsedSummary = true,
44
47
  }: {
45
48
  steps: PlanStep[];
46
49
  running: boolean;
50
+ defaultOpen?: boolean;
51
+ showCollapsedSummary?: boolean;
47
52
  }) {
48
53
  const done = steps.filter((step) => step.status === "done").length;
49
54
  const current = steps.find((step) => step.status === "in_progress");
50
- const [open, setOpen] = useState(true);
55
+ const [open, setOpen] = useState(defaultOpen);
51
56
 
52
57
  return (
53
58
  <div className="group -ml-0.5">
@@ -71,7 +76,7 @@ export function PlanBlock({
71
76
  className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
72
77
  />
73
78
  </span>
74
- {!open && current && (
79
+ {!open && showCollapsedSummary && current && (
75
80
  <span className="mt-1 block truncate font-mono text-[12px] leading-4 text-kumo-inactive">
76
81
  {current.text}
77
82
  </span>
@@ -79,35 +84,34 @@ export function PlanBlock({
79
84
  </span>
80
85
  </button>
81
86
  {open && (
82
- <div className="themed-surface-inset ml-8 mt-1 space-y-1.5 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3">
87
+ <div className="ml-8 mt-1 flex flex-col gap-2 rounded-xl border border-kumo-line bg-[rgba(0,0,0,0.03)] px-4 py-3">
83
88
  {steps.map((step, index) => (
84
89
  <div
85
90
  key={`${step.text}-${index}`}
86
- className="flex items-start gap-2.5 text-[13px] leading-[19px] tracking-[-0.25px]"
91
+ className="flex items-center gap-2 text-[14px] leading-[1.4] font-normal tracking-normal"
87
92
  >
88
93
  <span
89
- className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center"
94
+ className="flex size-4 flex-shrink-0 items-center justify-center"
90
95
  aria-hidden="true"
91
96
  >
92
97
  {step.status === "done" ? (
93
- <Check size={12} weight="bold" className="text-kumo-success" />
98
+ <CheckCircle size={16} weight="fill" className="text-[#00bf75]" />
94
99
  ) : step.status === "in_progress" ? (
95
100
  <CircleNotch
96
- size={12}
97
- weight="bold"
98
- className="animate-spin text-kumo-brand motion-reduce:animate-none"
101
+ size={16}
102
+ className="animate-spin text-[#ff9938] motion-reduce:animate-none"
99
103
  />
100
104
  ) : (
101
- <span className="h-1.5 w-1.5 rounded-full bg-kumo-fill" />
105
+ <span className="h-1.5 w-1.5 rounded-full bg-[color:var(--text-color-kumo-default)] opacity-20" />
102
106
  )}
103
107
  </span>
104
108
  <span
105
109
  className={
106
110
  step.status === "done"
107
- ? "min-w-0 flex-1 text-kumo-inactive line-through"
111
+ ? "min-w-0 flex-1 text-kumo-default/30 line-through"
108
112
  : step.status === "in_progress"
109
- ? "min-w-0 flex-1 font-medium text-kumo-default"
110
- : "min-w-0 flex-1 text-kumo-subtle"
113
+ ? "min-w-0 flex-1 text-kumo-default"
114
+ : "min-w-0 flex-1 text-kumo-default/70"
111
115
  }
112
116
  >
113
117
  {step.text}
@@ -255,7 +259,7 @@ export function AskUserBlock({
255
259
  data-part-type="ask_user"
256
260
  data-state={outcome.kind === "answered" ? "submitted" : outcome.kind}
257
261
  >
258
- <div className="flex flex-col gap-3 rounded-xl border border-kumo-line bg-kumo-base px-3 py-2.5">
262
+ <div className="flex flex-col gap-3 rounded-xl border border-kumo-line bg-operation-base px-3 py-2.5">
259
263
  {outcome.kind === "pending" ? (
260
264
  <>
261
265
  {questions.length > 1 && (
@@ -308,7 +312,7 @@ export function AskUserBlock({
308
312
  } ${
309
313
  selected
310
314
  ? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
311
- : "border-kumo-line bg-kumo-base"
315
+ : "border-kumo-line bg-operation-base"
312
316
  }`}
313
317
  >
314
318
  <input
@@ -543,7 +547,7 @@ export function ScheduleConfirmation({
543
547
  <section
544
548
  data-slot="schedule-card"
545
549
  aria-label={`Confirm scheduled task: ${name}`}
546
- className="themed-surface-inset flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated p-4 text-kumo-default shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(0,0,0,0.12)]"
550
+ className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-kumo-line/70 bg-operation-base p-4 text-kumo-default shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(0,0,0,0.12)]"
547
551
  >
548
552
  <div className="flex items-center gap-2.5">
549
553
  <span
@@ -607,7 +611,7 @@ export function ScheduleBlock({
607
611
  "aria-label": `Open scheduled task: ${String(call.input.label ?? call.input.title ?? "Scheduled task")}`,
608
612
  }
609
613
  : {})}
610
- className={`w-full rounded-2xl border border-kumo-line bg-kumo-base px-4 py-3 text-left${
614
+ className={`w-full rounded-2xl border border-kumo-line bg-operation-base px-4 py-3 text-left${
611
615
  clickable
612
616
  ? " cursor-pointer transition-colors duration-150 ease-out hover:bg-kumo-tint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/50"
613
617
  : ""
@@ -685,7 +689,7 @@ export function PermissionGrant({
685
689
  return (
686
690
  <section
687
691
  aria-label={ariaLabel}
688
- className="themed-surface-inset w-full rounded-xl border border-kumo-line bg-kumo-elevated p-4 text-kumo-default"
692
+ className="w-full rounded-xl border border-kumo-line bg-operation-base p-4 text-kumo-default"
689
693
  >
690
694
  <div className="flex items-center gap-2">
691
695
  <span
@@ -814,67 +818,93 @@ export function ParallelBlock({
814
818
  tools: ParallelTool[];
815
819
  }) {
816
820
  const done = tools.filter((tool) => tool.status === "done").length;
821
+ const [open, setOpen] = useState(false);
817
822
  return (
818
823
  <div className="max-w-[860px]">
819
- <div className="flex items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
824
+ <button
825
+ type="button"
826
+ onClick={() => setOpen((value) => !value)}
827
+ className="flex cursor-pointer items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle transition-colors duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none"
828
+ aria-expanded={open}
829
+ >
820
830
  <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
821
831
  <WorkIcon Icon={ListChecks} />
822
832
  </span>
823
833
  <span className="min-w-0 truncate">
824
834
  {label} · {done}/{tools.length}
825
835
  </span>
826
- </div>
827
- <div className="ml-8 mt-1 space-y-1">
828
- {tools.map((tool, index) => (
829
- <div
830
- key={`${tool.label}-${index}`}
831
- className="flex items-center gap-2 px-1.5 text-[13px] leading-[19px] tracking-[-0.25px] text-kumo-subtle"
832
- >
833
- <StatusDot status={tool.status} />
834
- <span className="min-w-0 flex-1 truncate">{tool.label}</span>
835
- {tool.meta && (
836
- <span className="flex-shrink-0 font-mono text-[11px] leading-4 text-kumo-inactive">
837
- {tool.meta}
838
- </span>
839
- )}
840
- </div>
841
- ))}
842
- </div>
836
+ <CaretRight
837
+ size={13}
838
+ weight="bold"
839
+ className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
840
+ />
841
+ </button>
842
+ {open && (
843
+ <div className="ml-8 mt-1 space-y-1">
844
+ {tools.map((tool, index) => (
845
+ <div
846
+ key={`${tool.label}-${index}`}
847
+ className="flex items-center gap-2 px-1.5 text-[13px] leading-[19px] tracking-[-0.25px] text-kumo-subtle"
848
+ >
849
+ <StatusDot status={tool.status} />
850
+ <span className="min-w-0 flex-1 truncate">{tool.label}</span>
851
+ {tool.meta && (
852
+ <span className="flex-shrink-0 font-mono text-[11px] leading-4 text-kumo-inactive">
853
+ {tool.meta}
854
+ </span>
855
+ )}
856
+ </div>
857
+ ))}
858
+ </div>
859
+ )}
843
860
  </div>
844
861
  );
845
862
  }
846
863
 
847
864
  export function SubAgentsBlock({ agents }: { agents: SubAgentView[] }) {
865
+ const [open, setOpen] = useState(false);
848
866
  return (
849
867
  <div className="max-w-[860px]">
850
- <div className="flex items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
868
+ <button
869
+ type="button"
870
+ onClick={() => setOpen((value) => !value)}
871
+ className="flex cursor-pointer items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle transition-colors duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none"
872
+ aria-expanded={open}
873
+ >
851
874
  <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
852
875
  <WorkIcon Icon={UsersThree} />
853
876
  </span>
854
877
  <span className="min-w-0 truncate">
855
878
  {agents.length === 1 ? "Sub-agent" : `${agents.length} sub-agents`}
856
879
  </span>
857
- </div>
858
- <div className="ml-8 mt-1 space-y-1">
859
- {agents.map((agent, index) => (
860
- <div
861
- key={`${agent.name}-${index}`}
862
- className="flex items-start gap-2 px-1.5 text-[13px] leading-[19px] tracking-[-0.25px]"
863
- >
864
- <span className="mt-1.5 flex">
865
- <StatusDot status={agent.status} />
866
- </span>
867
- <span className="min-w-0 flex-1">
868
- <span className="block truncate font-medium text-kumo-default">
869
- {agent.name}
880
+ <CaretRight
881
+ size={13}
882
+ weight="bold"
883
+ className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
884
+ />
885
+ </button>
886
+ {open && (
887
+ <div className="ml-8 mt-1 space-y-1">
888
+ {agents.map((agent, index) => (
889
+ <div
890
+ key={`${agent.name}-${index}`}
891
+ className="flex items-start gap-2 px-1.5 text-[13px] leading-[19px] tracking-[-0.25px]"
892
+ >
893
+ <span className="mt-1.5 flex">
894
+ <StatusDot status={agent.status} />
870
895
  </span>
871
- {agent.summary && (
872
- <span className="block text-kumo-subtle">{agent.summary}</span>
873
- )}
874
- </span>
875
- </div>
876
- ))}
877
- </div>
896
+ <span className="min-w-0 flex-1">
897
+ <span className="block truncate font-medium text-kumo-default">
898
+ {agent.name}
899
+ </span>
900
+ {agent.summary && (
901
+ <span className="block text-kumo-subtle">{agent.summary}</span>
902
+ )}
903
+ </span>
904
+ </div>
905
+ ))}
906
+ </div>
907
+ )}
878
908
  </div>
879
909
  );
880
910
  }
@@ -1,6 +1,10 @@
1
1
  import { Atom, CaretDown, CaretRight, Copy } from "@phosphor-icons/react";
2
- import { memo, useId, useState } from "react";
3
- import { MarkdownMessage } from "./markdown-message";
2
+ import { motion, useReducedMotion } from "motion/react";
3
+ import { memo, useId, useState, type ReactNode } from "react";
4
+ import {
5
+ MarkdownMessage,
6
+ type CloudOsUrlResolver,
7
+ } from "./markdown-message";
4
8
  import {
5
9
  getToolCallSummary,
6
10
  getToolIcon,
@@ -10,6 +14,11 @@ import {
10
14
  type ToolCallGroup,
11
15
  } from "./tool-presentation";
12
16
 
17
+ const thinkingSpark = new URL(
18
+ "../assets/thinking-spark.svg",
19
+ import.meta.url,
20
+ ).href;
21
+
13
22
  /**
14
23
  * 工作行(work rows): WorkIcon / ToolCallDetails / NestedToolCallRow /
15
24
  * ToolGroupRow / ThinkingTraceRow。渲染 UIMessage 归一化后的 CloudOsToolCall。
@@ -34,26 +43,74 @@ function displayOutput(value: unknown, fallback: string): string {
34
43
  return displayValue(record);
35
44
  }
36
45
 
37
- function OutputPre({ value }: { value: string }) {
46
+ interface ToolDetailItem {
47
+ key: string;
48
+ label: string;
49
+ output: string;
50
+ error: string;
51
+ }
52
+
53
+ function detailItems(calls: readonly CloudOsToolCall[]): ToolDetailItem[] {
54
+ return calls.map((call) => {
55
+ const summary = getToolCallSummary(call);
56
+ return {
57
+ key: call.toolCallId,
58
+ label: `${summary.verb}${summary.target ? ` ${summary.target}` : ""}`,
59
+ output: displayOutput(call.output, call.outputText),
60
+ error: call.errorText,
61
+ };
62
+ });
63
+ }
64
+
65
+ function OutputCard({ items }: { items: readonly ToolDetailItem[] }) {
66
+ const copyValue = items
67
+ .map((item) => {
68
+ const detail = [item.error, item.output]
69
+ .filter(Boolean)
70
+ .join("\n\n") || "No additional data";
71
+ return items.length === 1 ? detail : `${item.label}\n${detail}`;
72
+ })
73
+ .join("\n\n");
38
74
  return (
39
75
  <div
40
- className="rounded-xl border border-kumo-line/70 bg-kumo-elevated/45 px-4 py-3 text-kumo-subtle"
76
+ className="rounded-xl border border-[rgba(0,0,0,0.07)] bg-[rgba(0,0,0,0.03)] px-4 py-3 text-cos-output"
41
77
  data-output-card=""
42
78
  >
43
79
  <div className="flex items-center justify-between gap-3 text-[14px] font-medium leading-5">
44
80
  <span>Output</span>
45
81
  <button
46
82
  type="button"
47
- onClick={() => navigator.clipboard.writeText(value)}
48
- className="flex h-4 w-4 items-center justify-center text-kumo-subtle transition-colors hover:text-kumo-default focus-visible:outline-none focus-visible:text-kumo-default"
83
+ onClick={() => navigator.clipboard.writeText(copyValue)}
84
+ className="flex h-4 w-4 items-center justify-center text-cos-output transition-colors hover:text-kumo-default focus-visible:outline-none focus-visible:text-kumo-default"
49
85
  aria-label="Copy output"
50
86
  >
51
87
  <Copy size={16} />
52
88
  </button>
53
89
  </div>
54
- <pre className="mt-2 max-h-56 overflow-auto border-t border-kumo-line/70 pt-3 font-sans text-[14px] font-normal leading-5 whitespace-pre-wrap">
55
- {value}
56
- </pre>
90
+ <div className="mt-3 max-h-56 space-y-3 overflow-auto border-t border-kumo-line/70 pt-3 text-[14px] font-normal leading-[1.4]">
91
+ {items.map((item) => (
92
+ <div key={item.key} className="space-y-1.5">
93
+ {items.length > 1 && (
94
+ <p className="m-0 text-[12px] leading-4 text-cos-output">
95
+ {item.label}
96
+ </p>
97
+ )}
98
+ {item.error && (
99
+ <p className="m-0 whitespace-pre-wrap text-kumo-danger">
100
+ {item.error}
101
+ </p>
102
+ )}
103
+ {item.output && (
104
+ <p className="m-0 whitespace-pre-wrap text-cos-output">
105
+ {item.output}
106
+ </p>
107
+ )}
108
+ {!item.error && !item.output && (
109
+ <p className="m-0 text-cos-output">No additional data</p>
110
+ )}
111
+ </div>
112
+ ))}
113
+ </div>
57
114
  </div>
58
115
  );
59
116
  }
@@ -63,22 +120,15 @@ export const ToolCallDetails = memo(function ToolCallDetails({
63
120
  }: {
64
121
  toolCall: CloudOsToolCall;
65
122
  }) {
66
- const output = displayOutput(tc.output, tc.outputText);
67
- return (
68
- <div className="space-y-2">
69
- {tc.errorText && (
70
- <pre className="rounded-xl border border-kumo-danger/20 bg-kumo-danger-tint/40 p-3 font-mono text-[12px] leading-[18px] text-kumo-danger whitespace-pre-wrap">
71
- {tc.errorText}
72
- </pre>
73
- )}
74
- {output && <OutputPre value={output} />}
75
- {!output && !tc.errorText && (
76
- <p className="m-0 text-[12px] leading-4 text-kumo-inactive">
77
- No additional data
78
- </p>
79
- )}
80
- </div>
81
- );
123
+ return <OutputCard items={detailItems([tc])} />;
124
+ });
125
+
126
+ export const ToolGroupDetails = memo(function ToolGroupDetails({
127
+ calls,
128
+ }: {
129
+ calls: readonly CloudOsToolCall[];
130
+ }) {
131
+ return <OutputCard items={detailItems(calls)} />;
82
132
  });
83
133
 
84
134
  export const NestedToolCallRow = memo(function NestedToolCallRow({
@@ -184,6 +234,172 @@ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
184
234
  );
185
235
  });
186
236
 
237
+ export function formatWorkDuration(milliseconds: number): string {
238
+ const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
239
+ const hours = Math.floor(totalSeconds / 3600);
240
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
241
+ const seconds = totalSeconds % 60;
242
+ if (hours > 0) {
243
+ return `${hours}h ${minutes}m${seconds > 0 ? ` ${seconds}s` : ""}`;
244
+ }
245
+ if (minutes > 0) return `${minutes}m${seconds > 0 ? ` ${seconds}s` : ""}`;
246
+ return `${seconds}s`;
247
+ }
248
+
249
+ interface ActivitySummary {
250
+ leading: string;
251
+ emphasis?: string;
252
+ trailing?: string;
253
+ }
254
+
255
+ function plural(count: number, singular: string, plural = `${singular}s`): string {
256
+ return `${count} ${count === 1 ? singular : plural}`;
257
+ }
258
+
259
+ function activitySummary(groups: readonly ToolCallGroup[]): ActivitySummary | null {
260
+ const calls = groups.flatMap((group) => group.calls);
261
+ const count = (kind: CloudOsToolCall["kind"]) =>
262
+ calls.filter((call) => call.kind === kind).length;
263
+ const webSearches = count("web-search");
264
+ const webPages = count("web-fetch");
265
+ if (webSearches > 0 && webPages > 0) {
266
+ return {
267
+ leading: "searched the web and retrieved ",
268
+ emphasis: `${webPages} web`,
269
+ trailing: webPages === 1 ? " page" : " pages",
270
+ };
271
+ }
272
+ if (webSearches > 0) return { leading: "searched the web" };
273
+ if (webPages > 0) {
274
+ return {
275
+ leading: "retrieved ",
276
+ emphasis: `${webPages} web`,
277
+ trailing: webPages === 1 ? " page" : " pages",
278
+ };
279
+ }
280
+
281
+ const firstKnown = calls.find((call) =>
282
+ ["read", "write", "edit", "bash", "skill", "javascript"].includes(
283
+ call.kind,
284
+ ),
285
+ );
286
+ if (!firstKnown) return null;
287
+ const total = count(firstKnown.kind);
288
+ switch (firstKnown.kind) {
289
+ case "read":
290
+ return { leading: `read ${plural(total, "file")}` };
291
+ case "write":
292
+ return { leading: `wrote ${plural(total, "file")}` };
293
+ case "edit":
294
+ return { leading: `made ${plural(total, "edit")}` };
295
+ case "bash":
296
+ return { leading: `ran ${plural(total, "command")}` };
297
+ case "skill":
298
+ return { leading: `read ${plural(total, "instruction set")}` };
299
+ case "javascript":
300
+ return { leading: total === 1 ? "ran code" : `ran code ${total} times` };
301
+ default:
302
+ return null;
303
+ }
304
+ }
305
+
306
+ export const WorkTraceDisclosure = memo(function WorkTraceDisclosure({
307
+ durationMs,
308
+ groups,
309
+ completed = false,
310
+ open,
311
+ onToggle,
312
+ children,
313
+ }: {
314
+ durationMs?: number;
315
+ groups: readonly ToolCallGroup[];
316
+ completed?: boolean;
317
+ open: boolean;
318
+ onToggle: () => void;
319
+ children: ReactNode;
320
+ }) {
321
+ const contentId = useId();
322
+ const reduceMotion = useReducedMotion();
323
+ const activity = activitySummary(groups);
324
+ const duration = durationMs === undefined ? "" : ` for ${formatWorkDuration(durationMs)}`;
325
+ const verb = completed ? "Worked" : "Thought";
326
+ const label = `${verb}${duration}${activity ? `, ${activity.leading}${activity.emphasis ?? ""}${activity.trailing ?? ""}` : ""}`;
327
+
328
+ return (
329
+ <div data-agent-work="" data-state={open ? "open" : "closed"}>
330
+ <div className="w-full border-b border-kumo-line pb-2">
331
+ <button
332
+ type="button"
333
+ onClick={onToggle}
334
+ aria-expanded={open}
335
+ aria-controls={contentId}
336
+ aria-label={label}
337
+ className="flex min-w-0 items-center gap-2 text-left text-[14px] leading-[1.4] text-cos-work-description transition-colors duration-150 ease-out hover:text-cos-work-description-subtle focus-visible:text-cos-work-description-subtle focus-visible:outline-none"
338
+ >
339
+ <span className="grid size-4 shrink-0 place-items-center" aria-hidden="true">
340
+ <motion.img
341
+ src={thinkingSpark}
342
+ alt=""
343
+ className="size-[12.24px]"
344
+ initial={{ rotate: 0 }}
345
+ animate={
346
+ reduceMotion ? { rotate: 0 } : { rotate: [0, 360, -360, -360] }
347
+ }
348
+ transition={
349
+ reduceMotion
350
+ ? { duration: 0 }
351
+ : {
352
+ rotate: {
353
+ duration: 1.68,
354
+ times: [0, 0.8368, 0.8373, 1],
355
+ ease: [
356
+ [0.5, 0, 0.5, 1],
357
+ [0.5, 0, 0.5, 1],
358
+ "linear",
359
+ ],
360
+ repeat: Infinity,
361
+ },
362
+ }
363
+ }
364
+ />
365
+ </span>
366
+ <span className="min-w-0 truncate">
367
+ <span>{`${verb}${duration}${activity ? `, ${activity.leading}` : ""}`}</span>
368
+ {activity?.emphasis && (
369
+ <span className="text-kumo-default">{activity.emphasis}</span>
370
+ )}
371
+ {activity?.trailing}
372
+ </span>
373
+ <CaretRight
374
+ size={16}
375
+ className={`shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
376
+ aria-hidden="true"
377
+ />
378
+ </button>
379
+ </div>
380
+ {open && (
381
+ <div id={contentId} className="mt-4 space-y-3" data-agent-work-content="">
382
+ {children}
383
+ </div>
384
+ )}
385
+ </div>
386
+ );
387
+ });
388
+
389
+ export const WorkDescriptionRow = memo(function WorkDescriptionRow({
390
+ text,
391
+ resolveUrl,
392
+ }: {
393
+ text: string;
394
+ resolveUrl?: CloudOsUrlResolver;
395
+ }) {
396
+ return (
397
+ <div className="cos-markdown text-[14px] leading-[1.4] text-cos-work-description">
398
+ <MarkdownMessage message={text} resolveUrl={resolveUrl} />
399
+ </div>
400
+ );
401
+ });
402
+
187
403
  export const ToolGroupRow = memo(function ToolGroupRow({
188
404
  group,
189
405
  open,
@@ -195,19 +411,20 @@ export const ToolGroupRow = memo(function ToolGroupRow({
195
411
  expandedKeys: ReadonlySet<string>;
196
412
  onToggle: (key: string) => void;
197
413
  }) {
414
+ void expandedKeys;
198
415
  return (
199
- <div className="group -ml-0.5">
416
+ <div className="group/tool-call">
200
417
  <button
201
418
  type="button"
202
419
  onClick={() => onToggle(group.key)}
203
- className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-1.5 py-1 text-left text-kumo-subtle transition-colors duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none active:scale-[0.995]"
420
+ className="flex min-w-0 items-start gap-2 text-left text-[14px] leading-[1.4] text-cos-tool-group transition-colors duration-150 ease-out hover:text-kumo-subtle focus-visible:text-kumo-subtle focus-visible:outline-none"
204
421
  aria-expanded={open}
205
422
  >
206
- <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
423
+ <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
207
424
  <WorkIcon Icon={group.Icon} />
208
425
  </span>
209
426
  <span className="min-w-0 flex-1">
210
- <span className="flex min-w-0 items-center gap-2 text-[14px] leading-5 tracking-[-0.25px]">
427
+ <span className="flex min-w-0 items-center gap-2">
211
428
  <span
212
429
  className={`min-w-0 truncate ${group.hasRunning ? "cos-thinking-shimmer" : ""}`}
213
430
  >
@@ -219,38 +436,22 @@ export const ToolGroupRow = memo(function ToolGroupRow({
219
436
  </span>
220
437
  )}
221
438
  <CaretRight
222
- size={13}
223
- weight="bold"
224
- className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
439
+ size={16}
440
+ className={`shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
225
441
  />
226
442
  </span>
227
443
  {group.detailLines.length > 1 && (
228
- <span className="mt-1 block truncate font-mono text-[12px] leading-4 text-kumo-inactive">
444
+ <span className="mt-2 block truncate text-[12px] leading-4 text-[rgba(0,0,0,0.3)]">
229
445
  {group.detailLines.join(" · ")}
230
446
  </span>
231
447
  )}
232
448
  </span>
233
449
  </button>
234
- {open &&
235
- (group.calls.length === 1 ? (
236
- <div className="ml-8 mt-1 space-y-3">
237
- <ToolCallDetails toolCall={group.calls[0]} />
238
- </div>
239
- ) : (
240
- <div className="ml-8 mt-1 space-y-1">
241
- {group.calls.map((toolCall) => {
242
- const key = `call-${toolCall.toolCallId}`;
243
- return (
244
- <NestedToolCallRow
245
- key={toolCall.toolCallId}
246
- toolCall={toolCall}
247
- open={expandedKeys.has(key)}
248
- onToggle={onToggle}
249
- />
250
- );
251
- })}
252
- </div>
253
- ))}
450
+ {open && (
451
+ <div className="mt-3 pl-6">
452
+ <ToolGroupDetails calls={group.calls} />
453
+ </div>
454
+ )}
254
455
  </div>
255
456
  );
256
457
  });
@@ -27,6 +27,7 @@ export interface CloudOsMessageMetadata extends UnknownRecord {
27
27
  createdAt?: number;
28
28
  error?: string;
29
29
  interruptedByUser?: boolean;
30
+ turnId?: string;
30
31
  turnDurationMs?: number;
31
32
  turnStartedAt?: number;
32
33
  turnStatus?: string;
@@ -142,8 +143,10 @@ export type CloudOsEntry =
142
143
  type: "assistant";
143
144
  key: string;
144
145
  messageId: string;
146
+ turnId?: string;
145
147
  blocks: AssistantBlock[];
146
148
  copyText: string;
149
+ durationMs?: number;
147
150
  timestamp?: number;
148
151
  terminal?: { kind: "interrupted" | "error"; title: string; body?: string };
149
152
  };
@@ -167,6 +170,10 @@ function nonNegativeNumber(value: unknown): number | undefined {
167
170
  : undefined;
168
171
  }
169
172
 
173
+ function nonEmptyString(value: unknown): string | undefined {
174
+ return typeof value === "string" && value.trim() ? value : undefined;
175
+ }
176
+
170
177
  export function cloudOsMetadata(message: UIMessage): CloudOsMessageMetadata {
171
178
  return recordOf(message.metadata) as CloudOsMessageMetadata;
172
179
  }
@@ -221,6 +228,54 @@ export function formatFullTimestamp(timestamp: number): string {
221
228
  });
222
229
  }
223
230
 
231
+ function turnDuration(metadata: CloudOsMessageMetadata): number | undefined {
232
+ const explicit = nonNegativeNumber(metadata.turnDurationMs);
233
+ if (explicit !== undefined) return explicit;
234
+ const startedAt = nonNegativeNumber(metadata.turnStartedAt);
235
+ const completedAt = nonNegativeNumber(metadata.completedAt);
236
+ return startedAt !== undefined && completedAt !== undefined
237
+ ? Math.max(0, completedAt - startedAt)
238
+ : undefined;
239
+ }
240
+
241
+ function isWorkBlock(block: AssistantBlock): boolean {
242
+ return (
243
+ block.kind === "reasoning" ||
244
+ block.kind === "toolGroup" ||
245
+ block.kind === "plan" ||
246
+ block.kind === "parallel" ||
247
+ block.kind === "subagents"
248
+ );
249
+ }
250
+
251
+ /**
252
+ * 把一条 assistant 消息拆成可折叠的工作过程与始终可见的最终内容。
253
+ * 最后一段工作之前的 text 是过程旁白;最后一次工作之后的 text 才是正文。
254
+ */
255
+ export function partitionAssistantBlocks(blocks: readonly AssistantBlock[]): {
256
+ work: AssistantBlock[];
257
+ content: AssistantBlock[];
258
+ } {
259
+ let lastWorkIndex = -1;
260
+ blocks.forEach((block, index) => {
261
+ if (isWorkBlock(block)) lastWorkIndex = index;
262
+ });
263
+
264
+ const work: AssistantBlock[] = [];
265
+ const content: AssistantBlock[] = [];
266
+ blocks.forEach((block, index) => {
267
+ if (
268
+ isWorkBlock(block) ||
269
+ (block.kind === "text" && index < lastWorkIndex)
270
+ ) {
271
+ work.push(block);
272
+ } else {
273
+ content.push(block);
274
+ }
275
+ });
276
+ return { work, content };
277
+ }
278
+
224
279
  function toolNameOf(part: unknown): string | null {
225
280
  const type = String(recordOf(part).type ?? "");
226
281
  if (type === "dynamic-tool") {
@@ -620,6 +675,9 @@ export function buildCloudOsEntries({
620
675
  }
621
676
  return -1;
622
677
  })();
678
+ const activeTurnId = isActive && lastAssistantIndex >= 0
679
+ ? nonEmptyString(cloudOsMetadata(messages[lastAssistantIndex]!).turnId)
680
+ : undefined;
623
681
 
624
682
  messages.forEach((message, index) => {
625
683
  if (message.role === "system") return;
@@ -674,8 +732,10 @@ export function buildCloudOsEntries({
674
732
  type: "assistant",
675
733
  key: `assistant-${message.id}-${index}`,
676
734
  messageId: message.id,
735
+ turnId: nonEmptyString(metadata.turnId),
677
736
  blocks,
678
737
  copyText: messageText(message),
738
+ durationMs: turnDuration(metadata),
679
739
  timestamp:
680
740
  nonNegativeNumber(metadata.completedAt) ??
681
741
  nonNegativeNumber(metadata.createdAt),
@@ -683,7 +743,54 @@ export function buildCloudOsEntries({
683
743
  });
684
744
  });
685
745
 
686
- return dropSupersededActionPresentations(dropSupersededPlans(entries));
746
+ return aggregateCompletedTurnWork(
747
+ dropSupersededActionPresentations(dropSupersededPlans(entries)),
748
+ activeTurnId,
749
+ );
750
+ }
751
+
752
+ /** Collapse every completed Turn's assistant segments into one display entry. */
753
+ function aggregateCompletedTurnWork(
754
+ entries: CloudOsEntry[],
755
+ activeTurnId: string | undefined,
756
+ ): CloudOsEntry[] {
757
+ const indexesByTurn = new Map<string, number[]>();
758
+ entries.forEach((entry, index) => {
759
+ if (entry.type !== "assistant" || !entry.turnId) return;
760
+ const indexes = indexesByTurn.get(entry.turnId) ?? [];
761
+ indexes.push(index);
762
+ indexesByTurn.set(entry.turnId, indexes);
763
+ });
764
+
765
+ const hidden = new Set<number>();
766
+ const replacements = new Map<number, CloudOsEntry>();
767
+ for (const [turnId, indexes] of indexesByTurn) {
768
+ if (turnId === activeTurnId || indexes.length < 2) continue;
769
+ const assistantEntries = indexes.map(
770
+ (index) => entries[index] as Extract<CloudOsEntry, { type: "assistant" }>,
771
+ );
772
+ const anchorIndex = indexes[indexes.length - 1]!;
773
+ const anchor = assistantEntries[assistantEntries.length - 1]!;
774
+ const durations = assistantEntries.flatMap((entry) =>
775
+ entry.durationMs === undefined ? [] : [entry.durationMs]
776
+ );
777
+ const timestamps = assistantEntries.flatMap((entry) =>
778
+ entry.timestamp === undefined ? [] : [entry.timestamp]
779
+ );
780
+
781
+ indexes.slice(0, -1).forEach((index) => hidden.add(index));
782
+ replacements.set(anchorIndex, {
783
+ ...anchor,
784
+ key: `assistant-turn-${turnId}`,
785
+ blocks: assistantEntries.flatMap((entry) => entry.blocks),
786
+ ...(durations.length > 0 ? { durationMs: Math.max(...durations) } : {}),
787
+ ...(timestamps.length > 0 ? { timestamp: Math.max(...timestamps) } : {}),
788
+ });
789
+ }
790
+
791
+ return entries.flatMap((entry, index) =>
792
+ hidden.has(index) ? [] : [replacements.get(index) ?? entry]
793
+ );
687
794
  }
688
795
 
689
796
  function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntry[] {
package/cloud-os/index.ts CHANGED
@@ -65,8 +65,12 @@ export type { CloudOsUrlResolver } from "./chat/markdown-message";
65
65
  export {
66
66
  ThinkingTraceRow,
67
67
  ToolCallDetails,
68
+ ToolGroupDetails,
68
69
  ToolGroupRow,
70
+ WorkDescriptionRow,
69
71
  WorkIcon,
72
+ WorkTraceDisclosure,
73
+ formatWorkDuration,
70
74
  } from "./chat/tool-rows";
71
75
  export {
72
76
  ApprovalBlock,
@@ -91,6 +95,7 @@ export {
91
95
  formatClockTime,
92
96
  formatFullTimestamp,
93
97
  messageText,
98
+ partitionAssistantBlocks,
94
99
  requestedCapabilitiesOf,
95
100
  rhythmTopClass,
96
101
  } from "./chat/transcript-model";
@@ -51,6 +51,8 @@
51
51
  --color-kumo-success: oklch(72.3% 0.219 149.579);
52
52
  --color-kumo-success-tint: oklch(92% 0.08 150);
53
53
 
54
+ --color-operation-base: #ffffff;
55
+
54
56
  --color-kumo-tip-shadow: #14111014;
55
57
  --color-kumo-tip-stroke: transparent;
56
58
 
@@ -73,6 +75,10 @@
73
75
  --color-cos-status-live: oklch(72.3% 0.219 149.579);
74
76
  --color-cos-status-draft: oklch(68.1% 0.162 75.834);
75
77
  --color-cos-status-building: oklch(62.3% 0.214 259.815);
78
+ --text-color-cos-work-description: rgba(0, 0, 0, 0.5);
79
+ --text-color-cos-work-description-subtle: rgba(0, 0, 0, 0.7);
80
+ --text-color-cos-tool-group: rgba(0, 0, 0, 0.5);
81
+ --text-color-cos-output: #000000b3;
76
82
  }
77
83
 
78
84
  /* ── 作用域根:通用 token 只在 cloud-os 子树内生效 ─────────────────────────── */
@@ -152,6 +158,8 @@
152
158
  --color-kumo-success: oklch(52.7% 0.154 150.069);
153
159
  --color-kumo-success-tint: oklch(0.25 0.06 150.069);
154
160
 
161
+ --color-operation-base: var(--color-kumo-base);
162
+
155
163
  --color-kumo-tip-shadow: #00000066;
156
164
  --color-kumo-tip-stroke: oklch(0.34 0.022 285);
157
165
 
@@ -172,6 +180,10 @@
172
180
  --color-cos-status-live: oklch(79.2% 0.209 151.711);
173
181
  --color-cos-status-draft: oklch(82.8% 0.189 84.429);
174
182
  --color-cos-status-building: oklch(74% 0.16 250);
183
+ --text-color-cos-work-description: var(--text-color-kumo-subtle);
184
+ --text-color-cos-work-description-subtle: var(--text-color-kumo-subtle);
185
+ --text-color-cos-tool-group: var(--text-color-kumo-inactive);
186
+ --text-color-cos-output: var(--text-color-kumo-subtle);
175
187
 
176
188
  --color-selection-bg: color-mix(in srgb, var(--color-kumo-brand) 28%, transparent);
177
189
  --color-selection-text: oklch(0.97 0.006 285);
@@ -18,6 +18,7 @@ export interface ComposerCommandLike {
18
18
  }
19
19
  const demoCompletedAt = Date.now() - 1_000;
20
20
  const demoTurnStartedAt = demoCompletedAt - 18_000;
21
+ const demoTurnId = "camel-demo-research-turn";
21
22
  export const defaultPrompt =
22
23
  "请调研 2026 年 AI Agent 产品的交互趋势,并给出可落地的消息 UI 建议。";
23
24
  export const demoModels = [
@@ -744,7 +745,7 @@ const commandMessages = [
744
745
  const runningWork = {
745
746
  id: "camel-demo-running-work",
746
747
  role: "assistant",
747
- metadata: { turnStartedAt: demoTurnStartedAt },
748
+ metadata: { turnId: demoTurnId, turnStartedAt: demoTurnStartedAt },
748
749
  parts: [
749
750
  {
750
751
  type: "reasoning",
@@ -771,7 +772,7 @@ const runningWork = {
771
772
  const completedResearch = {
772
773
  id: "camel-demo-completed-research",
773
774
  role: "assistant",
774
- metadata: { turnStartedAt: demoTurnStartedAt },
775
+ metadata: { turnId: demoTurnId, turnStartedAt: demoTurnStartedAt },
775
776
  parts: [
776
777
  {
777
778
  type: "reasoning",
@@ -806,6 +807,7 @@ const completedResearch = {
806
807
  const completedSynthesis = {
807
808
  id: "camel-demo-completed-synthesis",
808
809
  role: "assistant",
810
+ metadata: { turnId: demoTurnId },
809
811
  parts: [
810
812
  {
811
813
  type: "tool-update_plan",
@@ -837,6 +839,7 @@ const completedSynthesis = {
837
839
  const approvalRequest = {
838
840
  id: "camel-demo-approval-request",
839
841
  role: "assistant",
842
+ metadata: { turnId: demoTurnId },
840
843
  parts: [
841
844
  {
842
845
  type: "tool-write_file",
@@ -852,6 +855,7 @@ const approvalRequest = {
852
855
  const collectedAnswer = {
853
856
  id: "camel-demo-collected-answer",
854
857
  role: "assistant",
858
+ metadata: { turnId: demoTurnId },
855
859
  parts: [
856
860
  {
857
861
  type: "text",
@@ -868,6 +872,7 @@ const finalAnswer = {
868
872
  id: "camel-demo-final-answer",
869
873
  role: "assistant",
870
874
  metadata: {
875
+ turnId: demoTurnId,
871
876
  createdAt: demoCompletedAt,
872
877
  completedAt: demoCompletedAt,
873
878
  turnDurationMs: 18_000,
@@ -936,6 +941,7 @@ const interruptedByUser = {
936
941
  id: "camel-demo-interrupted",
937
942
  role: "assistant",
938
943
  metadata: {
944
+ turnId: demoTurnId,
939
945
  createdAt: demoCompletedAt,
940
946
  completedAt: demoCompletedAt + 2_000,
941
947
  turnDurationMs: 2_000,
@@ -965,7 +971,7 @@ const compactSummary = {
965
971
  const failedWork = {
966
972
  id: "camel-demo-failed-work",
967
973
  role: "assistant",
968
- metadata: { turnStartedAt: demoTurnStartedAt },
974
+ metadata: { turnId: demoTurnId, turnStartedAt: demoTurnStartedAt },
969
975
  parts: [
970
976
  ...completedResearch.parts,
971
977
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.1.3-alpha.24",
3
+ "version": "0.1.3-alpha.26",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",