diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 46f988a..4cda87d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -24,9 +24,130 @@ const terminalRef = ref | null>(null) const responseRef = ref | null>(null) 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('idle') +const claudeTool = ref(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' onMounted(async () => { + // Connect to WebSocket for Claude status updates + connectStatusWs() + // Initialize WebMCP connection await initWebMCP() @@ -111,6 +232,12 @@ onMounted(async () => { onUnmounted(() => { 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 @@ -142,21 +269,66 @@ watch(() => route.name, (newPage) => { - + @@ -242,7 +414,7 @@ watch(() => route.name, (newPage) => { transform: translateX(-20px); } -/* Terminal FAB */ +/* Terminal FAB - Claude Life */ .terminal-fab { position: fixed; bottom: 20px; @@ -260,6 +432,7 @@ watch(() => route.name, (newPage) => { box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 9998; + overflow: visible; } .terminal-fab:hover { @@ -277,6 +450,199 @@ watch(() => route.name, (newPage) => { 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 { position: fixed; @@ -319,11 +685,16 @@ watch(() => route.name, (newPage) => { height: 52px; } - .terminal-fab.active { + .terminal-fab.active:not(.processing):not(.reading):not(.writing) { opacity: 0; pointer-events: none; } + .orbital-ring { + width: 70px; + height: 70px; + } + .voice-fab { bottom: 16px; left: 16px; diff --git a/server/routes/claude-status.ts b/server/routes/claude-status.ts new file mode 100644 index 0000000..0b307c5 --- /dev/null +++ b/server/routes/claude-status.ts @@ -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 { + 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) + } +} diff --git a/server/routes/index.ts b/server/routes/index.ts index e31b8a1..906cd53 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -9,6 +9,7 @@ import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea' import { handleTables, handleStats, handleTableSchema, handleTableData, handleQuery } from './database' import { handleWhisperRoutes } from './whisper' import { handleRecordingsRoutes } from './recordings' +import { handleClaudeStatus } from './claude-status' export async function handleRequest(req: Request): Promise { const url = new URL(req.url) @@ -42,6 +43,12 @@ export async function handleRequest(req: Request): Promise { if (res) return res } + // Claude Code status (thinking/idle) + if (path === '/api/claude-status') { + const res = await handleClaudeStatus(req) + if (res) return res + } + // Components if (path === '/api/components') { const res = await handleComponents(req) diff --git a/server/services/terminal.ts b/server/services/terminal.ts index 9a5a622..3bda363 100644 --- a/server/services/terminal.ts +++ b/server/services/terminal.ts @@ -77,7 +77,7 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes export function startTerminalServer() { const server = Bun.serve({ port: PORT_TERMINAL, - fetch(req, server) { + async fetch(req, server) { const url = new URL(req.url) const corsHeaders = { @@ -113,6 +113,17 @@ export function startTerminalServer() { 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 const upgradeHeader = req.headers.get('upgrade') 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}`) 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`) +}