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

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.
@@ -46,10 +46,10 @@ pnpm dev:cloud-os # http://localhost:9992
46
46
  shadcn 主题正面冲突。
47
47
  4. **作用域化主题**。只有 kumo 命名空间的 token 进 `@theme`;通用 token(字体 /
48
48
  圆角 / 字号 / 缓动)挂在 `.cloud-os-root` 上靠继承生效,宿主主题分毫不动。
49
- 5. **删掉的东西**:capsule(URL → 资源胶囊)、行内 slash picker、ComposerMirror、
50
- GatekeeperModal、Yjs 草稿 / 「接受改动」那一整套。它们都直挂 gadgets 的 capnweb
51
- RPC,UIMessage 协议下没有对应物。slash 命令退成「+」菜单里的一组条目 —— 能力
52
- 保住,但没有边打字边过滤的行内补全。
49
+ 5. **删掉的东西**:capsule(URL → 资源胶囊)、ComposerMirror、GatekeeperModal、
50
+ Yjs 草稿 / 「接受改动」那一整套。它们都直挂 gadgets 的 capnweb RPC,
51
+ UIMessage 协议下没有对应物。命令补全使用 package 内的 assistant-ui
52
+ `ComposerTriggerPopover`,宿主只传命令数据。
53
53
  6. **窄栏适配**。授权卡 / 提问卡 / 定时卡在上游是「图标 + 正文 + 右侧动作」一行到底;
54
54
  聊天栏默认只有 420px,一行放不下会把正文挤成 0 宽,所以改成允许换行。
55
55
 
@@ -0,0 +1,35 @@
1
+ import { Diamond, X } from "@phosphor-icons/react";
2
+
3
+ export interface CapabilityChipProps {
4
+ kind: "skill" | "plan";
5
+ label: string;
6
+ removable?: boolean;
7
+ onRemove?: () => void;
8
+ }
9
+
10
+ export function CapabilityChip({
11
+ kind,
12
+ label,
13
+ removable = false,
14
+ onRemove,
15
+ }: CapabilityChipProps) {
16
+ return (
17
+ <span
18
+ data-capability-kind={kind}
19
+ className="inline-flex max-w-full items-center gap-1 rounded-full border border-kumo-line bg-kumo-elevated px-2 py-1 text-[12px] leading-4 text-kumo-subtle"
20
+ >
21
+ <Diamond size={11} weight="duotone" className="shrink-0 text-kumo-brand" />
22
+ <span className="truncate">{label}</span>
23
+ {removable && onRemove && (
24
+ <button
25
+ type="button"
26
+ onClick={onRemove}
27
+ aria-label={`Remove ${label}`}
28
+ className="-mr-1 grid size-5 shrink-0 cursor-pointer place-items-center rounded-full text-kumo-inactive hover:bg-kumo-tint hover:text-kumo-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
29
+ >
30
+ <X size={10} weight="bold" />
31
+ </button>
32
+ )}
33
+ </span>
34
+ );
35
+ }
@@ -0,0 +1,29 @@
1
+ import { ThinkingOrb } from "thinking-orbs";
2
+ import { useCloudOsMode } from "../internal/theme-context";
3
+ import type { CloudOsActivity } from "./transcript-model";
4
+
5
+ /**
6
+ * 回合还在跑、但屏幕上没有任何东西在动时的活动指示。
7
+ *
8
+ * 用 thinking-orbs 的 canvas 球而不是一行 shimmer 文字:动画本身就是「还活着」
9
+ * 的信号,文案只负责说清在干什么(`deriveTurnActivity` 给出的四种之一)。
10
+ *
11
+ * 和 `src/status.tsx` 的 `ThinkingIndicator` 是同一个上游球、同一套 role/aria
12
+ * 形状,但**不共用实现** —— cloud-os 不 import `../src`(见 cloud-os/README.md,
13
+ * 由 check-chat-presentation-boundary.mjs 守着),文案也走 cloud-os 自己的
14
+ * 工具种类表,不是 src 那套工具名正则。
15
+ */
16
+ export function CloudOsActivityIndicator({ state, label }: CloudOsActivity) {
17
+ // 必须显式传 theme,不能用它的 `auto`:auto 先扫祖先的 data-theme / .dark / .light,
18
+ // 而本仓的浅色**是「没有 .dark」**,扫不到任何标记 → 它回落到
19
+ // prefers-color-scheme,也就是**操作系统**的深浅。系统深色 + 应用浅色时,
20
+ // 球会在浅底上画浅墨,几乎看不见。cloud-os 子树的深浅事实源是这个 context
21
+ // (CloudOsRoot 从宿主的 useThemeMode() 拿到),直接用它。
22
+ const mode = useCloudOsMode();
23
+ return (
24
+ <div className="cos-activity" role="status" aria-label={label}>
25
+ <ThinkingOrb state={state} size={20} theme={mode} aria-hidden="true" />
26
+ <span>{label}</span>
27
+ </div>
28
+ );
29
+ }
@@ -9,11 +9,13 @@ import {
9
9
  Terminal,
10
10
  } from "@phosphor-icons/react";
