@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.
Files changed (130) hide show
  1. package/README.md +253 -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/compat-shadcn-rgb.css +6 -0
  130. package/tokens/src/css/markdown.css +318 -0
@@ -0,0 +1,243 @@
1
+ "use client";
2
+
3
+ import type { ComponentProps, ReactNode } from "react";
4
+ import { IconChevronRight } from "@tabler/icons-react";
5
+
6
+ import {
7
+ Collapsible,
8
+ CollapsibleContent,
9
+ CollapsibleTrigger,
10
+ } from "../ui/collapsible";
11
+ import { CrossfadeIconSlot } from "../ui/crossfade-icon";
12
+ import { useCn } from "../ui/ui-config";
13
+ import { Shimmer } from "./shimmer";
14
+
15
+ /**
16
+ * One agent step as one line: icon, title, optional faded description, and an
17
+ * optional fold. Reasoning, tool calls, grouped calls, subagents, to-dos and
18
+ * sandbox startup stages all render through these parts.
19
+ *
20
+ * Adapted from shadcn/ui Trace (October 2026). Kept from our side: the
21
+ * compositor `Shimmer` (Trace's CSS shimmer repaints the text every frame), the
22
+ * measured-height `CollapsibleContent`, and `CrossfadeIconSlot` icon swaps.
23
+ */
24
+
25
+ export type AgentTraceStatus = "running" | "error" | "pending";
26
+
27
+ export function AgentTrace({ className, ...props }: ComponentProps<"div">) {
28
+ const merge = useCn();
29
+ return (
30
+ <div
31
+ data-slot="trace"
32
+ className={merge("flex w-full min-w-0 flex-col gap-2 text-sm", className)}
33
+ {...props}
34
+ />
35
+ );
36
+ }
37
+
38
+ export function AgentTraceLine({
39
+ className,
40
+ status,
41
+ ...props
42
+ }: ComponentProps<"span"> & { status?: AgentTraceStatus }) {
43
+ const merge = useCn();
44
+ return (
45
+ <span
46
+ data-slot="trace-line"
47
+ data-status={status}
48
+ className={merge(
49
+ "flex w-fit max-w-full min-w-0 items-center gap-2 text-muted-foreground text-sm transition-colors",
50
+ "in-[button:hover]:text-foreground",
51
+ "data-[status=error]:text-destructive data-[status=error]:in-[button:hover]:text-destructive",
52
+ "data-[status=pending]:opacity-60",
53
+ className,
54
+ )}
55
+ {...props}
56
+ />
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Pass `iconKey` to crossfade when the glyph or status changes (a spinner
62
+ * turning into a check); leave it out for a static glyph.
63
+ */
64
+ export function AgentTraceIcon({
65
+ className,
66
+ iconKey,
67
+ children,
68
+ }: {
69
+ className?: string;
70
+ iconKey?: string;
71
+ children: ReactNode;
72
+ }) {
73
+ const merge = useCn();
74
+ const classes = merge(
75
+ "relative flex size-4 shrink-0 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
76
+ className,
77
+ );
78
+ if (iconKey === undefined) {
79
+ return (
80
+ <span data-slot="trace-icon" aria-hidden="true" className={classes}>
81
+ {children}
82
+ </span>
83
+ );
84
+ }
85
+ return (
86
+ <CrossfadeIconSlot iconKey={iconKey} variant="soft" className={classes}>
87
+ {children}
88
+ </CrossfadeIconSlot>
89
+ );
90
+ }
91
+
92
+ export function AgentTraceTitle({
93
+ className,
94
+ shimmer = false,
95
+ children,
96
+ title,
97
+ }: {
98
+ className?: string;
99
+ /** Sweep a highlight across the title while the step runs. */
100
+ shimmer?: boolean;
101
+ children: string;
102
+ title?: string;
103
+ }) {
104
+ const merge = useCn();
105
+ return (
106
+ <span
107
+ data-slot="trace-title"
108
+ title={title}
109
+ className={merge("min-w-0 truncate", className)}
110
+ >
111
+ {shimmer ? (
112
+ <Shimmer as="span" duration={2.5} spread={1.5}>
113
+ {children}
114
+ </Shimmer>
115
+ ) : (
116
+ children
117
+ )}
118
+ </span>
119
+ );
120
+ }
121
+
122
+ export function AgentTraceDescription({
123
+ className,
124
+ ...props
125
+ }: ComponentProps<"span">) {
126
+ const merge = useCn();
127
+ return (
128
+ <span
129
+ data-slot="trace-description"
130
+ className={merge("shrink-0 tabular-nums opacity-70", className)}
131
+ {...props}
132
+ />
133
+ );
134
+ }
135
+
136
+ export function AgentTraceCollapsible({
137
+ className,
138
+ ...props
139
+ }: ComponentProps<typeof Collapsible>) {
140
+ const merge = useCn();
141
+ return (
142
+ <Collapsible
143
+ data-slot="trace-collapsible"
144
+ className={merge("flex w-full min-w-0 flex-col", className)}
145
+ {...props}
146
+ />
147
+ );
148
+ }
149
+
150
+ const TRIGGER_FOCUS =
151
+ "rounded-md focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/45";
152
+
153
+ /**
154
+ * `trailing` holds controls of their own (a clickable file chip). A button may
155
+ * not contain another button, so they render beside the trigger, not inside
156
+ * it. The trigger's `after:` overlay still makes the whole line clickable;
157
+ * buttons in `trailing` sit above that overlay so their own clicks
158
+ * land, and plain text in it lets clicks through to the trigger.
159
+ */
160
+ export function AgentTraceCollapsibleTrigger({
161
+ className,
162
+ children,
163
+ trailing,
164
+ ...props
165
+ }: ComponentProps<typeof CollapsibleTrigger> & { trailing?: ReactNode }) {
166
+ const merge = useCn();
167
+ const chevronClass = "transition-transform";
168
+ if (trailing === undefined) {
169
+ return (
170
+ <CollapsibleTrigger
171
+ data-slot="trace-collapsible-trigger"
172
+ className={merge(
173
+ "group/trace-trigger flex w-fit max-w-full min-w-0 items-center gap-1 text-left text-muted-foreground transition-colors hover:text-foreground has-[[data-status=error]]:text-destructive",
174
+ TRIGGER_FOCUS,
175
+ className,
176
+ )}
177
+ {...props}
178
+ >
179
+ {children}
180
+ <AgentTraceIcon
181
+ className={merge(chevronClass, "group-aria-expanded/trace-trigger:rotate-90")}
182
+ >
183
+ <IconChevronRight />
184
+ </AgentTraceIcon>
185
+ </CollapsibleTrigger>
186
+ );
187
+ }
188
+ return (
189
+ <div
190
+ data-slot="trace-collapsible-trigger"
191
+ className={merge(
192
+ "relative flex w-fit max-w-full min-w-0 items-center gap-2 text-muted-foreground transition-colors hover:text-foreground has-[[data-status=error]]:text-destructive",
193
+ className,
194
+ )}
195
+ >
196
+ <CollapsibleTrigger
197
+ className={merge(
198
+ "peer/trace-trigger flex min-w-0 items-center text-left after:absolute after:inset-0",
199
+ TRIGGER_FOCUS,
200
+ )}
201
+ {...props}
202
+ >
203
+ {children}
204
+ </CollapsibleTrigger>
205
+ <span className="pointer-events-none relative z-10 flex min-w-0 items-center gap-2 [&_button]:pointer-events-auto">
206
+ {trailing}
207
+ </span>
208
+ <AgentTraceIcon
209
+ className={merge(
210
+ chevronClass,
211
+ "pointer-events-none -ml-1 peer-aria-expanded/trace-trigger:rotate-90",
212
+ )}
213
+ >
214
+ <IconChevronRight />
215
+ </AgentTraceIcon>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ /** Folded body, indented behind a rail that starts under the icon's centre. */
221
+ export function AgentTraceCollapsibleContent({
222
+ className,
223
+ children,
224
+ ...props
225
+ }: ComponentProps<typeof CollapsibleContent>) {
226
+ const merge = useCn();
227
+ return (
228
+ <CollapsibleContent
229
+ data-slot="trace-collapsible-content"
230
+ className="min-w-0"
231
+ {...props}
232
+ >
233
+ <div
234
+ className={merge(
235
+ "mt-2 mb-1 ml-[calc(--spacing(2)-1px)] min-w-0 border-l border-border pr-1 pl-4 leading-relaxed wrap-break-word text-muted-foreground",
236
+ className,
237
+ )}
238
+ >
239
+ {children}
240
+ </div>
241
+ </CollapsibleContent>
242
+ );
243
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ const AUTO_CLOSE_DELAY_MS = 1000;
6
+
7
+ /**
8
+ * Open state for a fold that follows a live stream: it opens when streaming
9
+ * starts so the text is readable as it lands, and closes once, a second after
10
+ * streaming stops. A reader's own toggle cancels a pending auto-close.
11
+ */
12
+ export function useStreamingDisclosure(isStreaming: boolean): {
13
+ open: boolean;
14
+ onOpenChange: (open: boolean) => void;
15
+ } {
16
+ const [open, setOpen] = useState(isStreaming);
17
+ const [wasStreaming, setWasStreaming] = useState(isStreaming);
18
+ const [closeDue, setCloseDue] = useState(false);
19
+
20
+ if (isStreaming !== wasStreaming) {
21
+ setWasStreaming(isStreaming);
22
+ if (isStreaming) {
23
+ setOpen(true);
24
+ setCloseDue(false);
25
+ } else {
26
+ setCloseDue(true);
27
+ }
28
+ }
29
+
30
+ /* eslint-disable no-effect/no-event-handler --
31
+ The close is a delayed reaction to the stream stopping, not to a click:
32
+ only a timer can express "one second later". */
33
+ useEffect(() => {
34
+ if (!closeDue) return;
35
+ const timer = setTimeout(() => {
36
+ setOpen(false);
37
+ setCloseDue(false);
38
+ }, AUTO_CLOSE_DELAY_MS);
39
+ return () => clearTimeout(timer);
40
+ }, [closeDue]);
41
+ /* eslint-enable no-effect/no-event-handler */
42
+
43
+ return {
44
+ open,
45
+ onOpenChange: (next) => {
46
+ setCloseDue(false);
47
+ setOpen(next);
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,349 @@
1
+ "use client";
2
+
3
+ import type { ComponentProps, ReactNode, Ref, RefObject } from "react";
4
+
5
+ import { Button } from "../ui/button";
6
+ import { Input } from "../ui/input";
7
+ import { Tabs, TabsList, TabsTrigger } from "../ui/tabs";
8
+ import {
9
+ Tooltip,
10
+ TooltipContent,
11
+ TooltipProvider,
12
+ TooltipTrigger,
13
+ } from "../ui/tooltip";
14
+ import { IconBug, IconTerminal2 } from "@tabler/icons-react";
15
+ import { useCn } from "../ui/ui-config";
16
+ import { SURFACE_RADIUS_CLASS } from "../utils/surface-radius";
17
+ import {
18
+ createContext,
19
+ useCallback,
20
+ useContext,
21
+ useMemo,
22
+ useRef,
23
+ useState,
24
+ } from "react";
25
+
26
+ export interface WebPreviewContextValue {
27
+ url: string;
28
+ setUrl: (url: string) => void;
29
+ iframeRef: RefObject<HTMLIFrameElement | null>;
30
+ reload: () => void;
31
+ goBack: () => void;
32
+ goForward: () => void;
33
+ }
34
+
35
+ const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
36
+
37
+ export const useWebPreview = () => {
38
+ const context = useContext(WebPreviewContext);
39
+ if (!context) {
40
+ throw new Error("WebPreview components must be used within a WebPreview");
41
+ }
42
+ return context;
43
+ };
44
+
45
+ export type WebPreviewProps = ComponentProps<"div"> & {
46
+ defaultUrl?: string;
47
+ onUrlChange?: (url: string) => void;
48
+ ref?: Ref<HTMLDivElement>;
49
+ };
50
+
51
+ export const WebPreview = ({
52
+ className,
53
+ children,
54
+ defaultUrl = "",
55
+ onUrlChange,
56
+ ref,
57
+ ...props
58
+ }: WebPreviewProps) => {
59
+ const merge = useCn();
60
+ const [url, setUrl] = useState(defaultUrl);
61
+ const [, setReloadKey] = useState(0);
62
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
63
+
64
+ const handleUrlChange = useCallback(
65
+ (newUrl: string) => {
66
+ setUrl(newUrl);
67
+ onUrlChange?.(newUrl);
68
+ },
69
+ [onUrlChange],
70
+ );
71
+
72
+ const reload = useCallback(() => setReloadKey((k) => k + 1), []);
73
+
74
+ const goBack = useCallback(() => {
75
+ try {
76
+ iframeRef.current?.contentWindow?.history.back();
77
+ } catch {}
78
+ }, []);
79
+
80
+ const goForward = useCallback(() => {
81
+ try {
82
+ iframeRef.current?.contentWindow?.history.forward();
83
+ } catch {}
84
+ }, []);
85
+
86
+ const contextValue = useMemo<WebPreviewContextValue>(
87
+ () => ({
88
+ setUrl: handleUrlChange,
89
+ url,
90
+ iframeRef,
91
+ reload,
92
+ goBack,
93
+ goForward,
94
+ }),
95
+ [handleUrlChange, url, reload, goBack, goForward],
96
+ );
97
+
98
+ return (
99
+ <WebPreviewContext.Provider value={contextValue}>
100
+ <div
101
+ ref={ref}
102
+ className={merge(
103
+ "flex size-full flex-col border bg-card",
104
+ SURFACE_RADIUS_CLASS,
105
+ className,
106
+ )}
107
+ {...props}
108
+ >
109
+ {children}
110
+ </div>
111
+ </WebPreviewContext.Provider>
112
+ );
113
+ };
114
+
115
+ export type WebPreviewNavigationProps = ComponentProps<"div">;
116
+
117
+ export const WebPreviewNavigation = ({
118
+ className,
119
+ children,
120
+ ...props
121
+ }: WebPreviewNavigationProps) => {
122
+ const merge = useCn();
123
+ return (
124
+ <div
125
+ // `flex-wrap`: the nav holds back/forward/reload plus a URL field and a
126
+ // device toggle, which is wider than a phone-width preview pane. Without it
127
+ // the row overflows its container rather than taking a second line.
128
+ className={merge("flex items-center gap-1 border-b p-2 max-sm:flex-wrap", className)}
129
+ {...props}
130
+ >
131
+ {children}
132
+ </div>
133
+ );
134
+ };
135
+
136
+ export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
137
+ tooltip?: string;
138
+ };
139
+
140
+ export const WebPreviewNavigationButton = ({
141
+ onClick,
142
+ disabled,
143
+ tooltip,
144
+ children,
145
+ ...props
146
+ }: WebPreviewNavigationButtonProps) => (
147
+ <TooltipProvider>
148
+ <Tooltip>
149
+ <TooltipTrigger asChild>
150
+ <Button
151
+ className="h-8 w-8 p-0 hover:text-foreground"
152
+ disabled={disabled}
153
+ onClick={onClick}
154
+ size="sm"
155
+ variant="ghost"
156
+ // These buttons are icon-only, and a `TooltipContent` is wired up as
157
+ // `aria-describedby` — a description, not a name — so without this
158
+ // every preview nav control is announced as just "button". Placed
159
+ // before the spread so a call site can still override it.
160
+ aria-label={tooltip}
161
+ {...props}
162
+ >
163
+ {children}
164
+ </Button>
165
+ </TooltipTrigger>
166
+ <TooltipContent>
167
+ <p>{tooltip}</p>
168
+ </TooltipContent>
169
+ </Tooltip>
170
+ </TooltipProvider>
171
+ );
172
+
173
+ export type WebPreviewUrlProps = ComponentProps<typeof Input>;
174
+
175
+ export const WebPreviewUrl = ({
176
+ value,
177
+ onChange,
178
+ onKeyDown,
179
+ ...props
180
+ }: WebPreviewUrlProps) => {
181
+ const { url, setUrl } = useWebPreview();
182
+ const [prevUrl, setPrevUrl] = useState(url);
183
+ const [inputValue, setInputValue] = useState(url);
184
+
185
+ if (url !== prevUrl) {
186
+ setPrevUrl(url);
187
+ setInputValue(url);
188
+ }
189
+
190
+ const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
191
+ setInputValue(event.target.value);
192
+ onChange?.(event);
193
+ };
194
+
195
+ const handleKeyDown = useCallback(
196
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
197
+ if (event.key === "Enter") {
198
+ setUrl(event.currentTarget.value);
199
+ }
200
+ onKeyDown?.(event);
201
+ },
202
+ [setUrl, onKeyDown],
203
+ );
204
+
205
+ return (
206
+ <Input
207
+ className="h-8 flex-1 text-sm"
208
+ onChange={onChange ?? handleChange}
209
+ onKeyDown={handleKeyDown}
210
+ placeholder="Enter URL..."
211
+ value={value ?? inputValue}
212
+ {...props}
213
+ />
214
+ );
215
+ };
216
+
217
+ export type WebPreviewBodyProps = Omit<ComponentProps<"iframe">, "loading"> & {
218
+ loading?: ReactNode;
219
+ };
220
+
221
+ function ensureHttps(url: string): string {
222
+ try {
223
+ const parsed = new URL(url);
224
+ if (parsed.protocol === "http:") {
225
+ parsed.protocol = "https:";
226
+ }
227
+ return parsed.toString();
228
+ } catch {
229
+ return url;
230
+ }
231
+ }
232
+
233
+ export const WebPreviewBody = ({
234
+ className,
235
+ loading,
236
+ src,
237
+ ...props
238
+ }: WebPreviewBodyProps) => {
239
+ const merge = useCn();
240
+ const { url, iframeRef } = useWebPreview();
241
+ const rawSrc = (src ?? url) || undefined;
242
+ const safeSrc = rawSrc ? ensureHttps(rawSrc) : undefined;
243
+
244
+ return (
245
+ <div className="flex-1 h-full relative overflow-hidden min-h-0">
246
+ <iframe
247
+ ref={iframeRef}
248
+ className={merge("size-full", className)}
249
+ src={safeSrc}
250
+ title="Preview"
251
+ {...props}
252
+ />
253
+ {loading}
254
+ </div>
255
+ );
256
+ };
257
+
258
+ export type WebPreviewConsoleProps = ComponentProps<"div"> & {
259
+ logs?: {
260
+ level: "log" | "warn" | "error";
261
+ message: string;
262
+ timestamp: Date;
263
+ }[];
264
+ terminal?: ReactNode;
265
+ };
266
+
267
+ export const WebPreviewConsole = ({
268
+ className,
269
+ logs = [],
270
+ terminal,
271
+ children,
272
+ ...props
273
+ }: WebPreviewConsoleProps) => {
274
+ const merge = useCn();
275
+ const [activeTab, setActiveTab] = useState<"console" | "terminal">(
276
+ terminal ? "terminal" : "console",
277
+ );
278
+
279
+ return (
280
+ <div className={merge("border-t bg-surface-secondary/50 text-sm", className)} {...props}>
281
+ <Tabs
282
+ value={activeTab}
283
+ onValueChange={(v) => {
284
+ if (v === "console" || v === "terminal") setActiveTab(v);
285
+ }}
286
+ className="h-full flex flex-col"
287
+ >
288
+ <TabsList className="w-full justify-start rounded-none bg-transparent px-2 h-9">
289
+ <TabsTrigger
290
+ value="console"
291
+ className="text-xs px-3 rounded-none gap-1.5"
292
+ >
293
+ <IconBug className="w-3.5 h-3.5" />
294
+ Console
295
+ </TabsTrigger>
296
+ {terminal && (
297
+ <TabsTrigger
298
+ value="terminal"
299
+ className="text-xs px-3 rounded-none gap-1.5"
300
+ >
301
+ <IconTerminal2 className="w-3.5 h-3.5" />
302
+ Terminal
303
+ </TabsTrigger>
304
+ )}
305
+ </TabsList>
306
+ <div className="flex-1 min-h-0">
307
+ <div
308
+ className={merge(
309
+ "h-64 space-y-1 overflow-y-auto scrollbar px-4 py-3 font-mono",
310
+ activeTab !== "console" && "hidden",
311
+ )}
312
+ >
313
+ {logs.length === 0 ? (
314
+ <p className="text-muted-foreground text-xs">No console output</p>
315
+ ) : (
316
+ logs.map((log, index) => (
317
+ <div
318
+ className={merge(
319
+ "text-xs",
320
+ log.level === "error" && "text-destructive",
321
+ log.level === "warn" && "text-warning",
322
+ log.level === "log" && "text-foreground",
323
+ )}
324
+ key={`${log.timestamp.getTime()}-${index}`}
325
+ >
326
+ <span className="text-muted-foreground">
327
+ {log.timestamp.toLocaleTimeString()}
328
+ </span>{" "}
329
+ {log.message}
330
+ </div>
331
+ ))
332
+ )}
333
+ {children}
334
+ </div>
335
+ {terminal && (
336
+ <div
337
+ className={merge(
338
+ "h-64 overflow-y-auto scrollbar",
339
+ activeTab !== "terminal" && "hidden",
340
+ )}
341
+ >
342
+ {terminal}
343
+ </div>
344
+ )}
345
+ </div>
346
+ </Tabs>
347
+ </div>
348
+ );
349
+ };
@@ -112,7 +112,7 @@ export function ComposerSlashMenu({
112
112
  "transition-[background-color] active:scale-[0.99] active:duration-100 active:ease-out " +
113
113
  (isSelected
114
114
  ? "bg-primary/15 font-medium text-foreground dark:bg-primary/25"
115
- : "text-foreground/90 hover:bg-muted")
115
+ : "text-foreground/90 hover:bg-muted") // check-tokens-allow: muted
116
116
  }
117
117
  >
118
118
  {renderItem ? (