@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,632 @@
1
+ import type { UIMessage } from "ai";
2
+ import {
3
+ ArrowDown,
4
+ Brain,
5
+ CaretRight,
6
+ Check,
7
+ Copy,
8
+ File as FileIcon,
9
+ Terminal,
10
+ } from "@phosphor-icons/react";
11
+ import {
12
+ Fragment,
13
+ useCallback,
14
+ useEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ type ReactNode,
19
+ } from "react";
20
+ import { Tooltip } from "../primitives/tooltip";
21
+ import { WorkshopIconButton } from "../primitives/workshop-controls";
22
+ import {
23
+ MarkdownMessage,
24
+ type CloudOsUrlResolver,
25
+ } from "./markdown-message";
26
+ import {
27
+ ApprovalBlock,
28
+ AskUserBlock,
29
+ ErrorBlock,
30
+ ParallelBlock,
31
+ PlanBlock,
32
+ ScheduleBlock,
33
+ SubAgentsBlock,
34
+ SuggestionsBlock,
35
+ type ApprovalDecision,
36
+ } from "./rich-blocks";
37
+ import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
38
+ import { CloudOsActivityIndicator } from "./activity-indicator";
39
+ import {
40
+ buildCloudOsEntries,
41
+ deriveTurnActivity,
42
+ formatClockTime,
43
+ formatFullTimestamp,
44
+ rhythmTopClass,
45
+ type CloudOsEntry,
46
+ } from "./transcript-model";
47
+
48
+ export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
49
+
50
+ export interface CloudOsChatMessagesProps {
51
+ messages: readonly UIMessage[];
52
+ status: CloudOsChatStatus;
53
+ isHydrating?: boolean;
54
+ isRecovering?: boolean;
55
+ recoveryStatusLabel?: string;
56
+ /** 有 pending steer 时,活动占位说明「正在等当前步骤收尾」。 */
57
+ hasPendingSteer?: boolean;
58
+ /** 首帧就带着待发消息:此时不显示空会话欢迎语。 */
59
+ startedWithPending?: boolean;
60
+ error?: unknown;
61
+ showThinkingTraces?: boolean;
62
+ /** 把某个 part 映射成待授权 id;返回 undefined 表示不在此处渲染授权。 */
63
+ approvalIdOf?: (part: unknown) => string | undefined;
64
+ approvalsDisabled?: boolean;
65
+ /**
66
+ * 用宿主自己的卡片渲染某条待授权。返回 undefined 就退回内置的通用授权行。
67
+ *
68
+ * 存在的理由:授权行只认得工具名和入参,说不出「这次要花多少钱、买的是什么」。
69
+ * 需要在决定之前把这些摆到人面前的宿主,得自己画那张卡。
70
+ */
71
+ renderApproval?: (approval: {
72
+ approvalId: string;
73
+ toolName: string;
74
+ input: Record<string, unknown>;
75
+ disabled: boolean;
76
+ }) => ReactNode;
77
+ /** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
78
+ resolveUrl?: CloudOsUrlResolver;
79
+ /** 消息区宽度是否收敛到 920px 居中(GadgetEditor 里叫 constrainChatWidth)。 */
80
+ constrainWidth?: boolean;
81
+ emptyTitle?: string;
82
+ onRetry?: () => void;
83
+ onApprove?: (approvalId: string, decision: ApprovalDecision) => void;
84
+ onSuggestion?: (text: string) => void;
85
+ /**
86
+ * 把交互卡片上的操作回写成那次工具调用的结果(缺省 → 卡片不可操作)。
87
+ * 卡片不再知道「答案怎么变成消息」—— 它只认自己的 toolCallId。
88
+ */
89
+ onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
90
+ /** 点击定时任务卡片:宿主决定跳到哪(缺省 → 卡片不可点)。 */
91
+ onOpenSchedule?: (scheduleId: string) => void;
92
+ onCopy?: (text: string) => void;
93
+ }
94
+
95
+ function useCopyAction(): {
96
+ copied: string | null;
97
+ copy: (text: string) => void;
98
+ } {
99
+ const [copied, setCopied] = useState<string | null>(null);
100
+ const timerRef = useRef<number | null>(null);
101
+ useEffect(
102
+ () => () => {
103
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current);
104
+ },
105
+ [],
106
+ );
107
+ const copy = useCallback((text: string) => {
108
+ void navigator.clipboard.writeText(text).then(() => {
109
+ setCopied(text);
110
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current);
111
+ timerRef.current = window.setTimeout(() => setCopied(null), 1500);
112
+ });
113
+ }, []);
114
+ return { copied, copy };
115
+ }
116
+
117
+ function UserBubble({
118
+ entry,
119
+ onCopy,
120
+ copied,
121
+ resolveUrl,
122
+ }: {
123
+ entry: Extract<CloudOsEntry, { type: "user" }>;
124
+ onCopy: (text: string) => void;
125
+ copied: boolean;
126
+ resolveUrl?: CloudOsUrlResolver;
127
+ }) {
128
+ return (
129
+ <div className="group/message relative flex flex-col items-end">
130
+ {entry.attachments.length > 0 && (
131
+ <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-2">
132
+ {entry.attachments.map((attachment, index) =>
133
+ attachment.mediaType?.startsWith("image/") ? (
134
+ <img
135
+ key={`${attachment.url}:${index}`}
136
+ src={attachment.url}
137
+ alt={attachment.filename ?? "Attachment"}
138
+ className="themed-thumbnail-shadow max-h-52 max-w-64 rounded-xl border border-kumo-line object-cover"
139
+ />
140
+ ) : (
141
+ <span
142
+ key={`${attachment.url}:${index}`}
143
+ className="inline-flex items-center gap-1.5 rounded-lg border border-kumo-line bg-kumo-elevated px-2.5 py-1.5 text-[12px] leading-4 text-kumo-subtle"
144
+ >
145
+ <FileIcon size={13} className="text-kumo-inactive" />
146
+ {attachment.filename ?? "Attachment"}
147
+ </span>
148
+ ),
149
+ )}
150
+ </div>
151
+ )}
152
+ {entry.text && (
153
+ <div className="themed-user-bubble-shadow cos-markdown w-fit max-w-[min(680px,78%)] rounded-[24px] rounded-br-lg border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
154
+ {/* pre-wrap 让用户消息里的单个换行渲染成硬换行 */}
155
+ <div className="whitespace-pre-wrap">
156
+ <MarkdownMessage message={entry.text} resolveUrl={resolveUrl} />
157
+ </div>
158
+ </div>
159
+ )}
160
+ <div className="mt-0.5 flex items-center justify-end gap-2 pr-1 text-[11px] leading-4 text-kumo-inactive opacity-0 transition-opacity duration-150 ease-out group-hover/message:opacity-100 group-focus-within/message:opacity-100">
161
+ {entry.authorName && <span className="font-medium">{entry.authorName}</span>}
162
+ {entry.timestamp !== undefined && (
163
+ <Tooltip content={formatFullTimestamp(entry.timestamp)} asChild>
164
+ <span className="font-mono">{formatClockTime(entry.timestamp)}</span>
165
+ </Tooltip>
166
+ )}
167
+ {entry.text && (
168
+ <Tooltip content={copied ? "Copied!" : "Copy message"} asChild>
169
+ <button
170
+ type="button"
171
+ onClick={() => onCopy(entry.text)}
172
+ className="flex cursor-pointer items-center rounded-md p-1 text-kumo-inactive transition-[color,transform] duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none active:scale-[0.96]"
173
+ aria-label={copied ? "Copied!" : "Copy message"}
174
+ >
175
+ {copied ? <Check size={13} weight="bold" /> : <Copy size={13} />}
176
+ </button>
177
+ </Tooltip>
178
+ )}
179
+ </div>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ export function CloudOsChatMessages({
185
+ messages,
186
+ status,
187
+ isHydrating = false,
188
+ isRecovering = false,
189
+ recoveryStatusLabel,
190
+ hasPendingSteer = false,
191
+ startedWithPending = false,
192
+ error,
193
+ showThinkingTraces = true,
194
+ approvalIdOf,
195
+ approvalsDisabled = false,
196
+ renderApproval,
197
+ resolveUrl,
198
+ constrainWidth = true,
199
+ emptyTitle = "What are we working on?",
200
+ onRetry,
201
+ onApprove,
202
+ onSuggestion,
203
+ onRespond,
204
+ onOpenSchedule,
205
+ onCopy,
206
+ }: CloudOsChatMessagesProps) {
207
+ const scrollRef = useRef<HTMLDivElement>(null);
208
+ const endRef = useRef<HTMLDivElement>(null);
209
+ const [showScrollButton, setShowScrollButton] = useState(false);
210
+ const [expandedToolCalls, setExpandedToolCalls] = useState<ReadonlySet<string>>(
211
+ () => new Set(),
212
+ );
213
+ const [expandedCompactions, setExpandedCompactions] = useState<
214
+ ReadonlySet<string>
215
+ >(() => new Set());
216
+ const { copied, copy } = useCopyAction();
217
+ const isActive = status === "submitted" || status === "streaming";
218
+
219
+ const entries = useMemo(
220
+ () =>
221
+ buildCloudOsEntries({
222
+ messages,
223
+ isActive,
224
+ showThinkingTraces,
225
+ approvalIdOf,
226
+ }),
227
+ [approvalIdOf, isActive, messages, showThinkingTraces],
228
+ );
229
+
230
+ const entryTopClasses = useMemo(
231
+ () =>
232
+ entries.map((entry, index) =>
233
+ rhythmTopClass(index > 0 ? entries[index - 1] : null, entry),
234
+ ),
235
+ [entries],
236
+ );
237
+
238
+ const toggleToolCall = useCallback((key: string) => {
239
+ setExpandedToolCalls((current) => {
240
+ const next = new Set(current);
241
+ if (next.has(key)) next.delete(key);
242
+ else next.add(key);
243
+ return next;
244
+ });
245
+ }, []);
246
+
247
+ const toggleCompaction = useCallback((key: string) => {
248
+ setExpandedCompactions((current) => {
249
+ const next = new Set(current);
250
+ if (next.has(key)) next.delete(key);
251
+ else next.add(key);
252
+ return next;
253
+ });
254
+ }, []);
255
+
256
+ const syncScrollButton = useCallback(() => {
257
+ const element = scrollRef.current;
258
+ if (!element) return;
259
+ const distance =
260
+ element.scrollHeight - element.scrollTop - element.clientHeight;
261
+ setShowScrollButton(distance > 120);
262
+ }, []);
263
+
264
+ useEffect(() => {
265
+ const element = scrollRef.current;
266
+ if (!element || showScrollButton) return;
267
+ element.scrollTo({ top: element.scrollHeight });
268
+ }, [isRecovering, messages, status, showScrollButton]);
269
+
270
+ // 判据是「尾巴有没有活口」,不是原文件的「本回合有没有出过内容」——
271
+ // 后者会让「答完 ask_user 之后到模型下一个 chunk」这段完全没有指示,
272
+ // 而卡片此时写着 Submitted,读起来就是卡死了。见 deriveTurnActivity。
273
+ const activity = deriveTurnActivity(entries, { isActive, hasPendingSteer });
274
+ const copyText = (text: string) => {
275
+ copy(text);
276
+ onCopy?.(text);
277
+ };
278
+
279
+ return (
280
+ <div
281
+ className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-kumo-base"
282
+ data-cloud-os-transcript=""
283
+ data-status={status}
284
+ aria-busy={isActive || isHydrating}
285
+ >
286
+ <div
287
+ ref={scrollRef}
288
+ onScroll={syncScrollButton}
289
+ tabIndex={0}
290
+ role="region"
291
+ aria-label="Chat messages"
292
+ className="cos-chat-panel flex-1 overflow-y-auto"
293
+ >
294
+ <div
295
+ className={`flex flex-col px-6 pb-8 pt-8 ${
296
+ constrainWidth ? "mx-auto w-full max-w-[920px]" : ""
297
+ }`}
298
+ >
299
+ {entries.length === 0 && !startedWithPending && !isHydrating && (
300
+ <div className="flex min-h-56 items-center justify-center text-center text-[24px] leading-8 font-semibold tracking-[-0.4px] text-kumo-default">
301
+ {emptyTitle}
302
+ </div>
303
+ )}
304
+
305
+ {entries.map((entry, entryIndex) => {
306
+ const topClass = entryTopClasses[entryIndex] ?? "";
307
+
308
+ if (entry.type === "user") {
309
+ return (
310
+ <div key={entry.key} className={topClass}>
311
+ <UserBubble
312
+ entry={entry}
313
+ onCopy={copyText}
314
+ copied={copied === entry.text}
315
+ resolveUrl={resolveUrl}
316
+ />
317
+ </div>
318
+ );
319
+ }
320
+
321
+ if (entry.type === "slashCommand") {
322
+ return (
323
+ <div key={entry.key} className={`${topClass} flex flex-col items-end`}>
324
+ <div className="themed-user-bubble-shadow w-fit max-w-[min(680px,78%)] rounded-[24px] rounded-br-lg border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-brand">
325
+ <span className="whitespace-pre-wrap font-mono">{entry.text}</span>
326
+ </div>
327
+ </div>
328
+ );
329
+ }
330
+
331
+ if (entry.type === "localCommandOutput") {
332
+ return (
333
+ <div
334
+ key={entry.key}
335
+ className={`${topClass} max-w-[860px] text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle`}
336
+ >
337
+ <div className="flex items-center gap-3 px-1.5 py-1">
338
+ <span
339
+ className="flex h-5 w-5 flex-shrink-0 items-center justify-center text-kumo-inactive"
340
+ aria-hidden="true"
341
+ >
342
+ <Terminal size={16} />
343
+ </span>
344
+ <span className="min-w-0 truncate font-mono text-[13px]">
345
+ {entry.text}
346
+ </span>
347
+ </div>
348
+ </div>
349
+ );
350
+ }
351
+
352
+ if (entry.type === "notice") {
353
+ return (
354
+ <div
355
+ key={entry.key}
356
+ className={`${topClass} max-w-[860px] text-[13px] leading-[19px] tracking-[-0.25px] text-kumo-inactive`}
357
+ >
358
+ <div className="px-1.5 py-1">{entry.text}</div>
359
+ </div>
360
+ );
361
+ }
362
+
363
+ if (entry.type === "compactionBoundary") {
364
+ const expanded = expandedCompactions.has(entry.key);
365
+ return (
366
+ <div key={entry.key} className={`${topClass} mb-4 max-w-[860px]`}>
367
+ <div
368
+ className="flex items-center gap-3"
369
+ role="separator"
370
+ aria-label="Context compacted"
371
+ >
372
+ <span className="h-px flex-1 bg-kumo-line" aria-hidden="true" />
373
+ <button
374
+ type="button"
375
+ onClick={() => toggleCompaction(entry.key)}
376
+ aria-expanded={expanded}
377
+ className="flex flex-shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-1 py-0.5 text-[11px] leading-4 font-medium tracking-[0.6px] text-kumo-inactive uppercase transition-colors duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none"
378
+ >
379
+ <Brain size={13} aria-hidden="true" />
380
+ Context compacted
381
+ <CaretRight
382
+ size={11}
383
+ weight="bold"
384
+ className={`transition-transform duration-150 ease-out ${expanded ? "rotate-90" : ""}`}
385
+ />
386
+ </button>
387
+ <span className="h-px flex-1 bg-kumo-line" aria-hidden="true" />
388
+ </div>
389
+ {expanded && (
390
+ <div className="themed-surface-inset cos-markdown mt-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3.5 text-[13px] leading-[19px]">
391
+ <MarkdownMessage
392
+ message={entry.summary}
393
+ resolveUrl={resolveUrl}
394
+ />
395
+ </div>
396
+ )}
397
+ </div>
398
+ );
399
+ }
400
+
401
+ const hasNarrative = entry.blocks.some(
402
+ (block) => block.kind === "text",
403
+ );
404
+
405
+ return (
406
+ <div
407
+ key={entry.key}
408
+ className={`${topClass} min-w-0 w-full max-w-[860px] space-y-2`}
409
+ >
410
+ <div className="group/agentMessage relative space-y-1.5">
411
+ {entry.blocks.map((block) => {
412
+ switch (block.kind) {
413
+ case "reasoning":
414
+ return (
415
+ <ThinkingTraceRow key={block.key} reasoning={block.text} />
416
+ );
417
+ case "text":
418
+ return (
419
+ <div
420
+ key={block.key}
421
+ className="cos-markdown text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default"
422
+ >
423
+ <MarkdownMessage
424
+ message={block.text}
425
+ resolveUrl={resolveUrl}
426
+ />
427
+ </div>
428
+ );
429
+ case "toolGroup":
430
+ return (
431
+ <ToolGroupRow
432
+ key={block.key}
433
+ group={block.group}
434
+ open={expandedToolCalls.has(block.group.key)}
435
+ expandedKeys={expandedToolCalls}
436
+ onToggle={toggleToolCall}
437
+ />
438
+ );
439
+ case "plan":
440
+ return (
441
+ <PlanBlock
442
+ key={block.key}
443
+ steps={block.steps}
444
+ running={block.running}
445
+ />
446
+ );
447
+ case "askUser":
448
+ return (
449
+ <AskUserBlock
450
+ key={block.key}
451
+ call={block.call}
452
+ onRespond={onRespond}
453
+ />
454
+ );
455
+ case "suggestions":
456
+ return (
457
+ <SuggestionsBlock
458
+ key={block.key}
459
+ items={block.items}
460
+ onSuggestion={(text) => onSuggestion?.(text)}
461
+ />
462
+ );
463
+ case "schedule":
464
+ return (
465
+ <ScheduleBlock
466
+ key={block.key}
467
+ call={block.call}
468
+ onOpen={onOpenSchedule}
469
+ />
470
+ );
471
+ case "approval": {
472
+ const custom = renderApproval?.({
473
+ approvalId: block.approvalId,
474
+ toolName: block.call.toolName,
475
+ input: block.call.input,
476
+ disabled: approvalsDisabled,
477
+ });
478
+ if (custom !== undefined && custom !== null) {
479
+ return (
480
+ <Fragment key={block.key}>{custom}</Fragment>
481
+ );
482
+ }
483
+ return (
484
+ <ApprovalBlock
485
+ key={block.key}
486
+ call={block.call}
487
+ approvalId={block.approvalId}
488
+ disabled={approvalsDisabled}
489
+ onApprove={(id, decision) => onApprove?.(id, decision)}
490
+ />
491
+ );
492
+ }
493
+ case "parallel":
494
+ return (
495
+ <ParallelBlock
496
+ key={block.key}
497
+ label={block.label}
498
+ tools={block.tools}
499
+ />
500
+ );
501
+ case "subagents":
502
+ return (
503
+ <SubAgentsBlock key={block.key} agents={block.agents} />
504
+ );
505
+ case "image":
506
+ return (
507
+ <figure key={block.key} className="my-1">
508
+ <img
509
+ src={resolveUrl?.(block.src) ?? block.src}
510
+ alt={block.alt}
511
+ className="max-h-[28rem] max-w-full rounded-xl border border-kumo-line object-contain"
512
+ />
513
+ </figure>
514
+ );
515
+ case "error":
516
+ return (
517
+ <ErrorBlock
518
+ key={block.key}
519
+ title={block.title}
520
+ body={block.body}
521
+ />
522
+ );
523
+ }
524
+ })}
525
+
526
+ {entry.terminal?.kind === "error" && (
527
+ <ErrorBlock
528
+ title={entry.terminal.title}
529
+ body={entry.terminal.body}
530
+ onRetry={onRetry}
531
+ />
532
+ )}
533
+
534
+ {entry.terminal?.kind === "interrupted" && (
535
+ <div
536
+ className="px-1.5 py-1 text-[13px] leading-[19px] text-kumo-inactive"
537
+ role="status"
538
+ >
539
+ {entry.terminal.title}
540
+ </div>
541
+ )}
542
+
543
+ {hasNarrative && entry.timestamp !== undefined && (
544
+ <div className="mt-0.5 -ml-1 flex items-center gap-1 opacity-0 transition-opacity duration-150 ease-out group-hover/agentMessage:opacity-100 group-focus-within/agentMessage:opacity-100">
545
+ <Tooltip
546
+ content={copied === entry.copyText ? "Copied!" : "Copy message"}
547
+ asChild
548
+ >
549
+ <button
550
+ type="button"
551
+ onClick={() => copyText(entry.copyText)}
552
+ className="flex cursor-pointer items-center rounded-md p-1 text-kumo-inactive transition-[color,transform] duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none active:scale-[0.96]"
553
+ aria-label="Copy message"
554
+ >
555
+ {copied === entry.copyText ? (
556
+ <Check size={15} weight="bold" />
557
+ ) : (
558
+ <Copy size={15} />
559
+ )}
560
+ </button>
561
+ </Tooltip>
562
+ <Tooltip content={formatFullTimestamp(entry.timestamp)} asChild>
563
+ <span className="px-1 font-mono text-[11px] leading-4 text-kumo-inactive">
564
+ {formatClockTime(entry.timestamp)}
565
+ </span>
566
+ </Tooltip>
567
+ </div>
568
+ )}
569
+ </div>
570
+ </div>
571
+ );
572
+ })}
573
+
574
+ {isRecovering && (
575
+ <div className="mt-4 flex max-w-[860px] items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
576
+ <span className="cos-thinking-shimmer">
577
+ {recoveryStatusLabel ?? "Reconnecting…"}
578
+ </span>
579
+ </div>
580
+ )}
581
+
582
+ {activity && (
583
+ <div className="mt-5 inline-flex px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
584
+ <CloudOsActivityIndicator {...activity} />
585
+ </div>
586
+ )}
587
+
588
+ {error != null && (
589
+ <div className="mt-4">
590
+ <ErrorBlock
591
+ title="Error"
592
+ body={
593
+ error instanceof Error
594
+ ? error.message
595
+ : typeof error === "string"
596
+ ? error
597
+ : "Something went wrong"
598
+ }
599
+ onRetry={onRetry}
600
+ />
601
+ </div>
602
+ )}
603
+
604
+ <div ref={endRef} />
605
+ </div>
606
+ </div>
607
+
608
+ <WorkshopIconButton
609
+ aria-label="Scroll to bottom"
610
+ onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
611
+ className={`themed-floating-shadow absolute bottom-3 left-1/2 z-20 -translate-x-1/2 !rounded-full border border-kumo-line bg-kumo-base transition-all duration-200 ${
612
+ showScrollButton
613
+ ? "translate-y-0 opacity-100"
614
+ : "pointer-events-none translate-y-2 opacity-0"
615
+ }`}
616
+ >
617
+ <ArrowDown size={15} />
618
+ </WorkshopIconButton>
619
+
620
+ {isHydrating && (
621
+ <div className="absolute inset-0 z-30 grid place-items-center bg-kumo-base/90">
622
+ <div className="flex flex-col items-center gap-3">
623
+ <div className="h-6 w-6 animate-spin rounded-full border-2 border-kumo-brand border-t-transparent" />
624
+ <p className="m-0 text-[13px] leading-[18px] text-kumo-subtle">
625
+ Loading conversation…
626
+ </p>
627
+ </div>
628
+ </div>
629
+ )}
630
+ </div>
631
+ );
632
+ }