Files
claudecodeui/src/components/sidebar/hooks/useSidebarController.ts
Haileyesus 591b18e9e3 feat(sidebar): improve running session state tracking
Add a running-session view to the sidebar, including header controls, running counts, empty states, and row-level processing indicators so active provider work is visible outside the current chat.

Hydrate running state after refresh through a status-only /api/providers/sessions/running endpoint backed by chatRunRegistry.listRunningRuns, then sync and poll the frontend processingSessions map from AppContent without attaching to chat streams or replaying messages.

Preserve fresh local processing entries during sync so newly sent messages are not cleared before the backend registry catches up, and clear completed sessions once the status endpoint no longer reports them.

Thread active session state through sidebar project/session components, show rotating loaders for processing sessions, and keep the running search mode expanded and filterable.

Fix optimistic local user-message dedupe so repeated prompts are only collapsed when a matching server echo appears from the same send window, preventing sent messages from disappearing until assistant completion.

Add registry test coverage for listing currently running app sessions.

Tests: npx eslint on changed files; npx tsc --noEmit -p tsconfig.json; npx tsc --noEmit -p server/tsconfig.json; npx tsx --tsconfig server/tsconfig.json --test server/modules/websocket/tests/chat-run-registry.test.ts.
2026-06-11 20:04:38 +03:00

