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

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.
@@ -0,0 +1,470 @@
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
+ onAnswer={(payload) => {
380
+ const data =
381
+ payload && typeof payload === "object"
382
+ ? (payload as { selections?: string[][]; text?: string })
383
+ : {};
384
+ const answer = [
385
+ ...(data.selections?.flat() ?? []),
386
+ data.text?.trim() ?? "",
387
+ ]
388
+ .filter(Boolean)
389
+ .join("、");
390
+ if (answer) setHumanAnswer(answer);
391
+ selectScenario("planning");
392
+ setLastEvent(`已回答并继续:${answer || humanAnswer}`);
393
+ }}
394
+ />
395
+
396
+ <div className="flex-shrink-0 border-t border-kumo-line bg-kumo-base">
397
+ <CloudOsChatInput
398
+ value={value}
399
+ onChange={setValue}
400
+ status={status}
401
+ models={cloudOsModels}
402
+ selectedModel={model}
403
+ onModelChange={setModel}
404
+ attachments={attachments}
405
+ onFilesSelected={addFiles}
406
+ onAttachmentRemove={removeAttachment}
407
+ showThinkingTraces={showThinkingTraces}
408
+ onToggleThinkingTraces={() => {
409
+ setShowThinkingTraces((current) => !current);
410
+ setLastEvent(
411
+ showThinkingTraces ? "已隐藏思考过程" : "已显示思考过程",
412
+ );
413
+ }}
414
+ placeholder="Ask a follow-up…"
415
+ onSubmit={() => {
416
+ if (value.trim()) setPrompt(value.trim());
417
+ selectScenario("submitted");
418
+ setApproval(undefined);
419
+ setLastEvent(`已提交:${value.trim() || "附件"}`);
420
+ setValue("");
421
+ setAttachments([]);
422
+ }}
423
+ onStop={() => {
424
+ selectScenario("interrupted");
425
+ setLastEvent("已停止生成,保留当前回合");
426
+ }}
427
+ footnote={
428
+ <output aria-live="polite" className="truncate">
429
+ 最近交互:{lastEvent}
430
+ </output>
431
+ }
432
+ />
433
+ </div>
434
+ </div>
435
+ }
436
+ workspace={
437
+ <CloudOsWorkspacePanel
438
+ title="Agent 工作台"
439
+ titleIcon={Pulse}
440
+ activeTab={activeTab}
441
+ onTabChange={setActiveTab}
442
+ onClose={() => setWorkspaceOpen(false)}
443
+ actions={
444
+ <WorkshopIconButton
445
+ aria-label="Blueprints"
446
+ title="Blueprints"
447
+ onClick={() => setLastEvent("打开 Blueprints(演示占位)")}
448
+ >
449
+ <Blueprint size={16} />
450
+ </WorkshopIconButton>
451
+ }
452
+ tabs={[
453
+ {
454
+ value: "activity",
455
+ label: "Activity",
456
+ count: entries.filter((entry) => entry.type === "assistant").length,
457
+ content: <ActivityTab entries={entries} />,
458
+ },
459
+ {
460
+ value: "parts",
461
+ label: "Parts",
462
+ content: <PartsTab messagesJson={messagesJson} />,
463
+ },
464
+ ]}
465
+ />
466
+ }
467
+ />
468
+ </CloudOsRoot>
469
+ );
470
+ }
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.2",
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
  }
