Skip to content

Recent Activity feed - #75

Open
a-effort wants to merge 3 commits into
mainfrom
feat/activity-view
Open

Recent Activity feed#75
a-effort wants to merge 3 commits into
mainfrom
feat/activity-view

Conversation

@a-effort

@a-effort a-effort commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Builds ActivityView, the Recent Activity feed. ?view=activity previously fell through to the "coming soon" placeholder: the earlier spike shipped the plumbing (types, api client, hook, fixture, MSW handler) but never the list itself.
activity feed

Dependencies (merged)

#74 (BFF /api prefix) and #69 (the enabled option this hook needs) have merged. This branch is rebased onto main, so d967052 is gone from the diff and the view loads real data without any branch juggling. Both of this PR's own commits are unchanged by the rebase (identical patch-ids).

Included

  • activityStatus.ts holds the single status to {icon, tone} map, using the set ui/sonner.tsx already ships.
  • The feed is requested at limit: 100 rather than the hook's default of 10, because search filters the fetched window client-side.
  • No self-gating. HOME_STATES.activity already declares requiredPermission: "audit:read", so the page renders the skeleton while permissions load and PermissionDenied when the caller lacks it.
  • useRecentActivity now keeps the original error instead of flattening it to { message }. ApiError carries the status and isPermissionDenied needs the instance, so a 403 that slips past the page gate (stale or coarser client permissions, team-switch race) renders as denied rather than as a generic failure.

Notes
_audit_to_activity tests row.action in ("read", "execute"), but no write site emits either string, so read-type rows (view_server, view_prompt) render as success. The same mismatch mangles titles: 30 of the gateway's 31 audit write sites miss _ACTION_VERBS, so a deactivate renders as "Tool set tool state" and a delete as "Server delete server". Filed against IBM/mcp-context-forge#6342 (see comment for the full enumeration).

Both are server-side. title, description and status are server-rendered by contract and the UI must not re-derive them, so nothing here changes until #6342 lands. The awkward wording in a live feed will be addressed by that work.

Set AUDIT_TRAIL_ENABLED=true in the .env file when testing (off by default).

Tests

10 new tests in ActivityView.test.tsx covering rows, relative timestamps, the screen-reader status label, tab counts, severity filtering, search, empty-feed versus empty-filter, the 403 path, the non-403 path and the loading skeleton.

Full suite on the rebased branch: 3230 passed, 1 skipped, 198 files. tsc, eslint and the production build are clean.

@a-effort a-effort changed the title feat: build the Recent Activity feed view Recent Activity feed Aug 22, 2026
?view=activity fell through to the "coming soon" placeholder; the spike
had shipped the plumbing (types, api client, hook, fixture, MSW handler)
but never the list itself. Adds ActivityView and wires it into the
Dashboard main-content switch.

