# Changelog Section v4

> Changelog log layout: each date group opens on a horizontal rule instead of a timeline rail, with entries listed underneath by title, description, and a contributor avatar group.

A shadcn/ui block from the [@uiception](https://uiception.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiception/changelog-section-v4).

- Page, with a live preview: https://shadcn.exchange/r/uiception/changelog-section-v4
- Screenshot: https://shadcn.exchange/shot/uiception/changelog-section-v4/light.png
- Registry item: https://uiception.com/r/changelog-section-v4.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/uiception/changelog-section-v4.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `geist`
- Registry items: `https://uiception.com/r/block-media.json`, `avatar`

## Files

### `registry/new-york/blocks/changelog-section-v4/page.tsx`

```tsx
import ChangelogSectionV4 from "./components/changelog-section-v4"

export default function Page() {
  return <ChangelogSectionV4 />
}
```

### `registry/new-york/blocks/changelog-section-v4/components/changelog-section-v4.tsx`

```tsx
import ChangelogDateDivider from "./changelog-date-divider"
import ChangelogEntryRow from "./changelog-entry-row"
import { sectionMeta } from "../lib/changelog-content"
import { changelogGroups } from "../lib/changelog-format"

export default function ChangelogSectionV4() {
  return (
    <section className="py-4 md:py-6 lg:py-8">
      <div className="mx-auto max-w-4xl px-4">
        <h2 className="text-3xl font-medium tracking-tight sm:text-4xl">
          {sectionMeta.title}
        </h2>

        <div className="mt-12 sm:mt-16">
          {changelogGroups.map((group) => (
            <div key={group.date}>
              <ChangelogDateDivider label={group.label} />
              <div className="my-4">
                {group.entries.map((entry) => (
                  <ChangelogEntryRow key={entry.id} entry={entry} />
                ))}
              </div>
            </div>
          ))}
        </div>
      </div>
    </section>
  )
}
```

### `registry/new-york/blocks/changelog-section-v4/components/changelog-date-divider.tsx`

```tsx
import { GeistMono } from "geist/font/mono"

/**
 * Section break between date groups: a small uppercase date tag followed by a
 * rule that runs the rest of the row. Stands in for v3's vertical timeline
 * rail without drawing one — each group just opens with its own horizontal
 * line instead of hanging off a continuous spine.
 */
export default function ChangelogDateDivider({ label }: { label: string }) {
  return (
    <div className="flex items-center gap-4 pt-14 pb-6 first:pt-0">
      <span
        className={`${GeistMono.className} shrink-0 text-xs font-medium tracking-widest text-muted-foreground uppercase`}
      >
        {label}
      </span>
      <span aria-hidden className="h-px flex-1 bg-border" />
    </div>
  )
}
```

### `registry/new-york/blocks/changelog-section-v4/components/changelog-entry-row.tsx`

```tsx
import Link from "next/link"

import ContributorGroup from "./contributor-group"
import type { ChangelogEntry } from "../lib/changelog-content"

/** One entry under a date divider: headline, description, and contributors. */
export default function ChangelogEntryRow({ entry }: { entry: ChangelogEntry }) {
  return (
    <article>
      <Link
        href="#"
        target="_blank"
        rel="noreferrer"
        className="block px-4 py-8 transition-colors hover:bg-muted/40 sm:px-6 sm:py-10"
      >
        <h3 className="text-xl font-medium tracking-tight text-balance text-foreground sm:text-2xl">
          {entry.title}
        </h3>
        <p className="mt-3 max-w-2xl leading-relaxed text-muted-foreground">
          {entry.description}
        </p>
        <div className="mt-5">
          <ContributorGroup contributors={entry.contributors} />
        </div>
      </Link>
    </article>
  )
}
```

### `registry/new-york/blocks/changelog-section-v4/components/contributor-group.tsx`

```tsx
import {
  Avatar,
  AvatarFallback,
  AvatarGroup,
  AvatarImage,
} from "@/components/ui/avatar"

import type { Contributor } from "../lib/changelog-content"
import {
  MAX_VISIBLE_CONTRIBUTORS,
  formatContributorNames,
} from "../lib/changelog-format"

export default function ContributorGroup({
  contributors,
}: {
  contributors: Contributor[]
}) {
  // Never more than three avatars — anyone past that is covered by the names
  // label ("…and 2 others"), so there is no "+N" count bubble.
  const visible = contributors.slice(0, MAX_VISIBLE_CONTRIBUTORS)

  return (
    <div className="flex items-center gap-3">
      <AvatarGroup className="shrink-0">
        {visible.map((person) => (
          <Avatar key={person.id} size="sm">
            <AvatarImage
              src={person.avatarSrc}
              alt={person.name}
              className="object-cover object-center"
            />
            <AvatarFallback>{person.initials}</AvatarFallback>
          </Avatar>
        ))}
      </AvatarGroup>
      <span className="min-w-0 text-sm font-medium text-foreground">
        {formatContributorNames(contributors)}
      </span>
    </div>
  )
}
```

### `registry/new-york/blocks/changelog-section-v4/lib/changelog-content.ts`

```ts
import { createBlockImage } from "@/lib/block-media"

const blockAvatar = createBlockImage("changelog-section-v4")

export type Contributor = {
  id: string
  name: string
  initials: string
  avatarSrc: string
}

export type ChangelogEntry = {
  id: string
  /** ISO date (YYYY-MM-DD). Entries that share a date render under one date group. */
  date: string
  title: string
  description: string
  contributors: Contributor[]
}

export const sectionMeta = {
  title: "What's New",
}

const people = {
  claire: {
    id: "claire-donovan",
    name: "Claire Donovan",
    initials: "CD",
    avatarSrc: blockAvatar("avatar-1.png"),
  },
  ethan: {
    id: "ethan-morrison",
    name: "Ethan Morrison",
    initials: "EM",
    avatarSrc: blockAvatar("avatar-2.png"),
  },
  mei: {
    id: "mei-lin-chen",
    name: "Mei Lin Chen",
    initials: "MC",
    avatarSrc: blockAvatar("avatar-3.png"),
  },
  jordan: {
    id: "jordan-ellis",
    name: "Jordan Ellis",
    initials: "JE",
    avatarSrc: blockAvatar("avatar-4.png"),
  },
  hannah: {
    id: "hannah-bergstrom",
    name: "Hannah Bergstrom",
    initials: "HB",
    avatarSrc: blockAvatar("avatar-5.png"),
  },
  ryan: {
    id: "ryan-park",
    name: "Ryan Park",
    initials: "RP",
    avatarSrc: blockAvatar("avatar-6.png"),
  },
} satisfies Record<string, Contributor>

export const changelog: ChangelogEntry[] = [
  {
    id: "realtime-collaborative-editing",
    date: "2026-09-02",
    title: "Realtime Collaborative Editing",
    description:
      "Multiple teammates can now edit the same agent session at once, with live cursors and instant conflict resolution baked in.",
    contributors: [people.mei, people.claire],
  },
  {
    id: "custom-agent-personas",
    date: "2026-09-02",
    title: "Custom Agent Personas",
    description:
      "Define tone, guardrails, and default tools once, then apply the same persona across every project your team ships.",
    contributors: [people.ethan, people.jordan, people.ryan],
  },
  {
    id: "faster-cold-starts",
    date: "2026-08-25",
    title: "Faster Cold Starts",
    description:
      "Agent sandboxes now boot in under 400ms on average, down from 1.2s, by keeping a warm pool ready across regions.",
    contributors: [people.hannah],
  },
  {
    id: "audit-log-export",
    date: "2026-08-10",
    title: "Audit Log Export",
    description:
      "Stream every agent action to your own SIEM or data warehouse in near real time, with a schema that stays stable across releases.",
    contributors: [people.ryan, people.mei],
  },
  {
    id: "team-roles-and-permissions",
    date: "2026-08-10",
    title: "Team Roles & Permissions",
    description:
      "Granular roles now scope exactly which repos, environments, and spend limits each teammate's agents can touch.",
    contributors: [people.claire, people.ethan, people.hannah, people.jordan],
  },
]
```

### `registry/new-york/blocks/changelog-section-v4/lib/changelog-format.ts`

```ts
import type { ChangelogEntry, Contributor } from "./changelog-content"
import { changelog } from "./changelog-content"

export type ChangelogDateGroup = {
  date: string
  /** Display label, e.g. "2 September 2026". */
  label: string
  entries: ChangelogEntry[]
}

const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
]

/** "2026-09-02" -> "2 September 2026". Returns the raw input unchanged if it
 *  isn't a valid YYYY-MM-DD date. */
export function formatChangelogDate(iso: string): string {
  const parts = iso.split("-")
  if (parts.length !== 3) return iso
  const [year, month, day] = parts.map(Number)
  if (
    !Number.isInteger(year) ||
    !Number.isInteger(month) ||
    !Number.isInteger(day) ||
    month < 1 ||
    month > 12 ||
    day < 1 ||
    day > 31
  ) {
    return iso
  }
  return `${day} ${MONTHS[month - 1]} ${year}`
}

/**
 * Names line under an entry. Up to two names are spelled out; everyone else is
 * folded into "and N other(s)" — e.g. "Jeremy Dopkin, Elliot Dauber, and 1 other".
 */
export function formatContributorNames(contributors: Contributor[]): string {
  const names = contributors.map((person) => person.name)
  if (names.length <= 2) return names.join(", ")
  const rest = names.length - 2
  return `${names[0]}, ${names[1]}, and ${rest} other${rest === 1 ? "" : "s"}`
}

/** Collapse a flat entry list into date groups, newest date first. */
export function groupEntriesByDate(
  entries: ChangelogEntry[]
): ChangelogDateGroup[] {
  const byDate = new Map<string, ChangelogEntry[]>()
  for (const entry of entries) {
    const existing = byDate.get(entry.date)
    if (existing) existing.push(entry)
    else byDate.set(entry.date, [entry])
  }

  return [...byDate.entries()]
    .sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0))
    .map(([date, dateEntries]) => ({
      date,
      label: formatChangelogDate(date),
      entries: dateEntries,
    }))
}

export const changelogGroups = groupEntriesByDate(changelog)

/** Max avatars rendered before the "+N" count bubble takes over. */
export const MAX_VISIBLE_CONTRIBUTORS = 3
```

### `public/images/blocks/changelog-section-v4/avatar-1.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/changelog-section-v4/avatar-2.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/changelog-section-v4/avatar-3.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/changelog-section-v4/avatar-4.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/changelog-section-v4/avatar-5.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/changelog-section-v4/avatar-6.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```
