feat: Add tree file view for git status, AgentBar dock, and settings updates
- Add StatusTree component with collapsible directory hierarchy for staged/unstaged/untracked files - Replace flat file lists in GitPage with tree view showing file type icons and git status badges - Add AgentBar arc dock with per-agent terminal frame and voice modal - Update ejecutor settings with hooks for claude-status reporting
This commit is contained in:
@@ -5,6 +5,6 @@
|
|||||||
"repo": "anthropics/claude-plugins-official"
|
"repo": "anthropics/claude-plugins-official"
|
||||||
},
|
},
|
||||||
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official",
|
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official",
|
||||||
"lastUpdated": "2026-02-15T08:27:07.485Z"
|
"lastUpdated": "2026-02-15T19:50:55.853Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"env": {
|
"env": {
|
||||||
"DISABLE_TELEMETRY": "1"
|
"DISABLE_TELEMETRY": "1",
|
||||||
|
"AGENT_NAME": "ejecutor"
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
@@ -18,5 +19,65 @@
|
|||||||
"Task",
|
"Task",
|
||||||
"NotebookEdit"
|
"NotebookEdit"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"processing\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolUse\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolDone\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"notification\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"idle\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
.claude-ejecutor/settings.local.json
Normal file
62
.claude-ejecutor/settings.local.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"processing\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolUse\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolDone\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"notification\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"idle\\\",\\\"agent\\\":\\\"ejecutor\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"processing\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"processing\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"reading\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"reading\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"writing\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"writing\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolUse\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolUse\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolDone\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolDone\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"sessionStart\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"sessionStart\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"permissionRequest\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"permissionRequest\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"notification\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"notification\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"idle\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
"command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"idle\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
"timeout": 5000
|
"timeout": 5000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||||
"terminalBg": "#0f0a1a",
|
"terminalBg": "#0f0a1a",
|
||||||
"terminalBorder": "#6366f1",
|
"terminalBorder": "#6366f1",
|
||||||
"enabled": true
|
"enabled": false
|
||||||
}
|
}
|
||||||
|
|||||||
334
docs/agent-bar.md
Normal file
334
docs/agent-bar.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
# Agent Bar — Sistema de Burbujas por Agente
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
Cada agente de Claude Code (main, ejecutor, etc.) puede tener su propia burbuja flotante en la UI. Las burbujas muestran animaciones en tiempo real cuando el agente procesa, usa herramientas, recibe notificaciones, etc.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
Agente (hooks en settings.json)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
POST /api/claude-status { status, agent, tool? }
|
||||||
|
|
|
||||||
|
v
|
||||||
|
server/routes/claude-status.ts (valida y reenvía)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Terminal Server :4103 /claude-status (broadcast WebSocket)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AgentBar.vue (escucha ws://...:4103, matchea por agent id)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
CSS classes + keyframes (animaciones en la burbuja)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Archivos clave
|
||||||
|
|
||||||
|
| Archivo | Función |
|
||||||
|
|---|---|
|
||||||
|
| `.claude/ui.json` | Config visual del agente main |
|
||||||
|
| `.claude-ejecutor/ui.json` | Config visual de ejecutor |
|
||||||
|
| `.claude-ejecutor/settings.json` | Hooks que envían status de ejecutor |
|
||||||
|
| `server/routes/agents.ts` | API `GET /api/agents` — descubre agentes |
|
||||||
|
| `server/routes/claude-status.ts` | API `POST /api/claude-status` — recibe status |
|
||||||
|
| `server/services/terminal.ts` | Broadcast WebSocket a todos los clientes |
|
||||||
|
| `frontend/src/components/AgentBar.vue` | Burbujas flotantes + animaciones |
|
||||||
|
| `frontend/src/config/endpoints.ts` | URLs de WebSocket (dev vs HTTPS) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cómo agregar un nuevo agente
|
||||||
|
|
||||||
|
### 1. Crear directorio `.claude-<nombre>/`
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude-miagente/
|
||||||
|
ui.json
|
||||||
|
settings.json
|
||||||
|
CLAUDE.md (opcional — instrucciones del agente)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configurar `ui.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "Mi Agente",
|
||||||
|
"shortLabel": "MA",
|
||||||
|
"color": "#8b5cf6",
|
||||||
|
"gradient": "linear-gradient(135deg, #8b5cf6, #7c3aed)",
|
||||||
|
"terminalBg": "#0f0a1a",
|
||||||
|
"terminalBorder": "#8b5cf6",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|---|---|
|
||||||
|
| `label` | Nombre completo (tooltip, titlebar del terminal) |
|
||||||
|
| `shortLabel` | 1-3 caracteres que se muestran dentro de la burbuja |
|
||||||
|
| `color` | Color base (hex). Usado para box-shadow, glow, prompt |
|
||||||
|
| `gradient` | Fondo de la burbuja (CSS gradient) |
|
||||||
|
| `terminalBg` | Background del terminal frame mockup |
|
||||||
|
| `terminalBorder` | Color del borde del terminal frame |
|
||||||
|
| `enabled` | `true` = visible en la barra, `false` = oculto |
|
||||||
|
|
||||||
|
### 3. Configurar hooks en `settings.json`
|
||||||
|
|
||||||
|
Los hooks notifican a la UI cuando el agente hace algo. Agregar dentro de `settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"AGENT_NAME": "miagente"
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"processing\\\",\\\"agent\\\":\\\"miagente\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolUse\\\",\\\"agent\\\":\\\"miagente\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolDone\\\",\\\"agent\\\":\\\"miagente\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"notification\\\",\\\"agent\\\":\\\"miagente\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"idle\\\",\\\"agent\\\":\\\"miagente\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
||||||
|
"timeout": 5000
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Importante:** El campo `"AGENT_NAME"` en `env` es necesario para que los hooks heredados de `.claude/settings.local.json` (main) se desactiven automáticamente. Sin esto, el agente dispararía tanto sus hooks como los de main.
|
||||||
|
|
||||||
|
### 4. Resultado
|
||||||
|
|
||||||
|
La burbuja aparece automáticamente en la barra inferior. `GET /api/agents` la descubre al escanear `.claude-<nombre>/ui.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status disponibles
|
||||||
|
|
||||||
|
El payload `POST /api/claude-status` acepta:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "processing",
|
||||||
|
"agent": "ejecutor",
|
||||||
|
"tool": "render_vue_component"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Status | Hook que lo dispara | Animación default | Descripción |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `processing` | `UserPromptSubmit` | Pulse naranja + thinking dots | Agente procesando input |
|
||||||
|
| `toolUse` | `PreToolUse` | Flash blanco expandiéndose | Usando una herramienta |
|
||||||
|
| `toolDone` | `PostToolUse` | Reset de reading/writing | Herramienta completada |
|
||||||
|
| `reading` | `PreToolUse` (Read/Glob/Grep) | Bubble cyan + wobble + ojo | Leyendo archivos |
|
||||||
|
| `writing` | `PreToolUse` (Edit/Write) | Bubble verde + scale + lápiz | Escribiendo archivos |
|
||||||
|
| `notification` | `Notification` | Bounce vertical (4x) | Notificación del agente |
|
||||||
|
| `idle` | `Stop` | Vuelve al color original | Agente inactivo |
|
||||||
|
| `permissionRequest` | `PermissionRequest` | Rojo alert + triángulo shake | Esperando permiso |
|
||||||
|
| `sessionStart` | `SessionStart` | (solo App.vue FAB) | Sesión iniciada |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animaciones custom por agente
|
||||||
|
|
||||||
|
Cada agente puede tener sus propias animaciones sobreescribiendo las defaults con el selector `[data-agent="<id>"]` en `AgentBar.vue`.
|
||||||
|
|
||||||
|
### Ejemplo: Ejecutor
|
||||||
|
|
||||||
|
Ejecutor tiene animaciones completamente distintas definidas en el CSS de `AgentBar.vue`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Ejecutor Processing: Heartbeat — doble latido cardiaco */
|
||||||
|
.agent-bubble[data-agent="ejecutor"].processing {
|
||||||
|
animation: ej-heartbeat 1.2s ease-in-out infinite !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ejecutor Tool Flash: Shockwave crimson — doble anillo rojo */
|
||||||
|
.agent-bubble[data-agent="ejecutor"].tool-flash::after {
|
||||||
|
background: rgba(239, 68, 68, 0.5) !important;
|
||||||
|
animation: ej-shockwave 0.6s ease-out forwards !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ejecutor Notification: Glitch — jitter + skew + chromatic split */
|
||||||
|
.agent-bubble[data-agent="ejecutor"].notification {
|
||||||
|
animation: ej-glitch 0.15s steps(2) 8 !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Status | Ejecutor | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `processing` | **Heartbeat** — doble latido, ember glow rojo/naranja, arcos girando | Pulse naranja suave |
|
||||||
|
| `toolUse` | **Crimson Shockwave** — doble anillo rojo expandiéndose | Flash blanco simple |
|
||||||
|
| `notification` | **Glitch** — jitter rápido, skew, split chromático cyan/red | Bounce vertical |
|
||||||
|
| `reading` | **Infrared** — glow rojo barriendo alrededor del borde | Cyan wobble |
|
||||||
|
| `writing` | **Forge** — pulso white-hot a rojo, brightness boost | Verde scale pulse |
|
||||||
|
|
||||||
|
### Contenido interno
|
||||||
|
|
||||||
|
Cuando ejecutor está en `processing`, en vez de los 3 thinking dots genéricos, muestra un **ember ring**: dos arcos concéntricos girando en direcciones opuestas (blanco/naranja y rojo/crimson).
|
||||||
|
|
||||||
|
### Agregar animaciones custom a otro agente
|
||||||
|
|
||||||
|
1. Agregar `@keyframes` con prefijo único (ej: `ma-` para "miagente")
|
||||||
|
2. Sobreescribir con `.agent-bubble[data-agent="miagente"].processing { ... }`
|
||||||
|
3. Opcionalmente agregar contenido inner custom en el template con `v-if="agent.id === 'miagente'"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Herencia de hooks y el guard AGENT_NAME
|
||||||
|
|
||||||
|
Claude Code **hereda hooks** de `.claude/settings.local.json` a todos los perfiles. Esto significa que si main tiene hooks, ejecutor también los dispara.
|
||||||
|
|
||||||
|
**Solución implementada:**
|
||||||
|
|
||||||
|
1. Cada agente secundario define `"AGENT_NAME": "<id>"` en su `env` de `settings.json`
|
||||||
|
2. Los hooks de main tienen un guard al inicio:
|
||||||
|
|
||||||
|
```
|
||||||
|
if($env:AGENT_NAME){exit}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Cuando main corre → `AGENT_NAME` no existe → hooks de main disparan normalmente
|
||||||
|
4. Cuando ejecutor corre → `AGENT_NAME="ejecutor"` → hooks de main hacen `exit` → solo disparan los hooks propios de ejecutor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Probar animaciones manualmente
|
||||||
|
|
||||||
|
Se puede simular cualquier status con curl:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Processing (heartbeat)
|
||||||
|
curl -X POST http://localhost:4101/api/claude-status \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"processing","agent":"ejecutor"}'
|
||||||
|
|
||||||
|
# Tool use (shockwave)
|
||||||
|
curl -X POST http://localhost:4101/api/claude-status \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"toolUse","agent":"ejecutor","tool":"render_vue_component"}'
|
||||||
|
|
||||||
|
# Notification (glitch)
|
||||||
|
curl -X POST http://localhost:4101/api/claude-status \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"notification","agent":"ejecutor"}'
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
curl -X POST http://localhost:4101/api/claude-status \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"idle","agent":"ejecutor"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `GET /api/agents`
|
||||||
|
|
||||||
|
Retorna array de agentes descubiertos:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "main",
|
||||||
|
"name": "Claude Code (main)",
|
||||||
|
"directory": ".claude",
|
||||||
|
"files": [...],
|
||||||
|
"uiConfig": {
|
||||||
|
"label": "Main",
|
||||||
|
"shortLabel": "M",
|
||||||
|
"color": "#6366f1",
|
||||||
|
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||||
|
"terminalBg": "#0f0a1a",
|
||||||
|
"terminalBorder": "#6366f1",
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ejecutor",
|
||||||
|
"name": "Ejecutor",
|
||||||
|
"directory": ".claude-ejecutor",
|
||||||
|
"files": [...],
|
||||||
|
"uiConfig": {
|
||||||
|
"label": "Ejecutor",
|
||||||
|
"shortLabel": "EJ",
|
||||||
|
"color": "#ef4444",
|
||||||
|
"gradient": "linear-gradient(135deg, #ef4444, #dc2626)",
|
||||||
|
"terminalBg": "#0a0f1a",
|
||||||
|
"terminalBorder": "#ef4444",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `uiConfig: null` si no existe `ui.json` en el directorio del agente
|
||||||
|
- `enabled: true` por default si `ui.json` existe pero no tiene el campo
|
||||||
|
|
||||||
|
### `POST /api/claude-status`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "processing",
|
||||||
|
"agent": "ejecutor",
|
||||||
|
"tool": "render_vue_component"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `status` (requerido): uno de los valores de ClaudeStatus
|
||||||
|
- `agent` (opcional): id del agente. Default: `"main"`
|
||||||
|
- `tool` (opcional): nombre de la herramienta en uso
|
||||||
|
|
||||||
|
### `GET /api/agents/file?path=.claude-ejecutor/ui.json`
|
||||||
|
|
||||||
|
Lee un archivo de configuración de agente.
|
||||||
|
|
||||||
|
### `POST /api/agents/file?path=.claude-ejecutor/ui.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "content": "{...}" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Escribe un archivo de configuración. Valida JSON si el archivo termina en `.json`. Paths restringidos a `.claude/*`, `.claude-*/*`, `CLAUDE.md`, `.mcp.json`.
|
||||||
@@ -153,7 +153,12 @@ function connectStatusWs() {
|
|||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
if (msg.type === 'claude-status') {
|
if (msg.type === 'claude-status') {
|
||||||
const status = msg.status as ClaudeStatus
|
const status = msg.status as ClaudeStatus
|
||||||
console.log('[App] Claude status:', status, msg.tool)
|
const agent = msg.agent || 'main'
|
||||||
|
console.log('[App] Claude status:', status, msg.tool, agent)
|
||||||
|
|
||||||
|
// Only animate the main FAB for 'main' agent — other agents use AgentBar
|
||||||
|
if (agent !== 'main') return
|
||||||
|
|
||||||
claudeStatus.value = status
|
claudeStatus.value = status
|
||||||
claudeTool.value = msg.tool || null
|
claudeTool.value = msg.tool || null
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
import { ref, reactive, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { endpoints } from '../config/endpoints'
|
||||||
|
|
||||||
// Web Speech API types
|
// Web Speech API types
|
||||||
interface SpeechRecognitionEvent extends Event {
|
interface SpeechRecognitionEvent extends Event {
|
||||||
@@ -23,6 +24,18 @@ interface SpeechRecognition extends EventTarget {
|
|||||||
abort(): void
|
abort(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking'
|
||||||
|
|
||||||
|
interface AgentStatusState {
|
||||||
|
isProcessing: boolean
|
||||||
|
isReading: boolean
|
||||||
|
isWriting: boolean
|
||||||
|
awaitingPermission: boolean
|
||||||
|
showToolFlash: boolean
|
||||||
|
showNotification: boolean
|
||||||
|
currentTool: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface UiConfig {
|
interface UiConfig {
|
||||||
label: string
|
label: string
|
||||||
shortLabel: string
|
shortLabel: string
|
||||||
@@ -43,6 +56,171 @@ interface Agent {
|
|||||||
const agents = ref<Agent[]>([])
|
const agents = ref<Agent[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// Per-agent status tracking
|
||||||
|
const agentStatuses = reactive<Record<string, AgentStatusState>>({})
|
||||||
|
const agentTimers = new Map<string, Record<string, number>>()
|
||||||
|
|
||||||
|
function getAgentStatus(agentId: string): AgentStatusState {
|
||||||
|
if (!agentStatuses[agentId]) {
|
||||||
|
agentStatuses[agentId] = {
|
||||||
|
isProcessing: false,
|
||||||
|
isReading: false,
|
||||||
|
isWriting: false,
|
||||||
|
awaitingPermission: false,
|
||||||
|
showToolFlash: false,
|
||||||
|
showNotification: false,
|
||||||
|
currentTool: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return agentStatuses[agentId]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimers(agentId: string): Record<string, number> {
|
||||||
|
if (!agentTimers.has(agentId)) {
|
||||||
|
agentTimers.set(agentId, {})
|
||||||
|
}
|
||||||
|
return agentTimers.get(agentId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAgentTimer(agentId: string, key: string) {
|
||||||
|
const timers = getTimers(agentId)
|
||||||
|
if (timers[key]) {
|
||||||
|
clearTimeout(timers[key])
|
||||||
|
delete timers[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAgentTimer(agentId: string, key: string, fn: () => void, ms: number) {
|
||||||
|
clearAgentTimer(agentId, key)
|
||||||
|
const timers = getTimers(agentId)
|
||||||
|
timers[key] = window.setTimeout(fn, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket for claude-status
|
||||||
|
let statusWs: WebSocket | null = null
|
||||||
|
let reconnectTimeout: number | null = null
|
||||||
|
|
||||||
|
function connectStatusWs() {
|
||||||
|
if (statusWs?.readyState === WebSocket.OPEN) return
|
||||||
|
|
||||||
|
console.log('[AgentBar] Connecting to', endpoints.claudeStatus)
|
||||||
|
statusWs = new WebSocket(endpoints.claudeStatus)
|
||||||
|
|
||||||
|
statusWs.onopen = () => {
|
||||||
|
console.log('[AgentBar] WebSocket OPEN')
|
||||||
|
}
|
||||||
|
|
||||||
|
statusWs.onerror = (err) => {
|
||||||
|
console.error('[AgentBar] WebSocket ERROR', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusWs.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
console.log('[AgentBar] WS message:', msg.type, msg.status || '', msg.agent || '')
|
||||||
|
|
||||||
|
if (msg.type !== 'claude-status') return
|
||||||
|
|
||||||
|
const status = msg.status as ClaudeStatus
|
||||||
|
const agentName = (msg.agent || 'main') as string
|
||||||
|
const tool = msg.tool || null
|
||||||
|
|
||||||
|
console.log(`[AgentBar] Status: ${status}, agent: ${agentName}, tool: ${tool}`)
|
||||||
|
|
||||||
|
// Find matching agent by id, name, or label (case-insensitive)
|
||||||
|
const agent = enabledAgents.value.find(a =>
|
||||||
|
a.id.toLowerCase() === agentName.toLowerCase() ||
|
||||||
|
a.name.toLowerCase() === agentName.toLowerCase() ||
|
||||||
|
a.uiConfig?.label.toLowerCase() === agentName.toLowerCase()
|
||||||
|
)
|
||||||
|
if (!agent) {
|
||||||
|
console.log(`[AgentBar] No matching enabled agent for "${agentName}", enabled:`, enabledAgents.value.map(a => a.id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AgentBar] Matched agent: ${agent.id}, applying status: ${status}`)
|
||||||
|
const s = getAgentStatus(agent.id)
|
||||||
|
s.currentTool = tool
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'processing':
|
||||||
|
case 'thinking':
|
||||||
|
s.isProcessing = true
|
||||||
|
// Auto-reset safety (2min)
|
||||||
|
setAgentTimer(agent.id, 'processing', () => {
|
||||||
|
s.isProcessing = false
|
||||||
|
}, 120000)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'idle':
|
||||||
|
s.isProcessing = false
|
||||||
|
s.isReading = false
|
||||||
|
s.isWriting = false
|
||||||
|
s.awaitingPermission = false
|
||||||
|
clearAgentTimer(agent.id, 'processing')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'permissionRequest':
|
||||||
|
s.awaitingPermission = true
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'reading':
|
||||||
|
s.isReading = true
|
||||||
|
triggerToolFlash(agent.id)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'writing':
|
||||||
|
s.isWriting = true
|
||||||
|
triggerToolFlash(agent.id)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'toolUse':
|
||||||
|
triggerToolFlash(agent.id)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'toolDone':
|
||||||
|
s.isReading = false
|
||||||
|
s.isWriting = false
|
||||||
|
s.awaitingPermission = false
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'notification':
|
||||||
|
s.showNotification = true
|
||||||
|
setAgentTimer(agent.id, 'notification', () => {
|
||||||
|
s.showNotification = false
|
||||||
|
}, 2000)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
statusWs.onclose = () => {
|
||||||
|
reconnectTimeout = window.setTimeout(connectStatusWs, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerToolFlash(agentId: string) {
|
||||||
|
const s = getAgentStatus(agentId)
|
||||||
|
s.showToolFlash = true
|
||||||
|
setAgentTimer(agentId, 'toolFlash', () => {
|
||||||
|
s.showToolFlash = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bubble CSS classes based on agent status
|
||||||
|
function bubbleClasses(agent: Agent) {
|
||||||
|
const s = agentStatuses[agent.id]
|
||||||
|
if (!s) return {}
|
||||||
|
return {
|
||||||
|
processing: s.isProcessing && !s.isReading && !s.isWriting && !s.awaitingPermission,
|
||||||
|
reading: s.isReading,
|
||||||
|
writing: s.isWriting,
|
||||||
|
permission: s.awaitingPermission,
|
||||||
|
notification: s.showNotification,
|
||||||
|
'tool-flash': s.showToolFlash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Terminal frame state
|
// Terminal frame state
|
||||||
const terminalAgent = ref<Agent | null>(null)
|
const terminalAgent = ref<Agent | null>(null)
|
||||||
const showTerminal = ref(false)
|
const showTerminal = ref(false)
|
||||||
@@ -64,18 +242,43 @@ const enabledAgents = computed(() =>
|
|||||||
agents.value.filter(a => a.uiConfig?.enabled)
|
agents.value.filter(a => a.uiConfig?.enabled)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dynamic glow based on agent colors
|
// Check if agent has any active animation status
|
||||||
const barGlowStyle = computed(() => {
|
function isAnimating(agent: Agent): boolean {
|
||||||
const list = enabledAgents.value
|
const s = agentStatuses[agent.id]
|
||||||
if (!list.length) return {}
|
if (!s) return false
|
||||||
const glows = list.map(a => {
|
return s.isProcessing || s.isReading || s.isWriting || s.awaitingPermission || s.showNotification || s.showToolFlash
|
||||||
const c = a.uiConfig?.color || '#6366f1'
|
}
|
||||||
return `0 -6px 30px ${c}50, 0 -2px 15px ${c}30`
|
|
||||||
})
|
// Per-bubble glow style (dynamic based on status)
|
||||||
return {
|
// NOTE: When animating, boxShadow is NOT set inline — CSS animations handle it with !important
|
||||||
boxShadow: `${glows.join(', ')}, 0 -10px 50px rgba(0,0,0,0.4)`
|
function bubbleStyle(agent: Agent) {
|
||||||
|
const c = agent.uiConfig?.color || '#6366f1'
|
||||||
|
const s = agentStatuses[agent.id]
|
||||||
|
|
||||||
|
// Override gradient for special states (only background, NO boxShadow — let CSS animate it)
|
||||||
|
if (s?.awaitingPermission) {
|
||||||
|
return { background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' }
|
||||||
}
|
}
|
||||||
})
|
if (s?.isWriting) {
|
||||||
|
return { background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' }
|
||||||
|
}
|
||||||
|
if (s?.isReading) {
|
||||||
|
return { background: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)' }
|
||||||
|
}
|
||||||
|
if (s?.isProcessing) {
|
||||||
|
return { background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
background: agent.uiConfig?.gradient || c,
|
||||||
|
boxShadow: `0 4px 15px ${c}66, 0 8px 30px ${c}4D, inset 0 1px 0 rgba(255,255,255,0.2)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bubbleHoverShadow(agent: Agent) {
|
||||||
|
const c = agent.uiConfig?.color || '#6366f1'
|
||||||
|
return `0 8px 25px ${c}80, 0 15px 40px ${c}59, inset 0 1px 0 rgba(255,255,255,0.25)`
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAgents() {
|
async function fetchAgents() {
|
||||||
try {
|
try {
|
||||||
@@ -172,7 +375,7 @@ function stopRecognition() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pill interaction ---
|
// --- Bubble interaction ---
|
||||||
function handlePointerDown(agent: Agent) {
|
function handlePointerDown(agent: Agent) {
|
||||||
holdTriggered = false
|
holdTriggered = false
|
||||||
holdTimer = window.setTimeout(() => {
|
holdTimer = window.setTimeout(() => {
|
||||||
@@ -198,43 +401,81 @@ function handlePointerLeave() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive
|
|
||||||
const isMobile = ref(window.innerWidth < 768)
|
|
||||||
function onResize() {
|
|
||||||
isMobile.value = window.innerWidth < 768
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchAgents()
|
fetchAgents()
|
||||||
window.addEventListener('resize', onResize)
|
connectStatusWs()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', onResize)
|
|
||||||
stopRecognition()
|
stopRecognition()
|
||||||
recognition = null
|
recognition = null
|
||||||
|
statusWs?.close()
|
||||||
|
if (reconnectTimeout) clearTimeout(reconnectTimeout)
|
||||||
|
// Clear all agent timers
|
||||||
|
for (const [, timers] of agentTimers) {
|
||||||
|
for (const key of Object.keys(timers)) {
|
||||||
|
clearTimeout(timers[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agentTimers.clear()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Agent Bar — arc dock -->
|
<!-- Agent Bubbles — one per agent, like the terminal FAB -->
|
||||||
<div v-if="enabledAgents.length" class="agent-bar">
|
<div v-if="enabledAgents.length" class="agent-bubbles">
|
||||||
<div class="agent-bar-inner" :style="barGlowStyle">
|
<button
|
||||||
<button
|
v-for="agent in enabledAgents"
|
||||||
v-for="agent in enabledAgents"
|
:key="agent.id"
|
||||||
:key="agent.id"
|
class="agent-bubble"
|
||||||
class="agent-pill"
|
:class="bubbleClasses(agent)"
|
||||||
:style="{ background: agent.uiConfig?.gradient || agent.uiConfig?.color }"
|
:data-agent="agent.id"
|
||||||
@pointerdown.prevent="handlePointerDown(agent)"
|
:style="bubbleStyle(agent)"
|
||||||
@pointerup="handlePointerUp(agent)"
|
:title="agentStatuses[agent.id]?.awaitingPermission ? `Permiso requerido: ${agentStatuses[agent.id]?.currentTool || 'herramienta'}` : agentStatuses[agent.id]?.isProcessing ? `${agent.uiConfig?.label}: ${agentStatuses[agent.id]?.currentTool || 'processing'}` : agent.uiConfig?.label || agent.name"
|
||||||
@pointerleave="handlePointerLeave"
|
@pointerdown.prevent="handlePointerDown(agent)"
|
||||||
@contextmenu.prevent
|
@pointerup="handlePointerUp(agent)"
|
||||||
>
|
@pointerleave="handlePointerLeave"
|
||||||
<span class="pill-label">
|
@contextmenu.prevent
|
||||||
{{ isMobile ? agent.uiConfig?.shortLabel : agent.uiConfig?.label }}
|
@mouseenter="!isAnimating(agent) && (($event.currentTarget as HTMLElement).style.boxShadow = bubbleHoverShadow(agent))"
|
||||||
</span>
|
@mouseleave="!isAnimating(agent) && (($event.currentTarget as HTMLElement).style.boxShadow = bubbleStyle(agent).boxShadow || '')"
|
||||||
</button>
|
>
|
||||||
</div>
|
<!-- Permission alert icon -->
|
||||||
|
<svg v-if="agentStatuses[agent.id]?.awaitingPermission" class="bubble-status-icon permission-icon" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="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 0z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Ejecutor processing: ember ring -->
|
||||||
|
<div v-else-if="agent.id === 'ejecutor' && agentStatuses[agent.id]?.isProcessing && !agentStatuses[agent.id]?.isReading && !agentStatuses[agent.id]?.isWriting" class="ember-ring">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default processing: thinking dots -->
|
||||||
|
<div v-else-if="agentStatuses[agent.id]?.isProcessing && !agentStatuses[agent.id]?.isReading && !agentStatuses[agent.id]?.isWriting" class="thinking-dots">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reading icon (eye) -->
|
||||||
|
<svg v-else-if="agentStatuses[agent.id]?.isReading" class="bubble-status-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Writing icon (pencil) -->
|
||||||
|
<svg v-else-if="agentStatuses[agent.id]?.isWriting" class="bubble-status-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 19l7-7 3 3-7 7-3-3z"/>
|
||||||
|
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
|
||||||
|
<path d="M2 2l7.586 7.586"/>
|
||||||
|
<circle cx="11" cy="11" r="2"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Default: agent label -->
|
||||||
|
<span v-else class="bubble-label">{{ agent.uiConfig?.shortLabel }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Terminal Frame Modal -->
|
<!-- Terminal Frame Modal -->
|
||||||
@@ -315,122 +556,386 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ====================== AGENT BAR — ARC DOCK ====================== */
|
/* ====================== AGENT BUBBLES ====================== */
|
||||||
.agent-bar {
|
.agent-bubbles {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 20px;
|
||||||
left: 0;
|
left: 50%;
|
||||||
right: 0;
|
transform: translateX(-50%);
|
||||||
z-index: 9990;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
gap: 14px;
|
||||||
|
z-index: 9998;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-bar-inner {
|
.agent-bubble {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
display: flex;
|
width: 58px;
|
||||||
width: 100%;
|
height: 58px;
|
||||||
height: 50px;
|
border-radius: 18px;
|
||||||
overflow: hidden;
|
color: white;
|
||||||
/* Arc: wide elliptical curve at top, flat bottom */
|
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||||
border-radius: 50% 50% 0 0 / 22px 22px 0 0;
|
|
||||||
/* Top edge highlight */
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sheen line following the arc */
|
|
||||||
.agent-bar-inner::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 15%;
|
|
||||||
right: 15%;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-pill {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 14px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
white-space: nowrap;
|
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
transition: filter 0.15s ease, brightness 0.15s ease;
|
overflow: visible;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: manipulation;
|
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
|
touch-action: manipulation;
|
||||||
position: relative;
|
position: relative;
|
||||||
/* Safe area for phones with gesture bar */
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Divider between pills */
|
/* Hover glow ring */
|
||||||
.agent-pill + .agent-pill {
|
.agent-bubble::before {
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.18);
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -3px;
|
||||||
|
border-radius: 21px;
|
||||||
|
background: inherit;
|
||||||
|
filter: blur(8px);
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-pill:hover {
|
.agent-bubble:hover {
|
||||||
filter: brightness(1.15);
|
transform: translateY(-3px) scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-pill:active {
|
.agent-bubble:hover::before {
|
||||||
filter: brightness(0.9);
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill-label {
|
.agent-bubble:active {
|
||||||
line-height: 1;
|
transform: scale(0.95);
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== STATUS ANIMATIONS (DEFAULT) ====================== */
|
||||||
|
|
||||||
|
/* Kill transition on all animated states */
|
||||||
|
.agent-bubble.processing,
|
||||||
|
.agent-bubble.reading,
|
||||||
|
.agent-bubble.writing,
|
||||||
|
.agent-bubble.permission,
|
||||||
|
.agent-bubble.notification {
|
||||||
|
transition: background 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Processing - Orange pulsing glow */
|
||||||
|
.agent-bubble.processing {
|
||||||
|
animation: ab-processing-pulse 2s ease-in-out infinite !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reading - Cyan scanning wobble */
|
||||||
|
.agent-bubble.reading {
|
||||||
|
animation: ab-reading-scan 1.5s ease-in-out infinite !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Writing - Green pulse scale */
|
||||||
|
.agent-bubble.writing {
|
||||||
|
animation: ab-writing-pulse 0.8s ease-in-out infinite !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Permission - Red alert pulse (highest priority) */
|
||||||
|
.agent-bubble.permission {
|
||||||
|
animation: ab-permission-pulse 1s ease-in-out infinite !important;
|
||||||
|
transform: scale(1.1) !important;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification - Yellow bounce */
|
||||||
|
.agent-bubble.notification {
|
||||||
|
animation: ab-notification-bounce 0.5s ease-in-out 4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool flash - White expanding ring */
|
||||||
|
.agent-bubble.tool-flash::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
animation: ab-tool-flash 0.5s ease-out forwards !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thinking dots (default) */
|
||||||
|
.thinking-dots {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-dots span {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: ab-thinking-dot 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-dots span:nth-child(1) { animation-delay: 0s; }
|
||||||
|
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
/* Status icons inside bubbles */
|
||||||
|
.bubble-status-icon {
|
||||||
|
animation: ab-icon-breathe 1s ease-in-out infinite;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-icon {
|
||||||
|
animation: ab-permission-shake 0.5s ease-in-out infinite;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== EJECUTOR — UNIQUE ANIMATIONS ====================== */
|
||||||
|
|
||||||
|
/* Ejecutor Processing: Heartbeat — double pump with ember glow */
|
||||||
|
.agent-bubble[data-agent="ejecutor"].processing {
|
||||||
|
animation: ej-heartbeat 1.2s ease-in-out infinite !important;
|
||||||
|
border-color: rgba(255, 100, 50, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ejecutor Reading: Infrared scan — red sweep line */
|
||||||
|
.agent-bubble[data-agent="ejecutor"].reading {
|
||||||
|
animation: ej-infrared 1s linear infinite !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ejecutor Writing: Forge — intense white-hot pulse */
|
||||||
|
.agent-bubble[data-agent="ejecutor"].writing {
|
||||||
|
animation: ej-forge 0.6s ease-in-out infinite !important;
|
||||||
|
border-color: rgba(255, 200, 50, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ejecutor Notification: Glitch — rapid jitter + skew */
|
||||||
|
.agent-bubble[data-agent="ejecutor"].notification {
|
||||||
|
animation: ej-glitch 0.15s steps(2) 8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ejecutor Tool Flash: Crimson shockwave — double red ring */
|
||||||
|
.agent-bubble[data-agent="ejecutor"].tool-flash::after {
|
||||||
|
background: rgba(239, 68, 68, 0.5) !important;
|
||||||
|
animation: ej-shockwave 0.6s ease-out forwards !important;
|
||||||
|
border: 1px solid rgba(255, 100, 50, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-bubble[data-agent="ejecutor"].tool-flash::before {
|
||||||
|
content: '' !important;
|
||||||
|
position: absolute !important;
|
||||||
|
inset: -2px !important;
|
||||||
|
border-radius: 20px !important;
|
||||||
|
background: rgba(255, 150, 50, 0.3) !important;
|
||||||
|
filter: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
animation: ej-shockwave-inner 0.4s ease-out forwards !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ember ring — two orbiting arcs inside the bubble */
|
||||||
|
.ember-ring {
|
||||||
|
position: relative;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ember-ring span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ember-ring span:nth-child(1) {
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-right-color: rgba(255, 200, 50, 0.8);
|
||||||
|
animation: ej-ember-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ember-ring span:nth-child(2) {
|
||||||
|
border-bottom-color: rgba(255, 100, 50, 0.9);
|
||||||
|
border-left-color: rgba(255, 50, 50, 0.6);
|
||||||
|
animation: ej-ember-spin 1.2s linear infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== EJECUTOR KEYFRAMES ====================== */
|
||||||
|
|
||||||
|
/* Heartbeat: double-pump scale like a real heartbeat */
|
||||||
|
@keyframes ej-heartbeat {
|
||||||
|
0% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
|
||||||
|
14% { transform: scale(1.15) !important; box-shadow: 0 4px 35px rgba(239, 68, 68, 0.7) !important; }
|
||||||
|
28% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
|
||||||
|
42% { transform: scale(1.22) !important; box-shadow: 0 4px 45px rgba(255, 100, 50, 0.8) !important; }
|
||||||
|
70% { transform: scale(1) !important; box-shadow: 0 4px 15px rgba(239, 68, 68, 0.2) !important; }
|
||||||
|
100% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Infrared scan: red glow sweeps around the border */
|
||||||
|
@keyframes ej-infrared {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 -8px 20px rgba(239, 68, 68, 0.6), 0 8px 20px rgba(239, 68, 68, 0.1) !important;
|
||||||
|
transform: rotate(0deg) !important;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
box-shadow: 8px 0 20px rgba(239, 68, 68, 0.6), -8px 0 20px rgba(239, 68, 68, 0.1) !important;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 8px 20px rgba(239, 68, 68, 0.6), 0 -8px 20px rgba(239, 68, 68, 0.1) !important;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
box-shadow: -8px 0 20px rgba(239, 68, 68, 0.6), 8px 0 20px rgba(239, 68, 68, 0.1) !important;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 -8px 20px rgba(239, 68, 68, 0.6), 0 8px 20px rgba(239, 68, 68, 0.1) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forge: intense white-hot to red pulse */
|
||||||
|
@keyframes ej-forge {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1) !important;
|
||||||
|
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4) !important;
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.06) !important;
|
||||||
|
box-shadow: 0 4px 30px rgba(255, 200, 50, 0.8), 0 0 15px rgba(255, 255, 255, 0.3) !important;
|
||||||
|
filter: brightness(1.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glitch: rapid random jitter + skew */
|
||||||
|
@keyframes ej-glitch {
|
||||||
|
0% { transform: translate(0, 0) skewX(0deg) !important; }
|
||||||
|
25% { transform: translate(-3px, 1px) skewX(-4deg) !important; box-shadow: -3px 0 8px rgba(0, 255, 255, 0.4), 3px 0 8px rgba(255, 0, 50, 0.4) !important; }
|
||||||
|
50% { transform: translate(2px, -2px) skewX(3deg) !important; box-shadow: 3px 0 8px rgba(0, 255, 255, 0.4), -3px 0 8px rgba(255, 0, 50, 0.4) !important; }
|
||||||
|
75% { transform: translate(-1px, 2px) skewX(-2deg) !important; }
|
||||||
|
100%{ transform: translate(0, 0) skewX(0deg) !important; box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4) !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crimson shockwave: expanding red ring */
|
||||||
|
@keyframes ej-shockwave {
|
||||||
|
0% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(1.6); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inner shockwave: smaller faster ring */
|
||||||
|
@keyframes ej-shockwave-inner {
|
||||||
|
0% { opacity: 0.8; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(1.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ember spinning arcs */
|
||||||
|
@keyframes ej-ember-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== DEFAULT KEYFRAMES ====================== */
|
||||||
|
|
||||||
|
@keyframes ab-processing-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 8px 24px rgba(245, 158, 11, 0.4) !important; }
|
||||||
|
50% { box-shadow: 0 8px 40px rgba(245, 158, 11, 0.8) !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-reading-scan {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 8px 24px rgba(6, 182, 212, 0.4) !important;
|
||||||
|
transform: rotate(0deg) !important;
|
||||||
|
}
|
||||||
|
25% { transform: rotate(-3deg) !important; }
|
||||||
|
75% { transform: rotate(3deg) !important; }
|
||||||
|
50% { box-shadow: 0 8px 40px rgba(6, 182, 212, 0.8) !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-writing-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1) !important;
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4) !important;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.08) !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.7) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-permission-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7) !important;
|
||||||
|
transform: scale(1.1) !important;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 15px rgba(239, 68, 68, 0) !important;
|
||||||
|
transform: scale(1.05) !important;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0) !important;
|
||||||
|
transform: scale(1.1) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-permission-shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-2px); }
|
||||||
|
75% { transform: translateX(2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-notification-bounce {
|
||||||
|
0%, 100% { transform: translateY(0) !important; }
|
||||||
|
50% { transform: translateY(-10px) !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-tool-flash {
|
||||||
|
0% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(1.4); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-thinking-dot {
|
||||||
|
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
|
||||||
|
40% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-icon-breathe {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== LABELS ====================== */
|
||||||
|
|
||||||
|
.bubble-label {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Responsive: Tablet (768+) ---- */
|
/* Mobile */
|
||||||
@media (min-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.agent-bar-inner {
|
.agent-bubbles {
|
||||||
max-width: 480px;
|
bottom: 80px;
|
||||||
height: 56px;
|
gap: 12px;
|
||||||
border-radius: 50% 50% 0 0 / 26px 26px 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-pill {
|
.agent-bubble {
|
||||||
font-size: 15px;
|
width: 52px;
|
||||||
}
|
height: 52px;
|
||||||
}
|
border-radius: 16px;
|
||||||
|
|
||||||
/* ---- Responsive: Desktop (1200+) ---- */
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
.agent-bar-inner {
|
|
||||||
max-width: 580px;
|
|
||||||
height: 62px;
|
|
||||||
border-radius: 50% 50% 0 0 / 30px 30px 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-pill {
|
.agent-bubble::before {
|
||||||
font-size: 16px;
|
border-radius: 19px;
|
||||||
letter-spacing: 0.8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Responsive: 4K / TV (2400+) ---- */
|
|
||||||
@media (min-width: 2400px) {
|
|
||||||
.agent-bar-inner {
|
|
||||||
max-width: 740px;
|
|
||||||
height: 76px;
|
|
||||||
border-radius: 50% 50% 0 0 / 38px 38px 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-pill {
|
.bubble-label {
|
||||||
font-size: 20px;
|
font-size: 13px;
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
@@ -4,3 +4,4 @@ export { default as CommitList } from './CommitList.vue'
|
|||||||
export { default as BranchSelector } from './BranchSelector.vue'
|
export { default as BranchSelector } from './BranchSelector.vue'
|
||||||
export { default as ProjectTree } from './ProjectTree.vue'
|
export { default as ProjectTree } from './ProjectTree.vue'
|
||||||
export { default as FileViewer } from './FileViewer.vue'
|
export { default as FileViewer } from './FileViewer.vue'
|
||||||
|
export { default as StatusTree } from './StatusTree.vue'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useGitApi } from '@/composables/git'
|
import { useGitApi } from '@/composables/git'
|
||||||
import { DiffViewer, FileTree, CommitList, BranchSelector, ProjectTree, FileViewer } from '@/components/git'
|
import { DiffViewer, FileTree, CommitList, BranchSelector, ProjectTree, FileViewer, StatusTree } from '@/components/git'
|
||||||
import { endpoints } from '@/config/endpoints'
|
import { endpoints } from '@/config/endpoints'
|
||||||
|
|
||||||
type TabName = 'status' | 'history' | 'compare' | 'files'
|
type TabName = 'status' | 'history' | 'compare' | 'files'
|
||||||
@@ -309,17 +309,11 @@ const totalChanges = computed(() => {
|
|||||||
<span class="section-label">Staged</span>
|
<span class="section-label">Staged</span>
|
||||||
<span class="section-count">{{ status.staged.length }}</span>
|
<span class="section-count">{{ status.staged.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-list">
|
<StatusTree
|
||||||
<div
|
:files="status.staged.map(f => ({ path: f.path, status: f.status }))"
|
||||||
v-for="file in status.staged"
|
:selected-path="selectedStaged ? selectedFile : null"
|
||||||
:key="'staged-' + file.path"
|
@select="selectFile($event, true)"
|
||||||
:class="['file-row', { selected: selectedFile === file.path && selectedStaged }]"
|
/>
|
||||||
@click="selectFile(file.path, true)"
|
|
||||||
>
|
|
||||||
<span :class="['file-badge', file.status]">{{ file.status[0].toUpperCase() }}</span>
|
|
||||||
<span class="file-name">{{ file.path }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unstaged files -->
|
<!-- Unstaged files -->
|
||||||
@@ -329,17 +323,11 @@ const totalChanges = computed(() => {
|
|||||||
<span class="section-label">Unstaged</span>
|
<span class="section-label">Unstaged</span>
|
||||||
<span class="section-count">{{ status.unstaged.length }}</span>
|
<span class="section-count">{{ status.unstaged.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-list">
|
<StatusTree
|
||||||
<div
|
:files="status.unstaged.map(f => ({ path: f.path, status: f.status }))"
|
||||||
v-for="file in status.unstaged"
|
:selected-path="!selectedStaged ? selectedFile : null"
|
||||||
:key="'unstaged-' + file.path"
|
@select="selectFile($event, false)"
|
||||||
:class="['file-row', { selected: selectedFile === file.path && !selectedStaged }]"
|
/>
|
||||||
@click="selectFile(file.path, false)"
|
|
||||||
>
|
|
||||||
<span :class="['file-badge', file.status]">{{ file.status[0].toUpperCase() }}</span>
|
|
||||||
<span class="file-name">{{ file.path }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Untracked files -->
|
<!-- Untracked files -->
|
||||||
@@ -349,16 +337,9 @@ const totalChanges = computed(() => {
|
|||||||
<span class="section-label">Untracked</span>
|
<span class="section-label">Untracked</span>
|
||||||
<span class="section-count">{{ status.untracked.length }}</span>
|
<span class="section-count">{{ status.untracked.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-list">
|
<StatusTree
|
||||||
<div
|
:files="status.untracked.map(f => ({ path: f, status: 'untracked' }))"
|
||||||
v-for="file in status.untracked"
|
/>
|
||||||
:key="'untracked-' + file"
|
|
||||||
class="file-row untracked-file"
|
|
||||||
>
|
|
||||||
<span class="file-badge untracked">?</span>
|
|
||||||
<span class="file-name">{{ file }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Diff viewer for selected file -->
|
<!-- Diff viewer for selected file -->
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type ClaudeStatus =
|
|||||||
interface ClaudeStatusPayload {
|
interface ClaudeStatusPayload {
|
||||||
status: ClaudeStatus
|
status: ClaudeStatus
|
||||||
tool?: string
|
tool?: string
|
||||||
|
agent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleClaudeStatus(req: Request): Promise<Response | null> {
|
export async function handleClaudeStatus(req: Request): Promise<Response | null> {
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ export function startTerminalServer() {
|
|||||||
// Claude status broadcast endpoint
|
// Claude status broadcast endpoint
|
||||||
if (url.pathname === '/claude-status' && req.method === 'POST') {
|
if (url.pathname === '/claude-status' && req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await req.json() as { status: ClaudeStatus, tool?: string }
|
const body = await req.json() as { status: ClaudeStatus, tool?: string, agent?: string }
|
||||||
broadcastClaudeStatus(body.status, body.tool)
|
broadcastClaudeStatus(body.status, body.tool, body.agent)
|
||||||
return Response.json({ success: true }, { headers: corsHeaders })
|
return Response.json({ success: true }, { headers: corsHeaders })
|
||||||
} catch {
|
} catch {
|
||||||
return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders })
|
return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders })
|
||||||
@@ -246,11 +246,12 @@ export function startTerminalServer() {
|
|||||||
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking'
|
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking'
|
||||||
|
|
||||||
// Broadcast Claude status to ALL clients across ALL sessions
|
// Broadcast Claude status to ALL clients across ALL sessions
|
||||||
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string) {
|
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) {
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
type: 'claude-status',
|
type: 'claude-status',
|
||||||
status,
|
status,
|
||||||
tool,
|
tool,
|
||||||
|
agent: agent || 'main',
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user