zugzbot 1.0.13 → 1.0.15

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>>(() => {
@@ -188,6 +193,22 @@ export default function App() {
188
193
  localStorage.setItem('zugzweb_session_mappings', JSON.stringify(sessionMappings))
189
194
  }, [sessionMappings])
190
195
 
196
+ const [customSessionTitles, setCustomSessionTitles] = useState<Record<string, string>>(() => {
197
+ const saved = localStorage.getItem('zugzweb_custom_session_titles')
198
+ if (saved) {
199
+ try {
200
+ return JSON.parse(saved)
201
+ } catch {
202
+ // ignore
203
+ }
204
+ }
205
+ return {}
206
+ })
207
+
208
+ useEffect(() => {
209
+ localStorage.setItem('zugzweb_custom_session_titles', JSON.stringify(customSessionTitles))
210
+ }, [customSessionTitles])
211
+
191
212
  useEffect(() => {
192
213
  localStorage.setItem('zugzweb_active_project_id', activeProjectId)
193
214
  }, [activeProjectId])
@@ -224,7 +245,191 @@ export default function App() {
224
245
  const [newSessionFirstPrompt, setNewSessionFirstPrompt] = useState<string>('')
225
246
  const [selectedAgent, setSelectedAgent] = useState<string>('sdd-orchestrator')
226
247
 
248
+ // Estado de Onboarding Setup
249
+ const [setupCompleted, setSetupCompleted] = useState<boolean>(() => {
250
+ return localStorage.getItem('zugzweb_setup_completed') === 'true'
251
+ })
252
+
253
+ // Estados para el explorador de archivos
254
+ const [showExplorerModal, setShowExplorerModal] = useState(false)
255
+ const [explorerCurrentPath, setExplorerCurrentPath] = useState('')
256
+ const [explorerParentPath, setExplorerParentPath] = useState<string | null>(null)
257
+ const [explorerDirectories, setExplorerDirectories] = useState<string[]>([])
258
+ const [explorerHomePath, setExplorerHomePath] = useState('')
259
+ const [explorerCwdPath, setExplorerCwdPath] = useState('')
260
+ const [explorerError, setExplorerError] = useState('')
261
+ const [explorerLoading, setExplorerLoading] = useState(false)
262
+ const [explorerCallback, setExplorerCallback] = useState<((path: string) => void) | null>(null)
263
+ const [explorerTitle, setExplorerTitle] = useState('Seleccionar Carpeta')
264
+
265
+ // Función para cargar un directorio en el explorador de archivos
266
+ const loadDirectory = async (pathStr?: string) => {
267
+ setExplorerLoading(true)
268
+ setExplorerError('')
269
+ try {
270
+ const queryParam = pathStr ? `?path=${encodeURIComponent(pathStr)}` : ''
271
+ const res = await fetch(`/api-custom/list-dir${queryParam}`)
272
+ if (res.ok) {
273
+ const data = await res.json()
274
+ setExplorerCurrentPath(data.currentPath)
275
+ setExplorerParentPath(data.parentPath)
276
+ setExplorerDirectories(data.directories || [])
277
+ setExplorerHomePath(data.home || '')
278
+ setExplorerCwdPath(data.cwd || '')
279
+ setExplorerError('')
280
+ } else {
281
+ const data = await res.json()
282
+ setExplorerError(data.error || 'No se pudo leer el directorio')
283
+ }
284
+ } catch (err: any) {
285
+ setExplorerError('Error de red al conectar con el servidor: ' + err.message)
286
+ } finally {
287
+ setExplorerLoading(false)
288
+ }
289
+ }
290
+
291
+ // Abre el explorador de archivos con una configuración específica
292
+ const openExplorer = (title: string, initialPath: string, callback: (selectedPath: string) => void) => {
293
+ setExplorerTitle(title)
294
+ setExplorerCallback(() => callback)
295
+ setShowExplorerModal(true)
296
+ loadDirectory(initialPath || systemCwd)
297
+ }
298
+
299
+ // Carga inicial del explorador de archivos en base al CWD del sistema al arrancar
300
+ useEffect(() => {
301
+ if (systemCwd && !explorerCurrentPath) {
302
+ setExplorerCurrentPath(systemCwd)
303
+ loadDirectory(systemCwd)
304
+ }
305
+ // eslint-disable-next-line react-hooks/exhaustive-deps
306
+ }, [systemCwd])
307
+
308
+ const renderExplorerContent = (onConfirm: (path: string) => void) => {
309
+ return (
310
+ <div className="space-y-4">
311
+ {/* Caja de Texto de la Ruta y Controles Rápidos */}
312
+ <div className="space-y-2">
313
+ <div className="flex gap-2">
314
+ <input
315
+ type="text"
316
+ value={explorerCurrentPath}
317
+ onChange={(e) => setExplorerCurrentPath(e.target.value)}
318
+ onKeyDown={(e) => {
319
+ if (e.key === 'Enter') {
320
+ loadDirectory(explorerCurrentPath)
321
+ }
322
+ }}
323
+ 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"
324
+ placeholder="Ruta absoluta..."
325
+ />
326
+ <button
327
+ type="button"
328
+ onClick={() => loadDirectory(explorerCurrentPath)}
329
+ className="px-3 py-2 bg-white text-[#09090b] text-xs font-bold rounded-xl hover:bg-[#e4e4e7] transition cursor-pointer"
330
+ >
331
+ Ir
332
+ </button>
333
+ </div>
334
+
335
+ <div className="flex gap-2 items-center flex-wrap">
336
+ <button
337
+ type="button"
338
+ disabled={!explorerParentPath}
339
+ onClick={() => explorerParentPath && loadDirectory(explorerParentPath)}
340
+ 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"
341
+ title="Subir un nivel"
342
+ >
343
+ <ArrowUp size={12} />
344
+ <span>Subir Nivel</span>
345
+ </button>
346
+
347
+ <button
348
+ type="button"
349
+ onClick={() => loadDirectory(explorerHomePath)}
350
+ 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"
351
+ title="Carpeta Home"
352
+ >
353
+ <Home size={12} />
354
+ <span>Home</span>
355
+ </button>
356
+
357
+ <button
358
+ type="button"
359
+ onClick={() => loadDirectory(explorerCwdPath)}
360
+ 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"
361
+ title="Directorio Raíz del Servidor"
362
+ >
363
+ <Compass size={12} />
364
+ <span>CWD Servidor</span>
365
+ </button>
366
+ </div>
367
+ </div>
368
+
369
+ {/* Mensaje de Error */}
370
+ {explorerError && (
371
+ <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">
372
+ <AlertCircle size={14} className="shrink-0" />
373
+ <span>{explorerError}</span>
374
+ </div>
375
+ )}
376
+
377
+ {/* Lista de Carpetas */}
378
+ <div className="border border-[#1f1f23] rounded-xl bg-[#070709] overflow-hidden">
379
+ <div className="p-2 border-b border-[#1f1f23] bg-[#0c0c0e]/60 text-[10px] text-[#71717a] font-bold uppercase tracking-wider select-none">
380
+ Subcarpetas en este directorio:
381
+ </div>
382
+ <div className="max-h-64 overflow-y-auto p-2 space-y-1 scrollbar-thin">
383
+ {explorerLoading ? (
384
+ <div className="py-8 flex flex-col items-center justify-center text-[#71717a] space-y-2">
385
+ <RefreshCw size={18} className="animate-spin text-white/60" />
386
+ <span className="text-xs">Cargando carpetas...</span>
387
+ </div>
388
+ ) : explorerDirectories.length === 0 ? (
389
+ <div className="py-8 text-center text-xs text-[#52525b] italic">
390
+ No se encontraron subcarpetas visibles en este directorio.
391
+ </div>
392
+ ) : (
393
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
394
+ {explorerDirectories.map((dir, dIdx) => (
395
+ <button
396
+ key={dIdx}
397
+ type="button"
398
+ onClick={() => {
399
+ const separator = explorerCurrentPath.includes('\\') ? '\\' : '/'
400
+ const newPath = explorerCurrentPath.endsWith(separator)
401
+ ? explorerCurrentPath + dir
402
+ : explorerCurrentPath + separator + dir
403
+ loadDirectory(newPath)
404
+ }}
405
+ 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"
406
+ >
407
+ <Folder size={14} className="text-amber-500 shrink-0" />
408
+ <span className="truncate font-medium">{dir}</span>
409
+ </button>
410
+ ))}
411
+ </div>
412
+ )}
413
+ </div>
414
+ </div>
415
+
416
+ {/* Botón de Confirmación */}
417
+ <div className="flex justify-end pt-2">
418
+ <button
419
+ type="button"
420
+ onClick={() => onConfirm(explorerCurrentPath)}
421
+ 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"
422
+ >
423
+ <Check size={14} strokeWidth={2.5} />
424
+ <span>Confirmar Selección</span>
425
+ </button>
426
+ </div>
427
+ </div>
428
+ )
429
+ }
430
+
227
431
  const chatContainerRef = useRef<HTMLDivElement>(null)
432
+ const isAbortingRef = useRef<boolean>(false)
228
433
  const [userHasScrolledUp, setUserHasScrolledUp] = useState(false)
229
434
 
230
435
  const navigateTo = (path: string) => {
@@ -242,6 +447,7 @@ export default function App() {
242
447
  // 1. Cargar estado inicial y levantar listeners
243
448
  useEffect(() => {
244
449
  fetchTunnelStatus()
450
+ fetchSystemCwd()
245
451
  fetchInstances()
246
452
  fetchSessions()
247
453
 
@@ -270,7 +476,7 @@ export default function App() {
270
476
  if (currentSessionId) {
271
477
  fetchMessages(currentSessionId)
272
478
  fetchSessionStatus()
273
- fetchSessionDetails(currentSessionId)
479
+ fetchSessionDetails(currentSessionId, true)
274
480
  }
275
481
  }
276
482
 
@@ -281,34 +487,94 @@ export default function App() {
281
487
  // eslint-disable-next-line react-hooks/exhaustive-deps
282
488
  }, [currentSessionId, currentPort])
283
489
 
284
- // 3. Polling Inteligente y Adaptativo
490
+ // Referencias para el throttle del refresco de mensajes en tiempo real
491
+ const lastFetchTimeRef = useRef<number>(0)
492
+ const pendingFetchRef = useRef<ReturnType<typeof setTimeout> | null>(null)
493
+
494
+ const fetchMessagesThrottled = (id: string) => {
495
+ if (!id) return
496
+ const now = Date.now()
497
+ const limit = 200 // refresco a lo sumo cada 200ms para un streaming ultrasuave
498
+
499
+ if (pendingFetchRef.current) {
500
+ return // Ya hay una petición en cola que capturará el último token
501
+ }
502
+
503
+ const remaining = limit - (now - lastFetchTimeRef.current)
504
+ if (remaining <= 0) {
505
+ fetchMessages(id)
506
+ lastFetchTimeRef.current = now
507
+ } else {
508
+ pendingFetchRef.current = setTimeout(() => {
509
+ fetchMessages(id)
510
+ lastFetchTimeRef.current = Date.now()
511
+ pendingFetchRef.current = null
512
+ }, remaining)
513
+ }
514
+ }
515
+
516
+ // 3. Sistema SSE en tiempo real con fallback de Heartbeat lento
285
517
  useEffect(() => {
286
518
  if (!currentSessionId) return
287
519
 
288
- let rapidIntervalId: ReturnType<typeof setInterval> | null = null
520
+ // Configurar EventSource para recibir eventos de Opencode en caliente
521
+ const es = new EventSource('/api/event')
522
+
523
+ const handleEvent = (event: MessageEvent) => {
524
+ try {
525
+ const data = JSON.parse(event.data)
526
+ const eventType = data.type || event.type
527
+ const sessionID = data.properties?.sessionID || data.properties?.sessionId || data.sessionId || data.session_id
528
+
529
+ if (eventType.startsWith('message.') && (!sessionID || sessionID === currentSessionId)) {
530
+ fetchMessagesThrottled(currentSessionId)
531
+ } else if (eventType.startsWith('session.') && (!sessionID || sessionID === currentSessionId)) {
532
+ fetchSessionStatus()
533
+ fetchSessionDetails(currentSessionId)
534
+ fetchMessagesThrottled(currentSessionId)
535
+ }
536
+ } catch (err) {
537
+ // En caso de que no sea JSON, refrescar de todos modos si es relevante
538
+ fetchMessagesThrottled(currentSessionId)
539
+ }
540
+ }
541
+
542
+ es.addEventListener('message', handleEvent)
289
543
 
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)
544
+ const sseEventTypes = [
545
+ 'message.part.updated',
546
+ 'message.part.removed',
547
+ 'message.updated',
548
+ 'session.status',
549
+ 'session.idle',
550
+ 'session.updated',
551
+ 'session.created'
552
+ ]
553
+ sseEventTypes.forEach(type => {
554
+ es.addEventListener(type, handleEvent)
555
+ })
556
+
557
+ es.onerror = () => {
558
+ // El navegador reconecta automáticamente, pero podemos forzar un refresh por si acaso
297
559
  }
298
560
 
299
- // Heartbeat lento constante (cada 5s) para detectar de forma eficiente cambios de estado externos
561
+ // Heartbeat lento constante (cada 6s) como red de seguridad/fallback
300
562
  const heartbeatIntervalId = setInterval(() => {
301
563
  fetchSessionStatus()
302
- fetchMessages(currentSessionId)
564
+ fetchMessagesThrottled(currentSessionId)
303
565
  fetchSessionDetails(currentSessionId)
304
- }, 5000)
566
+ }, 6000)
305
567
 
306
568
  return () => {
307
- if (rapidIntervalId) clearInterval(rapidIntervalId)
569
+ es.close()
570
+ if (pendingFetchRef.current) {
571
+ clearTimeout(pendingFetchRef.current)
572
+ pendingFetchRef.current = null
573
+ }
308
574
  clearInterval(heartbeatIntervalId)
309
575
  }
310
576
  // eslint-disable-next-line react-hooks/exhaustive-deps
311
- }, [currentSessionId, currentPort, isProcessing])
577
+ }, [currentSessionId, currentPort])
312
578
 
