@vvedantb/ui 0.17.1 → 0.18.0

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 (129) hide show
  1. package/README.md +251 -3
  2. package/package.json +13 -5
  3. package/shell/src/SidebarResize.tsx +1 -4
  4. package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
  5. package/src/ai-elements/activity-question-utils.ts +26 -0
  6. package/src/ai-elements/activity-question.tsx +159 -0
  7. package/src/ai-elements/activity-shared.tsx +459 -0
  8. package/src/ai-elements/activity-step-detail.tsx +115 -0
  9. package/src/ai-elements/activity-step-label.ts +981 -0
  10. package/src/ai-elements/activity-tasks-utils.ts +199 -0
  11. package/src/ai-elements/activity-tasks.tsx +770 -0
  12. package/src/ai-elements/artifact.tsx +162 -52
  13. package/src/ai-elements/attachments.tsx +62 -32
  14. package/src/ai-elements/code-block.tsx +76 -22
  15. package/src/ai-elements/context.tsx +370 -0
  16. package/src/ai-elements/conversation.tsx +186 -62
  17. package/src/ai-elements/file-chip.tsx +53 -0
  18. package/src/ai-elements/message.tsx +161 -50
  19. package/src/ai-elements/model-picker-content.tsx +505 -0
  20. package/src/ai-elements/model-picker-types.ts +56 -0
  21. package/src/ai-elements/model-picker.tsx +301 -0
  22. package/src/ai-elements/model-selector.tsx +102 -21
  23. package/src/ai-elements/plan.tsx +130 -0
  24. package/src/ai-elements/prompt-input-settings.tsx +144 -0
  25. package/src/ai-elements/prompt-input-speech.tsx +189 -0
  26. package/src/ai-elements/prompt-input-traits.tsx +281 -0
  27. package/src/ai-elements/prompt-input.tsx +320 -134
  28. package/src/ai-elements/provider-icon.tsx +133 -0
  29. package/src/ai-elements/queue.tsx +293 -9
  30. package/src/ai-elements/shimmer.tsx +72 -1
  31. package/src/ai-elements/simple-model-ladder.tsx +108 -0
  32. package/src/ai-elements/test-results.tsx +510 -0
  33. package/src/ai-elements/trace.tsx +243 -0
  34. package/src/ai-elements/use-streaming-disclosure.ts +50 -0
  35. package/src/ai-elements/web-preview.tsx +349 -0
  36. package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
  37. package/src/index.ts +66 -12
  38. package/src/kibo/avatar-stack.tsx +40 -0
  39. package/src/kibo/contribution-graph.tsx +539 -0
  40. package/src/kibo/cursor.tsx +72 -0
  41. package/src/kibo/gantt-features.tsx +439 -0
  42. package/src/kibo/gantt-header.tsx +181 -0
  43. package/src/kibo/gantt-provider.tsx +657 -0
  44. package/src/kibo/gantt-sidebar.tsx +157 -0
  45. package/src/kibo/gantt-timeline.tsx +154 -0
  46. package/src/kibo/index.ts +151 -0
  47. package/src/kibo/kanban.tsx +338 -0
  48. package/src/kibo/kanbanDropTarget.ts +25 -0
  49. package/src/kibo/list.tsx +162 -0
  50. package/src/kibo/theme-switcher.tsx +109 -0
  51. package/src/kibo/video-player.tsx +237 -0
  52. package/src/markdown/Markdown.tsx +112 -0
  53. package/src/markdown/MarkdownBlockquote.tsx +45 -0
  54. package/src/markdown/MarkdownCodeBlock.tsx +197 -0
  55. package/src/markdown/MarkdownInlineCode.tsx +40 -0
  56. package/src/markdown/MarkdownLink.tsx +92 -0
  57. package/src/markdown/codeFence.ts +57 -0
  58. package/src/markdown/codeHighlight.ts +138 -0
  59. package/src/markdown/incrementalBlocks.ts +182 -0
  60. package/src/markdown/index.ts +3 -0
  61. package/src/markdown/markdownClipboard.ts +178 -0
  62. package/src/markdown/mermaid.ts +155 -0
  63. package/src/markdown/rehypeGithubAlerts.ts +80 -0
  64. package/src/motion/presets.ts +24 -0
  65. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
  66. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
  67. package/src/ui/_menu-parts.tsx +29 -0
  68. package/src/ui/accordion.alt.tsx +46 -53
  69. package/src/ui/accordion.tsx +59 -10
  70. package/src/ui/avatar.tsx +56 -0
  71. package/src/ui/badge.tsx +45 -21
  72. package/src/ui/border-beam.tsx +97 -0
  73. package/src/ui/button-group.tsx +50 -21
  74. package/src/ui/button.tsx +67 -45
  75. package/src/ui/calendar.tsx +48 -5
  76. package/src/ui/card.tsx +77 -8
  77. package/src/ui/carousel.tsx +60 -0
  78. package/src/ui/centered-spinner.tsx +30 -0
  79. package/src/ui/checkbox.tsx +34 -16
  80. package/src/ui/clear-input.alt.tsx +33 -75
  81. package/src/ui/clear-input.tsx +140 -15
  82. package/src/ui/collapsible.tsx +40 -2
  83. package/src/ui/command.tsx +143 -70
  84. package/src/ui/context-menu.tsx +159 -93
  85. package/src/ui/crossfade-icon.tsx +123 -0
  86. package/src/ui/dialog.tsx +154 -70
  87. package/src/ui/dropdown-menu.tsx +177 -117
  88. package/src/ui/hover-card.tsx +59 -17
  89. package/src/ui/input-group.alt.tsx +83 -83
  90. package/src/ui/input-group.tsx +129 -58
  91. package/src/ui/input.tsx +13 -4
  92. package/src/ui/label.tsx +11 -8
  93. package/src/ui/list-row.tsx +232 -0
  94. package/src/ui/loading-state.tsx +215 -0
  95. package/src/ui/popover.tsx +56 -23
  96. package/src/ui/progress.tsx +28 -16
  97. package/src/ui/refresh-spin-icon.tsx +41 -0
  98. package/src/ui/search-input.tsx +66 -15
  99. package/src/ui/select.tsx +171 -100
  100. package/src/ui/separator.tsx +23 -14
  101. package/src/ui/slider.tsx +21 -7
  102. package/src/ui/sonner.tsx +68 -30
  103. package/src/ui/spinner.tsx +229 -11
  104. package/src/ui/status-dot.tsx +84 -0
  105. package/src/ui/surface.tsx +49 -0
  106. package/src/ui/switch.tsx +29 -15
  107. package/src/ui/table.tsx +38 -16
  108. package/src/ui/tabs.tsx +123 -35
  109. package/src/ui/tabsSliding.ts +33 -1
  110. package/src/ui/textarea.tsx +13 -4
  111. package/src/ui/tooltip.tsx +50 -14
  112. package/src/ui/ui-config.tsx +149 -0
  113. package/src/utils/cat-color.ts +123 -0
  114. package/src/utils/dayjs.ts +6 -0
  115. package/src/utils/gesture.ts +59 -0
  116. package/src/utils/runtimeVisibility.ts +167 -0
  117. package/src/utils/sharedClock.ts +96 -0
  118. package/src/utils/surface-radius.ts +16 -0
  119. package/src/utils/useDragSensors.ts +58 -0
  120. package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
  121. package/tokens/examples/tokens-moved/README.md +20 -0
  122. package/tokens/examples/tokens-moved/index.html +12 -0
  123. package/tokens/examples/tokens-moved/main.tsx +6 -0
  124. package/tokens/examples/tokens-moved/package.json +14 -0
  125. package/tokens/examples/tokens-moved/styles.css +12 -0
  126. package/tokens/examples/tokens-moved/tsconfig.json +14 -0
  127. package/tokens/examples/tokens-moved/vite.config.ts +11 -0
  128. package/tokens/src/css/border-beam.css +116 -0
  129. package/tokens/src/css/markdown.css +318 -0
