@vvedantb/ui 0.17.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) 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/speech-input.tsx +5 -3
  33. package/src/ai-elements/test-results.tsx +510 -0
  34. package/src/ai-elements/trace.tsx +243 -0
  35. package/src/ai-elements/use-streaming-disclosure.ts +50 -0
  36. package/src/ai-elements/web-preview.tsx +349 -0
  37. package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
  38. package/src/index.ts +66 -12
  39. package/src/kibo/avatar-stack.tsx +40 -0
  40. package/src/kibo/contribution-graph.tsx +539 -0
  41. package/src/kibo/cursor.tsx +72 -0
  42. package/src/kibo/gantt-features.tsx +439 -0
  43. package/src/kibo/gantt-header.tsx +181 -0
  44. package/src/kibo/gantt-provider.tsx +657 -0
  45. package/src/kibo/gantt-sidebar.tsx +157 -0
  46. package/src/kibo/gantt-timeline.tsx +154 -0
  47. package/src/kibo/index.ts +151 -0
  48. package/src/kibo/kanban.tsx +338 -0
  49. package/src/kibo/kanbanDropTarget.ts +25 -0
  50. package/src/kibo/list.tsx +162 -0
  51. package/src/kibo/theme-switcher.tsx +109 -0
  52. package/src/kibo/video-player.tsx +237 -0
  53. package/src/markdown/Markdown.tsx +112 -0
  54. package/src/markdown/MarkdownBlockquote.tsx +45 -0
  55. package/src/markdown/MarkdownCodeBlock.tsx +197 -0
  56. package/src/markdown/MarkdownInlineCode.tsx +40 -0
  57. package/src/markdown/MarkdownLink.tsx +92 -0
  58. package/src/markdown/codeFence.ts +57 -0
  59. package/src/markdown/codeHighlight.ts +138 -0
  60. package/src/markdown/incrementalBlocks.ts +182 -0
  61. package/src/markdown/index.ts +3 -0
  62. package/src/markdown/markdownClipboard.ts +178 -0
  63. package/src/markdown/mermaid.ts +155 -0
  64. package/src/markdown/rehypeGithubAlerts.ts +80 -0
  65. package/src/motion/presets.ts +24 -0
  66. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
  67. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
  68. package/src/ui/_menu-parts.tsx +29 -0
  69. package/src/ui/accordion.alt.tsx +46 -53
  70. package/src/ui/accordion.tsx +59 -10
  71. package/src/ui/avatar.tsx +56 -0
  72. package/src/ui/badge.tsx +45 -21
  73. package/src/ui/border-beam.tsx +97 -0
  74. package/src/ui/button-group.tsx +50 -21
  75. package/src/ui/button.tsx +67 -45
  76. package/src/ui/calendar.tsx +48 -5
  77. package/src/ui/card.tsx +77 -8
  78. package/src/ui/carousel.tsx +60 -0
  79. package/src/ui/centered-spinner.tsx +30 -0
  80. package/src/ui/checkbox.tsx +34 -16
  81. package/src/ui/clear-input.alt.tsx +33 -75
  82. package/src/ui/clear-input.tsx +140 -15
  83. package/src/ui/collapsible.tsx +40 -2
  84. package/src/ui/command.tsx +143 -70
  85. package/src/ui/context-menu.tsx +159 -93
  86. package/src/ui/crossfade-icon.tsx +123 -0
  87. package/src/ui/dialog.tsx +154 -70
  88. package/src/ui/dropdown-menu.tsx +177 -117
  89. package/src/ui/hover-card.tsx +59 -17
  90. package/src/ui/input-group.alt.tsx +83 -83
  91. package/src/ui/input-group.tsx +129 -58
  92. package/src/ui/input.tsx +13 -4
  93. package/src/ui/label.tsx +11 -8
  94. package/src/ui/list-row.tsx +232 -0
  95. package/src/ui/loading-state.tsx +215 -0
  96. package/src/ui/popover.tsx +56 -23
  97. package/src/ui/progress.tsx +28 -16
  98. package/src/ui/refresh-spin-icon.tsx +41 -0
  99. package/src/ui/search-input.tsx +66 -15
  100. package/src/ui/select.tsx +171 -100
  101. package/src/ui/separator.tsx +23 -14
  102. package/src/ui/slider.tsx +21 -7
  103. package/src/ui/sonner.tsx +68 -30
  104. package/src/ui/spinner.tsx +229 -11
  105. package/src/ui/status-dot.tsx +84 -0
  106. package/src/ui/surface.tsx +49 -0
  107. package/src/ui/switch.tsx +29 -15
  108. package/src/ui/table.tsx +38 -16
  109. package/src/ui/tabs.tsx +123 -35
  110. package/src/ui/tabsSliding.ts +33 -1
  111. package/src/ui/textarea.tsx +13 -4
  112. package/src/ui/tooltip.tsx +50 -14
  113. package/src/ui/ui-config.tsx +149 -0
  114. package/src/utils/cat-color.ts +123 -0
  115. package/src/utils/dayjs.ts +6 -0
  116. package/src/utils/gesture.ts +59 -0
  117. package/src/utils/runtimeVisibility.ts +167 -0
  118. package/src/utils/sharedClock.ts +96 -0
  119. package/src/utils/surface-radius.ts +16 -0
  120. package/src/utils/useDragSensors.ts +58 -0
  121. package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
  122. package/tokens/examples/tokens-moved/README.md +20 -0
  123. package/tokens/examples/tokens-moved/index.html +12 -0
  124. package/tokens/examples/tokens-moved/main.tsx +6 -0
  125. package/tokens/examples/tokens-moved/package.json +14 -0
  126. package/tokens/examples/tokens-moved/styles.css +12 -0
  127. package/tokens/examples/tokens-moved/tsconfig.json +14 -0
  128. package/tokens/examples/tokens-moved/vite.config.ts +11 -0
  129. package/tokens/src/css/border-beam.css +116 -0
  130. 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
+ }