@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
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ import { ChevronRightIcon, type LucideIcon } from "lucide-react";
4
+ import type { ReactNode } from "react";
5
+ import {
6
+ Collapsible,
7
+ CollapsibleContent,
8
+ CollapsibleTrigger,
9
+ } from "../primitives/collapsible";
10
+ import { cn } from "../internal/cn";
11
+ import { ShimmerLabel, SwapLabel } from "./surfaces";
12
+ import { take } from "./range";
13
+
14
+ export interface TimelineStep {
15
+ verb: string;
16
+ chip: string;
17
+ icon: LucideIcon;
18
+ }
19
+
20
+ export interface TimelineStat {
21
+ file: string;
22
+ added?: number;
23
+ removed?: number;
24
+ }
25
+
26
+ export interface ToolTimelineProps {
27
+ steps: readonly TimelineStep[];
28
+ visibleSteps: number;
29
+ streaming: boolean;
30
+ open: boolean;
31
+ onOpenChange: (open: boolean) => void;
32
+ restingLabel: string;
33
+ activeLabel: string;
34
+ stats: TimelineStat[];
35
+ className?: string;
36
+ children?: ReactNode;
37
+ }
38
+
39
+ export function ToolTimeline({
40
+ steps,
41
+ visibleSteps,
42
+ streaming,
43
+ open,
44
+ onOpenChange,
45
+ restingLabel,
46
+ activeLabel,
47
+ stats,
48
+ className,
49
+ children,
50
+ }: ToolTimelineProps) {
51
+ return (
52
+ <Collapsible
53
+ data-slot="tool-timeline"
54
+ open={open}
55
+ onOpenChange={onOpenChange}
56
+ className={cn("w-full max-w-sm", className)}
57
+ >
58
+ <CollapsibleTrigger className="group/trigger text-foreground/55 hover:text-foreground/90 flex items-center gap-1.5 rounded-md py-1 text-[13.5px] transition-colors outline-none">
59
+ <ChevronRightIcon className="size-3.5 shrink-0 opacity-60 transition-transform duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-open/trigger:rotate-90 group-data-panel-open/trigger:rotate-90 motion-reduce:transition-none" />
60
+ <SwapLabel
61
+ active={streaming ? 0 : 1}
62
+ className="text-start tabular-nums"
63
+ >
64
+ <ShimmerLabel
65
+ active={streaming}
66
+ className="relative inline-block leading-none"
67
+ >
68
+ {activeLabel}
69
+ </ShimmerLabel>
70
+ <>{restingLabel}</>
71
+ </SwapLabel>
72
+ </CollapsibleTrigger>
73
+ <CollapsibleContent className="outline-none">
74
+ <div className="flex flex-col gap-2.5 ps-4 pt-2.5">
75
+ {children ?? take(steps, visibleSteps).map((step, index, shown) => {
76
+ const Icon = step.icon;
77
+ const active = streaming && index === shown.length - 1;
78
+
79
+ return (
80
+ <div
81
+ key={step.chip}
82
+ className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-foreground/55 flex items-center gap-2 text-[13.5px] duration-300"
83
+ >
84
+ <Icon className="text-foreground/35 size-3.5 shrink-0" />
85
+ <ShimmerLabel
86
+ active={active}
87
+ className="relative inline-block leading-none"
88
+ >
89
+ {step.verb}
90
+ </ShimmerLabel>
91
+ <span className="bg-foreground/[0.06] text-foreground/70 rounded-md px-1.5 py-0.5 font-mono text-[11px]">
92
+ {step.chip}
93
+ </span>
94
+ </div>
95
+ );
96
+ })}
97
+ {stats.length > 0 && (
98
+ <div className="flex flex-wrap gap-1.5 pt-1">
99
+ {stats.map((stat) => (
100
+ <span
101
+ key={stat.file}
102
+ className="bg-foreground/[0.06] text-foreground/70 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-[11px]"
103
+ >
104
+ <span>{stat.file}</span>
105
+ {stat.added !== undefined && (
106
+ <span className="text-emerald-600 dark:text-emerald-400">
107
+ +{stat.added}
108
+ </span>
109
+ )}
110
+ {stat.removed !== undefined && (
111
+ <span className="text-red-600 dark:text-red-400">
112
+ −{stat.removed}
113
+ </span>
114
+ )}
115
+ </span>
116
+ ))}
117
+ </div>
118
+ )}
119
+ </div>
120
+ </CollapsibleContent>
121
+ </Collapsible>
122
+ );
123
+ }
@@ -28,13 +28,14 @@ export interface CloudOsMessageMetadata extends UnknownRecord {
28
28
  error?: string;
29
29
  interruptedByUser?: boolean;
30
30
  turnDurationMs?: number;
31
+ turnId?: string;
31
32
  turnStartedAt?: number;
32
33
  turnStatus?: string;
33
34
  requestedCapabilities?: unknown;
34
35
  }
