diff --git a/apps/web/src/features/activity/core/feed-rows.test.ts b/apps/web/src/features/activity/core/feed-rows.test.ts new file mode 100644 index 0000000000..9a6c82d2eb --- /dev/null +++ b/apps/web/src/features/activity/core/feed-rows.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { decodeActivityEvent } from '../queries/decode'; +import { createdEvent, editedEvent } from '../queries/fixtures'; +import { flattenFeed, reuseRows, shouldFetchMore } from './feed-rows'; + +const created = decodeActivityEvent(createdEvent); +const edited = decodeActivityEvent(editedEvent); + +describe('flattenFeed', () => { + it('interleaves day headers and events in order and ends with a tail when more pages exist', () => { + const rows = flattenFeed( + [ + { key: 'today', label: 'Today', events: [created] }, + { key: 'yesterday', label: 'Yesterday', events: [edited] }, + ], + { hasMore: true } + ); + expect(rows.map((row) => row.kind)).toEqual([ + 'day', + 'event', + 'day', + 'event', + 'tail', + ]); + expect(rows[0]).toEqual({ kind: 'day', key: 'today', label: 'Today' }); + expect(rows[1]).toEqual({ kind: 'event', event: created }); + }); + + it('omits the tail on the last page', () => { + const rows = flattenFeed( + [{ key: 'today', label: 'Today', events: [created] }], + { hasMore: false } + ); + expect(rows.map((row) => row.kind)).toEqual(['day', 'event']); + }); +}); + +describe('reuseRows', () => { + it('keeps previous row objects for matching keys and adds the rest', () => { + const previous = flattenFeed( + [{ key: 'today', label: 'Today', events: [created] }], + { hasMore: true } + ); + const next = flattenFeed( + [ + { key: 'today', label: 'Today', events: [{ ...created }] }, + { key: 'yesterday', label: 'Yesterday', events: [edited] }, + ], + { hasMore: false } + ); + const reused = reuseRows(previous, next); + expect(reused[0]).toBe(previous[0]); + expect(reused[1]).toBe(previous[1]); + expect(reused[2]).toBe(next[2]); + expect(reused[3]).toBe(next[3]); + expect(reused.map((row) => row.kind)).toEqual([ + 'day', + 'event', + 'day', + 'event', + ]); + }); +}); + +describe('shouldFetchMore', () => { + it.each([ + // [scrollSize, viewportSize, offset, expected] + [3000, 800, 0, false], + [3000, 800, 1399, false], + [3000, 800, 1400, true], + [3000, 800, 2200, true], + [500, 800, 0, true], + [1000, 50, 800, false], + [1000, 50, 850, true], + ])( + 'scrollSize %i viewport %i offset %i -> %s', + (scrollSize, viewportSize, offset, expected) => { + expect(shouldFetchMore({ scrollSize, viewportSize, offset })).toBe( + expected + ); + } + ); +}); diff --git a/apps/web/src/features/activity/core/feed-rows.ts b/apps/web/src/features/activity/core/feed-rows.ts new file mode 100644 index 0000000000..944b23da21 --- /dev/null +++ b/apps/web/src/features/activity/core/feed-rows.ts @@ -0,0 +1,72 @@ +import type { ActivityEvent } from './event'; +import type { FeedGroup } from './group-events'; + +/** + * One virtualized row of the Activity screen. The overview card is row zero + * so it scrolls with the feed and the virtualizer needs no start margin. + */ +export type FeedRow = + | { kind: 'overview' } + | { kind: 'day'; key: string; label: string } + | { kind: 'event'; event: ActivityEvent } + | { kind: 'status'; status: 'loading' | 'error' | 'empty' } + | { kind: 'tail' }; + +/** Day headers and their events in order, plus a tail row while more pages exist. */ +export function flattenFeed( + groups: FeedGroup[], + options: { hasMore: boolean } +): FeedRow[] { + const rows: FeedRow[] = []; + for (const group of groups) { + rows.push({ kind: 'day', key: group.key, label: group.label }); + for (const event of group.events) rows.push({ kind: 'event', event }); + } + if (options.hasMore) rows.push({ kind: 'tail' }); + return rows; +} + +function rowKey(row: FeedRow): string { + switch (row.kind) { + case 'overview': + case 'tail': + return row.kind; + case 'day': + return `day:${row.key}`; + case 'event': + return `event:${row.event.id}`; + case 'status': + return `status:${row.status}`; + } +} + +/** + * Carry previous row objects forward where the key matches, so the list + * keys rows by reference and a refetch or a paging flag flip does not + * remount every mounted row. Events are immutable once recorded, so a + * matching id is a matching row. + */ +export function reuseRows(previous: FeedRow[], next: FeedRow[]): FeedRow[] { + if (previous.length === 0) return next; + const byKey = new Map(previous.map((row) => [rowKey(row), row])); + return next.map((row) => byKey.get(rowKey(row)) ?? row); +} + +/** Floor for the near-bottom threshold so tiny viewports still page. */ +const MIN_FETCH_THRESHOLD = 100; + +/** + * Whether the scroller is within one viewport of its end, the point at which + * the next page should start loading so it usually lands before the user + * reaches the bottom. + */ +export function shouldFetchMore(metrics: { + scrollSize: number; + viewportSize: number; + offset: number; +}): boolean { + const threshold = Math.max(MIN_FETCH_THRESHOLD, metrics.viewportSize); + return ( + metrics.scrollSize - metrics.viewportSize - metrics.offset <= threshold + ); +} diff --git a/apps/web/src/features/activity/primitives/my-activity.test.ts b/apps/web/src/features/activity/primitives/my-activity.test.ts index 48f42adc14..2942cca3b8 100644 --- a/apps/web/src/features/activity/primitives/my-activity.test.ts +++ b/apps/web/src/features/activity/primitives/my-activity.test.ts @@ -10,6 +10,8 @@ afterEach(() => { for (const dispose of disposals.splice(0)) dispose(); }); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + function setup() { const context = createMockActivityContext(); let state!: MyActivityState; @@ -72,6 +74,89 @@ describe('createMyActivityState', () => { expect(feed.hasMore).toBe(false); }); + it('exposes the overview as row zero and the flattened feed after it', () => { + const { state, graphql } = setup(); + expect(state.rows().map((row) => row.kind)).toEqual(['overview', 'status']); + + graphql.latest('MyActivity').resolve(feedPage([createdEvent], 'c2')); + const ready = state.rows(); + expect(ready.map((row) => row.kind)).toEqual([ + 'overview', + 'day', + 'event', + 'tail', + ]); + + state.loadMore(); + // The in-flight flag flips but the mounted rows keep their identity. + expect(state.rows()[1]).toBe(ready[1]); + expect(state.rows()[2]).toBe(ready[2]); + + graphql.latest('MyActivity').resolve(feedPage([editedEvent], null)); + expect(state.rows().map((row) => row.kind)).toEqual([ + 'overview', + 'day', + 'event', + 'event', + ]); + expect(state.rows()[2]).toBe(ready[2]); + }); + + it('ignores loadMore while a page is in flight or none remain', () => { + const { state, graphql } = setup(); + graphql.latest('MyActivity').resolve(feedPage([createdEvent], 'c2')); + const requests = () => + graphql.pending.filter((op) => op.name === 'MyActivity').length; + + state.loadMore(); + state.loadMore(); + expect(requests()).toBe(2); + + graphql.latest('MyActivity').resolve(feedPage([editedEvent], null)); + state.loadMore(); + expect(requests()).toBe(2); + }); + + it('stops auto-paging after a failed page until retryMore', async () => { + const { state, graphql } = setup(); + graphql.latest('MyActivity').resolve(feedPage([createdEvent], 'c2')); + const requests = () => + graphql.pending.filter((op) => op.name === 'MyActivity').length; + + state.loadMore(); + expect(requests()).toBe(2); + graphql.latest('MyActivity').fail('boom'); + // The page observer settles its failure a few microtasks after the result. + await settle(); + + const feed = state.feed(); + expect(feed.t).toBe('ready'); + if (feed.t !== 'ready') return; + expect(feed.moreFailed).toBe(true); + expect(feed.loadingMore).toBe(false); + expect(feed.hasMore).toBe(true); + + // The near-end check keeps firing while the user rests at the bottom; + // none of those turn into another request. + state.loadMore(); + state.loadMore(); + expect(requests()).toBe(2); + + state.retryMore(); + await settle(); + expect(requests()).toBe(3); + expect(graphql.latest('MyActivity').variables).toEqual({ + input: { limit: 50, cursor: 'c2' }, + }); + + graphql.latest('MyActivity').resolve(feedPage([editedEvent], null)); + const after = state.feed(); + if (after.t !== 'ready') throw new Error('feed should stay ready'); + expect(after.moreFailed).toBe(false); + expect(after.hasMore).toBe(false); + expect(after.groups.flatMap((g) => g.events)).toHaveLength(2); + }); + it('is empty when the first page has no rows', () => { const { state, graphql } = setup(); graphql.latest('MyActivity').resolve(feedPage([])); diff --git a/apps/web/src/features/activity/primitives/my-activity.ts b/apps/web/src/features/activity/primitives/my-activity.ts index bd8819e6d4..3527ddd3a4 100644 --- a/apps/web/src/features/activity/primitives/my-activity.ts +++ b/apps/web/src/features/activity/primitives/my-activity.ts @@ -1,6 +1,7 @@ import { type Accessor, createMemo } from 'solid-js'; import type { ActivityContext } from '../context/activity-context'; import type { ActivityOverview } from '../core/event'; +import { type FeedRow, flattenFeed, reuseRows } from '../core/feed-rows'; import { type FeedGroup, groupEventsByDay } from '../core/group-events'; import { createMyActivityQuery } from '../queries/feed-query'; import { createMyActivityOverviewQuery } from '../queries/overview-query'; @@ -9,7 +10,14 @@ export type FeedView = | { t: 'loading' } | { t: 'error' } | { t: 'empty' } - | { t: 'ready'; groups: FeedGroup[]; hasMore: boolean; loadingMore: boolean }; + | { + t: 'ready'; + groups: FeedGroup[]; + hasMore: boolean; + loadingMore: boolean; + /** The last next-page request failed; auto-paging waits for `retryMore`. */ + moreFailed: boolean; + }; export type OverviewView = | { t: 'loading' } @@ -19,7 +27,16 @@ export type OverviewView = export type MyActivityState = { overview: Accessor; feed: Accessor; + /** Every virtualized row of the screen: the overview first, then the feed. */ + rows: Accessor; + /** + * Fetch the next feed page. No-op while one is in flight, none remain, or + * the last attempt failed (so a scroller resting near the end does not + * hammer a failing endpoint). + */ loadMore: () => void; + /** Retry the failed next page. The one way to page again after a failure. */ + retryMore: () => void; }; /** @@ -50,6 +67,7 @@ export function createMyActivityState( groups: groups(), hasMore: feedQuery.hasNextPage, loadingMore: feedQuery.isFetchingNextPage, + moreFailed: feedQuery.isFetchNextPageError, }; } if (feedQuery.isLoading) return { t: 'loading' }; @@ -57,9 +75,28 @@ export function createMyActivityState( return { t: 'empty' }; }); + const rows = createMemo((previous) => { + const current = feed(); + const feedRows: FeedRow[] = + current.t === 'ready' + ? flattenFeed(current.groups, { hasMore: current.hasMore }) + : [{ kind: 'status', status: current.t }]; + return reuseRows(previous, [{ kind: 'overview' }, ...feedRows]); + }, []); + + const fetchNext = () => { + if (!feedQuery.hasNextPage || feedQuery.isFetchingNextPage) return; + void feedQuery.fetchNextPage(); + }; + return { overview, feed, - loadMore: () => void feedQuery.fetchNextPage(), + rows, + loadMore: () => { + if (feedQuery.isFetchNextPageError) return; + fetchNext(); + }, + retryMore: fetchNext, }; } diff --git a/apps/web/src/features/activity/views/my-activity-view.test.tsx b/apps/web/src/features/activity/views/my-activity-view.test.tsx index 2deef70563..8a7438827b 100644 --- a/apps/web/src/features/activity/views/my-activity-view.test.tsx +++ b/apps/web/src/features/activity/views/my-activity-view.test.tsx @@ -1,4 +1,5 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import type { JSX } from 'solid-js'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ActivityContextProvider } from '../context/activity-context'; import { placeholderOverview } from '../core/placeholder-overview'; @@ -11,6 +12,29 @@ vi.mock('@components/app/split-layout/components/SplitHeader', () => ({ SplitHeaderLeft: (props: { children: unknown }) => props.children, })); +// jsdom has no layout, so the virtualizer renders every row and exposes a +// fake handle plus its scroll callback so tests can drive paging. +const virtual = vi.hoisted(() => ({ + onScroll: undefined as ((offset: number) => void) | undefined, + handle: { scrollSize: 3000, viewportSize: 800, scrollOffset: 0 }, +})); + +vi.mock('virtua/solid', async () => { + const { For } = await import('solid-js'); + return { + Virtualizer: (props: { + data: readonly unknown[]; + children: (row: unknown, index: () => number) => JSX.Element; + ref?: (handle: unknown) => void; + onScroll?: (offset: number) => void; + }) => { + props.ref?.(virtual.handle); + virtual.onScroll = props.onScroll; + return {(row, i) => props.children(row, i)}; + }, + }; +}); + vi.mock( '@core/component/LexicalMarkdown/component/core/StaticMarkdown', () => ({ @@ -36,6 +60,8 @@ vi.mock('@service-storage/websocket', () => ({ afterEach(cleanup); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + function renderView() { const context = createMockActivityContext(); const onOpen = vi.fn(); @@ -85,8 +111,8 @@ describe('MyActivityView', () => { ); }); - it('renders grouped rows with actor names and pages on Show more', async () => { - const { graphql } = renderView(); + it('renders grouped rows with actor names and pages when scrolled near the end', () => { + const { container, graphql } = renderView(); graphql .latest('MyActivity') .resolve(feedPage([createdEvent, messagedEvent], 'cursor-2')); @@ -95,16 +121,75 @@ describe('MyActivityView', () => { expect(rows()[0]?.getAttribute('data-activity-action')).toBe('created'); expect(rows()[1]?.getAttribute('data-activity-action')).toBe('messaged'); expect(screen.getAllByText('sarah')).toHaveLength(2); + expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull(); + expect(container.querySelector('[data-activity-feed-tail]')).not.toBeNull(); + + const scroll = virtual.onScroll; + if (!scroll) throw new Error('virtualizer did not register onScroll'); + const feedRequests = () => + graphql.pending.filter((op) => op.name === 'MyActivity').length; + expect(feedRequests()).toBe(1); - fireEvent.click(screen.getByRole('button', { name: 'Show more' })); + // Far from the end: 3000 - 800 - 0 > max(100, 800). + scroll(0); + expect(feedRequests()).toBe(1); + + // Within one viewport of the end. + scroll(1500); + expect(feedRequests()).toBe(2); const next = graphql.latest('MyActivity'); expect(next.variables).toEqual({ input: { limit: 50, cursor: 'cursor-2' }, }); + expect(container.textContent).toContain('Loading…'); + + // A second scroll while the page is in flight does not double-fetch. + scroll(1600); + expect(feedRequests()).toBe(2); next.resolve(feedPage([{ ...createdEvent, id: 'evt-99' }], null)); expect(rows()).toHaveLength(3); - expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull(); + expect(container.querySelector('[data-activity-feed-tail]')).toBeNull(); + + // No more pages: scrolling to the end fetches nothing. + scroll(2200); + expect(feedRequests()).toBe(2); + }); + + it('offers a retry instead of re-paging when the next page fails', async () => { + const { container, graphql } = renderView(); + graphql + .latest('MyActivity') + .resolve(feedPage([createdEvent, messagedEvent], 'cursor-2')); + const scroll = virtual.onScroll; + if (!scroll) throw new Error('virtualizer did not register onScroll'); + const feedRequests = () => + graphql.pending.filter((op) => op.name === 'MyActivity').length; + + scroll(1500); + expect(feedRequests()).toBe(2); + graphql.latest('MyActivity').fail('boom'); + await settle(); + + const tail = container.querySelector('[data-activity-feed-tail]'); + expect(tail?.textContent).toContain("Couldn't load more."); + expect(tail?.textContent).not.toContain('Loading…'); + + // Still resting near the end: no automatic re-fetch. + scroll(1500); + scroll(1600); + expect(feedRequests()).toBe(2); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await settle(); + expect(feedRequests()).toBe(3); + expect(container.textContent).toContain('Loading…'); + + graphql + .latest('MyActivity') + .resolve(feedPage([{ ...createdEvent, id: 'evt-99' }], null)); + expect(rows()).toHaveLength(3); + expect(container.querySelector('[data-activity-feed-tail]')).toBeNull(); }); it('asks the host to open the row entity', () => { diff --git a/apps/web/src/features/activity/views/my-activity-view.tsx b/apps/web/src/features/activity/views/my-activity-view.tsx index 0bff1a0dab..e3a181e5c4 100644 --- a/apps/web/src/features/activity/views/my-activity-view.tsx +++ b/apps/web/src/features/activity/views/my-activity-view.tsx @@ -1,21 +1,39 @@ import { SoupSectionHeader } from '@app/features/next-soup/soup-view/section-header'; import { SplitHeaderLeft } from '@components/app/split-layout/components/SplitHeader'; import { StaticMarkdownContext } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown'; -import { Button } from '@ui'; -import { For, type JSX, Match, Show, Switch } from 'solid-js'; +import { + createEffect, + createSignal, + For, + type JSX, + on, + onCleanup, + Show, + Suspense, +} from 'solid-js'; +import { match } from 'ts-pattern'; +import { Virtualizer, type VirtualizerHandle } from 'virtua/solid'; import { ActionGraph } from '../components/action-graph'; +import { ActivityTimelineRow as ActivityTimelineRowView } from '../components/activity-timeline-row'; import { TopEntitiesSection, TopEntityChip } from '../components/top-entities'; import { type OpenEntityTarget, useActivityContext, } from '../context/activity-context'; import type { ActivityEvent, ActivityTopEntity } from '../core/event'; +import { type FeedRow, shouldFetchMore } from '../core/feed-rows'; import { placeholderOverview } from '../core/placeholder-overview'; import { createActorName } from '../primitives/actor-name'; import { createEntityOpener } from '../primitives/entity-opener'; -import { createMyActivityState } from '../primitives/my-activity'; +import { + createMyActivityState, + type MyActivityState, +} from '../primitives/my-activity'; import { ActivityTimelineRow } from './activity-timeline-row'; +/** Pixels of rows virtua keeps mounted beyond the viewport on each side. */ +const FEED_BUFFER_PX = 400; + function OverviewInset(props: { children: JSX.Element }) { return (
@@ -33,114 +51,63 @@ function FeedStatus(props: { children: JSX.Element }) { } /** - * The user's own activity, newest first. Reads `ActivityContext`; - * the host decides what a row click opens. + * The user's own activity, newest first, as one virtualized list with the + * overview card as its first row. Scrolling near the end fetches the next + * page. Reads `ActivityContext`; the host decides what a row click opens. */ export function MyActivityView(props: { onOpen: (target: OpenEntityTarget) => void; }) { const context = useActivityContext(); const state = createMyActivityState(context); - const overview = () => { - const current = state.overview(); - return current.t === 'ready' ? current.overview : undefined; - }; - const feed = () => { - const current = state.feed(); - return current.t === 'ready' ? current : undefined; + const [scroller, setScroller] = createSignal(); + let handle: VirtualizerHandle | undefined; + + const fetchMoreIfNearEnd = (offset: number) => { + if (!handle) return; + if ( + shouldFetchMore({ + scrollSize: handle.scrollSize, + viewportSize: handle.viewportSize, + offset, + }) + ) { + state.loadMore(); + } }; + // A page that does not fill the viewport never scrolls, so re-check once + // virtua has laid out the new rows. + createEffect( + on(state.rows, () => { + const frame = requestAnimationFrame(() => + fetchMoreIfNearEnd(handle?.scrollOffset ?? 0) + ); + onCleanup(() => cancelAnimationFrame(frame)); + }) + ); + return (
Activity -
+
- - - } - > -

- Activity overview is unavailable right now. -

-
- - } + { + handle = next; + }} + bufferSize={FEED_BUFFER_PX} + onScroll={fetchMoreIfNearEnd} > - {(overview) => ( - - - 0}> - - - {(entity) => ( - - )} - - - - + {(row) => ( + )} -
- - - {(feed) => ( - <> - - {(group) => ( - <> - {group.label} - - {(event) => ( - - )} - - - )} - - -
- -
-
- - )} -
- - Loading… - - - - Activity is unavailable right now. Try again in a moment. - - - - No activity yet. - -
+
@@ -148,6 +115,116 @@ export function MyActivityView(props: { ); } +function FeedRowView(props: { + row: FeedRow; + state: MyActivityState; + onOpen: (target: OpenEntityTarget) => void; +}) { + return match(props.row) + .with({ kind: 'overview' }, () => ( + + )) + .with({ kind: 'day' }, (row) => ( + {row.label} + )) + .with({ kind: 'event' }, (row) => ( + + )) + .with({ kind: 'status', status: 'loading' }, () => ( + Loading… + )) + .with({ kind: 'status', status: 'error' }, () => ( + + Activity is unavailable right now. Try again in a moment. + + )) + .with({ kind: 'status', status: 'empty' }, () => ( + No activity yet. + )) + .with({ kind: 'tail' }, () => ) + .exhaustive(); +} + +function FeedTail(props: { state: MyActivityState }) { + const ready = () => { + const feed = props.state.feed(); + return feed.t === 'ready' ? feed : undefined; + }; + return ( +
+ Loading… + + Couldn't load more. + + +
+ ); +} + +function OverviewRow(props: { + state: MyActivityState; + onOpen: (target: OpenEntityTarget) => void; +}) { + const overview = () => { + const current = props.state.overview(); + return current.t === 'ready' ? current.overview : undefined; + }; + return ( + + + } + > +

+ Activity overview is unavailable right now. +

+
+ + } + > + {(overview) => ( + + + 0}> + + + {(entity) => ( + + )} + + + + + )} +
+ ); +} + +// Rows mount as the user scrolls, and a row whose entity preview is still a +// cold query suspends the nearest boundary. Each row carries its own so the +// pane boundary never re-inserts the scroller, which would reset its scroll +// position to the top. function NamedActivityRow(props: { event: ActivityEvent; onOpen: (target: OpenEntityTarget) => void; @@ -155,17 +232,34 @@ function NamedActivityRow(props: { const context = useActivityContext(); const name = createActorName(context, () => props.event.actorId); return ( - + + } + > + + ); } function OpenableTopEntityChip(props: { entity: ActivityTopEntity; onOpen: (target: OpenEntityTarget) => void; +}) { + return ( + }> + + + ); +} + +function ResolvedTopEntityChip(props: { + entity: ActivityTopEntity; + onOpen: (target: OpenEntityTarget) => void; }) { const context = useActivityContext(); const opener = createEntityOpener( diff --git a/docs/AGENT_GUIDE/surfaces.md b/docs/AGENT_GUIDE/surfaces.md index e122cdf7fa..b77bc6c12b 100644 --- a/docs/AGENT_GUIDE/surfaces.md +++ b/docs/AGENT_GUIDE/surfaces.md @@ -107,7 +107,11 @@ GitHub-style actions heatmap (one a11y node per day — makes snapshots huge; pr snapshot to a file), then a `Most active` section header (styled like the feed's day headers) over a wrapping row of pill chips (entity icon, name, action count; click opens the entity, shift-click opens a new split; the section is absent when there are no entities), then a feed -of "You edited/created X" entries. +of "You edited/created X" entries grouped under day headers. The whole page is one virtualized +list: only rows near the viewport are in the DOM, and scrolling near the bottom fetches the +next page automatically (a `Loading…` tail appears while it lands). If a page fails, the tail +reads `Couldn't load more.` with a `Retry` button and automatic paging stops until it is +pressed. There is no `Show more` button. ## Home — `/app/component/home`