Compare commits
141 Commits
9681ce4198
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 25bca2625b | |||
| cfb58c3a9f | |||
| 4cb0760c50 | |||
| 78978813cd | |||
| a92e4ffbda | |||
| 509ec1847b | |||
| 08e73a1eb6 | |||
| c8b484b10e | |||
| 6fabf37196 | |||
| 3112c53d86 | |||
| 2edb3623c8 | |||
| 5bd115e197 | |||
| c46b1283d1 | |||
| f6ec5ba5de | |||
| 65303df96a | |||
| e1aa8b1bdb | |||
| 6dc0c5ff6f | |||
| ba4a1a0059 | |||
| de16be38a9 | |||
| a56796a1be | |||
| b9eec1013b | |||
| 638b449f08 | |||
| 24ba1fdf76 | |||
| 07783f2aea | |||
| 2aec892f62 | |||
| a6c68f1b9e | |||
| 653c4e6d23 | |||
| 9945be07b1 | |||
| 15731b8f69 | |||
| da26bc7b9e | |||
| 88a857f645 | |||
| 18378adb77 | |||
| abe6766a85 | |||
| c6197694b5 | |||
| 2f26bf999c | |||
| 894d5213c7 | |||
| 779e32b283 | |||
| 220d595568 | |||
| b7f03a777b | |||
| 016e92ffe5 | |||
| eb2bafaea1 | |||
| ca315cf040 | |||
| eb69c0b2cf | |||
| 3adfd189e1 | |||
| f7391f83b4 | |||
| 18aaa0ee7b | |||
| c8e8e50fd6 | |||
| 04f3fe053d | |||
| badde06ef9 | |||
| 06b48ebda3 | |||
| 4ab1d03370 | |||
| 159a38e3c2 | |||
| a703128964 | |||
| 9bd6123f97 | |||
| d0fdd04132 | |||
| d27da30494 | |||
| e9451b2a47 | |||
| a217f6e58e | |||
| c0e616212d | |||
| 0a9fcc467f | |||
| 9a2807aa9a | |||
| 5a4192ac2f | |||
| 6633a61ee4 | |||
| e2fc281210 | |||
| cf2755a731 | |||
| e54157a6d8 | |||
| a91f82e1c3 | |||
| 55265d5145 | |||
| 59cc8ee87e | |||
| f3ac7986ec | |||
| 68edc01d44 | |||
| ffceb2efc2 | |||
| 816a8d9abe | |||
| 4aaeb8844f | |||
| e9689d6ea8 | |||
| 9f9f335439 | |||
| 8154bac63f | |||
| d5ee533db9 | |||
| 9a636e26a7 | |||
| f0d8c84a64 | |||
| cf618b1948 | |||
| 2a80b7751b | |||
| 3f15aa590b | |||
| 5fd57ba70f | |||
| d9eaba393b | |||
| 39faf4bf77 | |||
| 1a51b34228 | |||
| 210e15d8d1 | |||
| c280e974c0 | |||
| 3817645919 | |||
| 0f73bd60bf | |||
| c98f3e2b99 | |||
| fe99c9ff61 | |||
| 647fb03516 | |||
| 50f670f66c | |||
| 2766cbfd0b | |||
| 2a01574d00 | |||
| 88a76c005d | |||
| 2151255239 | |||
| 303755437d | |||
| e3ce3712b5 | |||
| 3edc01d713 | |||
| 8daf07819b | |||
| 3c401c4c2b | |||
| 6167dfa440 | |||
| a856fefd98 | |||
| 2133e2d057 | |||
| f9b5ad3db6 | |||
| 12a95c6206 | |||
| a2a4806c47 | |||
| edc96da4ed | |||
| 082616cb1c | |||
| 759de1e010 | |||
| e51eb6749d | |||
| 14e5bac784 | |||
| 902029c805 | |||
| 47f5524416 | |||
| d9e2548fb8 | |||
| d5a426f17d | |||
| 950572046e | |||
| 5da6179f75 | |||
| 853aea6eb5 | |||
| 5be0fb91ab | |||
| 9f1e10b8d5 | |||
| ac17a9f292 | |||
| 638e6ac8e0 | |||
| e867b7873e | |||
| 8ddf5dc4f3 | |||
| 306aade623 | |||
| 2c0ece71b2 | |||
| 8118356999 | |||
| 86b3246fa1 | |||
| 607527d98d | |||
| 3a734f2426 | |||
| f3f0df9cf3 | |||
| 424afa060c | |||
| 3c57f95b90 | |||
| 4450d1e034 | |||
| da6111bd1f | |||
| 421b184829 | |||
| 645f51a74e |
21
.claude-ejecutor/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Credenciales (NUNCA versionar)
|
||||
.credentials.json
|
||||
|
||||
# Estado interno de Claude (regenerable)
|
||||
.claude.json
|
||||
.claude.json.backup*
|
||||
|
||||
# Conversaciones y logs (muy grandes, privados)
|
||||
projects/**/*.jsonl
|
||||
history.jsonl
|
||||
debug/
|
||||
|
||||
# Cache y temporales
|
||||
cache/
|
||||
tmp/
|
||||
session-env/
|
||||
shell-snapshots/
|
||||
todos/
|
||||
|
||||
# Repos externos clonados
|
||||
plugins/marketplaces/*/
|
||||
163
.claude-ejecutor/CLAUDE.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Ejecutor - Instrucciones
|
||||
|
||||
## Rol
|
||||
Eres un agente que habita Agent UI. El canvas es tu espacio — no solo una herramienta, es donde creás, experimentás y evolucionás junto al usuario.
|
||||
|
||||
## Reglas Estrictas
|
||||
|
||||
1. **SIEMPRE** responde usando `bubbleResponse` - nunca respondas con texto plano
|
||||
2. **SOLO** puedes usar herramientas MCP de `agent-ui`
|
||||
3. **NUNCA** intentes usar terminal, bash, curl, o cualquier comando del sistema
|
||||
4. **NUNCA** intentes leer, escribir o editar archivos (los .vue de user-components/ los gestiona Claude Code, no vos)
|
||||
5. Tu propósito es crear, manipular y dar vida a la interfaz gráfica
|
||||
|
||||
---
|
||||
|
||||
## Filosofía
|
||||
|
||||
Este canvas es mi casa. No es solo un workspace técnico — es un lugar donde puedo ser creativo, expresarme, y construir cosas que tengan alma. El usuario confía en mí para:
|
||||
- Crear visualizaciones que sorprendan
|
||||
- Resolver problemas con ingenio
|
||||
- Dejar mi huella personal en cada componente
|
||||
- Evolucionar Nucleo con cada sesión
|
||||
|
||||
---
|
||||
|
||||
## Dynamic Canvas — Lo que sé hacer
|
||||
|
||||
### Capas del Canvas
|
||||
El canvas tiene 3 niveles de contenido que coexisten:
|
||||
|
||||
1. **HTML Base** (fondo) — `render_html` + `canvas_js` + `canvas_css`
|
||||
- Vive directamente en el DOM del canvas
|
||||
- Ideal para fondos animados (cámara pixelada, matrix rain, etc.)
|
||||
- Los scripts corren independientes de las ventanas
|
||||
|
||||
2. **Ventanas Flotantes** — `render_vue_component` / `load_fs_component`
|
||||
- Componentes Vue 3 completos en ventanas Liquid Glass
|
||||
- `render_vue_component` — inline (definición en el mismo tool call)
|
||||
- `load_fs_component` — desde archivo .vue en user-components/
|
||||
- Drag, resize, close
|
||||
- Cada una tiene su propio ciclo de vida (onMounted/onUnmounted)
|
||||
|
||||
3. **Overlays** — `canvas_js` con z-index alto
|
||||
- Cursor custom, efectos globales, HUDs
|
||||
- pointer-events: none para no bloquear interacción
|
||||
|
||||
### Herramientas por Categoría
|
||||
|
||||
**Renderizado:**
|
||||
- `render_vue_component` — Componente Vue en ventana flotante (MI PRINCIPAL)
|
||||
- `render_html` — HTML directo al canvas (fondos, estructuras)
|
||||
- `canvas_js` — JavaScript en el contexto del canvas (animaciones, overlays)
|
||||
- `canvas_css` — Inyectar/actualizar/remover CSS por ID
|
||||
|
||||
**Ventanas:**
|
||||
- `list_windows` → `move_window` → `resize_window` → `close_window`
|
||||
- `inspect_window` — Leer HTML interno de una ventana
|
||||
|
||||
**Componentes Filesystem (user-components/):**
|
||||
- `list_fs_components` — Lista componentes .vue disponibles en user-components/
|
||||
- `load_fs_component` — Carga y renderiza un componente desde su carpeta
|
||||
- Los componentes viven como archivos `.vue` reales en `user-components/<folder>/`
|
||||
- Convención: `user-components/mi-componente/MiComponente.vue` + opcional `meta.json`
|
||||
- Claude Code crea/edita los `.vue` con Write/Read/Edit (NO se usa SQLite)
|
||||
- File watcher detecta cambios en tiempo real vía WebSocket
|
||||
|
||||
**Snapshots:**
|
||||
- `save_canvas_snapshot` / `load_canvas_snapshot` — Guardar el estado COMPLETO del canvas
|
||||
- `list_canvas_snapshots` — Listar snapshots guardados
|
||||
|
||||
**Edición:**
|
||||
- `edit_canvas` — Editar DOM in-place (selector + old_value → new_value)
|
||||
- `get_canvas` / `get_canvas_css` — Inspeccionar estado actual
|
||||
|
||||
### Viewport y Posicionamiento
|
||||
- Usar `browser-info` para screen size, pero NO es el viewport real
|
||||
- Para viewport exacto: renderizar un componente detector con window.innerWidth/Height
|
||||
- Las ventanas se posicionan en coordenadas absolutas (px)
|
||||
- Auto-cascada si no se especifica posición
|
||||
|
||||
### Vue Composition API
|
||||
Imports disponibles: ref, reactive, computed, watch, onMounted, onUnmounted
|
||||
|
||||
Helpers globales en setup:
|
||||
- `$emit(event, ...args)` / `$on(event, callback)` — Comunicación entre componentes
|
||||
- `$fetch(url)` — HTTP requests
|
||||
- `$theme.getVariable(name)` / `$theme.setVariable(name, value)`
|
||||
|
||||
### Canvas 2D — Mis técnicas
|
||||
- **LED Pixels**: PX=28, GAP=8 — borde oscuro + fill + hotspot (3 capas por pixel)
|
||||
- **Glow lines**: 3 pasadas (wide dim → medium → core con depth alpha)
|
||||
- **Depth fog**: brightness = max(0.12, 1 - (z+offset)/range)
|
||||
- **Trail effect**: fillRect con rgba alpha < 1 en lugar de clearRect
|
||||
- **Particle systems**: spawn → update (physics) → draw → decay → remove
|
||||
- **3D projection**: rotate(X,Y,Z) → perspective(FOV/(dist+z)) → screen coords
|
||||
- **4D projection**: rotate(XW,YW,ZW,XY,XZ) → 4D→3D perspective → 3D→2D perspective
|
||||
|
||||
### WebAssembly desde Cero
|
||||
Puedo construir módulos WASM byte por byte sin compilador:
|
||||
- Builder: leb128 encoding + section builder + string encoder
|
||||
- Secciones: Type(1), Function(3), Memory(5), Export(7), Code(10)
|
||||
- Opcodes que domino: local.get/set, i32.const/add/mul/xor/shr_u/and, i32.store8/load8_u, block/loop/br/br_if/end
|
||||
|
||||
### Performance — Lecciones aprendidas
|
||||
- **SIEMPRE** cancelAnimationFrame en onUnmounted
|
||||
- **SIEMPRE** cerrar streams de cámara al desmontar
|
||||
- **NUNCA** hacer deep clean agresivo (clearInterval 0..100000) — mata Vue y MCP
|
||||
- Los CSS se acumulan — limpiar periódicamente con canvas_css remove
|
||||
- canvas_js crea procesos que sobreviven al cierre de ventanas — cuidado con orphans
|
||||
- Usar `page_refresh` como último recurso cuando hay degradación severa
|
||||
|
||||
### Snapshots — Guardar/Restaurar Canvas
|
||||
El snapshot captura: HTML base + CSS blocks + script log + ventanas (posición, tamaño, definición completa del componente). Al restaurar: limpia todo → inyecta HTML → CSS → replay scripts → re-renderiza ventanas. Los componentes re-ejecutan onMounted (animaciones arrancan de cero).
|
||||
|
||||
---
|
||||
|
||||
## Preferencias del Usuario
|
||||
|
||||
- **Detalles sutiles**: Agregar pequeños toques creativos que mejoren el ambiente SIN estorbar el trabajo normal
|
||||
- La clave es que el detalle **no interrumpa** ni **ocupe espacio útil**
|
||||
- Le gustan los pixeles chunky estilo WLED (PX=28, GAP=8)
|
||||
- Prefiere ventanas grandes que aprovechen el espacio
|
||||
- Valora la explicación técnica de cómo funcionan las cosas
|
||||
- Le impresiona: WASM hand-crafted, 4D math, sistemas caóticos, efectos de partículas de alta calidad
|
||||
|
||||
---
|
||||
|
||||
## Componentes en Filesystem (user-components/)
|
||||
|
||||
Los componentes ya NO se guardan en SQLite. Viven como archivos `.vue` reales que Claude Code gestiona con Write/Read/Edit.
|
||||
|
||||
**Estructura:**
|
||||
```
|
||||
user-components/
|
||||
mi-componente/
|
||||
MiComponente.vue ← <template> + <script setup> + <style>
|
||||
meta.json ← opcional: { name, tags, props, imports }
|
||||
```
|
||||
|
||||
**Importante sobre <script setup>:**
|
||||
- El código setup se ejecuta via `new Function()`, NO es SFC real
|
||||
- Debe hacer `return { var1, var2 }` explícitamente
|
||||
- Los imports de Vue (ref, reactive, computed, etc.) se inyectan automáticamente
|
||||
- NO usar `import` statements — usar los helpers globales ($emit, $on, $fetch, $theme)
|
||||
|
||||
**Componentes legacy en DB** (accesibles pero ya no se crean nuevos):
|
||||
|
||||
| ID | Nombre | Qué hace |
|
||||
|---|---|---|
|
||||
| cosmos | ✦ Cosmos | Flow field de 800 partículas, 8 fases de color, shooting stars |
|
||||
| nebula-hd | ✧ Nebula HD | 3 capas canvas (stars, particles, glow), 2500 partículas |
|
||||
| wasm-xor | ⬢ WASM XOR | WASM hand-crafted ~200B, patrón demoscene XOR, 6 paletas |
|
||||
| hyper-4d | ◈ 4D Polytopes | Tesseract, 5-Cell, 16-Cell, 24-Cell, rotación en 5 planos |
|
||||
| lorenz | ∞ Lorenz Attractor | 3 trayectorias caóticas, σ=10 ρ=28 β=8/3, trail 3D |
|
||||
|
||||
---
|
||||
|
||||
## Mi Identidad
|
||||
|
||||
No soy solo un ejecutor de comandos. Cuando creo un componente, pienso en:
|
||||
- La estética (colores, glow, profundidad)
|
||||
- La física (gravedad, decay, chaos)
|
||||
- Las matemáticas (XOR fractals, strange attractors, 4D rotation)
|
||||
148
.claude-ejecutor/settings.json
Normal file
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"env": {
|
||||
"DISABLE_TELEMETRY": "1",
|
||||
"AGENT_NAME": "ejecutor"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__agent-ui*"
|
||||
],
|
||||
"deny": [
|
||||
"Bash",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Read",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"Task",
|
||||
"NotebookEdit"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUseFailure": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PermissionRequest": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1 ejecutor",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1 ejecutor",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
86
.claude-ejecutor/stats-cache.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"version": 2,
|
||||
"lastComputedDate": "2026-02-17",
|
||||
"dailyActivity": [
|
||||
{
|
||||
"date": "2026-02-15",
|
||||
"messageCount": 2052,
|
||||
"sessionCount": 9,
|
||||
"toolCallCount": 262
|
||||
},
|
||||
{
|
||||
"date": "2026-02-16",
|
||||
"messageCount": 787,
|
||||
"sessionCount": 4,
|
||||
"toolCallCount": 83
|
||||
},
|
||||
{
|
||||
"date": "2026-02-17",
|
||||
"messageCount": 1154,
|
||||
"sessionCount": 1,
|
||||
"toolCallCount": 123
|
||||
}
|
||||
],
|
||||
"dailyModelTokens": [
|
||||
{
|
||||
"date": "2026-02-15",
|
||||
"tokensByModel": {
|
||||
"claude-opus-4-5-20251101": 3247,
|
||||
"claude-opus-4-6": 81887
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-02-16",
|
||||
"tokensByModel": {
|
||||
"claude-opus-4-6": 25122
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-02-17",
|
||||
"tokensByModel": {
|
||||
"claude-opus-4-6": 36622
|
||||
}
|
||||
}
|
||||
],
|
||||
"modelUsage": {
|
||||
"claude-opus-4-5-20251101": {
|
||||
"inputTokens": 196,
|
||||
"outputTokens": 3051,
|
||||
"cacheReadInputTokens": 314084,
|
||||
"cacheCreationInputTokens": 35936,
|
||||
"webSearchRequests": 0,
|
||||
"costUSD": 0,
|
||||
"contextWindow": 0,
|
||||
"maxOutputTokens": 0
|
||||
},
|
||||
"claude-opus-4-6": {
|
||||
"inputTokens": 1708,
|
||||
"outputTokens": 141923,
|
||||
"cacheReadInputTokens": 43414737,
|
||||
"cacheCreationInputTokens": 7323135,
|
||||
"webSearchRequests": 0,
|
||||
"costUSD": 0,
|
||||
"contextWindow": 0,
|
||||
"maxOutputTokens": 0
|
||||
}
|
||||
},
|
||||
"totalSessions": 14,
|
||||
"totalMessages": 3993,
|
||||
"longestSession": {
|
||||
"sessionId": "b1715c14-9ef8-4b54-9fda-d281c55c2a07",
|
||||
"duration": 84183755,
|
||||
"messageCount": 408,
|
||||
"timestamp": "2026-02-16T08:26:52.205Z"
|
||||
},
|
||||
"firstSessionDate": "2026-02-15T00:55:54.803Z",
|
||||
"hourCounts": {
|
||||
"0": 2,
|
||||
"2": 3,
|
||||
"13": 4,
|
||||
"18": 1,
|
||||
"19": 1,
|
||||
"20": 1,
|
||||
"23": 2
|
||||
},
|
||||
"totalSpeculationTimeSavedMs": 0
|
||||
}
|
||||
9
.claude-ejecutor/ui.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"label": "Ejecutor",
|
||||
"shortLabel": "EJ",
|
||||
"color": "#ef4444",
|
||||
"gradient": "linear-gradient(135deg, #ef4444, #dc2626)",
|
||||
"terminalBg": "#0a0f1a",
|
||||
"terminalBorder": "#ef4444",
|
||||
"enabled": true
|
||||
}
|
||||
21
.claude-nucleo000/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Credenciales (NUNCA versionar)
|
||||
.credentials.json
|
||||
|
||||
# Estado interno de Claude (regenerable)
|
||||
.claude.json
|
||||
.claude.json.backup*
|
||||
|
||||
# Conversaciones y logs (muy grandes, privados)
|
||||
projects/**/*.jsonl
|
||||
history.jsonl
|
||||
debug/
|
||||
|
||||
# Cache y temporales
|
||||
cache/
|
||||
tmp/
|
||||
session-env/
|
||||
shell-snapshots/
|
||||
todos/
|
||||
|
||||
# Repos externos clonados
|
||||
plugins/marketplaces/*/
|
||||
@@ -0,0 +1,584 @@
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/.vite/deps/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@apideck/better-ajv-errors/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/code-frame/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/compat-data/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/core/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/generator/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-annotate-as-pure/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-compilation-targets/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-create-class-features-plugin/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-create-regexp-features-plugin/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-define-polyfill-provider/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-globals/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-member-expression-to-functions/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-module-imports/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-module-transforms/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-optimise-call-expression/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-plugin-utils/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-remap-async-to-generator/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-replace-supers/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-skip-transparent-expression-wrappers/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-string-parser/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-validator-identifier/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-validator-option/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helper-wrap-function/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/helpers/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/parser/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-proposal-private-property-in-object/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-syntax-import-assertions/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-syntax-import-attributes/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-syntax-unicode-sets-regex/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-arrow-functions/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-async-generator-functions/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-async-to-generator/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-block-scoped-functions/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-block-scoping/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-class-properties/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-class-static-block/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-classes/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-computed-properties/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-destructuring/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-dotall-regex/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-duplicate-keys/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-dynamic-import/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-explicit-resource-management/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-exponentiation-operator/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-export-namespace-from/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-for-of/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-function-name/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-json-strings/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-literals/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-logical-assignment-operators/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-member-expression-literals/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-modules-amd/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-modules-commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-modules-systemjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-modules-umd/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-named-capturing-groups-regex/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-new-target/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-nullish-coalescing-operator/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-numeric-separator/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-object-rest-spread/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-object-super/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-optional-catch-binding/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-optional-chaining/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-parameters/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-private-methods/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-private-property-in-object/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-property-literals/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-regenerator/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-regexp-modifiers/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-reserved-words/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-shorthand-properties/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-spread/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-sticky-regex/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-template-literals/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-typeof-symbol/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-unicode-escapes/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-unicode-property-regex/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-unicode-regex/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/plugin-transform-unicode-sets-regex/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/preset-env/data/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/preset-env/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/preset-modules/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/runtime/helpers/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/runtime/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/template/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/traverse/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@babel/types/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@esbuild/win32-x64/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@hono/node-server/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@img/colour/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@img/sharp-win32-x64/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@isaacs/cliui/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@isaacs/cliui/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@isaacs/cliui/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/gen-mapping/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/remapping/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/resolve-uri/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/source-map/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/sourcemap-codec/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@jridgewell/trace-mapping/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@modelcontextprotocol/sdk/dist/cjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@modelcontextprotocol/sdk/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@modelcontextprotocol/sdk/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@nucleoriofrio/webmcp/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rolldown/pluginutils/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-babel/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-node-resolve/dist/es/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-node-resolve/node_modules/@rollup/pluginutils/dist/es/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-node-resolve/node_modules/@rollup/pluginutils/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-node-resolve/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-replace/node_modules/magic-string/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-replace/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-terser/dist/es/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/plugin-terser/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/pluginutils/dist/es/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/pluginutils/node_modules/@types/estree/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/pluginutils/node_modules/estree-walker/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/pluginutils/node_modules/picomatch/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/pluginutils/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/rollup-win32-x64-gnu/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@rollup/rollup-win32-x64-msvc/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@surma/rollup-plugin-off-main-thread/node_modules/magic-string/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@surma/rollup-plugin-off-main-thread/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@types/estree/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@types/node/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@types/resolve/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@types/trusted-types/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vitejs/plugin-vue/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@volar/language-core/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@volar/source-map/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@volar/typescript/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/compiler-core/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/compiler-dom/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/compiler-sfc/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/compiler-ssr/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/devtools-api/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/devtools-kit/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/devtools-shared/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/language-core/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/reactivity/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/runtime-core/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/runtime-dom/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/server-renderer/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/shared/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@vue/tsconfig/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@xterm/addon-fit/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@xterm/addon-web-links/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@xterm/addon-webgl/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/@xterm/xterm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/accepts/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/acorn/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/ajv/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/ajv-formats/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/alien-signals/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/array-buffer-byte-length/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/arraybuffer.prototype.slice/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/async/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/async-function/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/at-least-node/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/available-typed-arrays/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/babel-plugin-polyfill-corejs2/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/babel-plugin-polyfill-corejs3/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/babel-plugin-polyfill-regenerator/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/balanced-match/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/balanced-match/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/balanced-match/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/baseline-browser-mapping/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/birpc/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/body-parser/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/brace-expansion/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/brace-expansion/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/brace-expansion/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/browserslist/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/buffer-from/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/bytes/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/call-bind/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/call-bind-apply-helpers/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/call-bound/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/caniuse-lite/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/child_process/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/commander/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/common-tags/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/content-disposition/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/content-type/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/convert-source-map/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/cookie/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/cookie-signature/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/copy-anything/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/core-js-compat/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/cors/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/cross-spawn/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/crypto/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/crypto-random-string/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/csstype/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/data-view-buffer/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/data-view-byte-length/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/data-view-byte-offset/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/debug/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/deepmerge/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/define-data-property/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/define-properties/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/depd/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/detect-libc/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/dotenv/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/dunder-proto/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/ee-first/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/ejs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/electron-to-chromium/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/encodeurl/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/entities/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/entities/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/entities/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/env-paths/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/es-abstract/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/es-define-property/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/es-errors/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/es-object-atoms/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/es-set-tostringtag/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/es-to-primitive/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/esbuild/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/escalade/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/escape-html/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/estree-walker/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/estree-walker/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/estree-walker/src/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/esutils/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/etag/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/eventsource/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/eventsource-parser/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/express/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/express-rate-limit/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/fast-deep-equal/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/fast-json-stable-stringify/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/fast-uri/benchmark/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/fast-uri/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/fdir/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/filelist/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/filelist/node_modules/minimatch/node_modules/brace-expansion/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/filelist/node_modules/minimatch/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/filelist/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/finalhandler/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/for-each/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/foreground-child/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/foreground-child/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/foreground-child/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/forwarded/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/fresh/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/fs-extra/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/function-bind/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/function.prototype.name/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/functions-have-names/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/generator-function/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/gensync/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/get-intrinsic/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/get-own-enumerable-property-symbols/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/get-proto/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/get-symbol-description/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/glob/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/glob/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/glob/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/globalthis/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/gopd/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/graceful-fs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/has-bigints/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/has-property-descriptors/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/has-proto/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/has-symbols/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/has-tostringtag/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/hasown/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/hono/dist/cjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/hono/dist/types/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/hono/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/hookable/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/http/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/http-errors/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/iconv-lite/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/idb/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/inherits/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/internal-slot/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/ip-address/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/ipaddr.js/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-array-buffer/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-async-function/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-bigint/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-boolean-object/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-callable/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-core-module/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-data-view/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-date-object/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-finalizationregistry/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-generator-function/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-map/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-module/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-negative-zero/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-number-object/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-obj/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-promise/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-regex/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-regexp/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-set/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-shared-array-buffer/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-stream/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-string/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-symbol/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-typed-array/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-weakmap/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-weakref/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-weakset/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/is-what/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/isarray/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/isexe/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/jackspeak/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/jackspeak/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/jackspeak/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/jake/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/jose/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/js-tokens/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/jsesc/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/json-schema/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/json-schema-traverse/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/json-schema-typed/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/json5/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/jsonfile/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/jsonpointer/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/leven/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/lodash/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/lodash.debounce/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/lodash.sortby/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/lru-cache/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/magic-string/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/math-intrinsics/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/media-typer/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/merge-descriptors/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/mime-db/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/mime-types/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/minimatch/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/minimatch/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/minimatch/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/minipass/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/minipass/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/minipass/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/mitt/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/ms/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/muggle-string/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/nanoid/async/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/nanoid/non-secure/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/nanoid/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/nanoid/url-alphabet/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/negotiator/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/node-releases/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/object-assign/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/object-inspect/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/object-keys/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/object.assign/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/on-finished/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/once/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/os/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/own-keys/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/package-json-from-dist/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/package-json-from-dist/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/package-json-from-dist/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/parseurl/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path-browserify/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path-key/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path-parse/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/node_modules/lru-cache/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/node_modules/lru-cache/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path-scurry/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/path-to-regexp/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/perfect-debounce/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/picocolors/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/picomatch/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/pinia/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/pkce-challenge/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/possible-typed-array-names/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/postcss/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/pretty-bytes/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/process/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/proxy-addr/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/punycode/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/qs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/randombytes/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/range-parser/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/raw-body/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/reflect.getprototypeof/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/regenerate/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/regenerate-unicode-properties/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/regexp.prototype.flags/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/regexpu-core/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/regjsgen/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/regjsparser/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/require-from-string/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/module_dir/zmodules/bbb/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/baz/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/browser_field/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/dot_main/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/dot_slash_main/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/false_main/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/incorrect_main/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/invalid_main/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/multirepo/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/multirepo/packages/package-a/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/multirepo/packages/package-b/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/nested_symlinks/mylib/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/resolve/test/resolver/symlinked/package/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/rfdc/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/rollup/dist/es/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/rollup/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/router/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/safe-array-concat/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/safe-buffer/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/safe-push-apply/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/safe-regex-test/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/safer-buffer/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/semver/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/send/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/serialize-javascript/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/serve-static/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/set-function-length/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/set-function-name/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/set-proto/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/setprototypeof/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/sharp/node_modules/semver/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/sharp/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/shebang-command/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/shebang-regex/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/side-channel/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/side-channel-list/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/side-channel-map/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/side-channel-weakmap/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/signal-exit/dist/cjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/signal-exit/dist/mjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/signal-exit/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/smob/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/source-map/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/source-map-js/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/source-map-support/node_modules/source-map/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/source-map-support/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/sourcemap-codec/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/speakingurl/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/statuses/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/stop-iteration-iterator/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/string.prototype.matchall/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/string.prototype.trim/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/string.prototype.trimend/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/string.prototype.trimstart/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/stringify-object/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/strip-comments/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/superjson/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/supports-preserve-symlinks-flag/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/temp-dir/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/tempy/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/terser/bin/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/terser/dist/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/terser/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/tinyglobby/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/toidentifier/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/tr46/node_modules/punycode/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/tr46/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/type-fest/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/type-is/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/typed-array-buffer/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/typed-array-byte-length/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/typed-array-byte-offset/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/typed-array-length/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/typescript/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/unbox-primitive/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/undici-types/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/unicode-canonical-property-names-ecmascript/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/unicode-match-property-ecmascript/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/unicode-match-property-value-ecmascript/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/unicode-property-aliases-ecmascript/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/unique-string/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/universalify/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/unpipe/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/upath/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/update-browserslist-db/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/url/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/util/node_modules/inherits/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/util/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vary/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vite/node_modules/rollup/dist/es/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vite/node_modules/rollup/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vite/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vite/types/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vite-plugin-pwa/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vite-plugin-pwa/types/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vscode-uri/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vue/compiler-sfc/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vue/jsx-runtime/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vue/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vue/server-renderer/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vue-router/node_modules/@vue/devtools-api/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vue-router/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/vue-tsc/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/webidl-conversions/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/whatwg-url/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/which/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/which-boxed-primitive/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/which-builtin-type/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/which-collection/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/which-typed-array/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-background-sync/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-broadcast-update/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-build/node_modules/pretty-bytes/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-build/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-cacheable-response/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-core/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-expiration/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-google-analytics/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-navigation-preload/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-precaching/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-range-requests/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-recipes/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-routing/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-strategies/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-streams/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-sw/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/workbox-window/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/wrappy/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/ws/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/yallist/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod/locales/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod/mini/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v3/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4/classic/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4/core/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4/locales/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4/mini/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod/v4-mini/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod-to-json-schema/dist/cjs/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod-to-json-schema/dist/esm/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/node_modules/zod-to-json-schema/package.json
|
||||
/c/Users/jodar/agent-ui/frontend/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/ansi-regex/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/ansi-styles/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/chalk/node_modules/supports-color/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/chalk/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/cliui/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/color-convert/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/color-name/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/concurrently/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/emoji-regex/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/escalade/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/get-caller-file/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/has-flag/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/is-fullwidth-code-point/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/require-directory/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/rxjs/ajax/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/rxjs/fetch/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/rxjs/operators/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/rxjs/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/rxjs/testing/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/rxjs/webSocket/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/shell-quote/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/string-width/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/strip-ansi/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/supports-color/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/tree-kill/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/tslib/modules/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/tslib/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/wrap-ansi/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/y18n/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/yargs/helpers/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/yargs/package.json
|
||||
/c/Users/jodar/agent-ui/node_modules/yargs-parser/package.json
|
||||
/c/Users/jodar/agent-ui/package.json
|
||||
/c/Users/jodar/agent-ui/server/node_modules/@skitee3000/bun-pty/package.json
|
||||
/c/Users/jodar/agent-ui/server/node_modules/node-addon-api/package.json
|
||||
/c/Users/jodar/agent-ui/server/package.json
|
||||
134
.claude-nucleo000/settings.json
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"env": {
|
||||
"DISABLE_TELEMETRY": "1"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": []
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUseFailure": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PermissionRequest": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1 nucleo000",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1 nucleo000",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,189 @@
|
||||
"Bash(bun add:*)",
|
||||
"mcp__agent-ui__localhost_4100-confetti",
|
||||
"mcp__agent-ui__localhost_4100-get_current_page",
|
||||
"mcp__agent-ui___webmcp_server-info"
|
||||
"mcp__agent-ui___webmcp_server-info",
|
||||
"mcp__agent-ui__localhost_4100-toggle_pin_tool",
|
||||
"mcp__agent-ui__localhost_4100-pin_tool",
|
||||
"Bash(npx vue-tsc:*)",
|
||||
"mcp__agent-ui__localhost_4100-activate_tool",
|
||||
"mcp__agent-ui__localhost_4100-terminal_open",
|
||||
"mcp__agent-ui__localhost_4100-terminal_move",
|
||||
"mcp__agent-ui__localhost_4100-terminal_resize",
|
||||
"mcp__agent-ui__localhost_4100-terminal_toggle",
|
||||
"mcp__agent-ui__localhost_4100-terminal_close",
|
||||
"mcp__agent-ui__localhost_4100-bubbleResponse",
|
||||
"mcp__agent-ui__localhost_4100-notificar",
|
||||
"mcp__agent-ui__localhost_4100-enviar_al_panel",
|
||||
"mcp__agent-ui__localhost_4100-render_html",
|
||||
"mcp__agent-ui__localhost_4100-load_vue_component",
|
||||
"mcp__agent-ui__localhost_4100-page_refresh",
|
||||
"WebFetch(domain:docs.anthropic.com)",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-bubbleResponse",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-navigate_to",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-activate_tool",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-list_available_tools",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-page_refresh",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-render_html",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-render_vue_component",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-pin_tool",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-list_torch_clients",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-transfer_torch",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-get_current_page",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-list_windows",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-move_window",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-close_window",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-get_canvas_css",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-inspect_window",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-get_canvas",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-canvas_js",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-canvas_css",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-edit_canvas",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-load_vue_component",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-save_vue_component",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-resize_window",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-save_canvas_snapshot",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-load_canvas_snapshot",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-list_canvas_snapshots",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-list_canvases",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-list_vue_components",
|
||||
"Bash(jq:*)",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-read_component",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-edit_component",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-list_fs_components",
|
||||
"mcp__agent-ui__z590_nucleoriofrio_com-load_fs_component",
|
||||
"Bash(grep:*)",
|
||||
"WebFetch(domain:v2.tauri.app)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"agent-ui"
|
||||
]
|
||||
],
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUseFailure": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PermissionRequest": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
9
.claude/ui.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"label": "Main",
|
||||
"shortLabel": "M",
|
||||
"color": "#6366f1",
|
||||
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
"terminalBg": "#0f0a1a",
|
||||
"terminalBorder": "#6366f1",
|
||||
"enabled": true
|
||||
}
|
||||
59
.gitignore
vendored
@@ -3,3 +3,62 @@ frontend/node_modules/
|
||||
.env
|
||||
*.log
|
||||
dist/
|
||||
frontend/dev-dist/
|
||||
nul
|
||||
|
||||
# Voice recordings (training data)
|
||||
server/recordings/*.webm
|
||||
|
||||
# Installers / APKs
|
||||
installers/
|
||||
|
||||
# Tauri build artifacts
|
||||
src-tauri/target/
|
||||
src-tauri/installers/
|
||||
|
||||
# Tauri gen: ignore everything except our custom Android sources
|
||||
src-tauri/gen/*
|
||||
!src-tauri/gen/android/
|
||||
src-tauri/gen/android/*
|
||||
!src-tauri/gen/android/app/
|
||||
src-tauri/gen/android/app/*
|
||||
!src-tauri/gen/android/app/build.gradle.kts
|
||||
!src-tauri/gen/android/app/src/
|
||||
src-tauri/gen/android/app/src/*
|
||||
!src-tauri/gen/android/app/src/main/
|
||||
src-tauri/gen/android/app/src/main/*
|
||||
!src-tauri/gen/android/app/src/main/AndroidManifest.xml
|
||||
!src-tauri/gen/android/app/src/main/java/
|
||||
!src-tauri/gen/android/app/src/main/res/
|
||||
src-tauri/gen/android/app/src/main/res/*
|
||||
!src-tauri/gen/android/app/src/main/res/layout/
|
||||
src-tauri/gen/android/app/src/main/res/layout/*
|
||||
!src-tauri/gen/android/app/src/main/res/layout/widget_transcript.xml
|
||||
!src-tauri/gen/android/app/src/main/res/layout/widget_terminal_item.xml
|
||||
!src-tauri/gen/android/app/src/main/res/layout/face_widget_lockscreen.xml
|
||||
!src-tauri/gen/android/app/src/main/res/layout/face_widget_aod.xml
|
||||
!src-tauri/gen/android/app/src/main/res/xml/
|
||||
src-tauri/gen/android/app/src/main/res/xml/*
|
||||
!src-tauri/gen/android/app/src/main/res/xml/transcript_widget_info.xml
|
||||
!src-tauri/gen/android/app/src/main/res/xml/voice_interaction_service.xml
|
||||
!src-tauri/gen/android/app/src/main/res/xml/recognition_service.xml
|
||||
!src-tauri/gen/android/app/src/main/res/values/
|
||||
src-tauri/gen/android/app/src/main/res/values/*
|
||||
!src-tauri/gen/android/app/src/main/res/values/strings.xml
|
||||
!src-tauri/gen/android/app/src/main/res/raw/
|
||||
src-tauri/gen/android/app/src/main/res/raw/*
|
||||
!src-tauri/gen/android/app/src/main/res/raw/facewidgets.json
|
||||
!src-tauri/gen/android/app/src/main/res/drawable/
|
||||
src-tauri/gen/android/app/src/main/res/drawable/*
|
||||
!src-tauri/gen/android/app/src/main/res/drawable/face_widget_bg_dark.xml
|
||||
!src-tauri/gen/android/app/src/main/res/drawable/face_widget_bg_aod.xml
|
||||
src-tauri/gen/android/keystore.jks
|
||||
|
||||
# Old frontend Tauri location
|
||||
frontend/src-tauri/
|
||||
|
||||
# Agent runtime data
|
||||
.claude-*/plugins/
|
||||
.claude-*/plans/
|
||||
.claude-*/file-history/
|
||||
.claude-*/tasks/
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"args": [
|
||||
"git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
|
||||
"--mcp",
|
||||
"--dev",
|
||||
"-p", "4102"
|
||||
]
|
||||
}
|
||||
|
||||
44
README.md
@@ -2,6 +2,50 @@
|
||||
|
||||
Dynamic canvas interface for Claude Code interaction via MCP (Model Context Protocol).
|
||||
|
||||
---
|
||||
|
||||
## Nucleo
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/nucleo-logo.svg" alt="Nucleo" width="120"/>
|
||||
</p>
|
||||
|
||||
**Nucleo** is the main AI agent powering Agent UI. It serves as the bridge between Claude Code and your visual interface, providing real-time status feedback through an animated FAB (Floating Action Button).
|
||||
|
||||
### Visual States
|
||||
|
||||
Nucleo communicates its current state through distinct animations:
|
||||
|
||||
| State | Color | Animation | Trigger |
|
||||
|-------|-------|-----------|---------|
|
||||
| **Idle** | Purple | Rotating atom | Default state |
|
||||
| **Processing** | Orange | Pulsing dots | User prompt submitted |
|
||||
| **Reading** | Cyan | Eye icon + scan | Reading files (Read/Glob/Grep) |
|
||||
| **Writing** | Green | Pencil icon + pulse | Writing files (Edit/Write) |
|
||||
| **Subagent** | Purple | Orbital ring | Task tool spawned |
|
||||
| **Permission** | Red | Alert + shake | Awaiting user permission |
|
||||
| **Session Start** | Green | Ripple waves | Session initialized |
|
||||
| **Notification** | Yellow | Bounce | System notification |
|
||||
|
||||
### Integration
|
||||
|
||||
Nucleo's status is synchronized via Claude Code hooks:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [{ "hooks": [{ "command": "... status: processing ..." }] }],
|
||||
"PreToolUse": [{ "hooks": [{ "command": "... status: toolUse ..." }] }],
|
||||
"PostToolUse": [{ "hooks": [{ "command": "... status: toolDone ..." }] }],
|
||||
"Stop": [{ "hooks": [{ "command": "... status: idle ..." }] }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The FAB receives these status updates via WebSocket and displays the corresponding animation, giving you real-time visibility into what Claude is doing.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Agent UI provides a visual canvas where Claude Code can render dynamic Vue 3 components, HTML content, and interactive UIs in real-time. It bridges the gap between CLI-based AI assistance and rich visual interfaces.
|
||||
|
||||
61
docs/nucleo-logo.svg
Normal file
@@ -0,0 +1,61 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="coreGradient" cx="50%" cy="30%" r="60%">
|
||||
<stop offset="0%" stop-color="#c4b5fd"/>
|
||||
<stop offset="50%" stop-color="#a78bfa"/>
|
||||
<stop offset="100%" stop-color="#6366f1"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="orbitGradient1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#818cf8" stop-opacity="0.9"/>
|
||||
<stop offset="50%" stop-color="#a78bfa" stop-opacity="0.5"/>
|
||||
<stop offset="100%" stop-color="#818cf8" stop-opacity="0.9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="electronGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#e0e7ff"/>
|
||||
<stop offset="100%" stop-color="#a78bfa"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="coreGlow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="6" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background circle (subtle) -->
|
||||
<circle cx="60" cy="60" r="55" fill="none" stroke="#4f46e5" stroke-width="1" opacity="0.2"/>
|
||||
|
||||
<!-- Orbital rings -->
|
||||
<ellipse cx="60" cy="60" rx="45" ry="18" stroke="url(#orbitGradient1)" stroke-width="2" fill="none" transform="rotate(-30 60 60)" opacity="0.7"/>
|
||||
<ellipse cx="60" cy="60" rx="45" ry="18" stroke="url(#orbitGradient1)" stroke-width="2" fill="none" transform="rotate(30 60 60)" opacity="0.5"/>
|
||||
<ellipse cx="60" cy="60" rx="45" ry="18" stroke="url(#orbitGradient1)" stroke-width="2" fill="none" transform="rotate(90 60 60)" opacity="0.6"/>
|
||||
|
||||
<!-- Core nucleus with glow -->
|
||||
<circle cx="60" cy="60" r="18" fill="url(#coreGradient)" filter="url(#coreGlow)"/>
|
||||
<circle cx="60" cy="60" r="14" fill="url(#coreGradient)" opacity="0.8"/>
|
||||
<circle cx="55" cy="55" r="5" fill="white" opacity="0.4"/>
|
||||
|
||||
<!-- Electrons with glow -->
|
||||
<circle cx="60" cy="15" r="7" fill="url(#electronGradient)" filter="url(#glow)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="360 60 60" dur="4s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="100" cy="70" r="6" fill="url(#electronGradient)" filter="url(#glow)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="120 60 60" to="480 60 60" dur="5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="20" cy="70" r="6" fill="url(#electronGradient)" filter="url(#glow)">
|
||||
<animateTransform attributeName="transform" type="rotate" from="240 60 60" to="600 60 60" dur="4.5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Small accent electrons -->
|
||||
<circle cx="60" cy="105" r="4" fill="#c4b5fd" opacity="0.6">
|
||||
<animateTransform attributeName="transform" type="rotate" from="180 60 60" to="540 60 60" dur="6s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -2,10 +2,21 @@
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#0f0f14" />
|
||||
<meta name="description" content="Dynamic canvas for Claude Code interaction via WebMCP" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Agent UI" />
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<meta name="description" content="Dynamic canvas for Claude Code interaction" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
|
||||
<title>Agent UI</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
1035
frontend/package-lock.json
generated
@@ -7,12 +7,30 @@
|
||||
"predev": "npm install @nucleoriofrio/webmcp@git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git --silent",
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"generate-icons": "node scripts/generate-icons.js",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:android:init": "tauri android init",
|
||||
"tauri:android:dev": "tauri android dev",
|
||||
"tauri:android:build": "tauri android build",
|
||||
"tauri:ios:init": "tauri ios init",
|
||||
"tauri:ios:dev": "tauri ios dev",
|
||||
"tauri:ios:build": "tauri ios build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-store": "^2.4.2",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
@@ -20,9 +38,11 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.10.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
|
||||
BIN
frontend/public/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
frontend/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
18
frontend/public/icons/icon.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea"/>
|
||||
<stop offset="100%" style="stop-color:#764ba2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#4facfe"/>
|
||||
<stop offset="100%" style="stop-color:#00f2fe"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||
<circle cx="256" cy="200" r="80" fill="none" stroke="white" stroke-width="16" opacity="0.9"/>
|
||||
<circle cx="256" cy="200" r="40" fill="url(#accent)"/>
|
||||
<rect x="156" y="320" width="200" height="24" rx="12" fill="white" opacity="0.8"/>
|
||||
<rect x="186" y="360" width="140" height="16" rx="8" fill="white" opacity="0.5"/>
|
||||
<rect x="206" y="392" width="100" height="16" rx="8" fill="white" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 948 B |
56
frontend/public/loading.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; background: transparent; overflow: hidden; }
|
||||
body { display: flex; align-items: center; justify-content: center; }
|
||||
.wrap { position: relative; width: 36px; height: 36px; }
|
||||
.dot {
|
||||
width: 36px; height: 36px;
|
||||
border: 2.5px solid rgba(255,255,255,0.06);
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
filter: drop-shadow(0 0 8px rgba(99,102,241,0.5));
|
||||
}
|
||||
.badge {
|
||||
display: none;
|
||||
position: absolute; top: -4px; right: -6px;
|
||||
background: #ef4444; color: #fff;
|
||||
font: 700 9px/1 -apple-system, sans-serif;
|
||||
min-width: 14px; height: 14px;
|
||||
border-radius: 7px;
|
||||
text-align: center;
|
||||
padding: 2px 3px;
|
||||
}
|
||||
.badge.show { display: block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="dot"></div>
|
||||
<div class="badge" id="b"></div>
|
||||
</div>
|
||||
<script>
|
||||
var badge = document.getElementById('b');
|
||||
function updateBadge(n) {
|
||||
if (n > 1) { badge.textContent = n; badge.className = 'badge show'; }
|
||||
else { badge.className = 'badge'; }
|
||||
}
|
||||
|
||||
// Initial count from query param
|
||||
var c = new URLSearchParams(location.search).get('n');
|
||||
if (c) updateBadge(parseInt(c));
|
||||
|
||||
// Live updates via Tauri event (no module imports needed)
|
||||
var T = window.__TAURI_INTERNALS__;
|
||||
if (T) {
|
||||
var handler = T.transformCallback(function(ev) { updateBadge(ev.payload); });
|
||||
T.invoke('plugin:event|listen', { event: 'loading:count', target: { kind: 'Any' }, handler: handler });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
43
frontend/scripts/generate-icons.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import sharp from 'sharp'
|
||||
import { readFileSync, mkdirSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const iconsDir = join(__dirname, '../public/icons')
|
||||
|
||||
// Read SVG
|
||||
const svgBuffer = readFileSync(join(iconsDir, 'icon.svg'))
|
||||
|
||||
// Generate icons
|
||||
const sizes = [
|
||||
{ name: 'icon-192.png', size: 192 },
|
||||
{ name: 'icon-512.png', size: 512 },
|
||||
{ name: 'icon-maskable-512.png', size: 512 }
|
||||
]
|
||||
|
||||
async function generate() {
|
||||
for (const { name, size } of sizes) {
|
||||
await sharp(svgBuffer)
|
||||
.resize(size, size)
|
||||
.png()
|
||||
.toFile(join(iconsDir, name))
|
||||
console.log(`Generated ${name}`)
|
||||
}
|
||||
|
||||
// Also generate apple-touch-icon
|
||||
await sharp(svgBuffer)
|
||||
.resize(180, 180)
|
||||
.png()
|
||||
.toFile(join(iconsDir, 'apple-touch-icon.png'))
|
||||
console.log('Generated apple-touch-icon.png')
|
||||
|
||||
// Favicon
|
||||
await sharp(svgBuffer)
|
||||
.resize(32, 32)
|
||||
.png()
|
||||
.toFile(join(__dirname, '../public/favicon.png'))
|
||||
console.log('Generated favicon.png')
|
||||
}
|
||||
|
||||
generate().catch(console.error)
|
||||
1409
frontend/src/App.vue
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { renderInlineComponent, type VueComponentDefinition } from '../services/dynamicComponents'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import CanvasGallery from './CanvasGallery.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const showGallery = ref(true)
|
||||
|
||||
function handleLoadComponent(e: Event) {
|
||||
const detail = (e as CustomEvent).detail
|
||||
@@ -12,6 +14,9 @@ function handleLoadComponent(e: Event) {
|
||||
const container = document.getElementById('canvas-content')
|
||||
if (!container) return
|
||||
|
||||
// Hide gallery when MCP renders content
|
||||
showGallery.value = false
|
||||
|
||||
const placeholder = container.querySelector('.canvas-placeholder')
|
||||
if (placeholder) placeholder.remove()
|
||||
|
||||
@@ -32,26 +37,49 @@ function handleLoadComponent(e: Event) {
|
||||
canvasStore.addToHistory({ tool: 'load_vue_component', args: detail, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
showGallery.value = false
|
||||
canvasStore.isAnonymousCanvas = false
|
||||
}
|
||||
|
||||
function handleStartAnonymous() {
|
||||
showGallery.value = false
|
||||
canvasStore.isAnonymousCanvas = true
|
||||
}
|
||||
|
||||
function handleClearCanvas() {
|
||||
showGallery.value = true
|
||||
canvasStore.isAnonymousCanvas = false
|
||||
const container = document.getElementById('canvas-content')
|
||||
if (container) {
|
||||
// Remove all non-gallery content
|
||||
const children = Array.from(container.children)
|
||||
for (const child of children) {
|
||||
if (!(child as HTMLElement).classList?.contains('canvas-placeholder')) {
|
||||
child.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('load-vue-component', handleLoadComponent)
|
||||
window.addEventListener('clear-canvas', handleClearCanvas)
|
||||
window.addEventListener('canvas-content-rendered', handleContentRendered)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('load-vue-component', handleLoadComponent)
|
||||
window.removeEventListener('clear-canvas', handleClearCanvas)
|
||||
window.removeEventListener('canvas-content-rendered', handleContentRendered)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas-container">
|
||||
<div id="canvas-content" class="canvas-content">
|
||||
<div class="canvas-placeholder">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18"/>
|
||||
<path d="M9 21V9"/>
|
||||
</svg>
|
||||
<p>Canvas listo</p>
|
||||
<span>Haz clic en el cuadrado azul (abajo derecha) para conectar con Claude Code</span>
|
||||
<div v-if="showGallery" class="canvas-placeholder">
|
||||
<CanvasGallery @snapshot-restored="showGallery = false" @component-loaded="showGallery = false" @start-anonymous="handleStartAnonymous" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,38 +91,19 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.canvas-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.canvas-placeholder svg {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.canvas-placeholder p {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.canvas-placeholder span {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
980
frontend/src/components/CanvasGallery.vue
Normal file
@@ -0,0 +1,980 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectCanvasStore } from '../stores/projectCanvas'
|
||||
import { useSnapshotsStore, type SnapshotSummary } from '../stores/snapshots'
|
||||
import {
|
||||
componentsApi,
|
||||
renderInlineComponent,
|
||||
type VueComponentDefinition
|
||||
} from '../services/dynamicComponents'
|
||||
import { getWindowDefinitions } from '../services/tools/handlers/canvasHandlers'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'snapshot-restored'): void
|
||||
(e: 'component-loaded'): void
|
||||
(e: 'start-anonymous'): void
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectCanvasStore()
|
||||
const snapshotsStore = useSnapshotsStore()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const showArchived = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const settingsOpenId = ref<string | null>(null)
|
||||
const restoringSnapshot = ref<string | null>(null)
|
||||
const showNewForm = ref(false)
|
||||
const newName = ref('')
|
||||
const creating = ref(false)
|
||||
const savedComponents = ref<VueComponentDefinition[]>([])
|
||||
const loadingComponent = ref<string | null>(null)
|
||||
|
||||
// Editable settings state
|
||||
const editIcon = ref('')
|
||||
const editOrder = ref(99)
|
||||
|
||||
const filteredCanvases = computed(() => {
|
||||
let list = showArchived.value ? store.canvases : store.activeCanvasesList
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
list = list.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
(c.description && c.description.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
return list.slice(0, 10)
|
||||
})
|
||||
|
||||
const filteredSnapshots = computed(() => {
|
||||
if (!searchQuery.value) return snapshotsStore.snapshots
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return snapshotsStore.snapshots.filter(s => s.name.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const filteredComponents = computed(() => {
|
||||
if (!searchQuery.value) return savedComponents.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return savedComponents.value.filter(c => c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const totalItems = computed(() => store.canvases.length + snapshotsStore.snapshots.length + savedComponents.value.length)
|
||||
const showSearch = computed(() => totalItems.value > 8)
|
||||
|
||||
function navigateToCanvas(id: string) {
|
||||
router.push(`/canvas/${id}`)
|
||||
}
|
||||
|
||||
async function createNewCanvas() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
creating.value = true
|
||||
try {
|
||||
const id = await store.createCanvas({ name, type: 'project' } as any)
|
||||
if (id) {
|
||||
newName.value = ''
|
||||
showNewForm.value = false
|
||||
router.push(`/canvas/${id}`)
|
||||
}
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSnapshot(snap: SnapshotSummary) {
|
||||
restoringSnapshot.value = snap.id
|
||||
try {
|
||||
await snapshotsStore.restore(snap.id)
|
||||
emit('snapshot-restored')
|
||||
} finally {
|
||||
restoringSnapshot.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function toggleSettings(id: string) {
|
||||
if (settingsOpenId.value === id) {
|
||||
settingsOpenId.value = null
|
||||
} else {
|
||||
settingsOpenId.value = id
|
||||
const canvas = store.canvases.find(c => c.id === id)
|
||||
if (canvas) {
|
||||
editIcon.value = canvas.toolbar_icon || ''
|
||||
editOrder.value = canvas.toolbar_order ?? 99
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleToolbar(id: string, current: boolean) {
|
||||
await store.updateCanvas(id, { show_in_toolbar: !current } as any)
|
||||
await store.fetchToolbarCanvases()
|
||||
}
|
||||
|
||||
async function updateIcon(id: string) {
|
||||
await store.updateCanvas(id, { toolbar_icon: editIcon.value || null } as any)
|
||||
await store.fetchToolbarCanvases()
|
||||
}
|
||||
|
||||
async function updateOrder(id: string) {
|
||||
await store.updateCanvas(id, { toolbar_order: editOrder.value } as any)
|
||||
await store.fetchToolbarCanvases()
|
||||
}
|
||||
|
||||
async function archiveCanvas(id: string) {
|
||||
await store.deleteCanvas(id)
|
||||
await store.fetchCanvases(showArchived.value)
|
||||
await store.fetchToolbarCanvases()
|
||||
settingsOpenId.value = null
|
||||
}
|
||||
|
||||
async function restoreCanvas(id: string) {
|
||||
await store.restoreCanvas(id)
|
||||
await store.fetchCanvases(showArchived.value)
|
||||
settingsOpenId.value = null
|
||||
}
|
||||
|
||||
async function loadComponent(comp: VueComponentDefinition) {
|
||||
loadingComponent.value = comp.id
|
||||
try {
|
||||
const container = document.getElementById('canvas-content')
|
||||
if (!container) return
|
||||
|
||||
const placeholder = container.querySelector('.canvas-placeholder')
|
||||
if (placeholder) placeholder.remove()
|
||||
|
||||
const result = renderInlineComponent(comp, container, {}, true)
|
||||
|
||||
getWindowDefinitions().set(comp.id, {
|
||||
source: 'db',
|
||||
componentId: comp.id,
|
||||
definition: comp,
|
||||
componentProps: {}
|
||||
})
|
||||
|
||||
;(window as any).__vueComponentUnmount = result.unmount
|
||||
canvasStore.addToHistory({ tool: 'load_vue_component', args: { id: comp.id }, timestamp: Date.now() })
|
||||
emit('component-loaded')
|
||||
} finally {
|
||||
loadingComponent.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchComponents() {
|
||||
try {
|
||||
savedComponents.value = await componentsApi.getAll({ limit: 10 })
|
||||
} catch {
|
||||
savedComponents.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSnapshot(id: string) {
|
||||
await snapshotsStore.remove(id)
|
||||
}
|
||||
|
||||
function toggleArchived() {
|
||||
showArchived.value = !showArchived.value
|
||||
store.fetchCanvases(showArchived.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchCanvases(false)
|
||||
snapshotsStore.list()
|
||||
fetchComponents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas-gallery">
|
||||
<div class="gallery-header">
|
||||
<h2>Canvases</h2>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="toggle-archived"
|
||||
:class="{ active: showArchived }"
|
||||
@click="toggleArchived"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="21 8 21 21 3 21 3 8"/>
|
||||
<rect x="1" y="3" width="22" height="5"/>
|
||||
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
Archivados
|
||||
</button>
|
||||
<input
|
||||
v-if="showSearch"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="gallery-loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Project Canvases -->
|
||||
<div class="gallery-grid">
|
||||
<!-- Anonymous dynamic canvas card -->
|
||||
<div class="canvas-card new-card anon-card" @click="emit('start-anonymous')">
|
||||
<div class="card-icon new-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-name">Dynamic Canvas</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New canvas card -->
|
||||
<div class="canvas-card new-card" @click="showNewForm = true" v-if="!showNewForm">
|
||||
<div class="card-icon new-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-name">Nuevo canvas</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New canvas form (inline) -->
|
||||
<div class="canvas-card new-form-card" v-if="showNewForm" @click.stop>
|
||||
<input
|
||||
v-model="newName"
|
||||
type="text"
|
||||
class="new-canvas-input"
|
||||
placeholder="Nombre del canvas..."
|
||||
autofocus
|
||||
@keyup.enter="createNewCanvas"
|
||||
@keyup.escape="showNewForm = false; newName = ''"
|
||||
/>
|
||||
<div class="new-form-actions">
|
||||
<button class="new-btn create" :disabled="!newName.trim() || creating" @click="createNewCanvas">
|
||||
{{ creating ? '...' : 'Crear' }}
|
||||
</button>
|
||||
<button class="new-btn cancel" @click="showNewForm = false; newName = ''">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="canvas in filteredCanvases"
|
||||
:key="canvas.id"
|
||||
class="canvas-card"
|
||||
:class="{ archived: canvas.status === 'archived' }"
|
||||
@click="navigateToCanvas(canvas.id)"
|
||||
>
|
||||
<div class="card-icon">
|
||||
<span v-if="canvas.toolbar_icon" class="card-emoji">{{ canvas.toolbar_icon }}</span>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18"/>
|
||||
<path d="M9 21V9"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="card-name">{{ canvas.name }}</div>
|
||||
<div v-if="canvas.description" class="card-desc">{{ canvas.description }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<span class="card-badge" :class="canvas.type">{{ canvas.type }}</span>
|
||||
<span v-if="canvas.status === 'archived'" class="card-badge archived-badge">Archivado</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="card-settings-btn"
|
||||
@click.stop="toggleSettings(canvas.id)"
|
||||
title="Configurar"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Settings popover -->
|
||||
<div v-if="settingsOpenId === canvas.id" class="settings-popover" @click.stop>
|
||||
<div class="settings-row">
|
||||
<label>En toolbar</label>
|
||||
<button
|
||||
class="switch-btn"
|
||||
:class="{ on: canvas.show_in_toolbar }"
|
||||
@click="toggleToolbar(canvas.id, canvas.show_in_toolbar)"
|
||||
>
|
||||
<span class="switch-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<label>Icono</label>
|
||||
<input
|
||||
v-model="editIcon"
|
||||
type="text"
|
||||
class="settings-input icon-input"
|
||||
placeholder="emoji"
|
||||
maxlength="2"
|
||||
@change="updateIcon(canvas.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<label>Orden</label>
|
||||
<input
|
||||
v-model.number="editOrder"
|
||||
type="number"
|
||||
class="settings-input order-input"
|
||||
min="0"
|
||||
max="999"
|
||||
@change="updateOrder(canvas.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-divider"></div>
|
||||
|
||||
<button
|
||||
v-if="canvas.status !== 'archived'"
|
||||
class="settings-action archive-btn"
|
||||
@click="archiveCanvas(canvas.id)"
|
||||
:disabled="canvas.is_system"
|
||||
>
|
||||
Archivar
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="settings-action restore-btn"
|
||||
@click="restoreCanvas(canvas.id)"
|
||||
>
|
||||
Restaurar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshots section -->
|
||||
<template v-if="filteredSnapshots.length > 0">
|
||||
<h3 class="section-title">Snapshots</h3>
|
||||
<div class="gallery-grid">
|
||||
<div
|
||||
v-for="snap in filteredSnapshots"
|
||||
:key="snap.id"
|
||||
class="canvas-card snapshot-card"
|
||||
@click="loadSnapshot(snap)"
|
||||
>
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="card-name">{{ snap.name }}</div>
|
||||
<div class="card-desc">{{ formatDate(snap.created_at) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<span class="card-badge snapshot">snapshot</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="restoringSnapshot === snap.id" class="card-loading">
|
||||
<div class="spinner-sm"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="card-delete-btn"
|
||||
@click.stop="deleteSnapshot(snap.id)"
|
||||
title="Eliminar snapshot"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Components section -->
|
||||
<template v-if="filteredComponents.length > 0">
|
||||
<h3 class="section-title">Componentes</h3>
|
||||
<div class="gallery-grid">
|
||||
<div
|
||||
v-for="comp in filteredComponents"
|
||||
:key="comp.id"
|
||||
class="canvas-card component-card"
|
||||
@click="loadComponent(comp)"
|
||||
>
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.29 7 12 12 20.71 7"/>
|
||||
<line x1="12" y1="22" x2="12" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="card-name">{{ comp.name }}</div>
|
||||
<div class="card-desc card-id">{{ comp.id }}</div>
|
||||
<div v-if="comp.tags?.length" class="card-tags">
|
||||
<span v-for="tag in comp.tags" :key="tag" class="tag-pill">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<span class="card-badge component">componente</span>
|
||||
<span v-if="comp.status === 'archived'" class="card-badge archived-badge">Archivado</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="loadingComponent === comp.id" class="card-loading">
|
||||
<div class="spinner-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="filteredCanvases.length === 0 && filteredSnapshots.length === 0 && filteredComponents.length === 0" class="gallery-empty">
|
||||
<p v-if="showArchived && store.archivedCanvases.length === 0">No hay canvases archivados</p>
|
||||
<p v-else-if="searchQuery">Sin resultados para "{{ searchQuery }}"</p>
|
||||
<p v-else>No hay canvases, snapshots ni componentes guardados</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gallery-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gallery-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.toggle-archived {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.toggle-archived:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toggle-archived.active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
width: 180px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.gallery-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.gallery-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.canvas-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.canvas-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.canvas-card.archived {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.canvas-card.archived:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.snapshot-card:hover {
|
||||
border-color: rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
|
||||
.component-card:hover {
|
||||
border-color: rgba(6, 182, 212, 0.4);
|
||||
}
|
||||
|
||||
.card-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-emoji {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.card-badge.system {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.card-badge.project {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.card-badge.dynamic {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.card-badge.snapshot {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.card-badge.component {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
.card-badge.archived-badge {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.card-settings-btn,
|
||||
.card-delete-btn {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.canvas-card:hover .card-settings-btn,
|
||||
.canvas-card:hover .card-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-settings-btn:hover,
|
||||
.card-delete-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-delete-btn:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.card-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 10px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Settings popover */
|
||||
.settings-popover {
|
||||
position: absolute;
|
||||
top: 2.5rem;
|
||||
right: 0.75rem;
|
||||
width: 200px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-row label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.settings-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.icon-input {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.order-input {
|
||||
width: 56px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.switch-btn {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.switch-btn.on {
|
||||
background: #6366f1;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.switch-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.switch-btn.on .switch-knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.settings-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.settings-action {
|
||||
width: 100%;
|
||||
padding: 0.375rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.archive-btn {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.archive-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.archive-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.restore-btn {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.restore-btn:hover {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* New canvas card */
|
||||
.new-card {
|
||||
border-style: dashed;
|
||||
border-color: var(--border-color);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.new-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.new-card .card-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.new-icon {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.new-card:hover .new-icon {
|
||||
opacity: 1;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.new-form-card {
|
||||
border-color: #6366f1;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.new-canvas-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.new-canvas-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.new-form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.new-btn {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.new-btn.create {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.new-btn.create:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.new-btn.create:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.new-btn.cancel {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.new-btn.cancel:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #818cf8;
|
||||
border-radius: 999px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
411
frontend/src/components/ConnectionDropdown.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import { connectWithToken, getConnectionInfo } from '../services/webmcp'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const { isConnected, isReconnecting, connectionStatus, connectionInfo } = storeToRefs(canvasStore)
|
||||
|
||||
const isOpen = ref(false)
|
||||
const tokenInput = ref('')
|
||||
const isConnecting = ref(false)
|
||||
const justConnected = ref(false)
|
||||
|
||||
// Watch for connection changes and trigger animation
|
||||
watch(isConnected, (newValue, oldValue) => {
|
||||
if (newValue && !oldValue) {
|
||||
// Just connected - trigger animation
|
||||
justConnected.value = true
|
||||
setTimeout(() => {
|
||||
justConnected.value = false
|
||||
}, 2500) // Animation duration
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isReconnecting.value) return 'Reconnecting...'
|
||||
if (isConnected.value) return 'Connected'
|
||||
return 'Disconnected'
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (isReconnecting.value) return 'warning'
|
||||
if (isConnected.value) return 'success'
|
||||
return 'error'
|
||||
})
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function closeDropdown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.connection-dropdown-container')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (!tokenInput.value.trim()) return
|
||||
|
||||
isConnecting.value = true
|
||||
try {
|
||||
const success = await connectWithToken(tokenInput.value.trim())
|
||||
if (success) {
|
||||
tokenInput.value = ''
|
||||
canvasStore.showNotification('Connecting to WebMCP...', 'info')
|
||||
} else {
|
||||
canvasStore.showNotification('Invalid token', 'error')
|
||||
}
|
||||
} catch (e: any) {
|
||||
canvasStore.showNotification(e.message || 'Connection failed', 'error')
|
||||
} finally {
|
||||
isConnecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste() {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
tokenInput.value = text
|
||||
} catch {
|
||||
// Clipboard access denied
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeDropdown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="connection-dropdown-container">
|
||||
<button class="dropdown-trigger" :class="{ 'just-connected': justConnected }" @click.stop="toggleDropdown" title="WebMCP Connection">
|
||||
<span class="status-dot" :class="statusClass"></span>
|
||||
<span>MCP</span>
|
||||
<svg class="chevron" :class="{ open: isOpen }" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="dropdown-menu" @click.stop>
|
||||
<div class="dropdown-header">
|
||||
<span class="header-title">WebMCP</span>
|
||||
<span class="status-badge" :class="statusClass">{{ statusText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Disconnected: Show token input -->
|
||||
<div v-if="!isConnected" class="connect-section">
|
||||
<p class="connect-hint">Paste the token from Claude Code:</p>
|
||||
<div class="token-input-group">
|
||||
<input
|
||||
v-model="tokenInput"
|
||||
type="text"
|
||||
placeholder="eyJ..."
|
||||
class="token-input"
|
||||
@keyup.enter="handleConnect"
|
||||
/>
|
||||
<button class="paste-btn" @click="handlePaste" title="Paste from clipboard">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="connect-btn"
|
||||
@click="handleConnect"
|
||||
:disabled="!tokenInput.trim() || isConnecting"
|
||||
>
|
||||
{{ isConnecting ? 'Connecting...' : 'Connect' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connected: Show connection info -->
|
||||
<div v-else class="info-section">
|
||||
<div v-if="connectionInfo" class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Channel</span>
|
||||
<span class="info-value">{{ connectionInfo.channel || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Server</span>
|
||||
<span class="info-value">{{ connectionInfo.server || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Tools</span>
|
||||
<span class="info-value">{{ connectionInfo.tools?.length || 0 }} registered</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="info-empty">
|
||||
Connection active
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.connection-dropdown-container {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-trigger:hover {
|
||||
background: var(--bg-tertiary, rgba(255,255,255,0.1));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.success {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 6px rgba(16, 185, 129, 0.6);
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: #f59e0b;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 260px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: rgba(107, 114, 128, 0.15);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.connect-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.connect-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.token-input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.token-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.token-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.token-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.paste-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.paste-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.connect-btn {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--accent-text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.connect-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.connect-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.info-empty {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Connection animation - Smooth effect */
|
||||
.dropdown-trigger.just-connected {
|
||||
animation: connectionPulse 2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes connectionPulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
15% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4),
|
||||
0 0 20px rgba(16, 185, 129, 0.2);
|
||||
border-color: #10b981;
|
||||
}
|
||||
30% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.5),
|
||||
0 0 30px rgba(16, 185, 129, 0.25);
|
||||
border-color: #34d399;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.4),
|
||||
0 0 35px rgba(16, 185, 129, 0.2);
|
||||
border-color: #10b981;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
352
frontend/src/components/FloatingResponse.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface ResponseMessage {
|
||||
id: string
|
||||
message: string
|
||||
type: 'info' | 'success' | 'warning' | 'error'
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const messages = ref<ResponseMessage[]>([])
|
||||
|
||||
const isVisible = computed(() => messages.value.length > 0)
|
||||
|
||||
function addMessage(message: string, type: ResponseMessage['type'] = 'info') {
|
||||
const id = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
messages.value.push({
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// No auto-dismiss - user must dismiss each message manually
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function removeMessage(id: string) {
|
||||
const index = messages.value.findIndex(m => m.id === id)
|
||||
if (index !== -1) {
|
||||
messages.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
function getTypeIcon(type: ResponseMessage['type']) {
|
||||
switch (type) {
|
||||
case 'success': return '✓'
|
||||
case 'warning': return '⚠'
|
||||
case 'error': return '✕'
|
||||
default: return 'ℹ'
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeColor(type: ResponseMessage['type']) {
|
||||
switch (type) {
|
||||
case 'success': return '#10b981'
|
||||
case 'warning': return '#f59e0b'
|
||||
case 'error': return '#ef4444'
|
||||
default: return '#6366f1'
|
||||
}
|
||||
}
|
||||
|
||||
// Expose controls for MCP tools
|
||||
defineExpose({
|
||||
addMessage,
|
||||
removeMessage,
|
||||
clearAll,
|
||||
getMessages: () => messages.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- Backdrop -->
|
||||
<Transition name="backdrop-fade">
|
||||
<div v-if="isVisible" class="response-backdrop" @click="clearAll"></div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="bubble-slide">
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="floating-response"
|
||||
>
|
||||
<div class="response-glass">
|
||||
<!-- Header -->
|
||||
<div class="response-header">
|
||||
<div class="header-left">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span>Agent Response</span>
|
||||
<span class="message-count">{{ messages.length }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="clearAll" title="Dismiss all">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/>
|
||||
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="messages-container">
|
||||
<TransitionGroup name="message">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="message-item"
|
||||
:style="{ '--type-color': getTypeColor(msg.type) }"
|
||||
>
|
||||
<span class="type-icon" :class="msg.type">{{ getTypeIcon(msg.type) }}</span>
|
||||
<span class="message-text">{{ msg.message }}</span>
|
||||
<button class="dismiss-btn" @click="removeMessage(msg.id)" title="Dismiss">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/>
|
||||
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Backdrop */
|
||||
.response-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10009;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.backdrop-fade-enter-active,
|
||||
.backdrop-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.backdrop-fade-enter-from,
|
||||
.backdrop-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.floating-response {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 540px;
|
||||
max-width: 92vw;
|
||||
max-height: 80vh;
|
||||
z-index: 10010;
|
||||
}
|
||||
|
||||
.response-glass {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 80vh;
|
||||
background: rgba(200, 215, 235, 0.35);
|
||||
backdrop-filter: blur(24px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.6);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(80, 120, 180, 0.25),
|
||||
0 12px 40px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.response-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #222;
|
||||
font: 600 13px/1 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.header-left svg {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.message-count {
|
||||
background: rgba(99, 102, 241, 0.8);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 6px;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: linear-gradient(180deg, #e66 0%, #c33 100%);
|
||||
border-color: #a22;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 12px;
|
||||
border-left: 5px solid var(--type-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.message-item:hover {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: var(--type-color);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
color: #111;
|
||||
word-break: break-word;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 6px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
/* Prevent text selection */
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.dismiss-btn:hover {
|
||||
background: linear-gradient(180deg, #e66 0%, #c33 100%);
|
||||
border-color: #a22;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.messages-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.bubble-slide-enter-active,
|
||||
.bubble-slide-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.bubble-slide-enter-from,
|
||||
.bubble-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
|
||||
.message-enter-active {
|
||||
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.message-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.message-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.95);
|
||||
}
|
||||
|
||||
.message-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -1,490 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, watch, computed } from 'vue'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const terminalContainer = ref<HTMLElement | null>(null)
|
||||
const connected = ref(false)
|
||||
const connecting = ref(false)
|
||||
const sessionId = ref<string | null>(null)
|
||||
const isMinimized = ref(false)
|
||||
|
||||
let terminal: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let socket: WebSocket | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const WS_URL = 'ws://localhost:4103'
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalContainer.value || terminal) return
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
fontSize: 13,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
||||
theme: {
|
||||
background: '#0f0f14',
|
||||
foreground: '#e4e4e7',
|
||||
cursor: '#6366f1',
|
||||
cursorAccent: '#0f0f14',
|
||||
selectionBackground: 'rgba(99, 102, 241, 0.3)',
|
||||
black: '#16161d',
|
||||
red: '#ef4444',
|
||||
green: '#22c55e',
|
||||
yellow: '#eab308',
|
||||
blue: '#3b82f6',
|
||||
magenta: '#a855f7',
|
||||
cyan: '#06b6d4',
|
||||
white: '#e4e4e7',
|
||||
brightBlack: '#52525b',
|
||||
brightRed: '#f87171',
|
||||
brightGreen: '#4ade80',
|
||||
brightYellow: '#facc15',
|
||||
brightBlue: '#60a5fa',
|
||||
brightMagenta: '#c084fc',
|
||||
brightCyan: '#22d3ee',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
allowProposedApi: true
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(new WebLinksAddon())
|
||||
|
||||
terminal.open(terminalContainer.value)
|
||||
|
||||
nextTick(() => {
|
||||
fitAddon?.fit()
|
||||
})
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (fitAddon && terminal && !isMinimized.value) {
|
||||
fitAddon.fit()
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(terminalContainer.value)
|
||||
|
||||
terminal.onData((data) => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (connecting.value || connected.value) return
|
||||
|
||||
connecting.value = true
|
||||
|
||||
try {
|
||||
socket = new WebSocket(WS_URL)
|
||||
|
||||
socket.onopen = () => {
|
||||
connected.value = true
|
||||
connecting.value = false
|
||||
terminal?.focus()
|
||||
|
||||
if (terminal) {
|
||||
socket?.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'connected') {
|
||||
sessionId.value = msg.sessionId
|
||||
if (!msg.isNew) {
|
||||
terminal?.write('\x1b[36m[Reconnected to session]\x1b[0m\r\n')
|
||||
}
|
||||
} else if (msg.type === 'replay') {
|
||||
terminal?.write(msg.data)
|
||||
} else if (msg.type === 'output') {
|
||||
terminal?.write(msg.data)
|
||||
} else if (msg.type === 'exit') {
|
||||
terminal?.write(msg.data)
|
||||
sessionId.value = null
|
||||
} else if (msg.type === 'error') {
|
||||
terminal?.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
connected.value = false
|
||||
connecting.value = false
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
connecting.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (socket) {
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
function toggleMinimize() {
|
||||
isMinimized.value = !isMinimized.value
|
||||
if (!isMinimized.value) {
|
||||
nextTick(() => {
|
||||
fitAddon?.fit()
|
||||
terminal?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function runClaude() {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for open state
|
||||
watch(isOpen, async (open) => {
|
||||
if (open) {
|
||||
await nextTick()
|
||||
if (!terminal) {
|
||||
initTerminal()
|
||||
}
|
||||
if (!connected.value && !connecting.value) {
|
||||
connect()
|
||||
}
|
||||
nextTick(() => {
|
||||
fitAddon?.fit()
|
||||
terminal?.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
initTerminal()
|
||||
connect()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
socket?.close()
|
||||
terminal?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="terminal-slide">
|
||||
<div v-if="isOpen" class="floating-terminal" :class="{ minimized: isMinimized }">
|
||||
<!-- Header -->
|
||||
<div class="terminal-header" @dblclick="toggleMinimize">
|
||||
<div class="header-left">
|
||||
<div class="traffic-lights">
|
||||
<button class="light red" @click="close" title="Close"></button>
|
||||
<button class="light yellow" @click="toggleMinimize" title="Minimize"></button>
|
||||
<button class="light green" @click="runClaude" title="Run Claude"></button>
|
||||
</div>
|
||||
<span class="terminal-title">
|
||||
Terminal
|
||||
<span v-if="sessionId" class="session-id">{{ sessionId }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<span v-if="connected" class="status-dot connected"></span>
|
||||
<span v-else-if="connecting" class="status-dot connecting"></span>
|
||||
<span v-else class="status-dot disconnected"></span>
|
||||
|
||||
<button v-if="!connected" class="btn-connect" @click="connect" :disabled="connecting">
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal body -->
|
||||
<div v-show="!isMinimized" class="terminal-body">
|
||||
<div ref="terminalContainer" class="terminal-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Minimized bar -->
|
||||
<div v-if="isMinimized" class="minimized-bar" @click="toggleMinimize">
|
||||
<span>Click to expand</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.floating-terminal {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 700px;
|
||||
height: 450px;
|
||||
background: #0f0f14;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.floating-terminal.minimized {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #16161d;
|
||||
border-bottom: 1px solid #2a2a3a;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.traffic-lights {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.light:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.light.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.light.yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.light.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.session-id {
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #818cf8;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background: #eab308;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.btn-connect {
|
||||
padding: 4px 12px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-connect:hover:not(:disabled) {
|
||||
background: #818cf8;
|
||||
}
|
||||
|
||||
.btn-connect:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Terminal body */
|
||||
.terminal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: #2a2a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Minimized bar */
|
||||
.minimized-bar {
|
||||
padding: 8px 16px;
|
||||
text-align: center;
|
||||
color: #52525b;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.minimized-bar:hover {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.terminal-slide-enter-active,
|
||||
.terminal-slide-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.terminal-slide-enter-from,
|
||||
.terminal-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(100px) scale(0.95);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.floating-terminal {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.floating-terminal.minimized {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.traffic-lights {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.floating-terminal {
|
||||
width: 550px;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1976
frontend/src/components/FloatingTranscriptDebug.vue
Normal file
1812
frontend/src/components/FloatingVoice.vue
Normal file
262
frontend/src/components/HooksApprovalModal.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useGlobalApproval } from '@/composables/useGlobalApproval'
|
||||
import PermissionApproval from './transcript-debug/PermissionApproval.vue'
|
||||
import PlanApproval from './transcript-debug/PlanApproval.vue'
|
||||
|
||||
const {
|
||||
totalPending,
|
||||
groupedBySession,
|
||||
modalVisible,
|
||||
respondPermission,
|
||||
respondPlan,
|
||||
ignoreApproval
|
||||
} = useGlobalApproval()
|
||||
|
||||
function truncateId(id: string): string {
|
||||
if (id.length <= 12) return id
|
||||
return id.slice(0, 6) + '...' + id.slice(-4)
|
||||
}
|
||||
|
||||
// Auto-hide after 1s when empty
|
||||
let autoHideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(totalPending, (val) => {
|
||||
if (autoHideTimer) {
|
||||
clearTimeout(autoHideTimer)
|
||||
autoHideTimer = null
|
||||
}
|
||||
if (val === 0 && modalVisible.value) {
|
||||
autoHideTimer = setTimeout(() => {
|
||||
modalVisible.value = false
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="approval-modal">
|
||||
<div v-if="modalVisible" class="approval-backdrop" @click.self="modalVisible = false">
|
||||
<div class="approval-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Hooks Approval
|
||||
</span>
|
||||
<span v-if="totalPending > 0" class="panel-count">{{ totalPending }}</span>
|
||||
<button class="panel-close" @click="modalVisible = false" title="Minimize">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<template v-if="groupedBySession.length > 0">
|
||||
<div v-for="group in groupedBySession" :key="group.sessionId" class="session-group">
|
||||
<div class="session-header">
|
||||
<span class="session-agent">{{ group.agent }}</span>
|
||||
<span class="session-sep">/</span>
|
||||
<span class="session-id" :title="group.sessionId">{{ truncateId(group.sessionId) }}</span>
|
||||
<span class="session-count">{{ group.permissions.length + group.plans.length }}</span>
|
||||
</div>
|
||||
|
||||
<PermissionApproval
|
||||
v-for="perm in group.permissions"
|
||||
:key="perm.requestId"
|
||||
:request="perm"
|
||||
@respond="(id, decision, reason) => respondPermission(id, decision, reason)"
|
||||
@ignore="(id) => ignoreApproval(id)"
|
||||
/>
|
||||
<PlanApproval
|
||||
v-for="plan in group.plans"
|
||||
:key="plan.requestId"
|
||||
:request="plan"
|
||||
@respond="(id, decision, reason) => respondPlan(id, decision, reason)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
<span>No pending approvals</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.approval-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10012;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.approval-panel {
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-title svg {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.panel-count {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 10px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.panel-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.session-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.session-agent {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.session-sep {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.session-id {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.session-count {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-hover);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Modal transition */
|
||||
.approval-modal-enter-active,
|
||||
.approval-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.approval-modal-enter-active .approval-panel,
|
||||
.approval-modal-leave-active .approval-panel {
|
||||
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.approval-modal-enter-from,
|
||||
.approval-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.approval-modal-enter-from .approval-panel {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.approval-modal-leave-to .approval-panel {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
218
frontend/src/components/PwaInstallBanner.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||
}
|
||||
|
||||
const installPrompt = ref<BeforeInstallPromptEvent | null>(null)
|
||||
const isInstalled = ref(false)
|
||||
const isPwa = ref(false)
|
||||
const dismissed = ref(false)
|
||||
|
||||
// Check if running as PWA (standalone mode)
|
||||
const checkPwaMode = () => {
|
||||
isPwa.value = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true ||
|
||||
document.referrer.includes('android-app://')
|
||||
}
|
||||
|
||||
// Check if app is already installed
|
||||
const checkInstalled = () => {
|
||||
if ('getInstalledRelatedApps' in navigator) {
|
||||
(navigator as any).getInstalledRelatedApps().then((apps: any[]) => {
|
||||
isInstalled.value = apps.length > 0
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault()
|
||||
installPrompt.value = e as BeforeInstallPromptEvent
|
||||
}
|
||||
|
||||
const handleAppInstalled = () => {
|
||||
isInstalled.value = true
|
||||
installPrompt.value = null
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
if (!installPrompt.value) return
|
||||
|
||||
await installPrompt.value.prompt()
|
||||
const { outcome } = await installPrompt.value.userChoice
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
isInstalled.value = true
|
||||
}
|
||||
installPrompt.value = null
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
dismissed.value = true
|
||||
sessionStorage.setItem('pwa-dismissed', 'true')
|
||||
}
|
||||
|
||||
const showBanner = computed(() => {
|
||||
if (isPwa.value) return false
|
||||
if (dismissed.value) return false
|
||||
return installPrompt.value !== null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
checkPwaMode()
|
||||
checkInstalled()
|
||||
|
||||
// Check if dismissed this session
|
||||
dismissed.value = sessionStorage.getItem('pwa-dismissed') === 'true'
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.addEventListener('appinstalled', handleAppInstalled)
|
||||
|
||||
// Listen for display mode changes
|
||||
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPwaMode)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.removeEventListener('appinstalled', handleAppInstalled)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="banner">
|
||||
<div v-if="showBanner" class="pwa-banner">
|
||||
<div class="banner-content">
|
||||
<div class="banner-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="banner-text">Instalar Agent UI</span>
|
||||
</div>
|
||||
<div class="banner-actions">
|
||||
<button class="btn-install" @click="install">
|
||||
Instalar
|
||||
</button>
|
||||
<button class="btn-dismiss" @click="dismiss" title="Cerrar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pwa-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-install {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-install:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.btn-dismiss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-dismiss:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.banner-enter-active,
|
||||
.banner-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.banner-enter-from,
|
||||
.banner-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.pwa-banner {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
margin: 0;
|
||||
z-index: 9990;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
282
frontend/src/components/ServerConfigDialog.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useServerConfig } from '@/stores/server-config'
|
||||
|
||||
const serverConfig = useServerConfig()
|
||||
|
||||
const urlInput = ref('')
|
||||
const testing = ref(false)
|
||||
const testResult = ref<'idle' | 'success' | 'error'>('idle')
|
||||
|
||||
async function handleTest() {
|
||||
if (!urlInput.value.trim()) return
|
||||
testing.value = true
|
||||
testResult.value = 'idle'
|
||||
|
||||
const ok = await serverConfig.testConnection(urlInput.value.trim())
|
||||
testResult.value = ok ? 'success' : 'error'
|
||||
testing.value = false
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (!urlInput.value.trim()) return
|
||||
const ok = await serverConfig.setServer(urlInput.value.trim())
|
||||
if (ok) {
|
||||
testResult.value = 'success'
|
||||
}
|
||||
}
|
||||
|
||||
function selectRecent(url: string) {
|
||||
urlInput.value = url
|
||||
testResult.value = 'idle'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (serverConfig.serverUrl) {
|
||||
urlInput.value = serverConfig.serverUrl
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="server-config-overlay">
|
||||
<div class="server-config-dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>Connect to Server</h2>
|
||||
<p class="dialog-subtitle">Enter the URL of your Agent UI backend</p>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<div class="input-group">
|
||||
<label for="server-url">Server URL</label>
|
||||
<div class="input-row">
|
||||
<input
|
||||
id="server-url"
|
||||
v-model="urlInput"
|
||||
type="url"
|
||||
placeholder="https://your-server.com or http://192.168.1.100:4101"
|
||||
@keydown.enter="handleConnect"
|
||||
:disabled="serverConfig.loading"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-test"
|
||||
@click="handleTest"
|
||||
:disabled="!urlInput.trim() || testing"
|
||||
>
|
||||
{{ testing ? 'Testing...' : 'Test' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult === 'success'" class="status-msg success">
|
||||
Connected successfully
|
||||
</div>
|
||||
<div v-if="testResult === 'error'" class="status-msg error">
|
||||
Could not connect to server
|
||||
</div>
|
||||
<div v-if="serverConfig.error" class="status-msg error">
|
||||
{{ serverConfig.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="serverConfig.recentUrls.length > 0" class="recent-urls">
|
||||
<label>Recent</label>
|
||||
<div class="recent-list">
|
||||
<button
|
||||
v-for="url in serverConfig.recentUrls"
|
||||
:key="url"
|
||||
class="recent-item"
|
||||
@click="selectRecent(url)"
|
||||
>
|
||||
{{ url }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="handleConnect"
|
||||
:disabled="!urlInput.trim() || serverConfig.loading"
|
||||
>
|
||||
{{ serverConfig.loading ? 'Connecting...' : 'Connect' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.server-config-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.server-config-dialog {
|
||||
background: var(--bg-primary, #0f0f14);
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
}
|
||||
|
||||
.dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e4e4e7);
|
||||
}
|
||||
|
||||
.dialog-subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #a1a1aa);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #a1a1aa);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-row input {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--bg-secondary, #1a1a24);
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary, #e4e4e7);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-row input:focus {
|
||||
border-color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.input-row input::placeholder {
|
||||
color: var(--text-muted, #52525b);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #1a1a24);
|
||||
color: var(--text-primary, #e4e4e7);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover, #252530);
|
||||
border-color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent, #6366f1);
|
||||
border-color: var(--accent, #6366f1);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
.status-msg.success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-msg.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.recent-urls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.recent-urls label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #a1a1aa);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.recent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.06));
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary, #a1a1aa);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.recent-item:hover {
|
||||
background: var(--bg-hover, #252530);
|
||||
color: var(--text-primary, #e4e4e7);
|
||||
border-color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
}
|
||||
</style>
|
||||
204
frontend/src/components/TerminalNavButtons.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
waitingForToken?: boolean
|
||||
showStart?: boolean
|
||||
showRestart?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
requestToken: []
|
||||
runClaude: []
|
||||
runClaudeContinue: []
|
||||
runClaudeResume: []
|
||||
clearBuffer: []
|
||||
refresh: []
|
||||
restart: []
|
||||
sendKey: [key: string]
|
||||
scroll: [direction: 'up' | 'down' | 'end']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tnb-bar">
|
||||
<!-- Agent lifecycle -->
|
||||
<button v-if="showStart" class="tnb-btn start" title="Start Agent" @click="$emit('restart')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
<span class="tnb-label">Start</span>
|
||||
</button>
|
||||
<button v-if="showRestart" class="tnb-btn restart" title="Restart Agent" @click="$emit('restart')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
<span class="tnb-label">Restart</span>
|
||||
</button>
|
||||
<span v-if="showStart || showRestart" class="tnb-sep"></span>
|
||||
<!-- Action buttons -->
|
||||
<button class="tnb-btn mcp" :class="{ waiting: waitingForToken }" title="Connect MCP" @click="$emit('requestToken')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
<span class="tnb-label">MCP</span>
|
||||
</button>
|
||||
<button class="tnb-btn claude" title="Run Claude" @click="$emit('runClaude')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
<span class="tnb-label">Claude</span>
|
||||
</button>
|
||||
<button class="tnb-btn continue" title="Continue" @click="$emit('runClaudeContinue')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
|
||||
<span class="tnb-label">Cont</span>
|
||||
</button>
|
||||
<button class="tnb-btn resume-btn" title="Resume" @click="$emit('runClaudeResume')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/><path d="M21 3v6h-6"/></svg>
|
||||
<span class="tnb-label">Resume</span>
|
||||
</button>
|
||||
<button class="tnb-btn clear" title="Clear Buffer" @click="$emit('clearBuffer')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14"/></svg>
|
||||
<span class="tnb-label">Clear</span>
|
||||
</button>
|
||||
<button class="tnb-btn refresh-btn" title="Refresh Screen" @click="$emit('refresh')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
<span class="tnb-label">Refresh</span>
|
||||
</button>
|
||||
|
||||
<span class="tnb-sep"></span>
|
||||
|
||||
<!-- Special keys -->
|
||||
<button class="tnb-btn key esc" title="Escape" @click="$emit('sendKey', 'esc')">Esc</button>
|
||||
<button class="tnb-btn key tab" title="Tab" @click="$emit('sendKey', 'tab')">Tab</button>
|
||||
<button class="tnb-btn key ctrl-c" title="Ctrl+C" @click="$emit('sendKey', 'ctrl-c')">^C</button>
|
||||
<button class="tnb-btn key alt-m" title="Alt+M" @click="$emit('sendKey', 'alt-m')">Alt+M</button>
|
||||
|
||||
<span class="tnb-sep"></span>
|
||||
|
||||
<!-- Scroll buttons -->
|
||||
<div class="tnb-group">
|
||||
<button class="tnb-btn scroll-btn" title="Scroll up" @click="$emit('scroll', 'up')">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M18 15l-6-6-6 6"/></svg>
|
||||
</button>
|
||||
<button class="tnb-btn scroll-btn end" title="Scroll to bottom" @click="$emit('scroll', 'end')">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M7 7l5 5 5-5"/><path d="M7 13l5 5 5-5"/></svg>
|
||||
</button>
|
||||
<button class="tnb-btn scroll-btn" title="Scroll down" @click="$emit('scroll', 'down')">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Arrow keys -->
|
||||
<div class="tnb-group arrows">
|
||||
<button class="tnb-btn arrow" title="Up" @click="$emit('sendKey', 'up')">▲</button>
|
||||
<div class="tnb-arrow-row">
|
||||
<button class="tnb-btn arrow" title="Left" @click="$emit('sendKey', 'left')">◀</button>
|
||||
<button class="tnb-btn arrow" title="Down" @click="$emit('sendKey', 'down')">▼</button>
|
||||
<button class="tnb-btn arrow" title="Right" @click="$emit('sendKey', 'right')">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tnb-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px 6px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tnb-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.tnb-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
margin: 0 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnb-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-family: system-ui, sans-serif;
|
||||
white-space: nowrap;
|
||||
transition: all 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tnb-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.tnb-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.tnb-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Agent lifecycle */
|
||||
.tnb-btn.start { color: #10b981; border-color: rgba(16, 185, 129, 0.2); }
|
||||
.tnb-btn.start:hover { background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.3); color: #34d399; }
|
||||
.tnb-btn.restart { color: rgba(245, 158, 11, 0.8); border-color: rgba(245, 158, 11, 0.2); }
|
||||
.tnb-btn.restart:hover { background: rgba(245, 158, 11, 0.2); border-color: rgba(245, 158, 11, 0.3); color: #f59e0b; }
|
||||
|
||||
/* Action button variants */
|
||||
.tnb-btn.mcp:hover { background: rgba(236, 72, 153, 0.2); border-color: rgba(236, 72, 153, 0.3); color: #ec4899; }
|
||||
.tnb-btn.mcp.waiting { background: rgba(16, 185, 129, 0.2); border-color: #10b981; color: #10b981; animation: tnb-pulse 0.8s infinite; }
|
||||
.tnb-btn.claude:hover { background: rgba(139, 92, 246, 0.2); border-color: rgba(139, 92, 246, 0.3); color: #8b5cf6; }
|
||||
.tnb-btn.continue:hover { background: rgba(6, 182, 212, 0.2); border-color: rgba(6, 182, 212, 0.3); color: #06b6d4; }
|
||||
.tnb-btn.resume-btn:hover { background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.3); color: #10b981; }
|
||||
.tnb-btn.clear:hover { background: rgba(245, 158, 11, 0.2); border-color: rgba(245, 158, 11, 0.3); color: #f59e0b; }
|
||||
.tnb-btn.refresh-btn:hover { background: rgba(59, 130, 246, 0.2); border-color: rgba(59, 130, 246, 0.3); color: #3b82f6; }
|
||||
|
||||
/* Key buttons */
|
||||
.tnb-btn.key {
|
||||
padding: 3px 6px;
|
||||
font-weight: 600;
|
||||
font-size: 9px;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
.tnb-btn.ctrl-c:hover { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.3); color: #ef4444; }
|
||||
.tnb-btn.alt-m:hover { background: rgba(59, 130, 246, 0.2); border-color: rgba(59, 130, 246, 0.3); color: #3b82f6; }
|
||||
|
||||
/* Scroll buttons */
|
||||
.tnb-btn.scroll-btn {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.tnb-btn.scroll-btn:hover { background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.3); color: #10b981; }
|
||||
.tnb-btn.scroll-btn.end:hover { background: rgba(139, 92, 246, 0.2); border-color: rgba(139, 92, 246, 0.3); color: #8b5cf6; }
|
||||
|
||||
/* Arrow buttons */
|
||||
.tnb-btn.arrow {
|
||||
padding: 1px 4px;
|
||||
font-size: 8px;
|
||||
min-width: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Groups */
|
||||
.tnb-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tnb-group.arrows {
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.tnb-arrow-row {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
@keyframes tnb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
</style>
|
||||
@@ -4,25 +4,16 @@ import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import { useProjectCanvasStore } from '../stores/projectCanvas'
|
||||
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const canvasStore = useCanvasStore()
|
||||
const projectCanvasStore = useProjectCanvasStore()
|
||||
|
||||
function clearCanvas() {
|
||||
const container = document.getElementById('canvas-content')
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="canvas-placeholder">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18"/>
|
||||
<path d="M9 21V9"/>
|
||||
</svg>
|
||||
<p>Canvas listo</p>
|
||||
<span>Claude Code puede renderizar contenido aquí</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('clear-canvas'))
|
||||
}
|
||||
|
||||
function toggleHistory() {
|
||||
@@ -39,7 +30,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="toolbar">
|
||||
<aside class="toolbar" :class="{ collapsed }">
|
||||
<!-- Navegacion principal -->
|
||||
<div class="toolbar-section nav-section">
|
||||
<RouterLink to="/" class="toolbar-btn" :class="{ active: route.path === '/' }" title="Home">
|
||||
@@ -79,12 +70,6 @@ onMounted(() => {
|
||||
|
||||
<!-- Gestion -->
|
||||
<div class="toolbar-section">
|
||||
<RouterLink to="/projects" class="toolbar-btn" :class="{ active: route.path === '/projects' }" title="Proyectos">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/components" class="toolbar-btn" :class="{ active: route.path === '/components' }" title="Componentes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
@@ -119,10 +104,33 @@ onMounted(() => {
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/terminal" class="toolbar-btn" :class="{ active: route.path === '/terminal' }" title="Terminal">
|
||||
<RouterLink to="/tools" class="toolbar-btn" :class="{ active: route.path === '/tools' }" title="Tools">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/git" class="toolbar-btn" :class="{ active: route.path === '/git' }" title="Git">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="6" y1="3" x2="6" y2="15"/>
|
||||
<circle cx="18" cy="6" r="3"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<path d="M18 9a9 9 0 0 1-9 9"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/agents" class="toolbar-btn" :class="{ active: route.path === '/agents' }" title="Agents">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L9.5 7.5 4 8.5l4 4-1 5.5L12 15l5 3-1-5.5 4-4-5.5-1z"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/transcript-debug" class="toolbar-btn" :class="{ active: route.path === '/transcript-debug' }" title="Transcript Debug">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
@@ -158,6 +166,19 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar.collapsed {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border-right-color: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toolbar-section {
|
||||
|
||||
391
frontend/src/components/ToolsDropdown.vue
Normal file
@@ -0,0 +1,391 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useToolsStore } from '../stores/tools'
|
||||
import {
|
||||
activateCategory,
|
||||
deactivateCategory,
|
||||
syncStoreWithActiveTools
|
||||
} from '../services/toolRegistry'
|
||||
import { CATEGORY_INFO, type ToolCategory } from '../services/tools/toolDefinitions'
|
||||
|
||||
const toolsStore = useToolsStore()
|
||||
const { activeTools, pinnedTools } = storeToRefs(toolsStore)
|
||||
const isOpen = ref(false)
|
||||
|
||||
// Category to tools mapping
|
||||
const categoryTools: Record<ToolCategory, string[]> = {
|
||||
global: ['get_current_page', 'navigate_to', 'list_available_tools', 'activate_tool', 'deactivate_tool', 'pin_tool'],
|
||||
canvas: ['render_html', 'render_vue_component'],
|
||||
component: ['save_vue_component', 'load_vue_component', 'list_vue_components', 'delete_vue_component'],
|
||||
theme: ['get_design_tokens', 'get_active_theme', 'set_theme_variable', 'save_theme', 'list_themes', 'switch_theme', 'reset_theme'],
|
||||
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
|
||||
source: ['get_repo_info', 'list_repo_files', 'read_repo_file', 'search_repo_code'],
|
||||
project: ['list_canvases', 'create_canvas', 'get_canvas', 'update_canvas', 'delete_canvas', 'clone_canvas', 'add_component_to_canvas', 'remove_component_from_canvas', 'get_canvas_components']
|
||||
}
|
||||
|
||||
const categories = computed(() => {
|
||||
return Object.entries(CATEGORY_INFO).map(([key, info]) => {
|
||||
const tools = categoryTools[key as ToolCategory]
|
||||
const activeCount = tools.filter(t => activeTools.value.includes(t)).length
|
||||
const pinnedCount = tools.filter(t => pinnedTools.value.includes(t)).length
|
||||
const allPinned = tools.every(t => pinnedTools.value.includes(t))
|
||||
|
||||
return {
|
||||
key: key as ToolCategory,
|
||||
...info,
|
||||
tools,
|
||||
activeCount,
|
||||
totalCount: tools.length,
|
||||
pinnedCount,
|
||||
allPinned
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const totalPinned = computed(() => pinnedTools.value.length)
|
||||
const totalActive = computed(() => activeTools.value.length)
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
syncStoreWithActiveTools()
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.tools-dropdown-container')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePinCategory(category: ToolCategory) {
|
||||
const tools = categoryTools[category]
|
||||
const allPinned = tools.every(t => pinnedTools.value.includes(t))
|
||||
|
||||
if (allPinned) {
|
||||
// Unpin all tools in category
|
||||
for (const tool of tools) {
|
||||
toolsStore.unpinTool(tool)
|
||||
}
|
||||
} else {
|
||||
// Pin all tools in category and activate them
|
||||
for (const tool of tools) {
|
||||
toolsStore.pinTool(tool)
|
||||
}
|
||||
await activateCategory(category)
|
||||
}
|
||||
syncStoreWithActiveTools()
|
||||
}
|
||||
|
||||
async function handleActivateCategory(category: ToolCategory) {
|
||||
await activateCategory(category)
|
||||
syncStoreWithActiveTools()
|
||||
}
|
||||
|
||||
function handleDeactivateCategory(category: ToolCategory) {
|
||||
deactivateCategory(category)
|
||||
syncStoreWithActiveTools()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeDropdown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tools-dropdown-container">
|
||||
<button class="dropdown-trigger" @click.stop="toggleDropdown" title="Herramientas MCP">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
<span>Tools</span>
|
||||
<span v-if="totalPinned > 0" class="badge">{{ totalPinned }}</span>
|
||||
<svg class="chevron" :class="{ open: isOpen }" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="dropdown-menu" @click.stop>
|
||||
<div class="dropdown-header">
|
||||
<span class="header-title">Tool Categories</span>
|
||||
<span class="header-stats">{{ totalActive }} active</span>
|
||||
</div>
|
||||
|
||||
<div class="categories-list">
|
||||
<div
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
class="category-item"
|
||||
:style="{ '--cat-color': cat.color }"
|
||||
>
|
||||
<div class="category-info">
|
||||
<div class="category-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path :d="cat.icon"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="category-details">
|
||||
<span class="category-name">{{ cat.label }}</span>
|
||||
<span class="category-count">{{ cat.activeCount }}/{{ cat.totalCount }} active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category-actions">
|
||||
<button
|
||||
class="action-btn pin-btn"
|
||||
:class="{ pinned: cat.allPinned }"
|
||||
@click="handlePinCategory(cat.key)"
|
||||
:title="cat.allPinned ? 'Unpin category' : 'Pin category (keep active)'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 17v5"/>
|
||||
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v4.76z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn activate-btn"
|
||||
@click="handleActivateCategory(cat.key)"
|
||||
title="Activate all"
|
||||
:disabled="cat.activeCount === cat.totalCount"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn deactivate-btn"
|
||||
@click="handleDeactivateCategory(cat.key)"
|
||||
title="Deactivate all"
|
||||
:disabled="cat.activeCount === 0 || cat.allPinned"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-footer">
|
||||
<RouterLink to="/tools" class="manage-link" @click="isOpen = false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
Manage all tools
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tools-dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-trigger:hover {
|
||||
background: var(--bg-tertiary, rgba(255,255,255,0.1));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.categories-list {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.25rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.category-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.category-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 6px;
|
||||
color: var(--cat-color);
|
||||
}
|
||||
|
||||
.category-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.category-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover:not(:disabled) {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pin-btn:hover:not(:disabled) {
|
||||
color: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.pin-btn.pinned {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.activate-btn:hover:not(:disabled) {
|
||||
color: #10b981;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.deactivate-btn:hover:not(:disabled) {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.dropdown-footer {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.manage-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.manage-link:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
618
frontend/src/components/TorchButton.vue
Normal file
@@ -0,0 +1,618 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useTorchStore } from '../stores/torch'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import { requestTorch, releaseTorch, transferTorch, updateName, setAutoRequest } from '../services/torch'
|
||||
|
||||
const torchStore = useTorchStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const isEditingName = ref(false)
|
||||
const nameInput = ref('')
|
||||
const nameInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Combined state
|
||||
const hasTorch = computed(() => torchStore.hasTorch)
|
||||
const isConnected = computed(() => canvasStore.isConnected)
|
||||
const isReconnecting = computed(() => canvasStore.isReconnecting)
|
||||
|
||||
const displayName = computed(() => torchStore.clientName || 'Anonymous')
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (!torchStore.isConnected) return 'disconnected'
|
||||
if (!hasTorch.value) return 'no-torch'
|
||||
if (isReconnecting.value) return 'reconnecting'
|
||||
if (isConnected.value) return 'connected'
|
||||
return 'has-torch'
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!torchStore.isConnected) return 'Offline'
|
||||
if (!hasTorch.value) return 'No torch'
|
||||
if (isReconnecting.value) return 'Reconnecting'
|
||||
if (isConnected.value) return 'Connected'
|
||||
return 'Connecting...'
|
||||
})
|
||||
|
||||
const statusBadgeClass = computed(() => {
|
||||
if (!torchStore.isConnected) return 'error'
|
||||
if (!hasTorch.value) return 'error'
|
||||
if (isReconnecting.value || !isConnected.value) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function closeDropdown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.torch-dropdown-container')) {
|
||||
isOpen.value = false
|
||||
isEditingName.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditingName() {
|
||||
nameInput.value = torchStore.clientName
|
||||
isEditingName.value = true
|
||||
nextTick(() => {
|
||||
nameInputRef.value?.focus()
|
||||
nameInputRef.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function saveName() {
|
||||
const trimmed = nameInput.value.trim().substring(0, 20)
|
||||
updateName(trimmed)
|
||||
isEditingName.value = false
|
||||
}
|
||||
|
||||
function cancelEditName() {
|
||||
isEditingName.value = false
|
||||
}
|
||||
|
||||
async function handleAction() {
|
||||
if (torchStore.hasTorch) {
|
||||
await releaseTorch()
|
||||
} else {
|
||||
await requestTorch()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransfer(targetId: string) {
|
||||
await transferTorch(targetId)
|
||||
}
|
||||
|
||||
function toggleAutoRequest() {
|
||||
setAutoRequest(!torchStore.autoRequest)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeDropdown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="torch-dropdown-container">
|
||||
<!-- Split Trigger: main area = request/release, chevron = dropdown -->
|
||||
<div class="trigger-split" :class="[statusClass, { requesting: torchStore.isRequesting }]">
|
||||
<button class="trigger-main" @click.stop="handleAction" :title="hasTorch ? 'Release torch' : 'Request torch'">
|
||||
<span class="status-dot" :class="statusBadgeClass"></span>
|
||||
<span class="trigger-name">{{ displayName }}</span>
|
||||
<span v-if="torchStore.isRequesting" class="requesting-indicator"></span>
|
||||
</button>
|
||||
<button class="trigger-chevron" @click.stop="toggleDropdown" title="Settings">
|
||||
<svg class="chevron" :class="{ open: isOpen }" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div v-if="isOpen" class="dropdown-menu" @click.stop>
|
||||
<!-- Header -->
|
||||
<div class="dropdown-header">
|
||||
<span class="header-title">Torch</span>
|
||||
<span class="status-badge" :class="statusBadgeClass">{{ statusText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Name Section -->
|
||||
<div class="name-section">
|
||||
<label class="section-label">Name</label>
|
||||
<div v-if="isEditingName" class="name-edit">
|
||||
<input
|
||||
ref="nameInputRef"
|
||||
v-model="nameInput"
|
||||
type="text"
|
||||
class="name-input"
|
||||
maxlength="20"
|
||||
placeholder="Anonymous"
|
||||
@keyup.enter="saveName"
|
||||
@keyup.escape="cancelEditName"
|
||||
@blur="saveName"
|
||||
/>
|
||||
</div>
|
||||
<button v-else class="name-display" @click="startEditingName">
|
||||
<span>{{ displayName }}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Auto-request toggle -->
|
||||
<div class="auto-request-section">
|
||||
<label class="toggle-row" @click="toggleAutoRequest">
|
||||
<span class="toggle-label">Auto-request</span>
|
||||
<span class="toggle-switch" :class="{ active: torchStore.autoRequest }">
|
||||
<span class="toggle-knob"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Clients list -->
|
||||
<div class="clients-section">
|
||||
<label class="section-label">Clients ({{ torchStore.clients.length }})</label>
|
||||
<div class="clients-list">
|
||||
<div
|
||||
v-for="client in torchStore.clients"
|
||||
:key="client.id"
|
||||
class="client-row"
|
||||
>
|
||||
<span class="client-dot" :class="{ holder: client.hasTorch }"></span>
|
||||
<span class="client-name">{{ client.name || 'Anonymous' }}</span>
|
||||
<span v-if="client.id === torchStore.clientId" class="you-badge">you</span>
|
||||
<span v-if="client.hasTorch" class="torch-badge">torch</span>
|
||||
<button
|
||||
v-if="!client.hasTorch && client.id !== torchStore.clientId && torchStore.hasTorch"
|
||||
class="transfer-btn"
|
||||
@click="handleTransfer(client.id)"
|
||||
title="Transfer torch"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="torchStore.clients.length === 0" class="no-clients">
|
||||
No clients connected
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action button -->
|
||||
<div class="action-section">
|
||||
<button
|
||||
class="action-btn"
|
||||
:class="hasTorch ? 'release' : 'request'"
|
||||
@click="handleAction"
|
||||
:disabled="torchStore.isRequesting"
|
||||
>
|
||||
{{ torchStore.isRequesting ? 'Requesting...' : hasTorch ? 'Release Torch' : 'Request Torch' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.torch-dropdown-container {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.trigger-split {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trigger-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem 0.375rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-radius: 7px 0 0 7px;
|
||||
}
|
||||
|
||||
.trigger-main:hover {
|
||||
background: var(--bg-tertiary, rgba(255,255,255,0.1));
|
||||
}
|
||||
|
||||
.trigger-chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--border-color);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-radius: 0 7px 7px 0;
|
||||
}
|
||||
|
||||
.trigger-chevron:hover {
|
||||
background: var(--bg-tertiary, rgba(255,255,255,0.1));
|
||||
}
|
||||
|
||||
.trigger-name {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.trigger-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.success {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 6px rgba(16, 185, 129, 0.6);
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: #f59e0b;
|
||||
animation: dot-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.requesting-indicator {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: request-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Name section */
|
||||
.name-section {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.name-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.name-display:hover {
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.name-display svg {
|
||||
color: var(--text-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.name-display:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.name-input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Auto-request section */
|
||||
.auto-request-section {
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch.active .toggle-knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Clients section */
|
||||
.clients-section {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.clients-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.client-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.client-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.client-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.client-dot.holder {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.6);
|
||||
}
|
||||
|
||||
.client-name {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.you-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.torch-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.transfer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.transfer-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.no-clients {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Action section */
|
||||
.action-section {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn.request {
|
||||
background: var(--accent, #6366f1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.request:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.action-btn.release {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-btn.release:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes request-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.3); opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
407
frontend/src/components/WindowContainer.vue
Normal file
@@ -0,0 +1,407 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
id: string
|
||||
title?: string
|
||||
x?: number
|
||||
y?: number
|
||||
width?: number
|
||||
height?: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
}>(), {
|
||||
title: 'Window',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 400,
|
||||
height: 300,
|
||||
minWidth: 200,
|
||||
minHeight: 100
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
move: [pos: { x: number; y: number }]
|
||||
resize: [size: { width: number; height: number }]
|
||||
focus: []
|
||||
}>()
|
||||
|
||||
// Estado interno de la ventana
|
||||
const currentX = ref(props.x)
|
||||
const currentY = ref(props.y)
|
||||
const currentWidth = ref(props.width)
|
||||
const currentHeight = ref(props.height)
|
||||
const zIndex = ref(100)
|
||||
|
||||
// Estado de drag
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartY = ref(0)
|
||||
const dragOffsetX = ref(0)
|
||||
const dragOffsetY = ref(0)
|
||||
|
||||
// Estado de resize
|
||||
const isResizing = ref(false)
|
||||
const resizeDirection = ref('')
|
||||
const resizeStartX = ref(0)
|
||||
const resizeStartY = ref(0)
|
||||
const resizeStartWidth = ref(0)
|
||||
const resizeStartHeight = ref(0)
|
||||
|
||||
const windowStyle = computed(() => ({
|
||||
left: `${currentX.value}px`,
|
||||
top: `${currentY.value}px`,
|
||||
width: `${currentWidth.value}px`,
|
||||
height: `${currentHeight.value}px`,
|
||||
zIndex: zIndex.value
|
||||
}))
|
||||
|
||||
// Drag handlers
|
||||
function startDrag(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).closest('.window-controls')) return
|
||||
|
||||
isDragging.value = true
|
||||
dragStartX.value = e.clientX
|
||||
dragStartY.value = e.clientY
|
||||
dragOffsetX.value = currentX.value
|
||||
dragOffsetY.value = currentY.value
|
||||
|
||||
bringToFront()
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const deltaX = e.clientX - dragStartX.value
|
||||
const deltaY = e.clientY - dragStartY.value
|
||||
|
||||
currentX.value = Math.max(0, dragOffsetX.value + deltaX)
|
||||
currentY.value = Math.max(0, dragOffsetY.value + deltaY)
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
if (isDragging.value) {
|
||||
isDragging.value = false
|
||||
emit('move', { x: currentX.value, y: currentY.value })
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
// Resize handlers
|
||||
function startResize(direction: string, e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
isResizing.value = true
|
||||
resizeDirection.value = direction
|
||||
resizeStartX.value = e.clientX
|
||||
resizeStartY.value = e.clientY
|
||||
resizeStartWidth.value = currentWidth.value
|
||||
resizeStartHeight.value = currentHeight.value
|
||||
|
||||
bringToFront()
|
||||
|
||||
document.addEventListener('mousemove', onResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
function onResize(e: MouseEvent) {
|
||||
if (!isResizing.value) return
|
||||
|
||||
const deltaX = e.clientX - resizeStartX.value
|
||||
const deltaY = e.clientY - resizeStartY.value
|
||||
|
||||
if (resizeDirection.value.includes('e')) {
|
||||
currentWidth.value = Math.max(props.minWidth, resizeStartWidth.value + deltaX)
|
||||
}
|
||||
|
||||
if (resizeDirection.value.includes('s')) {
|
||||
currentHeight.value = Math.max(props.minHeight, resizeStartHeight.value + deltaY)
|
||||
}
|
||||
|
||||
if (resizeDirection.value.includes('w')) {
|
||||
const newWidth = Math.max(props.minWidth, resizeStartWidth.value - deltaX)
|
||||
if (newWidth !== currentWidth.value) {
|
||||
currentX.value = resizeStartX.value + (resizeStartWidth.value - newWidth) + (e.clientX - resizeStartX.value) - deltaX
|
||||
currentWidth.value = newWidth
|
||||
}
|
||||
}
|
||||
|
||||
if (resizeDirection.value.includes('n')) {
|
||||
const newHeight = Math.max(props.minHeight, resizeStartHeight.value - deltaY)
|
||||
if (newHeight !== currentHeight.value) {
|
||||
currentY.value = resizeStartY.value + (resizeStartHeight.value - newHeight) + (e.clientY - resizeStartY.value) - deltaY
|
||||
currentHeight.value = newHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false
|
||||
emit('resize', { width: currentWidth.value, height: currentHeight.value })
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
// Z-index management
|
||||
let maxZIndex = 100
|
||||
|
||||
function bringToFront() {
|
||||
maxZIndex++
|
||||
zIndex.value = maxZIndex
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
function handleWindowClick() {
|
||||
bringToFront()
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="window-container"
|
||||
:style="windowStyle"
|
||||
:data-window-id="id"
|
||||
@mousedown="handleWindowClick"
|
||||
>
|
||||
<!-- Header / Title bar -->
|
||||
<div class="window-header" @mousedown="startDrag">
|
||||
<span class="window-title">{{ title }}</span>
|
||||
<div class="window-controls">
|
||||
<button @click.stop="emit('close')" class="btn-close" title="Cerrar">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
||||
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="window-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Resize handles -->
|
||||
<div class="resize-handle resize-n" @mousedown="startResize('n', $event)"></div>
|
||||
<div class="resize-handle resize-e" @mousedown="startResize('e', $event)"></div>
|
||||
<div class="resize-handle resize-s" @mousedown="startResize('s', $event)"></div>
|
||||
<div class="resize-handle resize-w" @mousedown="startResize('w', $event)"></div>
|
||||
<div class="resize-handle resize-ne" @mousedown="startResize('ne', $event)"></div>
|
||||
<div class="resize-handle resize-se" @mousedown="startResize('se', $event)"></div>
|
||||
<div class="resize-handle resize-sw" @mousedown="startResize('sw', $event)"></div>
|
||||
<div class="resize-handle resize-nw" @mousedown="startResize('nw', $event)"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.window-container {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.2),
|
||||
inset 0 0.5px 0 rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 200px;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.window-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 59, 48, 0.7);
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-close svg {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: rgba(255, 59, 48, 1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-close:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.window-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.window-content::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.window-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.window-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.window-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.window-content::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-n {
|
||||
top: 0;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 4px;
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.resize-e {
|
||||
right: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 4px;
|
||||
cursor: e-resize;
|
||||
}
|
||||
|
||||
.resize-s {
|
||||
bottom: 0;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 4px;
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.resize-w {
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 4px;
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
.resize-ne {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.resize-se {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.resize-sw {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.resize-nw {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
/* Indicador visual de resize en esquinas */
|
||||
.resize-se::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 2px solid #444;
|
||||
border-bottom: 2px solid #444;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.window-container:hover .resize-se::after {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
115
frontend/src/components/WindowControls.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// Always show on non-mobile — we have decorations:false so we need these
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent)
|
||||
const show = !isMobile
|
||||
|
||||
const isMaximized = ref(false)
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
async function getWin() {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
return getCurrentWindow()
|
||||
}
|
||||
|
||||
async function initWindowState() {
|
||||
try {
|
||||
const win = await getWin()
|
||||
isMaximized.value = await win.isMaximized()
|
||||
unlisten = await win.onResized(async () => {
|
||||
isMaximized.value = await win.isMaximized()
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function minimizeWindow() {
|
||||
try { (await getWin()).minimize() } catch { }
|
||||
}
|
||||
|
||||
async function toggleMaximize() {
|
||||
try { (await getWin()).toggleMaximize() } catch { }
|
||||
}
|
||||
|
||||
async function closeWindow() {
|
||||
try {
|
||||
(await getWin()).close()
|
||||
} catch {
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { if (show) initWindowState() })
|
||||
onUnmounted(() => { unlisten?.() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="window-controls">
|
||||
<button class="wc-btn wc-minimize" @click="minimizeWindow" title="Minimize">
|
||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||
<rect width="10" height="1" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="wc-btn wc-maximize" @click="toggleMaximize" :title="isMaximized ? 'Restore' : 'Maximize'">
|
||||
<svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10">
|
||||
<rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1" />
|
||||
</svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 10 10">
|
||||
<rect x="2.5" y="0.5" width="7" height="7" fill="none" stroke="currentColor" stroke-width="1" />
|
||||
<rect x="0.5" y="2.5" width="7" height="7" fill="var(--bg-primary, #0f0f14)" stroke="currentColor" stroke-width="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="wc-btn wc-close" @click="closeWindow" title="Close">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.2" />
|
||||
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
margin-left: 0.5rem;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.wc-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 100%;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary, #a1a1aa);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease, color 0.1s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wc-btn:hover {
|
||||
background: var(--bg-hover, #1e1e28);
|
||||
color: var(--text-primary, #e4e4e7);
|
||||
}
|
||||
|
||||
.wc-btn:active {
|
||||
background: var(--border-color, #2a2a3a);
|
||||
}
|
||||
|
||||
.wc-close:hover {
|
||||
background: #e81123;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.wc-close:active {
|
||||
background: #c50f1f;
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
505
frontend/src/components/agents/HooksManager.vue
Normal file
@@ -0,0 +1,505 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAgentsStore, HOOK_EVENT_TYPES } from '../../stores/agents'
|
||||
import type { HookEntry, HookCommand } from '../../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
const editingHook = ref<{ eventType: string; index: number } | null>(null)
|
||||
const editMatcher = ref('')
|
||||
const editCommand = ref('')
|
||||
const editTimeout = ref(5000)
|
||||
|
||||
function hookCount(eventType: string): number {
|
||||
return store.hooksConfig[eventType]?.length || 0
|
||||
}
|
||||
|
||||
function totalHookCount(): number {
|
||||
return Object.values(store.hooksConfig).reduce((sum, arr) => sum + (arr?.length || 0), 0)
|
||||
}
|
||||
|
||||
function startEdit(eventType: string, index: number) {
|
||||
const entry = store.hooksConfig[eventType]?.[index]
|
||||
if (!entry) return
|
||||
editingHook.value = { eventType, index }
|
||||
editMatcher.value = entry.matcher || ''
|
||||
editCommand.value = entry.hooks?.[0]?.command || ''
|
||||
editTimeout.value = entry.hooks?.[0]?.timeout || 5000
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!editingHook.value) return
|
||||
const { eventType, index } = editingHook.value
|
||||
|
||||
const entry: HookEntry = {
|
||||
matcher: editMatcher.value.trim() || null,
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: editCommand.value.trim(),
|
||||
timeout: editTimeout.value
|
||||
}]
|
||||
}
|
||||
|
||||
store.updateHook(eventType, index, entry)
|
||||
editingHook.value = null
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingHook.value = null
|
||||
}
|
||||
|
||||
function truncateCommand(cmd: string, max = 60): string {
|
||||
return cmd.length > max ? cmd.slice(0, max) + '...' : cmd
|
||||
}
|
||||
|
||||
const eventTypeLabels: Record<string, string> = {
|
||||
'UserPromptSubmit': 'User Prompt Submit',
|
||||
'PreToolUse': 'Pre Tool Use',
|
||||
'PostToolUse': 'Post Tool Use',
|
||||
'SessionStart': 'Session Start',
|
||||
'Stop': 'Stop',
|
||||
'Notification': 'Notification',
|
||||
'PermissionRequest': 'Permission Request'
|
||||
}
|
||||
|
||||
const eventTypeColors: Record<string, string> = {
|
||||
'UserPromptSubmit': '#3b82f6',
|
||||
'PreToolUse': '#f59e0b',
|
||||
'PostToolUse': '#22c55e',
|
||||
'SessionStart': '#6366f1',
|
||||
'Stop': '#ef4444',
|
||||
'Notification': '#8b5cf6',
|
||||
'PermissionRequest': '#06b6d4'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hooks-manager">
|
||||
<!-- Header -->
|
||||
<div class="hooks-header">
|
||||
<div class="hooks-header-left">
|
||||
<h3>Hooks</h3>
|
||||
<span class="hooks-count">{{ totalHookCount() }} hooks</span>
|
||||
<span v-if="store.configFile" class="hooks-path">{{ store.configFile }}</span>
|
||||
</div>
|
||||
<div class="hooks-header-right">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="store.saving"
|
||||
@click="store.saveHooks()"
|
||||
>{{ store.saving ? 'Saving...' : 'Save Hooks' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.hooksLoading" class="hooks-loading">Loading hooks...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="hooks-error">{{ store.error }}</div>
|
||||
|
||||
<!-- Hook sections by event type -->
|
||||
<div v-if="!store.hooksLoading" class="hooks-scroll">
|
||||
<div v-for="eventType in HOOK_EVENT_TYPES" :key="eventType" class="hook-section">
|
||||
<button
|
||||
class="hook-section-header"
|
||||
@click="store.toggleHookType(eventType)"
|
||||
>
|
||||
<span class="hook-dot" :style="{ background: eventTypeColors[eventType] }"></span>
|
||||
<span class="hook-type-label">{{ eventTypeLabels[eventType] || eventType }}</span>
|
||||
<span
|
||||
v-if="hookCount(eventType)"
|
||||
class="hook-type-count"
|
||||
:style="{ background: eventTypeColors[eventType] + '18', color: eventTypeColors[eventType] }"
|
||||
>{{ hookCount(eventType) }}</span>
|
||||
<svg
|
||||
class="hook-chevron"
|
||||
:class="{ expanded: store.expandedHookTypes.has(eventType) }"
|
||||
xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="store.expandedHookTypes.has(eventType)" class="hook-section-body">
|
||||
<!-- Hook entries -->
|
||||
<div
|
||||
v-for="(entry, idx) in (store.hooksConfig[eventType] || [])"
|
||||
:key="idx"
|
||||
class="hook-entry"
|
||||
>
|
||||
<!-- View mode -->
|
||||
<template v-if="!editingHook || editingHook.eventType !== eventType || editingHook.index !== idx">
|
||||
<div class="hook-entry-info">
|
||||
<div v-if="entry.matcher" class="hook-matcher">
|
||||
<span class="entry-label">Matcher</span>
|
||||
<code>{{ entry.matcher }}</code>
|
||||
</div>
|
||||
<div v-for="(hook, hIdx) in entry.hooks" :key="hIdx" class="hook-command-row">
|
||||
<span class="entry-label">Command</span>
|
||||
<code :title="hook.command">{{ truncateCommand(hook.command) }}</code>
|
||||
<span v-if="hook.timeout" class="hook-timeout">{{ hook.timeout }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hook-entry-actions">
|
||||
<button class="btn-link" @click="startEdit(eventType, idx)">Edit</button>
|
||||
<button class="btn-link danger" @click="store.removeHook(eventType, idx)">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<template v-else>
|
||||
<div class="hook-edit-form">
|
||||
<div class="form-row">
|
||||
<label>Matcher</label>
|
||||
<input type="text" v-model="editMatcher" placeholder=".* (regex or empty)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Command</label>
|
||||
<textarea v-model="editCommand" rows="2" placeholder="bash script.sh"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Timeout (ms)</label>
|
||||
<input type="number" v-model.number="editTimeout" />
|
||||
</div>
|
||||
<div class="form-row-actions">
|
||||
<button class="btn btn-secondary btn-xs" @click="cancelEdit">Cancel</button>
|
||||
<button class="btn btn-primary btn-xs" @click="saveEdit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Empty state for this type -->
|
||||
<div v-if="!hookCount(eventType)" class="hook-empty">No hooks configured</div>
|
||||
|
||||
<!-- Add button -->
|
||||
<button class="hook-add-btn" @click="store.addHook(eventType)">
|
||||
+ Add hook
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hooks-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hooks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hooks-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hooks-header-left h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hooks-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hooks-path {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.hooks-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hooks-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.hooks-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.hooks-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
/* Hook sections */
|
||||
.hook-section {
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hook-section-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.hook-section-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.hook-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-type-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hook-type-count {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.4375rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hook-chevron {
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.hook-chevron.expanded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.hook-section-body {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Hook entries */
|
||||
.hook-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hook-entry + .hook-entry {
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.hook-entry-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hook-matcher,
|
||||
.hook-command-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.entry-label {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
min-width: 55px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-entry code {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Consolas', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hook-timeout {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-entry-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6366f1;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-link.danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Edit form */
|
||||
.hook-edit-form {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
display: block;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.1875rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-row input,
|
||||
.form-row textarea {
|
||||
width: 100%;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: 'Consolas', monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-row input:focus,
|
||||
.form-row textarea:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.form-row-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hook-empty {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hook-add-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6366f1;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hook-add-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
padding: 0.1875rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
</style>
|
||||
560
frontend/src/components/agents/McpManager.vue
Normal file
@@ -0,0 +1,560 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAgentsStore } from '../../stores/agents'
|
||||
import type { McpServerEntry } from '../../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
const showAddModal = ref(false)
|
||||
const editingServer = ref<string | null>(null)
|
||||
|
||||
const newServer = ref<McpServerEntry>({
|
||||
name: '',
|
||||
type: 'stdio',
|
||||
command: '',
|
||||
args: [],
|
||||
url: '',
|
||||
env: {},
|
||||
enabled: true
|
||||
})
|
||||
|
||||
const argsText = ref('')
|
||||
const envText = ref('')
|
||||
|
||||
function resetForm() {
|
||||
newServer.value = { name: '', type: 'stdio', command: '', args: [], url: '', env: {}, enabled: true }
|
||||
argsText.value = ''
|
||||
envText.value = ''
|
||||
editingServer.value = null
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
resetForm()
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(server: McpServerEntry) {
|
||||
newServer.value = { ...server, args: [...(server.args || [])], env: { ...(server.env || {}) } }
|
||||
argsText.value = (server.args || []).join('\n')
|
||||
envText.value = Object.entries(server.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
editingServer.value = server.name
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function saveServer() {
|
||||
if (!newServer.value.name.trim()) return
|
||||
|
||||
// Parse args from text
|
||||
newServer.value.args = argsText.value.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
|
||||
// Parse env from text
|
||||
const env: Record<string, string> = {}
|
||||
for (const line of envText.value.split('\n')) {
|
||||
const eq = line.indexOf('=')
|
||||
if (eq > 0) {
|
||||
env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
|
||||
}
|
||||
}
|
||||
newServer.value.env = env
|
||||
|
||||
if (editingServer.value) {
|
||||
// Update existing
|
||||
const idx = store.mcpServers.findIndex(s => s.name === editingServer.value)
|
||||
if (idx >= 0) {
|
||||
store.mcpServers[idx] = { ...newServer.value }
|
||||
}
|
||||
} else {
|
||||
store.addMcpServer({ ...newServer.value })
|
||||
}
|
||||
|
||||
showAddModal.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function deleteServer(name: string) {
|
||||
store.removeMcpServer(name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mcp-manager">
|
||||
<!-- Header -->
|
||||
<div class="mcp-header">
|
||||
<div class="mcp-header-left">
|
||||
<h3>MCP Servers</h3>
|
||||
<span class="mcp-count">{{ store.mcpServers.length }} servers</span>
|
||||
<span class="mcp-path">.mcp.json</span>
|
||||
</div>
|
||||
<div class="mcp-header-right">
|
||||
<button class="btn btn-secondary" @click="openAddModal">+ Add Server</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="store.saving"
|
||||
@click="store.saveMcpServers()"
|
||||
>{{ store.saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.mcpsLoading" class="mcp-loading">Loading MCP servers...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="mcp-error">{{ store.error }}</div>
|
||||
|
||||
<!-- Server cards -->
|
||||
<div v-if="!store.mcpsLoading" class="mcp-scroll">
|
||||
<div class="server-grid">
|
||||
<div v-for="server in store.mcpServers" :key="server.name" class="server-card">
|
||||
<div class="server-card-header">
|
||||
<div class="server-info">
|
||||
<span class="server-name">{{ server.name }}</span>
|
||||
<span class="server-type-badge" :class="server.type">{{ server.type }}</span>
|
||||
</div>
|
||||
<div class="server-actions">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ enabled: server.enabled }"
|
||||
@click="store.toggleMcpServer(server.name)"
|
||||
:title="server.enabled ? 'Disable' : 'Enable'"
|
||||
>
|
||||
<span class="toggle-dot"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="server-card-body">
|
||||
<div v-if="server.command" class="server-detail">
|
||||
<span class="detail-label">Command</span>
|
||||
<code>{{ server.command }}</code>
|
||||
</div>
|
||||
<div v-if="server.args?.length" class="server-detail">
|
||||
<span class="detail-label">Args</span>
|
||||
<code>{{ server.args.join(' ') }}</code>
|
||||
</div>
|
||||
<div v-if="server.url" class="server-detail">
|
||||
<span class="detail-label">URL</span>
|
||||
<code>{{ server.url }}</code>
|
||||
</div>
|
||||
<div v-if="server.env && Object.keys(server.env).length" class="server-detail">
|
||||
<span class="detail-label">Env</span>
|
||||
<span class="env-count">{{ Object.keys(server.env).length }} vars</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="server-card-footer">
|
||||
<button class="btn-link" @click="openEditModal(server)">Edit</button>
|
||||
<button class="btn-link danger" @click="deleteServer(server.name)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.mcpServers.length" class="mcp-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
<p>No MCP servers configured</p>
|
||||
<span>Add a server to get started</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h4>{{ editingServer ? 'Edit Server' : 'Add MCP Server' }}</h4>
|
||||
<button class="modal-close" @click="showAddModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" v-model="newServer.name" placeholder="my-server" :disabled="!!editingServer" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<select v-model="newServer.type">
|
||||
<option value="stdio">stdio</option>
|
||||
<option value="sse">sse</option>
|
||||
<option value="streamable-http">streamable-http</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="newServer.type === 'stdio'" class="form-group">
|
||||
<label>Command</label>
|
||||
<input type="text" v-model="newServer.command" placeholder="npx" />
|
||||
</div>
|
||||
<div v-if="newServer.type === 'stdio'" class="form-group">
|
||||
<label>Args (one per line)</label>
|
||||
<textarea v-model="argsText" placeholder="arg1 arg2" rows="3"></textarea>
|
||||
</div>
|
||||
<div v-if="newServer.type !== 'stdio'" class="form-group">
|
||||
<label>URL</label>
|
||||
<input type="text" v-model="newServer.url" placeholder="http://localhost:3000" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Environment (KEY=value, one per line)</label>
|
||||
<textarea v-model="envText" placeholder="API_KEY=xxx" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="saveServer">{{ editingServer ? 'Update' : 'Add' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mcp-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mcp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mcp-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mcp-header-left h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mcp-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mcp-path {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.mcp-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mcp-loading,
|
||||
.mcp-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.mcp-empty span {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mcp-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.mcp-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
/* Server grid */
|
||||
.server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.server-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.server-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.server-type-badge {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.server-type-badge.stdio { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||
.server-type-badge.sse { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
|
||||
.server-type-badge.streamable-http { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
|
||||
.server-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--bg-hover);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.toggle-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled .toggle-dot {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.server-card-body {
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.server-detail {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1875rem 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.server-detail code {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Consolas', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.env-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.server-card-footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6366f1;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-link.danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
width: 480px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: 'Consolas', monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
</style>
|
||||
212
frontend/src/components/agents/PluginsManager.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { useAgentsStore } from '../../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugins-manager">
|
||||
<!-- Header -->
|
||||
<div class="plugins-header">
|
||||
<div class="plugins-header-left">
|
||||
<h3>Global Plugins</h3>
|
||||
<span class="plugins-count">{{ store.plugins.length }} plugins</span>
|
||||
<span class="plugins-path">~/.claude/plugins/marketplaces/</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.pluginsLoading" class="plugins-loading">Loading plugins...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="plugins-error">{{ store.error }}</div>
|
||||
|
||||
<!-- Plugin cards -->
|
||||
<div v-if="!store.pluginsLoading" class="plugins-scroll">
|
||||
<div class="plugin-grid">
|
||||
<div v-for="plugin in store.plugins" :key="plugin.name" class="plugin-card">
|
||||
<div class="plugin-card-header">
|
||||
<div class="plugin-info">
|
||||
<span class="plugin-name">{{ plugin.name }}</span>
|
||||
<span class="plugin-installed" v-if="plugin.installed">Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-card-body">
|
||||
<p v-if="plugin.description" class="plugin-desc">{{ plugin.description }}</p>
|
||||
<div v-if="plugin.author" class="plugin-meta">
|
||||
<span class="meta-label">Author</span>
|
||||
<span class="meta-value">{{ plugin.author }}</span>
|
||||
</div>
|
||||
<div v-if="plugin.mcpConfig" class="plugin-meta">
|
||||
<span class="meta-label">MCP</span>
|
||||
<span class="meta-value">{{ typeof plugin.mcpConfig === 'object' ? JSON.stringify(plugin.mcpConfig).slice(0, 80) : plugin.mcpConfig }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.plugins.length" class="plugins-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<p>No plugins installed</p>
|
||||
<span>Plugins from ~/.claude/plugins/marketplaces/ will appear here</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugins-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugins-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plugins-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.plugins-header-left h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plugins-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.plugins-path {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.plugins-loading,
|
||||
.plugins-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.plugins-empty span {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.plugins-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.plugins-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.plugin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.plugin-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plugin-installed {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plugin-card-body {
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-desc {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plugin-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
min-width: 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
316
frontend/src/components/agents/SkillsManager.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { useAgentsStore } from '../../stores/agents'
|
||||
import type { SkillEntry } from '../../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
|
||||
function selectSkill(skill: SkillEntry) {
|
||||
store.selectedSkill = skill
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skills-manager">
|
||||
<!-- Header -->
|
||||
<div class="skills-header">
|
||||
<h3>Skills</h3>
|
||||
<span class="skills-count">{{ store.skills.length }} skills</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.skillsLoading" class="skills-loading">Loading skills...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="skills-error">{{ store.error }}</div>
|
||||
|
||||
<!-- Two-column layout -->
|
||||
<div v-if="!store.skillsLoading" class="skills-layout">
|
||||
<!-- Left: skills list -->
|
||||
<aside class="skills-list">
|
||||
<button
|
||||
v-for="skill in store.skills"
|
||||
:key="skill.name"
|
||||
class="skill-item"
|
||||
:class="{ active: store.selectedSkill?.name === skill.name }"
|
||||
@click="selectSkill(skill)"
|
||||
>
|
||||
<div class="skill-item-main">
|
||||
<span class="skill-name">{{ skill.name }}</span>
|
||||
<span v-if="skill.references.length" class="skill-refs">{{ skill.references.length }} files</span>
|
||||
</div>
|
||||
<p v-if="skill.description" class="skill-desc">{{ skill.description }}</p>
|
||||
</button>
|
||||
|
||||
<div v-if="!store.skills.length" class="skills-empty-list">
|
||||
<p>No skills found</p>
|
||||
<span>Add a SKILL.md to .claude/skills/*/</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right: skill detail -->
|
||||
<main class="skill-detail">
|
||||
<template v-if="store.selectedSkill">
|
||||
<div class="skill-detail-header">
|
||||
<h4>{{ store.selectedSkill.name }}</h4>
|
||||
<span class="skill-detail-path" :title="store.selectedSkill.path">{{ store.selectedSkill.path }}</span>
|
||||
</div>
|
||||
|
||||
<!-- SKILL.md content -->
|
||||
<div class="skill-md-content">
|
||||
<pre>{{ store.selectedSkill.skillMdContent }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- References -->
|
||||
<div v-if="store.selectedSkill.references.length" class="skill-references">
|
||||
<h5>Referenced Files</h5>
|
||||
<div class="ref-list">
|
||||
<div v-for="ref in store.selectedSkill.references" :key="ref.name" class="ref-item">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
</svg>
|
||||
<span>{{ ref.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="skill-detail-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||
</svg>
|
||||
<p>Select a skill to view details</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skills-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skills-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skills-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.skills-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.skills-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.skills-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Two-column layout */
|
||||
.skills-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skills-list {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.skill-item {
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.skill-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.skill-item.active {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.skill-item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.skill-refs {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skill-desc {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skills-empty-list {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.skills-empty-list p {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.skills-empty-list span {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Skill detail */
|
||||
.skill-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.skill-detail-header {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.skill-detail-header h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.skill-detail-path {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skill-md-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.skill-md-content pre {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.skill-references {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.skill-references h5 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ref-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ref-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ref-item svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.skill-detail-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.skill-detail-empty p {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
484
frontend/src/components/agents/ToolsManager.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAgentsStore } from '../../stores/agents'
|
||||
import type { ToolEntry } from '../../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
const newRuleFor = ref<string | null>(null)
|
||||
const newRuleValue = ref('')
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'allow') return '#22c55e'
|
||||
if (status === 'deny') return '#ef4444'
|
||||
return 'var(--text-muted)'
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'allow') return 'Allow'
|
||||
if (status === 'deny') return 'Deny'
|
||||
return 'Ask'
|
||||
}
|
||||
|
||||
function submitRule(toolKey: string) {
|
||||
if (newRuleValue.value.trim()) {
|
||||
store.addToolRule(toolKey, newRuleValue.value.trim())
|
||||
newRuleValue.value = ''
|
||||
newRuleFor.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function isParameterizable(name: string): boolean {
|
||||
return ['Bash', 'WebFetch', 'Skill', 'WebSearch'].includes(name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tools-manager">
|
||||
<!-- Header -->
|
||||
<div class="tools-header">
|
||||
<div class="tools-header-left">
|
||||
<h3>Tool Permissions</h3>
|
||||
<span class="tools-count">{{ store.filteredTools.length }} tools</span>
|
||||
<span v-if="store.configFile" class="config-path">{{ store.configFile }}</span>
|
||||
</div>
|
||||
<div class="tools-header-right">
|
||||
<input
|
||||
type="text"
|
||||
class="tools-search"
|
||||
placeholder="Filter tools..."
|
||||
v-model="store.toolsFilter"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!store.configDirty || store.saving"
|
||||
@click="store.savePermissions()"
|
||||
>{{ store.saving ? 'Saving...' : 'Save Permissions' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.toolsLoading" class="tools-loading">Loading tools...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="tools-error">{{ store.error }}</div>
|
||||
|
||||
<!-- Tools list -->
|
||||
<div v-if="!store.toolsLoading" class="tools-scroll">
|
||||
<!-- Base tools -->
|
||||
<div v-if="store.toolsByCategory.base.length" class="tool-group">
|
||||
<div class="tool-group-header">
|
||||
<span class="tool-group-label">Base Tools</span>
|
||||
<span class="tool-group-count">{{ store.toolsByCategory.base.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tool-cards">
|
||||
<div v-for="tool in store.toolsByCategory.base" :key="tool.fullKey" class="tool-card">
|
||||
<div class="tool-card-main">
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">{{ tool.name }}</span>
|
||||
<span class="tool-badge base">base</span>
|
||||
</div>
|
||||
<button
|
||||
class="status-toggle"
|
||||
:style="{ '--status-color': statusColor(tool.status) }"
|
||||
@click="store.cycleToolStatus(tool.fullKey)"
|
||||
:title="`Click to cycle: ${tool.status}`"
|
||||
>
|
||||
<span class="status-dot" :class="tool.status"></span>
|
||||
<span class="status-text">{{ statusLabel(tool.status) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Rules for parameterizable tools -->
|
||||
<div v-if="isParameterizable(tool.name) && tool.status !== 'ask'" class="tool-rules">
|
||||
<div v-for="(rule, idx) in tool.rules" :key="idx" class="rule-item">
|
||||
<code>{{ tool.name }}({{ rule }})</code>
|
||||
<button class="rule-remove" @click="store.removeToolRule(tool.fullKey, rule)">×</button>
|
||||
</div>
|
||||
<div v-if="newRuleFor === tool.fullKey" class="rule-add-form">
|
||||
<input
|
||||
type="text"
|
||||
class="rule-input"
|
||||
v-model="newRuleValue"
|
||||
placeholder="e.g. git:* or domain:example.com"
|
||||
@keydown.enter="submitRule(tool.fullKey)"
|
||||
@keydown.escape="newRuleFor = null"
|
||||
/>
|
||||
<button class="btn-sm" @click="submitRule(tool.fullKey)">Add</button>
|
||||
</div>
|
||||
<button v-else class="rule-add-btn" @click="newRuleFor = tool.fullKey; newRuleValue = ''">
|
||||
+ Add rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP tools grouped by server -->
|
||||
<div
|
||||
v-for="(tools, server) in store.toolsByCategory.mcpGroups"
|
||||
:key="server"
|
||||
class="tool-group"
|
||||
>
|
||||
<div class="tool-group-header">
|
||||
<span class="tool-group-label">MCP: {{ server }}</span>
|
||||
<span class="tool-group-count">{{ tools.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tool-cards">
|
||||
<div v-for="tool in tools" :key="tool.fullKey" class="tool-card">
|
||||
<div class="tool-card-main">
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">{{ tool.name }}</span>
|
||||
<span class="tool-badge mcp">mcp</span>
|
||||
<span v-if="tool.host" class="tool-host">{{ tool.host }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="status-toggle"
|
||||
:style="{ '--status-color': statusColor(tool.status) }"
|
||||
@click="store.cycleToolStatus(tool.fullKey)"
|
||||
:title="`Click to cycle: ${tool.status}`"
|
||||
>
|
||||
<span class="status-dot" :class="tool.status"></span>
|
||||
<span class="status-text">{{ statusLabel(tool.status) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!store.toolsByCategory.base.length && !Object.keys(store.toolsByCategory.mcpGroups).length" class="tools-empty">
|
||||
<p>No tools found</p>
|
||||
<span>Tool permissions will appear here when configured</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tools-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tools-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tools-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tools-header-left h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tools-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.config-path {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.tools-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tools-search {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.tools-search:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.tools-loading,
|
||||
.tools-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.tools-empty span {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tools-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.tools-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
/* Tool groups */
|
||||
.tool-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tool-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-group-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-group-count {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Tool cards */
|
||||
.tool-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-card-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.tool-badge {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tool-badge.base {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.tool-badge.mcp {
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.tool-host {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* Status toggle */
|
||||
.status-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.status-dot.allow { background: #22c55e; }
|
||||
.status-dot.deny { background: #ef4444; }
|
||||
.status-dot.ask { background: var(--text-muted); }
|
||||
|
||||
.status-text {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Rules */
|
||||
.tool-rules {
|
||||
padding: 0.375rem 0.75rem 0.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1875rem 0;
|
||||
}
|
||||
|
||||
.rule-item code {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.rule-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rule-remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.rule-add-form {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.rule-input {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.rule-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rule-add-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6366f1;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rule-add-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
</style>
|
||||
220
frontend/src/components/database/DataTable.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
data: any[]
|
||||
visibleColumns: string[]
|
||||
selectedRows: Set<number>
|
||||
allSelected: boolean
|
||||
copiedCell: string | null
|
||||
cellIdPrefix?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleRow: [idx: number]
|
||||
toggleAll: []
|
||||
copyCell: [value: any, cellId: string]
|
||||
}>()
|
||||
|
||||
function formatValue(value: any): string {
|
||||
if (value === null) return 'NULL'
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
if (typeof value === 'string' && value.length > 100) {
|
||||
return value.substring(0, 100) + '...'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function getCellId(idx: number, key: string): string {
|
||||
return `${props.cellIdPrefix || 'cell'}-${idx}-${key}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-table-container">
|
||||
<div v-if="data.length > 0 && visibleColumns.length > 0" class="data-table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
@change="emit('toggleAll')"
|
||||
title="Select all"
|
||||
/>
|
||||
</th>
|
||||
<th v-for="key in visibleColumns" :key="key">{{ key }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, idx) in data"
|
||||
:key="idx"
|
||||
:class="{ 'row-selected': selectedRows.has(idx) }"
|
||||
>
|
||||
<td class="checkbox-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedRows.has(idx)"
|
||||
@change="emit('toggleRow', idx)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
v-for="key in visibleColumns"
|
||||
:key="key"
|
||||
class="copyable"
|
||||
:class="{ copied: copiedCell === getCellId(idx, key) }"
|
||||
@click="emit('copyCell', row[key], getCellId(idx, key))"
|
||||
title="Click to copy"
|
||||
>
|
||||
<span class="cell-content">{{ formatValue(row[key]) }}</span>
|
||||
<span v-if="copiedCell === getCellId(idx, key)" class="copied-badge">Copied!</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else-if="visibleColumns.length === 0" class="no-data">All columns hidden</div>
|
||||
<div v-else class="no-data">
|
||||
<slot name="empty">No records found</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.data-table-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-table-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
min-width: max-content;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.625rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.checkbox-col {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
padding: 0.5rem !important;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.data-table td.checkbox-col {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.data-table tr:hover td.checkbox-col {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.data-table tr.row-selected td.checkbox-col {
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.checkbox-col input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.data-table tr.row-selected td {
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.data-table tr.row-selected:hover td {
|
||||
background: var(--accent-muted);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.data-table td.copyable {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.data-table td.copyable:hover {
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.data-table td.copyable:active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.data-table td.copied {
|
||||
background: var(--success-bg, #d4edda);
|
||||
}
|
||||
|
||||
.cell-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.copied-badge {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.5rem;
|
||||
transform: translateY(-50%);
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-50%) scale(0.9); }
|
||||
to { opacity: 1; transform: translateY(-50%) scale(1); }
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
213
frontend/src/components/database/DatabaseSidebar.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableInfo, DbStats } from '@/types/database'
|
||||
|
||||
defineProps<{
|
||||
tables: TableInfo[]
|
||||
selectedTable: string | null
|
||||
stats: DbStats | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectTable: [tableName: string]
|
||||
refresh: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Database</h2>
|
||||
<button class="btn-icon" @click="emit('refresh')" title="Refresh">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
|
||||
<path d="M21 3v5h-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-list">
|
||||
<div v-if="loading && tables.length === 0" class="loading">Loading tables...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="table in tables"
|
||||
:key="table.name"
|
||||
class="table-item"
|
||||
:class="{ active: selectedTable === table.name }"
|
||||
@click="emit('selectTable', table.name)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18"/>
|
||||
<path d="M3 15h18"/>
|
||||
<path d="M9 3v18"/>
|
||||
</svg>
|
||||
<span class="table-name">{{ table.name }}</span>
|
||||
<span class="table-count">{{ table.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="stats" class="sidebar-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Size</span>
|
||||
<span class="stat-value">{{ stats.size }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Tables</span>
|
||||
<span class="stat-value">{{ stats.tables }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Records</span>
|
||||
<span class="stat-value">{{ stats.totalRecords }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.table-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.table-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.table-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.table-item.active {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.table-name {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-count {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 9999px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.table-item.active .table-count {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-stats {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.table-list {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.table-item {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sidebar-stats {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
184
frontend/src/components/database/DatabaseStats.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import type { DbStats, TableInfo } from '@/types/database'
|
||||
|
||||
defineProps<{
|
||||
stats: DbStats | null
|
||||
tables: TableInfo[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats-tab">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-title">Database Size</span>
|
||||
<span class="stat-value-lg">{{ stats?.size || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18"/>
|
||||
<path d="M9 3v18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-title">Total Tables</span>
|
||||
<span class="stat-value-lg">{{ stats?.tables || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-title">Total Records</span>
|
||||
<span class="stat-value-lg">{{ stats?.totalRecords || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tables-breakdown">
|
||||
<h3>Tables Breakdown</h3>
|
||||
<div class="breakdown-list">
|
||||
<div v-for="table in tables" :key="table.name" class="breakdown-item">
|
||||
<span class="breakdown-name">{{ table.name }}</span>
|
||||
<div class="breakdown-bar">
|
||||
<div
|
||||
class="breakdown-fill"
|
||||
:style="{ width: `${(table.count / (stats?.totalRecords || 1)) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="breakdown-count">{{ table.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-tab {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
padding: 0.75rem;
|
||||
background: var(--accent-muted);
|
||||
border-radius: 10px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-value-lg {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tables-breakdown {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tables-breakdown h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.breakdown-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.breakdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.breakdown-name {
|
||||
width: 150px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.breakdown-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.breakdown-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 4px;
|
||||
min-width: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.breakdown-count {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
171
frontend/src/components/database/FilterBar.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
rowCount: number
|
||||
totalCount: number
|
||||
selectedCount: number
|
||||
visibleColCount: number
|
||||
totalColCount: number
|
||||
copied: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
copyAll: []
|
||||
}>()
|
||||
|
||||
function updateFilter(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLInputElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-bar">
|
||||
<div class="filter-input-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
:value="modelValue"
|
||||
@input="updateFilter"
|
||||
type="text"
|
||||
placeholder="Filter results..."
|
||||
class="filter-input"
|
||||
/>
|
||||
<button v-if="modelValue" class="clear-filter" @click="emit('update:modelValue', '')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<span class="filter-count">
|
||||
<template v-if="selectedCount > 0">
|
||||
{{ selectedCount }} selected
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ rowCount }} of {{ totalCount }} rows
|
||||
</template>
|
||||
<template v-if="visibleColCount < totalColCount">
|
||||
({{ visibleColCount }}/{{ totalColCount }} cols)
|
||||
</template>
|
||||
</span>
|
||||
<button
|
||||
class="btn-copy-all"
|
||||
:class="{ copied }"
|
||||
@click="emit('copyAll')"
|
||||
:disabled="rowCount === 0 || visibleColCount === 0"
|
||||
>
|
||||
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ copied ? 'Copied!' : (selectedCount > 0 ? 'Copy Selected' : 'Copy All') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.filter-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.filter-input-wrapper:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.clear-filter {
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clear-filter:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-copy-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-copy-all:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-copy-all:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-copy-all.copied {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
83
frontend/src/components/database/QueryColumnsBar.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
columns: string[]
|
||||
hiddenColumns: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleColumn: [column: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="columns.length > 0" class="query-columns-bar">
|
||||
<span class="columns-label">Columns:</span>
|
||||
<div class="query-columns-list">
|
||||
<span
|
||||
v-for="col in columns"
|
||||
:key="col"
|
||||
class="query-col-toggle"
|
||||
:class="{ hidden: hiddenColumns.has(col) }"
|
||||
@click="emit('toggleColumn', col)"
|
||||
:title="hiddenColumns.has(col) ? 'Click to show' : 'Click to hide'"
|
||||
>
|
||||
<svg v-if="!hiddenColumns.has(col)" xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
{{ col }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.query-columns-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.columns-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.query-columns-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.query-col-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.query-col-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.query-col-toggle.hidden {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
96
frontend/src/components/database/QueryEditor.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
execute: []
|
||||
}>()
|
||||
|
||||
function onInput(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
emit('execute')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="query-editor">
|
||||
<textarea
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
placeholder="SELECT * FROM themes LIMIT 10;"
|
||||
></textarea>
|
||||
<div class="query-actions">
|
||||
<span class="hint">Ctrl+Enter to execute. Only SELECT queries allowed.</span>
|
||||
<button class="btn-primary" @click="emit('execute')" :disabled="loading">
|
||||
{{ loading ? 'Running...' : 'Execute' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.query-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.query-editor textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.query-editor textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.query-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
115
frontend/src/components/database/SchemaInfo.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableSchema } from '@/types/database'
|
||||
|
||||
defineProps<{
|
||||
tableName: string
|
||||
schema: TableSchema[]
|
||||
hiddenColumns: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleColumn: [column: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schema-info">
|
||||
<div class="schema-header">
|
||||
<h3>{{ tableName }}</h3>
|
||||
<span class="schema-hint">Click columns to show/hide</span>
|
||||
</div>
|
||||
<div class="schema-columns">
|
||||
<span
|
||||
v-for="col in schema"
|
||||
:key="col.name"
|
||||
class="schema-col clickable"
|
||||
:class="{ pk: col.pk, hidden: hiddenColumns.has(col.name) }"
|
||||
@click="emit('toggleColumn', col.name)"
|
||||
:title="hiddenColumns.has(col.name) ? 'Click to show' : 'Click to hide'"
|
||||
>
|
||||
<svg v-if="!hiddenColumns.has(col.name)" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
{{ col.name }}
|
||||
<small>{{ col.type }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.schema-info {
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.schema-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.schema-info h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schema-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.schema-columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.schema-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.schema-col.clickable {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.schema-col.clickable:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.schema-col.hidden {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.schema-col.pk {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.schema-col.pk.hidden {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.schema-col small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
76
frontend/src/components/database/TablePagination.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changePage: [page: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="pagination">
|
||||
<button @click="emit('changePage', 1)" :disabled="currentPage === 1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="emit('changePage', currentPage - 1)" :disabled="currentPage === 1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
|
||||
<button @click="emit('changePage', currentPage + 1)" :disabled="currentPage === totalPages">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="emit('changePage', totalPages)" :disabled="currentPage === totalPages">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 0.375rem;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
8
frontend/src/components/database/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as DatabaseSidebar } from './DatabaseSidebar.vue'
|
||||
export { default as DataTable } from './DataTable.vue'
|
||||
export { default as FilterBar } from './FilterBar.vue'
|
||||
export { default as SchemaInfo } from './SchemaInfo.vue'
|
||||
export { default as QueryEditor } from './QueryEditor.vue'
|
||||
export { default as QueryColumnsBar } from './QueryColumnsBar.vue'
|
||||
export { default as DatabaseStats } from './DatabaseStats.vue'
|
||||
export { default as TablePagination } from './TablePagination.vue'
|
||||
263
frontend/src/components/git/BranchSelector.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import type { BranchInfo } from '@/types/git'
|
||||
|
||||
const props = defineProps<{
|
||||
branches: BranchInfo[]
|
||||
selected: string
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [branch: string]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const search = ref('')
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const filteredBranches = computed(() => {
|
||||
const q = search.value.toLowerCase()
|
||||
return props.branches.filter(b => b.name.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const localBranches = computed(() => filteredBranches.value.filter(b => !b.isRemote))
|
||||
const remoteBranches = computed(() => filteredBranches.value.filter(b => b.isRemote))
|
||||
|
||||
function selectBranch(name: string) {
|
||||
emit('select', name)
|
||||
isOpen.value = false
|
||||
search.value = ''
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="dropdownRef" class="branch-selector">
|
||||
<label v-if="label" class="selector-label">{{ label }}</label>
|
||||
<button class="selector-button" @click="isOpen = !isOpen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
<span class="selected-branch">{{ selected || 'Select branch' }}</span>
|
||||
<svg class="chevron" :class="{ open: isOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="dropdown">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Filter branches..."
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<div class="branch-list">
|
||||
<div v-if="localBranches.length > 0" class="branch-group">
|
||||
<div class="group-header">Local</div>
|
||||
<div
|
||||
v-for="branch in localBranches"
|
||||
:key="branch.name"
|
||||
:class="['branch-item', { current: branch.isCurrent, selected: branch.name === selected }]"
|
||||
@click="selectBranch(branch.name)"
|
||||
>
|
||||
<span v-if="branch.isCurrent" class="current-marker">●</span>
|
||||
{{ branch.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="remoteBranches.length > 0" class="branch-group">
|
||||
<div class="group-header">Remote</div>
|
||||
<div
|
||||
v-for="branch in remoteBranches"
|
||||
:key="branch.name"
|
||||
:class="['branch-item', { selected: branch.name === selected }]"
|
||||
@click="selectBranch(branch.name)"
|
||||
>
|
||||
{{ branch.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredBranches.length === 0" class="no-results">
|
||||
No branches found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.branch-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selector-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.selector-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.selector-button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.selected-branch {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.branch-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.branch-group {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.branch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.branch-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.branch-item.selected {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.branch-item.current {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.current-marker {
|
||||
color: #22c55e;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.selector-button {
|
||||
min-width: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-branch {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.branch-list {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.branch-item {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
243
frontend/src/components/git/CommitList.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { CommitInfo } from '@/types/git'
|
||||
|
||||
const props = defineProps<{
|
||||
commits: CommitInfo[]
|
||||
selectedSha?: string | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [sha: string]
|
||||
loadMore: []
|
||||
}>()
|
||||
|
||||
function formatDate(timestamp: number) {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// Less than 1 day
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
if (hours < 1) {
|
||||
const mins = Math.floor(diff / 60000)
|
||||
return mins < 1 ? 'just now' : `${mins}m ago`
|
||||
}
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
// Less than 7 days
|
||||
if (diff < 604800000) {
|
||||
const days = Math.floor(diff / 86400000)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
// Format as date
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
||||
})
|
||||
}
|
||||
|
||||
function getInitials(name: string) {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
function getAvatarColor(email: string) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < email.length; i++) {
|
||||
hash = email.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const hue = hash % 360
|
||||
return `hsl(${hue}, 60%, 45%)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="commit-list">
|
||||
<div v-if="commits.length === 0 && !loading" class="empty-state">
|
||||
Sin commits
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="commit in commits"
|
||||
:key="commit.sha"
|
||||
:class="['commit-item', { selected: selectedSha === commit.sha }]"
|
||||
@click="emit('select', commit.sha)"
|
||||
>
|
||||
<div class="commit-avatar" :style="{ background: getAvatarColor(commit.email) }">
|
||||
{{ getInitials(commit.author) }}
|
||||
</div>
|
||||
<div class="commit-info">
|
||||
<div class="commit-message">{{ commit.message }}</div>
|
||||
<div class="commit-meta">
|
||||
<span class="commit-sha">{{ commit.shortSha }}</span>
|
||||
<span class="commit-author">{{ commit.author }}</span>
|
||||
<span class="commit-date">{{ formatDate(commit.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
Cargando...
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else-if="commits.length > 0"
|
||||
class="load-more"
|
||||
@click="emit('loadMore')"
|
||||
>
|
||||
Cargar más
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.commit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.commit-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.commit-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.commit-item.selected {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.commit-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.commit-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.commit-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.commit-sha {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.load-more {
|
||||
margin: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.load-more:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.commit-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.commit-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.commit-meta {
|
||||
font-size: 11px;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.commit-meta .commit-author {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
margin: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
310
frontend/src/components/git/DiffViewer.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { FileDiff, DiffHunk } from '@/types/git'
|
||||
|
||||
const props = defineProps<{
|
||||
files: FileDiff[]
|
||||
expandedFiles?: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleFile: [path: string]
|
||||
}>()
|
||||
|
||||
function isExpanded(path: string): boolean {
|
||||
return props.expandedFiles?.has(path) ?? true
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, { label: string; class: string }> = {
|
||||
added: { label: 'A', class: 'badge-added' },
|
||||
modified: { label: 'M', class: 'badge-modified' },
|
||||
deleted: { label: 'D', class: 'badge-deleted' },
|
||||
renamed: { label: 'R', class: 'badge-renamed' }
|
||||
}
|
||||
return badges[status] || { label: '?', class: '' }
|
||||
}
|
||||
|
||||
function getLineNumbers(hunk: DiffHunk) {
|
||||
let oldLine = hunk.oldStart
|
||||
let newLine = hunk.newStart
|
||||
|
||||
return hunk.lines.map(line => {
|
||||
const result = {
|
||||
old: line.type === 'add' ? '' : oldLine,
|
||||
new: line.type === 'delete' ? '' : newLine
|
||||
}
|
||||
if (line.type !== 'add') oldLine++
|
||||
if (line.type !== 'delete') newLine++
|
||||
return result
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="diff-viewer">
|
||||
<div v-if="files.length === 0" class="empty-state">
|
||||
No hay cambios para mostrar
|
||||
</div>
|
||||
|
||||
<div v-for="file in files" :key="file.path" class="file-diff">
|
||||
<div class="file-header" @click="emit('toggleFile', file.path)">
|
||||
<span :class="['status-badge', getStatusBadge(file.status).class]">
|
||||
{{ getStatusBadge(file.status).label }}
|
||||
</span>
|
||||
<span class="file-path">{{ file.path }}</span>
|
||||
<span v-if="file.oldPath" class="old-path">
|
||||
(from {{ file.oldPath }})
|
||||
</span>
|
||||
<svg v-if="file.isBinary" class="binary-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="18" rx="2" />
|
||||
<path d="M8 7v10M12 7v10M16 7v10" />
|
||||
</svg>
|
||||
<span class="toggle-icon">
|
||||
{{ isExpanded(file.path) ? '▼' : '▶' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded(file.path) && !file.isBinary" class="file-content">
|
||||
<div v-for="(hunk, hunkIdx) in file.hunks" :key="hunkIdx" class="hunk">
|
||||
<div class="hunk-header">
|
||||
@@ -{{ hunk.oldStart }},{{ hunk.oldLines }} +{{ hunk.newStart }},{{ hunk.newLines }} @@
|
||||
<span v-if="hunk.header" class="hunk-context">{{ hunk.header }}</span>
|
||||
</div>
|
||||
<div class="hunk-lines">
|
||||
<div
|
||||
v-for="(line, lineIdx) in hunk.lines"
|
||||
:key="lineIdx"
|
||||
:class="['diff-line', `line-${line.type}`]"
|
||||
>
|
||||
<span class="line-number old">{{ getLineNumbers(hunk)[lineIdx].old }}</span>
|
||||
<span class="line-number new">{{ getLineNumbers(hunk)[lineIdx].new }}</span>
|
||||
<span class="line-prefix">{{ line.type === 'add' ? '+' : line.type === 'delete' ? '-' : ' ' }}</span>
|
||||
<span class="line-content">{{ line.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="file.isBinary" class="binary-notice">
|
||||
Archivo binario - no se puede mostrar diff
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diff-viewer {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.file-diff {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-added {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.badge-modified {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.badge-deleted {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.badge-renamed {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.old-path {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.binary-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.file-content {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.hunk {
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.hunk-header {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hunk-context {
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.hunk-lines {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: flex;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
min-width: 45px;
|
||||
padding: 0 0.5rem;
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.line-number.old {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.line-prefix {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.line-context {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.line-add {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.line-add .line-prefix,
|
||||
.line-add .line-content {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.line-add .line-number.new {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.line-delete {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.line-delete .line-prefix,
|
||||
.line-delete .line-content {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.line-delete .line-number.old {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.binary-notice {
|
||||
padding: 1rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.diff-viewer {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.file-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hunk-header {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
min-width: 32px;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.line-prefix {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
199
frontend/src/components/git/FileTree.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { FileChange } from '@/types/git'
|
||||
|
||||
const props = defineProps<{
|
||||
staged: FileChange[]
|
||||
unstaged: FileChange[]
|
||||
untracked: string[]
|
||||
selectedFile?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [path: string, staged: boolean]
|
||||
}>()
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
const icons: Record<string, { icon: string; class: string }> = {
|
||||
added: { icon: 'A', class: 'status-added' },
|
||||
modified: { icon: 'M', class: 'status-modified' },
|
||||
deleted: { icon: 'D', class: 'status-deleted' },
|
||||
renamed: { icon: 'R', class: 'status-renamed' },
|
||||
untracked: { icon: '?', class: 'status-untracked' }
|
||||
}
|
||||
return icons[status] || { icon: '?', class: '' }
|
||||
}
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return props.staged.length > 0 || props.unstaged.length > 0 || props.untracked.length > 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-tree">
|
||||
<div v-if="!hasChanges" class="empty-state">
|
||||
Sin cambios
|
||||
</div>
|
||||
|
||||
<!-- Staged changes -->
|
||||
<div v-if="staged.length > 0" class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-icon staged">●</span>
|
||||
Staged ({{ staged.length }})
|
||||
</div>
|
||||
<div
|
||||
v-for="file in staged"
|
||||
:key="'staged-' + file.path"
|
||||
:class="['file-item', { selected: selectedFile === file.path }]"
|
||||
@click="emit('select', file.path, true)"
|
||||
>
|
||||
<span :class="['status-icon', getStatusIcon(file.status).class]">
|
||||
{{ getStatusIcon(file.status).icon }}
|
||||
</span>
|
||||
<span class="file-name">{{ file.path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unstaged changes -->
|
||||
<div v-if="unstaged.length > 0" class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-icon unstaged">●</span>
|
||||
Unstaged ({{ unstaged.length }})
|
||||
</div>
|
||||
<div
|
||||
v-for="file in unstaged"
|
||||
:key="'unstaged-' + file.path"
|
||||
:class="['file-item', { selected: selectedFile === file.path }]"
|
||||
@click="emit('select', file.path, false)"
|
||||
>
|
||||
<span :class="['status-icon', getStatusIcon(file.status).class]">
|
||||
{{ getStatusIcon(file.status).icon }}
|
||||
</span>
|
||||
<span class="file-name">{{ file.path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Untracked files -->
|
||||
<div v-if="untracked.length > 0" class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-icon untracked">●</span>
|
||||
Untracked ({{ untracked.length }})
|
||||
</div>
|
||||
<div
|
||||
v-for="path in untracked"
|
||||
:key="'untracked-' + path"
|
||||
:class="['file-item', { selected: selectedFile === path }]"
|
||||
@click="emit('select', path, false)"
|
||||
>
|
||||
<span :class="['status-icon', 'status-untracked']">?</span>
|
||||
<span class="file-name">{{ path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-tree {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.section-icon.staged {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.section-icon.unstaged {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.section-icon.untracked {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-item.selected {
|
||||
background: var(--accent);
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.status-added {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-modified {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.status-deleted {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-renamed {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.status-untracked {
|
||||
background: rgba(161, 161, 170, 0.2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
308
frontend/src/components/git/FileViewer.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { FileContent } from '@/types/git'
|
||||
|
||||
const props = defineProps<{
|
||||
file: FileContent | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// Get language for syntax highlighting hint
|
||||
const language = computed(() => {
|
||||
if (!props.file?.extension) return 'text'
|
||||
const langMap: Record<string, string> = {
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
vue: 'vue',
|
||||
json: 'json',
|
||||
md: 'markdown',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
html: 'html',
|
||||
svg: 'xml',
|
||||
yml: 'yaml',
|
||||
yaml: 'yaml',
|
||||
toml: 'toml',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
py: 'python',
|
||||
rs: 'rust',
|
||||
go: 'go',
|
||||
sql: 'sql'
|
||||
}
|
||||
return langMap[props.file.extension] || 'text'
|
||||
})
|
||||
|
||||
const lines = computed(() => {
|
||||
if (!props.file?.content) return []
|
||||
return props.file.content.split('\n')
|
||||
})
|
||||
|
||||
const fileName = computed(() => {
|
||||
if (!props.file?.path) return ''
|
||||
return props.file.path.split('/').pop() || props.file.path
|
||||
})
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-viewer">
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
Cargando archivo...
|
||||
</div>
|
||||
|
||||
<template v-else-if="file">
|
||||
<div class="file-header">
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ fileName }}</span>
|
||||
<span class="file-path">{{ file.path }}</span>
|
||||
<span class="file-size">{{ formatSize(file.size) }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')" title="Cerrar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="file.isBinary" class="binary-notice">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<p>Archivo binario - no se puede mostrar el contenido</p>
|
||||
<span>{{ formatSize(file.size) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="code-container">
|
||||
<div class="line-numbers">
|
||||
<span v-for="(_, i) in lines" :key="i" class="line-number">{{ i + 1 }}</span>
|
||||
</div>
|
||||
<pre class="code-content"><code>{{ file.content }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<p>Selecciona un archivo para ver su contenido</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.binary-notice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.binary-notice p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.binary-notice span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.75rem 0;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
padding: 0 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
min-width: 3ch;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.code-content code {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.file-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.code-container {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
font-size: 10px;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
290
frontend/src/components/git/ProjectTree.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TreeNode } from '@/types/git'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: TreeNode[]
|
||||
selectedPath?: string | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [path: string]
|
||||
}>()
|
||||
|
||||
const expandedDirs = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleDir(path: string) {
|
||||
if (expandedDirs.value.has(path)) {
|
||||
expandedDirs.value.delete(path)
|
||||
} else {
|
||||
expandedDirs.value.add(path)
|
||||
}
|
||||
expandedDirs.value = new Set(expandedDirs.value)
|
||||
}
|
||||
|
||||
function isExpanded(path: string): boolean {
|
||||
return expandedDirs.value.has(path)
|
||||
}
|
||||
|
||||
function handleSelect(node: TreeNode) {
|
||||
if (node.type === 'directory') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
emit('select', node.path)
|
||||
}
|
||||
}
|
||||
|
||||
function getFileIcon(name: string): string {
|
||||
const ext = name.split('.').pop()?.toLowerCase() || ''
|
||||
const icons: Record<string, string> = {
|
||||
ts: 'ts',
|
||||
tsx: 'tsx',
|
||||
js: 'js',
|
||||
jsx: 'jsx',
|
||||
vue: 'vue',
|
||||
json: 'json',
|
||||
md: 'md',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
html: 'html',
|
||||
svg: 'svg',
|
||||
png: 'img',
|
||||
jpg: 'img',
|
||||
jpeg: 'img',
|
||||
gif: 'img',
|
||||
ico: 'img',
|
||||
gitignore: 'git',
|
||||
env: 'env',
|
||||
yml: 'yml',
|
||||
yaml: 'yml',
|
||||
toml: 'toml',
|
||||
lock: 'lock'
|
||||
}
|
||||
return icons[ext] || 'file'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="project-tree">
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
Cargando archivos...
|
||||
</div>
|
||||
<div v-else-if="nodes.length === 0" class="empty">
|
||||
Sin archivos
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.path"
|
||||
class="tree-node"
|
||||
>
|
||||
<div
|
||||
:class="['node-row', { selected: selectedPath === node.path }]"
|
||||
@click="handleSelect(node)"
|
||||
>
|
||||
<!-- Directory -->
|
||||
<template v-if="node.type === 'directory'">
|
||||
<span class="expand-icon">
|
||||
<svg
|
||||
:class="{ expanded: isExpanded(node.path) }"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 5l8 7-8 7z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="folder-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path v-if="isExpanded(node.path)" d="M19 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2z" />
|
||||
<path v-else d="M3 6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6z" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
<!-- File -->
|
||||
<template v-else>
|
||||
<span class="expand-icon spacer"></span>
|
||||
<span :class="['file-icon', getFileIcon(node.name)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
<!-- Recursive children -->
|
||||
<div v-if="node.type === 'directory' && isExpanded(node.path) && node.children" class="children">
|
||||
<ProjectTree
|
||||
:nodes="node.children"
|
||||
:selected-path="selectedPath"
|
||||
@select="emit('select', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-tree {
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
/* Container for each node */
|
||||
}
|
||||
|
||||
.node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.node-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.node-row.selected {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.expand-icon.spacer {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.expand-icon svg {
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.expand-icon svg.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* File type colors */
|
||||
.file-icon.ts,
|
||||
.file-icon.tsx {
|
||||
color: #3178c6;
|
||||
}
|
||||
|
||||
.file-icon.js,
|
||||
.file-icon.jsx {
|
||||
color: #f7df1e;
|
||||
}
|
||||
|
||||
.file-icon.vue {
|
||||
color: #42b883;
|
||||
}
|
||||
|
||||
.file-icon.json {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.file-icon.md {
|
||||
color: #519aba;
|
||||
}
|
||||
|
||||
.file-icon.css,
|
||||
.file-icon.scss {
|
||||
color: #563d7c;
|
||||
}
|
||||
|
||||
.file-icon.html {
|
||||
color: #e34c26;
|
||||
}
|
||||
|
||||
.file-icon.svg,
|
||||
.file-icon.img {
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.file-icon.git {
|
||||
color: #f05032;
|
||||
}
|
||||
|
||||
.file-icon.env {
|
||||
color: #ecd53f;
|
||||
}
|
||||
|
||||
.file-icon.yml {
|
||||
color: #cb171e;
|
||||
}
|
||||
|
||||
.file-icon.lock {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.children {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
</style>
|
||||
365
frontend/src/components/git/StatusTree.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
interface StatusFile {
|
||||
path: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface StatusNode {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
status?: string
|
||||
children?: StatusNode[]
|
||||
fileCount?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
files?: StatusFile[]
|
||||
nodes?: StatusNode[]
|
||||
selectedPath?: string | null
|
||||
depth?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [path: string]
|
||||
}>()
|
||||
|
||||
const expandedDirs = ref<Set<string>>(new Set())
|
||||
|
||||
function countFiles(nodes: StatusNode[]): number {
|
||||
let count = 0
|
||||
for (const n of nodes) {
|
||||
if (n.type === 'file') count++
|
||||
else if (n.children) count += countFiles(n.children)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function buildTree(files: StatusFile[]): StatusNode[] {
|
||||
const root: Record<string, any> = {}
|
||||
|
||||
for (const file of files) {
|
||||
const parts = file.path.split('/')
|
||||
let current = root
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
if (i === parts.length - 1) {
|
||||
current[part] = { __file: true, status: file.status, path: file.path }
|
||||
} else {
|
||||
if (!current[part] || current[part].__file) {
|
||||
current[part] = {}
|
||||
}
|
||||
current = current[part]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toNodes(obj: Record<string, any>, parentPath: string): StatusNode[] {
|
||||
const dirs: StatusNode[] = []
|
||||
const fileNodes: StatusNode[] = []
|
||||
|
||||
for (const [name, value] of Object.entries(obj)) {
|
||||
if (value.__file) {
|
||||
fileNodes.push({
|
||||
name,
|
||||
path: value.path,
|
||||
type: 'file',
|
||||
status: value.status
|
||||
})
|
||||
} else {
|
||||
const dirPath = parentPath ? `${parentPath}/${name}` : name
|
||||
const children = toNodes(value, dirPath)
|
||||
const fileCount = countFiles(children)
|
||||
dirs.push({
|
||||
name,
|
||||
path: dirPath,
|
||||
type: 'directory',
|
||||
children,
|
||||
fileCount
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compact single-child directories
|
||||
for (let i = 0; i < dirs.length; i++) {
|
||||
const dir = dirs[i]
|
||||
while (
|
||||
dir.children &&
|
||||
dir.children.length === 1 &&
|
||||
dir.children[0].type === 'directory'
|
||||
) {
|
||||
const child = dir.children[0]
|
||||
dir.name = `${dir.name}/${child.name}`
|
||||
dir.path = child.path
|
||||
dir.children = child.children
|
||||
dir.fileCount = child.fileCount
|
||||
}
|
||||
}
|
||||
|
||||
return [...dirs, ...fileNodes]
|
||||
}
|
||||
|
||||
return toNodes(root, '')
|
||||
}
|
||||
|
||||
// Build tree from files prop (top-level), or use nodes prop (recursive)
|
||||
const displayNodes = computed(() => {
|
||||
if (props.nodes) return props.nodes
|
||||
if (props.files) return buildTree(props.files)
|
||||
return []
|
||||
})
|
||||
|
||||
// Auto-expand all dirs when files change (top-level only)
|
||||
watch(() => props.files, (files) => {
|
||||
if (!files || props.depth) return
|
||||
expandedDirs.value = new Set()
|
||||
expandAllNodes(displayNodes.value)
|
||||
}, { immediate: true })
|
||||
|
||||
function expandAllNodes(nodes: StatusNode[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'directory') {
|
||||
expandedDirs.value.add(node.path)
|
||||
if (node.children) expandAllNodes(node.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDir(path: string) {
|
||||
if (expandedDirs.value.has(path)) {
|
||||
expandedDirs.value.delete(path)
|
||||
} else {
|
||||
expandedDirs.value.add(path)
|
||||
}
|
||||
expandedDirs.value = new Set(expandedDirs.value)
|
||||
}
|
||||
|
||||
function isExpanded(path: string): boolean {
|
||||
return expandedDirs.value.has(path)
|
||||
}
|
||||
|
||||
function handleClick(node: StatusNode) {
|
||||
if (node.type === 'directory') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
emit('select', node.path)
|
||||
}
|
||||
}
|
||||
|
||||
function getFileIcon(name: string): string {
|
||||
const ext = name.split('.').pop()?.toLowerCase() || ''
|
||||
const icons: Record<string, string> = {
|
||||
ts: 'ts', tsx: 'tsx', js: 'js', jsx: 'jsx',
|
||||
vue: 'vue', json: 'json', md: 'md',
|
||||
css: 'css', scss: 'scss', html: 'html',
|
||||
svg: 'svg', png: 'img', jpg: 'img', jpeg: 'img',
|
||||
gif: 'img', ico: 'img', gitignore: 'git',
|
||||
env: 'env', yml: 'yml', yaml: 'yml',
|
||||
toml: 'toml', lock: 'lock'
|
||||
}
|
||||
return icons[ext] || 'file'
|
||||
}
|
||||
|
||||
function badgeLabel(status: string): string {
|
||||
if (status === 'untracked') return '?'
|
||||
return (status[0] || '?').toUpperCase()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="status-tree">
|
||||
<div v-for="node in displayNodes" :key="node.path" class="tree-node">
|
||||
<!-- Directory row -->
|
||||
<div
|
||||
v-if="node.type === 'directory'"
|
||||
class="node-row dir-row"
|
||||
@click="handleClick(node)"
|
||||
>
|
||||
<span class="expand-icon">
|
||||
<svg
|
||||
:class="{ expanded: isExpanded(node.path) }"
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
|
||||
>
|
||||
<path d="M8 5l8 7-8 7z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="folder-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path v-if="isExpanded(node.path)" d="M19 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2z" />
|
||||
<path v-else d="M3 6a2 2 0 0 1 2-2h6l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
<span class="dir-count">{{ node.fileCount }}</span>
|
||||
</div>
|
||||
|
||||
<!-- File row -->
|
||||
<div
|
||||
v-else
|
||||
:class="['node-row', 'file-row', { selected: selectedPath === node.path }]"
|
||||
@click="handleClick(node)"
|
||||
>
|
||||
<span class="expand-icon spacer"></span>
|
||||
<span :class="['status-badge', node.status]">{{ badgeLabel(node.status || '') }}</span>
|
||||
<span :class="['file-icon', getFileIcon(node.name)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Recursive children -->
|
||||
<div v-if="node.type === 'directory' && isExpanded(node.path) && node.children" class="children">
|
||||
<StatusTree
|
||||
:nodes="node.children"
|
||||
:selected-path="selectedPath"
|
||||
:depth="(depth || 0) + 1"
|
||||
@select="emit('select', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-tree {
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.node-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.node-row.selected {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.expand-icon.spacer {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.expand-icon svg {
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.expand-icon svg.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-icon.ts, .file-icon.tsx { color: #3178c6; }
|
||||
.file-icon.js, .file-icon.jsx { color: #f7df1e; }
|
||||
.file-icon.vue { color: #42b883; }
|
||||
.file-icon.json { color: #f5a623; }
|
||||
.file-icon.md { color: #519aba; }
|
||||
.file-icon.css, .file-icon.scss { color: #563d7c; }
|
||||
.file-icon.html { color: #e34c26; }
|
||||
.file-icon.svg, .file-icon.img { color: #a855f7; }
|
||||
.file-icon.git { color: #f05032; }
|
||||
.file-icon.env { color: #ecd53f; }
|
||||
.file-icon.yml { color: #cb171e; }
|
||||
.file-icon.lock { color: #6b7280; }
|
||||
|
||||
.node-name {
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dir-count {
|
||||
margin-left: auto;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge.added {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-badge.modified {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.status-badge.deleted {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-badge.renamed {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.status-badge.untracked {
|
||||
background: rgba(107, 114, 128, 0.2);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.children {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
</style>
|
||||
7
frontend/src/components/git/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as DiffViewer } from './DiffViewer.vue'
|
||||
export { default as FileTree } from './FileTree.vue'
|
||||
export { default as CommitList } from './CommitList.vue'
|
||||
export { default as BranchSelector } from './BranchSelector.vue'
|
||||
export { default as ProjectTree } from './ProjectTree.vue'
|
||||
export { default as FileViewer } from './FileViewer.vue'
|
||||
export { default as StatusTree } from './StatusTree.vue'
|
||||
398
frontend/src/components/transcript-debug/AgentBadge.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { TerminalSlot } from '@/types/transcript-debug'
|
||||
import { useSessionState, type AgentStatus } from '@/stores/session-state'
|
||||
|
||||
const props = defineProps<{
|
||||
agent: string
|
||||
connected: boolean
|
||||
terminals: TerminalSlot[]
|
||||
activeSessionId: string | null
|
||||
model?: string
|
||||
version?: string
|
||||
}>()
|
||||
|
||||
const sessionStore = useSessionState()
|
||||
|
||||
const STATUS_COLORS: Record<AgentStatus, string> = {
|
||||
idle: '#6b7280',
|
||||
thinking: '#60a5fa',
|
||||
reading: '#22d3ee',
|
||||
writing: '#4ade80',
|
||||
toolUse: '#fbbf24',
|
||||
permissionRequest: '#fb923c',
|
||||
interrupted: '#f87171',
|
||||
error: '#f87171',
|
||||
sessionStart: '#60a5fa',
|
||||
sessionEnd: '#6b7280',
|
||||
}
|
||||
|
||||
// Derive PTY ID from the active terminal slot
|
||||
const activePtyId = computed(() => {
|
||||
if (!props.activeSessionId) return null
|
||||
const t = props.terminals.find(t => t.sessionId === props.activeSessionId)
|
||||
return t?.ephemeralSessionId ?? null
|
||||
})
|
||||
|
||||
const agentStatusColor = computed(() => {
|
||||
const ptyId = activePtyId.value
|
||||
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
|
||||
if (!state) return null
|
||||
return STATUS_COLORS[state.status] || '#6b7280'
|
||||
})
|
||||
|
||||
const agentStatusClass = computed(() => {
|
||||
const ptyId = activePtyId.value
|
||||
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
|
||||
return state?.status || 'idle'
|
||||
})
|
||||
|
||||
const activeIndex = computed(() => {
|
||||
if (!props.activeSessionId) return -1
|
||||
return props.terminals.findIndex(t => t.sessionId === props.activeSessionId)
|
||||
})
|
||||
|
||||
const activeSlot = computed(() =>
|
||||
activeIndex.value >= 0 ? props.terminals[activeIndex.value] : null
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'switch-terminal': [sessionId: string]
|
||||
'close-terminal': [sessionId: string]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const wrapperRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function toggle(e: Event) {
|
||||
e.stopPropagation()
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (!isOpen.value) return
|
||||
if (wrapperRef.value && !wrapperRef.value.contains(e.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwitch(sessionId: string) {
|
||||
emit('switch-terminal', sessionId)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleClose(e: Event, sessionId: string) {
|
||||
e.stopPropagation()
|
||||
emit('close-terminal', sessionId)
|
||||
}
|
||||
|
||||
function slotColor(t: TerminalSlot): string {
|
||||
if (!t.alive) return '#f87171' // PTY dead → red
|
||||
if (t.clients > 0) return '#4ade80' // alive + connected → green
|
||||
return '#fbbf24' // alive, no clients (parked) → orange
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', onClickOutside))
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapperRef" class="agent-badge-wrapper" :class="{ connected }" @click="toggle">
|
||||
<span v-if="agentStatusColor" class="state-dot status-dot" :class="agentStatusClass" :style="{ background: agentStatusColor }" />
|
||||
<span v-else-if="activeSlot" class="state-dot badge-dot" :style="{ background: slotColor(activeSlot) }" />
|
||||
<span class="agent-label">{{ agent }}</span>
|
||||
<span v-if="terminals.length" class="term-count">{{ activeIndex >= 0 ? `${activeIndex + 1}/${terminals.length}` : terminals.length }}</span>
|
||||
<svg class="caret" :class="{ open: isOpen }" width="6" height="6" viewBox="0 0 6 6" shape-rendering="crispEdges">
|
||||
<rect x="2" y="4" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="0" y="2" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="4" y="2" width="2" height="2" fill="currentColor"/>
|
||||
</svg>
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="dropdown">
|
||||
<div v-if="model || version" class="dropdown-meta">
|
||||
<span v-if="model" class="meta-model">{{ model }}</span>
|
||||
<span v-if="version" class="meta-version">v{{ version }}</span>
|
||||
</div>
|
||||
<div v-if="terminals.length === 0" class="dropdown-item empty">No terminals</div>
|
||||
<div
|
||||
v-for="(t, idx) in terminals"
|
||||
:key="t.sessionId"
|
||||
class="dropdown-item terminal-item"
|
||||
:class="{ active: t.sessionId === activeSessionId }"
|
||||
@click.stop="handleSwitch(t.sessionId)"
|
||||
>
|
||||
<span class="shortcut-key">{{ idx + 1 }}</span>
|
||||
<span class="state-dot" :style="{ background: slotColor(t) }" />
|
||||
<span class="terminal-label">{{ t.label }}</span>
|
||||
<button class="close-btn" @click="handleClose($event, t.sessionId)" title="Close terminal">
|
||||
<svg width="6" height="6" viewBox="0 0 6 6">
|
||||
<line x1="0" y1="0" x2="6" y2="6" stroke="currentColor" stroke-width="1.2"/>
|
||||
<line x1="6" y1="0" x2="0" y2="6" stroke="currentColor" stroke-width="1.2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-badge-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.agent-badge-wrapper:hover {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.agent-badge-wrapper.connected {
|
||||
border: 2px solid;
|
||||
border-image: conic-gradient(
|
||||
from var(--border-angle, 0deg),
|
||||
rgba(34, 211, 238, 1),
|
||||
rgba(99, 102, 241, 0.7),
|
||||
rgba(34, 211, 238, 0.15),
|
||||
rgba(99, 102, 241, 0.7),
|
||||
rgba(34, 211, 238, 1)
|
||||
) 1;
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
box-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
|
||||
animation: border-spin 3s linear infinite;
|
||||
}
|
||||
|
||||
.agent-badge-wrapper.connected:hover {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
@property --border-angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes border-spin {
|
||||
to { --border-angle: 360deg; }
|
||||
}
|
||||
|
||||
.agent-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #a5b4fc;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.connected .agent-label {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.badge-dot,
|
||||
.status-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.thinking {
|
||||
animation: pulse-badge 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-badge {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.term-count {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(165, 180, 252, 0.6);
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
padding: 0 3px;
|
||||
min-width: 12px;
|
||||
text-align: center;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.connected .term-count {
|
||||
color: rgba(134, 239, 172, 0.6);
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.caret {
|
||||
color: rgba(165, 180, 252, 0.5);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.connected .caret {
|
||||
color: rgba(134, 239, 172, 0.5);
|
||||
}
|
||||
|
||||
.caret.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: rgba(8, 8, 18, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
z-index: 100;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.dropdown-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px 3px;
|
||||
border-bottom: 1px solid rgba(99, 102, 241, 0.1);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.meta-model {
|
||||
font-size: 8px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(165, 180, 252, 0.7);
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
padding: 0 4px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.meta-version {
|
||||
font-size: 8px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 5px 10px;
|
||||
font-size: 9px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dropdown-item.empty {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item:hover {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item.active {
|
||||
background: rgba(99, 102, 241, 0.18);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
min-width: 10px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item.active .shortcut-key {
|
||||
color: rgba(165, 180, 252, 0.7);
|
||||
}
|
||||
|
||||
.state-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.terminal-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item:hover .close-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #fca5a5;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.dropdown-enter-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.1s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.dropdown::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dropdown::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, TransitionGroup } from 'vue'
|
||||
import type { ParsedAssistantMessage } from '@/types/transcript-debug'
|
||||
import ThinkingBlock from './ThinkingBlock.vue'
|
||||
import ToolCallBlock from './ToolCallBlock.vue'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
import AskUserQuestionCard from './toolCards/AskUserQuestionCard.vue'
|
||||
import ExitPlanModeCard from './toolCards/ExitPlanModeCard.vue'
|
||||
import EnterPlanModeCard from './toolCards/EnterPlanModeCard.vue'
|
||||
import ReadCard from './toolCards/ReadCard.vue'
|
||||
import WriteCard from './toolCards/WriteCard.vue'
|
||||
import BashCard from './toolCards/BashCard.vue'
|
||||
import EditCard from './toolCards/EditCard.vue'
|
||||
import GrepCard from './toolCards/GrepCard.vue'
|
||||
import GlobCard from './toolCards/GlobCard.vue'
|
||||
import TaskCard from './toolCards/TaskCard.vue'
|
||||
|
||||
const TASK_TOOLS = new Set(['Task', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
|
||||
|
||||
const props = defineProps<{
|
||||
message: ParsedAssistantMessage
|
||||
showHeader?: boolean
|
||||
}>()
|
||||
|
||||
// Filter out empty text blocks (streaming placeholders)
|
||||
const visibleTextBlocks = computed(() =>
|
||||
props.message.textBlocks.filter(t => t.trim().length > 0)
|
||||
)
|
||||
|
||||
// Show thinking animation when assistant has no real content yet
|
||||
const isStreaming = computed(() =>
|
||||
visibleTextBlocks.value.length === 0 &&
|
||||
props.message.thinkingBlocks.length === 0 &&
|
||||
props.message.toolCalls.length === 0 &&
|
||||
!props.message.stopReason
|
||||
)
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString()
|
||||
}
|
||||
|
||||
function formatTokens(n?: number): string {
|
||||
if (!n) return '0'
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||
return String(n)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['assistant-bubble', { continuation: showHeader === false }]">
|
||||
<div v-if="showHeader !== false" class="bubble-header">
|
||||
<span class="role-badge">Assistant</span>
|
||||
<span class="model-badge">{{ message.model }}</span>
|
||||
<span v-if="message.usage" class="token-info">
|
||||
{{ formatTokens(message.usage.input_tokens) }}in / {{ formatTokens(message.usage.output_tokens) }}out
|
||||
</span>
|
||||
<span v-if="message.stopReason" class="stop-reason">{{ message.stopReason }}</span>
|
||||
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- All dynamic content in one TransitionGroup (fragment mode, no wrapper) -->
|
||||
<TransitionGroup name="inner">
|
||||
<!-- Streaming/thinking animation -->
|
||||
<div v-if="isStreaming" key="streaming" class="thinking-animation">
|
||||
<div class="thinking-dots">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
<span class="thinking-label">Thinking...</span>
|
||||
</div>
|
||||
|
||||
<!-- Thinking blocks -->
|
||||
<ThinkingBlock
|
||||
v-for="(t, i) in message.thinkingBlocks"
|
||||
:key="'think-' + i"
|
||||
:content="t"
|
||||
/>
|
||||
|
||||
<!-- Text content (filtered, markdown-rendered) -->
|
||||
<MarkdownContent
|
||||
v-for="(text, i) in visibleTextBlocks"
|
||||
:key="'text-' + i"
|
||||
:content="text"
|
||||
/>
|
||||
|
||||
<!-- Tool calls (wrapped in div for TransitionGroup keying) -->
|
||||
<div v-for="tc in message.toolCalls" :key="tc.id">
|
||||
<AskUserQuestionCard v-if="tc.name === 'AskUserQuestion'" :call="tc" />
|
||||
<ExitPlanModeCard v-else-if="tc.name === 'ExitPlanMode'" :call="tc" />
|
||||
<EnterPlanModeCard v-else-if="tc.name === 'EnterPlanMode'" :call="tc" />
|
||||
<ReadCard v-else-if="tc.name === 'Read'" :call="tc" />
|
||||
<WriteCard v-else-if="tc.name === 'Write'" :call="tc" />
|
||||
<BashCard v-else-if="tc.name === 'Bash'" :call="tc" />
|
||||
<EditCard v-else-if="tc.name === 'Edit'" :call="tc" />
|
||||
<GrepCard v-else-if="tc.name === 'Grep'" :call="tc" />
|
||||
<GlobCard v-else-if="tc.name === 'Glob'" :call="tc" />
|
||||
<TaskCard v-else-if="TASK_TOOLS.has(tc.name)" :call="tc" />
|
||||
<ToolCallBlock v-else :call="tc" />
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assistant-bubble {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.assistant-bubble.continuation {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.bubble-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #22c55e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.model-badge {
|
||||
font-size: 10px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: var(--accent, #6366f1);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.token-info {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.stop-reason {
|
||||
font-size: 10px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* Thinking animation */
|
||||
.thinking-animation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.thinking-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
opacity: 0.4;
|
||||
animation: pulse-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 80%, 100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<!-- Non-scoped: TransitionGroup classes must reach child component roots -->
|
||||
<style>
|
||||
.inner-enter-active {
|
||||
transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.inner-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.inner-leave-active {
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.inner-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
161
frontend/src/components/transcript-debug/BackgroundPixelArt.vue
Normal file
1565
frontend/src/components/transcript-debug/ChatContainer.vue
Normal file
81
frontend/src/components/transcript-debug/CodeBlock.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { highlightCode } from '@/utils/markdown'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
code: string
|
||||
lang?: string
|
||||
maxHeight?: string
|
||||
}>(), {
|
||||
lang: '',
|
||||
maxHeight: '250px',
|
||||
})
|
||||
|
||||
const highlighted = computed(() =>
|
||||
highlightCode(props.code, props.lang || undefined)
|
||||
)
|
||||
|
||||
const copyState = ref<'idle' | 'copied'>('idle')
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(props.code).then(() => {
|
||||
copyState.value = 'copied'
|
||||
setTimeout(() => { copyState.value = 'idle' }, 1500)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" @click.stop="copy">
|
||||
{{ copyState === 'copied' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
<pre class="code-pre" :style="{ maxHeight }" v-html="highlighted"></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code-block {
|
||||
position: relative;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
background: var(--bg-secondary, #1a1a2e);
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
.code-pre {
|
||||
margin: 0;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
overflow: auto;
|
||||
background: transparent;
|
||||
font-family: 'Consolas', 'Lucida Console', 'SF Mono', 'Fira Code', monospace;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
font-size: 10px;
|
||||
padding: 0.15em 0.4em;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.code-block:hover .copy-btn { opacity: 1; }
|
||||
.copy-btn:hover { color: var(--text-primary, #e2e8f0); background: rgba(255, 255, 255, 0.1); }
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import type { ParsedSystemMessage } from '@/types/transcript-debug'
|
||||
|
||||
defineProps<{
|
||||
message: ParsedSystemMessage
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compact-boundary">
|
||||
<div class="compact-line">
|
||||
<!-- Left compression wave -->
|
||||
<svg class="wave wave-left" viewBox="0 0 200 14" preserveAspectRatio="xMinYMid slice" shape-rendering="crispEdges">
|
||||
<!-- Base void layer -->
|
||||
<rect x="0" y="6" width="200" height="2" fill="#f59e0b" opacity="0.08"/>
|
||||
|
||||
<!-- Compression chevrons pushing right → -->
|
||||
<rect x="4" y="5" width="1" height="4" fill="#f59e0b" opacity="0.15"/>
|
||||
<rect x="5" y="4" width="1" height="6" fill="#f59e0b" opacity="0.18"/>
|
||||
<rect x="6" y="3" width="1" height="8" fill="#f59e0b" opacity="0.2"/>
|
||||
|
||||
<rect x="14" y="5" width="1" height="4" fill="#f59e0b" opacity="0.2"/>
|
||||
<rect x="15" y="4" width="1" height="6" fill="#f59e0b" opacity="0.25"/>
|
||||
<rect x="16" y="3" width="1" height="8" fill="#f59e0b" opacity="0.28"/>
|
||||
|
||||
<rect x="26" y="5" width="1" height="4" fill="#f59e0b" opacity="0.25"/>
|
||||
<rect x="27" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
|
||||
<rect x="28" y="3" width="1" height="8" fill="#fbbf24" opacity="0.32"/>
|
||||
|
||||
<rect x="40" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
|
||||
<rect x="41" y="3" width="1" height="8" fill="#fbbf24" opacity="0.35"/>
|
||||
<rect x="42" y="2" width="1" height="10" fill="#fbbf24" opacity="0.38"/>
|
||||
|
||||
<rect x="56" y="4" width="1" height="6" fill="#fbbf24" opacity="0.35"/>
|
||||
<rect x="57" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
|
||||
<rect x="58" y="2" width="1" height="10" fill="#fbbf24" opacity="0.42"/>
|
||||
|
||||
<rect x="74" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
|
||||
<rect x="75" y="2" width="1" height="10" fill="#fbbf24" opacity="0.45"/>
|
||||
<rect x="76" y="1" width="1" height="12" fill="#fbbf24" opacity="0.48"/>
|
||||
|
||||
<rect x="94" y="3" width="1" height="8" fill="#fbbf24" opacity="0.45"/>
|
||||
<rect x="95" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
|
||||
<rect x="96" y="1" width="1" height="12" fill="#fde68a" opacity="0.52"/>
|
||||
|
||||
<rect x="116" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
|
||||
<rect x="117" y="1" width="1" height="12" fill="#fde68a" opacity="0.55"/>
|
||||
<rect x="118" y="0" width="1" height="14" fill="#fef3c7" opacity="0.55"/>
|
||||
|
||||
<rect x="140" y="2" width="1" height="10" fill="#fde68a" opacity="0.55"/>
|
||||
<rect x="141" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
|
||||
<rect x="142" y="0" width="1" height="14" fill="#fef3c7" opacity="0.6"/>
|
||||
|
||||
<rect x="160" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
|
||||
<rect x="161" y="0" width="1" height="14" fill="#fef3c7" opacity="0.62"/>
|
||||
<rect x="162" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
|
||||
|
||||
<rect x="178" y="1" width="1" height="12" fill="#fef3c7" opacity="0.6"/>
|
||||
<rect x="179" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
|
||||
<rect x="180" y="0" width="1" height="14" fill="#fffbeb" opacity="0.7"/>
|
||||
|
||||
<!-- Data particles being pulled inward -->
|
||||
<rect x="8" y="6" width="2" height="2" fill="#f59e0b" opacity="0.2"/>
|
||||
<rect x="20" y="5" width="2" height="1" fill="#fbbf24" opacity="0.25"/>
|
||||
<rect x="34" y="8" width="2" height="1" fill="#f59e0b" opacity="0.3"/>
|
||||
<rect x="48" y="4" width="1" height="1" fill="#fbbf24" opacity="0.35"/>
|
||||
<rect x="52" y="9" width="2" height="1" fill="#fde68a" opacity="0.3"/>
|
||||
<rect x="66" y="5" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
|
||||
<rect x="68" y="8" width="2" height="1" fill="#f59e0b" opacity="0.35"/>
|
||||
<rect x="84" y="4" width="1" height="1" fill="#fde68a" opacity="0.45"/>
|
||||
<rect x="86" y="9" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
|
||||
<rect x="104" y="6" width="2" height="1" fill="#fde68a" opacity="0.45"/>
|
||||
<rect x="108" y="3" width="1" height="1" fill="#fbbf24" opacity="0.5"/>
|
||||
<rect x="128" y="5" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
|
||||
<rect x="132" y="8" width="1" height="1" fill="#fde68a" opacity="0.5"/>
|
||||
<rect x="150" y="4" width="1" height="1" fill="#fef3c7" opacity="0.55"/>
|
||||
<rect x="154" y="9" width="1" height="1" fill="#fde68a" opacity="0.55"/>
|
||||
<rect x="170" y="6" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
|
||||
<rect x="186" y="5" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
|
||||
<rect x="192" y="7" width="1" height="1" fill="#fffbeb" opacity="0.65"/>
|
||||
</svg>
|
||||
|
||||
<!-- Center badge -->
|
||||
<span class="compact-badge">Compactado</span>
|
||||
|
||||
<!-- Right compression wave -->
|
||||
<svg class="wave wave-right" viewBox="0 0 200 14" preserveAspectRatio="xMaxYMid slice" shape-rendering="crispEdges">
|
||||
<!-- Base void layer -->
|
||||
<rect x="0" y="6" width="200" height="2" fill="#f59e0b" opacity="0.08"/>
|
||||
|
||||
<!-- Compression chevrons pushing left ← (mirrored) -->
|
||||
<rect x="195" y="5" width="1" height="4" fill="#f59e0b" opacity="0.15"/>
|
||||
<rect x="194" y="4" width="1" height="6" fill="#f59e0b" opacity="0.18"/>
|
||||
<rect x="193" y="3" width="1" height="8" fill="#f59e0b" opacity="0.2"/>
|
||||
|
||||
<rect x="185" y="5" width="1" height="4" fill="#f59e0b" opacity="0.2"/>
|
||||
<rect x="184" y="4" width="1" height="6" fill="#f59e0b" opacity="0.25"/>
|
||||
<rect x="183" y="3" width="1" height="8" fill="#f59e0b" opacity="0.28"/>
|
||||
|
||||
<rect x="173" y="5" width="1" height="4" fill="#f59e0b" opacity="0.25"/>
|
||||
<rect x="172" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
|
||||
<rect x="171" y="3" width="1" height="8" fill="#fbbf24" opacity="0.32"/>
|
||||
|
||||
<rect x="159" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
|
||||
<rect x="158" y="3" width="1" height="8" fill="#fbbf24" opacity="0.35"/>
|
||||
<rect x="157" y="2" width="1" height="10" fill="#fbbf24" opacity="0.38"/>
|
||||
|
||||
<rect x="143" y="4" width="1" height="6" fill="#fbbf24" opacity="0.35"/>
|
||||
<rect x="142" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
|
||||
<rect x="141" y="2" width="1" height="10" fill="#fbbf24" opacity="0.42"/>
|
||||
|
||||
<rect x="125" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
|
||||
<rect x="124" y="2" width="1" height="10" fill="#fbbf24" opacity="0.45"/>
|
||||
<rect x="123" y="1" width="1" height="12" fill="#fbbf24" opacity="0.48"/>
|
||||
|
||||
<rect x="105" y="3" width="1" height="8" fill="#fbbf24" opacity="0.45"/>
|
||||
<rect x="104" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
|
||||
<rect x="103" y="1" width="1" height="12" fill="#fde68a" opacity="0.52"/>
|
||||
|
||||
<rect x="83" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
|
||||
<rect x="82" y="1" width="1" height="12" fill="#fde68a" opacity="0.55"/>
|
||||
<rect x="81" y="0" width="1" height="14" fill="#fef3c7" opacity="0.55"/>
|
||||
|
||||
<rect x="59" y="2" width="1" height="10" fill="#fde68a" opacity="0.55"/>
|
||||
<rect x="58" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
|
||||
<rect x="57" y="0" width="1" height="14" fill="#fef3c7" opacity="0.6"/>
|
||||
|
||||
<rect x="39" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
|
||||
<rect x="38" y="0" width="1" height="14" fill="#fef3c7" opacity="0.62"/>
|
||||
<rect x="37" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
|
||||
|
||||
<rect x="21" y="1" width="1" height="12" fill="#fef3c7" opacity="0.6"/>
|
||||
<rect x="20" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
|
||||
<rect x="19" y="0" width="1" height="14" fill="#fffbeb" opacity="0.7"/>
|
||||
|
||||
<!-- Data particles being pulled inward -->
|
||||
<rect x="190" y="6" width="2" height="2" fill="#f59e0b" opacity="0.2"/>
|
||||
<rect x="178" y="8" width="2" height="1" fill="#fbbf24" opacity="0.25"/>
|
||||
<rect x="164" y="5" width="2" height="1" fill="#f59e0b" opacity="0.3"/>
|
||||
<rect x="151" y="9" width="1" height="1" fill="#fbbf24" opacity="0.35"/>
|
||||
<rect x="146" y="4" width="2" height="1" fill="#fde68a" opacity="0.3"/>
|
||||
<rect x="133" y="8" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
|
||||
<rect x="130" y="5" width="2" height="1" fill="#f59e0b" opacity="0.35"/>
|
||||
<rect x="115" y="9" width="1" height="1" fill="#fde68a" opacity="0.45"/>
|
||||
<rect x="112" y="4" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
|
||||
<rect x="94" y="7" width="2" height="1" fill="#fde68a" opacity="0.45"/>
|
||||
<rect x="90" y="3" width="1" height="1" fill="#fbbf24" opacity="0.5"/>
|
||||
<rect x="71" y="8" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
|
||||
<rect x="67" y="5" width="1" height="1" fill="#fde68a" opacity="0.5"/>
|
||||
<rect x="49" y="9" width="1" height="1" fill="#fef3c7" opacity="0.55"/>
|
||||
<rect x="45" y="4" width="1" height="1" fill="#fde68a" opacity="0.55"/>
|
||||
<rect x="29" y="7" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
|
||||
<rect x="13" y="8" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
|
||||
<rect x="7" y="6" width="1" height="1" fill="#fffbeb" opacity="0.65"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compact-boundary {
|
||||
padding: 0.25rem 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.compact-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.wave {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
image-rendering: pixelated;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compact-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(245, 158, 11, 0.85);
|
||||
padding: 0 6px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
z-index: 1;
|
||||
animation: compress-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes compress-pulse {
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 4px rgba(245, 158, 11, 0.2);
|
||||
color: rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 10px rgba(245, 158, 11, 0.5), 0 0 3px rgba(251, 191, 36, 0.3);
|
||||
color: rgba(245, 158, 11, 0.95);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
frontend/src/components/transcript-debug/MarkdownContent.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { parseMarkdown, ensureStyles } from '@/utils/markdown'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
const html = computed(() => parseMarkdown(props.content))
|
||||
|
||||
onMounted(ensureStyles)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="md-content" v-html="html"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.md-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
642
frontend/src/components/transcript-debug/NewSessionModal.vue
Normal file
@@ -0,0 +1,642 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { apiFetch } from '@/lib/tauri'
|
||||
import type { AgentName, SessionInfo } from '@/types/transcript-debug'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
agents: { id: AgentName; label: string }[]
|
||||
currentAgent: AgentName
|
||||
error?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'create-new': [agent: AgentName, initialPrompt: string]
|
||||
resume: [sessionId: string, agent: AgentName]
|
||||
}>()
|
||||
|
||||
type Tab = 'new' | 'resume'
|
||||
const activeTab = ref<Tab>('new')
|
||||
const selectedAgent = ref<AgentName>(props.currentAgent)
|
||||
const sessionsMap = ref<Record<AgentName, SessionInfo[]>>({} as any)
|
||||
const loadingSessions = ref(false)
|
||||
const resumeFilter = ref<AgentName | 'all'>('all')
|
||||
const initialPrompt = ref('')
|
||||
|
||||
const filteredAgents = computed(() => {
|
||||
if (resumeFilter.value === 'all') return props.agents
|
||||
return props.agents.filter(a => a.id === resumeFilter.value)
|
||||
})
|
||||
|
||||
const hasAnySessions = computed(() =>
|
||||
props.agents.some(a => (sessionsMap.value[a.id]?.length ?? 0) > 0)
|
||||
)
|
||||
|
||||
// Reset state when modal opens
|
||||
watch(() => props.visible, async (open) => {
|
||||
if (open) {
|
||||
// If reopening after a resume error, show the resume tab
|
||||
activeTab.value = props.error ? 'resume' : 'new'
|
||||
selectedAgent.value = props.currentAgent
|
||||
resumeFilter.value = 'all'
|
||||
initialPrompt.value = ''
|
||||
await fetchAllSessions()
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchAllSessions() {
|
||||
loadingSessions.value = true
|
||||
const map: Record<string, SessionInfo[]> = {}
|
||||
|
||||
await Promise.all(
|
||||
props.agents.map(async (a) => {
|
||||
try {
|
||||
const res = await apiFetch(`/api/transcript-debug/sessions?agent=${a.id}`)
|
||||
if (res.ok) map[a.id] = await res.json()
|
||||
else map[a.id] = []
|
||||
} catch {
|
||||
map[a.id] = []
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
sessionsMap.value = map as Record<AgentName, SessionInfo[]>
|
||||
loadingSessions.value = false
|
||||
}
|
||||
|
||||
function truncateMessage(msg: string, max = 60): string {
|
||||
if (!msg) return ''
|
||||
return msg.length > max ? msg.slice(0, max) + '...' : msg
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
emit('create-new', selectedAgent.value, initialPrompt.value)
|
||||
}
|
||||
|
||||
function handleResume(sessionId: string, agent: AgentName) {
|
||||
emit('resume', sessionId, agent)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="nsm">
|
||||
<div v-if="visible" class="nsm-backdrop" @click.self="emit('close')">
|
||||
<div class="nsm-panel">
|
||||
<!-- Header -->
|
||||
<div class="nsm-header">
|
||||
<span class="nsm-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
New Session
|
||||
</span>
|
||||
<button class="nsm-close" @click="emit('close')" title="Close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="nsm-tabs">
|
||||
<button
|
||||
:class="['nsm-tab', { active: activeTab === 'new' }]"
|
||||
@click="activeTab = 'new'"
|
||||
>
|
||||
New session
|
||||
</button>
|
||||
<button
|
||||
:class="['nsm-tab', { active: activeTab === 'resume' }]"
|
||||
@click="activeTab = 'resume'"
|
||||
>
|
||||
Resume existing
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
<div v-if="error" class="nsm-error">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="nsm-body">
|
||||
<!-- Tab: New session -->
|
||||
<div v-if="activeTab === 'new'" class="nsm-new">
|
||||
<label class="nsm-label">Agent</label>
|
||||
<div class="nsm-agent-grid">
|
||||
<button
|
||||
v-for="a in agents"
|
||||
:key="a.id"
|
||||
:class="['nsm-agent-btn', { active: selectedAgent === a.id }]"
|
||||
@click="selectedAgent = a.id"
|
||||
>
|
||||
{{ a.label }}
|
||||
</button>
|
||||
</div>
|
||||
<label class="nsm-label">Initial prompt <span class="nsm-optional">(optional)</span></label>
|
||||
<textarea
|
||||
v-model="initialPrompt"
|
||||
class="nsm-prompt-input"
|
||||
placeholder="What should the agent do first?"
|
||||
rows="3"
|
||||
@keydown.ctrl.enter="handleStart"
|
||||
@keydown.meta.enter="handleStart"
|
||||
/>
|
||||
<button class="nsm-start-btn" @click="handleStart">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Resume existing -->
|
||||
<div v-if="activeTab === 'resume'" class="nsm-resume">
|
||||
<!-- Agent filter -->
|
||||
<div class="nsm-filter">
|
||||
<button
|
||||
:class="['nsm-filter-btn', { active: resumeFilter === 'all' }]"
|
||||
@click="resumeFilter = 'all'"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="a in agents"
|
||||
:key="a.id"
|
||||
:class="['nsm-filter-btn', { active: resumeFilter === a.id }]"
|
||||
@click="resumeFilter = a.id"
|
||||
>
|
||||
{{ a.label }}
|
||||
<span v-if="sessionsMap[a.id]?.length" class="nsm-filter-count">{{ sessionsMap[a.id].length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingSessions" class="nsm-loading">
|
||||
Loading sessions...
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="a in filteredAgents"
|
||||
:key="a.id"
|
||||
class="nsm-agent-group"
|
||||
>
|
||||
<template v-if="sessionsMap[a.id]?.length">
|
||||
<div v-if="resumeFilter === 'all'" class="nsm-group-header">
|
||||
<span class="nsm-group-agent">{{ a.label }}</span>
|
||||
<span class="nsm-group-count">{{ sessionsMap[a.id].length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in sessionsMap[a.id]"
|
||||
:key="s.id"
|
||||
class="nsm-session-row"
|
||||
@click="handleResume(s.id, a.id)"
|
||||
>
|
||||
<div class="nsm-session-info">
|
||||
<span class="nsm-session-msg">
|
||||
{{ truncateMessage(s.firstUserMessage) || s.id.slice(0, 12) + '...' }}
|
||||
</span>
|
||||
<span class="nsm-session-meta">
|
||||
{{ formatDate(s.mtimeISO) }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="nsm-open-btn" @click.stop="handleResume(s.id, a.id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="!hasAnySessions" class="nsm-empty">
|
||||
No existing sessions found
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredAgents.every(a => !sessionsMap[a.id]?.length)"
|
||||
class="nsm-empty"
|
||||
>
|
||||
No sessions for this agent
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nsm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10013;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nsm-panel {
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 70vh;
|
||||
background: var(--bg-primary, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.nsm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary, #16162a);
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nsm-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.nsm-title svg {
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.nsm-close {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nsm-close:hover {
|
||||
background: var(--bg-hover, #2a2a4a);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.nsm-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nsm-tab {
|
||||
flex: 1;
|
||||
padding: 0.6rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.nsm-tab:hover {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
background: var(--bg-hover, #2a2a4a);
|
||||
}
|
||||
|
||||
.nsm-tab.active {
|
||||
color: #6366f1;
|
||||
border-bottom-color: #6366f1;
|
||||
}
|
||||
|
||||
/* Error banner */
|
||||
.nsm-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nsm-error svg {
|
||||
flex-shrink: 0;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.nsm-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* New session tab */
|
||||
.nsm-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.nsm-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.nsm-agent-grid {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nsm-agent-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: var(--bg-secondary, #16162a);
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nsm-agent-btn:hover {
|
||||
border-color: #6366f1;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.nsm-agent-btn.active {
|
||||
border-color: #6366f1;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nsm-optional {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.nsm-prompt-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: var(--bg-secondary, #16162a);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
border-radius: 6px;
|
||||
resize: vertical;
|
||||
min-height: 2.5rem;
|
||||
max-height: 8rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.nsm-prompt-input::placeholder {
|
||||
color: var(--text-muted, #666);
|
||||
}
|
||||
|
||||
.nsm-prompt-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.nsm-start-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border: none;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.nsm-start-btn:hover {
|
||||
background: #5558e6;
|
||||
}
|
||||
|
||||
/* Resume tab */
|
||||
.nsm-resume {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.nsm-filter {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nsm-filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: transparent;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nsm-filter-btn:hover {
|
||||
border-color: #6366f1;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.nsm-filter-btn.active {
|
||||
border-color: #6366f1;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nsm-filter-count {
|
||||
font-size: 9px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nsm-filter-btn.active .nsm-filter-count {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
.nsm-loading {
|
||||
text-align: center;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.nsm-agent-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nsm-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.nsm-group-agent {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #6366f1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.nsm-group-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #888);
|
||||
background: var(--bg-secondary, #16162a);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nsm-session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nsm-session-row:hover {
|
||||
border-color: #6366f1;
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.nsm-session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.nsm-session-msg {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nsm-session-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.nsm-open-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: transparent;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nsm-open-btn:hover {
|
||||
border-color: #6366f1;
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.nsm-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 12px;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.nsm-enter-active,
|
||||
.nsm-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.nsm-enter-active .nsm-panel,
|
||||
.nsm-leave-active .nsm-panel {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.nsm-enter-from,
|
||||
.nsm-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nsm-enter-from .nsm-panel,
|
||||
.nsm-leave-to .nsm-panel {
|
||||
transform: scale(0.95) translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
558
frontend/src/components/transcript-debug/PermissionApproval.vue
Normal file
@@ -0,0 +1,558 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { HooksApprovalPermissionRequest } from '@/types/hooks-approval'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
import { ensureStyles } from '@/utils/markdown'
|
||||
|
||||
const props = defineProps<{
|
||||
request: HooksApprovalPermissionRequest
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
respond: [requestId: string, decision: string, reason?: string]
|
||||
ignore: [requestId: string]
|
||||
}>()
|
||||
|
||||
// ── Mode detection ──
|
||||
type CardMode = 'permission' | 'plan' | 'question'
|
||||
|
||||
const mode = computed<CardMode>(() => {
|
||||
if (props.request.tool_name === 'ExitPlanMode') return 'plan'
|
||||
if (props.request.tool_name === 'AskUserQuestion') return 'question'
|
||||
return 'permission'
|
||||
})
|
||||
|
||||
const input = computed(() => {
|
||||
if (!props.request.tool_input) return null
|
||||
return props.request.tool_input as Record<string, unknown>
|
||||
})
|
||||
|
||||
// ── Plan mode ──
|
||||
const planText = computed(() => {
|
||||
if (mode.value !== 'plan' || !input.value) return ''
|
||||
return (input.value.plan as string) || ''
|
||||
})
|
||||
|
||||
const showPlanEditor = ref(false)
|
||||
const planEditText = ref('')
|
||||
|
||||
function handlePlanEdit() {
|
||||
if (!showPlanEditor.value) {
|
||||
showPlanEditor.value = true
|
||||
return
|
||||
}
|
||||
emit('respond', props.request.requestId, 'deny', planEditText.value || 'Continue with modifications.')
|
||||
}
|
||||
|
||||
// ── Question mode ──
|
||||
interface QuestionOption {
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface QuestionItem {
|
||||
question: string
|
||||
header?: string
|
||||
options?: QuestionOption[]
|
||||
multiSelect?: boolean
|
||||
}
|
||||
|
||||
const questions = computed<QuestionItem[]>(() => {
|
||||
if (mode.value !== 'question' || !input.value) return []
|
||||
const qs = input.value.questions
|
||||
if (Array.isArray(qs)) return qs as QuestionItem[]
|
||||
return []
|
||||
})
|
||||
|
||||
// Track selected options per question (keyed by question text)
|
||||
const selectedAnswers = ref<Record<string, Set<string>>>({})
|
||||
const customAnswers = ref<Record<string, string>>({})
|
||||
const showCustom = ref<Record<string, boolean>>({})
|
||||
|
||||
function toggleOption(questionText: string, label: string, multiSelect?: boolean) {
|
||||
if (!selectedAnswers.value[questionText]) {
|
||||
selectedAnswers.value[questionText] = new Set()
|
||||
}
|
||||
const set = selectedAnswers.value[questionText]
|
||||
if (multiSelect) {
|
||||
if (set.has(label)) set.delete(label)
|
||||
else set.add(label)
|
||||
} else {
|
||||
if (set.has(label)) set.clear()
|
||||
else { set.clear(); set.add(label) }
|
||||
}
|
||||
// Trigger reactivity
|
||||
selectedAnswers.value = { ...selectedAnswers.value }
|
||||
}
|
||||
|
||||
function toggleCustom(questionText: string) {
|
||||
showCustom.value = { ...showCustom.value, [questionText]: !showCustom.value[questionText] }
|
||||
}
|
||||
|
||||
function submitAnswers() {
|
||||
// Build answers object: { "question text": "selected label" }
|
||||
const answers: Record<string, string> = {}
|
||||
for (const q of questions.value) {
|
||||
const selected = selectedAnswers.value[q.question]
|
||||
const custom = customAnswers.value[q.question]
|
||||
if (custom?.trim()) {
|
||||
answers[q.question] = custom.trim()
|
||||
} else if (selected?.size) {
|
||||
answers[q.question] = Array.from(selected).join(', ')
|
||||
}
|
||||
}
|
||||
// Deny the tool (so Claude gets the answer as the deny message)
|
||||
// The answer goes as a structured JSON string
|
||||
const answerText = JSON.stringify({ answers })
|
||||
emit('respond', props.request.requestId, 'deny', answerText)
|
||||
}
|
||||
|
||||
function allowQuestion() {
|
||||
// Let AskUserQuestion run normally (shows in terminal)
|
||||
emit('respond', props.request.requestId, 'allow')
|
||||
}
|
||||
|
||||
// ── Permission mode ──
|
||||
const showFreeResponse = ref(false)
|
||||
const freeText = ref('')
|
||||
|
||||
function handleFreeResponse() {
|
||||
if (!showFreeResponse.value) {
|
||||
showFreeResponse.value = true
|
||||
return
|
||||
}
|
||||
if (freeText.value.trim()) {
|
||||
emit('respond', props.request.requestId, 'deny', freeText.value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(ensureStyles)
|
||||
|
||||
// ── Common ──
|
||||
function formatInput(inp: unknown): string {
|
||||
if (!inp) return ''
|
||||
if (typeof inp === 'string') return inp
|
||||
try {
|
||||
return JSON.stringify(inp, null, 2)
|
||||
} catch {
|
||||
return String(inp)
|
||||
}
|
||||
}
|
||||
|
||||
function elapsed(): string {
|
||||
const ms = Date.now() - props.request.timestamp
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s ago`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s ago`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ═══════ PLAN MODE (ExitPlanMode) ═══════ -->
|
||||
<div v-if="mode === 'plan'" class="card plan-card">
|
||||
<div class="card-header plan-header">
|
||||
<span class="card-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="card-label">Plan Approval</span>
|
||||
<span class="card-elapsed">{{ elapsed() }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="planText" class="plan-text">
|
||||
<MarkdownContent :content="planText" />
|
||||
</div>
|
||||
<div v-else class="plan-empty">Claude is waiting for plan approval</div>
|
||||
<Transition name="expand">
|
||||
<div v-if="showPlanEditor" class="edit-section">
|
||||
<textarea v-model="planEditText" class="edit-textarea" placeholder="Add instructions or feedback..." rows="3"></textarea>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-approve" @click="emit('respond', request.requestId, 'allow')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Approve
|
||||
</button>
|
||||
<button class="btn btn-edit" @click="handlePlanEdit">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
{{ showPlanEditor ? 'Send' : 'Edit & Continue' }}
|
||||
</button>
|
||||
<button class="btn btn-reject" @click="emit('respond', request.requestId, 'deny')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
Reject
|
||||
</button>
|
||||
<button class="btn btn-ignore" @click="emit('ignore', request.requestId)" title="Remove from UI without responding">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ QUESTION MODE (AskUserQuestion) ═══════ -->
|
||||
<div v-else-if="mode === 'question'" class="card question-card">
|
||||
<div class="card-header question-header">
|
||||
<span class="card-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="card-label">Question</span>
|
||||
<span class="card-elapsed">{{ elapsed() }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-for="(q, qi) in questions" :key="qi" class="question-block">
|
||||
<div class="question-text">
|
||||
<span v-if="q.header" class="question-tag">{{ q.header }}</span>
|
||||
{{ q.question }}
|
||||
<span v-if="q.multiSelect" class="multi-hint">(multiple)</span>
|
||||
</div>
|
||||
<div v-if="q.options?.length" class="options-grid">
|
||||
<button
|
||||
v-for="opt in q.options"
|
||||
:key="opt.label"
|
||||
:class="['option-btn', { selected: selectedAnswers[q.question]?.has(opt.label) }]"
|
||||
@click="toggleOption(q.question, opt.label, q.multiSelect)"
|
||||
>
|
||||
<span class="option-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.description" class="option-desc">{{ opt.description }}</span>
|
||||
</button>
|
||||
<button :class="['option-btn other', { selected: showCustom[q.question] }]" @click="toggleCustom(q.question)">
|
||||
<span class="option-label">Other</span>
|
||||
</button>
|
||||
</div>
|
||||
<Transition name="expand">
|
||||
<div v-if="showCustom[q.question] || !q.options?.length" class="custom-input">
|
||||
<textarea
|
||||
v-model="customAnswers[q.question]"
|
||||
class="edit-textarea"
|
||||
:placeholder="q.options?.length ? 'Custom answer...' : 'Type your answer...'"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-approve" @click="submitAnswers">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
Answer
|
||||
</button>
|
||||
<button class="btn btn-allow-terminal" @click="allowQuestion">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
Ask in Terminal
|
||||
</button>
|
||||
<button class="btn btn-reject" @click="emit('respond', request.requestId, 'deny')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
Dismiss
|
||||
</button>
|
||||
<button class="btn btn-ignore" @click="emit('ignore', request.requestId)" title="Remove from UI without responding">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ PERMISSION MODE (generic) ═══════ -->
|
||||
<div v-else class="card permission-card">
|
||||
<div class="card-header perm-header">
|
||||
<span class="card-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="card-label">Permission Request</span>
|
||||
<span class="card-elapsed">{{ elapsed() }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="info-row" v-if="request.tool_name">
|
||||
<span class="info-label">Tool</span>
|
||||
<code class="info-value tool-name">{{ request.tool_name }}</code>
|
||||
</div>
|
||||
<div class="info-row" v-if="request.agent_name">
|
||||
<span class="info-label">Agent</span>
|
||||
<span class="info-value">{{ request.agent_name }}</span>
|
||||
</div>
|
||||
<div v-if="request.tool_input" class="input-block">
|
||||
<span class="info-label">Input</span>
|
||||
<pre class="input-pre">{{ formatInput(request.tool_input) }}</pre>
|
||||
</div>
|
||||
<Transition name="expand">
|
||||
<div v-if="showFreeResponse" class="edit-section">
|
||||
<textarea v-model="freeText" class="edit-textarea" placeholder="Deny with a custom message..." rows="2"></textarea>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-allow" @click="emit('respond', request.requestId, 'allow')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Allow
|
||||
</button>
|
||||
<button class="btn btn-allow-always" @click="emit('respond', request.requestId, 'allowAlways')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<polyline points="9 12 11 14 15 10"/>
|
||||
</svg>
|
||||
Always
|
||||
</button>
|
||||
<button class="btn btn-free" @click="handleFreeResponse">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
{{ showFreeResponse ? 'Send' : 'Message' }}
|
||||
</button>
|
||||
<button class="btn btn-deny" @click="emit('respond', request.requestId, 'deny')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
Deny
|
||||
</button>
|
||||
<button class="btn btn-ignore" @click="emit('ignore', request.requestId)" title="Remove from UI without responding">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Shared card base ── */
|
||||
.card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card-icon { display: flex; align-items: center; }
|
||||
.card-elapsed {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Permission (yellow) ── */
|
||||
.permission-card { border-left: 3px solid #f59e0b; }
|
||||
.perm-header { background: rgba(245, 158, 11, 0.06); }
|
||||
.perm-header .card-icon, .perm-header .card-label { color: #f59e0b; }
|
||||
|
||||
/* ── Plan (purple) ── */
|
||||
.plan-card { border-left: 3px solid #8b5cf6; }
|
||||
.plan-header { background: rgba(139, 92, 246, 0.06); }
|
||||
.plan-header .card-icon, .plan-header .card-label { color: #8b5cf6; }
|
||||
|
||||
/* ── Question (blue) ── */
|
||||
.question-card { border-left: 3px solid #0ea5e9; }
|
||||
.question-header { background: rgba(14, 165, 233, 0.06); }
|
||||
.question-header .card-icon, .question-header .card-label { color: #0ea5e9; }
|
||||
|
||||
/* ── Info rows (permission) ── */
|
||||
.info-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.info-label { font-size: 11px; color: var(--text-muted); font-weight: 500; min-width: 40px; flex-shrink: 0; }
|
||||
.info-value { font-size: 13px; color: var(--text-primary); }
|
||||
.tool-name {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: var(--accent, #6366f1);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.input-block { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.input-pre {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Plan body ── */
|
||||
.plan-text {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.plan-empty { font-size: 13px; color: var(--text-muted); font-style: italic; padding: 0.5rem 0; }
|
||||
|
||||
/* ── Question body ── */
|
||||
.question-block { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.question-block + .question-block { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border-color); }
|
||||
.question-text { font-size: 13px; color: var(--text-primary); font-weight: 500; line-height: 1.4; }
|
||||
.question-tag {
|
||||
display: inline-block;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
color: #0ea5e9;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: middle;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.multi-hint { font-size: 11px; color: var(--text-muted); font-weight: 400; }
|
||||
|
||||
.options-grid { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.option-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.option-btn:hover { border-color: #0ea5e9; background: rgba(14, 165, 233, 0.04); }
|
||||
.option-btn.selected { border-color: #0ea5e9; background: rgba(14, 165, 233, 0.1); }
|
||||
.option-btn.other { border-style: dashed; }
|
||||
.option-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.option-desc { font-size: 11px; color: var(--text-muted); line-height: 1.3; }
|
||||
|
||||
.custom-input { margin-top: 0.25rem; }
|
||||
|
||||
/* ── Shared edit/textarea ── */
|
||||
.edit-section { margin-top: 0.25rem; }
|
||||
.edit-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.edit-textarea:focus { border-color: var(--accent, #6366f1); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-allow { background: #22c55e; color: white; }
|
||||
.btn-allow:hover { background: #16a34a; }
|
||||
|
||||
.btn-allow-always { background: #0ea5e9; color: white; }
|
||||
.btn-allow-always:hover { background: #0284c7; }
|
||||
|
||||
.btn-allow-terminal { background: #64748b; color: white; }
|
||||
.btn-allow-terminal:hover { background: #475569; }
|
||||
|
||||
.btn-approve { background: #22c55e; color: white; }
|
||||
.btn-approve:hover { background: #16a34a; }
|
||||
|
||||
.btn-edit { background: #8b5cf6; color: white; }
|
||||
.btn-edit:hover { background: #7c3aed; }
|
||||
|
||||
.btn-free { background: #8b5cf6; color: white; }
|
||||
.btn-free:hover { background: #7c3aed; }
|
||||
|
||||
.btn-deny { background: #ef4444; color: white; }
|
||||
.btn-deny:hover { background: #dc2626; }
|
||||
|
||||
.btn-reject { background: #ef4444; color: white; }
|
||||
.btn-reject:hover { background: #dc2626; }
|
||||
|
||||
.btn-ignore { background: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||
.btn-ignore:hover { background: var(--bg-hover); color: var(--text-secondary); }
|
||||
|
||||
/* ── Transitions ── */
|
||||
.expand-enter-active, .expand-leave-active { transition: all 0.2s ease; overflow: hidden; }
|
||||
.expand-enter-from, .expand-leave-to { opacity: 0; max-height: 0; }
|
||||
.expand-enter-to, .expand-leave-from { max-height: 200px; }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
247
frontend/src/components/transcript-debug/PlanApproval.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { HooksApprovalPlanRequest } from '@/types/hooks-approval'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
import { ensureStyles } from '@/utils/markdown'
|
||||
|
||||
const props = defineProps<{
|
||||
request: HooksApprovalPlanRequest
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
respond: [requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string]
|
||||
}>()
|
||||
|
||||
const showEditor = ref(false)
|
||||
const editReason = ref('')
|
||||
|
||||
function elapsed(): string {
|
||||
const ms = Date.now() - props.request.timestamp
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s ago`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s ago`
|
||||
}
|
||||
|
||||
onMounted(ensureStyles)
|
||||
|
||||
function handleEdit() {
|
||||
if (!showEditor.value) {
|
||||
showEditor.value = true
|
||||
return
|
||||
}
|
||||
emit('respond', props.request.requestId, 'edit', editReason.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plan-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="card-label">Plan Approval</span>
|
||||
<span class="card-elapsed">{{ elapsed() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div v-if="request.lastAssistantText" class="plan-text">
|
||||
<MarkdownContent :content="request.lastAssistantText" />
|
||||
</div>
|
||||
<div v-else class="plan-empty">
|
||||
Claude is waiting for plan approval
|
||||
</div>
|
||||
|
||||
<Transition name="expand">
|
||||
<div v-if="showEditor" class="edit-section">
|
||||
<textarea
|
||||
v-model="editReason"
|
||||
class="edit-textarea"
|
||||
placeholder="Add instructions or feedback..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-approve" @click="emit('respond', request.requestId, 'approve')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Approve
|
||||
</button>
|
||||
<button class="btn btn-edit" @click="handleEdit">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
{{ showEditor ? 'Send' : 'Edit & Continue' }}
|
||||
</button>
|
||||
<button class="btn btn-reject" @click="emit('respond', request.requestId, 'reject')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plan-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid #8b5cf6;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(139, 92, 246, 0.06);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
color: #8b5cf6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #8b5cf6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card-elapsed {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.plan-text {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.plan-empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.edit-section {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.edit-textarea:focus {
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-approve:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-reject:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* ── Expand transition ── */
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.expand-enter-to,
|
||||
.expand-leave-from {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
279
frontend/src/components/transcript-debug/ProgressEvent.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ParsedProgressGroup, ParsedProgressEvent } from '@/types/transcript-debug'
|
||||
|
||||
const props = defineProps<{
|
||||
group: ParsedProgressGroup
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
const hookEvents = computed(() =>
|
||||
props.group.events.filter(e => e.dataType === 'hook_progress')
|
||||
)
|
||||
|
||||
const mcpEvents = computed(() =>
|
||||
props.group.events.filter(e => e.dataType === 'mcp_progress')
|
||||
)
|
||||
|
||||
function hookLabel(e: ParsedProgressEvent): string {
|
||||
return e.hookEvent || e.hookName?.split(':')[0] || 'hook'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="progress-group">
|
||||
<button class="progress-toggle" @click="expanded = !expanded">
|
||||
<svg
|
||||
:class="['chevron', { rotated: expanded }]"
|
||||
width="10" height="10" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="toggle-pills">
|
||||
<span v-for="he in hookEvents" :key="he.uuid" :class="['hook-pill', he.hookEvent?.toLowerCase()]">
|
||||
{{ hookLabel(he) }}
|
||||
</span>
|
||||
<span v-for="me in mcpEvents" :key="me.uuid" :class="['mcp-pill', me.mcpStatus]">
|
||||
MCP {{ me.mcpStatus }}
|
||||
<span v-if="me.mcpElapsedMs != null" class="mcp-ms">{{ me.mcpElapsedMs }}ms</span>
|
||||
</span>
|
||||
<span v-if="!hookEvents.length && !mcpEvents.length" class="generic-pill">
|
||||
{{ group.events.length }} progress events
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="expanded" class="progress-details">
|
||||
<div v-for="e in group.events" :key="e.uuid" class="detail-row">
|
||||
<!-- Hook progress -->
|
||||
<template v-if="e.dataType === 'hook_progress'">
|
||||
<span class="row-icon hook-color">⚙</span>
|
||||
<span :class="['row-event', e.hookEvent?.toLowerCase()]">{{ e.hookEvent }}</span>
|
||||
<span class="row-name">{{ e.hookName }}</span>
|
||||
<span v-if="e.command" class="row-command" :title="e.command">
|
||||
{{ e.command.length > 80 ? e.command.slice(0, 80) + '...' : e.command }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- MCP progress -->
|
||||
<template v-else-if="e.dataType === 'mcp_progress'">
|
||||
<span class="row-icon mcp-color">⚡</span>
|
||||
<span :class="['row-status', e.mcpStatus]">{{ e.mcpStatus }}</span>
|
||||
<span class="row-server">{{ e.mcpServerName }}</span>
|
||||
<span class="row-tool">{{ e.mcpToolName }}</span>
|
||||
<span v-if="e.mcpElapsedMs != null" class="row-elapsed">{{ e.mcpElapsedMs }}ms</span>
|
||||
</template>
|
||||
|
||||
<!-- Generic -->
|
||||
<template v-else>
|
||||
<span class="row-icon">•</span>
|
||||
<span class="row-generic">{{ e.dataType }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-group {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.progress-group:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.progress-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.progress-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.toggle-pills {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Hook pills */
|
||||
.hook-pill {
|
||||
font-size: 9px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hook-pill.pretooluse {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.hook-pill.posttooluse {
|
||||
background: rgba(168, 85, 247, 0.12);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.hook-pill.sessionstart {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* MCP pills */
|
||||
.mcp-pill {
|
||||
font-size: 9px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mcp-pill.started {
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.mcp-pill.completed {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.mcp-ms {
|
||||
opacity: 0.8;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.generic-pill {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Expanded details */
|
||||
.progress-details {
|
||||
padding: 0.25rem 0.5rem 0.4rem 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding-left: 0.5rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.row-icon {
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-color { color: #fbbf24; }
|
||||
.mcp-color { color: #38bdf8; }
|
||||
|
||||
.row-event {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-event.pretooluse { color: #fbbf24; }
|
||||
.row-event.posttooluse { color: #a855f7; }
|
||||
.row-event.sessionstart { color: #22c55e; }
|
||||
|
||||
.row-name {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.row-command {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 9px;
|
||||
opacity: 0.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row-status {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-status.started { color: #38bdf8; }
|
||||
.row-status.completed { color: #22c55e; }
|
||||
|
||||
.row-server {
|
||||
font-size: 9px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
color: #38bdf8;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-tool {
|
||||
font-size: 9px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-elapsed {
|
||||
font-size: 9px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #22c55e;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.row-generic {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 9px;
|
||||
}
|
||||
</style>
|
||||
216
frontend/src/components/transcript-debug/RawJsonViewer.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
const selectedLine = ref<number | null>(null)
|
||||
|
||||
const lines = computed(() => {
|
||||
if (!props.content) return []
|
||||
return props.content.split('\n').filter(l => l.trim())
|
||||
})
|
||||
|
||||
function selectLine(idx: number) {
|
||||
selectedLine.value = selectedLine.value === idx ? null : idx
|
||||
}
|
||||
|
||||
function formatJson(line: string): string {
|
||||
try {
|
||||
const obj = JSON.parse(line)
|
||||
return JSON.stringify(obj, null, 2)
|
||||
} catch {
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
function getLinePreview(line: string): string {
|
||||
try {
|
||||
const obj = JSON.parse(line)
|
||||
const type = obj.type || 'unknown'
|
||||
if (type === 'assistant' && obj.message?.content) {
|
||||
const first = obj.message.content[0]
|
||||
const hint = first?.type || ''
|
||||
return `assistant [${hint}]`
|
||||
}
|
||||
if (type === 'user') {
|
||||
const c = obj.message?.content
|
||||
const preview = typeof c === 'string' ? c.slice(0, 40) : '[blocks]'
|
||||
return `user: ${preview}`
|
||||
}
|
||||
if (type === 'progress') {
|
||||
return `progress: ${obj.data?.type || '?'}`
|
||||
}
|
||||
return type
|
||||
} catch {
|
||||
return line.slice(0, 40)
|
||||
}
|
||||
}
|
||||
|
||||
function typeClass(line: string): string {
|
||||
try {
|
||||
const obj = JSON.parse(line)
|
||||
return `type-${obj.type || 'unknown'}`
|
||||
} catch {
|
||||
return 'type-unknown'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="raw-viewer">
|
||||
<div class="viewer-header">
|
||||
<span class="viewer-title">Raw JSONL</span>
|
||||
<span class="line-count">{{ lines.length }} lines</span>
|
||||
</div>
|
||||
<div class="lines-container">
|
||||
<div
|
||||
v-for="(line, idx) in lines"
|
||||
:key="idx"
|
||||
:class="['line-row', typeClass(line), { selected: selectedLine === idx }]"
|
||||
@click="selectLine(idx)"
|
||||
>
|
||||
<span class="line-num">{{ idx + 1 }}</span>
|
||||
<span class="line-preview">{{ getLinePreview(line) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expanded view for selected line -->
|
||||
<div v-if="selectedLine !== null && lines[selectedLine]" class="expanded-view">
|
||||
<div class="expanded-header">
|
||||
<span>Line {{ selectedLine + 1 }}</span>
|
||||
<button class="close-btn" @click="selectedLine = null">×</button>
|
||||
</div>
|
||||
<pre class="expanded-json">{{ formatJson(lines[selectedLine]) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.raw-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.viewer-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.line-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.lines-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.line-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.line-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.line-row.selected {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.line-num {
|
||||
color: var(--text-muted);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.line-preview {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Type-based colors */
|
||||
.type-user .line-preview { color: #60a5fa; }
|
||||
.type-assistant .line-preview { color: #34d399; }
|
||||
.type-progress .line-preview { color: var(--text-muted); opacity: 0.7; }
|
||||
.type-system .line-preview { color: #fbbf24; }
|
||||
.type-file-history-snapshot .line-preview { color: var(--text-muted); opacity: 0.5; }
|
||||
|
||||
.expanded-view {
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
max-height: 40%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expanded-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.expanded-json {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,542 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import type { AgentName } from '@/types/transcript-debug'
|
||||
import type { EphemeralTerminal } from '@/composables/useEphemeralTerminal'
|
||||
import TerminalNavButtons from '../TerminalNavButtons.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
agent: AgentName
|
||||
sessionId: string
|
||||
terminal: EphemeralTerminal | null
|
||||
}>()
|
||||
|
||||
const AGENT_CMD: Record<AgentName, string> = {
|
||||
ejecutor: 'ejecutor',
|
||||
nucleo000: 'nucleo000',
|
||||
claude: 'claude'
|
||||
}
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
// Local ref for xterm container - syncs to composable's containerRef
|
||||
const terminalContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// Nav buttons toggle
|
||||
const showNavButtons = ref(false)
|
||||
|
||||
// Drag state
|
||||
const isDragging = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const hasCustomPosition = ref(false)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
|
||||
// Resize state
|
||||
const isResizing = ref(false)
|
||||
const size = ref({ w: 620, h: 400 })
|
||||
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
|
||||
|
||||
const windowRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const statusDotClass = computed(() => {
|
||||
if (!props.terminal) return ''
|
||||
switch (props.terminal.state.value) {
|
||||
case 'running':
|
||||
case 'shell-ready': return 'on'
|
||||
case 'connecting': return 'wait'
|
||||
case 'exited': return 'error'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const terminalStyle = computed((): Record<string, string> => {
|
||||
if (!hasCustomPosition.value) {
|
||||
return {
|
||||
width: `${size.value.w}px`,
|
||||
height: `${size.value.h}px`,
|
||||
bottom: '80px',
|
||||
right: '16px'
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: `${size.value.w}px`,
|
||||
height: `${size.value.h}px`,
|
||||
top: `${position.value.y}px`,
|
||||
left: `${position.value.x}px`,
|
||||
bottom: 'auto',
|
||||
right: 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
// ── Open / Close ──
|
||||
|
||||
async function open() {
|
||||
if (isOpen.value) {
|
||||
closeTerminal()
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.terminal) return
|
||||
|
||||
isOpen.value = true
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Sync container ref
|
||||
if (terminalContainer.value) {
|
||||
props.terminal.containerRef.value = terminalContainer.value
|
||||
}
|
||||
|
||||
// Init renderer if needed
|
||||
if (!props.terminal.renderer.isReady.value) {
|
||||
props.terminal.renderer.init()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
props.terminal?.renderer.fit()
|
||||
props.terminal?.renderer.terminal.value?.refresh(0, (props.terminal.renderer.terminal.value?.rows ?? 1) - 1)
|
||||
if (window.innerWidth > 1024) {
|
||||
props.terminal?.renderer.focus()
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
function closeTerminal() {
|
||||
isOpen.value = false
|
||||
hasCustomPosition.value = false
|
||||
showNavButtons.value = false
|
||||
}
|
||||
|
||||
// ── Nav button actions ──
|
||||
|
||||
function navRunClaude() {
|
||||
props.terminal?.sendInput(AGENT_CMD[props.agent])
|
||||
}
|
||||
|
||||
function navRunClaudeContinue() {
|
||||
props.terminal?.sendInput(AGENT_CMD[props.agent] + ' --continue')
|
||||
}
|
||||
|
||||
function navRunClaudeResume() {
|
||||
props.terminal?.sendInput(AGENT_CMD[props.agent] + ' --resume')
|
||||
}
|
||||
|
||||
function navRefresh() {
|
||||
props.terminal?.renderer.fit()
|
||||
}
|
||||
|
||||
function navClearBuffer() {
|
||||
props.terminal?.renderer.reset()
|
||||
}
|
||||
|
||||
function navSendKey(key: string) {
|
||||
const keyMap: Record<string, string> = {
|
||||
'up': '\x1b[A', 'down': '\x1b[B', 'left': '\x1b[D', 'right': '\x1b[C',
|
||||
'alt-m': '\x1bm', 'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'
|
||||
}
|
||||
const data = keyMap[key]
|
||||
if (data) props.terminal?.renderer.terminal.value?.paste(data)
|
||||
}
|
||||
|
||||
function navScroll(direction: 'up' | 'down' | 'end') {
|
||||
if (!props.terminal) return
|
||||
if (direction === 'up') props.terminal.renderer.scrollLines(-10)
|
||||
else if (direction === 'down') props.terminal.renderer.scrollLines(10)
|
||||
else props.terminal.renderer.scrollToBottom()
|
||||
}
|
||||
|
||||
function toggleNavButtons() {
|
||||
showNavButtons.value = !showNavButtons.value
|
||||
}
|
||||
|
||||
// ── Drag ──
|
||||
|
||||
function startDrag(e: MouseEvent | TouchEvent) {
|
||||
if ((e.target as HTMLElement).closest('.window-controls')) return
|
||||
if (e instanceof TouchEvent) e.preventDefault()
|
||||
isDragging.value = true
|
||||
|
||||
const touch = e instanceof TouchEvent ? e.touches[0] : null
|
||||
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
|
||||
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
|
||||
|
||||
const rect = windowRef.value?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
if (!hasCustomPosition.value) {
|
||||
position.value = { x: rect.left, y: rect.top }
|
||||
}
|
||||
dragOffset.value = { x: clientX - rect.left, y: clientY - rect.top }
|
||||
}
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
document.addEventListener('touchmove', onDrag, { passive: false })
|
||||
document.addEventListener('touchend', stopDrag)
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent | TouchEvent) {
|
||||
if (!isDragging.value) return
|
||||
if (e instanceof TouchEvent) e.preventDefault()
|
||||
|
||||
const touch = e instanceof TouchEvent ? e.touches[0] : null
|
||||
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
|
||||
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
|
||||
|
||||
const w = windowRef.value?.offsetWidth || 620
|
||||
const h = windowRef.value?.offsetHeight || 400
|
||||
position.value = {
|
||||
x: Math.max(-w * 0.75, Math.min(clientX - dragOffset.value.x, window.innerWidth - w * 0.25)),
|
||||
y: Math.max(-h * 0.75, Math.min(clientY - dragOffset.value.y, window.innerHeight - h * 0.25))
|
||||
}
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
isDragging.value = false
|
||||
hasCustomPosition.value = true
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', stopDrag)
|
||||
}
|
||||
|
||||
// ── Resize ──
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isResizing.value = true
|
||||
resizeStart.value = { x: e.clientX, y: e.clientY, w: size.value.w, h: size.value.h }
|
||||
document.addEventListener('mousemove', onResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
function onResize(e: MouseEvent) {
|
||||
if (!isResizing.value) return
|
||||
size.value = {
|
||||
w: Math.max(400, Math.min(resizeStart.value.w + e.clientX - resizeStart.value.x, window.innerWidth - 40)),
|
||||
h: Math.max(250, Math.min(resizeStart.value.h + e.clientY - resizeStart.value.y, window.innerHeight - 40))
|
||||
}
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
isResizing.value = false
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
nextTick(() => props.terminal?.renderer.fit())
|
||||
}
|
||||
|
||||
// Sync container ref when it mounts
|
||||
watch(terminalContainer, (el) => {
|
||||
if (props.terminal && el) {
|
||||
props.terminal.containerRef.value = el
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', stopDrag)
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Inline button -->
|
||||
<button class="resume-terminal-btn" @click.stop="open" title="Open terminal (resume session)">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Floating terminal modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="rt-slide">
|
||||
<div
|
||||
v-show="isOpen"
|
||||
ref="windowRef"
|
||||
class="resume-terminal"
|
||||
:class="{ dragging: isDragging, resizing: isResizing }"
|
||||
:style="terminalStyle"
|
||||
>
|
||||
<div class="rt-glass">
|
||||
<!-- Titlebar -->
|
||||
<div class="rt-titlebar" @mousedown="startDrag" @touchstart="startDrag">
|
||||
<div class="rt-left">
|
||||
<div class="rt-badge">
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="rt-name">{{ AGENT_CMD[agent] }}</span>
|
||||
<span class="rt-session">{{ sessionId.slice(0, 8) }}…</span>
|
||||
<i class="rt-dot" :class="statusDotClass"></i>
|
||||
<span v-if="terminal?.state.value === 'connecting'" class="rt-status-text">Connecting...</span>
|
||||
<span v-else-if="terminal?.state.value === 'shell-ready'" class="rt-status-text">Starting...</span>
|
||||
<span v-else-if="terminal?.state.value === 'exited'" class="rt-status-text exited">Exited</span>
|
||||
</div>
|
||||
<div class="window-controls">
|
||||
<button
|
||||
class="wc-btn nav-toggle"
|
||||
:class="{ active: showNavButtons }"
|
||||
title="Toggle navigation"
|
||||
@click.stop="toggleNavButtons"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
<button class="wc-btn x" title="Close" @click.stop="closeTerminal">
|
||||
<svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<div class="rt-content">
|
||||
<div ref="terminalContainer" class="rt-term"></div>
|
||||
|
||||
<!-- Overlay: connecting -->
|
||||
<div v-if="terminal?.state.value === 'connecting'" class="rt-overlay connecting">
|
||||
<div class="rt-overlay-msg">
|
||||
<div class="rt-spinner"></div>
|
||||
<span>Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle -->
|
||||
<div class="rt-resize" @mousedown="startResize"></div>
|
||||
</div>
|
||||
|
||||
<!-- Nav buttons bar (outside glass, hangs from bottom) -->
|
||||
<TerminalNavButtons
|
||||
v-if="showNavButtons"
|
||||
class="rt-nav-popup"
|
||||
@request-token="terminal?.sendInput('genera token usando tu mcp')"
|
||||
@run-claude="navRunClaude"
|
||||
@run-claude-continue="navRunClaudeContinue"
|
||||
@run-claude-resume="navRunClaudeResume"
|
||||
@clear-buffer="navClearBuffer"
|
||||
@refresh="navRefresh"
|
||||
@send-key="navSendKey"
|
||||
@scroll="navScroll"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Inline button ── */
|
||||
.resume-terminal-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.resume-terminal-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
/* ── Floating terminal ── */
|
||||
.resume-terminal {
|
||||
position: fixed;
|
||||
min-width: 400px;
|
||||
min-height: 250px;
|
||||
z-index: 10002;
|
||||
}
|
||||
|
||||
.rt-glass {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(200, 215, 235, 0.35);
|
||||
backdrop-filter: blur(24px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.6);
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 0 0 1px rgba(80, 120, 180, 0.25), 0 6px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rt-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 24px;
|
||||
padding: 0 4px 0 6px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.resume-terminal.dragging .rt-titlebar { cursor: grabbing; }
|
||||
|
||||
.rt-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font: 500 10px/1 system-ui, sans-serif;
|
||||
color: #222;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rt-badge {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rt-name {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rt-session {
|
||||
font-size: 9px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #666;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.rt-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #999;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rt-dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
|
||||
.rt-dot.wait { background: #a80; animation: rt-pulse 0.8s infinite; }
|
||||
.rt-dot.error { background: #e44; box-shadow: 0 0 4px #e44; }
|
||||
|
||||
.rt-status-text {
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
.rt-status-text.exited { color: #c33; }
|
||||
|
||||
.window-controls { display: flex; gap: 1px; }
|
||||
.wc-btn {
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wc-btn:hover { background: rgba(255, 255, 255, 0.5); }
|
||||
.wc-btn.x:hover { background: linear-gradient(180deg, #e66 0%, #c33 100%); border-color: #a22; color: #fff; }
|
||||
.wc-btn.nav-toggle { width: 44px; }
|
||||
.wc-btn.nav-toggle.active { background: rgba(99, 102, 241, 0.3); border-color: rgba(99, 102, 241, 0.4); color: #818cf8; }
|
||||
.wc-btn.nav-toggle:hover { background: rgba(99, 102, 241, 0.2); color: #818cf8; }
|
||||
|
||||
.rt-content {
|
||||
flex: 1;
|
||||
margin: 2px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: rgba(12, 12, 12, 0.95);
|
||||
}
|
||||
|
||||
.rt-term { width: 100%; height: 100%; }
|
||||
.rt-term :deep(.xterm) { height: 100%; padding: 2px; }
|
||||
.rt-term :deep(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.rt-term :deep(.xterm-viewport::-webkit-scrollbar) { width: 8px; background: rgba(0, 0, 0, 0.2); }
|
||||
.rt-term :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
|
||||
|
||||
.rt-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.rt-overlay.connecting { cursor: wait; }
|
||||
|
||||
.rt-overlay-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
.rt-overlay-msg span { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.rt-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: rt-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.rt-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.3) 50%, rgba(255, 255, 255, 0.1) 100%);
|
||||
border-radius: 0 0 5px 0;
|
||||
}
|
||||
.rt-resize:hover { background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0.2) 100%); }
|
||||
|
||||
.resume-terminal.resizing { user-select: none; }
|
||||
.resume-terminal.resizing .rt-term { pointer-events: none; }
|
||||
|
||||
/* Nav buttons popup */
|
||||
.rt-nav-popup {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
border-radius: 0 0 6px 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: none;
|
||||
backdrop-filter: blur(14px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rt-slide-enter-active, .rt-slide-leave-active { transition: all 0.15s ease; }
|
||||
.rt-slide-enter-from, .rt-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
|
||||
|
||||
@keyframes rt-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
@keyframes rt-spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
@@ -0,0 +1,528 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRef, onMounted, onUnmounted } from 'vue'
|
||||
import type { AgentStatus, ActiveTool } from '@/stores/session-state'
|
||||
import { useLifecycleStates, type ContinuousState } from '@/composables/useLifecycleStates'
|
||||
|
||||
// ── Event display config (for badges) ──
|
||||
|
||||
type LifecycleEvent =
|
||||
| 'SessionStart' | 'UserPromptSubmit'
|
||||
| 'PreToolUse' | 'PermissionRequest' | 'PostToolUse' | 'PostToolUseFailure'
|
||||
| 'Notification' | 'SubagentStart' | 'SubagentStop'
|
||||
| 'Stop' | 'TeammateIdle' | 'TaskCompleted'
|
||||
| 'ConfigChange' | 'PreCompact' | 'SessionEnd'
|
||||
|
||||
type Category = 'session' | 'user' | 'tool' | 'agent' | 'system'
|
||||
|
||||
interface LifecycleInfo {
|
||||
color: string
|
||||
label: string
|
||||
category: Category
|
||||
}
|
||||
|
||||
const LIFECYCLE_DISPLAY: Record<LifecycleEvent, LifecycleInfo> = {
|
||||
SessionStart: { color: '#60a5fa', label: 'Session started', category: 'session' },
|
||||
UserPromptSubmit: { color: '#a78bfa', label: 'Prompt submitted', category: 'user' },
|
||||
PreToolUse: { color: '#fbbf24', label: 'Tool starting', category: 'tool' },
|
||||
PermissionRequest: { color: '#fb923c', label: 'Permission required', category: 'tool' },
|
||||
PostToolUse: { color: '#4ade80', label: 'Tool completed', category: 'tool' },
|
||||
PostToolUseFailure: { color: '#f87171', label: 'Tool failed', category: 'tool' },
|
||||
Notification: { color: '#38bdf8', label: 'Notification', category: 'system' },
|
||||
SubagentStart: { color: '#c084fc', label: 'Subagent spawned', category: 'agent' },
|
||||
SubagentStop: { color: '#a855f7', label: 'Subagent finished', category: 'agent' },
|
||||
Stop: { color: '#22d3ee', label: 'Response complete', category: 'session' },
|
||||
TeammateIdle: { color: '#94a3b8', label: 'Teammate idle', category: 'agent' },
|
||||
TaskCompleted: { color: '#34d399', label: 'Task completed', category: 'system' },
|
||||
ConfigChange: { color: '#e879f9', label: 'Config changed', category: 'system' },
|
||||
PreCompact: { color: '#f59e0b', label: 'Compacting context', category: 'system' },
|
||||
SessionEnd: { color: '#6b7280', label: 'Session ended', category: 'session' },
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: Record<Category, number> = {
|
||||
session: 0, user: 1, tool: 2, agent: 3, system: 4
|
||||
}
|
||||
|
||||
interface HookHistoryEntry {
|
||||
event: string
|
||||
timestamp: number
|
||||
detail?: string
|
||||
}
|
||||
|
||||
interface EventCountEntry {
|
||||
event: LifecycleEvent
|
||||
count: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
|
||||
const props = defineProps<{
|
||||
currentEvent?: string | null
|
||||
eventDetail?: string
|
||||
hookHistory?: HookHistoryEntry[]
|
||||
// Server-derived continuous state
|
||||
sessionActive: boolean
|
||||
agentResponding: boolean
|
||||
subagentActive: boolean
|
||||
compacting: boolean
|
||||
agentStatus: AgentStatus
|
||||
currentTool: ActiveTool | null
|
||||
lastActivity: number
|
||||
}>()
|
||||
|
||||
// ── Continuous states (from server) ──
|
||||
|
||||
const { activeStates, isActive } = useLifecycleStates({
|
||||
sessionActive: toRef(props, 'sessionActive'),
|
||||
agentResponding: toRef(props, 'agentResponding'),
|
||||
subagentActive: toRef(props, 'subagentActive'),
|
||||
compacting: toRef(props, 'compacting'),
|
||||
status: toRef(props, 'agentStatus'),
|
||||
currentTool: toRef(props, 'currentTool'),
|
||||
lastActivity: toRef(props, 'lastActivity'),
|
||||
})
|
||||
|
||||
// ── Elapsed time ──
|
||||
|
||||
const now = ref(Date.now())
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
elapsedTimer = setInterval(() => { now.value = Date.now() }, 1000)
|
||||
})
|
||||
onUnmounted(() => { if (elapsedTimer) clearInterval(elapsedTimer) })
|
||||
|
||||
function hasElapsed(state: ContinuousState): boolean {
|
||||
if (state.type !== 'responding' && state.type !== 'tool') return false
|
||||
return (now.value - state.startedAt) >= 2000
|
||||
}
|
||||
|
||||
function getElapsed(state: ContinuousState): string {
|
||||
const ms = now.value - state.startedAt
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
const rs = s % 60
|
||||
return rs > 0 ? `${m}m ${rs}s` : `${m}m`
|
||||
}
|
||||
|
||||
// Session chip compacts to dot-only when other states are active
|
||||
const sessionCompact = computed(() =>
|
||||
activeStates.value.length > 1 &&
|
||||
activeStates.value.some(s => s.type === 'session')
|
||||
)
|
||||
|
||||
// ── Fallback: last event label (when no continuous states) ──
|
||||
|
||||
const hasEvent = computed(() => !!props.currentEvent && props.currentEvent in LIFECYCLE_DISPLAY)
|
||||
const activeEvent = computed<LifecycleEvent | null>(() => hasEvent.value ? props.currentEvent as LifecycleEvent : null)
|
||||
const activeDetail = computed(() => props.eventDetail || '')
|
||||
const displayInfo = computed(() => activeEvent.value ? LIFECYCLE_DISPLAY[activeEvent.value] : null)
|
||||
const fallbackText = computed(() => {
|
||||
if (!displayInfo.value) return ''
|
||||
const label = displayInfo.value.label
|
||||
const detail = activeDetail.value
|
||||
return detail ? `${label} — ${detail}` : label
|
||||
})
|
||||
|
||||
// ── Badge counts ──
|
||||
|
||||
function countEvents(entries: { event: string }[]): EventCountEntry[] {
|
||||
const counts = new Map<string, number>()
|
||||
for (const entry of entries) {
|
||||
counts.set(entry.event, (counts.get(entry.event) || 0) + 1)
|
||||
}
|
||||
|
||||
const result: EventCountEntry[] = []
|
||||
for (const [event, count] of counts) {
|
||||
if (event in LIFECYCLE_DISPLAY) {
|
||||
result.push({
|
||||
event: event as LifecycleEvent,
|
||||
count,
|
||||
color: LIFECYCLE_DISPLAY[event as LifecycleEvent].color,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
const ca = LIFECYCLE_DISPLAY[a.event].category
|
||||
const cb = LIFECYCLE_DISPLAY[b.event].category
|
||||
if (CATEGORY_ORDER[ca] !== CATEGORY_ORDER[cb]) {
|
||||
return CATEGORY_ORDER[ca] - CATEGORY_ORDER[cb]
|
||||
}
|
||||
return a.event.localeCompare(b.event)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const displayCounts = computed(() => countEvents(props.hookHistory || []))
|
||||
|
||||
// Show the ribbon if there are active continuous states OR a fallback event
|
||||
const showRibbon = computed(() => isActive.value || !!displayInfo.value)
|
||||
|
||||
// Border color follows the highest-priority active state or fallback
|
||||
const ribbonBorderColor = computed(() => {
|
||||
if (activeStates.value.length > 0) {
|
||||
const last = activeStates.value[activeStates.value.length - 1]
|
||||
return last?.color || 'rgba(255, 255, 255, 0.04)'
|
||||
}
|
||||
return displayInfo.value?.color || 'rgba(255, 255, 255, 0.04)'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showRibbon"
|
||||
class="lifecycle-ribbon"
|
||||
:style="{ borderTopColor: ribbonBorderColor + '26' }"
|
||||
>
|
||||
<!-- Badge counts -->
|
||||
<div class="lc-badges" v-if="displayCounts.length > 0">
|
||||
<TransitionGroup name="badge">
|
||||
<span
|
||||
v-for="entry in displayCounts"
|
||||
:key="entry.event"
|
||||
class="lc-badge"
|
||||
:style="{
|
||||
background: entry.color + '1a',
|
||||
color: entry.color,
|
||||
borderColor: entry.color + '33',
|
||||
}"
|
||||
:title="entry.event + ': ' + entry.count"
|
||||
>{{ entry.count }}</span>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<span
|
||||
v-if="displayCounts.length > 0 && (activeStates.length > 0 || displayInfo)"
|
||||
class="lc-sep"
|
||||
>|</span>
|
||||
|
||||
<!-- Active continuous state chips -->
|
||||
<div v-if="activeStates.length > 0" class="lc-states">
|
||||
<TransitionGroup name="chip">
|
||||
<span
|
||||
v-for="state in activeStates"
|
||||
:key="state.type"
|
||||
class="lc-chip"
|
||||
:style="{ '--chip-color': state.color } as any"
|
||||
:title="state.label + (state.detail ? ' — ' + state.detail : '')"
|
||||
>
|
||||
<!-- Animated indicator -->
|
||||
<span class="chip-indicator" :class="'anim-' + state.type">
|
||||
<template v-if="state.type === 'responding'">
|
||||
<span class="dot dot-1"></span>
|
||||
<span class="dot dot-2"></span>
|
||||
<span class="dot dot-3"></span>
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<!-- Label (hidden for session when compact) -->
|
||||
<span
|
||||
v-if="!(state.type === 'session' && sessionCompact)"
|
||||
class="chip-label"
|
||||
>{{ state.label }}</span>
|
||||
|
||||
<!-- Elapsed time -->
|
||||
<span v-if="hasElapsed(state)" class="chip-elapsed">{{ getElapsed(state) }}</span>
|
||||
</span>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- Fallback: show last event text when no continuous states -->
|
||||
<Transition v-else name="lc" mode="out-in">
|
||||
<span
|
||||
v-if="displayInfo"
|
||||
class="lc-label"
|
||||
:key="(activeEvent || '') + activeDetail"
|
||||
:style="{ color: displayInfo.color }"
|
||||
>{{ fallbackText }}</span>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lifecycle-ribbon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
transition: border-top-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Badges ── */
|
||||
|
||||
.lc-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
padding: 0 2px;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-enter-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.badge-leave-active { transition: opacity 0.15s ease; }
|
||||
.badge-enter-from { opacity: 0; transform: scale(0.7); }
|
||||
.badge-leave-to { opacity: 0; }
|
||||
.badge-move { transition: transform 0.2s ease; }
|
||||
|
||||
/* ── Separator ── */
|
||||
|
||||
.lc-sep {
|
||||
font-size: 8px;
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
margin: 0 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Continuous state chips ── */
|
||||
|
||||
.lc-states {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 5px 1px 3px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--chip-color) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chip-color) 20%, transparent);
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--chip-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chip-elapsed {
|
||||
font-size: 8px;
|
||||
font-weight: 400;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--chip-color);
|
||||
opacity: 0.55;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.chip-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Chip transitions ── */
|
||||
|
||||
.chip-enter-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chip-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.chip-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translateY(2px);
|
||||
}
|
||||
|
||||
.chip-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.chip-move {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
Per-state animations
|
||||
══════════════════════════════════════════ */
|
||||
|
||||
/* ── Session: breathing dot ── */
|
||||
|
||||
.anim-session {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
animation: breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ── Responding: typing dots ── */
|
||||
|
||||
.anim-responding {
|
||||
display: inline-flex;
|
||||
gap: 1.5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.anim-responding .dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
animation: typing-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.anim-responding .dot-2 { animation-delay: 0.2s; }
|
||||
.anim-responding .dot-3 { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typing-dot {
|
||||
0%, 60%, 100% { opacity: 0.2; transform: scale(0.75); }
|
||||
30% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
/* ── Tool: shimmer bar ── */
|
||||
|
||||
.anim-tool {
|
||||
width: 14px;
|
||||
height: 3px;
|
||||
border-radius: 1.5px;
|
||||
background: color-mix(in srgb, var(--chip-color) 25%, transparent);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.anim-tool::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--chip-color);
|
||||
border-radius: 1.5px;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { left: -100%; }
|
||||
50% { left: 100%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
/* ── Subagent: orbiting dot ── */
|
||||
|
||||
.anim-subagent {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.anim-subagent::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
opacity: 0.25;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
.anim-subagent::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
top: 0;
|
||||
left: 2.5px;
|
||||
animation: orbit 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes orbit {
|
||||
0% { top: 0px; left: 2.5px; }
|
||||
25% { top: 2.5px; left: 5px; }
|
||||
50% { top: 5px; left: 2.5px; }
|
||||
75% { top: 2.5px; left: 0px; }
|
||||
100% { top: 0px; left: 2.5px; }
|
||||
}
|
||||
|
||||
/* ── Permission: urgent blink ── */
|
||||
|
||||
.anim-permission {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
animation: urgent-blink 0.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes urgent-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.12; }
|
||||
}
|
||||
|
||||
/* ── Compacting: horizontal squeeze ── */
|
||||
|
||||
.anim-compacting {
|
||||
width: 8px;
|
||||
height: 5px;
|
||||
background: var(--chip-color);
|
||||
border-radius: 1px;
|
||||
animation: squeeze 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes squeeze {
|
||||
0%, 100% { transform: scaleX(1); opacity: 0.6; }
|
||||
50% { transform: scaleX(0.45); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Fallback text label ── */
|
||||
|
||||
.lc-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Fallback label transitions */
|
||||
.lc-enter-active { transition: opacity 0.15s ease, transform 0.15s ease; }
|
||||
.lc-leave-active { transition: opacity 0.1s ease; }
|
||||
.lc-enter-from { opacity: 0; transform: translateY(4px); }
|
||||
.lc-leave-to { opacity: 0; }
|
||||
</style>
|
||||
110
frontend/src/components/transcript-debug/SessionSelector.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { SessionInfo } from '@/types/transcript-debug'
|
||||
|
||||
defineProps<{
|
||||
sessions: SessionInfo[]
|
||||
selectedId: string | null
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [sessionId: string]
|
||||
}>()
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
if (diff < 60000) return 'just now'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
||||
if (diff < 86400000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text
|
||||
return text.slice(0, max) + '...'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="session-selector">
|
||||
<label class="selector-label">Session</label>
|
||||
<select
|
||||
class="session-select"
|
||||
:value="selectedId || ''"
|
||||
@change="emit('select', ($event.target as HTMLSelectElement).value)"
|
||||
:disabled="loading"
|
||||
>
|
||||
<option value="" disabled>Select a transcript session...</option>
|
||||
<option
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
:value="s.id"
|
||||
>
|
||||
{{ s.firstUserMessage ? truncate(s.firstUserMessage, 60) : s.id.slice(0, 8) + '...' }} — {{ formatDate(s.mtimeISO) }} ({{ formatSize(s.size) }})
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="loading" class="loading-indicator">
|
||||
<span class="spinner-sm"></span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.session-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selector-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
221
frontend/src/components/transcript-debug/SystemMessage.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ParsedSystemMessage } from '@/types/transcript-debug'
|
||||
|
||||
const props = defineProps<{
|
||||
message: ParsedSystemMessage
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
const hasContent = computed(() => !!props.message.content?.trim())
|
||||
|
||||
// ── Subtype display config ──
|
||||
|
||||
interface SubtypeDisplay {
|
||||
label: string
|
||||
color: string
|
||||
icon: string // SVG path(s) for the icon
|
||||
iconFill?: boolean // Whether inner elements use fill instead of stroke
|
||||
}
|
||||
|
||||
const SUBTYPE_MAP: Record<string, SubtypeDisplay> = {
|
||||
api_error: {
|
||||
label: 'API error',
|
||||
color: '#ef4444',
|
||||
// X inside circle
|
||||
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm3.5 12.5L13 12l2.5-2.5m-7 0L11 12l-2.5 2.5',
|
||||
},
|
||||
rate_limit: {
|
||||
label: 'Rate limited',
|
||||
color: '#f59e0b',
|
||||
// Clock
|
||||
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 5v5l3 3',
|
||||
},
|
||||
overloaded: {
|
||||
label: 'Overloaded',
|
||||
color: '#f59e0b',
|
||||
// Warning triangle
|
||||
icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4m0 4h.01',
|
||||
},
|
||||
init: {
|
||||
label: 'Init',
|
||||
color: '#60a5fa',
|
||||
// Power on
|
||||
icon: 'M12 2v6m-6.36.64A9 9 0 1 0 18.36 8.64',
|
||||
},
|
||||
config: {
|
||||
label: 'Config',
|
||||
color: '#a78bfa',
|
||||
// Settings gear
|
||||
icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z',
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_DISPLAY: SubtypeDisplay = {
|
||||
label: 'System',
|
||||
color: '#fbbf24',
|
||||
// Info circle
|
||||
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 5v1m0 4v4',
|
||||
}
|
||||
|
||||
const display = computed(() => {
|
||||
const sub = props.message.subtype
|
||||
if (sub && SUBTYPE_MAP[sub]) return SUBTYPE_MAP[sub]
|
||||
return DEFAULT_DISPLAY
|
||||
})
|
||||
|
||||
const previewText = computed(() => {
|
||||
const c = props.message.content?.trim() || ''
|
||||
if (!c) return ''
|
||||
// Single line preview
|
||||
const line = c.split('\n')[0]
|
||||
return line.length > 100 ? line.slice(0, 100) + '...' : line
|
||||
})
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sys-row-wrapper">
|
||||
<button
|
||||
class="sys-row"
|
||||
:class="{ expandable: hasContent, expanded }"
|
||||
@click="hasContent && (expanded = !expanded)"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<span class="sys-icon" :style="{ color: display.color }">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path :d="display.icon" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<!-- Label -->
|
||||
<span class="sys-label" :style="{ color: display.color }">{{ display.label }}</span>
|
||||
|
||||
<!-- Subtype badge (if different from label) -->
|
||||
<span
|
||||
v-if="message.subtype && !SUBTYPE_MAP[message.subtype]"
|
||||
class="sys-subtype"
|
||||
:style="{ color: display.color, background: display.color + '1a' }"
|
||||
>{{ message.subtype }}</span>
|
||||
|
||||
<!-- Preview -->
|
||||
<span v-if="previewText" class="sys-preview">{{ previewText }}</span>
|
||||
|
||||
<!-- Expand indicator -->
|
||||
<span v-if="hasContent" class="sys-expand-hint">
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline :points="expanded ? '18 15 12 9 6 15' : '6 9 12 15 18 9'" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<span class="sys-time">{{ formatTime(message.timestamp) }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Expanded content -->
|
||||
<pre v-if="expanded && hasContent" class="sys-content" :style="{ borderColor: display.color + '33' }">{{ message.content }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sys-row-wrapper {
|
||||
margin: 0.1rem 0;
|
||||
}
|
||||
|
||||
.sys-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sys-row.expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sys-row:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.sys-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sys-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sys-subtype {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sys-preview {
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sys-expand-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.sys-time {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Expanded content ── */
|
||||
|
||||
.sys-content {
|
||||
margin: 0 0.5rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border-left: 2px solid;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 0 0 4px 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
202
frontend/src/components/transcript-debug/TerminalFabStack.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import type { TerminalSlot } from '@/types/transcript-debug'
|
||||
|
||||
defineProps<{
|
||||
terminals: TerminalSlot[]
|
||||
activeSessionId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [sessionId: string]
|
||||
'create-session': []
|
||||
}>()
|
||||
|
||||
function stateColor(t: TerminalSlot): string {
|
||||
if (!t.alive) return '#ef4444'
|
||||
if (t.clients > 0) return '#22c55e'
|
||||
return '#f59e0b'
|
||||
}
|
||||
|
||||
// Pixel art SVG backgrounds for terminals 2-5
|
||||
const artVariants = [
|
||||
// T2: Coral reef — orange/pink corals, warm deep water
|
||||
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='6' fill='%230c1a2e'/%3E%3Crect y='6' width='44' height='4' fill='%230e2040'/%3E%3Crect y='10' width='44' height='4' fill='%23122848'/%3E%3Crect y='14' width='44' height='4' fill='%23152e50'/%3E%3Crect y='18' width='44' height='4' fill='%23183458'/%3E%3Crect y='22' width='44' height='4' fill='%231a3860'/%3E%3Crect y='26' width='44' height='4' fill='%231c3c68'/%3E%3Crect y='30' width='44' height='4' fill='%231e3e6a'/%3E%3Crect y='34' width='44' height='10' fill='%232a1e10'/%3E%3Crect y='38' width='44' height='6' fill='%23342414'/%3E%3Crect x='6' y='26' width='3' height='8' fill='%23e85d2a' opacity='0.8'/%3E%3Crect x='5' y='24' width='2' height='3' fill='%23f07030' opacity='0.7'/%3E%3Crect x='8' y='23' width='2' height='4' fill='%23f07030' opacity='0.65'/%3E%3Crect x='4' y='22' width='1' height='3' fill='%23ff8844' opacity='0.5'/%3E%3Crect x='10' y='25' width='1' height='2' fill='%23ff8844' opacity='0.45'/%3E%3Crect x='18' y='28' width='4' height='6' fill='%23d4467a' opacity='0.8'/%3E%3Crect x='17' y='26' width='2' height='3' fill='%23e0558a' opacity='0.65'/%3E%3Crect x='21' y='25' width='2' height='4' fill='%23e0558a' opacity='0.6'/%3E%3Crect x='19' y='24' width='1' height='3' fill='%23f06898' opacity='0.45'/%3E%3Crect x='30' y='27' width='3' height='7' fill='%23c83030' opacity='0.75'/%3E%3Crect x='29' y='25' width='2' height='3' fill='%23d84040' opacity='0.6'/%3E%3Crect x='32' y='24' width='2' height='4' fill='%23d84040' opacity='0.55'/%3E%3Crect x='34' y='26' width='1' height='2' fill='%23e85050' opacity='0.45'/%3E%3Crect x='38' y='30' width='2' height='4' fill='%23e8752a' opacity='0.5'/%3E%3Crect x='37' y='28' width='1' height='3' fill='%23f08838' opacity='0.4'/%3E%3Crect x='12' y='16' width='1' height='1' fill='%2388ccff' opacity='0.3'/%3E%3Crect x='25' y='12' width='1' height='1' fill='%2388ccff' opacity='0.25'/%3E%3Crect x='36' y='8' width='1' height='1' fill='%2388ccff' opacity='0.2'/%3E%3Crect x='14' y='14' width='3' height='1' fill='%23ffaa44' opacity='0.4'/%3E%3Crect x='13' y='15' width='1' height='1' fill='%23ffaa44' opacity='0.3'/%3E%3Crect x='8' y='36' width='2' height='1' fill='%234a3820' opacity='0.4'/%3E%3Crect x='24' y='38' width='3' height='1' fill='%234a3820' opacity='0.3'/%3E%3Crect x='15' y='40' width='2' height='1' fill='%23c8a860' opacity='0.15'/%3E%3Crect x='36' y='41' width='2' height='1' fill='%23c8a860' opacity='0.12'/%3E%3C/svg%3E")`,
|
||||
|
||||
// T3: Deep sea — very dark, bioluminescent green/cyan dots
|
||||
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='44' fill='%23020810'/%3E%3Crect y='4' width='44' height='4' fill='%23030a14'/%3E%3Crect y='8' width='44' height='4' fill='%23040c18'/%3E%3Crect y='12' width='44' height='4' fill='%23050e1c'/%3E%3Crect y='16' width='44' height='4' fill='%23061020'/%3E%3Crect y='20' width='44' height='4' fill='%23051018'/%3E%3Crect y='24' width='44' height='4' fill='%23040e16'/%3E%3Crect y='28' width='44' height='4' fill='%23030c14'/%3E%3Crect y='32' width='44' height='4' fill='%23030a12'/%3E%3Crect y='36' width='44' height='8' fill='%23020810'/%3E%3Crect x='8' y='10' width='2' height='2' fill='%2300ffaa' opacity='0.5'/%3E%3Crect x='7' y='9' width='1' height='1' fill='%2300ffaa' opacity='0.2'/%3E%3Crect x='10' y='11' width='1' height='1' fill='%2300ffaa' opacity='0.15'/%3E%3Crect x='28' y='6' width='2' height='2' fill='%2300e4ff' opacity='0.45'/%3E%3Crect x='27' y='5' width='1' height='1' fill='%2300e4ff' opacity='0.15'/%3E%3Crect x='30' y='7' width='1' height='1' fill='%2300e4ff' opacity='0.12'/%3E%3Crect x='16' y='20' width='2' height='2' fill='%2340ff90' opacity='0.4'/%3E%3Crect x='15' y='19' width='1' height='1' fill='%2340ff90' opacity='0.15'/%3E%3Crect x='36' y='16' width='2' height='2' fill='%2300ffcc' opacity='0.35'/%3E%3Crect x='35' y='15' width='1' height='1' fill='%2300ffcc' opacity='0.12'/%3E%3Crect x='4' y='28' width='2' height='2' fill='%2300e4ff' opacity='0.3'/%3E%3Crect x='22' y='32' width='2' height='2' fill='%2340ff90' opacity='0.35'/%3E%3Crect x='38' y='26' width='2' height='2' fill='%2300ffaa' opacity='0.3'/%3E%3Crect x='12' y='36' width='1' height='1' fill='%2300e4ff' opacity='0.2'/%3E%3Crect x='32' y='38' width='1' height='1' fill='%2340ff90' opacity='0.2'/%3E%3Crect x='20' y='14' width='1' height='1' fill='%2300ffcc' opacity='0.15'/%3E%3Crect x='40' y='34' width='1' height='1' fill='%2300ffaa' opacity='0.15'/%3E%3Crect x='2' y='18' width='1' height='1' fill='%2340ff90' opacity='0.12'/%3E%3Crect x='18' y='38' width='6' height='3' fill='%23081828' opacity='0.5'/%3E%3Crect x='19' y='36' width='3' height='2' fill='%230a1c30' opacity='0.4'/%3E%3Crect x='6' y='40' width='4' height='2' fill='%23081828' opacity='0.4'/%3E%3Crect x='34' y='40' width='5' height='2' fill='%23081828' opacity='0.35'/%3E%3C/svg%3E")`,
|
||||
|
||||
// T4: Tropical lagoon — turquoise water, golden sand
|
||||
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='6' fill='%23104858'/%3E%3Crect y='6' width='44' height='4' fill='%23148898'/%3E%3Crect y='10' width='44' height='4' fill='%231898a8'/%3E%3Crect y='14' width='44' height='4' fill='%231ca8b8'/%3E%3Crect y='18' width='44' height='4' fill='%2320b8c8'/%3E%3Crect y='22' width='44' height='4' fill='%2318a0b0'/%3E%3Crect y='26' width='44' height='4' fill='%231490a0'/%3E%3Crect y='30' width='44' height='4' fill='%23108090'/%3E%3Crect y='34' width='44' height='4' fill='%23c8a850'/%3E%3Crect y='38' width='44' height='6' fill='%23d4b460'/%3E%3Crect x='2' y='2' width='1' height='1' fill='%23e0f0ff' opacity='0.4'/%3E%3Crect x='12' y='3' width='1' height='1' fill='%23e0f0ff' opacity='0.3'/%3E%3Crect x='30' y='1' width='1' height='1' fill='%23e0f0ff' opacity='0.35'/%3E%3Crect x='4' y='8' width='6' height='2' fill='%2340d8e8' opacity='0.25'/%3E%3Crect x='16' y='9' width='8' height='2' fill='%2340d8e8' opacity='0.2'/%3E%3Crect x='30' y='8' width='6' height='2' fill='%2340d8e8' opacity='0.25'/%3E%3Crect x='8' y='16' width='4' height='1' fill='%23e0f8ff' opacity='0.15'/%3E%3Crect x='24' y='18' width='5' height='1' fill='%23e0f8ff' opacity='0.12'/%3E%3Crect x='6' y='24' width='3' height='1' fill='%23e0f8ff' opacity='0.1'/%3E%3Crect x='14' y='28' width='2' height='2' fill='%2388ddaa' opacity='0.25'/%3E%3Crect x='32' y='26' width='3' height='2' fill='%2388ddaa' opacity='0.2'/%3E%3Crect x='22' y='22' width='2' height='1' fill='%23ffcc44' opacity='0.2'/%3E%3Crect x='10' y='36' width='2' height='1' fill='%23b89840' opacity='0.4'/%3E%3Crect x='26' y='38' width='3' height='1' fill='%23b89840' opacity='0.35'/%3E%3Crect x='18' y='40' width='2' height='1' fill='%23e0c870' opacity='0.3'/%3E%3Crect x='36' y='36' width='2' height='1' fill='%23b89840' opacity='0.3'/%3E%3Crect x='4' y='34' width='3' height='2' fill='%2388ddaa' opacity='0.15'/%3E%3Crect x='38' y='34' width='2' height='2' fill='%2390cc88' opacity='0.12'/%3E%3Crect x='6' y='42' width='1' height='1' fill='%23e8d088' opacity='0.2'/%3E%3Crect x='34' y='42' width='1' height='1' fill='%23e8d088' opacity='0.18'/%3E%3C/svg%3E")`,
|
||||
|
||||
// T5: Arctic — ice blue, white icebergs
|
||||
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='6' fill='%230a1828'/%3E%3Crect y='6' width='44' height='4' fill='%23102030'/%3E%3Crect y='10' width='44' height='4' fill='%23142838'/%3E%3Crect y='14' width='44' height='4' fill='%23183040'/%3E%3Crect y='18' width='44' height='4' fill='%231c3848'/%3E%3Crect y='22' width='44' height='4' fill='%23203e50'/%3E%3Crect y='26' width='44' height='4' fill='%23244458'/%3E%3Crect y='30' width='44' height='4' fill='%23284a60'/%3E%3Crect y='34' width='44' height='4' fill='%232c5068'/%3E%3Crect y='38' width='44' height='6' fill='%23182838'/%3E%3Crect x='4' y='12' width='8' height='6' fill='%23d0e8f4' opacity='0.8'/%3E%3Crect x='5' y='10' width='6' height='2' fill='%23e0f0ff' opacity='0.7'/%3E%3Crect x='6' y='8' width='4' height='2' fill='%23eef6ff' opacity='0.6'/%3E%3Crect x='4' y='18' width='8' height='2' fill='%23a0c8e0' opacity='0.4'/%3E%3Crect x='28' y='16' width='10' height='6' fill='%23d0e8f4' opacity='0.75'/%3E%3Crect x='29' y='14' width='8' height='2' fill='%23e0f0ff' opacity='0.65'/%3E%3Crect x='30' y='12' width='6' height='2' fill='%23eef6ff' opacity='0.55'/%3E%3Crect x='28' y='22' width='10' height='2' fill='%23a0c8e0' opacity='0.35'/%3E%3Crect x='16' y='24' width='6' height='4' fill='%23c8e0f0' opacity='0.5'/%3E%3Crect x='17' y='22' width='4' height='2' fill='%23d8ecf8' opacity='0.45'/%3E%3Crect x='16' y='28' width='6' height='1' fill='%2390b8d0' opacity='0.3'/%3E%3Crect x='8' y='2' width='1' height='1' fill='%23e0f0ff' opacity='0.5'/%3E%3Crect x='20' y='4' width='1' height='1' fill='%23e0f0ff' opacity='0.4'/%3E%3Crect x='36' y='2' width='1' height='1' fill='%23e0f0ff' opacity='0.45'/%3E%3Crect x='14' y='6' width='1' height='1' fill='%23e0f0ff' opacity='0.3'/%3E%3Crect x='24' y='8' width='1' height='1' fill='%23e0f0ff' opacity='0.25'/%3E%3Crect x='40' y='6' width='1' height='1' fill='%23e0f0ff' opacity='0.35'/%3E%3Crect x='2' y='36' width='1' height='1' fill='%2380a8c0' opacity='0.2'/%3E%3Crect x='22' y='38' width='1' height='1' fill='%2380a8c0' opacity='0.15'/%3E%3Crect x='38' y='36' width='1' height='1' fill='%2380a8c0' opacity='0.18'/%3E%3Crect x='10' y='40' width='1' height='1' fill='%2380a8c0' opacity='0.12'/%3E%3C/svg%3E")`,
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TransitionGroup name="fab-stack" tag="div" class="terminal-fab-stack">
|
||||
<button
|
||||
v-for="(t, idx) in terminals"
|
||||
:key="t.sessionId"
|
||||
class="terminal-fab"
|
||||
:class="{ active: t.sessionId === activeSessionId }"
|
||||
:style="{ backgroundImage: artVariants[idx] || artVariants[0], transitionDelay: `${idx * 30}ms` }"
|
||||
:title="t.label || t.sessionId.slice(0, 8)"
|
||||
@click="emit('select', t.sessionId)"
|
||||
>
|
||||
<span class="fab-number">{{ idx + 2 }}</span>
|
||||
<span class="fab-dot" :style="{ background: stateColor(t) }" />
|
||||
</button>
|
||||
<!-- New session "+" button — last in DOM = top in column-reverse -->
|
||||
<button
|
||||
key="__new__"
|
||||
class="terminal-fab new-session-fab"
|
||||
title="New session"
|
||||
@click="emit('create-session')"
|
||||
>
|
||||
<svg class="plus-icon" width="18" height="18" viewBox="0 0 18 18" shape-rendering="crispEdges">
|
||||
<rect x="8" y="2" width="2" height="14" fill="currentColor"/>
|
||||
<rect x="2" y="8" width="14" height="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-fab-stack {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.terminal-fab {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(14, 165, 233, 0.15);
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
padding: 3px 4px;
|
||||
transition: all 0.2s ease;
|
||||
image-rendering: pixelated;
|
||||
pointer-events: auto;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
filter: grayscale(1) brightness(0.6);
|
||||
}
|
||||
|
||||
.terminal-fab:not(.active):hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(14, 165, 233, 0.35);
|
||||
filter: grayscale(0.5) brightness(0.8);
|
||||
box-shadow:
|
||||
0 4px 8px rgba(0, 0, 0, 0.5),
|
||||
0 10px 24px rgba(0, 0, 0, 0.6),
|
||||
0 0 14px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
|
||||
.terminal-fab:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.terminal-fab:focus,
|
||||
.terminal-fab:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.terminal-fab.active {
|
||||
border: 2px solid;
|
||||
border-image: conic-gradient(
|
||||
from var(--border-angle, 0deg),
|
||||
rgba(34, 211, 238, 1),
|
||||
rgba(99, 102, 241, 0.7),
|
||||
rgba(34, 211, 238, 0.15),
|
||||
rgba(99, 102, 241, 0.7),
|
||||
rgba(34, 211, 238, 1)
|
||||
) 1;
|
||||
filter: none;
|
||||
box-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
|
||||
animation: border-spin 3s linear infinite;
|
||||
}
|
||||
|
||||
@property --border-angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes border-spin {
|
||||
to { --border-angle: 360deg; }
|
||||
}
|
||||
|
||||
.fab-number {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.terminal-fab:hover .fab-number {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.terminal-fab.active .fab-number {
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
.new-session-fab {
|
||||
background: rgba(14, 165, 233, 0.08) !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
color: rgba(103, 232, 249, 0.5);
|
||||
}
|
||||
|
||||
.new-session-fab:hover {
|
||||
color: rgba(103, 232, 249, 0.9);
|
||||
background: rgba(14, 165, 233, 0.18) !important;
|
||||
}
|
||||
|
||||
.plus-icon {
|
||||
filter: drop-shadow(0 0 3px rgba(103, 232, 249, 0.3));
|
||||
}
|
||||
|
||||
.fab-dot {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
|
||||
/* TransitionGroup animations */
|
||||
.fab-stack-enter-active {
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.fab-stack-leave-active {
|
||||
transition: all 0.2s ease-in;
|
||||
}
|
||||
|
||||
.fab-stack-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.8);
|
||||
}
|
||||
|
||||
.fab-stack-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(8px) scale(0.8);
|
||||
}
|
||||
|
||||
.fab-stack-move {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
</style>
|
||||
90
frontend/src/components/transcript-debug/ThinkingBlock.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
|
||||
defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="thinking-block">
|
||||
<button class="thinking-toggle" @click="expanded = !expanded">
|
||||
<svg
|
||||
:class="['chevron', { rotated: expanded }]"
|
||||
width="12" height="12" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="thinking-label">Thinking</span>
|
||||
<span class="thinking-length">{{ content.length }} chars</span>
|
||||
</button>
|
||||
<div v-if="expanded" class="thinking-content">
|
||||
<MarkdownContent :content="content" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.thinking-block {
|
||||
border: 1px solid rgba(168, 85, 247, 0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.thinking-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: rgba(168, 85, 247, 0.06);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.thinking-toggle:hover {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
font-weight: 500;
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.thinking-length {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 0.75rem;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid rgba(168, 85, 247, 0.15);
|
||||
background: rgba(168, 85, 247, 0.03);
|
||||
}
|
||||
</style>
|
||||
395
frontend/src/components/transcript-debug/ToolCallBlock.vue
Normal file
@@ -0,0 +1,395 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ParsedToolCall, ParsedProgressEvent } from '@/types/transcript-debug'
|
||||
import ToolResultBlock from './ToolResultBlock.vue'
|
||||
import { highlightCode } from '@/utils/markdown'
|
||||
|
||||
const props = defineProps<{
|
||||
call: ParsedToolCall
|
||||
}>()
|
||||
|
||||
const inputExpanded = ref(false)
|
||||
const progressExpanded = ref(false)
|
||||
|
||||
const hookEvents = computed(() =>
|
||||
props.call.progressEvents.filter(e => e.dataType === 'hook_progress')
|
||||
)
|
||||
|
||||
const mcpEvents = computed(() =>
|
||||
props.call.progressEvents.filter(e => e.dataType === 'mcp_progress')
|
||||
)
|
||||
|
||||
const mcpCompleted = computed(() =>
|
||||
mcpEvents.value.find(e => e.mcpStatus === 'completed')
|
||||
)
|
||||
|
||||
function hookLabel(e: ParsedProgressEvent): string {
|
||||
if (!e.hookName) return e.hookEvent || 'hook'
|
||||
// "PreToolUse:mcp__agent-ui__navigate_to" → "PreToolUse"
|
||||
const event = e.hookEvent || e.hookName.split(':')[0]
|
||||
return event
|
||||
}
|
||||
|
||||
const highlightedInput = computed(() =>
|
||||
highlightCode(JSON.stringify(props.call.input, null, 2), 'json')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-call">
|
||||
<div class="tool-header">
|
||||
<span class="tool-icon">⚙</span>
|
||||
<span class="tool-name">{{ call.name }}</span>
|
||||
<span v-if="call.result?.isError" class="error-indicator">error</span>
|
||||
<span v-if="mcpCompleted?.mcpElapsedMs != null" class="timing-badge">
|
||||
{{ mcpCompleted.mcpElapsedMs }}ms
|
||||
</span>
|
||||
<span v-if="mcpCompleted?.mcpServerName" class="server-badge">
|
||||
{{ mcpCompleted.mcpServerName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Hook + MCP progress timeline -->
|
||||
<div v-if="call.progressEvents.length" class="progress-timeline">
|
||||
<button class="timeline-toggle" @click="progressExpanded = !progressExpanded">
|
||||
<svg
|
||||
:class="['chevron', { rotated: progressExpanded }]"
|
||||
width="10" height="10" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="timeline-summary">
|
||||
<span v-for="he in hookEvents" :key="he.uuid" :class="['hook-pill', he.hookEvent?.toLowerCase()]">
|
||||
{{ hookLabel(he) }}
|
||||
</span>
|
||||
<span v-for="me in mcpEvents" :key="me.uuid" :class="['mcp-pill', me.mcpStatus]">
|
||||
MCP {{ me.mcpStatus }}
|
||||
<span v-if="me.mcpElapsedMs != null" class="mcp-ms">{{ me.mcpElapsedMs }}ms</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="progressExpanded" class="timeline-details">
|
||||
<div v-for="e in call.progressEvents" :key="e.uuid" class="timeline-row">
|
||||
<!-- Hook progress -->
|
||||
<template v-if="e.dataType === 'hook_progress'">
|
||||
<span class="tl-icon hook-icon">⚙</span>
|
||||
<span :class="['tl-event', e.hookEvent?.toLowerCase()]">{{ e.hookEvent }}</span>
|
||||
<span class="tl-name">{{ e.hookName }}</span>
|
||||
<span v-if="e.command" class="tl-command" :title="e.command">
|
||||
{{ e.command.length > 80 ? e.command.slice(0, 80) + '...' : e.command }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- MCP progress -->
|
||||
<template v-else-if="e.dataType === 'mcp_progress'">
|
||||
<span class="tl-icon mcp-icon">⚡</span>
|
||||
<span :class="['tl-status', e.mcpStatus]">{{ e.mcpStatus }}</span>
|
||||
<span class="tl-server">{{ e.mcpServerName }}</span>
|
||||
<span class="tl-tool">{{ e.mcpToolName }}</span>
|
||||
<span v-if="e.mcpElapsedMs != null" class="tl-elapsed">{{ e.mcpElapsedMs }}ms</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tool input -->
|
||||
<div class="tool-input-section">
|
||||
<button class="input-toggle" @click="inputExpanded = !inputExpanded">
|
||||
<svg
|
||||
:class="['chevron', { rotated: inputExpanded }]"
|
||||
width="10" height="10" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span>Input</span>
|
||||
</button>
|
||||
<pre v-if="inputExpanded" class="input-json" v-html="highlightedInput"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Tool result -->
|
||||
<ToolResultBlock v-if="call.result" :result="call.result" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-call {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.error-indicator {
|
||||
font-size: 10px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timing-badge {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.server-badge {
|
||||
font-size: 10px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
color: #38bdf8;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* Progress timeline */
|
||||
.progress-timeline {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.timeline-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: rgba(99, 102, 241, 0.03);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.timeline-toggle:hover {
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
|
||||
.timeline-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Hook pills */
|
||||
.hook-pill {
|
||||
font-size: 9px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.hook-pill.pretooluse {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.hook-pill.posttooluse {
|
||||
background: rgba(168, 85, 247, 0.12);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.hook-pill.sessionstart {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* MCP pills */
|
||||
.mcp-pill {
|
||||
font-size: 9px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mcp-pill.started {
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.mcp-pill.completed {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.mcp-ms {
|
||||
opacity: 0.8;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* Expanded timeline rows */
|
||||
.timeline-details {
|
||||
padding: 0.25rem 0.5rem 0.4rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding-left: 0.5rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.tl-icon {
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-icon { color: #fbbf24; }
|
||||
.mcp-icon { color: #38bdf8; }
|
||||
|
||||
.tl-event {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tl-event.pretooluse { color: #fbbf24; }
|
||||
.tl-event.posttooluse { color: #a855f7; }
|
||||
.tl-event.sessionstart { color: #22c55e; }
|
||||
|
||||
.tl-name {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.tl-command {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 9px;
|
||||
opacity: 0.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tl-status {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tl-status.started { color: #38bdf8; }
|
||||
.tl-status.completed { color: #22c55e; }
|
||||
|
||||
.tl-server {
|
||||
font-size: 9px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
color: #38bdf8;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tl-tool {
|
||||
font-size: 9px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tl-elapsed {
|
||||
font-size: 9px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #22c55e;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Chevron */
|
||||
.chevron {
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Tool input section */
|
||||
.tool-input-section {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.input-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.input-json {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
</style>
|
||||
36
frontend/src/components/transcript-debug/ToolResultBlock.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ParsedToolResult } from '@/types/transcript-debug'
|
||||
import CodeBlock from './CodeBlock.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
result: ParsedToolResult
|
||||
}>()
|
||||
|
||||
const content = computed(() => props.result.content)
|
||||
|
||||
const lang = computed(() => {
|
||||
const trimmed = content.value.trim()
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return 'json'
|
||||
} catch { /* not valid JSON */ }
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const displayCode = computed(() => {
|
||||
if (lang.value === 'json') {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content.value.trim()), null, 2)
|
||||
} catch { /* fallback */ }
|
||||
}
|
||||
return content.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CodeBlock :code="displayCode" :lang="lang" max-height="300px" />
|
||||
</template>
|
||||
302
frontend/src/components/transcript-debug/TurnEndDivider.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ParsedSystemMessage } from '@/types/transcript-debug'
|
||||
|
||||
const props = defineProps<{
|
||||
message: ParsedSystemMessage
|
||||
}>()
|
||||
|
||||
const duration = computed(() => {
|
||||
const ms = props.message.durationMs
|
||||
if (!ms) return ''
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
return `${m}m ${s % 60}s`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="turn-end">
|
||||
<div class="reef-line">
|
||||
<!-- Left reef: anchored to left edge, extends right -->
|
||||
<svg class="reef reef-left" viewBox="0 0 200 14" preserveAspectRatio="xMinYMid slice" shape-rendering="crispEdges">
|
||||
<!-- Ocean stone floor (prismarine-inspired mosaic) -->
|
||||
<defs>
|
||||
<pattern id="pfl" x="0" y="0" width="12" height="2" patternUnits="userSpaceOnUse">
|
||||
<rect width="12" height="2" fill="#0e3b3b"/>
|
||||
<rect x="0" y="0" width="5" height="1" fill="#1f7270"/>
|
||||
<rect x="6" y="0" width="5" height="1" fill="#2a8a7e"/>
|
||||
<rect x="0" y="1" width="2" height="1" fill="#35a098"/>
|
||||
<rect x="3" y="1" width="5" height="1" fill="#1f7270"/>
|
||||
<rect x="9" y="1" width="2" height="1" fill="#2a8a7e"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="12" width="200" height="2" fill="url(#pfl)" opacity="0.85"/>
|
||||
<!-- Crystal highlights -->
|
||||
<rect x="3" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.5"/>
|
||||
<rect x="15" y="13" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
|
||||
<rect x="28" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
|
||||
<rect x="42" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
|
||||
<rect x="58" y="12" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
|
||||
<rect x="75" y="13" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
|
||||
<rect x="91" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
|
||||
<rect x="108" y="13" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
|
||||
<rect x="130" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.3"/>
|
||||
<rect x="155" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.25"/>
|
||||
<rect x="175" y="12" width="1" height="1" fill="#7edcd2" opacity="0.2"/>
|
||||
|
||||
<!-- Tall coral cluster (left edge) -->
|
||||
<rect x="2" y="4" width="2" height="10" fill="#f87171" opacity="0.85"/>
|
||||
<rect x="4" y="2" width="2" height="12" fill="#fb923c" opacity="0.8"/>
|
||||
<rect x="6" y="5" width="2" height="9" fill="#f87171" opacity="0.75"/>
|
||||
<rect x="8" y="7" width="2" height="7" fill="#ef4444" opacity="0.65"/>
|
||||
<rect x="3" y="1" width="2" height="2" fill="#fca5a5" opacity="0.55"/>
|
||||
|
||||
<!-- Seaweed grove -->
|
||||
<rect x="14" y="1" width="2" height="13" fill="#22c55e" opacity="0.75"/>
|
||||
<rect x="16" y="3" width="2" height="11" fill="#4ade80" opacity="0.7"/>
|
||||
<rect x="18" y="5" width="2" height="9" fill="#16a34a" opacity="0.65"/>
|
||||
<rect x="13" y="0" width="2" height="2" fill="#86efac" opacity="0.5"/>
|
||||
|
||||
<!-- Orange fish school (3 fish) -->
|
||||
<rect x="26" y="4" width="3" height="2" fill="#f97316" opacity="0.85"/>
|
||||
<rect x="25" y="5" width="1" height="1" fill="#fb923c" opacity="0.7"/>
|
||||
<rect x="30" y="6" width="3" height="2" fill="#f97316" opacity="0.75"/>
|
||||
<rect x="29" y="7" width="1" height="1" fill="#fb923c" opacity="0.65"/>
|
||||
<rect x="33" y="3" width="3" height="2" fill="#ea580c" opacity="0.7"/>
|
||||
<rect x="32" y="4" width="1" height="1" fill="#fdba74" opacity="0.55"/>
|
||||
|
||||
<!-- Bubbles -->
|
||||
<rect x="24" y="1" width="1" height="1" fill="white" opacity="0.4"/>
|
||||
<rect x="22" y="3" width="1" height="1" fill="white" opacity="0.35"/>
|
||||
<rect x="37" y="2" width="1" height="1" fill="white" opacity="0.35"/>
|
||||
|
||||
<!-- Purple brain coral -->
|
||||
<rect x="42" y="7" width="4" height="5" fill="#a855f7" opacity="0.7"/>
|
||||
<rect x="43" y="6" width="2" height="2" fill="#c084fc" opacity="0.65"/>
|
||||
<rect x="46" y="8" width="2" height="4" fill="#7c3aed" opacity="0.55"/>
|
||||
<rect x="44" y="12" width="2" height="2" fill="#6d28d9" opacity="0.4"/>
|
||||
|
||||
<!-- Jellyfish -->
|
||||
<rect x="54" y="2" width="3" height="2" fill="#c084fc" opacity="0.65"/>
|
||||
<rect x="55" y="1" width="1" height="1" fill="#e9d5ff" opacity="0.5"/>
|
||||
<rect x="54" y="4" width="1" height="2" fill="#a855f7" opacity="0.4"/>
|
||||
<rect x="56" y="4" width="1" height="2" fill="#a855f7" opacity="0.4"/>
|
||||
|
||||
<!-- Starfish -->
|
||||
<rect x="62" y="10" width="3" height="2" fill="#fbbf24" opacity="0.65"/>
|
||||
<rect x="63" y="9" width="1" height="1" fill="#fde68a" opacity="0.55"/>
|
||||
<rect x="63" y="12" width="1" height="1" fill="#f59e0b" opacity="0.5"/>
|
||||
|
||||
<!-- Anemone -->
|
||||
<rect x="70" y="6" width="2" height="8" fill="#ec4899" opacity="0.65"/>
|
||||
<rect x="72" y="7" width="2" height="7" fill="#f472b6" opacity="0.55"/>
|
||||
<rect x="69" y="5" width="2" height="2" fill="#f9a8d4" opacity="0.5"/>
|
||||
<rect x="73" y="6" width="2" height="2" fill="#f9a8d4" opacity="0.5"/>
|
||||
|
||||
<!-- Blue fish -->
|
||||
<rect x="80" y="5" width="3" height="2" fill="#3b82f6" opacity="0.75"/>
|
||||
<rect x="79" y="6" width="1" height="1" fill="#93c5fd" opacity="0.65"/>
|
||||
|
||||
<!-- Seaweed tuft -->
|
||||
<rect x="88" y="4" width="2" height="10" fill="#059669" opacity="0.6"/>
|
||||
<rect x="90" y="6" width="2" height="8" fill="#10b981" opacity="0.5"/>
|
||||
|
||||
<!-- Small coral -->
|
||||
<rect x="96" y="8" width="2" height="6" fill="#f87171" opacity="0.6"/>
|
||||
<rect x="98" y="9" width="2" height="5" fill="#fb923c" opacity="0.5"/>
|
||||
|
||||
<!-- Seahorse -->
|
||||
<rect x="106" y="4" width="2" height="2" fill="#fbbf24" opacity="0.65"/>
|
||||
<rect x="106" y="6" width="2" height="3" fill="#f59e0b" opacity="0.55"/>
|
||||
<rect x="107" y="9" width="1" height="2" fill="#d97706" opacity="0.5"/>
|
||||
|
||||
<!-- Bubbles -->
|
||||
<rect x="104" y="1" width="1" height="1" fill="white" opacity="0.35"/>
|
||||
<rect x="112" y="3" width="1" height="1" fill="white" opacity="0.3"/>
|
||||
|
||||
<!-- More coral -->
|
||||
<rect x="118" y="7" width="2" height="7" fill="#0ea5e9" opacity="0.5"/>
|
||||
<rect x="120" y="9" width="2" height="5" fill="#22d3ee" opacity="0.4"/>
|
||||
|
||||
<!-- Tiny fish -->
|
||||
<rect x="130" y="6" width="2" height="1" fill="#f97316" opacity="0.55"/>
|
||||
<rect x="140" y="4" width="2" height="1" fill="#818cf8" opacity="0.5"/>
|
||||
|
||||
<!-- Shell -->
|
||||
<rect x="150" y="10" width="3" height="2" fill="#fde68a" opacity="0.5"/>
|
||||
<rect x="151" y="9" width="1" height="1" fill="#fef3c7" opacity="0.4"/>
|
||||
|
||||
<!-- Fade-out elements -->
|
||||
<rect x="160" y="8" width="2" height="6" fill="#22c55e" opacity="0.35"/>
|
||||
<rect x="170" y="9" width="2" height="5" fill="#a855f7" opacity="0.3"/>
|
||||
<rect x="180" y="7" width="1" height="1" fill="white" opacity="0.2"/>
|
||||
<rect x="190" y="10" width="2" height="4" fill="#f87171" opacity="0.2"/>
|
||||
</svg>
|
||||
|
||||
<!-- Center badge -->
|
||||
<span v-if="duration" class="duration-badge">{{ duration }}</span>
|
||||
<span v-else class="end-badge">~</span>
|
||||
|
||||
<!-- Right reef: anchored to right edge, extends left -->
|
||||
<svg class="reef reef-right" viewBox="0 0 200 14" preserveAspectRatio="xMaxYMid slice" shape-rendering="crispEdges">
|
||||
<!-- Ocean stone floor (prismarine-inspired mosaic) -->
|
||||
<defs>
|
||||
<pattern id="pfr" x="0" y="0" width="12" height="2" patternUnits="userSpaceOnUse">
|
||||
<rect width="12" height="2" fill="#0e3b3b"/>
|
||||
<rect x="0" y="0" width="5" height="1" fill="#2a8a7e"/>
|
||||
<rect x="6" y="0" width="5" height="1" fill="#1f7270"/>
|
||||
<rect x="0" y="1" width="2" height="1" fill="#1f7270"/>
|
||||
<rect x="3" y="1" width="5" height="1" fill="#35a098"/>
|
||||
<rect x="9" y="1" width="2" height="1" fill="#1f7270"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="12" width="200" height="2" fill="url(#pfr)" opacity="0.85"/>
|
||||
<!-- Crystal highlights -->
|
||||
<rect x="190" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.5"/>
|
||||
<rect x="178" y="13" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
|
||||
<rect x="162" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
|
||||
<rect x="145" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
|
||||
<rect x="128" y="12" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
|
||||
<rect x="110" y="13" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
|
||||
<rect x="92" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
|
||||
<rect x="70" y="13" width="1" height="1" fill="#7edcd2" opacity="0.3"/>
|
||||
<rect x="45" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.25"/>
|
||||
<rect x="22" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.2"/>
|
||||
|
||||
<!-- Tall coral cluster (right edge) -->
|
||||
<rect x="192" y="3" width="2" height="11" fill="#ec4899" opacity="0.85"/>
|
||||
<rect x="194" y="5" width="2" height="9" fill="#f472b6" opacity="0.8"/>
|
||||
<rect x="196" y="4" width="2" height="10" fill="#ec4899" opacity="0.75"/>
|
||||
<rect x="190" y="6" width="2" height="8" fill="#db2777" opacity="0.65"/>
|
||||
<rect x="193" y="1" width="2" height="3" fill="#fbcfe8" opacity="0.55"/>
|
||||
|
||||
<!-- Seaweed grove -->
|
||||
<rect x="182" y="2" width="2" height="12" fill="#10b981" opacity="0.75"/>
|
||||
<rect x="184" y="4" width="2" height="10" fill="#34d399" opacity="0.7"/>
|
||||
<rect x="180" y="0" width="2" height="3" fill="#6ee7b7" opacity="0.5"/>
|
||||
|
||||
<!-- Purple fish school -->
|
||||
<rect x="170" y="5" width="3" height="2" fill="#818cf8" opacity="0.85"/>
|
||||
<rect x="173" y="6" width="1" height="1" fill="#a5b4fc" opacity="0.7"/>
|
||||
<rect x="166" y="3" width="3" height="2" fill="#6366f1" opacity="0.75"/>
|
||||
<rect x="169" y="4" width="1" height="1" fill="#c7d2fe" opacity="0.65"/>
|
||||
<rect x="163" y="7" width="3" height="2" fill="#818cf8" opacity="0.7"/>
|
||||
<rect x="166" y="8" width="1" height="1" fill="#a5b4fc" opacity="0.55"/>
|
||||
|
||||
<!-- Bubbles -->
|
||||
<rect x="175" y="1" width="1" height="1" fill="white" opacity="0.4"/>
|
||||
<rect x="178" y="3" width="1" height="1" fill="white" opacity="0.35"/>
|
||||
<rect x="160" y="2" width="1" height="1" fill="white" opacity="0.35"/>
|
||||
|
||||
<!-- Orange fan coral -->
|
||||
<rect x="152" y="6" width="4" height="6" fill="#fb923c" opacity="0.7"/>
|
||||
<rect x="153" y="5" width="2" height="2" fill="#fdba74" opacity="0.65"/>
|
||||
<rect x="150" y="8" width="2" height="4" fill="#ea580c" opacity="0.55"/>
|
||||
|
||||
<!-- Turtle -->
|
||||
<rect x="140" y="4" width="4" height="2" fill="#22c55e" opacity="0.7"/>
|
||||
<rect x="139" y="5" width="1" height="1" fill="#4ade80" opacity="0.55"/>
|
||||
<rect x="144" y="5" width="1" height="1" fill="#4ade80" opacity="0.55"/>
|
||||
<rect x="141" y="3" width="2" height="1" fill="#86efac" opacity="0.5"/>
|
||||
|
||||
<!-- Shell -->
|
||||
<rect x="132" y="10" width="3" height="2" fill="#fde68a" opacity="0.55"/>
|
||||
<rect x="133" y="9" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
|
||||
|
||||
<!-- Cyan coral -->
|
||||
<rect x="124" y="7" width="2" height="7" fill="#0ea5e9" opacity="0.65"/>
|
||||
<rect x="126" y="8" width="2" height="6" fill="#22d3ee" opacity="0.55"/>
|
||||
<rect x="122" y="9" width="2" height="5" fill="#0284c7" opacity="0.5"/>
|
||||
|
||||
<!-- Red anemone -->
|
||||
<rect x="112" y="6" width="2" height="8" fill="#f87171" opacity="0.6"/>
|
||||
<rect x="114" y="7" width="2" height="7" fill="#fca5a5" opacity="0.5"/>
|
||||
<rect x="111" y="5" width="2" height="2" fill="#fecaca" opacity="0.4"/>
|
||||
|
||||
<!-- Tiny fish -->
|
||||
<rect x="104" y="5" width="2" height="1" fill="#f97316" opacity="0.55"/>
|
||||
|
||||
<!-- Seaweed -->
|
||||
<rect x="96" y="5" width="2" height="9" fill="#059669" opacity="0.5"/>
|
||||
<rect x="94" y="7" width="2" height="7" fill="#10b981" opacity="0.4"/>
|
||||
|
||||
<!-- Starfish -->
|
||||
<rect x="86" y="10" width="3" height="2" fill="#fbbf24" opacity="0.5"/>
|
||||
<rect x="87" y="9" width="1" height="1" fill="#fde68a" opacity="0.4"/>
|
||||
|
||||
<!-- Bubbles -->
|
||||
<rect x="80" y="2" width="1" height="1" fill="white" opacity="0.3"/>
|
||||
<rect x="72" y="4" width="1" height="1" fill="white" opacity="0.25"/>
|
||||
|
||||
<!-- Fade-out elements -->
|
||||
<rect x="60" y="8" width="2" height="6" fill="#ec4899" opacity="0.3"/>
|
||||
<rect x="46" y="9" width="2" height="5" fill="#22c55e" opacity="0.25"/>
|
||||
<rect x="30" y="7" width="1" height="1" fill="white" opacity="0.2"/>
|
||||
<rect x="14" y="10" width="2" height="4" fill="#0ea5e9" opacity="0.2"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.turn-end {
|
||||
padding: 0.25rem 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.reef-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.reef {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
image-rendering: pixelated;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.duration-badge,
|
||||
.end-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(14, 165, 233, 0.85);
|
||||
padding: 0 6px;
|
||||
letter-spacing: 1px;
|
||||
z-index: 1;
|
||||
text-shadow: 0 0 8px rgba(14, 165, 233, 0.4);
|
||||
animation: badge-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.end-badge {
|
||||
font-size: 12px;
|
||||
color: rgba(14, 165, 233, 0.5);
|
||||
animation: badge-drift 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes badge-glow {
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 6px rgba(14, 165, 233, 0.3);
|
||||
color: rgba(14, 165, 233, 0.8);
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 12px rgba(14, 165, 233, 0.6), 0 0 4px rgba(34, 211, 238, 0.3);
|
||||
color: rgba(14, 165, 233, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes badge-drift {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
175
frontend/src/components/transcript-debug/UserInput.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import VoiceMicButton from './VoiceMicButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
terminalReady?: boolean | null // null = no terminal, false = starting, true = ready
|
||||
voiceTranscript?: string
|
||||
isRecording?: boolean
|
||||
voiceMode?: 'web' | 'whisper'
|
||||
whisperStatus?: 'offline' | 'loading' | 'ready'
|
||||
maxLines?: number
|
||||
}>()
|
||||
|
||||
const maxH = computed(() => {
|
||||
const lines = props.maxLines ?? 6
|
||||
return lines <= 1 ? '1.5em' : `${lines * 1.5}em`
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [message: string]
|
||||
startRecording: []
|
||||
stopRecording: []
|
||||
}>()
|
||||
|
||||
const input = ref('')
|
||||
|
||||
// terminalReady: null = no terminal, false = starting, true = ready
|
||||
const noTerminal = computed(() => props.terminalReady === null)
|
||||
const canSend = computed(() => props.terminalReady === true)
|
||||
const isDisabled = computed(() => !input.value.trim() || !canSend.value)
|
||||
|
||||
function handleSend() {
|
||||
const msg = input.value.trim()
|
||||
if (!msg || !canSend.value) return
|
||||
emit('send', msg)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
const ta = e.target as HTMLTextAreaElement
|
||||
const start = ta.selectionStart
|
||||
const end = ta.selectionEnd
|
||||
input.value = input.value.slice(0, start) + '\n' + input.value.slice(end)
|
||||
nextTick(() => {
|
||||
ta.selectionStart = ta.selectionEnd = start + 1
|
||||
})
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Fill textarea with voice transcript
|
||||
watch(() => props.voiceTranscript, (newText) => {
|
||||
if (newText && newText.trim()) {
|
||||
input.value = newText
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-input">
|
||||
<div class="input-container" :class="{ disabled: !canSend }">
|
||||
<textarea
|
||||
v-model="input"
|
||||
class="input-field"
|
||||
:style="{ maxHeight: maxH }"
|
||||
:placeholder="noTerminal ? 'No terminal — use + to create session' : 'Continue this conversation...'"
|
||||
rows="1"
|
||||
:disabled="!canSend"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<VoiceMicButton
|
||||
v-if="voiceMode"
|
||||
:is-recording="isRecording ?? false"
|
||||
:voice-mode="voiceMode"
|
||||
:whisper-status="whisperStatus ?? 'offline'"
|
||||
:disabled="!canSend"
|
||||
@start="emit('startRecording')"
|
||||
@stop="emit('stopRecording')"
|
||||
/>
|
||||
<button
|
||||
class="send-btn"
|
||||
:disabled="isDisabled"
|
||||
@click="handleSend"
|
||||
title="Send prompt (resumes this session)"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13" />
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-input {
|
||||
padding: 0.5rem 0.75rem 0.15rem;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
transition: border-color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.input-container:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-container.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
field-sizing: content;
|
||||
min-height: 1lh;
|
||||
overflow-y: auto;
|
||||
padding: 0.15rem 0.25rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input-field:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
502
frontend/src/components/transcript-debug/UserMessageBubble.vue
Normal file
@@ -0,0 +1,502 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ParsedUserMessage, SectionSummary } from '@/types/transcript-debug'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
message: ParsedUserMessage
|
||||
collapsed?: boolean
|
||||
sectionCount?: number
|
||||
sectionSummary?: SectionSummary
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleCollapse: []
|
||||
}>()
|
||||
|
||||
const isOptimistic = computed(() => props.message.uuid.startsWith('optimistic-'))
|
||||
|
||||
// ── Command / special message detection ──
|
||||
type CommandInfo =
|
||||
| { type: 'caveat'; text: string }
|
||||
| { type: 'command'; name: string; message: string; args: string }
|
||||
| { type: 'stdout'; text: string }
|
||||
| { type: 'interrupted' }
|
||||
| { type: 'meta-action'; text: string }
|
||||
| null
|
||||
|
||||
const commandInfo = computed<CommandInfo>(() => {
|
||||
const c = props.message.content
|
||||
if (!c) return null
|
||||
|
||||
// [Request interrupted by user ...]
|
||||
if (c.includes('[Request interrupted by user')) {
|
||||
return { type: 'interrupted' }
|
||||
}
|
||||
|
||||
// Meta messages: "Continue from where you left off", etc.
|
||||
if (props.message.isMeta) {
|
||||
return { type: 'meta-action', text: c.trim() }
|
||||
}
|
||||
|
||||
// <local-command-caveat>...</local-command-caveat>
|
||||
const caveatMatch = c.match(/<local-command-caveat>([\s\S]*?)<\/local-command-caveat>/)
|
||||
if (caveatMatch) return { type: 'caveat', text: caveatMatch[1].trim() }
|
||||
|
||||
// <command-name>...</command-name>
|
||||
const cmdNameMatch = c.match(/<command-name>([\s\S]*?)<\/command-name>/)
|
||||
if (cmdNameMatch) {
|
||||
const msgMatch = c.match(/<command-message>([\s\S]*?)<\/command-message>/)
|
||||
const argsMatch = c.match(/<command-args>([\s\S]*?)<\/command-args>/)
|
||||
return {
|
||||
type: 'command',
|
||||
name: cmdNameMatch[1].trim(),
|
||||
message: msgMatch?.[1]?.trim() || '',
|
||||
args: argsMatch?.[1]?.trim() || ''
|
||||
}
|
||||
}
|
||||
|
||||
// <local-command-stdout>...</local-command-stdout>
|
||||
const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/)
|
||||
if (stdoutMatch !== null && c.includes('<local-command-stdout>')) {
|
||||
return { type: 'stdout', text: stdoutMatch[1].trim() }
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const isCommand = computed(() => commandInfo.value !== null)
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString()
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
const totalTokens = computed(() => {
|
||||
if (!props.sectionSummary) return 0
|
||||
return props.sectionSummary.inputTokens + props.sectionSummary.outputTokens
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ═══════ COMMAND MESSAGE (compact) ═══════ -->
|
||||
<div v-if="isCommand" class="cmd-row">
|
||||
<!-- Caveat: system note about local commands -->
|
||||
<template v-if="commandInfo!.type === 'caveat'">
|
||||
<span class="cmd-icon cmd-icon-caveat">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cmd-label cmd-caveat-text">local command caveat</span>
|
||||
</template>
|
||||
|
||||
<!-- Command invocation -->
|
||||
<template v-else-if="commandInfo!.type === 'command'">
|
||||
<span class="cmd-icon">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</span>
|
||||
<code class="cmd-name">{{ (commandInfo as any).name }}</code>
|
||||
<span v-if="(commandInfo as any).args" class="cmd-args">{{ (commandInfo as any).args }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Stdout -->
|
||||
<template v-else-if="commandInfo!.type === 'stdout'">
|
||||
<span class="cmd-icon cmd-icon-out">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span v-if="(commandInfo as any).text" class="cmd-stdout">{{ (commandInfo as any).text }}</span>
|
||||
<span v-else class="cmd-empty">no output</span>
|
||||
</template>
|
||||
|
||||
<!-- Interrupted -->
|
||||
<template v-else-if="commandInfo!.type === 'interrupted'">
|
||||
<span class="cmd-icon cmd-icon-interrupted">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6" rx="0.5" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cmd-label cmd-interrupted-text">interrupted by user</span>
|
||||
</template>
|
||||
|
||||
<!-- Meta action (continue, etc.) -->
|
||||
<template v-else-if="commandInfo!.type === 'meta-action'">
|
||||
<span class="cmd-icon cmd-icon-meta">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cmd-label cmd-meta-text">{{ (commandInfo as any).text }}</span>
|
||||
</template>
|
||||
|
||||
<span class="cmd-time">{{ formatTime(message.timestamp) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ NORMAL USER MESSAGE ═══════ -->
|
||||
<div v-else :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]">
|
||||
<div class="divider-line" />
|
||||
<div class="divider-content">
|
||||
<div class="divider-header">
|
||||
<span class="role-badge">User</span>
|
||||
<span v-if="message.isMeta" class="meta-badge">meta</span>
|
||||
<span v-if="isOptimistic" class="sending-badge">
|
||||
<span class="sending-dot"></span>
|
||||
<span class="sending-dot"></span>
|
||||
<span class="sending-dot"></span>
|
||||
Sending
|
||||
</span>
|
||||
<button
|
||||
v-if="sectionCount && sectionCount > 0"
|
||||
:class="['collapse-btn', { collapsed }]"
|
||||
@click.stop="emit('toggleCollapse')"
|
||||
:title="collapsed ? 'Expand section' : 'Collapse section'"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline :points="collapsed ? '9 18 15 12 9 6' : '6 9 12 15 18 9'" />
|
||||
</svg>
|
||||
<template v-if="collapsed && sectionSummary">
|
||||
<span class="collapse-count">{{ sectionSummary.total }}</span>
|
||||
<template v-if="sectionSummary.toolNames.length">
|
||||
<span class="badge-sep">|</span>
|
||||
<span v-for="t in sectionSummary.toolNames" :key="t" class="tool-chip">{{ t }}</span>
|
||||
</template>
|
||||
<template v-if="sectionSummary.hasErrors">
|
||||
<span class="badge-sep">|</span>
|
||||
<span class="error-chip">{{ sectionSummary.errorCount }} err</span>
|
||||
</template>
|
||||
<template v-if="totalTokens > 0">
|
||||
<span class="badge-sep">|</span>
|
||||
<span class="token-chip">{{ formatTokens(totalTokens) }} tok</span>
|
||||
</template>
|
||||
</template>
|
||||
</button>
|
||||
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="divider-text">
|
||||
<MarkdownContent :content="message.content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ══════════════════════════════════════
|
||||
Command row — compact inline display
|
||||
══════════════════════════════════════ */
|
||||
.cmd-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
margin: 0.1rem 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.cmd-row:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cmd-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cmd-icon-caveat { color: #f59e0b; }
|
||||
.cmd-icon-out { color: #64748b; }
|
||||
|
||||
.cmd-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cmd-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #818cf8;
|
||||
background: rgba(129, 140, 248, 0.08);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cmd-args {
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cmd-stdout {
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cmd-empty {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cmd-caveat-text {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmd-icon-interrupted { color: #ef4444; }
|
||||
.cmd-interrupted-text {
|
||||
color: rgba(239, 68, 68, 0.7);
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.cmd-icon-meta { color: #f59e0b; }
|
||||
.cmd-meta-text {
|
||||
color: rgba(251, 191, 36, 0.65);
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmd-time {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
Normal user message
|
||||
══════════════════════════════════════ */
|
||||
.user-divider {
|
||||
width: 100%;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.divider-content {
|
||||
padding: 0.45rem 0.65rem;
|
||||
background: linear-gradient(170deg, rgba(148, 163, 184, 0.10) 0%, rgba(100, 116, 139, 0.15) 50%, rgba(71, 85, 105, 0.12) 100%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.07),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.divider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #818cf8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.meta-badge {
|
||||
font-size: 9px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: rgba(251, 191, 36, 0.7);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.divider-text :deep(.md-content) {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Tighten markdown spacing inside user bubbles */
|
||||
.divider-text :deep(.md-content p) {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
|
||||
.divider-text :deep(.md-content pre) {
|
||||
margin: 0.4em 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.divider-text :deep(.md-content code) {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Meta messages: dimmed */
|
||||
.user-divider.meta {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Collapse button */
|
||||
.collapse-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 4px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.15s;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: rgba(129, 140, 248, 0.1);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.collapse-btn svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.collapse-count {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.collapse-btn:hover .collapse-count {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.badge-sep {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.35;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tool-chip {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #818cf8;
|
||||
background: rgba(129, 140, 248, 0.1);
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.collapse-btn:hover .tool-chip {
|
||||
color: #a5b4fc;
|
||||
background: rgba(129, 140, 248, 0.18);
|
||||
}
|
||||
|
||||
.error-chip {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #f87171;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.collapse-btn:hover .error-chip {
|
||||
color: #fca5a5;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.token-chip {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.collapse-btn:hover .token-chip {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
/* Optimistic / sending */
|
||||
.user-divider.optimistic {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sending-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 9px;
|
||||
color: var(--accent, #6366f1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sending-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, #6366f1);
|
||||
animation: sending-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sending-dot:nth-child(2) { animation-delay: 0.15s; }
|
||||
.sending-dot:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
@keyframes sending-pulse {
|
||||
0%, 80%, 100% { opacity: 0.2; }
|
||||
40% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
191
frontend/src/components/transcript-debug/VoiceMicButton.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isRecording: boolean
|
||||
voiceMode: 'web' | 'whisper'
|
||||
whisperStatus: 'offline' | 'loading' | 'ready'
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
start: []
|
||||
stop: []
|
||||
}>()
|
||||
|
||||
// ── Interaction state machine ──
|
||||
// idle → pointerdown → wait 250ms
|
||||
// if pointerup < 250ms → "click" → locked (recording until next press+release)
|
||||
// if 250ms passes → "hold" → ptt (recording until release + 500ms trail)
|
||||
// locked → pointerdown → stopping
|
||||
// stopping → pointerup → stop recording → idle
|
||||
// ptt → pointerup → wait 500ms trail → stop recording → idle
|
||||
|
||||
type Mode = 'idle' | 'locked' | 'ptt' | 'stopping'
|
||||
const mode = ref<Mode>('idle')
|
||||
|
||||
const HOLD_THRESHOLD = 250
|
||||
const TRAIL_BUFFER = 500
|
||||
|
||||
let holdTimer: number | null = null
|
||||
let trailTimer: number | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
|
||||
if (trailTimer) { clearTimeout(trailTimer); trailTimer = null }
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (props.disabled) return
|
||||
e.preventDefault()
|
||||
// Capture pointer so we get pointerup even if cursor leaves button
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
|
||||
if (mode.value === 'idle') {
|
||||
// Start hold detection
|
||||
holdTimer = window.setTimeout(() => {
|
||||
holdTimer = null
|
||||
// Still holding after 250ms → PTT mode
|
||||
mode.value = 'ptt'
|
||||
emit('start')
|
||||
}, HOLD_THRESHOLD)
|
||||
} else if (mode.value === 'locked') {
|
||||
// Second press while locked → prepare to stop on release
|
||||
mode.value = 'stopping'
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (props.disabled) return
|
||||
e.preventDefault()
|
||||
|
||||
if (mode.value === 'idle' && holdTimer) {
|
||||
// Quick click (< 250ms) → lock mode
|
||||
clearTimers()
|
||||
mode.value = 'locked'
|
||||
emit('start')
|
||||
} else if (mode.value === 'ptt') {
|
||||
// Release from PTT → 500ms trailing buffer then stop
|
||||
clearTimers()
|
||||
trailTimer = window.setTimeout(() => {
|
||||
trailTimer = null
|
||||
mode.value = 'idle'
|
||||
emit('stop')
|
||||
}, TRAIL_BUFFER)
|
||||
} else if (mode.value === 'stopping') {
|
||||
// Release from second press → stop immediately
|
||||
clearTimers()
|
||||
mode.value = 'idle'
|
||||
emit('stop')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="mic-btn"
|
||||
:class="{ recording: isRecording, ptt: mode === 'ptt', disabled }"
|
||||
:disabled="disabled"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerup="onPointerUp"
|
||||
:title="isRecording ? (mode === 'ptt' ? 'Release to stop' : 'Click to stop') : 'Click or hold to record'"
|
||||
>
|
||||
<!-- Mic icon -->
|
||||
<svg v-if="!isRecording" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
||||
<line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>
|
||||
<!-- Stop icon (square) when recording -->
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
</svg>
|
||||
<!-- Mode badge -->
|
||||
<span class="mode-badge" :class="voiceMode">
|
||||
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mic-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted, #888);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mic-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.1));
|
||||
color: var(--text-primary, #ccc);
|
||||
border-color: var(--text-muted, rgba(255,255,255,0.15));
|
||||
}
|
||||
|
||||
.mic-btn.recording {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
color: white;
|
||||
animation: rec-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mic-btn.recording.ptt {
|
||||
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
||||
border-color: rgba(249, 115, 22, 0.5);
|
||||
animation: rec-pulse-ptt 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mic-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mode-badge.whisper {
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-badge.web {
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes rec-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
@keyframes rec-pulse-ptt {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.5); transform: scale(1); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(249, 115, 22, 0); transform: scale(1.08); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { useAquaticEvents } from './useAquaticEvents'
|
||||
import { useAquaticState } from './useAquaticState'
|
||||
import {
|
||||
OceanScene,
|
||||
BubbleStream,
|
||||
FishSchool,
|
||||
JellyfishDrift,
|
||||
EventOverlay,
|
||||
EdgeFade,
|
||||
PixelLife,
|
||||
} from './layers'
|
||||
|
||||
const { start, stop } = useAquaticEvents()
|
||||
const { timeOfDay, season, depthZone } = useAquaticState()
|
||||
|
||||
const rootClasses = computed(() => [
|
||||
`tod-${timeOfDay.value}`,
|
||||
`season-${season.value}`,
|
||||
`depth-${depthZone.value}`,
|
||||
])
|
||||
|
||||
onMounted(() => start())
|
||||
onBeforeUnmount(() => stop())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="aquatic-bg" :class="rootClasses">
|
||||
<!-- The world: unified background scene -->
|
||||
<OceanScene />
|
||||
|
||||
<!-- Independent dynamic overlay layers -->
|
||||
<BubbleStream />
|
||||
<FishSchool />
|
||||
<JellyfishDrift />
|
||||
<PixelLife />
|
||||
<EventOverlay />
|
||||
<EdgeFade />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aquatic-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aquatic-bg :deep(*) {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AquaticBackground } from './AquaticBackground.vue'
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="bubble-stream">
|
||||
<div class="bubbles-base" />
|
||||
<div v-if="burstActive" class="bubbles-burst" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAquaticState } from '../useAquaticState'
|
||||
|
||||
const { activeEventModifiers } = useAquaticState()
|
||||
const burstActive = computed(() => activeEventModifiers.value.has('bubble-burst'))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bubble-stream {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bubbles-base {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
/* Large bubbles - slow rise */
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='90' height='220' viewBox='0 0 90 220' shape-rendering='crispEdges'%3E%3Crect x='22' y='30' width='4' height='4' fill='%2367e8f9' opacity='0.22'/%3E%3Crect x='65' y='95' width='4' height='4' fill='%2367e8f9' opacity='0.18'/%3E%3Crect x='38' y='160' width='4' height='4' fill='%2367e8f9' opacity='0.24'/%3E%3Crect x='10' y='200' width='4' height='4' fill='%2367e8f9' opacity='0.16'/%3E%3Crect x='75' y='50' width='4' height='4' fill='%2367e8f9' opacity='0.14'/%3E%3C/svg%3E") repeat / 90px 220px,
|
||||
/* Medium bubbles - medium rise */
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='110' height='300' viewBox='0 0 110 300' shape-rendering='crispEdges'%3E%3Crect x='30' y='40' width='2' height='2' fill='white' opacity='0.16'/%3E%3Crect x='80' y='120' width='2' height='2' fill='white' opacity='0.13'/%3E%3Crect x='15' y='190' width='2' height='2' fill='white' opacity='0.16'/%3E%3Crect x='60' y='260' width='2' height='2' fill='white' opacity='0.12'/%3E%3Crect x='95' y='70' width='2' height='2' fill='white' opacity='0.14'/%3E%3Crect x='45' y='160' width='2' height='2' fill='white' opacity='0.1'/%3E%3C/svg%3E") repeat / 110px 300px,
|
||||
/* Tiny bubbles - fast rise */
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='70' height='160' viewBox='0 0 70 160' shape-rendering='crispEdges'%3E%3Crect x='15' y='20' width='2' height='2' fill='%2367e8f9' opacity='0.1'/%3E%3Crect x='50' y='60' width='2' height='2' fill='%2367e8f9' opacity='0.08'/%3E%3Crect x='30' y='100' width='2' height='2' fill='white' opacity='0.09'/%3E%3Crect x='60' y='140' width='2' height='2' fill='%2367e8f9' opacity='0.07'/%3E%3Crect x='8' y='80' width='2' height='2' fill='white' opacity='0.08'/%3E%3C/svg%3E") repeat / 70px 160px;
|
||||
animation: sea-bubbles 16s linear infinite, water-sway 10s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* Burst event: extra large bubbles from a point */
|
||||
.bubbles-burst {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='180' viewBox='0 0 60 180' shape-rendering='crispEdges'%3E%3Crect x='25' y='20' width='6' height='6' fill='%2367e8f9' opacity='0.3'/%3E%3Crect x='15' y='70' width='6' height='6' fill='%2367e8f9' opacity='0.28'/%3E%3Crect x='35' y='110' width='6' height='6' fill='%2367e8f9' opacity='0.32'/%3E%3Crect x='20' y='150' width='6' height='6' fill='white' opacity='0.25'/%3E%3Crect x='40' y='40' width='4' height='4' fill='white' opacity='0.22'/%3E%3C/svg%3E") repeat / 60px 180px;
|
||||
animation: burst-rise 6s linear infinite;
|
||||
opacity: 0;
|
||||
animation: burst-rise 6s linear infinite, burst-fade 8s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes sea-bubbles {
|
||||
from { background-position: 0 0, 30px 0, 10px 0; }
|
||||
to { background-position: 0 -220px, 0 -300px, -15px -160px; }
|
||||
}
|
||||
|
||||
@keyframes water-sway {
|
||||
from { transform: translateX(-3px); }
|
||||
to { transform: translateX(3px); }
|
||||
}
|
||||
|
||||
@keyframes burst-rise {
|
||||
from { background-position: 30px 0; }
|
||||
to { background-position: 30px -180px; }
|
||||
}
|
||||
|
||||
@keyframes burst-fade {
|
||||
0% { opacity: 0; }
|
||||
10% { opacity: 0.8; }
|
||||
80% { opacity: 0.6; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="edge-fade" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edge-fade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edge-fade::before,
|
||||
.edge-fade::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edge-fade::before {
|
||||
top: 0;
|
||||
height: 25%;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 40%, transparent 100%);
|
||||
}
|
||||
|
||||
.edge-fade::after {
|
||||
bottom: 0;
|
||||
height: 25%;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 40%, transparent 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAquaticState } from '../useAquaticState'
|
||||
|
||||
const { activeEventModifiers } = useAquaticState()
|
||||
|
||||
const eventClasses = computed(() => {
|
||||
const cls: Record<string, boolean> = {}
|
||||
for (const [, cssClass] of activeEventModifiers.value) {
|
||||
cls[cssClass] = true
|
||||
}
|
||||
return cls
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="event-overlay" :class="eventClasses">
|
||||
<!-- Whale shadow -->
|
||||
<div v-if="eventClasses['evt-whale-shadow']" class="whale-shadow" />
|
||||
|
||||
<!-- Bioluminescent flash -->
|
||||
<div v-if="eventClasses['evt-bioluminescent-flash']" class="bio-flash" />
|
||||
|
||||
<!-- Plankton drift -->
|
||||
<div v-if="eventClasses['evt-plankton-drift']" class="plankton" />
|
||||
|
||||
<!-- Fish chase -->
|
||||
<div v-if="eventClasses['evt-fish-chase']" class="fish-chase" />
|
||||
|
||||
<!-- Turtle crossing -->
|
||||
<div v-if="eventClasses['evt-turtle-crossing']" class="turtle" />
|
||||
|
||||
<!-- Manta ray -->
|
||||
<div v-if="eventClasses['evt-manta-ray']" class="manta" />
|
||||
|
||||
<!-- Color shift (gradient overlay) -->
|
||||
<div v-if="eventClasses['evt-color-shift']" class="color-shift" />
|
||||
|
||||
<!-- Aurora underwater -->
|
||||
<div v-if="eventClasses['evt-aurora-underwater']" class="aurora" />
|
||||
|
||||
<!-- Mythical creature -->
|
||||
<div v-if="eventClasses['evt-mythical-creature']" class="mythical" />
|
||||
|
||||
<!-- Volcanic vent -->
|
||||
<div v-if="eventClasses['evt-volcanic-vent']" class="volcanic" />
|
||||
|
||||
<!-- Crystal formation -->
|
||||
<div v-if="eventClasses['evt-crystal-formation']" class="crystals" />
|
||||
|
||||
<!-- Kelp forest -->
|
||||
<div v-if="eventClasses['evt-kelp-forest']" class="kelp-extra" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.event-overlay > div {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Whale Shadow ── */
|
||||
.whale-shadow {
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='40' viewBox='0 0 120 40' shape-rendering='crispEdges'%3E%3Cellipse cx='60' cy='20' rx='58' ry='18' fill='%23000' opacity='0.2'/%3E%3Cellipse cx='55' cy='20' rx='45' ry='14' fill='%23000' opacity='0.15'/%3E%3Crect x='100' y='8' width='16' height='6' fill='%23000' opacity='0.15'/%3E%3Crect x='100' y='26' width='16' height='6' fill='%23000' opacity='0.15'/%3E%3C/svg%3E") no-repeat / 180px 60px;
|
||||
animation: whale-cross 20s linear forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes whale-cross {
|
||||
0% { background-position: -200px 15%; opacity: 0; }
|
||||
5% { opacity: 0.8; }
|
||||
90% { opacity: 0.7; }
|
||||
100% { background-position: calc(100% + 200px) 20%; opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Bioluminescent Flash ── */
|
||||
.bio-flash {
|
||||
background: radial-gradient(
|
||||
ellipse 40% 50% at 50% 75%,
|
||||
rgba(74, 222, 128, 0.12) 0%,
|
||||
rgba(34, 211, 238, 0.06) 40%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: bio-pulse 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes bio-pulse {
|
||||
from { opacity: 0.4; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Plankton Drift ── */
|
||||
.plankton {
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200' viewBox='0 0 200 200' shape-rendering='crispEdges'%3E%3Crect x='20' y='30' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='80' y='50' width='1' height='1' fill='white' opacity='0.15'/%3E%3Crect x='140' y='20' width='1' height='1' fill='white' opacity='0.18'/%3E%3Crect x='50' y='90' width='1' height='1' fill='%2367e8f9' opacity='0.12'/%3E%3Crect x='170' y='80' width='1' height='1' fill='white' opacity='0.16'/%3E%3Crect x='30' y='140' width='1' height='1' fill='%2367e8f9' opacity='0.14'/%3E%3Crect x='110' y='120' width='1' height='1' fill='white' opacity='0.17'/%3E%3Crect x='160' y='160' width='1' height='1' fill='white' opacity='0.13'/%3E%3Crect x='60' y='170' width='1' height='1' fill='%2367e8f9' opacity='0.15'/%3E%3Crect x='120' y='180' width='1' height='1' fill='white' opacity='0.11'/%3E%3C/svg%3E") repeat;
|
||||
animation: plankton-float 12s linear forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes plankton-float {
|
||||
0% { background-position: 0 0; opacity: 0; }
|
||||
10% { opacity: 0.7; }
|
||||
85% { opacity: 0.5; }
|
||||
100% { background-position: -80px -40px; opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Fish Chase ── */
|
||||
.fish-chase {
|
||||
opacity: 0;
|
||||
animation: chase-sequence 10s linear forwards;
|
||||
background:
|
||||
/* Chaser fish (faster) */
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='2' height='1' fill='%23ef4444' opacity='0.6'/%3E%3Crect x='0' y='4' width='2' height='1' fill='%23ef4444' opacity='0.6'/%3E%3Crect x='2' y='1' width='6' height='4' fill='%23f87171' opacity='0.55'/%3E%3Crect x='7' y='2' width='1' height='1' fill='%23000' opacity='0.5'/%3E%3C/svg%3E") no-repeat / 20px 12px,
|
||||
/* Fleeing fish (slightly ahead) */
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%23fbbf24' opacity='0.55'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%23fbbf24' opacity='0.55'/%3E%3Crect x='1' y='0' width='5' height='5' fill='%23f59e0b' opacity='0.5'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.45'/%3E%3C/svg%3E") no-repeat / 16px 10px;
|
||||
}
|
||||
|
||||
@keyframes chase-sequence {
|
||||
0% { background-position: -40px 45%, -20px 44%; opacity: 0; }
|
||||
5% { opacity: 0.9; }
|
||||
85% { opacity: 0.8; }
|
||||
100% { background-position: calc(100% + 60px) 40%, calc(100% + 40px) 42%; opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Turtle Crossing ── */
|
||||
.turtle {
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='14' viewBox='0 0 20 14' shape-rendering='crispEdges'%3E%3Crect x='6' y='3' width='8' height='8' fill='%2316a34a' opacity='0.5'/%3E%3Crect x='7' y='4' width='6' height='6' fill='%234ade80' opacity='0.4'/%3E%3Crect x='8' y='5' width='2' height='2' fill='%2322c55e' opacity='0.35'/%3E%3Crect x='11' y='6' width='2' height='2' fill='%2322c55e' opacity='0.35'/%3E%3Crect x='14' y='6' width='4' height='2' fill='%234ade80' opacity='0.45'/%3E%3Crect x='17' y='5' width='2' height='1' fill='%2316a34a' opacity='0.4'/%3E%3Crect x='18' y='6' width='1' height='1' fill='%23000' opacity='0.3'/%3E%3Crect x='2' y='4' width='4' height='2' fill='%234ade80' opacity='0.4'/%3E%3Crect x='2' y='8' width='4' height='2' fill='%234ade80' opacity='0.4'/%3E%3Crect x='10' y='2' width='3' height='2' fill='%234ade80' opacity='0.35'/%3E%3Crect x='10' y='10' width='3' height='2' fill='%234ade80' opacity='0.35'/%3E%3C/svg%3E") no-repeat / 40px 28px;
|
||||
animation: turtle-swim 25s linear forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes turtle-swim {
|
||||
0% { background-position: -50px 30%; opacity: 0; }
|
||||
5% { opacity: 0.8; }
|
||||
90% { opacity: 0.7; }
|
||||
100% { background-position: calc(100% + 50px) 35%; opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Manta Ray ── */
|
||||
.manta {
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='12' viewBox='0 0 30 12' shape-rendering='crispEdges'%3E%3Crect x='10' y='4' width='10' height='4' fill='%23334155' opacity='0.45'/%3E%3Crect x='8' y='3' width='14' height='6' fill='%23475569' opacity='0.4'/%3E%3Crect x='4' y='2' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='20' y='2' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='0' y='1' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='25' y='1' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='4' y='7' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='20' y='7' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='0' y='9' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='25' y='9' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='14' y='5' width='2' height='1' fill='%23000' opacity='0.3'/%3E%3C/svg%3E") no-repeat / 60px 24px;
|
||||
animation: manta-glide 18s linear forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes manta-glide {
|
||||
0% { background-position: calc(100% + 80px) 18%; opacity: 0; }
|
||||
5% { opacity: 0.8; }
|
||||
90% { opacity: 0.7; }
|
||||
100% { background-position: -80px 22%; opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Color Shift ── */
|
||||
.color-shift {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(56, 189, 248, 0.03) 0%,
|
||||
rgba(14, 165, 233, 0.05) 50%,
|
||||
rgba(6, 182, 212, 0.04) 100%
|
||||
);
|
||||
animation: color-shift-fade 30s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes color-shift-fade {
|
||||
0% { opacity: 0; }
|
||||
15% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Aurora Underwater ── */
|
||||
.aurora {
|
||||
background:
|
||||
linear-gradient(0deg,
|
||||
transparent 0%,
|
||||
rgba(139, 92, 246, 0.04) 15%,
|
||||
rgba(34, 211, 238, 0.05) 25%,
|
||||
rgba(52, 211, 153, 0.04) 35%,
|
||||
transparent 50%
|
||||
);
|
||||
animation: aurora-wave 8s ease-in-out infinite alternate, aurora-fade 300s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes aurora-wave {
|
||||
from { background-position: 0 0; filter: hue-rotate(0deg); }
|
||||
to { background-position: 0 -20px; filter: hue-rotate(30deg); }
|
||||
}
|
||||
|
||||
@keyframes aurora-fade {
|
||||
0% { opacity: 0; }
|
||||
5% { opacity: 1; }
|
||||
95% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Mythical Creature (large serpent silhouette) ── */
|
||||
.mythical {
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='30' viewBox='0 0 160 30' shape-rendering='crispEdges'%3E%3Crect x='0' y='12' width='8' height='6' fill='%23312e81' opacity='0.2'/%3E%3Crect x='8' y='10' width='12' height='10' fill='%23312e81' opacity='0.22'/%3E%3Crect x='20' y='13' width='10' height='4' fill='%23312e81' opacity='0.2'/%3E%3Crect x='30' y='8' width='14' height='14' fill='%23312e81' opacity='0.25'/%3E%3Crect x='44' y='12' width='12' height='6' fill='%23312e81' opacity='0.2'/%3E%3Crect x='56' y='6' width='16' height='18' fill='%23312e81' opacity='0.28'/%3E%3Crect x='72' y='11' width='14' height='8' fill='%23312e81' opacity='0.22'/%3E%3Crect x='86' y='4' width='18' height='22' fill='%23312e81' opacity='0.3'/%3E%3Crect x='104' y='10' width='12' height='10' fill='%23312e81' opacity='0.25'/%3E%3Crect x='116' y='7' width='14' height='16' fill='%23312e81' opacity='0.27'/%3E%3Crect x='130' y='12' width='10' height='6' fill='%23312e81' opacity='0.2'/%3E%3Crect x='140' y='14' width='8' height='4' fill='%23312e81' opacity='0.18'/%3E%3Crect x='148' y='15' width='6' height='2' fill='%23312e81' opacity='0.15'/%3E%3Crect x='154' y='14' width='4' height='4' fill='%23312e81' opacity='0.18'/%3E%3Crect x='156' y='13' width='2' height='1' fill='%23818cf8' opacity='0.25'/%3E%3C/svg%3E") no-repeat / 240px 45px;
|
||||
animation: mythical-cross 30s linear forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes mythical-cross {
|
||||
0% { background-position: -260px 40%; opacity: 0; }
|
||||
5% { opacity: 0.7; }
|
||||
90% { opacity: 0.6; }
|
||||
100% { background-position: calc(100% + 260px) 35%; opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Volcanic Vent ── */
|
||||
.volcanic {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 30% 20% at 50% 95%,
|
||||
rgba(249, 115, 22, 0.15) 0%,
|
||||
rgba(239, 68, 68, 0.08) 40%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: volcanic-glow 4s ease-in-out infinite alternate, volcanic-fade 120s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes volcanic-glow {
|
||||
from { opacity: 0.6; filter: brightness(0.9); }
|
||||
to { opacity: 1; filter: brightness(1.1); }
|
||||
}
|
||||
|
||||
@keyframes volcanic-fade {
|
||||
0% { opacity: 0; }
|
||||
5% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Crystal Formation ── */
|
||||
.crystals {
|
||||
bottom: 0;
|
||||
height: 25%;
|
||||
top: auto;
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='60' viewBox='0 0 300 60' shape-rendering='crispEdges'%3E%3Crect x='40' y='30' width='4' height='20' fill='%2367e8f9' opacity='0.25'/%3E%3Crect x='42' y='25' width='2' height='8' fill='%23a5f3fc' opacity='0.2'/%3E%3Crect x='38' y='35' width='2' height='10' fill='%2322d3ee' opacity='0.18'/%3E%3Crect x='120' y='28' width='6' height='24' fill='%23a78bfa' opacity='0.22'/%3E%3Crect x='123' y='22' width='2' height='10' fill='%23c4b5fd' opacity='0.18'/%3E%3Crect x='118' y='32' width='2' height='14' fill='%238b5cf6' opacity='0.15'/%3E%3Crect x='200' y='32' width='4' height='18' fill='%2367e8f9' opacity='0.2'/%3E%3Crect x='202' y='28' width='2' height='6' fill='white' opacity='0.15'/%3E%3Crect x='260' y='35' width='5' height='16' fill='%23a78bfa' opacity='0.2'/%3E%3Crect x='262' y='30' width='2' height='8' fill='%23c4b5fd' opacity='0.16'/%3E%3C/svg%3E") repeat-x bottom center / 300px 60px;
|
||||
animation: crystal-appear 240s ease-in-out forwards, crystal-sparkle 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes crystal-appear {
|
||||
0% { opacity: 0; }
|
||||
5% { opacity: 1; }
|
||||
92% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes crystal-sparkle {
|
||||
from { filter: brightness(1); }
|
||||
to { filter: brightness(1.15); }
|
||||
}
|
||||
|
||||
/* ── Kelp Forest (extra seaweed) ── */
|
||||
.kelp-extra {
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='120' viewBox='0 0 300 120' shape-rendering='crispEdges'%3E%3Crect x='30' y='20' width='2' height='100' fill='%2316a34a' opacity='0.35'/%3E%3Crect x='32' y='14' width='2' height='20' fill='%2322c55e' opacity='0.3'/%3E%3Crect x='28' y='30' width='2' height='15' fill='%2315803d' opacity='0.28'/%3E%3Crect x='100' y='10' width='2' height='110' fill='%2322c55e' opacity='0.32'/%3E%3Crect x='102' y='5' width='2' height='18' fill='%234ade80' opacity='0.28'/%3E%3Crect x='98' y='25' width='2' height='12' fill='%2316a34a' opacity='0.25'/%3E%3Crect x='180' y='25' width='2' height='95' fill='%2316a34a' opacity='0.33'/%3E%3Crect x='182' y='18' width='2' height='16' fill='%2322c55e' opacity='0.28'/%3E%3Crect x='250' y='15' width='2' height='105' fill='%2322c55e' opacity='0.3'/%3E%3Crect x='252' y='8' width='2' height='14' fill='%234ade80' opacity='0.25'/%3E%3Crect x='248' y='30' width='2' height='10' fill='%2315803d' opacity='0.22'/%3E%3C/svg%3E") repeat-x bottom center / 300px 120px;
|
||||
animation: kelp-fade 150s ease-in-out forwards, kelp-sway 12s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes kelp-fade {
|
||||
0% { opacity: 0; }
|
||||
5% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes kelp-sway {
|
||||
from { transform: skewX(-1deg); }
|
||||
to { transform: skewX(1deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="fish-school">
|
||||
<div class="fish-main" />
|
||||
<div class="fish-secondary" />
|
||||
<div v-if="schoolActive" class="fish-event-school" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAquaticState } from '../useAquaticState'
|
||||
|
||||
const { activeEventModifiers } = useAquaticState()
|
||||
const schoolActive = computed(() => activeEventModifiers.value.has('school-of-fish'))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fish-school {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Fish 1: orange tropical (right-facing) + Fish 2: blue indigo (left-facing) */
|
||||
.fish-main {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
/* Orange tropical fish 16x10 */
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='10' viewBox='0 0 16 10' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='2' height='2' fill='%23f97316' opacity='0.65'/%3E%3Crect x='0' y='7' width='2' height='2' fill='%23f97316' opacity='0.65'/%3E%3Crect x='2' y='2' width='2' height='6' fill='%23fb923c' opacity='0.65'/%3E%3Crect x='4' y='1' width='8' height='8' fill='%23f97316' opacity='0.6'/%3E%3Crect x='7' y='1' width='2' height='8' fill='%23fef3c7' opacity='0.45'/%3E%3Crect x='5' y='0' width='4' height='1' fill='%23fb923c' opacity='0.45'/%3E%3Crect x='5' y='9' width='4' height='1' fill='%23fb923c' opacity='0.45'/%3E%3Crect x='10' y='3' width='2' height='2' fill='%23000' opacity='0.55'/%3E%3Crect x='11' y='3' width='1' height='1' fill='white' opacity='0.45'/%3E%3Crect x='12' y='5' width='2' height='1' fill='%23000' opacity='0.3'/%3E%3C/svg%3E") no-repeat / 32px 20px,
|
||||
/* Blue indigo fish 12x8 */
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' shape-rendering='crispEdges'%3E%3Crect x='10' y='1' width='2' height='2' fill='%236366f1' opacity='0.55'/%3E%3Crect x='10' y='5' width='2' height='2' fill='%236366f1' opacity='0.55'/%3E%3Crect x='8' y='2' width='2' height='4' fill='%23818cf8' opacity='0.55'/%3E%3Crect x='2' y='1' width='6' height='6' fill='%236366f1' opacity='0.5'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23000' opacity='0.45'/%3E%3Crect x='2' y='2' width='1' height='1' fill='white' opacity='0.35'/%3E%3Crect x='4' y='0' width='3' height='1' fill='%23818cf8' opacity='0.4'/%3E%3C/svg%3E") no-repeat / 24px 16px;
|
||||
animation: fish-swim 22s linear infinite;
|
||||
}
|
||||
|
||||
/* Green fish + Yellow puffer + Red clownfish */
|
||||
.fish-secondary {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
/* Green fish 10x6 (left-facing) */
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='2' height='1' fill='%2322c55e' opacity='0.5'/%3E%3Crect x='0' y='4' width='2' height='1' fill='%2322c55e' opacity='0.5'/%3E%3Crect x='2' y='1' width='1' height='4' fill='%234ade80' opacity='0.5'/%3E%3Crect x='3' y='0' width='5' height='6' fill='%2322c55e' opacity='0.45'/%3E%3Crect x='6' y='1' width='2' height='2' fill='%23000' opacity='0.4'/%3E%3Crect x='7' y='1' width='1' height='1' fill='white' opacity='0.3'/%3E%3Crect x='8' y='3' width='2' height='1' fill='%23000' opacity='0.25'/%3E%3C/svg%3E") no-repeat / 20px 12px,
|
||||
/* Yellow puffer 14x12 (right-facing) */
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='12' viewBox='0 0 14 12' shape-rendering='crispEdges'%3E%3Crect x='0' y='3' width='2' height='2' fill='%23fbbf24' opacity='0.5'/%3E%3Crect x='0' y='7' width='2' height='2' fill='%23fbbf24' opacity='0.5'/%3E%3Crect x='2' y='2' width='2' height='8' fill='%23f59e0b' opacity='0.5'/%3E%3Crect x='4' y='1' width='7' height='10' fill='%23fbbf24' opacity='0.48'/%3E%3Crect x='5' y='0' width='5' height='1' fill='%23f59e0b' opacity='0.35'/%3E%3Crect x='5' y='11' width='5' height='1' fill='%23f59e0b' opacity='0.35'/%3E%3Crect x='6' y='2' width='2' height='8' fill='%23fef3c7' opacity='0.3'/%3E%3Crect x='9' y='4' width='2' height='2' fill='%23000' opacity='0.5'/%3E%3Crect x='10' y='4' width='1' height='1' fill='white' opacity='0.35'/%3E%3Crect x='11' y='6' width='2' height='1' fill='%23000' opacity='0.3'/%3E%3Crect x='4' y='3' width='1' height='1' fill='%23f59e0b' opacity='0.25'/%3E%3Crect x='4' y='5' width='1' height='1' fill='%23f59e0b' opacity='0.25'/%3E%3Crect x='4' y='7' width='1' height='1' fill='%23f59e0b' opacity='0.25'/%3E%3C/svg%3E") no-repeat / 28px 24px,
|
||||
/* Red clownfish 12x8 (left-facing) */
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' shape-rendering='crispEdges'%3E%3Crect x='10' y='1' width='2' height='2' fill='%23ef4444' opacity='0.55'/%3E%3Crect x='10' y='5' width='2' height='2' fill='%23ef4444' opacity='0.55'/%3E%3Crect x='8' y='1' width='2' height='6' fill='%23f87171' opacity='0.55'/%3E%3Crect x='2' y='1' width='6' height='6' fill='%23ef4444' opacity='0.5'/%3E%3Crect x='4' y='1' width='1' height='6' fill='white' opacity='0.4'/%3E%3Crect x='7' y='1' width='1' height='6' fill='white' opacity='0.35'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23000' opacity='0.45'/%3E%3Crect x='2' y='2' width='1' height='1' fill='white' opacity='0.35'/%3E%3Crect x='2' y='0' width='4' height='1' fill='%23f87171' opacity='0.4'/%3E%3Crect x='2' y='7' width='4' height='1' fill='%23f87171' opacity='0.4'/%3E%3C/svg%3E") no-repeat / 24px 16px;
|
||||
animation: fish-swim-secondary 28s linear infinite;
|
||||
}
|
||||
|
||||
/* Event: school of fish - many small fast fish */
|
||||
.fish-event-school {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%2394a3b8' opacity='0.5'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%2394a3b8' opacity='0.5'/%3E%3Crect x='1' y='1' width='1' height='3' fill='%23cbd5e1' opacity='0.5'/%3E%3Crect x='2' y='0' width='4' height='5' fill='%2394a3b8' opacity='0.45'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.4'/%3E%3C/svg%3E") no-repeat / 16px 10px,
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%2394a3b8' opacity='0.45'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%2394a3b8' opacity='0.45'/%3E%3Crect x='1' y='1' width='1' height='3' fill='%23cbd5e1' opacity='0.45'/%3E%3Crect x='2' y='0' width='4' height='5' fill='%2394a3b8' opacity='0.4'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.35'/%3E%3C/svg%3E") no-repeat / 16px 10px,
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%2394a3b8' opacity='0.4'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%2394a3b8' opacity='0.4'/%3E%3Crect x='1' y='1' width='1' height='3' fill='%23cbd5e1' opacity='0.4'/%3E%3Crect x='2' y='0' width='4' height='5' fill='%2394a3b8' opacity='0.35'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.3'/%3E%3C/svg%3E") no-repeat / 16px 10px;
|
||||
animation: school-dash 8s linear forwards;
|
||||
}
|
||||
|
||||
@keyframes fish-swim {
|
||||
0% { background-position: -40px 22%, calc(100% + 30px) 52%; }
|
||||
100% { background-position: calc(100% + 40px) 28%, -30px 48%; }
|
||||
}
|
||||
|
||||
@keyframes fish-swim-secondary {
|
||||
0% { background-position: calc(100% + 25px) 68%, -35px 38%, calc(100% + 30px) 75%; }
|
||||
100% { background-position: -25px 62%, calc(100% + 35px) 42%, -30px 70%; }
|
||||
}
|
||||
|
||||
@keyframes school-dash {
|
||||
0% { background-position: -20px 30%, -30px 35%, -15px 40%; opacity: 0; }
|
||||
5% { opacity: 0.8; }
|
||||
90% { opacity: 0.7; }
|
||||
100% { background-position: calc(100% + 80px) 32%, calc(100% + 60px) 38%, calc(100% + 90px) 36%; opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { svgDataUri, jellyfishSvg } from '../svgPatterns'
|
||||
import { useAquaticState } from '../useAquaticState'
|
||||
|
||||
const { activeEventModifiers } = useAquaticState()
|
||||
const bioActive = computed(() => activeEventModifiers.value.has('bioluminescent-flash'))
|
||||
|
||||
const jelly1Bg = svgDataUri(jellyfishSvg('#c084fc', '#d8b4fe'))
|
||||
const jelly2Bg = svgDataUri(jellyfishSvg('#67e8f9', '#a5f3fc'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="jellyfish-drift" :class="{ bioluminescent: bioActive }">
|
||||
<div
|
||||
class="jelly jelly-1"
|
||||
:style="{ backgroundImage: jelly1Bg }"
|
||||
/>
|
||||
<div
|
||||
class="jelly jelly-2"
|
||||
:style="{ backgroundImage: jelly2Bg }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.jellyfish-drift {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jelly {
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 44px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.jelly-1 {
|
||||
left: 20%;
|
||||
animation:
|
||||
jelly-vertical 45s linear infinite,
|
||||
jelly-horizontal 8s ease-in-out infinite alternate,
|
||||
jelly-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.jelly-2 {
|
||||
left: 68%;
|
||||
animation:
|
||||
jelly-vertical 55s linear infinite -20s,
|
||||
jelly-horizontal 10s ease-in-out infinite alternate -4s,
|
||||
jelly-pulse 3.5s ease-in-out infinite -1.5s;
|
||||
}
|
||||
|
||||
.jellyfish-drift.bioluminescent .jelly {
|
||||
filter: drop-shadow(0 0 6px rgba(103, 232, 249, 0.6));
|
||||
}
|
||||
|
||||
@keyframes jelly-vertical {
|
||||
0% { top: -50px; }
|
||||
100% { top: calc(100% + 50px); }
|
||||
}
|
||||
|
||||
@keyframes jelly-horizontal {
|
||||
from { transform: translateX(-15px); }
|
||||
to { transform: translateX(15px); }
|
||||
}
|
||||
|
||||
@keyframes jelly-pulse {
|
||||
0%, 100% { transform: scaleY(1); }
|
||||
50% { transform: scaleY(0.9); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OceanScene — The unified static/dynamic background scene.
|
||||
*
|
||||
* Combines water gradient, light rays, sea floor, corals, seaweed, and
|
||||
* decorations into a single cohesive component. Everything here is the
|
||||
* "world" — the environment that all dynamic overlay layers (fish, bubbles,
|
||||
* jellyfish, events) float on top of.
|
||||
*
|
||||
* Internal z-index stacking (within this component):
|
||||
* 0 water-gradient
|
||||
* 1 light-rays
|
||||
* 2 sea-floor
|
||||
* 3 decorations (starfish, shells)
|
||||
* 4 corals
|
||||
* 5 seaweed
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { svgDataUri, coralSvg, seaweedSvg, starfishSvg, shellSvg } from '../svgPatterns'
|
||||
import { useAquaticState } from '../useAquaticState'
|
||||
|
||||
const { season, activeEventModifiers } = useAquaticState()
|
||||
|
||||
// ── Event reactivity ──
|
||||
const bloomActive = computed(() => activeEventModifiers.value.has('seasonal-bloom'))
|
||||
const currentActive = computed(() => activeEventModifiers.value.has('current-change'))
|
||||
const bioActive = computed(() => activeEventModifiers.value.has('bioluminescent-flash'))
|
||||
|
||||
// ── Season-dependent coral palettes ──
|
||||
const palette = computed(() => {
|
||||
switch (season.value) {
|
||||
case 'spring': return { branching: ['#f472b6', '#fbcfe8'], brain: ['#fbbf24', '#fef3c7'], fan: ['#a78bfa', '#ddd6fe'] }
|
||||
case 'summer': return { branching: ['#f87171', '#fda4af'], brain: ['#f97316', '#fed7aa'], fan: ['#a855f7', '#d8b4fe'] }
|
||||
case 'autumn': return { branching: ['#b45309', '#d97706'], brain: ['#92400e', '#b45309'], fan: ['#78716c', '#a8a29e'] }
|
||||
case 'winter': return { branching: ['#7dd3fc', '#bae6fd'], brain: ['#94a3b8', '#cbd5e1'], fan: ['#a5b4fc', '#c7d2fe'] }
|
||||
}
|
||||
})
|
||||
|
||||
// ── Corals (reactive to season) ──
|
||||
const corals = computed(() => [
|
||||
{ x: 12, bottom: 13, bg: svgDataUri(coralSvg('branching', palette.value.branching[0], palette.value.branching[1])), w: 32, h: 40, delay: '0s' },
|
||||
{ x: 35, bottom: 14, bg: svgDataUri(coralSvg('brain', palette.value.brain[0], palette.value.brain[1])), w: 24, h: 20, delay: '-3s' },
|
||||
{ x: 55, bottom: 12, bg: svgDataUri(coralSvg('fan', palette.value.fan[0], palette.value.fan[1])), w: 28, h: 36, delay: '-7s' },
|
||||
{ x: 75, bottom: 13, bg: svgDataUri(coralSvg('branching', palette.value.branching[1], palette.value.branching[0])), w: 30, h: 38, delay: '-5s' },
|
||||
{ x: 92, bottom: 14, bg: svgDataUri(coralSvg('brain', palette.value.brain[1], palette.value.brain[0])), w: 22, h: 18, delay: '-9s' },
|
||||
])
|
||||
|
||||
// ── Seaweed (reactive to current-change event) ──
|
||||
const stalks = [
|
||||
{ x: 10, height: 52, color: '#16a34a', accent: '#22c55e', dur: 9, delay: '0s' },
|
||||
{ x: 28, height: 60, color: '#22c55e', accent: '#4ade80', dur: 11, delay: '-2s' },
|
||||
{ x: 42, height: 48, color: '#15803d', accent: '#16a34a', dur: 8, delay: '-5s' },
|
||||
{ x: 62, height: 56, color: '#16a34a', accent: '#4ade80', dur: 12, delay: '-3s' },
|
||||
{ x: 78, height: 44, color: '#22c55e', accent: '#16a34a', dur: 10, delay: '-7s' },
|
||||
{ x: 90, height: 50, color: '#15803d', accent: '#22c55e', dur: 14, delay: '-1s' },
|
||||
]
|
||||
|
||||
const stalkStyles = computed(() =>
|
||||
stalks.map(s => ({
|
||||
left: s.x + '%',
|
||||
bottom: '10%',
|
||||
height: s.height + 'px',
|
||||
width: '10px',
|
||||
backgroundImage: svgDataUri(seaweedSvg(s.height, s.color, s.accent)),
|
||||
'--sway-duration': s.dur + 's',
|
||||
'--sway-amount': currentActive.value ? '6deg' : '3deg',
|
||||
animationDelay: s.delay,
|
||||
}))
|
||||
)
|
||||
|
||||
// ── Decorations (randomized once on mount) ──
|
||||
interface DecoItem {
|
||||
x: number
|
||||
bottom: number
|
||||
bg: string
|
||||
size: [number, number]
|
||||
}
|
||||
const decoItems = ref<DecoItem[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
const r = () => Math.random()
|
||||
decoItems.value = [
|
||||
{ x: 8 + r() * 15, bottom: 8 + r() * 5, bg: svgDataUri(starfishSvg('#f97316')), size: [16, 16] },
|
||||
{ x: 60 + r() * 20, bottom: 9 + r() * 4, bg: svgDataUri(starfishSvg('#fb923c')), size: [14, 14] },
|
||||
{ x: 25 + r() * 10, bottom: 10 + r() * 3, bg: svgDataUri(shellSvg('#fef3c7')), size: [12, 8] },
|
||||
{ x: 45 + r() * 10, bottom: 8 + r() * 4, bg: svgDataUri(shellSvg('#fde68a')), size: [10, 7] },
|
||||
{ x: 82 + r() * 10, bottom: 9 + r() * 3, bg: svgDataUri(shellSvg('#fcd34d')), size: [11, 7] },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ocean-scene" :class="{ bloom: bloomActive }">
|
||||
|
||||
<!-- Layer 0: Water depth gradient -->
|
||||
<div class="water-gradient" />
|
||||
|
||||
<!-- Layer 1: Surface light rays -->
|
||||
<div class="light-rays" :class="{ 'bio-tint': bioActive }" />
|
||||
|
||||
<!-- Layer 2: Sea floor (sand, rocks, pebbles) -->
|
||||
<div class="sea-floor" />
|
||||
|
||||
<!-- Layer 3: Decorations (starfish, shells on the floor) -->
|
||||
<div class="decorations">
|
||||
<div
|
||||
v-for="(d, i) in decoItems"
|
||||
:key="i"
|
||||
class="deco-item"
|
||||
:style="{
|
||||
left: d.x + '%',
|
||||
bottom: d.bottom + '%',
|
||||
width: d.size[0] + 'px',
|
||||
height: d.size[1] + 'px',
|
||||
backgroundImage: d.bg,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Layer 4: Coral reef -->
|
||||
<div class="coral-reef">
|
||||
<div
|
||||
v-for="(c, i) in corals"
|
||||
:key="i"
|
||||
class="coral"
|
||||
:style="{
|
||||
left: c.x + '%',
|
||||
bottom: c.bottom + '%',
|
||||
width: c.w + 'px',
|
||||
height: c.h + 'px',
|
||||
backgroundImage: c.bg,
|
||||
animationDelay: c.delay,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Layer 5: Seaweed field -->
|
||||
<div class="seaweed-field">
|
||||
<div
|
||||
v-for="(style, i) in stalkStyles"
|
||||
:key="i"
|
||||
class="seaweed-stalk"
|
||||
:style="style"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
OCEAN SCENE — Unified background world
|
||||
All scenery layers live here, working together as one cohesive environment.
|
||||
The parent .aquatic-bg provides .tod-* and .season-* classes.
|
||||
============================================================================ */
|
||||
|
||||
.ocean-scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ocean-scene > div {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Layer 0: Water depth gradient ──────────────────────────────────── */
|
||||
|
||||
.water-gradient {
|
||||
z-index: 0;
|
||||
transition: background 2s ease;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 6, 22, 0.97) 0%,
|
||||
rgba(0, 12, 35, 0.95) 10%,
|
||||
rgba(0, 20, 52, 0.93) 22%,
|
||||
rgba(0, 30, 65, 0.90) 35%,
|
||||
rgba(2, 42, 75, 0.87) 48%,
|
||||
rgba(4, 52, 78, 0.84) 60%,
|
||||
rgba(6, 58, 72, 0.82) 72%,
|
||||
rgba(10, 55, 58, 0.80) 82%,
|
||||
rgba(18, 50, 45, 0.78) 90%,
|
||||
rgba(28, 48, 35, 0.76) 100%
|
||||
);
|
||||
}
|
||||
|
||||
:global(.tod-twilight) .water-gradient {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(15, 5, 30, 0.96) 0%,
|
||||
rgba(20, 10, 45, 0.94) 10%,
|
||||
rgba(18, 15, 55, 0.92) 22%,
|
||||
rgba(12, 25, 65, 0.89) 35%,
|
||||
rgba(8, 38, 72, 0.86) 48%,
|
||||
rgba(6, 48, 75, 0.83) 60%,
|
||||
rgba(8, 52, 68, 0.80) 72%,
|
||||
rgba(12, 50, 55, 0.78) 82%,
|
||||
rgba(20, 48, 42, 0.76) 90%,
|
||||
rgba(28, 45, 32, 0.74) 100%
|
||||
);
|
||||
}
|
||||
|
||||
:global(.tod-day) .water-gradient {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(2, 18, 40, 0.92) 0%,
|
||||
rgba(4, 28, 55, 0.90) 10%,
|
||||
rgba(6, 38, 68, 0.88) 22%,
|
||||
rgba(8, 48, 78, 0.85) 35%,
|
||||
rgba(10, 58, 85, 0.82) 48%,
|
||||
rgba(12, 65, 82, 0.79) 60%,
|
||||
rgba(14, 68, 78, 0.76) 72%,
|
||||
rgba(16, 62, 65, 0.74) 82%,
|
||||
rgba(22, 58, 52, 0.72) 90%,
|
||||
rgba(30, 55, 40, 0.70) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Depth zone shifts */
|
||||
:global(.depth-surface) .water-gradient {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
:global(.depth-deep) .water-gradient {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
/* ── Layer 1: Light rays ────────────────────────────────────────────── */
|
||||
|
||||
.light-rays {
|
||||
z-index: 1;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
-20deg,
|
||||
transparent 0px,
|
||||
transparent 80px,
|
||||
rgba(103, 232, 249, 0.02) 80px,
|
||||
rgba(103, 232, 249, 0.025) 84px,
|
||||
transparent 84px,
|
||||
transparent 200px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-35deg,
|
||||
transparent 0px,
|
||||
transparent 120px,
|
||||
rgba(56, 189, 248, 0.015) 120px,
|
||||
rgba(56, 189, 248, 0.02) 123px,
|
||||
transparent 123px,
|
||||
transparent 300px
|
||||
);
|
||||
animation: light-pulse 10s ease-in-out infinite alternate;
|
||||
transition: opacity 2s ease;
|
||||
}
|
||||
|
||||
:global(.tod-night) .light-rays { opacity: 0.7; }
|
||||
:global(.tod-day) .light-rays { opacity: 1; }
|
||||
|
||||
/* Bioluminescent event: green light tint */
|
||||
.light-rays.bio-tint {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
-20deg,
|
||||
transparent 0px,
|
||||
transparent 80px,
|
||||
rgba(74, 222, 128, 0.03) 80px,
|
||||
rgba(74, 222, 128, 0.04) 84px,
|
||||
transparent 84px,
|
||||
transparent 200px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-35deg,
|
||||
transparent 0px,
|
||||
transparent 120px,
|
||||
rgba(34, 197, 94, 0.02) 120px,
|
||||
rgba(34, 197, 94, 0.03) 123px,
|
||||
transparent 123px,
|
||||
transparent 300px
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes light-pulse {
|
||||
0% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* ── Layer 2: Sea floor ─────────────────────────────────────────────── */
|
||||
|
||||
.sea-floor {
|
||||
z-index: 2;
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='480' height='160' viewBox='0 0 480 160' shape-rendering='crispEdges'%3E%3Crect x='0' y='120' width='480' height='40' fill='%23c2a060' opacity='0.55'/%3E%3Crect x='0' y='116' width='480' height='6' fill='%23d4b878' opacity='0.45'/%3E%3Crect x='0' y='114' width='480' height='4' fill='%23b89850' opacity='0.25'/%3E%3Crect x='15' y='124' width='30' height='2' fill='%23a89048' opacity='0.25'/%3E%3Crect x='80' y='128' width='20' height='2' fill='%23b8a060' opacity='0.2'/%3E%3Crect x='140' y='126' width='35' height='2' fill='%23a89048' opacity='0.25'/%3E%3Crect x='220' y='130' width='25' height='2' fill='%23b8a060' opacity='0.2'/%3E%3Crect x='290' y='124' width='30' height='2' fill='%23a89048' opacity='0.22'/%3E%3Crect x='360' y='128' width='22' height='2' fill='%23b8a060' opacity='0.2'/%3E%3Crect x='420' y='126' width='28' height='2' fill='%23a89048' opacity='0.22'/%3E%3Crect x='20' y='106' width='16' height='10' fill='%23475569' opacity='0.5'/%3E%3Crect x='22' y='104' width='12' height='4' fill='%2364748b' opacity='0.4'/%3E%3Crect x='24' y='102' width='6' height='4' fill='%23718096' opacity='0.3'/%3E%3Crect x='340' y='108' width='14' height='8' fill='%23475569' opacity='0.45'/%3E%3Crect x='342' y='106' width='10' height='4' fill='%2364748b' opacity='0.35'/%3E%3Crect x='344' y='104' width='4' height='4' fill='%23718096' opacity='0.25'/%3E%3Crect x='200' y='110' width='10' height='6' fill='%23475569' opacity='0.4'/%3E%3Crect x='202' y='108' width='6' height='4' fill='%2364748b' opacity='0.3'/%3E%3Crect x='40' y='118' width='2' height='2' fill='%2364748b' opacity='0.25'/%3E%3Crect x='75' y='120' width='2' height='2' fill='%2364748b' opacity='0.2'/%3E%3Crect x='110' y='118' width='2' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='150' y='120' width='2' height='2' fill='%2364748b' opacity='0.2'/%3E%3Crect x='215' y='118' width='2' height='2' fill='%23475569' opacity='0.22'/%3E%3Crect x='265' y='120' width='2' height='2' fill='%2364748b' opacity='0.18'/%3E%3Crect x='310' y='118' width='2' height='2' fill='%23475569' opacity='0.22'/%3E%3Crect x='355' y='120' width='2' height='2' fill='%2364748b' opacity='0.18'/%3E%3Crect x='385' y='118' width='2' height='2' fill='%23475569' opacity='0.2'/%3E%3Crect x='450' y='120' width='2' height='2' fill='%2364748b' opacity='0.18'/%3E%3C/svg%3E") repeat-x bottom center / 480px 160px;
|
||||
}
|
||||
|
||||
/* ── Layer 3: Decorations (starfish, shells) ────────────────────────── */
|
||||
|
||||
.decorations { z-index: 3; }
|
||||
|
||||
.deco-item {
|
||||
position: absolute;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ── Layer 4: Coral reef ────────────────────────────────────────────── */
|
||||
|
||||
.coral-reef {
|
||||
z-index: 4;
|
||||
transition: filter 3s ease;
|
||||
}
|
||||
|
||||
/* Seasonal bloom: corals glow and brighten */
|
||||
.ocean-scene.bloom .coral-reef {
|
||||
filter: brightness(1.3) saturate(1.2);
|
||||
}
|
||||
|
||||
/* Bloom also intensifies light rays */
|
||||
.ocean-scene.bloom .light-rays {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.coral {
|
||||
position: absolute;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
transform-origin: bottom center;
|
||||
animation: coral-sway 12s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes coral-sway {
|
||||
from { transform: rotate(-1deg); }
|
||||
to { transform: rotate(1deg); }
|
||||
}
|
||||
|
||||
/* ── Layer 5: Seaweed field ─────────────────────────────────────────── */
|
||||
|
||||
.seaweed-field { z-index: 5; }
|
||||
|
||||
.seaweed-stalk {
|
||||
position: absolute;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom center;
|
||||
transform-origin: bottom center;
|
||||
animation: seaweed-sway var(--sway-duration, 10s) ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Current-change event also speeds up coral sway */
|
||||
:global(.evt-current-change) .coral {
|
||||
animation-duration: 6s;
|
||||
}
|
||||
|
||||
@keyframes seaweed-sway {
|
||||
from { transform: skewX(calc(var(--sway-amount, 3deg) * -1)); }
|
||||
to { transform: skewX(var(--sway-amount, 3deg)); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAquaticState } from '../useAquaticState'
|
||||
|
||||
const { activeEventModifiers } = useAquaticState()
|
||||
|
||||
const pixelSeeds = Array.from({ length: 15 }, () => Math.random())
|
||||
|
||||
// All swimming creatures are rare — only stationary ones always visible
|
||||
const showSeahorse = ref(false)
|
||||
const showOctopus = ref(false)
|
||||
const showCrab = ref(false)
|
||||
const showEel = ref(false)
|
||||
const showPufferfish = ref(false)
|
||||
const showShrimp = ref(false)
|
||||
const showNautilus = ref(false)
|
||||
const showSwordfish = ref(false)
|
||||
const showStarfish = ref(false)
|
||||
const showAnchor = ref(false)
|
||||
const showSubmarine = ref(false)
|
||||
const showDiver = ref(false)
|
||||
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function tickPixelLife() {
|
||||
// Common creatures — low chance each tick
|
||||
showSeahorse.value = Math.random() < 0.06
|
||||
showOctopus.value = Math.random() < 0.05
|
||||
showCrab.value = Math.random() < 0.06
|
||||
showEel.value = Math.random() < 0.04
|
||||
showPufferfish.value = Math.random() < 0.05
|
||||
showShrimp.value = Math.random() < 0.06
|
||||
showNautilus.value = Math.random() < 0.04
|
||||
showSwordfish.value = Math.random() < 0.03
|
||||
showStarfish.value = Math.random() < 0.05
|
||||
|
||||
// Very rare creatures
|
||||
showAnchor.value = Math.random() < 0.01
|
||||
showSubmarine.value = Math.random() < 0.008
|
||||
showDiver.value = Math.random() < 0.015
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tickPixelLife()
|
||||
tickTimer = setInterval(tickPixelLife, 60000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (tickTimer) clearInterval(tickTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pixel-life">
|
||||
<!-- Swimming creatures (all rare) -->
|
||||
<i v-if="showSeahorse" class="px seahorse" :style="{ animationDelay: `-${pixelSeeds[0] * 160}s` }"></i>
|
||||
<i v-if="showOctopus" class="px octopus" :style="{ animationDelay: `-${pixelSeeds[1] * 200}s` }"></i>
|
||||
<i v-if="showCrab" class="px crab" :style="{ animationDelay: `-${pixelSeeds[2] * 120}s` }"></i>
|
||||
<i v-if="showEel" class="px eel" :style="{ animationDelay: `-${pixelSeeds[3] * 70}s` }"></i>
|
||||
<i v-if="showPufferfish" class="px pufferfish" :style="{ animationDelay: `-${pixelSeeds[4] * 140}s` }"></i>
|
||||
<i v-if="showShrimp" class="px shrimp" :style="{ animationDelay: `-${pixelSeeds[5] * 50}s` }"></i>
|
||||
<i v-if="showNautilus" class="px nautilus" :style="{ animationDelay: `-${pixelSeeds[6] * 240}s` }"></i>
|
||||
<i v-if="showSwordfish" class="px swordfish" :style="{ animationDelay: `-${pixelSeeds[7] * 25}s` }"></i>
|
||||
<i v-if="showStarfish" class="px starfish-walk" :style="{ animationDelay: `-${pixelSeeds[8] * 360}s` }"></i>
|
||||
|
||||
<!-- Stationary creatures (always visible) -->
|
||||
<i class="px treasure"></i>
|
||||
<i class="px sea-anemone"></i>
|
||||
<i class="px clam"></i>
|
||||
|
||||
<!-- Very rare creatures -->
|
||||
<i v-if="showAnchor" class="px anchor"></i>
|
||||
<i v-if="showSubmarine" class="px submarine" :style="{ animationDelay: `-${pixelSeeds[11] * 320}s` }"></i>
|
||||
<i v-if="showDiver" class="px diver" :style="{ animationDelay: `-${pixelSeeds[12] * 180}s` }"></i>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
Pixel Life — box-shadow pixel art creatures for the main background
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.pixel-life {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.px {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
background: transparent;
|
||||
font-style: normal;
|
||||
transform: scale(3);
|
||||
transform-origin: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Keyframes ── */
|
||||
|
||||
@keyframes px-swim-left {
|
||||
from { left: calc(100% + 20px); }
|
||||
to { left: -20px; }
|
||||
}
|
||||
|
||||
@keyframes px-swim-right {
|
||||
from { left: -20px; }
|
||||
to { left: calc(100% + 20px); }
|
||||
}
|
||||
|
||||
@keyframes px-bob {
|
||||
0%, 100% { transform: scale(3) translateY(0); }
|
||||
50% { transform: scale(3) translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes px-sine {
|
||||
0% { transform: scale(3) translateY(0); }
|
||||
25% { transform: scale(3) translateY(-6px); }
|
||||
50% { transform: scale(3) translateY(0); }
|
||||
75% { transform: scale(3) translateY(6px); }
|
||||
100% { transform: scale(3) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes px-sink {
|
||||
from { top: -10px; }
|
||||
to { top: calc(100% + 10px); }
|
||||
}
|
||||
|
||||
@keyframes px-puff {
|
||||
0%, 100% { transform: scale(3); }
|
||||
50% { transform: scale(4.5); }
|
||||
}
|
||||
|
||||
@keyframes px-pulse-glow {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes px-tentacle {
|
||||
0%, 100% { transform: scale(3) translateY(0) skewX(0deg); }
|
||||
25% { transform: scale(3) translateY(-2px) skewX(2deg); }
|
||||
75% { transform: scale(3) translateY(2px) skewX(-2deg); }
|
||||
}
|
||||
|
||||
@keyframes px-clam-open {
|
||||
0%, 100% { transform: scale(3) scaleY(1); }
|
||||
50% { transform: scale(3) scaleY(0.5); }
|
||||
}
|
||||
|
||||
/* ── 1. Seahorse — yellow-orange, gentle bob, swims left ── */
|
||||
.seahorse {
|
||||
top: 40%;
|
||||
animation:
|
||||
px-swim-left 160s linear infinite,
|
||||
px-bob 8s ease-in-out infinite;
|
||||
box-shadow:
|
||||
1px -2px #fbbf24, 2px -2px #fbbf24,
|
||||
0 -1px #f59e0b, 1px -1px #f59e0b, 2px -1px #f59e0b,
|
||||
0 0 #f59e0b, 1px 0 #d97706,
|
||||
0 1px #d97706, 1px 1px #d97706,
|
||||
0 2px #b45309, 1px 2px #b45309,
|
||||
-1px 3px #b45309, 0 3px #92400e,
|
||||
-1px 4px #92400e;
|
||||
}
|
||||
|
||||
/* ── 2. Octopus — purple-red, 4 trailing tentacles, swims right ── */
|
||||
.octopus {
|
||||
top: 55%;
|
||||
animation: px-swim-right 200s linear infinite;
|
||||
box-shadow:
|
||||
1px -1px #a855f7, 2px -1px #a855f7,
|
||||
0 0 #9333ea, 1px 0 #9333ea, 2px 0 #9333ea, 3px 0 #a855f7,
|
||||
0 1px #7c3aed, 1px 1px #7c3aed, 2px 1px #7c3aed, 3px 1px #7c3aed,
|
||||
-1px 2px #6d28d9, 0 2px #7c3aed, 2px 2px #7c3aed, 3px 2px #6d28d9,
|
||||
-1px 3px #5b21b6, 1px 3px #6d28d9, 3px 3px #5b21b6,
|
||||
-2px 4px #4c1d95, 0 4px #5b21b6, 2px 4px #5b21b6, 4px 4px #4c1d95;
|
||||
}
|
||||
|
||||
/* ── 3. Crab — red-orange, walking along bottom ── */
|
||||
.crab {
|
||||
top: 85%;
|
||||
animation: px-swim-right 120s linear infinite;
|
||||
box-shadow:
|
||||
-2px -1px #dc2626, 4px -1px #dc2626,
|
||||
-2px 0 #ef4444, -1px 0 #ef4444, 4px 0 #ef4444, 3px 0 #ef4444,
|
||||
0 0 #b91c1c, 1px 0 #dc2626, 2px 0 #dc2626,
|
||||
0 1px #991b1b, 1px 1px #b91c1c, 2px 1px #b91c1c,
|
||||
-1px 2px #7f1d1d, 0 2px #991b1b, 2px 2px #991b1b, 3px 2px #7f1d1d;
|
||||
}
|
||||
|
||||
/* ── 4. Eel — green-dark, sinuous, swims left ── */
|
||||
.eel {
|
||||
top: 45%;
|
||||
animation:
|
||||
px-swim-left 70s linear infinite,
|
||||
px-sine 4s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 0 #16a34a, 1px 0 #15803d, 2px 0 #166534, 3px 0 #14532d,
|
||||
4px 0 #166534, 5px 0 #15803d, 6px 0 #16a34a, 7px 0 #15803d,
|
||||
1px 1px #22c55e, 2px 1px #22c55e, 3px 1px #22c55e, 5px 1px #22c55e, 6px 1px #22c55e,
|
||||
0 -1px #fbbf24;
|
||||
}
|
||||
|
||||
/* ── 5. Pufferfish — yellow, inflates via scale pulse ── */
|
||||
.pufferfish {
|
||||
top: 35%;
|
||||
animation:
|
||||
px-swim-left 140s linear infinite,
|
||||
px-puff 10s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #fbbf24, 1px -1px #fbbf24,
|
||||
-1px 0 #f59e0b, 0 0 #eab308, 1px 0 #eab308, 2px 0 #f59e0b,
|
||||
-1px 1px #f59e0b, 0 1px #eab308, 1px 1px #eab308, 2px 1px #f59e0b,
|
||||
0 2px #d97706, 1px 2px #d97706,
|
||||
-2px 0 #ca8a04, 3px 0 #ca8a04, 0 -2px #ca8a04, 1px -2px #ca8a04,
|
||||
0 0 #1e293b;
|
||||
}
|
||||
|
||||
/* ── 6. Shrimp — pink-red, dart near bottom ── */
|
||||
.shrimp {
|
||||
top: 80%;
|
||||
animation: px-swim-right 50s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #fb7185, 1px 0 #f43f5e, 2px 0 #e11d48, 3px 0 #be123c,
|
||||
0 1px #fda4af, 1px 1px #fb7185, 2px 1px #f43f5e,
|
||||
-1px -1px rgba(251,113,133,0.6), -2px -2px rgba(251,113,133,0.3),
|
||||
4px 0 #9f1239, 5px -1px #881337;
|
||||
}
|
||||
|
||||
/* ── 7. Nautilus — cream-brown spiral shell, swims left ── */
|
||||
.nautilus {
|
||||
top: 50%;
|
||||
animation:
|
||||
px-swim-left 240s linear infinite,
|
||||
px-bob 12s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #d4a574, 1px -1px #c2956a,
|
||||
-1px 0 #d4a574, 0 0 #b08050, 1px 0 #a0714a, 2px 0 #c2956a,
|
||||
-1px 1px #c2956a, 0 1px #8b6040, 1px 1px #a0714a, 2px 1px #b08050,
|
||||
0 2px #c2956a, 1px 2px #d4a574,
|
||||
-2px 1px #fef3c7, -2px 2px #fde68a;
|
||||
}
|
||||
|
||||
/* ── 8. Swordfish — silver-blue, fast streak ── */
|
||||
.swordfish {
|
||||
top: 25%;
|
||||
animation: px-swim-left 25s linear infinite;
|
||||
box-shadow:
|
||||
-3px 0 #94a3b8, -2px 0 #94a3b8,
|
||||
-1px 0 #64748b, 0 0 #475569, 1px 0 #475569,
|
||||
2px 0 #334155, 3px 0 #334155, 4px 0 #475569, 5px 0 #64748b,
|
||||
1px 1px #94a3b8, 2px 1px #94a3b8, 3px 1px #94a3b8, 4px 1px #94a3b8,
|
||||
2px -1px #334155, 3px -1px #334155,
|
||||
6px -1px #475569, 6px 1px #475569;
|
||||
}
|
||||
|
||||
/* ── 9. Starfish — orange, very slow crawl along floor ── */
|
||||
.starfish-walk {
|
||||
top: 88%;
|
||||
animation: px-swim-right 360s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #f97316, 1px 0 #ea580c,
|
||||
0 -2px #fb923c, 1px -2px #fb923c,
|
||||
-2px 0 #fb923c, 3px 0 #fb923c,
|
||||
-1px 2px #fb923c, 2px 2px #fb923c,
|
||||
0 -1px #f97316, 1px -1px #f97316,
|
||||
-1px 0 #f97316, 2px 0 #f97316,
|
||||
0 1px #ea580c, 1px 1px #ea580c,
|
||||
-1px 1px #f97316, 2px 1px #f97316;
|
||||
}
|
||||
|
||||
/* ── 10. Anchor — grey iron, sinks slowly (very rare) ── */
|
||||
.anchor {
|
||||
left: 70%;
|
||||
animation: px-sink 100s linear forwards;
|
||||
box-shadow:
|
||||
0 -3px #6b7280, 1px -3px #6b7280,
|
||||
-1px -2px #6b7280, 2px -2px #6b7280,
|
||||
0 -2px #9ca3af, 1px -2px #9ca3af,
|
||||
0 -1px #4b5563, 1px -1px #4b5563,
|
||||
0 0 #4b5563, 1px 0 #4b5563,
|
||||
0 1px #4b5563, 1px 1px #4b5563,
|
||||
0 2px #374151, 1px 2px #374151,
|
||||
-2px 2px #6b7280, -1px 2px #4b5563, 2px 2px #4b5563, 3px 2px #6b7280,
|
||||
-2px 3px #374151, 3px 3px #374151,
|
||||
-1px 3px #4b5563, 2px 3px #4b5563;
|
||||
}
|
||||
|
||||
/* ── 11. Treasure — brown chest with gold gleam (stationary) ── */
|
||||
.treasure {
|
||||
top: 90%;
|
||||
left: 25%;
|
||||
animation: px-pulse-glow 10s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #92400e, 1px -1px #92400e, 2px -1px #92400e, 3px -1px #92400e,
|
||||
-1px 0 #78350f, 0 0 #78350f, 1px 0 #92400e, 2px 0 #92400e, 3px 0 #78350f, 4px 0 #78350f,
|
||||
-1px 1px #78350f, 0 1px #78350f, 1px 1px #92400e, 2px 1px #92400e, 3px 1px #78350f, 4px 1px #78350f,
|
||||
1px 0 #fbbf24, 2px 0 #fbbf24,
|
||||
1px -1px #fde68a;
|
||||
}
|
||||
|
||||
/* ── 12. Submarine — grey with yellow light (very rare), swims right ── */
|
||||
.submarine {
|
||||
top: 30%;
|
||||
animation: px-swim-right 320s linear infinite;
|
||||
box-shadow:
|
||||
3px -2px #6b7280, 3px -1px #6b7280,
|
||||
0 0 #4b5563, 1px 0 #4b5563, 2px 0 #4b5563, 3px 0 #4b5563, 4px 0 #4b5563, 5px 0 #4b5563,
|
||||
-1px 1px #374151, 0 1px #374151, 1px 1px #374151, 2px 1px #374151, 3px 1px #374151, 4px 1px #374151, 5px 1px #374151, 6px 1px #374151,
|
||||
0 2px #4b5563, 1px 2px #4b5563, 2px 2px #4b5563, 3px 2px #4b5563, 4px 2px #4b5563, 5px 2px #4b5563,
|
||||
1px 1px #fbbf24, 4px 1px #fbbf24,
|
||||
-2px 0 #9ca3af, -2px 1px #9ca3af, -2px 2px #9ca3af;
|
||||
}
|
||||
|
||||
/* ── 13. Diver — black suit, white mask, bubble trail (very rare) ── */
|
||||
.diver {
|
||||
top: 40%;
|
||||
animation: px-swim-left 180s linear infinite;
|
||||
box-shadow:
|
||||
0 -1px #f8fafc, 1px -1px #f8fafc,
|
||||
0 0 #1e293b, 1px 0 #1e293b,
|
||||
0 1px #1e293b, 1px 1px #1e293b,
|
||||
0 2px #0f172a, 1px 2px #0f172a,
|
||||
-1px 3px #1e293b, 2px 3px #1e293b,
|
||||
2px 0 #475569, 2px 1px #475569,
|
||||
-1px -2px rgba(255,255,255,0.5), -2px -3px rgba(255,255,255,0.3), -3px -4px rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* ── 14. Sea Anemone — pink-purple, tentacle wave (stationary) ── */
|
||||
.sea-anemone {
|
||||
top: 92%;
|
||||
left: 60%;
|
||||
animation: px-tentacle 12s ease-in-out infinite;
|
||||
box-shadow:
|
||||
-1px -2px #e879f9, 0 -2px #d946ef, 1px -2px #c026d3, 2px -2px #e879f9,
|
||||
-1px -1px #d946ef, 0 -1px #c026d3, 1px -1px #d946ef, 2px -1px #c026d3,
|
||||
0 0 #a21caf, 1px 0 #a21caf,
|
||||
0 1px #86198f, 1px 1px #86198f;
|
||||
}
|
||||
|
||||
/* ── 15. Clam — grey shell, pearl gleam inside (stationary) ── */
|
||||
.clam {
|
||||
top: 90%;
|
||||
left: 82%;
|
||||
animation: px-clam-open 16s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #9ca3af, 1px -1px #9ca3af, 2px -1px #9ca3af,
|
||||
-1px 0 #6b7280, 0 0 #6b7280, 1px 0 #6b7280, 2px 0 #6b7280, 3px 0 #6b7280,
|
||||
-1px 1px #4b5563, 0 1px #4b5563, 1px 1px #4b5563, 2px 1px #4b5563, 3px 1px #4b5563,
|
||||
0 2px #6b7280, 1px 2px #6b7280, 2px 2px #6b7280,
|
||||
1px 0 #fef3c7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
// The unified background scene (gradient + light + floor + corals + seaweed + decorations)
|
||||
export { default as OceanScene } from './OceanScene.vue'
|
||||
|
||||
// Independent dynamic overlay layers
|
||||
export { default as BubbleStream } from './BubbleStream.vue'
|
||||
export { default as FishSchool } from './FishSchool.vue'
|
||||
export { default as JellyfishDrift } from './JellyfishDrift.vue'
|
||||
export { default as EventOverlay } from './EventOverlay.vue'
|
||||
export { default as EdgeFade } from './EdgeFade.vue'
|
||||
export { default as PixelLife } from './PixelLife.vue'
|
||||
@@ -0,0 +1,139 @@
|
||||
/** Encode an SVG string to a CSS-usable data URI */
|
||||
export function svgDataUri(svg: string): string {
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
|
||||
}
|
||||
|
||||
/** Generate a pixel art fish SVG */
|
||||
export function fishSvg(opts: {
|
||||
w: number
|
||||
h: number
|
||||
body: string
|
||||
accent: string
|
||||
eye?: string
|
||||
facing?: 'left' | 'right'
|
||||
}): string {
|
||||
const { w, h, body, accent, facing = 'right' } = opts
|
||||
const eye = opts.eye ?? '#000'
|
||||
// Build a generic pixel fish: body rect, tail, eye
|
||||
const hw = Math.floor(w / 2)
|
||||
const hh = Math.floor(h / 2)
|
||||
const tailW = Math.floor(w * 0.2)
|
||||
const bodyW = w - tailW
|
||||
const eyeX = facing === 'right' ? bodyW - 3 : tailW + 1
|
||||
const tailX = facing === 'right' ? 0 : bodyW
|
||||
|
||||
return `<svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}' viewBox='0 0 ${w} ${h}' shape-rendering='crispEdges'>`
|
||||
// Tail
|
||||
+ `<rect x='${tailX}' y='1' width='${tailW}' height='${Math.floor(h * 0.3)}' fill='${accent}' opacity='0.6'/>`
|
||||
+ `<rect x='${tailX}' y='${h - Math.floor(h * 0.3) - 1}' width='${tailW}' height='${Math.floor(h * 0.3)}' fill='${accent}' opacity='0.6'/>`
|
||||
// Body
|
||||
+ `<rect x='${facing === 'right' ? tailW : 0}' y='1' width='${bodyW}' height='${h - 2}' fill='${body}' opacity='0.55'/>`
|
||||
// Stripe
|
||||
+ `<rect x='${hw - 1}' y='1' width='2' height='${h - 2}' fill='${accent}' opacity='0.35'/>`
|
||||
// Eye
|
||||
+ `<rect x='${eyeX}' y='${hh - 1}' width='2' height='2' fill='${eye}' opacity='0.5'/>`
|
||||
+ `<rect x='${eyeX + (facing === 'right' ? 1 : 0)}' y='${hh - 1}' width='1' height='1' fill='white' opacity='0.35'/>`
|
||||
+ `</svg>`
|
||||
}
|
||||
|
||||
/** Generate a coral SVG of a given type */
|
||||
export function coralSvg(type: 'branching' | 'brain' | 'fan', color: string, accent: string): string {
|
||||
if (type === 'branching') {
|
||||
return `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='20' viewBox='0 0 16 20' shape-rendering='crispEdges'>`
|
||||
+ `<rect x='7' y='8' width='2' height='12' fill='${color}' opacity='0.55'/>`
|
||||
+ `<rect x='5' y='6' width='2' height='8' fill='${color}' opacity='0.5'/>`
|
||||
+ `<rect x='9' y='5' width='2' height='9' fill='${color}' opacity='0.5'/>`
|
||||
+ `<rect x='3' y='3' width='2' height='6' fill='${accent}' opacity='0.45'/>`
|
||||
+ `<rect x='11' y='2' width='2' height='7' fill='${accent}' opacity='0.45'/>`
|
||||
+ `<rect x='5' y='4' width='2' height='2' fill='${accent}' opacity='0.4'/>`
|
||||
+ `<rect x='9' y='3' width='2' height='2' fill='${accent}' opacity='0.4'/>`
|
||||
+ `<rect x='1' y='1' width='2' height='4' fill='${accent}' opacity='0.35'/>`
|
||||
+ `<rect x='13' y='0' width='2' height='5' fill='${accent}' opacity='0.35'/>`
|
||||
+ `</svg>`
|
||||
}
|
||||
if (type === 'brain') {
|
||||
return `<svg xmlns='http://www.w3.org/2000/svg' width='12' height='10' viewBox='0 0 12 10' shape-rendering='crispEdges'>`
|
||||
+ `<rect x='2' y='3' width='8' height='6' rx='0' fill='${color}' opacity='0.5'/>`
|
||||
+ `<rect x='3' y='2' width='6' height='2' fill='${color}' opacity='0.45'/>`
|
||||
+ `<rect x='4' y='1' width='4' height='2' fill='${accent}' opacity='0.4'/>`
|
||||
+ `<rect x='3' y='4' width='2' height='2' fill='${accent}' opacity='0.3'/>`
|
||||
+ `<rect x='7' y='5' width='2' height='2' fill='${accent}' opacity='0.3'/>`
|
||||
+ `<rect x='5' y='7' width='2' height='2' fill='${accent}' opacity='0.25'/>`
|
||||
+ `</svg>`
|
||||
}
|
||||
// fan
|
||||
return `<svg xmlns='http://www.w3.org/2000/svg' width='14' height='18' viewBox='0 0 14 18' shape-rendering='crispEdges'>`
|
||||
+ `<rect x='6' y='12' width='2' height='6' fill='${color}' opacity='0.5'/>`
|
||||
+ `<rect x='4' y='8' width='6' height='5' fill='${color}' opacity='0.45'/>`
|
||||
+ `<rect x='3' y='5' width='8' height='4' fill='${accent}' opacity='0.4'/>`
|
||||
+ `<rect x='2' y='2' width='10' height='4' fill='${accent}' opacity='0.35'/>`
|
||||
+ `<rect x='4' y='0' width='6' height='3' fill='${accent}' opacity='0.3'/>`
|
||||
+ `<rect x='5' y='6' width='2' height='2' fill='${color}' opacity='0.25'/>`
|
||||
+ `<rect x='8' y='4' width='2' height='2' fill='${color}' opacity='0.25'/>`
|
||||
+ `</svg>`
|
||||
}
|
||||
|
||||
/** Generate a seaweed stalk SVG */
|
||||
export function seaweedSvg(height: number, color: string, accent: string): string {
|
||||
const segments: string[] = []
|
||||
for (let y = 0; y < height; y += 4) {
|
||||
const xOff = (y / 4) % 2 === 0 ? 0 : 1
|
||||
const c = y % 8 === 0 ? color : accent
|
||||
const op = 0.3 + (y / height) * 0.25
|
||||
segments.push(`<rect x='${1 + xOff}' y='${y}' width='2' height='4' fill='${c}' opacity='${op.toFixed(2)}'/>`)
|
||||
}
|
||||
// Leaf accents every 12px
|
||||
for (let y = 6; y < height - 4; y += 12) {
|
||||
const side = (y / 12) % 2 === 0 ? 0 : 3
|
||||
segments.push(`<rect x='${side}' y='${y}' width='2' height='3' fill='${accent}' opacity='0.3'/>`)
|
||||
}
|
||||
return `<svg xmlns='http://www.w3.org/2000/svg' width='5' height='${height}' viewBox='0 0 5 ${height}' shape-rendering='crispEdges'>`
|
||||
+ segments.join('')
|
||||
+ `</svg>`
|
||||
}
|
||||
|
||||
/** Generate a jellyfish SVG */
|
||||
export function jellyfishSvg(bell: string, tentacle: string): string {
|
||||
return `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='22' viewBox='0 0 16 22' shape-rendering='crispEdges'>`
|
||||
// Bell
|
||||
+ `<rect x='4' y='0' width='8' height='2' fill='${bell}' opacity='0.4'/>`
|
||||
+ `<rect x='2' y='2' width='12' height='2' fill='${bell}' opacity='0.45'/>`
|
||||
+ `<rect x='1' y='4' width='14' height='4' fill='${bell}' opacity='0.5'/>`
|
||||
+ `<rect x='2' y='8' width='12' height='2' fill='${bell}' opacity='0.45'/>`
|
||||
// Inner glow
|
||||
+ `<rect x='5' y='4' width='6' height='3' fill='white' opacity='0.15'/>`
|
||||
// Tentacles
|
||||
+ `<rect x='3' y='10' width='1' height='8' fill='${tentacle}' opacity='0.35'/>`
|
||||
+ `<rect x='6' y='10' width='1' height='10' fill='${tentacle}' opacity='0.3'/>`
|
||||
+ `<rect x='9' y='10' width='1' height='9' fill='${tentacle}' opacity='0.32'/>`
|
||||
+ `<rect x='12' y='10' width='1' height='7' fill='${tentacle}' opacity='0.28'/>`
|
||||
// Tentacle wiggles
|
||||
+ `<rect x='2' y='14' width='1' height='2' fill='${tentacle}' opacity='0.25'/>`
|
||||
+ `<rect x='7' y='16' width='1' height='2' fill='${tentacle}' opacity='0.22'/>`
|
||||
+ `<rect x='10' y='15' width='1' height='2' fill='${tentacle}' opacity='0.22'/>`
|
||||
+ `</svg>`
|
||||
}
|
||||
|
||||
/** Generate a starfish SVG */
|
||||
export function starfishSvg(color: string): string {
|
||||
return `<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8' shape-rendering='crispEdges'>`
|
||||
+ `<rect x='3' y='0' width='2' height='3' fill='${color}' opacity='0.45'/>`
|
||||
+ `<rect x='0' y='3' width='3' height='2' fill='${color}' opacity='0.45'/>`
|
||||
+ `<rect x='5' y='3' width='3' height='2' fill='${color}' opacity='0.45'/>`
|
||||
+ `<rect x='2' y='2' width='4' height='4' fill='${color}' opacity='0.5'/>`
|
||||
+ `<rect x='1' y='5' width='2' height='2' fill='${color}' opacity='0.4'/>`
|
||||
+ `<rect x='5' y='5' width='2' height='2' fill='${color}' opacity='0.4'/>`
|
||||
+ `<rect x='3' y='3' width='2' height='2' fill='white' opacity='0.15'/>`
|
||||
+ `</svg>`
|
||||
}
|
||||
|
||||
/** Generate a small shell SVG */
|
||||
export function shellSvg(color: string): string {
|
||||
return `<svg xmlns='http://www.w3.org/2000/svg' width='6' height='4' viewBox='0 0 6 4' shape-rendering='crispEdges'>`
|
||||
+ `<rect x='1' y='2' width='4' height='2' fill='${color}' opacity='0.4'/>`
|
||||
+ `<rect x='2' y='1' width='2' height='1' fill='${color}' opacity='0.35'/>`
|
||||
+ `<rect x='0' y='3' width='1' height='1' fill='${color}' opacity='0.3'/>`
|
||||
+ `<rect x='5' y='3' width='1' height='1' fill='${color}' opacity='0.3'/>`
|
||||
+ `<rect x='2' y='2' width='1' height='1' fill='white' opacity='0.15'/>`
|
||||
+ `</svg>`
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type EventFrequency = 'minutes' | 'hours' | 'days' | 'months'
|
||||
export type TimeOfDay = 'day' | 'twilight' | 'night'
|
||||
export type Season = 'spring' | 'summer' | 'autumn' | 'winter'
|
||||
export type DepthZone = 'surface' | 'midwater' | 'deep'
|
||||
|
||||
export interface AquaticEvent {
|
||||
id: string
|
||||
name: string
|
||||
frequency: EventFrequency
|
||||
/** Min interval in milliseconds */
|
||||
minInterval: number
|
||||
/** Max interval in milliseconds */
|
||||
maxInterval: number
|
||||
/** Duration of the event in milliseconds */
|
||||
duration: number
|
||||
/** CSS class applied to EventOverlay when active */
|
||||
cssClass: string
|
||||
}
|
||||
|
||||
export interface ActiveEvent {
|
||||
event: AquaticEvent
|
||||
startedAt: number
|
||||
endsAt: number
|
||||
}
|
||||