313
579
  // 3. Scroll automático inteligente al recibir nuevos mensajes
314
580
  useEffect(() => {
@@ -383,6 +649,52 @@ export default function App() {
383
649
  }
384
650
  }
385
651
 
652
+ async function fetchSystemCwd() {
653
+ try {
654
+ const res = await fetch('/api-custom/cwd')
655
+ if (res.ok) {
656
+ const data = await res.json()
657
+ if (data.cwd) {
658
+ setSystemCwd(data.cwd)
659
+
660
+ setProjects(prev => {
661
+ const hasSaved = localStorage.getItem('zugzweb_projects')
662
+ if (!hasSaved || prev.length === 0) {
663
+ return [
664
+ {
665
+ id: 'workspace_raiz',
666
+ name: 'Carpeta Base Zugz',
667
+ path: data.cwd
668
+ }
669
+ ]
670
+ }
671
+
672
+ // Si tiene proyectos guardados, nos aseguramos de que "workspace_raiz" apunte dinámicamente al cwd de esta ejecución
673
+ const baseProjExists = prev.some(p => p.id === 'workspace_raiz')
674
+ if (!baseProjExists) {
675
+ return [
676
+ {
677
+ id: 'workspace_raiz',
678
+ name: 'Carpeta Base Zugz',
679
+ path: data.cwd
680
+ },
681
+ ...prev
682
+ ]
683
+ }
684
+ return prev.map(p => {
685
+ if (p.id === 'workspace_raiz') {
686
+ return { ...p, name: 'Carpeta Base Zugz', path: data.cwd }
687
+ }
688
+ return p
689
+ })
690
+ })
691
+ }
692
+ }
693
+ } catch (error) {
694
+ console.error('Error al obtener cwd:', error)
695
+ }
696
+ }
697
+
386
698
  async function fetchInstances() {
387
699
  try {
388
700
  const res = await fetch('/api-custom/instances')
@@ -403,14 +715,33 @@ export default function App() {
403
715
  const data = await res.json()
404
716
  const sessionList = Array.isArray(data) ? data : (data.sessions || [])
405
717
  setSessions(sessionList)
718
+
719
+ // Registrar de forma permanente en localStorage el mapeo de las sesiones descubiertas al proyecto activo
720
+ setSessionMappings(prev => {
721
+ let updated = false
722
+ const nextMappings = { ...prev }
723
+ for (const s of sessionList) {
724
+ if (!nextMappings[s.id]) {
725
+ nextMappings[s.id] = activeProjectId
726
+ updated = true
727
+ }
728
+ }
729
+ return updated ? nextMappings : prev
730
+ })
731
+
732
+ // Limpiar errores inmediatamente al lograr conectar exitosamente con la API de Opencode
733
+ setErrorMsg('')
734
+
406
735
  if (sessionList.length > 0) {
407
- if (!currentSessionId) {
736
+ // Validar que la sesión actual exista de verdad en el servidor activo
737
+ const exists = sessionList.some((s: Session) => s.id === currentSessionIdRef.current)
738
+ if (!currentSessionIdRef.current || !exists) {
739
+ // Si no hay sesión seleccionada o es un ID huérfano viejo, auto-seleccionar la primera disponible
408
740
  setCurrentSessionId(sessionList[0].id)
409
741
  }
410
742
  } else {
411
743
  navigateTo('/new')
412
744
  }
413
- setErrorMsg('')
414
745
  } else {
415
746
  const errData = await res.json().catch(() => ({}))
416
747
  setErrorMsg(errData.message || 'No se pudo conectar con Opencode. ¿Has iniciado "opencode serve"?')
@@ -420,19 +751,20 @@ export default function App() {
420
751
  }
421
752
  }
422
753
 
423
- async function fetchSessionDetails(id: string) {
754
+ async function fetchSessionDetails(id: string, isInitialLoad = false) {
424
755
  try {
425
756
  const res = await fetch(`/api/session/${id}`)
426
757
  if (res.ok) {
427
758
  const data = await res.json()
428
759
  setCurrentSession(data)
429
- if (data.agent) {
760
+ if (data.agent && isInitialLoad) {
430
761
  setSelectedAgent(data.agent)
431
762
  }
432
763
  setErrorMsg('')
433
764
  } else {
434
- const errData = await res.json().catch(() => ({}))
435
- setErrorMsg(errData.message || 'No se pudieron recuperar los detalles de la sesión')
765
+ // ID de sesión huérfano. Limpiamos para que fetchSessions auto-asigne uno nuevo válido en el próximo ciclo
766
+ setCurrentSessionId('')
767
+ setCurrentSession(null)
436
768
  }
437
769
  } catch (error) {
438
770
  console.error(error)
@@ -468,12 +800,14 @@ export default function App() {
468
800
 
469
801
  if (isBusy) {
470
802
  setIsProcessing(prev => {
803
+ if (isAbortingRef.current) return false
471
804
  if (!prev) {
472
805
  return true
473
806
  }
474
807
  return prev
475
808
  })
476
809
  } else {
810
+ isAbortingRef.current = false
477
811
  setIsProcessing(prev => {
478
812
  if (prev) {
479
813
  showNotification('Opencode: ¡Ejecución Completada! 🎉', 'El agente ha finalizado el procesamiento de tu solicitud con éxito.')
@@ -500,11 +834,12 @@ export default function App() {
500
834
  e.preventDefault()
501
835
  if (!newProjectName.trim() || !newProjectPath.trim()) return
502
836
 
503
- // Asegurarse de que la ruta sea absoluta. Si es una subcarpeta relativa, concatenar a la raíz
837
+ // Asegurarse de que la ruta sea absoluta. Si es una subcarpeta relativa, concatenar a la Carpeta Base Zugz
504
838
  let resolvedPath = newProjectPath.trim()
505
839
  if (!resolvedPath.startsWith('/')) {
506
- const root = '/Users/wavesbyte/Documents/Repositorio Zugzbot'
507
- resolvedPath = `${root}/${resolvedPath}`
840
+ const baseProject = projects.find(p => p.id === 'workspace_raiz')
841
+ const basePath = baseProject ? baseProject.path : systemCwd
842
+ resolvedPath = `${basePath}/${resolvedPath}`
508
843
  }
509
844
 
510
845
  const newId = `project_${Date.now()}`
@@ -567,6 +902,53 @@ export default function App() {
567
902
  setExpandedProjects(prev => ({ ...prev, [projectId]: !prev[projectId] }))
568
903
  }
569
904
 
905
+ // Cambia de sesión y conmuta de forma automática el proyecto activo si es necesario
906
+ const handleSelectSession = async (sessionId: string, projectId: string) => {
907
+ const project = projects.find(p => p.id === projectId)
908
+ if (!project) return
909
+
910
+ if (activeProjectId !== projectId) {
911
+ setIsProcessing(true)
912
+ setErrorMsg(`Activando entorno de "${project.name}"...`)
913
+ try {
914
+ const res = await fetch('/api-custom/activate-project', {
915
+ method: 'POST',
916
+ headers: { 'Content-Type': 'application/json' },
917
+ body: JSON.stringify({ path: project.path })
918
+ })
919
+ if (res.ok) {
920
+ setActiveProjectId(projectId)
921
+
922
+ // Limpiar de forma temporal para evitar ruidos de la sesión anterior
923
+ setSessions([])
924
+ setCurrentSessionId('')
925
+ setCurrentSession(null)
926
+ setMessages([])
927
+
928
+ // Forzar la recarga de sesiones de este proyecto
929
+ await fetchSessions()
930
+
931
+ // Establecer la sesión seleccionada
932
+ setCurrentSessionId(sessionId)
933
+ setErrorMsg('')
934
+ navigateTo('/')
935
+ } else {
936
+ const errData = await res.json().catch(() => ({}))
937
+ setErrorMsg(errData.error || 'Error al activar el proyecto para esta sesión')
938
+ }
939
+ } catch {
940
+ setErrorMsg('Error de conexión al activar el proyecto de la sesión')
941
+ } finally {
942
+ setIsProcessing(false)
943
+ }
944
+ } else {
945
+ // Si ya está en el proyecto activo, simplemente la seleccionamos
946
+ setCurrentSessionId(sessionId)
947
+ setErrorMsg('')
948
+ navigateTo('/')
949
+ }
950
+ }
951
+
570
952
  // Activa un proyecto levantando opencode serve en su directorio en segundo plano
571
953
  const handleActivateProject = async (projectId: string) => {
572
954
  const project = projects.find(p => p.id === projectId)
@@ -655,6 +1037,12 @@ export default function App() {
655
1037
  setCurrentSessionId(newSess.id)
656
1038
  setCurrentSession(newSess)
657
1039
  setMessages([])
1040
+
1041
+ // Registrar el título personalizado en el diccionario local
1042
+ setCustomSessionTitles(prev => ({
1043
+ ...prev,
1044
+ [newSess.id]: title.trim()
1045
+ }))
658
1046
 
659
1047
  // Guardar mapeo sesión -> proyecto
660
1048
  setSessionMappings(prev => ({
@@ -753,6 +1141,12 @@ export default function App() {
753
1141
  setCurrentSessionId(newSess.id)
754
1142
  setCurrentSession(newSess)
755
1143
  setMessages([])
1144
+
1145
+ // Registrar el título personalizado en el diccionario local
1146
+ setCustomSessionTitles(prev => ({
1147
+ ...prev,
1148
+ [newSess.id]: newSessionTitle.trim()
1149
+ }))
756
1150
 
757
1151
  // Guardar el mapeo de la sesión al proyecto activo
758
1152
  setSessionMappings(prev => ({
@@ -923,17 +1317,23 @@ export default function App() {
923
1317
 
924
1318
  const handleAbort = async () => {
925
1319
  if (!currentSessionId) return
1320
+ isAbortingRef.current = true
1321
+ setIsProcessing(false) // Optimista
926
1322
  try {
927
1323
  const res = await fetch(`/api/session/${currentSessionId}/abort`, {
928
1324
  method: 'POST'
929
1325
  })
930
1326
  if (res.ok) {
931
- setIsProcessing(false)
932
1327
  showNotification('Opencode: Abortado', 'La sesión en ejecución ha sido cancelada forzadamente.')
933
1328
  triggerImmediateUpdate()
934
1329
  }
935
1330
  } catch (e) {
936
1331
  console.error(e)
1332
+ } finally {
1333
+ // Dejar que transcurran 2.5 segundos para que el servidor complete el aborto y se asiente
1334
+ setTimeout(() => {
1335
+ isAbortingRef.current = false
1336
+ }, 2500)
937
1337
  }
938
1338
  }
939
1339
 
@@ -982,7 +1382,7 @@ export default function App() {
982
1382
  return sessions.filter(s => s.parent_id === parentId || s.parentID === parentId)
983
1383
  }
984
1384
 
985
- // Filtrar rootSessions por proyecto
1385
+ // Filtrar rootSessions por proyecto, asociando dinámicamente las sesiones sin mapear al proyecto activo actual
986
1386
  const getSessionsForProject = (projectId: string) => {
987
1387
  return rootSessions.filter(s => {
988
1388
  const mappedId = sessionMappings[s.id]
@@ -991,7 +1391,10 @@ export default function App() {
991
1391
  return mappedId === projectId
992
1392
  }
993
1393
  }
994
- return projects[0]?.id === projectId
1394
+
1395
+ // Si la sesión no tiene un mapeo de proyecto en localStorage, asumimos
1396
+ // que pertenece al entorno del proyecto que está actualmente activo y conectado
1397
+ return activeProjectId === projectId
995
1398
  })
996
1399
  }
997
1400
 
@@ -1001,6 +1404,85 @@ export default function App() {
1001
1404
  // Obtener la instancia activa del Daemon
1002
1405
  const activeInstance = instances.find(inst => inst.port === currentPort)
1003
1406
 
1407
+ if (!setupCompleted) {
1408
+ return (
1409
+ <div className="flex h-screen bg-[#09090b] text-[#f4f4f5] items-center justify-center p-4 overflow-hidden select-none">
1410
+ <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]">
1411
+ {/* Cabecera */}
1412
+ <div className="text-center space-y-2 shrink-0">
1413
+ <div className="inline-flex p-3 bg-white text-[#09090b] rounded-2xl shadow-md mb-2">
1414
+ <Terminal size={28} className="font-bold animate-pulse" />
1415
+ </div>
1416
+ <h1 className="text-2xl font-black tracking-tight text-white flex items-center justify-center gap-2">
1417
+ <span>Bienvenido a ZugzWeb</span>
1418
+ <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>
1419
+ </h1>
1420
+ <p className="text-xs text-[#a1a1aa] max-w-md mx-auto leading-relaxed">
1421
+ 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.
1422
+ </p>
1423
+ </div>
1424
+
1425
+ {/* Cuerpo del Explorador */}
1426
+ <div className="flex-1 overflow-y-auto py-1 scrollbar-none">
1427
+ {renderExplorerContent(async (selectedPath) => {
1428
+ // 1. Guardar el Workspace Raíz inicial
1429
+ const initialProjects = [
1430
+ {
1431
+ id: 'workspace_raiz',
1432
+ name: 'Carpeta Base Zugz',
1433
+ path: selectedPath
1434
+ }
1435
+ ]
1436
+ setProjects(initialProjects)
1437
+ localStorage.setItem('zugzweb_projects', JSON.stringify(initialProjects))
1438
+
1439
+ setActiveProjectId('workspace_raiz')
1440
+ localStorage.setItem('zugzweb_active_project_id', 'workspace_raiz')
1441
+
1442
+ // 2. Activar el proyecto llamando al Daemon en segundo plano
1443
+ setIsProcessing(true)
1444
+ setErrorMsg('Activando entorno de desarrollo inicial...')
1445
+ try {
1446
+ // Asegurarse de que exista físicamente llamando a /api-custom/create-folder
1447
+ await fetch('/api-custom/create-folder', {
1448
+ method: 'POST',
1449
+ headers: { 'Content-Type': 'application/json' },
1450
+ body: JSON.stringify({ path: selectedPath })
1451
+ })
1452
+
1453
+ await fetch('/api-custom/activate-project', {
1454
+ method: 'POST',
1455
+ headers: { 'Content-Type': 'application/json' },
1456
+ body: JSON.stringify({ path: selectedPath })
1457
+ })
1458
+
1459
+ // 3. Completar Setup
1460
+ localStorage.setItem('zugzweb_setup_completed', 'true')
1461
+ setSetupCompleted(true)
1462
+
1463
+ // Forzar carga de sesiones de este nuevo directorio
1464
+ setTimeout(() => {
1465
+ fetchSessions()
1466
+ fetchInstances()
1467
+ }, 1000)
1468
+ } catch (err) {
1469
+ console.error(err)
1470
+ } finally {
1471
+ setIsProcessing(false)
1472
+ setErrorMsg('')
1473
+ }
1474
+ })}
1475
+ </div>
1476
+
1477
+ {/* Footer Informativo */}
1478
+ <div className="text-center text-[10px] text-[#52525b] border-t border-[#1f1f23]/60 pt-4 shrink-0">
1479
+ Podrás añadir, explorar o alternar más carpetas de proyectos en cualquier momento desde la barra lateral.
1480
+ </div>
1481
+ </div>
1482
+ </div>
1483
+ )
1484
+ }
1485
+
1004
1486
  return (
1005
1487
  <div className="flex h-screen bg-[#09090b] text-[#f4f4f5] overflow-hidden antialiased font-sans select-none">
1006
1488
 
@@ -1059,14 +1541,31 @@ export default function App() {
1059
1541
  </div>
1060
1542
  <div className="space-y-1">
1061
1543
  <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
- />
1544
+ <div className="flex gap-2">
1545
+ <input
1546
+ type="text"
1547
+ required
1548
+ value={newProjectPath}
1549
+ onChange={e => setNewProjectPath(e.target.value)}
1550
+ placeholder="Ej. mi-proyecto o /Users/nombre/mi-proyecto"
1551
+ 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"
1552
+ />
1553
+ <button
1554
+ type="button"
1555
+ onClick={() => openExplorer('Buscar Carpeta del Proyecto', newProjectPath || systemCwd, (selected) => setNewProjectPath(selected))}
1556
+ 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"
1557
+ >
1558
+ Buscar...
1559
+ </button>
1560
+ </div>
1561
+ <div className="text-[9px] text-[#71717a] bg-[#09090b]/40 p-2 rounded border border-[#1f1f23] font-mono break-all leading-normal mt-1">
1562
+ <span className="text-[#a1a1aa] font-semibold">📁 Ruta Destino: </span>
1563
+ <span className="text-emerald-400">
1564
+ {newProjectPath.trim().startsWith('/')
1565
+ ? newProjectPath.trim()
1566
+ : `${systemCwd}/${newProjectPath.trim() || 'mi-proyecto'}`}
1567
+ </span>
1568
+ </div>
1070
1569
  </div>
1071
1570
  <div className="flex gap-2 justify-end pt-1">
1072
1571
  <button
@@ -1121,14 +1620,44 @@ export default function App() {
1121
1620
  </button>
1122
1621
 
1123
1622
  <div className="flex items-center gap-1 shrink-0 ml-1">
1623
+ {project.id !== 'workspace_raiz' && (
1624
+ <button
1625
+ onClick={() => handleCreateSessionInProject(project.id)}
1626
+ className="p-1 hover:bg-[#27272a] text-emerald-500 hover:text-emerald-400 rounded transition cursor-pointer"
1627
+ title="Crear Sesión en este proyecto"
1628
+ >
1629
+ <Plus size={10} strokeWidth={3} />
1630
+ </button>
1631
+ )}
1124
1632
  <button
1125
- onClick={() => handleCreateSessionInProject(project.id)}
1126
- className="p-1 hover:bg-[#27272a] text-emerald-500 hover:text-emerald-400 rounded transition cursor-pointer"
1127
- title="Crear Sesión en este proyecto"
1633
+ onClick={() => {
1634
+ openExplorer(`Carpeta para: ${project.name}`, project.path, async (selected) => {
1635
+ setProjects(prev => prev.map(p => p.id === project.id ? { ...p, path: selected } : p))
1636
+ if (project.id === activeProjectId) {
1637
+ setIsProcessing(true)
1638
+ setErrorMsg('Re-activando proyecto en nueva ruta...')
1639
+ try {
1640
+ await fetch('/api-custom/activate-project', {
1641
+ method: 'POST',
1642
+ headers: { 'Content-Type': 'application/json' },
1643
+ body: JSON.stringify({ path: selected })
1644
+ })
1645
+ fetchSessions()
1646
+ } catch (e) {
1647
+ console.error(e)
1648
+ } finally {
1649
+ setIsProcessing(false)
1650
+ setErrorMsg('')
1651
+ }
1652
+ }
1653
+ })
1654
+ }}
1655
+ className="p-1 hover:bg-[#27272a] text-[#71717a] hover:text-white rounded transition cursor-pointer"
1656
+ title="Explorar/Cambiar Carpeta del Proyecto"
1128
1657
  >
1129
- <Plus size={10} strokeWidth={3} />
1658
+ <FolderOpen size={10} />
1130
1659
  </button>
1131
- {!isProjectActive && (
1660
+ {!isProjectActive && project.id !== 'workspace_raiz' && (
1132
1661
  <button
1133
1662
  onClick={() => handleActivateProject(project.id)}
1134
1663
  className="p-1 hover:bg-[#27272a] text-[#71717a] hover:text-white rounded transition cursor-pointer"
@@ -1152,7 +1681,14 @@ export default function App() {
1152
1681
  {/* Lista de sesiones anidadas del proyecto */}
1153
1682
  {isExpanded && (
1154
1683
  <div className="ml-3 pl-2.5 border-l border-[#1f1f23] space-y-1 pt-1.5 pb-0.5">
1155
- {projectSessions.length === 0 ? (
1684
+ {project.id === 'workspace_raiz' ? (
1685
+ <div className="text-[10px] text-[#71717a] py-1 pl-1 space-y-1 pr-2 leading-relaxed">
1686
+ <span className="block font-bold text-amber-500/90">📁 Carpeta Base Central</span>
1687
+ <p className="text-[#8e8e93]">
1688
+ Para iniciar chats de desarrollo, crea primero un subproyecto con el botón <strong className="text-white">"+ Proyecto"</strong> de arriba.
1689
+ </p>
1690
+ </div>
1691
+ ) : projectSessions.length === 0 ? (
1156
1692
  <div className="text-[10px] text-[#52525b] italic py-1 pl-1">
1157
1693
  No hay sesiones en este proyecto. Haz clic en el botón + de arriba para iniciar una.
1158
1694
  </div>
@@ -1172,14 +1708,12 @@ export default function App() {
1172
1708
  }`}>
1173
1709
  <button
1174
1710
  onClick={() => {
1175
- setCurrentSessionId(root.id)
1176
- setErrorMsg('')
1177
- navigateTo('/')
1711
+ handleSelectSession(root.id, project.id)
1178
1712
  }}
1179
1713
  className="flex-1 text-left cursor-pointer outline-none min-w-0"
1180
1714
  >
1181
1715
  <span className="font-semibold truncate text-[11px] block">
1182
- 💬 {root.title || `Sesión: ${root.id.substring(0, 8)}`}
1716
+ 💬 {customSessionTitles[root.id] || root.title || `Sesión: ${root.id.substring(0, 8)}`}
1183
1717
  </span>
1184
1718
  <div className="flex items-center justify-between text-[9px] text-[#52525b] mt-0.5">
1185
1719
  <span>{root.agent || 'sdd-orchestrator'}</span>
@@ -1206,9 +1740,7 @@ export default function App() {
1206
1740
  <button
1207
1741
  key={sub.id}
1208
1742
  onClick={() => {
1209
- setCurrentSessionId(sub.id)
1210
- setErrorMsg('')
1211
- navigateTo('/')
1743
+ handleSelectSession(sub.id, project.id)
1212
1744
  }}
1213
1745
  className={`w-full text-left p-1.5 rounded-md flex flex-col gap-0.5 transition duration-150 cursor-pointer text-[10px] ${
1214
1746
  isSubActive
@@ -1269,7 +1801,7 @@ export default function App() {
1269
1801
  }`}
1270
1802
  >
1271
1803
  <span className="font-semibold truncate text-[11px]">
1272
- 💬 {root.title || `Sesión: ${root.id.substring(0, 8)}`}
1804
+ 💬 {customSessionTitles[root.id] || root.title || `Sesión: ${root.id.substring(0, 8)}`}
1273
1805
  </span>
1274
1806
  <div className="flex items-center justify-between text-[9px] text-[#52525b]">
1275
1807
  <span>{root.agent || 'sdd-orchestrator'}</span>
@@ -1519,7 +2051,7 @@ export default function App() {
1519
2051
  <div className="h-14 border-b border-[#1f1f23] flex items-center justify-between px-6 bg-[#09090b]/80 backdrop-blur-md z-10 shrink-0">
1520
2052
  <div className="flex flex-col">
1521
2053
  <h1 className="text-sm font-bold m-0 p-0 text-[#fafafa] flex items-center gap-2">
1522
- {currentSession?.title || 'Cargando sesión...'}
2054
+ {(currentSession && customSessionTitles[currentSession.id]) || currentSession?.title || 'Cargando sesión...'}
1523
2055
  {isProcessing && (
1524
2056
  <span className="flex h-2 w-2 relative">
1525
2057
  <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#22c55e] opacity-75"></span>
@@ -1832,6 +2364,30 @@ export default function App() {
1832
2364
  )
1833
2365
  })
1834
2366
  )}
2367
+
2368
+ {/* INDICADOR DE CARGA EN TIEMPO REAL DEL AGENTE */}
2369
+ {isProcessing && messages.length > 0 && messages[messages.length - 1].info.role === 'user' && (
2370
+ <div className="flex gap-4 max-w-4xl mx-auto justify-start">
2371
+ <div className="h-8 w-8 rounded-full border border-[#2e2e33] bg-[#18181b] flex items-center justify-center text-[#fafafa] shrink-0">
2372
+ <Bot size={16} className="animate-spin text-emerald-400" style={{ animationDuration: '4s' }} />
2373
+ </div>
2374
+ <div className="rounded-xl px-4 py-3 bg-[#0c0c0e] text-[#f4f4f5] border border-[#1f1f23] max-w-[85%] shadow-sm space-y-1">
2375
+ <div className="flex justify-between items-start border-b border-[#1f1f23]/40 pb-1 text-[11px] text-[#71717a] font-medium gap-2">
2376
+ <div className="flex items-center gap-2">
2377
+ <span>AGENTE</span>
2378
+ <span>•</span>
2379
+ <span className="text-[10px] text-emerald-500 font-bold animate-pulse">PENSANDO...</span>
2380
+ </div>
2381
+ </div>
2382
+ <div className="flex items-center gap-1.5 text-sm text-[#a1a1aa] py-1.5">
2383
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-bounce" style={{ animationDelay: '0ms' }}></span>
2384
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-bounce" style={{ animationDelay: '150ms' }}></span>
2385
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-bounce" style={{ animationDelay: '300ms' }}></span>
2386
+ <span className="ml-1 text-xs text-[#a1a1aa] font-medium">Conectando con el modelo de IA...</span>
2387
+ </div>
2388
+ </div>
2389
+ </div>
2390
+ )}
1835
2391
  </div>
1836
2392
 
1837
2393
  {/* CONSOLA DE ENTRADA, SELECCIÓN DE MODELO Y ENVÍO */}
@@ -1948,6 +2504,38 @@ export default function App() {
1948
2504
  )}
1949
2505
  </div>
1950
2506
 
2507
+ {/* MODAL DEL EXPLORADOR DE ARCHIVOS GENERAL */}
2508
+ {showExplorerModal && (
2509
+ <div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4 z-[9999] animate-fadeIn">
2510
+ <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]">
2511
+ {/* Header del Modal */}
2512
+ <div className="flex items-center justify-between shrink-0 border-b border-[#1f1f23] pb-3">
2513
+ <h3 className="text-sm font-bold text-white flex items-center gap-2">
2514
+ <FolderOpen size={16} className="text-[#3b82f6]" />
2515
+ <span>{explorerTitle}</span>
2516
+ </h3>
2517
+ <button
2518
+ type="button"
2519
+ onClick={() => setShowExplorerModal(false)}
2520
+ className="text-xs text-[#71717a] hover:text-white border border-[#1f1f23] hover:border-[#27272a] px-2.5 py-1 rounded-md transition cursor-pointer"
2521
+ >
2522
+ Cerrar
2523
+ </button>
2524
+ </div>
2525
+
2526
+ {/* Contenido del Explorador */}
2527
+ <div className="flex-1 overflow-y-auto scrollbar-none py-1">
2528
+ {renderExplorerContent((selectedPath) => {
2529
+ if (explorerCallback) {
2530
+ explorerCallback(selectedPath)
2531
+ }
2532
+ setShowExplorerModal(false)
2533
+ })}
2534
+ </div>
2535
+ </div>
2536
+ </div>
2537
+ )}
2538
+
1951
2539
  </div>
1952
2540
  )
1953
2541
  }