zugzbot 1.0.7 → 1.0.8

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,1357 @@
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?: any | 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?: any
50
+ output?: any
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
+ // Lista estática curada de los mejores modelos de Opencode
75
+ const POPULAR_MODELS = [
76
+ { id: 'default', name: '🤖 Modelo por defecto' },
77
+ { id: 'deepseek/deepseek-chat', name: '⚡️ DeepSeek v4 (Chat rápido)' },
78
+ { id: 'deepseek/deepseek-reasoner', name: '💭 DeepSeek R1 (Pensamiento/Razonamiento)' },
79
+ { id: 'google/gemini-3.5-flash', name: '♊️ Gemini 3.5 Flash' },
80
+ { id: 'google/gemini-3.1-pro-preview', name: '🧠 Gemini 3.1 Pro (Complejo)' },
81
+ { id: 'opencode/deepseek-v4-flash-free', name: '🆓 DeepSeek v4 (Flash Gratis)' },
82
+ { id: 'opencode/nemotron-3-ultra-free', name: '🆓 Nemotron 3 Ultra (Gratis)' }
83
+ ]
84
+
85
+ const AVAILABLE_AGENTS = [
86
+ {
87
+ id: 'build',
88
+ name: '🛠️ Build',
89
+ description: 'Agente de desarrollo estándar. Tiene todas las herramientas de lectura, escritura y bash habilitadas para programar.',
90
+ color: '#3b82f6'
91
+ },
92
+ {
93
+ id: 'plan',
94
+ name: '📋 Plan',
95
+ description: 'Agente de solo lectura/planificación. Útil para analizar el código base y planificar cambios de forma segura sin editarlos directamente.',
96
+ color: '#a855f7'
97
+ },
98
+ {
99
+ id: 'sdd-orchestrator',
100
+ name: '🤖 Sdd-Orchestrator',
101
+ description: 'Coordinador principal del flujo SDD (Spec-Driven Development). Automatiza los subagentes spec-writer, coder y tester.',
102
+ color: '#10b981'
103
+ }
104
+ ]
105
+
106
+ export default function App() {
107
+ const [sessions, setSessions] = useState<Session[]>([])
108
+ const [currentSessionId, setCurrentSessionId] = useState<string>('')
109
+ const [currentSession, setCurrentSession] = useState<Session | null>(null)
110
+ const [messages, setMessages] = useState<Message[]>([])
111
+ const [inputValue, setInputValue] = useState('')
112
+ const [isProcessing, setIsProcessing] = useState(false)
113
+ const [notificationsEnabled, setNotificationsEnabled] = useState(false)
114
+ const [tunnelUrl, setTunnelUrl] = useState<string>('')
115
+ const [expandedParts, setExpandedParts] = useState<Record<string, boolean>>({})
116
+ const [refreshInterval, setRefreshInterval] = useState<number>(3000)
117
+ const [errorMsg, setErrorMsg] = useState<string>('')
118
+
119
+ // Soporte para multi-instancia / selección de proyectos
120
+ const [instances, setInstances] = useState<OpencodeInstance[]>([])
121
+ const [currentPort, setCurrentPort] = useState<number>(4096)
122
+ const [showInstanceDropdown, setShowInstanceDropdown] = useState(false)
123
+
124
+ // Opciones de prompt
125
+ const [selectedModel, setSelectedModel] = useState<string>('default')
126
+ const [copiedMessageId, setCopiedMessageId] = useState<string>('')
127
+
128
+ // Estados para la nueva vista /new y agente activo
129
+ const [isNewSessionView, setIsNewSessionView] = useState<boolean>(false)
130
+ const [newSessionTitle, setNewSessionTitle] = useState<string>('')
131
+ const [newSessionAgent, setNewSessionAgent] = useState<string>('sdd-orchestrator')
132
+ const [newSessionModel, setNewSessionModel] = useState<string>('default')
133
+ const [newSessionFirstPrompt, setNewSessionFirstPrompt] = useState<string>('')
134
+ const [selectedAgent, setSelectedAgent] = useState<string>('sdd-orchestrator')
135
+
136
+ const chatContainerRef = useRef<HTMLDivElement>(null)
137
+ const [userHasScrolledUp, setUserHasScrolledUp] = useState(false)
138
+
139
+ const navigateTo = (path: string) => {
140
+ window.history.pushState(null, '', path)
141
+ setIsNewSessionView(path === '/new')
142
+ if (path === '/new') {
143
+ // Pre-rellenar un título por defecto para la nueva sesión
144
+ const formattedDate = new Date().toLocaleString([], { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
145
+ setNewSessionTitle(`Sesión Web ${formattedDate}`)
146
+ }
147
+ }
148
+
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])
155
+
156
+ // 1. Cargar estado inicial y levantar listeners
157
+ useEffect(() => {
158
+ fetchTunnelStatus()
159
+ fetchInstances()
160
+ fetchSessions()
161
+
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
+ }
168
+
169
+ const handlePopState = () => {
170
+ setIsNewSessionView(window.location.pathname === '/new')
171
+ }
172
+ window.addEventListener('popstate', handlePopState)
173
+
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
+ }
181
+
182
+ return () => {
183
+ window.removeEventListener('popstate', handlePopState)
184
+ clearInterval(instanceInterval)
185
+ }
186
+ }, [])
187
+
188
+ // 2. Escuchar cambios de sesión activa o puerto del proxy y polling de mensajes
189
+ useEffect(() => {
190
+ if (!currentSessionId) return
191
+
192
+ fetchSessionDetails(currentSessionId)
193
+ fetchMessages(currentSessionId)
194
+ fetchSessionStatus()
195
+
196
+ // Establecer polling para actualizar mensajes y estado del agente en tiempo real
197
+ const interval = setInterval(() => {
198
+ fetchMessages(currentSessionId)
199
+ fetchSessionStatus()
200
+ fetchSessionDetails(currentSessionId)
201
+ }, refreshInterval)
202
+
203
+ return () => clearInterval(interval)
204
+ }, [currentSessionId, currentPort, refreshInterval])
205
+
206
+ // 3. Scroll automático inteligente al recibir nuevos mensajes
207
+ useEffect(() => {
208
+ if (!userHasScrolledUp && chatContainerRef.current) {
209
+ chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
210
+ }
211
+ }, [messages])
212
+
213
+ // Manejar el desplazamiento del scroll para desactivar/activar el Smart Scroll Anchoring
214
+ const handleScroll = () => {
215
+ if (chatContainerRef.current) {
216
+ const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current
217
+ // Si el usuario sube más de 120px desde el fondo, asumimos que subió manualmente
218
+ const isAtBottom = scrollHeight - scrollTop - clientHeight < 120
219
+ setUserHasScrolledUp(!isAtBottom)
220
+ }
221
+ }
222
+
223
+ // Parseador de Markdown seguro usando marked
224
+ const renderMarkdown = (text: string) => {
225
+ try {
226
+ return { __html: marked.parse(text, { gfm: true, breaks: true }) }
227
+ } catch (e) {
228
+ return { __html: text }
229
+ }
230
+ }
231
+
232
+ // Notificaciones Push del navegador
233
+ const toggleNotifications = async () => {
234
+ if (notificationsEnabled) {
235
+ setNotificationsEnabled(false)
236
+ return
237
+ }
238
+
239
+ const permission = await Notification.requestPermission()
240
+ if (permission === 'granted') {
241
+ setNotificationsEnabled(true)
242
+ showNotification('¡Notificaciones activadas!', 'Te avisaremos cuando tus flujos en Opencode terminen.')
243
+ } else {
244
+ alert('Permiso de notificación denegado por el navegador.')
245
+ }
246
+ }
247
+
248
+ const showNotification = (title: string, body: string) => {
249
+ if (Notification.permission === 'granted') {
250
+ new Notification(title, {
251
+ body,
252
+ icon: '/favicon.ico'
253
+ })
254
+ }
255
+ }
256
+
257
+ // Copiar al portapapeles
258
+ const handleCopyText = (text: string, msgId: string) => {
259
+ navigator.clipboard.writeText(text).then(() => {
260
+ setCopiedMessageId(msgId)
261
+ setTimeout(() => setCopiedMessageId(''), 2000)
262
+ })
263
+ }
264
+
265
+ // APIs del Servidor / Daemon Custom
266
+ const fetchTunnelStatus = async () => {
267
+ try {
268
+ const res = await fetch('/api-custom/tunnel-status')
269
+ if (res.ok) {
270
+ const data = await res.json()
271
+ setTunnelUrl(data.url || '')
272
+ }
273
+ } catch (e) {
274
+ console.error('Error obteniendo estado del túnel:', e)
275
+ }
276
+ }
277
+
278
+ const fetchInstances = async () => {
279
+ try {
280
+ const res = await fetch('/api-custom/instances')
281
+ if (res.ok) {
282
+ const data = await res.json()
283
+ setInstances(data.instances || [])
284
+ setCurrentPort(data.currentPort || 4096)
285
+ }
286
+ } catch (e) {
287
+ console.error('Error obteniendo instancias de Opencode:', e)
288
+ }
289
+ }
290
+
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 () => {
320
+ try {
321
+ const res = await fetch('/api/session')
322
+ if (res.ok) {
323
+ const data = await res.json()
324
+ const sessionList = Array.isArray(data) ? data : (data.sessions || [])
325
+ setSessions(sessionList)
326
+ if (sessionList.length > 0) {
327
+ setCurrentSessionId(sessionList[0].id)
328
+ } 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)
348
+ }
349
+ }
350
+ } catch (e) {
351
+ setErrorMsg('No se pudo conectar al Daemon local de Opencode')
352
+ }
353
+ }
354
+
355
+ const fetchSessionDetails = async (id: string) => {
356
+ try {
357
+ const res = await fetch(`/api/session/${id}`)
358
+ if (res.ok) {
359
+ const data = await res.json()
360
+ setCurrentSession(data)
361
+ }
362
+ } catch (e) {
363
+ console.error(e)
364
+ }
365
+ }
366
+
367
+ const fetchMessages = async (id: string) => {
368
+ try {
369
+ const res = await fetch(`/api/session/${id}/message`)
370
+ if (res.ok) {
371
+ const data = await res.json()
372
+ const msgList = Array.isArray(data) ? data : (data.messages || [])
373
+
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
+ setMessages(msgList)
384
+ }
385
+ } catch (e) {
386
+ console.error(e)
387
+ }
388
+ }
389
+
390
+ const fetchSessionStatus = async () => {
391
+ try {
392
+ const res = await fetch('/api/session/status')
393
+ if (res.ok) {
394
+ const data = await res.json()
395
+ if (currentSessionId && data[currentSessionId]) {
396
+ const status = data[currentSessionId]
397
+ 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)
403
+ }
404
+ }
405
+ }
406
+ } catch (e) {
407
+ console.error(e)
408
+ }
409
+ }
410
+
411
+ const handleStartNewSession = async (e?: React.FormEvent) => {
412
+ if (e) e.preventDefault()
413
+ if (!newSessionTitle.trim()) {
414
+ alert('Por favor introduce un título para la sesión')
415
+ return
416
+ }
417
+
418
+ try {
419
+ setIsProcessing(true)
420
+ // 1. Crear sesión en la API de Opencode
421
+ const res = await fetch('/api/session', {
422
+ method: 'POST',
423
+ headers: { 'Content-Type': 'application/json' },
424
+ body: JSON.stringify({ title: newSessionTitle.trim() })
425
+ })
426
+
427
+ if (!res.ok) throw new Error('Error al crear sesión')
428
+ const newSess = await res.json()
429
+
430
+ // 2. Agregar a la lista de sesiones local
431
+ setSessions(prev => [newSess, ...prev])
432
+ setCurrentSessionId(newSess.id)
433
+ setCurrentSession(newSess)
434
+ setMessages([])
435
+
436
+ // Establecer el agente y modelo seleccionados para esta sesión
437
+ setSelectedAgent(newSessionAgent)
438
+ setSelectedModel(newSessionModel)
439
+
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() }],
444
+ agent: newSessionAgent
445
+ }
446
+
447
+ if (newSessionModel && newSessionModel !== 'default') {
448
+ const [providerID, modelID] = newSessionModel.split('/')
449
+ bodyPayload.model = { providerID, modelID }
450
+ }
451
+
452
+ // Llamamos a prompt_async para enviar el prompt asíncronamente
453
+ await fetch(`/api/session/${newSess.id}/prompt_async`, {
454
+ method: 'POST',
455
+ headers: { 'Content-Type': 'application/json' },
456
+ body: JSON.stringify(bodyPayload)
457
+ })
458
+
459
+ // Reiniciar campos
460
+ setNewSessionFirstPrompt('')
461
+ }
462
+
463
+ // Volver a la vista principal / chat
464
+ navigateTo('/')
465
+ setRefreshInterval(1500)
466
+ } catch (e) {
467
+ alert('Error al iniciar la nueva sesión')
468
+ } finally {
469
+ setIsProcessing(false)
470
+ }
471
+ }
472
+
473
+ // Manejar el envío de prompts o la ejecución de comandos diagonal
474
+ const handleSendPrompt = async (e: React.FormEvent) => {
475
+ e.preventDefault()
476
+ if (!inputValue.trim() || !currentSessionId) return
477
+
478
+ const input = inputValue.trim()
479
+ setInputValue('')
480
+ setIsProcessing(true)
481
+ setUserHasScrolledUp(false) // Forzar autoscroll al enviar
482
+
483
+ // Agregar mensaje local temporal de usuario
484
+ const tempUserMessage: Message = {
485
+ info: {
486
+ id: `temp-${Date.now()}`,
487
+ session_id: currentSessionId,
488
+ role: 'user',
489
+ time: { created: Date.now() }
490
+ },
491
+ parts: [{ type: 'text', text: input }]
492
+ }
493
+ setMessages(prev => [...prev, tempUserMessage])
494
+
495
+ try {
496
+ if (input.startsWith('/')) {
497
+ // Ejecución de comandos de Opencode
498
+ const parts = input.split(' ')
499
+ const commandName = parts[0].substring(1) // Quitar '/'
500
+ const args = parts.slice(1).join(' ')
501
+
502
+ const res = await fetch(`/api/session/${currentSessionId}/command`, {
503
+ method: 'POST',
504
+ headers: { 'Content-Type': 'application/json' },
505
+ body: JSON.stringify({
506
+ command: commandName,
507
+ arguments: args,
508
+ agent: selectedAgent
509
+ })
510
+ })
511
+
512
+ if (!res.ok) throw new Error('Error al ejecutar comando')
513
+ } else {
514
+ // Envío de prompt normal asíncrono
515
+ const bodyPayload: any = {
516
+ parts: [{ type: 'text', text: input }],
517
+ agent: selectedAgent
518
+ }
519
+
520
+ // Agregar modelo personalizado si se ha seleccionado
521
+ if (selectedModel && selectedModel !== 'default') {
522
+ const [providerID, modelID] = selectedModel.split('/')
523
+ bodyPayload.model = { providerID, modelID }
524
+ }
525
+
526
+ const res = await fetch(`/api/session/${currentSessionId}/prompt_async`, {
527
+ method: 'POST',
528
+ headers: { 'Content-Type': 'application/json' },
529
+ body: JSON.stringify(bodyPayload)
530
+ })
531
+
532
+ if (!res.ok) throw new Error('Error al enviar prompt')
533
+ }
534
+
535
+ setRefreshInterval(1500)
536
+ } catch (e) {
537
+ setErrorMsg('Error al conectar. Comprueba el estado de Opencode.')
538
+ setIsProcessing(false)
539
+ }
540
+ }
541
+
542
+ // Activa comandos rápidos desde un botón de un solo clic
543
+ const handleQuickCommand = async (commandName: string, args: string = '') => {
544
+ if (!currentSessionId) return
545
+ setIsProcessing(true)
546
+ setUserHasScrolledUp(false) // Forzar autoscroll al enviar
547
+
548
+ const fullCommandText = `/${commandName} ${args}`.trim()
549
+
550
+ // Agregar mensaje temporal local
551
+ const tempUserMessage: Message = {
552
+ info: {
553
+ id: `temp-${Date.now()}`,
554
+ session_id: currentSessionId,
555
+ role: 'user',
556
+ time: { created: Date.now() }
557
+ },
558
+ parts: [{ type: 'text', text: fullCommandText }]
559
+ }
560
+ setMessages(prev => [...prev, tempUserMessage])
561
+
562
+ try {
563
+ const res = await fetch(`/api/session/${currentSessionId}/command`, {
564
+ method: 'POST',
565
+ headers: { 'Content-Type': 'application/json' },
566
+ body: JSON.stringify({
567
+ command: commandName,
568
+ arguments: args,
569
+ agent: selectedAgent
570
+ })
571
+ })
572
+
573
+ if (!res.ok) throw new Error('Error al ejecutar comando rápido')
574
+ setRefreshInterval(1500)
575
+ } catch (e) {
576
+ setErrorMsg('No se pudo ejecutar el comando rápido.')
577
+ setIsProcessing(false)
578
+ }
579
+ }
580
+
581
+ const handleAbort = async () => {
582
+ if (!currentSessionId) return
583
+ try {
584
+ const res = await fetch(`/api/session/${currentSessionId}/abort`, {
585
+ method: 'POST'
586
+ })
587
+ if (res.ok) {
588
+ setIsProcessing(false)
589
+ setRefreshInterval(3000)
590
+ showNotification('Opencode: Abortado', 'La sesión en ejecución ha sido cancelada forzadamente.')
591
+ }
592
+ } catch (e) {
593
+ console.error(e)
594
+ }
595
+ }
596
+
597
+ const togglePartExpansion = (id: string) => {
598
+ setExpandedParts(prev => ({ ...prev, [id]: !prev[id] }))
599
+ }
600
+
601
+ // Formateador de modelo para evitar colapsos
602
+ const formatModelName = (model: any): string => {
603
+ if (!model) return 'default'
604
+ if (typeof model === 'string') {
605
+ return model.split('/').pop() || model
606
+ }
607
+ if (typeof model === 'object') {
608
+ if (model.modelID) return model.modelID
609
+ if (model.id) return model.id
610
+ return JSON.stringify(model).substring(0, 20)
611
+ }
612
+ return 'default'
613
+ }
614
+
615
+ // Formateador robusto de fecha de mensaje
616
+ const formatMessageTime = (info: any): string => {
617
+ const rawTime = info?.time?.created || info?.time_created
618
+ if (!rawTime) return 'En curso...'
619
+ try {
620
+ const date = new Date(rawTime)
621
+ if (isNaN(date.getTime())) {
622
+ return 'En curso...'
623
+ }
624
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
625
+ } catch (e) {
626
+ return 'En curso...'
627
+ }
628
+ }
629
+
630
+ // Estructura Jerárquica del Árbol de Subagentes
631
+ // Separar las sesiones en principales (raíz) e hijas (subagentes)
632
+ const rootSessions = sessions.filter(s => !s.parent_id && !s.parentID)
633
+
634
+ const getSubagentsFor = (parentId: string) => {
635
+ return sessions.filter(s => s.parent_id === parentId || s.parentID === parentId)
636
+ }
637
+
638
+ // Obtener la instancia activa del Daemon
639
+ const activeInstance = instances.find(inst => inst.port === currentPort)
640
+
641
+ return (
642
+ <div className="flex h-screen bg-[#09090b] text-[#f4f4f5] overflow-hidden antialiased font-sans select-none">
643
+
644
+ {/* 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">
646
+
647
+ {/* Encabezado Principal */}
648
+ <div className="p-4 border-b border-[#1f1f23] flex items-center justify-between">
649
+ <div className="flex items-center gap-2">
650
+ <div className="p-1.5 bg-[#ffffff] rounded-md text-[#09090b]">
651
+ <Terminal size={18} className="font-bold animate-pulse" />
652
+ </div>
653
+ <span className="font-bold tracking-tight text-lg">ZugzWeb</span>
654
+ </div>
655
+ <button
656
+ onClick={() => navigateTo('/new')}
657
+ className="p-1.5 bg-[#27272a] hover:bg-[#3f3f46] text-[#fafafa] rounded-md transition duration-200 cursor-pointer"
658
+ title="Nueva Sesión"
659
+ >
660
+ <Plus size={16} />
661
+ </button>
662
+ </div>
663
+
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>
670
+ <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"
673
+ >
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" />
683
+ </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
+ </div>
719
+
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
731
+ </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 */}
741
+ <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
+ }`}
752
+ >
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>
761
+ </div>
762
+ </button>
763
+
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
769
+ 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>
792
+ )
793
+ })}
794
+ </div>
795
+ )}
796
+ </div>
797
+ )
798
+ })
799
+ )}
800
+ </div>
801
+
802
+ {/* Notificaciones y Estado del Túnel */}
803
+ <div className="p-4 bg-[#09090b] border-t border-[#1f1f23] space-y-3">
804
+ <button
805
+ onClick={toggleNotifications}
806
+ className={`w-full flex items-center justify-center gap-2 py-2 px-3 rounded-md text-xs font-medium border transition cursor-pointer ${
807
+ notificationsEnabled
808
+ ? 'bg-transparent border-[#22c55e]/30 hover:border-[#22c55e]/50 text-[#22c55e]'
809
+ : 'bg-transparent border-[#27272a] hover:border-[#3f3f46] text-[#a1a1aa]'
810
+ }`}
811
+ >
812
+ {notificationsEnabled ? (
813
+ <>
814
+ <Bell size={14} />
815
+ <span>Notificaciones Activas</span>
816
+ </>
817
+ ) : (
818
+ <>
819
+ <BellOff size={14} />
820
+ <span>Activar Alertas Navegador</span>
821
+ </>
822
+ )}
823
+ </button>
824
+
825
+ {tunnelUrl && (
826
+ <div className="p-2 bg-[#1c1c1f]/50 border border-[#2e2e33] rounded-lg space-y-1">
827
+ <div className="flex items-center gap-1.5 text-[11px] text-[#a1a1aa] font-medium">
828
+ <Globe size={11} className="text-[#3b82f6] animate-pulse" />
829
+ <span>Túnel Remoto Activo</span>
830
+ </div>
831
+ <a
832
+ href={tunnelUrl}
833
+ target="_blank"
834
+ rel="noopener noreferrer"
835
+ className="text-[10px] text-[#3b82f6] hover:underline flex items-center gap-1 font-mono break-all"
836
+ >
837
+ {tunnelUrl}
838
+ <ExternalLink size={8} />
839
+ </a>
840
+ </div>
841
+ )}
842
+ </div>
843
+ </div>
844
+
845
+ {/* CHAT / MAIN AREA */}
846
+ <div className="flex-1 flex flex-col bg-[#09090b] relative overflow-hidden">
847
+ {isNewSessionView ? (
848
+ <>
849
+ {/* Header de Nueva Sesión */}
850
+ <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">
851
+ <div className="flex items-center gap-2">
852
+ <Sparkles size={16} className="text-emerald-400 animate-pulse" />
853
+ <h1 className="text-sm font-bold text-[#fafafa]">Crear Nueva Sesión de Desarrollo</h1>
854
+ </div>
855
+ {sessions.length > 0 && (
856
+ <button
857
+ onClick={() => navigateTo('/')}
858
+ className="text-xs text-[#71717a] hover:text-white border border-[#1f1f23] hover:border-[#27272a] px-2.5 py-1 rounded-md transition cursor-pointer"
859
+ >
860
+ Cancelar
861
+ </button>
862
+ )}
863
+ </div>
864
+
865
+ {/* Formulario de Nueva Sesión */}
866
+ <div className="flex-1 overflow-y-auto p-8 max-w-2xl mx-auto w-full space-y-6">
867
+ <div className="space-y-1.5">
868
+ <h2 className="text-xl font-bold tracking-tight text-white flex items-center gap-2">
869
+ <span>🚀 Comenzar un nuevo flujo</span>
870
+ </h2>
871
+ <p className="text-xs text-[#71717a]">
872
+ Elige el agente y modelo adecuados para estructurar tu especificación, codificar o auditar tu aplicación de forma guiada y asíncrona.
873
+ </p>
874
+ </div>
875
+
876
+ <form onSubmit={handleStartNewSession} className="space-y-5">
877
+ {/* INPUT: TÍTULO DE LA SESIÓN */}
878
+ <div className="space-y-1.5">
879
+ <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Título de la Sesión</label>
880
+ <input
881
+ type="text"
882
+ value={newSessionTitle}
883
+ onChange={e => setNewSessionTitle(e.target.value)}
884
+ placeholder="Ej. Crear Login Form, Refactorizar Auth..."
885
+ 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"
886
+ required
887
+ />
888
+ </div>
889
+
890
+ {/* SELECCIÓN DE AGENTES: CARDS INTERACTIVAS */}
891
+ <div className="space-y-1.5">
892
+ <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Agente Primario Inicial</label>
893
+ <div className="grid grid-cols-1 gap-2.5">
894
+ {AVAILABLE_AGENTS.map(agent => {
895
+ const isSelected = newSessionAgent === agent.id
896
+ return (
897
+ <button
898
+ key={agent.id}
899
+ type="button"
900
+ onClick={() => setNewSessionAgent(agent.id)}
901
+ className={`w-full text-left p-3.5 rounded-xl border transition-all duration-200 flex gap-3 cursor-pointer relative ${
902
+ isSelected
903
+ ? 'bg-[#121215] border-white/60 shadow-[0_0_12px_rgba(255,255,255,0.05)]'
904
+ : 'bg-[#0c0c0e] border-[#1f1f23] hover:border-[#27272a]'
905
+ }`}
906
+ >
907
+ <div
908
+ className="h-8 w-8 rounded-lg flex items-center justify-center shrink-0"
909
+ style={{ backgroundColor: `${agent.color}15`, color: agent.color }}
910
+ >
911
+ {agent.id === 'build' ? <Settings size={16} /> : agent.id === 'plan' ? <Code size={16} /> : <Sparkles size={16} />}
912
+ </div>
913
+ <div className="space-y-0.5">
914
+ <div className="flex items-center gap-2">
915
+ <span className="text-xs font-bold text-white">{agent.name}</span>
916
+ {agent.id === 'sdd-orchestrator' && (
917
+ <span className="text-[9px] bg-white/10 text-white/80 font-mono px-1 rounded uppercase tracking-wider">Por defecto</span>
918
+ )}
919
+ </div>
920
+ <p className="text-[11px] text-[#71717a] leading-relaxed">{agent.description}</p>
921
+ </div>
922
+ {isSelected && (
923
+ <div className="absolute right-4 top-4 h-4 w-4 bg-white rounded-full flex items-center justify-center text-[#09090b]">
924
+ <Check size={10} strokeWidth={3} />
925
+ </div>
926
+ )}
927
+ </button>
928
+ )
929
+ })}
930
+ </div>
931
+ </div>
932
+
933
+ {/* SELECTOR DE MODELO */}
934
+ <div className="space-y-1.5">
935
+ <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Modelo Objetivo</label>
936
+ <div className="relative">
937
+ <select
938
+ value={newSessionModel}
939
+ onChange={e => setNewSessionModel(e.target.value)}
940
+ 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"
941
+ >
942
+ {POPULAR_MODELS.map(m => (
943
+ <option key={m.id} value={m.id}>
944
+ {m.name}
945
+ </option>
946
+ ))}
947
+ </select>
948
+ <div className="absolute inset-y-0 right-4 flex items-center pointer-events-none text-[#71717a]">
949
+ <ChevronDown size={16} />
950
+ </div>
951
+ </div>
952
+ </div>
953
+
954
+ {/* TEXTAREA: PRIMERA INSTRUCCIÓN (OPCIONAL) */}
955
+ <div className="space-y-1.5">
956
+ <div className="flex justify-between items-center">
957
+ <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Primera Instrucción (Opcional)</label>
958
+ <span className="text-[10px] text-[#52525b]">Arranca de inmediato</span>
959
+ </div>
960
+ <textarea
961
+ value={newSessionFirstPrompt}
962
+ onChange={e => setNewSessionFirstPrompt(e.target.value)}
963
+ placeholder="Ej. @sdd-spec-writer Crea un contrato para una calculadora..."
964
+ 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"
965
+ />
966
+ </div>
967
+
968
+ {/* BOTÓN DE CREAR */}
969
+ <button
970
+ type="submit"
971
+ disabled={isProcessing}
972
+ 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"
973
+ >
974
+ {isProcessing ? (
975
+ <RefreshCw size={14} className="animate-spin" />
976
+ ) : (
977
+ <Plus size={14} strokeWidth={2.5} />
978
+ )}
979
+ <span>{isProcessing ? 'Iniciando sesión...' : 'Crear e Iniciar Sesión'}</span>
980
+ </button>
981
+ </form>
982
+ </div>
983
+ </>
984
+ ) : (
985
+ <>
986
+ {/* Header de la Sesión */}
987
+ <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">
988
+ <div className="flex flex-col">
989
+ <h1 className="text-sm font-bold m-0 p-0 text-[#fafafa] flex items-center gap-2">
990
+ {currentSession?.title || 'Cargando sesión...'}
991
+ {isProcessing && (
992
+ <span className="flex h-2 w-2 relative">
993
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#22c55e] opacity-75"></span>
994
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-[#22c55e]"></span>
995
+ </span>
996
+ )}
997
+ </h1>
998
+ {currentSession && (
999
+ <div className="flex items-center gap-2 text-[11px] text-[#71717a] mt-0.5">
1000
+ <span>ID: <code className="text-[10px] font-mono bg-transparent p-0">{currentSession.id}</code></span>
1001
+ <span>•</span>
1002
+ <span>Costo: <span className="text-[#22c55e] font-mono">${(currentSession.cost || 0).toFixed(6)}</span></span>
1003
+ <span>•</span>
1004
+ <span>Tokens: In: {currentSession.tokens_input || 0} | Out: {currentSession.tokens_output || 0}</span>
1005
+ </div>
1006
+ )}
1007
+ </div>
1008
+
1009
+ <div className="flex items-center gap-2">
1010
+ {errorMsg && (
1011
+ <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">
1012
+ <AlertCircle size={12} />
1013
+ {errorMsg}
1014
+ </span>
1015
+ )}
1016
+ <button
1017
+ onClick={() => {
1018
+ if (currentSessionId) {
1019
+ fetchMessages(currentSessionId)
1020
+ fetchSessionDetails(currentSessionId)
1021
+ fetchSessionStatus()
1022
+ }
1023
+ }}
1024
+ className="p-1.5 bg-[#1c1c1f] border border-[#2e2e33] hover:bg-[#27272a] text-[#a1a1aa] hover:text-[#ffffff] rounded-md transition duration-200 cursor-pointer"
1025
+ title="Actualizar"
1026
+ >
1027
+ <RefreshCw size={14} className={isProcessing ? 'animate-spin' : ''} />
1028
+ </button>
1029
+ </div>
1030
+ </div>
1031
+
1032
+ {/* PANEL DE COMANDOS RÁPIDOS SUPERIOR */}
1033
+ {currentSessionId && (
1034
+ <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">
1035
+ <span className="text-[10px] text-[#71717a] font-bold uppercase tracking-wider shrink-0 mr-1 flex items-center gap-1 select-none">
1036
+ <Code size={11} />
1037
+ <span>Comandos Rápidos:</span>
1038
+ </span>
1039
+ <button
1040
+ onClick={() => handleQuickCommand('status')}
1041
+ 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"
1042
+ >
1043
+ /status
1044
+ </button>
1045
+ <button
1046
+ onClick={() => handleQuickCommand('undo')}
1047
+ 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"
1048
+ title="Deshacer última edición de archivo"
1049
+ >
1050
+ /undo
1051
+ </button>
1052
+ <button
1053
+ onClick={() => handleQuickCommand('reset')}
1054
+ 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"
1055
+ title="Reiniciar y limpiar sesión"
1056
+ >
1057
+ /reset
1058
+ </button>
1059
+ <button
1060
+ onClick={() => handleQuickCommand('loop', prompt('Introduce la tarea para el loop autónomo:', '') || '')}
1061
+ 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"
1062
+ >
1063
+ <Sparkles size={10} />
1064
+ <span>Iniciar Autopiloto (/loop)</span>
1065
+ </button>
1066
+ </div>
1067
+ )}
1068
+
1069
+ {/* HISTORIAL DE MENSAJES */}
1070
+ <div
1071
+ ref={chatContainerRef}
1072
+ onScroll={handleScroll}
1073
+ className="flex-1 overflow-y-auto p-6 space-y-6"
1074
+ >
1075
+ {messages.length === 0 ? (
1076
+ <div className="h-full flex flex-col items-center justify-center text-[#71717a] space-y-3">
1077
+ <MessageSquare size={36} className="text-[#27272a]" />
1078
+ <div className="text-center">
1079
+ <p className="text-sm font-medium">No hay mensajes en esta sesión</p>
1080
+ <p className="text-xs mt-0.5">Escribe una instrucción, comando o inicia un loop de desarrollo abajo.</p>
1081
+ </div>
1082
+ </div>
1083
+ ) : (
1084
+ messages.map((msg, idx) => {
1085
+ const isUser = msg.info.role === 'user'
1086
+ // Consolidar todo el texto de las partes del mensaje para copiar
1087
+ const fullTextMessage = msg.parts
1088
+ .filter(p => p.type === 'text')
1089
+ .map(p => p.text)
1090
+ .join('\n\n')
1091
+
1092
+ return (
1093
+ <div
1094
+ key={msg.info.id || idx}
1095
+ className={`flex gap-4 max-w-4xl mx-auto ${isUser ? 'justify-end' : 'justify-start'}`}
1096
+ >
1097
+ {!isUser && (
1098
+ <div className="h-8 w-8 rounded-full border border-[#2e2e33] bg-[#18181b] flex items-center justify-center text-[#fafafa] shrink-0">
1099
+ <Bot size={16} />
1100
+ </div>
1101
+ )}
1102
+
1103
+ <div
1104
+ className={`rounded-xl px-4 py-3 space-y-3 max-w-[85%] border shadow-sm group relative ${
1105
+ isUser
1106
+ ? 'bg-[#18181b] text-[#ffffff] border-[#2e2e33]'
1107
+ : 'bg-[#0c0c0e] text-[#f4f4f5] border-[#1f1f23]'
1108
+ }`}
1109
+ >
1110
+ {/* Encabezado del mensaje con fecha corregida */}
1111
+ <div className="flex justify-between items-start border-b border-[#1f1f23]/40 pb-1 text-[11px] text-[#71717a] font-medium gap-2">
1112
+ <div className="flex items-center gap-2 flex-wrap">
1113
+ <span>{isUser ? 'TÚ' : 'AGENTE'}</span>
1114
+ <span>•</span>
1115
+ <span className="flex items-center gap-1 font-mono">
1116
+ <Clock size={10} />
1117
+ {formatMessageTime(msg.info)}
1118
+ </span>
1119
+ </div>
1120
+
1121
+ {/* Botón de copiar resultado en la burbuja */}
1122
+ {fullTextMessage && (
1123
+ <button
1124
+ onClick={() => handleCopyText(fullTextMessage, msg.info.id || String(idx))}
1125
+ className="opacity-0 group-hover:opacity-100 p-1 hover:bg-[#1f1f23] text-[#a1a1aa] hover:text-white rounded transition cursor-pointer"
1126
+ title="Copiar texto"
1127
+ >
1128
+ {copiedMessageId === (msg.info.id || String(idx)) ? (
1129
+ <Check size={12} className="text-green-500" />
1130
+ ) : (
1131
+ <Copy size={12} />
1132
+ )}
1133
+ </button>
1134
+ )}
1135
+ </div>
1136
+
1137
+ {/* Partes del mensaje */}
1138
+ <div className="space-y-3">
1139
+ {msg.parts.map((part, pIdx) => {
1140
+ const partId = `${msg.info.id || idx}-${pIdx}`
1141
+ const isExpanded = expandedParts[partId]
1142
+
1143
+ // 1. TEXT PART
1144
+ if (part.type === 'text') {
1145
+ return (
1146
+ <div
1147
+ key={pIdx}
1148
+ className="text-sm leading-relaxed prose prose-invert max-w-none text-[#e4e4e7] markdown-content"
1149
+ dangerouslySetInnerHTML={renderMarkdown(part.text || '')}
1150
+ />
1151
+ )
1152
+ }
1153
+
1154
+ // 2. REASONING PART
1155
+ if (part.type === 'reasoning') {
1156
+ return (
1157
+ <div key={pIdx} className="border border-[#2e2e33]/50 rounded-lg overflow-hidden bg-[#121215]/30">
1158
+ <button
1159
+ onClick={() => togglePartExpansion(partId)}
1160
+ 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"
1161
+ >
1162
+ <div className="flex items-center gap-1.5">
1163
+ <span className="h-1.5 w-1.5 rounded-full bg-[#a855f7] animate-pulse"></span>
1164
+ <span>Pensamiento del modelo</span>
1165
+ </div>
1166
+ {isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
1167
+ </button>
1168
+ {isExpanded && (
1169
+ <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">
1170
+ {part.text}
1171
+ </div>
1172
+ )}
1173
+ </div>
1174
+ )
1175
+ }
1176
+
1177
+ // 3. TOOL CALL PART
1178
+ if (part.type === 'tool') {
1179
+ const status = part.state?.status || 'unknown'
1180
+ const toolName = part.tool || 'Desconocido'
1181
+ const hasOutput = !!part.state?.output
1182
+
1183
+ return (
1184
+ <div key={pIdx} className="border border-[#1f1f23] rounded-lg overflow-hidden bg-[#0a0a0c]">
1185
+ <button
1186
+ onClick={() => togglePartExpansion(partId)}
1187
+ 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"
1188
+ >
1189
+ <div className="flex items-center gap-2">
1190
+ <Settings size={13} className="text-amber-500 animate-spin" style={{ animationDuration: '3s' }} />
1191
+ <span className="font-semibold text-amber-400">Herramienta: {toolName}</span>
1192
+ <span className={`text-[10px] px-1.5 py-0.5 rounded border ${
1193
+ status === 'success' || status === 'completed'
1194
+ ? 'bg-green-950/20 text-green-400 border-green-900/50'
1195
+ : 'bg-yellow-950/20 text-yellow-500 border-yellow-900/50'
1196
+ }`}>
1197
+ {status}
1198
+ </span>
1199
+ </div>
1200
+ {isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
1201
+ </button>
1202
+
1203
+ {isExpanded && (
1204
+ <div className="p-3 border-t border-[#1f1f23] bg-[#09090b] text-xs font-mono space-y-3">
1205
+ <div>
1206
+ <div className="text-[10px] text-[#71717a] font-bold uppercase tracking-wider mb-1">Entrada / Parámetros:</div>
1207
+ <pre className="p-2 bg-[#0c0c0e] rounded border border-[#1f1f23] text-[#fafafa] overflow-x-auto max-h-40">
1208
+ {JSON.stringify(part.state?.input || {}, null, 2)}
1209
+ </pre>
1210
+ </div>
1211
+
1212
+ {hasOutput && (
1213
+ <div>
1214
+ <div className="text-[10px] text-[#71717a] font-bold uppercase tracking-wider mb-1">Salida / Resultado:</div>
1215
+ <pre className="p-2 bg-[#0c0c0e] rounded border border-[#1f1f23] text-[#fafafa] overflow-x-auto max-h-60 overflow-y-auto">
1216
+ {typeof part.state?.output === 'string'
1217
+ ? part.state.output
1218
+ : JSON.stringify(part.state?.output, null, 2)}
1219
+ </pre>
1220
+ </div>
1221
+ )}
1222
+ </div>
1223
+ )}
1224
+ </div>
1225
+ )
1226
+ }
1227
+
1228
+ return null
1229
+ })}
1230
+ </div>
1231
+ </div>
1232
+
1233
+ {isUser && (
1234
+ <div className="h-8 w-8 rounded-full border border-[#2e2e33] bg-[#ffffff] flex items-center justify-center text-[#09090b] shrink-0">
1235
+ <User size={16} />
1236
+ </div>
1237
+ )}
1238
+ </div>
1239
+ )
1240
+ })
1241
+ )}
1242
+ </div>
1243
+
1244
+ {/* CONSOLA DE ENTRADA, SELECCIÓN DE MODELO Y ENVÍO */}
1245
+ <div className="p-4 border-t border-[#1f1f23] bg-[#09090b] shrink-0">
1246
+ <form onSubmit={handleSendPrompt} className="max-w-4xl mx-auto space-y-3">
1247
+
1248
+ {/* Opciones Avanzadas del Prompt: Selector de Agente y Modelo */}
1249
+ <div className="flex items-center gap-4 px-1 flex-wrap">
1250
+ <div className="flex items-center gap-2">
1251
+ <span className="text-[10px] text-[#71717a] uppercase font-bold tracking-wider select-none">
1252
+ Agente Activo:
1253
+ </span>
1254
+ <div className="relative">
1255
+ <select
1256
+ value={selectedAgent}
1257
+ onChange={e => setSelectedAgent(e.target.value)}
1258
+ 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"
1259
+ >
1260
+ {AVAILABLE_AGENTS.map(a => (
1261
+ <option key={a.id} value={a.id}>
1262
+ {a.name}
1263
+ </option>
1264
+ ))}
1265
+ </select>
1266
+ <div className="absolute inset-y-0 right-1.5 flex items-center pointer-events-none text-[#71717a]">
1267
+ <ChevronDown size={11} />
1268
+ </div>
1269
+ </div>
1270
+ </div>
1271
+
1272
+ <div className="flex items-center gap-2">
1273
+ <span className="text-[10px] text-[#71717a] uppercase font-bold tracking-wider select-none">
1274
+ Modelo Objetivo:
1275
+ </span>
1276
+ <div className="relative">
1277
+ <select
1278
+ value={selectedModel}
1279
+ onChange={e => setSelectedModel(e.target.value)}
1280
+ 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"
1281
+ >
1282
+ {POPULAR_MODELS.map(m => (
1283
+ <option key={m.id} value={m.id}>
1284
+ {m.name}
1285
+ </option>
1286
+ ))}
1287
+ </select>
1288
+ <div className="absolute inset-y-0 right-1.5 flex items-center pointer-events-none text-[#71717a]">
1289
+ <ChevronDown size={11} />
1290
+ </div>
1291
+ </div>
1292
+ </div>
1293
+ </div>
1294
+
1295
+ {/* Input Textarea principal */}
1296
+ <div className="relative border border-[#27272a] focus-within:border-[#3f3f46] rounded-xl bg-[#0c0c0e] overflow-hidden shadow-lg transition duration-200">
1297
+ <textarea
1298
+ value={inputValue}
1299
+ onChange={e => setInputValue(e.target.value)}
1300
+ onKeyDown={e => {
1301
+ if (e.key === 'Enter' && !e.shiftKey) {
1302
+ e.preventDefault()
1303
+ handleSendPrompt(e)
1304
+ }
1305
+ }}
1306
+ placeholder={
1307
+ inputValue.startsWith('/')
1308
+ ? "Escribe los argumentos para el comando... (ej. /loop Crear login)"
1309
+ : `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
+ }
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"
1312
+ />
1313
+
1314
+ <div className="absolute right-3 bottom-3 flex items-center gap-2">
1315
+ {isProcessing ? (
1316
+ <button
1317
+ type="button"
1318
+ onClick={handleAbort}
1319
+ 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"
1320
+ title="Abortar ejecución"
1321
+ >
1322
+ <Square size={12} className="fill-current" />
1323
+ <span>Abortar</span>
1324
+ </button>
1325
+ ) : (
1326
+ <button
1327
+ type="submit"
1328
+ disabled={!inputValue.trim() || !currentSessionId}
1329
+ 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"
1330
+ >
1331
+ <Play size={10} className="fill-current" />
1332
+ <span>Enviar</span>
1333
+ </button>
1334
+ )}
1335
+ </div>
1336
+ </div>
1337
+
1338
+ <div className="flex justify-between items-center text-[11px] text-[#71717a] px-1">
1339
+ <div className="flex items-center gap-3">
1340
+ <span className="flex items-center gap-1">
1341
+ <span className={`h-1.5 w-1.5 rounded-full ${isProcessing ? 'bg-amber-500 animate-pulse' : 'bg-green-500'}`}></span>
1342
+ <span>{isProcessing ? 'Procesando comando/loop' : 'Listo'}</span>
1343
+ </span>
1344
+ <span>•</span>
1345
+ <span>Enlazado al puerto :{currentPort} de Opencode</span>
1346
+ </div>
1347
+ <span className="font-mono">v1.2.0-web</span>
1348
+ </div>
1349
+ </form>
1350
+ </div>
1351
+ </>
1352
+ )}
1353
+ </div>
1354
+
1355
+ </div>
1356
+ )
1357
+ }