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>
274 lines
8.8 KiB
TypeScript
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 };
|
|
}
|