Files
boocode/apps/web/src/hooks/useSidebar.ts
indifferentketchup 2fd7e5bf97 feat(web): workspace panes & tabs overhaul
A cohesive batch of pane/tab UX + the persisted workspace-state model (grouped
because the changes interleave across useWorkspacePanes, ChatTabBar, Workspace,
sessionEvents and the api types/client):

- Open a whole chat in a fresh pane via a new open_chat_in_new_pane event:
  ChatTabBar tab context menu "Open in new pane", and MessageBubble.fork() now
  lands the fork beside the original instead of replacing the active pane.
  openChatInNewPane detaches the chat from any pane already holding it
  (one-chat-per-pane).
- The tab-bar "+" becomes a New BooChat/BooTerm/BooCode menu (chat as a tab,
  term/coder as split panes); the split button is unchanged.
- Drop the per-message "Open in pane" button (it opened a single message's
  artifact) and its dead code; the artifact-pane machinery is left orphaned for
  a later teardown.
- Session history: the empty/landing pane lists the session's open chats plus
  archived chats (fetched separately), click to open / restore-and-open.
- Relocate-on-close: closing a chat pane moves its tabs (in order) into the
  oldest chat/empty pane instead of discarding them; terminal/coder panes close
  as before. Reopen strips the restored chatIds from all live panes first, so a
  relocated-then-reopened pane never duplicates a tab — no stack-shape change.
- Stable global tab numbering: tabNumbers/nextTabNumber assigned on chat-pane
  open, retired on close (never reused), rendered map-keyed (not positional).
- workspace_panes is now a WorkspaceState envelope { panes, tabNumbers,
  nextTabNumber, closedPaneStack }; the reopen stack moved from a module-level
  array into the persisted envelope so it survives reload. Hydrate/persist
  normalize the legacy bare-array shape. appendClosed dedupes a value-identical
  top entry to neutralize the StrictMode double-invoke of the setPanes updater.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:15:03 +00:00

281 lines
9.1 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':
case 'open_chat_in_new_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'open_markdown_artifact_pane':
case 'open_html_artifact_pane':
// v1.14.x-html-artifact-panes: consumed by useWorkspacePanes; 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':
case 'refetch_messages':
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 };
}