@geminilight/mindos 0.5.36 → 0.5.38

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.
@@ -2,83 +2,14 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useMemo } from 'react';
4
4
  import {
5
- AlertCircle, Loader2, ChevronDown, ChevronRight,
6
- Trash2, Plus, X, Search, Pencil,
5
+ Loader2, ChevronDown, ChevronRight,
6
+ Plus, X, Search,
7
7
  } from 'lucide-react';
8
8
  import { apiFetch } from '@/lib/api';
9
9
  import { useMcpDataOptional } from '@/hooks/useMcpData';
10
- import { Toggle } from './Primitives';
11
- import dynamic from 'next/dynamic';
12
10
  import type { SkillInfo, McpSkillsSectionProps } from './types';
13
-
14
- const MarkdownView = dynamic(() => import('@/components/MarkdownView'), { ssr: false });
15
-
16
- /* ── Helpers ───────────────────────────────────────────────────── */
17
-
18
- /** Strip YAML frontmatter (first `---` … `---` block) from markdown content. */
19
- function stripFrontmatter(content: string): string {
20
- if (!content.startsWith('---')) return content;
21
- const end = content.indexOf('\n---', 3);
22
- if (end === -1) return content;
23
- return content.slice(end + 4).replace(/^\n+/, '');
24
- }
25
-
26
- const skillFrontmatter = (n: string) => `---
27
- name: ${n}
28
- description: >
29
- Describe WHEN the agent should use this
30
- skill. Be specific about trigger conditions.
31
- ---`;
32
-
33
- const SKILL_TEMPLATES: Record<string, (name: string) => string> = {
34
- general: (n) => `${skillFrontmatter(n)}
35
-
36
- # Instructions
37
-
38
- ## Context
39
- <!-- Background knowledge for the agent -->
40
-
41
- ## Steps
42
- 1.
43
- 2.
44
-
45
- ## Rules
46
- <!-- Constraints, edge cases, formats -->
47
- - `,
48
-
49
- 'tool-use': (n) => `${skillFrontmatter(n)}
50
-
51
- # Instructions
52
-
53
- ## Available Tools
54
- <!-- List tools the agent can use -->
55
- -
56
-
57
- ## When to Use
58
- <!-- Conditions that trigger this skill -->
59
-
60
- ## Output Format
61
- <!-- Expected response structure -->
62
- `,
63
-
64
- workflow: (n) => `${skillFrontmatter(n)}
65
-
66
- # Instructions
67
-
68
- ## Trigger
69
- <!-- What triggers this workflow -->
70
-
71
- ## Steps
72
- 1.
73
- 2.
74
-
75
- ## Validation
76
- <!-- How to verify success -->
77
-
78
- ## Rollback
79
- <!-- What to do on failure -->
80
- `,
81
- };
11
+ import SkillRow from './McpSkillRow';
12
+ import SkillCreateForm from './McpSkillCreateForm';
82
13
 
83
14
  /* ── Skills Section ────────────────────────────────────────────── */
84
15
 
@@ -89,8 +20,6 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
89
20
  const [loading, setLoading] = useState(true);
90
21
  const [expanded, setExpanded] = useState<string | null>(null);
91
22
  const [adding, setAdding] = useState(false);
92
- const [newName, setNewName] = useState('');
93
- const [newContent, setNewContent] = useState('');
94
23
  const [saving, setSaving] = useState(false);
95
24
  const [createError, setCreateError] = useState('');
96
25
 
@@ -102,20 +31,17 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
102
31
  const [fullContent, setFullContent] = useState<Record<string, string>>({});
103
32
  const [loadingContent, setLoadingContent] = useState<string | null>(null);
104
33
  const [loadErrors, setLoadErrors] = useState<Record<string, string>>({});
105
- const [selectedTemplate, setSelectedTemplate] = useState<'general' | 'tool-use' | 'workflow'>('general');
106
- // 🟡 MAJOR #3: Prevent race condition in lang switch
107
34
  const [switchingLang, setSwitchingLang] = useState(false);
