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

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 (33) hide show
  1. package/cloud-os/README.md +71 -0
  2. package/cloud-os/chat/activity-indicator.tsx +29 -0
  3. package/cloud-os/chat/cloud-os-chat-messages.tsx +632 -0
  4. package/cloud-os/chat/markdown-message.tsx +78 -0
  5. package/cloud-os/chat/rich-blocks.tsx +672 -0
  6. package/cloud-os/chat/tool-presentation.ts +584 -0
  7. package/cloud-os/chat/tool-rows.tsx +216 -0
  8. package/cloud-os/chat/transcript-model.ts +672 -0
  9. package/cloud-os/composer/cloud-os-chat-input.tsx +541 -0
  10. package/cloud-os/index.ts +107 -0
  11. package/cloud-os/internal/cn.ts +6 -0
  12. package/cloud-os/internal/theme-context.tsx +16 -0
  13. package/cloud-os/layout/cloud-os-root.tsx +34 -0
  14. package/cloud-os/layout/cloud-os-workspace-split.tsx +242 -0
  15. package/cloud-os/primitives/dropdown-menu.tsx +82 -0
  16. package/cloud-os/primitives/tooltip.tsx +68 -0
  17. package/cloud-os/primitives/workshop-controls.tsx +114 -0
  18. package/cloud-os/styles/cloud-os.css +573 -0
  19. package/cloud-os/workspace/cloud-os-workspace-panel.tsx +272 -0
  20. package/demo/camel-chat-showcase.tsx +13 -917
  21. package/demo/chat-scenarios.ts +926 -0
  22. package/demo/cloud-os-chat-showcase.tsx +471 -0
  23. package/demo/index.ts +2 -0
  24. package/package.json +16 -4
  25. package/src/camel/camel-chat-messages.tsx +41 -18
  26. package/src/camel/camel-prompt-input.tsx +2 -1
  27. package/src/chat-summary-panel.tsx +1 -1
  28. package/src/composer/chat-composer.tsx +2 -1
  29. package/src/composer/composer.tsx +2 -1
  30. package/src/composer/index.ts +1 -0
  31. package/src/composer/key-rules.ts +12 -0
  32. package/src/message.tsx +0 -8
  33. package/src/styles/index.css +4 -1
