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