zugzbot 1.0.8 → 1.0.10

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.
@@ -33,7 +33,7 @@ interface Session {
33
33
  parent_id?: string | null
34
34
  parentID?: string | null // Algunos endpoints de Opencode devuelven camelCase
35
35
  agent?: string | null
36
- model?: any | null
36
+ model?: unknown | null
37
37
  cost?: number | null
38
38
  tokens_input?: number | null
39
39
  tokens_output?: number | null
@@ -46,8 +46,8 @@ interface MessagePart {
46
46
  tool?: string
47
47
  state?: {
48
48
  status: string
49
- input?: any
50
- output?: any
49
+ input?: unknown
50
+ output?: unknown
51
51
  }
52
52
  }
53
53
 
@@ -71,6 +71,12 @@ interface OpencodeInstance {
71
71
  isCurrentProject: boolean
72
72
  }
73
73
 
74
+ interface Project {
75
+ id: string
76
+ name: string
77
+ path: string
78
+ }
79
+
74
80
  // Lista estática curada de los mejores modelos de Opencode
75
81
  const POPULAR_MODELS = [
76
82
  { id: 'default', name: '🤖 Modelo por defecto' },
@@ -110,24 +116,109 @@ export default function App() {
110
116
  const [messages, setMessages] = useState<Message[]>([])
111
117
  const [inputValue, setInputValue] = useState('')
112
118
  const [isProcessing, setIsProcessing] = useState(false)
113
- const [notificationsEnabled, setNotificationsEnabled] = useState(false)
119
+ const [notificationsEnabled, setNotificationsEnabled] = useState<boolean>(
120
+ () => typeof Notification !== 'undefined' && Notification.permission === 'granted'
121
+ )
114
122
  const [tunnelUrl, setTunnelUrl] = useState<string>('')
115
123
  const [expandedParts, setExpandedParts] = useState<Record<string, boolean>>({})
116
- const [refreshInterval, setRefreshInterval] = useState<number>(3000)
117
124
  const [errorMsg, setErrorMsg] = useState<string>('')
118
125
 
119
126
  // Soporte para multi-instancia / selección de proyectos
120
127
  const [instances, setInstances] = useState<OpencodeInstance[]>([])
121
128
  const [currentPort, setCurrentPort] = useState<number>(4096)
122
- const [showInstanceDropdown, setShowInstanceDropdown] = useState(false)
129
+
130
+ // Gestión de Proyectos y Mapeos
131
+ const [projects, setProjects] = useState<Project[]>(() => {
132
+ const saved = localStorage.getItem('zugzweb_projects')
133
+ if (saved) {
134
+ try {
135
+ return JSON.parse(saved)
136
+ } catch {
137
+ // ignore
138
+ }
139
+ }
140
+ return [
141
+ {
142
+ id: 'workspace_raiz',
143
+ name: 'Workspace Raíz',
144
+ path: '/Users/wavesbyte/Documents/Repositorio Zugzbot'
145
+ }
146
+ ]
147
+ })
148
+
149
+ const [sessionMappings, setSessionMappings] = useState<Record<string, string>>(() => {
150
+ const saved = localStorage.getItem('zugzweb_session_mappings')
151
+ if (saved) {
152
+ try {
153
+ return JSON.parse(saved)
154
+ } catch {
155
+ // ignore
156
+ }
157
+ }
158
+ return {}
159
+ })
160
+
161
+ const [activeProjectId, setActiveProjectId] = useState<string>(() => {
162
+ const saved = localStorage.getItem('zugzweb_active_project_id')
163
+ return saved || 'workspace_raiz'
164
+ })
165
+
166
+ const [expandedProjects, setExpandedProjects] = useState<Record<string, boolean>>(() => {
167
+ const saved = localStorage.getItem('zugzweb_expanded_projects')
168
+ if (saved) {
169
+ try {
170
+ return JSON.parse(saved)
171
+ } catch {
172
+ // ignore
173
+ }
174
+ }
175
+ return { workspace_raiz: true }
176
+ })
177
+
178
+ // Formularios de Creación de Proyecto inline
179
+ const [showAddProjectForm, setShowAddProjectForm] = useState(false)
180
+ const [newProjectName, setNewProjectName] = useState('')
181
+ const [newProjectPath, setNewProjectPath] = useState('')
182
+
183
+ useEffect(() => {
184
+ localStorage.setItem('zugzweb_projects', JSON.stringify(projects))
185
+ }, [projects])
186
+
187
+ useEffect(() => {
188
+ localStorage.setItem('zugzweb_session_mappings', JSON.stringify(sessionMappings))
189
+ }, [sessionMappings])
190
+
191
+ useEffect(() => {
192
+ localStorage.setItem('zugzweb_active_project_id', activeProjectId)
193
+ }, [activeProjectId])
194
+
195
+ useEffect(() => {
196
+ localStorage.setItem('zugzweb_expanded_projects', JSON.stringify(expandedProjects))
197
+ }, [expandedProjects])
198
+
199
+ // Ancho de la barra lateral (resizable)
200
+ const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
201
+ const saved = localStorage.getItem('zugzweb_sidebar_width')
202
+ return saved ? parseInt(saved, 10) : 320
203
+ })
204
+
205
+ useEffect(() => {
206
+ localStorage.setItem('zugzweb_sidebar_width', String(sidebarWidth))
207
+ }, [sidebarWidth])
123
208
 
124
209
  // Opciones de prompt
125
210
  const [selectedModel, setSelectedModel] = useState<string>('default')
126
211
  const [copiedMessageId, setCopiedMessageId] = useState<string>('')
127
212
 
128
213
  // Estados para la nueva vista /new y agente activo
129
- const [isNewSessionView, setIsNewSessionView] = useState<boolean>(false)
130
- const [newSessionTitle, setNewSessionTitle] = useState<string>('')
214
+ const [isNewSessionView, setIsNewSessionView] = useState<boolean>(() => window.location.pathname === '/new')
215
+ const [newSessionTitle, setNewSessionTitle] = useState<string>(() => {
216
+ if (window.location.pathname === '/new') {
217
+ const formattedDate = new Date().toLocaleString([], { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
218
+ return `Sesión Web ${formattedDate}`
219
+ }
220
+ return ''
221
+ })
131
222
  const [newSessionAgent, setNewSessionAgent] = useState<string>('sdd-orchestrator')
132
223
  const [newSessionModel, setNewSessionModel] = useState<string>('default')
133
224
  const [newSessionFirstPrompt, setNewSessionFirstPrompt] = useState<string>('')
@@ -146,12 +237,7 @@ export default function App() {
146
237
  }
147
238
  }
148
239
 
149
- // Sincronizar agente de sesión activa al cambiar de sesión
150
- useEffect(() => {
151
- if (currentSession) {
152
- setSelectedAgent(currentSession.agent || 'sdd-orchestrator')
153
- }
154
- }, [currentSession?.id])
240
+
155
241
 
156
242
  // 1. Cargar estado inicial y levantar listeners
157
243
  useEffect(() => {
@@ -159,55 +245,77 @@ export default function App() {
159
245
  fetchInstances()
160
246
  fetchSessions()
161
247
 
162
- // Detectar ruta inicial /new
163
- if (window.location.pathname === '/new') {
164
- setIsNewSessionView(true)
165
- const formattedDate = new Date().toLocaleString([], { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
166
- setNewSessionTitle(`Sesión Web ${formattedDate}`)
167
- }
248
+
168
249
 
169
250
  const handlePopState = () => {
170
251
  setIsNewSessionView(window.location.pathname === '/new')
171
252
  }
172
253
  window.addEventListener('popstate', handlePopState)
173
254
 
174
- // Polling periódico para descubrir nuevas instancias
175
- const instanceInterval = setInterval(fetchInstances, 5000)
176
-
177
- // Solicitar permiso de notificaciones del navegador si ya estaba activo
178
- if (Notification.permission === 'granted') {
179
- setNotificationsEnabled(true)
180
- }
255
+ // Polling periódico para descubrir nuevas instancias y actualizar la lista de sesiones
256
+ const syncInterval = setInterval(() => {
257
+ fetchInstances()
258
+ fetchSessions()
259
+ }, 5000)
181
260
 
182
261
  return () => {
183
262
  window.removeEventListener('popstate', handlePopState)
184
- clearInterval(instanceInterval)
263
+ clearInterval(syncInterval)
185
264
  }
265
+ // eslint-disable-next-line react-hooks/exhaustive-deps
186
266
  }, [])
187
267
 
188
- // 2. Escuchar cambios de sesión activa o puerto del proxy y polling de mensajes
268
+ // Función para forzar una actualización instantánea de la interfaz
269
+ const triggerImmediateUpdate = () => {
270
+ if (currentSessionId) {
271
+ fetchMessages(currentSessionId)
272
+ fetchSessionStatus()
273
+ fetchSessionDetails(currentSessionId)
274
+ }
275
+ }
276
+
277
+ // 2. Escuchar cambios de sesión activa o puerto del proxy para realizar carga inicial
189
278
  useEffect(() => {
190
279
  if (!currentSessionId) return
191
-
192
- fetchSessionDetails(currentSessionId)
193
- fetchMessages(currentSessionId)
194
- fetchSessionStatus()
280
+ triggerImmediateUpdate()
281
+ // eslint-disable-next-line react-hooks/exhaustive-deps
282
+ }, [currentSessionId, currentPort])
195
283
 
196
- // Establecer polling para actualizar mensajes y estado del agente en tiempo real
197
- const interval = setInterval(() => {
198
- fetchMessages(currentSessionId)
284
+ // 3. Polling Inteligente y Adaptativo
285
+ useEffect(() => {
286
+ if (!currentSessionId) return
287
+
288
+ let rapidIntervalId: ReturnType<typeof setInterval> | null = null
289
+
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)
297
+ }
298
+
299
+ // Heartbeat lento constante (cada 5s) para detectar de forma eficiente cambios de estado externos
300
+ const heartbeatIntervalId = setInterval(() => {
199
301
  fetchSessionStatus()
302
+ fetchMessages(currentSessionId)
200
303
  fetchSessionDetails(currentSessionId)
201
- }, refreshInterval)
304
+ }, 5000)
202
305
 
203
- return () => clearInterval(interval)
204
- }, [currentSessionId, currentPort, refreshInterval])
306
+ return () => {
307
+ if (rapidIntervalId) clearInterval(rapidIntervalId)
308
+ clearInterval(heartbeatIntervalId)
309
+ }
310
+ // eslint-disable-next-line react-hooks/exhaustive-deps
311
+ }, [currentSessionId, currentPort, isProcessing])
205
312
 
206
313
  // 3. Scroll automático inteligente al recibir nuevos mensajes
207
314
  useEffect(() => {
208
315
  if (!userHasScrolledUp && chatContainerRef.current) {
209
316
  chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
210
317
  }
318
+ // eslint-disable-next-line react-hooks/exhaustive-deps
211
319
  }, [messages])
212
320
 
213
321
  // Manejar el desplazamiento del scroll para desactivar/activar el Smart Scroll Anchoring
@@ -224,7 +332,7 @@ export default function App() {
224
332
  const renderMarkdown = (text: string) => {
225
333
  try {
226
334
  return { __html: marked.parse(text, { gfm: true, breaks: true }) }
227
- } catch (e) {
335
+ } catch {
228
336
  return { __html: text }
229
337
  }
230
338
  }
@@ -263,19 +371,19 @@ export default function App() {
263
371
  }
264
372
 
265
373
  // APIs del Servidor / Daemon Custom
266
- const fetchTunnelStatus = async () => {
374
+ async function fetchTunnelStatus() {
267
375
  try {
268
376
  const res = await fetch('/api-custom/tunnel-status')
269
377
  if (res.ok) {
270
378
  const data = await res.json()
271
379
  setTunnelUrl(data.url || '')
272
380
  }
273
- } catch (e) {
274
- console.error('Error obteniendo estado del túnel:', e)
381
+ } catch (error) {
382
+ console.error('Error obteniendo estado del túnel:', error)
275
383
  }
276
384
  }
277
385
 
278
- const fetchInstances = async () => {
386
+ async function fetchInstances() {
279
387
  try {
280
388
  const res = await fetch('/api-custom/instances')
281
389
  if (res.ok) {
@@ -283,40 +391,12 @@ export default function App() {
283
391
  setInstances(data.instances || [])
284
392
  setCurrentPort(data.currentPort || 4096)
285
393
  }
286
- } catch (e) {
287
- console.error('Error obteniendo instancias de Opencode:', e)
394
+ } catch (error) {
395
+ console.error('Error obteniendo instancias de Opencode:', error)
288
396
  }
289
397
  }
290
398
 
291
- const handleSelectInstance = async (port: number) => {
292
- try {
293
- const res = await fetch('/api-custom/select-instance', {
294
- method: 'POST',
295
- headers: { 'Content-Type': 'application/json' },
296
- body: JSON.stringify({ port })
297
- })
298
-
299
- if (res.ok) {
300
- const data = await res.json()
301
- setCurrentPort(data.currentPort)
302
- setShowInstanceDropdown(false)
303
- setErrorMsg('')
304
-
305
- // Limpiar el estado de la sesión actual antes de recargar
306
- setSessions([])
307
- setCurrentSessionId('')
308
- setCurrentSession(null)
309
- setMessages([])
310
-
311
- // Cargar las sesiones de la nueva instancia seleccionada
312
- fetchSessionsAfterChange()
313
- }
314
- } catch (e) {
315
- alert('Error al conmutar instancia')
316
- }
317
- }
318
-
319
- const fetchSessionsAfterChange = async () => {
399
+ async function fetchSessions() {
320
400
  try {
321
401
  const res = await fetch('/api/session')
322
402
  if (res.ok) {
@@ -324,70 +404,60 @@ export default function App() {
324
404
  const sessionList = Array.isArray(data) ? data : (data.sessions || [])
325
405
  setSessions(sessionList)
326
406
  if (sessionList.length > 0) {
327
- setCurrentSessionId(sessionList[0].id)
407
+ if (!currentSessionId) {
408
+ setCurrentSessionId(sessionList[0].id)
409
+ }
328
410
  } else {
329
- setCurrentSessionId('')
330
- setCurrentSession(null)
331
- setMessages([])
332
- }
333
- }
334
- } catch (e) {
335
- setErrorMsg('Error al conectar a la nueva instancia')
336
- }
337
- }
338
-
339
- const fetchSessions = async () => {
340
- try {
341
- const res = await fetch('/api/session')
342
- if (res.ok) {
343
- const data = await res.json()
344
- const sessionList = Array.isArray(data) ? data : (data.sessions || [])
345
- setSessions(sessionList)
346
- if (sessionList.length > 0 && !currentSessionId) {
347
- setCurrentSessionId(sessionList[0].id)
411
+ navigateTo('/new')
348
412
  }
413
+ setErrorMsg('')
414
+ } else {
415
+ const errData = await res.json().catch(() => ({}))
416
+ setErrorMsg(errData.message || 'No se pudo conectar con Opencode. ¿Has iniciado "opencode serve"?')
349
417
  }
350
- } catch (e) {
418
+ } catch {
351
419
  setErrorMsg('No se pudo conectar al Daemon local de Opencode')
352
420
  }
353
421
  }
354
422
 
355
- const fetchSessionDetails = async (id: string) => {
423
+ async function fetchSessionDetails(id: string) {
356
424
  try {
357
425
  const res = await fetch(`/api/session/${id}`)
358
426
  if (res.ok) {
359
427
  const data = await res.json()
360
428
  setCurrentSession(data)
429
+ if (data.agent) {
430
+ setSelectedAgent(data.agent)
431
+ }
432
+ setErrorMsg('')
433
+ } else {
434
+ const errData = await res.json().catch(() => ({}))
435
+ setErrorMsg(errData.message || 'No se pudieron recuperar los detalles de la sesión')
361
436
  }
362
- } catch (e) {
363
- console.error(e)
437
+ } catch (error) {
438
+ console.error(error)
364
439
  }
365
440
  }
366
441
 
367
- const fetchMessages = async (id: string) => {
442
+ async function fetchMessages(id: string) {
368
443
  try {
369
444
  const res = await fetch(`/api/session/${id}/message`)
370
445
  if (res.ok) {
371
446
  const data = await res.json()
372
447
  const msgList = Array.isArray(data) ? data : (data.messages || [])
373
448
 
374
- // Alerta al completarse
375
- if (isProcessing && msgList.length > messages.length) {
376
- const lastMsg = msgList[msgList.length - 1]
377
- if (lastMsg.info.role === 'assistant') {
378
- setIsProcessing(false)
379
- showNotification('Opencode: ¡Respuesta Recibida!', 'El agente ha completado la ejecución de tu prompt.')
380
- }
381
- }
382
-
383
449
  setMessages(msgList)
450
+ setErrorMsg('')
451
+ } else {
452
+ const errData = await res.json().catch(() => ({}))
453
+ setErrorMsg(errData.message || 'No se pudieron recuperar los mensajes')
384
454
  }
385
- } catch (e) {
386
- console.error(e)
455
+ } catch (error) {
456
+ console.error(error)
387
457
  }
388
458
  }
389
459
 
390
- const fetchSessionStatus = async () => {
460
+ async function fetchSessionStatus() {
391
461
  try {
392
462
  const res = await fetch('/api/session/status')
393
463
  if (res.ok) {
@@ -395,19 +465,129 @@ export default function App() {
395
465
  if (currentSessionId && data[currentSessionId]) {
396
466
  const status = data[currentSessionId]
397
467
  const isBusy = status === 'thinking' || status === 'running_tools' || status === 'busy'
398
- if (isProcessing && !isBusy) {
399
- setIsProcessing(false)
400
- showNotification('Opencode: Ejecución Finalizada 🎉', 'El proceso en loop ha concluido de manera exitosa.')
401
- } else if (!isProcessing && isBusy) {
402
- setIsProcessing(true)
468
+
469
+ if (isBusy) {
470
+ setIsProcessing(prev => {
471
+ if (!prev) {
472
+ return true
473
+ }
474
+ return prev
475
+ })
476
+ } else {
477
+ setIsProcessing(prev => {
478
+ if (prev) {
479
+ showNotification('Opencode: ¡Ejecución Completada! 🎉', 'El agente ha finalizado el procesamiento de tu solicitud con éxito.')
480
+ // Actualizar datos de forma inmediata una última vez al terminar la ejecución
481
+ fetchMessages(currentSessionId)
482
+ fetchSessionDetails(currentSessionId)
483
+ return false
484
+ }
485
+ return prev
486
+ })
403
487
  }
404
488
  }
489
+ setErrorMsg('')
490
+ } else {
491
+ const errData = await res.json().catch(() => ({}))
492
+ setErrorMsg(errData.message || 'No se pudo comprobar el estado de Opencode')
405
493
  }
406
- } catch (e) {
407
- console.error(e)
494
+ } catch (error) {
495
+ console.error(error)
408
496
  }
409
497
  }
410
498
 
499
+ const handleAddProject = async (e: React.FormEvent) => {
500
+ e.preventDefault()
501
+ if (!newProjectName.trim() || !newProjectPath.trim()) return
502
+
503
+ // Asegurarse de que la ruta sea absoluta. Si es una subcarpeta relativa, concatenar a la raíz
504
+ let resolvedPath = newProjectPath.trim()
505
+ if (!resolvedPath.startsWith('/')) {
506
+ const root = '/Users/wavesbyte/Documents/Repositorio Zugzbot'
507
+ resolvedPath = `${root}/${resolvedPath}`
508
+ }
509
+
510
+ const newId = `project_${Date.now()}`
511
+ const newProj: Project = {
512
+ id: newId,
513
+ name: newProjectName.trim(),
514
+ path: resolvedPath
515
+ }
516
+
517
+ // 1. Intentar crear la carpeta física llamando al Daemon
518
+ try {
519
+ await fetch('/api-custom/create-folder', {
520
+ method: 'POST',
521
+ headers: { 'Content-Type': 'application/json' },
522
+ body: JSON.stringify({ path: resolvedPath })
523
+ })
524
+ } catch (err) {
525
+ console.error('Error al solicitar creación de carpeta física:', err)
526
+ }
527
+
528
+ // 2. Guardar el proyecto en el estado local
529
+ setProjects(prev => [...prev, newProj])
530
+ setExpandedProjects(prev => ({ ...prev, [newId]: true }))
531
+ setActiveProjectId(newId)
532
+
533
+ // 3. Resetear formulario
534
+ setNewProjectName('')
535
+ setNewProjectPath('')
536
+ setShowAddProjectForm(false)
537
+ }
538
+
539
+ const handleDeleteProject = (projectId: string) => {
540
+ if (projectId === 'workspace_raiz') {
541
+ alert('No puedes eliminar el proyecto raíz principal.')
542
+ return
543
+ }
544
+ if (!confirm('¿Estás seguro de que quieres eliminar este proyecto? Las sesiones asociadas se moverán al Inbox / Sin Clasificar.')) {
545
+ return
546
+ }
547
+
548
+ setProjects(prev => prev.filter(p => p.id !== projectId))
549
+
550
+ // Desvincular mapeos
551
+ setSessionMappings(prev => {
552
+ const copy = { ...prev }
553
+ for (const sId in copy) {
554
+ if (copy[sId] === projectId) {
555
+ delete copy[sId]
556
+ }
557
+ }
558
+ return copy
559
+ })
560
+
561
+ if (activeProjectId === projectId) {
562
+ setActiveProjectId('workspace_raiz')
563
+ }
564
+ }
565
+
566
+ const toggleProjectExpansion = (projectId: string) => {
567
+ setExpandedProjects(prev => ({ ...prev, [projectId]: !prev[projectId] }))
568
+ }
569
+
570
+ // Handler para el resize de la barra lateral
571
+ const handleMouseDown = (e: React.MouseEvent) => {
572
+ e.preventDefault()
573
+ const startX = e.clientX
574
+ const startWidth = sidebarWidth
575
+
576
+ const handleMouseMove = (moveEvent: MouseEvent) => {
577
+ const deltaX = moveEvent.clientX - startX
578
+ const newWidth = Math.max(220, Math.min(500, startWidth + deltaX))
579
+ setSidebarWidth(newWidth)
580
+ }
581
+
582
+ const handleMouseUp = () => {
583
+ document.removeEventListener('mousemove', handleMouseMove)
584
+ document.removeEventListener('mouseup', handleMouseUp)
585
+ }
586
+
587
+ document.addEventListener('mousemove', handleMouseMove)
588
+ document.addEventListener('mouseup', handleMouseUp)
589
+ }
590
+
411
591
  const handleStartNewSession = async (e?: React.FormEvent) => {
412
592
  if (e) e.preventDefault()
413
593
  if (!newSessionTitle.trim()) {
@@ -433,14 +613,32 @@ export default function App() {
433
613
  setCurrentSession(newSess)
434
614
  setMessages([])
435
615
 
616
+ // Guardar el mapeo de la sesión al proyecto activo
617
+ setSessionMappings(prev => ({
618
+ ...prev,
619
+ [newSess.id]: activeProjectId
620
+ }))
621
+
436
622
  // Establecer el agente y modelo seleccionados para esta sesión
437
623
  setSelectedAgent(newSessionAgent)
438
624
  setSelectedModel(newSessionModel)
439
625
 
440
- // 3. Si hay un primer prompt (instrucción inicial), enviarlo de inmediato
441
- if (newSessionFirstPrompt.trim()) {
442
- const bodyPayload: any = {
443
- parts: [{ type: 'text', text: newSessionFirstPrompt.trim() }],
626
+ // 3. Si hay una instrucción inicial o se requiere anclaje de directorio, enviarlo de inmediato
627
+ const activeProj = projects.find(p => p.id === activeProjectId)
628
+ let anchorPrefix = ''
629
+ if (activeProj && activeProj.id !== 'workspace_raiz') {
630
+ anchorPrefix = `[SISTEMA: Directorio de trabajo del proyecto: ${activeProj.path}. Por favor, realiza todas tus operaciones de archivos, edits, y ejecuciones de comandos de bash estrictamente dentro de esta subcarpeta usando el parámetro "workdir" o comandos relativos a la misma. No escribas archivos en la raíz del repositorio central de Zugzbot a menos que sea explícitamente necesario.]\n\n`
631
+ }
632
+
633
+ const promptText = (anchorPrefix + newSessionFirstPrompt.trim()).trim()
634
+
635
+ if (promptText) {
636
+ const bodyPayload: {
637
+ parts: { type: string; text: string }[];
638
+ agent: string;
639
+ model?: { providerID: string; modelID: string };
640
+ } = {
641
+ parts: [{ type: 'text', text: promptText }],
444
642
  agent: newSessionAgent
445
643
  }
446
644
 
@@ -462,8 +660,8 @@ export default function App() {
462
660
 
463
661
  // Volver a la vista principal / chat
464
662
  navigateTo('/')
465
- setRefreshInterval(1500)
466
- } catch (e) {
663
+ triggerImmediateUpdate()
664
+ } catch {
467
665
  alert('Error al iniciar la nueva sesión')
468
666
  } finally {
469
667
  setIsProcessing(false)
@@ -512,7 +710,11 @@ export default function App() {
512
710
  if (!res.ok) throw new Error('Error al ejecutar comando')
513
711
  } else {
514
712
  // Envío de prompt normal asíncrono
515
- const bodyPayload: any = {
713
+ const bodyPayload: {
714
+ parts: { type: string; text: string }[];
715
+ agent: string;
716
+ model?: { providerID: string; modelID: string };
717
+ } = {
516
718
  parts: [{ type: 'text', text: input }],
517
719
  agent: selectedAgent
518
720
  }
@@ -532,8 +734,8 @@ export default function App() {
532
734
  if (!res.ok) throw new Error('Error al enviar prompt')
533
735
  }
534
736
 
535
- setRefreshInterval(1500)
536
- } catch (e) {
737
+ triggerImmediateUpdate()
738
+ } catch {
537
739
  setErrorMsg('Error al conectar. Comprueba el estado de Opencode.')
538
740
  setIsProcessing(false)
539
741
  }
@@ -571,8 +773,8 @@ export default function App() {
571
773
  })
572
774
 
573
775
  if (!res.ok) throw new Error('Error al ejecutar comando rápido')
574
- setRefreshInterval(1500)
575
- } catch (e) {
776
+ triggerImmediateUpdate()
777
+ } catch {
576
778
  setErrorMsg('No se pudo ejecutar el comando rápido.')
577
779
  setIsProcessing(false)
578
780
  }
@@ -586,8 +788,8 @@ export default function App() {
586
788
  })
587
789
  if (res.ok) {
588
790
  setIsProcessing(false)
589
- setRefreshInterval(3000)
590
791
  showNotification('Opencode: Abortado', 'La sesión en ejecución ha sido cancelada forzadamente.')
792
+ triggerImmediateUpdate()
591
793
  }
592
794
  } catch (e) {
593
795
  console.error(e)
@@ -599,21 +801,25 @@ export default function App() {
599
801
  }
600
802
 
601
803
  // Formateador de modelo para evitar colapsos
602
- const formatModelName = (model: any): string => {
804
+ const formatModelName = (model: unknown): string => {
603
805
  if (!model) return 'default'
604
806
  if (typeof model === 'string') {
605
807
  return model.split('/').pop() || model
606
808
  }
607
- if (typeof model === 'object') {
608
- if (model.modelID) return model.modelID
609
- if (model.id) return model.id
809
+ if (typeof model === 'object' && model !== null) {
810
+ const obj = model as Record<string, unknown>
811
+ if (typeof obj.modelID === 'string') return obj.modelID
812
+ if (typeof obj.id === 'string') return obj.id
610
813
  return JSON.stringify(model).substring(0, 20)
611
814
  }
612
815
  return 'default'
613
816
  }
614
817
 
615
818
  // Formateador robusto de fecha de mensaje
616
- const formatMessageTime = (info: any): string => {
819
+ const formatMessageTime = (info: {
820
+ time?: { created: number };
821
+ time_created?: number;
822
+ }): string => {
617
823
  const rawTime = info?.time?.created || info?.time_created
618
824
  if (!rawTime) return 'En curso...'
619
825
  try {
@@ -622,7 +828,7 @@ export default function App() {
622
828
  return 'En curso...'
623
829
  }
624
830
  return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
625
- } catch (e) {
831
+ } catch {
626
832
  return 'En curso...'
627
833
  }
628
834
  }
@@ -635,6 +841,22 @@ export default function App() {
635
841
  return sessions.filter(s => s.parent_id === parentId || s.parentID === parentId)
636
842
  }
637
843
 
844
+ // Filtrar rootSessions por proyecto
845
+ const getSessionsForProject = (projectId: string) => {
846
+ return rootSessions.filter(s => {
847
+ const mappedId = sessionMappings[s.id]
848
+ if (mappedId) {
849
+ if (projects.some(p => p.id === mappedId)) {
850
+ return mappedId === projectId
851
+ }
852
+ }
853
+ return projects[0]?.id === projectId
854
+ })
855
+ }
856
+
857
+ // Sesiones sin clasificar (Inbox)
858
+ const unclassifiedSessions: Session[] = []
859
+
638
860
  // Obtener la instancia activa del Daemon
639
861
  const activeInstance = instances.find(inst => inst.port === currentPort)
640
862
 
@@ -642,7 +864,10 @@ export default function App() {
642
864
  <div className="flex h-screen bg-[#09090b] text-[#f4f4f5] overflow-hidden antialiased font-sans select-none">
643
865
 
644
866
  {/* SIDEBAR: Lista de Sesiones e Instancias con Árbol de Subagentes */}
645
- <div className="w-80 bg-[#0c0c0e] border-r border-[#1f1f23] flex flex-col justify-between shrink-0">
867
+ <div
868
+ style={{ width: `${sidebarWidth}px` }}
869
+ className="bg-[#0c0c0e] border-r border-[#1f1f23] flex flex-col justify-between shrink-0 relative"
870
+ >
646
871
 
647
872
  {/* Encabezado Principal */}
648
873
  <div className="p-4 border-b border-[#1f1f23] flex items-center justify-between">
@@ -661,141 +886,281 @@ export default function App() {
661
886
  </button>
662
887
  </div>
663
888
 
664
- {/* SELECTOR DE PROYECTOS / INSTANCIAS */}
665
- <div className="px-3 pt-3 pb-1 relative">
666
- <div className="text-[10px] text-[#71717a] uppercase font-bold tracking-wider px-2 mb-1.5 flex items-center gap-1">
667
- <Server size={10} />
668
- <span>Instancia Activa de Opencode</span>
669
- </div>
889
+ {/* ENCABEZADO DE PROYECTOS */}
890
+ <div className="p-3 border-b border-[#1f1f23] flex items-center justify-between bg-[#09090b]/40">
891
+ <span className="text-[10px] text-[#fafafa] uppercase font-extrabold tracking-wider flex items-center gap-1.5 select-none">
892
+ <Server size={11} className="text-emerald-400" />
893
+ <span>📁 GESTOR DE PROYECTOS</span>
894
+ </span>
670
895
  <button
671
- onClick={() => setShowInstanceDropdown(!showInstanceDropdown)}
672
- className="w-full flex items-center justify-between p-2.5 rounded-lg bg-[#121215] border border-[#1f1f23] text-left hover:bg-[#18181b] transition duration-200 cursor-pointer text-xs"
896
+ onClick={() => setShowAddProjectForm(!showAddProjectForm)}
897
+ className="text-[10px] bg-[#1c1c1f] hover:bg-[#27272a] text-[#a1a1aa] hover:text-white px-2 py-1 rounded border border-[#2e2e33] transition duration-200 cursor-pointer flex items-center gap-1 font-bold"
898
+ title="Añadir Proyecto Local"
673
899
  >
674
- <div className="truncate flex flex-col gap-0.5">
675
- <span className="font-semibold text-white truncate">
676
- {activeInstance ? activeInstance.name : `Puerto: ${currentPort}`}
677
- </span>
678
- <span className="text-[10px] text-[#71717a] truncate font-mono">
679
- {activeInstance ? activeInstance.path : 'Buscando proyecto...'}
680
- </span>
681
- </div>
682
- <ChevronDown size={14} className="text-[#a1a1aa] shrink-0 ml-1" />
900
+ <Plus size={10} strokeWidth={2.5} />
901
+ <span>Proyecto</span>
683
902
  </button>
684
-
685
- {/* Menú de selección de Instancias */}
686
- {showInstanceDropdown && (
687
- <div className="absolute left-3 right-3 top-16 z-50 bg-[#0c0c0e] border border-[#2e2e33] rounded-lg shadow-2xl p-1.5 space-y-1 max-h-60 overflow-y-auto">
688
- <div className="text-[9px] text-[#71717a] font-bold uppercase tracking-wider px-2 py-1 border-b border-[#1f1f23]/40">
689
- Proyectos / TUIs Detectados ({instances.length})
690
- </div>
691
- {instances.length === 0 ? (
692
- <div className="text-[11px] text-[#71717a] p-3 text-center italic animate-pulse">
693
- No se detectaron otras TUIs activas...
694
- </div>
695
- ) : (
696
- instances.map(inst => (
697
- <button
698
- key={inst.port}
699
- onClick={() => handleSelectInstance(inst.port)}
700
- className={`w-full text-left p-2 rounded-md flex flex-col gap-0.5 transition duration-150 cursor-pointer ${
701
- inst.port === currentPort
702
- ? 'bg-[#1c1c1f] text-white border-l-2 border-white'
703
- : 'hover:bg-[#121215] text-[#a1a1aa] hover:text-white'
704
- }`}
705
- >
706
- <div className="flex items-center justify-between text-xs font-semibold">
707
- <span className="truncate">{inst.name}</span>
708
- <span className="text-[9px] font-mono text-[#71717a] bg-[#1c1c1f] px-1 py-0.5 rounded">
709
- :{inst.port}
710
- </span>
711
- </div>
712
- <span className="text-[9px] text-[#71717a] truncate font-mono">{inst.path}</span>
713
- </button>
714
- ))
715
- )}
716
- </div>
717
- )}
718
903
  </div>
719
904
 
720
- {/* Separador */}
721
- <div className="px-4 py-1.5 text-[10px] text-[#71717a] uppercase font-bold tracking-wider flex items-center gap-1 select-none">
722
- <MessageSquare size={10} />
723
- <span>Árbol de Sesiones y Subagentes</span>
724
- </div>
725
-
726
- {/* BARRA LATERAL SCROLLABLE: Jerarquía de Sesiones */}
727
- <div className="flex-1 overflow-y-auto p-2 space-y-1">
728
- {sessions.length === 0 ? (
729
- <div className="text-center py-8 text-xs text-[#71717a] italic">
730
- No se encontraron sesiones en este puerto
905
+ {/* FORMULARIO PARA AÑADIR PROYECTO */}
906
+ {showAddProjectForm && (
907
+ <form onSubmit={handleAddProject} className="p-3 bg-[#121215] border-b border-[#1f1f23] space-y-2.5">
908
+ <div className="space-y-1">
909
+ <label className="text-[9px] font-bold text-[#71717a] uppercase">Nombre del Proyecto</label>
910
+ <input
911
+ type="text"
912
+ required
913
+ value={newProjectName}
914
+ onChange={e => setNewProjectName(e.target.value)}
915
+ placeholder="Ej. Proyecto ROOT"
916
+ 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]"
917
+ />
918
+ </div>
919
+ <div className="space-y-1">
920
+ <label className="text-[9px] font-bold text-[#71717a] uppercase">Subcarpeta o Ruta Absoluta</label>
921
+ <input
922
+ type="text"
923
+ required
924
+ value={newProjectPath}
925
+ onChange={e => setNewProjectPath(e.target.value)}
926
+ placeholder="Ej. Proyecto-A"
927
+ 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"
928
+ />
929
+ </div>
930
+ <div className="flex gap-2 justify-end pt-1">
931
+ <button
932
+ type="button"
933
+ onClick={() => setShowAddProjectForm(false)}
934
+ className="px-2 py-1 text-[10px] text-[#71717a] hover:text-white transition cursor-pointer"
935
+ >
936
+ Cancelar
937
+ </button>
938
+ <button
939
+ type="submit"
940
+ className="px-2.5 py-1 text-[10px] bg-white text-[#09090b] font-bold rounded hover:bg-[#e4e4e7] transition cursor-pointer"
941
+ >
942
+ Crear e Instalar
943
+ </button>
731
944
  </div>
732
- ) : (
733
- rootSessions.map(root => {
734
- const isRootActive = root.id === currentSessionId
735
- const subagents = getSubagentsFor(root.id)
736
- const hasSubagents = subagents.length > 0
737
-
738
- return (
739
- <div key={root.id} className="space-y-1">
740
- {/* Sesión Principal */}
945
+ </form>
946
+ )}
947
+
948
+ {/* ARBOL DE PROYECTOS Y SESIONES */}
949
+ <div className="flex-1 overflow-y-auto p-2 space-y-3">
950
+ {/* Listar proyectos en localStorage */}
951
+ {projects.map(project => {
952
+ const isProjectActive = project.id === activeProjectId
953
+ const projectSessions = getSessionsForProject(project.id)
954
+ const isExpanded = !!expandedProjects[project.id]
955
+
956
+ return (
957
+ <div key={project.id} className="space-y-1 border border-transparent rounded-lg p-1 bg-[#09090b]/20">
958
+ {/* Cabecera del Proyecto (Carpeta) */}
959
+ <div className={`flex items-center justify-between p-2 rounded-md transition duration-150 ${
960
+ isProjectActive ? 'bg-[#18181c] border border-[#2e2e33]/70' : 'hover:bg-[#121215]/50'
961
+ }`}>
741
962
  <button
742
- onClick={() => {
743
- setCurrentSessionId(root.id)
744
- setErrorMsg('')
745
- navigateTo('/')
746
- }}
747
- className={`w-full text-left p-3 rounded-lg flex flex-col gap-1 transition duration-200 cursor-pointer ${
748
- isRootActive
749
- ? 'bg-[#1c1c1f] border border-[#2e2e33]'
750
- : 'hover:bg-[#121215] border border-transparent'
751
- }`}
963
+ onClick={() => toggleProjectExpansion(project.id)}
964
+ className="flex-1 flex items-center gap-2 text-left cursor-pointer outline-none min-w-0"
752
965
  >
753
- <div className="flex justify-between items-start">
754
- <span className={`font-semibold truncate text-sm ${isRootActive ? 'text-white' : 'text-[#a1a1aa]'}`}>
755
- 📂 {root.title || `Sesión: ${root.id.substring(0, 8)}`}
756
- </span>
757
- </div>
758
- <div className="flex items-center justify-between text-[10px] text-[#71717a]">
759
- <span>{root.agent || 'sdd-orchestrator'}</span>
760
- <span>{formatModelName(root.model)}</span>
966
+ <span className="text-[#a1a1aa] shrink-0">
967
+ {isExpanded ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
968
+ </span>
969
+ <div className="truncate">
970
+ <div className="flex items-center gap-1.5">
971
+ <span className="text-white text-xs font-extrabold truncate">
972
+ 📁 {project.name}
973
+ </span>
974
+ {isProjectActive && (
975
+ <span className="text-[8px] bg-emerald-950 text-emerald-400 border border-emerald-900/50 px-1 rounded font-bold uppercase shrink-0 tracking-wider">Activo</span>
976
+ )}
977
+ </div>
978
+ <span className="text-[9px] text-[#52525b] block truncate font-mono mt-0.5">{project.path}</span>
761
979
  </div>
762
980
  </button>
763
981
 
764
- {/* Subagentes (Hijas) en Árbol */}
765
- {hasSubagents && (
766
- <div className="ml-4 pl-3 border-l border-[#1f1f23] space-y-1 py-1">
767
- {subagents.map(sub => {
768
- const isSubActive = sub.id === currentSessionId
982
+ <div className="flex items-center gap-1 shrink-0 ml-1">
983
+ {!isProjectActive && (
984
+ <button
985
+ onClick={() => setActiveProjectId(project.id)}
986
+ className="p-1 hover:bg-[#27272a] text-[#71717a] hover:text-white rounded transition cursor-pointer"
987
+ title="Activar Proyecto"
988
+ >
989
+ <Play size={10} className="fill-current" />
990
+ </button>
991
+ )}
992
+ {project.id !== 'workspace_raiz' && (
993
+ <button
994
+ onClick={() => handleDeleteProject(project.id)}
995
+ className="p-1 hover:bg-red-950/40 text-[#71717a] hover:text-red-400 rounded transition cursor-pointer"
996
+ title="Eliminar Proyecto"
997
+ >
998
+ <Square size={10} />
999
+ </button>
1000
+ )}
1001
+ </div>
1002
+ </div>
1003
+
1004
+ {/* Lista de sesiones anidadas del proyecto */}
1005
+ {isExpanded && (
1006
+ <div className="ml-3 pl-2.5 border-l border-[#1f1f23] space-y-1 pt-1.5 pb-0.5">
1007
+ {projectSessions.length === 0 ? (
1008
+ <div className="text-[10px] text-[#52525b] italic py-1 pl-1">
1009
+ No hay sesiones en este proyecto. Haz clic en el botón + de arriba para iniciar una.
1010
+ </div>
1011
+ ) : (
1012
+ projectSessions.map(root => {
1013
+ const isRootActive = root.id === currentSessionId
1014
+ const subagents = getSubagentsFor(root.id)
1015
+ const hasSubagents = subagents.length > 0
1016
+
769
1017
  return (
770
- <button
771
- key={sub.id}
772
- onClick={() => {
773
- setCurrentSessionId(sub.id)
774
- setErrorMsg('')
775
- navigateTo('/')
776
- }}
777
- className={`w-full text-left p-2 rounded-md flex flex-col gap-0.5 transition duration-150 cursor-pointer text-xs ${
778
- isSubActive
779
- ? 'bg-[#18181c] border border-[#2e2e33]/50 text-white'
780
- : 'hover:bg-[#0f0f12] text-[#71717a] hover:text-[#a1a1aa]'
781
- }`}
782
- >
783
- <div className="flex items-center gap-1 font-medium truncate">
784
- <CornerDownRight size={11} className="text-[#a1a1aa] shrink-0" />
785
- <span className="truncate">🤖 {sub.title || `Subagente: ${sub.id.substring(0, 6)}`}</span>
786
- </div>
787
- <div className="flex items-center justify-between text-[9px] text-[#52525b] pl-4 font-mono">
788
- <span>{sub.agent || 'subagent'}</span>
789
- <span>{formatModelName(sub.model)}</span>
790
- </div>
791
- </button>
1018
+ <div key={root.id} className="space-y-1">
1019
+ {/* Sesión Principal */}
1020
+ <button
1021
+ onClick={() => {
1022
+ setCurrentSessionId(root.id)
1023
+ setErrorMsg('')
1024
+ navigateTo('/')
1025
+ }}
1026
+ className={`w-full text-left p-2.5 rounded-lg flex flex-col gap-0.5 transition duration-200 cursor-pointer ${
1027
+ isRootActive
1028
+ ? 'bg-[#1c1c1f] border border-[#2e2e33] text-white shadow-sm'
1029
+ : 'hover:bg-[#121215] text-[#a1a1aa] hover:text-white'
1030
+ }`}
1031
+ >
1032
+ <span className="font-semibold truncate text-[11px]">
1033
+ 💬 {root.title || `Sesión: ${root.id.substring(0, 8)}`}
1034
+ </span>
1035
+ <div className="flex items-center justify-between text-[9px] text-[#52525b]">
1036
+ <span>{root.agent || 'sdd-orchestrator'}</span>
1037
+ <span>{formatModelName(root.model)}</span>
1038
+ </div>
1039
+ </button>
1040
+
1041
+ {/* Subagentes hijas */}
1042
+ {hasSubagents && (
1043
+ <div className="ml-3 pl-2 border-l border-[#1f1f23]/60 space-y-1 py-0.5">
1044
+ {subagents.map(sub => {
1045
+ const isSubActive = sub.id === currentSessionId
1046
+ return (
1047
+ <button
1048
+ key={sub.id}
1049
+ onClick={() => {
1050
+ setCurrentSessionId(sub.id)
1051
+ setErrorMsg('')
1052
+ navigateTo('/')
1053
+ }}
1054
+ className={`w-full text-left p-1.5 rounded-md flex flex-col gap-0.5 transition duration-150 cursor-pointer text-[10px] ${
1055
+ isSubActive
1056
+ ? 'bg-[#18181c] border border-[#2e2e33]/40 text-white'
1057
+ : 'hover:bg-[#0f0f12] text-[#71717a] hover:text-[#a1a1aa]'
1058
+ }`}
1059
+ >
1060
+ <div className="flex items-center gap-1 font-medium truncate">
1061
+ <CornerDownRight size={10} className="text-[#71717a] shrink-0" />
1062
+ <span className="truncate">🤖 {sub.title || `Sub: ${sub.id.substring(0, 5)}`}</span>
1063
+ </div>
1064
+ </button>
1065
+ )
1066
+ })}
1067
+ </div>
1068
+ )}
1069
+ </div>
792
1070
  )
793
- })}
1071
+ })
1072
+ )}
1073
+ </div>
1074
+ )}
1075
+ </div>
1076
+ )
1077
+ })}
1078
+
1079
+ {/* Inbox / Sesiones Sin Clasificar */}
1080
+ {unclassifiedSessions.length > 0 && (
1081
+ <div className="space-y-1 border border-dashed border-[#1f1f23] rounded-lg p-1 bg-[#09090b]/10 mt-2">
1082
+ <div className="p-2 flex items-center justify-between">
1083
+ <span className="text-xs font-bold text-amber-500/80 flex items-center gap-1.5">
1084
+ 📥 Inbox / Sin Clasificar
1085
+ </span>
1086
+ <span className="text-[9px] bg-amber-950/40 text-amber-500 border border-amber-900/40 px-1.5 py-0.5 rounded-full font-bold">
1087
+ {unclassifiedSessions.length}
1088
+ </span>
1089
+ </div>
1090
+
1091
+ <div className="ml-1 pl-1 space-y-1 pt-1">
1092
+ {unclassifiedSessions.map(root => {
1093
+ const isRootActive = root.id === currentSessionId
1094
+ const subagents = getSubagentsFor(root.id)
1095
+ const hasSubagents = subagents.length > 0
1096
+
1097
+ return (
1098
+ <div key={root.id} className="space-y-1">
1099
+ <div className="flex items-center gap-1">
1100
+ <button
1101
+ onClick={() => {
1102
+ setCurrentSessionId(root.id)
1103
+ setErrorMsg('')
1104
+ navigateTo('/')
1105
+ }}
1106
+ className={`flex-1 text-left p-2 rounded-lg flex flex-col gap-0.5 transition duration-200 cursor-pointer ${
1107
+ isRootActive
1108
+ ? 'bg-[#1c1c1f] border border-[#2e2e33] text-white shadow-sm'
1109
+ : 'hover:bg-[#121215] text-[#a1a1aa] hover:text-white'
1110
+ }`}
1111
+ >
1112
+ <span className="font-semibold truncate text-[11px]">
1113
+ 💬 {root.title || `Sesión: ${root.id.substring(0, 8)}`}
1114
+ </span>
1115
+ <div className="flex items-center justify-between text-[9px] text-[#52525b]">
1116
+ <span>{root.agent || 'sdd-orchestrator'}</span>
1117
+ </div>
1118
+ </button>
1119
+
1120
+ {/* Botón rápido para mapear al proyecto activo */}
1121
+ <button
1122
+ onClick={() => {
1123
+ setSessionMappings(prev => ({
1124
+ ...prev,
1125
+ [root.id]: activeProjectId
1126
+ }))
1127
+ }}
1128
+ className="p-1 hover:bg-[#1c1c1f] text-[#71717a] hover:text-white rounded border border-[#1f1f23] transition cursor-pointer text-[9px] font-bold"
1129
+ title="Mover al Proyecto Activo"
1130
+ >
1131
+ Mapear
1132
+ </button>
1133
+ </div>
1134
+
1135
+ {hasSubagents && (
1136
+ <div className="ml-3 pl-2 border-l border-[#1f1f23]/60 space-y-1 py-0.5">
1137
+ {subagents.map(sub => {
1138
+ const isSubActive = sub.id === currentSessionId
1139
+ return (
1140
+ <button
1141
+ key={sub.id}
1142
+ onClick={() => {
1143
+ setCurrentSessionId(sub.id)
1144
+ setErrorMsg('')
1145
+ navigateTo('/')
1146
+ }}
1147
+ className={`w-full text-left p-1 rounded-md flex flex-col gap-0.5 transition duration-150 cursor-pointer text-[10px] ${
1148
+ isSubActive
1149
+ ? 'bg-[#18181c] border border-[#2e2e33]/40 text-white'
1150
+ : 'hover:bg-[#0f0f12] text-[#71717a] hover:text-[#a1a1aa]'
1151
+ }`}
1152
+ >
1153
+ <span className="truncate">🤖 {sub.title || `Sub: ${sub.id.substring(0, 5)}`}</span>
1154
+ </button>
1155
+ )
1156
+ })}
1157
+ </div>
1158
+ )}
794
1159
  </div>
795
- )}
796
- </div>
797
- )
798
- })
1160
+ )
1161
+ })}
1162
+ </div>
1163
+ </div>
799
1164
  )}
800
1165
  </div>
801
1166
 
@@ -842,6 +1207,14 @@ export default function App() {
842
1207
  </div>
843
1208
  </div>
844
1209
 
1210
+ {/* RESIZER HANDLE: Permite redimensionar la barra lateral de forma fluida */}
1211
+ <div
1212
+ onMouseDown={handleMouseDown}
1213
+ className="w-1.5 hover:w-2 active:w-2 h-full bg-[#1f1f23]/80 hover:bg-[#3b82f6]/60 active:bg-[#3b82f6] cursor-col-resize shrink-0 transition-all duration-150 z-50 relative flex items-center justify-center group"
1214
+ >
1215
+ <div className="w-[1px] h-8 bg-transparent group-hover:bg-white/40 group-active:bg-white/60 transition" />
1216
+ </div>
1217
+
845
1218
  {/* CHAT / MAIN AREA */}
846
1219
  <div className="flex-1 flex flex-col bg-[#09090b] relative overflow-hidden">
847
1220
  {isNewSessionView ? (
@@ -1072,7 +1445,68 @@ export default function App() {
1072
1445
  onScroll={handleScroll}
1073
1446
  className="flex-1 overflow-y-auto p-6 space-y-6"
1074
1447
  >
1075
- {messages.length === 0 ? (
1448
+ {!currentSession ? (
1449
+ <div className="h-full flex flex-col items-center justify-center text-center max-w-md mx-auto space-y-6 p-4">
1450
+ {errorMsg ? (
1451
+ <>
1452
+ <div className="h-14 w-14 rounded-full bg-red-950/40 border border-red-900/50 flex items-center justify-center text-red-400">
1453
+ <AlertCircle size={28} />
1454
+ </div>
1455
+ <div className="space-y-2">
1456
+ <h2 className="text-lg font-bold text-white">Conexión con Opencode Pendiente</h2>
1457
+ <p className="text-xs text-[#a1a1aa] leading-relaxed">
1458
+ {errorMsg}
1459
+ </p>
1460
+ </div>
1461
+
1462
+ <div className="w-full bg-[#0c0c0e] border border-[#1f1f23] rounded-xl p-4 text-left space-y-3">
1463
+ <p className="text-[11px] font-bold text-[#fafafa] uppercase tracking-wider">💡 ¿Cómo solucionarlo?</p>
1464
+ <p className="text-xs text-[#71717a] leading-relaxed">
1465
+ Abre otra ventana de terminal en la carpeta raíz del proyecto y ejecuta el servidor de Opencode:
1466
+ </p>
1467
+ <div className="bg-[#18181b] border border-[#2e2e33] p-2.5 rounded-lg flex items-center justify-between font-mono text-xs text-white">
1468
+ <span>opencode serve</span>
1469
+ <button
1470
+ type="button"
1471
+ onClick={() => {
1472
+ navigator.clipboard.writeText('opencode serve')
1473
+ }}
1474
+ className="text-[#a1a1aa] hover:text-white transition cursor-pointer"
1475
+ title="Copiar comando"
1476
+ >
1477
+ <Copy size={12} />
1478
+ </button>
1479
+ </div>
1480
+ <p className="text-[11px] text-[#71717a]">
1481
+ Una vez levantado, ZugzWeb se conectará automáticamente y podrás controlar tu sesión.
1482
+ </p>
1483
+ </div>
1484
+
1485
+ <button
1486
+ type="button"
1487
+ onClick={() => {
1488
+ fetchSessions()
1489
+ fetchInstances()
1490
+ }}
1491
+ className="py-2 px-4 bg-white hover:bg-[#e4e4e7] text-[#09090b] font-bold rounded-lg text-xs transition duration-200 cursor-pointer shadow-md flex items-center gap-1.5"
1492
+ >
1493
+ <RefreshCw size={12} />
1494
+ <span>Volver a intentar conexión</span>
1495
+ </button>
1496
+ </>
1497
+ ) : (
1498
+ <>
1499
+ <div className="h-12 w-12 rounded-full border border-white/10 flex items-center justify-center text-[#71717a] animate-spin">
1500
+ <RefreshCw size={20} />
1501
+ </div>
1502
+ <div className="space-y-1">
1503
+ <h2 className="text-sm font-semibold text-white">Cargando sesión activa...</h2>
1504
+ <p className="text-xs text-[#71717a]">Estableciendo canal seguro con tu arnés Opencode local</p>
1505
+ </div>
1506
+ </>
1507
+ )}
1508
+ </div>
1509
+ ) : messages.length === 0 ? (
1076
1510
  <div className="h-full flex flex-col items-center justify-center text-[#71717a] space-y-3">
1077
1511
  <MessageSquare size={36} className="text-[#27272a]" />
1078
1512
  <div className="text-center">
@@ -1297,6 +1731,7 @@ export default function App() {
1297
1731
  <textarea
1298
1732
  value={inputValue}
1299
1733
  onChange={e => setInputValue(e.target.value)}
1734
+ disabled={!currentSessionId}
1300
1735
  onKeyDown={e => {
1301
1736
  if (e.key === 'Enter' && !e.shiftKey) {
1302
1737
  e.preventDefault()
@@ -1304,11 +1739,13 @@ export default function App() {
1304
1739
  }
1305
1740
  }}
1306
1741
  placeholder={
1307
- inputValue.startsWith('/')
1742
+ !currentSessionId
1743
+ ? "Debe haber una sesión activa de Opencode para enviar prompts. Por favor, inicia conexión o crea una sesión."
1744
+ : inputValue.startsWith('/')
1308
1745
  ? "Escribe los argumentos para el comando... (ej. /loop Crear login)"
1309
1746
  : `Instrucción para ${activeInstance?.name || 'Opencode'} usando ${AVAILABLE_AGENTS.find(a => a.id === selectedAgent)?.name || selectedAgent}... (Escribe / para ver comandos, Shift+Enter para nueva línea)`
1310
1747
  }
1311
- className="w-full bg-transparent px-4 py-3.5 pr-24 text-sm text-[#f4f4f5] placeholder-[#71717a] outline-none border-none resize-none h-14 max-h-40 min-h-[56px] focus:ring-0 font-medium"
1748
+ className="w-full bg-transparent px-4 py-3.5 pr-24 text-sm text-[#f4f4f5] placeholder-[#71717a] outline-none border-none resize-none h-14 max-h-40 min-h-[56px] focus:ring-0 font-medium disabled:cursor-not-allowed"
1312
1749
  />
1313
1750
 
1314
1751
  <div className="absolute right-3 bottom-3 flex items-center gap-2">