feat: Add real-time queue depth indicator to viewer UI
Implements a visual badge that displays the count of active work items (queued + currently processing) in the worker service. The badge appears next to the claude-mem logo and updates in real-time via SSE. Features: - Shows count of pending messages + active SDK generators - Only displays when queueDepth > 0 - Subtle pulse animation for visual feedback - Theme-aware styling Backend changes: - Added getTotalActiveWork() method to SessionManager - Updated worker-service to broadcast queueDepth via SSE - Enhanced processing status API endpoint Frontend changes: - Updated Header component to display queue bubble - Enhanced useSSE hook to track queueDepth state - Added CSS styling with pulse animation Closes #122 Closes #96 Closes #97 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -376,6 +376,36 @@
|
|||||||
animation: spin 1.5s linear infinite;
|
animation: spin 1.5s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-bubble {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
background: var(--color-accent-primary);
|
||||||
|
color: var(--color-text-button);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Monaspace Radon', monospace;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 5px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-family: 'Monaspace Radon', monospace;
|
font-family: 'Monaspace Radon', monospace;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
|
|||||||
@@ -322,9 +322,11 @@ export class WorkerService {
|
|||||||
|
|
||||||
// Send initial processing status (based on queue depth + active generators)
|
// Send initial processing status (based on queue depth + active generators)
|
||||||
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
||||||
|
const queueDepth = this.sessionManager.getTotalActiveWork(); // Includes queued + actively processing
|
||||||
this.sseBroadcaster.broadcast({
|
this.sseBroadcaster.broadcast({
|
||||||
type: 'processing_status',
|
type: 'processing_status',
|
||||||
isProcessing
|
isProcessing,
|
||||||
|
queueDepth
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,11 +808,12 @@ export class WorkerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get processing status (for viewer UI spinner)
|
* Get processing status (for viewer UI spinner and queue indicator)
|
||||||
*/
|
*/
|
||||||
private handleGetProcessingStatus(req: Request, res: Response): void {
|
private handleGetProcessingStatus(req: Request, res: Response): void {
|
||||||
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
||||||
res.json({ isProcessing });
|
const queueDepth = this.sessionManager.getTotalActiveWork(); // Includes queued + actively processing
|
||||||
|
res.json({ isProcessing, queueDepth });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -823,7 +826,7 @@ export class WorkerService {
|
|||||||
*/
|
*/
|
||||||
broadcastProcessingStatus(): void {
|
broadcastProcessingStatus(): void {
|
||||||
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
||||||
const queueDepth = this.sessionManager.getTotalQueueDepth();
|
const queueDepth = this.sessionManager.getTotalActiveWork(); // Includes queued + actively processing
|
||||||
const activeSessions = this.sessionManager.getActiveSessionCount();
|
const activeSessions = this.sessionManager.getActiveSessionCount();
|
||||||
|
|
||||||
logger.info('WORKER', 'Broadcasting processing status', {
|
logger.info('WORKER', 'Broadcasting processing status', {
|
||||||
@@ -834,7 +837,8 @@ export class WorkerService {
|
|||||||
|
|
||||||
this.sseBroadcaster.broadcast({
|
this.sseBroadcaster.broadcast({
|
||||||
type: 'processing_status',
|
type: 'processing_status',
|
||||||
isProcessing
|
isProcessing,
|
||||||
|
queueDepth
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,23 @@ export class SessionManager {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total active work (queued + currently processing)
|
||||||
|
* Counts both pending messages and items actively being processed by SDK agents
|
||||||
|
*/
|
||||||
|
getTotalActiveWork(): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
// Count queued messages
|
||||||
|
total += session.pendingMessages.length;
|
||||||
|
// Count currently processing item (1 per active generator)
|
||||||
|
if (session.generatorPromise !== null) {
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if any session is actively processing (has pending messages OR active generator)
|
* Check if any session is actively processing (has pending messages OR active generator)
|
||||||
* Used for activity indicator to prevent spinner from stopping while SDK is processing
|
* Used for activity indicator to prevent spinner from stopping while SDK is processing
|
||||||
|
|||||||
@@ -376,6 +376,36 @@
|
|||||||
animation: spin 1.5s linear infinite;
|
animation: spin 1.5s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-bubble {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
background: var(--color-accent-primary);
|
||||||
|
color: var(--color-text-button);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Monaspace Radon', monospace;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 5px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-family: 'Monaspace Radon', monospace;
|
font-family: 'Monaspace Radon', monospace;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function App() {
|
|||||||
const [paginatedSummaries, setPaginatedSummaries] = useState<Summary[]>([]);
|
const [paginatedSummaries, setPaginatedSummaries] = useState<Summary[]>([]);
|
||||||
const [paginatedPrompts, setPaginatedPrompts] = useState<UserPrompt[]>([]);
|
const [paginatedPrompts, setPaginatedPrompts] = useState<UserPrompt[]>([]);
|
||||||
|
|
||||||
const { observations, summaries, prompts, projects, isProcessing, isConnected } = useSSE();
|
const { observations, summaries, prompts, projects, isProcessing, queueDepth, isConnected } = useSSE();
|
||||||
const { settings, saveSettings, isSaving, saveStatus } = useSettings();
|
const { settings, saveSettings, isSaving, saveStatus } = useSettings();
|
||||||
const { stats, refreshStats } = useStats();
|
const { stats, refreshStats } = useStats();
|
||||||
const { preference, resolvedTheme, setThemePreference } = useTheme();
|
const { preference, resolvedTheme, setThemePreference } = useTheme();
|
||||||
@@ -96,6 +96,7 @@ export function App() {
|
|||||||
onSettingsToggle={toggleSidebar}
|
onSettingsToggle={toggleSidebar}
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
|
queueDepth={queueDepth}
|
||||||
themePreference={preference}
|
themePreference={preference}
|
||||||
onThemeChange={setThemePreference}
|
onThemeChange={setThemePreference}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface HeaderProps {
|
|||||||
onSettingsToggle: () => void;
|
onSettingsToggle: () => void;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
|
queueDepth: number;
|
||||||
themePreference: ThemePreference;
|
themePreference: ThemePreference;
|
||||||
onThemeChange: (theme: ThemePreference) => void;
|
onThemeChange: (theme: ThemePreference) => void;
|
||||||
}
|
}
|
||||||
@@ -22,13 +23,21 @@ export function Header({
|
|||||||
onSettingsToggle,
|
onSettingsToggle,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
|
queueDepth,
|
||||||
themePreference,
|
themePreference,
|
||||||
onThemeChange
|
onThemeChange
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<h1>
|
<h1>
|
||||||
<img src="claude-mem-logomark.webp" alt="" className={`logomark ${isProcessing ? 'spinning' : ''}`} />
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<img src="claude-mem-logomark.webp" alt="" className={`logomark ${isProcessing ? 'spinning' : ''}`} />
|
||||||
|
{queueDepth > 0 && (
|
||||||
|
<div className="queue-bubble">
|
||||||
|
{queueDepth}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className="logo-text">claude-mem</span>
|
<span className="logo-text">claude-mem</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="status">
|
<div className="status">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function useSSE() {
|
|||||||
const [projects, setProjects] = useState<string[]>([]);
|
const [projects, setProjects] = useState<string[]>([]);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [queueDepth, setQueueDepth] = useState(0);
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
@@ -83,8 +84,9 @@ export function useSSE() {
|
|||||||
|
|
||||||
case 'processing_status':
|
case 'processing_status':
|
||||||
if (typeof data.isProcessing === 'boolean') {
|
if (typeof data.isProcessing === 'boolean') {
|
||||||
console.log('[SSE] Processing status:', data.isProcessing);
|
console.log('[SSE] Processing status:', data.isProcessing, 'Queue depth:', data.queueDepth);
|
||||||
setIsProcessing(data.isProcessing);
|
setIsProcessing(data.isProcessing);
|
||||||
|
setQueueDepth(data.queueDepth || 0);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -107,5 +109,5 @@ export function useSSE() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { observations, summaries, prompts, projects, isProcessing, isConnected };
|
return { observations, summaries, prompts, projects, isProcessing, queueDepth, isConnected };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user