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 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'
|
||||
|
||||
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,13 +269,57 @@ watch(() => route.name, (newPage) => {
|
||||
</RouterView>
|
||||
</main>
|
||||
|
||||
<!-- Floating Terminal Toggle Button -->
|
||||
<!-- Floating Terminal Toggle Button (Claude Life FAB) -->
|
||||
<button
|
||||
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"
|
||||
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">
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<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="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<!-- Voice FAB Button -->
|
||||
@@ -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;
|
||||
|
||||
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 { handleWhisperRoutes } from './whisper'
|
||||
import { handleRecordingsRoutes } from './recordings'
|
||||
import { handleClaudeStatus } from './claude-status'
|
||||
|
||||
export async function handleRequest(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
@@ -42,6 +43,12 @@ export async function handleRequest(req: Request): Promise<Response> {
|
||||
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)
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user