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.
@@ -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
- // 3. Polling Inteligente y Adaptativo
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
- let rapidIntervalId: ReturnType<typeof setInterval> | null = null
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
- // Si está procesando/activo, ejecutamos polling rápido (1.5s) para recibir cambios en tiempo real
291
- if (isProcessing) {
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 5s) para detectar de forma eficiente cambios de estado externos
545
+ // Heartbeat lento constante (cada 6s) como red de seguridad/fallback
300
546
  const heartbeatIntervalId = setInterval(() => {
301
547
  fetchSessionStatus()
302
- fetchMessages(currentSessionId)
548
+ fetchMessagesThrottled(currentSessionId)
303
549
  fetchSessionDetails(currentSessionId)
304
- }, 5000)
550
+ }, 6000)
305
551
 
306
552
  return () => {
307
- if (rapidIntervalId) clearInterval(rapidIntervalId)
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, isProcessing])
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
- if (!currentSessionId) {
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
- const errData = await res.json().catch(() => ({}))
435
- setErrorMsg(errData.message || 'No se pudieron recuperar los detalles de la sesión')
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 raíz
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 root = '/Users/wavesbyte/Documents/Repositorio Zugzbot'
507
- resolvedPath = `${root}/${resolvedPath}`
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
- return projects[0]?.id === projectId
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
- <input
1063
- type="text"
1064
- required
1065
- value={newProjectPath}
1066
- onChange={e => setNewProjectPath(e.target.value)}
1067
- placeholder="Ej. Proyecto-A"
1068
- className="w-full bg-[#0c0c0e] border border-[#27272a] rounded-lg px-2.5 py-1.5 text-xs text-white outline-none focus:border-[#3f3f46] font-mono"
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
- setCurrentSessionId(root.id)
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
- setCurrentSessionId(sub.id)
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
  }