feat: add scroll-to-top button and improve pagination handling

- Implemented a scroll-to-top button in the viewer UI for better navigation.
- Added styles for the scroll-to-top button in viewer.html and viewer-template.html.
- Created a new ScrollToTop component to manage visibility and scrolling behavior.
- Updated Feed component to include the ScrollToTop component.
- Enhanced pagination logic in usePagination hook to prevent stale closures and improve performance.
- Modified SDKAgent to include additional observation fields for better data handling.
This commit is contained in:
Alex Newman
2025-11-07 17:57:54 -05:00
parent d6f1237283
commit 30a42036aa
9 changed files with 183 additions and 17 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -871,6 +871,49 @@
background-position: -200% 0;
}
}
/* Scroll to top button */
.scroll-to-top {
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
background: var(--color-bg-button);
color: var(--color-text-button);
border: none;
border-radius: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
z-index: 50;
animation: fadeInUp 0.3s ease-out;
}
.scroll-to-top:hover {
background: var(--color-bg-button-hover);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.scroll-to-top:active {
background: var(--color-bg-button-active);
transform: translateY(0);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>

View File

@@ -198,10 +198,17 @@ export class SDKAgent {
type: 'new_observation',
observation: {
id: obsId,
sdk_session_id: session.sdkSessionId,
session_id: session.claudeSessionId,
type: obs.type,
title: obs.title,
subtitle: obs.subtitle,
text: obs.text || null,
narrative: null,
facts: JSON.stringify(obs.facts || []),
concepts: JSON.stringify(obs.concepts || []),
files_read: JSON.stringify(obs.files || []),
files_modified: JSON.stringify([]),
project: session.project,
prompt_number: session.lastPromptNumber,
created_at_epoch: createdAtEpoch

View File

@@ -871,6 +871,49 @@
background-position: -200% 0;
}
}
/* Scroll to top button */
.scroll-to-top {
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
background: var(--color-bg-button);
color: var(--color-text-button);
border: none;
border-radius: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
z-index: 50;
animation: fadeInUp 0.3s ease-out;
}
.scroll-to-top:hover {
background: var(--color-bg-button-hover);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.scroll-to-top:active {
background: var(--color-bg-button-active);
transform: translateY(0);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>

View File

@@ -72,12 +72,13 @@ export function App() {
} catch (error) {
console.error('Failed to load more data:', error);
}
}, [pagination]);
}, [pagination.observations, pagination.summaries, pagination.prompts]);
// Load first page when filter changes or pagination handlers update
// Load first page only when filter changes
useEffect(() => {
handleLoadMore();
}, [currentFilter, handleLoadMore]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentFilter]); // Only re-run when filter changes, not when handleLoadMore changes
return (
<div className="container">

View File

@@ -3,6 +3,7 @@ import { Observation, Summary, UserPrompt, FeedItem } from '../types';
import { ObservationCard } from './ObservationCard';
import { SummaryCard } from './SummaryCard';
import { PromptCard } from './PromptCard';
import { ScrollToTop } from './ScrollToTop';
import { UI } from '../constants/ui';
interface FeedProps {
@@ -16,6 +17,7 @@ interface FeedProps {
export function Feed({ observations, summaries, prompts, onLoadMore, isLoading, hasMore }: FeedProps) {
const loadMoreRef = useRef<HTMLDivElement>(null);
const feedRef = useRef<HTMLDivElement>(null);
const onLoadMoreRef = useRef(onLoadMore);
// Keep the callback ref up to date
@@ -59,7 +61,8 @@ export function Feed({ observations, summaries, prompts, onLoadMore, isLoading,
}, [observations, summaries, prompts]);
return (
<div className="feed">
<div className="feed" ref={feedRef}>
<ScrollToTop targetRef={feedRef} />
<div className="feed-content">
{items.map(item => {
const key = `${item.itemType}-${item.id}`;

View File

@@ -0,0 +1,57 @@
import React, { useState, useEffect } from 'react';
interface ScrollToTopProps {
targetRef: React.RefObject<HTMLDivElement>;
}
export function ScrollToTop({ targetRef }: ScrollToTopProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleScroll = () => {
const target = targetRef.current;
if (target) {
setIsVisible(target.scrollTop > 300);
}
};
const target = targetRef.current;
if (target) {
target.addEventListener('scroll', handleScroll);
return () => target.removeEventListener('scroll', handleScroll);
}
}, []); // Empty deps - only set up listener once on mount
const scrollToTop = () => {
const target = targetRef.current;
if (target) {
target.scrollTo({
top: 0,
behavior: 'smooth'
});
}
};
if (!isVisible) return null;
return (
<button
onClick={scrollToTop}
className="scroll-to-top"
aria-label="Scroll to top"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Observation, Summary, UserPrompt } from '../types';
import { UI } from '../constants/ui';
import { API_ENDPOINTS } from '../constants/api';
@@ -21,6 +21,18 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
});
const [offset, setOffset] = useState(0);
// Use refs to avoid stale closures and prevent infinite loops
const stateRef = useRef(state);
const offsetRef = useRef(offset);
useEffect(() => {
stateRef.current = state;
}, [state]);
useEffect(() => {
offsetRef.current = offset;
}, [offset]);
// Reset pagination when filter changes
useEffect(() => {
setOffset(0);
@@ -34,17 +46,17 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
* Load more items from the API
*/
const loadMore = useCallback(async (): Promise<DataItem[]> => {
// Prevent concurrent requests using state
if (state.isLoading || !state.hasMore) {
// Prevent concurrent requests using ref (always current)
if (stateRef.current.isLoading || !stateRef.current.hasMore) {
return [];
}
setState(prev => ({ ...prev, isLoading: true }));
try {
// Build query params
// Build query params using ref (always current)
const params = new URLSearchParams({
offset: offset.toString(),
offset: offsetRef.current.toString(),
limit: UI.PAGINATION_PAGE_SIZE.toString()
});
@@ -74,7 +86,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
setState(prev => ({ ...prev, isLoading: false }));
return [];
}
}, [offset, state.hasMore, state.isLoading, currentFilter, endpoint, dataType]);
}, [currentFilter, endpoint, dataType]); // Only stable values - no state/offset deps
return {
...state,