@tpitre/story-ui 4.1.2 → 4.3.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/README.md CHANGED
@@ -10,7 +10,7 @@ Story UI revolutionizes component documentation by automatically generating Stor
10
10
  ## Why Story UI?
11
11
 
12
12
  - **Framework Agnostic**: Works with React, Vue, Angular, Svelte, and Web Components
13
- - **Multi-Provider AI**: Choose between Claude (Anthropic), GPT-5 (OpenAI), or Gemini (Google)
13
+ - **Multi-Provider AI**: Choose between Claude (Anthropic), GPT-4o (OpenAI), or Gemini (Google)
14
14
  - **Design System Aware**: Learns your component library and generates appropriate code
15
15
  - **Production Ready**: Deploy as a standalone web app with full MCP integration
16
16
  - **Zero Lock-in**: Use any component library - Mantine, Vuetify, Angular Material, Shoelace, or your own
@@ -47,6 +47,12 @@ Story UI will guide you through:
47
47
  - **TypeScript Support**: Full type-aware story generation
48
48
  - **Vision Support**: Attach screenshots for visual component requests
49
49
 
50
+ ### Story Management
51
+ - **Edit Existing Stories**: Modify any generated story through conversation
52
+ - **Delete Stories**: Remove stories directly from the Story UI panel
53
+ - **Orphan Detection**: Find and clean up stories without associated chat history
54
+ - **Full MCP Integration**: Manage stories via Claude Desktop or any MCP-compatible client
55
+
50
56
  ### Multi-Framework Support
51
57
 
52
58
  | Framework | Design Systems | Status |
@@ -54,16 +60,16 @@ Story UI will guide you through:
54
60
  | React | Mantine, Chakra UI, Material UI, Custom | Fully Supported |
55
61
  | Vue | Vuetify, Custom | Fully Supported |
56
62
  | Angular | Angular Material, Custom | Fully Supported |
57
- | Svelte | Skeleton UI, Custom | Fully Supported |
63
+ | Svelte | Flowbite-Svelte, Custom | Fully Supported |
58
64
  | Web Components | Shoelace, Custom | Fully Supported |
59
65
 
60
66
  ### Multi-Provider LLM Support
61
67
 
62
68
  | Provider | Models | Best For |
63
69
  |----------|--------|----------|
64
- | **Claude** (Anthropic) | Opus 4.5, Sonnet 4.5, Haiku 4.5 | Complex reasoning, code quality |
65
- | **GPT** (OpenAI) | GPT-5.1, GPT-5.1 Thinking, GPT-4o, GPT-4o Mini | Versatility, speed |
66
- | **Gemini** (Google) | Gemini 3 Pro, Gemini 2.0 Flash, Gemini 1.5 Pro | Fast generation, cost efficiency |
70
+ | **Claude** (Anthropic) | claude-opus-4-5, claude-sonnet-4-5, claude-haiku-4-5 | Complex reasoning, code quality |
71
+ | **GPT** (OpenAI) | gpt-4o, gpt-4o-mini, o1 | Versatility, speed |
72
+ | **Gemini** (Google) | gemini-2.0-flash, gemini-1.5-pro | Fast generation, cost efficiency |
67
73
 
68
74
  ### Production Deployment
69
75
  - **Railway**: Node.js backend with file-based story persistence
