@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.
- package/README.md +251 -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/speech-input.tsx +5 -3
- 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/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 ? (
|