Activity Feed
A record's history, Linear style — comment threads and change events in one list, one actor's runs on one line, a filter and J/K navigation.
- Status
- Since
0.23.123- Accessibility pattern
- labelled section, ordered list, J/K keyboard navigation
Last updated
Activity 2 comments
created the task
set priority to High; set the due date to Oct 7; attached scope-v2.pdf
- KN
ARassigned Kavya Nair
changed status from Todo to In progress
- New
Agent moved the due date from Oct 7 to Oct 9
- Agent
AR
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Activity Feed before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify activity-feedInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/activity-feedRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/person-avatar, @vegastack/person-hover-card, @vegastack/relative-time, @vegastack/searchable-select, @vegastack/select, @vegastack/skeleton.
Usage
import {
ActivityFeed,
ActivityFeedItem,
ActivityFeedList,
} from "@/components/ui/activity-feed";
<ActivityFeed>
<ActivityFeedList>
<ActivityFeedItem>{event}</ActivityFeedItem>
</ActivityFeedList>
</ActivityFeed>;Scope
- Owns the activity list, event presentation, filtering and order controls, and keyboard navigation.
- The host owns data fetching, merging, paging, filter/order state, and read acknowledgements.
- Compose with
CommentThread,CommentComposer, andCommentDayDividerfromcomments.
Anatomy
Examples
Comments and events
import {
ActivityEvent,
ActivityFeed,
ActivityFeedItem,
ActivityFeedList,
ActivityJumpToLatest,
ActivityKindIcon,
ActivityUnreadDivider,
ActivityValue,
useActivityFeedKeyboard,
} from "@/components/ui/activity-feed";
import { CommentComposer, CommentThread } from "@/components/ui/comments";
const feedRef = useActivityFeedKeyboard();
<ActivityFeed
ref={feedRef}
count={threads.length}
filter={filter}
onFilterChange={setFilter}
order={order}
onOrderChange={setOrder}
>
<ActivityFeedList>
{items.map((item) =>
item.type === "event" ? (
<ActivityFeedItem key={item.id}>
<ActivityEvent
actor={item.actor}
icon={<ActivityKindIcon kind={item.kind} />}
date={item.at}
>
changed status to <ActivityValue>{item.to}</ActivityValue>
</ActivityEvent>
</ActivityFeedItem>
) : (
<ActivityFeedItem key={item.id} kind="thread">
<CommentThread thread={item.thread} viewer={me} onReply={reply} />
</ActivityFeedItem>
),
)}
</ActivityFeedList>
<CommentComposer onSubmit={post} />
<ActivityJumpToLatest target={latestBoundary} />
</ActivityFeed>;The parts are presentational and data-agnostic: the host merges its comment threads and its
change events into one list, applies the filter and the order, and pages them. Events sit 4px
apart; a thread card or a divider keeps 12px around it. Each event leads with a 20px node in the
same column as the comments' avatars — an ActivityKindIcon, the new value's StatusIcon or
PriorityIcon, or the actor's avatar (icon="avatar") — then the actor, whose name opens their
person hover card, the sentence with its ActivityValues, and the relative time.
Put CommentDayDivider (from comments) between items on different days, and one
ActivityUnreadDivider before the first item the viewer hasn't seen; its onVisible fires once,
when the line is half on screen. The host chooses whether observing that line acknowledges anything.
ActivityJumpToLatest takes the host's latest-content boundary. It observes that element in its scrolling
container, points toward it, and transfers focus after reaching it. It floats only while that boundary is off
screen and the reader is scrolling, and fades about 1.5 seconds after they settle; hovering or focusing it holds
it in place. forceVisible keeps a catch-up action
available for content already on screen, and turning it on surfaces the control until the reader settles; capture a batch in onJump and acknowledge it in onReached.
Replacing the target or interrupting with a gesture cancels an in-progress completion and calls
onCancel, allowing the host to release its jump state. The control contains no read-state or data logic.
The latest items are last in oldest-first order and first in newest-first order. The arrow points
toward that boundary. Set direction="up" for newest-first or direction="down" for oldest-first
when an on-screen batch keeps the action visible. An agent event says "Agent" after the actor's name.
Activity 2 comments
created the task
set priority to High; set the due date to Oct 7; attached scope-v2.pdf
- PS
- KN
ARassigned Kavya Nair
changed status from Todo to In progress
- New
Agent moved the due date from Oct 7 to Oct 9
- Agent
AR
Event kinds
ActivityKindIcon draws a 14px muted icon for twenty-eight common kinds; approved, rejected,
requested and the agent-run kinds keep their status colour.
created the task
edited the description
renamed the task to Phase one: data copy
marked the task Done
set priority to Medium
assigned Kavya Nair
unassigned Kavya Nair
set the due date to Oct 9
moved the task to Migrations
attached scope-v2.pdf
removed scope-v1.pdf
linked INF-212
unlinked INF-198
commented
archived the task
restored the task
deleted the subtask Draft runbook
approved the request
rejected the request
requested approval from Asha Rao
Status and priority values
A status or priority change leads with the new value's own icon. Pass label="" — the sentence
already names the value.
changed status to Backlog
changed status to Todo
changed status to In progress
changed status to In review
changed status to Done
changed status to Canceled
changed status to Duplicate
changed status to Triage
set priority to Urgent
set priority to High
set priority to Medium
set priority to Low
set priority to No priority
Event groups
Pass pre-grouped events to ActivityEventGroup for one compact entry: the actor and latest
timestamp appear once, every change stays visible in chronological order, and the sentence wraps
under its text column. The host bounds a burst (two minutes, say), keeps actor, comment/reply,
calendar-day and unread boundaries, and groups before reversing the entry order. Server
comment-boundary markers let hidden replies separate edits too.
Supply each event's stable id so its position survives joining a group across cursor pages.
The children-based form remains available for explicitly folded rows (visible and defaultOpen).
renamed the task to Migrate billing data; edited the description; linked INF-212
renamed the task to Migrate billing data
edited the description
linked INF-212
Runs
groupActivityRuns(items, { actorKey, date, isBreak, breakBefore, runWindowMs }) splits a
chronological list into runs: one actor's consecutive events within runWindowMs (10 minutes by
default) of the run's first, ended by any isBreak item (a comment) or the host's own
breakBefore (a new day, the unread line). Render each run with ActivityEventGroup. Give events a
change — { field, verb?, from?, to } — and a run that changes one field reads as one chain:
"moved it Todo → In progress → In review". Set summarizeAt={3} and a run of three or more
different changes reads "made 3 changes" (or summarizeRun's words), a disclosure that lists them
under the line. Agents' bursts read as one line this way.
const runs = groupActivityRuns(items, {
actorKey: (item) => item.actorId,
date: (item) => item.createdAt,
isBreak: (item) => item.type === "comment",
});
{
runs.map((run) => (
<ActivityFeedItem key={run[0].id}>
<ActivityEventGroup events={run.map(toEventProps)} summarizeAt={3} />
</ActivityFeedItem>
));
}Agent moved it Todo to In progress to In review
- renamed the task to Migrate billing data
- edited the description
- linked INF-212
Cursor pagination
Pass loadMore={{ hasMore, onLoadMore, loading, error }} and a paginationKey containing
the filter, list generation and next cursor to ActivityFeed. The history edge loads automatically when it enters view: above the list
oldest first, below it newest first. Loading older items preserves the current visible change in both orders, including when you scroll during the request or the page joins an existing group.
While a page loads, skeleton event rows hold its place. Set loadMore.error after a failed read; automatic retries stop and a Retry button appears.
loadingMoreLabel and retryMoreLabel override the pagination copy. Keep an unread divider within the same timeline rather than paginating unread items separately.
Agents and the system
An actor (or comment author) with kind: "agent" and via — "Claude Code" working for Priya — is
drawn as the purple bot and named "Claude Code via Priya" with the "Agent" label; a comment gets a
violet bar. (The agent boolean on an event or a comment is the deprecated spelling of the same
and goes away next release.) An event with actor={null} is the system's. An actor's badge
(a string such as "Inactive", for someone deactivated or whose invite is pending) follows their name
as a small muted outline badge, as everywhere a person is named.
Agent triaged the task into Migrations
- Agent
changed status to In review
System marked the task overdue
Agent runs
delegated and undelegated mark a member handing a record to their agent and taking it back;
agent-queued, agent-working, agent-needs-you, agent-done and agent-failed are the agent's
run in its status tones (working is a static play mark — a history line never spins); agent-step is
one server-written step of the run.
delegated this to Claude Code (via Priya)
Agent picked this up
Agent started working
Agent read the task and 3 linked pages
Agent updated the description
Agent needs Priya
Agent finished
Agent stopped: hit a problem
removed Claude Code (via Priya)
Comments only
The filter's Comments option: the host shows the threads alone.
Activity 2 comments
- PS
- KN
AR- Agent
AR
Activity only
The filter's Activity option: the events alone.
Activity 2 comments
created the task
set priority to High; set the due date to Oct 7; attached scope-v2.pdf
assigned Kavya Nair
changed status from Todo to In progress
- New
Agent moved the due date from Oct 7 to Oct 9
Loading
ActivityFeedSkeleton holds the feed's shape — event rows, a thread card and the composer — while
it loads. Set pending on ActivityFeed instead while a new filter or order loads: the old items
stay, dimmed.
Activity 2 comments
Empty
A record with no history yet shows the header and the composer.
Activity
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
count | number | — | The number of comments, after the heading through countLabel; hidden at zero. |
countLabel | ((count: number) => string) | (n) => "1 comment" | "n comments" | The count's words, so the number says what it counts ("Activity · 2 comments"). |
filter | ActivityFilter | "all" | The filter shown; the filter menu shows when onFilterChange is set. |
loadingMoreLabel | string | "Loading earlier…" | Progress copy. |
loadMore | { hasMore: boolean; onLoadMore: () => Promise<void> | void; loading?: boolean; error?: boolean; } | — | Cursor pagination, using the collection paging contract. |
onFilterChange | ((filter: ActivityFilter) => void) | — | Called from the filter menu. |
onOrderChange | ((order: ActivityOrder) => void) | — | Called from the Oldest / Newest first toggle. |
order | ActivityOrder | "oldest" | The order shown; the toggle shows when onOrderChange is set. |
paginationKey | string | null | — | Filter, list generation and cursor identity; changes re-arm loading. |
pending | boolean | false | Dim the list while a new filter or order loads (the old items stay). |
retryMoreLabel | string | "Retry loading earlier" | Retry action copy. |
title | string | "Activity" | The heading (an h2). |
Data attributes and CSS variables on ActivityFeed
| Attribute | Values |
|---|---|
data-pending | "" |
data-slot | "activity-feed" | "activity-feed-count" | "activity-feed-filter" | "activity-feed-header" | "activity-feed-more" | "activity-feed-more-skeleton" | "activity-feed-order" |
ActivityFeedItem
| Prop | Type | Default | Description |
|---|---|---|---|
kind | "divider" | "event" | "thread" | "event" | What the row holds — its spacing follows: event rows sit 4px apart, a thread card or a
divider keeps 12px around it. |
Data attributes and CSS variables on ActivityFeedItem
| Attribute | Values |
|---|---|
data-kind | mirrors a prop or state value |
data-slot | "activity-feed-item" |
ActivityEvent
| Prop | Type | Default | Description |
|---|---|---|---|
actor* | Person | null | — | Who did it; null for the system. A badge on the person ("Inactive") follows the name. |
children* | React.ReactNode | — | What happened, after the actor's name — plain words with ActivityValues. |
date* | string | number | Date | — | When it happened. |
agent | boolean | false | Deprecated. |
change | ActivityChange | — | The field this event changed, for ActivityEventGroup's run summary; the event alone still
reads its children. |
className | string | — | Classes for the row. |
icon | React.ReactNode | a muted dot | The 20px node before the text — an ActivityKindIcon, a StatusIcon, a PriorityIcon.
"avatar" draws the actor's avatar (an assignee change, say). |
id | string | — | Stable event identity for preserving position across cursor-page joins. |
now | number | — | Pin the clock (docs, tests). |
Data attributes and CSS variables on ActivityEvent
| Attribute | Values |
|---|---|
data-activity-id | mirrors a prop or state value |
data-agent | "" |
data-slot | "activity-event" | "activity-event-icon" |
ActivityEventGroup
| Prop | Type | Default | Description |
|---|---|---|---|
changesLabel | ((count: number) => string) | (n) => "made n changes" | The folded line's words. |
children | React.ReactNode | — | Event rows, when not using events. |
className | string | — | Classes for the group. |
defaultOpen | boolean | false | Start unfolded. |
events | readonly ActivityEventProps[] | — | One run of events, in chronological order — one actor, close together (groupActivityRuns
finds them). It reads as one line with the latest time: a run that changes one change.field
is a chain ("moved it Todo → In progress → In review"); other runs list every change joined by
"; ", or — from summarizeAt changes — read "made 3 changes" and expand to list them. |
summarizeAt | number | — | Fold a run of different changes this long or longer (at least 2) into one expandable line ("made 3 changes"); unset, every change stays listed. 3 is the house setting. |
summarizeRun | ((events: readonly ActivityEventProps[]) => React.ReactNode) | — | The words for a run of different changes, after the actor ("updated the brief"), instead of
changesLabel; it still expands to list them. Return null for the default. |
visible | number | 1 | Events shown while folded (the children form). |
Data attributes and CSS variables on ActivityEventGroup
| Attribute | Values |
|---|---|
data-activity-id | mirrors a prop or state value |
data-activity-ids | mirrors a prop or state value |
data-layout | "chain" | "combined" | "run" |
data-open | "" |
data-slot | "activity-change" | "activity-event-group" | "activity-event-group-toggle" | "activity-run-changes" | "activity-run-toggle" |
ActivityJumpToLatest
| Prop | Type | Default | Description |
|---|---|---|---|
target* | HTMLElement | null | — | The latest content's focusable boundary, supplied by the host. |
className | string | — | Classes for the floating row. |
direction | "down" | "up" | — | The selected feed direction; when omitted, points toward the observed boundary. |
enabled | boolean | true | Enable the control. |
forceVisible | boolean | false | Keep the action available for a new batch already on screen; turning it on surfaces the control until the reader settles. |
label | string | "Jump to latest" | The button's label. |
onCancel | ((target: HTMLElement) => void) | — | Called when user interaction or a changed boundary cancels a jump. |
onJump | ((target: HTMLElement) => void) | — | Called at activation; capture the batch here. |
onReached | ((target: HTMLElement) => void) | — | Called once after that same boundary is reached. |
Data attributes and CSS variables on ActivityJumpToLatest
| Attribute | Values |
|---|---|
data-shown | "" |
data-slot | "activity-jump-floating" |
Accessibility
- The feed is a
sectionlabelled by its heading; the items are an ordered list named "Activity". - The filter is a select named "Show"; the order toggle is a button whose label names the order shown.
- An event is one sentence: the event's icon is decorative, the actor's name is a button that opens their hover card, and the relative time shows the exact time on hover or focus.
- Combined event groups keep every change visible and announce the actor and time once. A chain's
arrows read as "to". A folded run ("made 3 changes") and the children-based disclosure are buttons
with
aria-expanded; the folded run's button names the list it controls. - The heading's count says what it counts through
countLabel("Activity 2 comments"). - The unread line is a
separatornamed "New". - "Jump to latest" is hidden from assistive technology and out of the tab order while it is not shown; activating it moves focus to the "Latest activity" spot it scrolled to.
- J and K never fire while focus is in a field, a menu, a listbox or a dialog, with a modifier held, or when something else already handled the key. With several feeds on a page, the one holding focus answers (else the one mounted last); X ignores an item that has left the list.
- Scrolling (J/K, Jump to latest) is instant when the viewer prefers reduced motion.
| Key | Action |
|---|---|
| J | Move to the next item (tinted and scrolled into view) |
| K | Move to the previous item |
| X | Toggle the focused item (onToggle — unfold its replies) |
| Esc | Clear the focused item |
| Contract | States tested |
|---|---|
| Behaviour | filter, order, pending, event, system-actor, agent-actor, group-folded, group-open, unread, jump-to-latest, keyboard-focused, loading, explicit-jump-target, captured-jump-completion, run-chain, run-folded, run-expanded |
| Accessibility | labeled, semantic-html, keyboard-navigation, browser-accessibility-test |
| Visual | default, loading, focused |
Do / Don't
Comments
A record's comments, Linear style — thread cards with indented replies and a reply row, a bordered composer, day labels, order toggle, and in-place editing.
Comment Margin
Comment cards beside the text they are about — level with their highlight, stacked without overlap, the active one aligned — and a popover for narrow screens.
Customer wants the revised scope before we start. Can we split the migration into two phases?