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

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 (99) hide show
  1. package/demo/camel-chat-showcase.tsx +1265 -0
  2. package/demo/fixtures.ts +292 -0
  3. package/demo/message-panel-chat-showcase.tsx +250 -0
  4. package/demo/message-panel-gallery.tsx +389 -0
  5. package/package.json +6 -16
  6. package/src/adapters/ai-sdk.ts +32 -0
  7. package/src/camel/camel-chat-messages.tsx +1435 -0
  8. package/src/camel/camel-model-select.tsx +88 -0
  9. package/src/camel/camel-plan-bar.tsx +115 -0
  10. package/src/camel/camel-prompt-input.tsx +547 -0
  11. package/src/camel/camel-tool-presentation.tsx +1270 -0
  12. package/src/camel/camel-turn.ts +334 -0
  13. package/src/camel/turn-summary-bar.tsx +81 -0
  14. package/src/chat-summary-panel.tsx +229 -0
  15. package/src/composer/chat-composer.tsx +216 -0
  16. package/src/composer/composer-attachments.tsx +138 -0
  17. package/src/composer/composer-trigger-popover.tsx +236 -0
  18. package/src/composer/composer.tsx +425 -0
  19. package/src/composer/drop-controller.ts +80 -0
  20. package/src/composer/types.ts +79 -0
  21. package/src/contracts.ts +138 -0
  22. package/src/conversation.tsx +76 -0
  23. package/src/internal/cn.ts +3 -0
  24. package/src/message-panel.tsx +368 -0
  25. package/src/message.tsx +254 -0
  26. package/src/parts/index.tsx +1149 -0
  27. package/src/parts/kind.ts +67 -0
  28. package/src/parts/plan.ts +64 -0
  29. package/src/parts/primitives.tsx +439 -0
  30. package/src/registry.ts +44 -0
  31. package/src/status.tsx +132 -0
  32. package/dist/demo/camel-chat-showcase.d.ts +0 -1
  33. package/dist/demo/camel-chat-showcase.js +0 -1007
  34. package/dist/demo/fixtures.d.ts +0 -32
  35. package/dist/demo/fixtures.js +0 -250
  36. package/dist/demo/index.js +0 -4
  37. package/dist/demo/message-panel-chat-showcase.d.ts +0 -1
  38. package/dist/demo/message-panel-chat-showcase.js +0 -114
  39. package/dist/demo/message-panel-gallery.d.ts +0 -1
  40. package/dist/demo/message-panel-gallery.js +0 -130
  41. package/dist/src/adapters/ai-sdk.d.ts +0 -4
  42. package/dist/src/adapters/ai-sdk.js +0 -25
  43. package/dist/src/camel/camel-chat-messages.d.ts +0 -44
  44. package/dist/src/camel/camel-chat-messages.js +0 -421
  45. package/dist/src/camel/camel-model-select.d.ts +0 -13
  46. package/dist/src/camel/camel-model-select.js +0 -7
  47. package/dist/src/camel/camel-plan-bar.d.ts +0 -5
  48. package/dist/src/camel/camel-plan-bar.js +0 -24
  49. package/dist/src/camel/camel-prompt-input.d.ts +0 -32
  50. package/dist/src/camel/camel-prompt-input.js +0 -182
  51. package/dist/src/camel/camel-tool-presentation.d.ts +0 -21
  52. package/dist/src/camel/camel-tool-presentation.js +0 -784
  53. package/dist/src/camel/camel-turn.d.ts +0 -57
  54. package/dist/src/camel/camel-turn.js +0 -203
  55. package/dist/src/camel/index.js +0 -7
  56. package/dist/src/camel/turn-summary-bar.d.ts +0 -8
  57. package/dist/src/camel/turn-summary-bar.js +0 -12
  58. package/dist/src/chat-summary-panel.d.ts +0 -22
  59. package/dist/src/chat-summary-panel.js +0 -26
  60. package/dist/src/composer/chat-composer.d.ts +0 -4
  61. package/dist/src/composer/chat-composer.js +0 -90
  62. package/dist/src/composer/composer-attachments.d.ts +0 -13
  63. package/dist/src/composer/composer-attachments.js +0 -47
  64. package/dist/src/composer/composer-trigger-popover.d.ts +0 -31
  65. package/dist/src/composer/composer-trigger-popover.js +0 -29
  66. package/dist/src/composer/composer.d.ts +0 -15
  67. package/dist/src/composer/composer.js +0 -180
  68. package/dist/src/composer/drop-controller.d.ts +0 -8
  69. package/dist/src/composer/drop-controller.js +0 -69
  70. package/dist/src/composer/index.js +0 -6
  71. package/dist/src/composer/types.d.ts +0 -62
  72. package/dist/src/composer/types.js +0 -1
  73. package/dist/src/contracts.d.ts +0 -94
  74. package/dist/src/contracts.js +0 -1
  75. package/dist/src/conversation.d.ts +0 -13
  76. package/dist/src/conversation.js +0 -18
  77. package/dist/src/index.js +0 -12
  78. package/dist/src/internal/cn.d.ts +0 -1
  79. package/dist/src/internal/cn.js +0 -3
  80. package/dist/src/message-panel.d.ts +0 -4
  81. package/dist/src/message-panel.js +0 -144
  82. package/dist/src/message.d.ts +0 -36
  83. package/dist/src/message.js +0 -83
  84. package/dist/src/parts/index.d.ts +0 -187
  85. package/dist/src/parts/index.js +0 -332
  86. package/dist/src/parts/kind.d.ts +0 -3
  87. package/dist/src/parts/kind.js +0 -69
  88. package/dist/src/parts/plan.d.ts +0 -10
  89. package/dist/src/parts/plan.js +0 -40
  90. package/dist/src/parts/primitives.d.ts +0 -70
  91. package/dist/src/parts/primitives.js +0 -153
  92. package/dist/src/registry.d.ts +0 -4
  93. package/dist/src/registry.js +0 -31
  94. package/dist/src/status.d.ts +0 -26
  95. package/dist/src/status.js +0 -74
  96. /package/{dist/demo/index.d.ts → demo/index.ts} +0 -0
  97. /package/{dist/src/camel/index.d.ts → src/camel/index.ts} +0 -0
  98. /package/{dist/src/composer/index.d.ts → src/composer/index.ts} +0 -0
  99. /package/{dist/src/index.d.ts → src/index.ts} +0 -0
