Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
68552f6
docs: design for the docs page improvements
blove Sep 3, 2026
f21fdc4
docs: implementation plan for the docs page improvements
blove Sep 3, 2026
ebbab86
fix(docs): give docs prose links a visible affordance
blove Sep 3, 2026
7b22fe0
feat(docs): point the index Run rail item at the default example
blove Sep 3, 2026
a6898df
refactor(docs): collapse the duplicated Run rail item
blove Sep 3, 2026
71f2c08
docs: correct drifted CSS line references in the plan
blove Sep 3, 2026
9d44d05
feat(docs): lead the control plane with search, not Scope
blove Sep 3, 2026
afff700
feat(workspace-react): let a host supply the header trail
blove Sep 3, 2026
a420a5a
docs: close a Task 5 gap the plan missed
blove Sep 3, 2026
9740f0c
feat(docs): add CTA buttons for callouts
blove Sep 3, 2026
05cf1de
fix(docs): repair two suites the Scope removal broke
blove Sep 3, 2026
ddc93cd
fix(workspace-react): let the header trail shrink, and pin the last rung
blove Sep 3, 2026
bfe31eb
docs: give the live-demo callouts a run button
blove Sep 3, 2026
ba1aa4f
fix(docs): render one breadcrumb trail per doc page
blove Sep 3, 2026
62f42de
refactor(docs): delete the pageTitle prop nothing reads
blove Sep 3, 2026
d32ec53
feat(docs): invite a search at the foot of every docs page
blove Sep 3, 2026
d87a986
test(website): update docs e2e to the new docs chrome
blove Sep 3, 2026
933c627
refactor(docs): drop the inert Tailwind Typography classes
blove Sep 3, 2026
2d1433f
fix(docs): treat protocol-relative and mailto CTA hrefs as external
blove Sep 3, 2026
aaeba47
test(docs): pin the search footer's accessible name
blove Sep 3, 2026
bde7177
test(website): match whole media queries in the contract helper
blove Sep 3, 2026
c8c7428
style(docs): keep the header trail on one line on narrow screens
blove Sep 3, 2026
fbf3f90
test(website): rename the Nav drawer search test to match what it checks
blove Sep 3, 2026
27f7dff
refactor(docs): move the library mark into the header trail
blove Sep 3, 2026
a7898e6
fix(docs): size the trail library mark to its line box
blove Sep 3, 2026
aa126d9
Revert "style(docs): keep the header trail on one line on narrow scre…
blove Sep 3, 2026
983941f
Merge branch 'main' into blove/docs-page-improvements-66cf88
blove Sep 3, 2026
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 @@ -9,7 +9,12 @@ AG-UI is an open agent-to-UI protocol. It standardizes how agent runtimes stream
</Callout>