@@ -115,7 +121,7 @@ The interactive installer will ask:
115
121
  ```
116
122
  ? Which AI provider do you prefer?
117
123
  > Claude (Anthropic) - Recommended
118
- OpenAI (GPT-5)
124
+ OpenAI (GPT-4o)
119
125
  Google Gemini
120
126
 
121
127
  ? Enter your API key:
@@ -319,7 +325,7 @@ Once connected, you can use these commands in Claude Desktop:
319
325
 
320
326
  ## Production Deployment
321
327
 
322
- Story UI v3 can be deployed as a standalone web application accessible from anywhere.
328
+ Story UI can be deployed as a standalone web application accessible from anywhere.
323
329
 
324
330
  ### Architecture
325
331
 
@@ -461,7 +467,7 @@ npx story-ui mcp
461
467
 
462
468
  ## Upgrading from v2
463
469
 
464
- Story UI v3 is backwards compatible with v2 configurations. However, to take advantage of new features:
470
+ Story UI v4 is backwards compatible with previous configurations. However, to take advantage of new features:
465
471
 
466
472
  1. **Multi-Provider Support**: Add `llmProvider` to your config
467
473
  2. **Framework Detection**: Add `framework` to your config for non-React projects
package/dist/cli/index.js CHANGED
File without changes
@@ -78,6 +78,176 @@ app.get('/mcp/frameworks/detect', detectCurrentFramework);
78
78
  app.get('/mcp/frameworks/:type', getFrameworkDetails);
79
79
  app.post('/mcp/frameworks/validate', validateStoryForFramework);
80
80
  app.post('/mcp/frameworks/post-process', postProcessStoryForFramework);
81
+ // MCP story management routes - for Claude Desktop and other MCP clients
82
+ // List all stories
83
+ app.get('/mcp/stories', async (req, res) => {
84
+ try {
85
+ const storiesPath = config.generatedStoriesPath;
86
+ if (!fs.existsSync(storiesPath)) {
87
+ return res.json({ stories: [] });
88
+ }
89
+ const files = fs.readdirSync(storiesPath);
90
+ const stories = files
91
+ .filter(file => file.endsWith('.stories.tsx') || file.endsWith('.stories.ts') || file.endsWith('.stories.svelte'))
92
+ .map(file => {
93
+ const filePath = path.join(storiesPath, file);
94
+ const stats = fs.statSync(filePath);
95
+ const content = fs.readFileSync(filePath, 'utf-8');
96
+ // Extract title from story file
97
+ const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/);
98
+ let title = titleMatch ? titleMatch[1].replace('Generated/', '') : file.replace(/\.stories\.(tsx|ts|svelte)$/, '');
99
+ // Remove hash suffix from display title
100
+ title = title.replace(/\s*\([a-f0-9]{8}\)$/i, '');
101
+ return {
102
+ id: file.replace(/\.stories\.(tsx|ts|svelte)$/, ''),
103
+ storyId: file.replace(/\.stories\.(tsx|ts|svelte)$/, ''),
104
+ fileName: file,
105
+ title,
106
+ lastUpdated: stats.mtime.getTime(),
107
+ createdAt: stats.birthtime.getTime(),
108
+ content
109
+ };
110
+ })
111
+ .sort((a, b) => b.lastUpdated - a.lastUpdated);
112
+ return res.json({ stories });
113
+ }
114
+ catch (error) {
115
+ console.error('Error listing stories:', error);
116
+ return res.status(500).json({ error: 'Failed to list stories' });
117
+ }
118
+ });
119
+ // Get a specific story by ID
120
+ app.get('/mcp/stories/:storyId', async (req, res) => {
121
+ try {
122
+ const { storyId } = req.params;
123
+ const storiesPath = config.generatedStoriesPath;
124
+ if (!fs.existsSync(storiesPath)) {
125
+ return res.status(404).json({ error: 'Stories directory not found' });
126
+ }
127
+ const files = fs.readdirSync(storiesPath);
128
+ // Extract hash from story ID if in legacy format (story-a1b2c3d4)
129
+ const hashMatch = storyId.match(/^story-([a-f0-9]{8})$/);
130
+ const hash = hashMatch ? hashMatch[1] : null;
131
+ // Find matching file
132
+ const matchingFile = files.find(file => {
133
+ // Match by hash suffix
134
+ if (hash && file.includes(`-${hash}.stories.`))
135
+ return true;
136
+ // Match by exact ID
137
+ if (file.startsWith(`${storyId}.stories.`))
138
+ return true;
139
+ // Match by fileName
140
+ if (file === storyId)
141
+ return true;
142
+ // Match by ID without extension
143
+ if (file.replace(/\.stories\.(tsx|ts|svelte)$/, '') === storyId)
144
+ return true;
145
+ return false;
146
+ });
147
+ if (!matchingFile) {
148
+ return res.status(404).json({ error: `Story with ID ${storyId} not found` });
149
+ }
150
+ const filePath = path.join(storiesPath, matchingFile);
151
+ const stats = fs.statSync(filePath);
152
+ const content = fs.readFileSync(filePath, 'utf-8');
153
+ // Extract title from story file
154
+ const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/);
155
+ let title = titleMatch ? titleMatch[1].replace('Generated/', '') : matchingFile.replace(/\.stories\.(tsx|ts|svelte)$/, '');
156
+ title = title.replace(/\s*\([a-f0-9]{8}\)$/i, '');
157
+ return res.json({
158
+ id: matchingFile.replace(/\.stories\.(tsx|ts|svelte)$/, ''),
159
+ storyId: matchingFile.replace(/\.stories\.(tsx|ts|svelte)$/, ''),
160
+ fileName: matchingFile,
161
+ title,
162
+ lastUpdated: stats.mtime.getTime(),
163
+ createdAt: stats.birthtime.getTime(),
164
+ timestamp: stats.mtime.getTime(),
165
+ content,
166
+ story: content
167
+ });
168
+ }
169
+ catch (error) {
170
+ console.error('Error getting story:', error);
171
+ return res.status(500).json({ error: 'Failed to get story' });
172
+ }
173
+ });
174
+ // Get story content (raw code)
175
+ app.get('/mcp/stories/:storyId/content', async (req, res) => {
176
+ try {
177
+ const { storyId } = req.params;
178
+ const storiesPath = config.generatedStoriesPath;
179
+ if (!fs.existsSync(storiesPath)) {
180
+ return res.status(404).send('Stories directory not found');
181
+ }
182
+ const files = fs.readdirSync(storiesPath);
183
+ // Extract hash from story ID if in legacy format
184
+ const hashMatch = storyId.match(/^story-([a-f0-9]{8})$/);
185
+ const hash = hashMatch ? hashMatch[1] : null;
186
+ // Find matching file
187
+ const matchingFile = files.find(file => {
188
+ if (hash && file.includes(`-${hash}.stories.`))
189
+ return true;
190
+ if (file.startsWith(`${storyId}.stories.`))
191
+ return true;
192
+ if (file === storyId)
193
+ return true;
194
+ if (file.replace(/\.stories\.(tsx|ts|svelte)$/, '') === storyId)
195
+ return true;
196
+ return false;
197
+ });
198
+ if (!matchingFile) {
199
+ return res.status(404).send(`Story with ID ${storyId} not found`);
200
+ }
201
+ const filePath = path.join(storiesPath, matchingFile);
202
+ const content = fs.readFileSync(filePath, 'utf-8');
203
+ res.type('text/plain').send(content);
204
+ }
205
+ catch (error) {
206
+ console.error('Error getting story content:', error);
207
+ return res.status(500).send('Failed to get story content');
208
+ }
209
+ });
210
+ // Delete a story by ID
211
+ app.delete('/mcp/stories/:storyId', async (req, res) => {
212
+ try {
213
+ const { storyId } = req.params;
214
+ const storiesPath = config.generatedStoriesPath;
215
+ if (!fs.existsSync(storiesPath)) {
216
+ return res.status(404).json({ error: 'Stories directory not found' });
217
+ }
218
+ const files = fs.readdirSync(storiesPath);
219
+ // Extract hash from story ID if in legacy format
220
+ const hashMatch = storyId.match(/^story-([a-f0-9]{8})$/);
221
+ const hash = hashMatch ? hashMatch[1] : null;
222
+ // Find matching file
223
+ const matchingFile = files.find(file => {
224
+ if (hash && file.includes(`-${hash}.stories.`))
225
+ return true;
226
+ if (file.startsWith(`${storyId}.stories.`))
227
+ return true;
228
+ if (file === storyId)
229
+ return true;
230
+ if (file.replace(/\.stories\.(tsx|ts|svelte)$/, '') === storyId)
231
+ return true;
232
+ return false;
233
+ });
234
+ if (!matchingFile) {
235
+ return res.status(404).json({ error: `Story with ID ${storyId} not found` });
236
+ }
237
+ const filePath = path.join(storiesPath, matchingFile);
238
+ fs.unlinkSync(filePath);
239
+ console.log(`🗑️ Deleted story via MCP endpoint: ${matchingFile}`);
240
+ return res.json({
241
+ success: true,
242
+ deleted: matchingFile,
243
+ message: `Story "${matchingFile}" has been deleted successfully.`
244
+ });
245
+ }
246
+ catch (error) {
247
+ console.error('Error deleting story:', error);
248
+ return res.status(500).json({ error: 'Failed to delete story' });
249
+ }
250
+ });
81
251
  // File-based story routes - stories are generated as .stories.tsx files
82
252
  // Storybook discovers these automatically via its native file system watching
83
253
  // Proxy routes for frontend compatibility (maps /story-ui/ to /mcp/)
@@ -399,6 +569,125 @@ app.delete('/story-ui/stories', async (req, res) => {
399
569
  return res.status(500).json({ error: 'Failed to clear stories' });
400
570
  }
401
571
  });
572
+ // Orphan stories management - find and delete stories without associated chats
573
+ // POST to get list of orphans, DELETE to remove them
574
+ app.post('/story-ui/orphan-stories', async (req, res) => {
575
+ try {
576
+ const { chatFileNames } = req.body;
577
+ if (!chatFileNames || !Array.isArray(chatFileNames)) {
578
+ return res.status(400).json({ error: 'chatFileNames array is required' });
579
+ }
580
+ const storiesPath = config.generatedStoriesPath;
581
+ if (!fs.existsSync(storiesPath)) {
582
+ return res.json({ orphans: [], count: 0 });
583
+ }
584
+ const files = fs.readdirSync(storiesPath);
585
+ const storyFiles = files.filter(file => file.endsWith('.stories.tsx') ||
586
+ file.endsWith('.stories.ts') ||
587
+ file.endsWith('.stories.svelte'));
588
+ // Find orphans: stories that don't match any chat fileName
589
+ const orphans = storyFiles.filter(storyFile => {
590
+ // Extract the base name without extension for comparison
591
+ const storyBase = storyFile
592
+ .replace('.stories.tsx', '')
593
+ .replace('.stories.ts', '')
594
+ .replace('.stories.svelte', '');
595
+ // Check if any chat fileName matches this story
596
+ return !chatFileNames.some(chatFileName => {
597
+ if (!chatFileName)
598
+ return false;
599
+ const chatBase = chatFileName
600
+ .replace('.stories.tsx', '')
601
+ .replace('.stories.ts', '')
602
+ .replace('.stories.svelte', '');
603
+ return storyBase === chatBase || storyFile === chatFileName;
604
+ });
605
+ });
606
+ // Get details for each orphan
607
+ const orphanDetails = orphans.map(fileName => {
608
+ const filePath = path.join(storiesPath, fileName);
609
+ const content = fs.readFileSync(filePath, 'utf-8');
610
+ const stats = fs.statSync(filePath);
611
+ // Extract title from story file
612
+ const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/);
613
+ let title = titleMatch ? titleMatch[1].replace('Generated/', '') : fileName.replace(/\.stories\.[a-z]+$/, '');
614
+ // Remove hash suffix from display title
615
+ title = title.replace(/\s*\([a-f0-9]{8}\)$/i, '');
616
+ return {
617
+ fileName,
618
+ title,
619
+ lastUpdated: stats.mtime.getTime()
620
+ };
621
+ });
622
+ console.log(`📋 Found ${orphans.length} orphan stories out of ${storyFiles.length} total`);
623
+ return res.json({
624
+ orphans: orphanDetails,
625
+ count: orphans.length,
626
+ totalStories: storyFiles.length
627
+ });
628
+ }
629
+ catch (error) {
630
+ console.error('Error finding orphan stories:', error);
631
+ return res.status(500).json({ error: 'Failed to find orphan stories' });
632
+ }
633
+ });
634
+ app.delete('/story-ui/orphan-stories', async (req, res) => {
635
+ try {
636
+ const { chatFileNames } = req.body;
637
+ if (!chatFileNames || !Array.isArray(chatFileNames)) {
638
+ return res.status(400).json({ error: 'chatFileNames array is required' });
639
+ }
640
+ const storiesPath = config.generatedStoriesPath;
641
+ if (!fs.existsSync(storiesPath)) {
642
+ return res.json({ deleted: [], count: 0 });
643
+ }
644
+ const files = fs.readdirSync(storiesPath);
645
+ const storyFiles = files.filter(file => file.endsWith('.stories.tsx') ||
646
+ file.endsWith('.stories.ts') ||
647
+ file.endsWith('.stories.svelte'));
648
+ // Find orphans: stories that don't match any chat fileName
649
+ const orphans = storyFiles.filter(storyFile => {
650
+ const storyBase = storyFile
651
+ .replace('.stories.tsx', '')
652
+ .replace('.stories.ts', '')
653
+ .replace('.stories.svelte', '');
654
+ return !chatFileNames.some(chatFileName => {
655
+ if (!chatFileName)
656
+ return false;
657
+ const chatBase = chatFileName
658
+ .replace('.stories.tsx', '')
659
+ .replace('.stories.ts', '')
660
+ .replace('.stories.svelte', '');
661
+ return storyBase === chatBase || storyFile === chatFileName;
662
+ });
663
+ });
664
+ // Delete orphans
665
+ const deleted = [];
666
+ const errors = [];
667
+ for (const fileName of orphans) {
668
+ try {
669
+ const filePath = path.join(storiesPath, fileName);
670
+ fs.unlinkSync(filePath);
671
+ deleted.push(fileName);
672
+ console.log(`🗑️ Deleted orphan story: ${fileName}`);
673
+ }
674
+ catch (err) {
675
+ errors.push(fileName);
676
+ console.error(`❌ Error deleting orphan ${fileName}:`, err);
677
+ }
678
+ }
679
+ console.log(`✅ Deleted ${deleted.length} orphan stories`);
680
+ return res.json({
681
+ deleted,
682
+ count: deleted.length,
683
+ errors: errors.length > 0 ? errors : undefined
684
+ });
685
+ }
686
+ catch (error) {
687
+ console.error('Error deleting orphan stories:', error);
688
+ return res.status(500).json({ error: 'Failed to delete orphan stories' });
689
+ }
690
+ });
402
691
  // MCP Remote HTTP transport routes (for Claude Desktop remote connections)
403
692
  // Provides Streamable HTTP and legacy SSE endpoints for remote MCP access
404
693
  app.use('/mcp-remote', mcpRemoteRouter);
@@ -27,7 +27,8 @@ dotenv.config({ path: path.resolve(process.cwd(), '.env') });
27
27
  process.env.STORY_UI_MCP_MODE = 'true';
28
28
  // Get HTTP server port from environment variables (check multiple possible names)
29
29
  const HTTP_PORT = process.env.VITE_STORY_UI_PORT || process.env.STORY_UI_HTTP_PORT || process.env.PORT || '4001';
30
- const HTTP_BASE_URL = `http://localhost:${HTTP_PORT}`;
30
+ // Allow configurable base URL for Railway/cloud deployments, fallback to localhost for local dev
31
+ const HTTP_BASE_URL = process.env.STORY_UI_HTTP_BASE_URL || `http://localhost:${HTTP_PORT}`;
31
32
  // Initialize configuration
