Compare commits
132 Commits
607527d98d
...
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 |
21
.claude-ejecutor/.gitignore
vendored
Normal file
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
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
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
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
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
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
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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -45,11 +45,180 @@
|
||||
"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-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
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
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
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
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 |
@@ -1 +0,0 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "suppress-warnings.js",
|
||||
"revision": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.24e3u5ntq78"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/],
|
||||
denylist: [/^\/api\//]
|
||||
}));
|
||||
|
||||
}));
|
||||
//# sourceMappingURL=sw.js.map
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#16161d" />
|
||||
<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" />
|
||||
|
||||
298
frontend/package-lock.json
generated
298
frontend/package-lock.json
generated
@@ -9,8 +9,16 @@
|
||||
"version": "0.0.0",
|
||||
"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",
|
||||
@@ -18,6 +26,7 @@
|
||||
"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",
|
||||
@@ -1915,7 +1924,7 @@
|
||||
},
|
||||
"node_modules/@nucleoriofrio/webmcp": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git#cec5be355d67e0cf9049380ece74e9eac0e85f5e",
|
||||
"resolved": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git#870207f15199369bc262d27ce0f90a27f2854be4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||
@@ -2414,6 +2423,287 @@
|
||||
"sourcemap-codec": "^1.4.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz",
|
||||
"integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.10.0",
|
||||
"@tauri-apps/cli-darwin-x64": "2.10.0",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.10.0",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.10.0",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.10.0",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz",
|
||||
"integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
|
||||
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
|
||||
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
|
||||
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
|
||||
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
|
||||
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
|
||||
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
|
||||
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
|
||||
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
|
||||
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
|
||||
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-clipboard-manager": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz",
|
||||
"integrity": "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-global-shortcut": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz",
|
||||
"integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-http": {
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
|
||||
"integrity": "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-store": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz",
|
||||
"integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"license": "MIT"
|
||||
@@ -2624,6 +2914,12 @@
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
|
||||
@@ -8,12 +8,29 @@
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"generate-icons": "node scripts/generate-icons.js"
|
||||
"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",
|
||||
@@ -21,6 +38,7 @@
|
||||
"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",
|
||||
|
||||
56
frontend/public/loading.html
Normal file
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>
|
||||
1441
frontend/src/App.vue
1441
frontend/src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -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
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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import { connectWithToken, getConnectionInfo } from '../services/webmcp'
|
||||
@@ -10,6 +10,18 @@ const { isConnected, isReconnecting, connectionStatus, connectionInfo } = storeT
|
||||
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...'
|
||||
@@ -73,7 +85,7 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="connection-dropdown-container">
|
||||
<button class="dropdown-trigger" @click.stop="toggleDropdown" title="WebMCP Connection">
|
||||
<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">
|
||||
@@ -141,6 +153,7 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.connection-dropdown-container {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
@@ -360,4 +373,39 @@ onUnmounted(() => {
|
||||
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
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,583 +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 terminalRef = ref<HTMLElement | null>(null)
|
||||
const connected = ref(false)
|
||||
const connecting = ref(false)
|
||||
const sessionId = ref<string | null>(null)
|
||||
|
||||
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: 580, h: 360 })
|
||||
|
||||
let terminal: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let socket: WebSocket | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const WS_URL = `ws://${window.location.hostname}:4103`
|
||||
|
||||
// Mouse position tracking for Ctrl+E
|
||||
const mousePos = ref({ x: 0, y: 0 })
|
||||
let lastToggle = 0
|
||||
|
||||
function trackMouse(e: MouseEvent) {
|
||||
mousePos.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function toggleTerminal() {
|
||||
const now = Date.now()
|
||||
if (now - lastToggle < 150) return // Debounce 150ms
|
||||
lastToggle = now
|
||||
|
||||
if (!isOpen.value) {
|
||||
// Open at mouse position (allow 75% occlusion)
|
||||
const w = size.value.w
|
||||
const h = size.value.h
|
||||
const minX = -w * 0.75
|
||||
const maxX = window.innerWidth - w * 0.25
|
||||
const minY = -h * 0.75
|
||||
const maxY = window.innerHeight - h * 0.25
|
||||
position.value = {
|
||||
x: Math.max(minX, Math.min(mousePos.value.x - w / 2, maxX)),
|
||||
y: Math.max(minY, Math.min(mousePos.value.y - h / 2, maxY))
|
||||
}
|
||||
hasCustomPosition.value = true
|
||||
isOpen.value = true
|
||||
} else {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.key === 'e') {
|
||||
e.preventDefault()
|
||||
toggleTerminal()
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).closest('.window-controls')) return
|
||||
|
||||
isDragging.value = true
|
||||
const rect = terminalRef.value?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
// Capture actual position if using default bottom/right
|
||||
if (!hasCustomPosition.value) {
|
||||
position.value = { x: rect.left, y: rect.top }
|
||||
}
|
||||
dragOffset.value = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const newX = e.clientX - dragOffset.value.x
|
||||
const newY = e.clientY - dragOffset.value.y
|
||||
|
||||
const w = terminalRef.value?.offsetWidth || 580
|
||||
const h = terminalRef.value?.offsetHeight || 360
|
||||
|
||||
// Allow up to 75% occlusion per side (25% must remain visible)
|
||||
const minX = -w * 0.75
|
||||
const maxX = window.innerWidth - w * 0.25
|
||||
const minY = -h * 0.75
|
||||
const maxY = window.innerHeight - h * 0.25
|
||||
|
||||
position.value = {
|
||||
x: Math.max(minX, Math.min(newX, maxX)),
|
||||
y: Math.max(minY, Math.min(newY, maxY))
|
||||
}
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
isDragging.value = false
|
||||
hasCustomPosition.value = true
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
// Resize functions
|
||||
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
|
||||
|
||||
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
|
||||
|
||||
const deltaX = e.clientX - resizeStart.value.x
|
||||
const deltaY = e.clientY - resizeStart.value.y
|
||||
|
||||
size.value = {
|
||||
w: Math.max(400, Math.min(resizeStart.value.w + deltaX, window.innerWidth - 40)),
|
||||
h: Math.max(250, Math.min(resizeStart.value.h + deltaY, window.innerHeight - 40))
|
||||
}
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
isResizing.value = false
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
nextTick(() => fitAddon?.fit())
|
||||
}
|
||||
|
||||
const terminalStyle = computed(() => {
|
||||
const base = {
|
||||
width: `${size.value.w}px`,
|
||||
height: `${size.value.h}px`
|
||||
}
|
||||
if (!hasCustomPosition.value) {
|
||||
return { ...base, bottom: '16px', right: '16px' }
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
top: `${position.value.y}px`,
|
||||
left: `${position.value.x}px`,
|
||||
bottom: 'auto',
|
||||
right: 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalContainer.value || terminal) return
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
fontSize: 12,
|
||||
fontFamily: "'Consolas', 'Lucida Console', monospace",
|
||||
theme: {
|
||||
background: 'rgba(12, 12, 12, 0.95)',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: 'rgba(100, 150, 255, 0.4)',
|
||||
black: '#0c0c0c',
|
||||
red: '#c50f1f',
|
||||
green: '#13a10e',
|
||||
yellow: '#c19c00',
|
||||
blue: '#0037da',
|
||||
magenta: '#881798',
|
||||
cyan: '#3a96dd',
|
||||
white: '#cccccc',
|
||||
brightBlack: '#767676',
|
||||
brightRed: '#e74856',
|
||||
brightGreen: '#16c60c',
|
||||
brightYellow: '#f9f1a5',
|
||||
brightBlue: '#3b78ff',
|
||||
brightMagenta: '#b4009e',
|
||||
brightCyan: '#61d6d6',
|
||||
brightWhite: '#f2f2f2'
|
||||
},
|
||||
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) {
|
||||
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 }))
|
||||
}
|
||||
})
|
||||
|
||||
// Capture Ctrl+E even when terminal has focus
|
||||
terminal.attachCustomKeyEventHandler((e) => {
|
||||
if (e.ctrlKey && e.key === 'e') {
|
||||
e.preventDefault()
|
||||
toggleTerminal()
|
||||
return false // Prevent terminal from processing
|
||||
}
|
||||
return true // Let terminal handle other keys
|
||||
})
|
||||
}
|
||||
|
||||
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]\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 close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function runClaude() {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
|
||||
}
|
||||
}
|
||||
|
||||
watch(isOpen, async (open) => {
|
||||
if (open) {
|
||||
await nextTick()
|
||||
initTerminal()
|
||||
if (!connected.value && !connecting.value) connect()
|
||||
nextTick(() => {
|
||||
fitAddon?.fit()
|
||||
terminal?.focus()
|
||||
})
|
||||
} else {
|
||||
// Cleanup when closing
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
terminal?.dispose()
|
||||
terminal = null
|
||||
fitAddon = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Global listeners for Ctrl+E
|
||||
document.addEventListener('mousemove', trackMouse)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
initTerminal()
|
||||
connect()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
socket?.close()
|
||||
terminal?.dispose()
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
document.removeEventListener('mousemove', trackMouse)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// Expose controls for MCP tools
|
||||
defineExpose({
|
||||
open: (x?: number, y?: number) => {
|
||||
if (x !== undefined && y !== undefined) {
|
||||
position.value = { x, y }
|
||||
hasCustomPosition.value = true
|
||||
}
|
||||
isOpen.value = true
|
||||
},
|
||||
close: () => {
|
||||
isOpen.value = false
|
||||
},
|
||||
toggle: () => {
|
||||
toggleTerminal()
|
||||
},
|
||||
move: (x: number, y: number) => {
|
||||
position.value = { x, y }
|
||||
hasCustomPosition.value = true
|
||||
},
|
||||
resize: (w: number, h: number) => {
|
||||
size.value = { w: Math.max(400, w), h: Math.max(250, h) }
|
||||
nextTick(() => fitAddon?.fit())
|
||||
},
|
||||
getState: () => ({
|
||||
isOpen: isOpen.value,
|
||||
position: position.value,
|
||||
size: size.value
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="win-slide">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="terminalRef"
|
||||
class="aero-win"
|
||||
:class="{ dragging: isDragging, resizing: isResizing }"
|
||||
:style="terminalStyle"
|
||||
>
|
||||
<div class="glass">
|
||||
<!-- Titlebar -->
|
||||
<div class="titlebar" @mousedown="startDrag">
|
||||
<div class="left">
|
||||
<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>Terminal</span>
|
||||
<i class="dot" :class="{ on: connected, wait: connecting }"></i>
|
||||
<a v-if="!connected && !connecting" class="link" @click.stop="connect">connect</a>
|
||||
</div>
|
||||
<div class="window-controls">
|
||||
<button @click="runClaude" title="Claude"><svg width="8" height="8" 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></button>
|
||||
<button class="x" @click="close" title="Close"><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>
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<div ref="terminalContainer" class="term"></div>
|
||||
</div>
|
||||
<!-- Resize handle -->
|
||||
<div class="resize-handle" @mousedown="startResize"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aero-win {
|
||||
position: fixed;
|
||||
min-width: 400px;
|
||||
min-height: 250px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 22px;
|
||||
padding: 0 2px 0 6px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.3);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.aero-win.dragging .titlebar { cursor: grabbing; }
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #222;
|
||||
font: 500 10px/1 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 5px; height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #999;
|
||||
}
|
||||
.dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
|
||||
.dot.wait { background: #a80; animation: pulse .8s infinite; }
|
||||
|
||||
.link {
|
||||
margin-left: 2px;
|
||||
color: #369;
|
||||
font-size: 9px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover { color: #47a; }
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
.window-controls button {
|
||||
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;
|
||||
}
|
||||
.window-controls button:hover {
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
.window-controls button.x:hover {
|
||||
background: linear-gradient(180deg, #e66 0%, #c33 100%);
|
||||
border-color: #a22;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
margin: 2px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
background: rgba(0,0,0,0.92);
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
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;
|
||||
}
|
||||
.resize-handle:hover {
|
||||
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0.2) 100%);
|
||||
}
|
||||
|
||||
.aero-win.resizing {
|
||||
user-select: none;
|
||||
}
|
||||
.aero-win.resizing .term {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.term {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.term :deep(.xterm) {
|
||||
height: 100%;
|
||||
padding: 2px;
|
||||
}
|
||||
.term :deep(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
.term :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.win-slide-enter-active, .win-slide-leave-active { transition: all .15s ease; }
|
||||
.win-slide-enter-from, .win-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
|
||||
|
||||
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.aero-win {
|
||||
inset: auto 0 0 0 !important;
|
||||
width: 100% !important;
|
||||
height: 55% !important;
|
||||
}
|
||||
.glass { border-radius: 6px 6px 0 0; }
|
||||
}
|
||||
</style>
|
||||
1976
frontend/src/components/FloatingTranscriptDebug.vue
Normal file
1976
frontend/src/components/FloatingTranscriptDebug.vue
Normal file
File diff suppressed because it is too large
Load Diff
1812
frontend/src/components/FloatingVoice.vue
Normal file
1812
frontend/src/components/FloatingVoice.vue
Normal file
File diff suppressed because it is too large
Load Diff
262
frontend/src/components/HooksApprovalModal.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>
|
||||
282
frontend/src/components/ServerConfigDialog.vue
Normal file
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
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,18 +104,35 @@ onMounted(() => {
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/terminal" class="toolbar-btn" :class="{ active: route.path === '/terminal' }" title="Terminal">
|
||||
<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"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
@@ -164,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 {
|
||||
|
||||
@@ -21,8 +21,7 @@ const categoryTools: Record<ToolCategory, string[]> = {
|
||||
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'],
|
||||
terminal: ['terminal_open', 'terminal_close', 'terminal_toggle', 'terminal_move', 'terminal_resize']
|
||||
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(() => {
|
||||
|
||||
618
frontend/src/components/TorchButton.vue
Normal file
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
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
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
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
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
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
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
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>
|
||||
263
frontend/src/components/git/BranchSelector.vue
Normal file
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
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
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
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
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
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
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
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
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
161
frontend/src/components/transcript-debug/BackgroundPixelArt.vue
Normal file
File diff suppressed because one or more lines are too long
1565
frontend/src/components/transcript-debug/ChatContainer.vue
Normal file
1565
frontend/src/components/transcript-debug/ChatContainer.vue
Normal file
File diff suppressed because it is too large
Load Diff
81
frontend/src/components/transcript-debug/CodeBlock.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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import { useAquaticState } from './useAquaticState'
|
||||
import type { AquaticEvent, ActiveEvent, EventFrequency, TimeOfDay, Season, DepthZone } from './types'
|
||||
|
||||
// ── Event catalog ──
|
||||
|
||||
const MINUTE_EVENTS: AquaticEvent[] = [
|
||||
{ id: 'school-of-fish', name: 'School of Fish', frequency: 'minutes', minInterval: 120_000, maxInterval: 480_000, duration: 15_000, cssClass: 'evt-school-of-fish' },
|
||||
{ id: 'bubble-burst', name: 'Bubble Burst', frequency: 'minutes', minInterval: 120_000, maxInterval: 480_000, duration: 8_000, cssClass: 'evt-bubble-burst' },
|
||||
{ id: 'bioluminescent-flash', name: 'Bioluminescent Flash', frequency: 'minutes', minInterval: 180_000, maxInterval: 480_000, duration: 10_000, cssClass: 'evt-bioluminescent-flash' },
|
||||
{ id: 'plankton-drift', name: 'Plankton Drift', frequency: 'minutes', minInterval: 150_000, maxInterval: 420_000, duration: 12_000, cssClass: 'evt-plankton-drift' },
|
||||
{ id: 'fish-chase', name: 'Fish Chase', frequency: 'minutes', minInterval: 120_000, maxInterval: 360_000, duration: 10_000, cssClass: 'evt-fish-chase' },
|
||||
]
|
||||
|
||||
const HOUR_EVENTS: AquaticEvent[] = [
|
||||
{ id: 'whale-shadow', name: 'Whale Shadow', frequency: 'hours', minInterval: 3_600_000, maxInterval: 14_400_000, duration: 20_000, cssClass: 'evt-whale-shadow' },
|
||||
{ id: 'current-change', name: 'Current Change', frequency: 'hours', minInterval: 3_600_000, maxInterval: 10_800_000, duration: 60_000, cssClass: 'evt-current-change' },
|
||||
{ id: 'color-shift', name: 'Color Shift', frequency: 'hours', minInterval: 3_600_000, maxInterval: 14_400_000, duration: 30_000, cssClass: 'evt-color-shift' },
|
||||
{ id: 'turtle-crossing', name: 'Turtle Crossing', frequency: 'hours', minInterval: 7_200_000, maxInterval: 14_400_000, duration: 25_000, cssClass: 'evt-turtle-crossing' },
|
||||
{ id: 'manta-ray', name: 'Manta Ray', frequency: 'hours', minInterval: 5_400_000, maxInterval: 14_400_000, duration: 18_000, cssClass: 'evt-manta-ray' },
|
||||
]
|
||||
|
||||
const DAY_EVENTS: AquaticEvent[] = [
|
||||
{ id: 'day-night-shift', name: 'Day/Night Shift', frequency: 'days', minInterval: 86_400_000, maxInterval: 259_200_000, duration: 120_000, cssClass: 'evt-day-night-shift' },
|
||||
{ id: 'seasonal-bloom', name: 'Seasonal Bloom', frequency: 'days', minInterval: 86_400_000, maxInterval: 259_200_000, duration: 180_000, cssClass: 'evt-seasonal-bloom' },
|
||||
{ id: 'depth-change', name: 'Depth Change', frequency: 'days', minInterval: 86_400_000, maxInterval: 172_800_000, duration: 90_000, cssClass: 'evt-depth-change' },
|
||||
{ id: 'kelp-forest', name: 'Kelp Forest', frequency: 'days', minInterval: 129_600_000, maxInterval: 259_200_000, duration: 150_000, cssClass: 'evt-kelp-forest' },
|
||||
]
|
||||
|
||||
const MONTH_EVENTS: AquaticEvent[] = [
|
||||
{ id: 'aurora-underwater', name: 'Aurora Underwater', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 10_368_000_000, duration: 300_000, cssClass: 'evt-aurora-underwater' },
|
||||
{ id: 'mythical-creature', name: 'Mythical Creature', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 10_368_000_000, duration: 30_000, cssClass: 'evt-mythical-creature' },
|
||||
{ id: 'volcanic-vent', name: 'Volcanic Vent', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 7_776_000_000, duration: 120_000, cssClass: 'evt-volcanic-vent' },
|
||||
{ id: 'crystal-formation', name: 'Crystal Formation', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 10_368_000_000, duration: 240_000, cssClass: 'evt-crystal-formation' },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'aquatic-event-timestamps'
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function pickRandom<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)]
|
||||
}
|
||||
|
||||
function randomBetween(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min)
|
||||
}
|
||||
|
||||
function loadTimestamps(): Record<string, number> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function saveTimestamp(frequency: EventFrequency) {
|
||||
const ts = loadTimestamps()
|
||||
ts[frequency] = Date.now()
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(ts))
|
||||
}
|
||||
|
||||
// ── Composable ──
|
||||
|
||||
export function useAquaticEvents() {
|
||||
const { setEventModifier, clearEventModifier, setTimeOfDay, setSeason, setDepthZone } = useAquaticState()
|
||||
const activeEvents = ref<ActiveEvent[]>([])
|
||||
const timerIds: number[] = []
|
||||
|
||||
const TIERS: { frequency: EventFrequency; events: AquaticEvent[] }[] = [
|
||||
{ frequency: 'minutes', events: MINUTE_EVENTS },
|
||||
{ frequency: 'hours', events: HOUR_EVENTS },
|
||||
{ frequency: 'days', events: DAY_EVENTS },
|
||||
{ frequency: 'months', events: MONTH_EVENTS },
|
||||
]
|
||||
|
||||
function start() {
|
||||
const timestamps = loadTimestamps()
|
||||
const now = Date.now()
|
||||
|
||||
for (const tier of TIERS) {
|
||||
const lastFired = timestamps[tier.frequency] || 0
|
||||
const elapsed = now - lastFired
|
||||
// Pick a representative event to check interval
|
||||
const sample = tier.events[0]
|
||||
const minWait = sample.minInterval
|
||||
|
||||
if (elapsed >= minWait) {
|
||||
// Enough time passed — trigger one soon (5-30s from now)
|
||||
const initialDelay = randomBetween(5_000, 30_000)
|
||||
const id = window.setTimeout(() => {
|
||||
triggerRandomFromTier(tier.frequency, tier.events)
|
||||
scheduleTier(tier.frequency, tier.events)
|
||||
}, initialDelay)
|
||||
timerIds.push(id)
|
||||
} else {
|
||||
// Wait for remaining time, then start normal cycle
|
||||
const remaining = minWait - elapsed
|
||||
const id = window.setTimeout(() => {
|
||||
scheduleTier(tier.frequency, tier.events)
|
||||
}, remaining)
|
||||
timerIds.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
timerIds.forEach(id => clearTimeout(id))
|
||||
timerIds.length = 0
|
||||
// Clean up active events
|
||||
for (const ae of activeEvents.value) {
|
||||
clearEventModifier(ae.event.id)
|
||||
}
|
||||
activeEvents.value = []
|
||||
}
|
||||
|
||||
function scheduleTier(frequency: EventFrequency, events: AquaticEvent[]) {
|
||||
const event = pickRandom(events)
|
||||
const delay = randomBetween(event.minInterval, event.maxInterval)
|
||||
const id = window.setTimeout(() => {
|
||||
triggerRandomFromTier(frequency, events)
|
||||
scheduleTier(frequency, events)
|
||||
}, delay)
|
||||
timerIds.push(id)
|
||||
}
|
||||
|
||||
function triggerRandomFromTier(frequency: EventFrequency, events: AquaticEvent[]) {
|
||||
// Filter out already-active events
|
||||
const available = events.filter(e =>
|
||||
!activeEvents.value.some(ae => ae.event.id === e.id)
|
||||
)
|
||||
if (available.length === 0) return
|
||||
|
||||
const event = pickRandom(available)
|
||||
triggerEvent(event, frequency)
|
||||
}
|
||||
|
||||
function triggerEvent(event: AquaticEvent, frequency: EventFrequency) {
|
||||
const now = Date.now()
|
||||
const active: ActiveEvent = {
|
||||
event,
|
||||
startedAt: now,
|
||||
endsAt: now + event.duration,
|
||||
}
|
||||
activeEvents.value = [...activeEvents.value, active]
|
||||
setEventModifier(event.id, event.cssClass)
|
||||
saveTimestamp(frequency)
|
||||
|
||||
// Special state mutations for certain events
|
||||
if (event.id === 'day-night-shift') {
|
||||
const options: TimeOfDay[] = ['day', 'twilight', 'night']
|
||||
setTimeOfDay(pickRandom(options))
|
||||
}
|
||||
if (event.id === 'seasonal-bloom') {
|
||||
const options: Season[] = ['spring', 'summer', 'autumn', 'winter']
|
||||
setSeason(pickRandom(options))
|
||||
}
|
||||
if (event.id === 'depth-change') {
|
||||
const options: DepthZone[] = ['surface', 'midwater', 'deep']
|
||||
setDepthZone(pickRandom(options))
|
||||
}
|
||||
|
||||
// Schedule end
|
||||
const endId = window.setTimeout(() => {
|
||||
activeEvents.value = activeEvents.value.filter(ae => ae !== active)
|
||||
clearEventModifier(event.id)
|
||||
}, event.duration)
|
||||
timerIds.push(endId)
|
||||
}
|
||||
|
||||
return {
|
||||
activeEvents: readonly(activeEvents),
|
||||
start,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { TimeOfDay, Season, DepthZone } from './types'
|
||||
|
||||
// ── Module-level singleton refs ──
|
||||
const depthZone = ref<DepthZone>('midwater')
|
||||
const timeOfDay = ref<TimeOfDay>('night')
|
||||
const season = ref<Season>(getCurrentSeason())
|
||||
const activeEventModifiers = ref<Map<string, string>>(new Map())
|
||||
|
||||
function getCurrentSeason(): Season {
|
||||
const month = new Date().getMonth()
|
||||
if (month >= 2 && month <= 4) return 'spring'
|
||||
if (month >= 5 && month <= 7) return 'summer'
|
||||
if (month >= 8 && month <= 10) return 'autumn'
|
||||
return 'winter'
|
||||
}
|
||||
|
||||
export function useAquaticState() {
|
||||
function setDepthZone(zone: DepthZone) {
|
||||
depthZone.value = zone
|
||||
}
|
||||
|
||||
function setTimeOfDay(tod: TimeOfDay) {
|
||||
timeOfDay.value = tod
|
||||
}
|
||||
|
||||
function setSeason(s: Season) {
|
||||
season.value = s
|
||||
}
|
||||
|
||||
function setEventModifier(eventId: string, cssClass: string) {
|
||||
const next = new Map(activeEventModifiers.value)
|
||||
next.set(eventId, cssClass)
|
||||
activeEventModifiers.value = next
|
||||
}
|
||||
|
||||
function clearEventModifier(eventId: string) {
|
||||
const next = new Map(activeEventModifiers.value)
|
||||
next.delete(eventId)
|
||||
activeEventModifiers.value = next
|
||||
}
|
||||
|
||||
return {
|
||||
depthZone: readonly(depthZone),
|
||||
timeOfDay: readonly(timeOfDay),
|
||||
season: readonly(season),
|
||||
activeEventModifiers: readonly(activeEventModifiers),
|
||||
setDepthZone,
|
||||
setTimeOfDay,
|
||||
setSeason,
|
||||
setEventModifier,
|
||||
clearEventModifier,
|
||||
}
|
||||
}
|
||||
21
frontend/src/components/transcript-debug/index.ts
Normal file
21
frontend/src/components/transcript-debug/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { default as SessionSelector } from './SessionSelector.vue'
|
||||
export { default as RawJsonViewer } from './RawJsonViewer.vue'
|
||||
export { default as ChatContainer } from './ChatContainer.vue'
|
||||
export { default as UserMessageBubble } from './UserMessageBubble.vue'
|
||||
export { default as AssistantMessageBubble } from './AssistantMessageBubble.vue'
|
||||
export { default as ThinkingBlock } from './ThinkingBlock.vue'
|
||||
export { default as ToolCallBlock } from './ToolCallBlock.vue'
|
||||
export { default as ToolResultBlock } from './ToolResultBlock.vue'
|
||||
export { default as ProgressEvent } from './ProgressEvent.vue'
|
||||
export { default as SystemMessage } from './SystemMessage.vue'
|
||||
export { default as TurnEndDivider } from './TurnEndDivider.vue'
|
||||
export { default as UserInput } from './UserInput.vue'
|
||||
export { default as PermissionApproval } from './PermissionApproval.vue'
|
||||
export { default as PlanApproval } from './PlanApproval.vue'
|
||||
export { default as CodeBlock } from './CodeBlock.vue'
|
||||
export { default as AgentBadge } from './AgentBadge.vue'
|
||||
export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue'
|
||||
export { default as VoiceMicButton } from './VoiceMicButton.vue'
|
||||
export { default as NewSessionModal } from './NewSessionModal.vue'
|
||||
export { AquaticBackground } from './aquaticBackground'
|
||||
export { SyncEnginePanel } from './sync-engine'
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useSessionState, type PtySessionState } from '@/stores/session-state'
|
||||
|
||||
const sessionState = useSessionState()
|
||||
const ptySessions = computed(() => sessionState.ptySessionList)
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
idle: '#6b7280',
|
||||
thinking: '#a78bfa',
|
||||
reading: '#60a5fa',
|
||||
writing: '#fbbf24',
|
||||
toolUse: '#fb923c',
|
||||
permissionRequest: '#f87171',
|
||||
interrupted: '#ef4444',
|
||||
error: '#ef4444',
|
||||
sessionStart: '#4ade80',
|
||||
sessionEnd: '#6b7280',
|
||||
}
|
||||
|
||||
function elapsed(ts: number): string {
|
||||
if (!ts) return '-'
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 1000) return '<1s'
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ${Math.floor((diff % 60_000) / 1000)}s`
|
||||
return `${Math.floor(diff / 3600_000)}h ${Math.floor((diff % 3600_000) / 60_000)}m`
|
||||
}
|
||||
|
||||
function truncate(s: string | null | undefined, max = 24): string {
|
||||
if (!s) return '-'
|
||||
return s.length > max ? s.slice(0, max) + '...' : s
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agent-states">
|
||||
<div v-if="!ptySessions.length" class="empty">No PTY sessions</div>
|
||||
|
||||
<div v-for="ps in ptySessions" :key="ps.ptySessionId" class="agent-card">
|
||||
<div class="agent-header">
|
||||
<span class="agent-name pty-id">{{ ps.ptySessionId }}</span>
|
||||
<span class="status-badge" :style="{ background: STATUS_COLORS[ps.status] || '#6b7280' }">
|
||||
{{ ps.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">Agent</span>
|
||||
<span class="value">{{ ps.agent }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.currentTool" class="row">
|
||||
<span class="label">Tool</span>
|
||||
<span class="value tool-name">{{ ps.currentTool.name }}</span>
|
||||
<span class="value dim">{{ elapsed(ps.currentTool.startedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.transcriptSessionId" class="row">
|
||||
<span class="label">Session</span>
|
||||
<span class="value mono">{{ truncate(ps.transcriptSessionId, 20) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.model" class="row">
|
||||
<span class="label">Model</span>
|
||||
<span class="value">{{ ps.model }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.permissionMode" class="row">
|
||||
<span class="label">Mode</span>
|
||||
<span class="value">{{ ps.permissionMode }}</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">Activity</span>
|
||||
<span class="value">{{ elapsed(ps.lastActivity) }} ago</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.lastError" class="row error-row">
|
||||
<span class="label">Error</span>
|
||||
<span class="value error-text">{{ ps.lastError.tool }}: {{ truncate(ps.lastError.message, 40) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.pendingApprovals.length" class="row">
|
||||
<span class="label">Approvals</span>
|
||||
<span class="value warning-text">{{ ps.pendingApprovals.length }} pending</span>
|
||||
</div>
|
||||
|
||||
<div class="flags">
|
||||
<span v-if="ps.sessionActive" class="flag active">session</span>
|
||||
<span v-if="ps.agentResponding" class="flag responding">responding</span>
|
||||
<span v-if="ps.subagentActive" class="flag subagent">subagent</span>
|
||||
<span v-if="ps.compacting" class="flag compacting">compacting</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-states {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
max-width: 340px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(34, 211, 238, 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.agent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #64748b;
|
||||
min-width: 55px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.value.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.value.dim {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
color: #fbbf24;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
margin: 1px -4px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #f87171;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #fbbf24;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flag {
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.flag.active { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
|
||||
.flag.responding { background: rgba(167, 139, 250, 0.15); color: #a78bfa; }
|
||||
.flag.subagent { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
|
||||
.flag.compacting { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
|
||||
|
||||
.pty-id {
|
||||
font-size: 10px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #67e8f9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useSessionState, type HookHistoryEntry } from '@/stores/session-state'
|
||||
|
||||
const sessionState = useSessionState()
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
SessionStart: '#60a5fa',
|
||||
UserPromptSubmit: '#a78bfa',
|
||||
PreToolUse: '#fbbf24',
|
||||
PermissionRequest: '#fb923c',
|
||||
PostToolUse: '#4ade80',
|
||||
PostToolUseFailure: '#f87171',
|
||||
Notification: '#38bdf8',
|
||||
SubagentStart: '#c084fc',
|
||||
SubagentStop: '#a855f7',
|
||||
Stop: '#22d3ee',
|
||||
TeammateIdle: '#94a3b8',
|
||||
TaskCompleted: '#34d399',
|
||||
ConfigChange: '#e879f9',
|
||||
PreCompact: '#f59e0b',
|
||||
SessionEnd: '#6b7280',
|
||||
}
|
||||
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
ejecutor: '#60a5fa',
|
||||
nucleo000: '#4ade80',
|
||||
claude: '#a78bfa',
|
||||
}
|
||||
|
||||
interface TimelineEntry extends HookHistoryEntry {
|
||||
agent: string
|
||||
ptySessionId: string
|
||||
}
|
||||
|
||||
// Filters
|
||||
const ptyFilter = ref<string>('all')
|
||||
const eventFilter = ref<string>('all')
|
||||
const MAX_DISPLAY = 200
|
||||
|
||||
// Merge all per-PTY sessions into one sorted timeline
|
||||
const allEntries = computed<TimelineEntry[]>(() => {
|
||||
const entries: TimelineEntry[] = []
|
||||
const registry = sessionState.terminalRegistry
|
||||
|
||||
for (const [ptyId, ptyState] of Object.entries(sessionState.ptySessions)) {
|
||||
const regEntry = registry.find(r => r.ephemeralSessionId === ptyId)
|
||||
const agent = regEntry?.agent || ptyState.agent || 'unknown'
|
||||
for (const h of ptyState.hookHistory) {
|
||||
entries.push({ ...h, agent, ptySessionId: ptyId })
|
||||
}
|
||||
}
|
||||
entries.sort((a, b) => b.timestamp - a.timestamp)
|
||||
return entries
|
||||
})
|
||||
|
||||
// Get unique PTY session IDs with their agent for display
|
||||
const ptyOptions = computed(() => {
|
||||
const map = new Map<string, string>() // ptyId → agent
|
||||
for (const e of allEntries.value) map.set(e.ptySessionId, e.agent)
|
||||
return Array.from(map.entries()).map(([ptyId, agent]) => ({ ptyId, agent }))
|
||||
})
|
||||
|
||||
// Get unique event types for filter
|
||||
const eventTypes = computed(() => {
|
||||
const set = new Set<string>()
|
||||
for (const e of allEntries.value) set.add(e.event)
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
// Apply filters
|
||||
const filtered = computed(() => {
|
||||
let list = allEntries.value
|
||||
if (ptyFilter.value !== 'all') {
|
||||
list = list.filter(e => e.ptySessionId === ptyFilter.value)
|
||||
}
|
||||
if (eventFilter.value !== 'all') {
|
||||
list = list.filter(e => e.event === eventFilter.value)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const displayed = computed(() => filtered.value.slice(0, MAX_DISPLAY))
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hook-timeline">
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<button
|
||||
:class="['filter-btn', { active: ptyFilter === 'all' }]"
|
||||
@click="ptyFilter = 'all'"
|
||||
>All</button>
|
||||
<button
|
||||
v-for="p in ptyOptions"
|
||||
:key="p.ptyId"
|
||||
:class="['filter-btn', { active: ptyFilter === p.ptyId }]"
|
||||
:style="{ '--accent': AGENT_COLORS[p.agent] || '#64748b' }"
|
||||
@click="ptyFilter = p.ptyId"
|
||||
:title="p.ptyId"
|
||||
>{{ p.agent }}:{{ p.ptyId.slice(-6) }}</button>
|
||||
</div>
|
||||
<select v-model="eventFilter" class="event-select">
|
||||
<option value="all">All events</option>
|
||||
<option v-for="ev in eventTypes" :key="ev" :value="ev">{{ ev }}</option>
|
||||
</select>
|
||||
<span class="count">{{ displayed.length }}<span v-if="filtered.length > MAX_DISPLAY"> / {{ filtered.length }}</span></span>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="timeline-list">
|
||||
<div v-if="!displayed.length" class="empty">No events</div>
|
||||
<div v-for="(entry, i) in displayed" :key="i" class="timeline-entry">
|
||||
<span class="time">{{ formatTime(entry.timestamp) }}</span>
|
||||
<span class="agent-badge" :style="{ background: AGENT_COLORS[entry.agent] || '#64748b' }" :title="entry.ptySessionId">{{ entry.agent }}:{{ entry.ptySessionId.slice(-6) }}</span>
|
||||
<span class="event-name" :style="{ color: EVENT_COLORS[entry.event] || '#94a3b8' }">{{ entry.event }}</span>
|
||||
<span v-if="entry.detail" class="detail">{{ entry.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hook-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.filter-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.filter-btn.active {
|
||||
background: var(--accent, #0ea5e9);
|
||||
color: #fff;
|
||||
border-color: var(--accent, #0ea5e9);
|
||||
}
|
||||
|
||||
.event-select {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #cbd5e1;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.timeline-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.timeline-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #475569;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
min-width: 85px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-badge {
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
min-width: 85px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-name {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useWsMonitor } from '@/composables/useWsMonitor'
|
||||
import AgentStatesSection from './AgentStatesSection.vue'
|
||||
import HookTimelineSection from './HookTimelineSection.vue'
|
||||
import TerminalRegistrySection from './TerminalRegistrySection.vue'
|
||||
import WsMonitorSection from './WsMonitorSection.vue'
|
||||
|
||||
const wsMonitor = useWsMonitor()
|
||||
|
||||
onMounted(() => wsMonitor.start(5000))
|
||||
onUnmounted(() => wsMonitor.stop())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sync-engine-panel">
|
||||
<div class="se-section">
|
||||
<h3 class="se-section-title">Agent States</h3>
|
||||
<AgentStatesSection />
|
||||
</div>
|
||||
|
||||
<div class="se-section">
|
||||
<h3 class="se-section-title">Hook Timeline</h3>
|
||||
<HookTimelineSection />
|
||||
</div>
|
||||
|
||||
<div class="se-section">
|
||||
<h3 class="se-section-title">Terminal Registry</h3>
|
||||
<TerminalRegistrySection />
|
||||
</div>
|
||||
|
||||
<div class="se-section">
|
||||
<h3 class="se-section-title">WS Monitor</h3>
|
||||
<WsMonitorSection :data="wsMonitor.data.value" :error="wsMonitor.error.value" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-engine-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.se-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.se-section-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #64748b;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useSessionState, type PtySessionState } from '@/stores/session-state'
|
||||
|
||||
const sessionState = useSessionState()
|
||||
|
||||
const expandedIds = ref<Set<string>>(new Set())
|
||||
|
||||
function toggle(id: string) {
|
||||
const s = new Set(expandedIds.value)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
expandedIds.value = s
|
||||
}
|
||||
|
||||
function getHooks(ephemeralSessionId: string): PtySessionState | null {
|
||||
return sessionState.ptySessions[ephemeralSessionId] ?? null
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
SessionStart: '#60a5fa',
|
||||
UserPromptSubmit: '#a78bfa',
|
||||
PreToolUse: '#fbbf24',
|
||||
PermissionRequest: '#fb923c',
|
||||
PostToolUse: '#4ade80',
|
||||
PostToolUseFailure: '#f87171',
|
||||
Notification: '#38bdf8',
|
||||
SubagentStart: '#c084fc',
|
||||
SubagentStop: '#a855f7',
|
||||
Stop: '#22d3ee',
|
||||
TeammateIdle: '#94a3b8',
|
||||
TaskCompleted: '#34d399',
|
||||
ConfigChange: '#e879f9',
|
||||
PreCompact: '#f59e0b',
|
||||
SessionEnd: '#6b7280',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
idle: '#6b7280',
|
||||
thinking: '#a78bfa',
|
||||
reading: '#60a5fa',
|
||||
writing: '#fbbf24',
|
||||
toolUse: '#fb923c',
|
||||
permissionRequest: '#f87171',
|
||||
interrupted: '#ef4444',
|
||||
error: '#ef4444',
|
||||
sessionStart: '#4ade80',
|
||||
sessionEnd: '#6b7280',
|
||||
}
|
||||
|
||||
function elapsed(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m`
|
||||
return `${Math.floor(diff / 3600_000)}h ${Math.floor((diff % 3600_000) / 60_000)}m`
|
||||
}
|
||||
|
||||
function elapsedMs(ts: number): string {
|
||||
if (!ts) return '-'
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 1000) return '<1s'
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ${Math.floor((diff % 60_000) / 1000)}s`
|
||||
return `${Math.floor(diff / 3600_000)}h`
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
|
||||
}
|
||||
|
||||
const COLS = 8
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="terminal-registry">
|
||||
<div v-if="!sessionState.terminalRegistry.length" class="empty">No terminals registered</div>
|
||||
|
||||
<table v-else class="reg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Label</th>
|
||||
<th>Agent</th>
|
||||
<th>Session</th>
|
||||
<th>Command</th>
|
||||
<th>Buffer</th>
|
||||
<th>Clients</th>
|
||||
<th>Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-for="entry in sessionState.terminalRegistry" :key="entry.ephemeralSessionId">
|
||||
<!-- Main row -->
|
||||
<tr
|
||||
:class="['entry-row', { expanded: expandedIds.has(entry.ephemeralSessionId) }]"
|
||||
@click="toggle(entry.ephemeralSessionId)"
|
||||
>
|
||||
<td><span :class="['dot', entry.alive ? 'alive' : 'dead']"></span></td>
|
||||
<td class="cell-wrap">{{ entry.label }}</td>
|
||||
<td class="agent">{{ entry.agent }}</td>
|
||||
<td class="mono cell-wrap">{{ entry.ephemeralSessionId }}</td>
|
||||
<td class="mono cell-wrap">{{ entry.command }}</td>
|
||||
<td class="num">{{ entry.bufferSize.toLocaleString() }}</td>
|
||||
<td class="num">{{ entry.clients }}</td>
|
||||
<td class="dim">{{ elapsed(entry.createdAt) }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expanded hooks row -->
|
||||
<tr v-if="expandedIds.has(entry.ephemeralSessionId)" class="hooks-row">
|
||||
<td :colspan="COLS" class="hooks-cell">
|
||||
<template v-if="getHooks(entry.ephemeralSessionId)">
|
||||
<div class="hooks-panel">
|
||||
<!-- Session state summary -->
|
||||
<div class="hooks-summary">
|
||||
<span class="hooks-label">Status</span>
|
||||
<span class="hooks-status" :style="{ color: STATUS_COLORS[getHooks(entry.ephemeralSessionId)!.status] || '#6b7280' }">
|
||||
{{ getHooks(entry.ephemeralSessionId)!.status }}
|
||||
</span>
|
||||
|
||||
<template v-if="getHooks(entry.ephemeralSessionId)!.currentTool">
|
||||
<span class="hooks-label">Tool</span>
|
||||
<span class="hooks-tool">{{ getHooks(entry.ephemeralSessionId)!.currentTool!.name }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="getHooks(entry.ephemeralSessionId)!.lastHookEvent">
|
||||
<span class="hooks-label">Last event</span>
|
||||
<span class="hooks-event" :style="{ color: EVENT_COLORS[getHooks(entry.ephemeralSessionId)!.lastHookEvent!] || '#94a3b8' }">
|
||||
{{ getHooks(entry.ephemeralSessionId)!.lastHookEvent }}
|
||||
</span>
|
||||
<span v-if="getHooks(entry.ephemeralSessionId)!.lastHookDetail" class="hooks-detail">
|
||||
{{ getHooks(entry.ephemeralSessionId)!.lastHookDetail }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<span class="hooks-label">Activity</span>
|
||||
<span class="hooks-dim">{{ elapsedMs(getHooks(entry.ephemeralSessionId)!.lastActivity) }} ago</span>
|
||||
|
||||
<!-- Flags -->
|
||||
<div class="hooks-flags">
|
||||
<span v-if="getHooks(entry.ephemeralSessionId)!.sessionActive" class="hflag active">session</span>
|
||||
<span v-if="getHooks(entry.ephemeralSessionId)!.agentResponding" class="hflag responding">responding</span>
|
||||
<span v-if="getHooks(entry.ephemeralSessionId)!.subagentActive" class="hflag subagent">subagent</span>
|
||||
<span v-if="getHooks(entry.ephemeralSessionId)!.compacting" class="hflag compacting">compacting</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hook history -->
|
||||
<div class="hooks-history">
|
||||
<div class="hooks-history-title">
|
||||
Hook History ({{ getHooks(entry.ephemeralSessionId)!.hookHistory.length }})
|
||||
</div>
|
||||
<div class="hooks-list">
|
||||
<div
|
||||
v-for="(h, i) in [...getHooks(entry.ephemeralSessionId)!.hookHistory].reverse().slice(0, 100)"
|
||||
:key="i"
|
||||
class="hook-entry"
|
||||
>
|
||||
<span class="hook-time">{{ formatTime(h.timestamp) }}</span>
|
||||
<span class="hook-event" :style="{ color: EVENT_COLORS[h.event] || '#94a3b8' }">{{ h.event }}</span>
|
||||
<span v-if="h.detail" class="hook-detail">{{ h.detail }}</span>
|
||||
</div>
|
||||
<div v-if="getHooks(entry.ephemeralSessionId)!.hookHistory.length > 100" class="hooks-more">
|
||||
... {{ getHooks(entry.ephemeralSessionId)!.hookHistory.length - 100 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="hooks-empty">No hook data for terminal {{ entry.ephemeralSessionId }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-registry {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.reg-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.reg-table th {
|
||||
text-align: left;
|
||||
color: #64748b;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reg-table td {
|
||||
padding: 3px 6px;
|
||||
color: #cbd5e1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.entry-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.entry-row:hover td {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.entry-row.expanded td {
|
||||
background: rgba(14, 165, 233, 0.04);
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.cell-wrap {
|
||||
word-break: break-all;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.alive { background: #22c55e; }
|
||||
.dot.dead { background: #ef4444; }
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.agent {
|
||||
font-weight: 600;
|
||||
color: #60a5fa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Hooks expanded row ── */
|
||||
|
||||
.hooks-row td {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.hooks-cell {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.hooks-panel {
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hooks-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hooks-label {
|
||||
color: #475569;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.hooks-status {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hooks-tool {
|
||||
color: #fbbf24;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hooks-event {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hooks-detail {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hooks-dim {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hooks-flags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.hflag {
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.hflag.active { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
|
||||
.hflag.responding { background: rgba(167, 139, 250, 0.15); color: #a78bfa; }
|
||||
.hflag.subagent { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
|
||||
.hflag.compacting { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
|
||||
|
||||
.hooks-empty {
|
||||
padding: 8px 10px;
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Hook history list ── */
|
||||
|
||||
.hooks-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.hooks-history-title {
|
||||
color: #475569;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.hooks-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 4px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.hook-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 1px 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hook-time {
|
||||
color: #475569;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 9px;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-event {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
min-width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-detail {
|
||||
color: #64748b;
|
||||
font-size: 9px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hooks-more {
|
||||
color: #475569;
|
||||
font-size: 9px;
|
||||
padding: 2px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import type { WsMonitorData } from '@/composables/useWsMonitor'
|
||||
|
||||
const props = defineProps<{
|
||||
data: WsMonitorData | null
|
||||
error: string | null
|
||||
}>()
|
||||
|
||||
function elapsed(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m`
|
||||
return `${Math.floor(diff / 3600_000)}h`
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('en-GB', { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ws-monitor">
|
||||
<div v-if="error" class="error-msg">Failed: {{ error }}</div>
|
||||
<div v-else-if="!data" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="server-cards">
|
||||
<!-- Terminal Server -->
|
||||
<div class="server-card">
|
||||
<div class="server-header">
|
||||
<span class="server-name">Terminal Server</span>
|
||||
<span class="port">:4103</span>
|
||||
<span :class="['status-dot', data.terminal.status === 'ok' ? 'ok' : 'err']"></span>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Broadcast clients</span>
|
||||
<span class="stat-value">{{ data.terminal.broadcastClients }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">PTY sessions</span>
|
||||
<span class="stat-value">{{ data.terminal.sessions.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data.terminal.sessions.length" class="pty-list">
|
||||
<div v-for="s in data.terminal.sessions" :key="s.id" class="pty-entry">
|
||||
<span class="pty-id">{{ s.id }}</span>
|
||||
<span class="pty-stat">pid:{{ s.pid }}</span>
|
||||
<span class="pty-stat">clients:{{ s.clients }}</span>
|
||||
<span class="pty-stat">buf:{{ s.bufferSize.toLocaleString() }}</span>
|
||||
<span class="pty-stat dim">{{ elapsed(s.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.terminal.cwd" class="stat-row">
|
||||
<span class="stat-label">CWD</span>
|
||||
<span class="stat-value mono">{{ data.terminal.cwd }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Server -->
|
||||
<div class="server-card">
|
||||
<div class="server-header">
|
||||
<span class="server-name">Sync Server</span>
|
||||
<span class="port">:4105</span>
|
||||
<span :class="['status-dot', data.sync.status === 'ok' ? 'ok' : 'err']"></span>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Connected clients</span>
|
||||
<span class="stat-value">{{ data.sync.clients }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data.sync.torch" class="stat-row">
|
||||
<span class="stat-label">Torch</span>
|
||||
<span class="stat-value dim">active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="refresh-info">
|
||||
Last poll: {{ formatTimestamp(data.timestamp) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ws-monitor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #f87171;
|
||||
font-size: 11px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.server-cards {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.server-card {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.server-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.port {
|
||||
color: #64748b;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
}
|
||||
.status-dot.ok { background: #22c55e; }
|
||||
.status-dot.err { background: #ef4444; }
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-value.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.stat-value.dim {
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.pty-list {
|
||||
margin: 4px 0;
|
||||
padding: 4px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.pty-entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.pty-id {
|
||||
color: #60a5fa;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.pty-stat {
|
||||
color: #94a3b8;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.pty-stat.dim {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.refresh-info {
|
||||
color: #475569;
|
||||
font-size: 9px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SyncEnginePanel } from './SyncEnginePanel.vue'
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ParsedToolCall } from '@/types/transcript-debug'
|
||||
|
||||
const props = defineProps<{
|
||||
call: ParsedToolCall
|
||||
}>()
|
||||
|
||||
interface QuestionOption {
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface QuestionItem {
|
||||
question: string
|
||||
header?: string
|
||||
options?: QuestionOption[]
|
||||
multiSelect?: boolean
|
||||
}
|
||||
|
||||
const questions = computed<QuestionItem[]>(() => {
|
||||
const qs = props.call.input?.questions
|
||||
if (Array.isArray(qs)) return qs as QuestionItem[]
|
||||
return []
|
||||
})
|
||||
|
||||
// Parse the answer from the result
|
||||
const answer = computed<Record<string, string>>(() => {
|
||||
if (!props.call.result?.content) return {}
|
||||
const raw = props.call.result.content
|
||||
|
||||
// Try to extract "answered: X" pattern from result text
|
||||
// Format: 'User has answered your questions: "question"="answer"'
|
||||
const answerMap: Record<string, string> = {}
|
||||
const regex = /"([^"]+)"="([^"]+)"/g
|
||||
let match
|
||||
while ((match = regex.exec(raw)) !== null) {
|
||||
answerMap[match[1]] = match[2]
|
||||
}
|
||||
|
||||
// Also try JSON format { answers: { ... } }
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed?.answers) {
|
||||
for (const [k, v] of Object.entries(parsed.answers)) {
|
||||
answerMap[k] = String(v)
|
||||
}
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
|
||||
return answerMap
|
||||
})
|
||||
|
||||
// Check if an option was selected for a given question
|
||||
function isSelected(questionText: string, optionLabel: string): boolean {
|
||||
const ans = answer.value[questionText]
|
||||
if (!ans) return false
|
||||
return ans.split(', ').includes(optionLabel)
|
||||
}
|
||||
|
||||
// Get user notes from result (text after "user notes:")
|
||||
const userNotes = computed<string>(() => {
|
||||
if (!props.call.result?.content) return ''
|
||||
const match = props.call.result.content.match(/user notes:\s*(.+?)\.?\s*(?:You can|$)/i)
|
||||
return match ? match[1].trim() : ''
|
||||
})
|
||||
|
||||
const isError = computed(() => props.call.result?.isError ?? false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['question-card', { error: isError }]">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">
|
||||
<svg width="14" height="14" 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">AskUserQuestion</span>
|
||||
<span v-if="isError" class="error-badge">error</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">
|
||||
<div
|
||||
v-for="opt in q.options"
|
||||
:key="opt.label"
|
||||
:class="['option-item', { selected: isSelected(q.question, opt.label) }]"
|
||||
>
|
||||
<span class="option-check">
|
||||
<svg v-if="isSelected(q.question, opt.label)" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="option-content">
|
||||
<span class="option-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.description" class="option-desc">{{ opt.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show answer if it's custom text (not matching any option) -->
|
||||
<div v-if="answer[q.question] && !q.options?.some(o => isSelected(q.question, o.label))" class="custom-answer">
|
||||
<span class="answer-label">Answer:</span>
|
||||
<span class="answer-text">{{ answer[q.question] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User notes -->
|
||||
<div v-if="userNotes" class="user-notes">
|
||||
<span class="notes-label">Notes:</span>
|
||||
<span class="notes-text">{{ userNotes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result status -->
|
||||
<div v-if="call.result" :class="['card-footer', { error: isError }]">
|
||||
<span class="result-icon">{{ isError ? '✗' : '✓' }}</span>
|
||||
<span class="result-label">{{ isError ? 'Error' : 'Answered' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.question-card {
|
||||
border: 1px solid rgba(14, 165, 233, 0.25);
|
||||
border-left: 3px solid #0ea5e9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.question-card.error {
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
background: rgba(14, 165, 233, 0.06);
|
||||
border-bottom: 1px solid rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
|
||||
.question-card.error .card-header {
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-bottom-color: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
.question-card.error .card-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #0ea5e9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.question-card.error .card-label {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-badge {
|
||||
font-size: 10px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.question-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.question-block + .question-block {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
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.3rem;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.option-item.selected {
|
||||
border-color: #0ea5e9;
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
}
|
||||
|
||||
.option-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.option-item.selected .option-check {
|
||||
background: #0ea5e9;
|
||||
border-color: #0ea5e9;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.custom-answer {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: rgba(14, 165, 233, 0.06);
|
||||
border: 1px solid rgba(14, 165, 233, 0.15);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.answer-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #0ea5e9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.answer-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.user-notes {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: rgba(251, 191, 36, 0.06);
|
||||
border: 1px solid rgba(251, 191, 36, 0.15);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.notes-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fbbf24;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-top: 1px solid rgba(14, 165, 233, 0.12);
|
||||
background: rgba(34, 197, 94, 0.04);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.card-footer.error {
|
||||
background: rgba(239, 68, 68, 0.04);
|
||||
border-top-color: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-weight: 600;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.card-footer.error .result-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user