fix(ui): Timeline uses EventFeed's internal scroll + load-older button
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m29s
CI / docker (push) Successful in 1m15s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 39s

EventFeed has its own search + filter toolbar inside the component.
Wrapping it in InfiniteScrollArea made the toolbar scroll out of
sight. Drop InfiniteScrollArea for the Timeline, give EventFeed a
bounded-height flex container (it scrolls its own .list internally),
and add an explicit 'Load older events' button for cursor
pagination. Polling always on for events (low volume).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-17 15:25:48 +02:00
parent a3429a609e
commit 9292bd5f5f
3 changed files with 61 additions and 38 deletions

View File

@@ -283,9 +283,7 @@ export default function AgentHealth() {
});
}, [appConfig, configDraft, updateConfig, toast, appId]);
const [eventSortAsc, setEventSortAsc] = useState(false);
const [isTimelineAtTop, setIsTimelineAtTop] = useState(true);
const timelineScrollRef = useRef<HTMLDivElement | null>(null);
const eventStream = useInfiniteAgentEvents({ appId, isAtTop: isTimelineAtTop });
const eventStream = useInfiniteAgentEvents({ appId, isAtTop: true });
const [appFilter, setAppFilter] = useState('');
type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat';
@@ -985,32 +983,29 @@ export default function AgentHealth() {
<Button variant="ghost" size="sm" onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}>
{eventSortAsc ? '\u2191' : '\u2193'}
</Button>
<Button variant="ghost" size="sm" onClick={() => {
eventStream.refresh();
timelineScrollRef.current?.scrollTo({ top: 0 });
}} title="Refresh">
<Button variant="ghost" size="sm" onClick={() => eventStream.refresh()} title="Refresh">
<RefreshCw size={14} />
</Button>
</div>
</div>
<InfiniteScrollArea
scrollRef={timelineScrollRef}
onEndReached={eventStream.fetchNextPage}
onTopVisibilityChange={setIsTimelineAtTop}
isFetchingNextPage={eventStream.isFetchingNextPage}
hasNextPage={eventStream.hasNextPage}
isLoading={eventStream.isLoading}
hasItems={eventStream.items.length > 0}
maxHeight={360}
>
<div className={logStyles.eventFeedContainer}>
{feedEvents.length > 0 ? (
<EventFeed events={feedEvents} className={logStyles.flatScroll} />
<EventFeed events={feedEvents} />
) : (
<div className={logStyles.logEmpty}>
{eventStream.isLoading ? 'Loading events\u2026' : 'No events in the selected time range.'}
</div>
)}
</InfiniteScrollArea>
</div>
{eventStream.hasNextPage && (
<button
className={logStyles.loadOlderBtn}
onClick={eventStream.fetchNextPage}
disabled={eventStream.isFetchingNextPage}
>
{eventStream.isFetchingNextPage ? 'Loading\u2026' : 'Load older events'}
</button>
)}
</div>
</div>

View File

@@ -42,15 +42,13 @@ export default function AgentInstance() {
const [logSortAsc, setLogSortAsc] = useState(false);
const [eventSortAsc, setEventSortAsc] = useState(false);
const [isLogAtTop, setIsLogAtTop] = useState(true);
const [isTimelineAtTop, setIsTimelineAtTop] = useState(true);
const logScrollRef = useRef<HTMLDivElement | null>(null);
const timelineScrollRef = useRef<HTMLDivElement | null>(null);
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: agents, isLoading } = useAgents(undefined, appId);
const eventStream = useInfiniteAgentEvents({ appId, agentId: instanceId, isAtTop: isTimelineAtTop });
const eventStream = useInfiniteAgentEvents({ appId, agentId: instanceId, isAtTop: true });
const agent = useMemo(
() => (agents || []).find((a: any) => a.instanceId === instanceId) as any,
@@ -491,32 +489,29 @@ export default function AgentInstance() {
<button className={logStyles.sortBtn} onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}>
{eventSortAsc ? '\u2191' : '\u2193'}
</button>
<button className={logStyles.refreshBtn} onClick={() => {
eventStream.refresh();
timelineScrollRef.current?.scrollTo({ top: 0 });
}} title="Refresh">
<button className={logStyles.refreshBtn} onClick={() => eventStream.refresh()} title="Refresh">
<RefreshCw size={14} />
</button>
</div>
</div>
<InfiniteScrollArea
scrollRef={timelineScrollRef}
onEndReached={eventStream.fetchNextPage}
onTopVisibilityChange={setIsTimelineAtTop}
isFetchingNextPage={eventStream.isFetchingNextPage}
hasNextPage={eventStream.hasNextPage}
isLoading={eventStream.isLoading}
hasItems={eventStream.items.length > 0}
maxHeight={360}
>
<div className={logStyles.eventFeedContainer}>
{feedEvents.length > 0 ? (
<EventFeed events={feedEvents} className={logStyles.flatScroll} />
<EventFeed events={feedEvents} />
) : (
<div className={logStyles.logEmpty}>
{eventStream.isLoading ? 'Loading events…' : 'No events in the selected time range.'}
</div>
)}
</InfiniteScrollArea>
</div>
{eventStream.hasNextPage && (
<button
className={logStyles.loadOlderBtn}
onClick={eventStream.fetchNextPage}
disabled={eventStream.isFetchingNextPage}
>
{eventStream.isFetchingNextPage ? 'Loading…' : 'Load older events'}
</button>
)}
</div>
</div>
</div>

View File

@@ -129,3 +129,36 @@
align-items: center;
gap: 6px;
}
/* Timeline: EventFeed owns its own scroll (so its internal search/filter
toolbar stays pinned). Give it a bounded height and let its .list scroll.
Pagination via the explicit "Load older" button below, not scroll. */
.eventFeedContainer {
display: flex;
flex-direction: column;
max-height: 360px;
min-height: 0;
overflow: hidden;
}
.loadOlderBtn {
padding: 8px 12px;
border: none;
border-top: 1px solid var(--border-subtle);
background: var(--bg-surface);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
text-align: center;
font-family: var(--font-body);
}
.loadOlderBtn:hover:not(:disabled) {
color: var(--text-primary);
background: var(--bg-hover);
}
.loadOlderBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}