Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Three boxes. Two seams.

**The wire.** Server-Sent Events. Plain HTTP, no WebSocket gymnastics, no custom binary framing. Your firewall, load balancer, and reverse proxy already know what to do with it.

**The Angular side.** This is what Threadplane provides. `@threadplane/ag-ui` is the adapter. It consumes the AG-UI event stream and exposes a runtime-neutral `Agent` contract built from signals. `@threadplane/chat` is the UI. It reads from that contract and renders. The two are decoupled on purpose. We'll get to why.
**The Angular side.** This is what ThreadPlane provides. `@threadplane/ag-ui` is the adapter. It consumes the AG-UI event stream and exposes a runtime-neutral `Agent` contract built from signals. `@threadplane/chat` is the UI. It reads from that contract and renders. The two are decoupled on purpose. We'll get to why.

## Let's wire it up

Expand Down Expand Up @@ -150,7 +150,7 @@ That's it. Three files, maybe twenty lines of code, and you have a working strea
<figure>
<img
src="/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui/hero.png"
alt="The AG-UI streaming demo running in the browser. A user message reads 'What is Threadplane?' and the assistant has streamed back a multi-sentence response with regenerate, copy, and feedback controls underneath."
alt="The AG-UI streaming demo running in the browser. A user message reads 'What is Angular Agent Framework?' and the assistant has streamed back a multi-sentence response with regenerate, copy, and feedback controls underneath."
width="1280"
height="800"
style={{ width: '100%', height: 'auto', borderRadius: '0.5rem' }}
Expand All @@ -176,7 +176,7 @@ The AG-UI protocol has seventeen event types, grouped into five families:

Having worked on the protocol from the inside, I'll tell you the families are doing real work. They're not arbitrary. Lifecycle is for "is something happening?" Text messages are the streaming triad you already know from chat UIs. Tool calls are deliberately incremental so you can render the *intent* before the arguments are fully formed. State sync uses RFC 6902 JSON Patch so the wire stays small even when the agent's state is large. Every event earns its keep.

Threadplane's `@threadplane/ag-ui` runs each event through a small reducer that updates a handful of signals on the `Agent` contract:
ThreadPlane's `@threadplane/ag-ui` runs each event through a small reducer that updates a handful of signals on the `Agent` contract:

- `messages()`: `Message[]`, the chat history. `TEXT_MESSAGE_CONTENT` appends a delta to the in-flight assistant message.
- `status()`: `'idle' | 'running' | 'error' | 'paused'`. Driven by the `RUN_*` events.
Expand Down Expand Up @@ -318,7 +318,7 @@ Each of those is its own post. The point here is just that the protocol-to-signa

AG-UI is the protocol Angular devs have been quietly waiting for, whether they knew it or not. It standardizes the wire between the agent and the UI, it's small enough to hold in your head, and the event model maps onto Angular signals so cleanly that the runtime almost writes itself. Markus and the CopilotKit crew deserve real credit for that. *Thank you* for designing something Angular could meet on its own terms.

With Threadplane (`@threadplane/ag-ui` and `@threadplane/chat` on npm), the wiring is three lines: a provider, an inject, and a `<chat>`. The interesting work (your tool cards, your interrupt flows, your generative UI surfaces, your design system) is where you actually want to spend the day.
With ThreadPlane (`@threadplane/ag-ui` and `@threadplane/chat` on npm), the wiring is three lines: a provider, an inject, and a `<chat>`. The interesting work (your tool cards, your interrupt flows, your generative UI surfaces, your design system) is where you actually want to spend the day.

If you've been building agents in Angular by hand-rolling SSE parsing and gluing together React components in iframes, *stop*. The whole stack is here, it's open source (the adapters are MIT; `@threadplane/chat` is source-available with a free non-commercial tier), and the protocol underneath has real momentum from the community building it.

Expand Down
37 changes: 37 additions & 0 deletions apps/website/e2e/blog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';