35
36
 
36
37
  export interface CloudOsRequestedCapability {
37
- kind: "skill" | "plan";
38
+ kind: "skill" | "tool" | "plan";
38
39
  name: string;
39
40
  label: string;
40
41
  }
@@ -45,14 +46,39 @@ export interface CloudOsAttachment {
45
46
  mediaType?: string;
46
47
  }
47
48
 
49
+ interface CloudOsActionPresentationFile {
50
+ type: "file";
51
+ url: string;
52
+ mediaType?: string;
53
+ }
54
+
55
+ interface CloudOsActionProduces {
56
+ type: "image" | "audio" | "video" | "document" | "archive" | "data" | "other";
57
+ mediaType?: string;
58
+ }
59
+
60
+ export interface CloudOsActionPresentation {
61
+ type: "action-execution";
62
+ executionId: string;
63
+ state: "running" | "ready" | "failed" | "outcome_unknown";
64
+ failureCode?: "insufficient_credits";
65
+ produces?: CloudOsActionProduces;
66
+ content: CloudOsActionPresentationFile[];
67
+ }
68
+
48
69
  export interface PlanStep {
49
70
  text: string;
50
71
  status: "pending" | "in_progress" | "done";
51
72
  }
52
73
 
53
74
  export type AssistantBlock =
54
- | { kind: "reasoning"; key: string; text: string }
55
- | { kind: "text"; key: string; text: string }
75
+ | {
76
+ kind: "reasoning";
77
+ key: string;
78
+ text: string;
79
+ running: boolean;
80
+ }
81
+ | { kind: "text"; key: string; text: string; final: boolean }
56
82
  | { kind: "toolGroup"; key: string; group: ToolCallGroup }
57
83
  | { kind: "plan"; key: string; steps: PlanStep[]; running: boolean }
58
84
  // 答案就在 call.output 里(`ask_user` 是 client-settled tool,用户点选后
@@ -74,6 +100,11 @@ export type AssistantBlock =
74
100
  mediaType?: string;
75
101
  }
76
102
  | { kind: "file"; key: string; file: CloudOsAttachment }
103
+ | {
104
+ kind: "actionPresentation";
105
+ key: string;
106
+ presentation: CloudOsActionPresentation;
107
+ }
77
108
  | { kind: "error"; key: string; title: string; body?: string };
78
109
 
