@springbrand/message-panel 0.1.3-alpha.2 → 0.1.3-alpha.22

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 (41) 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/capability-chip.tsx +35 -0
  8. package/cloud-os/chat/activity-indicator.tsx +29 -0
  9. package/cloud-os/chat/cloud-os-chat-messages.tsx +130 -70
  10. package/cloud-os/chat/markdown-message.tsx +85 -40
  11. package/cloud-os/chat/rich-blocks.tsx +494 -190
  12. package/cloud-os/chat/tool-presentation.ts +67 -6
  13. package/cloud-os/chat/tool-rows.tsx +87 -43
  14. package/cloud-os/chat/transcript-model.ts +215 -23
  15. package/cloud-os/composer/cloud-os-chat-input.tsx +242 -127
  16. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  17. package/cloud-os/file-view.tsx +266 -0
  18. package/cloud-os/index.ts +29 -2
  19. package/cloud-os/layout/cloud-os-workspace-split.tsx +107 -24
  20. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  21. package/cloud-os/styles/cloud-os.css +93 -19
  22. package/demo/camel-chat-showcase.tsx +21 -13
  23. package/demo/chat-scenarios.ts +100 -40
  24. package/demo/cloud-os-chat-showcase.tsx +51 -17
  25. package/demo/fixtures.ts +4 -5
  26. package/demo/message-panel-gallery.tsx +16 -2
  27. package/package.json +3 -4
  28. package/src/camel/camel-chat-messages.tsx +82 -77
  29. package/src/camel/camel-prompt-input.tsx +2 -1
  30. package/src/camel/camel-tool-presentation.tsx +19 -15
  31. package/src/camel/camel-turn.ts +1 -17
  32. package/src/chat-summary-panel.tsx +1 -1
  33. package/src/composer/chat-composer.tsx +2 -1
  34. package/src/composer/composer-trigger-popover.tsx +1 -1
  35. package/src/composer/composer.tsx +2 -1
  36. package/src/composer/index.ts +1 -0
  37. package/src/composer/key-rules.ts +12 -0
  38. package/src/contracts.ts +0 -2
  39. package/src/message-panel.tsx +0 -23
  40. package/src/parts/index.tsx +102 -63
  41. package/src/styles/index.css +4 -1
@@ -145,7 +145,7 @@ function truncate(value: string, maxLength: number): string {
145
145
  return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
146
146
  }
147
147
 
