Files
boocode/apps/web/src/hooks/useSidebar.ts
indifferentketchup e82e5670ee batch3 T5 review fixes: backoff off-by-one + activeSession shape + headers
- useUserEvents: double delay before scheduling, producing 1/2/4/8/16/30s
- useSidebar: activeSessionProjectId -> activeSession {session_id,project_id}
  so consumers can verify URL match and ignore stale values
- api.panes.create/update: drop redundant Content-Type (request helper sets)
- useUserEvents: minimal type guard on incoming WS frame before emit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:28:11 +00:00

204 lines
6.3 KiB
TypeScript

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<void> | 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<void> {
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>(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 };
}