@@ -0,0 +1,370 @@
1
+ "use client";
2
+
3
+ import type { ComponentProps } from "react";
4
+ import { createContext, useContext, useMemo } from "react";
5
+
6
+ import { Button } from "../ui/button";
7
+ import {
8
+ HoverCard,
9
+ HoverCardContent,
10
+ HoverCardTrigger,
11
+ } from "../ui/hover-card";
12
+ import { Progress } from "../ui/progress";
13
+ import { useCn } from "../ui/ui-config";
14
+
15
+ const PERCENT_MAX = 100;
16
+ const ICON_RADIUS = 10;
17
+ const ICON_VIEWBOX = 24;
18
+ const ICON_CENTER = 12;
19
+ const ICON_STROKE_WIDTH = 2;
20
+
21
+ /**
22
+ * Per-category token counts. Cache reads and writes are priced very differently
23
+ * (reads ~0.1x input, writes ~1.25x input), so they are tracked separately.
24
+ */
25
+ interface LanguageModelUsage {
26
+ inputTokens?: number;
27
+ outputTokens?: number;
28
+ cachedInputReadTokens?: number;
29
+ cachedInputWriteTokens?: number;
30
+ }
31
+
32
+ /**
33
+ * Pre-computed costs (USD). Pass these when the authoritative cost is known
34
+ * (e.g. Claude returns `total_cost_usd` directly). Avoids needing a pricing
35
+ * catalog at render time.
36
+ */
37
+ interface ContextCosts {
38
+ totalUSD?: number;
39
+ inputUSD?: number;
40
+ outputUSD?: number;
41
+ cacheReadUSD?: number;
42
+ cacheWriteUSD?: number;
43
+ }
44
+
45
+ interface ContextSchema {
46
+ usedTokens: number;
47
+ maxTokens: number;
48
+ usage?: LanguageModelUsage;
49
+ costs?: ContextCosts;
50
+ }
51
+
52
+ const ContextContext = createContext<ContextSchema | null>(null);
53
+
54
+ const useContextValue = () => {
55
+ const context = useContext(ContextContext);
56
+
57
+ if (!context) {
58
+ throw new Error("ContextUsage components must be used within ContextUsage");
59
+ }
60
+
61
+ return context;
62
+ };
63
+
64
+ export type ContextUsageProps = ComponentProps<typeof HoverCard> & ContextSchema;
65
+
66
+ export const ContextUsage = ({
67
+ usedTokens,
68
+ maxTokens,
69
+ usage,
70
+ costs,
71
+ ...props
72
+ }: ContextUsageProps) => {
73
+ const contextValue = useMemo(
74
+ () => ({ costs, maxTokens, usage, usedTokens }),
75
+ [costs, maxTokens, usage, usedTokens],
76
+ );
77
+
78
+ return (
79
+ <ContextContext.Provider value={contextValue}>
80
+ <HoverCard closeDelay={0} openDelay={0} {...props} />
81
+ </ContextContext.Provider>
82
+ );
83
+ };
84
+
85
+ const ContextIcon = () => {
86
+ const { usedTokens, maxTokens } = useContextValue();
87
+ const circumference = 2 * Math.PI * ICON_RADIUS;
88
+ const usedPercent = maxTokens > 0 ? usedTokens / maxTokens : 0;
89
+ const dashOffset = circumference * (1 - usedPercent);
90
+
91
+ return (
92
+ <svg
93
+ aria-label="Model context usage"
94
+ height="16"
95
+ role="img"
96
+ style={{ color: "currentcolor" }}
97
+ viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
98
+ width="16"
99
+ >
100
+ <circle
101
+ cx={ICON_CENTER}
102
+ cy={ICON_CENTER}
103
+ fill="none"
104
+ opacity="0.25"
105
+ r={ICON_RADIUS}
106
+ stroke="currentColor"
107
+ strokeWidth={ICON_STROKE_WIDTH}
108
+ />
109
+ <circle
110
+ cx={ICON_CENTER}
111
+ cy={ICON_CENTER}
112
+ fill="none"
113
+ opacity="0.7"
114
+ r={ICON_RADIUS}
115
+ stroke="currentColor"
116
+ strokeDasharray={`${circumference} ${circumference}`}
117
+ strokeDashoffset={dashOffset}
118
+ strokeLinecap="round"
119
+ strokeWidth={ICON_STROKE_WIDTH}
120
+ style={{ transform: "rotate(-90deg)", transformOrigin: "center" }}
121
+ />
122
+ </svg>
123
+ );
124
+ };
125
+
126
+ export type ContextUsageTriggerProps = ComponentProps<typeof Button>;
127
+
128
+ export const ContextUsageTrigger = ({ children, ...props }: ContextUsageTriggerProps) => {
129
+ const { usedTokens, maxTokens } = useContextValue();
130
+ const usedPercent = maxTokens > 0 ? usedTokens / maxTokens : 0;
131
+ const renderedPercent = new Intl.NumberFormat("en-US", {
132
+ maximumFractionDigits: 1,
133
+ style: "percent",
134
+ }).format(usedPercent);
135
+
136
+ return (
137
+ <HoverCardTrigger asChild>
138
+ {children ?? (
139
+ <Button type="button" variant="ghost" size="sm" {...props}>
140
+ <span className="font-medium text-muted-foreground text-xs tabular-nums">
141
+ {renderedPercent}
142
+ </span>
143
+ <ContextIcon />
144
+ </Button>
145
+ )}
146
+ </HoverCardTrigger>
147
+ );
148
+ };
149
+
150
+ export type ContextUsageContentProps = ComponentProps<typeof HoverCardContent>;
151
+
152
+ export const ContextUsageContent = ({
153
+ className,
154
+ ...props
155
+ }: ContextUsageContentProps) => {
156
+ const merge = useCn();
157
+ return (
158
+ <HoverCardContent
159
+ className={merge("min-w-60 divide-y overflow-hidden p-0", className)}
160
+ {...props}
161
+ />
162
+ );
163
+ };
164
+
165
+ export type ContextUsageContentHeaderProps = ComponentProps<"div">;
166
+
167
+ export const ContextUsageContentHeader = ({
168
+ children,
169
+ className,
170
+ ...props
171
+ }: ContextUsageContentHeaderProps) => {
172
+ const merge = useCn();
173
+ const { usedTokens, maxTokens } = useContextValue();
174
+ const usedPercent = maxTokens > 0 ? usedTokens / maxTokens : 0;
175
+ const displayPct = new Intl.NumberFormat("en-US", {
176
+ maximumFractionDigits: 1,
177
+ style: "percent",
178
+ }).format(usedPercent);
179
+ const used = new Intl.NumberFormat("en-US", {
180
+ notation: "compact",
181
+ }).format(usedTokens);
182
+ const total = new Intl.NumberFormat("en-US", {
183
+ notation: "compact",
184
+ }).format(maxTokens);
185
+
186
+ return (
187
+ <div className={merge("w-full space-y-2 p-3", className)} {...props}>
188
+ {children ?? (
189
+ <>
190
+ <div className="flex items-center justify-between gap-3 text-xs tabular-nums">
191
+ <p>{displayPct}</p>
192
+ <p className="font-mono text-muted-foreground">
193
+ {used} / {total}
194
+ </p>
195
+ </div>
196
+ <div className="space-y-2">
197
+ <Progress className="bg-surface-secondary" value={usedPercent * PERCENT_MAX} />
198
+ </div>
199
+ </>
200
+ )}
201
+ </div>
202
+ );
203
+ };
204
+
205
+ export type ContextUsageContentBodyProps = ComponentProps<"div">;
206
+
207
+ export const ContextUsageContentBody = ({
208
+ children,
209
+ className,
210
+ ...props
211
+ }: ContextUsageContentBodyProps) => {
212
+ const merge = useCn();
213
+ return (
214
+ <div className={merge("w-full p-3", className)} {...props}>
215
+ {children}
216
+ </div>
217
+ );
218
+ };
219
+
220
+ const formatUSD = (value: number) =>
221
+ new Intl.NumberFormat("en-US", {
222
+ currency: "USD",
223
+ // Claude's per-call cost is often sub-cent; show enough precision to be useful.
224
+ maximumFractionDigits: 4,
225
+ minimumFractionDigits: 2,
226
+ style: "currency",
227
+ }).format(value);
228
+
229
+ const formatTokens = (value: number) =>
230
+ new Intl.NumberFormat("en-US", { notation: "compact" }).format(value);
231
+
232
+ export type ContextUsageContentFooterProps = ComponentProps<"div">;
233
+
234
+ export const ContextUsageContentFooter = ({
235
+ children,
236
+ className,
237
+ ...props
238
+ }: ContextUsageContentFooterProps) => {
239
+ const merge = useCn();
240
+ const { costs } = useContextValue();
241
+ const totalCost = costs?.totalUSD;
242
+
243
+ return (
244
+ <div
245
+ className={merge(
246
+ "flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
247
+ className,
248
+ )}
249
+ {...props}
250
+ >
251
+ {children ?? (
252
+ <>
253
+ <span className="text-muted-foreground">Total cost</span>
254
+ <span className="font-mono tabular-nums">
255
+ {totalCost === undefined ? "—" : formatUSD(totalCost)}
256
+ </span>
257
+ </>
258
+ )}
259
+ </div>
260
+ );
261
+ };
262
+
263
+ const UsageRow = ({
264
+ label,
265
+ tokens,
266
+ costUSD,
267
+ className,
268
+ ...rest
269
+ }: {
270
+ label: string;
271
+ tokens: number;
272
+ costUSD?: number;
273
+ } & ComponentProps<"div">) => {
274
+ const merge = useCn();
275
+ if (!tokens) return null;
276
+ return (
277
+ <div
278
+ className={merge("flex items-center justify-between text-xs", className)}
279
+ {...rest}
280
+ >
281
+ <span className="text-muted-foreground">{label}</span>
282
+ <span className="tabular-nums">
283
+ {formatTokens(tokens)}
284
+ {costUSD !== undefined ? (
285
+ <span className="ml-2 font-mono text-muted-foreground">
286
+ {formatUSD(costUSD)}
287
+ </span>
288
+ ) : null}
289
+ </span>
290
+ </div>
291
+ );
292
+ };
293
+
294
+ export type ContextUsageInputProps = ComponentProps<"div">;
295
+
296
+ export const ContextUsageInput = (props: ContextUsageInputProps) => {
297
+ const { usage, costs } = useContextValue();
298
+ return (
299
+ <UsageRow
300
+ label="Input"
301
+ tokens={usage?.inputTokens ?? 0}
302
+ costUSD={costs?.inputUSD}
303
+ {...props}
304
+ />
305
+ );
306
+ };
307
+
308
+ export type ContextUsageOutputProps = ComponentProps<"div">;
309
+
310
+ export const ContextUsageOutput = (props: ContextUsageOutputProps) => {
311
+ const { usage, costs } = useContextValue();
312
+ return (
313
+ <UsageRow
314
+ label="Output"
315
+ tokens={usage?.outputTokens ?? 0}
316
+ costUSD={costs?.outputUSD}
317
+ {...props}
318
+ />
319
+ );
320
+ };
321
+
322
+ export type ContextUsageCacheReadProps = ComponentProps<"div">;
323
+
324
+ export const ContextUsageCacheRead = (props: ContextUsageCacheReadProps) => {
325
+ const { usage, costs } = useContextValue();
326
+ return (
327
+ <UsageRow
328
+ label="Cache read"
329
+ tokens={usage?.cachedInputReadTokens ?? 0}
330
+ costUSD={costs?.cacheReadUSD}
331
+ {...props}
332
+ />
333
+ );
334
+ };
335
+
336
+ export type ContextUsageCacheWriteProps = ComponentProps<"div">;
337
+
338
+ export const ContextUsageCacheWrite = (props: ContextUsageCacheWriteProps) => {
339
+ const { usage, costs } = useContextValue();
340
+ return (
341
+ <UsageRow
342
+ label="Cache write"
343
+ tokens={usage?.cachedInputWriteTokens ?? 0}
344
+ costUSD={costs?.cacheWriteUSD}
345
+ {...props}
346
+ />
347
+ );
348
+ };
349
+
350
+ /**
351
+ * @deprecated Use ContextUsageCacheRead and ContextUsageCacheWrite separately.
352
+ * Kept for backwards compat — shows combined cache reads + writes as one row.
353
+ */
354
+ export const ContextUsageCache = (props: ComponentProps<"div">) => {
355
+ const { usage, costs } = useContextValue();
356
+ const cacheTokens =
357
+ (usage?.cachedInputReadTokens ?? 0) + (usage?.cachedInputWriteTokens ?? 0);
358
+ const cacheCost =
359
+ costs?.cacheReadUSD !== undefined || costs?.cacheWriteUSD !== undefined
360
+ ? (costs?.cacheReadUSD ?? 0) + (costs?.cacheWriteUSD ?? 0)
361
+ : undefined;
362
+ return (
363
+ <UsageRow
364
+ label="Cache"
365
+ tokens={cacheTokens}
366
+ costUSD={cacheCost}
367
+ {...props}
368
+ />
369
+ );
370
+ };
@@ -1,34 +1,71 @@
1
1
  "use client";
