@springbrand/message-panel 0.1.3-alpha.0 → 0.1.3-alpha.10

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