148
- function humanize(name: string): string {
148
+ export function humanizeToolName(name: string): string {
149
149
  const spaced = name
150
150
  .replace(/^mcp__/, "")
151
151
  .replace(/__/g, " ")
@@ -298,7 +298,8 @@ export function getToolCallSummary(call: CloudOsToolCall): {
298
298
  return { verb: running ? "Editing" : "Edited", target: filename || undefined };
299
299
  case "bash": {
300
300
  const command =
301
- stringField(input, "description") || stringField(input, "command", "cmd");
301
+ stringField(input, "description") ||
302
+ stringField(input, "script", "command", "cmd");
302
303
  return {
303
304
  verb: running ? "Running" : "Ran",
304
305
  target: command ? truncate(command, 60) : undefined,
@@ -343,7 +344,7 @@ export function getToolCallSummary(call: CloudOsToolCall): {
343
344
  case "agent": {
344
345
  const name =
345
346
  stringField(input, "subagentName", "agent", "name", "agentType") ||
346
- humanize(toolName);
347
+ humanizeToolName(toolName);
347
348
  return { verb: running ? "Running" : "Ran", target: name };
348
349
  }
349
350
  case "skill": {
@@ -378,13 +379,15 @@ export function getToolCallSummary(call: CloudOsToolCall): {
378
379
  const [, server = "", ...rest] = toolName.split("__");
379
380
  return {
380
381
  verb: running ? "Calling" : "Called",
381
- target: rest.length ? `${humanize(rest.join(" "))} · ${humanize(server)}` : undefined,
382
+ target: rest.length
383
+ ? `${humanizeToolName(rest.join(" "))} · ${humanizeToolName(server)}`
384
+ : undefined,
382
385
  };
383
386
  }
384
387
  case "generic":
385
388
  break;
386
389
  }
387
- const displayName = humanize(toolName);
390
+ const displayName = humanizeToolName(toolName);
388
391
  return { verb: running ? `Using ${displayName}` : `Used ${displayName}` };
389
392
  }
390
393
 
@@ -435,7 +438,7 @@ export function describeToolCallCount(
435
438
  case "generic":
436
439
  break;
437
440
  }
438
- const displayName = humanize(toolName);
441
+ const displayName = humanizeToolName(toolName);
439
442
  return count === 1 ? `Used ${displayName}` : `Used ${displayName} ${formatTimes(count)}`;
440
443
  }
441
444
 
@@ -489,6 +492,64 @@ export function toCloudOsToolCall(
489
492
  };
490
493
  }
491
494
 
495
+ // ── ask_user 的终局 ─────────────────────────────────────────────────────────
496
+
497
+ /**
498
+ * `ask_user` 这次调用的三种终局,全部只看它自己的 state 和 output。
499
+ *
500
+ * - `pending`:还等着用户操作,选项可点。**只有 input 态才算 pending** ——
501
+ * 服务端一旦结算,这次 interaction 就不再接受响应。
502
+ * - `answered`:`ask_user` 的 settle 写了按问题对应的 `{ answers }`。
503
+ * - `closed`:被结算掉了但不是一个答案 —— 用户改成直接发言(`{ cancelled: true }`)、
504
+ * Turn 提前结束、或工具出错。必须和 pending 分开,否则卡片会留一个点了拿
505
+ * `{ ok: false }` 的假 Submit,读起来就是「点了没反应」。
506
+ *
507
+ * 判据放在这里而不是卡片里:卡片(能不能点)和底部活动指示器(还在不在等人)
508
+ * 都要用它,两处各判一次迟早会分叉。
509
+ */
510
+ export type AskUserOutcome =
511
+ | { kind: "pending" }
512
+ | {
513
+ kind: "answered";
514
+ answers: Array<{
515
+ question: string;
516
+ selections: string[];
517
+ text: string;
518
+ }>;
519
+ }
520
+ | { kind: "closed"; reason: "user_replied_freeform" | "other" };
521
+
522
+ export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
523
+ if (call.state === "input-streaming" || call.state === "input-available") {
524
+ return { kind: "pending" };
525
+ }
526
+ const record = recordOf(call.output);
527
+ if (Array.isArray(record.answers) && record.answers.length > 0) {
528
+ const answers = record.answers.map(recordOf);
529
+ if (answers.every((answer) =>
530
+ typeof answer.question === "string" &&
531
+ Array.isArray(answer.selections) &&
532
+ answer.selections.every((value) => typeof value === "string") &&
533
+ (answer.text === undefined || typeof answer.text === "string")
534
+ )) {
535
+ return {
536
+ kind: "answered",
537
+ answers: answers.map((answer) => ({
538
+ question: answer.question as string,
539
+ selections: answer.selections as string[],
540
+ text: typeof answer.text === "string" ? answer.text : "",
541
+ })),
542
+ };
543
+ }
544
+ }
545
+ return {
546
+ kind: "closed",
547
+ reason: record.reason === "user_replied_freeform"
548
+ ? "user_replied_freeform"
549
+ : "other",
550
+ };
551
+ }
552
+
492
553
  // ── 分组(逐行对应原 buildToolCallGroups)────────────────────────────────────
493
554
 
494
555
  export function buildToolCallGroups(calls: CloudOsToolCall[]): ToolCallGroup[] {
@@ -1,40 +1,60 @@
1
- import { CaretRight } from "@phosphor-icons/react";
2
- import { memo } from "react";
1
+ import { Atom, CaretDown, CaretRight, Copy } from "@phosphor-icons/react";
2
+ import { memo, useId, useState } from "react";
3
3
  import { MarkdownMessage } from "./markdown-message";
4
4
  import {
5
5
  getToolCallSummary,
6
6
  getToolIcon,
7
7
  normalizeOutputText,
8
- safeStringify,
9
8
  type CloudOsToolCall,
10
9
  type PhosphorIcon,
11
10
  type ToolCallGroup,
12
11
  } from "./tool-presentation";
13
12
 
14
13
  /**
15
- * 工作行(work rows)。整块从 cloudflare-os-main ChatInterface.tsx 拷来:
16
- * WorkIcon / ToolCallDetails / NestedToolCallRow / ToolGroupRow / ThinkingTraceRow
17
- * className 一个字没改;改的只有取数——从 gadgets 的 AiToolCall 换成
18
- * UIMessage 归一化后的 CloudOsToolCall。
14
+ * 工作行(work rows): WorkIcon / ToolCallDetails / NestedToolCallRow /
15
+ * ToolGroupRow / ThinkingTraceRow。渲染 UIMessage 归一化后的 CloudOsToolCall
19
16
  */
20
17
 
21
18
  export function WorkIcon({ Icon }: { Icon: PhosphorIcon }) {
22
19
  return <Icon size={15} className="text-kumo-inactive" />;
23
20
  }
24
21
 
25
- function OutputPre({ label, value }: { label?: string; value: string }) {
26
- if (!value) return null;
22
+ function displayOutput(value: unknown, fallback: string): string {
23
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
24
+ return fallback;
25
+ }
26
+ const record = value as Record<string, unknown>;
27
+ if (typeof record.summary === "string" && record.summary.trim()) {
28
+ return record.summary.trim();
29
+ }
30
+ const displayValue = (item: unknown): string =>
31
+ item != null && typeof item === "object"
32
+ ? Object.entries(item).map(([key, child]) => `${key}: ${displayValue(child)}`).join("\n")
33
+ : String(item ?? "");
34
+ return displayValue(record);
35
+ }
36
+
37
+ function OutputPre({ value }: { value: string }) {
27
38
  return (
28
- <>
29
- {label && (
30
- <span className="font-mono text-[11px] leading-4 text-kumo-inactive uppercase tracking-[0.08em]">
31
- {label}
32
- </span>
33
- )}
34
- <pre className="max-h-56 overflow-auto rounded-xl border border-kumo-line/70 bg-kumo-base p-3 font-mono text-[12px] leading-[18px] text-kumo-subtle whitespace-pre-wrap">
39
+ <div
40
+ className="rounded-xl border border-kumo-line/70 bg-kumo-elevated/45 px-4 py-3 text-kumo-subtle"
41
+ data-output-card=""
42
+ >
43
+ <div className="flex items-center justify-between gap-3 text-[14px] font-medium leading-5">
44
+ <span>Output</span>
45
+ <button
46
+ 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"
49
+ aria-label="Copy output"
50
+ >
51
+ <Copy size={16} />
52
+ </button>
53
+ </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">
35
55
  {value}
36
56
  </pre>
37
- </>
57
+ </div>
38
58
  );
39
59
  }
40
60
 
@@ -43,14 +63,7 @@ export const ToolCallDetails = memo(function ToolCallDetails({
43
63
  }: {
44
64
  toolCall: CloudOsToolCall;
45
65
  }) {
46
- const code =
47
- typeof tc.input.code === "string"
48
- ? tc.input.code
49
- : typeof tc.input.command === "string"
50
- ? tc.input.command
51
- : "";
52
- const inputText =
53
- Object.keys(tc.input).length > 0 ? safeStringify(tc.input) : "";
66
+ const output = displayOutput(tc.output, tc.outputText);
54
67
  return (
55
68
  <div className="space-y-2">
56
69
  {tc.errorText && (
@@ -58,18 +71,8 @@ export const ToolCallDetails = memo(function ToolCallDetails({
58
71
  {tc.errorText}
59
72
  </pre>
60
73
  )}
61
- {code ? (
62
- <>
63
- <OutputPre label="Code" value={code} />
64
- <OutputPre label="Output" value={tc.outputText} />
65
- </>
66
- ) : (
67
- <>
68
- <OutputPre value={inputText} />
69
- {tc.outputText && <OutputPre label="Output" value={tc.outputText} />}
70
- </>
71
- )}
72
- {!code && !inputText && !tc.outputText && !tc.errorText && (
74
+ {output && <OutputPre value={output} />}
75
+ {!output && !tc.errorText && (
73
76
  <p className="m-0 text-[12px] leading-4 text-kumo-inactive">
74
77
  No additional data
75
78
  </p>
@@ -118,7 +121,7 @@ export const NestedToolCallRow = memo(function NestedToolCallRow({
118
121
  </span>
119
122
  </button>
120
123
  {open && (
121
- <div className="themed-surface-inset ml-8 mt-1 space-y-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3">
124
+ <div className="ml-8 mt-1 space-y-3">
122
125
  <ToolCallDetails toolCall={tc} />
123
126
  </div>
124
127
  )}
@@ -128,14 +131,55 @@ export const NestedToolCallRow = memo(function NestedToolCallRow({
128
131
 
129
132
  export const ThinkingTraceRow = memo(function ThinkingTraceRow({
130
133
  reasoning,
134
+ running,
131
135
  }: {
132
136
  reasoning: string;
137
+ running: boolean;
133
138
  }) {
139
+ const [open, setOpen] = useState(false);
140
+ const contentId = useId();
141
+ const summary = reasoning.replace(/\s+/g, " ").trim();
142
+
134
143
  return (
135
- <div className="min-w-0 py-1 text-kumo-subtle">
136
- <div className="cos-markdown min-w-0 text-[13px] leading-[19px]">
137
- <MarkdownMessage message={reasoning} />
138
- </div>
144
+ <div
145
+ className="flex min-w-0 flex-col items-start gap-2"
146
+ data-thinking-trace=""
147
+ data-state={open ? "open" : "closed"}
148
+ >
149
+ <button
150
+ type="button"
151
+ onClick={() => setOpen((current) => !current)}
152
+ className="group -ml-0.5 flex w-fit max-w-[80%] min-w-0 items-center gap-1.5 rounded-xl px-1.5 py-1 text-left text-[14px] leading-5 text-kumo-inactive transition-colors hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none"
153
+ aria-expanded={open}
154
+ aria-controls={contentId}
155
+ >
156
+ <span className="relative grid size-4 flex-shrink-0 place-items-center" aria-hidden="true">
157
+ <Atom
158
+ size={14}
159
+ className="transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"
160
+ />
161
+ <CaretDown
162
+ size={14}
163
+ className={`absolute opacity-0 transition-[opacity,transform] group-hover:opacity-100 group-focus-visible:opacity-100 ${open ? "" : "-rotate-90"}`}
164
+ />
165
+ </span>
166
+ <span className="flex-shrink-0 text-kumo-subtle">Think</span>
167
+ <span aria-hidden="true">·</span>
168
+ <span
169
+ className="min-w-0 flex-1 truncate"
170
+ style={running ? { direction: "rtl" } : undefined}
171
+ >
172
+ <bdi dir="auto">{summary}</bdi>
173
+ </span>
174
+ </button>
175
+ {open && (
176
+ <div
177
+ id={contentId}
178
+ className="cos-markdown w-full rounded-xl border border-kumo-line bg-kumo-elevated/45 px-4 py-3 text-[14px] leading-[1.4] text-kumo-default/70"
179
+ >
180
+ <MarkdownMessage message={reasoning} />
181
+ </div>
182
+ )}
139
183
  </div>
140
184
  );
141
185
  });
@@ -189,7 +233,7 @@ export const ToolGroupRow = memo(function ToolGroupRow({
189
233
  </button>
190
234
  {open &&
191
235
  (group.calls.length === 1 ? (
192
- <div className="themed-surface-inset ml-8 mt-1 space-y-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3">
236
+ <div className="ml-8 mt-1 space-y-3">
193
237
  <ToolCallDetails toolCall={group.calls[0]} />
194
238
  </div>
195
239
  ) : (
@@ -1,8 +1,11 @@
1
1
  import type { UIMessage } from "ai";
2
+ import type { OrbState } from "thinking-orbs";
2
3
  import {
4
+ askUserOutcome,
3
5
  buildToolCallGroups,
4
6
  toCloudOsToolCall,
5
7
  type CloudOsToolCall,
8
+ type CloudOsToolKind,
6
9
  type ToolCallGroup,
7
10
  } from "./tool-presentation";
8
11
 
@@ -27,6 +30,13 @@ export interface CloudOsMessageMetadata extends UnknownRecord {
27
30
  turnDurationMs?: number;
28
31
  turnStartedAt?: number;
29
32
  turnStatus?: string;
33
+ requestedCapabilities?: unknown;
34
+ }
35
+
36
+ export interface CloudOsRequestedCapability {
37
+ kind: "skill" | "plan";
38
+ name: string;
39
+ label: string;
30
40
  }
31
41
 
32
42
  export interface CloudOsAttachment {
@@ -41,19 +51,34 @@ export interface PlanStep {
41
51
  }
42
52
 
43
53
  export type AssistantBlock =
44
- | { kind: "reasoning"; key: string; text: string }
54
+ | {
55
+ kind: "reasoning";
56
+ key: string;
57
+ text: string;
58
+ running: boolean;
59
+ }
45
60
  | { kind: "text"; key: string; text: string }
46
61
  | { kind: "toolGroup"; key: string; group: ToolCallGroup }
47
62
  | { kind: "plan"; key: string; steps: PlanStep[]; running: boolean }
48
- // answeredText 取的是**这条消息之后的第一条用户直答**,不是工具 output ——
49
- // ask_user output 往往只是 `{ asked: true }` 之类的回执。
50
- | { kind: "askUser"; key: string; call: CloudOsToolCall; answeredText?: string }
63
+ // 答案就在 call.output 里(`ask_user` 是 client-settled tool,用户点选后
64
+ // respondToolInteraction 回写成这次调用的结果)。曾经靠扫下一条用户消息
65
+ // 推断,那是有损且会误认无关消息的,已彻底删除。
66
+ // `pending` 是这张卡还在等人 —— 卡片据此决定能不能点,活动指示器据此决定
67
+ // 该不该出现,同一条判据(askUserOutcome)算一次。
68
+ | { kind: "askUser"; key: string; call: CloudOsToolCall; pending: boolean }
51
69
  | { kind: "suggestions"; key: string; items: string[] }
52
70
  | { kind: "schedule"; key: string; call: CloudOsToolCall }
53
71
  | { kind: "approval"; key: string; call: CloudOsToolCall; approvalId: string }
54
72
  | { kind: "parallel"; key: string; label: string; tools: ParallelTool[] }
55
73
  | { kind: "subagents"; key: string; agents: SubAgentView[] }
56
- | { kind: "image"; key: string; src: string; alt: string }
74
+ | {
75
+ kind: "image";
76
+ key: string;
77
+ src: string;
78
+ alt: string;
79
+ mediaType?: string;
80
+ }
81
+ | { kind: "file"; key: string; file: CloudOsAttachment }
57
82
  | { kind: "error"; key: string; title: string; body?: string };
58
83
 
59
84
  export interface ParallelTool {
@@ -75,6 +100,7 @@ export type CloudOsEntry =
75
100
  messageId: string;
76
101
  text: string;
77
102
  attachments: CloudOsAttachment[];
103
+ capabilities: CloudOsRequestedCapability[];
78
104
  authorName?: string;
79
105
  timestamp?: number;
80
106
  }
@@ -121,6 +147,31 @@ export function cloudOsMetadata(message: UIMessage): CloudOsMessageMetadata {
121
147
  return recordOf(message.metadata) as CloudOsMessageMetadata;
122
148
  }
123
149
 
150
+ export function requestedCapabilitiesOf(
151
+ metadata: CloudOsMessageMetadata,
152
+ ): CloudOsRequestedCapability[] {
153
+ if (!Array.isArray(metadata.requestedCapabilities)) return [];
154
+ const seen = new Set<string>();
155
+ return metadata.requestedCapabilities.flatMap((value) => {
156
+ const capability = recordOf(value);
157
+ if (
158
+ (capability.kind !== "skill" && capability.kind !== "plan") ||
159
+ typeof capability.name !== "string" ||
160
+ !capability.name.trim() ||
161
+ typeof capability.label !== "string" ||
162
+ !capability.label.trim()
163
+ ) return [];
164
+ const key = `${capability.kind}:${capability.name}`;
165
+ if (seen.has(key)) return [];
166
+ seen.add(key);
167
+ return [{
168
+ kind: capability.kind,
169
+ name: capability.name,
170
+ label: capability.label,
171
+ }];
172
+ });
173
+ }
174
+
124
175
  export function messageText(message: UIMessage): string {
125
176
  return message.parts
126
177
  .filter(
@@ -248,7 +299,6 @@ function assistantBlocks(
248
299
  message: UIMessage,
249
300
  isActive: boolean,
250
301
  showThinkingTraces: boolean,
251
- replyText: string | undefined,
252
302
  approvalIdOf: (part: unknown) => string | undefined,
253
303
  ): AssistantBlock[] {
254
304
  const blocks: AssistantBlock[] = [];
@@ -281,7 +331,12 @@ function assistantBlocks(
281
331
  const text = String(record.text ?? "").trim();
282
332
  if (!text || !showThinkingTraces) return;
283
333
  flush();
284
- blocks.push({ kind: "reasoning", key, text });
334
+ blocks.push({
335
+ kind: "reasoning",
336
+ key,
337
+ text,
338
+ running: record.state === "streaming",
339
+ });
285
340
  return;
286
341
  }
287
342
 
@@ -339,15 +394,32 @@ function assistantBlocks(
339
394
  const data = dataOf(part);
340
395
  const src = String(data.src ?? data.url ?? record.url ?? "");
341
396
  const mediaType = String(record.mediaType ?? data.mediaType ?? "");
342
- if (src && (dataKind === "image" || mediaType.startsWith("image/"))) {
343
- flush();
397
+ if (!src) return;
398
+
399
+ flush();
400
+ const filename = String(
401
+ data.alt ?? data.filename ?? record.filename ?? "Attachment",
402
+ );
403
+ if (dataKind === "image" || mediaType.startsWith("image/")) {
344
404
  blocks.push({
345
405
  kind: "image",
346
406
  key,
347
407
  src,
348
- alt: String(data.alt ?? data.filename ?? record.filename ?? "Image"),
408
+ alt: filename,
409
+ mediaType: mediaType || undefined,
349
410
  });
411
+ return;
350
412
  }
413
+
414
+ blocks.push({
415
+ kind: "file",
416
+ key,
417
+ file: {
418
+ url: src,
419
+ filename,
420
+ mediaType: mediaType || undefined,
421
+ },
422
+ });
351
423
  return;
352
424
  }
353
425
 
@@ -375,15 +447,13 @@ function assistantBlocks(
375
447
  }
376
448
 
377
449
  if (call.kind === "ask-user") {
450
+ if (call.failed) return;
378
451
  flush();
379
452
  blocks.push({
380
453
  kind: "askUser",
381
454
  key,
382
455
  call,
383
- answeredText:
384
- replyText?.trim() ||
385
- (typeof call.output === "string" ? call.output.trim() : "") ||
386
- undefined,
456
+ pending: askUserOutcome(call).kind === "pending",
387
457
  });
388
458
  return;
389
459
  }
@@ -432,13 +502,6 @@ function terminalOf(
432
502
  return undefined;
433
503
  }
434
504
 
435
- function isDirectUserMessage(message: UIMessage): boolean {
436
- return (
437
- message.role === "user" &&
438
- !message.parts.some((part) => dataKindOf(part) !== null)
439
- );
440
- }
441
-
442
505
  export interface BuildEntriesOptions {
443
506
  messages: readonly UIMessage[];
444
507
  /** streaming/submitted 时最后一条 assistant 消息才算「活着」。 */
@@ -494,6 +557,7 @@ export function buildCloudOsEntries({
494
557
  messageId: message.id,
495
558
  text,
496
559
  attachments,
560
+ capabilities: requestedCapabilitiesOf(metadata),
497
561
  authorName: metadata.authorDisplayName,
498
562
  timestamp:
499
563
  nonNegativeNumber(metadata.createdAt) ??
@@ -502,12 +566,10 @@ export function buildCloudOsEntries({
502
566
  return;
503
567
  }
504
568
 
505
- const reply = messages.slice(index + 1).find(isDirectUserMessage);
506
569
  const blocks = assistantBlocks(
507
570
  message,
508
571
  isActive && index === lastAssistantIndex,
509
572
  showThinkingTraces,
510
- reply ? messageText(reply).trim() || undefined : undefined,
511
573
  approvalIdOf,
512
574
  );
513
575
  const terminal = terminalOf(message);
@@ -560,6 +622,136 @@ function dropSupersededPlans(entries: CloudOsEntry[]): CloudOsEntry[] {
560
622
  );
561
623
  }
562
624
 
625
+ // ── 活动指示(本回合此刻在干什么)────────────────────────────────────────────
626
+
627
+ /**
628
+ * 底部活动指示器要显示的动画与文案。
629
+ */
630
+ export interface CloudOsActivity {
631
+ state: OrbState;
632
+ label: string;
633
+ }
634
+
635
+ const SEARCHING_TOOL_KINDS: ReadonlySet<CloudOsToolKind> = new Set([
636
+ "read",
637
+ "glob",
638
+ "grep",
639
+ "list",
640
+ "web-search",
641
+ "web-fetch",
642
+ ]);
643
+ const SHAPING_TOOL_KINDS: ReadonlySet<CloudOsToolKind> = new Set([
644
+ "write",
645
+ "edit",
646
+ "tasks",
647
+ "create-agent",
648
+ "publish-extension",
649
+ ]);
650
+
651
+ function activityForTool(call: CloudOsToolCall | undefined): CloudOsActivity {
652
+ if (!call) return { state: "working", label: "Working…" };
653
+ const name = call.toolName.replaceAll("-", "_").toLowerCase();
654
+ if (call.kind === "agent") {
655
+ return {
656
+ state: "weaving",
657
+ label: call.running ? "Coordinating agents…" : "Combining results…",
658
+ };
659
+ }
660
+ if (SEARCHING_TOOL_KINDS.has(call.kind)) {
661
+ return {
662
+ state: "searching",
663
+ label: call.running ? "Searching…" : "Reading results…",
664
+ };
665
+ }
666
+ if (SHAPING_TOOL_KINDS.has(call.kind)) {
667
+ return { state: "shaping", label: "Shaping result…" };
668
+ }
669
+ if (call.kind === "skill" || name === "activate_skill") {
670
+ return { state: "working", label: "Loading skill…" };
671
+ }
672
+ if (name === "run_skill_script") {
673
+ return { state: "working", label: "Running skill…" };
674
+ }
675
+ return { state: "working", label: "Working…" };
676
+ }
677
+
678
+ function activityForTail(block: AssistantBlock | undefined): CloudOsActivity {
679
+ if (!block) return { state: "solving", label: "Thinking…" };
680
+ switch (block.kind) {
681
+ case "reasoning":
682
+ return { state: "solving", label: "Reasoning…" };
683
+ case "text":
684
+ case "suggestions":
685
+ return { state: "composing", label: "Composing answer…" };
686
+ case "toolGroup":
687
+ return activityForTool(block.group.calls.at(-1));
688
+ case "plan":
689
+ case "image":
690
+ case "file":
691
+ return { state: "shaping", label: "Shaping result…" };
692
+ case "askUser":
693
+ return block.pending
694
+ ? { state: "listening", label: "Waiting for your answer…" }
695
+ : { state: "solving", label: "Picking up your answer…" };
696
+ case "approval":
697
+ return { state: "breathing", label: "Waiting for approval…" };
698
+ case "schedule":
699
+ return block.call.awaitingApproval
700
+ ? { state: "breathing", label: "Waiting for approval…" }
701
+ : { state: "working", label: "Working…" };
702
+ case "parallel":
703
+ return {
704
+ state: "weaving",
705
+ label: block.tools.some((tool) => tool.status === "running")
706
+ ? "Coordinating tasks…"
707
+ : "Combining results…",
708
+ };
709
+ case "subagents":
710
+ return {
711
+ state: "weaving",
712
+ label: block.agents.some((agent) => agent.status === "running")
713
+ ? "Coordinating agents…"
714
+ : "Combining results…",
715
+ };
716
+ default:
717
+ return { state: "working", label: "Working…" };
718
+ }
719
+ }
720
+
721
+ /**
722
+ * 回合活着时始终返回一个 Orb 状态;只有回合终止时才返回 `null`。
723
+ */
724
+ export function deriveTurnActivity(
725
+ entries: readonly CloudOsEntry[],
726
+ options: {
727
+ isActive: boolean;
728
+ isRecovering?: boolean;
729
+ recoveryStatusLabel?: string;
730
+ awaitingApproval?: boolean;
731
+ hasPendingSteer?: boolean;
732
+ },
733
+ ): CloudOsActivity | null {
734
+ if (options.isRecovering) {
735
+ return {
736
+ state: "connecting",
737
+ label: options.recoveryStatusLabel ?? "Reconnecting…",
738
+ };
739
+ }
740
+ if (!options.isActive) return null;
741
+ if (options.awaitingApproval) {
742
+ return { state: "breathing", label: "Waiting for approval…" };
743
+ }
744
+ if (options.hasPendingSteer) {
745
+ return {
746
+ state: "breathing",
747
+ label: "Waiting for the current step to finish…",
748
+ };
749
+ }
750
+ const tail = entries.at(-1) ?? null;
751
+ const block = tail?.type === "assistant" ? tail.blocks.at(-1) : undefined;
752
+ return activityForTail(block);
753
+ }
754
+
563
755
  // ── 行间距节奏(照搬原 rhythmTopClass)──────────────────────────────────────
564
756
 
565
757
  function isUserEntry(entry: CloudOsEntry): boolean {