@@ -71,10 +71,7 @@ export interface CamelChatMessagesProps {
71
71
  finalToolNames?: readonly string[];
72
72
  isHydrating?: boolean;
73
73
  isRecovering?: boolean;
74
- isServerStreaming?: boolean;
75
- /** Show the opt-in activity bar for recovering or server-streaming states. */
76
- showActivityStatus?: boolean;
77
- activityStatusLabel?: string;
74
+ recoveryStatusLabel?: string;
78
75
  hasPendingSteer?: boolean;
79
76
  startedWithPending?: boolean;
80
77
  error?: unknown;
@@ -164,6 +161,23 @@ export function CamelChatError({
164
161
  );
165
162
  }
166
163
 
164
+ export function CamelChatRecovery({
165
+ label = "模型响应暂时中断,正在重试…",
166
+ }: {
167
+ label?: string;
168
+ }) {
169
+ return (
170
+ <div
171
+ 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"
172
+ data-camel-chat-recovery=""
173
+ role="status"
174
+ >
175
+ <Loader2Icon className="size-4 shrink-0 animate-spin" aria-hidden="true" />
176
+ <span>{label}</span>
177
+ </div>
178
+ );
179
+ }
180
+
167
181
  export function CamelApprovalPrompts({
168
182
  approvals,
169
183
  error,
@@ -1151,6 +1165,7 @@ function RenderAssistantPart({
1151
1165
  function AssistantTurn({
1152
1166
  turn,
1153
1167
  isActive,
1168
+ keepTraceExpanded,
1154
1169
  hasPendingSteer,
1155
1170
  hasPendingApproval,
1156
1171
  onFork,
@@ -1162,6 +1177,7 @@ function AssistantTurn({
1162
1177
  }: {
1163
1178
  turn: CamelAssistantTurn;
1164
1179
  isActive: boolean;
1180
+ keepTraceExpanded: boolean;
1165
1181
  hasPendingSteer: boolean;
1166
1182
  hasPendingApproval: boolean;
1167
1183
  onFork?: (messageId: string) => void | Promise<void>;
@@ -1185,7 +1201,10 @@ function AssistantTurn({
1185
1201
  />
1186
1202
  );
1187
1203
  const showWorkInPlace =
1188
- isActive || hasPendingApproval || turn.terminalState !== undefined;
1204
+ isActive ||
1205
+ keepTraceExpanded ||
1206
+ hasPendingApproval ||
1207
+ turn.terminalState !== undefined;
1189
1208
  const showSummary = !showWorkInPlace && turn.traceParts.length > 0;
1190
1209
  const completedAt = turn.completedAt ?? camelCompletedAt(turn.message);
1191
1210
  const messageTime =
@@ -1294,9 +1313,7 @@ export function CamelChatMessages({
1294
1313
  finalToolNames,
1295
1314
  isHydrating = false,
1296
1315
  isRecovering = false,
1297
- isServerStreaming = false,
1298
- showActivityStatus = false,
1299
- activityStatusLabel,
1316
+ recoveryStatusLabel,
1300
1317
  hasPendingSteer = false,
1301
1318
  startedWithPending = false,
1302
1319
  error,
@@ -1328,6 +1345,10 @@ export function CamelChatMessages({
1328
1345
  isActive && lastItem?.kind === "assistant"
1329
1346
  ? lastItem.actionMessageId
1330
1347
  : null;
1348
+ const recoveringAssistantMessageId =
1349
+ isRecovering && lastItem?.kind === "assistant"
1350
+ ? lastItem.actionMessageId
1351
+ : null;
1331
1352
 
1332
1353
  const syncScrollButton = useCallback(() => {
1333
1354
  const element = scrollRef.current;
@@ -1341,7 +1362,7 @@ export function CamelChatMessages({
1341
1362
  const element = scrollRef.current;
1342
1363
  if (!element || showScrollButton) return;
1343
1364
  element.scrollTo({ top: element.scrollHeight });
1344
- }, [messages, status, showScrollButton]);
1365
+ }, [isRecovering, messages, status, showScrollButton]);
1345
1366
 
1346
1367
  return (
1347
1368
  <div
@@ -1351,15 +1372,6 @@ export function CamelChatMessages({
1351
1372
  data-status={status}
1352
1373
  aria-busy={isActive || isHydrating}
1353
1374
  >
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
1375
  <div
1364
1376
  ref={scrollRef}
1365
1377
  onScroll={syncScrollButton}
@@ -1380,6 +1392,9 @@ export function CamelChatMessages({
1380
1392
  key={item.key}
1381
1393
  turn={item}
1382
1394
  isActive={item.actionMessageId === activeAssistantMessageId}
1395
+ keepTraceExpanded={
1396
+ item.actionMessageId === recoveringAssistantMessageId
1397
+ }
1383
1398
  hasPendingSteer={hasPendingSteer}
1384
1399
  hasPendingApproval={item.parts.some((partRef) => {
1385
1400
  const approval =
@@ -1398,6 +1413,9 @@ export function CamelChatMessages({
1398
1413
  <StandaloneMessage key={item.key} item={item} />
1399
1414
  ),
1400
1415
  )}
1416
+ {isRecovering && (
1417
+ <CamelChatRecovery label={recoveryStatusLabel} />
1418
+ )}
1401
1419
  {error != null && <CamelChatError error={error} onRetry={onRetry} />}
1402
1420
  {isActive && activeAssistantMessageId === null && (
1403
1421
  <ThinkingIndicator
package/src/message.tsx CHANGED
@@ -85,13 +85,6 @@ export const MessageAction = forwardRef<HTMLButtonElement, MessageActionProps>(
85
85
  MessageAction.displayName = "MessageAction";
86
86
 
87
87
  const streamdownPlugins = { cjk, code, math, mermaid };
88
- const streamAnimation = {
89
- animation: "fadeIn",
90
- sep: "word",
91
- duration: 260,
92
- stagger: 14,
93
- } as const;
94
-
95
88
  export type MessageResponseProps = StreamdownProps & {
96
89
  streaming?: boolean;
97
90
  resolveUrl?: MessageUrlResolver;
@@ -146,7 +139,6 @@ export const MessageResponse = memo(
146
139
  ? {
147
140
  mode: "streaming" as const,
148
141
  isAnimating: true,
149
- animated: streamAnimation,
150
142
  }
151
143
  : { mode: "static" as const })}
152
144
  {...props}