Forte Activity Feed Step 4 Implementation Summary

Stage 1 - Backend route, controller, and server-side view/selection resolution

  • Changes:

    • src/ForumRewrite/Application.php: new route /forte/activity/ (and slash-less variant) dispatches to a new renderForteActivity(string $requestedView, string $requestedSelected). It calls fetchActivity($view) once per one of the 5 views (all/content/identity/bootstrap/approval - 5 calls total, each the same cheap indexed/LIMIT-ed query classic's own renderActivity() already makes for whichever single view it's showing) and merges the results into one deduplicated-by-id pool, recording per item which view(s) it appeared in (view_all/view_content/view_identity/view_bootstrap/view_approval booleans) and a forte_link (see Stage 2). ?view= resolves via the existing normalizeActivityView() (already used by classic, falls back to all); ?selected= resolves against the resolved view's own item-id list, defaulting to that list's first (most recent) entry - both resolve-with-safe-fallback, matching resolveForteBoardTag()/resolveForteBoardSelection()'s style. New template templates/pages/forte_activity.php renders via renderStandalonePage(..., 'paned-reader-body', ['/assets/paned_activity_reader.js'], ['/assets/forte.css']).

    • Deviation from the plan's stated design (caught during verification, not before writing code): the plan's Stage 1 called for a single fetchActivity('all') superset fetch with view membership re-derived from the same predicates. That was implemented first and found wrong by direct comparison against classic: a filtered view's own LIMIT 100 window can reach further back in time than the unfiltered all window once enough non-matching rows crowd out its most recent items, so deriving membership from one all fetch undercounted every filtered view (e.g. "content" showed 56 items against classic's real 100). Switched to the 5-separate-calls design described above, which reproduces classic's counts exactly (see Verification). This still adds zero new SQL - fetchActivity()/activityViewSql() are called, not modified.

  • Verification:

    • php -l: clean.

    • Raw HTTP against a live seeded instance: /forte/activity/ returns 200, all 5 filter counts render (100/100/100/100/100 on this dataset).

    • Cross-checked against classic directly: curl /activity/?view={all,content,identity,bootstrap,approval} each show exactly 100 <article class="card"> cards - matching the new page's filter-pane counts exactly, confirming true per-view parity (not the earlier, incorrect 100/56/... split from the superset approach).

    • ?view=bogus falls back to all (confirmed via filter-pane selection state); ?view=approval&selected=<id-not-in-approval> falls back to approval's own first item rather than erroring or showing an empty detail pane.

    • Full test suite: 399 passing / 9 failing, identical failing-test set to a stashed baseline run of unmodified main (5 pre-existing BrowserSigningNormalizationTest Node-helper failures + 4 pre-existing LocalAppSmokeTest failures, none touching Forte/Activity code) - no regressions.

  • Notes: a genuine, expected consequence of reproducing classic's per-view LIMIT windows exactly: view_bootstrap/view_approval are not strict subsets of view_identity at the flag level (an item can satisfy the bootstrap predicate and rank in bootstrap's own top 100 while ranking just outside identity's own, less-restrictive top-100 cutoff). This is correct - it's the same thing that would happen if you literally opened classic's own /activity/?view=identity vs ?view=bootstrap side by side - not a bug in the merge logic.

