zugzbot 1.0.16 → 1.0.18

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.
Files changed (32) hide show
  1. package/.utils/zugzweb/client/node_modules/.tmp/tsconfig.app.tsbuildinfo +1 -0
  2. package/.utils/zugzweb/client/node_modules/.tmp/tsconfig.node.tsbuildinfo +1 -0
  3. package/.utils/zugzweb/client/node_modules/nanoid/.claude/settings.local.json +14 -0
  4. package/bin/init.js +1 -1
  5. package/opencode.json +2 -1
  6. package/package.json +3 -48
  7. package/.opencode/commands/web.md +0 -15
  8. package/.utils/zugzweb/client/README.md +0 -73
  9. package/.utils/zugzweb/client/dist/assets/index-GxkFc3Mh.css +0 -1
  10. package/.utils/zugzweb/client/dist/assets/index-YZKuRh4S.js +0 -291
  11. package/.utils/zugzweb/client/dist/favicon.svg +0 -1
  12. package/.utils/zugzweb/client/dist/icons.svg +0 -24
  13. package/.utils/zugzweb/client/dist/index.html +0 -14
  14. package/.utils/zugzweb/client/eslint.config.js +0 -22
  15. package/.utils/zugzweb/client/index.html +0 -13
  16. package/.utils/zugzweb/client/package.json +0 -36
  17. package/.utils/zugzweb/client/public/favicon.svg +0 -1
  18. package/.utils/zugzweb/client/public/icons.svg +0 -24
  19. package/.utils/zugzweb/client/src/App.tsx +0 -2560
  20. package/.utils/zugzweb/client/src/assets/hero.png +0 -0
  21. package/.utils/zugzweb/client/src/assets/react.svg +0 -1
  22. package/.utils/zugzweb/client/src/assets/vite.svg +0 -1
  23. package/.utils/zugzweb/client/src/index.css +0 -185
  24. package/.utils/zugzweb/client/src/main.tsx +0 -10
  25. package/.utils/zugzweb/client/tsconfig.app.json +0 -25
  26. package/.utils/zugzweb/client/tsconfig.json +0 -7
  27. package/.utils/zugzweb/client/tsconfig.node.json +0 -24
  28. package/.utils/zugzweb/client/vite.config.ts +0 -11
  29. package/.utils/zugzweb/daemon.js +0 -396
  30. package/.utils/zugzweb/daemon.log +0 -5
  31. package/.utils/zugzweb/package.json +0 -3
  32. package/bin/web.js +0 -20
