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:
@@ -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;
|
||||||
|
|||||||
51
server/routes/claude-status.ts
Normal file
51
server/routes/claude-status.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user