test.describe('Blog landing page', () => {
test('renders brand header + tag filter + at least one post card', async ({ page }) => {
await page.goto('/blog');

// Brand eyebrow + H1
await expect(page.getByText('Blog', { exact: true }).first()).toBeVisible();
await expect(page.getByRole('heading', { level: 1, name: /Articles from ThreadPlane/i })).toBeVisible();

// Filter row contains the "All" chip in active state
await expect(page.getByText('All', { exact: true })).toBeVisible();

// At least one post link rendered
await expect(page.locator('a[href^="/blog/"]').first()).toBeVisible();
});

test('clicking a tag chip filters the list via ?tag=', async ({ page }) => {
await page.goto('/blog');

// Click the "tutorial" chip — assumes at least one post is tagged tutorial.
await page.getByRole('link', { name: 'tutorial', exact: true }).first().click();

// URL reflects the filter
await expect(page).toHaveURL(/[?&]tag=tutorial(&|$)/);

// The active chip uses aria-current=page
await expect(page.locator('[aria-current="page"]').filter({ hasText: 'tutorial' })).toBeVisible();
});

test('unknown tag renders empty state with a view-all link', async ({ page }) => {
await page.goto('/blog?tag=__no_such_tag__');

await expect(page.getByText(/No posts tagged/i)).toBeVisible();
await expect(page.getByRole('link', { name: 'View all posts' })).toBeVisible();
});
});
27 changes: 27 additions & 0 deletions apps/website/e2e/recent-articles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { test, expect } from '@playwright/test';

test.describe('Home page — Recent articles', () => {
test('renders the section header on /', async ({ page }) => {
await page.goto('/');
// Stable id, not copy — copy changes more often than structure.
await expect(page.locator('#recent-articles-heading')).toBeVisible();
await expect(page.locator('#recent-articles-heading')).toHaveText(/Recent articles/i);
});

test('shows at least one post card linking to /blog/<slug>', async ({ page }) => {
await page.goto('/');
const section = page.locator('section[aria-labelledby="recent-articles-heading"]');
await expect(section).toBeVisible();
// At least one card with a blog post link inside the section.
const firstCard = section.locator('a[href^="/blog/"]').first();
await expect(firstCard).toBeVisible();
});

test('"View all articles" link points to /blog', async ({ page }) => {
await page.goto('/');
const section = page.locator('section[aria-labelledby="recent-articles-heading"]');
const viewAll = section.getByRole('link', { name: /View all articles/i });
await expect(viewAll).toBeVisible();
await expect(viewAll).toHaveAttribute('href', '/blog');
});
});
28 changes: 4 additions & 24 deletions apps/website/src/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { DocsTOC } from '../../../components/docs/DocsTOC';
import { AuthorByline } from '../../../components/blog/AuthorByline';
import { TagChips } from '../../../components/blog/TagChips';
import { Eyebrow } from '../../../components/ui/Eyebrow';
import { getAllPosts, getPostBySlug } from '../../../lib/blog';
import { getAllPosts, getPostBySlug, formatPostDate, readingTimeMin } from '../../../lib/blog';
import { getAuthor } from '../../../lib/blog-authors';
import { extractHeadings } from '../../../lib/extract-headings';
import { createPageMetadata } from '../../../lib/site-metadata';
Expand All @@ -23,36 +23,16 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post || post.frontmatter.draft) {
return { title: 'Post not found — Threadplane' };
return { title: 'Post not found — ThreadPlane' };
}
return createPageMetadata({
title: `${post.frontmatter.title} — Threadplane`,
title: `${post.frontmatter.title} — ThreadPlane`,
description: post.frontmatter.description,
pathname: `/blog/${post.slug}`,
type: 'article',
});
}

function formatDate(iso: string): string {
// Parse YYYY-MM-DD as a date in UTC, then format using UTC parts to avoid
// timezone shifts (e.g. "2026-05-21" rendering as "May 20" west of UTC).
const d = new Date(`${iso}T00:00:00Z`);
return d.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
}