11
11
  import {
12
+ Fragment,
12
13
  useCallback,
13
14
  useEffect,
14
15
  useMemo,
15
16
  useRef,
16
17
  useState,
18
+ type ReactNode,
17
19
  } from "react";
18
20
  import { Tooltip } from "../primitives/tooltip";
19
21
  import { WorkshopIconButton } from "../primitives/workshop-controls";
@@ -33,8 +35,11 @@ import {
33
35
  type ApprovalDecision,
34
36
  } from "./rich-blocks";
35
37
  import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
38
+ import { CloudOsActivityIndicator } from "./activity-indicator";
39
+ import { CapabilityChip } from "../capability-chip";
36
40
  import {
37
41
  buildCloudOsEntries,
42
+ deriveTurnActivity,
38
43
  formatClockTime,
39
44
  formatFullTimestamp,
40
45
  rhythmTopClass,
@@ -58,6 +63,18 @@ export interface CloudOsChatMessagesProps {
58
63
  /** 把某个 part 映射成待授权 id;返回 undefined 表示不在此处渲染授权。 */
59
64
  approvalIdOf?: (part: unknown) => string | undefined;
60
65
  approvalsDisabled?: boolean;
66
+ /**
67
+ * 用宿主自己的卡片渲染某条待授权。返回 undefined 就退回内置的通用授权行。
68
+ *
69
+ * 存在的理由:授权行只认得工具名和入参,说不出「这次要花多少钱、买的是什么」。
70
+ * 需要在决定之前把这些摆到人面前的宿主,得自己画那张卡。
71
+ */
72
+ renderApproval?: (approval: {
73
+ approvalId: string;
74
+ toolName: string;
75
+ input: Record<string, unknown>;
76
+ disabled: boolean;
77
+ }) => ReactNode;
61
78
  /** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
62
79
  resolveUrl?: CloudOsUrlResolver;
63
80
  /** 消息区宽度是否收敛到 920px 居中(GadgetEditor 里叫 constrainChatWidth)。 */
@@ -66,7 +83,11 @@ export interface CloudOsChatMessagesProps {
66
83
  onRetry?: () => void;
67
84
  onApprove?: (approvalId: string, decision: ApprovalDecision) => void;
68
85
  onSuggestion?: (text: string) => void;
69
- onAnswer?: (payload: unknown) => void;
86
+ /**
87
+ * 把交互卡片上的操作回写成那次工具调用的结果(缺省 → 卡片不可操作)。
88
+ * 卡片不再知道「答案怎么变成消息」—— 它只认自己的 toolCallId。
89
+ */
90
+ onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
70
91
  /** 点击定时任务卡片:宿主决定跳到哪(缺省 → 卡片不可点)。 */
71
92
  onOpenSchedule?: (scheduleId: string) => void;
72
93
  onCopy?: (text: string) => void;
@@ -107,6 +128,17 @@ function UserBubble({
107
128
  }) {
108
129
  return (
109
130
  <div className="group/message relative flex flex-col items-end">
131
+ {entry.capabilities.length > 0 && (
132
+ <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-1.5">
133
+ {entry.capabilities.map((capability) => (
134
+ <CapabilityChip
135
+ key={`${capability.kind}:${capability.name}`}
136
+ kind={capability.kind}
137
+ label={capability.label}
138
+ />
139
+ ))}
140
+ </div>
141
+ )}
110
142
  {entry.attachments.length > 0 && (
111
143
  <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-2">
112
144
  {entry.attachments.map((attachment, index) =>
@@ -173,13 +205,14 @@ export function CloudOsChatMessages({
173
205
  showThinkingTraces = true,
174
206
  approvalIdOf,
175
207
  approvalsDisabled = false,
208
+ renderApproval,
176
209
  resolveUrl,
177
210
  constrainWidth = true,
178
211
  emptyTitle = "What are we working on?",
179
212
  onRetry,
180
213
  onApprove,
181
214
  onSuggestion,
182
- onAnswer,
215
+ onRespond,
183
216
  onOpenSchedule,
184
217
  onCopy,
185
218
  }: CloudOsChatMessagesProps) {
@@ -246,14 +279,10 @@ export function CloudOsChatMessages({
246
279
  element.scrollTo({ top: element.scrollHeight });
247
280
  }, [isRecovering, messages, status, showScrollButton]);
248
281
 
249
- // 只有在「本回合还没吐出任何可见内容」时才显示 Thinking 占位;一旦有真实输出,
250
- // 输出自己会说话(照搬原文件的 showThinking 判据)。
251
- const lastEntry = entries.at(-1) ?? null;
252
- const awaitingFirstOutput =
253
- isActive &&
254
- (lastEntry === null ||
255
- lastEntry.type === "user" ||
256
- lastEntry.type === "slashCommand");
282
+ // 判据是「尾巴有没有活口」,不是原文件的「本回合有没有出过内容」——
283
+ // 后者会让「答完 ask_user 之后到模型下一个 chunk」这段完全没有指示,
284
+ // 而卡片此时写着 Submitted,读起来就是卡死了。见 deriveTurnActivity。
285
+ const activity = deriveTurnActivity(entries, { isActive, hasPendingSteer });
257
286
  const copyText = (text: string) => {
258
287
  copy(text);
259
288
  onCopy?.(text);
@@ -432,8 +461,7 @@ export function CloudOsChatMessages({
432
461
  <AskUserBlock
433
462
  key={block.key}
434
463
  call={block.call}
435
- answeredText={block.answeredText}
436
- onAnswer={(payload) => onAnswer?.(payload)}
464
+ onRespond={onRespond}
437
465
  />
438
466
  );
439
467
  case "suggestions":
@@ -452,7 +480,18 @@ export function CloudOsChatMessages({
452
480
  onOpen={onOpenSchedule}
453
481
  />
454
482
  );
455
- case "approval":
483
+ case "approval": {
484
+ const custom = renderApproval?.({
485
+ approvalId: block.approvalId,
486
+ toolName: block.call.toolName,
487
+ input: block.call.input,
488
+ disabled: approvalsDisabled,
489
+ });
490
+ if (custom !== undefined && custom !== null) {
491
+ return (
492
+ <Fragment key={block.key}>{custom}</Fragment>
493
+ );
494
+ }
456
495
  return (
457
496
  <ApprovalBlock
458
497
  key={block.key}
@@ -462,6 +501,7 @@ export function CloudOsChatMessages({
462
501
  onApprove={(id, decision) => onApprove?.(id, decision)}
463
502
  />
464
503
  );
504
+ }
465
505
  case "parallel":
466
506
  return (
467
507
  <ParallelBlock
@@ -551,11 +591,9 @@ export function CloudOsChatMessages({
551
591
  </div>
552
592
  )}
553
593
 
554
- {awaitingFirstOutput && (
594
+ {activity && (
555
595
  <div className="mt-5 inline-flex px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
556
- <span className="cos-thinking-shimmer">
557
- {hasPendingSteer ? "Waiting for the current step to finish…" : "Thinking"}
558
- </span>
596
+ <CloudOsActivityIndicator {...activity} />
559
597
  </div>
560
598
  )}
561
599
 
@@ -18,7 +18,7 @@ import type {
18
18
  PlanStep,
19
19
  SubAgentView,
20
20
  } from "./transcript-model";
21
- import type { CloudOsToolCall } from "./tool-presentation";
21
+ import { askUserOutcome, type CloudOsToolCall } from "./tool-presentation";
22
22
 
23
23
  /**
24
24
  * UIMessage 协议里存在、gadgets 那套没有的几类 part —— 计划快照、原位提问、
@@ -149,99 +149,131 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
149
149
 
150
150
  export function AskUserBlock({
151
151
  call,
152
- answeredText,
153
- onAnswer,
152
+ onRespond,
154
153
  }: {
155
154
  call: CloudOsToolCall;
156
- answeredText?: string;
157
- onAnswer: (payload: unknown) => void;
155
+ /** 缺省(宿主没接)时选项不可点 —— 形状对齐 ScheduleBlock 的 onOpen。 */
156
+ onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
158
157
  }) {
159
158
  const questions = questionsOf(call.input);
160
- const [selections, setSelections] = useState<string[][]>(() =>
161
- questions.map(() => []),
162
- );
163
- const [submitted, setSubmitted] = useState(false);
164
- const external = answeredText?.trim() ?? "";
165
- const externalSelections = new Set(
166
- external.split("、").map((value) => value.trim()).filter(Boolean),
167
- );
168
- const resolved = submitted || Boolean(external);
169
- const valid = questions.every(
159
+ // 不能用 `useState(() => questions.map(() => []))` 初始化:工具入参是**流式**到达的,
160
+ // 首帧 questions 往往还是空数组,那之后每次 setSelections 都在空数组上 map,
161
+ // 选择永远存不进去 → Submit 永久禁用。改成稀疏存 + 按当前 questions 补齐。
162
+ const [selections, setSelections] = useState<string[][]>([]);
163
+ const selectionAt = (index: number) => selections[index] ?? [];
164
+ const toggle = (questionIndex: number, option: string, multi: boolean) =>
165
+ setSelections((current) => {
166
+ const next = questions.map((_, index) => current[index] ?? []);
167
+ const values = next[questionIndex] ?? [];
168
+ next[questionIndex] = !multi
169
+ ? [option]
170
+ : values.includes(option)
171
+ ? values.filter((value) => value !== option)
172
+ : [...values, option];
173
+ return next;
174
+ });
175
+ // 在途只用于按钮转圈;权威状态永远是 part 的 state。
176
+ const [inFlight, setInFlight] = useState(false);
177
+ const outcome = askUserOutcome(call);
178
+ const answer = outcome.kind === "answered" ? outcome : null;
179
+ const answeredSelections = new Set(answer?.selections ?? []);
180
+ const answeredText = [
181
+ ...(answer?.selections ?? []),
182
+ ...(answer?.text.trim() ? [answer.text.trim()] : []),
183
+ ].join(" / ");
184
+ // 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
185
+ const resolved = outcome.kind !== "pending" || inFlight;
186
+ const valid = questions.length > 0 && questions.every(
170
187
  (question, index) =>
171
- question.options.length === 0 || (selections[index]?.length ?? 0) > 0,
188
+ question.options.length === 0 || selectionAt(index).length > 0,
189
+ );
190
+ const buttonLabel = outcome.kind === "answered"
191
+ ? "Submitted"
192
+ : outcome.kind === "closed"
193
+ ? "Closed"
194
+ : inFlight
195
+ ? "Submitting…"
196
+ : "Submit";
197
+ const footnote = outcome.kind === "answered"
198
+ ? answeredText && `Answered: ${answeredText}`
199
+ : outcome.kind === "closed"
200
+ ? outcome.reason === "user_replied_freeform"
201
+ ? "Answered in the chat instead."
202
+ : "Closed without an answer."
203
+ : "";
204
+
205
+ // 提交按钮固定在卡片右下角,和脚注同一行 —— 脚注不再单独占一行,多问题时
206
+ // 按钮也不会卡在某一题的选项中间。选项没到(流式首帧 questions 为空)时这行
207
+ // 照样渲染,否则用户看到的是一张空卡。
208
+ const submit = (
209
+ <WorkshopButton
210
+ tone="primary"
211
+ disabled={resolved || !valid || !onRespond}
212
+ onClick={() => {
213
+ if (!onRespond) return;
214
+ setInFlight(true);
215
+ // 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
216
+ void onRespond(call.toolCallId, {
217
+ selections: questions.flatMap((_, index) => selectionAt(index)),
218
+ text: "",
219
+ }).then((ok) => {
220
+ if (!ok) setInFlight(false);
221
+ });
222
+ }}
223
+ className="ml-auto !h-7 flex-shrink-0 gap-1 !rounded-lg px-2.5 text-[12px]"
224
+ >
225
+ {buttonLabel}
226
+ </WorkshopButton>
172
227
  );
173
228
 
174
229
  return (
175
- <div className="max-w-[860px]">
176
- <div className="rounded-2xl border border-kumo-line bg-kumo-base px-4 py-3">
177
- {/* ApprovalBlock:窄栏下让提交按钮整体换行,别把问题正文挤没。 */}
178
- <div className="flex flex-wrap items-start gap-3">
179
- <span
180
- className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kumo-tint text-kumo-brand"
181
- aria-hidden="true"
230
+ // 收成 w-fit:短问题不该撑成一条横贯全栏的长条,长问题到 560px 再换行。
231
+ <div className="w-fit min-w-[15rem] max-w-[560px]">
232
+ {/* 组内(问题↔选项)10px、组间(题与题、题与按钮行)12px —— 组间必须比组内
233
+ 松,否则多问题时上一题的选项会看起来像下一题的。
234
+ 用 flex+gap 而不是 space-y-*:Tailwind v4 的 space-y 选择器裹在
235
+ `:where()` 里,特异性被抹平,会被子元素自己的 `m-0` 压掉 不生效。 */}
236
+ <div className="flex flex-col gap-3 rounded-xl border border-kumo-line bg-kumo-base px-3 py-2.5">
237
+ {questions.map((question, questionIndex) => (
238
+ <div
239
+ key={`${question.prompt}:${questionIndex}`}
240
+ className="flex flex-col gap-2.5"
182
241
  >
183
- <Bell size={18} weight="fill" />
184
- </span>
185
- <div className="min-w-[12rem] flex-1 space-y-2.5">
186
- {questions.map((question, questionIndex) => (
187
- <div key={`${question.prompt}:${questionIndex}`} className="space-y-1.5">
188
- <p className="m-0 text-[14px] leading-5 font-medium tracking-[-0.25px] text-kumo-default">
189
- {question.prompt}
190
- </p>
191
- <div className="flex flex-wrap gap-1.5">
192
- {question.options.map((option) => {
193
- const selected = external
194
- ? externalSelections.has(option)
195
- : selections[questionIndex]?.includes(option);
196
- return (
197
- <button
198
- key={option}
199
- type="button"
200
- disabled={resolved}
201
- onClick={() =>
202
- setSelections((current) =>
203
- current.map((values, index) => {
204
- if (index !== questionIndex) return values;
205
- if (!question.multi) return [option];
206
- return values.includes(option)
207
- ? values.filter((value) => value !== option)
208
- : [...values, option];
209
- }),
210
- )
211
- }
212
- className={`inline-flex h-7 cursor-pointer items-center rounded-lg border px-2.5 text-[13px] leading-none tracking-[-0.25px] transition-colors duration-150 ease-out disabled:cursor-default ${
213
- selected
214
- ? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
215
- : "border-kumo-line bg-kumo-base text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
216
- }`}
217
- >
218
- {option}
219
- </button>
220
- );
221
- })}
222
- </div>
223
- </div>
224
- ))}
225
- </div>
226
- <div className="ml-auto flex flex-shrink-0 items-center self-center">
227
- <WorkshopButton
228
- tone="primary"
229
- disabled={resolved || !valid}
230
- onClick={() => {
231
- onAnswer({ selections, text: "" });
232
- setSubmitted(true);
233
- }}
234
- className="!h-8 gap-1 !rounded-lg text-[12px]"
235
- >
236
- {resolved ? "Submitted" : "Submit"}
237
- </WorkshopButton>
242
+ <p className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
243
+ {question.prompt}
244
+ </p>
245
+ <div className="flex flex-wrap items-center gap-1.5">
246
+ {question.options.map((option) => {
247
+ const selected = answer
248
+ ? answeredSelections.has(option)
249
+ : selectionAt(questionIndex).includes(option);
250
+ return (
251
+ <button
252
+ key={option}
253
+ type="button"
254
+ disabled={resolved || !onRespond}
255
+ onClick={() => toggle(questionIndex, option, question.multi)}
256
+ className={`inline-flex h-7 cursor-pointer items-center rounded-lg border px-2.5 text-[13px] leading-none tracking-[-0.25px] transition-colors duration-150 ease-out disabled:cursor-default ${
257
+ selected
258
+ ? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
259
+ : "border-kumo-line bg-kumo-base text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
260
+ }`}
261
+ >
262
+ {option}
263
+ </button>
264
+ );
265
+ })}
266
+ </div>
238
267
  </div>
268
+ ))}
269
+ <div className="flex items-center gap-3">
270
+ {footnote && (
271
+ <p className="m-0 min-w-0 text-[12px] leading-4 text-kumo-inactive">
272
+ {footnote}
273
+ </p>
274
+ )}
275
+ {submit}
239
276
  </div>
240
- {external && (
241
- <p className="mt-2 mb-0 pl-12 text-[12px] leading-4 text-kumo-inactive">
242
- Answered: {external}
243
- </p>
244
- )}
245
277
  </div>
246
278
  </div>
247
279
  );
@@ -489,6 +489,48 @@ export function toCloudOsToolCall(
489
489
  };
490
490
  }
491
491
 
492
+ // ── ask_user 的终局 ─────────────────────────────────────────────────────────
493
+
494
+ /**
495
+ * `ask_user` 这次调用的三种终局,全部只看它自己的 state 和 output。
496
+ *
497
+ * - `pending`:还等着用户操作,选项可点。**只有 input 态才算 pending** ——
498
+ * 服务端一旦结算,这次 interaction 就不再接受响应。
499
+ * - `answered`:`ask_user` 的 settle 写了 `{ selections, text }`。
500
+ * - `closed`:被结算掉了但不是一个答案 —— 用户改成直接发言(`{ cancelled: true }`)、
501
+ * Turn 提前结束、或工具出错。必须和 pending 分开,否则卡片会留一个点了拿
502
+ * `{ ok: false }` 的假 Submit,读起来就是「点了没反应」。
503
+ *
504
+ * 判据放在这里而不是卡片里:卡片(能不能点)和底部活动指示器(还在不在等人)
505
+ * 都要用它,两处各判一次迟早会分叉。
506
+ */
507
+ export type AskUserOutcome =
508
+ | { kind: "pending" }
509
+ | { kind: "answered"; selections: string[]; text: string }
510
+ | { kind: "closed"; reason: "user_replied_freeform" | "other" };
511
+
512
+ export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
513
+ if (call.state === "input-streaming" || call.state === "input-available") {
514
+ return { kind: "pending" };
515
+ }
516
+ const record = recordOf(call.output);
517
+ if (Array.isArray(record.selections)) {
518
+ return {
519
+ kind: "answered",
520
+ selections: record.selections.filter(
521
+ (value): value is string => typeof value === "string",
522
+ ),
523
+ text: typeof record.text === "string" ? record.text : "",
524
+ };
525
+ }
526
+ return {
527
+ kind: "closed",
528
+ reason: record.reason === "user_replied_freeform"
529
+ ? "user_replied_freeform"
530
+ : "other",
531
+ };
532
+ }
533
+
492
534
  // ── 分组(逐行对应原 buildToolCallGroups)────────────────────────────────────
493
535
 
494
536
  export function buildToolCallGroups(calls: CloudOsToolCall[]): ToolCallGroup[] {