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:
2026-02-24 11:42:06 -06:00
parent c8b484b10e
commit 08e73a1eb6
7 changed files with 10 additions and 1362 deletions

View File

@@ -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`.

View File

@@ -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)">&times;</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>

View File

@@ -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">&times;</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>

View File

@@ -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}`
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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