@tpitre/story-ui 2.3.2 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpitre/story-ui",
3
- "version": "2.3.2",
3
+ "version": "2.5.0",
4
4
  "description": "AI-powered Storybook story generator with dynamic component discovery",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import React, { useState, useEffect, useRef, useCallback } from 'react';
9
+ import * as Babel from '@babel/standalone';
9
10
  import { LivePreviewRenderer } from './LivePreviewRenderer';
10
11
  import { availableComponents } from './componentRegistry';
11
12
  import { aiConsiderations, hasConsiderations } from './considerations';
@@ -160,22 +161,22 @@ const useResizable = (initialWidth: number, minWidth: number, maxWidth: number)
160
161
  return { width, startResize };
161
162
  };
162
163
 
163
- // Default models for fallback
164
+ // Default models for fallback - Updated November 2025
164
165
  const DEFAULT_MODELS: Record<string, ModelOption[]> = {
165
166
  claude: [
166
- { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', provider: 'claude' },
167
- { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'claude' },
168
- { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', provider: 'claude' },
167
+ { id: 'claude-opus-4-5', name: 'Opus 4.5', provider: 'claude' },
168
+ { id: 'claude-sonnet-4-5', name: 'Sonnet 4.5', provider: 'claude' },
169
+ { id: 'claude-haiku-4-5', name: 'Haiku 4.5', provider: 'claude' },
169
170
  ],
170
171
  openai: [
171
- { id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' },
172
- { id: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'openai' },
173
- { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'openai' },
172
+ { id: 'gpt-5.1', name: 'GPT-5.1', provider: 'openai' },
173
+ { id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'openai' },
174
+ { id: 'gpt-5-nano', name: 'GPT-5 Nano', provider: 'openai' },
174
175
  ],
175
176
  gemini: [
176
- { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', provider: 'gemini' },
177
- { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', provider: 'gemini' },
178
- { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', provider: 'gemini' },
177
+ { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', provider: 'gemini' },
178
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'gemini' },
179
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'gemini' },
179
180
  ],
180
181
  };
181
182
 
@@ -314,6 +315,11 @@ const Icons = {
314
315
  <path d="M6 9l6 6 6-6" />
315
316
  </svg>
316
317
  ),
318
+ ExternalLink: () => (
319
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
320
+ <path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
321
+ </svg>
322
+ ),
317
323
  };
318
324
 
319
325
  // ============================================================================
@@ -551,8 +557,38 @@ const ImageUploadArea: React.FC<{
551
557
  );
552
558
  };
553
559
 
560
+ // Load Prism.js dynamically for syntax highlighting
561
+ const loadPrism = (): Promise<void> => {
562
+ return new Promise((resolve) => {
563
+ if ((window as any).Prism) {
564
+ resolve();
565
+ return;
566
+ }
567
+
568
+ // Load CSS
569
+ const link = document.createElement('link');
570
+ link.rel = 'stylesheet';
571
+ link.href = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css';
572
+ document.head.appendChild(link);
573
+
574
+ // Load Prism core
575
+ const script = document.createElement('script');
576
+ script.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js';
577
+ script.onload = () => {
578
+ // Load JSX component after core
579
+ const jsxScript = document.createElement('script');
580
+ jsxScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js';
581
+ jsxScript.onload = () => resolve();
582
+ document.head.appendChild(jsxScript);
583
+ };
584
+ document.head.appendChild(script);
585
+ });
586
+ };
587
+
554
588
  const CodeViewer: React.FC<{ code: string }> = ({ code }) => {
555
589
  const [copied, setCopied] = useState(false);
590
+ const [prismLoaded, setPrismLoaded] = useState(false);
591
+ const codeRef = useRef<HTMLElement>(null);
556
592
 
557
593
  const handleCopy = async () => {
558
594
  await navigator.clipboard.writeText(code);
@@ -560,6 +596,18 @@ const CodeViewer: React.FC<{ code: string }> = ({ code }) => {
560
596
  setTimeout(() => setCopied(false), 2000);
561
597
  };
562
598
 
599
+ // Load Prism on mount
600
+ useEffect(() => {
601
+ loadPrism().then(() => setPrismLoaded(true));
602
+ }, []);
603
+
604
+ // Apply Prism syntax highlighting when code changes or Prism loads
605
+ useEffect(() => {
606
+ if (codeRef.current && prismLoaded && (window as any).Prism) {
607
+ (window as any).Prism.highlightElement(codeRef.current);
608
+ }
609
+ }, [code, prismLoaded]);
610
+
563
611
  return (
564
612
  <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
565
613
  <div style={{
@@ -599,21 +647,97 @@ const CodeViewer: React.FC<{ code: string }> = ({ code }) => {
599
647
  fontSize: '13px',
600
648
  lineHeight: 1.6,
601
649
  fontFamily: '"Fira Code", "SF Mono", Monaco, monospace',
602
- background: '#0d1117',
603
- color: '#e6edf3',
650
+ background: '#1d1f21',
651
+ borderRadius: 0,
604
652
  }}
605
653
  >
606
- {code}
654
+ <code ref={codeRef} className="language-jsx">
655
+ {code}
656
+ </code>
607
657
  </pre>
608
658
  </div>
609
659
  );
610
660
  };
611
661
 
662
+ // ============================================================================
663
+ // POPOUT MODE COMPONENT
664
+ // ============================================================================
665
+
666
+ // Check if we're in popout mode (loaded in iframe for full-width preview)
667
+ const isPopoutMode = new URLSearchParams(window.location.search).get('popout') === 'true';
668
+
669
+ /**
670
+ * Popout Preview Component
671
+ *
672
+ * This is a minimal component that renders ONLY the LivePreviewRenderer.
673
+ * It listens for postMessage from the parent window to receive the JSX code.
674
+ *
675
+ * This approach is completely design-system agnostic because:
676
+ * - It uses the same bundled CSS (whatever library the user has)
677
+ * - It uses the same component registry
678
+ * - No framework-specific code needed
679
+ */
680
+ const PopoutPreview: React.FC = () => {
681
+ const [code, setCode] = useState<string | null>(null);
682
+
683
+ useEffect(() => {
684
+ // Listen for the preview code from the parent window
685
+ const handleMessage = (event: MessageEvent) => {
686
+ // Verify the message is from our parent and has the right type
687
+ if (event.data && event.data.type === 'PREVIEW_CODE' && typeof event.data.code === 'string') {
688
+ setCode(event.data.code);
689
+ }
690
+ };
691
+
692
+ window.addEventListener('message', handleMessage);
693
+ return () => window.removeEventListener('message', handleMessage);
694
+ }, []);
695
+
696
+ // Simple full-viewport layout for the preview
697
+ return (
698
+ <div style={{
699
+ width: '100%',
700
+ height: '100vh',
701
+ overflow: 'auto',
702
+ background: THEME.bgSurface,
703
+ }}>
704
+ {code ? (
705
+ <LivePreviewRenderer
706
+ code={code}
707
+ containerStyle={{
708
+ width: '100%',
709
+ minHeight: '100%',
710
+ padding: '24px',
711
+ background: THEME.bgSurface
712
+ }}
713
+ onError={(err) => console.error('Preview error:', err)}
714
+ />
715
+ ) : (
716
+ <div style={{
717
+ display: 'flex',
718
+ alignItems: 'center',
719
+ justifyContent: 'center',
720
+ height: '100%',
721
+ color: THEME.textMuted,
722
+ fontSize: '14px',
723
+ }}>
724
+ Loading preview...
725
+ </div>
726
+ )}
727
+ </div>
728
+ );
729
+ };
730
+
612
731
  // ============================================================================
613
732
  // MAIN APP
614
733
  // ============================================================================
615
734
 
616
735
  const App: React.FC = () => {
736
+ // If we're in popout mode, render only the preview component
737
+ if (isPopoutMode) {
738
+ return <PopoutPreview />;
739
+ }
740
+
617
741
  // Server configuration (providers, models)
618
742
  const serverConfig = useServerConfig();
619
743
 
@@ -628,6 +752,7 @@ const App: React.FC = () => {
628
752
 
629
753
  const messagesEndRef = useRef<HTMLDivElement>(null);
630
754
  const inputRef = useRef<HTMLTextAreaElement>(null);
755
+ const previewContainerRef = useRef<HTMLDivElement>(null);
631
756
  const { width: chatWidth, startResize } = useResizable(400, 320, 600);
632
757
 
633
758
  const activeConversation = conversations.find(c => c.id === activeConversationId);
@@ -687,27 +812,94 @@ const App: React.FC = () => {
687
812
  }
688
813
  };
689
814
 
690
- const generateComponent = async (prompt: string, imageAttachments: ImageAttachment[]): Promise<string> => {
691
- // Get the last generated code from conversation for iteration context
692
- const lastGeneratedCode = activeConversation?.messages
815
+ /**
816
+ * Open preview in a new window with an iframe for full-width viewing.
817
+ *
818
+ * This approach is completely design-system agnostic:
819
+ * - The iframe loads the same production app with ?popout=true
820
+ * - All CSS (whatever library) is loaded automatically
821
+ * - All components from the registry are available
822
+ * - Only uses postMessage to pass the JSX code
823
+ */
824
+ const openPreviewInNewWindow = () => {
825
+ const newWindow = window.open('', '_blank');
826
+ if (!newWindow) {
827
+ console.error('Could not open new window - popup may be blocked');
828
+ return;
829
+ }
830
+
831
+ // Get the base URL for the popout iframe
832
+ const popoutUrl = `${window.location.origin}${window.location.pathname}?popout=true`;
833
+
834
+ // Create a minimal HTML wrapper with an iframe
835
+ // The iframe loads the same app in popout mode, ensuring all styles work
836
+ const html = `<!DOCTYPE html>
837
+ <html>
838
+ <head>
839
+ <meta charset="UTF-8">
840
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
841
+ <title>Story UI - Full Width Preview</title>
842
+ <style>
843
+ * { box-sizing: border-box; margin: 0; padding: 0; }
844
+ html, body { height: 100%; background: ${THEME.bg}; font-family: system-ui, sans-serif; }
845
+ .header {
846
+ position: fixed; top: 0; left: 0; right: 0; height: 48px;
847
+ padding: 0 24px; background: ${THEME.bgElevated};
848
+ border-bottom: 1px solid ${THEME.border};
849
+ display: flex; justify-content: space-between; align-items: center;
850
+ z-index: 1000;
851
+ }
852
+ .header h1 { font-size: 14px; font-weight: 500; color: ${THEME.textMuted}; }
853
+ .header span { font-size: 12px; color: ${THEME.textSubtle}; }
854
+ .iframe-container { position: absolute; top: 48px; left: 0; right: 0; bottom: 0; }
855
+ iframe { width: 100%; height: 100%; border: none; }
856
+ </style>
857
+ </head>
858
+ <body>
859
+ <div class="header">
860
+ <h1>Story UI - Full Width Preview</h1>
861
+ <span>Close tab to return</span>
862
+ </div>
863
+ <div class="iframe-container">
864
+ <iframe id="preview-frame" src="${popoutUrl}"></iframe>
865
+ </div>
866
+ <script>
867
+ var code = ${JSON.stringify(previewCode)};
868
+ var iframe = document.getElementById('preview-frame');
869
+ iframe.onload = function() {
870
+ iframe.contentWindow.postMessage({ type: 'PREVIEW_CODE', code: code }, '*');
871
+ };
872
+ </script>
873
+ </body>
874
+ </html>`;
875
+
876
+ newWindow.document.write(html);
877
+ newWindow.document.close();
878
+ };
879
+
880
+ const generateComponent = async (prompt: string, imageAttachments: ImageAttachment[], currentMessages: Message[]): Promise<string> => {
881
+ // Get the last generated code from the passed-in messages (not stale state)
882
+ const lastGeneratedCode = currentMessages
693
883
  .filter(m => m.generatedCode)
694
884
  .slice(-1)[0]?.generatedCode;
695
885
 
696
886
  // Check if this is an iteration (modification of existing code)
697
- const isIteration = !!lastGeneratedCode && (activeConversation?.messages.length || 0) > 0;
887
+ const isIteration = !!lastGeneratedCode && currentMessages.length > 0;
698
888
 
699
- // Build conversation history, but for iterations include the code reference differently
889
+ // Build conversation history from passed-in messages (excluding the just-added user message)
700
890
  const conversationHistory = isIteration
701
- ? activeConversation?.messages.slice(0, -1).map(msg => ({
891
+ ? currentMessages.slice(0, -1).map(msg => ({
702
892
  role: msg.role,
703
893
  content: msg.role === 'assistant' && msg.generatedCode
704
894
  ? `[Generated JSX component - see CURRENT_CODE below]`
705
895
  : msg.content
706
- })) || []
896
+ }))
707
897
  : [];
708
898
 
709
899
  // Build a UNIVERSAL system prompt that works with ANY component library
710
- // Design-system-specific rules come from aiConsiderations (generated from story-ui-considerations.md)
900
+ // Design-system-specific rules come from aiConsiderations which includes:
901
+ // - Full documentation from story-ui-docs/ directory (guidelines, tokens, patterns, components)
902
+ // - Legacy considerations from story-ui-considerations.md
711
903
  const basePrompt = `You are a JSX code generator. Your ONLY job is to output raw JSX code.
712
904
 
713
905
  CRITICAL OUTPUT RULES:
@@ -719,24 +911,22 @@ CRITICAL OUTPUT RULES:
719
911
 
720
912
  UNIVERSAL BEST PRACTICES (applies to ALL design systems):
721
913
 
722
- THEME & COLORS:
723
- - Components render on a LIGHT BACKGROUND by default
724
- - Use DARK text colors for body text (ensure readability)
725
- - Never use white/light text colors unless on a dark or colored background
914
+ VISUAL DESIGN:
915
+ - Create polished, professional-looking interfaces
916
+ - Use proper visual hierarchy with appropriate heading and text sizes
726
917
  - Ensure sufficient color contrast (4.5:1 for normal text, 3:1 for large text)
918
+ - Use dark text on light backgrounds for readability
919
+ - Never use emojis - use icons from the component library instead
920
+
921
+ RESPONSIVE DESIGN:
922
+ - All layouts should be responsive and mobile-friendly by default
923
+ - Avoid fixed pixel widths - use flexible/responsive approaches
924
+ - Layouts should stack appropriately on smaller screens
727
925
 
728
- ACCESSIBILITY (WCAG):
729
- - Use semantic HTML structure (headings, lists, landmarks)
926
+ ACCESSIBILITY:
927
+ - Use semantic HTML structure
730
928
  - Include aria-labels on interactive elements without visible text
731
- - Ensure focusable elements have visible focus states
732
- - Use role attributes appropriately
733
929
  - Form inputs should have associated labels
734
- - Interactive elements should be keyboard accessible
735
-
736
- RESPONSIVE DESIGN:
737
- - Components should work at various viewport sizes
738
- - Use relative units and flexible layouts
739
- - Avoid fixed pixel widths that could cause overflow
740
930
 
741
931
  AVAILABLE COMPONENTS (use ONLY these):
742
932
  ${availableComponents.join(', ')}${hasConsiderations ? `
@@ -826,9 +1016,124 @@ OUTPUT: Start immediately with < and output only JSX.`;
826
1016
  .replace(/\n?```$/, '');
827
1017
  }
828
1018
 
1019
+ // Validate that the response is actually JSX and not metadata/thinking
1020
+ const validationResult = validateJSXResponse(cleanCode);
1021
+ if (!validationResult.isValid) {
1022
+ throw new Error(validationResult.error || 'Invalid JSX response');
1023
+ }
1024
+
829
1025
  return cleanCode;
830
1026
  };
831
1027
 
1028
+ // Validate that the LLM response is valid JSX and not internal metadata
1029
+ const validateJSXResponse = (code: string): { isValid: boolean; error?: string } => {
1030
+ const trimmed = code.trim();
1031
+
1032
+ // Check for empty response
1033
+ if (!trimmed) {
1034
+ return { isValid: false, error: 'Empty response from LLM' };
1035
+ }
1036
+
1037
+ // Check for internal metadata tags that LLMs sometimes output
1038
+ const metadataTags = ['<budget>', '<usage>', '<thinking>', '<reflection>', '<output>', '<result>'];
1039
+ for (const tag of metadataTags) {
1040
+ if (trimmed.toLowerCase().includes(tag)) {
1041
+ return { isValid: false, error: 'LLM returned internal metadata instead of JSX components. Please try again.' };
1042
+ }
1043
+ }
1044
+
1045
+ // Check that it starts with a valid JSX opening tag (component or HTML element)
1046
+ if (!trimmed.startsWith('<')) {
1047
+ return { isValid: false, error: 'Response does not start with JSX. LLM may have returned explanatory text.' };
1048
+ }
1049
+
1050
+ // Check for markdown artifacts
1051
+ if (trimmed.startsWith('```') || trimmed.includes('```jsx') || trimmed.includes('```tsx')) {
1052
+ return { isValid: false, error: 'Response contains markdown code fences' };
1053
+ }
1054
+
1055
+ // Check that the JSX ends properly (not truncated mid-tag or mid-string)
1056
+ // Common truncation patterns:
1057
+ // - Ends with incomplete tag: <Text size="sm" c="dimmed" ta="center">);
1058
+ // - Ends mid-attribute: <Text size="
1059
+ // - Ends with unclosed string: "some text
1060
+ const lastChar = trimmed.slice(-1);
1061
+ const last10Chars = trimmed.slice(-10);
1062
+
1063
+ // Should end with > or /> or } or ; but NOT with ); which indicates truncation
1064
+ if (last10Chars.includes('>);') || last10Chars.includes('>");')) {
1065
+ return { isValid: false, error: 'JSX appears to be truncated (ends with >); pattern). LLM output was cut off.' };
1066
+ }
1067
+
1068
+ // Check for unclosed quotes at the end
1069
+ const quoteCount = (trimmed.match(/"/g) || []).length;
1070
+ if (quoteCount % 2 !== 0) {
1071
+ return { isValid: false, error: 'JSX has unclosed quotes - response appears truncated' };
1072
+ }
1073
+
1074
+ // Check for unclosed JSX expression braces
1075
+ const openBraces = (trimmed.match(/\{/g) || []).length;
1076
+ const closeBraces = (trimmed.match(/\}/g) || []).length;
1077
+ if (openBraces !== closeBraces) {
1078
+ return { isValid: false, error: `JSX has unbalanced braces (${openBraces} open, ${closeBraces} close) - response appears truncated` };
1079
+ }
1080
+
1081
+ // Check for unclosed parentheses (common in arrow functions)
1082
+ const openParens = (trimmed.match(/\(/g) || []).length;
1083
+ const closeParens = (trimmed.match(/\)/g) || []).length;
1084
+ if (openParens !== closeParens) {
1085
+ return { isValid: false, error: `JSX has unbalanced parentheses (${openParens} open, ${closeParens} close) - response appears truncated` };
1086
+ }
1087
+
1088
+ // Try to do a basic Babel parse to catch syntax errors before rendering
1089
+ // This is the most reliable check - if Babel can't parse it, it won't render
1090
+ try {
1091
+ const wrappedCode = `(function() { return (${trimmed}); })()`;
1092
+ Babel.transform(wrappedCode, {
1093
+ presets: ['react'],
1094
+ filename: 'validation.tsx'
1095
+ });
1096
+ } catch (babelError: any) {
1097
+ // Extract useful error message
1098
+ const errorMsg = babelError.message || 'Unknown syntax error';
1099
+ if (errorMsg.includes('Unterminated') || errorMsg.includes('Unexpected token') || errorMsg.includes('Unexpected end')) {
1100
+ return { isValid: false, error: `JSX syntax error: ${errorMsg}. LLM response may be truncated or malformed.` };
1101
+ }
1102
+ // For other Babel errors, still fail validation
1103
+ return { isValid: false, error: `JSX parsing failed: ${errorMsg}` };
1104
+ }
1105
+
1106
+ return { isValid: true };
1107
+ };
1108
+
1109
+ // Wrapper function with retry logic
1110
+ const generateComponentWithRetry = async (
1111
+ prompt: string,
1112
+ imageAttachments: ImageAttachment[],
1113
+ currentMessages: Message[],
1114
+ maxRetries: number = 2
1115
+ ): Promise<string> => {
1116
+ let lastError: Error | null = null;
1117
+
1118
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1119
+ try {
1120
+ const result = await generateComponent(prompt, imageAttachments, currentMessages);
1121
+ return result;
1122
+ } catch (error) {
1123
+ lastError = error instanceof Error ? error : new Error(String(error));
1124
+ console.warn(`Generation attempt ${attempt + 1} failed:`, lastError.message);
1125
+
1126
+ // If this isn't the last attempt, wait briefly before retrying
1127
+ if (attempt < maxRetries) {
1128
+ await new Promise(resolve => setTimeout(resolve, 500));
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ // All retries exhausted
1134
+ throw lastError || new Error('Generation failed after multiple attempts');
1135
+ };
1136
+
832
1137
  const sendMessage = async () => {
833
1138
  if (!inputValue.trim() || isGenerating) return;
834
1139
 
@@ -842,12 +1147,18 @@ OUTPUT: Start immediately with < and output only JSX.`;
842
1147
 
843
1148
  // Determine if we need to create a new conversation or add to existing
844
1149
  let conversationId = activeConversationId;
1150
+ let currentMessages: Message[] = [];
845
1151
 
846
1152
  if (!conversationId) {
847
1153
  // Create new conversation with the first message
848
1154
  conversationId = createConversationWithMessage(userMessage);
849
1155
  setActiveConversationId(conversationId);
1156
+ currentMessages = [userMessage];
850
1157
  } else {
1158
+ // Get the current messages BEFORE the async state update
1159
+ const existingConv = conversations.find(c => c.id === conversationId);
1160
+ currentMessages = existingConv ? [...existingConv.messages, userMessage] : [userMessage];
1161
+
851
1162
  // Add message to existing conversation
852
1163
  setConversations(prev => prev.map(conv => {
853
1164
  if (conv.id === conversationId) {
@@ -867,7 +1178,9 @@ OUTPUT: Start immediately with < and output only JSX.`;
867
1178
  setIsGenerating(true);
868
1179
 
869
1180
  try {
870
- const generatedCode = await generateComponent(inputValue.trim(), currentImages);
1181
+ // Pass currentMessages directly to avoid stale closure issues
1182
+ // Use retry wrapper to handle invalid LLM responses automatically
1183
+ const generatedCode = await generateComponentWithRetry(inputValue.trim(), currentImages, currentMessages);
871
1184
 
872
1185
  const assistantMessage: Message = {
873
1186
  id: generateId(),
@@ -1109,7 +1422,7 @@ OUTPUT: Start immediately with < and output only JSX.`;
1109
1422
  }}
1110
1423
  >
1111
1424
  {serverConfig.models.map(model => (
1112
- <option key={model.id} value={model.id}>{model.id}</option>
1425
+ <option key={model.id} value={model.id}>{model.name}</option>
1113
1426
  ))}
1114
1427
  </select>
1115
1428
 
@@ -1501,9 +1814,30 @@ OUTPUT: Start immediately with < and output only JSX.`;
1501
1814
  </div>
1502
1815
 
1503
1816
  {previewCode && previewTab === 'preview' && (
1504
- <span style={{ fontSize: '12px', color: THEME.textMuted }}>
1505
- Live Preview
1506
- </span>
1817
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
1818
+ <span style={{ fontSize: '12px', color: THEME.textMuted }}>
1819
+ Live Preview
1820
+ </span>
1821
+ <button
1822
+ onClick={() => openPreviewInNewWindow()}
1823
+ title="Open in new window"
1824
+ style={{
1825
+ padding: '6px 10px',
1826
+ background: THEME.bgElevated,
1827
+ border: `1px solid ${THEME.border}`,
1828
+ borderRadius: '6px',
1829
+ color: THEME.textMuted,
1830
+ fontSize: '12px',
1831
+ cursor: 'pointer',
1832
+ display: 'flex',
1833
+ alignItems: 'center',
1834
+ gap: '4px',
1835
+ }}
1836
+ >
1837
+ <Icons.ExternalLink />
1838
+ Pop Out
1839
+ </button>
1840
+ </div>
1507
1841
  )}
1508
1842
  </div>
1509
1843
 
@@ -1511,11 +1845,13 @@ OUTPUT: Start immediately with < and output only JSX.`;
1511
1845
  <div style={{ flex: 1, overflow: 'hidden' }}>
1512
1846
  {previewCode ? (
1513
1847
  previewTab === 'preview' ? (
1514
- <LivePreviewRenderer
1515
- code={previewCode}
1516
- containerStyle={{ height: '100%', background: THEME.bgSurface }}
1517
- onError={(err) => console.error('Preview error:', err)}
1518
- />
1848
+ <div ref={previewContainerRef} style={{ height: '100%' }}>
1849
+ <LivePreviewRenderer
1850
+ code={previewCode}
1851
+ containerStyle={{ height: '100%', background: THEME.bgSurface }}
1852
+ onError={(err) => console.error('Preview error:', err)}
1853
+ />
1854
+ </div>
1519
1855
  ) : (
1520
1856
  <CodeViewer code={previewCode} />
1521
1857
  )