agent-ecosystem/src/renderer/components/sidebar/DateGroupedSessions.tsx
matt e8f65cf492 Add CI and Release workflows; enhance loading skeletons and splash screen
- Introduced CI workflow for automated testing and validation on push and pull request events.
- Added Release workflow for packaging and distributing the application on version tag pushes.
- Enhanced loading skeletons in ChatHistory and ProjectsGrid components for improved visual feedback.
- Updated splash screen with new animations and styles for better user experience.
- Refined CSS variables for skeleton loading states to ensure consistency across themes.
2026-02-11 20:31:27 +09:00

361 lines
11 KiB
TypeScript

/**
* DateGroupedSessions - Sessions organized by date categories with virtual scrolling.
* Uses @tanstack/react-virtual for efficient DOM rendering with infinite scroll.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useStore } from '@renderer/store';
import {
getNonEmptyCategories,
groupSessionsByDate,
separatePinnedSessions,
} from '@renderer/utils/dateGrouping';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Calendar, Loader2, MessageSquareOff, Pin } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SessionItem } from './SessionItem';
import type { Session } from '@renderer/types/data';
import type { DateCategory } from '@renderer/types/tabs';
// Virtual list item types
type VirtualItem =
| { type: 'header'; category: DateCategory; id: string }
| { type: 'pinned-header'; id: string }
| { type: 'session'; session: Session; isPinned: boolean; id: string }
| { type: 'loader'; id: string };
/**
* Item height constants for virtual scroll positioning.
* CRITICAL: These values MUST match the actual rendered heights of components.
* If SessionItem height changes, update SESSION_HEIGHT here AND add h-[Xpx] to SessionItem.
* Mismatch causes items to overlap!
*/
const HEADER_HEIGHT = 28;
const SESSION_HEIGHT = 48; // Must match h-[48px] in SessionItem.tsx
const LOADER_HEIGHT = 36;
const OVERSCAN = 5;
export const DateGroupedSessions = (): React.JSX.Element => {
const {
sessions,
selectedSessionId,
selectedProjectId,
sessionsLoading,
sessionsError,
sessionsHasMore,
sessionsLoadingMore,
sessionsTotalCount,
fetchSessionsMore,
pinnedSessionIds,
} = useStore(
useShallow((s) => ({
sessions: s.sessions,
selectedSessionId: s.selectedSessionId,
selectedProjectId: s.selectedProjectId,
sessionsLoading: s.sessionsLoading,
sessionsError: s.sessionsError,
sessionsHasMore: s.sessionsHasMore,
sessionsLoadingMore: s.sessionsLoadingMore,
sessionsTotalCount: s.sessionsTotalCount,
fetchSessionsMore: s.fetchSessionsMore,
pinnedSessionIds: s.pinnedSessionIds,
}))
);
const parentRef = useRef<HTMLDivElement>(null);
// Separate pinned sessions from unpinned
const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo(
() => separatePinnedSessions(sessions, pinnedSessionIds),
[sessions, pinnedSessionIds]
);
// Group only unpinned sessions by date
const groupedSessions = useMemo(() => groupSessionsByDate(unpinnedSessions), [unpinnedSessions]);
// Get non-empty categories in display order
const nonEmptyCategories = useMemo(
() => getNonEmptyCategories(groupedSessions),
[groupedSessions]
);
// Flatten sessions with date headers into virtual list items
const virtualItems = useMemo((): VirtualItem[] => {
const items: VirtualItem[] = [];
// Add pinned section first
if (pinnedSessions.length > 0) {
items.push({
type: 'pinned-header',
id: 'header-pinned',
});
for (const session of pinnedSessions) {
items.push({
type: 'session',
session,
isPinned: true,
id: `session-${session.id}`,
});
}
}
for (const category of nonEmptyCategories) {
// Add header item
items.push({
type: 'header',
category,
id: `header-${category}`,
});
// Add session items
for (const session of groupedSessions[category]) {
items.push({
type: 'session',
session,
isPinned: false,
id: `session-${session.id}`,
});
}
}
// Add loader item if there are more sessions to load
if (sessionsHasMore) {
items.push({
type: 'loader',
id: 'loader',
});
}
return items;
}, [pinnedSessions, nonEmptyCategories, groupedSessions, sessionsHasMore]);
// Estimate item size based on type
const estimateSize = useCallback(
(index: number) => {
const item = virtualItems[index];
if (!item) return SESSION_HEIGHT;
switch (item.type) {
case 'header':
case 'pinned-header':
return HEADER_HEIGHT;
case 'loader':
return LOADER_HEIGHT;
case 'session':
default:
return SESSION_HEIGHT;
}
},
[virtualItems]
);
// Set up virtualizer
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual API limitation, not fixable in user code
const rowVirtualizer = useVirtualizer({
count: virtualItems.length,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: OVERSCAN,
});
// Get virtual items for dependency tracking
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualRowsLength = virtualRows.length;
// Load more when scrolling near end
useEffect(() => {
if (virtualRowsLength === 0) return;
const lastItem = virtualRows[virtualRowsLength - 1];
if (!lastItem) return;
// If we're within 3 items of the end and there's more to load, fetch more
if (
lastItem.index >= virtualItems.length - 3 &&
sessionsHasMore &&
!sessionsLoadingMore &&
!sessionsLoading
) {
void fetchSessionsMore();
}
}, [
virtualRows,
virtualRowsLength,
virtualItems.length,
sessionsHasMore,
sessionsLoadingMore,
sessionsLoading,
fetchSessionsMore,
]);
if (!selectedProjectId) {
return (
<div className="p-4">
<div className="py-8 text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<p>Select a project to view sessions</p>
</div>
</div>
);
}
if (sessionsLoading && sessions.length === 0) {
const widths = [
{ header: '30%', title: '75%', sub: '90%' },
{ header: '22%', title: '60%', sub: '80%' },
{ header: '26%', title: '85%', sub: '65%' },
];
return (
<div className="p-4">
<div className="space-y-3">
{widths.map((w, i) => (
<div key={i} className="space-y-2">
<div
className="skeleton-shimmer h-3 rounded-sm"
style={{ backgroundColor: 'var(--skeleton-base-dim)', width: w.header }}
/>
<div
className="skeleton-shimmer h-4 rounded-sm"
style={{ backgroundColor: 'var(--skeleton-base)', width: w.title }}
/>
<div
className="skeleton-shimmer h-3 rounded-sm"
style={{ backgroundColor: 'var(--skeleton-base-dim)', width: w.sub }}
/>
</div>
))}
</div>
</div>
);
}
if (sessionsError) {
return (
<div className="p-4">
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-muted)',
}}
>
<p className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
Error loading sessions
</p>
<p>{sessionsError}</p>
</div>
</div>
);
}
if (sessions.length === 0) {
return (
<div className="p-4">
<div className="py-8 text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<MessageSquareOff className="mx-auto mb-2 size-8 opacity-50" />
<p className="mb-2">No sessions found</p>
<p className="text-xs opacity-70">This project has no sessions yet</p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="mt-2 flex items-center gap-2 px-4 py-3">
<Calendar className="size-4" style={{ color: 'var(--color-text-muted)' }} />
<h2
className="text-xs uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Sessions
</h2>
<span className="text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}>
({sessions.length}
{sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''})
</span>
</div>
<div ref={parentRef} className="flex-1 overflow-y-auto">
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const item = virtualItems[virtualRow.index];
if (!item) return null;
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{item.type === 'pinned-header' ? (
<div
className="sticky top-0 flex h-full items-center gap-1.5 border-t px-4 py-1.5 text-[11px] font-semibold uppercase tracking-wider backdrop-blur-sm"
style={{
backgroundColor:
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
color: 'var(--color-text-muted)',
borderColor: 'var(--color-border-emphasis)',
}}
>
<Pin className="size-3" />
Pinned
</div>
) : item.type === 'header' ? (
<div
className="sticky top-0 flex h-full items-center border-t px-4 py-1.5 text-[11px] font-semibold uppercase tracking-wider backdrop-blur-sm"
style={{
backgroundColor:
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
color: 'var(--color-text-muted)',
borderColor: 'var(--color-border-emphasis)',
}}
>
{item.category}
</div>
) : item.type === 'loader' ? (
<div
className="flex h-full items-center justify-center"
style={{ color: 'var(--color-text-muted)' }}
>
{sessionsLoadingMore ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
<span className="text-xs">Loading more sessions...</span>
</>
) : (
<span className="text-xs opacity-50">Scroll to load more</span>
)}
</div>
) : (
<SessionItem
session={item.session}
isActive={selectedSessionId === item.session.id}
isPinned={item.isPinned}
/>
)}
</div>
);
})}
</div>
</div>
</div>
);
};