feat: Add rich Claude status animations to FAB

- Add multiple hook-driven animations for FAB (processing, reading, writing, subagent, sessionStart, notification)
- Create claude-status.ts route to handle status updates from Claude Code hooks
- Broadcast status via WebSocket to all connected clients
- Processing (UserPromptSubmit→Stop): orange pulsing dots
- Reading (Read/Glob/Grep): cyan eye icon with scan animation
- Writing (Edit/Write): green pencil icon with pulse
- Subagent: purple orbital ring animation
- SessionStart: green wake-up ripple effect (3s)
- Notification: yellow bounce animation (2s)
- Tool flash: quick white flash on any tool use
This commit is contained in:
2026-02-14 02:42:30 -06:00
parent d5a426f17d
commit d9e2548fb8
4 changed files with 479 additions and 12 deletions

View File

@@ -24,9 +24,130 @@ const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null)
const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null) const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null)
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
// Claude status state (for FAB animations)
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'thinking'
const claudeStatus = ref<ClaudeStatus>('idle')
const claudeTool = ref<string | null>(null)
const isProcessing = ref(false) // Main processing state (UserPromptSubmit → Stop)
const isReading = ref(false) // Reading files
const isWriting = ref(false) // Writing files
const hasSubagent = ref(false) // Subagent active
const showSessionStart = ref(false) // Session start animation (3s)
const showNotification = ref(false) // Notification pulse (2s)
const showToolFlash = ref(false) // Tool use flash (500ms)
let statusWs: WebSocket | null = null
let statusReconnectTimeout: number | null = null
let processingTimeout: number | null = null
let sessionStartTimeout: number | null = null
let notificationTimeout: number | null = null
let toolFlashTimeout: number | null = null
function connectStatusWs() {
if (statusWs?.readyState === WebSocket.OPEN) return
statusWs = new WebSocket(`ws://${window.location.hostname}:4103`)
statusWs.onopen = () => {
console.log('[App] Status WebSocket connected')
}
statusWs.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'claude-status') {
const status = msg.status as ClaudeStatus
console.log('[App] Claude status:', status, msg.tool)
claudeStatus.value = status
claudeTool.value = msg.tool || null
// Handle different status types
switch (status) {
case 'processing':
case 'thinking':
isProcessing.value = true
// Auto-reset after 2 minutes (safety)
if (processingTimeout) clearTimeout(processingTimeout)
processingTimeout = window.setTimeout(() => {
isProcessing.value = false
}, 120000)
break
case 'idle':
isProcessing.value = false
isReading.value = false
isWriting.value = false
if (processingTimeout) clearTimeout(processingTimeout)
break
case 'reading':
isReading.value = true
triggerToolFlash()
break
case 'writing':
isWriting.value = true
triggerToolFlash()
break
case 'toolUse':
triggerToolFlash()
break
case 'toolDone':
isReading.value = false
isWriting.value = false
break
case 'sessionStart':
showSessionStart.value = true
if (sessionStartTimeout) clearTimeout(sessionStartTimeout)
sessionStartTimeout = window.setTimeout(() => {
showSessionStart.value = false
}, 3000)
break
case 'subagentStart':
hasSubagent.value = true
break
case 'subagentStop':
hasSubagent.value = false
break
case 'notification':
showNotification.value = true
if (notificationTimeout) clearTimeout(notificationTimeout)
notificationTimeout = window.setTimeout(() => {
showNotification.value = false
}, 2000)
break
}
}
} catch { /* ignore non-JSON messages */ }
}
statusWs.onclose = () => {
isProcessing.value = false
statusReconnectTimeout = window.setTimeout(connectStatusWs, 2000)
}
}
function triggerToolFlash() {
showToolFlash.value = true
if (toolFlashTimeout) clearTimeout(toolFlashTimeout)
toolFlashTimeout = window.setTimeout(() => {
showToolFlash.value = false
}, 500)
}
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools' type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools'
onMounted(async () => { onMounted(async () => {
// Connect to WebSocket for Claude status updates
connectStatusWs()
// Initialize WebMCP connection // Initialize WebMCP connection
await initWebMCP() await initWebMCP()
@@ -111,6 +232,12 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
stopTokenPolling() stopTokenPolling()
if (statusReconnectTimeout) clearTimeout(statusReconnectTimeout)
if (processingTimeout) clearTimeout(processingTimeout)
if (sessionStartTimeout) clearTimeout(sessionStartTimeout)
if (notificationTimeout) clearTimeout(notificationTimeout)
if (toolFlashTimeout) clearTimeout(toolFlashTimeout)
statusWs?.close()
}) })
// Watch for route changes and update tools // Watch for route changes and update tools
@@ -142,13 +269,57 @@ watch(() => route.name, (newPage) => {
</RouterView> </RouterView>
</main> </main>
<!-- Floating Terminal Toggle Button --> <!-- Floating Terminal Toggle Button (Claude Life FAB) -->
<button <button
class="terminal-fab" class="terminal-fab"
:class="{ active: showTerminal }" :class="{
active: showTerminal,
processing: isProcessing,
reading: isReading,
writing: isWriting,
subagent: hasSubagent,
'session-start': showSessionStart,
notification: showNotification,
'tool-flash': showToolFlash
}"
@click="showTerminal = !showTerminal" @click="showTerminal = !showTerminal"
title="Toggle Terminal" :title="isProcessing ? `Claude: ${claudeTool || 'processing'}` : 'Toggle Terminal'"
> >
<!-- Subagent orbital ring -->
<svg v-if="hasSubagent" class="orbital-ring" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="20 10" />
</svg>
<!-- Session start ripples -->
<div v-if="showSessionStart" class="session-ripples">
<span></span>
<span></span>
<span></span>
</div>
<!-- Processing animation (three dots) -->
<div v-if="isProcessing && !isReading && !isWriting" class="thinking-dots">
<span></span>
<span></span>
<span></span>
</div>
<!-- Reading icon (eye) -->
<svg v-else-if="isReading" class="status-icon" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<!-- Writing icon (pencil) -->
<svg v-else-if="isWriting" class="status-icon" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 19l7-7 3 3-7 7-3-3z"/>
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
<path d="M2 2l7.586 7.586"/>
<circle cx="11" cy="11" r="2"/>
</svg>
<!-- Normal icons -->
<template v-else>
<svg v-if="!showTerminal" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg v-if="!showTerminal" xmlns="http://www.w3.org/2000/svg" width="22" height="22" 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"></polyline> <polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line> <line x1="12" y1="19" x2="20" y2="19"></line>
@@ -157,6 +328,7 @@ watch(() => route.name, (newPage) => {
<line x1="18" y1="6" x2="6" y2="18"></line> <line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line> <line x1="6" y1="6" x2="18" y2="18"></line>
</svg> </svg>
</template>
</button> </button>
<!-- Voice FAB Button --> <!-- Voice FAB Button -->
@@ -242,7 +414,7 @@ watch(() => route.name, (newPage) => {
transform: translateX(-20px); transform: translateX(-20px);
} }
/* Terminal FAB */ /* Terminal FAB - Claude Life */
.terminal-fab { .terminal-fab {
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
@@ -260,6 +432,7 @@ watch(() => route.name, (newPage) => {
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 9998; z-index: 9998;
overflow: visible;
} }
.terminal-fab:hover { .terminal-fab:hover {
@@ -277,6 +450,199 @@ watch(() => route.name, (newPage) => {
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.5); box-shadow: 0 12px 32px rgba(239, 68, 68, 0.5);
} }
/* Processing state (UserPromptSubmit → Stop) - Orange pulsing */
.terminal-fab.processing {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%) !important;
box-shadow: 0 8px 24px rgba(245, 158, 11, 0.4) !important;
animation: processing-pulse 2s ease-in-out infinite !important;
transform: rotate(0deg) !important;
}
.terminal-fab.processing:hover {
box-shadow: 0 12px 32px rgba(245, 158, 11, 0.5) !important;
transform: scale(1.05) !important;
}
/* Reading state - Cyan scanning */
.terminal-fab.reading {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%) !important;
box-shadow: 0 8px 24px rgba(6, 182, 212, 0.4) !important;
animation: reading-scan 1.5s ease-in-out infinite !important;
}
/* Writing state - Green pulse */
.terminal-fab.writing {
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4) !important;
animation: writing-pulse 0.8s ease-in-out infinite !important;
}
/* Subagent active - Purple with orbital ring */
.terminal-fab.subagent {
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.5) !important;
}
/* Session start - Green wake-up effect */
.terminal-fab.session-start {
animation: session-wake 3s ease-out forwards !important;
}
/* Notification - Yellow bounce */
.terminal-fab.notification {
animation: notification-bounce 0.5s ease-in-out 4 !important;
}
/* Tool flash - Quick white flash */
.terminal-fab.tool-flash::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.4);
animation: tool-flash 0.5s ease-out forwards;
pointer-events: none;
}
/* Thinking dots animation */
.thinking-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.thinking-dots span {
width: 6px;
height: 6px;
background: white;
border-radius: 50%;
animation: thinking-dot 1.4s ease-in-out infinite;
}
.thinking-dots span:nth-child(1) { animation-delay: 0s; }
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
/* Status icons */
.status-icon {
animation: icon-breathe 1s ease-in-out infinite;
}
/* Orbital ring for subagent */
.orbital-ring {
position: absolute;
width: 80px;
height: 80px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: orbit-spin 3s linear infinite;
color: rgba(139, 92, 246, 0.8);
pointer-events: none;
}
/* Session start ripples */
.session-ripples {
position: absolute;
inset: 0;
pointer-events: none;
}
.session-ripples span {
position: absolute;
inset: -10px;
border: 2px solid rgba(16, 185, 129, 0.6);
border-radius: 20px;
animation: ripple-expand 3s ease-out forwards;
}
.session-ripples span:nth-child(1) { animation-delay: 0s; }
.session-ripples span:nth-child(2) { animation-delay: 0.5s; }
.session-ripples span:nth-child(3) { animation-delay: 1s; }
/* Keyframes */
@keyframes thinking-dot {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes processing-pulse {
0%, 100% { box-shadow: 0 8px 24px rgba(245, 158, 11, 0.4); }
50% { box-shadow: 0 8px 40px rgba(245, 158, 11, 0.7); }
}
@keyframes reading-scan {
0%, 100% {
box-shadow: 0 8px 24px rgba(6, 182, 212, 0.4);
transform: rotate(0deg);
}
25% { transform: rotate(-2deg); }
75% { transform: rotate(2deg); }
50% {
box-shadow: 0 8px 40px rgba(6, 182, 212, 0.7);
}
}
@keyframes writing-pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4);
}
50% {
transform: scale(1.05);
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.6);
}
}
@keyframes session-wake {
0% {
transform: scale(0.8);
opacity: 0.5;
box-shadow: 0 0 0 rgba(16, 185, 129, 0);
}
30% {
transform: scale(1.15);
box-shadow: 0 0 60px rgba(16, 185, 129, 0.6);
}
60% { transform: scale(0.95); }
100% {
transform: scale(1);
opacity: 1;
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4);
}
}
@keyframes notification-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes tool-flash {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.3); }
}
@keyframes icon-breathe {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes orbit-spin {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes ripple-expand {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
/* Voice FAB */ /* Voice FAB */
.voice-fab { .voice-fab {
position: fixed; position: fixed;
@@ -319,11 +685,16 @@ watch(() => route.name, (newPage) => {
height: 52px; height: 52px;
} }
.terminal-fab.active { .terminal-fab.active:not(.processing):not(.reading):not(.writing) {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
.orbital-ring {
width: 70px;
height: 70px;
}
.voice-fab { .voice-fab {
bottom: 16px; bottom: 16px;
left: 16px; left: 16px;

View File

@@ -0,0 +1,51 @@
import { jsonResponse, errorResponse } from '../utils/cors'
import { PORT_TERMINAL } from '../config'
type ClaudeStatus =
| 'idle'
| 'processing' // UserPromptSubmit - Claude is processing user input
| 'toolUse' // PreToolUse - Using a tool (generic)
| 'toolDone' // PostToolUse - Tool finished
| 'reading' // PreToolUse(Read/Glob/Grep) - Reading files
| 'writing' // PreToolUse(Edit/Write) - Writing files
| 'sessionStart' // SessionStart - Session just started
| 'subagentStart' // SubagentStart - Spawning subagent
| 'subagentStop' // SubagentStop - Subagent finished
| 'notification' // Notification - Claude sent notification
| 'thinking' // Legacy support
interface ClaudeStatusPayload {
status: ClaudeStatus
tool?: 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', 'processing', 'toolUse', 'toolDone', 'reading', 'writing',
'sessionStart', 'subagentStart', 'subagentStop', 'notification', 'thinking'
]
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

@@ -9,6 +9,7 @@ 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'
export async function handleRequest(req: Request): Promise<Response> { export async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url) const url = new URL(req.url)
@@ -42,6 +43,12 @@ 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
}
// Components // Components
if (path === '/api/components') { if (path === '/api/components') {
const res = await handleComponents(req) const res = await handleComponents(req)

View File

@@ -77,7 +77,7 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
export function startTerminalServer() { export function startTerminalServer() {
const server = Bun.serve({ const server = Bun.serve({
port: PORT_TERMINAL, port: PORT_TERMINAL,
fetch(req, server) { async fetch(req, server) {
const url = new URL(req.url) const url = new URL(req.url)
const corsHeaders = { const corsHeaders = {
@@ -113,6 +113,17 @@ 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 }
broadcastClaudeStatus(body.status, body.tool)
return Response.json({ success: true }, { headers: corsHeaders })
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders })
}
}
// Check if this is a WebSocket upgrade request // Check if this is a WebSocket upgrade request
const upgradeHeader = req.headers.get('upgrade') const upgradeHeader = req.headers.get('upgrade')
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`) console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
@@ -201,3 +212,30 @@ export function startTerminalServer() {
console.log(`[Terminal] WebSocket running at ws://localhost:${PORT_TERMINAL}`) console.log(`[Terminal] WebSocket running at ws://localhost:${PORT_TERMINAL}`)
return server return server
} }
// Claude status types
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'thinking'
// Broadcast Claude status to ALL clients across ALL sessions
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string) {
const message = JSON.stringify({
type: 'claude-status',
status,
tool,
timestamp: Date.now()
})
let clientCount = 0
for (const [, session] of sessions) {
for (const ws of session.clients) {
try {
ws.send(message)
clientCount++
} catch {
// Client disconnected, ignore
}
}
}
console.log(`[Terminal] Claude status broadcast: ${status}${tool ? ` (${tool})` : ''}${clientCount} clients`)
}