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