@@ -0,0 +1,471 @@
1
+ import {
2
+ ArrowsOutSimple,
3
+ Blueprint,
4
+ Info,
5
+ Moon,
6
+ Pulse,
7
+ Sun,
8
+ } from "@phosphor-icons/react";
9
+ import { useCallback, useMemo, useState } from "react";
10
+ import {
11
+ CloudOsChatInput,
12
+ CloudOsChatMessages,
13
+ CloudOsRoot,
14
+ CloudOsWorkspacePanel,
15
+ CloudOsWorkspaceSplit,
16
+ WorkshopIconButton,
17
+ buildCloudOsEntries,
18
+ type CloudOsAttachmentView,
19
+ type CloudOsMode,
20
+ } from "../cloud-os";
21
+ import {
22
+ defaultPrompt,
23
+ demoModels,
24
+ flowSteps,
25
+ messagesForScenario,
26
+ scenarios,
27
+ type DemoScenario,
28
+ type DemoScenarioMeta,
29
+ } from "./chat-scenarios";
30
+
31
+ /**
32
+ * cloud-os 版快速预览。场景表和 camel-chat-showcase 共用 `chat-scenarios.ts`,
33
+ * 所以两边的状态逐条对齐(同样 22 个场景、同样的 7 步流程条、同样的初始 URL 参数)。
34
+ * 差别只在外壳:这里是 cloudflare-os 的左聊天 / 右工作区双栏。
35
+ */
36
+
37
+ const cloudOsModels = demoModels.map((model) => ({
38
+ id: model.value,
39
+ name: model.label,
40
+ }));
41
+
42
+ function initialScenario(): DemoScenario {
43
+ if (typeof window === "undefined") return "complete";
44
+ const value = new URLSearchParams(window.location.search).get("scenario");
45
+ return value && Object.hasOwn(scenarios, value)
46
+ ? (value as DemoScenario)
47
+ : "complete";
48
+ }
49
+
50
+ /** 右栏「Activity」页:把同一份消息流投影成一条工作流水,验证面板确实吃 UIMessage。 */
51
+ function ActivityTab({
52
+ entries,
53
+ }: {
54
+ entries: ReturnType<typeof buildCloudOsEntries>;
55
+ }) {
56
+ const rows = entries.flatMap((entry) =>
57
+ entry.type === "assistant"
58
+ ? entry.blocks.flatMap((block) =>
59
+ block.kind === "toolGroup"
60
+ ? block.group.calls.map((call) => ({
61
+ key: `${block.key}-${call.toolCallId}`,
62
+ name: call.toolName,
63
+ state: call.failed
64
+ ? "failed"
65
+ : call.running
66
+ ? "running"
67
+ : "done",
68
+ }))
69
+ : [],
70
+ )
71
+ : [],
72
+ );
73
+
74
+ if (rows.length === 0) {
75
+ return (
76
+ <div className="flex h-full items-center justify-center px-6 text-center">
77
+ <p className="m-0 max-w-[320px] text-[13px] leading-[19px] text-kumo-subtle">
78
+ 这个场景还没有工具调用。切到「工具执行中」「并行与子 Agent」看活动流水。
79
+ </p>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ return (
85
+ <div className="space-y-1 p-3">
86
+ {rows.map((row) => (
87
+ <div
88
+ key={row.key}
89
+ className="flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-[13px] leading-[19px] tracking-[-0.25px] text-kumo-subtle hover:bg-kumo-tint/50"
90
+ >
91
+ <span
92
+ className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
93
+ row.state === "failed"
94
+ ? "bg-kumo-danger"
95
+ : row.state === "running"
96
+ ? "animate-pulse bg-kumo-brand motion-reduce:animate-none"
97
+ : "bg-kumo-success"
98
+ }`}
99
+ aria-hidden="true"
100
+ />
101
+ <span className="min-w-0 flex-1 truncate font-mono text-[12px]">
102
+ {row.name}
103
+ </span>
104
+ <span className="flex-shrink-0 text-[11px] uppercase tracking-[0.06em] text-kumo-inactive">
105
+ {row.state}
106
+ </span>
107
+ </div>
108
+ ))}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ function PartsTab({ messagesJson }: { messagesJson: string }) {
114
+ return (
115
+ <pre className="m-0 h-full overflow-auto bg-kumo-base p-3 font-mono text-[11px] leading-[17px] text-kumo-subtle whitespace-pre-wrap">
116
+ {messagesJson}
117
+ </pre>
118
+ );
119
+ }
120
+
121
+ export function CloudOsChatShowcase() {
122
+ const [mode, setMode] = useState<CloudOsMode>("light");
123
+ const [scenario, setScenario] = useState<DemoScenario>(initialScenario);
124
+ const [prompt, setPrompt] = useState(defaultPrompt);
125
+ const [humanAnswer, setHumanAnswer] = useState("管理层");
126
+ const [model, setModel] = useState<string | null>(cloudOsModels[0].id);
127
+ const [value, setValue] = useState("");
128
+ const [attachments, setAttachments] = useState<CloudOsAttachmentView[]>([]);
129
+ const [approval, setApproval] = useState<boolean | undefined>();
130
+ const [showThinkingTraces, setShowThinkingTraces] = useState(true);
131
+ const [workspaceOpen, setWorkspaceOpen] = useState(true);
132
+ const [activeTab, setActiveTab] = useState("activity");
133
+ const [lastEvent, setLastEvent] = useState("等待交互");
134
+
135
+ const selectScenario = useCallback((next: DemoScenario) => {
136
+ setScenario(next);
137
+ const url = new URL(window.location.href);
138
+ url.searchParams.set("scenario", next);
139
+ window.history.replaceState(window.history.state, "", url);
140
+ }, []);
141
+
142
+ const scenarioMeta: DemoScenarioMeta = scenarios[scenario];
143
+ const status = scenarioMeta.status;
144
+ const messages = useMemo(
145
+ () => messagesForScenario(scenario, prompt, humanAnswer, approval),
146
+ [approval, humanAnswer, prompt, scenario],
147
+ );
148
+
149
+ // 待授权的 part → 授权 id。和 camel showcase 的 approvalView 判据一致。
150
+ const approvalIdOf = useCallback((part: unknown): string | undefined => {
151
+ const record =
152
+ part && typeof part === "object"
153
+ ? (part as { type?: unknown; state?: unknown })
154
+ : {};
155
+ return (record.type === "tool-write_file" ||
156
+ record.type === "tool-cancel_schedule") &&
157
+ record.state === "approval-requested"
158
+ ? "cloud-os-demo-approval"
159
+ : undefined;
160
+ }, []);
161
+
162
+ const entries = useMemo(
163
+ () =>
164
+ buildCloudOsEntries({
165
+ messages,
166
+ isActive: status === "submitted" || status === "streaming",
167
+ showThinkingTraces,
168
+ approvalIdOf,
169
+ }),
170
+ [approvalIdOf, messages, showThinkingTraces, status],
171
+ );
172
+
173
+ const messagesJson = useMemo(
174
+ () => JSON.stringify(messages, null, 2),
175
+ [messages],
176
+ );
177
+
178
+ const statusError = useMemo(
179
+ () =>
180
+ scenario === "error"
181
+ ? new Error("数据源连接中断。已完成步骤仍然保留,请点击 Retry 继续。")
182
+ : undefined,
183
+ [scenario],
184
+ );
185
+
186
+ const addFiles = useCallback((files: readonly File[]) => {
187
+ setAttachments((current) => [
188
+ ...current,
189
+ ...files.map((file, index) => ({
190
+ id: `${file.name}-${file.lastModified}-${index}`,
191
+ filename: file.name,
192
+ mediaType: file.type,
193
+ size: file.size,
194
+ previewUrl: file.type.startsWith("image/")
195
+ ? URL.createObjectURL(file)
196
+ : undefined,
197
+ status: "ready" as const,
198
+ })),
199
+ ]);
200
+ setLastEvent(`添加了 ${files.length} 个附件`);
201
+ }, []);
202
+
203
+ const removeAttachment = useCallback((id: string) => {
204
+ setAttachments((current) => {
205
+ const removed = current.find((attachment) => attachment.id === id);
206
+ if (removed?.previewUrl?.startsWith("blob:")) {
207
+ URL.revokeObjectURL(removed.previewUrl);
208
+ }
209
+ return current.filter((attachment) => attachment.id !== id);
210
+ });
211
+ setLastEvent("移除了附件");
212
+ }, []);
213
+
214
+ return (
215
+ <CloudOsRoot mode={mode} className="flex h-dvh min-h-0 w-full flex-col overflow-hidden">
216
+ {/* 顶栏:高度对齐 cloudflare-os 的 h-14 */}
217
+ <header className="relative z-30 flex flex-shrink-0 flex-wrap items-center gap-2 border-b border-kumo-line bg-kumo-base px-4 py-2">
218
+ <a
219
+ href="/"
220
+ className="rounded-md px-2 py-1 text-[12px] leading-4 text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
221
+ >
222
+ ← 返回系统
223
+ </a>
224
+ <div className="min-w-48 flex-1">
225
+ <h1 className="m-0 text-[14px] leading-5 font-semibold tracking-[-0.25px] text-kumo-default">
226
+ cloud-os Chat · 真实回合验收
227
+ </h1>
228
+ <p className="m-0 text-[11px] leading-4 text-kumo-inactive">
229
+ 与 camel-chat-showcase 同一份场景表,外壳换成 cloudflare-os 的左聊天 / 右工作区
230
+ </p>
231
+ </div>
232
+ <button
233
+ type="button"
234
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-kumo-line bg-kumo-base px-2.5 text-[12px] leading-4 text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
235
+ onClick={() => setMode((current) => (current === "dark" ? "light" : "dark"))}
236
+ aria-label="切换主题"
237
+ >
238
+ {mode === "dark" ? <Sun size={13} /> : <Moon size={13} />}
239
+ {mode === "dark" ? "浅色" : "深色"}
240
+ </button>
241
+ <button
242
+ type="button"
243
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-kumo-line bg-kumo-base px-2.5 text-[12px] leading-4 text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
244
+ onClick={() => setWorkspaceOpen((open) => !open)}
245
+ >
246
+ <ArrowsOutSimple size={13} />
247
+ {workspaceOpen ? "收起工作区" : "展开工作区"}
248
+ </button>
249
+ <div
250
+ className="order-last flex basis-full flex-wrap items-center gap-1 border-t border-kumo-line/60 pt-2"
251
+ role="group"
252
+ aria-label="消息场景"
253
+ >
254
+ <span className="mr-1 text-[12px] leading-4 text-kumo-inactive">消息场景</span>
255
+ {(Object.entries(scenarios) as [DemoScenario, DemoScenarioMeta][]).map(
256
+ ([option, meta]) => {
257
+ const selected = scenario === option;
258
+ return (
259
+ <button
260
+ key={option}
261
+ type="button"
262
+ data-scenario={option}
263
+ aria-pressed={selected}
264
+ className={[
265
+ "rounded-md border px-2 py-1 text-[11px] leading-4 transition-colors focus-visible:outline-none",
266
+ selected
267
+ ? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
268
+ : "border-kumo-line text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default",
269
+ ].join(" ")}
270
+ onClick={() => {
271
+ selectScenario(option);
272
+ setApproval(undefined);
273
+ setLastEvent(`切换到:${meta.label}`);
274
+ }}
275
+ >
276
+ {meta.label}
277
+ </button>
278
+ );
279
+ },
280
+ )}
281
+ </div>
282
+ </header>
283
+
284
+ <CloudOsWorkspaceSplit
285
+ workspaceOpen={workspaceOpen}
286
+ isAgentActive={status === "submitted" || status === "streaming"}
287
+ chat={
288
+ <div className="flex h-full min-h-0 flex-col bg-kumo-base">
289
+ <aside
290
+ className="flex-shrink-0 border-b border-kumo-line bg-kumo-elevated/60 px-4 py-3"
291
+ aria-label="消息 UI 验收说明"
292
+ >
293
+ <div className="flex items-start gap-2.5">
294
+ <Info size={15} className="mt-0.5 flex-shrink-0 text-kumo-inactive" />
295
+ <div className="min-w-0">
296
+ <h2 className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
297
+ 消息体说明
298
+ </h2>
299
+ <p className="m-0 mt-0.5 text-[12px] leading-[18px] text-kumo-subtle">
300
+ {scenarioMeta.description}
301
+ {scenario !== "empty" &&
302
+ scenario !== "commands" &&
303
+ " 下方始终使用同一个用户问题,便于直接比较状态变化。"}
304
+ </p>
305
+ </div>
306
+ </div>
307
+ <ol className="mt-2.5 flex flex-wrap gap-1.5" aria-label="工具收齐流程">
308
+ {flowSteps.map((step, index) => {
309
+ const reached = index <= scenarioMeta.flowStep;
310
+ return (
311
+ <li
312
+ key={`${step.label}-${index}`}
313
+ className="min-w-0"
314
+ style={{ flex: "1 1 8rem" }}
315
+ >
316
+ <button
317
+ type="button"
318
+ className={[
319
+ "flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-[11px] leading-4 transition-colors",
320
+ reached
321
+ ? "border-kumo-line bg-kumo-tint text-kumo-default"
322
+ : "border-kumo-line/60 text-kumo-inactive hover:bg-kumo-tint/50",
323
+ ].join(" ")}
324
+ onClick={() => {
325
+ selectScenario(step.scenario);
326
+ setApproval(undefined);
327
+ setLastEvent(`查看流程:${step.label}`);
328
+ }}
329
+ >
330
+ <span
331
+ className={[
332
+ "grid size-4 flex-shrink-0 place-items-center rounded-full font-mono text-[9px]",
333
+ reached
334
+ ? "bg-kumo-contrast text-kumo-inverse"
335
+ : "bg-kumo-fill text-kumo-inactive",
336
+ ].join(" ")}
337
+ >
338
+ {index + 1}
339
+ </span>
340
+ <span className="truncate">{step.label}</span>
341
+ </button>
342
+ </li>
343
+ );
344
+ })}
345
+ </ol>
346
+ </aside>
347
+
348
+ <CloudOsChatMessages
349
+ messages={messages}
350
+ status={status}
351
+ isHydrating={scenario === "hydrating"}
352
+ isRecovering={scenario === "recovering"}
353
+ recoveryStatusLabel="模型响应暂时中断,正在重试…"
354
+ showThinkingTraces={showThinkingTraces}
355
+ approvalIdOf={approvalIdOf}
356
+ error={statusError}
357
+ constrainWidth={!workspaceOpen}
358
+ onRetry={() => {
359
+ selectScenario("recovering");
360
+ setLastEvent("已重试,从失败步骤继续执行");
361
+ }}
362
+ onApprove={(_approvalId, decision) => {
363
+ const approved = decision !== "deny";
364
+ if (scenario === "schedule_cancel") {
365
+ setApproval(approved);
366
+ setLastEvent(approved ? "已允许取消定时任务" : "已拒绝取消定时任务");
367
+ return;
368
+ }
369
+ setApproval(approved);
370
+ selectScenario(approved ? "collected" : "error");
371
+ setLastEvent(approved ? "已允许写入报告" : "已拒绝写入报告");
372
+ }}
373
+ onSuggestion={(text) => {
374
+ setPrompt(text);
375
+ selectScenario("submitted");
376
+ setValue("");
377
+ setLastEvent(`已发送后续问题:${text}`);
378
+ }}
379
+ onRespond={async (_toolCallId, response) => {
380
+ const data =
381
+ response && typeof response === "object"
382
+ ? (response as { selections?: string[]; text?: string })
383
+ : {};
384
+ const answer = [
385
+ ...(data.selections ?? []),
386
+ data.text?.trim() ?? "",
387
+ ]
388
+ .filter(Boolean)
389
+ .join(" / ");
390
+ if (answer) setHumanAnswer(answer);
391
+ selectScenario("planning");
392
+ setLastEvent(`已回答并继续:${answer || humanAnswer}`);
393
+ return true;
394
+ }}
395
+ />
396
+
397
+ <div className="flex-shrink-0 border-t border-kumo-line bg-kumo-base">
398
+ <CloudOsChatInput
399
+ value={value}
400
+ onChange={setValue}
401
+ status={status}
402
+ models={cloudOsModels}
403
+ selectedModel={model}
404
+ onModelChange={setModel}
405
+ attachments={attachments}
406
+ onFilesSelected={addFiles}
407
+ onAttachmentRemove={removeAttachment}
408
+ showThinkingTraces={showThinkingTraces}
409
+ onToggleThinkingTraces={() => {
410
+ setShowThinkingTraces((current) => !current);
411
+ setLastEvent(
412
+ showThinkingTraces ? "已隐藏思考过程" : "已显示思考过程",
413
+ );
414
+ }}
415
+ placeholder="Ask a follow-up…"
416
+ onSubmit={() => {
417
+ if (value.trim()) setPrompt(value.trim());
418
+ selectScenario("submitted");
419
+ setApproval(undefined);
420
+ setLastEvent(`已提交:${value.trim() || "附件"}`);
421
+ setValue("");
422
+ setAttachments([]);
423
+ }}
424
+ onStop={() => {
425
+ selectScenario("interrupted");
426
+ setLastEvent("已停止生成,保留当前回合");
427
+ }}
428
+ footnote={
429
+ <output aria-live="polite" className="truncate">
430
+ 最近交互:{lastEvent}
431
+ </output>
432
+ }
433
+ />
434
+ </div>
435
+ </div>
436
+ }
437
+ workspace={
438
+ <CloudOsWorkspacePanel
439
+ title="Agent 工作台"
440
+ titleIcon={Pulse}
441
+ activeTab={activeTab}
442
+ onTabChange={setActiveTab}
443
+ onClose={() => setWorkspaceOpen(false)}
444
+ actions={
445
+ <WorkshopIconButton
446
+ aria-label="Blueprints"
447
+ title="Blueprints"
448
+ onClick={() => setLastEvent("打开 Blueprints(演示占位)")}
449
+ >
450
+ <Blueprint size={16} />
451
+ </WorkshopIconButton>
452
+ }
453
+ tabs={[
454
+ {
455
+ value: "activity",
456
+ label: "Activity",
457
+ count: entries.filter((entry) => entry.type === "assistant").length,
458
+ content: <ActivityTab entries={entries} />,
459
+ },
460
+ {
461
+ value: "parts",
462
+ label: "Parts",
463
+ content: <PartsTab messagesJson={messagesJson} />,
464
+ },
465
+ ]}
466
+ />
467
+ }
468
+ />
469
+ </CloudOsRoot>
470
+ );
471
+ }
package/demo/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export * from "./camel-chat-showcase";
2
+ export * from "./chat-scenarios";
3
+ export * from "./cloud-os-chat-showcase";
2
4
  export * from "./fixtures";
3
5
  export * from "./message-panel-chat-showcase";
4
6
  export * from "./message-panel-gallery";
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.1.3-alpha.1",
3
+ "version": "0.1.3-alpha.3",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
7
- "demo"
7
+ "demo",
8
+ "cloud-os"
8
9
  ],
9
10
  "publishConfig": {
10
11
  "access": "public"
@@ -13,6 +14,8 @@
13
14
  ".": "./src/index.ts",
14
15
  "./composer": "./src/composer/index.ts",
15
16
  "./styles.css": "./src/styles/index.css",
17
+ "./cloud-os": "./cloud-os/index.ts",
18
+ "./cloud-os.css": "./cloud-os/styles/cloud-os.css",
16
19
  "./demo": "./demo/index.ts"
17
20
  },
18
21
  "peerDependencies": {
@@ -22,22 +25,31 @@
22
25
  },
23
26
  "dependencies": {
24
27
  "@assistant-ui/react": "^0.15.1",
28
+ "@phosphor-icons/react": "^2.1.10",
25
29
  "@streamdown/cjk": "^1.0.3",
26
30
  "@streamdown/code": "^1.1.1",
27
31
  "@streamdown/math": "^1.0.2",
28
32
  "@streamdown/mermaid": "^1.0.2",
29
33
  "lucide-react": "^1.24.0",
30
34
  "radix-ui": "^1.6.2",
35
+ "react-markdown": "^10.1.0",
36
+ "remark-gfm": "^4.0.1",
31
37
  "streamdown": "^2.5.0",
32
38
  "thinking-orbs": "0.1.1",
33
39
  "use-stick-to-bottom": "^1.1.6"
34
40
  },
35
41
  "devDependencies": {
42
+ "@tailwindcss/vite": "^4.3.2",
36
43
  "@types/react": "^19.2.17",
37
44
  "@types/react-dom": "^19.2.3",
38
- "typescript": "^7.0.2"
45
+ "@vitejs/plugin-react": "^6.0.3",
46
+ "tailwindcss": "^4.3.2",
47
+ "typescript": "^7.0.2",
48
+ "vite": "^8.1.4"
39
49
  },
40
50
  "scripts": {
41
- "typecheck": "tsc --noEmit"
51
+ "typecheck": "tsc --noEmit",
52
+ "dev:cloud-os": "vite",
53
+ "build:cloud-os": "vite build"
42
54
  }
43
55
  }
@@ -1,3 +1,8 @@
1
+ // camel 是 dev-only 的展示变体(App.tsx 的 `/__camel-chat` 路由),没有接生产宿主。
2
+ // 它的交互卡片仍用旧的 `onAnswer(payload)` 回调,**没有**迁到 client-settled tool 的
3
+ // `onRespond(toolCallId, response)` —— 生产聊天面板走的是 cloud-os,已经迁完。
4
+ // 哪天 camel 要上生产,这里必须一起改,否则卡片点了不会把答案回写给那次工具调用。
5
+
1
6
  import type { UIMessage } from "ai";
2
7
  import {
3
8
  ArrowDownIcon,
@@ -71,10 +76,7 @@ export interface CamelChatMessagesProps {
71
76
  finalToolNames?: readonly string[];
72
77
  isHydrating?: boolean;
73
78
  isRecovering?: boolean;
74
- isServerStreaming?: boolean;
75
- /** Show the opt-in activity bar for recovering or server-streaming states. */
76
- showActivityStatus?: boolean;
77
- activityStatusLabel?: string;
79
+ recoveryStatusLabel?: string;
78
80
  hasPendingSteer?: boolean;
79
81
  startedWithPending?: boolean;
80
82
  error?: unknown;
@@ -164,6 +166,23 @@ export function CamelChatError({
164
166
  );
165
167
  }
166
168
 
169
+ export function CamelChatRecovery({
170
+ label = "模型响应暂时中断,正在重试…",
171
+ }: {
172
+ label?: string;
173
+ }) {
174
+ return (
175
+ <div
176
+ className="mt-4 flex items-center gap-2 rounded-lg border border-border/70 bg-muted/40 px-3 py-2 text-sm text-muted-foreground"
177
+ data-camel-chat-recovery=""
178
+ role="status"
179
+ >
180
+ <Loader2Icon className="size-4 shrink-0 animate-spin" aria-hidden="true" />
181
+ <span>{label}</span>
182
+ </div>
183
+ );
184
+ }
185
+
167
186
  export function CamelApprovalPrompts({
168
187
  approvals,
169
188
  error,
@@ -1151,6 +1170,7 @@ function RenderAssistantPart({
1151
1170
  function AssistantTurn({
1152
1171
  turn,
1153
1172
  isActive,
1173
+ keepTraceExpanded,
1154
1174
  hasPendingSteer,
1155
1175
  hasPendingApproval,
1156
1176
  onFork,
@@ -1162,6 +1182,7 @@ function AssistantTurn({
1162
1182
  }: {
1163
1183
  turn: CamelAssistantTurn;
1164
1184
  isActive: boolean;
1185
+ keepTraceExpanded: boolean;
1165
1186
  hasPendingSteer: boolean;
1166
1187
  hasPendingApproval: boolean;
1167
1188
  onFork?: (messageId: string) => void | Promise<void>;
@@ -1185,7 +1206,10 @@ function AssistantTurn({
1185
1206
  />
1186
1207
  );
1187
1208
  const showWorkInPlace =
1188
- isActive || hasPendingApproval || turn.terminalState !== undefined;
1209
+ isActive ||
1210
+ keepTraceExpanded ||
1211
+ hasPendingApproval ||
1212
+ turn.terminalState !== undefined;
1189
1213
  const showSummary = !showWorkInPlace && turn.traceParts.length > 0;
1190
1214
  const completedAt = turn.completedAt ?? camelCompletedAt(turn.message);
1191
1215
  const messageTime =
@@ -1294,9 +1318,7 @@ export function CamelChatMessages({
1294
1318
  finalToolNames,
1295
1319
  isHydrating = false,
1296
1320
  isRecovering = false,
1297
- isServerStreaming = false,
1298
- showActivityStatus = false,
1299
- activityStatusLabel,
1321
+ recoveryStatusLabel,
1300
1322
  hasPendingSteer = false,
1301
1323
  startedWithPending = false,
1302
1324
  error,
@@ -1328,6 +1350,10 @@ export function CamelChatMessages({
1328
1350
  isActive && lastItem?.kind === "assistant"
1329
1351
  ? lastItem.actionMessageId
1330
1352
  : null;
1353
+ const recoveringAssistantMessageId =
1354
+ isRecovering && lastItem?.kind === "assistant"
1355
+ ? lastItem.actionMessageId
1356
+ : null;
1331
1357
 
1332
1358
  const syncScrollButton = useCallback(() => {
1333
1359
  const element = scrollRef.current;
@@ -1341,7 +1367,7 @@ export function CamelChatMessages({
1341
1367
  const element = scrollRef.current;
1342
1368
  if (!element || showScrollButton) return;
1343
1369
  element.scrollTo({ top: element.scrollHeight });
1344
- }, [messages, status, showScrollButton]);
1370
+ }, [isRecovering, messages, status, showScrollButton]);
1345
1371
 
1346
1372
  return (
1347
1373
  <div
@@ -1351,15 +1377,6 @@ export function CamelChatMessages({
1351
1377
  data-status={status}
1352
1378
  aria-busy={isActive || isHydrating}
1353
1379
  >
1354
- {showActivityStatus && (isRecovering || isServerStreaming) && (
1355
- <div
1356
- className="absolute inset-x-0 top-0 z-10 flex h-7 items-center justify-center gap-2 bg-background/85 text-xs text-muted-foreground backdrop-blur-sm"
1357
- role="status"
1358
- >
1359
- <span className="size-1.5 animate-pulse rounded-full bg-blue-500" />
1360
- {activityStatusLabel ?? (isRecovering ? "Recovering…" : "Running…")}
1361
- </div>
1362
- )}
1363
1380
  <div
1364
1381
  ref={scrollRef}
1365
1382
  onScroll={syncScrollButton}
@@ -1380,6 +1397,9 @@ export function CamelChatMessages({
1380
1397
  key={item.key}
1381
1398
  turn={item}
1382
1399
  isActive={item.actionMessageId === activeAssistantMessageId}
1400
+ keepTraceExpanded={
1401
+ item.actionMessageId === recoveringAssistantMessageId
1402
+ }
1383
1403
  hasPendingSteer={hasPendingSteer}
1384
1404
  hasPendingApproval={item.parts.some((partRef) => {
1385
1405
  const approval =
@@ -1398,6 +1418,9 @@ export function CamelChatMessages({
1398
1418
  <StandaloneMessage key={item.key} item={item} />
1399
1419
  ),
1400
1420
  )}
1421
+ {isRecovering && (
1422
+ <CamelChatRecovery label={recoveryStatusLabel} />
1423
+ )}
1401
1424
  {error != null && <CamelChatError error={error} onRetry={onRetry} />}
1402
1425
  {isActive && activeAssistantMessageId === null && (
1403
1426
  <ThinkingIndicator
@@ -39,6 +39,7 @@ import {
39
39
  type ReactNode,
40
40
  } from "react";
41
41
  import { ComposerTriggerPopover } from "../composer/composer-trigger-popover";
42
+ import { isComposingKeyEvent } from "../composer/key-rules";
42
43
  import type { ComposerCommand } from "../composer/types";
43
44
  import { cn } from "../internal/cn";
44
45
 
@@ -345,7 +346,7 @@ function CamelPromptInputContent({
345
346
  if (
346
347
  event.key === "Enter" &&
347
348
  !event.shiftKey &&
348
- !event.nativeEvent.isComposing
349
+ !isComposingKeyEvent(event.nativeEvent)
349
350
  ) {
350
351
  event.preventDefault();
351
352
  submit();