@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,71 @@
1
+ # cloud-os
2
+
3
+ 从 `cloudflare-os-main`(`packages/workshop-frontend`)搬来的聊天体 + 右侧工作区面板,
4
+ 适配到本仓的 UIMessage 协议。**完全独立**:不 import `../src` 的任何东西,自带
5
+ `cn` / 主题 / 图标 / markdown / Tooltip / DropdownMenu。这条边界由
6
+ `scripts/check-chat-presentation-boundary.mjs` 守着 —— 一旦开始互相 import,
7
+ 下次要整段同步上游就没法做了。
8
+
9
+ ## 怎么预览
10
+
11
+ ```bash
12
+ pnpm dev:cloud-os # http://localhost:9992
13
+ ```
14
+
15
+ 场景表和 `demo/camel-chat-showcase.tsx` 共用 `demo/chat-scenarios.ts`,
16
+ 所以两边 22 个场景逐条对齐;`?scenario=<id>` 可直接深链。
17
+
18
+ ## 目录
19
+
20
+ | 路径 | 对应上游文件 |
21
+ | --- | --- |
22
+ | `styles/cloud-os.css` | `src/styles.css` + `src/ChatInterface.module.css` |
23
+ | `chat/tool-rows.tsx` | `ChatInterface.tsx` 的 WorkIcon / ToolCallDetails / NestedToolCallRow / ToolGroupRow / ThinkingTraceRow |
24
+ | `chat/cloud-os-chat-messages.tsx` | `ChatInterface.tsx` 的消息区主渲染 |
25
+ | `chat/transcript-model.ts` | `buildChatDisplayEntries` + `rhythmTopClass` |
26
+ | `chat/tool-presentation.ts` | `getToolCallSummary` / `describeToolCallCount` / `getToolIcon` / `buildToolCallGroups` |
27
+ | `chat/rich-blocks.tsx` | `renderActionCard` 的 blocking callout(其余是 UIMessage 才有的 part) |
28
+ | `composer/cloud-os-chat-input.tsx` | `ChatInterface.tsx` 的 `ChatInput` |
29
+ | `workspace/cloud-os-workspace-panel.tsx` | `GadgetEditor.tsx` 的 PaneLabel / PaneWorkpieceTabs / PaneTab + tab bar |
30
+ | `layout/cloud-os-workspace-split.tsx` | `GadgetEditor.tsx` 的 BODY 双栏 + 拖拽把手 |
31
+ | `primitives/*` | `components/WorkshopControls.tsx`、`components/CountBadge.tsx`,以及 Kumo 的 Tooltip / DropdownMenu |
32
+
33
+ ## 相对上游做了什么改动
34
+
35
+ 搬运原则是「先照抄,再最小适配」。改动只有这几类,别的地方 className 一个字没动:
36
+
37
+ 1. **数据源**。上游吃的是 gadgets 自己的 `AiChatMessage`(离散消息类型 + 独立
38
+ toolCalls 数组);这里吃 UIMessage —— 一条 assistant 消息里一个有序 `parts`
39
+ 数组。所以「按消息类型分派」换成「按 parts 顺序切块」:连续的工具 part 攒成一个
40
+ `ToolCallGroup`,遇到非工具 part 就 flush。
41
+ 2. **工具文案**。上游是穷举十几个 gadgets 工具名的 `switch`;UIMessage 下工具名是
42
+ 开放集合,穷举不成立,改成「规范化工具种类 → 动词/复数/图标」查表。
43
+ 3. **底座替换**。`@cloudflare/kumo` 的 Tooltip / DropdownMenu / Button / Input 换成
44
+ radix + 原生元素。视觉全部由拷来的 className 决定,所以看起来一样;不引 Kumo 是
45
+ 因为它会往同一层 `@theme` 里塞一整套 token,和宿主 `universal-agent-ui` 的
46
+ shadcn 主题正面冲突。
47
+ 4. **作用域化主题**。只有 kumo 命名空间的 token 进 `@theme`;通用 token(字体 /
48
+ 圆角 / 字号 / 缓动)挂在 `.cloud-os-root` 上靠继承生效,宿主主题分毫不动。
49
+ 5. **删掉的东西**:capsule(URL → 资源胶囊)、行内 slash picker、ComposerMirror、
50
+ GatekeeperModal、Yjs 草稿 / 「接受改动」那一整套。它们都直挂 gadgets 的 capnweb
51
+ RPC,UIMessage 协议下没有对应物。slash 命令退成「+」菜单里的一组条目 —— 能力
52
+ 保住,但没有边打字边过滤的行内补全。
53
+ 6. **窄栏适配**。授权卡 / 提问卡 / 定时卡在上游是「图标 + 正文 + 右侧动作」一行到底;
54
+ 聊天栏默认只有 420px,一行放不下会把正文挤成 0 宽,所以改成允许换行。
55
+
56
+ ## 协议语义
57
+
58
+ - `update_plan` 是**整份快照覆盖**,不是增量事件。整条会话只保留最后一份计划快照
59
+ (`dropSupersededPlans`),否则同一份计划会被画上四遍。
60
+ - `ask_user` 的「已回答」取的是**这条消息之后的第一条用户直答**,不是工具 output ——
61
+ 后者往往只是 `{ asked: true }` 之类的回执。
62
+ - `approval-requested` 的工具是否渲染成原位授权卡,由宿主的 `approvalIdOf` 决定;
63
+ 返回 `undefined` 就退回普通工具行(本仓的持久化审批走输入框上方那条队列)。
64
+
65
+ ## 宿主接线要求
66
+
67
+ 1. Tailwind 入口里 **`@import` 本包的 css 必须和其它 `@import` 挤在最前面**。排到
68
+ `@source` / `@layer` 之后,Tailwind 就不会内联它,`@theme` 块会被整块忽略,
69
+ kumo 的 utility 一个都生成不出来(踩过一次)。
70
+ 2. 把本目录加进 `@source`,且必须写显式 glob:`"…/cloud-os/**/*.{ts,tsx}"`。
71
+ 3. 所有组件包在 `<CloudOsRoot mode={...}>` 里,否则拿不到作用域 token。
@@ -0,0 +1,606 @@
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
+ useCallback,
13
+ useEffect,
14
+ useMemo,
15
+ useRef,
16
+ useState,
17
+ } from "react";
18
+ import { Tooltip } from "../primitives/tooltip";
19
+ import { WorkshopIconButton } from "../primitives/workshop-controls";
20
+ import {
21
+ MarkdownMessage,
22
+ type CloudOsUrlResolver,
23
+ } from "./markdown-message";
24
+ import {
25
+ ApprovalBlock,
26
+ AskUserBlock,
27
+ ErrorBlock,
28
+ ParallelBlock,
29
+ PlanBlock,
30
+ ScheduleBlock,
31
+ SubAgentsBlock,
32
+ SuggestionsBlock,
33
+ type ApprovalDecision,
34
+ } from "./rich-blocks";
35
+ import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
36
+ import {
37
+ buildCloudOsEntries,
38
+ formatClockTime,
39
+ formatFullTimestamp,
40
+ rhythmTopClass,
41
+ type CloudOsEntry,
42
+ } from "./transcript-model";
43
+
44
+ export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
45
+
46
+ export interface CloudOsChatMessagesProps {
47
+ messages: readonly UIMessage[];
48
+ status: CloudOsChatStatus;
49
+ isHydrating?: boolean;
50
+ isRecovering?: boolean;
51
+ recoveryStatusLabel?: string;
52
+ /** 有 pending steer 时,活动占位说明「正在等当前步骤收尾」。 */
53
+ hasPendingSteer?: boolean;
54
+ /** 首帧就带着待发消息:此时不显示空会话欢迎语。 */
55
+ startedWithPending?: boolean;
56
+ error?: unknown;
57
+ showThinkingTraces?: boolean;
58
+ /** 把某个 part 映射成待授权 id;返回 undefined 表示不在此处渲染授权。 */
59
+ approvalIdOf?: (part: unknown) => string | undefined;
60
+ approvalsDisabled?: boolean;
61
+ /** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
62
+ resolveUrl?: CloudOsUrlResolver;
63
+ /** 消息区宽度是否收敛到 920px 居中(GadgetEditor 里叫 constrainChatWidth)。 */
64
+ constrainWidth?: boolean;
65
+ emptyTitle?: string;
66
+ onRetry?: () => void;
67
+ onApprove?: (approvalId: string, decision: ApprovalDecision) => void;
68
+ onSuggestion?: (text: string) => void;
69
+ onAnswer?: (payload: unknown) => void;
70
+ /** 点击定时任务卡片:宿主决定跳到哪(缺省 → 卡片不可点)。 */
71
+ onOpenSchedule?: (scheduleId: string) => void;
72
+ onCopy?: (text: string) => void;
73
+ }
74
+
75
+ function useCopyAction(): {
76
+ copied: string | null;
77
+ copy: (text: string) => void;
78
+ } {
79
+ const [copied, setCopied] = useState<string | null>(null);
80
+ const timerRef = useRef<number | null>(null);
81
+ useEffect(
82
+ () => () => {
83
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current);
84
+ },
85
+ [],
86
+ );
87
+ const copy = useCallback((text: string) => {
88
+ void navigator.clipboard.writeText(text).then(() => {
89
+ setCopied(text);
90
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current);
91
+ timerRef.current = window.setTimeout(() => setCopied(null), 1500);
92
+ });
93
+ }, []);
94
+ return { copied, copy };
95
+ }
96
+
97
+ function UserBubble({
98
+ entry,
99
+ onCopy,
100
+ copied,
101
+ resolveUrl,
102
+ }: {
103
+ entry: Extract<CloudOsEntry, { type: "user" }>;
104
+ onCopy: (text: string) => void;
105
+ copied: boolean;
106
+ resolveUrl?: CloudOsUrlResolver;
107
+ }) {
108
+ return (
109
+ <div className="group/message relative flex flex-col items-end">
110
+ {entry.attachments.length > 0 && (
111
+ <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-2">
112
+ {entry.attachments.map((attachment, index) =>
113
+ attachment.mediaType?.startsWith("image/") ? (
114
+ <img
115
+ key={`${attachment.url}:${index}`}
116
+ src={attachment.url}
117
+ alt={attachment.filename ?? "Attachment"}
118
+ className="themed-thumbnail-shadow max-h-52 max-w-64 rounded-xl border border-kumo-line object-cover"
119
+ />
120
+ ) : (
121
+ <span
122
+ key={`${attachment.url}:${index}`}
123
+ 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"
124
+ >
125
+ <FileIcon size={13} className="text-kumo-inactive" />
126
+ {attachment.filename ?? "Attachment"}
127
+ </span>
128
+ ),
129
+ )}
130
+ </div>
131
+ )}
132
+ {entry.text && (
133
+ <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">
134
+ {/* pre-wrap 让用户消息里的单个换行渲染成硬换行 */}
135
+ <div className="whitespace-pre-wrap">
136
+ <MarkdownMessage message={entry.text} resolveUrl={resolveUrl} />
137
+ </div>
138
+ </div>
139
+ )}
140
+ <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">
141
+ {entry.authorName && <span className="font-medium">{entry.authorName}</span>}
142
+ {entry.timestamp !== undefined && (
143
+ <Tooltip content={formatFullTimestamp(entry.timestamp)} asChild>
144
+ <span className="font-mono">{formatClockTime(entry.timestamp)}</span>
145
+ </Tooltip>
146
+ )}
147
+ {entry.text && (
148
+ <Tooltip content={copied ? "Copied!" : "Copy message"} asChild>
149
+ <button
150
+ type="button"
151
+ onClick={() => onCopy(entry.text)}
152
+ 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]"
153
+ aria-label={copied ? "Copied!" : "Copy message"}
154
+ >
155
+ {copied ? <Check size={13} weight="bold" /> : <Copy size={13} />}
156
+ </button>
157
+ </Tooltip>
158
+ )}
159
+ </div>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ export function CloudOsChatMessages({
165
+ messages,
166
+ status,
167
+ isHydrating = false,
168
+ isRecovering = false,
169
+ recoveryStatusLabel,
170
+ hasPendingSteer = false,
171
+ startedWithPending = false,
172
+ error,
173
+ showThinkingTraces = true,
174
+ approvalIdOf,
175
+ approvalsDisabled = false,
176
+ resolveUrl,
177
+ constrainWidth = true,
178
+ emptyTitle = "What are we working on?",
179
+ onRetry,
180
+ onApprove,
181
+ onSuggestion,
182
+ onAnswer,
183
+ onOpenSchedule,
184
+ onCopy,
185
+ }: CloudOsChatMessagesProps) {
186
+ const scrollRef = useRef<HTMLDivElement>(null);
187
+ const endRef = useRef<HTMLDivElement>(null);
188
+ const [showScrollButton, setShowScrollButton] = useState(false);
189
+ const [expandedToolCalls, setExpandedToolCalls] = useState<ReadonlySet<string>>(
190
+ () => new Set(),
191
+ );
192
+ const [expandedCompactions, setExpandedCompactions] = useState<
193
+ ReadonlySet<string>
194
+ >(() => new Set());
195
+ const { copied, copy } = useCopyAction();
196
+ const isActive = status === "submitted" || status === "streaming";
197
+
198
+ const entries = useMemo(
199
+ () =>
200
+ buildCloudOsEntries({
201
+ messages,
202
+ isActive,
203
+ showThinkingTraces,
204
+ approvalIdOf,
205
+ }),
206
+ [approvalIdOf, isActive, messages, showThinkingTraces],
207
+ );
208
+
209
+ const entryTopClasses = useMemo(
210
+ () =>
211
+ entries.map((entry, index) =>
212
+ rhythmTopClass(index > 0 ? entries[index - 1] : null, entry),
213
+ ),
214
+ [entries],
215
+ );
216
+
217
+ const toggleToolCall = useCallback((key: string) => {
218
+ setExpandedToolCalls((current) => {
219
+ const next = new Set(current);
220
+ if (next.has(key)) next.delete(key);
221
+ else next.add(key);
222
+ return next;
223
+ });
224
+ }, []);
225
+
226
+ const toggleCompaction = useCallback((key: string) => {
227
+ setExpandedCompactions((current) => {
228
+ const next = new Set(current);
229
+ if (next.has(key)) next.delete(key);
230
+ else next.add(key);
231
+ return next;
232
+ });
233
+ }, []);
234
+
235
+ const syncScrollButton = useCallback(() => {
236
+ const element = scrollRef.current;
237
+ if (!element) return;
238
+ const distance =
239
+ element.scrollHeight - element.scrollTop - element.clientHeight;
240
+ setShowScrollButton(distance > 120);
241
+ }, []);
242
+
243
+ useEffect(() => {
244
+ const element = scrollRef.current;
245
+ if (!element || showScrollButton) return;
246
+ element.scrollTo({ top: element.scrollHeight });
247
+ }, [isRecovering, messages, status, showScrollButton]);
248
+
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");
257
+ const copyText = (text: string) => {
258
+ copy(text);
259
+ onCopy?.(text);
260
+ };
261
+
262
+ return (
263
+ <div
264
+ className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-kumo-base"
265
+ data-cloud-os-transcript=""
266
+ data-status={status}
267
+ aria-busy={isActive || isHydrating}
268
+ >
269
+ <div
270
+ ref={scrollRef}
271
+ onScroll={syncScrollButton}
272
+ tabIndex={0}
273
+ role="region"
274
+ aria-label="Chat messages"
275
+ className="cos-chat-panel flex-1 overflow-y-auto"
276
+ >
277
+ <div
278
+ className={`flex flex-col px-6 pb-8 pt-8 ${
279
+ constrainWidth ? "mx-auto w-full max-w-[920px]" : ""
280
+ }`}
281
+ >
282
+ {entries.length === 0 && !startedWithPending && !isHydrating && (
283
+ <div className="flex min-h-56 items-center justify-center text-center text-[24px] leading-8 font-semibold tracking-[-0.4px] text-kumo-default">
284
+ {emptyTitle}
285
+ </div>
286
+ )}
287
+
288
+ {entries.map((entry, entryIndex) => {
289
+ const topClass = entryTopClasses[entryIndex] ?? "";
290
+
291
+ if (entry.type === "user") {
292
+ return (
293
+ <div key={entry.key} className={topClass}>
294
+ <UserBubble
295
+ entry={entry}
296
+ onCopy={copyText}
297
+ copied={copied === entry.text}
298
+ resolveUrl={resolveUrl}
299
+ />
300
+ </div>
301
+ );
302
+ }
303
+
304
+ if (entry.type === "slashCommand") {
305
+ return (
306
+ <div key={entry.key} className={`${topClass} flex flex-col items-end`}>
307
+ <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">
308
+ <span className="whitespace-pre-wrap font-mono">{entry.text}</span>
309
+ </div>
310
+ </div>
311
+ );
312
+ }
313
+
314
+ if (entry.type === "localCommandOutput") {
315
+ return (
316
+ <div
317
+ key={entry.key}
318
+ className={`${topClass} max-w-[860px] text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle`}
319
+ >
320
+ <div className="flex items-center gap-3 px-1.5 py-1">
321
+ <span
322
+ className="flex h-5 w-5 flex-shrink-0 items-center justify-center text-kumo-inactive"
323
+ aria-hidden="true"
324
+ >
325
+ <Terminal size={16} />
326
+ </span>
327
+ <span className="min-w-0 truncate font-mono text-[13px]">
328
+ {entry.text}
329
+ </span>
330
+ </div>
331
+ </div>
332
+ );
333
+ }
334
+
335
+ if (entry.type === "notice") {
336
+ return (
337
+ <div
338
+ key={entry.key}
339
+ className={`${topClass} max-w-[860px] text-[13px] leading-[19px] tracking-[-0.25px] text-kumo-inactive`}
340
+ >
341
+ <div className="px-1.5 py-1">{entry.text}</div>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ if (entry.type === "compactionBoundary") {
347
+ const expanded = expandedCompactions.has(entry.key);
348
+ return (
349
+ <div key={entry.key} className={`${topClass} mb-4 max-w-[860px]`}>
350
+ <div
351
+ className="flex items-center gap-3"
352
+ role="separator"
353
+ aria-label="Context compacted"
354
+ >
355
+ <span className="h-px flex-1 bg-kumo-line" aria-hidden="true" />
356
+ <button
357
+ type="button"
358
+ onClick={() => toggleCompaction(entry.key)}
359
+ aria-expanded={expanded}
360
+ 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"
361
+ >
362
+ <Brain size={13} aria-hidden="true" />
363
+ Context compacted
364
+ <CaretRight
365
+ size={11}
366
+ weight="bold"
367
+ className={`transition-transform duration-150 ease-out ${expanded ? "rotate-90" : ""}`}
368
+ />
369
+ </button>
370
+ <span className="h-px flex-1 bg-kumo-line" aria-hidden="true" />
371
+ </div>
372
+ {expanded && (
373
+ <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]">
374
+ <MarkdownMessage
375
+ message={entry.summary}
376
+ resolveUrl={resolveUrl}
377
+ />
378
+ </div>
379
+ )}
380
+ </div>
381
+ );
382
+ }
383
+
384
+ const hasNarrative = entry.blocks.some(
385
+ (block) => block.kind === "text",
386
+ );
387
+
388
+ return (
389
+ <div
390
+ key={entry.key}
391
+ className={`${topClass} min-w-0 w-full max-w-[860px] space-y-2`}
392
+ >
393
+ <div className="group/agentMessage relative space-y-1.5">
394
+ {entry.blocks.map((block) => {
395
+ switch (block.kind) {
396
+ case "reasoning":
397
+ return (
398
+ <ThinkingTraceRow key={block.key} reasoning={block.text} />
399
+ );
400
+ case "text":
401
+ return (
402
+ <div
403
+ key={block.key}
404
+ className="cos-markdown text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default"
405
+ >
406
+ <MarkdownMessage
407
+ message={block.text}
408
+ resolveUrl={resolveUrl}
409
+ />
410
+ </div>
411
+ );
412
+ case "toolGroup":
413
+ return (
414
+ <ToolGroupRow
415
+ key={block.key}
416
+ group={block.group}
417
+ open={expandedToolCalls.has(block.group.key)}
418
+ expandedKeys={expandedToolCalls}
419
+ onToggle={toggleToolCall}
420
+ />
421
+ );
422
+ case "plan":
423
+ return (
424
+ <PlanBlock
425
+ key={block.key}
426
+ steps={block.steps}
427
+ running={block.running}
428
+ />
429
+ );
430
+ case "askUser":
431
+ return (
432
+ <AskUserBlock
433
+ key={block.key}
434
+ call={block.call}
435
+ answeredText={block.answeredText}
436
+ onAnswer={(payload) => onAnswer?.(payload)}
437
+ />
438
+ );
439
+ case "suggestions":
440
+ return (
441
+ <SuggestionsBlock
442
+ key={block.key}
443
+ items={block.items}
444
+ onSuggestion={(text) => onSuggestion?.(text)}
445
+ />
446
+ );
447
+ case "schedule":
448
+ return (
449
+ <ScheduleBlock
450
+ key={block.key}
451
+ call={block.call}
452
+ onOpen={onOpenSchedule}
453
+ />
454
+ );
455
+ case "approval":
456
+ return (
457
+ <ApprovalBlock
458
+ key={block.key}
459
+ call={block.call}
460
+ approvalId={block.approvalId}
461
+ disabled={approvalsDisabled}
462
+ onApprove={(id, decision) => onApprove?.(id, decision)}
463
+ />
464
+ );
465
+ case "parallel":
466
+ return (
467
+ <ParallelBlock
468
+ key={block.key}
469
+ label={block.label}
470
+ tools={block.tools}
471
+ />
472
+ );
473
+ case "subagents":
474
+ return (
475
+ <SubAgentsBlock key={block.key} agents={block.agents} />
476
+ );
477
+ case "image":
478
+ return (
479
+ <figure key={block.key} className="my-1">
480
+ <img
481
+ src={resolveUrl?.(block.src) ?? block.src}
482
+ alt={block.alt}
483
+ className="max-h-[28rem] max-w-full rounded-xl border border-kumo-line object-contain"
484
+ />
485
+ </figure>
486
+ );
487
+ case "error":
488
+ return (
489
+ <ErrorBlock
490
+ key={block.key}
491
+ title={block.title}
492
+ body={block.body}
493
+ />
494
+ );
495
+ }
496
+ })}
497
+
498
+ {entry.terminal?.kind === "error" && (
499
+ <ErrorBlock
500
+ title={entry.terminal.title}
501
+ body={entry.terminal.body}
502
+ onRetry={onRetry}
503
+ />
504
+ )}
505
+
506
+ {entry.terminal?.kind === "interrupted" && (
507
+ <div
508
+ className="px-1.5 py-1 text-[13px] leading-[19px] text-kumo-inactive"
509
+ role="status"
510
+ >
511
+ {entry.terminal.title}
512
+ </div>
513
+ )}
514
+
515
+ {hasNarrative && entry.timestamp !== undefined && (
516
+ <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">
517
+ <Tooltip
518
+ content={copied === entry.copyText ? "Copied!" : "Copy message"}
519
+ asChild
520
+ >
521
+ <button
522
+ type="button"
523
+ onClick={() => copyText(entry.copyText)}
524
+ 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]"
525
+ aria-label="Copy message"
526
+ >
527
+ {copied === entry.copyText ? (
528
+ <Check size={15} weight="bold" />
529
+ ) : (
530
+ <Copy size={15} />
531
+ )}
532
+ </button>
533
+ </Tooltip>
534
+ <Tooltip content={formatFullTimestamp(entry.timestamp)} asChild>
535
+ <span className="px-1 font-mono text-[11px] leading-4 text-kumo-inactive">
536
+ {formatClockTime(entry.timestamp)}
537
+ </span>
538
+ </Tooltip>
539
+ </div>
540
+ )}
541
+ </div>
542
+ </div>
543
+ );
544
+ })}
545
+
546
+ {isRecovering && (
547
+ <div className="mt-4 flex max-w-[860px] items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
548
+ <span className="cos-thinking-shimmer">
549
+ {recoveryStatusLabel ?? "Reconnecting…"}
550
+ </span>
551
+ </div>
552
+ )}
553
+
554
+ {awaitingFirstOutput && (
555
+ <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>
559
+ </div>
560
+ )}
561
+
562
+ {error != null && (
563
+ <div className="mt-4">
564
+ <ErrorBlock
565
+ title="Error"
566
+ body={
567
+ error instanceof Error
568
+ ? error.message
569
+ : typeof error === "string"
570
+ ? error
571
+ : "Something went wrong"
572
+ }
573
+ onRetry={onRetry}
574
+ />
575
+ </div>
576
+ )}
577
+
578
+ <div ref={endRef} />
579
+ </div>
580
+ </div>
581
+
582
+ <WorkshopIconButton
583
+ aria-label="Scroll to bottom"
584
+ onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
585
+ 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 ${
586
+ showScrollButton
587
+ ? "translate-y-0 opacity-100"
588
+ : "pointer-events-none translate-y-2 opacity-0"
589
+ }`}
590
+ >
591
+ <ArrowDown size={15} />
592
+ </WorkshopIconButton>
593
+
594
+ {isHydrating && (
595
+ <div className="absolute inset-0 z-30 grid place-items-center bg-kumo-base/90">
596
+ <div className="flex flex-col items-center gap-3">
597
+ <div className="h-6 w-6 animate-spin rounded-full border-2 border-kumo-brand border-t-transparent" />
598
+ <p className="m-0 text-[13px] leading-[18px] text-kumo-subtle">
599
+ Loading conversation…
600
+ </p>
601
+ </div>
602
+ </div>
603
+ )}
604
+ </div>
605
+ );
606
+ }