- activityStatus.ts holds the one status -> {icon, tone} map, using the
  set ui/sonner.tsx already ships. Keeping it in a single record is what
  makes the app-wide token rollout (#62) a one-file change here. `info`
  is unaccented, matching sonner, so high-volume read/execute rows
  recede while errors and warnings carry.
- Filter tabs count `error`/`warning` the same way the mini cards do, so
  the two can't disagree. `info` gets no tab of its own.
- The feed is requested at limit 100, not the hook's default of 10:
  search filters the fetched window client-side, so a 10-row window
  would make it near-useless.
- No self-gating. HOME_STATES.activity already declares
  requiredPermission: "audit:read", so the page renders the skeleton
  while permissions load and PermissionDenied when the caller lacks it.

useRecentActivity now keeps the original error instead of flattening it
to { message }. ApiError carries the status, and isPermissionDenied
needs the instance, so a 403 that slips past the page gate (stale or
coarser client permissions, team-switch race) can render as denied
rather than as a generic failure.

Signed-off-by: Anna Effort <anna.effort@ibm.com>
Measured against frame 4979-39243.

- Text style updates.
- Adds a `text-xxs` theme token (10px/16) for the step below Tailwind's
  built-in scale, matching Figma's own `text-xxs`.
- Wraps the feed in the single bordered panel the design shows, with a
  divider under the tab row, replacing the loose filters + ringed list.
- Drops the count from "All activity" and lets the remaining counts
  inherit their label colour rather than rendering muted.
- TabsTrigger has no display utility, so the `gap-*` between label and
  count was inert; set inline-flex here.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

@marekdano marekdano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking

1. Tab count renders with no space — "Errors2", not "Errors 2"

In ActivityFilters.tsx:

<TabsTrigger >
  {intl.formatMessage({ id: FILTER_LABEL[id] })}
  {id !== "all" && <span>{counts[id]}</span>}
</TabsTrigger>

These two expressions are separated only by a newline in JSX, which gets stripped — there's no literal text node between them. The rendered/announced text is literally "Errors2", not "Errors 2". The gap-1.5 flex gutter creates a visual gap, but the actual DOM text (and the accessible name a screen reader announces) has none.

The test doesn't catch this because within(...).getByText("2") matches the count <span> independently of what's next to it.

Fix: add an explicit space, e.g.:

{intl.formatMessage({ id: FILTER_LABEL[id] })}{" "}
{id !== "all" && <span>{counts[id]}</span>}

🟡 Functionally impacting

2. A transient poll failure discards an already-loaded feed

In ActivityView.tsx:

if (isLoading) return <Skeleton .../>;
if (error) {
  return isPermissionDenied(error) ? <PermissionDenied /> : <EmptyStatePlaceholder .../>;
}
if (items.length === 0) return <EmptyStatePlaceholder .../>;

error is checked before items.length, so once the feed has successfully loaded, a later 30s poll tick that fails (network blip, transient 5xx) replaces the whole populated list with the generic error placeholder — even though items still holds the last-good data.

Worth aligning with the sibling card in this same area, McpHealthCard.tsx, which handles exactly this case and documents the reasoning:

"Only surface the error card when there is no roster to show. Once servers have loaded, a transient (non-403) refetch failure keeps the last-known roster on screen rather than replacing it with an error."

Suggest gating the non-403 branch on !items.length (a 403 should still take over immediately — losing permission mid-session shouldn't keep showing stale data, same reasoning McpHealthCard uses). Might also be worth a test for "error arrives after items are already loaded."

3. isPermissionDenied's doc comment is stale for this new caller

PermissionDenied.tsx says:

"useQuery sanitizes thrown ApiErrors into plain { message, status, ... } objects … so an instanceof test would never match a query error"

That's true for useQuery-based callers, but useRecentActivity is a hand-rolled hook that now stores the live ApiError instance directly (post-PR). The structural check ("status" in err && err.status === 403) still works correctly here since ApiError exposes status — this isn't a functional bug — but the comment now reads as if an instanceof check would be wrong in general, which could mislead the next person touching this. Worth a one-line note in ActivityView (or an update to the shared comment) clarifying that the structural check is intentionally shape-agnostic across both callers.


🔵 Suggestions / minor

4. Search isn't debounced

Every keystroke re-filters via useMemo. At the documented cap of 100 items this is cheap, so not a real perf concern, but other search boxes in the app debounce — could be a useDeferredValue wrap for consistency, low priority.

5. Dead PLACEHOLDER_MESSAGE.activity entry in Dashboard.tsx

const PLACEHOLDER_MESSAGE: Record<HomeViewId, string> = {
  default: "dashboard.home.emptyState",
  activity: "dashboard.home.emptyState", // ← unreachable
  

if (active === "activity") return <ActivityView />; above MainContent means this key is never read. Worth removing (and narrowing the Record type) so a future reader doesn't think it's live.

6. ActivityRow renders item.title/item.description as plain text — worth a one-line comment

These are server-rendered strings passed straight through JSX, so React's escaping covers it — this is safe as-is. A short comment noting why no sanitization is needed would save a future reviewer from flagging it again.


✅ What's well done

  • Defense-in-depth 403 handling: page-level gate and a runtime check for the stale-permission/team-switch race, each covered by its own test.
  • No raw server error text ever reaches the DOM (verified by the psycopg2.OperationalError test).
  • aria-hidden icon + sr-only status text pattern in ActivityRow is correct and consistent with the rest of the dashboard.
  • Tab counts are derived from items, not the filtered/searched result, so they stay stable while typing — a good, clearly-documented UX call.
  • i18n parity across en-US, es-ES, pt-BR in the same commit.

- The tab label and count were separated only by a JSX newline, so the
  rendered text was "Errors2". The gap-1.5 gutter made it look right.
- A failed poll replaced an already-loaded feed with the error
  placeholder. The hook leaves items untouched on failure, so the last
  good feed now stays up and the next tick recovers. A 403 still takes
  over immediately.
- Drop the unreachable activity key from PLACEHOLDER_MESSAGE; the view
  returns above the lookup.

The tab-count test asserted the count span in isolation and could not
see the missing space. The relative-timestamp test pinned an absolute
date against the real clock and started failing once it aged past "ago".

Signed-off-by: Anna Effort <anna.effort@ibm.com>
@a-effort

Copy link
Copy Markdown
Contributor Author

Warning & info type examples:
non-success

@a-effort

a-effort commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, this was a good catch list. Pushed 8246f7a.

Fixed:

  1. "Errors2": right, the newline is stripped and there's no text node between the two expressions. Added {" "}. Also rewrote the assertion: within(...).getByText("2") matched the count span in isolation, so it could never see this. It's now toHaveTextContent("Errors 2"), which does fail on the unspaced version. One note on the diagnosis: browser accname implementations generally do insert a separator at element boundaries, so the announced name was probably already "Errors 2". The DOM text content was definitely unseparated though, and the fix costs nothing.

  2. Transient poll failure discarding the feed: confirmed and fixed. useRecentActivity only calls setItems on success, so items really was holding last-good data that the error branch was throwing away. Now isPermissionDenied short-circuits first and every other error only renders when items.length === 0. Two tests added: a 500 leaves the loaded rows up, a 403 still takes over a populated list.

  3. Dead PLACEHOLDER_MESSAGE.activity: removed, and narrowed to Record<Exclude<HomeViewId, "activity">, string>. Worth noting mcp and system are dead for the same reason; I left them because removing them orphans locale copy in three files, which felt like a separate change.

Also, unrelated to the review: the "renders relative timestamps" test was failing on this branch before any of the above. It pinned 2026-08-21T12:00:00Z against the real clock, so once the date aged past a week formatLastSeen returned "last week" and the /ago|now/ assertion failed. It's now derived from Date.now(). The suite is 3232 passing.

-->

Reasoning why updates to these things may not be needed:

  • Stale isPermissionDenied comment: I read it as still accurate. It explains why the check is structural rather than instanceof, and already ends with "Matches both shapes," which covers the live ApiError case.

  • Debouncing search: there's actually a shared useLocalSearch (debounced 300ms) that the other lists use, so the consistency point is stronger than stated. But it takes a single getText and this view searches four fields, and adopting it means fake timers across six tests for no measurable gain at 100 items.

  • Comment on item.title passthrough: we keep rationale in file-header docs rather than inline, and the ActivityRow header already covers that title/description are server-rendered and not re-derived.

@gcgoncalves gcgoncalves left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid PR, just raised a few performance/A11y improvement suggestions.

const intl = useIntl();
const { items, isLoading, error } = useRecentActivity({ limit: ACTIVITY_FEED_LIMIT });
const [filter, setFilter] = useState<ActivityFilter>("all");
const [search, setSearch] = useState("");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: No maximum length constraint on search input.

const [search, setSearch] = useState("");  // ❌ Unbounded

Risk: While unlikely to be exploited, extremely long search strings could cause performance degradation or memory issues.

Recommendation:

// ✅ Add reasonable limit
const MAX_SEARCH_LENGTH = 200;

<ListSearch
  value={search}
  onChange={(value) => setSearch(value.slice(0, MAX_SEARCH_LENGTH))}
  // ...
/>

Comment on lines +96 to +100
{visible.length === 0 ? (
<div className="border-t border-border px-4 py-8 text-sm text-muted-foreground">
{intl.formatMessage({ id: "dashboard.home.activity.noMatches" })}
</div>
) : (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Empty filter results are not announced to screen readers when filters change.

// ❌ No live region for dynamic content
<div className="border-t border-border px-4 py-8 text-sm text-muted-foreground">
  {intl.formatMessage({ id: "dashboard.home.activity.noMatches" })}
</div>

Impact: Screen reader users won't be notified when their filter/search produces no results.

Recommendation:

// ✅ Add live region
<div 
  className="border-t border-border px-4 py-8 text-sm text-muted-foreground"
  role="status"
  aria-live="polite"
>
  {intl.formatMessage({ id: "dashboard.home.activity.noMatches" })}
</div>

Comment on lines +50 to +57
<TabsTrigger
key={id}
value={id}
className="inline-flex items-center gap-1.5 text-xs font-medium"
>
{intl.formatMessage({ id: FILTER_LABEL[id] })}{" "}
{id !== "all" && <span>{counts[id]}</span>}
</TabsTrigger>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Tab counts are not explicitly associated with their labels for screen readers.

// ❌ Count is separate text node
{intl.formatMessage({ id: FILTER_LABEL[id] })}{" "}
{id !== "all" && <span>{counts[id]}</span>}

Recommendation:

// ✅ Use aria-label for complete context
<TabsTrigger
  key={id}
  value={id}
  aria-label={`${intl.formatMessage({ id: FILTER_LABEL[id] })}${
    id !== "all" ? ` (${counts[id]})` : ""
  }`}
  className="inline-flex items-center gap-1.5 text-xs font-medium"
>
  {intl.formatMessage({ id: FILTER_LABEL[id] })}{" "}
  {id !== "all" && <span aria-hidden="true">{counts[id]}</span>}
</TabsTrigger>

const intl = useIntl();
const { items, isLoading, error } = useRecentActivity({ limit: ACTIVITY_FEED_LIMIT });
const [filter, setFilter] = useState<ActivityFilter>("all");
const [search, setSearch] = useState("");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Search triggers re-filtering on every keystroke without debouncing.

// ❌ No debouncing
const [search, setSearch] = useState("");

Impact: For large feeds (100 items), rapid typing could cause unnecessary re-renders.

Recommendation:

// ✅ Add debouncing
import { useDebouncedValue } from "@/hooks/useDebouncedValue";

const [searchInput, setSearchInput] = useState("");
const search = useDebouncedValue(searchInput, 300);

// Use searchInput for controlled input, search for filtering


return (
<div className="flex items-center justify-between gap-3">
<Tabs value={filter} onValueChange={(value) => onFilterChange(value as ActivityFilter)}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Inline arrow function creates new reference on every render.

// ❌ New function on every render
onValueChange={(value) => onFilterChange(value as ActivityFilter)}

Impact: Minor - TabsList likely doesn't memo its children, but it's a best practice.

Recommendation:

// ✅ Memoize callback
const handleFilterChange = useCallback(
  (value: string) => onFilterChange(value as ActivityFilter),
  [onFilterChange]
);

<Tabs value={filter} onValueChange={handleFilterChange}>

@marekdano marekdano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functionally-impacting

  1. src/components/dashboard/ActivityRow.tsx:23ACTIVITY_STATUS_STYLE[item.status] has no fallback for an unrecognized status. Since the backend endpoint is still unimplemented and api.get() does a raw cast with no runtime validation, any status string outside success/info/warning/error throws during render and crashes the panel.

  2. src/hooks/useRecentActivity.ts:91 — the 30s-interval poll call omits the AbortSignal that the initial fetch gets (line 84), so cleanup's controller.abort() doesn't cancel it. A poll racing a mount/unmount cycle can resolve late and clobber a fresh instance's state.

  3. src/components/dashboard/ActivityFilters.tsx:6 — doc comment claims tab counts and mini-card counts "never disagree," but they read different windows/cadences (useMiniCardStatuses uses limit: 10, one-shot; ActivityView uses limit: 100, 30s poll). Counts will visibly diverge once there are >10 errors/warnings.

  4. src/components/dashboard/ActivityView.tsx:47 — mounts a second, independent useRecentActivity fetch on top of the one useMiniCardStatuses already keeps running at the Dashboard level — two uncoordinated GETs to the same endpoint with no shared cache.

Suggestions

  1. src/components/dashboard/ActivityView.tsx:39 — hand-rolled matchesSearch reimplements the existing useLocalSearch hook, but without its 300ms debounce — every keystroke re-filters up to 100 rows synchronously.

  2. src/components/dashboard/activityStatus.ts:36ACTIVITY_STATUS_STYLE duplicates the same 4 tone colors already defined in StatusDot.tsx's TONE_CLASS, independently — a future rebrand won't propagate here.

Minor

  1. src/components/dashboard/ActivityRow.tsx:34 — when formatLastSeen returns null, the time indicator is silently omitted with no fallback (other call sites in the codebase use an explicit "Not available" label).

  2. src/components/dashboard/ActivityFilters.tsx:18ACTIVITY_FILTERS and FILTER_LABEL are two separate declarations of the same filter set that must be kept in sync by hand.

  3. src/pages/Dashboard.tsx:243 — the new Exclude<HomeViewId, "activity"> narrowing is inconsistent (mcp/system are equally unreachable via the map but weren't excluded), and the added comment overstates what the map actually guarantees.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants