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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/cloud-os/README.md +6 -6
  2. package/cloud-os/assets/followup-arrow.svg +3 -0
  3. package/cloud-os/assets/loading-corner.svg +3 -0
  4. package/cloud-os/assets/loading-mark.svg +16 -0
  5. package/cloud-os/assets/loading-spark.svg +3 -0
  6. package/cloud-os/assets/model-selected.svg +5 -0
  7. package/cloud-os/assets/thinking-spark.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +35 -0
  9. package/cloud-os/chat/activity-indicator.tsx +2 -2
  10. package/cloud-os/chat/cloud-os-chat-messages.tsx +374 -63
  11. package/cloud-os/chat/image-generation.tsx +76 -0
  12. package/cloud-os/chat/loading-state.tsx +25 -0
  13. package/cloud-os/chat/markdown-message.tsx +144 -41
  14. package/cloud-os/chat/rich-blocks.tsx +548 -246
  15. package/cloud-os/chat/tool-presentation.ts +35 -16
  16. package/cloud-os/chat/tool-rows.tsx +330 -85
  17. package/cloud-os/chat/transcript-model.ts +428 -56
  18. package/cloud-os/composer/cloud-os-chat-input.tsx +228 -142
  19. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  20. package/cloud-os/file-view.tsx +441 -0
  21. package/cloud-os/index.ts +35 -3
  22. package/cloud-os/layout/cloud-os-workspace-split.tsx +58 -15
  23. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  24. package/cloud-os/styles/cloud-os.css +100 -20
  25. package/demo/camel-chat-showcase.tsx +21 -13
  26. package/demo/chat-scenarios.ts +223 -43
  27. package/demo/cloud-os-chat-showcase.tsx +47 -14
  28. package/demo/fixtures.ts +4 -5
  29. package/demo/message-panel-gallery.tsx +16 -2
  30. package/package.json +3 -4
  31. package/src/camel/camel-chat-messages.tsx +78 -78
  32. package/src/camel/camel-tool-presentation.tsx +19 -15
  33. package/src/camel/camel-turn.ts +1 -17
  34. package/src/composer/composer-attachments.tsx +1 -1
  35. package/src/composer/composer-trigger-popover.tsx +1 -1
  36. package/src/composer/composer.tsx +2 -2
  37. package/src/contracts.ts +0 -2
  38. package/src/message-panel.tsx +1 -24
  39. package/src/parts/index.tsx +103 -64
@@ -5,6 +5,7 @@ import {
5
5
  buildToolCallGroups,
6
6
  toCloudOsToolCall,
7
7
  type CloudOsToolCall,
8
+ type CloudOsToolKind,
8
9
  type ToolCallGroup,
9
10
  } from "./tool-presentation";
10
11
 
@@ -26,9 +27,17 @@ export interface CloudOsMessageMetadata extends UnknownRecord {
26
27
  createdAt?: number;
27
28
  error?: string;
28
29
  interruptedByUser?: boolean;
30
+ turnId?: string;
29
31
  turnDurationMs?: number;
30
32
  turnStartedAt?: number;
31
33
  turnStatus?: string;
34
+ requestedCapabilities?: unknown;
35
+ }
36
+
37
+ export interface CloudOsRequestedCapability {
38
+ kind: "skill" | "tool" | "plan";
39
+ name: string;
40
+ label: string;
32
41
  }
33
42
 
34
43
  export interface CloudOsAttachment {
@@ -37,13 +46,38 @@ export interface CloudOsAttachment {
37
46
  mediaType?: string;
38
47
  }
39
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
+
40
69
  export interface PlanStep {
41
70
  text: string;
42
71
  status: "pending" | "in_progress" | "done";
43
72
  }
44
73
 
45
74
  export type AssistantBlock =
46
- | { kind: "reasoning"; key: string; text: string }
75
+ | {
76
+ kind: "reasoning";
77
+ key: string;
78
+ text: string;
79
+ running: boolean;
80
+ }
47
81
  | { kind: "text"; key: string; text: string }
48
82
  | { kind: "toolGroup"; key: string; group: ToolCallGroup }
49
83
  | { kind: "plan"; key: string; steps: PlanStep[]; running: boolean }
@@ -58,7 +92,19 @@ export type AssistantBlock =
58
92
  | { kind: "approval"; key: string; call: CloudOsToolCall; approvalId: string }
59
93
  | { kind: "parallel"; key: string; label: string; tools: ParallelTool[] }
60
94
  | { kind: "subagents"; key: string; agents: SubAgentView[] }
61
- | { kind: "image"; key: string; src: string; alt: string }
95
+ | {
96
+ kind: "image";
97
+ key: string;
98
+ src: string;
99
+ alt: string;
100
+ mediaType?: string;
101
+ }
102
+ | { kind: "file"; key: string; file: CloudOsAttachment }
103
+ | {
104
+ kind: "actionPresentation";
105
+ key: string;
106
+ presentation: CloudOsActionPresentation;
107
+ }
62
108
  | { kind: "error"; key: string; title: string; body?: string };
63
109
 
64
110
  export interface ParallelTool {
@@ -80,6 +126,7 @@ export type CloudOsEntry =
80
126
  messageId: string;
81
127
  text: string;
82
128
  attachments: CloudOsAttachment[];
129
+ capabilities: CloudOsRequestedCapability[];
83
130
  authorName?: string;
84
131
  timestamp?: number;
85
132
  }
@@ -97,8 +144,10 @@ export type CloudOsEntry =
97
144
  type: "assistant";
98
145
  key: string;
99
146
  messageId: string;
147
+ turnId?: string;
100
148
  blocks: AssistantBlock[];
101
149
  copyText: string;
150
+ durationMs?: number;
102
151
  timestamp?: number;
103
152
  terminal?: { kind: "interrupted" | "error"; title: string; body?: string };
104
153
  };
@@ -122,10 +171,39 @@ function nonNegativeNumber(value: unknown): number | undefined {
122
171
  : undefined;
123
172
  }
124
173
 
174
+ function nonEmptyString(value: unknown): string | undefined {
175
+ return typeof value === "string" && value.trim() ? value : undefined;
176
+ }
177
+
125
178
  export function cloudOsMetadata(message: UIMessage): CloudOsMessageMetadata {
126
179
  return recordOf(message.metadata) as CloudOsMessageMetadata;
127
180
  }
128
181
 
182
+ export function requestedCapabilitiesOf(
183
+ metadata: CloudOsMessageMetadata,
184
+ ): CloudOsRequestedCapability[] {
185
+ if (!Array.isArray(metadata.requestedCapabilities)) return [];
186
+ const seen = new Set<string>();
187
+ return metadata.requestedCapabilities.flatMap((value) => {
188
+ const capability = recordOf(value);
189
+ if (
190
+ (capability.kind !== "skill" && capability.kind !== "tool" && capability.kind !== "plan") ||
191
+ typeof capability.name !== "string" ||
192
+ !capability.name.trim() ||
193
+ typeof capability.label !== "string" ||
194
+ !capability.label.trim()
195
+ ) return [];
196
+ const key = `${capability.kind}:${capability.name}`;
197
+ if (seen.has(key)) return [];
198
+ seen.add(key);
199
+ return [{
200
+ kind: capability.kind,
201
+ name: capability.name,
202
+ label: capability.label,
203
+ }];
204
+ });
205
+ }
206
+
129
207
  export function messageText(message: UIMessage): string {
130
208
  return message.parts
131
209
  .filter(
@@ -151,6 +229,54 @@ export function formatFullTimestamp(timestamp: number): string {
151
229
  });
152
230
  }
153
231
 