@@ -0,0 +1,1435 @@
1
+ import type { UIMessage } from "ai";
2
+ import {
3
+ ArrowDownIcon,
4
+ BellIcon,
5
+ CheckIcon,
6
+ ChevronRightIcon,
7
+ CopyIcon,
8
+ GitForkIcon,
9
+ Loader2Icon,
10
+ TriangleAlertIcon,
11
+ } from "lucide-react";
12
+ import {
13
+ useCallback,
14
+ useEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ type ButtonHTMLAttributes,
19
+ type ReactNode,
20
+ } from "react";
21
+ import {
22
+ MessageResponse,
23
+ type MessageUrlResolver,
24
+ } from "../message";
25
+ import {
26
+ ParallelPart,
27
+ SubAgentsPart,
28
+ resolvePartKind,
29
+ toolNameOfPart,
30
+ type ParallelTool,
31
+ type SubAgent,
32
+ } from "../parts";
33
+ import { deriveThinkingActivity, ThinkingIndicator } from "../status";
34
+ import type {
35
+ ToolApprovalDecision,
36
+ ToolApprovalView,
37
+ } from "../contracts";
38
+ import { cn } from "../internal/cn";
39
+ import {
40
+ CamelToolDetails,
41
+ getCamelToolPresentation,
42
+ } from "./camel-tool-presentation";
43
+ import {
44
+ camelCompletedAt,
45
+ camelCreatedAt,
46
+ camelMessageText,
47
+ camelMetadata,
48
+ formatCamelMessageTime,
49
+ projectCamelMessages,
50
+ type CamelAssistantTurn,
51
+ type CamelPartRef,
52
+ type CamelStandaloneMessage,
53
+ } from "./camel-turn";
54
+ import { TurnSummaryBar } from "./turn-summary-bar";
55
+
56
+ type ChatStatus = "ready" | "submitted" | "streaming" | "error";
57
+
58
+ export interface CamelPendingApproval {
59
+ id: string;
60
+ action: string;
61
+ summary: string;
62
+ executionLevelLabel: string;
63
+ requiredExecutionLevelLabel: string;
64
+ disabled?: boolean;
65
+ }
66
+
67
+ export interface CamelChatMessagesProps {
68
+ messages: readonly UIMessage[];
69
+ status: ChatStatus;
70
+ /** Exact tool names promoted from the collapsible trace to final output. */
71
+ finalToolNames?: readonly string[];
72
+ isHydrating?: boolean;
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;
78
+ hasPendingSteer?: boolean;
79
+ startedWithPending?: boolean;
80
+ error?: unknown;
81
+ onRetry?: () => void;
82
+ onApprove: (approvalId: string, decision: ToolApprovalDecision) => void;
83
+ onSuggestion: (text: string) => void;
84
+ onAnswer: (payload: unknown) => void;
85
+ onFork?: (messageId: string) => void | Promise<void>;
86
+ getToolApproval?: (part: unknown) => ToolApprovalView | undefined;
87
+ resolveUrl?: MessageUrlResolver;
88
+ /** Set false when durable approval actions are rendered outside the trace. */
89
+ inlineApprovalActions?: boolean;
90
+ }
91
+
92
+ const pendingSteerActivity = {
93
+ state: "working" as const,
94
+ label: "Waiting for the current step to finish…",
95
+ };
96
+
97
+ type UnknownRecord = Record<string, unknown>;
98
+
99
+ type CamelButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
100
+ size?: "sm" | "icon" | "icon-sm";
101
+ variant?: "default" | "outline" | "ghost";
102
+ };
103
+
104
+ function Button({
105
+ className,
106
+ size = "sm",
107
+ variant = "default",
108
+ type = "button",
109
+ ...props
110
+ }: CamelButtonProps) {
111
+ return (
112
+ <button
113
+ type={type}
114
+ className={cn(
115
+ "inline-flex shrink-0 items-center justify-center gap-1 rounded-md text-xs font-medium transition-all outline-none disabled:pointer-events-none disabled:opacity-50",
116
+ variant === "default" &&
117
+ "bg-primary text-primary-foreground hover:bg-primary/80",
118
+ variant === "outline" &&
119
+ "border border-border bg-background hover:bg-muted",
120
+ variant === "ghost" && "hover:bg-muted hover:text-foreground",
121
+ size === "sm" && "h-7 px-2",
122
+ size === "icon" && "size-9 rounded-full",
123
+ size === "icon-sm" && "size-8 p-0",
124
+ className,
125
+ )}
126
+ {...props}
127
+ />
128
+ );
129
+ }
130
+
131
+ export function CamelChatError({
132
+ error,
133
+ onRetry,
134
+ }: {
135
+ error: unknown;
136
+ onRetry?: () => void;
137
+ }) {
138
+ return (
139
+ <div
140
+ className="flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2 text-sm text-destructive"
141
+ data-camel-chat-error=""
142
+ role="alert"
143
+ >
144
+ <TriangleAlertIcon className="size-4 shrink-0" />
145
+ <span className="min-w-0 flex-1 break-words">
146
+ {error instanceof Error
147
+ ? error.message
148
+ : typeof error === "string"
149
+ ? error
150
+ : "Something went wrong"}
151
+ </span>
152
+ {onRetry && (
153
+ <Button
154
+ type="button"
155
+ size="sm"
156
+ variant="outline"
157
+ className="shrink-0"
158
+ onClick={onRetry}
159
+ >
160
+ Retry
161
+ </Button>
162
+ )}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ export function CamelApprovalPrompts({
168
+ approvals,
169
+ error,
170
+ onApprove,
171
+ }: {
172
+ approvals: readonly CamelPendingApproval[];
173
+ error?: string | null;
174
+ onApprove: (approvalId: string, decision: ToolApprovalDecision) => void;
175
+ }) {
176
+ if (approvals.length === 0) return null;
177
+
178
+ return (
179
+ <div className="flex flex-col gap-2" data-camel-approval-prompts="">
180
+ {approvals.map((approval) => (
181
+ <section
182
+ key={approval.id}
183
+ aria-label="等待审批"
184
+ className="rounded-xl border border-border bg-background p-3 shadow-sm"
185
+ >
186
+ <div className="flex flex-wrap items-center justify-between gap-2">
187
+ <p className="text-xs font-medium text-muted-foreground">
188
+ 需要审批 · {approval.action}
189
+ </p>
190
+ <p className="text-xs text-muted-foreground">
191
+ Agent: {approval.executionLevelLabel} · Tool:{" "}
192
+ {approval.requiredExecutionLevelLabel}
193
+ </p>
194
+ </div>
195
+ <p className="mt-1 text-sm text-foreground">{approval.summary}</p>
196
+ <div className="mt-2 flex flex-wrap justify-end gap-2">
197
+ <Button
198
+ variant="outline"
199
+ disabled={approval.disabled}
200
+ onClick={() => onApprove(approval.id, "deny")}
201
+ >
202
+ 拒绝
203
+ </Button>
204
+ <Button
205
+ variant="outline"
206
+ disabled={approval.disabled}
207
+ onClick={() => onApprove(approval.id, "allow_once")}
208
+ >
209
+ 仅本次允许
210
+ </Button>
211
+ <Button
212
+ disabled={approval.disabled}
213
+ onClick={() => onApprove(approval.id, "allow_level")}
214
+ >
215
+ 允许该档位
216
+ </Button>
217
+ </div>
218
+ </section>
219
+ ))}
220
+ {error && (
221
+ <p className="px-1 text-xs text-destructive" role="alert">
222
+ {error}
223
+ </p>
224
+ )}
225
+ </div>
226
+ );
227
+ }
228
+
229
+ function Collapsible({
230
+ open,
231
+ onOpenChange,
232
+ disabled = false,
233
+ trigger,
234
+ children,
235
+ }: {
236
+ open: boolean;
237
+ onOpenChange: (open: boolean) => void;
238
+ disabled?: boolean;
239
+ trigger: ReactNode;
240
+ children: ReactNode;
241
+ }) {
242
+ return (
243
+ <>
244
+ <div onClick={() => {
245
+ if (!disabled) onOpenChange(!open);
246
+ }}>
247
+ {trigger}
248
+ </div>
249
+ <div
250
+ data-camel-collapsible-content=""
251
+ data-state={open ? "open" : "closed"}
252
+ aria-hidden={!open}
253
+ inert={!open ? true : undefined}
254
+ >
255
+ {open && (
256
+ <div data-camel-collapsible-inner="">
257
+ {children}
258
+ </div>
259
+ )}
260
+ </div>
261
+ </>
262
+ );
263
+ }
264
+
265
+ function recordOf(value: unknown): UnknownRecord {
266
+ return value != null && typeof value === "object"
267
+ ? (value as UnknownRecord)
268
+ : {};
269
+ }
270
+
271
+ function dataOf(part: unknown): UnknownRecord {
272
+ const record = recordOf(part);
273
+ return recordOf(record.data ?? record.input ?? record);
274
+ }
275
+
276
+ function toolApprovalOf(part: unknown): ToolApprovalView | undefined {
277
+ const approval = recordOf(recordOf(part).approval);
278
+ return typeof approval.id === "string"
279
+ ? {
280
+ id: approval.id,
281
+ ...(typeof approval.approved === "boolean"
282
+ ? { approved: approval.approved }
283
+ : {}),
284
+ }
285
+ : undefined;
286
+ }
287
+
288
+ function normalizedPartType(part: unknown): string {
289
+ const record = recordOf(part);
290
+ const rawType = typeof record.type === "string" ? record.type : "unknown";
291
+ return (
292
+ resolvePartKind(part) ??
293
+ (rawType.startsWith("data-")
294
+ ? rawType.slice(5).replaceAll("-", "_")
295
+ : rawType)
296
+ );
297
+ }
298
+
299
+ function ClipboardAction({ text }: { text: string }) {
300
+ const [copied, setCopied] = useState(false);
301
+ const timerRef = useRef<number | null>(null);
302
+
303
+ useEffect(
304
+ () => () => {
305
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current);
306
+ },
307
+ [],
308
+ );
309
+
310
+ const copy = useCallback(() => {
311
+ void navigator.clipboard.writeText(text).then(() => {
312
+ setCopied(true);
313
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current);
314
+ timerRef.current = window.setTimeout(() => setCopied(false), 1500);
315
+ });
316
+ }, [text]);
317
+
318
+ return (
319
+ <Button
320
+ type="button"
321
+ variant="ghost"
322
+ size="icon-sm"
323
+ className="size-8 text-muted-foreground"
324
+ onClick={copy}
325
+ aria-label={copied ? "Copied!" : "Copy message"}
326
+ title={copied ? "Copied!" : "Copy message"}
327
+ >
328
+ {copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
329
+ </Button>
330
+ );
331
+ }
332
+
333
+ function CamelThinking({
334
+ part,
335
+ isActive,
336
+ }: {
337
+ part: unknown;
338
+ isActive: boolean;
339
+ }) {
340
+ const record = recordOf(part);
341
+ const data = dataOf(part);
342
+ const text = String(record.text ?? data.detail ?? data.text ?? "");
343
+ const streaming = isActive && record.state === "streaming";
344
+ const [open, setOpen] = useState(false);
345
+
346
+ return (
347
+ <div data-part-type="reasoning">
348
+ <Collapsible
349
+ open={open}
350
+ onOpenChange={setOpen}
351
+ trigger={
352
+ <div
353
+ role="button"
354
+ tabIndex={0}
355
+ className={cn(
356
+ "thinking-block group/thinking -mx-2 flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1 text-left text-sm text-muted-foreground",
357
+ "transition-colors duration-150 hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50",
358
+ )}
359
+ onKeyDown={(event) => {
360
+ if (event.key === "Enter" || event.key === " ") {
361
+ event.preventDefault();
362
+ setOpen((current) => !current);
363
+ }
364
+ }}
365
+ >
366
+ <span
367
+ className={cn(
368
+ "size-1.5 shrink-0 rounded-full",
369
+ streaming
370
+ ? "animate-pulse bg-blue-500 motion-reduce:animate-none"
371
+ : "bg-green-500",
372
+ )}
373
+ />
374
+ <span className="min-w-0 flex-1 truncate">
375
+ {streaming ? "Thinking…" : "Thought"}
376
+ </span>
377
+ <ChevronRightIcon
378
+ className={cn(
379
+ "size-4 text-muted-foreground/50 opacity-0 transition-all duration-150 group-hover/thinking:opacity-100",
380
+ open && "rotate-90 opacity-100",
381
+ )}
382
+ />
383
+ </div>
384
+ }
385
+ >
386
+ <div className="ml-1 mt-1 border-l border-border/40 pl-4 text-xs text-muted-foreground/80">
387
+ <MessageResponse>{text}</MessageResponse>
388
+ </div>
389
+ </Collapsible>
390
+ </div>
391
+ );
392
+ }
393
+
394
+ function CamelToolRow({
395
+ part,
396
+ isActive,
397
+ children,
398
+ onApprove,
399
+ getToolApproval,
400
+ }: {
401
+ part: unknown;
402
+ isActive: boolean;
403
+ children?: ReactNode;
404
+ onApprove: (approvalId: string, decision: ToolApprovalDecision) => void;
405
+ getToolApproval?: (part: unknown) => ToolApprovalView | undefined;
406
+ }) {
407
+ const approval = getToolApproval?.(part) ?? toolApprovalOf(part);
408
+ const pendingApproval = approval && approval.approved === undefined;
409
+ const presentation = getCamelToolPresentation(part, isActive);
410
+ const autoOpen = presentation.running && (
411
+ presentation.isSubAgent ||
412
+ presentation.kind === "write" ||
413
+ presentation.kind === "edit"
414
+ );
415
+ const [open, setOpen] = useState(autoOpen);
416
+ const hasDetails = children != null || presentation.hasDetails;
417
+ const title = presentation.summary;
418
+
419
+ useEffect(() => {
420
+ if (autoOpen) setOpen(true);
421
+ }, [autoOpen]);
422
+
423
+ return (
424
+ <div data-part-type={normalizedPartType(part)}>
425
+ <Collapsible
426
+ open={open}
427
+ onOpenChange={setOpen}
428
+ disabled={!hasDetails}
429
+ trigger={
430
+ <div
431
+ role="button"
432
+ tabIndex={0}
433
+ aria-label={title}
434
+ aria-expanded={hasDetails ? open : undefined}
435
+ className={cn(
436
+ "tool-call group/toolcall -mx-2 flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm text-muted-foreground",
437
+ hasDetails && "cursor-pointer hover:bg-muted/30",
438
+ "transition-colors duration-150 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50",
439
+ )}
440
+ onKeyDown={(event) => {
441
+ if (hasDetails && (event.key === "Enter" || event.key === " ")) {
442
+ event.preventDefault();
443
+ setOpen((current) => !current);
444
+ }
445
+ }}
446
+ >
447
+ <span
448
+ className={cn(
449
+ "size-1.5 shrink-0 rounded-full",
450
+ presentation.failed
451
+ ? "bg-red-500"
452
+ : presentation.running || pendingApproval
453
+ ? "animate-pulse bg-blue-500 motion-reduce:animate-none"
454
+ : "bg-green-500",
455
+ )}
456
+ />
457
+ <span className="min-w-0 flex-1 truncate">
458
+ {title}
459
+ </span>
460
+ {hasDetails && (
461
+ <ChevronRightIcon
462
+ className={cn(
463
+ "size-4 text-muted-foreground/50 opacity-0 transition-all duration-150 group-hover/toolcall:opacity-100",
464
+ open && "rotate-90 opacity-100",
465
+ )}
466
+ />
467
+ )}
468
+ </div>
469
+ }
470
+ >
471
+ {hasDetails ? (
472
+ <div className="group/details ml-1 mt-1 border-l border-border/50 pl-4 text-xs text-muted-foreground/80">
473
+ {children ?? (
474
+ <CamelToolDetails presentation={presentation} />
475
+ )}
476
+ </div>
477
+ ) : null}
478
+ </Collapsible>
479
+ {pendingApproval && (
480
+ <div className="ml-1 mt-2 flex flex-wrap items-center gap-2 border-l border-border/50 pl-4">
481
+ <Button
482
+ type="button"
483
+ size="sm"
484
+ variant="outline"
485
+ onClick={() => onApprove(approval.id, "deny")}
486
+ >
487
+ Deny
488
+ </Button>
489
+ <Button
490
+ type="button"
491
+ size="sm"
492
+ variant="outline"
493
+ onClick={() => onApprove(approval.id, "allow_once")}
494
+ >
495
+ Allow once
496
+ </Button>
497
+ <Button
498
+ type="button"
499
+ size="sm"
500
+ onClick={() => onApprove(approval.id, "allow_level")}
501
+ >
502
+ Allow this level
503
+ </Button>
504
+ </div>
505
+ )}
506
+ </div>
507
+ );
508
+ }
509
+
510
+ function CamelSuggestions({
511
+ part,
512
+ onSuggestion,
513
+ }: {
514
+ part: unknown;
515
+ onSuggestion: (text: string) => void;
516
+ }) {
517
+ const data = dataOf(part);
518
+ const items = Array.isArray(data.items)
519
+ ? data.items.filter((item): item is string => typeof item === "string")
520
+ : [];
521
+ return (
522
+ <div
523
+ className="space-y-2 pt-1"
524
+ data-part-type="suggestions"
525
+ >
526
+ <div className="flex items-center gap-2 text-sm text-emerald-600">
527
+ <CheckIcon className="size-4" />
528
+ <span>Task complete</span>
529
+ </div>
530
+ <div className="flex flex-wrap gap-2">
531
+ {items.map((item) => (
532
+ <Button
533
+ key={item}
534
+ type="button"
535
+ variant="outline"
536
+ size="sm"
537
+ className="rounded-full"
538
+ onClick={() => onSuggestion(item)}
539
+ >
540
+ {item}
541
+ </Button>
542
+ ))}
543
+ </div>
544
+ </div>
545
+ );
546
+ }
547
+
548
+ function scheduleDuration(seconds: unknown): string {
549
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
550
+ return "";
551
+ }
552
+ for (const [size, unit] of [
553
+ [86_400, "day"],
554
+ [3_600, "hour"],
555
+ [60, "minute"],
556
+ ] as const) {
557
+ if (seconds % size === 0) {
558
+ const value = seconds / size;
559
+ return `${value} ${unit}${value === 1 ? "" : "s"}`;
560
+ }
561
+ }
562
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
563
+ }
564
+
565
+ function scheduleCadence(data: UnknownRecord): string {
566
+ const trigger = recordOf(data.trigger);
567
+ const kind = trigger.kind;
568
+ let cadence = "";
569
+ if (kind === "once-in") {
570
+ cadence = `In ${scheduleDuration(trigger.seconds)}`;
571
+ } else if (kind === "once-at" && typeof trigger.at === "string") {
572
+ cadence = `Once at ${trigger.at}`;
573
+ } else if (kind === "interval") {
574
+ cadence = `Every ${scheduleDuration(trigger.seconds)}`;
575
+ } else if (kind === "cron" && typeof trigger.cron === "string") {
576
+ cadence = `Cron ${trigger.cron}`;
577
+ }
578
+ if (!cadence) {
579
+ cadence = String(data.cadence ?? data.schedule ?? "Scheduled");
580
+ }
581
+ const timezone =
582
+ typeof data.timezone === "string"
583
+ ? data.timezone
584
+ : typeof data.tz === "string"
585
+ ? data.tz
586
+ : "";
587
+ return timezone ? `${cadence} · ${timezone}` : cadence;
588
+ }
589
+
590
+ function CamelSchedule({
591
+ part,
592
+ isActive,
593
+ }: {
594
+ part: unknown;
595
+ isActive: boolean;
596
+ }) {
597
+ const data = dataOf(part);
598
+ const presentation = getCamelToolPresentation(part, isActive);
599
+ const state = presentation.failed
600
+ ? "failed"
601
+ : presentation.running
602
+ ? "running"
603
+ : "scheduled";
604
+ return (
605
+ <section
606
+ className="rounded-xl border border-border bg-muted/20 p-3"
607
+ data-part-type="schedule"
608
+ data-state={state}
609
+ >
610
+ <div className="flex items-start gap-3">
611
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
612
+ <BellIcon className="size-4" />
613
+ </span>
614
+ <div className="min-w-0 flex-1">
615
+ <strong className="block truncate text-sm text-foreground">
616
+ {String(data.label ?? data.title ?? "Scheduled task")}
617
+ </strong>
618
+ <p className="mt-1 text-xs text-muted-foreground">
619
+ {scheduleCadence(data)}
620
+ </p>
621
+ </div>
622
+ <span
623
+ className={cn(
624
+ "flex shrink-0 items-center gap-1 text-xs",
625
+ state === "failed"
626
+ ? "text-destructive"
627
+ : state === "running"
628
+ ? "text-blue-500"
629
+ : "text-emerald-600",
630
+ )}
631
+ >
632
+ {state === "failed" ? (
633
+ <TriangleAlertIcon className="size-3.5" />
634
+ ) : state === "running" ? (
635
+ <Loader2Icon className="size-3.5 animate-spin motion-reduce:animate-none" />
636
+ ) : (
637
+ <CheckIcon className="size-3.5" />
638
+ )}
639
+ {state === "failed"
640
+ ? "Failed"
641
+ : state === "running"
642
+ ? "Scheduling…"
643
+ : "Scheduled"}
644
+ </span>
645
+ </div>
646
+ </section>
647
+ );
648
+ }
649
+
650
+ function CamelAskUser({
651
+ part,
652
+ submittedAnswer,
653
+ onAnswer,
654
+ }: {
655
+ part: unknown;
656
+ submittedAnswer?: string;
657
+ onAnswer: (payload: unknown) => void;
658
+ }) {
659
+ const record = recordOf(part);
660
+ const data = dataOf(part);
661
+ const output = record.output;
662
+ const questions = Array.isArray(data.questions)
663
+ ? data.questions.map(recordOf)
664
+ : typeof data.question === "string"
665
+ ? [{
666
+ prompt: data.question,
667
+ kind: data.multiSelect === true ? "multi" : "single",
668
+ options: Array.isArray(data.options)
669
+ ? data.options.filter(
670
+ (option): option is string => typeof option === "string",
671
+ )
672
+ : [],
673
+ }]
674
+ : [];
675
+ const [selections, setSelections] = useState<string[][]>(() =>
676
+ questions.map(() => []),
677
+ );
678
+ const [text, setText] = useState("");
679
+ const [submitted, setSubmitted] = useState(false);
680
+ const outputRecord = recordOf(output);
681
+ const externalAnswer =
682
+ submittedAnswer?.trim() ||
683
+ (typeof output === "string"
684
+ ? output.trim()
685
+ : typeof outputRecord.text === "string"
686
+ ? outputRecord.text.trim()
687
+ : "");
688
+ const externalSelections = new Set(
689
+ externalAnswer
690
+ .split("、")
691
+ .map((value) => value.trim())
692
+ .filter(Boolean),
693
+ );
694
+ const resolvedSubmitted = submitted || Boolean(externalAnswer);
695
+ const valid = questions.every((question, index) => {
696
+ const options = Array.isArray(question.options) ? question.options : [];
697
+ return options.length === 0 || (selections[index]?.length ?? 0) > 0;
698
+ });
699
+
700
+ return (
701
+ <section
702
+ className="space-y-3 rounded-xl border border-border bg-muted/20 p-3"
703
+ data-part-type="ask_user"
704
+ data-state={resolvedSubmitted ? "submitted" : "idle"}
705
+ >
706
+ {questions.map((question, questionIndex) => {
707
+ const prompt = String(question.prompt ?? "Choose an option");
708
+ const options = Array.isArray(question.options)
709
+ ? question.options.filter(
710
+ (option): option is string => typeof option === "string",
711
+ )
712
+ : [];
713
+ const multi = question.kind === "multi";
714
+ return (
715
+ <div className="space-y-2" key={`${prompt}:${questionIndex}`}>
716
+ <p className="text-sm text-foreground">{prompt}</p>
717
+ <div className="flex flex-wrap gap-2">
718
+ {options.map((option) => {
719
+ const selected = externalAnswer
720
+ ? externalSelections.has(option)
721
+ : selections[questionIndex]?.includes(option);
722
+ return (
723
+ <Button
724
+ key={option}
725
+ type="button"
726
+ size="sm"
727
+ variant={selected ? "default" : "outline"}
728
+ disabled={resolvedSubmitted}
729
+ onClick={() =>
730
+ setSelections((current) =>
731
+ current.map((values, index) => {
732
+ if (index !== questionIndex) return values;
733
+ if (!multi) return [option];
734
+ return values.includes(option)
735
+ ? values.filter((value) => value !== option)
736
+ : [...values, option];
737
+ }),
738
+ )
739
+ }
740
+ >
741
+ {option}
742
+ </Button>
743
+ );
744
+ })}
745
+ </div>
746
+ </div>
747
+ );
748
+ })}
749
+ {typeof data.freeTextPlaceholder === "string" && (
750
+ <input
751
+ value={text}
752
+ disabled={resolvedSubmitted}
753
+ onChange={(event) => setText(event.currentTarget.value)}
754
+ placeholder={data.freeTextPlaceholder}
755
+ className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
756
+ />
757
+ )}
758
+ <Button
759
+ type="button"
760
+ size="sm"
761
+ disabled={resolvedSubmitted || !valid}
762
+ onClick={() => {
763
+ onAnswer({ selections, text });
764
+ setSubmitted(true);
765
+ }}
766
+ >
767
+ {resolvedSubmitted ? "Submitted" : "Submit"}
768
+ </Button>
769
+ {externalAnswer && (
770
+ <p className="text-xs text-muted-foreground">
771
+ 已回答:{externalAnswer}
772
+ </p>
773
+ )}
774
+ </section>
775
+ );
776
+ }
777
+
778
+ function CamelRichPart({
779
+ part,
780
+ isActive,
781
+ onApprove,
782
+ onSuggestion,
783
+ onAnswer,
784
+ replyText,
785
+ getToolApproval,
786
+ }: {
787
+ part: unknown;
788
+ isActive: boolean;
789
+ onApprove: (approvalId: string, decision: ToolApprovalDecision) => void;
790
+ onSuggestion: (text: string) => void;
791
+ onAnswer: (payload: unknown) => void;
792
+ replyText?: string;
793
+ getToolApproval?: (part: unknown) => ToolApprovalView | undefined;
794
+ }) {
795
+ const kind = normalizedPartType(part);
796
+ const data = dataOf(part);
797
+
798
+ if (kind === "suggestions") {
799
+ return <CamelSuggestions part={part} onSuggestion={onSuggestion} />;
800
+ }
801
+ if (kind === "schedule") {
802
+ return <CamelSchedule part={part} isActive={isActive} />;
803
+ }
804
+ if (kind === "ask_user") {
805
+ return (
806
+ <CamelAskUser
807
+ part={part}
808
+ submittedAnswer={replyText}
809
+ onAnswer={onAnswer}
810
+ />
811
+ );
812
+ }
813
+ if (kind === "parallel") {
814
+ const tools = Array.isArray(data.tools)
815
+ ? (data.tools as ParallelTool[])
816
+ : [];
817
+ return (
818
+ <ParallelPart
819
+ label={
820
+ typeof data.label === "string" ? data.label : "Parallel tasks"
821
+ }
822
+ tools={tools}
823
+ isActive={isActive}
824
+ />
825
+ );
826
+ }
827
+ if (kind === "subagents") {
828
+ const presentation = getCamelToolPresentation(part, isActive);
829
+ const toolName = toolNameOfPart(part);
830
+ const fallbackName =
831
+ toolName === "run_temporary_agent"
832
+ ? "Temporary Agent"
833
+ : toolName === "dispatch_background"
834
+ ? `${String(data.agentType ?? "Background").replace(/^./, (value) => value.toUpperCase())} Agent`
835
+ : toolName === "fanout"
836
+ ? "Fanout Agent"
837
+ : toolName === "extract"
838
+ ? "Extract Agent"
839
+ : "Sub-agent";
840
+ const agents = Array.isArray(data.agents)
841
+ ? (data.agents as SubAgent[])
842
+ : [
843
+ {
844
+ name: String(
845
+ data.subagentName ??
846
+ data.name ??
847
+ fallbackName,
848
+ ),
849
+ summary:
850
+ presentation.errorText ||
851
+ presentation.outputText ||
852
+ String(data.summary ?? ""),
853
+ status: presentation.failed
854
+ ? "failed" as const
855
+ : presentation.running
856
+ ? "running" as const
857
+ : "done" as const,
858
+ },
859
+ ];
860
+ return <SubAgentsPart agents={agents} isActive={isActive} />;
861
+ }
862
+ if (kind === "image") {
863
+ const src = String(data.src ?? data.url ?? "");
864
+ return (
865
+ <figure className="my-1" data-part-type="image">
866
+ <img
867
+ src={src}
868
+ alt={String(data.alt ?? data.filename ?? "Generated image")}
869
+ className="max-h-[28rem] max-w-full rounded-lg border border-border object-contain"
870
+ />
871
+ </figure>
872
+ );
873
+ }
874
+ if (kind === "error") {
875
+ return (
876
+ <div
877
+ className="flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2 text-sm text-destructive"
878
+ data-part-type="error"
879
+ role="alert"
880
+ >
881
+ <TriangleAlertIcon className="mt-0.5 size-4 shrink-0" />
882
+ <div>
883
+ <div className="font-medium">{String(data.title ?? "Error")}</div>
884
+ <div className="mt-0.5 text-xs opacity-80">
885
+ {String(data.body ?? data.message ?? "")}
886
+ </div>
887
+ </div>
888
+ </div>
889
+ );
890
+ }
891
+
892
+ return (
893
+ <CamelToolRow
894
+ part={part}
895
+ isActive={isActive}
896
+ onApprove={onApprove}
897
+ getToolApproval={getToolApproval}
898
+ />
899
+ );
900
+ }
901
+
902
+ function AssistantPart({
903
+ part,
904
+ isActive,
905
+ onApprove,
906
+ onSuggestion,
907
+ onAnswer,
908
+ replyText,
909
+ getToolApproval,
910
+ resolveUrl,
911
+ }: {
912
+ part: unknown;
913
+ isActive: boolean;
914
+ onApprove: (approvalId: string, decision: ToolApprovalDecision) => void;
915
+ onSuggestion: (text: string) => void;
916
+ onAnswer: (payload: unknown) => void;
917
+ replyText?: string;
918
+ getToolApproval?: (part: unknown) => ToolApprovalView | undefined;
919
+ resolveUrl?: MessageUrlResolver;
920
+ }) {
921
+ const record = recordOf(part);
922
+ if (record.type === "step-start") return null;
923
+ if (record.type === "text") {
924
+ const text = String(record.text ?? "").trim();
925
+ if (!text) return null;
926
+ return (
927
+ <div data-part-type="text">
928
+ <MessageResponse streaming={isActive} resolveUrl={resolveUrl}>
929
+ {text}
930
+ </MessageResponse>
931
+ </div>
932
+ );
933
+ }
934
+ if (record.type === "reasoning") {
935
+ return <CamelThinking part={part} isActive={isActive} />;
936
+ }
937
+ return (
938
+ <CamelRichPart
939
+ part={part}
940
+ isActive={isActive}
941
+ onApprove={onApprove}
942
+ onSuggestion={onSuggestion}
943
+ onAnswer={onAnswer}
944
+ replyText={replyText}
945
+ getToolApproval={getToolApproval}
946
+ />
947
+ );
948
+ }
949
+
950
+ function ForkAction({
951
+ messageId,
952
+ onFork,
953
+ }: {
954
+ messageId: string;
955
+ onFork: (messageId: string) => void | Promise<void>;
956
+ }) {
957
+ const [forking, setForking] = useState(false);
958
+
959
+ return (
960
+ <Button
961
+ type="button"
962
+ variant="ghost"
963
+ size="icon-sm"
964
+ className="size-8 text-muted-foreground"
965
+ disabled={forking}
966
+ onClick={() => {
967
+ setForking(true);
968
+ void Promise.resolve(onFork(messageId)).finally(() => setForking(false));
969
+ }}
970
+ aria-label={forking ? "Starting new thread…" : "New thread from here"}
971
+ title={forking ? "Starting new thread…" : "New thread from here"}
972
+ >
973
+ {forking ? (
974
+ <Loader2Icon className="size-4 animate-spin" />
975
+ ) : (
976
+ <GitForkIcon className="size-4" />
977
+ )}
978
+ </Button>
979
+ );
980
+ }
981
+
982
+ function UserMessage({ message }: { message: UIMessage }) {
983
+ const text = camelMessageText(message);
984
+ const files = message.parts.filter((part) => part.type === "file");
985
+ const metadata = camelMetadata(message);
986
+ const createdAt = camelCreatedAt(message);
987
+ const messageTime =
988
+ createdAt === undefined ? null : formatCamelMessageTime(createdAt);
989
+ const authorStamp = [metadata.authorDisplayName, messageTime]
990
+ .filter(Boolean)
991
+ .join(" ");
992
+
993
+ if (!text && files.length === 0) return null;
994
+
995
+ return (
996
+ <div
997
+ className="group mb-1 mt-6"
998
+ data-message-id={message.id}
999
+ data-message-role="user"
1000
+ >
1001
+ <div className="flex flex-col items-end gap-2">
1002
+ {files.length > 0 && (
1003
+ <div className="flex max-w-[85%] flex-wrap justify-end gap-2">
1004
+ {files.map((file, index) =>
1005
+ file.mediaType?.startsWith("image/") ? (
1006
+ <img
1007
+ key={`${file.url}:${index}`}
1008
+ src={file.url}
1009
+ alt={file.filename ?? "Attachment"}
1010
+ className="max-h-52 max-w-64 rounded-xl border border-border object-cover"
1011
+ />
1012
+ ) : (
1013
+ <span
1014
+ key={`${file.url}:${index}`}
1015
+ className="rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
1016
+ >
1017
+ {file.filename ?? "Attachment"}
1018
+ </span>
1019
+ ),
1020
+ )}
1021
+ </div>
1022
+ )}
1023
+ {text && (
1024
+ <div className="camel-user-message max-w-[85%] rounded-3xl border border-border bg-muted/30 px-4 py-3 text-foreground">
1025
+ <MessageResponse>{text}</MessageResponse>
1026
+ </div>
1027
+ )}
1028
+ {text && (
1029
+ <div
1030
+ className="flex items-center justify-end gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
1031
+ role="group"
1032
+ aria-label="Message actions"
1033
+ >
1034
+ {authorStamp && (
1035
+ <span className="mr-1 text-xs text-muted-foreground">
1036
+ {authorStamp}
1037
+ </span>
1038
+ )}
1039
+ <ClipboardAction text={text} />
1040
+ </div>
1041
+ )}
1042
+ </div>
1043
+ </div>
1044
+ );
1045
+ }
1046
+
1047
+ function standaloneText(message: UIMessage): string {
1048
+ const part = message.parts.find((candidate) =>
1049
+ candidate.type.startsWith("data-"),
1050
+ );
1051
+ const data = dataOf(part);
1052
+ return String(data.text ?? data.command ?? data.output ?? "").trim();
1053
+ }
1054
+
1055
+ function CompactSummary({ message }: { message: UIMessage }) {
1056
+ const text = standaloneText(message);
1057
+ if (!text) return null;
1058
+ return (
1059
+ <details
1060
+ className="my-3 rounded-xl border border-border bg-muted/20 px-3 py-2 text-sm"
1061
+ data-camel-compact-summary=""
1062
+ data-message-id={message.id}
1063
+ >
1064
+ <summary className="cursor-pointer text-xs font-medium text-muted-foreground">
1065
+ Compacted context
1066
+ </summary>
1067
+ <div className="mt-3 border-t border-border/40 pt-3">
1068
+ <MessageResponse>{text}</MessageResponse>
1069
+ </div>
1070
+ </details>
1071
+ );
1072
+ }
1073
+
1074
+ function StandaloneMessage({ item }: { item: CamelStandaloneMessage }) {
1075
+ const { message, displayKind } = item;
1076
+ if (displayKind === "user") return <UserMessage message={message} />;
1077
+ if (displayKind === "compact-summary") {
1078
+ return <CompactSummary message={message} />;
1079
+ }
1080
+ const text = standaloneText(message);
1081
+ if (!text) return null;
1082
+ if (displayKind === "slash-command") {
1083
+ return (
1084
+ <div
1085
+ className="mt-4 flex justify-end"
1086
+ data-message-id={message.id}
1087
+ data-message-kind="slash-command"
1088
+ >
1089
+ <span className="font-mono text-sm text-foreground">{text}</span>
1090
+ </div>
1091
+ );
1092
+ }
1093
+ if (displayKind === "local-command-output") {
1094
+ return (
1095
+ <div
1096
+ className="mt-4 flex justify-start"
1097
+ data-message-id={message.id}
1098
+ data-message-kind="local-command-output"
1099
+ >
1100
+ <span className="text-sm italic text-muted-foreground">{text}</span>
1101
+ </div>
1102
+ );
1103
+ }
1104
+ return (
1105
+ <div
1106
+ className="my-3 text-sm italic text-muted-foreground"
1107
+ data-message-id={message.id}
1108
+ data-message-kind="notice"
1109
+ >
1110
+ {text}
1111
+ </div>
1112
+ );
1113
+ }
1114
+
1115
+ function RenderAssistantPart({
1116
+ partRef,
1117
+ isActive,
1118
+ onApprove,
1119
+ onSuggestion,
1120
+ onAnswer,
1121
+ replyText,
1122
+ getToolApproval,
1123
+ resolveUrl,
1124
+ }: {
1125
+ partRef: CamelPartRef;
1126
+ isActive: boolean;
1127
+ onApprove: (approvalId: string, decision: ToolApprovalDecision) => void;
1128
+ onSuggestion: (text: string) => void;
1129
+ onAnswer: (payload: unknown) => void;
1130
+ replyText?: string;
1131
+ getToolApproval?: (part: unknown) => ToolApprovalView | undefined;
1132
+ resolveUrl?: MessageUrlResolver;
1133
+ }) {
1134
+ return (
1135
+ <AssistantPart
1136
+ key={`${partRef.messageId}:${partRef.partIndex}:${String(
1137
+ recordOf(partRef.part).type ?? "part",
1138
+ )}`}
1139
+ part={partRef.part}
1140
+ isActive={isActive}
1141
+ onApprove={onApprove}
1142
+ onSuggestion={onSuggestion}
1143
+ onAnswer={onAnswer}
1144
+ replyText={replyText}
1145
+ getToolApproval={getToolApproval}
1146
+ resolveUrl={resolveUrl}
1147
+ />
1148
+ );
1149
+ }
1150
+
1151
+ function AssistantTurn({
1152
+ turn,
1153
+ isActive,
1154
+ hasPendingSteer,
1155
+ hasPendingApproval,
1156
+ onFork,
1157
+ onApprove,
1158
+ onSuggestion,
1159
+ onAnswer,
1160
+ getToolApproval,
1161
+ resolveUrl,
1162
+ }: {
1163
+ turn: CamelAssistantTurn;
1164
+ isActive: boolean;
1165
+ hasPendingSteer: boolean;
1166
+ hasPendingApproval: boolean;
1167
+ onFork?: (messageId: string) => void | Promise<void>;
1168
+ onApprove: (approvalId: string, decision: ToolApprovalDecision) => void;
1169
+ onSuggestion: (text: string) => void;
1170
+ onAnswer: (payload: unknown) => void;
1171
+ getToolApproval?: (part: unknown) => ToolApprovalView | undefined;
1172
+ resolveUrl?: MessageUrlResolver;
1173
+ }) {
1174
+ const renderPart = (partRef: CamelPartRef, active = false) => (
1175
+ <RenderAssistantPart
1176
+ key={`${partRef.messageId}:${partRef.partIndex}`}
1177
+ partRef={partRef}
1178
+ isActive={active}
1179
+ onApprove={onApprove}
1180
+ onSuggestion={onSuggestion}
1181
+ onAnswer={onAnswer}
1182
+ replyText={turn.replyText}
1183
+ getToolApproval={getToolApproval}
1184
+ resolveUrl={resolveUrl}
1185
+ />
1186
+ );
1187
+ const showWorkInPlace =
1188
+ isActive || hasPendingApproval || turn.terminalState !== undefined;
1189
+ const showSummary = !showWorkInPlace && turn.traceParts.length > 0;
1190
+ const completedAt = turn.completedAt ?? camelCompletedAt(turn.message);
1191
+ const messageTime =
1192
+ completedAt === undefined ? null : formatCamelMessageTime(completedAt);
1193
+ const hasActions =
1194
+ !showWorkInPlace && Boolean(messageTime || turn.copyText || onFork);
1195
+ const thinkingActivity = deriveThinkingActivity(
1196
+ turn.traceParts.at(-1)?.part ?? turn.finalParts.at(-1)?.part,
1197
+ );
1198
+ const hasRunningTool = turn.traceParts.some(
1199
+ ({ part }) =>
1200
+ toolNameOfPart(part) !== null &&
1201
+ getCamelToolPresentation(part, isActive).running,
1202
+ );
1203
+ const activeActivity = hasPendingSteer
1204
+ ? pendingSteerActivity
1205
+ : thinkingActivity;
1206
+
1207
+ return (
1208
+ <div
1209
+ className="group/turn"
1210
+ data-message-id={turn.actionMessageId}
1211
+ data-message-role="assistant"
1212
+ data-turn-step-count={turn.stepCount}
1213
+ translate={showWorkInPlace ? "no" : undefined}
1214
+ >
1215
+ <div className="flex flex-col gap-1">
1216
+ {showSummary && (
1217
+ <TurnSummaryBar
1218
+ durationMs={turn.durationMs}
1219
+ stepCount={turn.stepCount}
1220
+ showSeparator={
1221
+ turn.finalParts.length > 0 || turn.terminalState !== undefined
1222
+ }
1223
+ >
1224
+ {turn.traceParts.map((partRef) => renderPart(partRef))}
1225
+ </TurnSummaryBar>
1226
+ )}
1227
+ {showWorkInPlace && turn.traceParts.length > 0 && (
1228
+ <div className="max-w-none space-y-1" data-camel-turn-trace="">
1229
+ {turn.traceParts.map((partRef) => renderPart(partRef, isActive))}
1230
+ </div>
1231
+ )}
1232
+ {(turn.finalParts.length > 0 ||
1233
+ turn.terminalState?.kind === "error") && (
1234
+ <div className="max-w-none space-y-4" data-camel-final-answer="">
1235
+ {turn.finalParts.map((partRef) => renderPart(partRef, isActive))}
1236
+ {turn.terminalState?.kind === "error" && (
1237
+ <div
1238
+ className="flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2 text-sm text-destructive"
1239
+ data-part-type="error"
1240
+ role="alert"
1241
+ >
1242
+ <TriangleAlertIcon className="mt-0.5 size-4 shrink-0" />
1243
+ <div>
1244
+ <div className="font-medium">
1245
+ {turn.terminalState.title}
1246
+ </div>
1247
+ {turn.terminalState.body && (
1248
+ <div className="mt-0.5 text-xs opacity-80">
1249
+ {turn.terminalState.body}
1250
+ </div>
1251
+ )}
1252
+ </div>
1253
+ </div>
1254
+ )}
1255
+ </div>
1256
+ )}
1257
+ {turn.terminalState?.kind === "interrupted" && (
1258
+ <div
1259
+ className="px-1 py-1 text-xs text-muted-foreground/60"
1260
+ data-camel-turn-status="interrupted"
1261
+ role="status"
1262
+ >
1263
+ {turn.terminalState.title}
1264
+ </div>
1265
+ )}
1266
+ {hasActions && (
1267
+ <div
1268
+ className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/turn:opacity-100 group-focus-within/turn:opacity-100 pointer-coarse:opacity-100"
1269
+ role="group"
1270
+ aria-label="Message actions"
1271
+ >
1272
+ {messageTime && (
1273
+ <span className="mr-1 text-xs text-muted-foreground">
1274
+ {messageTime}
1275
+ </span>
1276
+ )}
1277
+ {onFork && (
1278
+ <ForkAction messageId={turn.actionMessageId} onFork={onFork} />
1279
+ )}
1280
+ {turn.copyText && <ClipboardAction text={turn.copyText} />}
1281
+ </div>
1282
+ )}
1283
+ {isActive && turn.finalParts.length === 0 && !hasRunningTool && (
1284
+ <ThinkingIndicator {...activeActivity} />
1285
+ )}
1286
+ </div>
1287
+ </div>
1288
+ );
1289
+ }
1290
+
1291
+ export function CamelChatMessages({
1292
+ messages,
1293
+ status,
1294
+ finalToolNames,
1295
+ isHydrating = false,
1296
+ isRecovering = false,
1297
+ isServerStreaming = false,
1298
+ showActivityStatus = false,
1299
+ activityStatusLabel,
1300
+ hasPendingSteer = false,
1301
+ startedWithPending = false,
1302
+ error,
1303
+ onRetry,
1304
+ onApprove,
1305
+ onSuggestion,
1306
+ onAnswer,
1307
+ onFork,
1308
+ getToolApproval,
1309
+ resolveUrl,
1310
+ inlineApprovalActions = true,
1311
+ }: CamelChatMessagesProps) {
1312
+ const scrollRef = useRef<HTMLDivElement>(null);
1313
+ const endRef = useRef<HTMLDivElement>(null);
1314
+ const [showScrollButton, setShowScrollButton] = useState(false);
1315
+ const isActive = status === "submitted" || status === "streaming";
1316
+ const items = useMemo(
1317
+ () => projectCamelMessages(messages, finalToolNames),
1318
+ [messages, finalToolNames],
1319
+ );
1320
+ const presentedToolApproval = (part: unknown) => {
1321
+ const approval = getToolApproval?.(part) ?? toolApprovalOf(part);
1322
+ return !inlineApprovalActions && approval?.approved === undefined && approval
1323
+ ? { ...approval, approved: false }
1324
+ : approval;
1325
+ };
1326
+ const lastItem = items.at(-1);
1327
+ const activeAssistantMessageId =
1328
+ isActive && lastItem?.kind === "assistant"
1329
+ ? lastItem.actionMessageId
1330
+ : null;
1331
+
1332
+ const syncScrollButton = useCallback(() => {
1333
+ const element = scrollRef.current;
1334
+ if (!element) return;
1335
+ const distance =
1336
+ element.scrollHeight - element.scrollTop - element.clientHeight;
1337
+ setShowScrollButton(distance > 120);
1338
+ }, []);
1339
+
1340
+ useEffect(() => {
1341
+ const element = scrollRef.current;
1342
+ if (!element || showScrollButton) return;
1343
+ element.scrollTo({ top: element.scrollHeight });
1344
+ }, [messages, status, showScrollButton]);
1345
+
1346
+ return (
1347
+ <div
1348
+ className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
1349
+ data-sb-message-panel=""
1350
+ data-camel-chat-transcript=""
1351
+ data-status={status}
1352
+ aria-busy={isActive || isHydrating}
1353
+ >
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
+ <div
1364
+ ref={scrollRef}
1365
+ onScroll={syncScrollButton}
1366
+ tabIndex={0}
1367
+ role="region"
1368
+ aria-label="Chat messages"
1369
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden"
1370
+ >
1371
+ <div className="mx-auto flex w-full max-w-3xl flex-col px-4 pb-6 pt-2 md:px-6">
1372
+ {messages.length === 0 && !startedWithPending && !isHydrating ? (
1373
+ <div className="flex min-h-56 items-center justify-center text-center font-[family-name:var(--font-display)] text-4xl text-foreground">
1374
+ How can I help?
1375
+ </div>
1376
+ ) : null}
1377
+ {items.map((item) =>
1378
+ item.kind === "assistant" ? (
1379
+ <AssistantTurn
1380
+ key={item.key}
1381
+ turn={item}
1382
+ isActive={item.actionMessageId === activeAssistantMessageId}
1383
+ hasPendingSteer={hasPendingSteer}
1384
+ hasPendingApproval={item.parts.some((partRef) => {
1385
+ const approval =
1386
+ getToolApproval?.(partRef.part) ??
1387
+ toolApprovalOf(partRef.part);
1388
+ return approval != null && approval.approved === undefined;
1389
+ })}
1390
+ onFork={onFork}
1391
+ onApprove={onApprove}
1392
+ onSuggestion={onSuggestion}
1393
+ onAnswer={onAnswer}
1394
+ getToolApproval={presentedToolApproval}
1395
+ resolveUrl={resolveUrl}
1396
+ />
1397
+ ) : (
1398
+ <StandaloneMessage key={item.key} item={item} />
1399
+ ),
1400
+ )}
1401
+ {error != null && <CamelChatError error={error} onRetry={onRetry} />}
1402
+ {isActive && activeAssistantMessageId === null && (
1403
+ <ThinkingIndicator
1404
+ {...(hasPendingSteer ? pendingSteerActivity : {})}
1405
+ />
1406
+ )}
1407
+ <div ref={endRef} />
1408
+ </div>
1409
+ </div>
1410
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background to-transparent" />
1411
+ <Button
1412
+ type="button"
1413
+ variant="outline"
1414
+ size="icon"
1415
+ className={cn(
1416
+ "absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-full border-border/50 bg-background/80 shadow-md backdrop-blur-sm transition-all duration-200",
1417
+ showScrollButton
1418
+ ? "translate-y-0 opacity-100"
1419
+ : "pointer-events-none translate-y-2 opacity-0",
1420
+ )}
1421
+ onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
1422
+ aria-label="Scroll to bottom"
1423
+ >
1424
+ <ArrowDownIcon className="size-4" />
1425
+ </Button>
1426
+ {isHydrating && (
1427
+ <div className="absolute inset-0 z-30 grid place-items-center bg-background/90">
1428
+ <span className="text-sm text-muted-foreground">
1429
+ Loading conversation…
1430
+ </span>
1431
+ </div>
1432
+ )}
1433
+ </div>
1434
+ );
1435
+ }