2
- import type { ComponentProps } from "react";
3
- import { AnimatePresence, motion } from "motion/react";
2
+ import type { ComponentProps, ReactNode } from "react";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { AnimatePresence, domAnimation, LazyMotion, m } from "motion/react";
4
5
  import { IconArrowDown, IconDownload } from "@tabler/icons-react";
5
6
  import { Button } from "../ui/button";
6
7
  import { cn } from "../utils/cn";
7
8
  import { flatInteractiveClass } from "../ui/surface-classes";
9
+ import { useCn, useUiConfig } from "../ui/ui-config";
8
10
  import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
9
11
  import { fastTransition } from "../motion/presets.alt";
12
+ type MotionDivProps = ComponentProps<typeof m.div>;
13
+ /**
14
+ * `UiConfig.Conversation`. `classes` replace built-in classes: `content`
15
+ * (`ConversationContent`), `scroll` (a default `scrollClassName`, merged
16
+ * before the prop), `download`, and the scroll button's `scrollButton`,
17
+ * `scrollButtonMotion` (the animated wrapper; `""` drops its class) and
18
+ * `scrollButtonFrame` (a static wrapper around the presence, absent by
19
+ * default). `scrollButton` holds the scroll button's prop defaults and
20
+ * motion.
21
+ */
22
+ export interface ConversationConfig {
23
+ classes?: {
24
+ content?: string;
25
+ scroll?: string;
26
+ download?: string;
27
+ scrollButton?: string;
28
+ scrollButtonMotion?: string;
29
+ scrollButtonFrame?: string;
30
+ };
31
+ scrollButton?: Pick<ConversationScrollButtonProps, "label" | "icon" | "showDelay" | "size"> & {
32
+ initial?: MotionDivProps["initial"];
33
+ animate?: MotionDivProps["animate"];
34
+ exit?: MotionDivProps["exit"];
35
+ transition?: MotionDivProps["transition"];
36
+ };
37
+ }
10
38
  export type ConversationProps = ComponentProps<typeof StickToBottom>;
