import { useEffect, useState } from 'react'; import { api } from '@/api/client'; import type { SidebarProject, SidebarResponse, SidebarSession } from '@/api/types'; import { sessionEvents } from './sessionEvents'; const RECENT_SESSIONS_LIMIT = 6; // Module-scope shared state — there is at most one sidebar fetch // for the lifetime of the page, regardless of how many components // call useSidebar(). let sharedData: SidebarResponse | null = null; let sharedError: string | null = null; let sharedLoading: boolean = true; let initialized = false; let fetchInFlight: Promise | null = null; let activeSession: { session_id: string; project_id: string } | null = null; const subscribers = new Set<() => void>(); function notify(): void { for (const sub of subscribers) { try { sub(); } catch { // swallow — one bad subscriber shouldn't break others } } } function load(): Promise { if (fetchInFlight) return fetchInFlight; sharedLoading = true; sharedError = null; notify(); const p = (async () => { try { const res = await api.sidebar.get(); sharedData = res; sharedError = null; } catch (err) { sharedData = null; sharedError = err instanceof Error ? err.message : 'failed to load sidebar'; } finally { sharedLoading = false; fetchInFlight = null; notify(); } })(); fetchInFlight = p; return p; } function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').SessionEvent): SidebarResponse { switch (event.type) { case 'project_created': { const fresh: SidebarProject = { id: event.project.id, name: event.project.name, recent_sessions: [], total_sessions: 0, }; return { ...prev, projects: [fresh, ...prev.projects] }; } case 'project_deleted': { const next = prev.projects.filter((p) => p.id !== event.project_id); if (next.length === prev.projects.length) return prev; return { ...prev, projects: next }; } case 'session_created': { let changed = false; const projects = prev.projects.map((p) => { if (p.id !== event.project_id) return p; changed = true; const fresh: SidebarSession = { id: event.session.id, name: event.session.name, model: event.session.model, updated_at: event.session.updated_at, project_id: event.project_id, }; return { ...p, recent_sessions: [fresh, ...p.recent_sessions].slice(0, RECENT_SESSIONS_LIMIT), total_sessions: p.total_sessions + 1, }; }); return changed ? { ...prev, projects } : prev; } case 'session_deleted': { let changed = false; const projects = prev.projects.map((p) => { if (p.id !== event.project_id) return p; changed = true; const recent = p.recent_sessions.filter((s) => s.id !== event.session_id); return { ...p, recent_sessions: recent, total_sessions: Math.max(0, p.total_sessions - 1), }; }); return changed ? { ...prev, projects } : prev; } case 'session_renamed': { let changed = false; const projects = prev.projects.map((p) => { let projectChanged = false; const recent = p.recent_sessions.map((s) => { if (s.id !== event.session_id) return s; if (s.name === event.name) return s; projectChanged = true; return { ...s, name: event.name }; }); if (!projectChanged) return p; changed = true; return { ...p, recent_sessions: recent }; }); return changed ? { ...prev, projects } : prev; } case 'session_updated': { let changed = false; const projects = prev.projects.map((p) => { if (p.id !== event.project_id) return p; let projectChanged = false; const recent = p.recent_sessions.map((s) => { if (s.id !== event.session_id) return s; projectChanged = true; return { ...s, name: event.name, updated_at: event.updated_at }; }); if (!projectChanged) return p; changed = true; const sorted = [...recent].sort( (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() ); return { ...p, recent_sessions: sorted }; }); return changed ? { ...prev, projects } : prev; } case 'session_loaded': // activeSessionProjectId is updated in the subscribe callback; no data change here. return prev; case 'open_file_in_browser': // Consumed by Workspace (T7); no sidebar state change needed. return prev; } } // One bus subscription for the lifetime of the module. Events arriving // before the initial fetch resolves are dropped; the eventual fetch // result is the source of truth. sessionEvents.subscribe((event) => { // session_loaded updates activeSessionProjectId regardless of whether // sharedData is populated yet — notify so subscribers can re-read. if (event.type === 'session_loaded') { activeSession = { session_id: event.session_id, project_id: event.project_id }; notify(); return; } if (!sharedData) return; const next = applyEvent(sharedData, event); if (next === sharedData) return; sharedData = next; notify(); }); interface Snapshot { data: SidebarResponse | null; error: string | null; loading: boolean; activeSession: { session_id: string; project_id: string } | null; } function snapshot(): Snapshot { return { data: sharedData, error: sharedError, loading: sharedLoading, activeSession }; } export function useSidebar(): { data: SidebarResponse | null; error: string | null; loading: boolean; retry: () => void; activeSession: { session_id: string; project_id: string } | null; } { const [state, setState] = useState(snapshot); useEffect(() => { const sub = () => setState(snapshot()); subscribers.add(sub); // Sync up if the module state changed between render and effect. sub(); if (!initialized) { initialized = true; void load(); } return () => { subscribers.delete(sub); }; }, []); const retry = () => { void load(); }; return { data: state.data, error: state.error, loading: state.loading, retry, activeSession: state.activeSession }; }