This commit is contained in:
Alex Newman
2025-11-06 15:47:16 -05:00
parent b68ea38bcc
commit f8dc7f940f
13 changed files with 157 additions and 186 deletions
+13
View File
@@ -69,6 +69,19 @@ async function cleanupHook(input?: SessionEndInput): Promise<void> {
db.close();
// Tell worker to stop spinner
try {
const workerPort = session.worker_port || 37777;
await fetch(`http://127.0.0.1:${workerPort}/sessions/${session.id}/complete`, {
method: 'POST',
signal: AbortSignal.timeout(1000)
});
console.error('[claude-mem cleanup] Worker notified to stop processing indicator');
} catch (err) {
// Non-critical - worker might be down
console.error('[claude-mem cleanup] Failed to notify worker (non-critical):', err);
}
console.error('[claude-mem cleanup] Cleanup completed successfully');
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
+1
View File
@@ -55,6 +55,7 @@ Skip routine operations:
- Package installations with no errors
- Simple file listings
- Repetitive operations you've already documented
- If file related research comes back as empty or not found
- **No output necessary if skipping.**
OUTPUT FORMAT
+68 -54
View File
@@ -101,6 +101,8 @@ class WorkerService {
private sessions: Map<number, ActiveSession> = new Map();
private chromaSync!: ChromaSync;
private sseClients: Set<Response> = new Set();
private isProcessing: boolean = false;
private spinnerStopTimer: NodeJS.Timeout | null = null;
constructor() {
this.app = express();
@@ -126,13 +128,14 @@ class WorkerService {
this.app.get('/api/observations', this.handleGetObservations.bind(this));
this.app.get('/api/summaries', this.handleGetSummaries.bind(this));
this.app.get('/api/prompts', this.handleGetPrompts.bind(this));
this.app.get('/api/processing-status', this.handleGetProcessingStatus.bind(this));
// Session endpoints
this.app.post('/sessions/:sessionDbId/init', this.handleInit.bind(this));
this.app.post('/sessions/:sessionDbId/observations', this.handleObservation.bind(this));
this.app.post('/sessions/:sessionDbId/summarize', this.handleSummarize.bind(this));
this.app.post('/sessions/:sessionDbId/complete', this.handleComplete.bind(this));
this.app.get('/sessions/:sessionDbId/status', this.handleStatus.bind(this));
this.app.delete('/sessions/:sessionDbId', this.handleDelete.bind(this));
}
async start(): Promise<void> {
@@ -274,16 +277,46 @@ class WorkerService {
/**
* Broadcast processing status to SSE clients
*/
private broadcastProcessingStatus(claudeSessionId: string, isProcessing: boolean): void {
private broadcastProcessingStatus(isProcessing: boolean): void {
this.isProcessing = isProcessing;
this.broadcastSSE({
type: 'processing_status',
processing: {
session_id: claudeSessionId,
is_processing: isProcessing
}
isProcessing
});
}
/**
* Check if all sessions have empty queues and stop spinner after debounce
*/
private checkAndStopSpinner(): void {
// Clear any existing timer
if (this.spinnerStopTimer) {
clearTimeout(this.spinnerStopTimer);
this.spinnerStopTimer = null;
}
// Check if any session has pending messages
const hasPendingMessages = Array.from(this.sessions.values()).some(
session => session.pendingMessages.length > 0
);
if (!hasPendingMessages) {
// Debounce: wait 1.5s and check again
this.spinnerStopTimer = setTimeout(() => {
const stillEmpty = Array.from(this.sessions.values()).every(
session => session.pendingMessages.length === 0
);
if (stillEmpty) {
logger.debug('WORKER', 'All queues empty - stopping spinner');
this.broadcastProcessingStatus(false);
}
this.spinnerStopTimer = null;
}, 1500);
}
}
/**
* GET /api/stats - Return worker and database stats
*/
@@ -597,6 +630,14 @@ class WorkerService {
}
}
/**
* GET /api/processing-status
* Returns current processing status (boolean)
*/
private handleGetProcessingStatus(_req: Request, res: Response): void {
res.json({ isProcessing: this.isProcessing });
}
/**
* POST /sessions/:sessionDbId/init
* Body: { project, userPrompt }
@@ -691,6 +732,9 @@ class WorkerService {
this.sessions.delete(sessionDbId);
});
// Start processing indicator (user submitted prompt)
this.broadcastProcessingStatus(true);
logger.success('WORKER', 'Session initialized', { sessionId: sessionDbId, port: this.port });
res.json({
status: 'initialized',
@@ -755,9 +799,6 @@ class WorkerService {
prompt_number
});
// Don't broadcast processing status for observations - only for summaries
// Observations are processed continuously, skeleton should only show during summary generation
res.json({ status: 'queued', queueLength: session.pendingMessages.length });
}
@@ -813,12 +854,24 @@ class WorkerService {
prompt_number
});
// Notify UI that processing is active
this.broadcastProcessingStatus(session.claudeSessionId, true);
res.json({ status: 'queued', queueLength: session.pendingMessages.length });
}
/**
* POST /sessions/:sessionDbId/complete
* Called by cleanup hook to stop spinner when session ends
*/
private handleComplete(req: Request, res: Response): void {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
logger.info('WORKER', 'Session completed - stopping spinner', { sessionId: sessionDbId });
// Stop processing indicator
this.broadcastProcessingStatus(false);
res.json({ status: 'ok' });
}
/**
* GET /sessions/:sessionDbId/status
*/
@@ -839,42 +892,6 @@ class WorkerService {
});
}
/**
* DELETE /sessions/:sessionDbId
*/
private async handleDelete(req: Request, res: Response): Promise<void> {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
const session = this.sessions.get(sessionDbId);
if (!session) {
res.status(404).json({ error: 'Session not found' });
return;
}
logger.warn('WORKER', 'Session delete requested', { sessionId: sessionDbId });
// Abort SDK agent
session.abortController.abort();
// Wait for generator to finish (with timeout)
if (session.generatorPromise) {
await Promise.race([
session.generatorPromise,
new Promise(resolve => setTimeout(resolve, 5000))
]);
}
// Mark as failed since we're aborting
const db = new SessionStore();
db.markSessionFailed(sessionDbId);
db.close();
this.sessions.delete(sessionDbId);
logger.info('WORKER', 'Session deleted', { sessionId: sessionDbId });
res.json({ status: 'deleted' });
}
/**
* Run SDK agent for a session
*/
@@ -1149,9 +1166,6 @@ class WorkerService {
}
});
// Notify UI that processing is complete (summary is the final step)
this.broadcastProcessingStatus(session.claudeSessionId, false);
// Sync to Chroma (non-blocking fire-and-forget, but crash on failure)
this.chromaSync.syncSummary(
id,
@@ -1178,12 +1192,12 @@ class WorkerService {
promptNumber,
contentSample: content.substring(0, 500)
});
// Still mark processing as complete even if no summary was generated
this.broadcastProcessingStatus(session.claudeSessionId, false);
}
db.close();
// Check if queue is empty and stop spinner after debounce
this.checkAndStopSpinner();
}
}
+2 -3
View File
@@ -17,7 +17,7 @@ export function App() {
const [paginatedSummaries, setPaginatedSummaries] = useState<Summary[]>([]);
const [paginatedPrompts, setPaginatedPrompts] = useState<UserPrompt[]>([]);
const { observations, summaries, prompts, projects, processingSessions, isConnected } = useSSE();
const { observations, summaries, prompts, projects, isProcessing, isConnected } = useSSE();
const { settings, saveSettings, isSaving, saveStatus } = useSettings();
const { stats } = useStats();
const { preference, resolvedTheme, setThemePreference } = useTheme();
@@ -89,7 +89,7 @@ export function App() {
onFilterChange={setCurrentFilter}
onSettingsToggle={toggleSidebar}
sidebarOpen={sidebarOpen}
isProcessing={processingSessions.size > 0}
isProcessing={isProcessing}
themePreference={preference}
onThemeChange={setThemePreference}
/>
@@ -97,7 +97,6 @@ export function App() {
observations={allObservations}
summaries={allSummaries}
prompts={allPrompts}
processingSessions={processingSessions}
onLoadMore={handleLoadMore}
isLoading={pagination.observations.isLoading || pagination.summaries.isLoading || pagination.prompts.isLoading}
hasMore={pagination.observations.hasMore || pagination.summaries.hasMore || pagination.prompts.hasMore}
+4 -39
View File
@@ -2,7 +2,6 @@ import React, { useMemo, useRef, useEffect } from 'react';
import { Observation, Summary, UserPrompt, FeedItem } from '../types';
import { ObservationCard } from './ObservationCard';
import { SummaryCard } from './SummaryCard';
import { SummarySkeleton } from './SummarySkeleton';
import { PromptCard } from './PromptCard';
import { UI } from '../constants/ui';
@@ -10,13 +9,12 @@ interface FeedProps {
observations: Observation[];
summaries: Summary[];
prompts: UserPrompt[];
processingSessions: Set<string>;
onLoadMore: () => void;
isLoading: boolean;
hasMore: boolean;
}
export function Feed({ observations, summaries, prompts, processingSessions, onLoadMore, isLoading, hasMore }: FeedProps) {
export function Feed({ observations, summaries, prompts, onLoadMore, isLoading, hasMore }: FeedProps) {
const loadMoreRef = useRef<HTMLDivElement>(null);
const onLoadMoreRef = useRef(onLoadMore);
@@ -51,45 +49,14 @@ export function Feed({ observations, summaries, prompts, processingSessions, onL
}, [hasMore, isLoading]);
const items = useMemo<FeedItem[]>(() => {
// Create a set of session IDs that already have summaries
const sessionsWithSummaries = new Set(summaries.map(s => s.session_id));
// Find the most recent prompt for each processing session
const sessionPrompts = new Map<string, UserPrompt>();
prompts.forEach(p => {
const existing = sessionPrompts.get(p.claude_session_id);
if (!existing || p.created_at_epoch > existing.created_at_epoch) {
sessionPrompts.set(p.claude_session_id, p);
}
});
// Create skeleton items for sessions being processed that don't have summaries yet
const skeletons: FeedItem[] = [];
processingSessions.forEach(sessionId => {
if (!sessionsWithSummaries.has(sessionId)) {
const prompt = sessionPrompts.get(sessionId);
skeletons.push({
itemType: 'skeleton',
id: sessionId, // Don't add prefix - key construction adds itemType already
session_id: sessionId,
project: prompt?.project,
// Always use current time so skeletons appear at top of feed
created_at_epoch: Date.now()
});
}
});
// Data is already filtered by App.tsx - no need to filter again
const combined = [
...observations.map(o => ({ ...o, itemType: 'observation' as const })),
...summaries.map(s => ({ ...s, itemType: 'summary' as const })),
...prompts.map(p => ({ ...p, itemType: 'prompt' as const })),
...skeletons
...prompts.map(p => ({ ...p, itemType: 'prompt' as const }))
];
return combined
.sort((a, b) => b.created_at_epoch - a.created_at_epoch);
}, [observations, summaries, prompts, processingSessions]);
return combined.sort((a, b) => b.created_at_epoch - a.created_at_epoch);
}, [observations, summaries, prompts]);
return (
<div className="feed">
@@ -100,8 +67,6 @@ export function Feed({ observations, summaries, prompts, processingSessions, onL
return <ObservationCard key={key} observation={item} />;
} else if (item.itemType === 'summary') {
return <SummaryCard key={key} summary={item} />;
} else if (item.itemType === 'skeleton') {
return <SummarySkeleton key={key} sessionId={item.session_id} project={item.project} />;
} else {
return <PromptCard key={key} prompt={item} />;
}
+24 -4
View File
@@ -33,10 +33,10 @@ export function Header({
</h1>
<div className="status">
<a
href="https://github.com/thedotmack/claude-mem/"
href="https://docs.claude-mem.ai"
target="_blank"
rel="noopener noreferrer"
title="GitHub"
title="Documentation"
style={{
display: 'block',
padding: '8px 4px 8px 8px',
@@ -44,7 +44,27 @@ export function Header({
transition: 'color 0.2s',
lineHeight: 0
}}
onMouseEnter={(e) => e.currentTarget.style.color = '#ffffff'}
onMouseEnter={(e) => e.currentTarget.style.color = '#606060'}
onMouseLeave={(e) => e.currentTarget.style.color = '#a0a0a0'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
</a>
<a
href="https://github.com/thedotmack/claude-mem/"
target="_blank"
rel="noopener noreferrer"
title="GitHub"
style={{
display: 'block',
padding: '8px 4px',
color: '#a0a0a0',
transition: 'color 0.2s',
lineHeight: 0
}}
onMouseEnter={(e) => e.currentTarget.style.color = '#606060'}
onMouseLeave={(e) => e.currentTarget.style.color = '#a0a0a0'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -63,7 +83,7 @@ export function Header({
transition: 'color 0.2s',
lineHeight: 0
}}
onMouseEnter={(e) => e.currentTarget.style.color = '#ffffff'}
onMouseEnter={(e) => e.currentTarget.style.color = '#606060'}
onMouseLeave={(e) => e.currentTarget.style.color = '#a0a0a0'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -1,25 +0,0 @@
import React from 'react';
interface SummarySkeletonProps {
sessionId: string;
project?: string;
}
export function SummarySkeleton({ sessionId, project }: SummarySkeletonProps) {
return (
<div className="card summary-card summary-skeleton">
<div className="card-header">
<span className="card-type">SUMMARY</span>
{project && <span>{project}</span>}
<div className="processing-indicator">
<div className="spinner"></div>
<span>Generating...</span>
</div>
</div>
<div className="skeleton-line skeleton-title"></div>
<div className="skeleton-line skeleton-subtitle"></div>
<div className="skeleton-line skeleton-subtitle short"></div>
<div className="card-meta">Session: {sessionId}</div>
</div>
);
}
+1
View File
@@ -8,5 +8,6 @@ export const API_ENDPOINTS = {
PROMPTS: '/api/prompts',
SETTINGS: '/api/settings',
STATS: '/api/stats',
PROCESSING_STATUS: '/api/processing-status',
STREAM: '/stream',
} as const;
+13 -20
View File
@@ -9,10 +9,18 @@ export function useSSE() {
const [prompts, setPrompts] = useState<UserPrompt[]>([]);
const [projects, setProjects] = useState<string[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
const [isProcessing, setIsProcessing] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
// Fetch initial processing status on mount
useEffect(() => {
fetch(API_ENDPOINTS.PROCESSING_STATUS)
.then(res => res.json())
.then(data => setIsProcessing(data.isProcessing))
.catch(err => console.error('[SSE] Failed to fetch initial processing status:', err));
}, []);
useEffect(() => {
const connect = () => {
// Clean up existing connection
@@ -70,12 +78,6 @@ export function useSSE() {
const summary = data.summary;
console.log('[SSE] New summary:', summary.id);
setSummaries(prev => [summary, ...prev]);
// Mark session as no longer processing (summary is the final step)
setProcessingSessions(prev => {
const next = new Set(prev);
next.delete(summary.session_id);
return next;
});
}
break;
@@ -88,18 +90,9 @@ export function useSSE() {
break;
case 'processing_status':
if (data.processing) {
const processing = data.processing;
console.log('[SSE] Processing status:', processing);
setProcessingSessions(prev => {
const next = new Set(prev);
if (processing.is_processing) {
next.add(processing.session_id);
} else {
next.delete(processing.session_id);
}
return next;
});
if (typeof data.isProcessing === 'boolean') {
console.log('[SSE] Processing status:', data.isProcessing);
setIsProcessing(data.isProcessing);
}
break;
}
@@ -122,5 +115,5 @@ export function useSSE() {
};
}, []);
return { observations, summaries, prompts, projects, processingSessions, isConnected };
return { observations, summaries, prompts, projects, isProcessing, isConnected };
}
+2 -13
View File
@@ -29,18 +29,10 @@ export interface UserPrompt {
created_at_epoch: number;
}
export interface SkeletonItem {
id: string;
session_id: string;
project?: string;
created_at_epoch: number;
}
export type FeedItem =
| (Observation & { itemType: 'observation' })
| (Summary & { itemType: 'summary' })
| (UserPrompt & { itemType: 'prompt' })
| (SkeletonItem & { itemType: 'skeleton' });
| (UserPrompt & { itemType: 'prompt' });
export interface StreamEvent {
type: 'initial_load' | 'new_observation' | 'new_summary' | 'new_prompt' | 'processing_status';
@@ -51,10 +43,7 @@ export interface StreamEvent {
observation?: Observation;
summary?: Summary;
prompt?: UserPrompt;
processing?: {
session_id: string;
is_processing: boolean;
};
isProcessing?: boolean;
}
export interface Settings {