11
- export const Conversation = ({ className, ...props }: ConversationProps) => (
12
- <StickToBottom
13
- className={cn("relative flex-1 overflow-y-hidden", className)}
14
- initial="instant"
15
- resize="smooth"
16
- role="log"
17
- {...props}
18
- />
19
- );
39
+ export const Conversation = ({ className, ...props }: ConversationProps) => {
40
+ const merge = useCn();
41
+ return (
42
+ <StickToBottom
43
+ className={merge("relative flex-1 overflow-y-hidden", className)}
44
+ initial="instant"
45
+ resize="smooth"
46
+ role="log"
47
+ {...props}
48
+ />
49
+ );
50
+ };
20
51
  export type ConversationContentProps = ComponentProps<
21
52
  typeof StickToBottom.Content
22
53
  >;
23
54
  export const ConversationContent = ({
24
55
  className,
25
56
  ...props
26
- }: ConversationContentProps) => (
27
- <StickToBottom.Content
28
- className={cn("flex flex-col gap-4 px-3 py-3", className)}
29
- {...props}
30
- />
31
- );
57
+ }: ConversationContentProps) => {
58
+ const classes = useUiConfig("Conversation")?.classes;
59
+ const merge = useCn();
60
+ const scroll = classes?.scroll;
61
+ return (
62
+ <StickToBottom.Content
63
+ className={merge(classes?.content ?? "flex flex-col gap-4 px-3 py-3", className)}
64
+ {...props}
65
+ {...(scroll === undefined ? {} : { scrollClassName: merge(scroll, props.scrollClassName) })}
66
+ />
67
+ );
68
+ };
32
69
  export type ConversationEmptyStateProps = ComponentProps<"div"> & {
33
70
  title?: string;
34
71
  description?: string;
@@ -41,59 +78,144 @@ export const ConversationEmptyState = ({
41
78
  icon,
42
79
  children,
43
80
  ...props
44
- }: ConversationEmptyStateProps) => (
45
- <div
46
- className={cn(
47
- "flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
48
- className,
49
- )}
50
- {...props}
51
- >
52
- {children ?? (
53
- <>
54
- {icon && <div className="text-muted-foreground">{icon}</div>}
55
- <div className="space-y-1">
56
- <h3 className="font-medium text-sm">{title}</h3>
57
- {description && <p className="text-muted-foreground text-sm">{description}</p>}
58
- </div>
59
- </>
60
- )}
61
- </div>
62
- );
63
- export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
81
+ }: ConversationEmptyStateProps) => {
82
+ const merge = useCn();
83
+ return (
84
+ <div
85
+ className={merge(
86
+ "flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
87
+ className,
88
+ )}
89
+ {...props}
90
+ >
91
+ {children ?? (
92
+ <>
93
+ {icon && <div className="text-muted-foreground">{icon}</div>}
94
+ <div className="space-y-1">
95
+ <h3 className="font-medium text-sm">{title}</h3>
96
+ {description && <p className="text-muted-foreground text-sm">{description}</p>}
97
+ </div>
98
+ </>
99
+ )}
100
+ </div>
101
+ );
102
+ };
103
+ export type ConversationScrollButtonProps = ComponentProps<typeof Button> & {
104
+ /** Text after the icon; also the button's `aria-label` and `title`. */
105
+ label?: string;
106
+ /** Replaces the arrow icon. */
107
+ icon?: ReactNode;
108
+ /**
109
+ * Wait this long (ms) after leaving the bottom before showing the button,
110
+ * so brief layout settles do not flash it. Hiding is immediate. Default 0.
111
+ */
112
+ showDelay?: number;
113
+ /**
114
+ * When this changes (for example the thread id), hide the button at once and
115
+ * cancel a pending show; it shows again on the next scroll away.
116
+ */
117
+ resetKey?: string;
118
+ };
119
+ /**
120
+ * `wanted`, or (with a delay or reset key) `wanted` held for `delay` ms, and
121
+ * a function that hides it at once.
122
+ */
123
+ function useDelayedShow(
124
+ wanted: boolean,
125
+ delay: number,
126
+ resetKey: string | undefined,
127
+ ): [boolean, () => void] {
128
+ const debounced = delay > 0 || resetKey !== undefined;
129
+ const [shown, setShown] = useState(false);
130
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
131
+ const hide = () => {
132
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
133
+ timerRef.current = null;
134
+ setShown(false);
135
+ };
136
+ // The reset also cancels the pending show timer, a side effect.
137
+ useEffect(() => {
138
+ if (!debounced) return;
139
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
140
+ timerRef.current = null;
141
+ setShown(false);
142
+ }, [resetKey, debounced]);
143
+ useEffect(() => {
144
+ if (!debounced) return;
145
+ if (!wanted) {
146
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
147
+ timerRef.current = null;
148
+ setShown(false);
149
+ return;
150
+ }
151
+ timerRef.current = setTimeout(() => {
152
+ setShown(true);
153
+ timerRef.current = null;
154
+ }, delay);
155
+ return () => {
156
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
157
+ timerRef.current = null;
158
+ };
159
+ }, [wanted, delay, debounced]);
160
+ return [debounced ? shown : wanted, debounced ? hide : () => {}];
161
+ }
64
162
  export const ConversationScrollButton = ({
65
163
  className,
164
+ label: labelProp,
165
+ icon: iconProp,
166
+ showDelay,
167
+ resetKey,
168
+ size,
66
169
  ...props
67
170
  }: ConversationScrollButtonProps) => {
171
+ const config = useUiConfig("Conversation");
172
+ const defaults = config?.scrollButton;
173
+ const classes = config?.classes;
174
+ const merge = useCn();
68
175
  const { isAtBottom, scrollToBottom } = useStickToBottomContext();
176
+ const [visible, hide] = useDelayedShow(
177
+ !isAtBottom,
178
+ showDelay ?? defaults?.showDelay ?? 0,
179
+ resetKey,
180
+ );
181
+ const label = labelProp ?? defaults?.label;
182
+ const icon = iconProp ?? defaults?.icon;
69
183
  const handleScrollToBottom = () => {
184
+ hide();
70
185
  scrollToBottom();
71
186
  };
72
- return (
73
- <AnimatePresence initial={false}>
74
- {isAtBottom ? null : (
75
- <motion.div
76
- key="scroll-to-bottom"
77
- className="absolute bottom-4 left-0 right-0 z-10 flex justify-center"
78
- initial={{ opacity: 0, y: 8 }}
79
- animate={{ opacity: 1, y: 0 }}
80
- exit={{ opacity: 0, y: 8 }}
81
- transition={fastTransition}
82
- >
83
- <Button
84
- className={cn(flatInteractiveClass, "rounded-full", className)}
85
- onClick={handleScrollToBottom}
86
- size="icon"
87
- type="button"
88
- variant="outline"
89
- {...props}
187
+ const motionClass = classes?.scrollButtonMotion ?? "absolute bottom-4 left-0 right-0 z-10 flex justify-center";
188
+ const presence = (
189
+ <LazyMotion features={domAnimation}>
190
+ <AnimatePresence initial={false}>
191
+ {visible ? (
192
+ <m.div
193
+ key="scroll-to-bottom"
194
+ className={motionClass === "" ? undefined : motionClass}
195
+ initial={defaults?.initial ?? { opacity: 0, y: 8 }}
196
+ animate={defaults?.animate ?? { opacity: 1, y: 0 }}
197
+ exit={defaults?.exit ?? { opacity: 0, y: 8 }}
198
+ transition={defaults?.transition ?? fastTransition}
90
199
  >
91
- <IconArrowDown className="size-4" />
92
- </Button>
93
- </motion.div>
94
- )}
95
- </AnimatePresence>
200
+ <Button
201
+ className={merge(classes?.scrollButton ?? [flatInteractiveClass, "rounded-full"], className)}
202
+ onClick={handleScrollToBottom}
203
+ size={size ?? defaults?.size ?? "icon"}
204
+ type="button"
205
+ variant="outline"
206
+ {...(label ? { "aria-label": label, title: label } : {})}
207
+ {...props}
208
+ >
209
+ {icon ?? <IconArrowDown className="size-4" />}
210
+ {label}
211
+ </Button>
212
+ </m.div>
213
+ ) : null}
214
+ </AnimatePresence>
215
+ </LazyMotion>
96
216
  );
217
+ const frame = classes?.scrollButtonFrame;
218
+ return frame === undefined ? presence : <div className={frame}>{presence}</div>;
97
219
  };
98
220
  export interface ConversationMessage {
99
221
  role: "user" | "assistant" | "system" | "data" | "tool";
@@ -127,6 +249,8 @@ export const ConversationDownload = ({
127
249
  children,
128
250
  ...props
129
251
  }: ConversationDownloadProps) => {
252
+ const download = useUiConfig("Conversation")?.classes?.download;
253
+ const merge = useCn();
130
254
  const handleDownload = () => {
131
255
  const markdown = messagesToMarkdown(messages, formatMessage);
132
256
  const blob = new Blob([markdown], { type: "text/markdown" });
@@ -141,8 +265,8 @@ export const ConversationDownload = ({
141
265
  };
142
266
  return (
143
267
  <Button
144
- className={cn(
145
- cn(flatInteractiveClass, "absolute top-4 right-4 rounded-full"),
268
+ className={merge(
269
+ download ?? cn(flatInteractiveClass, "absolute top-4 right-4 rounded-full"),
146
270
  className,
147
271
  )}
148
272
  onClick={handleDownload}