function readingTimeMin(markdown: string): number {
const words = markdown
.replace(/```[\s\S]*?```/g, '') // strip code fences (not real reading)
.replace(/[#*_`>-]/g, ' ')
.split(/\s+/)
.filter(Boolean).length;
return Math.max(1, Math.round(words / 220));
}

export default async function BlogPostPage({ params }: Params) {
const { slug } = await params;
Expand Down Expand Up @@ -80,7 +60,7 @@ export default async function BlogPostPage({ params }: Params) {
<article style={{ width: '100%', maxWidth: 768, padding: '64px 24px', flexShrink: 0 }}>
<header style={{ marginBottom: 48 }}>
<Eyebrow tone="accent" style={{ marginBottom: 24 }}>
{primaryTag} · {formatDate(post.frontmatter.date)} · {minutes} min read
{primaryTag} · {formatPostDate(post.frontmatter.date)} · {minutes} min read
</Eyebrow>
<h1
style={{
Expand Down
146 changes: 103 additions & 43 deletions apps/website/src/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,122 @@
import Link from 'next/link';
import { tokens } from '@threadplane/design-tokens';
import { createPageMetadata } from '../../lib/site-metadata';
import { getAllPosts, getFeaturedPost, getAllTags } from '../../lib/blog';
import { FeaturedPostCard } from '../../components/blog/FeaturedPostCard';
import { PostCard } from '../../components/blog/PostCard';
import { TagChips } from '../../components/blog/TagChips';
import { BlogTagFilter } from '../../components/blog/BlogTagFilter';
import { Eyebrow } from '../../components/ui/Eyebrow';

export const metadata = createPageMetadata({
title: 'Blog — Threadplane',
title: 'Blog — ThreadPlane',
description:
'Long-form writing on agent UI for Angular: streaming, generative UI, threads, interrupts, production patterns.',
pathname: '/blog',
type: 'website',
});

export default function BlogIndexPage() {
interface Props {
searchParams: Promise<{ tag?: string }>;
}

export default async function BlogIndexPage({ searchParams }: Props) {
const { tag: activeTag } = await searchParams;

const all = getAllPosts();
const featured = getFeaturedPost();
const rest = featured ? all.filter((p) => p.slug !== featured.slug) : all;
const tags = getAllTags().map((t) => t.tag);

const filtered = activeTag
? all.filter((p) => p.frontmatter.tags?.includes(activeTag))
: all;

// Featured only when no filter is active — feels like a clean list otherwise.
const featured = activeTag ? null : getFeaturedPost();
const grid = featured ? filtered.filter((p) => p.slug !== featured.slug) : filtered;

return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '64px 24px' }}>
<p
style={{
fontSize: 13,
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: 8,
opacity: 0.7,
}}
>
Blog
</p>
<h1
style={{
fontSize: 48,
fontWeight: 600,
letterSpacing: '-0.02em',
marginBottom: 12,
}}
>
Notes from Threadplane
</h1>
<p style={{ fontSize: 18, opacity: 0.8, marginBottom: 32, maxWidth: '60ch' }}>
Writing on agent UI for Angular — production patterns, design choices,
and what we&apos;re shipping.
</p>
<TagChips tags={tags} />
{featured ? <FeaturedPostCard post={featured} /> : null}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: 16,
}}
>
{rest.map((p) => (
<PostCard key={p.slug} post={p} />
))}
<div style={{ paddingTop: 80, background: tokens.surfaces.canvas, minHeight: '100vh' }}>
<div style={{ maxWidth: 960, margin: '0 auto', padding: '64px 24px' }}>
<header style={{ marginBottom: 32 }}>
<Eyebrow tone="accent" style={{ marginBottom: 16 }}>
Blog
</Eyebrow>
<h1
style={{
fontFamily: tokens.typography.h1.family,
fontSize: tokens.typography.h1.size,
lineHeight: tokens.typography.h1.line,
fontWeight: 700,
letterSpacing: '-0.02em',
color: tokens.colors.textPrimary,
margin: '0 0 16px',
}}
>
Articles from ThreadPlane
</h1>
<p
style={{
fontFamily: tokens.typography.bodyLg.family,
fontSize: tokens.typography.bodyLg.size,
lineHeight: tokens.typography.bodyLg.line,
color: tokens.colors.textSecondary,
margin: 0,
maxWidth: '60ch',
}}
>
Writing on agent UI for Angular &mdash; production patterns, design
choices, and what we&apos;re shipping.
</p>
</header>

<BlogTagFilter activeTag={activeTag} tags={tags} />

{featured ? <FeaturedPostCard post={featured} /> : null}

{grid.length === 0 ? (
<div
style={{
padding: 48,
textAlign: 'center',
background: tokens.surfaces.surfaceTinted,
border: `1px solid ${tokens.surfaces.border}`,
borderRadius: 12,
}}
>
<p
style={{
fontFamily: tokens.typography.bodyLg.family,
fontSize: tokens.typography.bodyLg.size,
lineHeight: tokens.typography.bodyLg.line,
color: tokens.colors.textSecondary,
margin: '0 0 16px',
}}
>
No posts tagged <em>{activeTag}</em> yet.
</p>
<Link
href="/blog"
style={{
color: tokens.colors.accent,
textDecoration: 'underline',
fontWeight: 500,
}}
>
View all posts
</Link>
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: 16,
}}
>
{grid.map((p) => (
<PostCard key={p.slug} post={p} />
))}
</div>
)}
</div>
</div>
);
Expand Down
Loading
Loading