108
35
 
109
36
  const fetchSkills = useCallback(async () => {
110
37
  try {
111
38
  const data = await apiFetch<{ skills: SkillInfo[] }>('/api/skills');
112
39
  setSkills(data.skills);
113
- setLoadErrors({}); // Clear errors on success
40
+ setLoadErrors({});
114
41
  } catch (err) {
115
42
  const msg = err instanceof Error ? err.message : 'Failed to load skills';
116
43
  console.error('fetchSkills error:', msg);
117
44
  setLoadErrors(prev => ({ ...prev, _root: msg }));
118
- // Keep existing skills data rather than clearing
119
45
  }
120
46
  setLoading(false);
121
47
  }, []);
@@ -132,14 +58,14 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
132
58
  const customSkills = useMemo(() => filtered.filter(s => s.source === 'user'), [filtered]);
133
59
  const builtinSkills = useMemo(() => filtered.filter(s => s.source === 'builtin'), [filtered]);
134
60
 
61
+ // ── Handlers ──
62
+
135
63
  const handleToggle = async (name: string, enabled: boolean) => {
136
- // Delegate to McpProvider when available — single API call, no event storm
137
64
  if (mcp) {
138
65
  await mcp.toggleSkill(name, enabled);
139
66
  setSkills(prev => prev.map(s => s.name === name ? { ...s, enabled } : s));
140
67
  return;
141
68
  }
142
- // Fallback: direct API call (no McpProvider context)
143
69
  try {
144
70
  await apiFetch('/api/skills', {
145
71
  method: 'POST',
@@ -198,9 +124,7 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
198
124
  const handleExpand = (name: string) => {
199
125
  const next = expanded === name ? null : name;
200
126
  setExpanded(next);
201
- if (next) {
202
- loadFullContent(name);
203
- }
127
+ if (next) loadFullContent(name);
204
128
  if (editing && editing !== name) setEditing(null);
205
129
  };
206
130
 
@@ -221,7 +145,7 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
221
145
  });
222
146
  setFullContent(prev => ({ ...prev, [name]: editContent }));
223
147
  setEditing(null);
224
- fetchSkills(); // refresh description from updated frontmatter
148
+ fetchSkills();
225
149
  window.dispatchEvent(new Event('mindos:skills-changed'));
226
150
  } catch (err: unknown) {
227
151
  setEditError(err instanceof Error ? err.message : 'Failed to save skill');
@@ -236,27 +160,17 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
236
160
  setEditError('');
237
161
  };
238
162
 
239
- const getTemplate = (skillName: string, tmpl?: 'general' | 'tool-use' | 'workflow') => {
240
- const key = tmpl || selectedTemplate;
241
- const fn = SKILL_TEMPLATES[key] || SKILL_TEMPLATES.general;
242
- return fn(skillName || 'my-skill');
243
- };
244
-
245
- const handleCreate = async () => {
246
- if (!newName.trim()) return;
163
+ const handleCreate = async (name: string, content: string) => {
164
+ if (!name) return;
247
165
  setSaving(true);
248
166
  setCreateError('');
249
167
  try {
250
- // Content is the full SKILL.md (with frontmatter)
251
- const content = newContent || getTemplate(newName.trim());
252
168
  await apiFetch('/api/skills', {
253
169
  method: 'POST',
254
170
  headers: { 'Content-Type': 'application/json' },
255
- body: JSON.stringify({ action: 'create', name: newName.trim(), content }),
171
+ body: JSON.stringify({ action: 'create', name, content }),
256
172
  });
257
173
  setAdding(false);
258
- setNewName('');
259
- setNewContent('');
260
174
  fetchSkills();
261
175
  window.dispatchEvent(new Event('mindos:skills-changed'));
262
176
  } catch (err: unknown) {
@@ -266,25 +180,6 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
266
180
  }
267
181
  };
268
182
 
269
- // Sync template name when newName changes (only if content matches a template)
270
- const handleNameChange = (val: string) => {
271
- const cleaned = val.toLowerCase().replace(/[^a-z0-9-]/g, '');
272
- const oldTemplate = getTemplate(newName || 'my-skill');
273
- if (!newContent || newContent === oldTemplate) {
274
- setNewContent(getTemplate(cleaned || 'my-skill'));
275
- }
276
- setNewName(cleaned);
277
- };
278
-
279
- const handleTemplateChange = (tmpl: 'general' | 'tool-use' | 'workflow') => {
280
- const oldTemplate = getTemplate(newName || 'my-skill', selectedTemplate);
281
- setSelectedTemplate(tmpl);
282
- // Only replace content if it matches the old template (user hasn't customized)
283
- if (!newContent || newContent === oldTemplate) {
284
- setNewContent(getTemplate(newName || 'my-skill', tmpl));
285
- }
286
- };
287
-
288
183
  if (loading) {
289
184
  return (
290
185
  <div className="flex justify-center py-4">
@@ -293,107 +188,7 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
293
188
  );
294
189
  }
295
190
 
296
- const renderSkillRow = (skill: SkillInfo) => (
297
- <div key={skill.name} className="border border-border rounded-lg overflow-hidden">
298
- <div
299
- className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
300
- onClick={() => handleExpand(skill.name)}
301
- >
302
- {expanded === skill.name ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
303
- <span className="text-xs font-medium flex-1">{skill.name}</span>
304
- <span className={`text-2xs px-1.5 py-0.5 rounded ${
305
- skill.source === 'builtin' ? 'bg-blue-500/15 text-blue-500' : 'bg-purple-500/15 text-purple-500'
306
- }`}>
307
- {skill.source === 'builtin' ? (m?.skillBuiltin ?? 'Built-in') : (m?.skillUser ?? 'Custom')}
308
- </span>
309
- <Toggle size="sm" checked={skill.enabled} onClick={e => { e.stopPropagation(); handleToggle(skill.name, !skill.enabled); }} />
310
- </div>
311
-
312
- {expanded === skill.name && (
313
- <div className="px-3 py-2 border-t border-border text-xs space-y-2 bg-muted/20">
314
- <p className="text-muted-foreground">{skill.description || 'No description'}</p>
315
- <p className="text-muted-foreground font-mono text-2xs">{skill.path}</p>
316
-
317
- {/* Full content display / edit */}
318
- {loadingContent === skill.name ? (
319
- <div className="flex items-center gap-1.5 text-muted-foreground">
320
- <Loader2 size={10} className="animate-spin" />
321
- <span className="text-2xs">Loading...</span>
322
- </div>
323
- ) : fullContent[skill.name] ? (
324
- <div className="space-y-1.5">
325
- <div className="flex items-center justify-between">
326
- <span className="text-2xs text-muted-foreground font-medium">{m?.skillContent ?? 'Content'}</span>
327
- <div className="flex items-center gap-2">
328
- {skill.editable && editing !== skill.name && (
329
- <button
330
- onClick={() => handleEditStart(skill.name)}
331
- className="flex items-center gap-1 text-2xs text-muted-foreground hover:text-foreground transition-colors"
332
- >
333
- <Pencil size={10} />
334
- {m?.editSkill ?? 'Edit'}
335
- </button>
336
- )}
337
- {skill.editable && (
338
- <button
339
- onClick={() => handleDelete(skill.name)}
340
- className="flex items-center gap-1 text-2xs text-destructive hover:underline"
341
- >
342
- <Trash2 size={10} />
343
- {m?.deleteSkill ?? 'Delete'}
344
- </button>
345
- )}
346
- </div>
347
- </div>
348
-
349
- {editing === skill.name ? (
350
- <div className="space-y-1.5">
351
- <textarea
352
- value={editContent}
353
- onChange={e => setEditContent(e.target.value)}
354
- rows={Math.min(20, (editContent.match(/\n/g) || []).length + 3)}
355
- className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y font-mono"
356
- />
357
- <div className="flex items-center gap-2">
358
- <button
359
- onClick={() => handleEditSave(skill.name)}
360
- disabled={saving}
361
- className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
362
- style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
363
- >
364
- {saving && <Loader2 size={10} className="animate-spin" />}
365
- {m?.saveSkill ?? 'Save'}
366
- </button>
367
- <button
368
- onClick={handleEditCancel}
369
- className="px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground transition-colors"
370
- >
371
- {m?.cancelSkill ?? 'Cancel'}
372
- </button>
373
- </div>
374
- {editError && editing === skill.name && (
375
- <p className="text-2xs text-destructive flex items-center gap-1">
376
- <AlertCircle size={10} />
377
- {editError}
378
- </p>
379
- )}
380
- </div>
381
- ) : (
382
- <div className="w-full rounded-md border border-border bg-background/50 max-h-[300px] overflow-y-auto px-2.5 py-1.5 text-xs [&_.prose]:max-w-none [&_.prose]:text-xs [&_h1]:text-sm [&_h2]:text-xs [&_h3]:text-xs [&_pre]:text-2xs [&_code]:text-2xs">
383
- <MarkdownView content={stripFrontmatter(fullContent[skill.name])} />
384
- </div>
385
- )}
386
- </div>
387
- ) : loadErrors[skill.name] ? (
388
- <p className="text-2xs text-destructive flex items-center gap-1">
389
- <AlertCircle size={10} />
390
- {loadErrors[skill.name]}
391
- </p>
392
- ) : null}
393
- </div>
394
- )}
395
- </div>
396
- );
191
+ // ── Render ──
397
192
 
398
193
  return (
399
194
  <div className="space-y-3 pt-2">
@@ -408,10 +203,7 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
408
203
  className="w-full pl-7 pr-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
409
204
  />
410
205
  {search && (
411
- <button
412
- onClick={() => setSearch('')}
413
- className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
414
- >
206
+ <button onClick={() => setSearch('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
415
207
  <X size={10} />
416
208
  </button>
417
209
  )}
@@ -426,7 +218,6 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
426
218
  setSwitchingLang(true);
427
219
  try {
428
220
  if (lang === 'en') {
429
- // Sequential to ensure both complete or both revert on failure
430
221
  await handleToggle('mindos', true);
431
222
  await handleToggle('mindos-zh', false);
432
223
  } else {
@@ -435,7 +226,6 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
435
226
  }
436
227
  } catch (err) {
437
228
  console.error('Lang switch failed:', err);
438
- // Errors are already set by handleToggle; no further action needed
439
229
  } finally {
440
230
  setSwitchingLang(false);
441
231
  }
@@ -448,9 +238,7 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
448
238
  onClick={() => handleLangSwitch('en')}
449
239
  disabled={switchingLang}
450
240
  className={`px-2.5 py-1 text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
451
- currentLang === 'en'
452
- ? 'bg-amber-500/15 text-amber-600 font-medium'
453
- : 'text-muted-foreground hover:bg-muted'
241
+ currentLang === 'en' ? 'bg-amber-500/15 text-amber-600 font-medium' : 'text-muted-foreground hover:bg-muted'
454
242
  }`}
455
243
  >
456
244
  {m?.skillLangEn ?? 'English'}
@@ -459,9 +247,7 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
459
247
  onClick={() => handleLangSwitch('zh')}
460
248
  disabled={switchingLang}
461
249
  className={`px-2.5 py-1 text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-l border-border ${
462
- currentLang === 'zh'
463
- ? 'bg-amber-500/15 text-amber-600 font-medium'
464
- : 'text-muted-foreground hover:bg-muted'
250
+ currentLang === 'zh' ? 'bg-amber-500/15 text-amber-600 font-medium' : 'text-muted-foreground hover:bg-muted'
465
251
  }`}
466
252
  >
467
253
  {m?.skillLangZh ?? '中文'}
@@ -478,19 +264,40 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
478
264
  </p>
479
265
  )}
480
266
 
481
- {/* Custom group — always open */}
267
+ {/* Custom group */}
482
268
  {customSkills.length > 0 && (
483
269
  <div className="space-y-1.5">
484
270
  <div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
485
271
  <span>{m?.customGroup ?? 'Custom'} ({customSkills.length})</span>
486
272
  </div>
487
273
  <div className="space-y-1.5">
488
- {customSkills.map(renderSkillRow)}
274
+ {customSkills.map(skill => (
275
+ <SkillRow
276
+ key={skill.name}
277
+ skill={skill}
278
+ expanded={expanded === skill.name}
279
+ onExpand={handleExpand}
280
+ onToggle={handleToggle}
281
+ onDelete={handleDelete}
282
+ onEditStart={handleEditStart}
283
+ onEditSave={handleEditSave}
284
+ onEditCancel={handleEditCancel}
285
+ editing={editing}
286
+ editContent={editContent}
287
+ setEditContent={setEditContent}
288
+ editError={editError}
289
+ saving={saving}
290
+ fullContent={fullContent}
291
+ loadingContent={loadingContent}
292
+ loadErrors={loadErrors}
293
+ m={m}
294
+ />
295
+ ))}
489
296
  </div>
490
297
  </div>
491
298
  )}
492
299
 
493
- {/* Built-in group — collapsible, default collapsed */}
300
+ {/* Built-in group */}
494
301
  {builtinSkills.length > 0 && (
495
302
  <div className="space-y-1.5">
496
303
  <div
@@ -502,88 +309,45 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
502
309
  </div>
503
310
  {!builtinCollapsed && (
504
311
  <div className="space-y-1.5">
505
- {builtinSkills.map(renderSkillRow)}
312
+ {builtinSkills.map(skill => (
313
+ <SkillRow
314
+ key={skill.name}
315
+ skill={skill}
316
+ expanded={expanded === skill.name}
317
+ onExpand={handleExpand}
318
+ onToggle={handleToggle}
319
+ onDelete={handleDelete}
320
+ onEditStart={handleEditStart}
321
+ onEditSave={handleEditSave}
322
+ onEditCancel={handleEditCancel}
323
+ editing={editing}
324
+ editContent={editContent}
325
+ setEditContent={setEditContent}
326
+ editError={editError}
327
+ saving={saving}
328
+ fullContent={fullContent}
329
+ loadingContent={loadingContent}
330
+ loadErrors={loadErrors}
331
+ m={m}
332
+ />
333
+ ))}
506
334
  </div>
507
335
  )}
508
336
  </div>
509
337
  )}
510
338
 
511
- {/* Add skill form — template-based */}
339
+ {/* Add skill */}
512
340
  {adding ? (
513
- <div className="border border-border rounded-lg p-3 space-y-2">
514
- <div className="flex items-center justify-between">
515
- <span className="text-xs font-medium">{m?.addSkill ?? '+ Add Skill'}</span>
516
- <button onClick={() => { setAdding(false); setNewName(''); setNewContent(''); setCreateError(''); }} className="p-0.5 rounded hover:bg-muted text-muted-foreground">
517
- <X size={12} />
518
- </button>
519
- </div>
520
- <div className="space-y-1">
521
- <label className="text-2xs text-muted-foreground">{m?.skillName ?? 'Name'}</label>
522
- <input
523
- type="text"
524
- value={newName}
525
- onChange={e => handleNameChange(e.target.value)}
526
- placeholder="my-skill"
527
- className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
528
- />
529
- </div>
530
- <div className="space-y-1">
531
- <label className="text-2xs text-muted-foreground">{m?.skillTemplate ?? 'Template'}</label>
532
- <div className="flex rounded-md border border-border overflow-hidden w-fit">
533
- {(['general', 'tool-use', 'workflow'] as const).map((tmpl, i) => (
534
- <button
535
- key={tmpl}
536
- onClick={() => handleTemplateChange(tmpl)}
537
- className={`px-2.5 py-1 text-xs transition-colors ${i > 0 ? 'border-l border-border' : ''} ${
538
- selectedTemplate === tmpl
539
- ? 'bg-amber-500/15 text-amber-600 font-medium'
540
- : 'text-muted-foreground hover:bg-muted'
541
- }`}
542
- >
543
- {tmpl === 'general' ? (m?.skillTemplateGeneral ?? 'General')
544
- : tmpl === 'tool-use' ? (m?.skillTemplateToolUse ?? 'Tool-use')
545
- : (m?.skillTemplateWorkflow ?? 'Workflow')}
546
- </button>
547
- ))}
548
- </div>
549
- </div>
550
- <div className="space-y-1">
551
- <label className="text-2xs text-muted-foreground">{m?.skillContent ?? 'Content'}</label>
552
- <textarea
553
- value={newContent}
554
- onChange={e => setNewContent(e.target.value)}
555
- rows={16}
556
- placeholder="Skill instructions (markdown)..."
557
- className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y font-mono"
558
- />
559
- </div>
560
- {createError && (
561
- <p className="text-2xs text-destructive flex items-center gap-1">
562
- <AlertCircle size={10} />
563
- {createError}
564
- </p>
565
- )}
566
- <div className="flex items-center gap-2">
567
- <button
568
- onClick={handleCreate}
569
- disabled={!newName.trim() || saving}
570
- className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
571
- style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
572
- >
573
- {saving && <Loader2 size={10} className="animate-spin" />}
574
- {m?.saveSkill ?? 'Save'}
575
- </button>
576
- <button
577
- onClick={() => { setAdding(false); setNewName(''); setNewContent(''); setCreateError(''); }}
578
- className="px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground transition-colors"
579
- >
580
- {m?.cancelSkill ?? 'Cancel'}
581
- </button>
582
- </div>
583
- </div>
341
+ <SkillCreateForm
342
+ onSave={handleCreate}
343
+ onCancel={() => { setAdding(false); setCreateError(''); }}
344
+ saving={saving}
345
+ error={createError}
346
+ m={m}
347
+ />
584
348
  ) : (
585
349
  <button
586
- onClick={() => { setAdding(true); setSelectedTemplate('general'); setNewContent(getTemplate('my-skill', 'general')); }}
350
+ onClick={() => setAdding(true)}
587
351
  className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
588
352
  >
589
353
  <Plus size={12} />
@@ -0,0 +1,117 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { RIGHT_ASK_DEFAULT_WIDTH, RIGHT_ASK_MIN_WIDTH, RIGHT_ASK_MAX_WIDTH } from '@/components/RightAskPanel';
5
+ import { useAskModal } from './useAskModal';
6
+
7
+ export interface AskPanelState {
8
+ askPanelOpen: boolean;
9
+ askPanelWidth: number;
10
+ askMode: 'panel' | 'popup';
11
+ desktopAskPopupOpen: boolean;
12
+ askInitialMessage: string;
13
+ askOpenSource: 'user' | 'guide' | 'guide-next';
14
+ toggleAskPanel: () => void;
15
+ closeAskPanel: () => void;
16
+ closeDesktopAskPopup: () => void;
17
+ handleAskWidthChange: (w: number) => void;
18
+ handleAskWidthCommit: (w: number) => void;
19
+ handleAskModeSwitch: () => void;
20
+ }
21
+
22
+ /**
23
+ * Manages right-side Ask AI panel state: open/close, width, panel/popup mode, initial message.
24
+ * Extracted from SidebarLayout to reduce its state complexity.
25
+ */
26
+ export function useAskPanel(): AskPanelState {
27
+ const [askPanelOpen, setAskPanelOpen] = useState(false);
28
+ const [askPanelWidth, setAskPanelWidth] = useState(RIGHT_ASK_DEFAULT_WIDTH);
29
+ const [askMode, setAskMode] = useState<'panel' | 'popup'>('panel');
30
+ const [desktopAskPopupOpen, setDesktopAskPopupOpen] = useState(false);
31
+ const [askInitialMessage, setAskInitialMessage] = useState('');
32
+ const [askOpenSource, setAskOpenSource] = useState<'user' | 'guide' | 'guide-next'>('user');
33
+
34
+ const askModal = useAskModal();
35
+
36
+ // Load persisted width + mode
37
+ useEffect(() => {
38
+ try {
39
+ const stored = localStorage.getItem('right-ask-panel-width');
40
+ if (stored) {
41
+ const w = parseInt(stored, 10);
42
+ if (w >= RIGHT_ASK_MIN_WIDTH && w <= RIGHT_ASK_MAX_WIDTH) setAskPanelWidth(w);
43
+ }
44
+ const mode = localStorage.getItem('ask-mode');
45
+ if (mode === 'popup') setAskMode('popup');
46
+ } catch {}
47
+
48
+ const onStorage = (e: StorageEvent) => {
49
+ if (e.key === 'ask-mode' && (e.newValue === 'panel' || e.newValue === 'popup')) {
50
+ setAskMode(e.newValue);
51
+ }
52
+ };
53
+ window.addEventListener('storage', onStorage);
54
+ return () => window.removeEventListener('storage', onStorage);
55
+ }, []);
56
+
57
+ // Bridge useAskModal store → right Ask panel or popup
58
+ useEffect(() => {
59
+ if (askModal.open) {
60
+ setAskInitialMessage(askModal.initialMessage);
61
+ setAskOpenSource(askModal.source);
62
+ if (askMode === 'popup') {
63
+ setDesktopAskPopupOpen(true);
64
+ } else {
65
+ setAskPanelOpen(true);
66
+ }
67
+ askModal.close();
68
+ }
69
+ }, [askModal.open, askModal.initialMessage, askModal.source, askModal.close, askMode]);
70
+
71
+ const toggleAskPanel = useCallback(() => {
72
+ if (askMode === 'popup') {
73
+ setDesktopAskPopupOpen(v => {
74
+ if (!v) { setAskInitialMessage(''); setAskOpenSource('user'); }
75
+ return !v;
76
+ });
77
+ } else {
78
+ setAskPanelOpen(v => {
79
+ if (!v) { setAskInitialMessage(''); setAskOpenSource('user'); }
80
+ return !v;
81
+ });
82
+ }
83
+ }, [askMode]);
84
+
85
+ const closeAskPanel = useCallback(() => setAskPanelOpen(false), []);
86
+ const closeDesktopAskPopup = useCallback(() => setDesktopAskPopupOpen(false), []);
87
+
88
+ const handleAskWidthChange = useCallback((w: number) => setAskPanelWidth(w), []);
89
+ const handleAskWidthCommit = useCallback((w: number) => {
90
+ try { localStorage.setItem('right-ask-panel-width', String(w)); } catch {}
91
+ }, []);
92
+
93
+ const handleAskModeSwitch = useCallback(() => {
94
+ setAskMode(prev => {
95
+ const next = prev === 'panel' ? 'popup' : 'panel';
96
+ try {
97
+ localStorage.setItem('ask-mode', next);
98
+ window.dispatchEvent(new StorageEvent('storage', { key: 'ask-mode', newValue: next }));
99
+ } catch {}
100
+ if (next === 'popup') {
101
+ setAskPanelOpen(false);
102
+ setDesktopAskPopupOpen(true);
103
+ } else {
104
+ setDesktopAskPopupOpen(false);
105
+ setAskPanelOpen(true);
106
+ }
107
+ return next;
108
+ });
109
+ }, []);
110
+
111
+ return {
112
+ askPanelOpen, askPanelWidth, askMode, desktopAskPopupOpen,
113
+ askInitialMessage, askOpenSource,
114
+ toggleAskPanel, closeAskPanel, closeDesktopAskPopup,
115
+ handleAskWidthChange, handleAskWidthCommit, handleAskModeSwitch,
116
+ };
117
+ }