zugzbot 1.0.13 → 1.0.14
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/.utils/zugzweb/client/dist/assets/index-B8li_PQD.js +291 -0
- package/.utils/zugzweb/client/dist/assets/index-S6XzvY4z.css +1 -0
- package/.utils/zugzweb/client/dist/index.html +2 -2
- package/.utils/zugzweb/client/src/App.tsx +597 -46
- package/.utils/zugzweb/daemon.js +57 -3
- package/.utils/zugzweb/daemon.log +5 -0
- package/bin/web.js +20 -0
- package/package.json +48 -3
- package/.utils/zugzweb/client/dist/assets/index-CcbbJtSz.css +0 -1
- package/.utils/zugzweb/client/dist/assets/index-DXxAlmSX.js +0 -266
|
@@ -23,7 +23,12 @@ import {
|
|
|
23
23
|
Code,
|
|
24
24
|
Sparkles,
|
|
25
25
|
Clock,
|
|
26
|
-
AlertCircle
|
|
26
|
+
AlertCircle,
|
|
27
|
+
Folder,
|
|
28
|
+
FolderOpen,
|
|
29
|
+
ArrowUp,
|
|
30
|
+
Home,
|
|
31
|
+
Compass
|
|
27
32
|
} from 'lucide-react'
|
|
28
33
|
|
|
29
34
|
// Interfaces basadas en la API de Opencode
|
|
@@ -112,6 +117,11 @@ const AVAILABLE_AGENTS = [
|
|
|
112
117
|
export default function App() {
|
|
113
118
|
const [sessions, setSessions] = useState<Session[]>([])
|
|
114
119
|
const [currentSessionId, setCurrentSessionId] = useState<string>('')
|
|
120
|
+
const currentSessionIdRef = useRef<string>('')
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
currentSessionIdRef.current = currentSessionId
|
|
124
|
+
}, [currentSessionId])
|
|
115
125
|
const [currentSession, setCurrentSession] = useState<Session | null>(null)
|
|
116
126
|
const [messages, setMessages] = useState<Message[]>([])
|
|
117
127
|
const [inputValue, setInputValue] = useState('')
|
|
@@ -122,6 +132,7 @@ export default function App() {
|
|
|
122
132
|
const [tunnelUrl, setTunnelUrl] = useState<string>('')
|
|
123
133
|
const [expandedParts, setExpandedParts] = useState<Record<string, boolean>>({})
|
|
124
134
|
const [errorMsg, setErrorMsg] = useState<string>('')
|
|
135
|
+
const [systemCwd, setSystemCwd] = useState<string>('')
|
|
125
136
|
|
|
126
137
|
// Soporte para multi-instancia / selección de proyectos
|
|
127
138
|
const [instances, setInstances] = useState<OpencodeInstance[]>([])
|
|
@@ -137,13 +148,7 @@ export default function App() {
|
|
|
137
148
|
// ignore
|
|
138
149
|
}
|
|
139
150
|
}
|
|
140
|
-
return [
|
|
141
|
-
{
|
|
142
|
-
id: 'workspace_raiz',
|
|
143
|
-
name: 'Workspace Raíz',
|
|
144
|
-
path: '/Users/wavesbyte/Documents/Repositorio Zugzbot'
|
|
145
|
-
}
|
|
146
|
-
]
|
|
151
|
+
return []
|
|
147
152
|
})
|
|
148
153
|
|
|
149
154
|
const [sessionMappings, setSessionMappings] = useState<Record<string, string>>(() => {
|
|
@@ -224,7 +229,191 @@ export default function App() {
|
|
|
224
229
|
const [newSessionFirstPrompt, setNewSessionFirstPrompt] = useState<string>('')
|
|
225
230
|
const [selectedAgent, setSelectedAgent] = useState<string>('sdd-orchestrator')
|
|
226
231
|
|
|
232
|
+
// Estado de Onboarding Setup
|
|
233
|
+
const [setupCompleted, setSetupCompleted] = useState<boolean>(() => {
|
|
234
|
+
return localStorage.getItem('zugzweb_setup_completed') === 'true'
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// Estados para el explorador de archivos
|
|
238
|
+
const [showExplorerModal, setShowExplorerModal] = useState(false)
|
|
239
|
+
const [explorerCurrentPath, setExplorerCurrentPath] = useState('')
|
|
240
|
+
const [explorerParentPath, setExplorerParentPath] = useState<string | null>(null)
|
|
241
|
+
const [explorerDirectories, setExplorerDirectories] = useState<string[]>([])
|
|
242
|
+
const [explorerHomePath, setExplorerHomePath] = useState('')
|
|
243
|
+
const [explorerCwdPath, setExplorerCwdPath] = useState('')
|
|
244
|
+
const [explorerError, setExplorerError] = useState('')
|
|
245
|
+
const [explorerLoading, setExplorerLoading] = useState(false)
|
|
246
|
+
const [explorerCallback, setExplorerCallback] = useState<((path: string) => void) | null>(null)
|
|
247
|
+
const [explorerTitle, setExplorerTitle] = useState('Seleccionar Carpeta')
|
|
248
|
+
|
|
249
|
+
// Función para cargar un directorio en el explorador de archivos
|
|
250
|
+
const loadDirectory = async (pathStr?: string) => {
|
|
251
|
+
setExplorerLoading(true)
|
|
252
|
+
setExplorerError('')
|
|
253
|
+
try {
|
|
254
|
+
const queryParam = pathStr ? `?path=${encodeURIComponent(pathStr)}` : ''
|
|
255
|
+
const res = await fetch(`/api-custom/list-dir${queryParam}`)
|
|
256
|
+
if (res.ok) {
|
|
257
|
+
const data = await res.json()
|
|
258
|
+
setExplorerCurrentPath(data.currentPath)
|
|
259
|
+
setExplorerParentPath(data.parentPath)
|
|
260
|
+
setExplorerDirectories(data.directories || [])
|
|
261
|
+
setExplorerHomePath(data.home || '')
|
|
262
|
+
setExplorerCwdPath(data.cwd || '')
|
|
263
|
+
setExplorerError('')
|
|
264
|
+
} else {
|
|
265
|
+
const data = await res.json()
|
|
266
|
+
setExplorerError(data.error || 'No se pudo leer el directorio')
|
|
267
|
+
}
|
|
268
|
+
} catch (err: any) {
|
|
269
|
+
setExplorerError('Error de red al conectar con el servidor: ' + err.message)
|
|
270
|
+
} finally {
|
|
271
|
+
setExplorerLoading(false)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Abre el explorador de archivos con una configuración específica
|
|
276
|
+
const openExplorer = (title: string, initialPath: string, callback: (selectedPath: string) => void) => {
|
|
277
|
+
setExplorerTitle(title)
|
|
278
|
+
setExplorerCallback(() => callback)
|
|
279
|
+
setShowExplorerModal(true)
|
|
280
|
+
loadDirectory(initialPath || systemCwd)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Carga inicial del explorador de archivos en base al CWD del sistema al arrancar
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (systemCwd && !explorerCurrentPath) {
|
|
286
|
+
setExplorerCurrentPath(systemCwd)
|
|
287
|
+
loadDirectory(systemCwd)
|
|
288
|
+
}
|
|
289
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
290
|
+
}, [systemCwd])
|
|
291
|
+
|
|
292
|
+
const renderExplorerContent = (onConfirm: (path: string) => void) => {
|
|
293
|
+
return (
|
|
294
|
+
<div className="space-y-4">
|
|
295
|
+
{/* Caja de Texto de la Ruta y Controles Rápidos */}
|
|
296
|
+
<div className="space-y-2">
|
|
297
|
+
<div className="flex gap-2">
|
|
298
|
+
<input
|
|
299
|
+
type="text"
|
|
300
|
+
value={explorerCurrentPath}
|
|
301
|
+
onChange={(e) => setExplorerCurrentPath(e.target.value)}
|
|
302
|
+
onKeyDown={(e) => {
|
|
303
|
+
if (e.key === 'Enter') {
|
|
304
|
+
loadDirectory(explorerCurrentPath)
|
|
305
|
+
}
|
|
306
|
+
}}
|
|
307
|
+
className="flex-1 bg-[#121215] border border-[#27272a] focus:border-[#3f3f46] rounded-xl px-3.5 py-2 text-xs font-mono text-white outline-none transition"
|
|
308
|
+
placeholder="Ruta absoluta..."
|
|
309
|
+
/>
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
onClick={() => loadDirectory(explorerCurrentPath)}
|
|
313
|
+
className="px-3 py-2 bg-white text-[#09090b] text-xs font-bold rounded-xl hover:bg-[#e4e4e7] transition cursor-pointer"
|
|
314
|
+
>
|
|
315
|
+
Ir
|
|
316
|
+
</button>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div className="flex gap-2 items-center flex-wrap">
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
disabled={!explorerParentPath}
|
|
323
|
+
onClick={() => explorerParentPath && loadDirectory(explorerParentPath)}
|
|
324
|
+
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#1c1c1f] hover:bg-[#27272a] disabled:opacity-40 disabled:hover:bg-[#1c1c1f] border border-[#2e2e33] rounded-lg text-xs text-white transition cursor-pointer font-medium"
|
|
325
|
+
title="Subir un nivel"
|
|
326
|
+
>
|
|
327
|
+
<ArrowUp size={12} />
|
|
328
|
+
<span>Subir Nivel</span>
|
|
329
|
+
</button>
|
|
330
|
+
|
|
331
|
+
<button
|
|
332
|
+
type="button"
|
|
333
|
+
onClick={() => loadDirectory(explorerHomePath)}
|
|
334
|
+
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#1c1c1f] hover:bg-[#27272a] border border-[#2e2e33] rounded-lg text-xs text-white transition cursor-pointer font-medium"
|
|
335
|
+
title="Carpeta Home"
|
|
336
|
+
>
|
|
337
|
+
<Home size={12} />
|
|
338
|
+
<span>Home</span>
|
|
339
|
+
</button>
|
|
340
|
+
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
onClick={() => loadDirectory(explorerCwdPath)}
|
|
344
|
+
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#1c1c1f] hover:bg-[#27272a] border border-[#2e2e33] rounded-lg text-xs text-white transition cursor-pointer font-medium"
|
|
345
|
+
title="Directorio Raíz del Servidor"
|
|
346
|
+
>
|
|
347
|
+
<Compass size={12} />
|
|
348
|
+
<span>CWD Servidor</span>
|
|
349
|
+
</button>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
{/* Mensaje de Error */}
|
|
354
|
+
{explorerError && (
|
|
355
|
+
<div className="p-3 bg-red-950/40 border border-red-900/50 rounded-xl flex items-center gap-2 text-red-400 text-xs animate-pulse">
|
|
356
|
+
<AlertCircle size={14} className="shrink-0" />
|
|
357
|
+
<span>{explorerError}</span>
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{/* Lista de Carpetas */}
|
|
362
|
+
<div className="border border-[#1f1f23] rounded-xl bg-[#070709] overflow-hidden">
|
|
363
|
+
<div className="p-2 border-b border-[#1f1f23] bg-[#0c0c0e]/60 text-[10px] text-[#71717a] font-bold uppercase tracking-wider select-none">
|
|
364
|
+
Subcarpetas en este directorio:
|
|
365
|
+
</div>
|
|
366
|
+
<div className="max-h-64 overflow-y-auto p-2 space-y-1 scrollbar-thin">
|
|
367
|
+
{explorerLoading ? (
|
|
368
|
+
<div className="py-8 flex flex-col items-center justify-center text-[#71717a] space-y-2">
|
|
369
|
+
<RefreshCw size={18} className="animate-spin text-white/60" />
|
|
370
|
+
<span className="text-xs">Cargando carpetas...</span>
|
|
371
|
+
</div>
|
|
372
|
+
) : explorerDirectories.length === 0 ? (
|
|
373
|
+
<div className="py-8 text-center text-xs text-[#52525b] italic">
|
|
374
|
+
No se encontraron subcarpetas visibles en este directorio.
|
|
375
|
+
</div>
|
|
376
|
+
) : (
|
|
377
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
|
378
|
+
{explorerDirectories.map((dir, dIdx) => (
|
|
379
|
+
<button
|
|
380
|
+
key={dIdx}
|
|
381
|
+
type="button"
|
|
382
|
+
onClick={() => {
|
|
383
|
+
const separator = explorerCurrentPath.includes('\\') ? '\\' : '/'
|
|
384
|
+
const newPath = explorerCurrentPath.endsWith(separator)
|
|
385
|
+
? explorerCurrentPath + dir
|
|
386
|
+
: explorerCurrentPath + separator + dir
|
|
387
|
+
loadDirectory(newPath)
|
|
388
|
+
}}
|
|
389
|
+
className="flex items-center gap-2 p-2 rounded-lg bg-[#0c0c0e]/40 border border-[#1c1c1f] hover:border-[#27272a] hover:bg-[#121215] text-left text-xs text-white transition cursor-pointer outline-none truncate"
|
|
390
|
+
>
|
|
391
|
+
<Folder size={14} className="text-amber-500 shrink-0" />
|
|
392
|
+
<span className="truncate font-medium">{dir}</span>
|
|
393
|
+
</button>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
{/* Botón de Confirmación */}
|
|
401
|
+
<div className="flex justify-end pt-2">
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
onClick={() => onConfirm(explorerCurrentPath)}
|
|
405
|
+
className="flex items-center gap-1.5 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-400 text-[#09090b] text-xs font-bold rounded-xl transition cursor-pointer shadow-md"
|
|
406
|
+
>
|
|
407
|
+
<Check size={14} strokeWidth={2.5} />
|
|
408
|
+
<span>Confirmar Selección</span>
|
|
409
|
+
</button>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
227
415
|
const chatContainerRef = useRef<HTMLDivElement>(null)
|
|
416
|
+
const isAbortingRef = useRef<boolean>(false)
|
|
228
417
|
const [userHasScrolledUp, setUserHasScrolledUp] = useState(false)
|
|
229
418
|
|
|
230
419
|
const navigateTo = (path: string) => {
|
|
@@ -242,6 +431,7 @@ export default function App() {
|
|
|
242
431
|
// 1. Cargar estado inicial y levantar listeners
|
|
243
432
|
useEffect(() => {
|
|
244
433
|
fetchTunnelStatus()
|
|
434
|
+
fetchSystemCwd()
|
|
245
435
|
fetchInstances()
|
|
246
436
|
fetchSessions()
|
|
247
437
|
|
|
@@ -281,34 +471,94 @@ export default function App() {
|
|
|
281
471
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
282
472
|
}, [currentSessionId, currentPort])
|
|
283
473
|
|
|
284
|
-
//
|
|
474
|
+
// Referencias para el throttle del refresco de mensajes en tiempo real
|
|
475
|
+
const lastFetchTimeRef = useRef<number>(0)
|
|
476
|
+
const pendingFetchRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
477
|
+
|
|
478
|
+
const fetchMessagesThrottled = (id: string) => {
|
|
479
|
+
if (!id) return
|
|
480
|
+
const now = Date.now()
|
|
481
|
+
const limit = 200 // refresco a lo sumo cada 200ms para un streaming ultrasuave
|
|
482
|
+
|
|
483
|
+
if (pendingFetchRef.current) {
|
|
484
|
+
return // Ya hay una petición en cola que capturará el último token
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const remaining = limit - (now - lastFetchTimeRef.current)
|
|
488
|
+
if (remaining <= 0) {
|
|
489
|
+
fetchMessages(id)
|
|
490
|
+
lastFetchTimeRef.current = now
|
|
491
|
+
} else {
|
|
492
|
+
pendingFetchRef.current = setTimeout(() => {
|
|
493
|
+
fetchMessages(id)
|
|
494
|
+
lastFetchTimeRef.current = Date.now()
|
|
495
|
+
pendingFetchRef.current = null
|
|
496
|
+
}, remaining)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// 3. Sistema SSE en tiempo real con fallback de Heartbeat lento
|
|
285
501
|
useEffect(() => {
|
|
286
502
|
if (!currentSessionId) return
|
|
287
503
|
|
|
288
|
-
|
|
504
|
+
// Configurar EventSource para recibir eventos de Opencode en caliente
|
|
505
|
+
const es = new EventSource('/api/event')
|
|
506
|
+
|
|
507
|
+
const handleEvent = (event: MessageEvent) => {
|
|
508
|
+
try {
|
|
509
|
+
const data = JSON.parse(event.data)
|
|
510
|
+
const eventType = data.type || event.type
|
|
511
|
+
const sessionID = data.properties?.sessionID || data.properties?.sessionId || data.sessionId || data.session_id
|
|
512
|
+
|
|
513
|
+
if (eventType.startsWith('message.') && (!sessionID || sessionID === currentSessionId)) {
|
|
514
|
+
fetchMessagesThrottled(currentSessionId)
|
|
515
|
+
} else if (eventType.startsWith('session.') && (!sessionID || sessionID === currentSessionId)) {
|
|
516
|
+
fetchSessionStatus()
|
|
517
|
+
fetchSessionDetails(currentSessionId)
|
|
518
|
+
fetchMessagesThrottled(currentSessionId)
|
|
519
|
+
}
|
|
520
|
+
} catch (err) {
|
|
521
|
+
// En caso de que no sea JSON, refrescar de todos modos si es relevante
|
|
522
|
+
fetchMessagesThrottled(currentSessionId)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
es.addEventListener('message', handleEvent)
|
|
527
|
+
|
|
528
|
+
const sseEventTypes = [
|
|
529
|
+
'message.part.updated',
|
|
530
|
+
'message.part.removed',
|
|
531
|
+
'message.updated',
|
|
532
|
+
'session.status',
|
|
533
|
+
'session.idle',
|
|
534
|
+
'session.updated',
|
|
535
|
+
'session.created'
|
|
536
|
+
]
|
|
537
|
+
sseEventTypes.forEach(type => {
|
|
538
|
+
es.addEventListener(type, handleEvent)
|
|
539
|
+
})
|
|
289
540
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
rapidIntervalId = setInterval(() => {
|
|
293
|
-
fetchMessages(currentSessionId)
|
|
294
|
-
fetchSessionDetails(currentSessionId)
|
|
295
|
-
fetchSessionStatus()
|
|
296
|
-
}, 1500)
|
|
541
|
+
es.onerror = () => {
|
|
542
|
+
// El navegador reconecta automáticamente, pero podemos forzar un refresh por si acaso
|
|
297
543
|
}
|
|
298
544
|
|
|
299
|
-
// Heartbeat lento constante (cada
|
|
545
|
+
// Heartbeat lento constante (cada 6s) como red de seguridad/fallback
|
|
300
546
|
const heartbeatIntervalId = setInterval(() => {
|
|
301
547
|
fetchSessionStatus()
|
|
302
|
-
|
|
548
|
+
fetchMessagesThrottled(currentSessionId)
|
|
303
549
|
fetchSessionDetails(currentSessionId)
|
|
304
|
-
},
|
|
550
|
+
}, 6000)
|
|
305
551
|
|
|
306
552
|
return () => {
|
|
307
|
-
|
|
553
|
+
es.close()
|
|
554
|
+
if (pendingFetchRef.current) {
|
|
555
|
+
clearTimeout(pendingFetchRef.current)
|
|
556
|
+
pendingFetchRef.current = null
|
|
557
|
+
}
|
|
308
558
|
clearInterval(heartbeatIntervalId)
|
|
309
559
|
}
|
|
310
560
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
311
|
-
}, [currentSessionId, currentPort
|
|
561
|
+
}, [currentSessionId, currentPort])
|
|
312
562
|
|
|
313
563
|
// 3. Scroll automático inteligente al recibir nuevos mensajes
|
|
314
564
|
useEffect(() => {
|
|
@@ -383,6 +633,52 @@ export default function App() {
|
|
|
383
633
|
}
|
|
384
634
|
}
|
|
385
635
|
|
|
636
|
+
async function fetchSystemCwd() {
|
|
637
|
+
try {
|
|
638
|
+
const res = await fetch('/api-custom/cwd')
|
|
639
|
+
if (res.ok) {
|
|
640
|
+
const data = await res.json()
|
|
641
|
+
if (data.cwd) {
|
|
642
|
+
setSystemCwd(data.cwd)
|
|
643
|
+
|
|
644
|
+
setProjects(prev => {
|
|
645
|
+
const hasSaved = localStorage.getItem('zugzweb_projects')
|
|
646
|
+
if (!hasSaved || prev.length === 0) {
|
|
647
|
+
return [
|
|
648
|
+
{
|
|
649
|
+
id: 'workspace_raiz',
|
|
650
|
+
name: 'Carpeta Base Zugz',
|
|
651
|
+
path: data.cwd
|
|
652
|
+
}
|
|
653
|
+
]
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Si tiene proyectos guardados, nos aseguramos de que "workspace_raiz" apunte dinámicamente al cwd de esta ejecución
|
|
657
|
+
const baseProjExists = prev.some(p => p.id === 'workspace_raiz')
|
|
658
|
+
if (!baseProjExists) {
|
|
659
|
+
return [
|
|
660
|
+
{
|
|
661
|
+
id: 'workspace_raiz',
|
|
662
|
+
name: 'Carpeta Base Zugz',
|
|
663
|
+
path: data.cwd
|
|
664
|
+
},
|
|
665
|
+
...prev
|
|
666
|
+
]
|
|
667
|
+
}
|
|
668
|
+
return prev.map(p => {
|
|
669
|
+
if (p.id === 'workspace_raiz') {
|
|
670
|
+
return { ...p, name: 'Carpeta Base Zugz', path: data.cwd }
|
|
671
|
+
}
|
|
672
|
+
return p
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
} catch (error) {
|
|
678
|
+
console.error('Error al obtener cwd:', error)
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
386
682
|
async function fetchInstances() {
|
|
387
683
|
try {
|
|
388
684
|
const res = await fetch('/api-custom/instances')
|
|
@@ -403,14 +699,33 @@ export default function App() {
|
|
|
403
699
|
const data = await res.json()
|
|
404
700
|
const sessionList = Array.isArray(data) ? data : (data.sessions || [])
|
|
405
701
|
setSessions(sessionList)
|
|
702
|
+
|
|
703
|
+
// Registrar de forma permanente en localStorage el mapeo de las sesiones descubiertas al proyecto activo
|
|
704
|
+
setSessionMappings(prev => {
|
|
705
|
+
let updated = false
|
|
706
|
+
const nextMappings = { ...prev }
|
|
707
|
+
for (const s of sessionList) {
|
|
708
|
+
if (!nextMappings[s.id]) {
|
|
709
|
+
nextMappings[s.id] = activeProjectId
|
|
710
|
+
updated = true
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return updated ? nextMappings : prev
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
// Limpiar errores inmediatamente al lograr conectar exitosamente con la API de Opencode
|
|
717
|
+
setErrorMsg('')
|
|
718
|
+
|
|
406
719
|
if (sessionList.length > 0) {
|
|
407
|
-
|
|
720
|
+
// Validar que la sesión actual exista de verdad en el servidor activo
|
|
721
|
+
const exists = sessionList.some((s: Session) => s.id === currentSessionIdRef.current)
|
|
722
|
+
if (!currentSessionIdRef.current || !exists) {
|
|
723
|
+
// Si no hay sesión seleccionada o es un ID huérfano viejo, auto-seleccionar la primera disponible
|
|
408
724
|
setCurrentSessionId(sessionList[0].id)
|
|
409
725
|
}
|
|
410
726
|
} else {
|
|
411
727
|
navigateTo('/new')
|
|
412
728
|
}
|
|
413
|
-
setErrorMsg('')
|
|
414
729
|
} else {
|
|
415
730
|
const errData = await res.json().catch(() => ({}))
|
|
416
731
|
setErrorMsg(errData.message || 'No se pudo conectar con Opencode. ¿Has iniciado "opencode serve"?')
|
|
@@ -431,8 +746,9 @@ export default function App() {
|
|
|
431
746
|
}
|
|
432
747
|
setErrorMsg('')
|
|
433
748
|
} else {
|
|
434
|
-
|
|
435
|
-
|
|
749
|
+
// ID de sesión huérfano. Limpiamos para que fetchSessions auto-asigne uno nuevo válido en el próximo ciclo
|
|
750
|
+
setCurrentSessionId('')
|
|
751
|
+
setCurrentSession(null)
|
|
436
752
|
}
|
|
437
753
|
} catch (error) {
|
|
438
754
|
console.error(error)
|
|
@@ -468,12 +784,14 @@ export default function App() {
|
|
|
468
784
|
|
|
469
785
|
if (isBusy) {
|
|
470
786
|
setIsProcessing(prev => {
|
|
787
|
+
if (isAbortingRef.current) return false
|
|
471
788
|
if (!prev) {
|
|
472
789
|
return true
|
|
473
790
|
}
|
|
474
791
|
return prev
|
|
475
792
|
})
|
|
476
793
|
} else {
|
|
794
|
+
isAbortingRef.current = false
|
|
477
795
|
setIsProcessing(prev => {
|
|
478
796
|
if (prev) {
|
|
479
797
|
showNotification('Opencode: ¡Ejecución Completada! 🎉', 'El agente ha finalizado el procesamiento de tu solicitud con éxito.')
|
|
@@ -500,11 +818,12 @@ export default function App() {
|
|
|
500
818
|
e.preventDefault()
|
|
501
819
|
if (!newProjectName.trim() || !newProjectPath.trim()) return
|
|
502
820
|
|
|
503
|
-
// Asegurarse de que la ruta sea absoluta. Si es una subcarpeta relativa, concatenar a la
|
|
821
|
+
// Asegurarse de que la ruta sea absoluta. Si es una subcarpeta relativa, concatenar a la Carpeta Base Zugz
|
|
504
822
|
let resolvedPath = newProjectPath.trim()
|
|
505
823
|
if (!resolvedPath.startsWith('/')) {
|
|
506
|
-
const
|
|
507
|
-
|
|
824
|
+
const baseProject = projects.find(p => p.id === 'workspace_raiz')
|
|
825
|
+
const basePath = baseProject ? baseProject.path : systemCwd
|
|
826
|
+
resolvedPath = `${basePath}/${resolvedPath}`
|
|
508
827
|
}
|
|
509
828
|
|
|
510
829
|
const newId = `project_${Date.now()}`
|
|
@@ -567,6 +886,53 @@ export default function App() {
|
|
|
567
886
|
setExpandedProjects(prev => ({ ...prev, [projectId]: !prev[projectId] }))
|
|
568
887
|
}
|
|
569
888
|
|
|
889
|
+
// Cambia de sesión y conmuta de forma automática el proyecto activo si es necesario
|
|
890
|
+
const handleSelectSession = async (sessionId: string, projectId: string) => {
|
|
891
|
+
const project = projects.find(p => p.id === projectId)
|
|
892
|
+
if (!project) return
|
|
893
|
+
|
|
894
|
+
if (activeProjectId !== projectId) {
|
|
895
|
+
setIsProcessing(true)
|
|
896
|
+
setErrorMsg(`Activando entorno de "${project.name}"...`)
|
|
897
|
+
try {
|
|
898
|
+
const res = await fetch('/api-custom/activate-project', {
|
|
899
|
+
method: 'POST',
|
|
900
|
+
headers: { 'Content-Type': 'application/json' },
|
|
901
|
+
body: JSON.stringify({ path: project.path })
|
|
902
|
+
})
|
|
903
|
+
if (res.ok) {
|
|
904
|
+
setActiveProjectId(projectId)
|
|
905
|
+
|
|
906
|
+
// Limpiar de forma temporal para evitar ruidos de la sesión anterior
|
|
907
|
+
setSessions([])
|
|
908
|
+
setCurrentSessionId('')
|
|
909
|
+
setCurrentSession(null)
|
|
910
|
+
setMessages([])
|
|
911
|
+
|
|
912
|
+
// Forzar la recarga de sesiones de este proyecto
|
|
913
|
+
await fetchSessions()
|
|
914
|
+
|
|
915
|
+
// Establecer la sesión seleccionada
|
|
916
|
+
setCurrentSessionId(sessionId)
|
|
917
|
+
setErrorMsg('')
|
|
918
|
+
navigateTo('/')
|
|
919
|
+
} else {
|
|
920
|
+
const errData = await res.json().catch(() => ({}))
|
|
921
|
+
setErrorMsg(errData.error || 'Error al activar el proyecto para esta sesión')
|
|
922
|
+
}
|
|
923
|
+
} catch {
|
|
924
|
+
setErrorMsg('Error de conexión al activar el proyecto de la sesión')
|
|
925
|
+
} finally {
|
|
926
|
+
setIsProcessing(false)
|
|
927
|
+
}
|
|
928
|
+
} else {
|
|
929
|
+
// Si ya está en el proyecto activo, simplemente la seleccionamos
|
|
930
|
+
setCurrentSessionId(sessionId)
|
|
931
|
+
setErrorMsg('')
|
|
932
|
+
navigateTo('/')
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
570
936
|
// Activa un proyecto levantando opencode serve en su directorio en segundo plano
|
|
571
937
|
const handleActivateProject = async (projectId: string) => {
|
|
572
938
|
const project = projects.find(p => p.id === projectId)
|
|
@@ -923,17 +1289,23 @@ export default function App() {
|
|
|
923
1289
|
|
|
924
1290
|
const handleAbort = async () => {
|
|
925
1291
|
if (!currentSessionId) return
|
|
1292
|
+
isAbortingRef.current = true
|
|
1293
|
+
setIsProcessing(false) // Optimista
|
|
926
1294
|
try {
|
|
927
1295
|
const res = await fetch(`/api/session/${currentSessionId}/abort`, {
|
|
928
1296
|
method: 'POST'
|
|
929
1297
|
})
|
|
930
1298
|
if (res.ok) {
|
|
931
|
-
setIsProcessing(false)
|
|
932
1299
|
showNotification('Opencode: Abortado', 'La sesión en ejecución ha sido cancelada forzadamente.')
|
|
933
1300
|
triggerImmediateUpdate()
|
|
934
1301
|
}
|
|
935
1302
|
} catch (e) {
|
|
936
1303
|
console.error(e)
|
|
1304
|
+
} finally {
|
|
1305
|
+
// Dejar que transcurran 2.5 segundos para que el servidor complete el aborto y se asiente
|
|
1306
|
+
setTimeout(() => {
|
|
1307
|
+
isAbortingRef.current = false
|
|
1308
|
+
}, 2500)
|
|
937
1309
|
}
|
|
938
1310
|
}
|
|
939
1311
|
|
|
@@ -982,7 +1354,7 @@ export default function App() {
|
|
|
982
1354
|
return sessions.filter(s => s.parent_id === parentId || s.parentID === parentId)
|
|
983
1355
|
}
|
|
984
1356
|
|
|
985
|
-
// Filtrar rootSessions por proyecto
|
|
1357
|
+
// Filtrar rootSessions por proyecto, asociando dinámicamente las sesiones sin mapear al proyecto activo actual
|
|
986
1358
|
const getSessionsForProject = (projectId: string) => {
|
|
987
1359
|
return rootSessions.filter(s => {
|
|
988
1360
|
const mappedId = sessionMappings[s.id]
|
|
@@ -991,7 +1363,10 @@ export default function App() {
|
|
|
991
1363
|
return mappedId === projectId
|
|
992
1364
|
}
|
|
993
1365
|
}
|
|
994
|
-
|
|
1366
|
+
|
|
1367
|
+
// Si la sesión no tiene un mapeo de proyecto en localStorage, asumimos
|
|
1368
|
+
// que pertenece al entorno del proyecto que está actualmente activo y conectado
|
|
1369
|
+
return activeProjectId === projectId
|
|
995
1370
|
})
|
|
996
1371
|
}
|
|
997
1372
|
|
|
@@ -1001,6 +1376,85 @@ export default function App() {
|
|
|
1001
1376
|
// Obtener la instancia activa del Daemon
|
|
1002
1377
|
const activeInstance = instances.find(inst => inst.port === currentPort)
|
|
1003
1378
|
|
|
1379
|
+
if (!setupCompleted) {
|
|
1380
|
+
return (
|
|
1381
|
+
<div className="flex h-screen bg-[#09090b] text-[#f4f4f5] items-center justify-center p-4 overflow-hidden select-none">
|
|
1382
|
+
<div className="w-full max-w-2xl bg-[#0c0c0e] border border-[#1f1f23] rounded-2xl shadow-2xl p-6 md:p-8 space-y-6 flex flex-col max-h-[90vh]">
|
|
1383
|
+
{/* Cabecera */}
|
|
1384
|
+
<div className="text-center space-y-2 shrink-0">
|
|
1385
|
+
<div className="inline-flex p-3 bg-white text-[#09090b] rounded-2xl shadow-md mb-2">
|
|
1386
|
+
<Terminal size={28} className="font-bold animate-pulse" />
|
|
1387
|
+
</div>
|
|
1388
|
+
<h1 className="text-2xl font-black tracking-tight text-white flex items-center justify-center gap-2">
|
|
1389
|
+
<span>Bienvenido a ZugzWeb</span>
|
|
1390
|
+
<span className="text-[10px] bg-emerald-950 text-emerald-400 border border-emerald-900/50 px-1.5 py-0.5 rounded font-bold uppercase tracking-wider">Harness v1.2</span>
|
|
1391
|
+
</h1>
|
|
1392
|
+
<p className="text-xs text-[#a1a1aa] max-w-md mx-auto leading-relaxed">
|
|
1393
|
+
Para comenzar a programar de forma autónoma con tu arnés SDD, necesitamos que selecciones la carpeta de tu computadora que servirá como tu espacio de trabajo inicial.
|
|
1394
|
+
</p>
|
|
1395
|
+
</div>
|
|
1396
|
+
|
|
1397
|
+
{/* Cuerpo del Explorador */}
|
|
1398
|
+
<div className="flex-1 overflow-y-auto py-1 scrollbar-none">
|
|
1399
|
+
{renderExplorerContent(async (selectedPath) => {
|
|
1400
|
+
// 1. Guardar el Workspace Raíz inicial
|
|
1401
|
+
const initialProjects = [
|
|
1402
|
+
{
|
|
1403
|
+
id: 'workspace_raiz',
|
|
1404
|
+
name: 'Carpeta Base Zugz',
|
|
1405
|
+
path: selectedPath
|
|
1406
|
+
}
|
|
1407
|
+
]
|
|
1408
|
+
setProjects(initialProjects)
|
|
1409
|
+
localStorage.setItem('zugzweb_projects', JSON.stringify(initialProjects))
|
|
1410
|
+
|
|
1411
|
+
setActiveProjectId('workspace_raiz')
|
|
1412
|
+
localStorage.setItem('zugzweb_active_project_id', 'workspace_raiz')
|
|
1413
|
+
|
|
1414
|
+
// 2. Activar el proyecto llamando al Daemon en segundo plano
|
|
1415
|
+
setIsProcessing(true)
|
|
1416
|
+
setErrorMsg('Activando entorno de desarrollo inicial...')
|
|
1417
|
+
try {
|
|
1418
|
+
// Asegurarse de que exista físicamente llamando a /api-custom/create-folder
|
|
1419
|
+
await fetch('/api-custom/create-folder', {
|
|
1420
|
+
method: 'POST',
|
|
1421
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1422
|
+
body: JSON.stringify({ path: selectedPath })
|
|
1423
|
+
})
|
|
1424
|
+
|
|
1425
|
+
await fetch('/api-custom/activate-project', {
|
|
1426
|
+
method: 'POST',
|
|
1427
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1428
|
+
body: JSON.stringify({ path: selectedPath })
|
|
1429
|
+
})
|
|
1430
|
+
|
|
1431
|
+
// 3. Completar Setup
|
|
1432
|
+
localStorage.setItem('zugzweb_setup_completed', 'true')
|
|
1433
|
+
setSetupCompleted(true)
|
|
1434
|
+
|
|
1435
|
+
// Forzar carga de sesiones de este nuevo directorio
|
|
1436
|
+
setTimeout(() => {
|
|
1437
|
+
fetchSessions()
|
|
1438
|
+
fetchInstances()
|
|
1439
|
+
}, 1000)
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
console.error(err)
|
|
1442
|
+
} finally {
|
|
1443
|
+
setIsProcessing(false)
|
|
1444
|
+
setErrorMsg('')
|
|
1445
|
+
}
|
|
1446
|
+
})}
|
|
1447
|
+
</div>
|
|
1448
|
+
|
|
1449
|
+
{/* Footer Informativo */}
|
|
1450
|
+
<div className="text-center text-[10px] text-[#52525b] border-t border-[#1f1f23]/60 pt-4 shrink-0">
|
|
1451
|
+
Podrás añadir, explorar o alternar más carpetas de proyectos en cualquier momento desde la barra lateral.
|
|
1452
|
+
</div>
|
|
1453
|
+
</div>
|
|
1454
|
+
</div>
|
|
1455
|
+
)
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1004
1458
|
return (
|
|
1005
1459
|
<div className="flex h-screen bg-[#09090b] text-[#f4f4f5] overflow-hidden antialiased font-sans select-none">
|
|
1006
1460
|
|
|
@@ -1059,14 +1513,31 @@ export default function App() {
|
|
|
1059
1513
|
</div>
|
|
1060
1514
|
<div className="space-y-1">
|
|
1061
1515
|
<label className="text-[9px] font-bold text-[#71717a] uppercase">Subcarpeta o Ruta Absoluta</label>
|
|
1062
|
-
<
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1516
|
+
<div className="flex gap-2">
|
|
1517
|
+
<input
|
|
1518
|
+
type="text"
|
|
1519
|
+
required
|
|
1520
|
+
value={newProjectPath}
|
|
1521
|
+
onChange={e => setNewProjectPath(e.target.value)}
|
|
1522
|
+
placeholder="Ej. mi-proyecto o /Users/nombre/mi-proyecto"
|
|
1523
|
+
className="flex-1 bg-[#0c0c0e] border border-[#27272a] rounded-lg px-2.5 py-1.5 text-xs text-white outline-none focus:border-[#3f3f46] font-mono"
|
|
1524
|
+
/>
|
|
1525
|
+
<button
|
|
1526
|
+
type="button"
|
|
1527
|
+
onClick={() => openExplorer('Buscar Carpeta del Proyecto', newProjectPath || systemCwd, (selected) => setNewProjectPath(selected))}
|
|
1528
|
+
className="px-2.5 py-1 bg-[#1c1c1f] hover:bg-[#27272a] border border-[#2e2e33] text-[10px] rounded-lg text-white font-semibold transition cursor-pointer shrink-0"
|
|
1529
|
+
>
|
|
1530
|
+
Buscar...
|
|
1531
|
+
</button>
|
|
1532
|
+
</div>
|
|
1533
|
+
<div className="text-[9px] text-[#71717a] bg-[#09090b]/40 p-2 rounded border border-[#1f1f23] font-mono break-all leading-normal mt-1">
|
|
1534
|
+
<span className="text-[#a1a1aa] font-semibold">📁 Ruta Destino: </span>
|
|
1535
|
+
<span className="text-emerald-400">
|
|
1536
|
+
{newProjectPath.trim().startsWith('/')
|
|
1537
|
+
? newProjectPath.trim()
|
|
1538
|
+
: `${systemCwd}/${newProjectPath.trim() || 'mi-proyecto'}`}
|
|
1539
|
+
</span>
|
|
1540
|
+
</div>
|
|
1070
1541
|
</div>
|
|
1071
1542
|
<div className="flex gap-2 justify-end pt-1">
|
|
1072
1543
|
<button
|
|
@@ -1128,6 +1599,34 @@ export default function App() {
|
|
|
1128
1599
|
>
|
|
1129
1600
|
<Plus size={10} strokeWidth={3} />
|
|
1130
1601
|
</button>
|
|
1602
|
+
<button
|
|
1603
|
+
onClick={() => {
|
|
1604
|
+
openExplorer(`Carpeta para: ${project.name}`, project.path, async (selected) => {
|
|
1605
|
+
setProjects(prev => prev.map(p => p.id === project.id ? { ...p, path: selected } : p))
|
|
1606
|
+
if (project.id === activeProjectId) {
|
|
1607
|
+
setIsProcessing(true)
|
|
1608
|
+
setErrorMsg('Re-activando proyecto en nueva ruta...')
|
|
1609
|
+
try {
|
|
1610
|
+
await fetch('/api-custom/activate-project', {
|
|
1611
|
+
method: 'POST',
|
|
1612
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1613
|
+
body: JSON.stringify({ path: selected })
|
|
1614
|
+
})
|
|
1615
|
+
fetchSessions()
|
|
1616
|
+
} catch (e) {
|
|
1617
|
+
console.error(e)
|
|
1618
|
+
} finally {
|
|
1619
|
+
setIsProcessing(false)
|
|
1620
|
+
setErrorMsg('')
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
})
|
|
1624
|
+
}}
|
|
1625
|
+
className="p-1 hover:bg-[#27272a] text-[#71717a] hover:text-white rounded transition cursor-pointer"
|
|
1626
|
+
title="Explorar/Cambiar Carpeta del Proyecto"
|
|
1627
|
+
>
|
|
1628
|
+
<FolderOpen size={10} />
|
|
1629
|
+
</button>
|
|
1131
1630
|
{!isProjectActive && (
|
|
1132
1631
|
<button
|
|
1133
1632
|
onClick={() => handleActivateProject(project.id)}
|
|
@@ -1172,9 +1671,7 @@ export default function App() {
|
|
|
1172
1671
|
}`}>
|
|
1173
1672
|
<button
|
|
1174
1673
|
onClick={() => {
|
|
1175
|
-
|
|
1176
|
-
setErrorMsg('')
|
|
1177
|
-
navigateTo('/')
|
|
1674
|
+
handleSelectSession(root.id, project.id)
|
|
1178
1675
|
}}
|
|
1179
1676
|
className="flex-1 text-left cursor-pointer outline-none min-w-0"
|
|
1180
1677
|
>
|
|
@@ -1206,9 +1703,7 @@ export default function App() {
|
|
|
1206
1703
|
<button
|
|
1207
1704
|
key={sub.id}
|
|
1208
1705
|
onClick={() => {
|
|
1209
|
-
|
|
1210
|
-
setErrorMsg('')
|
|
1211
|
-
navigateTo('/')
|
|
1706
|
+
handleSelectSession(sub.id, project.id)
|
|
1212
1707
|
}}
|
|
1213
1708
|
className={`w-full text-left p-1.5 rounded-md flex flex-col gap-0.5 transition duration-150 cursor-pointer text-[10px] ${
|
|
1214
1709
|
isSubActive
|
|
@@ -1832,6 +2327,30 @@ export default function App() {
|
|
|
1832
2327
|
)
|
|
1833
2328
|
})
|
|
1834
2329
|
)}
|
|
2330
|
+
|
|
2331
|
+
{/* INDICADOR DE CARGA EN TIEMPO REAL DEL AGENTE */}
|
|
2332
|
+
{isProcessing && messages.length > 0 && messages[messages.length - 1].info.role === 'user' && (
|
|
2333
|
+
<div className="flex gap-4 max-w-4xl mx-auto justify-start">
|
|
2334
|
+
<div className="h-8 w-8 rounded-full border border-[#2e2e33] bg-[#18181b] flex items-center justify-center text-[#fafafa] shrink-0">
|
|
2335
|
+
<Bot size={16} className="animate-spin text-emerald-400" style={{ animationDuration: '4s' }} />
|
|
2336
|
+
</div>
|
|
2337
|
+
<div className="rounded-xl px-4 py-3 bg-[#0c0c0e] text-[#f4f4f5] border border-[#1f1f23] max-w-[85%] shadow-sm space-y-1">
|
|
2338
|
+
<div className="flex justify-between items-start border-b border-[#1f1f23]/40 pb-1 text-[11px] text-[#71717a] font-medium gap-2">
|
|
2339
|
+
<div className="flex items-center gap-2">
|
|
2340
|
+
<span>AGENTE</span>
|
|
2341
|
+
<span>•</span>
|
|
2342
|
+
<span className="text-[10px] text-emerald-500 font-bold animate-pulse">PENSANDO...</span>
|
|
2343
|
+
</div>
|
|
2344
|
+
</div>
|
|
2345
|
+
<div className="flex items-center gap-1.5 text-sm text-[#a1a1aa] py-1.5">
|
|
2346
|
+
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-bounce" style={{ animationDelay: '0ms' }}></span>
|
|
2347
|
+
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-bounce" style={{ animationDelay: '150ms' }}></span>
|
|
2348
|
+
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-bounce" style={{ animationDelay: '300ms' }}></span>
|
|
2349
|
+
<span className="ml-1 text-xs text-[#a1a1aa] font-medium">Conectando con el modelo de IA...</span>
|
|
2350
|
+
</div>
|
|
2351
|
+
</div>
|
|
2352
|
+
</div>
|
|
2353
|
+
)}
|
|
1835
2354
|
</div>
|
|
1836
2355
|
|
|
1837
2356
|
{/* CONSOLA DE ENTRADA, SELECCIÓN DE MODELO Y ENVÍO */}
|
|
@@ -1948,6 +2467,38 @@ export default function App() {
|
|
|
1948
2467
|
)}
|
|
1949
2468
|
</div>
|
|
1950
2469
|
|
|
2470
|
+
{/* MODAL DEL EXPLORADOR DE ARCHIVOS GENERAL */}
|
|
2471
|
+
{showExplorerModal && (
|
|
2472
|
+
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4 z-[9999] animate-fadeIn">
|
|
2473
|
+
<div className="w-full max-w-xl bg-[#0c0c0e] border border-[#1f1f23] rounded-2xl shadow-2xl p-6 space-y-4 flex flex-col max-h-[85vh]">
|
|
2474
|
+
{/* Header del Modal */}
|
|
2475
|
+
<div className="flex items-center justify-between shrink-0 border-b border-[#1f1f23] pb-3">
|
|
2476
|
+
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
|
2477
|
+
<FolderOpen size={16} className="text-[#3b82f6]" />
|
|
2478
|
+
<span>{explorerTitle}</span>
|
|
2479
|
+
</h3>
|
|
2480
|
+
<button
|
|
2481
|
+
type="button"
|
|
2482
|
+
onClick={() => setShowExplorerModal(false)}
|
|
2483
|
+
className="text-xs text-[#71717a] hover:text-white border border-[#1f1f23] hover:border-[#27272a] px-2.5 py-1 rounded-md transition cursor-pointer"
|
|
2484
|
+
>
|
|
2485
|
+
Cerrar
|
|
2486
|
+
</button>
|
|
2487
|
+
</div>
|
|
2488
|
+
|
|
2489
|
+
{/* Contenido del Explorador */}
|
|
2490
|
+
<div className="flex-1 overflow-y-auto scrollbar-none py-1">
|
|
2491
|
+
{renderExplorerContent((selectedPath) => {
|
|
2492
|
+
if (explorerCallback) {
|
|
2493
|
+
explorerCallback(selectedPath)
|
|
2494
|
+
}
|
|
2495
|
+
setShowExplorerModal(false)
|
|
2496
|
+
})}
|
|
2497
|
+
</div>
|
|
2498
|
+
</div>
|
|
2499
|
+
</div>
|
|
2500
|
+
)}
|
|
2501
|
+
|
|
1951
2502
|
</div>
|
|
1952
2503
|
)
|
|
1953
2504
|
}
|