32
33
  const config = loadUserConfig();
33
34
  // Create MCP server instance
@@ -181,8 +182,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
181
182
  throw new Error(`Failed to generate story: ${error}`);
182
183
  }
183
184
  const result = await response.json();
184
- // Debug log to see what we're getting
185
- console.error('Story generation result:', JSON.stringify(result, null, 2));
186
185
  return {
187
186
  content: [{
188
187
  type: "text",
@@ -509,8 +508,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
509
508
  throw new Error(`Failed to update story: ${error}`);
510
509
  }
511
510
  const result = await response.json();
512
- // Debug log to see what we're getting
513
- console.error('Story update result:', JSON.stringify(result, null, 2));
514
511
  return {
515
512
  content: [{
516
513
  type: "text",
@@ -150,7 +150,7 @@ class AdapterRegistry {
150
150
  return {
151
151
  systemPrompt: adapter.generateSystemPrompt(config, options),
152
152
  componentReference: adapter.generateComponentReference(components, config),
153
- layoutInstructions: '', // TODO: Implement layout instructions
153
+ layoutInstructions: '',
154
154
  examples: adapter.generateExamples(config),
155
155
  sampleStory: adapter.generateSampleStory(config, components),
156
156
  framework: this.detectedFramework || {
@@ -1 +1 @@
1
- {"version":3,"file":"StoryUIPanel.d.ts","sourceRoot":"","sources":["../../../templates/StoryUI/StoryUIPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,oBAAoB,CAAC;AAuwB5B,UAAU,iBAAiB;IACzB,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC3B;AAED,iBAAS,YAAY,CAAC,EAAE,OAAO,EAAE,EAAE,iBAAiB,2CAsiCnD;AAED,eAAe,YAAY,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,CAAC"}
1
+ {"version":3,"file":"StoryUIPanel.d.ts","sourceRoot":"","sources":["../../../templates/StoryUI/StoryUIPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,oBAAoB,CAAC;AAwwB5B,UAAU,iBAAiB;IACzB,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC3B;AAED,iBAAS,YAAY,CAAC,EAAE,OAAO,EAAE,EAAE,iBAAiB,2CAwnCnD;AAED,eAAe,YAAY,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,CAAC"}
@@ -151,6 +151,7 @@ const MCP_API = `${API_BASE}/mcp/generate-story`;
151
151
  const MCP_STREAM_API = `${API_BASE}/mcp/generate-story-stream`;
152
152
  const PROVIDERS_API = `${API_BASE}/mcp/providers`;
153
153
  const STORIES_API = `${API_BASE}/story-ui/stories`;
154
+ const ORPHAN_STORIES_API = `${API_BASE}/story-ui/orphan-stories`;
154
155
  const CONSIDERATIONS_API = `${API_BASE}/mcp/considerations`;
155
156
  function isEdgeMode() {
156
157
  if (typeof window !== 'undefined') {
@@ -444,6 +445,8 @@ function StoryUIPanel({ mcpPort }) {
444
445
  const [contextMenuId, setContextMenuId] = useState(null);
445
446
  const [renamingChatId, setRenamingChatId] = useState(null);
446
447
  const [renameValue, setRenameValue] = useState('');
448
+ const [orphanCount, setOrphanCount] = useState(0);
449
+ const [isDeletingOrphans, setIsDeletingOrphans] = useState(false);
447
450
  const chatEndRef = useRef(null);
448
451
  const inputRef = useRef(null);
449
452
  const fileInputRef = useRef(null);
@@ -1144,6 +1147,65 @@ function StoryUIPanel({ mcpPort }) {
1144
1147
  }
1145
1148
  }
1146
1149
  };
1150
+ // Check for orphan stories (stories without associated chats)
1151
+ const checkOrphanStories = useCallback(async () => {
1152
+ if (!state.connectionStatus.connected)
1153
+ return;
1154
+ try {
1155
+ const chatFileNames = state.recentChats.map(chat => chat.fileName);
1156
+ const response = await fetch(ORPHAN_STORIES_API, {
1157
+ method: 'POST',
1158
+ headers: { 'Content-Type': 'application/json' },
1159
+ body: JSON.stringify({ chatFileNames }),
1160
+ });
1161
+ if (response.ok) {
1162
+ const data = await response.json();
1163
+ setOrphanCount(data.count || 0);
1164
+ }
1165
+ }
1166
+ catch (error) {
1167
+ console.error('Failed to check orphan stories:', error);
1168
+ }
1169
+ }, [state.connectionStatus.connected, state.recentChats]);
1170
+ // Delete all orphan stories
1171
+ const handleDeleteOrphans = async () => {
1172
+ if (orphanCount === 0)
1173
+ return;
1174
+ if (!confirm(`Delete ${orphanCount} orphan ${orphanCount === 1 ? 'story' : 'stories'}? These are generated story files without associated chats.`)) {
1175
+ return;
1176
+ }
1177
+ setIsDeletingOrphans(true);
1178
+ try {
1179
+ const chatFileNames = state.recentChats.map(chat => chat.fileName);
1180
+ const response = await fetch(ORPHAN_STORIES_API, {
1181
+ method: 'DELETE',
1182
+ headers: { 'Content-Type': 'application/json' },
1183
+ body: JSON.stringify({ chatFileNames }),
1184
+ });
1185
+ if (response.ok) {
1186
+ const data = await response.json();
1187
+ setOrphanCount(0);
1188
+ if (data.count > 0) {
1189
+ // Show success message briefly
1190
+ alert(`Deleted ${data.count} orphan ${data.count === 1 ? 'story' : 'stories'}.`);
1191
+ }
1192
+ }
1193
+ else {
1194
+ alert('Failed to delete orphan stories. Please try again.');
1195
+ }
1196
+ }
1197
+ catch (error) {
1198
+ console.error('Failed to delete orphan stories:', error);
1199
+ alert('Failed to delete orphan stories. Please try again.');
1200
+ }
1201
+ finally {
1202
+ setIsDeletingOrphans(false);
1203
+ }
1204
+ };
1205
+ // Check for orphans when chats change or connection is established
1206
+ useEffect(() => {
1207
+ checkOrphanStories();
1208
+ }, [checkOrphanStories]);
1147
1209
  const handleStartRename = (chatId, currentTitle, e) => {
1148
1210
  if (e)
1149
1211
  e.stopPropagation();
@@ -1255,7 +1317,7 @@ function StoryUIPanel({ mcpPort }) {
1255
1317
  handleConfirmRename(chat.id);
1256
1318
  if (e.key === 'Escape')
1257
1319
  handleCancelRename();
1258
- }, onClick: e => e.stopPropagation(), autoFocus: true }), _jsx("button", { className: "sui-button sui-button-icon sui-button-sm", onClick: e => { e.stopPropagation(); handleConfirmRename(chat.id); }, "aria-label": "Save", children: Icons.check }), _jsx("button", { className: "sui-button sui-button-icon sui-button-sm", onClick: e => { e.stopPropagation(); handleCancelRename(); }, "aria-label": "Cancel", children: Icons.x })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "sui-chat-item-title", children: chat.title }), _jsxs("div", { className: "sui-chat-item-actions", children: [_jsx("button", { className: "sui-chat-item-menu sui-button sui-button-icon sui-button-sm", onClick: e => { e.stopPropagation(); setContextMenuId(contextMenuId === chat.id ? null : chat.id); }, "aria-label": "More options", children: Icons.moreVertical }), contextMenuId === chat.id && (_jsxs("div", { className: "sui-context-menu", children: [_jsxs("button", { className: "sui-context-menu-item", onClick: e => handleStartRename(chat.id, chat.title, e), children: [Icons.pencil, _jsx("span", { children: "Rename" })] }), _jsxs("button", { className: "sui-context-menu-item sui-context-menu-item-danger", onClick: e => handleDeleteChat(chat.id, e), children: [Icons.trash, _jsx("span", { children: "Delete" })] })] }))] })] })) }, chat.id))) })] })), !state.sidebarOpen && (_jsx("div", { style: { padding: '12px', display: 'flex', justifyContent: 'center' }, children: _jsx("button", { className: "sui-button sui-button-ghost sui-button-icon", onClick: () => dispatch({ type: 'SET_SIDEBAR', payload: true }), "aria-label": "Show sidebar", children: Icons.panelLeft }) }))] }), _jsxs("main", { className: "sui-main", onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop, children: [state.isDragging && (_jsx("div", { className: "sui-drop-overlay", children: _jsxs("div", { className: "sui-drop-overlay-text", children: [Icons.image, _jsx("span", { children: "Drop images here" })] }) })), _jsxs("header", { className: "sui-header", children: [_jsxs("div", { className: "sui-header-left", children: [_jsx("span", { className: "sui-header-title", children: "Story UI" }), _jsxs(Badge, { variant: state.connectionStatus.connected ? 'success' : 'destructive', children: [_jsx("span", { className: "sui-badge-dot" }), state.connectionStatus.connected ? getConnectionDisplayText() : 'Disconnected'] })] }), _jsx("div", { className: "sui-header-right", children: state.connectionStatus.connected && state.availableProviders.length > 0 && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sui-select", children: [_jsxs("div", { className: "sui-select-trigger", children: [_jsx("span", { children: state.availableProviders.find(p => p.type === state.selectedProvider)?.name || 'Provider' }), Icons.chevronDown] }), _jsx("select", { className: "sui-select-native", value: state.selectedProvider, onChange: e => {
1320
+ }, onClick: e => e.stopPropagation(), autoFocus: true }), _jsx("button", { className: "sui-button sui-button-icon sui-button-sm", onClick: e => { e.stopPropagation(); handleConfirmRename(chat.id); }, "aria-label": "Save", children: Icons.check }), _jsx("button", { className: "sui-button sui-button-icon sui-button-sm", onClick: e => { e.stopPropagation(); handleCancelRename(); }, "aria-label": "Cancel", children: Icons.x })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "sui-chat-item-title", children: chat.title }), _jsxs("div", { className: "sui-chat-item-actions", children: [_jsx("button", { className: "sui-chat-item-menu sui-button sui-button-icon sui-button-sm", onClick: e => { e.stopPropagation(); setContextMenuId(contextMenuId === chat.id ? null : chat.id); }, "aria-label": "More options", children: Icons.moreVertical }), contextMenuId === chat.id && (_jsxs("div", { className: "sui-context-menu", children: [_jsxs("button", { className: "sui-context-menu-item", onClick: e => handleStartRename(chat.id, chat.title, e), children: [Icons.pencil, _jsx("span", { children: "Rename" })] }), _jsxs("button", { className: "sui-context-menu-item sui-context-menu-item-danger", onClick: e => handleDeleteChat(chat.id, e), children: [Icons.trash, _jsx("span", { children: "Delete" })] })] }))] })] })) }, chat.id))) }), orphanCount > 0 && (_jsx("div", { className: "sui-orphan-footer", children: _jsx("button", { className: "sui-orphan-delete-btn", onClick: handleDeleteOrphans, disabled: isDeletingOrphans, title: `${orphanCount} story ${orphanCount === 1 ? 'file has' : 'files have'} no associated chat`, children: isDeletingOrphans ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "sui-orphan-spinner" }), _jsx("span", { children: "Deleting..." })] })) : (_jsxs(_Fragment, { children: [Icons.trash, _jsxs("span", { children: [orphanCount, " orphan ", orphanCount === 1 ? 'story' : 'stories'] })] })) }) }))] })), !state.sidebarOpen && (_jsx("div", { style: { padding: '12px', display: 'flex', justifyContent: 'center' }, children: _jsx("button", { className: "sui-button sui-button-ghost sui-button-icon", onClick: () => dispatch({ type: 'SET_SIDEBAR', payload: true }), "aria-label": "Show sidebar", children: Icons.panelLeft }) }))] }), _jsxs("main", { className: "sui-main", onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop, children: [state.isDragging && (_jsx("div", { className: "sui-drop-overlay", children: _jsxs("div", { className: "sui-drop-overlay-text", children: [Icons.image, _jsx("span", { children: "Drop images here" })] }) })), _jsxs("header", { className: "sui-header", children: [_jsxs("div", { className: "sui-header-left", children: [_jsx("span", { className: "sui-header-title", children: "Story UI" }), _jsxs(Badge, { variant: state.connectionStatus.connected ? 'success' : 'destructive', children: [_jsx("span", { className: "sui-badge-dot" }), state.connectionStatus.connected ? getConnectionDisplayText() : 'Disconnected'] })] }), _jsx("div", { className: "sui-header-right", children: state.connectionStatus.connected && state.availableProviders.length > 0 && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sui-select", children: [_jsxs("div", { className: "sui-select-trigger", children: [_jsx("span", { children: state.availableProviders.find(p => p.type === state.selectedProvider)?.name || 'Provider' }), Icons.chevronDown] }), _jsx("select", { className: "sui-select-native", value: state.selectedProvider, onChange: e => {
1259
1321
  const newProvider = e.target.value;
1260
1322
  dispatch({ type: 'SET_SELECTED_PROVIDER', payload: newProvider });
1261
1323
  const provider = state.availableProviders.find(p => p.type === newProvider);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpitre/story-ui",
3
- "version": "4.1.2",
3
+ "version": "4.3.0",
4
4
  "description": "AI-powered Storybook story generator with dynamic component discovery",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1467,3 +1467,59 @@
1467
1467
  .sui-hidden {
1468
1468
  display: none;
1469
1469
  }
1470
+
1471
+ /* ============================================
1472
+ Orphan Stories Footer
1473
+ ============================================ */
1474
+ .sui-orphan-footer {
1475
+ flex-shrink: 0;
1476
+ padding-top: var(--space-2);
1477
+ border-top: 1px solid hsl(var(--border));
1478
+ margin-top: auto;
1479
+ }
1480
+
1481
+ .sui-orphan-delete-btn {
1482
+ display: flex;
1483
+ align-items: center;
1484
+ gap: var(--space-2);
1485
+ width: 100%;
1486
+ padding: var(--space-2) var(--space-3);
1487
+ border: none;
1488
+ background: transparent;
1489
+ color: hsl(var(--muted-foreground));
1490
+ font-size: 0.75rem;
1491
+ cursor: pointer;
1492
+ border-radius: var(--radius-sm);
1493
+ transition: all var(--transition-fast);
1494
+ }
1495
+
1496
+ .sui-orphan-delete-btn:hover:not(:disabled) {
1497
+ background: hsl(var(--destructive) / 0.1);
1498
+ color: hsl(var(--destructive));
1499
+ }
1500
+
1501
+ .sui-orphan-delete-btn:disabled {
1502
+ cursor: not-allowed;
1503
+ opacity: 0.6;
1504
+ }
1505
+
1506
+ .sui-orphan-delete-btn svg {
1507
+ width: 14px;
1508
+ height: 14px;
1509
+ flex-shrink: 0;
1510
+ }
1511
+
1512
+ .sui-orphan-spinner {
1513
+ width: 14px;
1514
+ height: 14px;
1515
+ border: 2px solid hsl(var(--muted-foreground) / 0.3);
1516
+ border-top-color: hsl(var(--muted-foreground));
1517
+ border-radius: 50%;
1518
+ animation: sui-spin 0.6s linear infinite;
1519
+ }
1520
+
1521
+ @keyframes sui-spin {
1522
+ to {
1523
+ transform: rotate(360deg);
1524
+ }
1525
+ }