Files
boocode/apps/web/src/hooks/useSidebar.ts
indifferentketchup 48ee63a286 v1.12.1: rich status indicator + server-side workspace pane sync
Status indicator (StatusDot): drops the flat amber pulse for a richer set
of states — orbiting amber for streaming, spinning sky ring for tool_running,
static violet for waiting_for_input, plus the existing idle/error. Backend
chat_status frame widens from 'working|idle|error' to discriminate streaming
vs tool execution vs paused for user input.

Workspace pane sync: pane layout moves from per-device localStorage to
server-side sessions.workspace_panes jsonb. PATCH /api/sessions/:id/workspace
broadcasts session_workspace_updated on the user channel for cross-device live
sync. Echo dedup via JSON comparison so the round-trip frame doesn't loop.
Legacy localStorage seeds the server on first hydrate, then is deleted.
Deprecated session_panes table dropped.

Resilience: startup sweep marks any stale 'streaming' message older than
5 minutes as 'failed' so v1.12.0-style hung rows clear on container restart.
useWorkspacePanes gains validatePanes() to prune dead chatId references from
saved pane state when the chat list lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:32:02 +00:00

274 lines
8.8 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': {
if (prev.projects.some((p) => p.id === event.project.id)) return prev;
const fresh: SidebarProject = {
id: event.project.id,
name: event.project.name,
path: event.project.path,
gitea_remote: event.project.gitea_remote ?? null,
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;
if (p.recent_sessions.some((s) => s.id === event.session.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;
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id);
const wasPresent = recent.length !== p.recent_sessions.length;
if (!wasPresent) return p;
changed = true;
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 'session_workspace_updated':
// Pane layout is consumed by useWorkspacePanes; sidebar has no stake.
return prev;
case 'open_file_in_browser':
// Consumed by Workspace (T7); no sidebar state change needed.
return prev;
case 'attach_chat_file':
return prev;
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'open_settings_pane':
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
// Sidebar data is untouched.
return prev;
case 'session_archived': {
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
const recent = p.recent_sessions.filter((s) => s.id !== event.session_id);
if (recent.length === p.recent_sessions.length) return p;
changed = true;
return {
...p,
recent_sessions: recent,
total_sessions: Math.max(0, p.total_sessions - 1),
};
});
return changed ? { ...prev, projects } : prev;
}
case 'chat_created':
case 'chat_updated':
case 'chat_archived':
case 'chat_unarchived':
case 'chat_deleted':
case 'chat_status':
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);
if (next.length === prev.projects.length) return prev;
return { ...prev, projects: next };
}
case 'project_unarchived': {
if (prev.projects.some((p) => p.id === event.project.id)) return prev;
const fresh: SidebarProject = {
id: event.project.id,
name: event.project.name,
path: event.project.path,
gitea_remote: event.project.gitea_remote ?? null,
recent_sessions: [],
total_sessions: 0,
};
return { ...prev, projects: [fresh, ...prev.projects] };
}
case 'project_updated': {
let changed = false;
const projects = prev.projects.map((p) => {
if (p.id !== event.project_id) return p;
if (p.name === event.name) return p;
changed = true;
return { ...p, name: event.name };
});
return changed ? { ...prev, projects } : 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.
// Guard prevents duplicate listeners during Vite HMR reloads.
const G = globalThis as Record<string, unknown>;
if (!G.__boocode_sidebar_subscribed) {
G.__boocode_sidebar_subscribed = true;
sessionEvents.subscribe((event) => {
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 };
}