refactor: remove dead notification systems and legacy broadcasts
- Delete claude-hooks.ts store (processHook never called, always empty) - Delete HookNotifications.vue and NotificationLog.vue (orphan components) - Delete claude-status.ts route and broadcastClaudeStatus (no consumers) - Delete agent-bar.md legacy doc - Remove legacy WS 'claude-hook' broadcast (frontend ignores it) - Move isAgentRunning tracking into broadcastClaudeHook
This commit is contained in:
@@ -1,334 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useClaudeHooksStore, type HookNotification } from '../stores/claude-hooks'
|
|
||||||
|
|
||||||
const store = useClaudeHooksStore()
|
|
||||||
|
|
||||||
function iconForEvent(n: HookNotification) {
|
|
||||||
switch (n.event) {
|
|
||||||
case 'SessionStart': return 'session'
|
|
||||||
case 'UserPromptSubmit': return 'prompt'
|
|
||||||
case 'PreToolUse': return 'tool'
|
|
||||||
case 'PostToolUse': return 'result'
|
|
||||||
case 'PermissionRequest': return 'permission'
|
|
||||||
case 'Notification': return 'bell'
|
|
||||||
case 'Stop': return 'check'
|
|
||||||
default: return 'info'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeClass(n: HookNotification) {
|
|
||||||
return `toast--${n.type}`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<TransitionGroup name="toast" tag="div" class="hook-toasts">
|
|
||||||
<div
|
|
||||||
v-for="n in store.visible"
|
|
||||||
:key="n.id"
|
|
||||||
class="hook-toast"
|
|
||||||
:class="[typeClass(n), { 'hook-toast--permission': n.event === 'PermissionRequest' }]"
|
|
||||||
>
|
|
||||||
<!-- Icon -->
|
|
||||||
<div class="toast-icon">
|
|
||||||
<!-- Session -->
|
|
||||||
<svg v-if="iconForEvent(n) === 'session'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
<ellipse cx="12" cy="12" rx="9" ry="4" stroke-dasharray="4 2" transform="rotate(-30 12 12)"/>
|
|
||||||
<ellipse cx="12" cy="12" rx="9" ry="4" stroke-dasharray="4 2" transform="rotate(30 12 12)"/>
|
|
||||||
</svg>
|
|
||||||
<!-- Prompt (user message) -->
|
|
||||||
<svg v-else-if="iconForEvent(n) === 'prompt'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<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>
|
|
||||||
<!-- Tool -->
|
|
||||||
<svg v-else-if="iconForEvent(n) === 'tool'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<polyline points="4 17 10 11 4 5"/>
|
|
||||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
|
||||||
</svg>
|
|
||||||
<!-- Result (PostToolUse) -->
|
|
||||||
<svg v-else-if="iconForEvent(n) === 'result'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<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>
|
|
||||||
<!-- Permission -->
|
|
||||||
<svg v-else-if="iconForEvent(n) === 'permission'" width="16" height="16" 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>
|
|
||||||
<!-- Bell -->
|
|
||||||
<svg v-else-if="iconForEvent(n) === 'bell'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
|
||||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
|
||||||
</svg>
|
|
||||||
<!-- Check -->
|
|
||||||
<svg v-else-if="iconForEvent(n) === 'check'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<polyline points="20 6 9 17 4 12"/>
|
|
||||||
</svg>
|
|
||||||
<!-- Info fallback -->
|
|
||||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="toast-body">
|
|
||||||
<span class="toast-title">{{ n.title }}</span>
|
|
||||||
<span v-if="n.detail" class="toast-detail">{{ n.detail }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dismiss -->
|
|
||||||
<button class="toast-close" @click="store.remove(n.id)">×</button>
|
|
||||||
</div>
|
|
||||||
</TransitionGroup>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.hook-toasts {
|
|
||||||
position: fixed;
|
|
||||||
top: 40px;
|
|
||||||
right: 16px;
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
max-width: 380px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hook-toast {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(22, 22, 29, 0.85);
|
|
||||||
backdrop-filter: blur(20px) saturate(1.4);
|
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
|
||||||
pointer-events: auto;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--text-primary, #e4e4e7);
|
|
||||||
min-width: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Type colors - left accent */
|
|
||||||
.hook-toast::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 6px;
|
|
||||||
bottom: 6px;
|
|
||||||
width: 3px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
.hook-toast { position: relative; }
|
|
||||||
|
|
||||||
.toast--info::before { background: var(--accent, #6366f1); }
|
|
||||||
.toast--success::before { background: #10b981; }
|
|
||||||
.toast--warning::before { background: #f59e0b; }
|
|
||||||
.toast--error::before { background: #ef4444; }
|
|
||||||
|
|
||||||
/* Permission toast - emphasized */
|
|
||||||
.hook-toast--permission {
|
|
||||||
border-color: rgba(239, 68, 68, 0.3);
|
|
||||||
background: rgba(30, 15, 15, 0.9);
|
|
||||||
animation: permission-glow 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes permission-glow {
|
|
||||||
0%, 100% { box-shadow: 0 4px 20px rgba(239, 68, 68, 0.15); }
|
|
||||||
50% { box-shadow: 0 4px 24px rgba(239, 68, 68, 0.35); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon */
|
|
||||||
.toast-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 7px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast--info .toast-icon { background: rgba(99,102,241,0.15); color: #818cf8; }
|
|
||||||
.toast--success .toast-icon { background: rgba(16,185,129,0.15); color: #34d399; }
|
|
||||||
.toast--warning .toast-icon { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
|
||||||
.toast--error .toast-icon { background: rgba(239,68,68,0.15); color: #f87171; }
|
|
||||||
|
|
||||||
/* Body */
|
|
||||||
.toast-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 12.5px;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-detail {
|
|
||||||
color: var(--text-secondary, #a1a1aa);
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dismiss */
|
|
||||||
.toast-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted, #52525b);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 0 2px;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.hook-toast:hover .toast-close { opacity: 1; }
|
|
||||||
|
|
||||||
/* Permission action buttons */
|
|
||||||
.toast-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-btn {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-btn--allow {
|
|
||||||
background: rgba(16, 185, 129, 0.15);
|
|
||||||
color: #34d399;
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
|
||||||
}
|
|
||||||
.toast-btn--allow:hover {
|
|
||||||
background: rgba(16, 185, 129, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-btn--deny {
|
|
||||||
background: rgba(239, 68, 68, 0.15);
|
|
||||||
color: #f87171;
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
|
||||||
}
|
|
||||||
.toast-btn--deny:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transitions */
|
|
||||||
.toast-enter-active {
|
|
||||||
animation: toast-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
|
||||||
.toast-leave-active {
|
|
||||||
animation: toast-out 0.2s ease-in forwards;
|
|
||||||
}
|
|
||||||
.toast-move {
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes toast-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(40px) scale(0.95);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes toast-out {
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(30px) scale(0.95);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.hook-toasts {
|
|
||||||
right: 8px;
|
|
||||||
left: 8px;
|
|
||||||
max-width: none;
|
|
||||||
top: 36px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch, onMounted } from 'vue'
|
|
||||||
import { useClaudeHooksStore } from '../stores/claude-hooks'
|
|
||||||
import { useCanvasStore } from '../stores/canvas'
|
|
||||||
|
|
||||||
interface LogEntry {
|
|
||||||
id: string
|
|
||||||
source: 'hook' | 'canvas' | 'response'
|
|
||||||
type: 'info' | 'success' | 'warning' | 'error'
|
|
||||||
title: string
|
|
||||||
detail: string
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'notification-log'
|
|
||||||
const MAX_ENTRIES = 500
|
|
||||||
|
|
||||||
const hooksStore = useClaudeHooksStore()
|
|
||||||
const canvasStore = useCanvasStore()
|
|
||||||
|
|
||||||
const isOpen = ref(false)
|
|
||||||
const entries = ref<LogEntry[]>(loadFromStorage())
|
|
||||||
const filter = ref<'all' | 'hook' | 'canvas' | 'response'>('all')
|
|
||||||
|
|
||||||
// Track what we've already logged to avoid duplicates
|
|
||||||
const seenHookIds = new Set<string>()
|
|
||||||
const seenCanvasIds = new Set<number>()
|
|
||||||
|
|
||||||
// Seed seen IDs from existing entries on load
|
|
||||||
entries.value.forEach(e => {
|
|
||||||
if (e.source === 'hook') seenHookIds.add(e.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
function loadFromStorage(): LogEntry[] {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
|
||||||
return raw ? JSON.parse(raw) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveToStorage() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.value.slice(-MAX_ENTRIES)))
|
|
||||||
} catch { /* quota exceeded — silently ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function addEntry(entry: LogEntry) {
|
|
||||||
entries.value.push(entry)
|
|
||||||
if (entries.value.length > MAX_ENTRIES) {
|
|
||||||
entries.value = entries.value.slice(-MAX_ENTRIES)
|
|
||||||
}
|
|
||||||
saveToStorage()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAll() {
|
|
||||||
entries.value = []
|
|
||||||
seenHookIds.clear()
|
|
||||||
seenCanvasIds.clear()
|
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(ts: number): string {
|
|
||||||
return new Date(ts).toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(ts: number): string {
|
|
||||||
return new Date(ts).toLocaleDateString('es', { day: '2-digit', month: 'short' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group entries by date
|
|
||||||
function getFilteredEntries() {
|
|
||||||
if (filter.value === 'all') return entries.value
|
|
||||||
return entries.value.filter(e => e.source === filter.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch hook notifications
|
|
||||||
watch(() => hooksStore.notifications, (notifs) => {
|
|
||||||
for (const n of notifs) {
|
|
||||||
if (seenHookIds.has(n.id)) continue
|
|
||||||
seenHookIds.add(n.id)
|
|
||||||
addEntry({
|
|
||||||
id: n.id,
|
|
||||||
source: 'hook',
|
|
||||||
type: n.type,
|
|
||||||
title: n.title,
|
|
||||||
detail: n.detail,
|
|
||||||
timestamp: n.timestamp
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Watch canvas notifications
|
|
||||||
watch(() => canvasStore.notifications, (notifs) => {
|
|
||||||
for (const n of notifs) {
|
|
||||||
if (seenCanvasIds.has(n.id)) continue
|
|
||||||
seenCanvasIds.add(n.id)
|
|
||||||
addEntry({
|
|
||||||
id: `canvas_${n.id}`,
|
|
||||||
source: 'canvas',
|
|
||||||
type: n.type as LogEntry['type'],
|
|
||||||
title: 'App',
|
|
||||||
detail: n.message,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Expose addResponseEntry for external use (FloatingResponse)
|
|
||||||
function addResponseEntry(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info') {
|
|
||||||
addEntry({
|
|
||||||
id: `resp_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`,
|
|
||||||
source: 'response',
|
|
||||||
type,
|
|
||||||
title: 'Agent Response',
|
|
||||||
detail: message.length > 300 ? message.slice(0, 300) + '...' : message,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ addResponseEntry })
|
|
||||||
|
|
||||||
const count = ref(0)
|
|
||||||
watch(entries, (e) => { count.value = e.length }, { immediate: true })
|
|
||||||
|
|
||||||
const sourceLabels: Record<string, string> = {
|
|
||||||
hook: 'Hook',
|
|
||||||
canvas: 'App',
|
|
||||||
response: 'Response'
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
|
||||||
info: '#6366f1',
|
|
||||||
success: '#10b981',
|
|
||||||
warning: '#f59e0b',
|
|
||||||
error: '#ef4444'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<!-- Toggle button -->
|
|
||||||
<button
|
|
||||||
class="notif-log-toggle"
|
|
||||||
:class="{ open: isOpen }"
|
|
||||||
@click="isOpen = !isOpen"
|
|
||||||
title="Notification Log"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
|
||||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
|
||||||
</svg>
|
|
||||||
<span v-if="count > 0" class="notif-badge">{{ count > 99 ? '99+' : count }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Panel -->
|
|
||||||
<Transition name="notif-slide">
|
|
||||||
<div v-if="isOpen" class="notif-log-panel">
|
|
||||||
<div class="notif-log-header">
|
|
||||||
<span class="notif-log-title">Notifications ({{ getFilteredEntries().length }})</span>
|
|
||||||
<div class="notif-log-actions">
|
|
||||||
<select v-model="filter" class="notif-filter">
|
|
||||||
<option value="all">All</option>
|
|
||||||
<option value="hook">Hooks</option>
|
|
||||||
<option value="canvas">App</option>
|
|
||||||
<option value="response">Response</option>
|
|
||||||
</select>
|
|
||||||
<button @click="clearAll" class="notif-clear" title="Clear all">Clear</button>
|
|
||||||
<button @click="isOpen = false" class="notif-close">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notif-log-body">
|
|
||||||
<template v-if="getFilteredEntries().length">
|
|
||||||
<div
|
|
||||||
v-for="entry in [...getFilteredEntries()].reverse()"
|
|
||||||
:key="entry.id"
|
|
||||||
class="notif-entry"
|
|
||||||
>
|
|
||||||
<div class="notif-entry-accent" :style="{ background: typeColors[entry.type] }"></div>
|
|
||||||
<div class="notif-entry-content">
|
|
||||||
<div class="notif-entry-top">
|
|
||||||
<span class="notif-source" :class="entry.source">{{ sourceLabels[entry.source] || entry.source }}</span>
|
|
||||||
<span class="notif-type-dot" :style="{ background: typeColors[entry.type] }"></span>
|
|
||||||
<span class="notif-entry-title">{{ entry.title }}</span>
|
|
||||||
<span class="notif-entry-time">{{ formatTime(entry.timestamp) }}</span>
|
|
||||||
<span class="notif-entry-date">{{ formatDate(entry.timestamp) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="entry.detail" class="notif-entry-detail">{{ entry.detail }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-else class="notif-empty">No notifications yet</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.notif-log-toggle {
|
|
||||||
position: fixed;
|
|
||||||
top: 8px;
|
|
||||||
right: 80px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(40, 40, 50, 0.85);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
color: #999;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 10003;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-log-toggle:hover {
|
|
||||||
background: rgba(60, 60, 75, 0.95);
|
|
||||||
color: #ddd;
|
|
||||||
border-color: rgba(99, 102, 241, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-log-toggle.open {
|
|
||||||
background: rgba(99, 102, 241, 0.25);
|
|
||||||
color: #a5b4fc;
|
|
||||||
border-color: rgba(99, 102, 241, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
right: -4px;
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 10px;
|
|
||||||
min-width: 14px;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Panel */
|
|
||||||
.notif-log-panel {
|
|
||||||
position: fixed;
|
|
||||||
top: 44px;
|
|
||||||
right: 12px;
|
|
||||||
width: 380px;
|
|
||||||
max-height: calc(100vh - 100px);
|
|
||||||
background: rgba(18, 18, 24, 0.98);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
z-index: 10003;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-log-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-log-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-log-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-filter {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-filter option {
|
|
||||||
background: #1a1a24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-clear,
|
|
||||||
.notif-close {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #888;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-close {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 0 6px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-clear:hover { background: rgba(239, 68, 68, 0.25); color: #fca5a5; }
|
|
||||||
.notif-close:hover { background: rgba(255, 255, 255, 0.15); color: #ddd; }
|
|
||||||
|
|
||||||
/* Body */
|
|
||||||
.notif-log-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 6px;
|
|
||||||
max-height: calc(100vh - 180px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-log-body::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-log-body::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-log-body::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Entry */
|
|
||||||
.notif-entry {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-entry:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-entry-accent {
|
|
||||||
width: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-entry-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 6px 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-entry-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-source {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-source.hook {
|
|
||||||
background: rgba(99, 102, 241, 0.2);
|
|
||||||
color: #a5b4fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-source.canvas {
|
|
||||||
background: rgba(16, 185, 129, 0.2);
|
|
||||||
color: #6ee7b7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-source.response {
|
|
||||||
background: rgba(245, 158, 11, 0.2);
|
|
||||||
color: #fcd34d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-type-dot {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-entry-title {
|
|
||||||
color: #ccc;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-entry-time {
|
|
||||||
color: #555;
|
|
||||||
font-size: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-entry-date {
|
|
||||||
color: #444;
|
|
||||||
font-size: 9px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-entry-detail {
|
|
||||||
color: #777;
|
|
||||||
font-size: 10px;
|
|
||||||
margin-top: 2px;
|
|
||||||
word-break: break-word;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-empty {
|
|
||||||
text-align: center;
|
|
||||||
color: #555;
|
|
||||||
padding: 40px 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transitions */
|
|
||||||
.notif-slide-enter-active,
|
|
||||||
.notif-slide-leave-active {
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notif-slide-enter-from,
|
|
||||||
.notif-slide-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px) scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.notif-log-panel {
|
|
||||||
left: 8px;
|
|
||||||
right: 8px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { ref, computed } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
export interface HookNotification {
|
|
||||||
id: string
|
|
||||||
event: string
|
|
||||||
icon: string
|
|
||||||
title: string
|
|
||||||
detail: string
|
|
||||||
type: 'info' | 'success' | 'warning' | 'error'
|
|
||||||
timestamp: number
|
|
||||||
persistent?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useClaudeHooksStore = defineStore('claude-hooks', () => {
|
|
||||||
const notifications = ref<HookNotification[]>([])
|
|
||||||
const MAX_VISIBLE = 5
|
|
||||||
|
|
||||||
const visible = computed(() => notifications.value.slice(-MAX_VISIBLE))
|
|
||||||
|
|
||||||
function add(notif: HookNotification) {
|
|
||||||
notifications.value.push(notif)
|
|
||||||
if (!notif.persistent) {
|
|
||||||
const duration = notif.type === 'warning' ? 5000 : 3500
|
|
||||||
setTimeout(() => remove(notif.id), duration)
|
|
||||||
}
|
|
||||||
// Cap total stored
|
|
||||||
if (notifications.value.length > 30) {
|
|
||||||
notifications.value = notifications.value.slice(-20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove(id: string) {
|
|
||||||
const idx = notifications.value.findIndex(n => n.id === id)
|
|
||||||
if (idx !== -1) notifications.value.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear() {
|
|
||||||
notifications.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process a raw claude-hook WS message into a notification (or ignore it)
|
|
||||||
function processHook(data: Record<string, any>) {
|
|
||||||
const event = data.hook_event_name
|
|
||||||
const toolName = data.tool_name || ''
|
|
||||||
const id = `hook_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
|
||||||
|
|
||||||
switch (event) {
|
|
||||||
case 'SessionStart':
|
|
||||||
add({
|
|
||||||
id, event, type: 'info',
|
|
||||||
icon: '', title: 'Session started',
|
|
||||||
detail: data.model ? `Model: ${data.model}` : '',
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'UserPromptSubmit': {
|
|
||||||
const prompt = data.prompt || ''
|
|
||||||
add({
|
|
||||||
id, event, type: 'info',
|
|
||||||
icon: '', title: 'Prompt',
|
|
||||||
detail: prompt.length > 100 ? prompt.slice(0, 100) + '...' : prompt,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'PreToolUse': {
|
|
||||||
// Only notify for interesting tools, not reads
|
|
||||||
if (/^(Read|Glob|Grep)$/.test(toolName)) return
|
|
||||||
const input = data.tool_input || {}
|
|
||||||
let detail = ''
|
|
||||||
if (toolName === 'Bash') {
|
|
||||||
const cmd = input.command || ''
|
|
||||||
detail = cmd.length > 80 ? cmd.slice(0, 80) + '...' : cmd
|
|
||||||
} else if (toolName === 'Edit' || toolName === 'Write') {
|
|
||||||
detail = input.file_path ? shortPath(input.file_path) : ''
|
|
||||||
} else if (toolName === 'Task') {
|
|
||||||
detail = input.description || input.prompt?.slice(0, 60) || ''
|
|
||||||
} else if (toolName === 'WebSearch') {
|
|
||||||
detail = input.query || ''
|
|
||||||
} else if (toolName === 'WebFetch') {
|
|
||||||
detail = input.url || ''
|
|
||||||
} else {
|
|
||||||
detail = toolName
|
|
||||||
}
|
|
||||||
add({
|
|
||||||
id, event, type: 'info',
|
|
||||||
icon: '', title: formatToolName(toolName),
|
|
||||||
detail, timestamp: Date.now()
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'PostToolUse': {
|
|
||||||
// Skip noisy read tools
|
|
||||||
if (/^(Read|Glob|Grep)$/.test(toolName)) return
|
|
||||||
const response = data.tool_response
|
|
||||||
let detail = ''
|
|
||||||
if (typeof response === 'string') {
|
|
||||||
detail = response.length > 120 ? response.slice(0, 120) + '...' : response
|
|
||||||
} else if (response) {
|
|
||||||
const json = JSON.stringify(response)
|
|
||||||
detail = json.length > 120 ? json.slice(0, 120) + '...' : json
|
|
||||||
}
|
|
||||||
// Clean control chars and excessive whitespace
|
|
||||||
detail = detail.replace(/[\x00-\x1f]+/g, ' ').replace(/\s+/g, ' ').trim()
|
|
||||||
if (!detail) return
|
|
||||||
add({
|
|
||||||
id, event, type: 'success',
|
|
||||||
icon: '', title: `${toolName} result`,
|
|
||||||
detail, timestamp: Date.now()
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'Notification':
|
|
||||||
add({
|
|
||||||
id, event, type: 'warning',
|
|
||||||
icon: '', title: 'Claude notification',
|
|
||||||
detail: data.message || '',
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'Stop': {
|
|
||||||
const response = data.assistant_response || ''
|
|
||||||
const detail = response.length > 200 ? response.slice(0, 200) + '...' : response
|
|
||||||
add({
|
|
||||||
id, event, type: 'success',
|
|
||||||
icon: '', title: response ? 'Claude response' : 'Session finished',
|
|
||||||
detail,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
persistent: !!response // Keep visible if there's a response to read
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { notifications, visible, add, remove, clear, processHook }
|
|
||||||
})
|
|
||||||
|
|
||||||
function shortPath(p: string): string {
|
|
||||||
const parts = p.replace(/\\/g, '/').split('/')
|
|
||||||
if (parts.length <= 3) return parts.join('/')
|
|
||||||
return '.../' + parts.slice(-2).join('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatToolName(name: string): string {
|
|
||||||
switch (name) {
|
|
||||||
case 'Bash': return 'Running command'
|
|
||||||
case 'Edit': return 'Editing file'
|
|
||||||
case 'Write': return 'Writing file'
|
|
||||||
case 'Task': return 'Spawning agent'
|
|
||||||
case 'WebSearch': return 'Searching web'
|
|
||||||
case 'WebFetch': return 'Fetching URL'
|
|
||||||
case 'NotebookEdit': return 'Editing notebook'
|
|
||||||
default: return `Tool: ${name}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
|
||||||
import { PORT_TERMINAL } from '../config'
|
|
||||||
|
|
||||||
type ClaudeStatus =
|
|
||||||
| 'idle'
|
|
||||||
| 'thinking' // UserPromptSubmit / PostToolUse - Claude is thinking
|
|
||||||
| 'toolUse' // PreToolUse - Using a tool (generic)
|
|
||||||
| 'reading' // PreToolUse(Read/Glob/Grep) - Reading files
|
|
||||||
| 'writing' // PreToolUse(Edit/Write) - Writing files
|
|
||||||
| 'sessionStart' // SessionStart - Session just started
|
|
||||||
| 'sessionEnd' // SessionEnd - Session ended
|
|
||||||
| 'permissionRequest' // PermissionRequest - Waiting for user permission
|
|
||||||
| 'interrupted' // PostToolUseFailure with is_interrupt
|
|
||||||
| 'error' // PostToolUseFailure without is_interrupt
|
|
||||||
|
|
||||||
interface ClaudeStatusPayload {
|
|
||||||
status: ClaudeStatus
|
|
||||||
tool?: string
|
|
||||||
agent?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleClaudeStatus(req: Request): Promise<Response | null> {
|
|
||||||
if (req.method !== 'POST') return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await req.json() as ClaudeStatusPayload
|
|
||||||
|
|
||||||
const validStatuses: ClaudeStatus[] = [
|
|
||||||
'idle', 'thinking', 'toolUse', 'reading', 'writing',
|
|
||||||
'sessionStart', 'sessionEnd', 'permissionRequest',
|
|
||||||
'interrupted', 'error'
|
|
||||||
]
|
|
||||||
if (!body.status || !validStatuses.includes(body.status)) {
|
|
||||||
return errorResponse(`Invalid status. Must be one of: ${validStatuses.join(', ')}`, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward to terminal server for WebSocket broadcast
|
|
||||||
try {
|
|
||||||
await fetch(`http://localhost:${PORT_TERMINAL}/claude-status`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[claude-status] Failed to forward to terminal server:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse({ success: true, status: body.status })
|
|
||||||
} catch (e) {
|
|
||||||
return errorResponse('Invalid JSON body', 400)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea'
|
|||||||
import { handleTables, handleStats, handleTableSchema, handleTableData, handleQuery } from './database'
|
import { handleTables, handleStats, handleTableSchema, handleTableData, handleQuery } from './database'
|
||||||
import { handleWhisperRoutes } from './whisper'
|
import { handleWhisperRoutes } from './whisper'
|
||||||
import { handleRecordingsRoutes } from './recordings'
|
import { handleRecordingsRoutes } from './recordings'
|
||||||
import { handleClaudeStatus } from './claude-status'
|
|
||||||
import { handleClaudeHook } from './claude-hook'
|
import { handleClaudeHook } from './claude-hook'
|
||||||
import { handleSnapshots, handleSnapshotById } from './snapshots'
|
import { handleSnapshots, handleSnapshotById } from './snapshots'
|
||||||
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git'
|
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git'
|
||||||
@@ -68,12 +67,6 @@ export async function handleRequest(req: Request): Promise<Response> {
|
|||||||
if (res) return res
|
if (res) return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude Code status (thinking/idle)
|
|
||||||
if (path === '/api/claude-status') {
|
|
||||||
const res = await handleClaudeStatus(req)
|
|
||||||
if (res) return res
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claude Code hook (rich stdin data forwarding)
|
// Claude Code hook (rich stdin data forwarding)
|
||||||
if (path === '/api/claude-hook') {
|
if (path === '/api/claude-hook') {
|
||||||
const res = await handleClaudeHook(req)
|
const res = await handleClaudeHook(req)
|
||||||
|
|||||||
@@ -295,17 +295,6 @@ export function startTerminalServer() {
|
|||||||
return Response.json({ sessions: list })
|
return Response.json({ sessions: list })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude status broadcast endpoint
|
|
||||||
if (url.pathname === '/claude-status' && req.method === 'POST') {
|
|
||||||
try {
|
|
||||||
const body = await req.json() as { status: ClaudeStatus, tool?: string, agent?: string }
|
|
||||||
broadcastClaudeStatus(body.status, body.tool, body.agent)
|
|
||||||
return Response.json({ success: true }, { headers: corsHeaders })
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claude hook broadcast endpoint (rich data from stdin)
|
// Claude hook broadcast endpoint (rich data from stdin)
|
||||||
if (url.pathname === '/claude-hook' && req.method === 'POST') {
|
if (url.pathname === '/claude-hook' && req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
@@ -718,57 +707,20 @@ export function startTerminalServer() {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude status types
|
// Process hook event and broadcast session state patch to ALL clients
|
||||||
type ClaudeStatus = 'idle' | 'thinking' | 'toolUse' | 'reading' | 'writing' | 'sessionStart' | 'sessionEnd' | 'permissionRequest' | 'interrupted' | 'error'
|
|
||||||
|
|
||||||
// Broadcast Claude status to ALL clients across ALL sessions
|
|
||||||
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) {
|
|
||||||
const agentName = agent || 'claude'
|
|
||||||
|
|
||||||
// Track agent running state
|
|
||||||
if (status === 'sessionStart') {
|
|
||||||
const state = agentSessions.get(agentName)
|
|
||||||
if (state) {
|
|
||||||
state.isAgentRunning = true
|
|
||||||
console.log(`[Terminal] Agent ${agentName} marked as running (sessionStart)`)
|
|
||||||
}
|
|
||||||
} else if (status === 'sessionEnd') {
|
|
||||||
const state = agentSessions.get(agentName)
|
|
||||||
if (state) {
|
|
||||||
state.isAgentRunning = false
|
|
||||||
console.log(`[Terminal] Agent ${agentName} marked as stopped (sessionEnd)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = JSON.stringify({
|
|
||||||
type: 'claude-status',
|
|
||||||
status,
|
|
||||||
tool,
|
|
||||||
agent: agentName,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
|
|
||||||
const clientCount = broadcastToAll(message)
|
|
||||||
console.log(`[Terminal] Claude status broadcast: ${status}${tool ? ` (${tool})` : ''} → ${clientCount} clients`)
|
|
||||||
|
|
||||||
// Note: session state is updated via broadcastClaudeHook which has full payload context.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast full Claude hook data to ALL clients
|
|
||||||
export function broadcastClaudeHook(data: Record<string, unknown>) {
|
export function broadcastClaudeHook(data: Record<string, unknown>) {
|
||||||
// ── Update centralized session state and broadcast patch ──
|
|
||||||
const statePatch = sessionState.processHookEvent(data as any)
|
const statePatch = sessionState.processHookEvent(data as any)
|
||||||
broadcastSessionStatePatch(statePatch)
|
broadcastSessionStatePatch(statePatch)
|
||||||
|
|
||||||
// ── Legacy raw broadcast (dual temporal — kept for backward compatibility) ──
|
// Track agent running state in terminal sessions
|
||||||
const message = JSON.stringify({
|
const agentName = (data.agent_name as string) || 'claude'
|
||||||
type: 'claude-hook',
|
const event = data.hook_event_name as string
|
||||||
...data,
|
if (event === 'SessionStart' || event === 'SessionEnd') {
|
||||||
timestamp: Date.now()
|
const state = agentSessions.get(agentName)
|
||||||
})
|
if (state) {
|
||||||
|
state.isAgentRunning = event === 'SessionStart'
|
||||||
const clientCount = broadcastToAll(message)
|
}
|
||||||
console.log(`[Terminal] Claude hook broadcast: ${data.hook_event_name || 'unknown'}${data.tool_name ? ` (${data.tool_name})` : ''} → ${clientCount} clients`)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast transcript updates to ALL clients
|
// Broadcast transcript updates to ALL clients
|
||||||
|
|||||||
Reference in New Issue
Block a user