@@ -5,22 +5,37 @@ import {
55 ChevronRightIcon ,
66 ExternalLinkIcon ,
77 GlobeIcon ,
8+ LaptopIcon ,
89 LoaderCircleIcon ,
10+ MaximizeIcon ,
11+ MonitorIcon ,
912 PlusIcon ,
1013 RefreshCwIcon ,
14+ SmartphoneIcon ,
1115 StarIcon ,
16+ TabletIcon ,
1217 WrenchIcon ,
1318 XIcon ,
1419} from "lucide-react" ;
1520
1621import { validateHttpPreviewUrl } from "@okcode/shared/preview" ;
1722import { readDesktopPreviewBridge } from "~/desktopPreview" ;
23+ import { type BrowserPresetId , BROWSER_PRESETS , getBrowserPreset } from "~/lib/browserPresets" ;
1824import { cn } from "~/lib/utils" ;
1925import { readNativeApi } from "~/nativeApi" ;
2026import { usePreviewStateStore } from "~/previewStateStore" ;
2127
2228import { Button } from "./ui/button" ;
2329import { Input } from "./ui/input" ;
30+ import {
31+ Menu ,
32+ MenuGroupLabel ,
33+ MenuPopup ,
34+ MenuRadioGroup ,
35+ MenuRadioItem ,
36+ MenuSeparator ,
37+ MenuTrigger ,
38+ } from "./ui/menu" ;
2439
2540const EMPTY_TABS_STATE : PreviewTabsState = {
2641 tabs : [ ] ,
@@ -38,6 +53,17 @@ const HIDDEN_PREVIEW_BOUNDS = {
3853 viewportHeight : 0 ,
3954} as const ;
4055
56+ const PRESET_ICONS : Record < BrowserPresetId , typeof SmartphoneIcon > = {
57+ mobile : SmartphoneIcon ,
58+ tablet : TabletIcon ,
59+ laptop : LaptopIcon ,
60+ desktop : MonitorIcon ,
61+ ultrawide : MonitorIcon ,
62+ } ;
63+
64+ /** Sentinel value used by the radio group to represent "no preset" (responsive). */
65+ const RESPONSIVE_VALUE = "__responsive__" ;
66+
4167function getActiveTab ( state : PreviewTabsState ) : PreviewTabState | null {
4268 if ( ! state . activeTabId ) return null ;
4369 return state . tabs . find ( ( t ) => t . tabId === state . activeTabId ) ?? null ;
@@ -66,6 +92,10 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
6692 const setGlobalOpen = usePreviewStateStore ( ( state ) => state . setGlobalOpen ) ;
6793 const favoriteUrls = usePreviewStateStore ( ( state ) => state . favoriteUrls ) ;
6894 const toggleFavoriteUrl = usePreviewStateStore ( ( state ) => state . toggleFavoriteUrl ) ;
95+ const presetId = usePreviewStateStore ( ( state ) => state . presetByThreadId [ threadId ] ?? null ) ;
96+ const setThreadPreset = usePreviewStateStore ( ( state ) => state . setThreadPreset ) ;
97+ const activePreset = presetId ? getBrowserPreset ( presetId ) : null ;
98+ const PresetIcon = presetId ? PRESET_ICONS [ presetId ] : null ;
6999
70100 const [ tabsState , setTabsState ] = useState < PreviewTabsState > ( EMPTY_TABS_STATE ) ;
71101 const [ inputUrl , setInputUrl ] = useState ( "" ) ;
@@ -299,6 +329,61 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
299329 </ form >
300330 </ div >
301331 < div className = "flex items-center gap-1" >
332+ < Menu >
333+ < MenuTrigger
334+ className = { cn (
335+ "inline-flex h-6 cursor-default items-center gap-1 rounded-md px-1.5 text-[11px] transition-colors" ,
336+ presetId
337+ ? "bg-accent/60 text-foreground"
338+ : "text-muted-foreground/55 hover:bg-accent/40 hover:text-foreground" ,
339+ ) }
340+ aria-label = "Viewport preset"
341+ >
342+ { PresetIcon ? (
343+ < PresetIcon className = "size-3" />
344+ ) : (
345+ < MaximizeIcon className = "size-3" />
346+ ) }
347+ < span className = "max-sm:hidden" >
348+ { activePreset ? activePreset . label : "Responsive" }
349+ </ span >
350+ </ MenuTrigger >
351+ < MenuPopup side = "bottom" align = "end" sideOffset = { 6 } >
352+ < MenuGroupLabel > Viewport</ MenuGroupLabel >
353+ < MenuRadioGroup
354+ value = { presetId ?? RESPONSIVE_VALUE }
355+ onValueChange = { ( value ) => {
356+ setThreadPreset (
357+ threadId ,
358+ value === RESPONSIVE_VALUE ? null : ( value as BrowserPresetId ) ,
359+ ) ;
360+ } }
361+ >
362+ < MenuRadioItem value = { RESPONSIVE_VALUE } >
363+ < span className = "flex items-center gap-2" >
364+ < MaximizeIcon className = "size-3.5 opacity-60" />
365+ Responsive
366+ </ span >
367+ </ MenuRadioItem >
368+ < MenuSeparator />
369+ { BROWSER_PRESETS . map ( ( preset ) => {
370+ const Icon = PRESET_ICONS [ preset . id ] ;
371+ return (
372+ < MenuRadioItem key = { preset . id } value = { preset . id } >
373+ < span className = "flex items-center gap-2" >
374+ < Icon className = "size-3.5 opacity-60" />
375+ < span > { preset . label } </ span >
376+ < span className = "ml-auto text-[10px] tabular-nums text-muted-foreground/60" >
377+ { preset . width } ×{ preset . height }
378+ </ span >
379+ </ span >
380+ </ MenuRadioItem >
381+ ) ;
382+ } ) }
383+ </ MenuRadioGroup >
384+ </ MenuPopup >
385+ </ Menu >
386+
302387 < Button
303388 type = "button"
304389 size = "icon-xs"
@@ -418,10 +503,28 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
418503 ) }
419504
420505 { /* Content area */ }
421- < div className = "flex min-h-0 flex-1 flex-col p-3" >
506+ < div
507+ className = { cn (
508+ "flex min-h-0 flex-1 p-3" ,
509+ activePreset ? "items-center justify-center" : "flex-col" ,
510+ ) }
511+ >
422512 < div
423513 ref = { surfaceRef }
424- className = "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border/70 bg-card/20"
514+ className = { cn (
515+ "relative overflow-hidden rounded-lg border border-border/70 bg-card/20" ,
516+ ! activePreset && "min-h-0 flex-1" ,
517+ ) }
518+ style = {
519+ activePreset
520+ ? {
521+ width : activePreset . width ,
522+ height : activePreset . height ,
523+ maxWidth : "100%" ,
524+ maxHeight : "100%" ,
525+ }
526+ : undefined
527+ }
425528 >
426529 { ! showEmbeddedSurface ? (
427530 < div className = "flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground/70" >
0 commit comments