zugzbot 1.0.7 → 1.0.9

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.
@@ -0,0 +1,1794 @@
1
+ import React, { useState, useEffect, useRef } from 'react'
2
+ import { marked } from 'marked'
3
+ import {
4
+ Play,
5
+ Square,
6
+ Plus,
7
+ Terminal,
8
+ Settings,
9
+ Globe,
10
+ RefreshCw,
11
+ User,
12
+ Bot,
13
+ ChevronDown,
14
+ ChevronUp,
15
+ ExternalLink,
16
+ Bell,
17
+ BellOff,
18
+ MessageSquare,
19
+ Server,
20
+ Copy,
21
+ Check,
22
+ CornerDownRight,
23
+ Code,
24
+ Sparkles,
25
+ Clock,
26
+ AlertCircle
27
+ } from 'lucide-react'
28
+
29
+ // Interfaces basadas en la API de Opencode
30
+ interface Session {
31
+ id: string
32
+ title: string | null
33
+ parent_id?: string | null
34
+ parentID?: string | null // Algunos endpoints de Opencode devuelven camelCase
35
+ agent?: string | null
36
+ model?: unknown | null
37
+ cost?: number | null
38
+ tokens_input?: number | null
39
+ tokens_output?: number | null
40
+ time_created?: number
41
+ }
42
+
43
+ interface MessagePart {
44
+ type: 'text' | 'reasoning' | 'tool' | string
45
+ text?: string
46
+ tool?: string
47
+ state?: {
48
+ status: string
49
+ input?: unknown
50
+ output?: unknown
51
+ }
52
+ }
53
+
54
+ interface Message {
55
+ info: {
56
+ id: string
57
+ session_id: string
58
+ role: 'user' | 'assistant' | 'system'
59
+ time?: {
60
+ created: number
61
+ }
62
+ time_created?: number
63
+ }
64
+ parts: MessagePart[]
65
+ }
66
+
67
+ interface OpencodeInstance {
68
+ port: number
69
+ path: string
70
+ name: string
71
+ isCurrentProject: boolean
72
+ }
73
+
74
+ interface Project {
75
+ id: string
76
+ name: string
77
+ path: string
78
+ }
79
+
80
+ // Lista estática curada de los mejores modelos de Opencode
81
+ const POPULAR_MODELS = [
82
+ { id: 'default', name: '🤖 Modelo por defecto' },
83
+ { id: 'deepseek/deepseek-chat', name: '⚡️ DeepSeek v4 (Chat rápido)' },
84
+ { id: 'deepseek/deepseek-reasoner', name: '💭 DeepSeek R1 (Pensamiento/Razonamiento)' },
85
+ { id: 'google/gemini-3.5-flash', name: '♊️ Gemini 3.5 Flash' },
86
+ { id: 'google/gemini-3.1-pro-preview', name: '🧠 Gemini 3.1 Pro (Complejo)' },
87
+ { id: 'opencode/deepseek-v4-flash-free', name: '🆓 DeepSeek v4 (Flash Gratis)' },
88
+ { id: 'opencode/nemotron-3-ultra-free', name: '🆓 Nemotron 3 Ultra (Gratis)' }
89
+ ]
90
+
91
+ const AVAILABLE_AGENTS = [
92
+ {
93
+ id: 'build',
94
+ name: '🛠️ Build',
95
+ description: 'Agente de desarrollo estándar. Tiene todas las herramientas de lectura, escritura y bash habilitadas para programar.',
96
+ color: '#3b82f6'
97
+ },
98
+ {
99
+ id: 'plan',
100
+ name: '📋 Plan',
101
+ description: 'Agente de solo lectura/planificación. Útil para analizar el código base y planificar cambios de forma segura sin editarlos directamente.',
102
+ color: '#a855f7'
103
+ },
104
+ {
105
+ id: 'sdd-orchestrator',
106
+ name: '🤖 Sdd-Orchestrator',
107
+ description: 'Coordinador principal del flujo SDD (Spec-Driven Development). Automatiza los subagentes spec-writer, coder y tester.',
108
+ color: '#10b981'
109
+ }
110
+ ]
111
+
112
+ export default function App() {
113
+ const [sessions, setSessions] = useState<Session[]>([])
114
+ const [currentSessionId, setCurrentSessionId] = useState<string>('')
115
+ const [currentSession, setCurrentSession] = useState<Session | null>(null)
116
+ const [messages, setMessages] = useState<Message[]>([])
117
+ const [inputValue, setInputValue] = useState('')
118
+ const [isProcessing, setIsProcessing] = useState(false)
119
+ const [notificationsEnabled, setNotificationsEnabled] = useState<boolean>(
120
+ () => typeof Notification !== 'undefined' && Notification.permission === 'granted'
121
+ )
122
+ const [tunnelUrl, setTunnelUrl] = useState<string>('')
123
+ const [expandedParts, setExpandedParts] = useState<Record<string, boolean>>({})
124
+ const [errorMsg, setErrorMsg] = useState<string>('')
125
+
126
+ // Soporte para multi-instancia / selección de proyectos
127
+ const [instances, setInstances] = useState<OpencodeInstance[]>([])
128
+ const [currentPort, setCurrentPort] = useState<number>(4096)
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])
208
+
209
+ // Opciones de prompt
210
+ const [selectedModel, setSelectedModel] = useState<string>('default')
211
+ const [copiedMessageId, setCopiedMessageId] = useState<string>('')
212
+
213
+ // Estados para la nueva vista /new y agente activo
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
+ })
222
+ const [newSessionAgent, setNewSessionAgent] = useState<string>('sdd-orchestrator')
223
+ const [newSessionModel, setNewSessionModel] = useState<string>('default')
224
+ const [newSessionFirstPrompt, setNewSessionFirstPrompt] = useState<string>('')
225
+ const [selectedAgent, setSelectedAgent] = useState<string>('sdd-orchestrator')
226
+
227
+ const chatContainerRef = useRef<HTMLDivElement>(null)
228
+ const [userHasScrolledUp, setUserHasScrolledUp] = useState(false)
229
+
230
+ const navigateTo = (path: string) => {
231
+ window.history.pushState(null, '', path)
232
+ setIsNewSessionView(path === '/new')
233
+ if (path === '/new') {
234
+ // Pre-rellenar un título por defecto para la nueva sesión
235
+ const formattedDate = new Date().toLocaleString([], { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
236
+ setNewSessionTitle(`Sesión Web ${formattedDate}`)
237
+ }
238
+ }
239
+
240
+
241
+
242
+ // 1. Cargar estado inicial y levantar listeners
243
+ useEffect(() => {
244
+ fetchTunnelStatus()
245
+ fetchInstances()
246
+ fetchSessions()
247
+
248
+
249
+
250
+ const handlePopState = () => {
251
+ setIsNewSessionView(window.location.pathname === '/new')
252
+ }
253
+ window.addEventListener('popstate', handlePopState)
254
+
255
+ // Polling periódico para descubrir nuevas instancias y actualizar la lista de sesiones
256
+ const syncInterval = setInterval(() => {
257
+ fetchInstances()
258
+ fetchSessions()
259
+ }, 5000)
260
+
261
+ return () => {
262
+ window.removeEventListener('popstate', handlePopState)
263
+ clearInterval(syncInterval)
264
+ }
265
+ // eslint-disable-next-line react-hooks/exhaustive-deps
266
+ }, [])
267
+
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
278
+ useEffect(() => {
279
+ if (!currentSessionId) return
280
+ triggerImmediateUpdate()
281
+ // eslint-disable-next-line react-hooks/exhaustive-deps
282
+ }, [currentSessionId, currentPort])
283
+
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(() => {
301
+ fetchSessionStatus()
302
+ fetchMessages(currentSessionId)
303
+ fetchSessionDetails(currentSessionId)
304
+ }, 5000)
305
+
306
+ return () => {
307
+ if (rapidIntervalId) clearInterval(rapidIntervalId)
308
+ clearInterval(heartbeatIntervalId)
309
+ }
310
+ // eslint-disable-next-line react-hooks/exhaustive-deps
311
+ }, [currentSessionId, currentPort, isProcessing])
312
+
313
+ // 3. Scroll automático inteligente al recibir nuevos mensajes
314
+ useEffect(() => {
315
+ if (!userHasScrolledUp && chatContainerRef.current) {
316
+ chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
317
+ }
318
+ // eslint-disable-next-line react-hooks/exhaustive-deps
319
+ }, [messages])
320
+
321
+ // Manejar el desplazamiento del scroll para desactivar/activar el Smart Scroll Anchoring
322
+ const handleScroll = () => {
323
+ if (chatContainerRef.current) {
324
+ const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current
325
+ // Si el usuario sube más de 120px desde el fondo, asumimos que subió manualmente
326
+ const isAtBottom = scrollHeight - scrollTop - clientHeight < 120
327
+ setUserHasScrolledUp(!isAtBottom)
328
+ }
329
+ }
330
+
331
+ // Parseador de Markdown seguro usando marked
332
+ const renderMarkdown = (text: string) => {
333
+ try {
334
+ return { __html: marked.parse(text, { gfm: true, breaks: true }) }
335
+ } catch {
336
+ return { __html: text }
337
+ }
338
+ }
339
+
340
+ // Notificaciones Push del navegador
341
+ const toggleNotifications = async () => {
342
+ if (notificationsEnabled) {
343
+ setNotificationsEnabled(false)
344
+ return
345
+ }
346
+
347
+ const permission = await Notification.requestPermission()
348
+ if (permission === 'granted') {
349
+ setNotificationsEnabled(true)
350
+ showNotification('¡Notificaciones activadas!', 'Te avisaremos cuando tus flujos en Opencode terminen.')
351
+ } else {
352
+ alert('Permiso de notificación denegado por el navegador.')
353
+ }
354
+ }
355
+
356
+ const showNotification = (title: string, body: string) => {
357
+ if (Notification.permission === 'granted') {
358
+ new Notification(title, {
359
+ body,
360
+ icon: '/favicon.ico'
361
+ })
362
+ }
363
+ }
364
+
365
+ // Copiar al portapapeles
366
+ const handleCopyText = (text: string, msgId: string) => {
367
+ navigator.clipboard.writeText(text).then(() => {
368
+ setCopiedMessageId(msgId)
369
+ setTimeout(() => setCopiedMessageId(''), 2000)
370
+ })
371
+ }
372
+
373
+ // APIs del Servidor / Daemon Custom
374
+ async function fetchTunnelStatus() {
375
+ try {
376
+ const res = await fetch('/api-custom/tunnel-status')
377
+ if (res.ok) {
378
+ const data = await res.json()
379
+ setTunnelUrl(data.url || '')
380
+ }
381
+ } catch (error) {
382
+ console.error('Error obteniendo estado del túnel:', error)
383
+ }
384
+ }
385
+
386
+ async function fetchInstances() {
387
+ try {
388
+ const res = await fetch('/api-custom/instances')
389
+ if (res.ok) {
390
+ const data = await res.json()
391
+ setInstances(data.instances || [])
392
+ setCurrentPort(data.currentPort || 4096)
393
+ }
394
+ } catch (error) {
395
+ console.error('Error obteniendo instancias de Opencode:', error)
396
+ }
397
+ }
398
+
399
+ async function fetchSessions() {
400
+ try {
401
+ const res = await fetch('/api/session')
402
+ if (res.ok) {
403
+ const data = await res.json()
404
+ const sessionList = Array.isArray(data) ? data : (data.sessions || [])
405
+ setSessions(sessionList)
406
+ if (sessionList.length > 0) {
407
+ if (!currentSessionId) {
408
+ setCurrentSessionId(sessionList[0].id)
409
+ }
410
+ } else {
411
+ navigateTo('/new')
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"?')
417
+ }
418
+ } catch {
419
+ setErrorMsg('No se pudo conectar al Daemon local de Opencode')
420
+ }
421
+ }
422
+
423
+ async function fetchSessionDetails(id: string) {
424
+ try {
425
+ const res = await fetch(`/api/session/${id}`)
426
+ if (res.ok) {
427
+ const data = await res.json()
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')
436
+ }
437
+ } catch (error) {
438
+ console.error(error)
439
+ }
440
+ }
441
+
442
+ async function fetchMessages(id: string) {
443
+ try {
444
+ const res = await fetch(`/api/session/${id}/message`)
445
+ if (res.ok) {
446
+ const data = await res.json()
447
+ const msgList = Array.isArray(data) ? data : (data.messages || [])
448
+
449
+ setMessages(msgList)
450
+ setErrorMsg('')
451
+ } else {
452
+ const errData = await res.json().catch(() => ({}))
453
+ setErrorMsg(errData.message || 'No se pudieron recuperar los mensajes')
454
+ }
455
+ } catch (error) {
456
+ console.error(error)
457
+ }
458
+ }
459
+
460
+ async function fetchSessionStatus() {
461
+ try {
462
+ const res = await fetch('/api/session/status')
463
+ if (res.ok) {
464
+ const data = await res.json()
465
+ if (currentSessionId && data[currentSessionId]) {
466
+ const status = data[currentSessionId]
467
+ const isBusy = status === 'thinking' || status === 'running_tools' || status === 'busy'
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
+ })
487
+ }
488
+ }
489
+ setErrorMsg('')
490
+ } else {
491
+ const errData = await res.json().catch(() => ({}))
492
+ setErrorMsg(errData.message || 'No se pudo comprobar el estado de Opencode')
493
+ }
494
+ } catch (error) {
495
+ console.error(error)
496
+ }
497
+ }
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
+
591
+ const handleStartNewSession = async (e?: React.FormEvent) => {
592
+ if (e) e.preventDefault()
593
+ if (!newSessionTitle.trim()) {
594
+ alert('Por favor introduce un título para la sesión')
595
+ return
596
+ }
597
+
598
+ try {
599
+ setIsProcessing(true)
600
+ // 1. Crear sesión en la API de Opencode
601
+ const res = await fetch('/api/session', {
602
+ method: 'POST',
603
+ headers: { 'Content-Type': 'application/json' },
604
+ body: JSON.stringify({ title: newSessionTitle.trim() })
605
+ })
606
+
607
+ if (!res.ok) throw new Error('Error al crear sesión')
608
+ const newSess = await res.json()
609
+
610
+ // 2. Agregar a la lista de sesiones local
611
+ setSessions(prev => [newSess, ...prev])
612
+ setCurrentSessionId(newSess.id)
613
+ setCurrentSession(newSess)
614
+ setMessages([])
615
+
616
+ // Guardar el mapeo de la sesión al proyecto activo
617
+ setSessionMappings(prev => ({
618
+ ...prev,
619
+ [newSess.id]: activeProjectId
620
+ }))
621
+
622
+ // Establecer el agente y modelo seleccionados para esta sesión
623
+ setSelectedAgent(newSessionAgent)
624
+ setSelectedModel(newSessionModel)
625
+
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 }],
642
+ agent: newSessionAgent
643
+ }
644
+
645
+ if (newSessionModel && newSessionModel !== 'default') {
646
+ const [providerID, modelID] = newSessionModel.split('/')
647
+ bodyPayload.model = { providerID, modelID }
648
+ }
649
+
650
+ // Llamamos a prompt_async para enviar el prompt asíncronamente
651
+ await fetch(`/api/session/${newSess.id}/prompt_async`, {
652
+ method: 'POST',
653
+ headers: { 'Content-Type': 'application/json' },
654
+ body: JSON.stringify(bodyPayload)
655
+ })
656
+
657
+ // Reiniciar campos
658
+ setNewSessionFirstPrompt('')
659
+ }
660
+
661
+ // Volver a la vista principal / chat
662
+ navigateTo('/')
663
+ triggerImmediateUpdate()
664
+ } catch {
665
+ alert('Error al iniciar la nueva sesión')
666
+ } finally {
667
+ setIsProcessing(false)
668
+ }
669
+ }
670
+
671
+ // Manejar el envío de prompts o la ejecución de comandos diagonal
672
+ const handleSendPrompt = async (e: React.FormEvent) => {
673
+ e.preventDefault()
674
+ if (!inputValue.trim() || !currentSessionId) return
675
+
676
+ const input = inputValue.trim()
677
+ setInputValue('')
678
+ setIsProcessing(true)
679
+ setUserHasScrolledUp(false) // Forzar autoscroll al enviar
680
+
681
+ // Agregar mensaje local temporal de usuario
682
+ const tempUserMessage: Message = {
683
+ info: {
684
+ id: `temp-${Date.now()}`,
685
+ session_id: currentSessionId,
686
+ role: 'user',
687
+ time: { created: Date.now() }
688
+ },
689
+ parts: [{ type: 'text', text: input }]
690
+ }
691
+ setMessages(prev => [...prev, tempUserMessage])
692
+
693
+ try {
694
+ if (input.startsWith('/')) {
695
+ // Ejecución de comandos de Opencode
696
+ const parts = input.split(' ')
697
+ const commandName = parts[0].substring(1) // Quitar '/'
698
+ const args = parts.slice(1).join(' ')
699
+
700
+ const res = await fetch(`/api/session/${currentSessionId}/command`, {
701
+ method: 'POST',
702
+ headers: { 'Content-Type': 'application/json' },
703
+ body: JSON.stringify({
704
+ command: commandName,
705
+ arguments: args,
706
+ agent: selectedAgent
707
+ })
708
+ })
709
+
710
+ if (!res.ok) throw new Error('Error al ejecutar comando')
711
+ } else {
712
+ // Envío de prompt normal asíncrono
713
+ const bodyPayload: {
714
+ parts: { type: string; text: string }[];
715
+ agent: string;
716
+ model?: { providerID: string; modelID: string };
717
+ } = {
718
+ parts: [{ type: 'text', text: input }],
719
+ agent: selectedAgent
720
+ }
721
+
722
+ // Agregar modelo personalizado si se ha seleccionado
723
+ if (selectedModel && selectedModel !== 'default') {
724
+ const [providerID, modelID] = selectedModel.split('/')
725
+ bodyPayload.model = { providerID, modelID }
726
+ }
727
+
728
+ const res = await fetch(`/api/session/${currentSessionId}/prompt_async`, {
729
+ method: 'POST',
730
+ headers: { 'Content-Type': 'application/json' },
731
+ body: JSON.stringify(bodyPayload)
732
+ })
733
+
734
+ if (!res.ok) throw new Error('Error al enviar prompt')
735
+ }
736
+
737
+ triggerImmediateUpdate()
738
+ } catch {
739
+ setErrorMsg('Error al conectar. Comprueba el estado de Opencode.')
740
+ setIsProcessing(false)
741
+ }
742
+ }
743
+
744
+ // Activa comandos rápidos desde un botón de un solo clic
745
+ const handleQuickCommand = async (commandName: string, args: string = '') => {
746
+ if (!currentSessionId) return
747
+ setIsProcessing(true)
748
+ setUserHasScrolledUp(false) // Forzar autoscroll al enviar
749
+
750
+ const fullCommandText = `/${commandName} ${args}`.trim()
751
+
752
+ // Agregar mensaje temporal local
753
+ const tempUserMessage: Message = {
754
+ info: {
755
+ id: `temp-${Date.now()}`,
756
+ session_id: currentSessionId,
757
+ role: 'user',
758
+ time: { created: Date.now() }
759
+ },
760
+ parts: [{ type: 'text', text: fullCommandText }]
761
+ }
762
+ setMessages(prev => [...prev, tempUserMessage])
763
+
764
+ try {
765
+ const res = await fetch(`/api/session/${currentSessionId}/command`, {
766
+ method: 'POST',
767
+ headers: { 'Content-Type': 'application/json' },
768
+ body: JSON.stringify({
769
+ command: commandName,
770
+ arguments: args,
771
+ agent: selectedAgent
772
+ })
773
+ })
774
+
775
+ if (!res.ok) throw new Error('Error al ejecutar comando rápido')
776
+ triggerImmediateUpdate()
777
+ } catch {
778
+ setErrorMsg('No se pudo ejecutar el comando rápido.')
779
+ setIsProcessing(false)
780
+ }
781
+ }
782
+
783
+ const handleAbort = async () => {
784
+ if (!currentSessionId) return
785
+ try {
786
+ const res = await fetch(`/api/session/${currentSessionId}/abort`, {
787
+ method: 'POST'
788
+ })
789
+ if (res.ok) {
790
+ setIsProcessing(false)
791
+ showNotification('Opencode: Abortado', 'La sesión en ejecución ha sido cancelada forzadamente.')
792
+ triggerImmediateUpdate()
793
+ }
794
+ } catch (e) {
795
+ console.error(e)
796
+ }
797
+ }
798
+
799
+ const togglePartExpansion = (id: string) => {
800
+ setExpandedParts(prev => ({ ...prev, [id]: !prev[id] }))
801
+ }
802
+
803
+ // Formateador de modelo para evitar colapsos
804
+ const formatModelName = (model: unknown): string => {
805
+ if (!model) return 'default'
806
+ if (typeof model === 'string') {
807
+ return model.split('/').pop() || model
808
+ }
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
813
+ return JSON.stringify(model).substring(0, 20)
814
+ }
815
+ return 'default'
816
+ }
817
+
818
+ // Formateador robusto de fecha de mensaje
819
+ const formatMessageTime = (info: {
820
+ time?: { created: number };
821
+ time_created?: number;
822
+ }): string => {
823
+ const rawTime = info?.time?.created || info?.time_created
824
+ if (!rawTime) return 'En curso...'
825
+ try {
826
+ const date = new Date(rawTime)
827
+ if (isNaN(date.getTime())) {
828
+ return 'En curso...'
829
+ }
830
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
831
+ } catch {
832
+ return 'En curso...'
833
+ }
834
+ }
835
+
836
+ // Estructura Jerárquica del Árbol de Subagentes
837
+ // Separar las sesiones en principales (raíz) e hijas (subagentes)
838
+ const rootSessions = sessions.filter(s => !s.parent_id && !s.parentID)
839
+
840
+ const getSubagentsFor = (parentId: string) => {
841
+ return sessions.filter(s => s.parent_id === parentId || s.parentID === parentId)
842
+ }
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
+
860
+ // Obtener la instancia activa del Daemon
861
+ const activeInstance = instances.find(inst => inst.port === currentPort)
862
+
863
+ return (
864
+ <div className="flex h-screen bg-[#09090b] text-[#f4f4f5] overflow-hidden antialiased font-sans select-none">
865
+
866
+ {/* SIDEBAR: Lista de Sesiones e Instancias con Árbol de Subagentes */}
867
+ <div
868
+ style={{ width: `${sidebarWidth}px` }}
869
+ className="bg-[#0c0c0e] border-r border-[#1f1f23] flex flex-col justify-between shrink-0 relative"
870
+ >
871
+
872
+ {/* Encabezado Principal */}
873
+ <div className="p-4 border-b border-[#1f1f23] flex items-center justify-between">
874
+ <div className="flex items-center gap-2">
875
+ <div className="p-1.5 bg-[#ffffff] rounded-md text-[#09090b]">
876
+ <Terminal size={18} className="font-bold animate-pulse" />
877
+ </div>
878
+ <span className="font-bold tracking-tight text-lg">ZugzWeb</span>
879
+ </div>
880
+ <button
881
+ onClick={() => navigateTo('/new')}
882
+ className="p-1.5 bg-[#27272a] hover:bg-[#3f3f46] text-[#fafafa] rounded-md transition duration-200 cursor-pointer"
883
+ title="Nueva Sesión"
884
+ >
885
+ <Plus size={16} />
886
+ </button>
887
+ </div>
888
+
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>
895
+ <button
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"
899
+ >
900
+ <Plus size={10} strokeWidth={2.5} />
901
+ <span>Proyecto</span>
902
+ </button>
903
+ </div>
904
+
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>
944
+ </div>
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
+ }`}>
962
+ <button
963
+ onClick={() => toggleProjectExpansion(project.id)}
964
+ className="flex-1 flex items-center gap-2 text-left cursor-pointer outline-none min-w-0"
965
+ >
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>
979
+ </div>
980
+ </button>
981
+
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
+
1017
+ return (
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>
1070
+ )
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
+ )}
1159
+ </div>
1160
+ )
1161
+ })}
1162
+ </div>
1163
+ </div>
1164
+ )}
1165
+ </div>
1166
+
1167
+ {/* Notificaciones y Estado del Túnel */}
1168
+ <div className="p-4 bg-[#09090b] border-t border-[#1f1f23] space-y-3">
1169
+ <button
1170
+ onClick={toggleNotifications}
1171
+ className={`w-full flex items-center justify-center gap-2 py-2 px-3 rounded-md text-xs font-medium border transition cursor-pointer ${
1172
+ notificationsEnabled
1173
+ ? 'bg-transparent border-[#22c55e]/30 hover:border-[#22c55e]/50 text-[#22c55e]'
1174
+ : 'bg-transparent border-[#27272a] hover:border-[#3f3f46] text-[#a1a1aa]'
1175
+ }`}
1176
+ >
1177
+ {notificationsEnabled ? (
1178
+ <>
1179
+ <Bell size={14} />
1180
+ <span>Notificaciones Activas</span>
1181
+ </>
1182
+ ) : (
1183
+ <>
1184
+ <BellOff size={14} />
1185
+ <span>Activar Alertas Navegador</span>
1186
+ </>
1187
+ )}
1188
+ </button>
1189
+
1190
+ {tunnelUrl && (
1191
+ <div className="p-2 bg-[#1c1c1f]/50 border border-[#2e2e33] rounded-lg space-y-1">
1192
+ <div className="flex items-center gap-1.5 text-[11px] text-[#a1a1aa] font-medium">
1193
+ <Globe size={11} className="text-[#3b82f6] animate-pulse" />
1194
+ <span>Túnel Remoto Activo</span>
1195
+ </div>
1196
+ <a
1197
+ href={tunnelUrl}
1198
+ target="_blank"
1199
+ rel="noopener noreferrer"
1200
+ className="text-[10px] text-[#3b82f6] hover:underline flex items-center gap-1 font-mono break-all"
1201
+ >
1202
+ {tunnelUrl}
1203
+ <ExternalLink size={8} />
1204
+ </a>
1205
+ </div>
1206
+ )}
1207
+ </div>
1208
+ </div>
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
+
1218
+ {/* CHAT / MAIN AREA */}
1219
+ <div className="flex-1 flex flex-col bg-[#09090b] relative overflow-hidden">
1220
+ {isNewSessionView ? (
1221
+ <>
1222
+ {/* Header de Nueva Sesión */}
1223
+ <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">
1224
+ <div className="flex items-center gap-2">
1225
+ <Sparkles size={16} className="text-emerald-400 animate-pulse" />
1226
+ <h1 className="text-sm font-bold text-[#fafafa]">Crear Nueva Sesión de Desarrollo</h1>
1227
+ </div>
1228
+ {sessions.length > 0 && (
1229
+ <button
1230
+ onClick={() => navigateTo('/')}
1231
+ className="text-xs text-[#71717a] hover:text-white border border-[#1f1f23] hover:border-[#27272a] px-2.5 py-1 rounded-md transition cursor-pointer"
1232
+ >
1233
+ Cancelar
1234
+ </button>
1235
+ )}
1236
+ </div>
1237
+
1238
+ {/* Formulario de Nueva Sesión */}
1239
+ <div className="flex-1 overflow-y-auto p-8 max-w-2xl mx-auto w-full space-y-6">
1240
+ <div className="space-y-1.5">
1241
+ <h2 className="text-xl font-bold tracking-tight text-white flex items-center gap-2">
1242
+ <span>🚀 Comenzar un nuevo flujo</span>
1243
+ </h2>
1244
+ <p className="text-xs text-[#71717a]">
1245
+ Elige el agente y modelo adecuados para estructurar tu especificación, codificar o auditar tu aplicación de forma guiada y asíncrona.
1246
+ </p>
1247
+ </div>
1248
+
1249
+ <form onSubmit={handleStartNewSession} className="space-y-5">
1250
+ {/* INPUT: TÍTULO DE LA SESIÓN */}
1251
+ <div className="space-y-1.5">
1252
+ <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Título de la Sesión</label>
1253
+ <input
1254
+ type="text"
1255
+ value={newSessionTitle}
1256
+ onChange={e => setNewSessionTitle(e.target.value)}
1257
+ placeholder="Ej. Crear Login Form, Refactorizar Auth..."
1258
+ className="w-full bg-[#0c0c0e] border border-[#27272a] focus:border-[#3f3f46] rounded-xl px-4 py-3 text-sm text-[#fafafa] placeholder-[#52525b] outline-none transition font-medium"
1259
+ required
1260
+ />
1261
+ </div>
1262
+
1263
+ {/* SELECCIÓN DE AGENTES: CARDS INTERACTIVAS */}
1264
+ <div className="space-y-1.5">
1265
+ <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Agente Primario Inicial</label>
1266
+ <div className="grid grid-cols-1 gap-2.5">
1267
+ {AVAILABLE_AGENTS.map(agent => {
1268
+ const isSelected = newSessionAgent === agent.id
1269
+ return (
1270
+ <button
1271
+ key={agent.id}
1272
+ type="button"
1273
+ onClick={() => setNewSessionAgent(agent.id)}
1274
+ className={`w-full text-left p-3.5 rounded-xl border transition-all duration-200 flex gap-3 cursor-pointer relative ${
1275
+ isSelected
1276
+ ? 'bg-[#121215] border-white/60 shadow-[0_0_12px_rgba(255,255,255,0.05)]'
1277
+ : 'bg-[#0c0c0e] border-[#1f1f23] hover:border-[#27272a]'
1278
+ }`}
1279
+ >
1280
+ <div
1281
+ className="h-8 w-8 rounded-lg flex items-center justify-center shrink-0"
1282
+ style={{ backgroundColor: `${agent.color}15`, color: agent.color }}
1283
+ >
1284
+ {agent.id === 'build' ? <Settings size={16} /> : agent.id === 'plan' ? <Code size={16} /> : <Sparkles size={16} />}
1285
+ </div>
1286
+ <div className="space-y-0.5">
1287
+ <div className="flex items-center gap-2">
1288
+ <span className="text-xs font-bold text-white">{agent.name}</span>
1289
+ {agent.id === 'sdd-orchestrator' && (
1290
+ <span className="text-[9px] bg-white/10 text-white/80 font-mono px-1 rounded uppercase tracking-wider">Por defecto</span>
1291
+ )}
1292
+ </div>
1293
+ <p className="text-[11px] text-[#71717a] leading-relaxed">{agent.description}</p>
1294
+ </div>
1295
+ {isSelected && (
1296
+ <div className="absolute right-4 top-4 h-4 w-4 bg-white rounded-full flex items-center justify-center text-[#09090b]">
1297
+ <Check size={10} strokeWidth={3} />
1298
+ </div>
1299
+ )}
1300
+ </button>
1301
+ )
1302
+ })}
1303
+ </div>
1304
+ </div>
1305
+
1306
+ {/* SELECTOR DE MODELO */}
1307
+ <div className="space-y-1.5">
1308
+ <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Modelo Objetivo</label>
1309
+ <div className="relative">
1310
+ <select
1311
+ value={newSessionModel}
1312
+ onChange={e => setNewSessionModel(e.target.value)}
1313
+ className="w-full bg-[#0c0c0e] border border-[#27272a] rounded-xl py-3 pl-4 pr-10 text-sm text-[#fafafa] hover:border-[#27272a] focus:border-[#3f3f46] outline-none transition cursor-pointer appearance-none font-medium"
1314
+ >
1315
+ {POPULAR_MODELS.map(m => (
1316
+ <option key={m.id} value={m.id}>
1317
+ {m.name}
1318
+ </option>
1319
+ ))}
1320
+ </select>
1321
+ <div className="absolute inset-y-0 right-4 flex items-center pointer-events-none text-[#71717a]">
1322
+ <ChevronDown size={16} />
1323
+ </div>
1324
+ </div>
1325
+ </div>
1326
+
1327
+ {/* TEXTAREA: PRIMERA INSTRUCCIÓN (OPCIONAL) */}
1328
+ <div className="space-y-1.5">
1329
+ <div className="flex justify-between items-center">
1330
+ <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Primera Instrucción (Opcional)</label>
1331
+ <span className="text-[10px] text-[#52525b]">Arranca de inmediato</span>
1332
+ </div>
1333
+ <textarea
1334
+ value={newSessionFirstPrompt}
1335
+ onChange={e => setNewSessionFirstPrompt(e.target.value)}
1336
+ placeholder="Ej. @sdd-spec-writer Crea un contrato para una calculadora..."
1337
+ className="w-full bg-[#0c0c0e] border border-[#27272a] focus:border-[#3f3f46] rounded-xl px-4 py-3 text-sm text-[#fafafa] placeholder-[#52525b] outline-none transition h-24 resize-none"
1338
+ />
1339
+ </div>
1340
+
1341
+ {/* BOTÓN DE CREAR */}
1342
+ <button
1343
+ type="submit"
1344
+ disabled={isProcessing}
1345
+ className="w-full bg-white hover:bg-[#e4e4e7] text-[#09090b] disabled:bg-[#18181b] disabled:text-[#71717a] font-bold py-3 px-4 rounded-xl transition duration-200 cursor-pointer text-sm shadow-md flex items-center justify-center gap-2"
1346
+ >
1347
+ {isProcessing ? (
1348
+ <RefreshCw size={14} className="animate-spin" />
1349
+ ) : (
1350
+ <Plus size={14} strokeWidth={2.5} />
1351
+ )}
1352
+ <span>{isProcessing ? 'Iniciando sesión...' : 'Crear e Iniciar Sesión'}</span>
1353
+ </button>
1354
+ </form>
1355
+ </div>
1356
+ </>
1357
+ ) : (
1358
+ <>
1359
+ {/* Header de la Sesión */}
1360
+ <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">
1361
+ <div className="flex flex-col">
1362
+ <h1 className="text-sm font-bold m-0 p-0 text-[#fafafa] flex items-center gap-2">
1363
+ {currentSession?.title || 'Cargando sesión...'}
1364
+ {isProcessing && (
1365
+ <span className="flex h-2 w-2 relative">
1366
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#22c55e] opacity-75"></span>
1367
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-[#22c55e]"></span>
1368
+ </span>
1369
+ )}
1370
+ </h1>
1371
+ {currentSession && (
1372
+ <div className="flex items-center gap-2 text-[11px] text-[#71717a] mt-0.5">
1373
+ <span>ID: <code className="text-[10px] font-mono bg-transparent p-0">{currentSession.id}</code></span>
1374
+ <span>•</span>
1375
+ <span>Costo: <span className="text-[#22c55e] font-mono">${(currentSession.cost || 0).toFixed(6)}</span></span>
1376
+ <span>•</span>
1377
+ <span>Tokens: In: {currentSession.tokens_input || 0} | Out: {currentSession.tokens_output || 0}</span>
1378
+ </div>
1379
+ )}
1380
+ </div>
1381
+
1382
+ <div className="flex items-center gap-2">
1383
+ {errorMsg && (
1384
+ <span className="text-xs text-red-400 bg-red-950/40 border border-red-900/50 py-1 px-3 rounded-md animate-pulse flex items-center gap-1">
1385
+ <AlertCircle size={12} />
1386
+ {errorMsg}
1387
+ </span>
1388
+ )}
1389
+ <button
1390
+ onClick={() => {
1391
+ if (currentSessionId) {
1392
+ fetchMessages(currentSessionId)
1393
+ fetchSessionDetails(currentSessionId)
1394
+ fetchSessionStatus()
1395
+ }
1396
+ }}
1397
+ className="p-1.5 bg-[#1c1c1f] border border-[#2e2e33] hover:bg-[#27272a] text-[#a1a1aa] hover:text-[#ffffff] rounded-md transition duration-200 cursor-pointer"
1398
+ title="Actualizar"
1399
+ >
1400
+ <RefreshCw size={14} className={isProcessing ? 'animate-spin' : ''} />
1401
+ </button>
1402
+ </div>
1403
+ </div>
1404
+
1405
+ {/* PANEL DE COMANDOS RÁPIDOS SUPERIOR */}
1406
+ {currentSessionId && (
1407
+ <div className="bg-[#0c0c0e]/60 border-b border-[#1f1f23] px-6 py-2 flex items-center gap-2 overflow-x-auto shrink-0 scrollbar-none">
1408
+ <span className="text-[10px] text-[#71717a] font-bold uppercase tracking-wider shrink-0 mr-1 flex items-center gap-1 select-none">
1409
+ <Code size={11} />
1410
+ <span>Comandos Rápidos:</span>
1411
+ </span>
1412
+ <button
1413
+ onClick={() => handleQuickCommand('status')}
1414
+ className="py-1 px-2.5 bg-[#121215] hover:bg-[#1c1c1f] border border-[#1f1f23] text-[11px] text-[#e4e4e7] rounded-md transition cursor-pointer shrink-0 font-medium font-mono"
1415
+ >
1416
+ /status
1417
+ </button>
1418
+ <button
1419
+ onClick={() => handleQuickCommand('undo')}
1420
+ className="py-1 px-2.5 bg-[#121215] hover:bg-[#1c1c1f] border border-[#1f1f23] text-[11px] text-[#e4e4e7] rounded-md transition cursor-pointer shrink-0 font-medium font-mono"
1421
+ title="Deshacer última edición de archivo"
1422
+ >
1423
+ /undo
1424
+ </button>
1425
+ <button
1426
+ onClick={() => handleQuickCommand('reset')}
1427
+ className="py-1 px-2.5 bg-[#121215] hover:bg-[#1c1c1f] border border-[#1f1f23] text-[11px] text-[#e4e4e7] rounded-md transition cursor-pointer shrink-0 font-medium font-mono"
1428
+ title="Reiniciar y limpiar sesión"
1429
+ >
1430
+ /reset
1431
+ </button>
1432
+ <button
1433
+ onClick={() => handleQuickCommand('loop', prompt('Introduce la tarea para el loop autónomo:', '') || '')}
1434
+ className="py-1 px-2.5 bg-[#ffffff] hover:bg-[#e4e4e7] text-[#09090b] text-[11px] font-bold rounded-md transition cursor-pointer shrink-0 flex items-center gap-1"
1435
+ >
1436
+ <Sparkles size={10} />
1437
+ <span>Iniciar Autopiloto (/loop)</span>
1438
+ </button>
1439
+ </div>
1440
+ )}
1441
+
1442
+ {/* HISTORIAL DE MENSAJES */}
1443
+ <div
1444
+ ref={chatContainerRef}
1445
+ onScroll={handleScroll}
1446
+ className="flex-1 overflow-y-auto p-6 space-y-6"
1447
+ >
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 ? (
1510
+ <div className="h-full flex flex-col items-center justify-center text-[#71717a] space-y-3">
1511
+ <MessageSquare size={36} className="text-[#27272a]" />
1512
+ <div className="text-center">
1513
+ <p className="text-sm font-medium">No hay mensajes en esta sesión</p>
1514
+ <p className="text-xs mt-0.5">Escribe una instrucción, comando o inicia un loop de desarrollo abajo.</p>
1515
+ </div>
1516
+ </div>
1517
+ ) : (
1518
+ messages.map((msg, idx) => {
1519
+ const isUser = msg.info.role === 'user'
1520
+ // Consolidar todo el texto de las partes del mensaje para copiar
1521
+ const fullTextMessage = msg.parts
1522
+ .filter(p => p.type === 'text')
1523
+ .map(p => p.text)
1524
+ .join('\n\n')
1525
+
1526
+ return (
1527
+ <div
1528
+ key={msg.info.id || idx}
1529
+ className={`flex gap-4 max-w-4xl mx-auto ${isUser ? 'justify-end' : 'justify-start'}`}
1530
+ >
1531
+ {!isUser && (
1532
+ <div className="h-8 w-8 rounded-full border border-[#2e2e33] bg-[#18181b] flex items-center justify-center text-[#fafafa] shrink-0">
1533
+ <Bot size={16} />
1534
+ </div>
1535
+ )}
1536
+
1537
+ <div
1538
+ className={`rounded-xl px-4 py-3 space-y-3 max-w-[85%] border shadow-sm group relative ${
1539
+ isUser
1540
+ ? 'bg-[#18181b] text-[#ffffff] border-[#2e2e33]'
1541
+ : 'bg-[#0c0c0e] text-[#f4f4f5] border-[#1f1f23]'
1542
+ }`}
1543
+ >
1544
+ {/* Encabezado del mensaje con fecha corregida */}
1545
+ <div className="flex justify-between items-start border-b border-[#1f1f23]/40 pb-1 text-[11px] text-[#71717a] font-medium gap-2">
1546
+ <div className="flex items-center gap-2 flex-wrap">
1547
+ <span>{isUser ? 'TÚ' : 'AGENTE'}</span>
1548
+ <span>•</span>
1549
+ <span className="flex items-center gap-1 font-mono">
1550
+ <Clock size={10} />
1551
+ {formatMessageTime(msg.info)}
1552
+ </span>
1553
+ </div>
1554
+
1555
+ {/* Botón de copiar resultado en la burbuja */}
1556
+ {fullTextMessage && (
1557
+ <button
1558
+ onClick={() => handleCopyText(fullTextMessage, msg.info.id || String(idx))}
1559
+ className="opacity-0 group-hover:opacity-100 p-1 hover:bg-[#1f1f23] text-[#a1a1aa] hover:text-white rounded transition cursor-pointer"
1560
+ title="Copiar texto"
1561
+ >
1562
+ {copiedMessageId === (msg.info.id || String(idx)) ? (
1563
+ <Check size={12} className="text-green-500" />
1564
+ ) : (
1565
+ <Copy size={12} />
1566
+ )}
1567
+ </button>
1568
+ )}
1569
+ </div>
1570
+
1571
+ {/* Partes del mensaje */}
1572
+ <div className="space-y-3">
1573
+ {msg.parts.map((part, pIdx) => {
1574
+ const partId = `${msg.info.id || idx}-${pIdx}`
1575
+ const isExpanded = expandedParts[partId]
1576
+
1577
+ // 1. TEXT PART
1578
+ if (part.type === 'text') {
1579
+ return (
1580
+ <div
1581
+ key={pIdx}
1582
+ className="text-sm leading-relaxed prose prose-invert max-w-none text-[#e4e4e7] markdown-content"
1583
+ dangerouslySetInnerHTML={renderMarkdown(part.text || '')}
1584
+ />
1585
+ )
1586
+ }
1587
+
1588
+ // 2. REASONING PART
1589
+ if (part.type === 'reasoning') {
1590
+ return (
1591
+ <div key={pIdx} className="border border-[#2e2e33]/50 rounded-lg overflow-hidden bg-[#121215]/30">
1592
+ <button
1593
+ onClick={() => togglePartExpansion(partId)}
1594
+ className="w-full flex items-center justify-between p-2 text-xs font-medium text-[#a1a1aa] hover:text-[#fafafa] hover:bg-[#1c1c1f]/30 transition duration-200 cursor-pointer"
1595
+ >
1596
+ <div className="flex items-center gap-1.5">
1597
+ <span className="h-1.5 w-1.5 rounded-full bg-[#a855f7] animate-pulse"></span>
1598
+ <span>Pensamiento del modelo</span>
1599
+ </div>
1600
+ {isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
1601
+ </button>
1602
+ {isExpanded && (
1603
+ <div className="p-3 border-t border-[#1f1f23] text-xs font-mono text-[#a1a1aa] bg-[#0c0c0e]/80 whitespace-pre-wrap leading-relaxed max-h-60 overflow-y-auto">
1604
+ {part.text}
1605
+ </div>
1606
+ )}
1607
+ </div>
1608
+ )
1609
+ }
1610
+
1611
+ // 3. TOOL CALL PART
1612
+ if (part.type === 'tool') {
1613
+ const status = part.state?.status || 'unknown'
1614
+ const toolName = part.tool || 'Desconocido'
1615
+ const hasOutput = !!part.state?.output
1616
+
1617
+ return (
1618
+ <div key={pIdx} className="border border-[#1f1f23] rounded-lg overflow-hidden bg-[#0a0a0c]">
1619
+ <button
1620
+ onClick={() => togglePartExpansion(partId)}
1621
+ className="w-full flex items-center justify-between p-2.5 text-xs font-mono text-[#e4e4e7] hover:bg-[#121215] transition duration-200 cursor-pointer"
1622
+ >
1623
+ <div className="flex items-center gap-2">
1624
+ <Settings size={13} className="text-amber-500 animate-spin" style={{ animationDuration: '3s' }} />
1625
+ <span className="font-semibold text-amber-400">Herramienta: {toolName}</span>
1626
+ <span className={`text-[10px] px-1.5 py-0.5 rounded border ${
1627
+ status === 'success' || status === 'completed'
1628
+ ? 'bg-green-950/20 text-green-400 border-green-900/50'
1629
+ : 'bg-yellow-950/20 text-yellow-500 border-yellow-900/50'
1630
+ }`}>
1631
+ {status}
1632
+ </span>
1633
+ </div>
1634
+ {isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
1635
+ </button>
1636
+
1637
+ {isExpanded && (
1638
+ <div className="p-3 border-t border-[#1f1f23] bg-[#09090b] text-xs font-mono space-y-3">
1639
+ <div>
1640
+ <div className="text-[10px] text-[#71717a] font-bold uppercase tracking-wider mb-1">Entrada / Parámetros:</div>
1641
+ <pre className="p-2 bg-[#0c0c0e] rounded border border-[#1f1f23] text-[#fafafa] overflow-x-auto max-h-40">
1642
+ {JSON.stringify(part.state?.input || {}, null, 2)}
1643
+ </pre>
1644
+ </div>
1645
+
1646
+ {hasOutput && (
1647
+ <div>
1648
+ <div className="text-[10px] text-[#71717a] font-bold uppercase tracking-wider mb-1">Salida / Resultado:</div>
1649
+ <pre className="p-2 bg-[#0c0c0e] rounded border border-[#1f1f23] text-[#fafafa] overflow-x-auto max-h-60 overflow-y-auto">
1650
+ {typeof part.state?.output === 'string'
1651
+ ? part.state.output
1652
+ : JSON.stringify(part.state?.output, null, 2)}
1653
+ </pre>
1654
+ </div>
1655
+ )}
1656
+ </div>
1657
+ )}
1658
+ </div>
1659
+ )
1660
+ }
1661
+
1662
+ return null
1663
+ })}
1664
+ </div>
1665
+ </div>
1666
+
1667
+ {isUser && (
1668
+ <div className="h-8 w-8 rounded-full border border-[#2e2e33] bg-[#ffffff] flex items-center justify-center text-[#09090b] shrink-0">
1669
+ <User size={16} />
1670
+ </div>
1671
+ )}
1672
+ </div>
1673
+ )
1674
+ })
1675
+ )}
1676
+ </div>
1677
+
1678
+ {/* CONSOLA DE ENTRADA, SELECCIÓN DE MODELO Y ENVÍO */}
1679
+ <div className="p-4 border-t border-[#1f1f23] bg-[#09090b] shrink-0">
1680
+ <form onSubmit={handleSendPrompt} className="max-w-4xl mx-auto space-y-3">
1681
+
1682
+ {/* Opciones Avanzadas del Prompt: Selector de Agente y Modelo */}
1683
+ <div className="flex items-center gap-4 px-1 flex-wrap">
1684
+ <div className="flex items-center gap-2">
1685
+ <span className="text-[10px] text-[#71717a] uppercase font-bold tracking-wider select-none">
1686
+ Agente Activo:
1687
+ </span>
1688
+ <div className="relative">
1689
+ <select
1690
+ value={selectedAgent}
1691
+ onChange={e => setSelectedAgent(e.target.value)}
1692
+ className="bg-[#121215] border border-[#1f1f23] text-[11px] text-[#e4e4e7] rounded-md py-1 pl-2 pr-6 outline-none hover:border-[#27272a] transition cursor-pointer appearance-none font-semibold"
1693
+ >
1694
+ {AVAILABLE_AGENTS.map(a => (
1695
+ <option key={a.id} value={a.id}>
1696
+ {a.name}
1697
+ </option>
1698
+ ))}
1699
+ </select>
1700
+ <div className="absolute inset-y-0 right-1.5 flex items-center pointer-events-none text-[#71717a]">
1701
+ <ChevronDown size={11} />
1702
+ </div>
1703
+ </div>
1704
+ </div>
1705
+
1706
+ <div className="flex items-center gap-2">
1707
+ <span className="text-[10px] text-[#71717a] uppercase font-bold tracking-wider select-none">
1708
+ Modelo Objetivo:
1709
+ </span>
1710
+ <div className="relative">
1711
+ <select
1712
+ value={selectedModel}
1713
+ onChange={e => setSelectedModel(e.target.value)}
1714
+ className="bg-[#121215] border border-[#1f1f23] text-[11px] text-[#e4e4e7] rounded-md py-1 pl-2 pr-6 outline-none hover:border-[#27272a] transition cursor-pointer appearance-none font-semibold"
1715
+ >
1716
+ {POPULAR_MODELS.map(m => (
1717
+ <option key={m.id} value={m.id}>
1718
+ {m.name}
1719
+ </option>
1720
+ ))}
1721
+ </select>
1722
+ <div className="absolute inset-y-0 right-1.5 flex items-center pointer-events-none text-[#71717a]">
1723
+ <ChevronDown size={11} />
1724
+ </div>
1725
+ </div>
1726
+ </div>
1727
+ </div>
1728
+
1729
+ {/* Input Textarea principal */}
1730
+ <div className="relative border border-[#27272a] focus-within:border-[#3f3f46] rounded-xl bg-[#0c0c0e] overflow-hidden shadow-lg transition duration-200">
1731
+ <textarea
1732
+ value={inputValue}
1733
+ onChange={e => setInputValue(e.target.value)}
1734
+ disabled={!currentSessionId}
1735
+ onKeyDown={e => {
1736
+ if (e.key === 'Enter' && !e.shiftKey) {
1737
+ e.preventDefault()
1738
+ handleSendPrompt(e)
1739
+ }
1740
+ }}
1741
+ placeholder={
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('/')
1745
+ ? "Escribe los argumentos para el comando... (ej. /loop Crear login)"
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)`
1747
+ }
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"
1749
+ />
1750
+
1751
+ <div className="absolute right-3 bottom-3 flex items-center gap-2">
1752
+ {isProcessing ? (
1753
+ <button
1754
+ type="button"
1755
+ onClick={handleAbort}
1756
+ className="flex items-center gap-1.5 px-3 py-1.5 bg-[#ef4444] hover:bg-[#dc2626] text-white rounded-lg text-xs font-semibold transition cursor-pointer shadow animate-pulse"
1757
+ title="Abortar ejecución"
1758
+ >
1759
+ <Square size={12} className="fill-current" />
1760
+ <span>Abortar</span>
1761
+ </button>
1762
+ ) : (
1763
+ <button
1764
+ type="submit"
1765
+ disabled={!inputValue.trim() || !currentSessionId}
1766
+ className="flex items-center gap-1.5 px-3 py-1.5 bg-[#ffffff] hover:bg-[#e4e4e7] disabled:bg-[#18181b] text-[#09090b] disabled:text-[#71717a] rounded-lg text-xs font-bold transition cursor-pointer shadow disabled:cursor-not-allowed"
1767
+ >
1768
+ <Play size={10} className="fill-current" />
1769
+ <span>Enviar</span>
1770
+ </button>
1771
+ )}
1772
+ </div>
1773
+ </div>
1774
+
1775
+ <div className="flex justify-between items-center text-[11px] text-[#71717a] px-1">
1776
+ <div className="flex items-center gap-3">
1777
+ <span className="flex items-center gap-1">
1778
+ <span className={`h-1.5 w-1.5 rounded-full ${isProcessing ? 'bg-amber-500 animate-pulse' : 'bg-green-500'}`}></span>
1779
+ <span>{isProcessing ? 'Procesando comando/loop' : 'Listo'}</span>
1780
+ </span>
1781
+ <span>•</span>
1782
+ <span>Enlazado al puerto :{currentPort} de Opencode</span>
1783
+ </div>
1784
+ <span className="font-mono">v1.2.0-web</span>
1785
+ </div>
1786
+ </form>
1787
+ </div>
1788
+ </>
1789
+ )}
1790
+ </div>
1791
+
1792
+ </div>
1793
+ )
1794
+ }