@@ -1,2560 +0,0 @@
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
- Folder,
28
- FolderOpen,
29
- ArrowUp,
30
- Home,
31
- Compass
32
- } from 'lucide-react'
33
-
34
- // Interfaces basadas en la API de Opencode
35
- interface Session {
36
- id: string
37
- title: string | null
38
- parent_id?: string | null
39
- parentID?: string | null // Algunos endpoints de Opencode devuelven camelCase
40
- agent?: string | null
41
- model?: unknown | null
42
- cost?: number | null
43
- tokens_input?: number | null
44
- tokens_output?: number | null
45
- time_created?: number
46
- }
47
-
48
- interface MessagePart {
49
- type: 'text' | 'reasoning' | 'tool' | string
50
- text?: string
51
- tool?: string
52
- state?: {
53
- status: string
54
- input?: unknown
55
- output?: unknown
56
- }
57
- }
58
-
59
- interface Message {
60
- info: {
61
- id: string
62
- session_id: string
63
- role: 'user' | 'assistant' | 'system'
64
- time?: {
65
- created: number
66
- }
67
- time_created?: number
68
- }
69
- parts: MessagePart[]
70
- }
71
-
72
- interface OpencodeInstance {
73
- port: number
74
- path: string
75
- name: string
76
- isCurrentProject: boolean
77
- }
78
-
79
- interface Project {
80
- id: string
81
- name: string
82
- path: string
83
- }
84
-
85
- // Lista estática curada de los mejores modelos de Opencode
86
- const POPULAR_MODELS = [
87
- { id: 'default', name: '🤖 Modelo por defecto' },
88
- { id: 'deepseek/deepseek-chat', name: '⚡️ DeepSeek v4 (Chat rápido)' },
89
- { id: 'deepseek/deepseek-reasoner', name: '💭 DeepSeek R1 (Pensamiento/Razonamiento)' },
90
- { id: 'google/gemini-3.5-flash', name: '♊️ Gemini 3.5 Flash' },
91
- { id: 'google/gemini-3.1-pro-preview', name: '🧠 Gemini 3.1 Pro (Complejo)' },
92
- { id: 'opencode/deepseek-v4-flash-free', name: '🆓 DeepSeek v4 (Flash Gratis)' },
93
- { id: 'opencode/nemotron-3-ultra-free', name: '🆓 Nemotron 3 Ultra (Gratis)' }
94
- ]
95
-
96
- const AVAILABLE_AGENTS = [
97
- {
98
- id: 'build',
99
- name: '🛠️ Build',
100
- description: 'Agente de desarrollo estándar. Tiene todas las herramientas de lectura, escritura y bash habilitadas para programar.',
101
- color: '#3b82f6'
102
- },
103
- {
104
- id: 'plan',
105
- name: '📋 Plan',
106
- description: 'Agente de solo lectura/planificación. Útil para analizar el código base y planificar cambios de forma segura sin editarlos directamente.',
107
- color: '#a855f7'
108
- },
109
- {
110
- id: 'sdd-orchestrator',
111
- name: '🤖 Sdd-Orchestrator',
112
- description: 'Coordinador principal del flujo SDD (Spec-Driven Development). Automatiza los subagentes spec-writer, coder y tester.',
113
- color: '#10b981'
114
- }
115
- ]
116
-
117
- export default function App() {
118
- const [sessions, setSessions] = useState<Session[]>([])
119
- const [currentSessionId, setCurrentSessionId] = useState<string>('')
120
- const currentSessionIdRef = useRef<string>('')
121
-
122
- useEffect(() => {
123
- currentSessionIdRef.current = currentSessionId
124
- }, [currentSessionId])
125
- const [currentSession, setCurrentSession] = useState<Session | null>(null)
126
- const [messages, setMessages] = useState<Message[]>([])
127
- const [inputValue, setInputValue] = useState('')
128
- const [isProcessing, setIsProcessing] = useState(false)
129
- const [notificationsEnabled, setNotificationsEnabled] = useState<boolean>(
130
- () => typeof Notification !== 'undefined' && Notification.permission === 'granted'
131
- )
132
- const [tunnelUrl, setTunnelUrl] = useState<string>('')
133
- const [expandedParts, setExpandedParts] = useState<Record<string, boolean>>({})
134
- const [errorMsg, setErrorMsg] = useState<string>('')
135
- const [systemCwd, setSystemCwd] = useState<string>('')
136
-
137
- // Soporte para multi-instancia / selección de proyectos
138
- const [instances, setInstances] = useState<OpencodeInstance[]>([])
139
- const [currentPort, setCurrentPort] = useState<number>(4096)
140
-
141
- // Gestión de Proyectos y Mapeos
142
- const [projects, setProjects] = useState<Project[]>(() => {
143
- const saved = localStorage.getItem('zugzweb_projects')
144
- if (saved) {
145
- try {
146
- return JSON.parse(saved)
147
- } catch {
148
- // ignore
149
- }
150
- }
151
- return []
152
- })
153
-
154
- const [sessionMappings, setSessionMappings] = useState<Record<string, string>>(() => {
155
- const saved = localStorage.getItem('zugzweb_session_mappings')
156
- if (saved) {
157
- try {
158
- return JSON.parse(saved)
159
- } catch {
160
- // ignore
161
- }
162
- }
163
- return {}
164
- })
165
-
166
- const [activeProjectId, setActiveProjectId] = useState<string>(() => {
167
- const saved = localStorage.getItem('zugzweb_active_project_id')
168
- return saved || 'workspace_raiz'
169
- })
170
-
171
- const [expandedProjects, setExpandedProjects] = useState<Record<string, boolean>>(() => {
172
- const saved = localStorage.getItem('zugzweb_expanded_projects')
173
- if (saved) {
174
- try {
175
- return JSON.parse(saved)
176
- } catch {
177
- // ignore
178
- }
179
- }
180
- return { workspace_raiz: true }
181
- })
182
-
183
- // Formularios de Creación de Proyecto inline
184
- const [showAddProjectForm, setShowAddProjectForm] = useState(false)
185
- const [newProjectName, setNewProjectName] = useState('')
186
- const [newProjectPath, setNewProjectPath] = useState('')
187
-
188
- useEffect(() => {
189
- localStorage.setItem('zugzweb_projects', JSON.stringify(projects))
190
- }, [projects])
191
-
192
- useEffect(() => {
193
- localStorage.setItem('zugzweb_session_mappings', JSON.stringify(sessionMappings))
194
- }, [sessionMappings])
195
-
196
- const [customSessionTitles, setCustomSessionTitles] = useState<Record<string, string>>(() => {
197
- const saved = localStorage.getItem('zugzweb_custom_session_titles')
198
- if (saved) {
199
- try {
200
- return JSON.parse(saved)
201
- } catch {
202
- // ignore
203
- }
204
- }
205
- return {}
206
- })
207
-
208
- useEffect(() => {
209
- localStorage.setItem('zugzweb_custom_session_titles', JSON.stringify(customSessionTitles))
210
- }, [customSessionTitles])
211
-
212
- useEffect(() => {
213
- localStorage.setItem('zugzweb_active_project_id', activeProjectId)
214
- }, [activeProjectId])
215
-
216
- useEffect(() => {
217
- localStorage.setItem('zugzweb_expanded_projects', JSON.stringify(expandedProjects))
218
- }, [expandedProjects])
219
-
220
- // Ancho de la barra lateral (resizable)
221
- const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
222
- const saved = localStorage.getItem('zugzweb_sidebar_width')
223
- return saved ? parseInt(saved, 10) : 320
224
- })
225
-
226
- useEffect(() => {
227
- localStorage.setItem('zugzweb_sidebar_width', String(sidebarWidth))
228
- }, [sidebarWidth])
229
-
230
- // Opciones de prompt
231
- const [selectedModel, setSelectedModel] = useState<string>('default')
232
- const [copiedMessageId, setCopiedMessageId] = useState<string>('')
233
-
234
- // Estados para la nueva vista /new y agente activo
235
- const [isNewSessionView, setIsNewSessionView] = useState<boolean>(() => window.location.pathname === '/new')
236
- const [newSessionTitle, setNewSessionTitle] = useState<string>(() => {
237
- if (window.location.pathname === '/new') {
238
- const formattedDate = new Date().toLocaleString([], { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
239
- return `Sesión Web ${formattedDate}`
240
- }
241
- return ''
242
- })
243
- const [newSessionAgent, setNewSessionAgent] = useState<string>('sdd-orchestrator')
244
- const [newSessionModel, setNewSessionModel] = useState<string>('default')
245
- const [newSessionFirstPrompt, setNewSessionFirstPrompt] = useState<string>('')
246
- const [selectedAgent, setSelectedAgent] = useState<string>('sdd-orchestrator')
247
-
248
- // Estado de Onboarding Setup
249
- const [setupCompleted, setSetupCompleted] = useState<boolean>(() => {
250
- return localStorage.getItem('zugzweb_setup_completed') === 'true'
251
- })
252
-
253
- // Estados para el explorador de archivos
254
- const [showExplorerModal, setShowExplorerModal] = useState(false)
255
- const [explorerCurrentPath, setExplorerCurrentPath] = useState('')
256
- const [explorerParentPath, setExplorerParentPath] = useState<string | null>(null)
257
- const [explorerDirectories, setExplorerDirectories] = useState<string[]>([])
258
- const [explorerHomePath, setExplorerHomePath] = useState('')
259
- const [explorerCwdPath, setExplorerCwdPath] = useState('')
260
- const [explorerError, setExplorerError] = useState('')
261
- const [explorerLoading, setExplorerLoading] = useState(false)
262
- const [explorerCallback, setExplorerCallback] = useState<((path: string) => void) | null>(null)
263
- const [explorerTitle, setExplorerTitle] = useState('Seleccionar Carpeta')
264
-
265
- // Función para cargar un directorio en el explorador de archivos
266
- const loadDirectory = async (pathStr?: string) => {
267
- setExplorerLoading(true)
268
- setExplorerError('')
269
- try {
270
- const queryParam = pathStr ? `?path=${encodeURIComponent(pathStr)}` : ''
271
- const res = await fetch(`/api-custom/list-dir${queryParam}`)
272
- if (res.ok) {
273
- const data = await res.json()
274
- setExplorerCurrentPath(data.currentPath)
275
- setExplorerParentPath(data.parentPath)
276
- setExplorerDirectories(data.directories || [])
277
- setExplorerHomePath(data.home || '')
278
- setExplorerCwdPath(data.cwd || '')
279
- setExplorerError('')
280
- } else {
281
- const data = await res.json()
282
- setExplorerError(data.error || 'No se pudo leer el directorio')
283
- }
284
- } catch (err: any) {
285
- setExplorerError('Error de red al conectar con el servidor: ' + err.message)
286
- } finally {
287
- setExplorerLoading(false)
288
- }
289
- }
290
-
291
- // Abre el explorador de archivos con una configuración específica
292
- const openExplorer = (title: string, initialPath: string, callback: (selectedPath: string) => void) => {
293
- setExplorerTitle(title)
294
- setExplorerCallback(() => callback)
295
- setShowExplorerModal(true)
296
- loadDirectory(initialPath || systemCwd)
297
- }
298
-
299
- // Carga inicial del explorador de archivos en base al CWD del sistema al arrancar
300
- useEffect(() => {
301
- if (systemCwd && !explorerCurrentPath) {
302
- setExplorerCurrentPath(systemCwd)
303
- loadDirectory(systemCwd)
304
- }
305
- // eslint-disable-next-line react-hooks/exhaustive-deps
306
- }, [systemCwd])
307
-
308
- const renderExplorerContent = (onConfirm: (path: string) => void) => {
309
- return (
310
- <div className="space-y-4">
311
- {/* Caja de Texto de la Ruta y Controles Rápidos */}
312
- <div className="space-y-2">
313
- <div className="flex gap-2">
314
- <input
315
- type="text"
316
- value={explorerCurrentPath}
317
- onChange={(e) => setExplorerCurrentPath(e.target.value)}
318
- onKeyDown={(e) => {
319
- if (e.key === 'Enter') {
320
- loadDirectory(explorerCurrentPath)
321
- }
322
- }}
323
- className="flex-1 bg-[#121215] border border-[#27272a] focus:border-[#3f3f46] rounded-xl px-3.5 py-2 text-xs font-mono text-white outline-none transition"
324
- placeholder="Ruta absoluta..."
325
- />
326
- <button
327
- type="button"
328
- onClick={() => loadDirectory(explorerCurrentPath)}
329
- className="px-3 py-2 bg-white text-[#09090b] text-xs font-bold rounded-xl hover:bg-[#e4e4e7] transition cursor-pointer"
330
- >
331
- Ir
332
- </button>
333
- </div>
334
-
335
- <div className="flex gap-2 items-center flex-wrap">
336
- <button
337
- type="button"
338
- disabled={!explorerParentPath}
339
- onClick={() => explorerParentPath && loadDirectory(explorerParentPath)}
340
- className="flex items-center gap-1.5 px-3 py-1.5 bg-[#1c1c1f] hover:bg-[#27272a] disabled:opacity-40 disabled:hover:bg-[#1c1c1f] border border-[#2e2e33] rounded-lg text-xs text-white transition cursor-pointer font-medium"
341
- title="Subir un nivel"
342
- >
343
- <ArrowUp size={12} />
344
- <span>Subir Nivel</span>
345
- </button>
346
-
347
- <button
348
- type="button"
349
- onClick={() => loadDirectory(explorerHomePath)}
350
- className="flex items-center gap-1.5 px-3 py-1.5 bg-[#1c1c1f] hover:bg-[#27272a] border border-[#2e2e33] rounded-lg text-xs text-white transition cursor-pointer font-medium"
351
- title="Carpeta Home"
352
- >
353
- <Home size={12} />
354
- <span>Home</span>
355
- </button>
356
-
357
- <button
358
- type="button"
359
- onClick={() => loadDirectory(explorerCwdPath)}
360
- className="flex items-center gap-1.5 px-3 py-1.5 bg-[#1c1c1f] hover:bg-[#27272a] border border-[#2e2e33] rounded-lg text-xs text-white transition cursor-pointer font-medium"
361
- title="Directorio Raíz del Servidor"
362
- >
363
- <Compass size={12} />
364
- <span>CWD Servidor</span>
365
- </button>
366
- </div>
367
- </div>
368
-
369
- {/* Mensaje de Error */}
370
- {explorerError && (
371
- <div className="p-3 bg-red-950/40 border border-red-900/50 rounded-xl flex items-center gap-2 text-red-400 text-xs animate-pulse">
372
- <AlertCircle size={14} className="shrink-0" />
373
- <span>{explorerError}</span>
374
- </div>
375
- )}
376
-
377
- {/* Lista de Carpetas */}
378
- <div className="border border-[#1f1f23] rounded-xl bg-[#070709] overflow-hidden">
379
- <div className="p-2 border-b border-[#1f1f23] bg-[#0c0c0e]/60 text-[10px] text-[#71717a] font-bold uppercase tracking-wider select-none">
380
- Subcarpetas en este directorio:
381
- </div>
382
- <div className="max-h-64 overflow-y-auto p-2 space-y-1 scrollbar-thin">
383
- {explorerLoading ? (
384
- <div className="py-8 flex flex-col items-center justify-center text-[#71717a] space-y-2">
385
- <RefreshCw size={18} className="animate-spin text-white/60" />
386
- <span className="text-xs">Cargando carpetas...</span>
387
- </div>
388
- ) : explorerDirectories.length === 0 ? (
389
- <div className="py-8 text-center text-xs text-[#52525b] italic">
390
- No se encontraron subcarpetas visibles en este directorio.
391
- </div>
392
- ) : (
393
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
394
- {explorerDirectories.map((dir, dIdx) => (
395
- <button
396
- key={dIdx}
397
- type="button"
398
- onClick={() => {
399
- const separator = explorerCurrentPath.includes('\\') ? '\\' : '/'
400
- const newPath = explorerCurrentPath.endsWith(separator)
401
- ? explorerCurrentPath + dir
402
- : explorerCurrentPath + separator + dir
403
- loadDirectory(newPath)
404
- }}
405
- className="flex items-center gap-2 p-2 rounded-lg bg-[#0c0c0e]/40 border border-[#1c1c1f] hover:border-[#27272a] hover:bg-[#121215] text-left text-xs text-white transition cursor-pointer outline-none truncate"
406
- >
407
- <Folder size={14} className="text-amber-500 shrink-0" />
408
- <span className="truncate font-medium">{dir}</span>
409
- </button>
410
- ))}
411
- </div>
412
- )}
413
- </div>
414
- </div>
415
-
416
- {/* Botón de Confirmación */}
417
- <div className="flex justify-end pt-2">
418
- <button
419
- type="button"
420
- onClick={() => onConfirm(explorerCurrentPath)}
421
- className="flex items-center gap-1.5 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-400 text-[#09090b] text-xs font-bold rounded-xl transition cursor-pointer shadow-md"
422
- >
423
- <Check size={14} strokeWidth={2.5} />
424
- <span>Confirmar Selección</span>
425
- </button>
426
- </div>
427
- </div>
428
- )
429
- }
430
-
431
- const chatContainerRef = useRef<HTMLDivElement>(null)
432
- const isAbortingRef = useRef<boolean>(false)
433
- const [userHasScrolledUp, setUserHasScrolledUp] = useState(false)
434
-
435
- const navigateTo = (path: string) => {
436
- window.history.pushState(null, '', path)
437
- setIsNewSessionView(path === '/new')
438
- if (path === '/new') {
439
- // Pre-rellenar un título por defecto para la nueva sesión
440
- const formattedDate = new Date().toLocaleString([], { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
441
- setNewSessionTitle(`Sesión Web ${formattedDate}`)
442
- }
443
- }
444
-
445
-
446
-
447
- // 1. Cargar estado inicial y levantar listeners
448
- useEffect(() => {
449
- fetchTunnelStatus()
450
- fetchSystemCwd()
451
- fetchInstances()
452
- fetchSessions()
453
-
454
-
455
-
456
- const handlePopState = () => {
457
- setIsNewSessionView(window.location.pathname === '/new')
458
- }
459
- window.addEventListener('popstate', handlePopState)
460
-
461
- // Polling periódico para descubrir nuevas instancias y actualizar la lista de sesiones
462
- const syncInterval = setInterval(() => {
463
- fetchInstances()
464
- fetchSessions()
465
- }, 5000)
466
-
467
- return () => {
468
- window.removeEventListener('popstate', handlePopState)
469
- clearInterval(syncInterval)
470
- }
471
- // eslint-disable-next-line react-hooks/exhaustive-deps
472
- }, [])
473
-
474
- // Función para forzar una actualización instantánea de la interfaz
475
- const triggerImmediateUpdate = () => {
476
- if (currentSessionId) {
477
- fetchMessages(currentSessionId)
478
- fetchSessionStatus()
479
- fetchSessionDetails(currentSessionId, true)
480
- }
481
- }
482
-
483
- // 2. Escuchar cambios de sesión activa o puerto del proxy para realizar carga inicial
484
- useEffect(() => {
485
- if (!currentSessionId) return
486
- triggerImmediateUpdate()
487
- // eslint-disable-next-line react-hooks/exhaustive-deps
488
- }, [currentSessionId, currentPort])
489
-
490
- // Referencias para el throttle del refresco de mensajes en tiempo real
491
- const lastFetchTimeRef = useRef<number>(0)
492
- const pendingFetchRef = useRef<ReturnType<typeof setTimeout> | null>(null)
493
-
494
- const fetchMessagesThrottled = (id: string) => {
495
- if (!id) return
496
- const now = Date.now()
497
- const limit = 200 // refresco a lo sumo cada 200ms para un streaming ultrasuave
498
-
499
- if (pendingFetchRef.current) {
500
- return // Ya hay una petición en cola que capturará el último token
501
- }
502
-
503
- const remaining = limit - (now - lastFetchTimeRef.current)
504
- if (remaining <= 0) {
505
- fetchMessages(id)
506
- lastFetchTimeRef.current = now
507
- } else {
508
- pendingFetchRef.current = setTimeout(() => {
509
- fetchMessages(id)
510
- lastFetchTimeRef.current = Date.now()
511
- pendingFetchRef.current = null
512
- }, remaining)
513
- }
514
- }
515
-
516
- // 3. Sistema SSE en tiempo real con fallback de Heartbeat lento
517
- useEffect(() => {
518
- if (!currentSessionId) return
519
-
520
- // Configurar EventSource para recibir eventos de Opencode en caliente
521
- const es = new EventSource('/api/event')
522
-
523
- const handleEvent = (event: MessageEvent) => {
524
- try {
525
- const data = JSON.parse(event.data)
526
- const eventType = data.type || event.type
527
- const sessionID = data.properties?.sessionID || data.properties?.sessionId || data.sessionId || data.session_id
528
-
529
- if (eventType.startsWith('message.') && (!sessionID || sessionID === currentSessionId)) {
530
- fetchMessagesThrottled(currentSessionId)
531
- } else if (eventType.startsWith('session.') && (!sessionID || sessionID === currentSessionId)) {
532
- fetchSessionStatus()
533
- fetchSessionDetails(currentSessionId)
534
- fetchMessagesThrottled(currentSessionId)
535
- }
536
- } catch (err) {
537
- // En caso de que no sea JSON, refrescar de todos modos si es relevante
538
- fetchMessagesThrottled(currentSessionId)
539
- }
540
- }
541
-
542
- es.addEventListener('message', handleEvent)
543
-
544
- const sseEventTypes = [
545
- 'message.part.updated',
546
- 'message.part.removed',
547
- 'message.updated',
548
- 'session.status',
549
- 'session.idle',
550
- 'session.updated',
551
- 'session.created'
552
- ]
553
- sseEventTypes.forEach(type => {
554
- es.addEventListener(type, handleEvent)
555
- })
556
-
557
- es.onerror = () => {
558
- // El navegador reconecta automáticamente, pero podemos forzar un refresh por si acaso
559
- }
560
-
561
- // Heartbeat lento constante (cada 6s) como red de seguridad/fallback
562
- const heartbeatIntervalId = setInterval(() => {
563
- fetchSessionStatus()
564
- fetchMessagesThrottled(currentSessionId)
565
- fetchSessionDetails(currentSessionId)
566
- }, 6000)
567
-
568
- return () => {
569
- es.close()
570
- if (pendingFetchRef.current) {
571
- clearTimeout(pendingFetchRef.current)
572
- pendingFetchRef.current = null
573
- }
574
- clearInterval(heartbeatIntervalId)
575
- }
576
- // eslint-disable-next-line react-hooks/exhaustive-deps
577
- }, [currentSessionId, currentPort])
578
-
579
- // 3. Scroll automático inteligente al recibir nuevos mensajes
580
- useEffect(() => {
581
- if (!userHasScrolledUp && chatContainerRef.current) {
582
- chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
583
- }
584
- // eslint-disable-next-line react-hooks/exhaustive-deps
585
- }, [messages])
586
-
587
- // Manejar el desplazamiento del scroll para desactivar/activar el Smart Scroll Anchoring
588
- const handleScroll = () => {
589
- if (chatContainerRef.current) {
590
- const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current
591
- // Si el usuario sube más de 120px desde el fondo, asumimos que subió manualmente
592
- const isAtBottom = scrollHeight - scrollTop - clientHeight < 120
593
- setUserHasScrolledUp(!isAtBottom)
594
- }
595
- }
596
-
597
- // Parseador de Markdown seguro usando marked
598
- const renderMarkdown = (text: string) => {
599
- try {
600
- return { __html: marked.parse(text, { gfm: true, breaks: true }) }
601
- } catch {
602
- return { __html: text }
603
- }
604
- }
605
-
606
- // Notificaciones Push del navegador
607
- const toggleNotifications = async () => {
608
- if (notificationsEnabled) {
609
- setNotificationsEnabled(false)
610
- return
611
- }
612
-
613
- const permission = await Notification.requestPermission()
614
- if (permission === 'granted') {
615
- setNotificationsEnabled(true)
616
- showNotification('¡Notificaciones activadas!', 'Te avisaremos cuando tus flujos en Opencode terminen.')
617
- } else {
618
- alert('Permiso de notificación denegado por el navegador.')
619
- }
620
- }
621
-
622
- const showNotification = (title: string, body: string) => {
623
- if (Notification.permission === 'granted') {
624
- new Notification(title, {
625
- body,
626
- icon: '/favicon.ico'
627
- })
628
- }
629
- }
630
-
631
- // Copiar al portapapeles
632
- const handleCopyText = (text: string, msgId: string) => {
633
- navigator.clipboard.writeText(text).then(() => {
634
- setCopiedMessageId(msgId)
635
- setTimeout(() => setCopiedMessageId(''), 2000)
636
- })
637
- }
638
-
639
- // APIs del Servidor / Daemon Custom
640
- async function fetchTunnelStatus() {
641
- try {
642
- const res = await fetch('/api-custom/tunnel-status')
643
- if (res.ok) {
644
- const data = await res.json()
645
- setTunnelUrl(data.url || '')
646
- }
647
- } catch (error) {
648
- console.error('Error obteniendo estado del túnel:', error)
649
- }
650
- }
651
-
652
- async function fetchSystemCwd() {
653
- try {
654
- const res = await fetch('/api-custom/cwd')
655
- if (res.ok) {
656
- const data = await res.json()
657
- if (data.cwd) {
658
- setSystemCwd(data.cwd)
659
-
660
- setProjects(prev => {
661
- const hasSaved = localStorage.getItem('zugzweb_projects')
662
- if (!hasSaved || prev.length === 0) {
663
- return [
664
- {
665
- id: 'workspace_raiz',
666
- name: 'Carpeta Base Zugz',
667
- path: data.cwd
668
- }
669
- ]
670
- }
671
-
672
- // Si tiene proyectos guardados, nos aseguramos de que "workspace_raiz" apunte dinámicamente al cwd de esta ejecución
673
- const baseProjExists = prev.some(p => p.id === 'workspace_raiz')
674
- if (!baseProjExists) {
675
- return [
676
- {
677
- id: 'workspace_raiz',
678
- name: 'Carpeta Base Zugz',
679
- path: data.cwd
680
- },
681
- ...prev
682
- ]
683
- }
684
- return prev.map(p => {
685
- if (p.id === 'workspace_raiz') {
686
- return { ...p, name: 'Carpeta Base Zugz', path: data.cwd }
687
- }
688
- return p
689
- })
690
- })
691
- }
692
- }
693
- } catch (error) {
694
- console.error('Error al obtener cwd:', error)
695
- }
696
- }
697
-
698
- async function fetchInstances() {
699
- try {
700
- const res = await fetch('/api-custom/instances')
701
- if (res.ok) {
702
- const data = await res.json()
703
- setInstances(data.instances || [])
704
- setCurrentPort(data.currentPort || 4096)
705
- }
706
- } catch (error) {
707
- console.error('Error obteniendo instancias de Opencode:', error)
708
- }
709
- }
710
-
711
- async function fetchSessions() {
712
- try {
713
- const res = await fetch('/api/session')
714
- if (res.ok) {
715
- const data = await res.json()
716
- const sessionList = Array.isArray(data) ? data : (data.sessions || [])
717
- setSessions(sessionList)
718
-
719
- // Registrar de forma permanente en localStorage el mapeo de las sesiones descubiertas al proyecto activo
720
- setSessionMappings(prev => {
721
- let updated = false
722
- const nextMappings = { ...prev }
723
- for (const s of sessionList) {
724
- if (!nextMappings[s.id]) {
725
- nextMappings[s.id] = activeProjectId
726
- updated = true
727
- }
728
- }
729
- return updated ? nextMappings : prev
730
- })
731
-
732
- // Limpiar errores inmediatamente al lograr conectar exitosamente con la API de Opencode
733
- setErrorMsg('')
734
-
735
- if (sessionList.length > 0) {
736
- // Validar que la sesión actual exista de verdad en el servidor activo
737
- const exists = sessionList.some((s: Session) => s.id === currentSessionIdRef.current)
738
- if (!currentSessionIdRef.current || !exists) {
739
- // Si no hay sesión seleccionada o es un ID huérfano viejo, auto-seleccionar la primera disponible
740
- setCurrentSessionId(sessionList[0].id)
741
- }
742
- } else {
743
- navigateTo('/new')
744
- }
745
- } else {
746
- const errData = await res.json().catch(() => ({}))
747
- setErrorMsg(errData.message || 'No se pudo conectar con Opencode. ¿Has iniciado "opencode serve"?')
748
- }
749
- } catch {
750
- setErrorMsg('No se pudo conectar al Daemon local de Opencode')
751
- }
752
- }
753
-
754
- async function fetchSessionDetails(id: string, isInitialLoad = false) {
755
- try {
756
- const res = await fetch(`/api/session/${id}`)
757
- if (res.ok) {
758
- const data = await res.json()
759
- setCurrentSession(data)
760
- if (data.agent && isInitialLoad) {
761
- setSelectedAgent(data.agent)
762
- }
763
- setErrorMsg('')
764
- } else {
765
- // ID de sesión huérfano. Limpiamos para que fetchSessions auto-asigne uno nuevo válido en el próximo ciclo
766
- setCurrentSessionId('')
767
- setCurrentSession(null)
768
- }
769
- } catch (error) {
770
- console.error(error)
771
- }
772
- }
773
-
774
- async function fetchMessages(id: string) {
775
- try {
776
- const res = await fetch(`/api/session/${id}/message`)
777
- if (res.ok) {
778
- const data = await res.json()
779
- const msgList = Array.isArray(data) ? data : (data.messages || [])
780
-
781
- setMessages(msgList)
782
- setErrorMsg('')
783
- } else {
784
- const errData = await res.json().catch(() => ({}))
785
- setErrorMsg(errData.message || 'No se pudieron recuperar los mensajes')
786
- }
787
- } catch (error) {
788
- console.error(error)
789
- }
790
- }
791
-
792
- async function fetchSessionStatus() {
793
- try {
794
- const res = await fetch('/api/session/status')
795
- if (res.ok) {
796
- const data = await res.json()
797
- if (currentSessionId && data[currentSessionId]) {
798
- const status = data[currentSessionId]
799
- const isBusy = status === 'thinking' || status === 'running_tools' || status === 'busy' || status === 'running' || status === 'executing' || status === 'processing'
800
-
801
- // Heurística de doble capa: buscar si la última parte de herramienta en el historial sigue activa
802
- let hasRunningTool = false
803
- if (messages.length > 0) {
804
- const lastMsg = messages[messages.length - 1]
805
- if (lastMsg.info.role === 'assistant' && lastMsg.parts) {
806
- const lastToolPart = [...lastMsg.parts].reverse().find(p => p.type === 'tool')
807
- if (lastToolPart && lastToolPart.state) {
808
- const toolStatus = lastToolPart.state.status
809
- if (toolStatus === 'running' || toolStatus === 'thinking' || toolStatus === 'processing' || toolStatus === 'active') {
810
- hasRunningTool = true
811
- }
812
- }
813
- }
814
- }
815
-
816
- const isReallyBusy = isBusy || hasRunningTool
817
-
818
- if (isReallyBusy) {
819
- setIsProcessing(prev => {
820
- if (isAbortingRef.current) return false
821
- if (!prev) {
822
- return true
823
- }
824
- return prev
825
- })
826
- } else {
827
- isAbortingRef.current = false
828
- setIsProcessing(prev => {
829
- if (prev) {
830
- showNotification('Opencode: ¡Ejecución Completada! 🎉', 'El agente ha finalizado el procesamiento de tu solicitud con éxito.')
831
- // Actualizar datos de forma inmediata una última vez al terminar la ejecución
832
- fetchMessages(currentSessionId)
833
- fetchSessionDetails(currentSessionId)
834
- return false
835
- }
836
- return prev
837
- })
838
- }
839
- }
840
- setErrorMsg('')
841
- } else {
842
- const errData = await res.json().catch(() => ({}))
843
- setErrorMsg(errData.message || 'No se pudo comprobar el estado de Opencode')
844
- }
845
- } catch (error) {
846
- console.error(error)
847
- }
848
- }
849
-
850
- const handleAddProject = async (e: React.FormEvent) => {
851
- e.preventDefault()
852
- if (!newProjectName.trim() || !newProjectPath.trim()) return
853
-
854
- // Asegurarse de que la ruta sea absoluta. Si es una subcarpeta relativa, concatenar a la Carpeta Base Zugz
855
- let resolvedPath = newProjectPath.trim()
856
- if (!resolvedPath.startsWith('/')) {
857
- const baseProject = projects.find(p => p.id === 'workspace_raiz')
858
- const basePath = baseProject ? baseProject.path : systemCwd
859
- resolvedPath = `${basePath}/${resolvedPath}`
860
- }
861
-
862
- const newId = `project_${Date.now()}`
863
- const newProj: Project = {
864
- id: newId,
865
- name: newProjectName.trim(),
866
- path: resolvedPath
867
- }
868
-
869
- // 1. Intentar crear la carpeta física llamando al Daemon
870
- try {
871
- await fetch('/api-custom/create-folder', {
872
- method: 'POST',
873
- headers: { 'Content-Type': 'application/json' },
874
- body: JSON.stringify({ path: resolvedPath })
875
- })
876
- } catch (err) {
877
- console.error('Error al solicitar creación de carpeta física:', err)
878
- }
879
-
880
- // 2. Guardar el proyecto en el estado local
881
- setProjects(prev => [...prev, newProj])
882
- setExpandedProjects(prev => ({ ...prev, [newId]: true }))
883
- setActiveProjectId(newId)
884
-
885
- // 3. Resetear formulario
886
- setNewProjectName('')
887
- setNewProjectPath('')
888
- setShowAddProjectForm(false)
889
- }
890
-
891
- const handleDeleteProject = (projectId: string) => {
892
- if (projectId === 'workspace_raiz') {
893
- alert('No puedes eliminar el proyecto raíz principal.')
894
- return
895
- }
896
- if (!confirm('¿Estás seguro de que quieres eliminar este proyecto? Las sesiones asociadas se moverán al Inbox / Sin Clasificar.')) {
897
- return
898
- }
899
-
900
- setProjects(prev => prev.filter(p => p.id !== projectId))
901
-
902
- // Desvincular mapeos
903
- setSessionMappings(prev => {
904
- const copy = { ...prev }
905
- for (const sId in copy) {
906
- if (copy[sId] === projectId) {
907
- delete copy[sId]
908
- }
909
- }
910
- return copy
911
- })
912
-
913
- if (activeProjectId === projectId) {
914
- setActiveProjectId('workspace_raiz')
915
- }
916
- }
917
-
918
- const toggleProjectExpansion = (projectId: string) => {
919
- setExpandedProjects(prev => ({ ...prev, [projectId]: !prev[projectId] }))
920
- }
921
-
922
- // Cambia de sesión y conmuta de forma automática el proyecto activo si es necesario
923
- const handleSelectSession = async (sessionId: string, projectId: string) => {
924
- const project = projects.find(p => p.id === projectId)
925
- if (!project) return
926
-
927
- if (activeProjectId !== projectId) {
928
- setIsProcessing(true)
929
- setErrorMsg(`Activando entorno de "${project.name}"...`)
930
- try {
931
- const res = await fetch('/api-custom/activate-project', {
932
- method: 'POST',
933
- headers: { 'Content-Type': 'application/json' },
934
- body: JSON.stringify({ path: project.path })
935
- })
936
- if (res.ok) {
937
- setActiveProjectId(projectId)
938
-
939
- // Limpiar de forma temporal para evitar ruidos de la sesión anterior
940
- setSessions([])
941
- setCurrentSessionId('')
942
- setCurrentSession(null)
943
- setMessages([])
944
-
945
- // Forzar la recarga de sesiones de este proyecto
946
- await fetchSessions()
947
-
948
- // Establecer la sesión seleccionada
949
- setCurrentSessionId(sessionId)
950
- setErrorMsg('')
951
- navigateTo('/')
952
- } else {
953
- const errData = await res.json().catch(() => ({}))
954
- setErrorMsg(errData.error || 'Error al activar el proyecto para esta sesión')
955
- }
956
- } catch {
957
- setErrorMsg('Error de conexión al activar el proyecto de la sesión')
958
- } finally {
959
- setIsProcessing(false)
960
- }
961
- } else {
962
- // Si ya está en el proyecto activo, simplemente la seleccionamos
963
- setCurrentSessionId(sessionId)
964
- setErrorMsg('')
965
- navigateTo('/')
966
- }
967
- }
968
-
969
- // Activa un proyecto levantando opencode serve en su directorio en segundo plano
970
- const handleActivateProject = async (projectId: string) => {
971
- const project = projects.find(p => p.id === projectId)
972
- if (!project) return
973
-
974
- setIsProcessing(true)
975
- setErrorMsg('Activando entorno de proyecto en segundo plano...')
976
- try {
977
- const res = await fetch('/api-custom/activate-project', {
978
- method: 'POST',
979
- headers: { 'Content-Type': 'application/json' },
980
- body: JSON.stringify({ path: project.path })
981
- })
982
- if (res.ok) {
983
- setActiveProjectId(projectId)
984
-
985
- // Limpiar estados de sesión de la instancia anterior
986
- setSessions([])
987
- setCurrentSessionId('')
988
- setCurrentSession(null)
989
- setMessages([])
990
-
991
- // Forzar recarga de sesiones del nuevo entorno
992
- await fetchSessions()
993
- setErrorMsg('')
994
- } else {
995
- const errData = await res.json().catch(() => ({}))
996
- setErrorMsg(errData.error || 'Error al activar el proyecto')
997
- }
998
- } catch {
999
- setErrorMsg('No se pudo conectar con el Daemon para activar el proyecto')
1000
- } finally {
1001
- setIsProcessing(false)
1002
- }
1003
- }
1004
-
1005
- // Crea e inicia de forma inmediata una sesión dentro de un proyecto específico de un solo clic
1006
- const handleCreateSessionInProject = async (projectId: string) => {
1007
- const project = projects.find(p => p.id === projectId)
1008
- if (!project) return
1009
-
1010
- // 1. Si el proyecto no es el activo, activarlo primero en segundo plano de forma transparente
1011
- if (activeProjectId !== projectId) {
1012
- setIsProcessing(true)
1013
- setErrorMsg(`Activando entorno de "${project.name}"...`)
1014
- try {
1015
- const res = await fetch('/api-custom/activate-project', {
1016
- method: 'POST',
1017
- headers: { 'Content-Type': 'application/json' },
1018
- body: JSON.stringify({ path: project.path })
1019
- })
1020
- if (res.ok) {
1021
- setActiveProjectId(projectId)
1022
- setSessions([])
1023
- setCurrentSessionId('')
1024
- setCurrentSession(null)
1025
- setMessages([])
1026
- setErrorMsg('')
1027
- } else {
1028
- alert(`No se pudo activar el proyecto: ${project.name}`)
1029
- setIsProcessing(false)
1030
- return
1031
- }
1032
- } catch {
1033
- alert(`Error al conectar al Daemon para activar el proyecto: ${project.name}`)
1034
- setIsProcessing(false)
1035
- return
1036
- }
1037
- }
1038
-
1039
- // 2. Solicitar el título de la sesión de forma sencilla
1040
- const title = prompt(`Introduce el título de la nueva sesión para "${project.name}":`, `Sesión ${new Date().toLocaleDateString()}`)
1041
- if (!title || !title.trim()) return
1042
-
1043
- setIsProcessing(true)
1044
- setErrorMsg('Iniciando sesión...')
1045
- try {
1046
- const res = await fetch('/api/session', {
1047
- method: 'POST',
1048
- headers: { 'Content-Type': 'application/json' },
1049
- body: JSON.stringify({ title: title.trim() })
1050
- })
1051
- if (res.ok) {
1052
- const newSess = await res.json()
1053
- setSessions(prev => [newSess, ...prev])
1054
- setCurrentSessionId(newSess.id)
1055
- setCurrentSession(newSess)
1056
- setMessages([])
1057
-
1058
- // Registrar el título personalizado en el diccionario local
1059
- setCustomSessionTitles(prev => ({
1060
- ...prev,
1061
- [newSess.id]: title.trim()
1062
- }))
1063
-
1064
- // Guardar mapeo sesión -> proyecto
1065
- setSessionMappings(prev => ({
1066
- ...prev,
1067
- [newSess.id]: projectId
1068
- }))
1069
-
1070
- // Redirigir a la vista de chat
1071
- navigateTo('/')
1072
- } else {
1073
- alert('Error al crear la sesión en Opencode')
1074
- }
1075
- } catch {
1076
- alert('Error de conexión al crear sesión')
1077
- } finally {
1078
- setIsProcessing(false)
1079
- setErrorMsg('')
1080
- }
1081
- }
1082
-
1083
- // Elimina de forma permanente una sesión y sus mensajes en la API de Opencode
1084
- const handleDeleteSession = async (sessionId: string, e: React.MouseEvent) => {
1085
- e.stopPropagation()
1086
-
1087
- if (!confirm('¿Estás seguro de que quieres eliminar esta sesión y todos sus mensajes permanentemente?')) {
1088
- return
1089
- }
1090
-
1091
- try {
1092
- const res = await fetch(`/api/session/${sessionId}`, {
1093
- method: 'DELETE'
1094
- })
1095
- if (res.ok) {
1096
- setSessions(prev => prev.filter(s => s.id !== sessionId))
1097
- setSessionMappings(prev => {
1098
- const copy = { ...prev }
1099
- delete copy[sessionId]
1100
- return copy
1101
- })
1102
-
1103
- if (currentSessionId === sessionId) {
1104
- setCurrentSessionId('')
1105
- setCurrentSession(null)
1106
- setMessages([])
1107
- }
1108
- } else {
1109
- alert('No se pudo eliminar la sesión de Opencode')
1110
- }
1111
- } catch {
1112
- alert('Error de red al eliminar la sesión')
1113
- }
1114
- }
1115
-
1116
- // Handler para el resize de la barra lateral
1117
- const handleMouseDown = (e: React.MouseEvent) => {
1118
- e.preventDefault()
1119
- const startX = e.clientX
1120
- const startWidth = sidebarWidth
1121
-
1122
- const handleMouseMove = (moveEvent: MouseEvent) => {
1123
- const deltaX = moveEvent.clientX - startX
1124
- const newWidth = Math.max(220, Math.min(500, startWidth + deltaX))
1125
- setSidebarWidth(newWidth)
1126
- }
1127
-
1128
- const handleMouseUp = () => {
1129
- document.removeEventListener('mousemove', handleMouseMove)
1130
- document.removeEventListener('mouseup', handleMouseUp)
1131
- }
1132
-
1133
- document.addEventListener('mousemove', handleMouseMove)
1134
- document.addEventListener('mouseup', handleMouseUp)
1135
- }
1136
-
1137
- const handleStartNewSession = async (e?: React.FormEvent) => {
1138
- if (e) e.preventDefault()
1139
- if (!newSessionTitle.trim()) {
1140
- alert('Por favor introduce un título para la sesión')
1141
- return
1142
- }
1143
-
1144
- try {
1145
- setIsProcessing(true)
1146
- // 1. Crear sesión en la API de Opencode
1147
- const res = await fetch('/api/session', {
1148
- method: 'POST',
1149
- headers: { 'Content-Type': 'application/json' },
1150
- body: JSON.stringify({ title: newSessionTitle.trim() })
1151
- })
1152
-
1153
- if (!res.ok) throw new Error('Error al crear sesión')
1154
- const newSess = await res.json()
1155
-
1156
- // 2. Agregar a la lista de sesiones local
1157
- setSessions(prev => [newSess, ...prev])
1158
- setCurrentSessionId(newSess.id)
1159
- setCurrentSession(newSess)
1160
- setMessages([])
1161
-
1162
- // Registrar el título personalizado en el diccionario local
1163
- setCustomSessionTitles(prev => ({
1164
- ...prev,
1165
- [newSess.id]: newSessionTitle.trim()
1166
- }))
1167
-
1168
- // Guardar el mapeo de la sesión al proyecto activo
1169
- setSessionMappings(prev => ({
1170
- ...prev,
1171
- [newSess.id]: activeProjectId
1172
- }))
1173
-
1174
- // Establecer el agente y modelo seleccionados para esta sesión
1175
- setSelectedAgent(newSessionAgent)
1176
- setSelectedModel(newSessionModel)
1177
-
1178
- // 3. Si hay una instrucción inicial o se requiere anclaje de directorio, enviarlo de inmediato
1179
- const activeProj = projects.find(p => p.id === activeProjectId)
1180
- let anchorPrefix = ''
1181
- if (activeProj && activeProj.id !== 'workspace_raiz') {
1182
- 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`
1183
- }
1184
-
1185
- const promptText = (anchorPrefix + newSessionFirstPrompt.trim()).trim()
1186
-
1187
- if (promptText) {
1188
- const bodyPayload: {
1189
- parts: { type: string; text: string }[];
1190
- agent: string;
1191
- model?: { providerID: string; modelID: string };
1192
- } = {
1193
- parts: [{ type: 'text', text: promptText }],
1194
- agent: newSessionAgent
1195
- }
1196
-
1197
- if (newSessionModel && newSessionModel !== 'default') {
1198
- const [providerID, modelID] = newSessionModel.split('/')
1199
- bodyPayload.model = { providerID, modelID }
1200
- }
1201
-
1202
- // Llamamos a prompt_async para enviar el prompt asíncronamente
1203
- await fetch(`/api/session/${newSess.id}/prompt_async`, {
1204
- method: 'POST',
1205
- headers: { 'Content-Type': 'application/json' },
1206
- body: JSON.stringify(bodyPayload)
1207
- })
1208
-
1209
- // Reiniciar campos
1210
- setNewSessionFirstPrompt('')
1211
- }
1212
-
1213
- // Volver a la vista principal / chat
1214
- navigateTo('/')
1215
- triggerImmediateUpdate()
1216
- } catch {
1217
- alert('Error al iniciar la nueva sesión')
1218
- } finally {
1219
- setIsProcessing(false)
1220
- }
1221
- }
1222
-
1223
- // Manejar el envío de prompts o la ejecución de comandos diagonal
1224
- const handleSendPrompt = async (e: React.FormEvent) => {
1225
- e.preventDefault()
1226
- if (!inputValue.trim() || !currentSessionId) return
1227
-
1228
- const input = inputValue.trim()
1229
- setInputValue('')
1230
- setIsProcessing(true)
1231
- setUserHasScrolledUp(false) // Forzar autoscroll al enviar
1232
-
1233
- // Agregar mensaje local temporal de usuario
1234
- const tempUserMessage: Message = {
1235
- info: {
1236
- id: `temp-${Date.now()}`,
1237
- session_id: currentSessionId,
1238
- role: 'user',
1239
- time: { created: Date.now() }
1240
- },
1241
- parts: [{ type: 'text', text: input }]
1242
- }
1243
- setMessages(prev => [...prev, tempUserMessage])
1244
-
1245
- try {
1246
- if (input.startsWith('/')) {
1247
- // Ejecución de comandos de Opencode
1248
- const parts = input.split(' ')
1249
- const commandName = parts[0].substring(1) // Quitar '/'
1250
- const args = parts.slice(1).join(' ')
1251
-
1252
- const res = await fetch(`/api/session/${currentSessionId}/command`, {
1253
- method: 'POST',
1254
- headers: { 'Content-Type': 'application/json' },
1255
- body: JSON.stringify({
1256
- command: commandName,
1257
- arguments: args,
1258
- agent: selectedAgent
1259
- })
1260
- })
1261
-
1262
- if (!res.ok) throw new Error('Error al ejecutar comando')
1263
- } else {
1264
- // Envío de prompt normal asíncrono
1265
- const bodyPayload: {
1266
- parts: { type: string; text: string }[];
1267
- agent: string;
1268
- model?: { providerID: string; modelID: string };
1269
- } = {
1270
- parts: [{ type: 'text', text: input }],
1271
- agent: selectedAgent
1272
- }
1273
-
1274
- // Agregar modelo personalizado si se ha seleccionado
1275
- if (selectedModel && selectedModel !== 'default') {
1276
- const [providerID, modelID] = selectedModel.split('/')
1277
- bodyPayload.model = { providerID, modelID }
1278
- }
1279
-
1280
- const res = await fetch(`/api/session/${currentSessionId}/prompt_async`, {
1281
- method: 'POST',
1282
- headers: { 'Content-Type': 'application/json' },
1283
- body: JSON.stringify(bodyPayload)
1284
- })
1285
-
1286
- if (!res.ok) throw new Error('Error al enviar prompt')
1287
- }
1288
-
1289
- triggerImmediateUpdate()
1290
- } catch {
1291
- setErrorMsg('Error al conectar. Comprueba el estado de Opencode.')
1292
- setIsProcessing(false)
1293
- }
1294
- }
1295
-
1296
- // Activa comandos rápidos desde un botón de un solo clic
1297
- const handleQuickCommand = async (commandName: string, args: string = '') => {
1298
- if (!currentSessionId) return
1299
- setIsProcessing(true)
1300
- setUserHasScrolledUp(false) // Forzar autoscroll al enviar
1301
-
1302
- const fullCommandText = `/${commandName} ${args}`.trim()
1303
-
1304
- // Agregar mensaje temporal local
1305
- const tempUserMessage: Message = {
1306
- info: {
1307
- id: `temp-${Date.now()}`,
1308
- session_id: currentSessionId,
1309
- role: 'user',
1310
- time: { created: Date.now() }
1311
- },
1312
- parts: [{ type: 'text', text: fullCommandText }]
1313
- }
1314
- setMessages(prev => [...prev, tempUserMessage])
1315
-
1316
- try {
1317
- const res = await fetch(`/api/session/${currentSessionId}/command`, {
1318
- method: 'POST',
1319
- headers: { 'Content-Type': 'application/json' },
1320
- body: JSON.stringify({
1321
- command: commandName,
1322
- arguments: args,
1323
- agent: selectedAgent
1324
- })
1325
- })
1326
-
1327
- if (!res.ok) throw new Error('Error al ejecutar comando rápido')
1328
- triggerImmediateUpdate()
1329
- } catch {
1330
- setErrorMsg('No se pudo ejecutar el comando rápido.')
1331
- setIsProcessing(false)
1332
- }
1333
- }
1334
-
1335
- const handleAbort = async () => {
1336
- if (!currentSessionId) return
1337
- isAbortingRef.current = true
1338
- setIsProcessing(false) // Optimista
1339
- try {
1340
- const res = await fetch(`/api/session/${currentSessionId}/abort`, {
1341
- method: 'POST'
1342
- })
1343
- if (res.ok) {
1344
- showNotification('Opencode: Abortado', 'La sesión en ejecución ha sido cancelada forzadamente.')
1345
- triggerImmediateUpdate()
1346
- }
1347
- } catch (e) {
1348
- console.error(e)
1349
- } finally {
1350
- // Dejar que transcurran 2.5 segundos para que el servidor complete el aborto y se asiente
1351
- setTimeout(() => {
1352
- isAbortingRef.current = false
1353
- }, 2500)
1354
- }
1355
- }
1356
-
1357
- const togglePartExpansion = (id: string) => {
1358
- setExpandedParts(prev => ({ ...prev, [id]: !prev[id] }))
1359
- }
1360
-
1361
- // Formateador de modelo para evitar colapsos
1362
- const formatModelName = (model: unknown): string => {
1363
- if (!model) return 'default'
1364
- if (typeof model === 'string') {
1365
- return model.split('/').pop() || model
1366
- }
1367
- if (typeof model === 'object' && model !== null) {
1368
- const obj = model as Record<string, unknown>
1369
- if (typeof obj.modelID === 'string') return obj.modelID
1370
- if (typeof obj.id === 'string') return obj.id
1371
- return JSON.stringify(model).substring(0, 20)
1372
- }
1373
- return 'default'
1374
- }
1375
-
1376
- // Formateador robusto de fecha de mensaje
1377
- const formatMessageTime = (info: {
1378
- time?: { created: number };
1379
- time_created?: number;
1380
- }): string => {
1381
- const rawTime = info?.time?.created || info?.time_created
1382
- if (!rawTime) return 'En curso...'
1383
- try {
1384
- const date = new Date(rawTime)
1385
- if (isNaN(date.getTime())) {
1386
- return 'En curso...'
1387
- }
1388
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
1389
- } catch {
1390
- return 'En curso...'
1391
- }
1392
- }
1393
-
1394
- // Estructura Jerárquica del Árbol de Subagentes
1395
- // Separar las sesiones en principales (raíz) e hijas (subagentes)
1396
- const rootSessions = sessions.filter(s => !s.parent_id && !s.parentID)
1397
-
1398
- const getSubagentsFor = (parentId: string) => {
1399
- return sessions.filter(s => s.parent_id === parentId || s.parentID === parentId)
1400
- }
1401
-
1402
- // Filtrar rootSessions por proyecto, asociando dinámicamente las sesiones sin mapear al proyecto activo actual
1403
- const getSessionsForProject = (projectId: string) => {
1404
- return rootSessions.filter(s => {
1405
- const mappedId = sessionMappings[s.id]
1406
- if (mappedId) {
1407
- if (projects.some(p => p.id === mappedId)) {
1408
- return mappedId === projectId
1409
- }
1410
- }
1411
-
1412
- // Si la sesión no tiene un mapeo de proyecto en localStorage, asumimos
1413
- // que pertenece al entorno del proyecto que está actualmente activo y conectado
1414
- return activeProjectId === projectId
1415
- })
1416
- }
1417
-
1418
- // Sesiones sin clasificar (Inbox)
1419
- const unclassifiedSessions: Session[] = []
1420
-
1421
- // Obtener la instancia activa del Daemon
1422
- const activeInstance = instances.find(inst => inst.port === currentPort)
1423
-
1424
- if (!setupCompleted) {
1425
- return (
1426
- <div className="flex h-screen bg-[#09090b] text-[#f4f4f5] items-center justify-center p-4 overflow-hidden select-none">
1427
- <div className="w-full max-w-2xl bg-[#0c0c0e] border border-[#1f1f23] rounded-2xl shadow-2xl p-6 md:p-8 space-y-6 flex flex-col max-h-[90vh]">
1428
- {/* Cabecera */}
1429
- <div className="text-center space-y-2 shrink-0">
1430
- <div className="inline-flex p-3 bg-white text-[#09090b] rounded-2xl shadow-md mb-2">
1431
- <Terminal size={28} className="font-bold animate-pulse" />
1432
- </div>
1433
- <h1 className="text-2xl font-black tracking-tight text-white flex items-center justify-center gap-2">
1434
- <span>Bienvenido a ZugzWeb</span>
1435
- <span className="text-[10px] bg-emerald-950 text-emerald-400 border border-emerald-900/50 px-1.5 py-0.5 rounded font-bold uppercase tracking-wider">Harness v1.2</span>
1436
- </h1>
1437
- <p className="text-xs text-[#a1a1aa] max-w-md mx-auto leading-relaxed">
1438
- Para comenzar a programar de forma autónoma con tu arnés SDD, necesitamos que selecciones la carpeta de tu computadora que servirá como tu espacio de trabajo inicial.
1439
- </p>
1440
- </div>
1441
-
1442
- {/* Cuerpo del Explorador */}
1443
- <div className="flex-1 overflow-y-auto py-1 scrollbar-none">
1444
- {renderExplorerContent(async (selectedPath) => {
1445
- // 1. Guardar el Workspace Raíz inicial
1446
- const initialProjects = [
1447
- {
1448
- id: 'workspace_raiz',
1449
- name: 'Carpeta Base Zugz',
1450
- path: selectedPath
1451
- }
1452
- ]
1453
- setProjects(initialProjects)
1454
- localStorage.setItem('zugzweb_projects', JSON.stringify(initialProjects))
1455
-
1456
- setActiveProjectId('workspace_raiz')
1457
- localStorage.setItem('zugzweb_active_project_id', 'workspace_raiz')
1458
-
1459
- // 2. Activar el proyecto llamando al Daemon en segundo plano
1460
- setIsProcessing(true)
1461
- setErrorMsg('Activando entorno de desarrollo inicial...')
1462
- try {
1463
- // Asegurarse de que exista físicamente llamando a /api-custom/create-folder
1464
- await fetch('/api-custom/create-folder', {
1465
- method: 'POST',
1466
- headers: { 'Content-Type': 'application/json' },
1467
- body: JSON.stringify({ path: selectedPath })
1468
- })
1469
-
1470
- await fetch('/api-custom/activate-project', {
1471
- method: 'POST',
1472
- headers: { 'Content-Type': 'application/json' },
1473
- body: JSON.stringify({ path: selectedPath })
1474
- })
1475
-
1476
- // 3. Completar Setup
1477
- localStorage.setItem('zugzweb_setup_completed', 'true')
1478
- setSetupCompleted(true)
1479
-
1480
- // Forzar carga de sesiones de este nuevo directorio
1481
- setTimeout(() => {
1482
- fetchSessions()
1483
- fetchInstances()
1484
- }, 1000)
1485
- } catch (err) {
1486
- console.error(err)
1487
- } finally {
1488
- setIsProcessing(false)
1489
- setErrorMsg('')
1490
- }
1491
- })}
1492
- </div>
1493
-
1494
- {/* Footer Informativo */}
1495
- <div className="text-center text-[10px] text-[#52525b] border-t border-[#1f1f23]/60 pt-4 shrink-0">
1496
- Podrás añadir, explorar o alternar más carpetas de proyectos en cualquier momento desde la barra lateral.
1497
- </div>
1498
- </div>
1499
- </div>
1500
- )
1501
- }
1502
-
1503
- return (
1504
- <div className="flex h-screen bg-[#09090b] text-[#f4f4f5] overflow-hidden antialiased font-sans select-none">
1505
-
1506
- {/* SIDEBAR: Lista de Sesiones e Instancias con Árbol de Subagentes */}
1507
- <div
1508
- style={{ width: `${sidebarWidth}px` }}
1509
- className="bg-[#0c0c0e] border-r border-[#1f1f23] flex flex-col justify-between shrink-0 relative"
1510
- >
1511
-
1512
- {/* Encabezado Principal */}
1513
- <div className="p-4 border-b border-[#1f1f23] flex items-center justify-between">
1514
- <div className="flex items-center gap-2">
1515
- <div className="p-1.5 bg-[#ffffff] rounded-md text-[#09090b]">
1516
- <Terminal size={18} className="font-bold animate-pulse" />
1517
- </div>
1518
- <span className="font-bold tracking-tight text-lg">ZugzWeb</span>
1519
- </div>
1520
- <button
1521
- onClick={() => navigateTo('/new')}
1522
- className="p-1.5 bg-[#27272a] hover:bg-[#3f3f46] text-[#fafafa] rounded-md transition duration-200 cursor-pointer"
1523
- title="Nueva Sesión"
1524
- >
1525
- <Plus size={16} />
1526
- </button>
1527
- </div>
1528
-
1529
- {/* ENCABEZADO DE PROYECTOS */}
1530
- <div className="p-3 border-b border-[#1f1f23] flex items-center justify-between bg-[#09090b]/40">
1531
- <span className="text-[10px] text-[#fafafa] uppercase font-extrabold tracking-wider flex items-center gap-1.5 select-none">
1532
- <Server size={11} className="text-emerald-400" />
1533
- <span>📁 GESTOR DE PROYECTOS</span>
1534
- </span>
1535
- <button
1536
- onClick={() => setShowAddProjectForm(!showAddProjectForm)}
1537
- 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"
1538
- title="Añadir Proyecto Local"
1539
- >
1540
- <Plus size={10} strokeWidth={2.5} />
1541
- <span>Proyecto</span>
1542
- </button>
1543
- </div>
1544
-
1545
- {/* FORMULARIO PARA AÑADIR PROYECTO */}
1546
- {showAddProjectForm && (
1547
- <form onSubmit={handleAddProject} className="p-3 bg-[#121215] border-b border-[#1f1f23] space-y-2.5">
1548
- <div className="space-y-1">
1549
- <label className="text-[9px] font-bold text-[#71717a] uppercase">Nombre del Proyecto</label>
1550
- <input
1551
- type="text"
1552
- required
1553
- value={newProjectName}
1554
- onChange={e => setNewProjectName(e.target.value)}
1555
- placeholder="Ej. Proyecto ROOT"
1556
- 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]"
1557
- />
1558
- </div>
1559
- <div className="space-y-1">
1560
- <label className="text-[9px] font-bold text-[#71717a] uppercase">Subcarpeta o Ruta Absoluta</label>
1561
- <div className="flex gap-2">
1562
- <input
1563
- type="text"
1564
- required
1565
- value={newProjectPath}
1566
- onChange={e => setNewProjectPath(e.target.value)}
1567
- placeholder="Ej. mi-proyecto o /Users/nombre/mi-proyecto"
1568
- className="flex-1 bg-[#0c0c0e] border border-[#27272a] rounded-lg px-2.5 py-1.5 text-xs text-white outline-none focus:border-[#3f3f46] font-mono"
1569
- />
1570
- <button
1571
- type="button"
1572
- onClick={() => openExplorer('Buscar Carpeta del Proyecto', newProjectPath || systemCwd, (selected) => setNewProjectPath(selected))}
1573
- className="px-2.5 py-1 bg-[#1c1c1f] hover:bg-[#27272a] border border-[#2e2e33] text-[10px] rounded-lg text-white font-semibold transition cursor-pointer shrink-0"
1574
- >
1575
- Buscar...
1576
- </button>
1577
- </div>
1578
- <div className="text-[9px] text-[#71717a] bg-[#09090b]/40 p-2 rounded border border-[#1f1f23] font-mono break-all leading-normal mt-1">
1579
- <span className="text-[#a1a1aa] font-semibold">📁 Ruta Destino: </span>
1580
- <span className="text-emerald-400">
1581
- {newProjectPath.trim().startsWith('/')
1582
- ? newProjectPath.trim()
1583
- : `${systemCwd}/${newProjectPath.trim() || 'mi-proyecto'}`}
1584
- </span>
1585
- </div>
1586
- </div>
1587
- <div className="flex gap-2 justify-end pt-1">
1588
- <button
1589
- type="button"
1590
- onClick={() => setShowAddProjectForm(false)}
1591
- className="px-2 py-1 text-[10px] text-[#71717a] hover:text-white transition cursor-pointer"
1592
- >
1593
- Cancelar
1594
- </button>
1595
- <button
1596
- type="submit"
1597
- className="px-2.5 py-1 text-[10px] bg-white text-[#09090b] font-bold rounded hover:bg-[#e4e4e7] transition cursor-pointer"
1598
- >
1599
- Crear e Instalar
1600
- </button>
1601
- </div>
1602
- </form>
1603
- )}
1604
-
1605
- {/* ARBOL DE PROYECTOS Y SESIONES */}
1606
- <div className="flex-1 overflow-y-auto p-2 space-y-3">
1607
- {/* Listar proyectos en localStorage */}
1608
- {projects.map(project => {
1609
- const isProjectActive = project.id === activeProjectId
1610
- const projectSessions = getSessionsForProject(project.id)
1611
- const isExpanded = !!expandedProjects[project.id]
1612
-
1613
- return (
1614
- <div key={project.id} className="space-y-1 border border-transparent rounded-lg p-1 bg-[#09090b]/20">
1615
- {/* Cabecera del Proyecto (Carpeta) */}
1616
- <div className={`flex items-center justify-between p-2 rounded-md transition duration-150 ${
1617
- isProjectActive ? 'bg-[#18181c] border border-[#2e2e33]/70' : 'hover:bg-[#121215]/50'
1618
- }`}>
1619
- <button
1620
- onClick={() => toggleProjectExpansion(project.id)}
1621
- className="flex-1 flex items-center gap-2 text-left cursor-pointer outline-none min-w-0"
1622
- >
1623
- <span className="text-[#a1a1aa] shrink-0">
1624
- {isExpanded ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
1625
- </span>
1626
- <div className="truncate">
1627
- <div className="flex items-center gap-1.5">
1628
- <span className="text-white text-xs font-extrabold truncate">
1629
- 📁 {project.name}
1630
- </span>
1631
- {isProjectActive && (
1632
- <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>
1633
- )}
1634
- </div>
1635
- <span className="text-[9px] text-[#52525b] block truncate font-mono mt-0.5">{project.path}</span>
1636
- </div>
1637
- </button>
1638
-
1639
- <div className="flex items-center gap-1 shrink-0 ml-1">
1640
- {project.id !== 'workspace_raiz' && (
1641
- <button
1642
- onClick={() => handleCreateSessionInProject(project.id)}
1643
- className="p-1 hover:bg-[#27272a] text-emerald-500 hover:text-emerald-400 rounded transition cursor-pointer"
1644
- title="Crear Sesión en este proyecto"
1645
- >
1646
- <Plus size={10} strokeWidth={3} />
1647
- </button>
1648
- )}
1649
- <button
1650
- onClick={() => {
1651
- openExplorer(`Carpeta para: ${project.name}`, project.path, async (selected) => {
1652
- setProjects(prev => prev.map(p => p.id === project.id ? { ...p, path: selected } : p))
1653
- if (project.id === activeProjectId) {
1654
- setIsProcessing(true)
1655
- setErrorMsg('Re-activando proyecto en nueva ruta...')
1656
- try {
1657
- await fetch('/api-custom/activate-project', {
1658
- method: 'POST',
1659
- headers: { 'Content-Type': 'application/json' },
1660
- body: JSON.stringify({ path: selected })
1661
- })
1662
- fetchSessions()
1663
- } catch (e) {
1664
- console.error(e)
1665
- } finally {
1666
- setIsProcessing(false)
1667
- setErrorMsg('')
1668
- }
1669
- }
1670
- })
1671
- }}
1672
- className="p-1 hover:bg-[#27272a] text-[#71717a] hover:text-white rounded transition cursor-pointer"
1673
- title="Explorar/Cambiar Carpeta del Proyecto"
1674
- >
1675
- <FolderOpen size={10} />
1676
- </button>
1677
- {!isProjectActive && project.id !== 'workspace_raiz' && (
1678
- <button
1679
- onClick={() => handleActivateProject(project.id)}
1680
- className="p-1 hover:bg-[#27272a] text-[#71717a] hover:text-white rounded transition cursor-pointer"
1681
- title="Activar Proyecto"
1682
- >
1683
- <Play size={10} className="fill-current" />
1684
- </button>
1685
- )}
1686
- {project.id !== 'workspace_raiz' && (
1687
- <button
1688
- onClick={() => handleDeleteProject(project.id)}
1689
- className="p-1 hover:bg-red-950/40 text-[#71717a] hover:text-red-400 rounded transition cursor-pointer"
1690
- title="Eliminar Proyecto"
1691
- >
1692
- <Square size={10} />
1693
- </button>
1694
- )}
1695
- </div>
1696
- </div>
1697
-
1698
- {/* Lista de sesiones anidadas del proyecto */}
1699
- {isExpanded && (
1700
- <div className="ml-3 pl-2.5 border-l border-[#1f1f23] space-y-1 pt-1.5 pb-0.5">
1701
- {project.id === 'workspace_raiz' ? (
1702
- <div className="text-[10px] text-[#71717a] py-1 pl-1 space-y-1 pr-2 leading-relaxed">
1703
- <span className="block font-bold text-amber-500/90">📁 Carpeta Base Central</span>
1704
- <p className="text-[#8e8e93]">
1705
- Para iniciar chats de desarrollo, crea primero un subproyecto con el botón <strong className="text-white">"+ Proyecto"</strong> de arriba.
1706
- </p>
1707
- </div>
1708
- ) : projectSessions.length === 0 ? (
1709
- <div className="text-[10px] text-[#52525b] italic py-1 pl-1">
1710
- No hay sesiones en este proyecto. Haz clic en el botón + de arriba para iniciar una.
1711
- </div>
1712
- ) : (
1713
- projectSessions.map(root => {
1714
- const isRootActive = root.id === currentSessionId
1715
- const subagents = getSubagentsFor(root.id)
1716
- const hasSubagents = subagents.length > 0
1717
-
1718
- return (
1719
- <div key={root.id} className="space-y-1">
1720
- {/* Sesión Principal */}
1721
- <div className={`w-full group/sess flex items-center justify-between rounded-lg p-1.5 transition duration-200 ${
1722
- isRootActive
1723
- ? 'bg-[#1c1c1f] border border-[#2e2e33] text-white shadow-sm'
1724
- : 'hover:bg-[#121215] text-[#a1a1aa] hover:text-white'
1725
- }`}>
1726
- <button
1727
- onClick={() => {
1728
- handleSelectSession(root.id, project.id)
1729
- }}
1730
- className="flex-1 text-left cursor-pointer outline-none min-w-0"
1731
- >
1732
- <span className="font-semibold truncate text-[11px] block">
1733
- 💬 {customSessionTitles[root.id] || root.title || `Sesión: ${root.id.substring(0, 8)}`}
1734
- </span>
1735
- <div className="flex items-center justify-between text-[9px] text-[#52525b] mt-0.5">
1736
- <span>{root.agent || 'sdd-orchestrator'}</span>
1737
- <span>{formatModelName(root.model)}</span>
1738
- </div>
1739
- </button>
1740
-
1741
- {/* Botón de eliminar sesión */}
1742
- <button
1743
- onClick={(e) => handleDeleteSession(root.id, e)}
1744
- className="opacity-0 group-hover/sess:opacity-100 p-1 hover:bg-red-950/40 text-[#71717a] hover:text-red-400 rounded transition cursor-pointer shrink-0 ml-1"
1745
- title="Eliminar Sesión"
1746
- >
1747
- <Square size={10} />
1748
- </button>
1749
- </div>
1750
-
1751
- {/* Subagentes hijas */}
1752
- {hasSubagents && (
1753
- <div className="ml-3 pl-2 border-l border-[#1f1f23]/60 space-y-1 py-0.5">
1754
- {subagents.map(sub => {
1755
- const isSubActive = sub.id === currentSessionId
1756
- return (
1757
- <button
1758
- key={sub.id}
1759
- onClick={() => {
1760
- handleSelectSession(sub.id, project.id)
1761
- }}
1762
- className={`w-full text-left p-1.5 rounded-md flex flex-col gap-0.5 transition duration-150 cursor-pointer text-[10px] ${
1763
- isSubActive
1764
- ? 'bg-[#18181c] border border-[#2e2e33]/40 text-white'
1765
- : 'hover:bg-[#0f0f12] text-[#71717a] hover:text-[#a1a1aa]'
1766
- }`}
1767
- >
1768
- <div className="flex items-center gap-1 font-medium truncate">
1769
- <CornerDownRight size={10} className="text-[#71717a] shrink-0" />
1770
- <span className="truncate">🤖 {sub.title || `Sub: ${sub.id.substring(0, 5)}`}</span>
1771
- </div>
1772
- </button>
1773
- )
1774
- })}
1775
- </div>
1776
- )}
1777
- </div>
1778
- )
1779
- })
1780
- )}
1781
- </div>
1782
- )}
1783
- </div>
1784
- )
1785
- })}
1786
-
1787
- {/* Inbox / Sesiones Sin Clasificar */}
1788
- {unclassifiedSessions.length > 0 && (
1789
- <div className="space-y-1 border border-dashed border-[#1f1f23] rounded-lg p-1 bg-[#09090b]/10 mt-2">
1790
- <div className="p-2 flex items-center justify-between">
1791
- <span className="text-xs font-bold text-amber-500/80 flex items-center gap-1.5">
1792
- 📥 Inbox / Sin Clasificar
1793
- </span>
1794
- <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">
1795
- {unclassifiedSessions.length}
1796
- </span>
1797
- </div>
1798
-
1799
- <div className="ml-1 pl-1 space-y-1 pt-1">
1800
- {unclassifiedSessions.map(root => {
1801
- const isRootActive = root.id === currentSessionId
1802
- const subagents = getSubagentsFor(root.id)
1803
- const hasSubagents = subagents.length > 0
1804
-
1805
- return (
1806
- <div key={root.id} className="space-y-1">
1807
- <div className="flex items-center gap-1">
1808
- <button
1809
- onClick={() => {
1810
- setCurrentSessionId(root.id)
1811
- setErrorMsg('')
1812
- navigateTo('/')
1813
- }}
1814
- className={`flex-1 text-left p-2 rounded-lg flex flex-col gap-0.5 transition duration-200 cursor-pointer ${
1815
- isRootActive
1816
- ? 'bg-[#1c1c1f] border border-[#2e2e33] text-white shadow-sm'
1817
- : 'hover:bg-[#121215] text-[#a1a1aa] hover:text-white'
1818
- }`}
1819
- >
1820
- <span className="font-semibold truncate text-[11px]">
1821
- 💬 {customSessionTitles[root.id] || root.title || `Sesión: ${root.id.substring(0, 8)}`}
1822
- </span>
1823
- <div className="flex items-center justify-between text-[9px] text-[#52525b]">
1824
- <span>{root.agent || 'sdd-orchestrator'}</span>
1825
- </div>
1826
- </button>
1827
-
1828
- {/* Botón rápido para mapear al proyecto activo */}
1829
- <button
1830
- onClick={() => {
1831
- setSessionMappings(prev => ({
1832
- ...prev,
1833
- [root.id]: activeProjectId
1834
- }))
1835
- }}
1836
- className="p-1 hover:bg-[#1c1c1f] text-[#71717a] hover:text-white rounded border border-[#1f1f23] transition cursor-pointer text-[9px] font-bold"
1837
- title="Mover al Proyecto Activo"
1838
- >
1839
- Mapear
1840
- </button>
1841
- </div>
1842
-
1843
- {hasSubagents && (
1844
- <div className="ml-3 pl-2 border-l border-[#1f1f23]/60 space-y-1 py-0.5">
1845
- {subagents.map(sub => {
1846
- const isSubActive = sub.id === currentSessionId
1847
- return (
1848
- <button
1849
- key={sub.id}
1850
- onClick={() => {
1851
- setCurrentSessionId(sub.id)
1852
- setErrorMsg('')
1853
- navigateTo('/')
1854
- }}
1855
- className={`w-full text-left p-1 rounded-md flex flex-col gap-0.5 transition duration-150 cursor-pointer text-[10px] ${
1856
- isSubActive
1857
- ? 'bg-[#18181c] border border-[#2e2e33]/40 text-white'
1858
- : 'hover:bg-[#0f0f12] text-[#71717a] hover:text-[#a1a1aa]'
1859
- }`}
1860
- >
1861
- <span className="truncate">🤖 {sub.title || `Sub: ${sub.id.substring(0, 5)}`}</span>
1862
- </button>
1863
- )
1864
- })}
1865
- </div>
1866
- )}
1867
- </div>
1868
- )
1869
- })}
1870
- </div>
1871
- </div>
1872
- )}
1873
- </div>
1874
-
1875
- {/* Notificaciones y Estado del Túnel */}
1876
- <div className="p-4 bg-[#09090b] border-t border-[#1f1f23] space-y-3">
1877
- <button
1878
- onClick={toggleNotifications}
1879
- className={`w-full flex items-center justify-center gap-2 py-2 px-3 rounded-md text-xs font-medium border transition cursor-pointer ${
1880
- notificationsEnabled
1881
- ? 'bg-transparent border-[#22c55e]/30 hover:border-[#22c55e]/50 text-[#22c55e]'
1882
- : 'bg-transparent border-[#27272a] hover:border-[#3f3f46] text-[#a1a1aa]'
1883
- }`}
1884
- >
1885
- {notificationsEnabled ? (
1886
- <>
1887
- <Bell size={14} />
1888
- <span>Notificaciones Activas</span>
1889
- </>
1890
- ) : (
1891
- <>
1892
- <BellOff size={14} />
1893
- <span>Activar Alertas Navegador</span>
1894
- </>
1895
- )}
1896
- </button>
1897
-
1898
- {tunnelUrl && (
1899
- <div className="p-2 bg-[#1c1c1f]/50 border border-[#2e2e33] rounded-lg space-y-1">
1900
- <div className="flex items-center gap-1.5 text-[11px] text-[#a1a1aa] font-medium">
1901
- <Globe size={11} className="text-[#3b82f6] animate-pulse" />
1902
- <span>Túnel Remoto Activo</span>
1903
- </div>
1904
- <a
1905
- href={tunnelUrl}
1906
- target="_blank"
1907
- rel="noopener noreferrer"
1908
- className="text-[10px] text-[#3b82f6] hover:underline flex items-center gap-1 font-mono break-all"
1909
- >
1910
- {tunnelUrl}
1911
- <ExternalLink size={8} />
1912
- </a>
1913
- </div>
1914
- )}
1915
- </div>
1916
- </div>
1917
-
1918
- {/* RESIZER HANDLE: Permite redimensionar la barra lateral de forma fluida */}
1919
- <div
1920
- onMouseDown={handleMouseDown}
1921
- 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"
1922
- >
1923
- <div className="w-[1px] h-8 bg-transparent group-hover:bg-white/40 group-active:bg-white/60 transition" />
1924
- </div>
1925
-
1926
- {/* CHAT / MAIN AREA */}
1927
- <div className="flex-1 flex flex-col bg-[#09090b] relative overflow-hidden">
1928
- {isNewSessionView ? (
1929
- <>
1930
- {/* Header de Nueva Sesión */}
1931
- <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">
1932
- <div className="flex items-center gap-2">
1933
- <Sparkles size={16} className="text-emerald-400 animate-pulse" />
1934
- <h1 className="text-sm font-bold text-[#fafafa]">Crear Nueva Sesión de Desarrollo</h1>
1935
- </div>
1936
- {sessions.length > 0 && (
1937
- <button
1938
- onClick={() => navigateTo('/')}
1939
- className="text-xs text-[#71717a] hover:text-white border border-[#1f1f23] hover:border-[#27272a] px-2.5 py-1 rounded-md transition cursor-pointer"
1940
- >
1941
- Cancelar
1942
- </button>
1943
- )}
1944
- </div>
1945
-
1946
- {/* Formulario de Nueva Sesión */}
1947
- <div className="flex-1 overflow-y-auto p-8 max-w-2xl mx-auto w-full space-y-6">
1948
- <div className="space-y-1.5">
1949
- <h2 className="text-xl font-bold tracking-tight text-white flex items-center gap-2">
1950
- <span>🚀 Comenzar un nuevo flujo</span>
1951
- </h2>
1952
- <p className="text-xs text-[#71717a]">
1953
- Elige el agente y modelo adecuados para estructurar tu especificación, codificar o auditar tu aplicación de forma guiada y asíncrona.
1954
- </p>
1955
- </div>
1956
-
1957
- <form onSubmit={handleStartNewSession} className="space-y-5">
1958
- {/* INPUT: TÍTULO DE LA SESIÓN */}
1959
- <div className="space-y-1.5">
1960
- <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Título de la Sesión</label>
1961
- <input
1962
- type="text"
1963
- value={newSessionTitle}
1964
- onChange={e => setNewSessionTitle(e.target.value)}
1965
- placeholder="Ej. Crear Login Form, Refactorizar Auth..."
1966
- 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"
1967
- required
1968
- />
1969
- </div>
1970
-
1971
- {/* SELECCIÓN DE AGENTES: CARDS INTERACTIVAS */}
1972
- <div className="space-y-1.5">
1973
- <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Agente Primario Inicial</label>
1974
- <div className="grid grid-cols-1 gap-2.5">
1975
- {AVAILABLE_AGENTS.map(agent => {
1976
- const isSelected = newSessionAgent === agent.id
1977
- return (
1978
- <button
1979
- key={agent.id}
1980
- type="button"
1981
- onClick={() => setNewSessionAgent(agent.id)}
1982
- className={`w-full text-left p-3.5 rounded-xl border transition-all duration-200 flex gap-3 cursor-pointer relative ${
1983
- isSelected
1984
- ? 'bg-[#121215] border-white/60 shadow-[0_0_12px_rgba(255,255,255,0.05)]'
1985
- : 'bg-[#0c0c0e] border-[#1f1f23] hover:border-[#27272a]'
1986
- }`}
1987
- >
1988
- <div
1989
- className="h-8 w-8 rounded-lg flex items-center justify-center shrink-0"
1990
- style={{ backgroundColor: `${agent.color}15`, color: agent.color }}
1991
- >
1992
- {agent.id === 'build' ? <Settings size={16} /> : agent.id === 'plan' ? <Code size={16} /> : <Sparkles size={16} />}
1993
- </div>
1994
- <div className="space-y-0.5">
1995
- <div className="flex items-center gap-2">
1996
- <span className="text-xs font-bold text-white">{agent.name}</span>
1997
- {agent.id === 'sdd-orchestrator' && (
1998
- <span className="text-[9px] bg-white/10 text-white/80 font-mono px-1 rounded uppercase tracking-wider">Por defecto</span>
1999
- )}
2000
- </div>
2001
- <p className="text-[11px] text-[#71717a] leading-relaxed">{agent.description}</p>
2002
- </div>
2003
- {isSelected && (
2004
- <div className="absolute right-4 top-4 h-4 w-4 bg-white rounded-full flex items-center justify-center text-[#09090b]">
2005
- <Check size={10} strokeWidth={3} />
2006
- </div>
2007
- )}
2008
- </button>
2009
- )
2010
- })}
2011
- </div>
2012
- </div>
2013
-
2014
- {/* SELECTOR DE MODELO */}
2015
- <div className="space-y-1.5">
2016
- <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Modelo Objetivo</label>
2017
- <div className="relative">
2018
- <select
2019
- value={newSessionModel}
2020
- onChange={e => setNewSessionModel(e.target.value)}
2021
- 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"
2022
- >
2023
- {POPULAR_MODELS.map(m => (
2024
- <option key={m.id} value={m.id}>
2025
- {m.name}
2026
- </option>
2027
- ))}
2028
- </select>
2029
- <div className="absolute inset-y-0 right-4 flex items-center pointer-events-none text-[#71717a]">
2030
- <ChevronDown size={16} />
2031
- </div>
2032
- </div>
2033
- </div>
2034
-
2035
- {/* TEXTAREA: PRIMERA INSTRUCCIÓN (OPCIONAL) */}
2036
- <div className="space-y-1.5">
2037
- <div className="flex justify-between items-center">
2038
- <label className="text-xs font-bold text-[#a1a1aa] uppercase tracking-wider">Primera Instrucción (Opcional)</label>
2039
- <span className="text-[10px] text-[#52525b]">Arranca de inmediato</span>
2040
- </div>
2041
- <textarea
2042
- value={newSessionFirstPrompt}
2043
- onChange={e => setNewSessionFirstPrompt(e.target.value)}
2044
- placeholder="Ej. @sdd-spec-writer Crea un contrato para una calculadora..."
2045
- 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"
2046
- />
2047
- </div>
2048
-
2049
- {/* BOTÓN DE CREAR */}
2050
- <button
2051
- type="submit"
2052
- disabled={isProcessing}
2053
- 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"
2054
- >
2055
- {isProcessing ? (
2056
- <RefreshCw size={14} className="animate-spin" />
2057
- ) : (
2058
- <Plus size={14} strokeWidth={2.5} />
2059
- )}
2060
- <span>{isProcessing ? 'Iniciando sesión...' : 'Crear e Iniciar Sesión'}</span>
2061
- </button>
2062
- </form>
2063
- </div>
2064
- </>
2065
- ) : (
2066
- <>
2067
- {/* Header de la Sesión */}
2068
- <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">
2069
- <div className="flex flex-col">
2070
- <h1 className="text-sm font-bold m-0 p-0 text-[#fafafa] flex items-center gap-2">
2071
- {(currentSession && customSessionTitles[currentSession.id]) || currentSession?.title || 'Cargando sesión...'}
2072
- {isProcessing && (
2073
- <span className="flex h-2 w-2 relative">
2074
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#22c55e] opacity-75"></span>
2075
- <span className="relative inline-flex rounded-full h-2 w-2 bg-[#22c55e]"></span>
2076
- </span>
2077
- )}
2078
- </h1>
2079
- {currentSession && (
2080
- <div className="flex items-center gap-2 text-[11px] text-[#71717a] mt-0.5">
2081
- <span>ID: <code className="text-[10px] font-mono bg-transparent p-0">{currentSession.id}</code></span>
2082
- <span>•</span>
2083
- <span>Costo: <span className="text-[#22c55e] font-mono">${(currentSession.cost || 0).toFixed(6)}</span></span>
2084
- <span>•</span>
2085
- <span>Tokens: In: {currentSession.tokens_input || 0} | Out: {currentSession.tokens_output || 0}</span>
2086
- </div>
2087
- )}
2088
- </div>
2089
-
2090
- <div className="flex items-center gap-2">
2091
- {errorMsg && (
2092
- <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">
2093
- <AlertCircle size={12} />
2094
- {errorMsg}
2095
- </span>
2096
- )}
2097
- <button
2098
- onClick={() => {
2099
- if (currentSessionId) {
2100
- fetchMessages(currentSessionId)
2101
- fetchSessionDetails(currentSessionId)
2102
- fetchSessionStatus()
2103
- }
2104
- }}
2105
- className="p-1.5 bg-[#1c1c1f] border border-[#2e2e33] hover:bg-[#27272a] text-[#a1a1aa] hover:text-[#ffffff] rounded-md transition duration-200 cursor-pointer"
2106
- title="Actualizar"
2107
- >
2108
- <RefreshCw size={14} className={isProcessing ? 'animate-spin' : ''} />
2109
- </button>
2110
- </div>
2111
- </div>
2112
-
2113
- {/* PANEL DE COMANDOS RÁPIDOS SUPERIOR */}
2114
- {currentSessionId && (
2115
- <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">
2116
- <span className="text-[10px] text-[#71717a] font-bold uppercase tracking-wider shrink-0 mr-1 flex items-center gap-1 select-none">
2117
- <Code size={11} />
2118
- <span>Comandos Rápidos:</span>
2119
- </span>
2120
- <button
2121
- onClick={() => handleQuickCommand('status')}
2122
- 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"
2123
- >
2124
- /status
2125
- </button>
2126
- <button
2127
- onClick={() => handleQuickCommand('undo')}
2128
- 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"
2129
- title="Deshacer última edición de archivo"
2130
- >
2131
- /undo
2132
- </button>
2133
- <button
2134
- onClick={() => handleQuickCommand('reset')}
2135
- 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"
2136
- title="Reiniciar y limpiar sesión"
2137
- >
2138
- /reset
2139
- </button>
2140
- <button
2141
- onClick={() => handleQuickCommand('loop', prompt('Introduce la tarea para el loop autónomo:', '') || '')}
2142
- 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"
2143
- >
2144
- <Sparkles size={10} />
2145
- <span>Iniciar Autopiloto (/loop)</span>
2146
- </button>
2147
- </div>
2148
- )}
2149
-
2150
- {/* HISTORIAL DE MENSAJES */}
2151
- <div
2152
- ref={chatContainerRef}
2153
- onScroll={handleScroll}
2154
- className="flex-1 overflow-y-auto p-6 space-y-6"
2155
- >
2156
- {!currentSession ? (
2157
- <div className="h-full flex flex-col items-center justify-center text-center max-w-md mx-auto space-y-6 p-4">
2158
- {errorMsg ? (
2159
- <>
2160
- <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">
2161
- <AlertCircle size={28} />
2162
- </div>
2163
- <div className="space-y-2">
2164
- <h2 className="text-lg font-bold text-white">Conexión con Opencode Pendiente</h2>
2165
- <p className="text-xs text-[#a1a1aa] leading-relaxed">
2166
- {errorMsg}
2167
- </p>
2168
- </div>
2169
-
2170
- <div className="w-full bg-[#0c0c0e] border border-[#1f1f23] rounded-xl p-4 text-left space-y-3">
2171
- <p className="text-[11px] font-bold text-[#fafafa] uppercase tracking-wider">💡 ¿Cómo solucionarlo?</p>
2172
- <p className="text-xs text-[#71717a] leading-relaxed">
2173
- Abre otra ventana de terminal en la carpeta raíz del proyecto y ejecuta el servidor de Opencode:
2174
- </p>
2175
- <div className="bg-[#18181b] border border-[#2e2e33] p-2.5 rounded-lg flex items-center justify-between font-mono text-xs text-white">
2176
- <span>opencode serve</span>
2177
- <button
2178
- type="button"
2179
- onClick={() => {
2180
- navigator.clipboard.writeText('opencode serve')
2181
- }}
2182
- className="text-[#a1a1aa] hover:text-white transition cursor-pointer"
2183
- title="Copiar comando"
2184
- >
2185
- <Copy size={12} />
2186
- </button>
2187
- </div>
2188
- <p className="text-[11px] text-[#71717a]">
2189
- Una vez levantado, ZugzWeb se conectará automáticamente y podrás controlar tu sesión.
2190
- </p>
2191
- </div>
2192
-
2193
- <button
2194
- type="button"
2195
- onClick={() => {
2196
- fetchSessions()
2197
- fetchInstances()
2198
- }}
2199
- 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"
2200
- >
2201
- <RefreshCw size={12} />
2202
- <span>Volver a intentar conexión</span>
2203
- </button>
2204
- </>
2205
- ) : (
2206
- <>
2207
- <div className="h-12 w-12 rounded-full border border-white/10 flex items-center justify-center text-[#71717a] animate-spin">
2208
- <RefreshCw size={20} />
2209
- </div>
2210
- <div className="space-y-1">
2211
- <h2 className="text-sm font-semibold text-white">Cargando sesión activa...</h2>
2212
- <p className="text-xs text-[#71717a]">Estableciendo canal seguro con tu arnés Opencode local</p>
2213
- </div>
2214
- </>
2215
- )}
2216
- </div>
2217
- ) : messages.length === 0 ? (
2218
- <div className="h-full flex flex-col items-center justify-center text-[#71717a] space-y-3">
2219
- <MessageSquare size={36} className="text-[#27272a]" />
2220
- <div className="text-center">
2221
- <p className="text-sm font-medium">No hay mensajes en esta sesión</p>
2222
- <p className="text-xs mt-0.5">Escribe una instrucción, comando o inicia un loop de desarrollo abajo.</p>
2223
- </div>
2224
- </div>
2225
- ) : (
2226
- messages.map((msg, idx) => {
2227
- const isUser = msg.info.role === 'user'
2228
- // Consolidar todo el texto de las partes del mensaje para copiar
2229
- const fullTextMessage = msg.parts
2230
- .filter(p => p.type === 'text')
2231
- .map(p => p.text)
2232
- .join('\n\n')
2233
-
2234
- return (
2235
- <div
2236
- key={msg.info.id || idx}
2237
- className={`flex gap-4 max-w-4xl mx-auto ${isUser ? 'justify-end' : 'justify-start'}`}
2238
- >
2239
- {!isUser && (
2240
- <div className="h-8 w-8 rounded-full border border-[#2e2e33] bg-[#18181b] flex items-center justify-center text-[#fafafa] shrink-0">
2241
- <Bot size={16} />
2242
- </div>
2243
- )}
2244
-
2245
- <div
2246
- className={`rounded-xl px-4 py-3 space-y-3 max-w-[85%] border shadow-sm group relative ${
2247
- isUser
2248
- ? 'bg-[#18181b] text-[#ffffff] border-[#2e2e33]'
2249
- : 'bg-[#0c0c0e] text-[#f4f4f5] border-[#1f1f23]'
2250
- }`}
2251
- >
2252
- {/* Encabezado del mensaje con fecha corregida */}
2253
- <div className="flex justify-between items-start border-b border-[#1f1f23]/40 pb-1 text-[11px] text-[#71717a] font-medium gap-2">
2254
- <div className="flex items-center gap-2 flex-wrap">
2255
- <span>{isUser ? 'TÚ' : 'AGENTE'}</span>
2256
- <span>•</span>
2257
- <span className="flex items-center gap-1 font-mono">
2258
- <Clock size={10} />
2259
- {formatMessageTime(msg.info)}
2260
- </span>
2261
- </div>
2262
-
2263
- {/* Botón de copiar resultado en la burbuja */}
2264
- {fullTextMessage && (
2265
- <button
2266
- onClick={() => handleCopyText(fullTextMessage, msg.info.id || String(idx))}
2267
- className="opacity-0 group-hover:opacity-100 p-1 hover:bg-[#1f1f23] text-[#a1a1aa] hover:text-white rounded transition cursor-pointer"
2268
- title="Copiar texto"
2269
- >
2270
- {copiedMessageId === (msg.info.id || String(idx)) ? (
2271
- <Check size={12} className="text-green-500" />
2272
- ) : (
2273
- <Copy size={12} />
2274
- )}
2275
- </button>
2276
- )}
2277
- </div>
2278
-
2279
- {/* Partes del mensaje */}
2280
- <div className="space-y-3">
2281
- {msg.parts.map((part, pIdx) => {
2282
- const partId = `${msg.info.id || idx}-${pIdx}`
2283
- const isExpanded = expandedParts[partId]
2284
-
2285
- // 1. TEXT PART
2286
- if (part.type === 'text') {
2287
- return (
2288
- <div
2289
- key={pIdx}
2290
- className="text-sm leading-relaxed prose prose-invert max-w-none text-[#e4e4e7] markdown-content"
2291
- dangerouslySetInnerHTML={renderMarkdown(part.text || '')}
2292
- />
2293
- )
2294
- }
2295
-
2296
- // 2. REASONING PART
2297
- if (part.type === 'reasoning') {
2298
- return (
2299
- <div key={pIdx} className="border border-[#2e2e33]/50 rounded-lg overflow-hidden bg-[#121215]/30">
2300
- <button
2301
- onClick={() => togglePartExpansion(partId)}
2302
- 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"
2303
- >
2304
- <div className="flex items-center gap-1.5">
2305
- <span className="h-1.5 w-1.5 rounded-full bg-[#a855f7] animate-pulse"></span>
2306
- <span>Pensamiento del modelo</span>
2307
- </div>
2308
- {isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
2309
- </button>
2310
- {isExpanded && (
2311
- <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">
2312
- {part.text}
2313
- </div>
2314
- )}
2315
- </div>
2316
- )
2317
- }
2318
-
2319
- // 3. TOOL CALL PART
2320
- if (part.type === 'tool') {
2321
- const status = part.state?.status || 'unknown'
2322
- const toolName = part.tool || 'Desconocido'
2323
- const hasOutput = !!part.state?.output
2324
-
2325
- return (
2326
- <div key={pIdx} className="border border-[#1f1f23] rounded-lg overflow-hidden bg-[#0a0a0c]">
2327
- <button
2328
- onClick={() => togglePartExpansion(partId)}
2329
- 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"
2330
- >
2331
- <div className="flex items-center gap-2">
2332
- <Settings size={13} className="text-amber-500 animate-spin" style={{ animationDuration: '3s' }} />
2333
- <span className="font-semibold text-amber-400">Herramienta: {toolName}</span>
2334
- <span className={`text-[10px] px-1.5 py-0.5 rounded border ${
2335
- status === 'success' || status === 'completed'
2336
- ? 'bg-green-950/20 text-green-400 border-green-900/50'
2337
- : 'bg-yellow-950/20 text-yellow-500 border-yellow-900/50'
2338
- }`}>
2339
- {status}
2340
- </span>
2341
- </div>
2342
- {isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
2343
- </button>
2344
-
2345
- {isExpanded && (
2346
- <div className="p-3 border-t border-[#1f1f23] bg-[#09090b] text-xs font-mono space-y-3">
2347
- <div>
2348
- <div className="text-[10px] text-[#71717a] font-bold uppercase tracking-wider mb-1">Entrada / Parámetros:</div>
2349
- <pre className="p-2 bg-[#0c0c0e] rounded border border-[#1f1f23] text-[#fafafa] overflow-x-auto max-h-40">
2350
- {JSON.stringify(part.state?.input || {}, null, 2)}
2351
- </pre>
2352
- </div>
2353
-
2354
- {hasOutput && (
2355
- <div>
2356
- <div className="text-[10px] text-[#71717a] font-bold uppercase tracking-wider mb-1">Salida / Resultado:</div>
2357
- <pre className="p-2 bg-[#0c0c0e] rounded border border-[#1f1f23] text-[#fafafa] overflow-x-auto max-h-60 overflow-y-auto">
2358
- {typeof part.state?.output === 'string'
2359
- ? part.state.output
2360
- : JSON.stringify(part.state?.output, null, 2)}
2361
- </pre>
2362
- </div>
2363
- )}
2364
- </div>
2365
- )}
2366
- </div>
2367
- )
2368
- }
2369
-
2370
- return null
2371
- })}
2372
- </div>
2373
- </div>
2374
-
2375
- {isUser && (
2376
- <div className="h-8 w-8 rounded-full border border-[#2e2e33] bg-[#ffffff] flex items-center justify-center text-[#09090b] shrink-0">
2377
- <User size={16} />
2378
- </div>
2379
- )}
2380
- </div>
2381
- )
2382
- })
2383
- )}
2384
-
2385
- {/* INDICADOR DE CARGA EN TIEMPO REAL DEL AGENTE */}
2386
- {isProcessing && (
2387
- <div className="flex gap-4 max-w-4xl mx-auto justify-start">
2388
- <div className="h-8 w-8 rounded-full border border-[#2e2e33] bg-[#18181b] flex items-center justify-center text-[#fafafa] shrink-0">
2389
- <Bot size={16} className="animate-spin text-emerald-400" style={{ animationDuration: '4s' }} />
2390
- </div>
2391
- <div className="rounded-xl px-4 py-3 bg-[#0c0c0e] text-[#f4f4f5] border border-[#1f1f23] max-w-[85%] shadow-sm space-y-1">
2392
- <div className="flex justify-between items-start border-b border-[#1f1f23]/40 pb-1 text-[11px] text-[#71717a] font-medium gap-2">
2393
- <div className="flex items-center gap-2">
2394
- <span>AGENTE</span>
2395
- <span>•</span>
2396
- <span className="text-[10px] text-emerald-500 font-bold animate-pulse">PENSANDO...</span>
2397
- </div>
2398
- </div>
2399
- <div className="flex items-center gap-1.5 text-sm text-[#a1a1aa] py-1.5">
2400
- <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-bounce" style={{ animationDelay: '0ms' }}></span>
2401
- <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-bounce" style={{ animationDelay: '150ms' }}></span>
2402
- <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-bounce" style={{ animationDelay: '300ms' }}></span>
2403
- <span className="ml-1 text-xs text-[#a1a1aa] font-medium">Conectando con el modelo de IA...</span>
2404
- </div>
2405
- </div>
2406
- </div>
2407
- )}
2408
- </div>
2409
-
2410
- {/* CONSOLA DE ENTRADA, SELECCIÓN DE MODELO Y ENVÍO */}
2411
- <div className="p-4 border-t border-[#1f1f23] bg-[#09090b] shrink-0">
2412
- <form onSubmit={handleSendPrompt} className="max-w-4xl mx-auto space-y-3">
2413
-
2414
- {/* Opciones Avanzadas del Prompt: Selector de Agente y Modelo */}
2415
- <div className="flex items-center gap-4 px-1 flex-wrap">
2416
- <div className="flex items-center gap-2">
2417
- <span className="text-[10px] text-[#71717a] uppercase font-bold tracking-wider select-none">
2418
- Agente Activo:
2419
- </span>
2420
- <div className="relative">
2421
- <select
2422
- value={selectedAgent}
2423
- onChange={e => setSelectedAgent(e.target.value)}
2424
- 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"
2425
- >
2426
- {AVAILABLE_AGENTS.map(a => (
2427
- <option key={a.id} value={a.id}>
2428
- {a.name}
2429
- </option>
2430
- ))}
2431
- </select>
2432
- <div className="absolute inset-y-0 right-1.5 flex items-center pointer-events-none text-[#71717a]">
2433
- <ChevronDown size={11} />
2434
- </div>
2435
- </div>
2436
- </div>
2437
-
2438
- <div className="flex items-center gap-2">
2439
- <span className="text-[10px] text-[#71717a] uppercase font-bold tracking-wider select-none">
2440
- Modelo Objetivo:
2441
- </span>
2442
- <div className="relative">
2443
- <select
2444
- value={selectedModel}
2445
- onChange={e => setSelectedModel(e.target.value)}
2446
- 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"
2447
- >
2448
- {POPULAR_MODELS.map(m => (
2449
- <option key={m.id} value={m.id}>
2450
- {m.name}
2451
- </option>
2452
- ))}
2453
- </select>
2454
- <div className="absolute inset-y-0 right-1.5 flex items-center pointer-events-none text-[#71717a]">
2455
- <ChevronDown size={11} />
2456
- </div>
2457
- </div>
2458
- </div>
2459
- </div>
2460
-
2461
- {/* Input Textarea principal */}
2462
- <div className="relative border border-[#27272a] focus-within:border-[#3f3f46] rounded-xl bg-[#0c0c0e] overflow-hidden shadow-lg transition duration-200">
2463
- <textarea
2464
- value={inputValue}
2465
- onChange={e => setInputValue(e.target.value)}
2466
- disabled={!currentSessionId || isProcessing}
2467
- onKeyDown={e => {
2468
- if (e.key === 'Enter' && !e.shiftKey) {
2469
- e.preventDefault()
2470
- handleSendPrompt(e)
2471
- }
2472
- }}
2473
- placeholder={
2474
- !currentSessionId
2475
- ? "Debe haber una sesión activa de Opencode para enviar prompts. Por favor, inicia conexión o crea una sesión."
2476
- : isProcessing
2477
- ? "El agente está procesando tu solicitud... Por favor, espera a que termine o haz clic en 'Abortar' para cancelar."
2478
- : inputValue.startsWith('/')
2479
- ? "Escribe los argumentos para el comando... (ej. /loop Crear login)"
2480
- : `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)`
2481
- }
2482
- 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"
2483
- />
2484
-
2485
- <div className="absolute right-3 bottom-3 flex items-center gap-2">
2486
- {isProcessing ? (
2487
- <button
2488
- type="button"
2489
- onClick={handleAbort}
2490
- 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"
2491
- title="Abortar ejecución"
2492
- >
2493
- <Square size={12} className="fill-current" />
2494
- <span>Abortar</span>
2495
- </button>
2496
- ) : (
2497
- <button
2498
- type="submit"
2499
- disabled={!inputValue.trim() || !currentSessionId}
2500
- 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"
2501
- >
2502
- <Play size={10} className="fill-current" />
2503
- <span>Enviar</span>
2504
- </button>
2505
- )}
2506
- </div>
2507
- </div>
2508
-
2509
- <div className="flex justify-between items-center text-[11px] text-[#71717a] px-1">
2510
- <div className="flex items-center gap-3">
2511
- <span className="flex items-center gap-1">
2512
- <span className={`h-1.5 w-1.5 rounded-full ${isProcessing ? 'bg-amber-500 animate-pulse' : 'bg-green-500'}`}></span>
2513
- <span>{isProcessing ? 'Procesando comando/loop' : 'Listo'}</span>
2514
- </span>
2515
- <span>•</span>
2516
- <span>Enlazado al puerto :{currentPort} de Opencode</span>
2517
- </div>
2518
- <span className="font-mono">v1.2.0-web</span>
2519
- </div>
2520
- </form>
2521
- </div>
2522
- </>
2523
- )}
2524
- </div>
2525
-
2526
- {/* MODAL DEL EXPLORADOR DE ARCHIVOS GENERAL */}
2527
- {showExplorerModal && (
2528
- <div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4 z-[9999] animate-fadeIn">
2529
- <div className="w-full max-w-xl bg-[#0c0c0e] border border-[#1f1f23] rounded-2xl shadow-2xl p-6 space-y-4 flex flex-col max-h-[85vh]">
2530
- {/* Header del Modal */}
2531
- <div className="flex items-center justify-between shrink-0 border-b border-[#1f1f23] pb-3">
2532
- <h3 className="text-sm font-bold text-white flex items-center gap-2">
2533
- <FolderOpen size={16} className="text-[#3b82f6]" />
2534
- <span>{explorerTitle}</span>
2535
- </h3>
2536
- <button
2537
- type="button"
2538
- onClick={() => setShowExplorerModal(false)}
2539
- className="text-xs text-[#71717a] hover:text-white border border-[#1f1f23] hover:border-[#27272a] px-2.5 py-1 rounded-md transition cursor-pointer"
2540
- >
2541
- Cerrar
2542
- </button>
2543
- </div>
2544
-
2545
- {/* Contenido del Explorador */}
2546
- <div className="flex-1 overflow-y-auto scrollbar-none py-1">
2547
- {renderExplorerContent((selectedPath) => {
2548
- if (explorerCallback) {
2549
- explorerCallback(selectedPath)
2550
- }
2551
- setShowExplorerModal(false)
2552
- })}
2553
- </div>
2554
- </div>
2555
- </div>
2556
- )}
2557
-
2558
- </div>
2559
- )
2560
- }