79
110
  export interface ParallelTool {
@@ -113,6 +144,7 @@ export type CloudOsEntry =
113
144
  type: "assistant";
114
145
  key: string;
115
146
  messageId: string;
147
+ turnId: string;
116
148
  blocks: AssistantBlock[];
117
149
  copyText: string;
118
150
  timestamp?: number;
@@ -150,7 +182,7 @@ export function requestedCapabilitiesOf(
150
182
  return metadata.requestedCapabilities.flatMap((value) => {
151
183
  const capability = recordOf(value);
152
184
  if (
153
- (capability.kind !== "skill" && capability.kind !== "plan") ||
185
+ (capability.kind !== "skill" && capability.kind !== "tool" && capability.kind !== "plan") ||
154
186
  typeof capability.name !== "string" ||
155
187
  !capability.name.trim() ||
156
188
  typeof capability.label !== "string" ||
@@ -239,6 +271,74 @@ function normalizeStatus(value: unknown): ParallelTool["status"] {
239
271
  return "pending";
240
272
  }
241
273
 
274
+ function actionPresentationOf(value: unknown): CloudOsActionPresentation | null {
275
+ const presentation = recordOf(recordOf(value).presentation);
276
+ const state = presentation.state;
277
+ if (
278
+ presentation.type !== "action-execution" ||
279
+ typeof presentation.executionId !== "string" ||
280
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
281
+ presentation.executionId,
282
+ ) ||
283
+ (state !== "running" &&
284
+ state !== "ready" &&
285
+ state !== "failed" &&
286
+ state !== "outcome_unknown") ||
287
+ !Array.isArray(presentation.content)
288
+ ) return null;
289
+
290
+ let produces: CloudOsActionProduces | undefined;
291
+ if (presentation.produces !== undefined) {
292
+ const hint = recordOf(presentation.produces);
293
+ if (
294
+ !["image", "audio", "video", "document", "archive", "data", "other"].includes(
295
+ String(hint.type),
296
+ ) ||
297
+ (hint.mediaType !== undefined &&
298
+ (typeof hint.mediaType !== "string" || !/^[^\s/]+\/[^\s/]+$/.test(hint.mediaType)))
299
+ ) return null;
300
+ produces = {
301
+ type: hint.type as CloudOsActionProduces["type"],
302
+ ...(hint.mediaType === undefined ? {} : { mediaType: hint.mediaType as string }),
303
+ };
304
+ }
305
+ if (state !== "running" && produces) return null;
306
+
307
+ const content: CloudOsActionPresentationFile[] = [];
308
+ for (const value of presentation.content) {
309
+ const file = recordOf(value);
310
+ if (file.type !== "file" || typeof file.url !== "string") return null;
311
+ try {
312
+ if (new URL(file.url).protocol !== "https:") return null;
313
+ } catch {
314
+ return null;
315
+ }
316
+ if (
317
+ file.mediaType !== undefined &&
318
+ (typeof file.mediaType !== "string" || !file.mediaType.trim())
319
+ ) return null;
320
+ content.push({
321
+ type: "file" as const,
322
+ url: file.url,
323
+ ...(file.mediaType !== undefined
324
+ ? { mediaType: file.mediaType }
325
+ : {}),
326
+ });
327
+ }
328
+ if (state !== "ready" && content.length > 0) return null;
329
+
330
+ return {
331
+ type: "action-execution",
332
+ executionId: presentation.executionId,
333
+ state,
334
+ ...(state === "failed" && presentation.failureCode === "insufficient_credits"
335
+ ? { failureCode: presentation.failureCode }
336
+ : {}),
337
+ ...(produces ? { produces } : {}),
338
+ content,
339
+ };
340
+ }
341
+
242
342
  // ── 独立消息(非 assistant 回合)判定 ────────────────────────────────────────
243
343
 
244
344
  function standaloneEntry(
@@ -318,7 +418,7 @@ function assistantBlocks(
318
418
  const text = String(record.text ?? "").trim();
319
419
  if (!text) return;
320
420
  flush();
321
- blocks.push({ kind: "text", key, text });
421
+ blocks.push({ kind: "text", key, text, final: false });
322
422
  return;
323
423
  }
324
424
 
@@ -326,7 +426,12 @@ function assistantBlocks(
326
426
  const text = String(record.text ?? "").trim();
327
427
  if (!text || !showThinkingTraces) return;
328
428
  flush();
329
- blocks.push({ kind: "reasoning", key, text });
429
+ blocks.push({
430
+ kind: "reasoning",
431
+ key,
432
+ text,
433
+ running: record.state === "streaming",
434
+ });
330
435
  return;
331
436
  }
332
437
 
@@ -417,6 +522,13 @@ function assistantBlocks(
417
522
  if (toolName === null) return;
418
523
 
419
524
  const call = toCloudOsToolCall(part, key, isActive);
525
+ const presentation = actionPresentationOf(call.output);
526
+ if (presentation) {
527
+ pendingCalls.push(call);
528
+ flush();
529
+ blocks.push({ kind: "actionPresentation", key, presentation });
530
+ return;
531
+ }
420
532
 
421
533
  if (call.awaitingApproval) {
422
534
  const approvalId = approvalIdOf(part);
@@ -437,6 +549,7 @@ function assistantBlocks(
437
549
  }
438
550
 
439
551
  if (call.kind === "ask-user") {
552
+ if (call.failed) return;
440
553
  flush();
441
554
  blocks.push({
442
555
  kind: "askUser",
@@ -470,6 +583,8 @@ function assistantBlocks(
470
583
  }
471
584
 
472
585
  const USER_STOP_REASON = "Stopped by user";
586
+ const REQUEST_UNAVAILABLE_ERROR =
587
+ "We’re unable to complete your request right now. Please try again in a few moments.";
473
588
 
474
589
  function terminalOf(
475
590
  message: UIMessage,
@@ -484,7 +599,9 @@ function terminalOf(
484
599
  if (metadata.turnStatus === "error" || metadata.error) {
485
600
  return {
486
601
  kind: "error",
487
- title: "Error",
602
+ title: metadata.error === REQUEST_UNAVAILABLE_ERROR
603
+ ? "Unable to complete your request"
604
+ : "Error",
488
605
  body: metadata.error ?? "The response failed.",
489
606
  };
490
607
  }
@@ -507,6 +624,7 @@ export function buildCloudOsEntries({
507
624
  approvalIdOf = () => undefined,
508
625
  }: BuildEntriesOptions): CloudOsEntry[] {
509
626
  const entries: CloudOsEntry[] = [];
627
+ let fallbackTurnId = "turn:initial";
510
628
  const lastAssistantIndex = (() => {
511
629
  for (let index = messages.length - 1; index >= 0; index -= 1) {
512
630
  if (messages[index].role === "assistant") return index;
@@ -540,6 +658,9 @@ export function buildCloudOsEntries({
540
658
  };
541
659
  });
542
660
  if (!text && attachments.length === 0) return;
661
+ fallbackTurnId = typeof metadata.turnId === "string" && metadata.turnId
662
+ ? metadata.turnId
663
+ : `turn:user:${message.id}`;
543
664
  entries.push({
544
665
  type: "user",
545
666
  key: `user-${message.id}-${index}`,
@@ -567,6 +688,9 @@ export function buildCloudOsEntries({
567
688
  type: "assistant",
568
689
  key: `assistant-${message.id}-${index}`,
569
690
  messageId: message.id,
691
+ turnId: typeof metadata.turnId === "string" && metadata.turnId
692
+ ? metadata.turnId
693
+ : fallbackTurnId,
570
694
  blocks,
571
695
  copyText: messageText(message),
572
696
  timestamp:
@@ -576,35 +700,144 @@ export function buildCloudOsEntries({
576
700
  });
577
701
  });
578
702
 
579
- return dropSupersededPlans(entries);
703
+ return markFinalTextBlocks(mergeConsecutiveAssistantTurns(
704
+ dropSupersededActionPresentations(dropSupersededPlans(entries)),
705
+ ));
706
+ }
707
+
708
+ function isFinalOutputBlock(block: AssistantBlock): boolean {
709
+ return block.kind === "suggestions" ||
710
+ block.kind === "image" ||
711
+ block.kind === "file" ||
712
+ (block.kind === "actionPresentation" && block.presentation.state === "ready");
713
+ }
714
+
715
+ function isWorkBoundaryBlock(block: AssistantBlock): boolean {
716
+ return block.kind !== "text" && !isFinalOutputBlock(block);
717
+ }
718
+
719
+ function markFinalTextBlocks(entries: CloudOsEntry[]): CloudOsEntry[] {
720
+ return entries.map((entry) => {
721
+ if (entry.type !== "assistant") return entry;
722
+
723
+ const finalOutputBoundary = entry.blocks.findLastIndex(isFinalOutputBlock);
724
+ // Provider cleanup after final output must not pull the answer back into Work.
725
+ const searchBefore = finalOutputBoundary === -1
726
+ ? entry.blocks.length
727
+ : finalOutputBoundary;
728
+ let lastWorkBoundary = -1;
729
+ for (let index = searchBefore - 1; index >= 0; index -= 1) {
730
+ if (!isWorkBoundaryBlock(entry.blocks[index]!)) continue;
731
+ lastWorkBoundary = index;
732
+ break;
733
+ }
734
+
735
+ return {
736
+ ...entry,
737
+ blocks: entry.blocks.map((block, index) =>
738
+ block.kind === "text"
739
+ ? { ...block, final: index > lastWorkBoundary }
740
+ : block
741
+ ),
742
+ };
743
+ });
744
+ }
745
+
746
+ function mergeConsecutiveAssistantTurns(entries: CloudOsEntry[]): CloudOsEntry[] {
747
+ return entries.reduce<CloudOsEntry[]>((merged, entry) => {
748
+ const previous = merged.at(-1);
749
+ if (
750
+ entry.type !== "assistant" ||
751
+ previous?.type !== "assistant" ||
752
+ previous.turnId !== entry.turnId
753
+ ) {
754
+ merged.push(entry);
755
+ return merged;
756
+ }
757
+ merged[merged.length - 1] = {
758
+ ...previous,
759
+ messageId: entry.messageId,
760
+ blocks: [...previous.blocks, ...entry.blocks],
761
+ copyText: [previous.copyText, entry.copyText].filter(Boolean).join("\n\n"),
762
+ timestamp: entry.timestamp ?? previous.timestamp,
763
+ terminal: entry.terminal ?? previous.terminal,
764
+ };
765
+ return merged;
766
+ }, []);
767
+ }
768
+
769
+ function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntry[] {
770
+ const snapshots = new Map<string, {
771
+ entryIndex: number;
772
+ blockIndex: number;
773
+ presentation: CloudOsActionPresentation;
774
+ }>();
775
+ entries.forEach((entry, entryIndex) => {
776
+ if (entry.type !== "assistant") return;
777
+ entry.blocks.forEach((block, blockIndex) => {
778
+ if (block.kind !== "actionPresentation") return;
779
+ const current = snapshots.get(block.presentation.executionId);
780
+ if (!current) {
781
+ snapshots.set(block.presentation.executionId, {
782
+ entryIndex,
783
+ blockIndex,
784
+ presentation: block.presentation,
785
+ });
786
+ return;
787
+ }
788
+ if (block.presentation.state !== "running") {
789
+ current.presentation = block.presentation;
790
+ } else if (current.presentation.state === "running") {
791
+ current.presentation = {
792
+ ...block.presentation,
793
+ produces: block.presentation.produces ?? current.presentation.produces,
794
+ };
795
+ }
796
+ });
797
+ });
798
+
799
+ return entries.map((entry, entryIndex) =>
800
+ entry.type !== "assistant"
801
+ ? entry
802
+ : {
803
+ ...entry,
804
+ blocks: entry.blocks.flatMap<AssistantBlock>((block, blockIndex) => {
805
+ if (block.kind !== "actionPresentation") return [block];
806
+ const snapshot = snapshots.get(block.presentation.executionId)!;
807
+ return snapshot.entryIndex === entryIndex && snapshot.blockIndex === blockIndex
808
+ ? [{ ...block, presentation: snapshot.presentation }]
809
+ : [];
810
+ }),
811
+ }
812
+ );
580
813
  }
581
814
 
582
815
  /**
583
- * update_plan 是**整份快照覆盖**,不是增量事件 —— 一次任务里每推进一步就会再发一份
584
- * 完整计划。原样按序渲染会把同一份计划画四遍(0/3、1/3、2/3、3/3),只有最后那份
585
- * 是当前事实。所以整条会话里只保留最后一份计划快照。
816
+ * update_plan 是**整份快照覆盖**,不是增量事件 —— 一次 Turn 里每推进一步就会再发一份
817
+ * 完整计划。原样按序渲染会把同一份计划画四遍(0/3、1/3、2/3、3/3),只有该 Turn 的
818
+ * 最后一份是当前事实。所以每个 turnId 各自保留最后一份计划快照。
586
819
  */
587
820
  function dropSupersededPlans(entries: CloudOsEntry[]): CloudOsEntry[] {
588
- let lastPlanEntry = -1;
589
- let lastPlanBlock = -1;
821
+ const lastPlanByTurn = new Map<string, { entryIndex: number; blockIndex: number }>();
590
822
  entries.forEach((entry, entryIndex) => {
591
823
  if (entry.type !== "assistant") return;
592
824
  entry.blocks.forEach((block, blockIndex) => {
593
825
  if (block.kind !== "plan") return;
594
- lastPlanEntry = entryIndex;
595
- lastPlanBlock = blockIndex;
826
+ lastPlanByTurn.set(entry.turnId, { entryIndex, blockIndex });
596
827
  });
597
828
  });
598
- if (lastPlanEntry === -1) return entries;
829
+ if (lastPlanByTurn.size === 0) return entries;
599
830
 
600
831
  return entries.map((entry, entryIndex) =>
601
832
  entry.type === "assistant"
602
833
  ? {
603
834
  ...entry,
604
835
  blocks: entry.blocks.filter(
605
- (block, blockIndex) =>
606
- block.kind !== "plan" ||
607
- (entryIndex === lastPlanEntry && blockIndex === lastPlanBlock),
836
+ (block, blockIndex) => {
837
+ if (block.kind !== "plan") return true;
838
+ const last = lastPlanByTurn.get(entry.turnId);
839
+ return last?.entryIndex === entryIndex && last.blockIndex === blockIndex;
840
+ },
608
841
  ),
609
842
  }
610
843
  : entry,
@@ -715,6 +948,8 @@ export function deriveTurnActivity(
715
948
  options: {
716
949
  isActive: boolean;
717
950
  isRecovering?: boolean;
951
+ recoveryAttempt?: number;
952
+ recoveryMax?: number;
718
953
  recoveryStatusLabel?: string;
719
954
  awaitingApproval?: boolean;
720
955
  hasPendingSteer?: boolean;
@@ -723,7 +958,10 @@ export function deriveTurnActivity(
723
958
  if (options.isRecovering) {
724
959
  return {
725
960
  state: "connecting",
726
- label: options.recoveryStatusLabel ?? "Reconnecting…",
961
+ label: options.recoveryStatusLabel ??
962
+ (options.recoveryAttempt === undefined || options.recoveryMax === undefined
963
+ ? "Recovering model response…"
964
+ : `Recovering model response ${options.recoveryAttempt}/${options.recoveryMax}…`),
727
965
  };
728
966
  }
729
967
  if (!options.isActive) return null;
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import type { ComponentProps } from "react";
4
+ import { SearchIcon } from "lucide-react";
5
+ import { cn } from "../internal/cn";
6
+ import { field, mono, ShimmerLabel } from "./surfaces";
7
+ import { take } from "./range";
8
+ import type { CloudOsToolCall } from "./tool-presentation";
9
+
10
+ /** Adapted from assistant-ui's MIT-licensed Web Search element. */
11
+ export interface WebSearchResult {
12
+ title: string;
13
+ domain: string;
14
+ }
15
+
16
+ export function WebSearch({
17
+ query,
18
+ results,
19
+ visibleResults,
20
+ searching,
21
+ cycle,
22
+ className,
23
+ ...props
24
+ }: Omit<
25
+ ComponentProps<"div">,
26
+ "children" | "results"
27
+ > & {
28
+ query: string;
29
+ results: readonly WebSearchResult[];
30
+ visibleResults: number;
31
+ searching: boolean;
32
+ cycle: number;
33
+ }) {
34
+ const revealedResults = take(results, visibleResults);
35
+ return (
36
+ <div
37
+ data-slot="web-search"
38
+ className={cn("flex w-full max-w-sm flex-col gap-2.5", className)}
39
+ {...props}
40
+ >
41
+ <span
42
+ className={cn(
43
+ field,
44
+ "text-foreground/70 inline-flex w-fit items-center gap-1.5 rounded-full px-3.5 py-2 text-xs",
45
+ )}
46
+ >
47
+ <SearchIcon className="text-foreground/40 size-3" />
48
+ {query}
49
+ </span>
50
+ <div className="text-foreground/45 text-xs">
51
+ {searching ? (
52
+ <ShimmerLabel className="relative inline-block leading-none">
53
+ Searching
54
+ </ShimmerLabel>
55
+ ) : (
56
+ <span className="fade-in animate-in duration-300">
57
+ Read {results.length} {results.length === 1 ? "source" : "sources"}
58
+ </span>
59
+ )}
60
+ </div>
61
+ <div className="flex min-h-[5.75rem] max-h-48 flex-col overflow-y-auto overscroll-contain pr-1">
62
+ {revealedResults.map((result, index) => (
63
+ <div
64
+ key={`${cycle}-${result.domain}-${index}`}
65
+ className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both hover:bg-foreground/[0.03] -mx-2.5 flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 transition-colors duration-300"
66
+ >
67
+ <span className="bg-foreground/[0.06] text-foreground/45 flex size-4 shrink-0 items-center justify-center rounded text-[9px] font-medium">
68
+ {result.domain.charAt(0).toUpperCase()}
69
+ </span>
70
+ <span className="text-foreground/90 min-w-0 flex-1 truncate text-[13.5px]">
71
+ {result.title}
72
+ </span>
73
+ <span className={cn(mono, "text-foreground/35 shrink-0")}>
74
+ {result.domain}
75
+ </span>
76
+ </div>
77
+ ))}
78
+ </div>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ type UnknownRecord = Record<string, unknown>;
84
+
85
+ function recordOf(value: unknown): UnknownRecord {
86
+ return value != null && typeof value === "object" && !Array.isArray(value)
87
+ ? value as UnknownRecord
88
+ : {};
89
+ }
90
+
91
+ function webSearchResults(output: unknown): WebSearchResult[] {
92
+ const record = recordOf(output);
93
+ const candidates = [record.sources, record.searchResults]
94
+ .flatMap((value) => Array.isArray(value) ? value : []);
95
+ const seen = new Set<string>();
96
+ return candidates.flatMap((value) => {
97
+ const result = recordOf(value);
98
+ if (typeof result.url !== "string") return [];
99
+ try {
100
+ const url = new URL(result.url);
101
+ if (url.protocol !== "http:" && url.protocol !== "https:") return [];
102
+ if (seen.has(url.href)) return [];
103
+ seen.add(url.href);
104
+ return [{
105
+ title: typeof result.title === "string" && result.title.trim()
106
+ ? result.title.trim()
107
+ : url.hostname,
108
+ domain: url.hostname,
109
+ }];
110
+ } catch {
111
+ return [];
112
+ }
113
+ });
114
+ }
115
+
116
+ export function WebSearchToolCall({
117
+ toolCall,
118
+ }: {
119
+ toolCall: CloudOsToolCall;
120
+ }) {
121
+ const query = typeof toolCall.input.query === "string" &&
122
+ toolCall.input.query.trim()
123
+ ? toolCall.input.query.trim()
124
+ : "Web search";
125
+ const results = webSearchResults(toolCall.output);
126
+ return (
127
+ <WebSearch
128
+ query={query}
129
+ results={results}
130
+ visibleResults={toolCall.running ? 0 : results.length}
131
+ searching={toolCall.running}
132
+ cycle={0}
133
+ />
134
+ );
135
+ }