- 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>
204 lines
6.3 KiB
TypeScript
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 };
|
|
}
|