232
+ function turnDuration(metadata: CloudOsMessageMetadata): number | undefined {
233
+ const explicit = nonNegativeNumber(metadata.turnDurationMs);
234
+ if (explicit !== undefined) return explicit;
235
+ const startedAt = nonNegativeNumber(metadata.turnStartedAt);
236
+ const completedAt = nonNegativeNumber(metadata.completedAt);
237
+ return startedAt !== undefined && completedAt !== undefined
238
+ ? Math.max(0, completedAt - startedAt)
239
+ : undefined;
240
+ }
241
+
242
+ function isWorkBlock(block: AssistantBlock): boolean {
243
+ return (
244
+ block.kind === "reasoning" ||
245
+ block.kind === "toolGroup" ||
246
+ block.kind === "plan" ||
247
+ block.kind === "parallel" ||
248
+ block.kind === "subagents"
249
+ );
250
+ }
251
+
252
+ /**
253
+ * 把一条 assistant 消息拆成可折叠的工作过程与始终可见的最终内容。
254
+ * 最后一段工作之前的 text 是过程旁白;最后一次工作之后的 text 才是正文。
255
+ */
256
+ export function partitionAssistantBlocks(blocks: readonly AssistantBlock[]): {
257
+ work: AssistantBlock[];
258
+ content: AssistantBlock[];
259
+ } {
260
+ let lastWorkIndex = -1;
261
+ blocks.forEach((block, index) => {
262
+ if (isWorkBlock(block)) lastWorkIndex = index;
263
+ });
264
+
265
+ const work: AssistantBlock[] = [];
266
+ const content: AssistantBlock[] = [];
267
+ blocks.forEach((block, index) => {
268
+ if (
269
+ isWorkBlock(block) ||
270
+ (block.kind === "text" && index < lastWorkIndex)
271
+ ) {
272
+ work.push(block);
273
+ } else {
274
+ content.push(block);
275
+ }
276
+ });
277
+ return { work, content };
278
+ }
279
+
154
280
  function toolNameOf(part: unknown): string | null {
155
281
  const type = String(recordOf(part).type ?? "");
156
282
  if (type === "dynamic-tool") {
@@ -198,6 +324,74 @@ function normalizeStatus(value: unknown): ParallelTool["status"] {
198
324
  return "pending";
199
325
  }
200
326
 
327
+ function actionPresentationOf(value: unknown): CloudOsActionPresentation | null {
328
+ const presentation = recordOf(recordOf(value).presentation);
329
+ const state = presentation.state;
330
+ if (
331
+ presentation.type !== "action-execution" ||
332
+ typeof presentation.executionId !== "string" ||
333
+ !/^[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(
334
+ presentation.executionId,
335
+ ) ||
336
+ (state !== "running" &&
337
+ state !== "ready" &&
338
+ state !== "failed" &&
339
+ state !== "outcome_unknown") ||
340
+ !Array.isArray(presentation.content)
341
+ ) return null;
342
+
343
+ let produces: CloudOsActionProduces | undefined;
344
+ if (presentation.produces !== undefined) {
345
+ const hint = recordOf(presentation.produces);
346
+ if (
347
+ !["image", "audio", "video", "document", "archive", "data", "other"].includes(
348
+ String(hint.type),
349
+ ) ||
350
+ (hint.mediaType !== undefined &&
351
+ (typeof hint.mediaType !== "string" || !/^[^\s/]+\/[^\s/]+$/.test(hint.mediaType)))
352
+ ) return null;
353
+ produces = {
354
+ type: hint.type as CloudOsActionProduces["type"],
355
+ ...(hint.mediaType === undefined ? {} : { mediaType: hint.mediaType as string }),
356
+ };
357
+ }
358
+ if (state !== "running" && produces) return null;
359
+
360
+ const content: CloudOsActionPresentationFile[] = [];
361
+ for (const value of presentation.content) {
362
+ const file = recordOf(value);
363
+ if (file.type !== "file" || typeof file.url !== "string") return null;
364
+ try {
365
+ if (new URL(file.url).protocol !== "https:") return null;
366
+ } catch {
367
+ return null;
368
+ }
369
+ if (
370
+ file.mediaType !== undefined &&
371
+ (typeof file.mediaType !== "string" || !file.mediaType.trim())
372
+ ) return null;
373
+ content.push({
374
+ type: "file" as const,
375
+ url: file.url,
376
+ ...(file.mediaType !== undefined
377
+ ? { mediaType: file.mediaType }
378
+ : {}),
379
+ });
380
+ }
381
+ if (state !== "ready" && content.length > 0) return null;
382
+
383
+ return {
384
+ type: "action-execution",
385
+ executionId: presentation.executionId,
386
+ state,
387
+ ...(state === "failed" && presentation.failureCode === "insufficient_credits"
388
+ ? { failureCode: presentation.failureCode }
389
+ : {}),
390
+ ...(produces ? { produces } : {}),
391
+ content,
392
+ };
393
+ }
394
+
201
395
  // ── 独立消息(非 assistant 回合)判定 ────────────────────────────────────────
202
396
 
203
397
  function standaloneEntry(
@@ -285,7 +479,12 @@ function assistantBlocks(
285
479
  const text = String(record.text ?? "").trim();
286
480
  if (!text || !showThinkingTraces) return;
287
481
  flush();
288
- blocks.push({ kind: "reasoning", key, text });
482
+ blocks.push({
483
+ kind: "reasoning",
484
+ key,
485
+ text,
486
+ running: record.state === "streaming",
487
+ });
289
488
  return;
290
489
  }
291
490
 
@@ -343,15 +542,32 @@ function assistantBlocks(
343
542
  const data = dataOf(part);
344
543
  const src = String(data.src ?? data.url ?? record.url ?? "");
345
544
  const mediaType = String(record.mediaType ?? data.mediaType ?? "");
346
- if (src && (dataKind === "image" || mediaType.startsWith("image/"))) {
347
- flush();
545
+ if (!src) return;
546
+
547
+ flush();
548
+ const filename = String(
549
+ data.alt ?? data.filename ?? record.filename ?? "Attachment",
550
+ );
551
+ if (dataKind === "image" || mediaType.startsWith("image/")) {
348
552
  blocks.push({
349
553
  kind: "image",
350
554
  key,
351
555
  src,
352
- alt: String(data.alt ?? data.filename ?? record.filename ?? "Image"),
556
+ alt: filename,
557
+ mediaType: mediaType || undefined,
353
558
  });
559
+ return;
354
560
  }
561
+
562
+ blocks.push({
563
+ kind: "file",
564
+ key,
565
+ file: {
566
+ url: src,
567
+ filename,
568
+ mediaType: mediaType || undefined,
569
+ },
570
+ });
355
571
  return;
356
572
  }
357
573
 
@@ -359,6 +575,13 @@ function assistantBlocks(
359
575
  if (toolName === null) return;
360
576
 
361
577
  const call = toCloudOsToolCall(part, key, isActive);
578
+ const presentation = actionPresentationOf(call.output);
579
+ if (presentation) {
580
+ pendingCalls.push(call);
581
+ flush();
582
+ blocks.push({ kind: "actionPresentation", key, presentation });
583
+ return;
584
+ }
362
585
 
363
586
  if (call.awaitingApproval) {
364
587
  const approvalId = approvalIdOf(part);
@@ -379,6 +602,7 @@ function assistantBlocks(
379
602
  }
380
603
 
381
604
  if (call.kind === "ask-user") {
605
+ if (call.failed) return;
382
606
  flush();
383
607
  blocks.push({
384
608
  kind: "askUser",
@@ -455,6 +679,9 @@ export function buildCloudOsEntries({
455
679
  }
456
680
  return -1;
457
681
  })();
682
+ const activeTurnId = isActive && lastAssistantIndex >= 0
683
+ ? nonEmptyString(cloudOsMetadata(messages[lastAssistantIndex]!).turnId)
684
+ : undefined;
458
685
 
459
686
  messages.forEach((message, index) => {
460
687
  if (message.role === "system") return;
@@ -488,6 +715,7 @@ export function buildCloudOsEntries({
488
715
  messageId: message.id,
489
716
  text,
490
717
  attachments,
718
+ capabilities: requestedCapabilitiesOf(metadata),
491
719
  authorName: metadata.authorDisplayName,
492
720
  timestamp:
493
721
  nonNegativeNumber(metadata.createdAt) ??
@@ -508,8 +736,10 @@ export function buildCloudOsEntries({
508
736
  type: "assistant",
509
737
  key: `assistant-${message.id}-${index}`,
510
738
  messageId: message.id,
739
+ turnId: nonEmptyString(metadata.turnId),
511
740
  blocks,
512
741
  copyText: messageText(message),
742
+ durationMs: turnDuration(metadata),
513
743
  timestamp:
514
744
  nonNegativeNumber(metadata.completedAt) ??
515
745
  nonNegativeNumber(metadata.createdAt),
@@ -517,7 +747,100 @@ export function buildCloudOsEntries({
517
747
  });
518
748
  });
519
749
 
520
- return dropSupersededPlans(entries);
750
+ return aggregateCompletedTurnWork(
751
+ dropSupersededActionPresentations(dropSupersededPlans(entries)),
752
+ activeTurnId,
753
+ );
754
+ }
755
+
756
+ /** Collapse every completed Turn's assistant segments into one display entry. */
757
+ function aggregateCompletedTurnWork(
758
+ entries: CloudOsEntry[],
759
+ activeTurnId: string | undefined,
760
+ ): CloudOsEntry[] {
761
+ const indexesByTurn = new Map<string, number[]>();
762
+ entries.forEach((entry, index) => {
763
+ if (entry.type !== "assistant" || !entry.turnId) return;
764
+ const indexes = indexesByTurn.get(entry.turnId) ?? [];
765
+ indexes.push(index);
766
+ indexesByTurn.set(entry.turnId, indexes);
767
+ });
768
+
769
+ const hidden = new Set<number>();
770
+ const replacements = new Map<number, CloudOsEntry>();
771
+ for (const [turnId, indexes] of indexesByTurn) {
772
+ if (turnId === activeTurnId || indexes.length < 2) continue;
773
+ const assistantEntries = indexes.map(
774
+ (index) => entries[index] as Extract<CloudOsEntry, { type: "assistant" }>,
775
+ );
776
+ const anchorIndex = indexes[indexes.length - 1]!;
777
+ const anchor = assistantEntries[assistantEntries.length - 1]!;
778
+ const durations = assistantEntries.flatMap((entry) =>
779
+ entry.durationMs === undefined ? [] : [entry.durationMs]
780
+ );
781
+ const timestamps = assistantEntries.flatMap((entry) =>
782
+ entry.timestamp === undefined ? [] : [entry.timestamp]
783
+ );
784
+
785
+ indexes.slice(0, -1).forEach((index) => hidden.add(index));
786
+ replacements.set(anchorIndex, {
787
+ ...anchor,
788
+ key: `assistant-turn-${turnId}`,
789
+ blocks: assistantEntries.flatMap((entry) => entry.blocks),
790
+ ...(durations.length > 0 ? { durationMs: Math.max(...durations) } : {}),
791
+ ...(timestamps.length > 0 ? { timestamp: Math.max(...timestamps) } : {}),
792
+ });
793
+ }
794
+
795
+ return entries.flatMap((entry, index) =>
796
+ hidden.has(index) ? [] : [replacements.get(index) ?? entry]
797
+ );
798
+ }
799
+
800
+ function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntry[] {
801
+ const snapshots = new Map<string, {
802
+ entryIndex: number;
803
+ blockIndex: number;
804
+ presentation: CloudOsActionPresentation;
805
+ }>();
806
+ entries.forEach((entry, entryIndex) => {
807
+ if (entry.type !== "assistant") return;
808
+ entry.blocks.forEach((block, blockIndex) => {
809
+ if (block.kind !== "actionPresentation") return;
810
+ const current = snapshots.get(block.presentation.executionId);
811
+ if (!current) {
812
+ snapshots.set(block.presentation.executionId, {
813
+ entryIndex,
814
+ blockIndex,
815
+ presentation: block.presentation,
816
+ });
817
+ return;
818
+ }
819
+ if (block.presentation.state !== "running") {
820
+ current.presentation = block.presentation;
821
+ } else if (current.presentation.state === "running") {
822
+ current.presentation = {
823
+ ...block.presentation,
824
+ produces: block.presentation.produces ?? current.presentation.produces,
825
+ };
826
+ }
827
+ });
828
+ });
829
+
830
+ return entries.map((entry, entryIndex) =>
831
+ entry.type !== "assistant"
832
+ ? entry
833
+ : {
834
+ ...entry,
835
+ blocks: entry.blocks.flatMap<AssistantBlock>((block, blockIndex) => {
836
+ if (block.kind !== "actionPresentation") return [block];
837
+ const snapshot = snapshots.get(block.presentation.executionId)!;
838
+ return snapshot.entryIndex === entryIndex && snapshot.blockIndex === blockIndex
839
+ ? [{ ...block, presentation: snapshot.presentation }]
840
+ : [];
841
+ }),
842
+ }
843
+ );
521
844
  }
522
845
 
523
846
  /**
@@ -562,75 +885,124 @@ export interface CloudOsActivity {
562
885
  label: string;
563
886
  }
564
887
 
565
- /**
566
- * 尾巴是否**自己会说话** —— 有它在动,就不该再挂一个活动指示器。
567
- *
568
- * 正在流的正文/思考、还在跑的工具组或计划,以及一张**还等着你作答**的
569
- * `ask_user` 卡都算:前三者自身就有动效,最后一个等的是人不是模型,这时候
570
- * 显示「Thinking」是在撒谎。
571
- */
572
- function isLiveBlock(block: AssistantBlock): boolean {
888
+ const SEARCHING_TOOL_KINDS: ReadonlySet<CloudOsToolKind> = new Set([
889
+ "read",
890
+ "glob",
891
+ "grep",
892
+ "list",
893
+ "web-search",
894
+ "web-fetch",
895
+ ]);
896
+ const SHAPING_TOOL_KINDS: ReadonlySet<CloudOsToolKind> = new Set([
897
+ "write",
898
+ "edit",
899
+ "tasks",
900
+ "create-agent",
901
+ "publish-extension",
902
+ ]);
903
+
904
+ function activityForTool(call: CloudOsToolCall | undefined): CloudOsActivity {
905
+ if (!call) return { state: "working", label: "Working…" };
906
+ const name = call.toolName.replaceAll("-", "_").toLowerCase();
907
+ if (call.kind === "agent") {
908
+ return {
909
+ state: "weaving",
910
+ label: call.running ? "Coordinating agents…" : "Combining results…",
911
+ };
912
+ }
913
+ if (SEARCHING_TOOL_KINDS.has(call.kind)) {
914
+ return {
915
+ state: "searching",
916
+ label: call.running ? "Searching…" : "Reading results…",
917
+ };
918
+ }
919
+ if (SHAPING_TOOL_KINDS.has(call.kind)) {
920
+ return { state: "shaping", label: "Shaping result…" };
921
+ }
922
+ if (call.kind === "skill" || name === "activate_skill") {
923
+ return { state: "working", label: "Loading skill…" };
924
+ }
925
+ if (name === "run_skill_script") {
926
+ return { state: "working", label: "Running skill…" };
927
+ }
928
+ return { state: "working", label: "Working…" };
929
+ }
930
+
931
+ function activityForTail(block: AssistantBlock | undefined): CloudOsActivity {
932
+ if (!block) return { state: "solving", label: "Thinking…" };
573
933
  switch (block.kind) {
574
- case "text":
575
934
  case "reasoning":
576
- return true;
935
+ return { state: "solving", label: "Reasoning…" };
936
+ case "text":
937
+ case "suggestions":
938
+ return { state: "composing", label: "Composing answer…" };
577
939
  case "toolGroup":
578
- return block.group.hasRunning;
940
+ return activityForTool(block.group.calls.at(-1));
579
941
  case "plan":
580
- return block.running;
942
+ case "image":
943
+ case "file":
944
+ return { state: "shaping", label: "Shaping result…" };
581
945
  case "askUser":
582
- return block.pending;
583
- // approval 块只在 awaitingApproval 时才建出来,所以它在场就是在等人。
946
+ return block.pending
947
+ ? { state: "listening", label: "Waiting for your answer…" }
948
+ : { state: "solving", label: "Picking up your answer…" };
584
949
  case "approval":
585
- return true;
950
+ return { state: "breathing", label: "Waiting for approval…" };
951
+ case "schedule":
952
+ return block.call.awaitingApproval
953
+ ? { state: "breathing", label: "Waiting for approval…" }
954
+ : { state: "working", label: "Working…" };
586
955
  case "parallel":
587
- return block.tools.some((tool) => tool.status === "running");
956
+ return {
957
+ state: "weaving",
958
+ label: block.tools.some((tool) => tool.status === "running")
959
+ ? "Coordinating tasks…"
960
+ : "Combining results…",
961
+ };
588
962
  case "subagents":
589
- return block.agents.some((agent) => agent.status === "running");
963
+ return {
964
+ state: "weaving",
965
+ label: block.agents.some((agent) => agent.status === "running")
966
+ ? "Coordinating agents…"
967
+ : "Combining results…",
968
+ };
590
969
  default:
591
- return false;
970
+ return { state: "working", label: "Working…" };
592
971
  }
593
972
  }
594
973
 
595
- // 已结算的尾巴 → 此刻真正在发生的事。措辞刻意只说得出口的那部分:
596
- // 工具跑完了,模型在读它的输出;卡答完了,模型在接你的答案。
597
- function activityForTail(block: AssistantBlock | undefined): CloudOsActivity {
598
- if (!block) return { state: "solving", label: "Thinking…" };
599
- if (block.kind === "askUser") {
600
- return { state: "solving", label: "Picking up your answer…" };
601
- }
602
- if (block.kind === "toolGroup") {
603
- const kind = block.group.calls.at(-1)?.kind;
604
- return kind !== undefined &&
605
- ["web-search", "web-fetch", "grep", "glob", "list", "read"].includes(
606
- kind,
607
- )
608
- ? { state: "searching", label: "Reading results…" }
609
- : { state: "working", label: "Working…" };
610
- }
611
- return { state: "working", label: "Working…" };
612
- }
613
-
614
974
  /**
615
- * 本回合此刻该显示什么活动;`null` = 尾巴自己会说话,别再叠一个。
616
- *
617
- * @remarks
618
- * 判据是「**尾巴有没有活口**」,不是「本回合有没有出过内容」。后者曾经是这里的
619
- * 写法,于是答完 `ask_user`(卡片变成 Submitted,读起来就是"结束了")到模型下一
620
- * 个 chunk 之间整屏没有任何在跑的迹象 —— 而那段恰好是最长的一次 TTFT,看起来
621
- * 就是卡死。凡是尾巴为**已结算卡片**的断档都是同一个洞,这里一并补上。
975
+ * 回合活着时始终返回一个 Orb 状态;只有回合终止时才返回 `null`。
622
976
  */
623
977
  export function deriveTurnActivity(
624
978
  entries: readonly CloudOsEntry[],
625
- options: { isActive: boolean; hasPendingSteer?: boolean },
979
+ options: {
980
+ isActive: boolean;
981
+ isRecovering?: boolean;
982
+ recoveryStatusLabel?: string;
983
+ awaitingApproval?: boolean;
984
+ hasPendingSteer?: boolean;
985
+ },
626
986
  ): CloudOsActivity | null {
987
+ if (options.isRecovering) {
988
+ return {
989
+ state: "connecting",
990
+ label: options.recoveryStatusLabel ?? "Reconnecting…",
991
+ };
992
+ }
627
993
  if (!options.isActive) return null;
994
+ if (options.awaitingApproval) {
995
+ return { state: "breathing", label: "Waiting for approval…" };
996
+ }
997
+ if (options.hasPendingSteer) {
998
+ return {
999
+ state: "breathing",
1000
+ label: "Waiting for the current step to finish…",
1001
+ };
1002
+ }
628
1003
  const tail = entries.at(-1) ?? null;
629
1004
  const block = tail?.type === "assistant" ? tail.blocks.at(-1) : undefined;
630
- if (block && isLiveBlock(block)) return null;
631
- return options.hasPendingSteer
632
- ? { state: "solving", label: "Waiting for the current step to finish…" }
633
- : activityForTail(block);
1005
+ return activityForTail(block);
634
1006
  }
635
1007
 
636
1008
  // ── 行间距节奏(照搬原 rhythmTopClass)──────────────────────────────────────