<Callout type="tip" title="See it live">
The [AG-UI demo](https://ag-ui.threadplane.ai) runs this exact chat surface against an AG-UI backend — streaming, tool calls, and generative UI included. Compare it side by side with the [LangGraph demo](https://demo.threadplane.ai).
The AG-UI demo runs this exact chat surface against an AG-UI backend — streaming, tool calls, and generative UI included.

<CalloutActions>
<CalloutAction href="https://ag-ui.threadplane.ai">Run AG-UI demo</CalloutAction>
<CalloutAction href="https://demo.threadplane.ai" variant="secondary">Compare LangGraph demo</CalloutAction>
</CalloutActions>
</Callout>

## How it fits
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ Angular 20–22 project using a Node.js version supported by that Angular major.
</Callout>

<Callout type="tip" title="Try it first">
Want to see the finished result before you build? Open the live [AG-UI demo](https://ag-ui.threadplane.ai).
Want to see the finished result before you build?

<CalloutActions>
<CalloutAction href="https://ag-ui.threadplane.ai">Run AG-UI demo</CalloutAction>
</CalloutActions>
</Callout>

<Steps>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
This guide walks you through the complete workflow: build a LangGraph agent in Python, run it locally, connect it to an Angular app with `injectAgent()`, and deploy to production.
</Callout>

<Callout type="tip" title="See it live">
The LangGraph demo runs this chat surface against a LangGraph backend — streaming, threads, interrupts, and tool calls included.

<CalloutActions>
<CalloutAction href="https://demo.threadplane.ai">Run LangGraph demo</CalloutAction>
</CalloutActions>
</Callout>

## How it fits

<StackDiagram
Expand Down
7 changes: 6 additions & 1 deletion apps/website/content/docs/runtimes/aws-strands/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ description: What the AWS Strands integration demonstrates through @threadplane/
The Threadplane example is a meeting scheduler. It runs a Strands agent behind FastAPI, streams to an ordinary Angular app, and pauses for human approval before it books anything.

<Callout type="tip" title="See it live">
The hosted example runs at [examples.threadplane.ai/runtimes/aws-strands](https://examples.threadplane.ai/runtimes/aws-strands/). The source is [`cockpit/runtimes/aws-strands`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/runtimes/aws-strands).
The hosted example runs the AWS Strands integration end to end.

<CalloutActions>
<CalloutAction href="https://examples.threadplane.ai/runtimes/aws-strands/">Run the example</CalloutAction>
<CalloutAction href="https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/runtimes/aws-strands" variant="secondary">View source</CalloutAction>
</CalloutActions>
</Callout>

## What the integration demonstrates
Expand Down
7 changes: 6 additions & 1 deletion apps/website/content/docs/runtimes/mastra/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ description: What the Mastra integration demonstrates through @threadplane/ag-ui
The Threadplane example is a camping trip planner. It streams messages, calls a backend tool, keeps a packing list in shared state through Mastra's working memory, and suspends a run for human approval before reserving a campsite.

<Callout type="tip" title="See it live">
The hosted example runs at [examples.threadplane.ai/runtimes/mastra](https://examples.threadplane.ai/runtimes/mastra/). The Angular source is [`cockpit/runtimes/mastra`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/runtimes/mastra), and its backend is [`deployments/ag-ui-mastra`](https://github.com/cacheplane/angular-agent-framework/tree/main/deployments/ag-ui-mastra) rather than the FastAPI deployment the two Python runtimes share.
The hosted example runs the Mastra integration end to end. Its backend is [`deployments/ag-ui-mastra`](https://github.com/cacheplane/angular-agent-framework/tree/main/deployments/ag-ui-mastra) rather than the FastAPI deployment the two Python runtimes share.

<CalloutActions>
<CalloutAction href="https://examples.threadplane.ai/runtimes/mastra/">Run Mastra example</CalloutAction>
<CalloutAction href="https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/runtimes/mastra" variant="secondary">View source</CalloutAction>
</CalloutActions>
</Callout>

## What the integration demonstrates
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ description: What the Microsoft Agent Framework integration demonstrates through
The Threadplane example is an expense assistant. It looks up policy through a server-side tool, streams a proposed expense into frontend state while the model is still writing it, and requires human approval before submitting.

<Callout type="tip" title="See it live">
The hosted example runs at [examples.threadplane.ai/runtimes/microsoft-agent-framework](https://examples.threadplane.ai/runtimes/microsoft-agent-framework/). The source is [`cockpit/runtimes/microsoft-agent-framework`](https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/runtimes/microsoft-agent-framework).
The hosted example runs the Microsoft Agent Framework integration end to end.

<CalloutActions>
<CalloutAction href="https://examples.threadplane.ai/runtimes/microsoft-agent-framework/">Run the example</CalloutAction>
<CalloutAction href="https://github.com/cacheplane/angular-agent-framework/tree/main/cockpit/runtimes/microsoft-agent-framework" variant="secondary">View source</CalloutAction>
</CalloutActions>
</Callout>

## What the integration demonstrates
Expand Down
29 changes: 26 additions & 3 deletions apps/website/e2e/docs-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,40 @@ test.describe('DocsTOC rail', () => {
// Nothing is active at the top: the first heading is below the reading line.
await expect(page.locator('.docs-toc-link[data-active]')).toHaveCount(0);

// The reading line lands a heading as "active" once its top crosses
// scrollRoot.top + scrollRoot.clientHeight * 0.25 (see DocsTOC.tsx). A
// hard-coded `scrollTop: 4000` broke the last time this page's content
// grew (a callout pushed everything below it further down the article),
// so compute the scroll offset that puts a specific heading right at
// that line instead of relying on a pixel constant that content edits
// keep invalidating.
const targetHeadingId = 'connect-with-angular';
const articleScroller = page.locator('.docs-workspace-article');
await articleScroller.evaluate((element) =>
element.scrollTo({ top: 4000, behavior: 'instant' }),
const scrollTop = await articleScroller.evaluate((scrollRoot, headingId) => {
const heading = document.getElementById(headingId);
if (!heading) throw new Error(`missing heading #${headingId}`);
// Heading's distance from the top of the scroller's content, i.e.
// where its rect top would be if scrollTop were 0.
const headingTopAtZero =
heading.getBoundingClientRect().top -
scrollRoot.getBoundingClientRect().top +
scrollRoot.scrollTop;
const line = scrollRoot.clientHeight * 0.25;
// A couple of extra pixels so the heading's top is unambiguously at
// or above the line, matching DocsTOC's `<=` comparison.
return Math.max(0, Math.round(headingTopAtZero - line + 2));
}, targetHeadingId);
await articleScroller.evaluate(
(element, top) => element.scrollTo({ top, behavior: 'instant' }),
scrollTop,
);
await expect
.poll(() =>
page
.locator('.docs-toc-link[data-active]')
.evaluateAll((els) => els.map((e) => e.getAttribute('href'))),
)
.toEqual(['#connect-with-angular']);
.toEqual([`#${targetHeadingId}`]);

// ...and it follows the scroll rather than latching on the first match.
await articleScroller.evaluate((element) =>
Expand Down
21 changes: 15 additions & 6 deletions apps/website/e2e/docs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ test.describe('Docs landing page', () => {
test.describe('Docs slug page', () => {
const route = '/docs/langgraph/getting-started/introduction';

test('keeps page scroll fixed when focusing the bottom control-plane action', async ({
test('keeps page scroll fixed when focusing the last control-plane nav link', async ({
page,
}) => {
await page.setViewportSize({ width: 1024, height: 900 });
Expand All @@ -62,14 +62,21 @@ test.describe('Docs slug page', () => {
const pane = page.locator(
'[data-cockpit-desktop-navigation] [data-control-plane-pane]',
);
const search = pane.getByRole('button', { name: 'Search docs' });
// Search moved to the top of the pane (was the bottom Actions-bar item
// this test used to focus), so it can no longer stand in for "something
// near the bottom of the scrollable pane". The last link in the Learn
// section tree still sits at the bottom of the pane's content and is
// rendered on every docs page, so it exercises the same bug class: focus
// deep in the pane must scroll the pane, not the window.
const lastNavLink = pane.locator('.docs-sidebar-section-link').last();
await expect(pane).toBeVisible();
await expect(lastNavLink).toBeVisible();
await pane.evaluate((element) => {
element.scrollTop = 0;
});
await page.evaluate(() => window.scrollTo(0, 0));

await search.focus();
await lastNavLink.focus();

await expect.poll(() => pane.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
expect(await page.evaluate(() => window.scrollY)).toBe(0);
Expand All @@ -82,12 +89,14 @@ test.describe('Docs slug page', () => {
await expect(page.locator('article').first()).toBeVisible();
});

test('renders the branded chrome (sidebar mark, page-header eyebrow, prev/next direction)', async ({ page }) => {
test('renders the branded chrome (sidebar mark, breadcrumb trail, prev/next direction)', async ({ page }) => {
await page.goto(route);
// Sidebar shows the active library's logo mark
await expect(page.locator('aside img[src="/logos/langgraph.svg"]').first()).toBeVisible();
// Branded page header eyebrow
await expect(page.getByText(/LangGraph\s+·\s+Getting Started/i).first()).toBeVisible();
// The lib · section label moved into the shell's breadcrumb trail.
const breadcrumb = page.locator('nav[aria-label="Breadcrumb"]').first();
await expect(breadcrumb).toContainText('LangGraph');
await expect(breadcrumb).toContainText('Getting Started');
// Prev/Next: introduction is the first page, so a "Next →" card is present
await expect(page.getByText('Next →').first()).toBeVisible();
// Per-page LLM actions trigger
Expand Down
108 changes: 76 additions & 32 deletions apps/website/e2e/workspace-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,39 +331,83 @@ test.describe('workspace shell', () => {
}
});

for (const path of ['/docs', '/docs/choosing-an-adapter']) {
test(`docs-only ${path} keeps operational modes focusable and local`, async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(path);
test('docs-only /docs/choosing-an-adapter keeps operational modes focusable and local', async ({
page,
}) => {
const path = '/docs/choosing-an-adapter';
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(path);

const controlPlane = page.locator('[data-docs-control-plane]');
await expect(controlPlane).toBeVisible();
for (const mode of ['Run', 'Code', 'API'] as const) {
const control = controlPlane.getByRole('button', {
name: mode,
exact: true,
});
await expect(control).toHaveAttribute('aria-disabled', 'true');
await expect(control).toHaveAccessibleDescription(
new RegExp(
`${mode} is unavailable because this page has no workspace capability`,
'i'
)
);
await expect(control).not.toHaveAttribute('href', /.+/);
await expect(control).not.toHaveAttribute('target', /.+/);
await control.focus();
await expect(control).toBeFocused();
await control.click({ force: true });
await expect(page).toHaveURL(path);
}
await expect(
controlPlane.getByRole('button', { name: 'Search docs' })
).toBeVisible();
});
}
const controlPlane = page.locator('[data-docs-control-plane]');
await expect(controlPlane).toBeVisible();
for (const mode of ['Run', 'Code', 'API'] as const) {
const control = controlPlane.getByRole('button', {
name: mode,
exact: true,
});
await expect(control).toHaveAttribute('aria-disabled', 'true');
await expect(control).toHaveAccessibleDescription(
new RegExp(
`${mode} is unavailable because this page has no workspace capability`,
'i'
)
);
await expect(control).not.toHaveAttribute('href', /.+/);
await expect(control).not.toHaveAttribute('target', /.+/);
await control.focus();
await expect(control).toBeFocused();
await control.click({ force: true });
await expect(page).toHaveURL(path);
}
await expect(
controlPlane.getByRole('button', { name: 'Search docs' })
).toBeVisible();
});

test('docs-only /docs keeps Code and API disabled but Run is a live link to the canonical example', async ({
page,
}) => {
const path = '/docs';
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(path);

const controlPlane = page.locator('[data-docs-control-plane]');
await expect(controlPlane).toBeVisible();

for (const mode of ['Code', 'API'] as const) {
const control = controlPlane.getByRole('button', {
name: mode,
exact: true,
});
await expect(control).toHaveAttribute('aria-disabled', 'true');
await expect(control).toHaveAccessibleDescription(
new RegExp(
`${mode} is unavailable because this page has no workspace capability`,
'i'
)
);
await expect(control).not.toHaveAttribute('href', /.+/);
await expect(control).not.toHaveAttribute('target', /.+/);
}

await expect(
controlPlane.getByRole('button', { name: 'Search docs' })
).toBeVisible();

const run = controlPlane.getByRole('link', { name: 'Run', exact: true });
await expect(run).toHaveAttribute(
'href',
'/docs/langgraph/guides/streaming?mode=run'
);
// Prove the destination actually resolves rather than 404ing.
const [response] = await Promise.all([
page.waitForNavigation(),
run.click(),
]);
expect(response?.status()).toBe(200);
await expect(page).toHaveURL('/docs/langgraph/guides/streaming?mode=run');
await expect(page.locator('[data-workspace-shell]')).toBeVisible();
});

test('uses workspace fallbacks only when a shared Docs path would lose identity', async ({
page,
Expand Down
51 changes: 46 additions & 5 deletions apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { isValidElement, type ComponentType, type ReactNode } from 'react';
import { describe, expect, it } from 'vitest';
import { DocsBreadcrumb } from '../../../../../components/docs/DocsBreadcrumb';
import { DocsPageHeader } from '../../../../../components/docs/DocsPageHeader';
import { LibraryMark } from '../../../../../components/docs/LibraryMark';
import { DocsSearchFooter } from '../../../../../components/docs/DocsSearchFooter';
import { DocsTOC } from '../../../../../components/docs/DocsTOC';
import { MdxRenderer } from '../../../../../components/docs/MdxRenderer';
import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace';
Expand All @@ -13,6 +14,7 @@ interface ElementProps {
requestedMode?: string | null;
resolution?: { kind?: string; identity?: { availableModes?: string[] } };
contentBundle?: { runtimeUrl?: string | null };
contextTrail?: readonly { label: string; href?: string; icon?: ReactNode }[];
}

function findElement(
Expand Down Expand Up @@ -60,7 +62,6 @@ describe('unified docs workspace route', () => {
activeLibrary: 'langgraph',
activeSection: 'guides',
activeSlug: 'streaming',
pageTitle: 'Streaming',
});
});

Expand All @@ -73,16 +74,56 @@ describe('unified docs workspace route', () => {
const slot = workspace?.props.docsSlot;

expect(workspace?.props.resolution).toMatchObject({ kind: 'docs-only' });
expect(
findElement(slot, DocsBreadcrumb as ComponentType<never>)
).toBeTruthy();
expect(
findElement(slot, DocsPageHeader as ComponentType<never>)
).toBeTruthy();
expect(findElement(slot, MdxRenderer as ComponentType<never>)).toBeTruthy();
expect(findElement(slot, DocsTOC as ComponentType<never>)).toBeTruthy();
});

it('invites a search at the foot of a content page', async () => {
const tree = await route('langgraph', 'guides', 'testing');
const workspace = findElement(
tree,
WebsiteWorkspace as ComponentType<never>
);

expect(
findElement(
workspace?.props.docsSlot,
DocsSearchFooter as ComponentType<never>
)
).toBeTruthy();
});

it('hands the shell one accurate trail instead of four renditions', async () => {
const tree = await route('ag-ui', 'getting-started', 'introduction');
const workspace = findElement(
tree,
WebsiteWorkspace as ComponentType<never>
);

// Docs titles, not manifest identity: the derived label read
// "Ag Ui / Getting Started / Overview".
const trail = workspace?.props.contextTrail ?? [];
expect(trail.map(({ label, href }) => ({ label, href }))).toEqual([
{ label: 'Docs', href: '/docs' },
{ label: 'AG-UI', href: '/docs/ag-ui/getting-started/introduction' },
{ label: 'Getting Started', href: undefined },
{ label: 'Introduction', href: undefined },
]);

// Only the library rung carries the mark; the rest are plain labels.
trail.forEach((crumb, index) => {
if (index === 1) {
expect(isValidElement(crumb.icon)).toBe(true);
expect((crumb.icon as React.ReactElement).type).toBe(LibraryMark);
} else {
expect(crumb.icon).toBeUndefined();
}
});
});

it('keeps canonical metadata independent of the workspace mode query', async () => {
const metadata = await generateMetadata({
params: Promise.resolve({
Expand Down
Loading
Loading