1012 lines
32 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { TFunction } from 'i18next';
import { api } from '../../../utils/api';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionActivityMap } from '../../../hooks/useSessionProtection';
import type {
ArchivedProjectListItem,
ArchivedSessionListItem,
DeleteProjectConfirmation,
ProjectSortOrder,
SidebarSearchMode,
SessionDeleteConfirmation,
SessionWithProvider,
} from '../types/types';
import {
clearLegacyStarredProjectIds,
filterProjects,
getAllSessions,
readLegacyStarredProjectIds,
readProjectSortOrder,
sortProjects,
} from '../utils/utils';
type SnippetHighlight = {
start: number;
end: number;
};
type ConversationMatch = {
role: string;
snippet: string;
highlights: SnippetHighlight[];
timestamp: string | null;
provider?: string;
messageUuid?: string | null;
};
type ConversationSession = {
sessionId: string;
sessionSummary: string;
provider?: string;
matches: ConversationMatch[];
};
type ConversationProjectResult = {
// Emitted by the provider search service so the sidebar can map a
// match back to the Project in its current state by projectId.
projectId: string | null;
projectName: string;
projectDisplayName: string;
sessions: ConversationSession[];
};
export type ConversationSearchResults = {
results: ConversationProjectResult[];
totalMatches: number;
query: string;
};
export type SearchProgress = {
scannedProjects: number;
totalProjects: number;
};
type ArchivedSessionsApiPayload = {
success?: boolean;
data?: {
sessions?: ArchivedSessionListItem[];
};
};
type ArchivedProjectsApiPayload = {
success?: boolean;
data?: {
projects?: ArchivedProjectListItem[];
};
};
type UseSidebarControllerArgs = {
projects: Project[];
selectedProject: Project | null;
selectedSession: ProjectSession | null;
activeSessions: SessionActivityMap;
isLoading: boolean;
isMobile: boolean;
t: TFunction;
onRefresh: () => Promise<void> | void;
onProjectSelect: (project: Project) => void;
onSessionSelect: (session: ProjectSession) => void;
onSessionDelete?: (sessionId: string) => void;
onLoadMoreSessions?: (projectId: string) => Promise<void> | void;
// `projectId` is the DB-assigned identifier; callbacks use that post-migration.
onProjectDelete?: (projectId: string) => void;
setCurrentProject: (project: Project) => void;
setSidebarVisible: (visible: boolean) => void;
sidebarVisible: boolean;
};
export function useSidebarController({
projects,
selectedProject,
selectedSession: _selectedSession,
activeSessions,
isLoading,
isMobile,
t,
onRefresh,
onProjectSelect,
onSessionSelect,
onSessionDelete,
onLoadMoreSessions,
onProjectDelete,
setCurrentProject,
setSidebarVisible,
sidebarVisible,
}: UseSidebarControllerArgs) {
const paletteOps = usePaletteOps();
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
const [editingProject, setEditingProject] = useState<string | null>(null);
const [showNewProject, setShowNewProject] = useState(false);
const [editingName, setEditingName] = useState('');
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState<Set<string>>(new Set());
const [currentTime, setCurrentTime] = useState(new Date());
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
const [isRefreshing, setIsRefreshing] = useState(false);
const [editingSession, setEditingSession] = useState<string | null>(null);
const [editingSessionName, setEditingSessionName] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [deletingProjects, setDeletingProjects] = useState<Set<string>>(new Set());
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteProjectConfirmation | null>(null);
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
const [showVersionModal, setShowVersionModal] = useState(false);
const [searchMode, setSearchMode] = useState<SidebarSearchMode>('projects');
const [conversationResults, setConversationResults] = useState<ConversationSearchResults | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchProgress, setSearchProgress] = useState<SearchProgress | null>(null);
const [archivedProjects, setArchivedProjects] = useState<ArchivedProjectListItem[]>([]);
const [archivedSessions, setArchivedSessions] = useState<ArchivedSessionListItem[]>([]);
const [isArchivedSessionsLoading, setIsArchivedSessionsLoading] = useState(false);
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [optimisticStarByProjectId, setOptimisticStarByProjectId] = useState<Map<string, boolean>>(new Map());
const [loadingMoreProjects, setLoadingMoreProjects] = useState<Set<string>>(new Set());
const searchSeqRef = useRef(0);
const eventSourceRef = useRef<EventSource | null>(null);
const starToggleSequenceByProjectRef = useRef<Map<string, number>>(new Map());
const migrationStartedRef = useRef(false);
const onRefreshRef = useRef(onRefresh);
const isSidebarCollapsed = !isMobile && !sidebarVisible;
const activeSessionIds = useMemo(() => new Set(activeSessions.keys()), [activeSessions]);
const runningSessionsCount = activeSessionIds.size;
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 60000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
setInitialSessionsLoaded(new Set());
}, [projects]);
useEffect(() => {
// Auto-expand only when the selected project identity changes.
// Depending on the full `selectedProject` object (or `selectedSession`) causes
// websocket-driven list refreshes to re-open projects users manually collapsed.
const selectedProjectId = selectedProject?.projectId;
if (!selectedProjectId) {
return;
}
setExpandedProjects((prev) => {
if (prev.has(selectedProjectId)) {
return prev;
}
const next = new Set(prev);
next.add(selectedProjectId);
return next;
});
}, [selectedProject?.projectId]);
useEffect(() => {
if (projects.length > 0 && !isLoading) {
const loadedProjects = new Set<string>();
projects.forEach((project) => {
if (project.sessions && project.sessions.length >= 0) {
loadedProjects.add(project.projectId);
}
});
setInitialSessionsLoaded(loadedProjects);
}
}, [projects, isLoading]);
useEffect(() => {
const loadSortOrder = () => {
setProjectSortOrder(readProjectSortOrder());
};
loadSortOrder();
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'claude-settings') {
loadSortOrder();
}
};
window.addEventListener('storage', handleStorageChange);
const interval = setInterval(() => {
if (document.hasFocus()) {
loadSortOrder();
}
}, 1000);
return () => {
window.removeEventListener('storage', handleStorageChange);
clearInterval(interval);
};
}, []);
useEffect(() => {
onRefreshRef.current = onRefresh;
}, [onRefresh]);
const fetchArchivedSessions = useCallback(async () => {
setIsArchivedSessionsLoading(true);
try {
const [archivedProjectsResponse, archivedSessionsResponse] = await Promise.all([
api.archivedProjects(),
api.getArchivedSessions(),
]);
if (!archivedProjectsResponse.ok) {
throw new Error(`Failed to load archived projects: ${archivedProjectsResponse.status}`);
}
if (!archivedSessionsResponse.ok) {
throw new Error(`Failed to load archived sessions: ${archivedSessionsResponse.status}`);
}
const archivedProjectsPayload = (await archivedProjectsResponse.json()) as ArchivedProjectsApiPayload;
const archivedSessionsPayload = (await archivedSessionsResponse.json()) as ArchivedSessionsApiPayload;
const nextProjects = Array.isArray(archivedProjectsPayload.data?.projects) ? archivedProjectsPayload.data.projects : [];
const archivedProjectIds = new Set(nextProjects.map((project) => project.projectId));
const nextStandaloneSessions = Array.isArray(archivedSessionsPayload.data?.sessions)
? archivedSessionsPayload.data.sessions.filter((session) => !session.projectId || !archivedProjectIds.has(session.projectId))
: [];
setArchivedProjects(nextProjects);
setArchivedSessions(nextStandaloneSessions);
} catch (error) {
console.error('[Sidebar] Failed to load archived sessions:', error);
} finally {
setIsArchivedSessionsLoading(false);
}
}, []);
useEffect(() => {
if (migrationStartedRef.current) {
return;
}
const legacyStarredProjectIds = readLegacyStarredProjectIds();
if (legacyStarredProjectIds.length === 0) {
return;
}
migrationStartedRef.current = true;
const migrateLegacyStars = async () => {
try {
await api.migrateLegacyProjectStars(legacyStarredProjectIds);
await onRefreshRef.current();
} catch (error) {
console.error('[Sidebar] Failed to migrate legacy starred projects:', error);
} finally {
clearLegacyStarredProjectIds();
}
};
void migrateLegacyStars();
}, [onRefresh]);
useEffect(() => {
void fetchArchivedSessions();
}, [fetchArchivedSessions]);
useEffect(() => {
if (searchMode !== 'archived') {
return;
}
// Refresh archive contents when the archived tab opens so restore actions
// and background synchronizer updates are reflected without a full reload.
void fetchArchivedSessions();
}, [fetchArchivedSessions, searchMode]);
useEffect(() => {
setOptimisticStarByProjectId((previous) => {
if (previous.size === 0) {
return previous;
}
const next = new Map(previous);
let changed = false;
for (const [projectId, optimisticValue] of previous.entries()) {
const project = projects.find((candidate) => candidate.projectId === projectId);
if (!project) {
next.delete(projectId);
changed = true;
continue;
}
if (Boolean(project.isStarred) === optimisticValue) {
next.delete(projectId);
changed = true;
}
}
return changed ? next : previous;
});
}, [projects]);
// Debounce search text updates so both project filtering and conversation
// SSE requests avoid running on every keypress.
useEffect(() => {
const timeout = setTimeout(() => {
setDebouncedSearchQuery(searchFilter.trim());
}, 300);
return () => {
clearTimeout(timeout);
};
}, [searchFilter]);
// Debounced conversation search with SSE streaming
useEffect(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
const query = debouncedSearchQuery;
if (searchMode !== 'conversations' || query.length < 2) {
searchSeqRef.current += 1;
setConversationResults(null);
setSearchProgress(null);
setIsSearching(false);
return;
}
setIsSearching(true);
const seq = ++searchSeqRef.current;
if (seq !== searchSeqRef.current) {
return;
}
const url = api.searchConversationsUrl(query);
const es = new EventSource(url);
eventSourceRef.current = es;
const accumulated: ConversationProjectResult[] = [];
let totalMatches = 0;
es.addEventListener('result', (evt) => {
if (seq !== searchSeqRef.current) { es.close(); return; }
try {
const data = JSON.parse(evt.data) as {
projectResult: ConversationProjectResult;
totalMatches: number;
scannedProjects: number;
totalProjects: number;
};
accumulated.push(data.projectResult);
totalMatches = data.totalMatches;
setConversationResults({ results: [...accumulated], totalMatches, query });
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
} catch {
// Ignore malformed SSE data
}
});
es.addEventListener('progress', (evt) => {
if (seq !== searchSeqRef.current) { es.close(); return; }
try {
const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number };
totalMatches = data.totalMatches;
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
} catch {
// Ignore malformed SSE data
}
});
es.addEventListener('done', () => {
if (seq !== searchSeqRef.current) { es.close(); return; }
es.close();
eventSourceRef.current = null;
setIsSearching(false);
setSearchProgress(null);
if (accumulated.length === 0) {
setConversationResults({ results: [], totalMatches: 0, query });
}
});
es.addEventListener('error', () => {
if (seq !== searchSeqRef.current) { es.close(); return; }
es.close();
eventSourceRef.current = null;
setIsSearching(false);
setSearchProgress(null);
if (accumulated.length === 0) {
setConversationResults({ results: [], totalMatches: 0, query });
}
});
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [debouncedSearchQuery, searchMode]);
// All sidebar state keys (expanded, starred, loading, etc.) use the DB
// `projectId` as their identifier after the migration.
const toggleProject = useCallback((projectId: string) => {
setExpandedProjects((prev) => {
const next = new Set<string>();
if (!prev.has(projectId)) {
next.add(projectId);
}
return next;
});
}, []);
const handleSessionClick = useCallback(
(session: SessionWithProvider, projectId: string) => {
// Tag the session with its owning projectId so downstream handlers
// can correlate it with the selectedProject in the app state.
onSessionSelect({ ...session, __projectId: projectId });
},
[onSessionSelect],
);
const resolveProjectStarState = useCallback(
(projectId: string): boolean => {
if (optimisticStarByProjectId.has(projectId)) {
return Boolean(optimisticStarByProjectId.get(projectId));
}
return projects.some((project) => project.projectId === projectId && Boolean(project.isStarred));
},
[optimisticStarByProjectId, projects],
);
const toggleStarProject = useCallback((projectId: string) => {
const previousStarState = resolveProjectStarState(projectId);
const optimisticStarState = !previousStarState;
const latestSequence = (starToggleSequenceByProjectRef.current.get(projectId) ?? 0) + 1;
starToggleSequenceByProjectRef.current.set(projectId, latestSequence);
setOptimisticStarByProjectId((previous) => {
const next = new Map(previous);
next.set(projectId, optimisticStarState);
return next;
});
const updateStar = async () => {
try {
const response = await api.toggleProjectStar(projectId);
if (!response.ok) {
const payload = (await response.json()) as { error?: string | { message?: string } };
const errorPayload = payload.error;
const message =
typeof errorPayload === 'string'
? errorPayload
: errorPayload && typeof errorPayload === 'object' && errorPayload.message
? errorPayload.message
: t('messages.updateProjectError');
throw new Error(message);
}
const payload = (await response.json()) as { isStarred?: boolean };
const isLatestSequence = starToggleSequenceByProjectRef.current.get(projectId) === latestSequence;
if (!isLatestSequence) {
return;
}
setOptimisticStarByProjectId((previous) => {
const next = new Map(previous);
next.set(projectId, Boolean(payload.isStarred));
return next;
});
} catch (error) {
const isLatestSequence = starToggleSequenceByProjectRef.current.get(projectId) === latestSequence;
if (!isLatestSequence) {
return;
}
setOptimisticStarByProjectId((previous) => {
const next = new Map(previous);
next.set(projectId, previousStarState);
return next;
});
console.error('[Sidebar] Failed to toggle project star:', error);
alert(t('messages.updateProjectError'));
}
};
void updateStar();
}, [resolveProjectStarState, t]);
const isProjectStarred = useCallback(
(projectId: string) => resolveProjectStarState(projectId),
[resolveProjectStarState],
);
const getProjectSessions = useCallback((project: Project) => getAllSessions(project), []);
const loadMoreSessionsForProject = useCallback(async (projectId: string) => {
if (!onLoadMoreSessions) {
return;
}
let shouldLoad = false;
setLoadingMoreProjects((previous) => {
if (previous.has(projectId)) {
return previous;
}
shouldLoad = true;
const next = new Set(previous);
next.add(projectId);
return next;
});
if (!shouldLoad) {
return;
}
try {
await onLoadMoreSessions(projectId);
} catch (error) {
console.error('[Sidebar] Failed to load more sessions:', error);
alert(t('messages.refreshError'));
} finally {
setLoadingMoreProjects((previous) => {
const next = new Set(previous);
next.delete(projectId);
return next;
});
}
}, [onLoadMoreSessions, t]);
const projectsWithResolvedStarState = useMemo(() => {
if (optimisticStarByProjectId.size === 0) {
return projects;
}
return projects.map((project) => {
const optimisticStarState = optimisticStarByProjectId.get(project.projectId);
if (optimisticStarState === undefined) {
return project;
}
const currentStarState = Boolean(project.isStarred);
if (currentStarState === optimisticStarState) {
return project;
}
return {
...project,
isStarred: optimisticStarState,
};
});
}, [optimisticStarByProjectId, projects]);
const sortedProjects = useMemo(
() => sortProjects(projectsWithResolvedStarState, projectSortOrder),
[projectSortOrder, projectsWithResolvedStarState],
);
const runningProjects = useMemo(() => {
if (activeSessionIds.size === 0) {
return [];
}
return sortedProjects.reduce<Project[]>((acc, project) => {
const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
const cursorSessions = (project.cursorSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
const codexSessions = (project.codexSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
const geminiSessions = (project.geminiSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
const opencodeSessions = (project.opencodeSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
const runningCount =
sessions.length
+ cursorSessions.length
+ codexSessions.length
+ geminiSessions.length
+ opencodeSessions.length;
if (runningCount === 0) {
return acc;
}
acc.push({
...project,
sessions,
cursorSessions,
codexSessions,
geminiSessions,
opencodeSessions,
sessionMeta: {
...project.sessionMeta,
total: runningCount,
hasMore: false,
},
});
return acc;
}, []);
}, [activeSessionIds, sortedProjects]);
const filteredProjects = useMemo(
() => filterProjects(searchMode === 'running' ? runningProjects : sortedProjects, debouncedSearchQuery),
[debouncedSearchQuery, runningProjects, searchMode, sortedProjects],
);
const filteredArchivedSessions = useMemo(() => {
const normalizedSearch = debouncedSearchQuery.trim().toLowerCase();
if (!normalizedSearch) {
return archivedSessions;
}
return archivedSessions.filter((session) => {
const searchableFields = [
session.sessionTitle,
session.projectDisplayName,
session.projectPath ?? '',
session.provider,
];
return searchableFields.some((value) => value.toLowerCase().includes(normalizedSearch));
});
}, [archivedSessions, debouncedSearchQuery]);
const filteredArchivedProjects = useMemo(() => {
const normalizedSearch = debouncedSearchQuery.trim().toLowerCase();
if (!normalizedSearch) {
return archivedProjects;
}
return archivedProjects.filter((project) => {
const projectMatches = [
project.displayName,
project.fullPath || '',
].some((value) => value.toLowerCase().includes(normalizedSearch));
if (projectMatches) {
return true;
}
return getAllSessions(project).some((session) => {
const sessionSummary =
typeof session.summary === 'string' && session.summary.trim().length > 0
? session.summary
: typeof session.name === 'string'
? session.name
: '';
return [
sessionSummary,
session.__provider,
].some((value) => value.toLowerCase().includes(normalizedSearch));
});
});
}, [archivedProjects, debouncedSearchQuery]);
const startEditing = useCallback((project: Project) => {
// `editingProject` is keyed by projectId so it stays stable across
// display-name mutations that happen while the input is open.
setEditingProject(project.projectId);
setEditingName(project.displayName);
}, []);
const cancelEditing = useCallback(() => {
setEditingProject(null);
setEditingName('');
}, []);
const saveProjectName = useCallback(
// `projectId` is the DB primary key; the rename API resolves the path
// through the `projects` table before writing the new display name.
async (projectId: string) => {
try {
const response = await api.renameProject(projectId, editingName);
if (response.ok) {
await paletteOps.refreshProjects();
} else {
console.error('Failed to rename project');
}
} catch (error) {
console.error('Error renaming project:', error);
} finally {
setEditingProject(null);
setEditingName('');
}
},
[editingName, paletteOps],
);
const showDeleteSessionConfirmation = useCallback(
// Kept with project/provider arguments for component wiring compatibility;
// deletion now uses only `sessionId` via /api/providers/sessions/:sessionId.
(
projectId: string | null,
sessionId: string,
sessionTitle: string,
provider: SessionDeleteConfirmation['provider'] = 'claude',
options: {
isArchived?: boolean;
} = {},
) => {
setSessionDeleteConfirmation({
projectId,
sessionId,
sessionTitle,
provider,
isArchived: Boolean(options.isArchived),
});
},
[],
);
const confirmDeleteSession = useCallback(async (hardDelete = false) => {
if (!sessionDeleteConfirmation) {
return;
}
const { sessionId } = sessionDeleteConfirmation;
setSessionDeleteConfirmation(null);
try {
const response = await api.deleteSession(sessionId, hardDelete);
if (response.ok) {
onSessionDelete?.(sessionId);
await fetchArchivedSessions();
} else {
const errorText = await response.text();
console.error('[Sidebar] Failed to delete session:', {
status: response.status,
error: errorText,
});
alert(t('messages.deleteSessionFailed'));
}
} catch (error) {
console.error('[Sidebar] Error deleting session:', error);
alert(t('messages.deleteSessionError'));
}
}, [fetchArchivedSessions, onSessionDelete, sessionDeleteConfirmation, t]);
const requestProjectDelete = useCallback(
(project: Project) => {
setDeleteConfirmation({
project,
sessionCount: getProjectSessions(project).length,
});
},
[getProjectSessions],
);
const confirmDeleteProject = useCallback(async (deleteData = false) => {
if (!deleteConfirmation) {
return;
}
const { project } = deleteConfirmation;
setDeleteConfirmation(null);
// Track in-flight deletes by projectId so the UI can disable actions
// even if the project object is rebuilt while the request is flying.
setDeletingProjects((prev) => new Set([...prev, project.projectId]));
try {
const response = await api.deleteProject(project.projectId, deleteData);
if (response.ok) {
onProjectDelete?.(project.projectId);
} else {
const data = (await response.json()) as { error?: string | { message?: string } };
const err = data.error;
const message =
typeof err === 'string' ? err : err && typeof err === 'object' && err.message ? err.message : t('messages.deleteProjectFailed');
alert(message);
}
} catch (error) {
console.error('Error deleting project:', error);
alert(t('messages.deleteProjectError'));
} finally {
setDeletingProjects((prev) => {
const next = new Set(prev);
next.delete(project.projectId);
return next;
});
}
}, [deleteConfirmation, onProjectDelete, t]);
const handleProjectSelect = useCallback(
(project: Project) => {
onProjectSelect(project);
setCurrentProject(project);
},
[onProjectSelect, setCurrentProject],
);
const openArchivedSession = useCallback((session: ArchivedSessionListItem) => {
const activeProject = session.projectId
? projects.find((candidate) => candidate.projectId === session.projectId)
: null;
const archivedProject = session.projectId
? archivedProjects.find((candidate) => candidate.projectId === session.projectId)
: null;
const matchingProject = activeProject ?? archivedProject ?? null;
const sessionPayload: ProjectSession = {
id: session.sessionId,
summary: session.sessionTitle,
__provider: session.provider,
__projectId: matchingProject?.projectId ?? session.projectId ?? undefined,
};
// Archived sessions still need a selected project context. Active projects
// come from the normal sidebar list, while archived-project sessions resolve
// through the archive payload loaded by this controller.
if (matchingProject) {
handleProjectSelect(matchingProject);
}
onSessionSelect(sessionPayload);
}, [archivedProjects, handleProjectSelect, onSessionSelect, projects]);
const restoreArchivedProject = useCallback(async (projectId: string) => {
try {
const response = await api.restoreProject(projectId);
if (!response.ok) {
const errorText = await response.text();
console.error('[Sidebar] Failed to restore project:', {
status: response.status,
error: errorText,
});
alert(t('messages.restoreProjectFailed', 'Failed to restore project. Please try again.'));
return;
}
await Promise.all([
Promise.resolve(onRefresh()),
fetchArchivedSessions(),
]);
} catch (error) {
console.error('[Sidebar] Error restoring project:', error);
alert(t('messages.restoreProjectError', 'Error restoring project. Please try again.'));
}
}, [fetchArchivedSessions, onRefresh, t]);
const restoreArchivedSession = useCallback(async (sessionId: string) => {
try {
const response = await api.restoreSession(sessionId);
if (!response.ok) {
const errorText = await response.text();
console.error('[Sidebar] Failed to restore session:', {
status: response.status,
error: errorText,
});
alert(t('messages.restoreSessionFailed', 'Failed to restore session. Please try again.'));
return;
}
await Promise.all([
Promise.resolve(onRefresh()),
fetchArchivedSessions(),
]);
} catch (error) {
console.error('[Sidebar] Error restoring session:', error);
alert(t('messages.restoreSessionError', 'Error restoring session. Please try again.'));
}
}, [fetchArchivedSessions, onRefresh, t]);
const refreshProjects = useCallback(async () => {
setIsRefreshing(true);
try {
await Promise.all([
Promise.resolve(onRefresh()),
fetchArchivedSessions(),
]);
} finally {
setIsRefreshing(false);
}
}, [fetchArchivedSessions, onRefresh]);
const updateSessionSummary = useCallback(
// `_projectId` and `_provider` are preserved for compatibility with
// existing sidebar callback signatures; backend rename only needs sessionId.
async (_projectId: string, sessionId: string, summary: string, _provider: LLMProvider) => {
const trimmed = summary.trim();
if (!trimmed) {
setEditingSession(null);
setEditingSessionName('');
return;
}
try {
const response = await api.renameSession(sessionId, trimmed);
if (response.ok) {
await onRefresh();
} else {
console.error('[Sidebar] Failed to rename session:', response.status);
alert(t('messages.renameSessionFailed'));
}
} catch (error) {
console.error('[Sidebar] Error renaming session:', error);
alert(t('messages.renameSessionError'));
} finally {
setEditingSession(null);
setEditingSessionName('');
}
},
[onRefresh, t],
);
const collapseSidebar = useCallback(() => {
setSidebarVisible(false);
}, [setSidebarVisible]);
const expandSidebar = useCallback(() => {
setSidebarVisible(true);
}, [setSidebarVisible]);
return {
isSidebarCollapsed,
expandedProjects,
editingProject,
showNewProject,
editingName,
initialSessionsLoaded,
currentTime,
projectSortOrder,
isRefreshing,
editingSession,
editingSessionName,
searchFilter,
deletingProjects,
loadingMoreProjects,
deleteConfirmation,
sessionDeleteConfirmation,
showVersionModal,
filteredProjects,
runningSessionsCount,
archivedProjects: filteredArchivedProjects,
archivedSessions: filteredArchivedSessions,
archivedSessionsCount: archivedProjects.length + archivedSessions.length,
isArchivedSessionsLoading,
toggleProject,
handleSessionClick,
toggleStarProject,
isProjectStarred,
getProjectSessions,
loadMoreSessionsForProject,
startEditing,
cancelEditing,
saveProjectName,
showDeleteSessionConfirmation,
confirmDeleteSession,
requestProjectDelete,
confirmDeleteProject,
handleProjectSelect,
openArchivedSession,
restoreArchivedProject,
restoreArchivedSession,
refreshProjects,
updateSessionSummary,
collapseSidebar,
expandSidebar,
setShowNewProject,
setEditingName,
setEditingSession,
setEditingSessionName,
searchMode,
setSearchMode,
conversationResults,
isSearching,
searchProgress,
clearConversationResults: useCallback(() => {
searchSeqRef.current += 1;
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setIsSearching(false);
setSearchProgress(null);
setConversationResults(null);
}, []),
setSearchFilter,
setDeleteConfirmation,
setSessionDeleteConfirmation,
setShowVersionModal,
};
}