Stage 2 - Left filter pane, list pane, and detail pane markup

  • Changes:

    • New templates/partials/paned_activity_filter_list.php: a sibling to paned_folder_tree.php (not a modification), reusing its exact CSS classes (.paned-folder-tree, .paned-folder-item, .paned-folder-item--selected, .paned-folder-count) and ARIA/roving-tabindex markup pattern, keyed on data-paned-activity-view instead of data-paned-folder.

    • New templates/partials/paned_activity_item_list.php: reuses .paned-list-pane/.paned-list-head/.paned-list-row verbatim. Deliberately reuses the existing .paned-list-from/.paned-list-subject/.paned-list-date column classes (fixed 9rem / flex-fill / fixed 13rem) for a 3-column Kind/Label/Date layout instead of introducing new CSS - zero new CSS was needed for Stage 2 or 3. Each row carries data-paned-activity-id and the 5 data-paned-activity-view-*="0"/"1" flags from Stage 1.

    • New templates/partials/paned_activity_detail_pane.php: reuses .paned-content-pane/.paned-content-post/.paned-content-head structure and calls the existing partials/source_metadata.php verbatim with the same field set activity.php already passes it (source path/href, commit sha/href truncated to 12 chars or "commit unavailable", signature path/href/status), plus kind/label/created_at/the "Author: reply-agent" badge, plus the item's forte_link (see below).

    • activityItemBoardLink() (Application.php) maps each item's Forte-native destination. Refinement found during headless-browser verification, not anticipated by the plan: the plan said "items whose classic destination is a board post/thread" get the Forte permalink and "other kinds" keep classic's link - read literally by kind, this sent identity/bootstrap/approval-only items (e.g. identity_bootstrap) to /forte?selected=<id>&created_post_id=<id>, but those posts are excluded from the public board by the exact same predicate the "content" view itself uses, so the permalink silently resolved to "no thread selected" - a dead end, confirmed with a headless click-through. Fixed by keying the decision on board visibility (!isHiddenBootstrapBoardTagsJson(...), the same predicate fetchActivity()'s own content filter already uses) instead of kind: only board-visible items get the Forte permalink; everything else (identity/bootstrap/approval-only items, plus thread_label_add/site_feature_flag) keeps classic's real /posts/{id}, /threads/{id}, or /tools/feature-flags/ destination.

  • Verification:

    • php -l: clean.

    • Raw HTML check: a content-visible reply's detail pane renders <a href="/forte?selected=thread-...&created_post_id=reply-...#post-reply-...">; an identity-bootstrap item (not board-visible) renders <a href="/posts/bootstrap-..."> instead - confirmed both paths render the intended link shape for their respective category.

    • curl "/forte?selected=<thread>&created_post_id=<reply>" directly: the target thread row carries paned-list-row--selected and the target reply's data-paned-reply-post-id node is present - confirms the Forte-permalink items truly resolve server-side (not just constructed-looking URLs).

    • curl "/posts/bootstrap-..." returns 200 in classic - confirms the fallback destination for non-board-visible items is real, not a guess.

    • Full test suite re-run after this stage: 399/9, same set as Stage 1 - no regressions.

  • Notes: none beyond the two deviations already called out above.

Stage 3 - Toolbar entry point and way back

  • Changes:

    • templates/pages/forte_board.php: added an "Activity" toolbar button immediately after "Users", exact same markup pattern as commit e0eb3b7's "Users" button (separator span + <a class="paned-toolbar-btn"> + inline 16x16 stroke-only currentColor SVG + <span> label), linking to /forte/activity/. New icon is a simple clock glyph (circle + hands) to read distinctly from Users' person-pair icon.

    • templates/pages/forte_activity.php: toolbar's first button is <a href="/forte" class="paned-toolbar-btn"> with a back-arrow icon and "Board" label (a working equivalent of forte_users.php's <p class="paned-standalone-back"> link, adapted to toolbar-button form since this page uses the full three-pane chrome, not forte_users.php's standalone-dialog chrome). A disabled "Refresh" button (matching the board's own disabled placeholder) fills out the toolbar.

  • Verification:

    • php -l: clean.

    • Raw HTML check: /forte now contains the new "Activity" button pointing at /forte/activity/; /forte/activity/ contains a "Board" button pointing at /forte.

    • Headless click-through (Stage 4/5 tooling, reused here): loading /forte/activity/ and clicking "Board" navigates back to /forte with no console errors.

    • Full test suite re-run: 399/9, same set - no regressions.

  • Notes: none.

Stage 4 - Client-side filter switching, item selection, and keyboard nav

  • Changes:

    • New public/assets/paned_activity_reader.js (sibling to paned_board_reader.js, that file untouched). Mirrors its structure: selectFilter(view) toggles .paned-folder-item--selected/aria-selected/tabindex on the filter items and shows/hides list rows via each row's data-paned-activity-view-{view} flag (no network request); selectItem(itemId) toggles the matching list row and the matching detail-pane article's hidden attribute; both the filter list and item list get ArrowUp/ArrowDown roving-tabindex keyboard handlers, directly modeled on paned_board_reader.js's folder-tree/list-body keydown handlers (index math, .closest() for the click delegate, hidden-row skipping).

  • Verification (headless Chromium via puppeteer-core driving the already-installed system chromium-browser binary, in a throwaway scratch dir - no dependency added to the project):

    • Clicking the "Approvals" filter updates the visible row set and the URL (?view=approval&selected=<first-approval-item>) with zero network requests (confirmed by the URL changing via pushState semantics without a networkidle-triggering navigation).

    • Clicking a list row updates the detail pane to that exact item id (matches: true in the check) and the URL's selected=.

    • ArrowDown from a focused, selected row moves focus, selection, and the URL to the next visible row.

    • Browser back navigation correctly restores the prior filter+selection state.

    • Zero console/page errors across the full interaction sequence.

  • Notes: ArrowDown from the last filter item correctly does nothing (clamped, matching the identical guard in paned_board_reader.js) - initially looked like a bug in a test run but is the same intentional boundary-clamping behavior already proven in the board reader.

  • Changes:

    • paned_activity_reader.js: urlForState(view, itemId) builds /forte/activity/?view=&selected= (omitting view when all, mirroring how the board reader omits tag when empty); pushStateIfChanged() avoids duplicate history entries; a popstate listener re-runs the same restoreSelectionFromUrl() used on initial load, so back/forward, a direct reload, and a shared link all converge on the same code path as a normal click.

  • Verification:

    • Direct load of /forte/activity/?view=identity (no selected=) server-renders with "Identity" pre-selected in the filter pane and a real row pre-selected in the detail pane (Stage 1's server-side default-selection resolver) - confirmed via headless check (hasSelectedRow: true) and matches the plan's requirement that a shared/reloaded URL reproduce state without relying on client JS to "correct" it after the fact.

    • Full click-through end-to-end: on /forte/activity/?view=content, clicked a content item's Forte-permalink board link and landed on /forte?selected=thread-...&created_post_id=reply-... with that exact thread row selected (selectedThreadId matches) and the exact reply carrying .paned-highlight-new (hasHighlight: true, highlightedPostId matches) - the existing, unmodified permalink/highlight mechanism works unchanged for links originating from the new Activity page.

    • Full test suite re-run (final): 399 passing / 9 failing, identical failing-test set to the pre-change baseline - no regressions from any stage.

  • Notes: none.