@vvedantb/ui 0.17.0 → 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.
- package/README.md +253 -3
- package/package.json +13 -5
- package/shell/src/SidebarResize.tsx +1 -4
- package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
- package/src/ai-elements/activity-question-utils.ts +26 -0
- package/src/ai-elements/activity-question.tsx +159 -0
- package/src/ai-elements/activity-shared.tsx +459 -0
- package/src/ai-elements/activity-step-detail.tsx +115 -0
- package/src/ai-elements/activity-step-label.ts +981 -0
- package/src/ai-elements/activity-tasks-utils.ts +199 -0
- package/src/ai-elements/activity-tasks.tsx +770 -0
- package/src/ai-elements/artifact.tsx +162 -52
- package/src/ai-elements/attachments.tsx +62 -32
- package/src/ai-elements/code-block.tsx +76 -22
- package/src/ai-elements/context.tsx +370 -0
- package/src/ai-elements/conversation.tsx +186 -62
- package/src/ai-elements/file-chip.tsx +53 -0
- package/src/ai-elements/message.tsx +161 -50
- package/src/ai-elements/model-picker-content.tsx +505 -0
- package/src/ai-elements/model-picker-types.ts +56 -0
- package/src/ai-elements/model-picker.tsx +301 -0
- package/src/ai-elements/model-selector.tsx +102 -21
- package/src/ai-elements/plan.tsx +130 -0
- package/src/ai-elements/prompt-input-settings.tsx +144 -0
- package/src/ai-elements/prompt-input-speech.tsx +189 -0
- package/src/ai-elements/prompt-input-traits.tsx +281 -0
- package/src/ai-elements/prompt-input.tsx +320 -134
- package/src/ai-elements/provider-icon.tsx +133 -0
- package/src/ai-elements/queue.tsx +293 -9
- package/src/ai-elements/shimmer.tsx +72 -1
- package/src/ai-elements/simple-model-ladder.tsx +108 -0
- package/src/ai-elements/test-results.tsx +510 -0
- package/src/ai-elements/trace.tsx +243 -0
- package/src/ai-elements/use-streaming-disclosure.ts +50 -0
- package/src/ai-elements/web-preview.tsx +349 -0
- package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
- package/src/index.ts +66 -12
- package/src/kibo/avatar-stack.tsx +40 -0
- package/src/kibo/contribution-graph.tsx +539 -0
- package/src/kibo/cursor.tsx +72 -0
- package/src/kibo/gantt-features.tsx +439 -0
- package/src/kibo/gantt-header.tsx +181 -0
- package/src/kibo/gantt-provider.tsx +657 -0
- package/src/kibo/gantt-sidebar.tsx +157 -0
- package/src/kibo/gantt-timeline.tsx +154 -0
- package/src/kibo/index.ts +151 -0
- package/src/kibo/kanban.tsx +338 -0
- package/src/kibo/kanbanDropTarget.ts +25 -0
- package/src/kibo/list.tsx +162 -0
- package/src/kibo/theme-switcher.tsx +109 -0
- package/src/kibo/video-player.tsx +237 -0
- package/src/markdown/Markdown.tsx +112 -0
- package/src/markdown/MarkdownBlockquote.tsx +45 -0
- package/src/markdown/MarkdownCodeBlock.tsx +197 -0
- package/src/markdown/MarkdownInlineCode.tsx +40 -0
- package/src/markdown/MarkdownLink.tsx +92 -0
- package/src/markdown/codeFence.ts +57 -0
- package/src/markdown/codeHighlight.ts +138 -0
- package/src/markdown/incrementalBlocks.ts +182 -0
- package/src/markdown/index.ts +3 -0
- package/src/markdown/markdownClipboard.ts +178 -0
- package/src/markdown/mermaid.ts +155 -0
- package/src/markdown/rehypeGithubAlerts.ts +80 -0
- package/src/motion/presets.ts +24 -0
- package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
- package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
- package/src/ui/_menu-parts.tsx +29 -0
- package/src/ui/accordion.alt.tsx +46 -53
- package/src/ui/accordion.tsx +59 -10
- package/src/ui/avatar.tsx +56 -0
- package/src/ui/badge.tsx +45 -21
- package/src/ui/border-beam.tsx +97 -0
- package/src/ui/button-group.tsx +50 -21
- package/src/ui/button.tsx +67 -45
- package/src/ui/calendar.tsx +48 -5
- package/src/ui/card.tsx +77 -8
- package/src/ui/carousel.tsx +60 -0
- package/src/ui/centered-spinner.tsx +30 -0
- package/src/ui/checkbox.tsx +34 -16
- package/src/ui/clear-input.alt.tsx +33 -75
- package/src/ui/clear-input.tsx +140 -15
- package/src/ui/collapsible.tsx +40 -2
- package/src/ui/command.tsx +143 -70
- package/src/ui/context-menu.tsx +159 -93
- package/src/ui/crossfade-icon.tsx +123 -0
- package/src/ui/dialog.tsx +154 -70
- package/src/ui/dropdown-menu.tsx +177 -117
- package/src/ui/hover-card.tsx +59 -17
- package/src/ui/input-group.alt.tsx +83 -83
- package/src/ui/input-group.tsx +129 -58
- package/src/ui/input.tsx +13 -4
- package/src/ui/label.tsx +11 -8
- package/src/ui/list-row.tsx +232 -0
- package/src/ui/loading-state.tsx +215 -0
- package/src/ui/popover.tsx +56 -23
- package/src/ui/progress.tsx +28 -16
- package/src/ui/refresh-spin-icon.tsx +41 -0
- package/src/ui/search-input.tsx +66 -15
- package/src/ui/select.tsx +171 -100
- package/src/ui/separator.tsx +23 -14
- package/src/ui/slider.tsx +21 -7
- package/src/ui/sonner.tsx +68 -30
- package/src/ui/spinner.tsx +229 -11
- package/src/ui/status-dot.tsx +84 -0
- package/src/ui/surface.tsx +49 -0
- package/src/ui/switch.tsx +29 -15
- package/src/ui/table.tsx +38 -16
- package/src/ui/tabs.tsx +123 -35
- package/src/ui/tabsSliding.ts +33 -1
- package/src/ui/textarea.tsx +13 -4
- package/src/ui/tooltip.tsx +50 -14
- package/src/ui/ui-config.tsx +149 -0
- package/src/utils/cat-color.ts +123 -0
- package/src/utils/dayjs.ts +6 -0
- package/src/utils/gesture.ts +59 -0
- package/src/utils/runtimeVisibility.ts +167 -0
- package/src/utils/sharedClock.ts +96 -0
- package/src/utils/surface-radius.ts +16 -0
- package/src/utils/useDragSensors.ts +58 -0
- package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
- package/tokens/examples/tokens-moved/README.md +20 -0
- package/tokens/examples/tokens-moved/index.html +12 -0
- package/tokens/examples/tokens-moved/main.tsx +6 -0
- package/tokens/examples/tokens-moved/package.json +14 -0
- package/tokens/examples/tokens-moved/styles.css +12 -0
- package/tokens/examples/tokens-moved/tsconfig.json +14 -0
- package/tokens/examples/tokens-moved/vite.config.ts +11 -0
- package/tokens/src/css/border-beam.css +116 -0
- package/tokens/src/css/compat-shadcn-rgb.css +6 -0
- package/tokens/src/css/markdown.css +318 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
MediaControlBar,
|
|
5
|
+
MediaController,
|
|
6
|
+
MediaFullscreenButton,
|
|
7
|
+
MediaMuteButton,
|
|
8
|
+
MediaPlayButton,
|
|
9
|
+
MediaPlaybackRateButton,
|
|
10
|
+
MediaSeekBackwardButton,
|
|
11
|
+
MediaSeekForwardButton,
|
|
12
|
+
MediaTimeDisplay,
|
|
13
|
+
MediaTimeRange,
|
|
14
|
+
MediaVolumeRange,
|
|
15
|
+
} from "media-chrome/react";
|
|
16
|
+
import type { ComponentProps } from "react";
|
|
17
|
+
import { useCn, useUiConfig } from "../ui/ui-config";
|
|
18
|
+
|
|
19
|
+
export interface VideoPlayerConfig {
|
|
20
|
+
/** Replaces the built-in classes of a part. */
|
|
21
|
+
classes?: {
|
|
22
|
+
/** `VideoPlayer` (the media controller): clipping, radius and shadow. */
|
|
23
|
+
root?: string;
|
|
24
|
+
/** `VideoPlayerControlBar`: backdrop, padding and control transitions. */
|
|
25
|
+
controlBar?: string;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defaultClasses = {
|
|
30
|
+
root: "overflow-hidden rounded-surface shadow-lg shadow-black/20",
|
|
31
|
+
controlBar: "bg-zinc-950/95 px-1 *:transition-colors *:duration-150",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// The control bar carries the dark backdrop (set via Tailwind on the bar),
|
|
35
|
+
// so individual controls stay transparent and hover applies a subtle light
|
|
36
|
+
// overlay on top — that reads as a "dimmer highlight" rather than going
|
|
37
|
+
// transparent against the chat bubble behind.
|
|
38
|
+
// Tooltip background is set explicitly because the default falls through
|
|
39
|
+
// to --media-secondary-color, which is transparent here.
|
|
40
|
+
const variables: Record<string, string> = {
|
|
41
|
+
"--media-primary-color": "rgb(255 255 255 / 0.96)",
|
|
42
|
+
"--media-secondary-color": "transparent",
|
|
43
|
+
"--media-text-color": "rgb(255 255 255 / 0.92)",
|
|
44
|
+
"--media-background-color": "rgb(9 9 11 / 1)",
|
|
45
|
+
"--media-control-background": "transparent",
|
|
46
|
+
"--media-control-hover-background": "rgb(255 255 255 / 0.14)",
|
|
47
|
+
"--media-font-family": "var(--font-sans)",
|
|
48
|
+
"--media-live-button-icon-color": "rgb(255 255 255 / 0.78)",
|
|
49
|
+
"--media-live-button-indicator-color": "rgb(244 63 94)",
|
|
50
|
+
"--media-range-track-background": "rgb(255 255 255 / 0.22)",
|
|
51
|
+
"--media-range-thumb-background": "rgb(255 255 255 / 0.95)",
|
|
52
|
+
"--media-tooltip-background-color": "rgb(24 24 27 / 0.98)",
|
|
53
|
+
"--media-tooltip-arrow-color": "rgb(24 24 27 / 0.98)",
|
|
54
|
+
"--media-tooltip-filter":
|
|
55
|
+
"drop-shadow(0 6px 16px rgb(0 0 0 / 0.55)) drop-shadow(0 1px 2px rgb(0 0 0 / 0.4))",
|
|
56
|
+
"--media-tooltip-distance": "10px",
|
|
57
|
+
"--media-tooltip-border-radius": "6px",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type VideoPlayerProps = ComponentProps<typeof MediaController>;
|
|
61
|
+
|
|
62
|
+
export const VideoPlayer = ({
|
|
63
|
+
style,
|
|
64
|
+
className,
|
|
65
|
+
...props
|
|
66
|
+
}: VideoPlayerProps) => {
|
|
67
|
+
const classes = { ...defaultClasses, ...useUiConfig("VideoPlayer")?.classes };
|
|
68
|
+
const merge = useCn();
|
|
69
|
+
return (
|
|
70
|
+
<MediaController
|
|
71
|
+
className={merge(classes.root, className)}
|
|
72
|
+
style={{
|
|
73
|
+
...variables,
|
|
74
|
+
...style,
|
|
75
|
+
}}
|
|
76
|
+
{...props}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type VideoPlayerControlBarProps = ComponentProps<typeof MediaControlBar>;
|
|
82
|
+
|
|
83
|
+
export const VideoPlayerControlBar = ({
|
|
84
|
+
className,
|
|
85
|
+
...props
|
|
86
|
+
}: VideoPlayerControlBarProps) => {
|
|
87
|
+
const classes = { ...defaultClasses, ...useUiConfig("VideoPlayer")?.classes };
|
|
88
|
+
const merge = useCn();
|
|
89
|
+
return <MediaControlBar className={merge(classes.controlBar, className)} {...props} />;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type VideoPlayerTimeRangeProps = ComponentProps<typeof MediaTimeRange>;
|
|
93
|
+
|
|
94
|
+
export const VideoPlayerTimeRange = ({
|
|
95
|
+
className,
|
|
96
|
+
...props
|
|
97
|
+
}: VideoPlayerTimeRangeProps) => {
|
|
98
|
+
const merge = useCn();
|
|
99
|
+
return (
|
|
100
|
+
<MediaTimeRange className={merge("p-2.5 min-w-12", className)} {...props} />
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type VideoPlayerTimeDisplayProps = ComponentProps<
|
|
105
|
+
typeof MediaTimeDisplay
|
|
106
|
+
>;
|
|
107
|
+
|
|
108
|
+
export const VideoPlayerTimeDisplay = ({
|
|
109
|
+
className,
|
|
110
|
+
...props
|
|
111
|
+
}: VideoPlayerTimeDisplayProps) => {
|
|
112
|
+
const merge = useCn();
|
|
113
|
+
return (
|
|
114
|
+
<MediaTimeDisplay
|
|
115
|
+
className={merge("p-2.5 text-xs tabular-nums tracking-tight", className)}
|
|
116
|
+
{...props}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type VideoPlayerVolumeRangeProps = ComponentProps<
|
|
122
|
+
typeof MediaVolumeRange
|
|
123
|
+
>;
|
|
124
|
+
|
|
125
|
+
export const VideoPlayerVolumeRange = ({
|
|
126
|
+
className,
|
|
127
|
+
...props
|
|
128
|
+
}: VideoPlayerVolumeRangeProps) => {
|
|
129
|
+
const merge = useCn();
|
|
130
|
+
return (
|
|
131
|
+
<MediaVolumeRange className={merge("p-2 rounded-md", className)} {...props} />
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export type VideoPlayerPlayButtonProps = ComponentProps<typeof MediaPlayButton>;
|
|
136
|
+
|
|
137
|
+
export const VideoPlayerPlayButton = ({
|
|
138
|
+
className,
|
|
139
|
+
...props
|
|
140
|
+
}: VideoPlayerPlayButtonProps) => {
|
|
141
|
+
const merge = useCn();
|
|
142
|
+
return (
|
|
143
|
+
<MediaPlayButton className={merge("p-2 rounded-md", className)} {...props} />
|
|
144
|
+
);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type VideoPlayerSeekBackwardButtonProps = ComponentProps<
|
|
148
|
+
typeof MediaSeekBackwardButton
|
|
149
|
+
>;
|
|
150
|
+
|
|
151
|
+
export const VideoPlayerSeekBackwardButton = ({
|
|
152
|
+
className,
|
|
153
|
+
...props
|
|
154
|
+
}: VideoPlayerSeekBackwardButtonProps) => {
|
|
155
|
+
const merge = useCn();
|
|
156
|
+
return (
|
|
157
|
+
<MediaSeekBackwardButton
|
|
158
|
+
className={merge("p-2 rounded-md", className)}
|
|
159
|
+
{...props}
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type VideoPlayerSeekForwardButtonProps = ComponentProps<
|
|
165
|
+
typeof MediaSeekForwardButton
|
|
166
|
+
>;
|
|
167
|
+
|
|
168
|
+
export const VideoPlayerSeekForwardButton = ({
|
|
169
|
+
className,
|
|
170
|
+
...props
|
|
171
|
+
}: VideoPlayerSeekForwardButtonProps) => {
|
|
172
|
+
const merge = useCn();
|
|
173
|
+
return (
|
|
174
|
+
<MediaSeekForwardButton
|
|
175
|
+
className={merge("p-2 rounded-md", className)}
|
|
176
|
+
{...props}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export type VideoPlayerMuteButtonProps = ComponentProps<typeof MediaMuteButton>;
|
|
182
|
+
|
|
183
|
+
export const VideoPlayerMuteButton = ({
|
|
184
|
+
className,
|
|
185
|
+
...props
|
|
186
|
+
}: VideoPlayerMuteButtonProps) => {
|
|
187
|
+
const merge = useCn();
|
|
188
|
+
return (
|
|
189
|
+
<MediaMuteButton className={merge("p-2 rounded-md", className)} {...props} />
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export type VideoPlayerPlaybackRateButtonProps = ComponentProps<
|
|
194
|
+
typeof MediaPlaybackRateButton
|
|
195
|
+
>;
|
|
196
|
+
|
|
197
|
+
export const VideoPlayerPlaybackRateButton = ({
|
|
198
|
+
className,
|
|
199
|
+
...props
|
|
200
|
+
}: VideoPlayerPlaybackRateButtonProps) => {
|
|
201
|
+
const merge = useCn();
|
|
202
|
+
return (
|
|
203
|
+
<MediaPlaybackRateButton
|
|
204
|
+
className={merge("p-2 rounded-md", className)}
|
|
205
|
+
{...props}
|
|
206
|
+
/>
|
|
207
|
+
);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export type VideoPlayerFullscreenButtonProps = ComponentProps<
|
|
211
|
+
typeof MediaFullscreenButton
|
|
212
|
+
>;
|
|
213
|
+
|
|
214
|
+
export const VideoPlayerFullscreenButton = ({
|
|
215
|
+
className,
|
|
216
|
+
...props
|
|
217
|
+
}: VideoPlayerFullscreenButtonProps) => {
|
|
218
|
+
const merge = useCn();
|
|
219
|
+
return (
|
|
220
|
+
<MediaFullscreenButton
|
|
221
|
+
className={merge("p-2 rounded-md", className)}
|
|
222
|
+
{...props}
|
|
223
|
+
/>
|
|
224
|
+
);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export type VideoPlayerContentProps = ComponentProps<"video">;
|
|
228
|
+
|
|
229
|
+
export const VideoPlayerContent = ({
|
|
230
|
+
className,
|
|
231
|
+
...props
|
|
232
|
+
}: VideoPlayerContentProps) => {
|
|
233
|
+
const merge = useCn();
|
|
234
|
+
return (
|
|
235
|
+
<video className={merge("mt-0 mb-0", className)} {...props} />
|
|
236
|
+
);
|
|
237
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ComponentProps } from "react";
|
|
4
|
+
import { memo } from "react";
|
|
5
|
+
import { cjk } from "@streamdown/cjk";
|
|
6
|
+
import { code } from "@streamdown/code";
|
|
7
|
+
import { math } from "@streamdown/math";
|
|
8
|
+
import type { Components } from "streamdown";
|
|
9
|
+
import {
|
|
10
|
+
Streamdown,
|
|
11
|
+
defaultRehypePlugins,
|
|
12
|
+
defaultRemarkPlugins,
|
|
13
|
+
} from "streamdown";
|
|
14
|
+
|
|
15
|
+
import { useCn } from "../ui/ui-config";
|
|
16
|
+
import { parseMarkdownIntoBlocksIncremental } from "./incrementalBlocks";
|
|
17
|
+
import { MarkdownBlockquote } from "./MarkdownBlockquote";
|
|
18
|
+
import { MarkdownPre } from "./MarkdownCodeBlock";
|
|
19
|
+
import { MarkdownInlineCode } from "./MarkdownInlineCode";
|
|
20
|
+
import { MarkdownLink } from "./MarkdownLink";
|
|
21
|
+
import { mermaidPlugin } from "./mermaid";
|
|
22
|
+
import { copyMarkdownSelection } from "./markdownClipboard";
|
|
23
|
+
import { rehypeGithubAlerts } from "./rehypeGithubAlerts";
|
|
24
|
+
// The math plugin renders KaTeX markup but ships no stylesheet with it.
|
|
25
|
+
import "katex/dist/katex.min.css";
|
|
26
|
+
|
|
27
|
+
const PLUGINS = { cjk, code, math, mermaid: mermaidPlugin };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `remarkPlugins` / `rehypePlugins` REPLACE Streamdown's defaults rather than
|
|
31
|
+
* extending them, so the defaults (GFM + fence meta; raw HTML, sanitise,
|
|
32
|
+
* harden) are spread back in first. Alerts run after the sanitiser, which
|
|
33
|
+
* would otherwise strip the attribute they set.
|
|
34
|
+
*/
|
|
35
|
+
const REMARK_PLUGINS = Object.values(defaultRemarkPlugins);
|
|
36
|
+
const REHYPE_PLUGINS = [...Object.values(defaultRehypePlugins), rehypeGithubAlerts];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Plain tags where Streamdown's defaults bake in Tailwind classes (text-3xl
|
|
40
|
+
* headings, `list-inside` lists, a `span` for bold, padded table cells): the
|
|
41
|
+
* `.markdown` stylesheet owns typography, so these render unstyled. `table`
|
|
42
|
+
* itself stays Streamdown's, for its copy / download / fullscreen controls.
|
|
43
|
+
*/
|
|
44
|
+
const MARKDOWN_COMPONENTS: Components = {
|
|
45
|
+
h1: "h1",
|
|
46
|
+
h2: "h2",
|
|
47
|
+
h3: "h3",
|
|
48
|
+
h4: "h4",
|
|
49
|
+
h5: "h5",
|
|
50
|
+
h6: "h6",
|
|
51
|
+
ul: "ul",
|
|
52
|
+
ol: "ol",
|
|
53
|
+
li: "li",
|
|
54
|
+
hr: "hr",
|
|
55
|
+
strong: "strong",
|
|
56
|
+
thead: "thead",
|
|
57
|
+
tbody: "tbody",
|
|
58
|
+
tr: "tr",
|
|
59
|
+
th: "th",
|
|
60
|
+
td: "td",
|
|
61
|
+
a: MarkdownLink,
|
|
62
|
+
blockquote: MarkdownBlockquote,
|
|
63
|
+
pre: MarkdownPre,
|
|
64
|
+
inlineCode: MarkdownInlineCode,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type MarkdownProps = Omit<
|
|
68
|
+
ComponentProps<typeof Streamdown>,
|
|
69
|
+
"plugins" | "rehypePlugins" | "parseMarkdownIntoBlocksFn"
|
|
70
|
+
> & {
|
|
71
|
+
children: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A markdown renderer for chat replies, reasoning, comments, PR bodies and
|
|
76
|
+
* changelogs. Streamdown streams it block by block; this layer adds the code
|
|
77
|
+
* blocks, callouts, links, typography and copy-as-markdown every surface
|
|
78
|
+
* shares. Typography lives in `@vvedantb/ui/tokens/css/markdown.css`.
|
|
79
|
+
* `components` and `remarkPlugins` extend the defaults, never replace them
|
|
80
|
+
* (mention chips, line breaks).
|
|
81
|
+
*/
|
|
82
|
+
export const Markdown = memo(function Markdown({
|
|
83
|
+
className,
|
|
84
|
+
components,
|
|
85
|
+
remarkPlugins,
|
|
86
|
+
children,
|
|
87
|
+
...props
|
|
88
|
+
}: MarkdownProps) {
|
|
89
|
+
const merge = useCn();
|
|
90
|
+
return (
|
|
91
|
+
<div className="contents" onCopy={copyMarkdownSelection}>
|
|
92
|
+
<Streamdown
|
|
93
|
+
className={merge("markdown", className)}
|
|
94
|
+
plugins={PLUGINS}
|
|
95
|
+
components={
|
|
96
|
+
components ? { ...MARKDOWN_COMPONENTS, ...components } : MARKDOWN_COMPONENTS
|
|
97
|
+
}
|
|
98
|
+
remarkPlugins={
|
|
99
|
+
remarkPlugins ? [...REMARK_PLUGINS, ...remarkPlugins] : REMARK_PLUGINS
|
|
100
|
+
}
|
|
101
|
+
rehypePlugins={REHYPE_PLUGINS}
|
|
102
|
+
// Streamdown re-renders only the block still being written, but its
|
|
103
|
+
// default split re-lexes the whole reply behind every token. This one
|
|
104
|
+
// reuses the blocks a byte-identical prefix already produced.
|
|
105
|
+
parseMarkdownIntoBlocksFn={parseMarkdownIntoBlocksIncremental}
|
|
106
|
+
{...props}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</Streamdown>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import {
|
|
3
|
+
IconAlertOctagon,
|
|
4
|
+
IconAlertTriangle,
|
|
5
|
+
IconBulb,
|
|
6
|
+
IconInfoCircle,
|
|
7
|
+
IconMessageReport,
|
|
8
|
+
} from "@tabler/icons-react";
|
|
9
|
+
import type { ExtraProps } from "streamdown";
|
|
10
|
+
|
|
11
|
+
import type { AlertKind } from "./rehypeGithubAlerts";
|
|
12
|
+
import { isAlertKind } from "./rehypeGithubAlerts";
|
|
13
|
+
|
|
14
|
+
const ALERTS: Record<AlertKind, { label: string; Icon: typeof IconInfoCircle }> = {
|
|
15
|
+
note: { label: "Note", Icon: IconInfoCircle },
|
|
16
|
+
tip: { label: "Tip", Icon: IconBulb },
|
|
17
|
+
important: { label: "Important", Icon: IconMessageReport },
|
|
18
|
+
warning: { label: "Warning", Icon: IconAlertTriangle },
|
|
19
|
+
caution: { label: "Caution", Icon: IconAlertOctagon },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `blockquote` renderer. Alerts tagged by `rehypeGithubAlerts` become a tinted
|
|
24
|
+
* callout with an icon and title; a plain quote stays a quote. The callout is
|
|
25
|
+
* a `div role="note"`, not a `blockquote`, so quote styling never mutes it.
|
|
26
|
+
*/
|
|
27
|
+
export function MarkdownBlockquote({
|
|
28
|
+
children,
|
|
29
|
+
node,
|
|
30
|
+
}: ComponentProps<"blockquote"> & ExtraProps) {
|
|
31
|
+
const kind = node?.properties.dataAlert;
|
|
32
|
+
if (typeof kind !== "string" || !isAlertKind(kind)) {
|
|
33
|
+
return <blockquote>{children}</blockquote>;
|
|
34
|
+
}
|
|
35
|
+
const { label, Icon } = ALERTS[kind];
|
|
36
|
+
return (
|
|
37
|
+
<div role="note" className="markdown-alert" data-alert={kind}>
|
|
38
|
+
<p className="markdown-alert-title" data-markdown-copy={`[!${kind.toUpperCase()}]`}>
|
|
39
|
+
<Icon aria-hidden />
|
|
40
|
+
{label}
|
|
41
|
+
</p>
|
|
42
|
+
{children}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ComponentProps, ReactNode, Ref } from "react";
|
|
4
|
+
import { Fragment, cloneElement, isValidElement, useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
IconCheck,
|
|
7
|
+
IconCopy,
|
|
8
|
+
IconFileCode,
|
|
9
|
+
IconTextWrap,
|
|
10
|
+
} from "@tabler/icons-react";
|
|
11
|
+
import type { ExtraProps } from "streamdown";
|
|
12
|
+
import { useIsCodeFenceIncomplete } from "streamdown";
|
|
13
|
+
|
|
14
|
+
import { MessageAction } from "../ai-elements/message";
|
|
15
|
+
import type { FencedCode } from "./codeFence";
|
|
16
|
+
import { readFencedCode } from "./codeFence";
|
|
17
|
+
import type { CodeLine, HighlightToken } from "./codeHighlight";
|
|
18
|
+
import { useHighlightedLines } from "./codeHighlight";
|
|
19
|
+
import { useMermaidRender } from "./mermaid";
|
|
20
|
+
const COPIED_MS = 1200;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Token colours travel as custom properties so one render serves both
|
|
24
|
+
* themes: `--md-c` is the light colour, `--shiki-dark` the dark one, and the
|
|
25
|
+
* stylesheet picks between them.
|
|
26
|
+
*/
|
|
27
|
+
function tokenStyle(token: HighlightToken): Record<string, string> {
|
|
28
|
+
const style: Record<string, string> = {};
|
|
29
|
+
if (token.color) style["--md-c"] = token.color;
|
|
30
|
+
for (const [name, value] of Object.entries(token.htmlStyle ?? {})) {
|
|
31
|
+
style[name === "color" ? "--md-c" : name] = value;
|
|
32
|
+
}
|
|
33
|
+
return style;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function CodeLineView({ line, newline }: { line: CodeLine; newline: boolean }) {
|
|
37
|
+
return (
|
|
38
|
+
<span className="markdown-code-line">
|
|
39
|
+
{line.tokens === null
|
|
40
|
+
? line.text
|
|
41
|
+
: line.tokens.map((token, index) => (
|
|
42
|
+
<span key={index} style={tokenStyle(token)}>
|
|
43
|
+
{token.content}
|
|
44
|
+
</span>
|
|
45
|
+
))}
|
|
46
|
+
{newline ? "\n" : null}
|
|
47
|
+
</span>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function MarkdownCodeBlock({
|
|
52
|
+
code,
|
|
53
|
+
language,
|
|
54
|
+
title,
|
|
55
|
+
ref,
|
|
56
|
+
}: {
|
|
57
|
+
code: string;
|
|
58
|
+
language: string;
|
|
59
|
+
title: string | null;
|
|
60
|
+
ref?: Ref<HTMLDivElement>;
|
|
61
|
+
}) {
|
|
62
|
+
const isIncomplete = useIsCodeFenceIncomplete();
|
|
63
|
+
const lines = useHighlightedLines(code, language, isIncomplete);
|
|
64
|
+
const [wrap, setWrap] = useState(false);
|
|
65
|
+
const [copied, setCopied] = useState(false);
|
|
66
|
+
|
|
67
|
+
const copy = () => {
|
|
68
|
+
void navigator.clipboard.writeText(code).then(() => {
|
|
69
|
+
setCopied(true);
|
|
70
|
+
setTimeout(() => setCopied(false), COPIED_MS);
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
ref={ref}
|
|
77
|
+
className="markdown-code"
|
|
78
|
+
data-language={language || undefined}
|
|
79
|
+
data-wrap={wrap || undefined}
|
|
80
|
+
>
|
|
81
|
+
<div className="markdown-code-header" data-markdown-copy="skip">
|
|
82
|
+
<span className="markdown-code-label">
|
|
83
|
+
{title ? (
|
|
84
|
+
<>
|
|
85
|
+
<IconFileCode aria-hidden />
|
|
86
|
+
<span className="truncate">{title}</span>
|
|
87
|
+
</>
|
|
88
|
+
) : (
|
|
89
|
+
(language || "text").toLowerCase()
|
|
90
|
+
)}
|
|
91
|
+
</span>
|
|
92
|
+
<span className="markdown-code-actions">
|
|
93
|
+
<MessageAction
|
|
94
|
+
tooltip={wrap ? "Scroll long lines" : "Wrap long lines"}
|
|
95
|
+
size="icon-xs"
|
|
96
|
+
aria-pressed={wrap}
|
|
97
|
+
className={wrap ? "text-foreground" : "text-muted-foreground"}
|
|
98
|
+
onClick={() => setWrap(!wrap)}
|
|
99
|
+
>
|
|
100
|
+
<IconTextWrap />
|
|
101
|
+
</MessageAction>
|
|
102
|
+
<MessageAction
|
|
103
|
+
tooltip={copied ? "Copied" : "Copy code"}
|
|
104
|
+
size="icon-xs"
|
|
105
|
+
className="text-muted-foreground"
|
|
106
|
+
onClick={copy}
|
|
107
|
+
>
|
|
108
|
+
{copied ? <IconCheck /> : <IconCopy />}
|
|
109
|
+
</MessageAction>
|
|
110
|
+
</span>
|
|
111
|
+
</div>
|
|
112
|
+
<pre>
|
|
113
|
+
<code>
|
|
114
|
+
{lines.map((line, index) => (
|
|
115
|
+
<CodeLineView
|
|
116
|
+
key={index}
|
|
117
|
+
line={line}
|
|
118
|
+
newline={index < lines.length - 1}
|
|
119
|
+
/>
|
|
120
|
+
))}
|
|
121
|
+
</code>
|
|
122
|
+
</pre>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Start a diagram render this far before it scrolls into view. */
|
|
128
|
+
const MERMAID_PRELOAD_MARGIN = "400px";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A mermaid fence: the plain code block until the diagram has rendered, then
|
|
132
|
+
* Streamdown's diagram view (pan, zoom, fullscreen, download). A fence still
|
|
133
|
+
* streaming, or one that will not parse, stays code — no error box. Nothing
|
|
134
|
+
* renders (or loads mermaid) until the block nears the viewport, so a long
|
|
135
|
+
* chat does not render every diagram in its history on open.
|
|
136
|
+
*/
|
|
137
|
+
function MarkdownMermaid({
|
|
138
|
+
fenced,
|
|
139
|
+
diagram,
|
|
140
|
+
}: {
|
|
141
|
+
fenced: FencedCode;
|
|
142
|
+
diagram: ReactNode;
|
|
143
|
+
}) {
|
|
144
|
+
const isIncomplete = useIsCodeFenceIncomplete();
|
|
145
|
+
const [isNearView, setIsNearView] = useState(false);
|
|
146
|
+
const { theme, status } = useMermaidRender(
|
|
147
|
+
fenced.code,
|
|
148
|
+
!isIncomplete && isNearView,
|
|
149
|
+
);
|
|
150
|
+
const observeView = (element: HTMLDivElement | null) => {
|
|
151
|
+
if (element === null || isNearView) return;
|
|
152
|
+
const observer = new IntersectionObserver(
|
|
153
|
+
(entries) => {
|
|
154
|
+
if (entries.some((entry) => entry.isIntersecting)) setIsNearView(true);
|
|
155
|
+
},
|
|
156
|
+
{ rootMargin: MERMAID_PRELOAD_MARGIN },
|
|
157
|
+
);
|
|
158
|
+
observer.observe(element);
|
|
159
|
+
return () => observer.disconnect();
|
|
160
|
+
};
|
|
161
|
+
if (status === "done") {
|
|
162
|
+
// Streamdown renders once per mount, so a theme change remounts it.
|
|
163
|
+
return <Fragment key={theme}>{diagram}</Fragment>;
|
|
164
|
+
}
|
|
165
|
+
return (
|
|
166
|
+
<MarkdownCodeBlock
|
|
167
|
+
ref={observeView}
|
|
168
|
+
code={fenced.code}
|
|
169
|
+
language={fenced.language}
|
|
170
|
+
title={fenced.title}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `pre` renderer: fenced code becomes our code block (header with file name or
|
|
177
|
+
* language, wrap toggle, copy). Mermaid fences become diagrams once they
|
|
178
|
+
* render, through Streamdown's own `code` renderer, which is what its default
|
|
179
|
+
* `pre` does too.
|
|
180
|
+
*/
|
|
181
|
+
export function MarkdownPre({ children, node }: ComponentProps<"pre"> & ExtraProps) {
|
|
182
|
+
const fenced = node ? readFencedCode(node) : null;
|
|
183
|
+
const passthrough = isValidElement<{ "data-block"?: string }>(children)
|
|
184
|
+
? cloneElement(children, { "data-block": "true" })
|
|
185
|
+
: <pre>{children}</pre>;
|
|
186
|
+
if (fenced === null) return passthrough;
|
|
187
|
+
if (fenced.language.toLowerCase() === "mermaid") {
|
|
188
|
+
return <MarkdownMermaid fenced={fenced} diagram={passthrough} />;
|
|
189
|
+
}
|
|
190
|
+
return (
|
|
191
|
+
<MarkdownCodeBlock
|
|
192
|
+
code={fenced.code}
|
|
193
|
+
language={fenced.language}
|
|
194
|
+
title={fenced.title}
|
|
195
|
+
/>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { Children } from "react";
|
|
3
|
+
import { IconFile } from "@tabler/icons-react";
|
|
4
|
+
import type { ExtraProps } from "streamdown";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Inline code that names a file, optionally with a `:line[:col]` suffix. A
|
|
8
|
+
* path with a directory (`src/a.ts`, `./x/y.md`, `~/z.json`) may end in any
|
|
9
|
+
* extension; a bare name must end in a common source extension, because
|
|
10
|
+
* `console.log` and `ctx.db` are code, not files.
|
|
11
|
+
*/
|
|
12
|
+
const PATH_WITH_DIRECTORY =
|
|
13
|
+
/^(?:~|\.{1,2})?\/?(?:[\w@.+-]+\/)*[\w@+-][\w@.+-]*\.[a-z][a-z0-9]{0,7}(?::\d+(?::\d+)?)?$/i;
|
|
14
|
+
const BARE_FILE_NAME =
|
|
15
|
+
/^[\w@+-][\w@.+-]*\.(?:[cm]?[jt]sx?|json|jsonc|mdx?|css|scss|html|ya?ml|toml|py|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|sh|sql|txt|lock|xml|svg|vue|svelte|astro|prisma|graphql|csv|ipynb|env)(?::\d+(?::\d+)?)?$/;
|
|
16
|
+
|
|
17
|
+
export function looksLikeFilePath(text: string): boolean {
|
|
18
|
+
if (text.includes("://")) return false;
|
|
19
|
+
return text.includes("/")
|
|
20
|
+
? PATH_WITH_DIRECTORY.test(text)
|
|
21
|
+
: BARE_FILE_NAME.test(text);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Inline `code` renderer. A span that names a file gets a file icon so paths
|
|
26
|
+
* read as paths at a glance; everything else is ordinary inline code.
|
|
27
|
+
*/
|
|
28
|
+
export function MarkdownInlineCode({ children }: ComponentProps<"code"> & ExtraProps) {
|
|
29
|
+
const parts = Children.toArray(children);
|
|
30
|
+
const text = parts.length === 1 && typeof parts[0] === "string" ? parts[0] : null;
|
|
31
|
+
if (text === null || !looksLikeFilePath(text)) {
|
|
32
|
+
return <code className="markdown-inline-code">{children}</code>;
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<code className="markdown-inline-code" data-file title={text}>
|
|
36
|
+
<IconFile aria-hidden />
|
|
37
|
+
{text}
|
|
38
|
+
</code>
|
|
39
|
+
);
|
|
40
|
+
}
|