- Added a new `metadataLevel` option to the sessions pagination API, allowing clients to specify the depth of metadata returned (light or deep). - Updated the `ProjectScanner` to handle session metadata based on the specified level, improving performance and flexibility in session data retrieval. - Enhanced the HTTP client and session slice to support the new metadata level option, ensuring consistent behavior across the application. This commit enhances session management by providing users with the ability to choose the level of detail in session metadata, optimizing performance for different use cases.
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
/**
|
|
* Session slice - manages session list state and pagination.
|
|
*/
|
|
|
|
import { api } from '@renderer/api';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
|
|
import type { AppState } from '../types';
|
|
import type { Session } from '@renderer/types/data';
|
|
import type { StateCreator } from 'zustand';
|
|
|
|
const logger = createLogger('Store:session');
|
|
|
|
/**
|
|
* Tracks the latest in-place refresh generation per project.
|
|
* Used to guarantee last-write-wins under rapid file change events.
|
|
*/
|
|
const projectRefreshGeneration = new Map<string, number>();
|
|
|
|
// =============================================================================
|
|
// Slice Interface
|
|
// =============================================================================
|
|
|
|
export interface SessionSlice {
|
|
// State
|
|
sessions: Session[];
|
|
selectedSessionId: string | null;
|
|
sessionsLoading: boolean;
|
|
sessionsError: string | null;
|
|
// Pagination state
|
|
sessionsCursor: string | null;
|
|
sessionsHasMore: boolean;
|
|
sessionsTotalCount: number;
|
|
sessionsLoadingMore: boolean;
|
|
// Pinned sessions
|
|
pinnedSessionIds: string[];
|
|
|
|
// Actions
|
|
fetchSessions: (projectId: string) => Promise<void>;
|
|
fetchSessionsInitial: (projectId: string) => Promise<void>;
|
|
fetchSessionsMore: () => Promise<void>;
|
|
resetSessionsPagination: () => void;
|
|
selectSession: (id: string) => void;
|
|
clearSelection: () => void;
|
|
/** Refresh sessions list without loading states - for real-time updates */
|
|
refreshSessionsInPlace: (projectId: string) => Promise<void>;
|
|
/** Toggle pin/unpin for a session */
|
|
togglePinSession: (sessionId: string) => Promise<void>;
|
|
/** Load pinned sessions from config for current project */
|
|
loadPinnedSessions: () => Promise<void>;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Slice Creator
|
|
// =============================================================================
|
|
|
|
export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> = (set, get) => ({
|
|
// Initial state
|
|
sessions: [],
|
|
selectedSessionId: null,
|
|
sessionsLoading: false,
|
|
sessionsError: null,
|
|
// Pagination state
|
|
sessionsCursor: null,
|
|
sessionsHasMore: false,
|
|
sessionsTotalCount: 0,
|
|
sessionsLoadingMore: false,
|
|
// Pinned sessions
|
|
pinnedSessionIds: [],
|
|
|
|
// Fetch sessions for a specific project (legacy - not paginated)
|
|
fetchSessions: async (projectId: string) => {
|
|
set({ sessionsLoading: true, sessionsError: null });
|
|
try {
|
|
const sessions = await api.getSessions(projectId);
|
|
// Sort by createdAt (descending)
|
|
const sorted = [...sessions].sort((a, b) => b.createdAt - a.createdAt);
|
|
set({ sessions: sorted, sessionsLoading: false });
|
|
} catch (error) {
|
|
set({
|
|
sessionsError: error instanceof Error ? error.message : 'Failed to fetch sessions',
|
|
sessionsLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
// Fetch initial page of sessions (paginated)
|
|
fetchSessionsInitial: async (projectId: string) => {
|
|
set({
|
|
sessionsLoading: true,
|
|
sessionsError: null,
|
|
sessions: [],
|
|
sessionsCursor: null,
|
|
sessionsHasMore: false,
|
|
sessionsTotalCount: 0,
|
|
});
|
|
try {
|
|
const { connectionMode } = get();
|
|
const result = await api.getSessionsPaginated(projectId, null, 20, {
|
|
includeTotalCount: false,
|
|
prefilterAll: false,
|
|
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
|
|
});
|
|
set({
|
|
sessions: result.sessions,
|
|
sessionsCursor: result.nextCursor,
|
|
sessionsHasMore: result.hasMore,
|
|
sessionsTotalCount: result.totalCount,
|
|
sessionsLoading: false,
|
|
});
|
|
|
|
// Load pinned sessions after fetching session list
|
|
void get().loadPinnedSessions();
|
|
} catch (error) {
|
|
set({
|
|
sessionsError: error instanceof Error ? error.message : 'Failed to fetch sessions',
|
|
sessionsLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
// Fetch more sessions (next page)
|
|
fetchSessionsMore: async () => {
|
|
const state = get();
|
|
const { selectedProjectId, sessionsCursor, sessionsHasMore, sessionsLoadingMore } = state;
|
|
|
|
// Guard: don't fetch if already loading, no more pages, or no project
|
|
if (!selectedProjectId || !sessionsHasMore || sessionsLoadingMore || !sessionsCursor) {
|
|
return;
|
|
}
|
|
|
|
set({ sessionsLoadingMore: true });
|
|
try {
|
|
const { connectionMode } = get();
|
|
const result = await api.getSessionsPaginated(selectedProjectId, sessionsCursor, 20, {
|
|
includeTotalCount: false,
|
|
prefilterAll: false,
|
|
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
|
|
});
|
|
set((prevState) => {
|
|
// Deduplicate: pinned sessions fetched earlier may appear in paginated results
|
|
const existingIds = new Set(prevState.sessions.map((s) => s.id));
|
|
const newSessions = result.sessions.filter((s) => !existingIds.has(s.id));
|
|
return {
|
|
sessions: [...prevState.sessions, ...newSessions],
|
|
sessionsCursor: result.nextCursor,
|
|
sessionsHasMore: result.hasMore,
|
|
sessionsLoadingMore: false,
|
|
};
|
|
});
|
|
} catch (error) {
|
|
set({
|
|
sessionsError: error instanceof Error ? error.message : 'Failed to fetch more sessions',
|
|
sessionsLoadingMore: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
// Reset pagination state
|
|
resetSessionsPagination: () => {
|
|
set({
|
|
sessions: [],
|
|
sessionsCursor: null,
|
|
sessionsHasMore: false,
|
|
sessionsTotalCount: 0,
|
|
sessionsLoadingMore: false,
|
|
sessionsError: null,
|
|
});
|
|
},
|
|
|
|
// Select a session and fetch its detail
|
|
selectSession: (id: string) => {
|
|
set({
|
|
selectedSessionId: id,
|
|
sessionDetail: null,
|
|
sessionContextStats: null,
|
|
sessionDetailError: null,
|
|
});
|
|
|
|
// Fetch detail for this session, passing the active tabId for per-tab data
|
|
const state = get();
|
|
const projectId = state.selectedProjectId;
|
|
if (projectId) {
|
|
const activeTabId = state.activeTabId ?? undefined;
|
|
void state.fetchSessionDetail(projectId, id, activeTabId);
|
|
} else {
|
|
logger.warn('Cannot fetch session detail: no project selected');
|
|
}
|
|
},
|
|
|
|
// Clear all selections
|
|
clearSelection: () => {
|
|
set({
|
|
selectedProjectId: null,
|
|
selectedSessionId: null,
|
|
sessions: [],
|
|
sessionDetail: null,
|
|
sessionContextStats: null,
|
|
});
|
|
},
|
|
|
|
// Refresh sessions list in place without loading states
|
|
// Used for real-time updates when new sessions are added
|
|
refreshSessionsInPlace: async (projectId: string) => {
|
|
const currentState = get();
|
|
|
|
// Only refresh if viewing this project
|
|
if (currentState.selectedProjectId !== projectId) {
|
|
return;
|
|
}
|
|
|
|
const generation = (projectRefreshGeneration.get(projectId) ?? 0) + 1;
|
|
projectRefreshGeneration.set(projectId, generation);
|
|
|
|
try {
|
|
const { connectionMode } = get();
|
|
const result = await api.getSessionsPaginated(projectId, null, 20, {
|
|
includeTotalCount: false,
|
|
prefilterAll: false,
|
|
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
|
|
});
|
|
|
|
// Drop stale responses from older in-flight refreshes
|
|
if (projectRefreshGeneration.get(projectId) !== generation) {
|
|
return;
|
|
}
|
|
|
|
// Preserve pinned sessions that are beyond page 1
|
|
const { pinnedSessionIds, sessions: prevSessions } = get();
|
|
const newPageIds = new Set(result.sessions.map((s) => s.id));
|
|
const pinnedSet = new Set(pinnedSessionIds);
|
|
const pinnedToRetain = prevSessions.filter(
|
|
(s) => pinnedSet.has(s.id) && !newPageIds.has(s.id)
|
|
);
|
|
|
|
// Update sessions without loading state
|
|
set({
|
|
sessions: [...result.sessions, ...pinnedToRetain],
|
|
sessionsCursor: result.nextCursor,
|
|
sessionsHasMore: result.hasMore,
|
|
sessionsTotalCount: result.totalCount,
|
|
// Don't touch sessionsLoading - keep it as-is
|
|
});
|
|
} catch (error) {
|
|
logger.error('refreshSessionsInPlace error:', error);
|
|
// Don't set error state - this is a background refresh
|
|
}
|
|
},
|
|
|
|
// Toggle pin/unpin for a session (optimistic update)
|
|
togglePinSession: async (sessionId: string) => {
|
|
const state = get();
|
|
const projectId = state.selectedProjectId;
|
|
if (!projectId) return;
|
|
|
|
const isPinned = state.pinnedSessionIds.includes(sessionId);
|
|
const previousPinnedIds = state.pinnedSessionIds;
|
|
|
|
// Optimistic: update UI immediately
|
|
if (isPinned) {
|
|
set({ pinnedSessionIds: previousPinnedIds.filter((id) => id !== sessionId) });
|
|
} else {
|
|
set({ pinnedSessionIds: [sessionId, ...previousPinnedIds] });
|
|
}
|
|
|
|
try {
|
|
if (isPinned) {
|
|
await api.config.unpinSession(projectId, sessionId);
|
|
} else {
|
|
await api.config.pinSession(projectId, sessionId);
|
|
}
|
|
} catch (error) {
|
|
// Rollback on failure
|
|
set({ pinnedSessionIds: previousPinnedIds });
|
|
logger.error('togglePinSession error:', error);
|
|
}
|
|
},
|
|
|
|
// Load pinned sessions from config for current project
|
|
// Fetches missing pinned session data that may be beyond the paginated page
|
|
loadPinnedSessions: async () => {
|
|
const state = get();
|
|
const projectId = state.selectedProjectId;
|
|
if (!projectId) {
|
|
set({ pinnedSessionIds: [] });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const config = await api.config.get();
|
|
const pins = config.sessions?.pinnedSessions?.[projectId] ?? [];
|
|
const pinnedIds = pins.map((p) => p.sessionId);
|
|
set({ pinnedSessionIds: pinnedIds });
|
|
|
|
// Determine which pinned sessions are missing from the loaded sessions array
|
|
const currentSessions = get().sessions;
|
|
const loadedIds = new Set(currentSessions.map((s) => s.id));
|
|
const missingIds = pinnedIds.filter((id) => !loadedIds.has(id));
|
|
|
|
if (missingIds.length > 0) {
|
|
const missingSessions = await api.getSessionsByIds(projectId, missingIds);
|
|
if (missingSessions.length > 0) {
|
|
// Re-read sessions in case they changed during the async call
|
|
const latestSessions = get().sessions;
|
|
const latestIds = new Set(latestSessions.map((s) => s.id));
|
|
const toAppend = missingSessions.filter((s) => !latestIds.has(s.id));
|
|
if (toAppend.length > 0) {
|
|
set({ sessions: [...latestSessions, ...toAppend] });
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('loadPinnedSessions error:', error);
|
|
set({ pinnedSessionIds: [] });
|
|
}
|
|
},
|
|
});
|