Просмотр исходного кода

feat(web): add blog section with initial posts (#11127)

* feat(web): add blog section with 4 initial posts

Implements MKT-66 through MKT-74:

Content Layer (MKT-67):
- Markdown files in src/content/blog with Zod-validated frontmatter
- Pacific Time scheduling evaluated at request-time (no deploy needed)
- gray-matter for parsing, react-markdown + remark-gfm for rendering

Blog Pages (MKT-68, MKT-69):
- Index page at /blog with dynamic SSR
- Post page at /blog/[slug] with dynamic SSR
- Breadcrumb navigation and prev/next post navigation

SEO (MKT-70):
- Full OpenGraph and Twitter card metadata
- Schema.org JSON-LD (Article, BreadcrumbList, CollectionPage)
- Canonical URLs pointing to roocode.com/blog

Analytics (MKT-74):
- PostHog blog_post_viewed and blog_index_viewed events
- Referrer tracking for attribution

Navigation (MKT-72):
- Updated nav-bar and footer to link to internal /blog
- Blog link in Resources dropdown

Sitemap (MKT-71):
- Dynamic blog paths with PT scheduling check

Initial Posts:
- PRDs Are Becoming Artifacts of the Past (Jan 12)
- Code Review Got Faster, Not Easier (Jan 19)
- Vibe Coders Build and Rebuild (Jan 26)
- Async Agents Change the Speed vs Quality Calculus (Feb 2)

* fix(test): update HistoryPreview tests to match refactored component

The HistoryPreview component was refactored to use useGroupedTasks and
TaskGroupItem instead of rendering TaskItem directly. This updates the
test file to properly mock the new dependencies:
- Mock useGroupedTasks hook to provide grouped task data
- Mock TaskGroupItem instead of TaskItem
- Update assertions to test for task groups instead of individual tasks

* feat(blog): add Vercel-inspired patterns and Tone of Voice alignment

- Add reading time display to blog posts
- Create BlogPostCTA component with 4 variants (default, extension, cloud, enterprise)
- Add zebra striping to tables in blog posts
- Add CTA to blog landing and paginated pages
- Remove 'Posted' prefix from dates
- Update blog description: 'How teams use agents to iterate, review, and ship PRs with proof'
- Add BlogPostList and BlogPagination components
- Add 100+ new blog posts from content pipeline

* feat(blog): add source badges for podcast content (Office Hours, After Hours, Roo Cast)

- Add BlogSource type to types.ts
- Export BlogSource from blog index
- Add SourceBadge component to BlogPostList with colored badges
- Each podcast has distinct color: blue (Office Hours), purple (After Hours), emerald (Roo Cast)

* feat(blog): add source field to all blog posts (Roo Cast, Office Hours, After Hours)

- Add add-blog-sources.ts script to build title→source mapping
- Updated 122 blog posts with correct podcast sources
- Sources: Roo Cast (52), Office Hours (62), After Hours (8)

* feat(blog): add source badges with consistent styling

- Add source field to Zod validation schema
- Source badges use same styling as tag badges (rounded, greyscale)
- Badges display on /blog landing page for Office Hours, After Hours, Roo Cast

* feat(blog): improve schema.org structured data for SEO

- Change @type from Article to BlogPosting (more specific)
- Add image property using OG image URL
- Add wordCount for AEO optimization

* feat(blog): timestamped YouTube quotes + attribution polish

* chore(blog): update 'Series A team' to 'Series A - C team' and fix 'Tovin' to 'Tovan'

- Changed 22 instances of 'Series A team' to 'Series A - C team' across 20 blog posts
- Changed 12 instances of 'Tovin' to 'Tovan' across 4 blog posts

This broadens the messaging to better represent teams that Roo Code serves (Series A through C).

* ci: retry CI after timeout

* blog: featured posts + copy edits

* blog: remove draft posts from web content

* fix(blog): loop HTML tag stripping to prevent incomplete sanitization

The single-pass .replace(/<[^>]+>/g, "") in calculateReadingTime() was
flagged by CodeQL as vulnerable to incomplete multi-character sanitization.
Input like "<scr<script>ipt>" would still contain "<script" after one pass.

Added a stripHtmlTags() helper that loops the replacement until stable,
plus a final pass to remove any remaining angle brackets.

* fix(blog): replace iterative HTML tag stripping with single-pass angle bracket removal

The CodeQL scanner flagged the iterative stripHtmlTags function for
incomplete multi-character sanitization. The regex /<[^>]+>/g only
matches complete tags, so partial fragments like <script (without a
closing >) could survive intermediate loop iterations.

Since this function is only used for word counting in
calculateReadingTime, replace the multi-step approach with a simple
single-pass removal of all < and > characters. This eliminates the
incomplete sanitization pattern entirely.

---------

Co-authored-by: Roo Code <[email protected]>
Co-authored-by: Michael Preuss <[email protected]>
roomote[bot] 1 месяц назад
Родитель
Сommit
dc243e4cf9
34 измененных файлов с 3658 добавлено и 67 удалено
  1. 94 8
      apps/web-roo-code/next-sitemap.config.cjs
  2. 6 2
      apps/web-roo-code/package.json
  3. 337 0
      apps/web-roo-code/src/app/blog/[slug]/page.tsx
  4. 181 0
      apps/web-roo-code/src/app/blog/page.tsx
  5. 174 0
      apps/web-roo-code/src/app/blog/page/[page]/page.tsx
  6. 50 0
      apps/web-roo-code/src/components/blog/BlogAnalytics.tsx
  7. 241 0
      apps/web-roo-code/src/components/blog/BlogContent.tsx
  8. 66 0
      apps/web-roo-code/src/components/blog/BlogFAQ.tsx
  9. 185 0
      apps/web-roo-code/src/components/blog/BlogPagination.tsx
  10. 225 0
      apps/web-roo-code/src/components/blog/BlogPostCTA.tsx
  11. 63 0
      apps/web-roo-code/src/components/blog/BlogPostList.tsx
  12. 58 0
      apps/web-roo-code/src/components/blog/BlogViewToggle.tsx
  13. 140 0
      apps/web-roo-code/src/components/blog/YouTubeModal.test.ts
  14. 171 0
      apps/web-roo-code/src/components/blog/YouTubeModal.tsx
  15. 7 0
      apps/web-roo-code/src/components/chromes/footer.tsx
  16. 15 0
      apps/web-roo-code/src/components/chromes/nav-bar.tsx
  17. 24 7
      apps/web-roo-code/src/components/providers/posthog-provider.tsx
  18. 116 0
      apps/web-roo-code/src/content/blog/ai-best-practices-spread-through-internal-influencers-not-topdown-mandates.md
  19. 124 0
      apps/web-roo-code/src/content/blog/manage-ai-spend-by-measuring-return-not-cost.md
  20. 120 0
      apps/web-roo-code/src/content/blog/non-engineers-stopped-waiting-for-engineers-to-unblock-them.md
  21. 119 0
      apps/web-roo-code/src/content/blog/over-half-of-googles-production-code-is-now-aigenerated.md
  22. 122 0
      apps/web-roo-code/src/content/blog/prds-are-becoming-artifacts-of-the-past.md
  23. 115 0
      apps/web-roo-code/src/content/blog/score-agents-like-employees-not-like-models.md
  24. 40 0
      apps/web-roo-code/src/lib/blog/analytics.ts
  25. 205 0
      apps/web-roo-code/src/lib/blog/content.ts
  26. 23 0
      apps/web-roo-code/src/lib/blog/curated.ts
  27. 37 0
      apps/web-roo-code/src/lib/blog/index.ts
  28. 158 0
      apps/web-roo-code/src/lib/blog/time.ts
  29. 32 0
      apps/web-roo-code/src/lib/blog/types.ts
  30. 29 0
      apps/web-roo-code/src/lib/blog/validation.ts
  31. 2 1
      apps/web-roo-code/src/lib/constants.ts
  32. 14 0
      apps/web-roo-code/vitest.config.ts
  33. 222 10
      pnpm-lock.yaml
  34. 143 39
      webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx

+ 94 - 8
apps/web-roo-code/next-sitemap.config.cjs

@@ -1,3 +1,68 @@
+const path = require('path');
+const fs = require('fs');
+const matter = require('gray-matter');
+
+/**
+ * Get published blog posts for sitemap
+ * Note: This runs at build time, so recently-scheduled posts may lag
+ */
+function getPublishedBlogPosts() {
+  const BLOG_DIR = path.join(process.cwd(), 'src/content/blog');
+  
+  if (!fs.existsSync(BLOG_DIR)) {
+    return [];
+  }
+  
+  const files = fs.readdirSync(BLOG_DIR).filter(f => f.endsWith('.md'));
+  const posts = [];
+  
+  // Get current time in PT for publish check
+  const formatter = new Intl.DateTimeFormat('en-US', {
+    timeZone: 'America/Los_Angeles',
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    hour12: false,
+  });
+  
+  const parts = formatter.formatToParts(new Date());
+  const get = (type) => parts.find(p => p.type === type)?.value ?? '';
+  const nowDate = `${get('year')}-${get('month')}-${get('day')}`;
+  const nowMinutes = parseInt(get('hour')) * 60 + parseInt(get('minute'));
+  
+  for (const file of files) {
+    const filepath = path.join(BLOG_DIR, file);
+    const raw = fs.readFileSync(filepath, 'utf8');
+    const { data } = matter(raw);
+    
+    // Check if post is published
+    if (data.status !== 'published') continue;
+    
+    // Parse publish time
+    const timeMatch = data.publish_time_pt?.match(/^(1[0-2]|[1-9]):([0-5][0-9])(am|pm)$/i);
+    if (!timeMatch) continue;
+    
+    let hours = parseInt(timeMatch[1]);
+    const mins = parseInt(timeMatch[2]);
+    const isPm = timeMatch[3].toLowerCase() === 'pm';
+    if (hours === 12) hours = isPm ? 12 : 0;
+    else if (isPm) hours += 12;
+    const postMinutes = hours * 60 + mins;
+    
+    // Check if post is past publish date/time
+    const isPublished = nowDate > data.publish_date || 
+      (nowDate === data.publish_date && nowMinutes >= postMinutes);
+    
+    if (isPublished && data.slug) {
+      posts.push(data.slug);
+    }
+  }
+  
+  return posts;
+}
+
 /** @type {import('next-sitemap').IConfig} */
 module.exports = {
   siteUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://roocode.com',
@@ -39,6 +104,12 @@ module.exports = {
     } else if (path === '/privacy' || path === '/terms') {
       priority = 0.5;
       changefreq = 'yearly';
+    } else if (path === '/blog') {
+      priority = 0.8;
+      changefreq = 'weekly';
+    } else if (path.startsWith('/blog/')) {
+      priority = 0.7;
+      changefreq = 'monthly';
     }
     
     return {
@@ -50,24 +121,39 @@ module.exports = {
     };
   },
   additionalPaths: async (config) => {
-    // Add any additional paths that might not be automatically discovered
-    // This is useful for dynamic routes or API-generated pages
+    const result = [];
+    
     // Add the /evals page since it's a dynamic route
-    return [{
+    result.push({
       loc: '/evals',
       changefreq: 'monthly',
       priority: 0.8,
       lastmod: new Date().toISOString(),
-    }];
+    });
     
-    // Add the /evals page since it's a dynamic route
+    // Add /blog index
     result.push({
-      loc: '/evals',
-      changefreq: 'monthly',
+      loc: '/blog',
+      changefreq: 'weekly',
       priority: 0.8,
       lastmod: new Date().toISOString(),
     });
     
+    // Add published blog posts
+    try {
+      const slugs = getPublishedBlogPosts();
+      for (const slug of slugs) {
+        result.push({
+          loc: `/blog/${slug}`,
+          changefreq: 'monthly',
+          priority: 0.7,
+          lastmod: new Date().toISOString(),
+        });
+      }
+    } catch (e) {
+      console.warn('Could not load blog posts for sitemap:', e.message);
+    }
+    
     return result;
   },
-};
+};

+ 6 - 2
apps/web-roo-code/package.json

@@ -9,7 +9,9 @@
 		"build": "next build",
 		"postbuild": "next-sitemap --config next-sitemap.config.cjs",
 		"start": "next start",
-		"clean": "rimraf .next .turbo"
+		"clean": "rimraf .next .turbo",
+		"test": "vitest run",
+		"test:watch": "vitest"
 	},
 	"dependencies": {
 		"@radix-ui/react-dialog": "^1.1.15",
@@ -25,6 +27,7 @@
 		"embla-carousel-autoplay": "^8.6.0",
 		"embla-carousel-react": "^8.6.0",
 		"framer-motion": "^12.29.2",
+		"gray-matter": "^4.0.3",
 		"lucide-react": "^0.563.0",
 		"next": "^16.1.6",
 		"next-themes": "^0.4.6",
@@ -52,6 +55,7 @@
 		"autoprefixer": "^10.4.23",
 		"next-sitemap": "^4.2.3",
 		"postcss": "^8.5.6",
-		"tailwindcss": "^3.4.17"
+		"tailwindcss": "^3.4.17",
+		"vitest": "^4.0.18"
 	}
 }

+ 337 - 0
apps/web-roo-code/src/app/blog/[slug]/page.tsx

@@ -0,0 +1,337 @@
+/**
+ * Blog Post Page
+ * MKT-69: Blog Post Page
+ *
+ * Renders a single blog post from Markdown.
+ * Uses dynamic rendering (force-dynamic) for request-time publish gating.
+ * Does NOT use generateStaticParams to avoid static generation.
+ *
+ * AEO Enhancement: Parses FAQ sections from markdown, renders as accordion,
+ * and generates FAQPage JSON-LD schema for AI search optimization.
+ */
+
+import type { Metadata } from "next"
+import Link from "next/link"
+import { notFound } from "next/navigation"
+import Script from "next/script"
+import { ChevronLeft, ChevronRight, Clock } from "lucide-react"
+import {
+	getBlogPostBySlug,
+	getAdjacentPosts,
+	formatPostDatePt,
+	calculateReadingTime,
+	formatReadingTime,
+} from "@/lib/blog"
+import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
+import { BlogPostAnalytics } from "@/components/blog/BlogAnalytics"
+import { BlogContent } from "@/components/blog/BlogContent"
+import { BlogFAQ, type FAQItem } from "@/components/blog/BlogFAQ"
+import { BlogPostCTA } from "@/components/blog/BlogPostCTA"
+
+// Force dynamic rendering for request-time publish gating
+export const dynamic = "force-dynamic"
+export const runtime = "nodejs"
+
+interface Props {
+	params: Promise<{ slug: string }>
+}
+
+/**
+ * Parse FAQ section from markdown content
+ *
+ * Looks for a section starting with "## Frequently asked questions"
+ * and extracts H3 questions with their content as answers.
+ *
+ * Returns the FAQ items and the content with FAQ section removed.
+ */
+function parseFAQFromMarkdown(content: string): {
+	faqItems: FAQItem[]
+	contentWithoutFAQ: string
+} {
+	// Match FAQ section: ## Frequently asked questions (case-insensitive)
+	const faqSectionRegex = /^## Frequently asked questions\s*$/im
+	const faqMatch = content.match(faqSectionRegex)
+
+	if (!faqMatch || faqMatch.index === undefined) {
+		return { faqItems: [], contentWithoutFAQ: content }
+	}
+
+	const faqStartIndex = faqMatch.index
+	const beforeFAQ = content.slice(0, faqStartIndex).trim()
+	const faqSection = content.slice(faqStartIndex)
+
+	// Find where FAQ section ends (next H2 or end of content)
+	const nextH2Match = faqSection.slice(faqMatch[0].length).match(/^## /m)
+	const faqContent =
+		nextH2Match && nextH2Match.index !== undefined
+			? faqSection.slice(0, faqMatch[0].length + nextH2Match.index)
+			: faqSection
+
+	const afterFAQ =
+		nextH2Match && nextH2Match.index !== undefined ? faqSection.slice(faqMatch[0].length + nextH2Match.index) : ""
+
+	// Parse individual FAQ items (### Question followed by content)
+	const faqItems: FAQItem[] = []
+	const questionRegex = /^### (.+?)$\s*([\s\S]*?)(?=^### |$(?![\s\S]))/gm
+	let match
+
+	while ((match = questionRegex.exec(faqContent)) !== null) {
+		const question = match[1]?.trim()
+		const answer = match[2]?.trim()
+		if (question && answer) {
+			faqItems.push({ question, answer })
+		}
+	}
+
+	const contentWithoutFAQ = (beforeFAQ + "\n\n" + afterFAQ).trim()
+
+	return { faqItems, contentWithoutFAQ }
+}
+
+export async function generateMetadata({ params }: Props): Promise<Metadata> {
+	const { slug } = await params
+	const post = getBlogPostBySlug(slug)
+
+	if (!post) {
+		return {}
+	}
+
+	const path = `/blog/${post.slug}`
+
+	return {
+		title: post.title,
+		description: post.description,
+		alternates: {
+			canonical: `${SEO.url}${path}`,
+		},
+		openGraph: {
+			title: post.title,
+			description: post.description,
+			url: `${SEO.url}${path}`,
+			siteName: SEO.name,
+			images: [
+				{
+					url: ogImageUrl(post.title, post.description),
+					width: 1200,
+					height: 630,
+					alt: post.title,
+				},
+			],
+			locale: SEO.locale,
+			type: "article",
+			publishedTime: post.publish_date,
+		},
+		twitter: {
+			card: SEO.twitterCard,
+			title: post.title,
+			description: post.description,
+			images: [ogImageUrl(post.title, post.description)],
+		},
+		keywords: [...SEO.keywords, ...post.tags],
+	}
+}
+
+export default async function BlogPostPage({ params }: Props) {
+	const { slug } = await params
+	const post = getBlogPostBySlug(slug)
+
+	if (!post) {
+		notFound()
+	}
+
+	const { previous, next } = getAdjacentPosts(slug)
+
+	// Calculate reading time
+	const readingTime = calculateReadingTime(post.content)
+	const readingTimeDisplay = formatReadingTime(readingTime)
+
+	// Parse FAQ section from markdown content
+	const { faqItems, contentWithoutFAQ } = parseFAQFromMarkdown(post.content)
+	const hasFAQ = faqItems.length > 0
+
+	// BlogPosting JSON-LD schema (more specific than Article for SEO)
+	const articleSchema = {
+		"@context": "https://schema.org",
+		"@type": "BlogPosting",
+		headline: post.title,
+		description: post.description,
+		datePublished: post.publish_date,
+		image: ogImageUrl(post.title, post.description),
+		wordCount: post.content.split(/\s+/).filter(Boolean).length,
+		mainEntityOfPage: {
+			"@type": "WebPage",
+			"@id": `${SEO.url}/blog/${post.slug}`,
+		},
+		url: `${SEO.url}/blog/${post.slug}`,
+		author: {
+			"@type": "Organization",
+			"@id": `${SEO.url}#org`,
+			name: SEO.name,
+		},
+		publisher: {
+			"@type": "Organization",
+			"@id": `${SEO.url}#org`,
+			name: SEO.name,
+			logo: {
+				"@type": "ImageObject",
+				url: `${SEO.url}/android-chrome-512x512.png`,
+			},
+		},
+	}
+
+	// Breadcrumb schema
+	const breadcrumbSchema = {
+		"@context": "https://schema.org",
+		"@type": "BreadcrumbList",
+		itemListElement: [
+			{
+				"@type": "ListItem",
+				position: 1,
+				name: "Home",
+				item: SEO.url,
+			},
+			{
+				"@type": "ListItem",
+				position: 2,
+				name: "Blog",
+				item: `${SEO.url}/blog`,
+			},
+			{
+				"@type": "ListItem",
+				position: 3,
+				name: post.title,
+				item: `${SEO.url}/blog/${post.slug}`,
+			},
+		],
+	}
+
+	// FAQPage schema (only if post has FAQ section) - AEO optimization
+	const faqSchema = hasFAQ
+		? {
+				"@context": "https://schema.org",
+				"@type": "FAQPage",
+				mainEntity: faqItems.map((item) => ({
+					"@type": "Question",
+					name: item.question,
+					acceptedAnswer: {
+						"@type": "Answer",
+						text: item.answer,
+					},
+				})),
+			}
+		: null
+
+	return (
+		<>
+			<Script
+				id="article-schema"
+				type="application/ld+json"
+				dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
+			/>
+			<Script
+				id="breadcrumb-schema"
+				type="application/ld+json"
+				dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+			/>
+			{faqSchema && (
+				<Script
+					id="faq-schema"
+					type="application/ld+json"
+					dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
+				/>
+			)}
+
+			<BlogPostAnalytics post={post} />
+
+			<article className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
+				<div className="mx-auto max-w-4xl">
+					{/* Visual Breadcrumb Navigation */}
+					<nav aria-label="Breadcrumb" className="mb-8">
+						<ol className="flex items-center gap-1 text-sm text-muted-foreground">
+							<li>
+								<Link href="/blog" className="transition-colors hover:text-foreground">
+									Blog
+								</Link>
+							</li>
+							<li>
+								<ChevronRight className="h-4 w-4" />
+							</li>
+							<li className="truncate text-foreground" aria-current="page">
+								{post.title}
+							</li>
+						</ol>
+					</nav>
+
+					<div className="prose prose-lg dark:prose-invert">
+						<header className="not-prose mb-8">
+							<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">{post.title}</h1>
+							<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
+								<span>{formatPostDatePt(post.publish_date)}</span>
+								<span className="text-border">•</span>
+								<span className="flex items-center gap-1">
+									<Clock className="h-4 w-4" />
+									{readingTimeDisplay}
+								</span>
+							</div>
+							{post.tags.length > 0 && (
+								<div className="mt-4 flex flex-wrap gap-2">
+									{post.tags.map((tag) => (
+										<span
+											key={tag}
+											className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground">
+											{tag}
+										</span>
+									))}
+								</div>
+							)}
+						</header>
+
+						<BlogContent content={contentWithoutFAQ} />
+
+						{/* FAQ Section rendered as accordion */}
+						{hasFAQ && <BlogFAQ items={faqItems} />}
+
+						{/* Product CTA Module - Inspired by Vercel's blog design
+						    Default variant prioritizes Roo Code Cloud sign-up */}
+						<BlogPostCTA />
+					</div>
+
+					{/* Previous/Next Post Navigation */}
+					{(previous || next) && (
+						<nav aria-label="Post navigation" className="mt-12 border-t border-border pt-8">
+							<div className="flex flex-col gap-4 sm:flex-row sm:justify-between">
+								{previous ? (
+									<Link
+										href={`/blog/${previous.slug}`}
+										className="group flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground">
+										<ChevronLeft className="h-4 w-4 transition-transform group-hover:-translate-x-1" />
+										<div className="flex flex-col">
+											<span className="text-xs uppercase tracking-wide">Previous</span>
+											<span className="font-medium text-foreground">{previous.title}</span>
+										</div>
+									</Link>
+								) : (
+									<div />
+								)}
+								{next ? (
+									<Link
+										href={`/blog/${next.slug}`}
+										className="group flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground sm:flex-row-reverse sm:text-right">
+										<ChevronRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
+										<div className="flex flex-col">
+											<span className="text-xs uppercase tracking-wide">Next</span>
+											<span className="font-medium text-foreground">{next.title}</span>
+										</div>
+									</Link>
+								) : (
+									<div />
+								)}
+							</div>
+						</nav>
+					)}
+				</div>
+			</article>
+		</>
+	)
+}

+ 181 - 0
apps/web-roo-code/src/app/blog/page.tsx

@@ -0,0 +1,181 @@
+/**
+ * Blog Index Page
+ * MKT-68: Blog Index Page
+ *
+ * Lists published blog posts with two views:
+ * - Featured (default): Curated 24 posts aligned with Roo Code positioning
+ * - All: Full list with pagination (12 posts per page)
+ *
+ * Uses dynamic rendering (force-dynamic) for request-time publish gating.
+ */
+
+import type { Metadata } from "next"
+import Script from "next/script"
+import { Suspense } from "react"
+import { getPaginatedBlogPosts, getAllBlogPosts, getCuratedBlogPosts } from "@/lib/blog"
+import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
+import { BlogIndexAnalytics } from "@/components/blog/BlogAnalytics"
+import { BlogPostList } from "@/components/blog/BlogPostList"
+import { BlogPagination } from "@/components/blog/BlogPagination"
+import { BlogPostCTA } from "@/components/blog/BlogPostCTA"
+import { BlogViewToggle } from "@/components/blog/BlogViewToggle"
+
+// Force dynamic rendering for request-time publish gating
+export const dynamic = "force-dynamic"
+export const runtime = "nodejs"
+
+const TITLE = "Blog"
+const DESCRIPTION = "How teams use agents to iterate, review, and ship PRs with proof."
+const PATH = "/blog"
+
+export const metadata: Metadata = {
+	title: TITLE,
+	description: DESCRIPTION,
+	alternates: {
+		canonical: `${SEO.url}${PATH}`,
+	},
+	openGraph: {
+		title: TITLE,
+		description: DESCRIPTION,
+		url: `${SEO.url}${PATH}`,
+		siteName: SEO.name,
+		images: [
+			{
+				url: ogImageUrl(TITLE, DESCRIPTION),
+				width: 1200,
+				height: 630,
+				alt: TITLE,
+			},
+		],
+		locale: SEO.locale,
+		type: "website",
+	},
+	twitter: {
+		card: SEO.twitterCard,
+		title: TITLE,
+		description: DESCRIPTION,
+		images: [ogImageUrl(TITLE, DESCRIPTION)],
+	},
+	keywords: [...SEO.keywords, "blog", "articles", "engineering", "AI development"],
+}
+
+interface Props {
+	searchParams: Promise<{ view?: string; page?: string }>
+}
+
+export default async function BlogIndexPage({ searchParams }: Props) {
+	const params = await searchParams
+	const view = params.view === "all" ? "all" : "featured"
+	const pageParam = params.page ? parseInt(params.page, 10) : 1
+
+	// Get all posts for counts and schema
+	const allPosts = getAllBlogPosts()
+	const curatedPosts = getCuratedBlogPosts()
+
+	// Determine which posts to display based on view
+	let displayPosts
+	let currentPage = 1
+	let totalPages = 1
+	let showPagination = false
+
+	if (view === "all") {
+		const paginated = getPaginatedBlogPosts(pageParam)
+		displayPosts = paginated.posts
+		currentPage = paginated.currentPage
+		totalPages = paginated.totalPages
+		showPagination = totalPages > 1
+	} else {
+		// Featured view shows curated posts without pagination
+		displayPosts = curatedPosts
+	}
+
+	// Schema.org CollectionPage + ItemList (includes all posts for SEO)
+	const blogSchema = {
+		"@context": "https://schema.org",
+		"@type": "CollectionPage",
+		name: TITLE,
+		description: DESCRIPTION,
+		url: `${SEO.url}${PATH}`,
+		mainEntity: {
+			"@type": "ItemList",
+			itemListElement: allPosts.map((post, index) => ({
+				"@type": "ListItem",
+				position: index + 1,
+				url: `${SEO.url}/blog/${post.slug}`,
+				name: post.title,
+			})),
+		},
+	}
+
+	// Breadcrumb schema
+	const breadcrumbSchema = {
+		"@context": "https://schema.org",
+		"@type": "BreadcrumbList",
+		itemListElement: [
+			{
+				"@type": "ListItem",
+				position: 1,
+				name: "Home",
+				item: SEO.url,
+			},
+			{
+				"@type": "ListItem",
+				position: 2,
+				name: "Blog",
+				item: `${SEO.url}${PATH}`,
+			},
+		],
+	}
+
+	return (
+		<>
+			<Script
+				id="blog-schema"
+				type="application/ld+json"
+				dangerouslySetInnerHTML={{ __html: JSON.stringify(blogSchema) }}
+			/>
+			<Script
+				id="breadcrumb-schema"
+				type="application/ld+json"
+				dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+			/>
+
+			<BlogIndexAnalytics />
+
+			<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
+				<div className="mx-auto max-w-4xl">
+					<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">Blog</h1>
+					<p className="mt-4 text-lg text-muted-foreground">{DESCRIPTION}</p>
+
+					{/* View Toggle */}
+					<Suspense
+						fallback={
+							<div className="mt-6 flex items-center gap-4">
+								<div className="h-11 w-64 animate-pulse rounded-lg bg-muted" />
+							</div>
+						}>
+						<BlogViewToggle curatedCount={curatedPosts.length} totalCount={allPosts.length} />
+					</Suspense>
+
+					{/* Post count indicator for "all" view */}
+					{view === "all" && totalPages > 1 && (
+						<p className="mt-4 text-sm text-muted-foreground">
+							Showing {displayPosts.length} of {allPosts.length} posts
+						</p>
+					)}
+
+					<BlogPostList posts={displayPosts} />
+
+					{/* Pagination only for "all" view */}
+					{showPagination && (
+						<BlogPagination currentPage={currentPage} totalPages={totalPages} useQueryParams />
+					)}
+
+					{/* Cloud CTA - shown after posts */}
+					<BlogPostCTA />
+				</div>
+			</div>
+		</>
+	)
+}

+ 174 - 0
apps/web-roo-code/src/app/blog/page/[page]/page.tsx

@@ -0,0 +1,174 @@
+/**
+ * Blog Paginated Index Page
+ * Handles pages 2+ of the blog listing
+ *
+ * URL structure: /blog/page/2, /blog/page/3, etc.
+ * Page 1 redirects to /blog for canonical URL consistency.
+ */
+
+import type { Metadata } from "next"
+import { notFound, redirect } from "next/navigation"
+import Script from "next/script"
+import { getPaginatedBlogPosts, getAllBlogPosts, POSTS_PER_PAGE } from "@/lib/blog"
+import { SEO } from "@/lib/seo"
+import { ogImageUrl } from "@/lib/og"
+import { BlogIndexAnalytics } from "@/components/blog/BlogAnalytics"
+import { BlogPostList } from "@/components/blog/BlogPostList"
+import { BlogPagination } from "@/components/blog/BlogPagination"
+import { BlogPostCTA } from "@/components/blog/BlogPostCTA"
+
+// Force dynamic rendering for request-time publish gating
+export const dynamic = "force-dynamic"
+export const runtime = "nodejs"
+
+interface PageProps {
+	params: Promise<{ page: string }>
+}
+
+const BASE_TITLE = "Blog"
+const DESCRIPTION = "How teams use agents to iterate, review, and ship PRs with proof."
+const BASE_PATH = "/blog"
+
+export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
+	const { page: pageParam } = await params
+	const pageNumber = parseInt(pageParam, 10)
+	const title = `${BASE_TITLE} - Page ${pageNumber}`
+	const path = `${BASE_PATH}/page/${pageNumber}`
+
+	return {
+		title,
+		description: DESCRIPTION,
+		alternates: {
+			canonical: `${SEO.url}${path}`,
+		},
+		openGraph: {
+			title,
+			description: DESCRIPTION,
+			url: `${SEO.url}${path}`,
+			siteName: SEO.name,
+			images: [
+				{
+					url: ogImageUrl(title, DESCRIPTION),
+					width: 1200,
+					height: 630,
+					alt: title,
+				},
+			],
+			locale: SEO.locale,
+			type: "website",
+		},
+		twitter: {
+			card: SEO.twitterCard,
+			title,
+			description: DESCRIPTION,
+			images: [ogImageUrl(title, DESCRIPTION)],
+		},
+		keywords: [...SEO.keywords, "blog", "articles", "engineering", "AI development"],
+		robots: {
+			index: true,
+			follow: true,
+		},
+	}
+}
+
+/**
+ * Generate static params for known pages at build time
+ * This helps with initial page load performance
+ */
+export async function generateStaticParams() {
+	const allPosts = getAllBlogPosts()
+	const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE)
+
+	// Generate params for pages 2 through totalPages
+	const params = []
+	for (let page = 2; page <= totalPages; page++) {
+		params.push({ page: page.toString() })
+	}
+
+	return params
+}
+
+export default async function BlogPaginatedPage({ params }: PageProps) {
+	const { page: pageParam } = await params
+	const pageNumber = parseInt(pageParam, 10)
+
+	// Validate page number
+	if (isNaN(pageNumber) || pageNumber < 1) {
+		notFound()
+	}
+
+	// Redirect page 1 to /blog for canonical URL
+	if (pageNumber === 1) {
+		redirect("/blog")
+	}
+
+	const { posts, currentPage, totalPages, totalPosts } = getPaginatedBlogPosts(pageNumber)
+
+	// If page is beyond total pages, 404
+	if (pageNumber > totalPages && totalPages > 0) {
+		notFound()
+	}
+
+	const title = `${BASE_TITLE} - Page ${pageNumber}`
+	const path = `${BASE_PATH}/page/${pageNumber}`
+
+	// Breadcrumb schema
+	const breadcrumbSchema = {
+		"@context": "https://schema.org",
+		"@type": "BreadcrumbList",
+		itemListElement: [
+			{
+				"@type": "ListItem",
+				position: 1,
+				name: "Home",
+				item: SEO.url,
+			},
+			{
+				"@type": "ListItem",
+				position: 2,
+				name: "Blog",
+				item: `${SEO.url}${BASE_PATH}`,
+			},
+			{
+				"@type": "ListItem",
+				position: 3,
+				name: `Page ${pageNumber}`,
+				item: `${SEO.url}${path}`,
+			},
+		],
+	}
+
+	// Calculate post range for display
+	const startPost = (currentPage - 1) * POSTS_PER_PAGE + 1
+	const endPost = Math.min(currentPage * POSTS_PER_PAGE, totalPosts)
+
+	return (
+		<>
+			<Script
+				id="breadcrumb-schema"
+				type="application/ld+json"
+				dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+			/>
+
+			<BlogIndexAnalytics />
+
+			<div className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
+				<div className="mx-auto max-w-4xl">
+					<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">{title}</h1>
+					<p className="mt-4 text-lg text-muted-foreground">{DESCRIPTION}</p>
+
+					<p className="mt-2 text-sm text-muted-foreground">
+						Showing posts {startPost}-{endPost} of {totalPosts}
+					</p>
+
+					<BlogPostList posts={posts} />
+
+					<BlogPagination currentPage={currentPage} totalPages={totalPages} />
+
+					{/* Cloud CTA - shown after pagination */}
+					<BlogPostCTA />
+				</div>
+			</div>
+		</>
+	)
+}

+ 50 - 0
apps/web-roo-code/src/components/blog/BlogAnalytics.tsx

@@ -0,0 +1,50 @@
+"use client"
+
+/**
+ * Client-side blog analytics components
+ * MKT-74: Blog Analytics (PostHog)
+ */
+
+import { useEffect } from "react"
+import { trackBlogIndexView, trackBlogPostView } from "@/lib/blog/analytics"
+import type { BlogPost } from "@/lib/blog/types"
+
+/**
+ * Tracks blog index page view
+ * Place this component on the blog index page
+ */
+export function BlogIndexAnalytics() {
+	useEffect(() => {
+		trackBlogIndexView()
+	}, [])
+
+	return null
+}
+
+/**
+ * Tracks individual blog post view
+ * Place this component on blog post pages
+ */
+export function BlogPostAnalytics({ post }: { post: BlogPost }) {
+	useEffect(() => {
+		trackBlogPostView(post)
+	}, [post])
+
+	return null
+}
+
+/**
+ * Serializable post data for client component
+ * Use this type when passing post data to BlogPostAnalytics
+ */
+export interface SerializablePostData {
+	slug: string
+	title: string
+	publish_date: string
+	publish_time_pt: string
+	tags: string[]
+	status: "draft" | "published"
+	description: string
+	content: string
+	filepath: string
+}

+ 241 - 0
apps/web-roo-code/src/components/blog/BlogContent.tsx

@@ -0,0 +1,241 @@
+"use client"
+
+import * as React from "react"
+import ReactMarkdown from "react-markdown"
+import remarkGfm from "remark-gfm"
+import { Play } from "lucide-react"
+import { YouTubeModal, isYouTubeUrl, extractYouTubeVideoId, extractYouTubeTimestamp } from "./YouTubeModal"
+
+type HastLikeNode = {
+	type?: unknown
+	tagName?: unknown
+	url?: unknown
+	properties?: unknown
+	children?: unknown
+}
+
+function getNodeUrl(node: HastLikeNode): string | null {
+	// mdast link node (remark)
+	if (node.type === "link" && typeof node.url === "string") return node.url
+
+	// hast anchor element (rehype)
+	if (node.type === "element" && node.tagName === "a") {
+		const props = node.properties as { href?: unknown } | undefined
+		if (typeof props?.href === "string") return props.href
+	}
+
+	return null
+}
+
+function nodeHasYouTubeLink(node: unknown): boolean {
+	if (!node || typeof node !== "object") return false
+
+	const anyNode = node as HastLikeNode
+	const url = getNodeUrl(anyNode)
+	if (url && isYouTubeUrl(url)) {
+		return true
+	}
+
+	const children = (anyNode as { children?: unknown }).children
+	if (Array.isArray(children)) {
+		return children.some(nodeHasYouTubeLink)
+	}
+
+	return false
+}
+
+interface BlogContentProps {
+	/** The markdown content to render */
+	content: string
+}
+
+/**
+ * State for the currently active YouTube video modal
+ */
+interface YouTubeModalState {
+	isOpen: boolean
+	videoId: string
+	startTime: number
+	title: string
+}
+
+/**
+ * BlogContent component
+ *
+ * Renders markdown content with special handling for YouTube links.
+ * YouTube links open in a modal with embedded video player instead of
+ * navigating away from the page.
+ *
+ * @example
+ * ```tsx
+ * <BlogContent content={markdownString} />
+ * ```
+ */
+export function BlogContent({ content }: BlogContentProps) {
+	const [youtubeModal, setYoutubeModal] = React.useState<YouTubeModalState>({
+		isOpen: false,
+		videoId: "",
+		startTime: 0,
+		title: "",
+	})
+
+	/**
+	 * Opens the YouTube modal with the specified video
+	 */
+	const openYouTubeModal = React.useCallback((url: string, linkText: string) => {
+		const videoId = extractYouTubeVideoId(url)
+		if (!videoId) return
+
+		const startTime = extractYouTubeTimestamp(url)
+
+		setYoutubeModal({
+			isOpen: true,
+			videoId,
+			startTime,
+			title: linkText,
+		})
+	}, [])
+
+	/**
+	 * Closes the YouTube modal
+	 */
+	const closeYouTubeModal = React.useCallback(() => {
+		setYoutubeModal((prev) => ({ ...prev, isOpen: false }))
+	}, [])
+
+	return (
+		<>
+			<ReactMarkdown
+				remarkPlugins={[remarkGfm]}
+				components={{
+					// Custom heading styles - note: h1 in content becomes h2 to preserve single H1
+					h1: ({ node: _node, ...props }) => <h2 className="mt-12 text-2xl font-bold" {...props} />,
+					h2: ({ node: _node, ...props }) => <h2 className="mt-12 text-2xl font-bold" {...props} />,
+					h3: ({ node: _node, ...props }) => <h3 className="mt-8 text-xl font-semibold" {...props} />,
+					// Custom link component with YouTube modal support
+					a: ({ href, children, node: _node }) => {
+						const url = href ?? ""
+						const linkText =
+							typeof children === "string"
+								? children
+								: Array.isArray(children)
+									? children.join("")
+									: "YouTube Video"
+
+						// Check if this is a YouTube link
+						if (isYouTubeUrl(url)) {
+							return (
+								<button
+									type="button"
+									onClick={(e) => {
+										e.preventDefault()
+										openYouTubeModal(url, linkText)
+									}}
+									className="inline-flex items-baseline gap-1 text-primary hover:underline">
+									<Play className="relative top-px h-4 w-4" />
+									{children}
+								</button>
+							)
+						}
+
+						// Regular external link - opens in new tab
+						return (
+							<a
+								className="text-primary hover:underline"
+								target="_blank"
+								rel="noopener noreferrer"
+								href={href}>
+								{children}
+							</a>
+						)
+					},
+					// Styled blockquotes
+					blockquote: ({ node, ...props }) => {
+						const children = (node as { children?: unknown[] } | undefined)?.children
+						const lastNonTextChild = Array.isArray(children)
+							? [...children].reverse().find((c) => {
+									if (!c || typeof c !== "object") return false
+									const anyChild = c as { type?: unknown }
+									// mdast uses "paragraph"; hast uses "element"
+									return anyChild.type === "paragraph" || anyChild.type === "element"
+								})
+							: null
+						const isAttributedQuote = nodeHasYouTubeLink(lastNonTextChild)
+
+						return (
+							<blockquote
+								className={[
+									// Opt out of Tailwind Typography's automatic quote marks for blockquotes.
+									"not-prose my-6 border-l-4 border-primary pl-4 text-muted-foreground",
+									// Normalize paragraph spacing inside blockquotes regardless of our global <p> renderer.
+									"[&>p]:m-0 [&>p+ p]:mt-4",
+									isAttributedQuote
+										? [
+												// Quote text reads well in italics, but the attribution line shouldn't.
+												"[&>p:not(:last-child)]:italic",
+												"[&>p:last-child]:not-italic",
+											].join(" ")
+										: "italic",
+								].join(" ")}
+								{...props}
+							/>
+						)
+					},
+					// Code blocks
+					code: ({ className, children, node: _node, ...props }) => {
+						const isInline = !className
+						if (isInline) {
+							return (
+								<code className="rounded bg-muted px-1.5 py-0.5 text-sm" {...props}>
+									{children}
+								</code>
+							)
+						}
+						return (
+							<code className={className} {...props}>
+								{children}
+							</code>
+						)
+					},
+					// Strong text
+					strong: ({ node: _node, ...props }) => <strong className="font-semibold" {...props} />,
+					// Paragraphs
+					p: ({ node: _node, ...props }) => <p className="leading-7 [&:not(:first-child)]:mt-6" {...props} />,
+					// Lists
+					ul: ({ node: _node, ...props }) => <ul className="my-6 ml-6 list-disc [&>li]:mt-2" {...props} />,
+					ol: ({ node: _node, ...props }) => <ol className="my-6 ml-6 list-decimal [&>li]:mt-2" {...props} />,
+					// Tables with zebra striping (visible in both light and dark mode)
+					table: ({ node: _node, ...props }) => (
+						<div className="not-prose my-6 w-full overflow-x-auto rounded-lg border border-border">
+							<table className="w-full border-collapse text-sm" {...props} />
+						</div>
+					),
+					thead: ({ node: _node, ...props }) => <thead className="bg-muted" {...props} />,
+					tbody: ({ node: _node, ...props }) => <tbody {...props} />,
+					tr: ({ node: _node, ...props }) => (
+						<tr
+							className="border-b border-border last:border-b-0 transition-colors even:bg-muted/70 hover:bg-muted"
+							{...props}
+						/>
+					),
+					th: ({ node: _node, ...props }) => (
+						<th className="px-4 py-3 text-left font-semibold text-foreground" {...props} />
+					),
+					td: ({ node: _node, ...props }) => <td className="px-4 py-3" {...props} />,
+				}}>
+				{content}
+			</ReactMarkdown>
+
+			{/* YouTube Video Modal */}
+			<YouTubeModal
+				open={youtubeModal.isOpen}
+				onOpenChange={closeYouTubeModal}
+				videoId={youtubeModal.videoId}
+				startTime={youtubeModal.startTime}
+				title={youtubeModal.title}
+			/>
+		</>
+	)
+}
+
+export default BlogContent

+ 66 - 0
apps/web-roo-code/src/components/blog/BlogFAQ.tsx

@@ -0,0 +1,66 @@
+"use client"
+
+import { useState } from "react"
+import { ChevronDown } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+export interface FAQItem {
+	question: string
+	answer: string
+}
+
+interface BlogFAQProps {
+	items: FAQItem[]
+}
+
+/**
+ * BlogFAQ - Accordion-style FAQ section for blog posts
+ *
+ * Renders FAQ items in a collapsible accordion format to reduce page length
+ * while maintaining full content visibility for AI crawlers (server-rendered).
+ */
+export function BlogFAQ({ items }: BlogFAQProps) {
+	const [openIndex, setOpenIndex] = useState<number | null>(null)
+
+	const toggleFAQ = (index: number) => {
+		setOpenIndex(openIndex === index ? null : index)
+	}
+
+	if (items.length === 0) return null
+
+	return (
+		<section className="mt-12 not-prose">
+			<h2 className="text-2xl font-bold mb-6">Frequently asked questions</h2>
+			<div className="space-y-3">
+				{items.map((item, index) => (
+					<div key={index} className="rounded-lg border border-border bg-card/50 overflow-hidden">
+						<button
+							onClick={() => toggleFAQ(index)}
+							className="flex w-full items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
+							aria-expanded={openIndex === index}>
+							<h3 className="text-base font-medium text-foreground pr-4">{item.question}</h3>
+							<ChevronDown
+								className={cn(
+									"h-5 w-5 flex-shrink-0 text-muted-foreground transition-transform duration-200",
+									openIndex === index ? "rotate-180" : "",
+								)}
+							/>
+						</button>
+						<div
+							className={cn(
+								"overflow-hidden transition-all duration-300 ease-in-out",
+								openIndex === index ? "max-h-[500px]" : "max-h-0",
+							)}>
+							<div className="px-4 pb-4 text-muted-foreground leading-relaxed">{item.answer}</div>
+						</div>
+						{/* Hidden content for crawlers - ensures FAQ content is always in the DOM */}
+						<div className="sr-only" aria-hidden="true">
+							<p>{item.question}</p>
+							<p>{item.answer}</p>
+						</div>
+					</div>
+				))}
+			</div>
+		</section>
+	)
+}

+ 185 - 0
apps/web-roo-code/src/components/blog/BlogPagination.tsx

@@ -0,0 +1,185 @@
+/**
+ * Blog Pagination Component
+ * Provides navigation between paginated blog listing pages
+ */
+
+import Link from "next/link"
+import { ChevronLeft, ChevronRight } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface BlogPaginationProps {
+	currentPage: number
+	totalPages: number
+	basePath?: string
+	/** If true, use query params (?view=all&page=N) instead of path-based pagination */
+	useQueryParams?: boolean
+}
+
+/**
+ * Generates the URL for a given page number
+ * Default: Page 1 goes to /blog, pages 2+ go to /blog/page/N
+ * With useQueryParams: Uses /blog?view=all or /blog?view=all&page=N
+ */
+function getPageUrl(page: number, basePath: string, useQueryParams?: boolean): string {
+	if (useQueryParams) {
+		if (page === 1) {
+			return "/blog?view=all"
+		}
+		return `/blog?view=all&page=${page}`
+	}
+
+	if (page === 1) {
+		return basePath
+	}
+	return `${basePath}/page/${page}`
+}
+
+/**
+ * Generates page numbers to display
+ * Shows: first, last, current, and neighbors with ellipsis for gaps
+ */
+function getPageNumbers(currentPage: number, totalPages: number): (number | "ellipsis")[] {
+	const pages: (number | "ellipsis")[] = []
+
+	if (totalPages <= 7) {
+		// Show all pages if 7 or fewer
+		for (let i = 1; i <= totalPages; i++) {
+			pages.push(i)
+		}
+		return pages
+	}
+
+	// Always show first page
+	pages.push(1)
+
+	// Calculate range around current page
+	const leftBound = Math.max(2, currentPage - 1)
+	const rightBound = Math.min(totalPages - 1, currentPage + 1)
+
+	// Add ellipsis before range if needed
+	if (leftBound > 2) {
+		pages.push("ellipsis")
+	}
+
+	// Add pages in range
+	for (let i = leftBound; i <= rightBound; i++) {
+		pages.push(i)
+	}
+
+	// Add ellipsis after range if needed
+	if (rightBound < totalPages - 1) {
+		pages.push("ellipsis")
+	}
+
+	// Always show last page
+	pages.push(totalPages)
+
+	return pages
+}
+
+export function BlogPagination({ currentPage, totalPages, basePath = "/blog", useQueryParams }: BlogPaginationProps) {
+	if (totalPages <= 1) {
+		return null
+	}
+
+	const pageNumbers = getPageNumbers(currentPage, totalPages)
+	const hasPreviousPage = currentPage > 1
+	const hasNextPage = currentPage < totalPages
+
+	return (
+		<nav aria-label="Blog pagination" className="mt-12 flex items-center justify-center gap-1 sm:gap-2">
+			{/* Previous button */}
+			{hasPreviousPage ? (
+				<Link
+					href={getPageUrl(currentPage - 1, basePath, useQueryParams)}
+					className={cn(
+						"flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium",
+						"text-muted-foreground hover:bg-muted hover:text-foreground",
+						"transition-colors",
+					)}
+					aria-label="Go to previous page">
+					<ChevronLeft className="h-4 w-4" />
+					<span className="hidden sm:inline">Previous</span>
+				</Link>
+			) : (
+				<span
+					className={cn(
+						"flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium",
+						"cursor-not-allowed text-muted-foreground/50",
+					)}
+					aria-disabled="true">
+					<ChevronLeft className="h-4 w-4" />
+					<span className="hidden sm:inline">Previous</span>
+				</span>
+			)}
+
+			{/* Page numbers */}
+			<div className="flex items-center gap-1">
+				{pageNumbers.map((page, index) => {
+					if (page === "ellipsis") {
+						return (
+							<span key={`ellipsis-${index}`} className="px-2 py-2 text-sm text-muted-foreground">
+								...
+							</span>
+						)
+					}
+
+					const isCurrentPage = page === currentPage
+
+					if (isCurrentPage) {
+						return (
+							<span
+								key={page}
+								className={cn(
+									"flex h-9 w-9 items-center justify-center rounded-md text-sm font-medium",
+									"bg-primary text-primary-foreground",
+								)}
+								aria-current="page">
+								{page}
+							</span>
+						)
+					}
+
+					return (
+						<Link
+							key={page}
+							href={getPageUrl(page, basePath, useQueryParams)}
+							className={cn(
+								"flex h-9 w-9 items-center justify-center rounded-md text-sm font-medium",
+								"text-muted-foreground hover:bg-muted hover:text-foreground",
+								"transition-colors",
+							)}
+							aria-label={`Go to page ${page}`}>
+							{page}
+						</Link>
+					)
+				})}
+			</div>
+
+			{/* Next button */}
+			{hasNextPage ? (
+				<Link
+					href={getPageUrl(currentPage + 1, basePath, useQueryParams)}
+					className={cn(
+						"flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium",
+						"text-muted-foreground hover:bg-muted hover:text-foreground",
+						"transition-colors",
+					)}
+					aria-label="Go to next page">
+					<span className="hidden sm:inline">Next</span>
+					<ChevronRight className="h-4 w-4" />
+				</Link>
+			) : (
+				<span
+					className={cn(
+						"flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium",
+						"cursor-not-allowed text-muted-foreground/50",
+					)}
+					aria-disabled="true">
+					<span className="hidden sm:inline">Next</span>
+					<ChevronRight className="h-4 w-4" />
+				</span>
+			)}
+		</nav>
+	)
+}

+ 225 - 0
apps/web-roo-code/src/components/blog/BlogPostCTA.tsx

@@ -0,0 +1,225 @@
+/**
+ * Blog Post CTA Component
+ * Inspired by Vercel's blog design - contextual call-to-action modules
+ *
+ * Provides end-of-article product CTAs to help convert readers into users.
+ */
+
+import Link from "next/link"
+import { ArrowRight, Sparkles, Code2, GitPullRequest } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { EXTERNAL_LINKS } from "@/lib/constants"
+
+interface CTALink {
+	label: string
+	href: string
+	description?: string
+	external?: boolean
+}
+
+interface BlogPostCTAProps {
+	/** The main headline for the CTA module */
+	headline?: string
+	/** Description text below the headline */
+	description?: string
+	/** Primary CTA button text */
+	primaryButtonText?: string
+	/** Primary CTA button link */
+	primaryButtonHref?: string
+	/** Secondary CTA button text */
+	secondaryButtonText?: string
+	/** Secondary CTA button link */
+	secondaryButtonHref?: string
+	/** Optional list of related links to show */
+	links?: CTALink[]
+	/** Variant style for the CTA */
+	variant?: "default" | "extension" | "cloud" | "enterprise"
+}
+
+const variantConfig = {
+	// Default prioritizes Cloud sign-up with workflow-truth messaging
+	default: {
+		headline: "Stop being the human glue between PRs",
+		description:
+			"Cloud Agents review code, catch issues, and suggest fixes before you open the diff. You review the results, not the process.",
+		primaryText: "Try Cloud Free",
+		primaryHref: EXTERNAL_LINKS.CLOUD_APP_SIGNUP_HOME,
+		secondaryText: "See How It Works",
+		secondaryHref: "/cloud",
+		icon: Sparkles,
+	},
+	extension: {
+		headline: "Stop copy-pasting between terminal and chat",
+		description:
+			"The agent runs commands, sees the output, and iterates until the tests pass. You review the diff and approve.",
+		primaryText: "Install for VS Code",
+		primaryHref: EXTERNAL_LINKS.MARKETPLACE,
+		secondaryText: "View Docs",
+		secondaryHref: EXTERNAL_LINKS.DOCUMENTATION,
+		icon: Code2,
+	},
+	cloud: {
+		headline: "Let Cloud Agents handle the review queue",
+		description:
+			"PR Reviewer checks out your branch, runs your linters, and leaves inline suggestions. You decide what to merge.",
+		primaryText: "Start Free",
+		primaryHref: EXTERNAL_LINKS.CLOUD_APP_SIGNUP_HOME,
+		secondaryText: "View Pricing",
+		secondaryHref: "/pricing",
+		icon: Sparkles,
+	},
+	enterprise: {
+		headline: "Thinking about security, compliance, or adoption?",
+		description:
+			"We're explicit about data handling, control boundaries, and what runs where. Talk to us about your constraints.",
+		primaryText: "Talk to Sales",
+		primaryHref: "/enterprise",
+		secondaryText: "View Trust Center",
+		secondaryHref: EXTERNAL_LINKS.SECURITY,
+		icon: GitPullRequest,
+	},
+}
+
+/**
+ * A contextual CTA module for blog posts
+ * Inspired by Vercel's "Get started" modules at the end of blog posts
+ */
+export function BlogPostCTA({
+	headline,
+	description,
+	primaryButtonText,
+	primaryButtonHref,
+	secondaryButtonText,
+	secondaryButtonHref,
+	links,
+	variant = "default",
+}: BlogPostCTAProps) {
+	const config = variantConfig[variant]
+	const Icon = config.icon
+
+	const finalHeadline = headline ?? config.headline
+	const finalDescription = description ?? config.description
+	const finalPrimaryText = primaryButtonText ?? config.primaryText
+	const finalPrimaryHref = primaryButtonHref ?? config.primaryHref
+	const finalSecondaryText = secondaryButtonText ?? config.secondaryText
+	const finalSecondaryHref = secondaryButtonHref ?? config.secondaryHref
+
+	const isExternalPrimary = finalPrimaryHref.startsWith("http")
+	const isExternalSecondary = finalSecondaryHref.startsWith("http")
+
+	return (
+		<div className="not-prose mt-12 rounded-xl border border-border bg-muted/30 p-6 sm:p-8">
+			<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:gap-8">
+				{/* Icon */}
+				<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
+					<Icon className="h-6 w-6 text-primary" />
+				</div>
+
+				{/* Content */}
+				<div className="flex-1">
+					<h3 className="text-xl font-semibold text-foreground">{finalHeadline}</h3>
+					<p className="mt-2 text-muted-foreground">{finalDescription}</p>
+
+					{/* Links list (optional, Vercel-style numbered list) */}
+					{links && links.length > 0 && (
+						<ol className="mt-4 space-y-2">
+							{links.map((link, index) => (
+								<li key={link.href} className="flex items-start gap-3">
+									<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
+										{index + 1}
+									</span>
+									<div>
+										{link.external ? (
+											<a
+												href={link.href}
+												target="_blank"
+												rel="noopener noreferrer"
+												className="font-medium text-primary hover:underline">
+												{link.label}
+											</a>
+										) : (
+											<Link href={link.href} className="font-medium text-primary hover:underline">
+												{link.label}
+											</Link>
+										)}
+										{link.description && (
+											<span className="text-muted-foreground"> — {link.description}</span>
+										)}
+									</div>
+								</li>
+							))}
+						</ol>
+					)}
+
+					{/* Action buttons */}
+					<div className="mt-6 flex flex-col gap-3 sm:flex-row">
+						{isExternalPrimary ? (
+							<Button asChild>
+								<a href={finalPrimaryHref} target="_blank" rel="noopener noreferrer">
+									{finalPrimaryText}
+									<ArrowRight className="ml-1 h-4 w-4" />
+								</a>
+							</Button>
+						) : (
+							<Button asChild>
+								<Link href={finalPrimaryHref}>
+									{finalPrimaryText}
+									<ArrowRight className="ml-1 h-4 w-4" />
+								</Link>
+							</Button>
+						)}
+
+						{isExternalSecondary ? (
+							<Button variant="outline" asChild>
+								<a href={finalSecondaryHref} target="_blank" rel="noopener noreferrer">
+									{finalSecondaryText}
+								</a>
+							</Button>
+						) : (
+							<Button variant="outline" asChild>
+								<Link href={finalSecondaryHref}>{finalSecondaryText}</Link>
+							</Button>
+						)}
+					</div>
+				</div>
+			</div>
+		</div>
+	)
+}
+
+/**
+ * Get Started links component (Vercel-style numbered list)
+ * Can be used standalone or within BlogPostCTA
+ */
+export function GetStartedLinks({ links }: { links: CTALink[] }) {
+	return (
+		<div className="not-prose mt-8 rounded-lg border border-border bg-muted/30 p-6">
+			<h4 className="font-semibold text-foreground">Get started</h4>
+			<ol className="mt-4 space-y-3">
+				{links.map((link, index) => (
+					<li key={link.href} className="flex items-start gap-3">
+						<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
+							{index + 1}
+						</span>
+						<div>
+							{link.external ? (
+								<a
+									href={link.href}
+									target="_blank"
+									rel="noopener noreferrer"
+									className="font-medium text-primary hover:underline">
+									{link.label}
+								</a>
+							) : (
+								<Link href={link.href} className="font-medium text-primary hover:underline">
+									{link.label}
+								</Link>
+							)}
+							{link.description && <span className="text-muted-foreground"> — {link.description}</span>}
+						</div>
+					</li>
+				))}
+			</ol>
+		</div>
+	)
+}

+ 63 - 0
apps/web-roo-code/src/components/blog/BlogPostList.tsx

@@ -0,0 +1,63 @@
+/**
+ * Blog Post List Component
+ * Renders a list of blog post previews
+ */
+
+import Link from "next/link"
+import type { BlogPost, BlogSource } from "@/lib/blog"
+import { formatPostDatePt } from "@/lib/blog"
+
+interface BlogPostListProps {
+	posts: BlogPost[]
+}
+
+/**
+ * Source badge component
+ * Styling matches the tag badges (rounded, same padding)
+ */
+function SourceBadge({ source }: { source: BlogSource }) {
+	return <span className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground">{source}</span>
+}
+
+export function BlogPostList({ posts }: BlogPostListProps) {
+	if (posts.length === 0) {
+		return (
+			<div className="mt-12 text-center">
+				<p className="text-muted-foreground">No posts published yet. Check back soon!</p>
+			</div>
+		)
+	}
+
+	return (
+		<div className="mt-12 space-y-12">
+			{posts.map((post) => (
+				<article key={post.slug} className="border-b border-border pb-12 last:border-b-0">
+					<Link href={`/blog/${post.slug}`} className="group">
+						<h2 className="text-xl font-semibold tracking-tight transition-colors group-hover:text-primary sm:text-2xl">
+							{post.title}
+						</h2>
+					</Link>
+					<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
+						{post.source && <SourceBadge source={post.source} />}
+						<span>{formatPostDatePt(post.publish_date)}</span>
+					</div>
+					<p className="mt-3 text-muted-foreground">{post.description}</p>
+					{post.tags.length > 0 && (
+						<div className="mt-4 flex flex-wrap gap-2">
+							{post.tags.map((tag) => (
+								<span key={tag} className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground">
+									{tag}
+								</span>
+							))}
+						</div>
+					)}
+					<Link
+						href={`/blog/${post.slug}`}
+						className="mt-4 inline-block text-sm font-medium text-primary hover:underline">
+						Read more →
+					</Link>
+				</article>
+			))}
+		</div>
+	)
+}

+ 58 - 0
apps/web-roo-code/src/components/blog/BlogViewToggle.tsx

@@ -0,0 +1,58 @@
+/**
+ * Blog View Toggle Component
+ *
+ * Toggles between curated (featured) posts and all posts view.
+ * Uses URL search params for state to support SSR and sharing.
+ */
+
+"use client"
+
+import Link from "next/link"
+import { useSearchParams } from "next/navigation"
+
+export type BlogView = "featured" | "all"
+
+interface BlogViewToggleProps {
+	curatedCount: number
+	totalCount: number
+}
+
+export function BlogViewToggle({ curatedCount, totalCount }: BlogViewToggleProps) {
+	const searchParams = useSearchParams()
+	const currentView = (searchParams.get("view") as BlogView) || "featured"
+
+	return (
+		<div className="mt-6 flex items-center gap-4">
+			<div className="flex rounded-lg border border-border bg-muted/50 p-1">
+				<Link
+					href="/blog"
+					className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
+						currentView === "featured"
+							? "bg-background text-foreground shadow-sm"
+							: "text-muted-foreground hover:text-foreground"
+					}`}>
+					Featured
+					<span className="ml-1.5 text-xs text-muted-foreground">({curatedCount})</span>
+				</Link>
+				<Link
+					href="/blog?view=all"
+					className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
+						currentView === "all"
+							? "bg-background text-foreground shadow-sm"
+							: "text-muted-foreground hover:text-foreground"
+					}`}>
+					All Posts
+					<span className="ml-1.5 text-xs text-muted-foreground">({totalCount})</span>
+				</Link>
+			</div>
+		</div>
+	)
+}
+
+/**
+ * Get the current blog view from search params
+ */
+export function getBlogView(searchParams: URLSearchParams): BlogView {
+	const view = searchParams.get("view")
+	return view === "all" ? "all" : "featured"
+}

+ 140 - 0
apps/web-roo-code/src/components/blog/YouTubeModal.test.ts

@@ -0,0 +1,140 @@
+import { describe, it, expect } from "vitest"
+import { extractYouTubeVideoId, extractYouTubeTimestamp, isYouTubeUrl } from "./YouTubeModal"
+
+describe("isYouTubeUrl", () => {
+	it("should return true for youtube.com/watch URLs", () => {
+		expect(isYouTubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
+		expect(isYouTubeUrl("http://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
+		expect(isYouTubeUrl("https://youtube.com/watch?v=abc123_-XYZ")).toBe(true)
+	})
+
+	it("should return true for youtu.be URLs", () => {
+		expect(isYouTubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true)
+		expect(isYouTubeUrl("http://youtu.be/abc123_-XYZ")).toBe(true)
+	})
+
+	it("should return true for youtube.com/embed URLs", () => {
+		expect(isYouTubeUrl("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(true)
+	})
+
+	it("should return true for youtube.com/v URLs", () => {
+		expect(isYouTubeUrl("https://www.youtube.com/v/dQw4w9WgXcQ")).toBe(true)
+	})
+
+	it("should return false for non-YouTube URLs", () => {
+		expect(isYouTubeUrl("https://www.google.com")).toBe(false)
+		expect(isYouTubeUrl("https://vimeo.com/123456")).toBe(false)
+		expect(isYouTubeUrl("https://example.com/youtube.com")).toBe(false)
+	})
+
+	it("should return false for invalid URLs", () => {
+		expect(isYouTubeUrl("not a url")).toBe(false)
+		expect(isYouTubeUrl("")).toBe(false)
+	})
+})
+
+describe("extractYouTubeVideoId", () => {
+	it("should extract video ID from youtube.com/watch URLs", () => {
+		expect(extractYouTubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ")
+		expect(extractYouTubeVideoId("https://www.youtube.com/watch?v=abc123_-XYZ&list=PLxyz")).toBe("abc123_-XYZ")
+		expect(extractYouTubeVideoId("https://www.youtube.com/watch?feature=share&v=dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ")
+	})
+
+	it("should extract video ID from youtu.be URLs", () => {
+		expect(extractYouTubeVideoId("https://youtu.be/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ")
+		expect(extractYouTubeVideoId("https://youtu.be/abc123_-XYZ?t=42")).toBe("abc123_-XYZ")
+	})
+
+	it("should extract video ID from youtube.com/embed URLs", () => {
+		expect(extractYouTubeVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ")
+	})
+
+	it("should extract video ID from youtube.com/v URLs", () => {
+		expect(extractYouTubeVideoId("https://www.youtube.com/v/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ")
+	})
+
+	it("should return null for non-YouTube URLs", () => {
+		expect(extractYouTubeVideoId("https://www.google.com")).toBeNull()
+		expect(extractYouTubeVideoId("https://vimeo.com/123456")).toBeNull()
+	})
+
+	it("should return null for invalid URLs", () => {
+		expect(extractYouTubeVideoId("not a url")).toBeNull()
+		expect(extractYouTubeVideoId("")).toBeNull()
+	})
+})
+
+describe("extractYouTubeTimestamp", () => {
+	describe("numeric timestamps (seconds)", () => {
+		it("should extract timestamp in seconds from youtube.com URLs", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42")).toBe(42)
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?t=120&v=dQw4w9WgXcQ")).toBe(120)
+		})
+
+		it("should extract timestamp from youtu.be URLs", () => {
+			expect(extractYouTubeTimestamp("https://youtu.be/dQw4w9WgXcQ?t=42")).toBe(42)
+			expect(extractYouTubeTimestamp("https://youtu.be/dQw4w9WgXcQ?t=3600")).toBe(3600)
+		})
+
+		it("should extract timestamp from start= URLs", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=42")).toBe(42)
+			expect(extractYouTubeTimestamp("https://www.youtube.com/embed/dQw4w9WgXcQ?start=120")).toBe(120)
+		})
+
+		it("should extract timestamp from fragment (#t=) URLs", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ#t=42")).toBe(42)
+		})
+	})
+
+	describe("h/m/s format timestamps", () => {
+		it("should parse hours, minutes, and seconds", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s")).toBe(3723) // 1*3600 + 2*60 + 3
+		})
+
+		it("should parse minutes and seconds", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2m30s")).toBe(150) // 2*60 + 30
+		})
+
+		it("should parse seconds only", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s")).toBe(45)
+		})
+
+		it("should parse hours and minutes", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h30m")).toBe(5400) // 1*3600 + 30*60
+		})
+
+		it("should parse hours only", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h")).toBe(7200) // 2*3600
+		})
+
+		it("should be case-insensitive", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1H2M3S")).toBe(3723)
+		})
+	})
+
+	describe("colon-separated timestamps", () => {
+		it("should parse mm:ss format", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1:23")).toBe(83)
+		})
+
+		it("should parse hh:mm:ss format", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1:02:03")).toBe(3723)
+		})
+	})
+
+	describe("edge cases", () => {
+		it("should return 0 when no timestamp is present", () => {
+			expect(extractYouTubeTimestamp("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(0)
+			expect(extractYouTubeTimestamp("https://youtu.be/dQw4w9WgXcQ")).toBe(0)
+		})
+
+		it("should return 0 for non-YouTube URLs", () => {
+			expect(extractYouTubeTimestamp("https://www.google.com")).toBe(0)
+		})
+
+		it("should return 0 for invalid URLs", () => {
+			expect(extractYouTubeTimestamp("not a url")).toBe(0)
+			expect(extractYouTubeTimestamp("")).toBe(0)
+		})
+	})
+})

+ 171 - 0
apps/web-roo-code/src/components/blog/YouTubeModal.tsx

@@ -0,0 +1,171 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/modal"
+
+/**
+ * YouTube URL patterns and utilities
+ */
+
+/** Regular expression to match YouTube URLs and extract video ID */
+const YOUTUBE_URL_REGEX = /(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/i
+
+/**
+ * Regular expression to extract timestamp from YouTube URLs.
+ *
+ * Supports:
+ * - query params: `t=123`, `t=1h2m3s`, `start=123`, `t=1:23`, `t=1:02:03`
+ * - fragment params: `#t=123` (less common but seen in some links)
+ */
+const TIMESTAMP_REGEX = /(?:[?&#](?:t|start)=)([0-9hms:]+)/i
+
+/**
+ * Extracts the video ID from a YouTube URL
+ * Supports various YouTube URL formats:
+ * - https://www.youtube.com/watch?v=VIDEO_ID
+ * - https://youtu.be/VIDEO_ID
+ * - https://www.youtube.com/embed/VIDEO_ID
+ * - https://www.youtube.com/v/VIDEO_ID
+ *
+ * @param url - The YouTube URL to parse
+ * @returns The video ID or null if not found
+ */
+export function extractYouTubeVideoId(url: string): string | null {
+	const match = url.match(YOUTUBE_URL_REGEX)
+	return match?.[1] ?? null
+}
+
+/**
+ * Parses a YouTube timestamp string to seconds
+ * Supports formats like:
+ * - "123" (seconds)
+ * - "1h2m3s" (hours, minutes, seconds)
+ * - "2m30s" (minutes and seconds)
+ * - "45s" (seconds only)
+ *
+ * @param timestamp - The timestamp string
+ * @returns The timestamp in seconds
+ */
+function parseTimestampToSeconds(timestamp: string): number {
+	// If it's just a number, it's already in seconds
+	if (/^\d+$/.test(timestamp)) {
+		return parseInt(timestamp, 10)
+	}
+
+	// Parse colon-separated formats:
+	// - "mm:ss"
+	// - "hh:mm:ss"
+	const colonMatch = timestamp.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
+	if (colonMatch) {
+		const a = parseInt(colonMatch[1] ?? "0", 10)
+		const b = parseInt(colonMatch[2] ?? "0", 10)
+		const c = colonMatch[3] ? parseInt(colonMatch[3], 10) : null
+
+		if (c === null) return a * 60 + b
+		return a * 3600 + b * 60 + c
+	}
+
+	// Parse h/m/s format
+	let totalSeconds = 0
+	const hours = timestamp.match(/(\d+)h/i)
+	const minutes = timestamp.match(/(\d+)m/i)
+	const seconds = timestamp.match(/(\d+)s/i)
+
+	if (hours?.[1]) totalSeconds += parseInt(hours[1], 10) * 3600
+	if (minutes?.[1]) totalSeconds += parseInt(minutes[1], 10) * 60
+	if (seconds?.[1]) totalSeconds += parseInt(seconds[1], 10)
+
+	return totalSeconds
+}
+
+/**
+ * Extracts the start time (in seconds) from a YouTube URL
+ *
+ * @param url - The YouTube URL to parse
+ * @returns The start time in seconds or 0 if not found
+ */
+export function extractYouTubeTimestamp(url: string): number {
+	const match = url.match(TIMESTAMP_REGEX)
+	if (!match?.[1]) return 0
+	return parseTimestampToSeconds(match[1])
+}
+
+/**
+ * Checks if a URL is a YouTube URL
+ *
+ * @param url - The URL to check
+ * @returns True if the URL is a YouTube URL
+ */
+export function isYouTubeUrl(url: string): boolean {
+	return YOUTUBE_URL_REGEX.test(url)
+}
+
+interface YouTubeModalProps {
+	/** Whether the modal is open */
+	open: boolean
+	/** Callback when the modal open state changes */
+	onOpenChange: (open: boolean) => void
+	/** The YouTube video ID */
+	videoId: string
+	/** The start time in seconds (optional) */
+	startTime?: number
+	/** The video title for accessibility (optional) */
+	title?: string
+}
+
+/**
+ * YouTubeModal component
+ *
+ * A modal dialog that embeds a YouTube video player.
+ * Supports starting playback at a specific timestamp.
+ *
+ * @example
+ * ```tsx
+ * <YouTubeModal
+ *   open={isOpen}
+ *   onOpenChange={setIsOpen}
+ *   videoId="dQw4w9WgXcQ"
+ *   startTime={42}
+ *   title="Never Gonna Give You Up"
+ * />
+ * ```
+ */
+export function YouTubeModal({ open, onOpenChange, videoId, startTime = 0, title }: YouTubeModalProps) {
+	// Build the YouTube embed URL with parameters
+	const embedUrl = React.useMemo(() => {
+		const params = new URLSearchParams({
+			autoplay: "1",
+			rel: "0", // Don't show related videos from other channels
+			modestbranding: "1", // Minimal YouTube branding
+		})
+
+		if (startTime > 0) {
+			params.set("start", startTime.toString())
+		}
+
+		return `https://www.youtube.com/embed/${videoId}?${params.toString()}`
+	}, [videoId, startTime])
+
+	return (
+		<Dialog open={open} onOpenChange={onOpenChange}>
+			<DialogContent className="max-w-4xl w-[90vw] p-0 overflow-hidden bg-black" aria-describedby={undefined}>
+				{/* Visually hidden title for accessibility */}
+				<DialogTitle className="sr-only">{title ?? "YouTube Video"}</DialogTitle>
+				<div className="relative w-full pt-[56.25%]">
+					{/* 16:9 aspect ratio container */}
+					{open && (
+						<iframe
+							className="absolute inset-0 w-full h-full"
+							src={embedUrl}
+							title={title ?? "YouTube Video"}
+							allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+							allowFullScreen
+						/>
+					)}
+				</div>
+			</DialogContent>
+		</Dialog>
+	)
+}
+
+export default YouTubeModal

+ 7 - 0
apps/web-roo-code/src/components/chromes/footer.tsx

@@ -162,6 +162,13 @@ export function Footer() {
 							<div className="mt-10 md:mt-0">
 								<h3 className="text-sm font-semibold uppercase leading-6 text-foreground">Resources</h3>
 								<ul className="mt-6 space-y-4">
+									<li>
+										<Link
+											href="/blog"
+											className="text-sm leading-6 text-muted-foreground transition-colors hover:text-foreground">
+											Blog
+										</Link>
+									</li>
 									<li>
 										<a
 											href={EXTERNAL_LINKS.EVALS}

+ 15 - 0
apps/web-roo-code/src/components/chromes/nav-bar.tsx

@@ -120,6 +120,15 @@ export function NavBar({ stars, downloads }: NavBarProps) {
 							</NavigationMenuTrigger>
 							<NavigationMenuContent>
 								<ul className="grid min-w-[260px] gap-1 p-2">
+									<li>
+										<NavigationMenuLink asChild>
+											<Link
+												href="/blog"
+												className="block select-none rounded-md px-3 py-2 text-sm leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
+												Blog
+											</Link>
+										</NavigationMenuLink>
+									</li>
 									<li>
 										<NavigationMenuLink asChild>
 											<Link
@@ -281,6 +290,12 @@ export function NavBar({ stars, downloads }: NavBarProps) {
 							<div className="px-5 pb-2 pt-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
 								Resources
 							</div>
+							<Link
+								href="/blog"
+								className="block w-full p-5 py-3 text-left text-foreground active:opacity-50"
+								onClick={() => setIsMenuOpen(false)}>
+								Blog
+							</Link>
 							<ScrollButton
 								targetId="faq"
 								className="block w-full p-5 py-3 text-left text-foreground active:opacity-50"

+ 24 - 7
apps/web-roo-code/src/components/providers/posthog-provider.tsx

@@ -3,26 +3,43 @@
 import { usePathname, useSearchParams } from "next/navigation"
 import posthog from "posthog-js"
 import { PostHogProvider as OriginalPostHogProvider } from "posthog-js/react"
-import { useEffect, Suspense } from "react"
+import { useEffect, useRef, Suspense } from "react"
 import { hasConsent } from "@/lib/analytics/consent-manager"
 
 function PageViewTracker() {
 	const pathname = usePathname()
 	const searchParams = useSearchParams()
+	const previousUrl = useRef<string | null>(null)
 
-	// Track page views
+	// Track page views with proper referrer for SPA navigations
 	useEffect(() => {
 		if (pathname && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
-			let url = window.location.origin + pathname
-			if (searchParams && searchParams.toString()) {
-				url = url + `?${searchParams.toString()}`
+			const searchString = searchParams?.toString() ?? ""
+			const currentUrl = window.location.origin + pathname + (searchString ? `?${searchString}` : "")
+
+			// Get referrer - for SPA navigations, use previous URL; otherwise use document.referrer
+			const referrer = previousUrl.current ?? document.referrer
+			let referringDomain = ""
+
+			if (referrer) {
+				try {
+					referringDomain = new URL(referrer).hostname
+				} catch {
+					// Invalid URL, leave empty
+				}
 			}
+
 			posthog.capture("$pageview", {
-				$current_url: url,
+				$current_url: currentUrl,
+				$referrer: referrer,
+				$referring_domain: referringDomain,
 			})
+
+			// Update previous URL for next navigation
+			previousUrl.current = currentUrl
 		}
 		// eslint-disable-next-line react-hooks/exhaustive-deps
-	}, [pathname, searchParams.toString()])
+	}, [pathname, searchParams?.toString()])
 
 	return null
 }

+ 116 - 0
apps/web-roo-code/src/content/blog/ai-best-practices-spread-through-internal-influencers-not-topdown-mandates.md

@@ -0,0 +1,116 @@
+---
+title: AI Best Practices Spread Through Internal Influencers, Not Top-Down Mandates
+slug: ai-best-practices-spread-through-internal-influencers-not-topdown-mandates
+description: Why centralized AI documentation fails and how engineering orgs drive adoption through trusted internal influencers and recurring demo forums.
+primary_schema:
+    - Article
+    - FAQPage
+tags:
+    - ai-adoption
+    - engineering-culture
+    - developer-productivity
+    - change-management
+status: published
+featured: true
+publish_date: "2025-04-30"
+publish_time_pt: "9:00am"
+source: "Office Hours"
+---
+
+"When that person speaks, everyone listens."
+
+That's how adoption actually works. Not through wikis. Not through mandates. Through the developer everyone already watches.
+
+## The documentation problem
+
+You're rolling out AI coding tools to a large engineering org. The instinct is to centralize: write a best practices guide, create a wiki, schedule a training session.
+
+Three weeks later, the wiki is stale. The domain moved. The models changed. The prompts that worked in September don't work in October.
+
+Centralized guidance for AI tools fails for a structural reason: the field changes too fast for documentation to keep up. By the time you've written the guide, reviewed it, and published it, the landscape has shifted.
+
+## The organic model
+
+What works instead is something Netflix discovered through practice, not theory.
+
+> "I think that's probably the model that has happened organically at Netflix is look at the people who are the most effective and try to do what they're doing."
+>
+> David Leen, [Office Hours S01E04](https://www.youtube.com/watch?v=ZnKkwWuQ9QQ&t=1628)
+
+The insight: every engineering org already has internal influencers. Developers that others watch and emulate. When one of these engineers shares a prompt or recommends a tool, people try it. Not because of a mandate. Because trust already exists.
+
+> "A term that I like to use is almost like influencers. Developers that other people see, wow, this is a 10x developer or look how productive she is. And when that person speaks everyone listens and when they say try this as your prompt or I found this tool to be amazing, people around them try to emulate them."
+>
+> David Leen, [Office Hours S01E04](https://www.youtube.com/watch?v=ZnKkwWuQ9QQ&t=1584)
+
+This isn't about creating evangelists from scratch. It's about identifying who already has influence and giving them time and space to experiment.
+
+## The infrastructure that makes it work
+
+Identifying influencers is step one. Step two is creating forums where they can share what they learn.
+
+> "We have monthly get-togethers at the company where people can demo what they've built, shared lessons learned and that usually has like a two to three hundred engineer audience every month and that's been super valuable for sharing what's new."
+>
+> David Leen, [Office Hours S01E04](https://www.youtube.com/watch?v=ZnKkwWuQ9QQ&t=2465)
+
+The format matters. A demo with a live audience is different from a doc in a wiki. Demos show what actually works. They invite questions. They create social proof in real time.
+
+The cadence matters too. Monthly keeps pace with how fast AI tools evolve. Quarterly is too slow; the landscape shifts between demos.
+
+## The constraint: this requires investment
+
+The tradeoff is real. Internal influencers need time to experiment. They need permission to try tools that might not work. They need a stage to share what they learn.
+
+For an engineering leader, this means protecting experimentation time for your high-signal developers. It means scheduling recurring demo forums and treating them as infrastructure, not optional.
+
+The alternative - a centralized wiki that goes stale - costs less upfront but delivers less adoption.
+
+## Why this matters for your org
+
+For a 50-person engineering team evaluating AI coding tools, the adoption pattern predicts success or failure. Top-down mandates create compliance without enthusiasm. Organic spread through trusted developers creates actual behavior change.
+
+The compounding effect: when an internal influencer shares a workflow that saves 30 minutes per PR, and 10 engineers adopt it, that's 5 hours per day across the team. When they share a prompt pattern that reduces debugging loops, the multiplier grows.
+
+The first step is naming your influencers. Who do engineers already watch? Who gets asked "how did you do that?" in Slack? Start there.
+
+Create the forum. Protect the experimentation time. Let adoption spread through trust instead of mandates.
+
+## How Roo Code supports organic adoption patterns
+
+Internal influencers need tools that produce repeatable, reviewable outcomes - not just clever prompts. Roo Code helps by turning intent into inspectable artifacts (diffs, test output, and a clear trail of what happened) that are easy to demo and share.
+
+Influencers can share what actually worked: the workflow, the artifacts, and the failure modes. That's what spreads.
+
+**Roo Code accelerates organic adoption because internal influencers can demonstrate real, reproducible workflows - not theoretical best practices - in their monthly demo forums.**
+
+## AI adoption approaches compared
+
+| Dimension            | Centralized documentation     | Influencer-driven adoption            |
+| -------------------- | ----------------------------- | ------------------------------------- |
+| Update velocity      | Weeks to months               | Real-time as tools evolve             |
+| Trust source         | Institutional authority       | Peer credibility                      |
+| Learning format      | Static text and video         | Live demos with Q&A                   |
+| Experimentation cost | Hidden in stale content       | Visible investment in influencer time |
+| Adoption depth       | Compliance without enthusiasm | Behavior change through emulation     |
+
+## Frequently asked questions
+
+### How do I identify internal influencers in my engineering org?
+
+Look for developers who get asked "how did you do that?" in Slack or code reviews. They're often not the loudest voices but the ones whose PRs others study. Check who gets tagged when people are stuck on tooling questions. These signals reveal existing trust networks you can leverage.
+
+### What's the right cadence for AI tool demo forums?
+
+Monthly works best for most organizations. AI tools evolve quickly enough that quarterly sessions become stale, but weekly creates participation fatigue. Monthly gives influencers time to experiment deeply while keeping the organization current with rapidly changing capabilities.
+
+### How much experimentation time should influencers get?
+
+Start with 10-20% of their time dedicated to exploring new AI workflows. This is enough to build genuine expertise without disrupting delivery commitments. The investment pays back when their discoveries multiply across the team.
+
+### How does Roo Code help internal influencers share what they learn?
+
+Roo Code makes workflows easier to demonstrate because the artifacts are explicit: diffs, test output, and a clear trail of what was tried. That makes it easier to share what worked (and what didn't) without hand-wavy advice.
+
+### Why do centralized AI wikis fail?
+
+They fail because the field changes faster than documentation cycles. A prompt technique documented in October may be obsolete by November due to model updates, new features, or discovered failure modes. Live demos from trusted peers adapt in real time; static docs cannot.

+ 124 - 0
apps/web-roo-code/src/content/blog/manage-ai-spend-by-measuring-return-not-cost.md

@@ -0,0 +1,124 @@
+---
+title: Manage AI Spend by Measuring Return, Not Cost
+slug: manage-ai-spend-by-measuring-return-not-cost
+description: Learn why centralizing AI tool spend and measuring output instead of cost unlocks productivity gains - insights from Smartsheet's engineering leadership approach.
+primary_schema:
+    - Article
+    - FAQPage
+tags:
+    - ai-spend
+    - engineering-leadership
+    - productivity
+    - developer-tools
+status: published
+featured: true
+publish_date: "2025-11-14"
+publish_time_pt: "9:00am"
+source: "Roo Cast"
+---
+
+"I own it just so that I can tell them I want you to focus on your return, not your cost."
+
+That's JB Brown from Smartsheet, explaining why he consolidated all AI tool spend into a single account under his control.
+
+## The budget trap
+
+When AI token costs sit in team budgets, engineers optimize for the wrong metric. They watch the spend. They pick smaller models. They skip the task that might cost fifteen dollars even when it would save three hours.
+
+The incentive structure is backwards. You're measuring input (tokens consumed) instead of output (work completed). Every team manages their own line item, and every team gets cautious.
+
+This is the predictable outcome of distributed AI budgets: usage goes down, taking the productivity gain with it.
+
+## Why not track per-person?
+
+The technical capability to track individual spend exists. You authenticate to use tokens. The data is there. Smartsheet deliberately stays away from it.
+
+> "I could actually get down to it because you have to authenticate to use tokens and so then there's a tracking to amount of tokens per person but I don't. I'm kind of staying away from that. I think it will lead to bad mindset and bad behavior."
+>
+> JB Brown, [Roo Cast S01E17](https://www.youtube.com/watch?v=R4U89z9eGPg&t=824)
+
+The tracking is possible. The question is whether you should. Per-person dashboards create the exact cost anxiety that undermines the productivity gain you're paying for.
+
+## The Smartsheet approach
+
+Smartsheet took the opposite path. They moved all AI tool spend into a single account owned by engineering leadership. Not to track costs more closely, but to remove cost from the team-level conversation entirely.
+
+> "Here we're trying to drive return, not trying to reduce cost. And so to get that mindset shift and behavior and practice shift, I'm sort of precluding people from thinking about the cost too much."
+>
+> JB Brown, [Roo Cast S01E17](https://www.youtube.com/watch?v=R4U89z9eGPg&t=748)
+
+The goal is explicit: shift the mental model from "how do I spend less?" to "how do I ship more?"
+
+## The metric that matters
+
+If you're not measuring cost, what are you measuring?
+
+Their answer: MR throughput. Merge requests completed. Commits merged. Work shipped.
+
+> "We would measure it by MR throughput. And that's what we're trying to drive towards as that outcome."
+>
+> JB Brown, [Roo Cast S01E17](https://www.youtube.com/watch?v=R4U89z9eGPg&t=668)
+
+This is the difference between treating AI as an expense line and treating it as a productivity lever. Expenses get minimized. Levers get pulled. A frontier model costs a fraction of an intern's hourly rate but can iterate on code continuously. The return-on-cost math favors spending more, not less.
+
+## The tradeoff
+
+Centralizing spend requires leadership to take ownership of a growing line item. That's a real commitment. You're betting that the productivity gains justify the cost, and you're removing the natural friction that distributed budgets create.
+
+This works when you have the instrumentation to measure output. If you can't track MR throughput (or your equivalent of work completed), you're flying blind. The model only makes sense if you have visibility into what you're getting for the spend.
+
+The other risk: engineers might overconsume without constraints. Smartsheet's approach relies on trust and a focus on outcomes. If your teams aren't outcome-oriented, centralizing spend without guardrails could backfire.
+
+## Why this matters for your organization
+
+If you're evaluating AI coding tools at the org level, the budget question comes early. Finance wants to know where the costs sit. Engineering wants to experiment. Someone has to decide who owns the number.
+
+For a fast-moving team, the difference between cautious usage and full adoption compounds. If engineers second-guess every expensive task, you're leaving the productivity gain on the table. If they're told "focus on output, I'll handle the spend," you unlock a different behavior entirely.
+
+## How Roo Code fits this model
+
+Roo Code's BYOK model lets you connect a single organizational API account. Engineers use full-capability models without watching their own spend.
+
+Because the agent iterates until tests pass, token spend maps to merged code rather than manual copy-paste cycles. The iteration loop that would take an engineer 30 minutes of context-switching runs autonomously.
+
+Track MRs against token consumption. That's the cost-per-outcome math.
+
+## Cost anxiety vs. outcome focus: a comparison
+
+| Dimension             | Distributed budgets (cost focus)            | Centralized spend (return focus)        |
+| --------------------- | ------------------------------------------- | --------------------------------------- |
+| Engineer behavior     | Avoids expensive tasks even when high-value | Uses the right model for the job        |
+| Optimization target   | Minimize token consumption                  | Maximize merge request throughput       |
+| Model selection       | Defaults to cheaper, smaller models         | Selects based on task complexity        |
+| Leadership visibility | Fragmented across team ledgers              | Single account with outcome correlation |
+| Risk profile          | Under-utilization of AI capability          | Requires output instrumentation         |
+
+## The decision
+
+Audit where your AI spend currently sits. If it's distributed across team budgets, ask: are engineers optimizing for cost or for output?
+
+If the answer is cost, consider consolidating. Own the spend at a level where someone can credibly say: "I want you to focus on your return, not your cost."
+
+Then measure MR throughput.
+
+## Frequently asked questions
+
+### How do I convince finance to centralize AI tool spend?
+
+Frame the conversation around measurable output, not cost containment. Present a pilot where you track merge request throughput before and after removing per-team budget constraints. Finance responds to productivity metrics with ROI attached. Show them the cost-per-merged-PR math, not just the monthly token bill.
+
+### What output metrics work best for measuring AI tool ROI?
+
+Merge request throughput is the most direct proxy because it measures completed work. Other valid metrics include commits merged, story points delivered, or time-to-first-commit on new tasks. The key is choosing a metric that captures finished work rather than activity. Avoid measuring tokens consumed or hours using the tool - these are inputs, not outputs.
+
+### Does Roo Code support centralized API key management for teams?
+
+Yes. Roo Code's BYOK model lets you connect a single organizational API account that all team members use. This consolidates token spend into one billing relationship while giving engineers full access to capable models. The transparent pricing - no token markup - makes it easier to correlate spend with output when you measure at the organizational level.
+
+### What's the risk of removing cost constraints from engineers?
+
+Without output instrumentation, you lose visibility into what you're getting for the spend. The mitigation is measurement: if you track merge request throughput alongside token consumption, you can identify both inefficient usage patterns and high-value workflows. The risk of over-consumption is lower than the risk of under-utilization when engineers self-censor to avoid costs.
+
+### How do I start measuring MR throughput against AI spend?
+
+Begin by consolidating AI tool spend into a single account with clear billing visibility. Then establish a baseline: track merge requests per engineer per week before and after centralizing spend. Correlate changes in throughput with changes in token consumption. Most teams see throughput increase faster than cost, which is the return you're measuring.

+ 120 - 0
apps/web-roo-code/src/content/blog/non-engineers-stopped-waiting-for-engineers-to-unblock-them.md

@@ -0,0 +1,120 @@
+---
+title: Non-Engineers Stopped Waiting for Engineers to Unblock Them
+slug: non-engineers-stopped-waiting-for-engineers-to-unblock-them
+description: How product managers, ops, and support teams use AI coding agents to query codebases directly - reducing engineering interruptions and cutting the meeting tax.
+primary_schema:
+    - Article
+    - FAQPage
+tags:
+    - team-productivity
+    - codebase-understanding
+    - enterprise-workflows
+    - cross-functional-collaboration
+status: published
+featured: true
+publish_date: "2025-10-01"
+publish_time_pt: "9:00am"
+source: "Roo Cast"
+---
+
+"I use @roomote every day to ask questions about the codebase."
+
+That's a product manager talking. Not an engineer. Not someone who reads code for a living.
+
+## The meeting that didn't happen
+
+Every growing startup has this bottleneck. A PM needs to know why the pricing modal shows one value to new users and another to existing customers. Support needs to understand why a refund didn't process. Ops wants to know if a feature flag applies to enterprise accounts.
+
+The default behavior: send a Slack or schedule a meeting. Wait for engineering bandwidth. Hope someone remembers the context when they finally have time to answer.
+
+At Roo Vet, the behavior changed. Product managers, support reps, and operations staff started querying the codebase directly through a Roo Code's Slack agent. The question goes to the agent before it goes to a calendar invite.
+
+> "One nonobvious behavior change that came out of that I think is people don't wait around to be unblocked anymore. Non-engineers especially just ask our @roomote agent first."
+>
+> John Sterns, [Roo Cast S01E11](https://www.youtube.com/watch?v=bqLEMZ1c9Uk&t=290)
+
+## The shift: ask the agent first
+
+This isn't about replacing engineers. It's about reducing the number of times someone has to interrupt an engineer for a question that lives in the code.
+
+A PM wondering about pricing logic can ask the agent to find where pricing is calculated. A support rep confused about a refund flow can ask what conditions trigger a failed transaction. An ops person checking feature flags can get a direct answer without waiting for standup.
+
+The answers come from the codebase itself. Not tribal knowledge. Not someone's memory of a Slack thread from six months ago.
+
+> "I use @roomote every day to ask questions about the codebase. I think that is the most effective thing as a PM."
+>
+> Theo, [Roo Cast S01E11](https://www.youtube.com/watch?v=bqLEMZ1c9Uk&t=2288)
+
+## What the queries look like
+
+The questions that used to block people are often simple:
+
+- "Where is the logic that determines trial length for enterprise accounts?"
+- "What happens if a user cancels mid-billing cycle?"
+- "Which API endpoint does the mobile app call for user preferences?"
+
+These aren't deep architectural questions. They're "point me to the right file" questions. But without direct access, each one requires finding an engineer, explaining the context, waiting for them to context-switch, and hoping they have time to answer before your next deadline.
+
+With a codebase-connected agent, the answer comes back fast.
+
+> "You can ask it questions to better understand the logic that's been built into the product... it took me like 30 seconds to find that answer."
+>
+> Audrey, [Roo Cast S01E11](https://www.youtube.com/watch?v=bqLEMZ1c9Uk)
+
+## The tradeoff
+
+This works well for "where is X" and "what does Y do" questions. It works less well for "should we change Z" questions that require judgment and context about why something was built a certain way.
+
+The agent can point to the code. It cannot explain the meeting where the team decided to handle edge cases a specific way, or the incident that led to a particular validation check.
+
+Non-engineers still need engineers for decisions. But they no longer need engineers for navigation.
+
+## Old approach vs. new approach
+
+| Dimension              | Old approach                      | New approach                          |
+| ---------------------- | --------------------------------- | ------------------------------------- |
+| Question routing       | Schedule meeting with engineering | Ask the agent first                   |
+| Wait time              | Hours to days for bandwidth       | Seconds for direct answers            |
+| Engineer interruptions | Multiple context switches daily   | Only for decision-requiring questions |
+| Knowledge source       | Tribal knowledge and memory       | Codebase itself                       |
+| Bottleneck             | Engineering availability          | None for navigation questions         |
+
+## Why this matters for your team
+
+For a Series A or B company, the meeting tax is real. Every "quick question" that requires an engineer to stop, context-switch, answer, and then recover their flow costs more than the five minutes the question takes.
+
+If half of those questions can be answered by the codebase directly, the compounding effect is significant. Not because individual questions take less time, but because engineers stay in flow and non-engineers stop waiting.
+
+The behavior shift is the real outcome: people stop treating engineering bandwidth as a prerequisite for understanding the product.
+
+## How Roo Code makes questions self-serve
+
+The key capability for cross-functional teams is read-only codebase access through integrations. People can ask questions in the tools they already use and get answers grounded in the actual implementation, not documentation that may be outdated.
+
+In practice, the best answers are inspectable: what file to look at, what function or endpoint is involved, and what conditions drive the behavior.
+
+**Roo Code helps non-engineers self-serve codebase questions, reducing engineering interruptions while keeping engineers available for decisions that require judgment.**
+
+## The first step
+
+Connect your codebase to a channel where non-technical team members already work. Slack integration is the common pattern. Start with read-only access: let people ask questions about what exists before you consider letting anyone propose changes.
+
+The goal is simple: make "ask the agent first" the default behavior before "schedule a meeting with engineering." The questions that require an engineer will become obvious. Everything else just gets answered.
+
+## Frequently asked questions
+
+### What types of questions can non-engineers actually answer with an AI coding agent?
+
+Navigation and understanding questions work well: "where is X calculated," "what triggers Y behavior," and "which API handles Z." These are lookup questions where the answer lives in the code. Questions requiring judgment about why something was built a certain way, or whether it should change, still need engineers.
+
+### How do you prevent non-technical staff from getting confused by raw code responses?
+
+Modern AI coding agents like Roo Code explain code in plain language rather than just returning file contents. When a PM asks about pricing logic, they get an explanation of the flow, not a code dump. The agent translates between codebase structure and business concepts.
+
+### Does giving non-engineers codebase access create security risks?
+
+Read-only access through a controlled integration can reduce exposure compared to giving users direct repository access. Teams typically start with specific channels and expand access based on demonstrated value and a security review.
+
+### What's the ROI of reducing "quick question" interruptions for engineers?
+
+Context-switching costs compound. A five-minute question can cost far more than five minutes once you include interruption and recovery. If you can route routine "where is X" questions away from engineering, you buy back real focus time without adding headcount.

+ 119 - 0
apps/web-roo-code/src/content/blog/over-half-of-googles-production-code-is-now-aigenerated.md

@@ -0,0 +1,119 @@
+---
+title: Over Half of Google's Production Code Is Now AI-Generated
+slug: over-half-of-googles-production-code-is-now-aigenerated
+description: Google reports that over 50% of production code passing review each week is AI-generated. Learn what this threshold means for engineering teams and how to adopt workflows that let AI contribute code that survives review.
+primary_schema:
+    - Article
+    - FAQPage
+tags:
+    - ai-coding
+    - developer-productivity
+    - code-review
+    - engineering-workflow
+status: published
+featured: true
+publish_date: "2026-01-12"
+publish_time_pt: "9:00am"
+source: "Office Hours"
+---
+
+Fifty percent.
+
+Not prototypes. Not experiments.
+
+Production code that ships.
+
+## The threshold
+
+At Google, more than half of the code checked into production each week is generated by AI. This is code that passes review, gets accepted, and does not get rolled back.
+
+> "Each week, over 50% of the code that gets checked in, and through code review, is accepted, isn't rolled back, is generated by AI."
+>
+> Paige Bailey, [Office Hours S01E15](https://www.youtube.com/watch?v=sAFQIqmDFL4&t=2328)
+
+This is not a demo. This is not a projection. This is the current state of one of the world's largest engineering organizations.
+
+The tools driving this: AI Studio, Gemini CLI, and Jules. Engineers are not evaluating whether to use AI. They are using it to get their work done.
+
+## What this means for fast-moving teams
+
+If you are still running pilots or debating adoption policies, the competitive landscape has shifted underneath you.
+
+Google has the resources to build custom tooling, train internal models, and run extensive evaluations. But the workflows that enable this level of AI contribution are not proprietary magic. They are built on patterns any team can adopt: agentic iteration, code review integration, and clear approval boundaries.
+
+The barrier is no longer "does AI code actually work?" The barrier is "do we have a workflow that lets AI contribute code that survives review?"
+
+## The workflow that survives review
+
+The 50% number is not about raw generation. It is about code that passes the same review gates as human-written code.
+
+That means:
+
+- The AI contribution fits the codebase style and patterns
+- The PR addresses a real issue or ticket
+- The code compiles, passes tests, and does not introduce regressions
+- A human reviewer can verify and accept it
+
+Execution context is the difference. Agents that can run commands, see outputs, and iterate produce code that survives review. Agents that generate suggestions in isolation produce code that needs fixing.
+
+## The tradeoff
+
+Adopting agentic workflows is not free. There is upfront investment in:
+
+- Defining approval boundaries (what can the agent run without asking?)
+- Integrating with your existing review workflow
+- Setting up a development environment where the agent can run and verify code
+- Helping engineers shift from writing code to managing agents (clear outcomes, evaluate output, give feedback)
+- Building trust through small wins before scaling
+
+The shift is less about learning new tools and more about thinking like a manager: outcomes-oriented, clear in what you want, and focused on evaluating output and giving feedback.
+
+The teams that have crossed the threshold did not adopt AI everywhere at once. They started with narrow, high-frequency tasks: boilerplate generation, test scaffolding, refactoring patterns. They built confidence in the workflow before expanding scope.
+
+## Why this matters for your team
+
+If your competitors are shipping with 50% AI-generated code, they're moving twice as fast. Start with one engineer, one repo, one workflow, and scale from there.
+
+## How Roo Code delivers review-ready code
+
+Roo Code gives the agent execution context. It proposes a diff, runs your tests, sees the actual output, and iterates until the code passes. The agent doesn't hand you suggestions to fix. It fixes them itself. Without this, agents confidently claim success while producing a blank screen.
+
+For teams looking to cross the 50% threshold, the workflow matters more than the model. Roo Code provides the agentic iteration layer that turns AI suggestions into merged PRs.
+
+## Traditional vs. agentic AI coding workflows
+
+| Dimension             | Traditional AI autocomplete               | Agentic workflow (iterate until passing) |
+| --------------------- | ----------------------------------------- | ---------------------------------------- |
+| Execution context     | None - generates suggestions in isolation | Runs commands, sees output, iterates     |
+| Test validation       | Human must run tests manually             | Agent runs tests and fixes failures      |
+| Review readiness      | Often requires human fixes before PR      | Produces review-ready code               |
+| Iteration speed       | One suggestion at a time                  | Continuous iteration until passing       |
+| Scope of contribution | Line or function level                    | Full feature or task level               |
+
+## The first step
+
+Pick a low-risk task: test generation, docs, or boilerplate. Let the agent contribute PRs for a week. After that, check what merged, what needed fixes, and how much time it saved.
+
+You don't need Google's infrastructure. You need one passing PR from an agent. Start there.
+
+## Frequently asked questions
+
+### What does "50% AI-generated code" actually mean at Google?
+
+It means that over half of the code that passes code review, gets accepted by reviewers, and does not get rolled back each week was generated by AI tools. This is production code that ships to users, not experimental prototypes.
+
+### Why does code review acceptance rate matter more than raw generation volume?
+
+Any AI tool can generate code. The real measure is whether that code survives the same review standards as human-written code. Code that requires extensive human fixes before merging does not actually save engineering time. The 50% threshold specifically tracks code that reviewers accept without rollback.
+
+### Can teams without Google's resources achieve similar AI contribution rates?
+
+Yes. The workflow patterns that enable high AI contribution rates are not unique to Google's scale. Agentic iteration, clear approval boundaries, and code review integration can be implemented by any team. Fast-moving teams often adopt faster because they can skip months of policy review.
+
+### How does Roo Code help teams build workflows where AI code survives review?
+
+Roo Code iterates by running commands, observing output, and converging on passing tests. Instead of generating suggestions that a human must fix, the agent produces code that is ready for review. The approval system lets teams define exactly what the agent can run autonomously, building trust incrementally.
+
+### What is the best first task to pilot an AI coding agent?
+
+Start with high-frequency, low-risk tasks: test generation, documentation updates, or boilerplate scaffolding. These tasks have clear success criteria and limited blast radius. Run the pilot for one week and measure how many PRs merged without human fixes.

+ 122 - 0
apps/web-roo-code/src/content/blog/prds-are-becoming-artifacts-of-the-past.md

@@ -0,0 +1,122 @@
+---
+title: PRDs Are Becoming Artifacts of the Past
+slug: prds-are-becoming-artifacts-of-the-past
+description: The economics of software specification have flipped. When prototypes ship faster than PRDs can be written, teams are discovering that working code is the best documentation.
+primary_schema:
+    - Article
+    - FAQPage
+tags:
+    - product-management
+    - ai-development
+    - iterative-development
+    - documentation
+status: published
+featured: true
+publish_date: "2026-01-12"
+publish_time_pt: "9:00am"
+source: "Office Hours"
+---
+
+Forty-seven pages. Three months of stakeholder reviews. One product requirements document.
+
+By the time it shipped, the model it described was two generations behind.
+
+## The document that ages out
+
+You've seen this loop. A PM spends weeks gathering requirements, aligning stakeholders, formatting sections. The PRD becomes a ceremony: cover page, executive summary, user stories, acceptance criteria, and fourteen appendices.
+
+Then Claude 4 ships. Or the API you were planning around deprecates. Or your team discovers a workflow that makes half the document irrelevant.
+
+The PRD that was perfect in January is not perfect in February.
+
+This is what Paige Bailey observed at Google: teams are abandoning the rigorous PRD process not because they've gotten sloppy, but because the velocity of change has outpaced the document's shelf life.
+
+> "Now things are moving so fast that even if you had a PRD that was perfect as of January, it would not be perfect as of February."
+>
+> Paige Bailey, [Office Hours S01E15](https://www.youtube.com/watch?v=sAFQIqmDFL4&t=2760)
+
+## The shift: prototypes as specification
+
+The replacement is not "no documentation." The replacement is documentation that emerges from working software.
+
+Instead of writing a spec and then building, teams build something small and iterate against real feedback. The prototype becomes the specification. The commit history becomes the decision log. The PR comments become the rationale.
+
+> "For software, it's much more effective to get something out and keep iterating on it really really quickly."
+>
+> Paige Bailey, [Office Hours S01E15](https://www.youtube.com/watch?v=sAFQIqmDFL4&t=3003)
+
+This works because AI tooling has compressed the cost of producing working code. When you can generate a functional prototype in an afternoon, the economics of "plan first, build second" flip. The sunk cost of writing a detailed PRD becomes harder to justify when you could have shipped the first version instead.
+
+## The tradeoff is real
+
+Abandoning upfront planning does not mean abandoning coordination. Teams still need to align on scope, surface constraints, and communicate with stakeholders.
+
+The difference is when that alignment happens. Waterfall-style PRDs front-load alignment before any code exists. Prototype-first workflows back-load alignment: you ship something, learn what breaks, and document the decisions retrospectively.
+
+This approach has failure modes:
+
+**Scope creep without anchors.** If there's no initial constraint document, the prototype can drift in directions that no stakeholder wanted.
+
+**Lost rationale.** If you don't capture why decisions were made, you lose institutional memory. This matters when teammates leave or when you need to revisit a choice six months later.
+
+**Stakeholder whiplash.** Executives who expect a polished plan before greenlighting work may not trust a "we'll figure it out as we build" pitch.
+
+The mitigation is lightweight decision records: ADRs, RFC-style docs, or even structured commit messages that capture the why, not just the what. The goal is not zero documentation. The goal is documentation that emerges from shipped work rather than preceding it.
+
+## How Roo Code captures the why automatically
+
+When you build with Roo Code, you get decision documentation as a side effect of the workflow. Every task generates a log of what was tried, what failed, and what worked. The diff history shows the evolution. PR comments capture the reasoning. The prototype becomes the specification, and the trail becomes the decision record.
+
+This addresses the "lost rationale" failure mode without requiring a separate documentation step. The institutional memory accumulates automatically because Roo Code iterates: it proposes changes, runs tests, observes results, and keeps iterating until the code passes. All of that is logged.
+
+## PRD-first vs. prototype-first workflows
+
+|                            | PRD-First (Waterfall)           | Prototype-First (Iterate)       |
+| -------------------------- | ------------------------------- | ------------------------------- |
+| **Time to first feedback** | Weeks (after spec review)       | Hours (working prototype)       |
+| **Spec accuracy**          | Stale by implementation time    | Accurate-it's the code          |
+| **Decision rationale**     | In the document (if maintained) | In task logs, PRs, and comments |
+| **Stakeholder alignment**  | Before code exists              | Around working software         |
+| **Adaptability**           | Change requires spec revision   | Change is the workflow          |
+
+## Why this matters for your team
+
+For a fast-moving team, a three-week PRD cycle is a significant tax. That's three weeks where no code ships while stakeholders negotiate requirements that will change anyway.
+
+If your team is shipping daily, the prototype-first model lets you compress the feedback loop. Instead of aligning on a document and then discovering problems in production, you discover problems in the prototype and align on fixes that already work.
+
+The compounding effect: teams that treat prototypes as the specification ship more iterations per quarter. Teams that still use waterfall-style documentation lose velocity to teams that iterate against real user feedback.
+
+> "I also feel like we don't have nearly as rigorous of a process around PRDs. PRDs kind of feel like an artifact of the past."
+>
+> Paige Bailey, [Office Hours S01E15](https://www.youtube.com/watch?v=sAFQIqmDFL4&t=2739)
+
+## The decision
+
+The question is not "should we have documentation?" The question is "when does documentation happen?"
+
+If your PRDs take longer to write than your prototypes take to ship, the economics have flipped. Start with a working prototype. Capture decisions as you make them. Align stakeholders around something they can touch, not something they have to imagine.
+
+The artifact that matters is the shipped code, not the document.
+
+## Frequently asked questions
+
+### Should my team stop writing PRDs entirely?
+
+Not necessarily-but the question is when. If your PRDs take longer to write than your prototypes take to ship, the economics have flipped. Instead of a detailed spec that ages out, consider a lightweight brief that states the problem and constraints, then iterate on a working prototype. Document decisions as you make them, not before.
+
+### How do I maintain institutional memory without detailed documentation?
+
+The answer is documentation that emerges from work, not precedes it. Task logs, PR comments, ADRs, and commit messages all capture the "why" if you're intentional about it. Tools like Roo Code generate this trail automatically-every task logs what was tried, what failed, and what worked. The prototype becomes the specification; the trail becomes the decision record.
+
+### How do I get stakeholder buy-in without a polished PRD?
+
+Align stakeholders around something they can touch, not something they have to imagine. A working prototype-even a rough one-is more persuasive than a 47-page document. The conversation shifts from "let me describe what we'll build" to "let me show you what we built and what we learned."
+
+### What about scope creep without a spec to anchor scope?
+
+Lightweight constraints still matter. A one-page brief stating the problem, success criteria, and hard boundaries can anchor scope without a multi-week PRD process. The difference is that you iterate against real feedback instead of predicted requirements. If the prototype drifts, you course-correct faster because you have working software to evaluate.
+
+### How does Roo Code help with prototype-first workflows?
+
+Roo Code connects idea to working prototype. You describe what you want; Roo Code proposes changes, runs tests, observes results, and iterates. The task log captures decisions automatically. The result is a working prototype with a built-in decision trail-no separate documentation step required.

+ 115 - 0
apps/web-roo-code/src/content/blog/score-agents-like-employees-not-like-models.md

@@ -0,0 +1,115 @@
+---
+title: Score Agents Like Employees, Not Like Models
+slug: score-agents-like-employees-not-like-models
+description: "Code correctness benchmarks miss critical agent failure modes. Score agents like employees: proactivity, context management, communication, and testing."
+primary_schema:
+    - Article
+    - FAQPage
+tags:
+    - ai-agents
+    - developer-productivity
+    - evaluation
+    - coding-agents
+status: published
+featured: true
+publish_date: "2025-11-05"
+publish_time_pt: "9:00am"
+source: "Roo Cast"
+---
+
+You're grading your AI agent on the wrong rubric.
+
+Code correctness tells you if the output compiles. It tells you nothing about whether the agent will drift, ignore context, or go silent when it hits a wall.
+
+## The benchmark trap
+
+Your agent passes the coding benchmark. It writes syntactically correct code. It handles toy problems in an eval suite.
+
+Then you put it on a real task: refactor this authentication module, follow our patterns, don't break the existing tests.
+
+It writes code that compiles. It also ignores half the context you gave it, doesn't tell you when it's stuck, and "helpfully" changes things you didn't ask for.
+
+The benchmark said it was capable. Production said otherwise.
+
+## The rubric shift
+
+Some of the teams building these agents grade them differently. They treat the agent like an employee, not like a model.
+
+> "If you design your coding evals like you would a software engineer performance review, then you can measure their ability in the same ways as you can measure somebody who's coding."
+>
+> Brian Fioca, [Roo Cast S01E16](https://www.youtube.com/watch?v=Nu5TeVQbOOE&t=1225)
+
+The rubric he described:
+
+1. **Proactivity:** Does it go ahead and do all of it, or does it stop and wait when it could keep moving?
+2. **Context management:** Can it keep all of the context it needs in memory without getting lost?
+3. **Communication:** Does it tell you its plan before executing? Does it surface when it's stuck?
+4. **Testing:** Does it validate its own work, or does it hand you untested code?
+
+These aren't code quality metrics. They're work style metrics. The difference matters.
+
+## Why correctness evals miss the failure modes
+
+A code correctness eval asks: "Did the output match the expected output?"
+
+A work style eval asks: "How did it get there, and what happens when the task gets harder?"
+
+An agent that scores high on correctness but low on communication will confidently produce wrong code without flagging uncertainty. An agent that scores low on context management will lose track of requirements halfway through a multi-file change. An agent that scores low on proactivity will stop and wait for you to hold its hand on every sub-task.
+
+These failure modes don't show up in benchmarks. They show up in real work.
+
+## Benchmark approach vs. work style approach
+
+| Dimension                   | Benchmark Approach                 | Work Style Approach                           |
+| --------------------------- | ---------------------------------- | --------------------------------------------- |
+| What it measures            | Code correctness on isolated tasks | Behavior patterns across complex workflows    |
+| Failure modes caught        | Syntax errors, wrong outputs       | Drift, context loss, silent failures          |
+| Task realism                | Toy problems, synthetic evals      | Multi-file changes, production patterns       |
+| Feedback loop               | Pass/fail on expected output       | Grades on proactivity, communication, testing |
+| Production readiness signal | "It can write code"                | "It can work reliably on your team"           |
+
+## How to build the rubric
+
+The approach: human-grade first, then tune an LLM-as-a-judge until it matches your scoring.
+
+1. Run realistic tasks (not toy problems)
+2. Have humans grade on proactivity, context management, communication, and testing
+3. Build an LLM-as-a-judge to replicate the grades
+4. Iterate until it correlates; use it for scale, spot-check with humans
+
+The tradeoff: this takes more upfront work than a correctness benchmark. The payoff is catching failure modes before they hit production.
+
+## How Roo Code makes agents reviewable
+
+Closing the loop is necessary, but it's not the whole job. What matters in production is whether an agent can iterate in a real environment **before the PR** and hand you something you can actually review: a diff, evidence, and a clear trail of what happened.
+
+That's the direction Roo Code is built for, and it maps directly to the rubric:
+
+- **Proactivity:** It can keep moving through sub-tasks and iterate until it has something reviewable
+- **Context management:** It can maintain context across multi-file changes without losing requirements halfway through
+- **Communication:** It can surface a plan and blockers as it goes, with artifacts you can inspect
+- **Testing:** It can run commands/tests and iterate on failures instead of handing you unverified code
+
+## Why this matters for your team
+
+For a Series A–C team with five to twenty engineers, agent reliability is a force multiplier. If your agent drifts or goes silent on complex tasks, someone has to babysit it. That someone is an engineer who could be shipping.
+
+Work style evals surface these problems before you've built workflows around an agent that can't handle the job. You find out in the eval, not in the incident postmortem.
+
+The rubric: proactivity, context management, communication, testing. Grade your agent like you'd grade a junior engineer on a trial period.
+
+If it can't tell you its plan, it's not ready for production.
+
+## Frequently asked questions
+
+### Why do code correctness benchmarks fail to predict production reliability?
+
+Code correctness benchmarks measure whether the output matches an expected result on isolated tasks. They don't capture how an agent behaves when context is complex, when it gets stuck, or when requirements span multiple files. An agent can score perfectly on benchmarks while drifting silently on real work.
+
+### What are the four work style metrics for evaluating coding agents?
+
+The four metrics are proactivity (does it keep moving or stop unnecessarily), context management (can it track requirements across a complex task), communication (does it share its plan and surface blockers), and testing (does it validate its own work). These predict production reliability better than correctness scores.
+
+### Can I use LLM-as-a-judge for automated work style evaluation?
+
+Yes. The recommended approach is to have humans grade agent work on the four dimensions first, then train an LLM-as-a-judge to replicate those grades. Once the automated judge correlates with human judgment, use it for scale while spot-checking with humans periodically.

+ 40 - 0
apps/web-roo-code/src/lib/blog/analytics.ts

@@ -0,0 +1,40 @@
+/**
+ * Blog-specific PostHog analytics events
+ * MKT-74: Blog Analytics (PostHog)
+ */
+
+import posthog from "posthog-js"
+import type { BlogPost } from "./types"
+
+/**
+ * Track blog index page view
+ */
+export function trackBlogIndexView(): void {
+	if (typeof window !== "undefined" && posthog.__loaded) {
+		posthog.capture("blog_index_view")
+	}
+}
+
+/**
+ * Track individual blog post view
+ */
+export function trackBlogPostView(post: BlogPost): void {
+	if (typeof window !== "undefined" && posthog.__loaded) {
+		posthog.capture("blog_post_view", {
+			slug: post.slug,
+			title: post.title,
+			publish_date: post.publish_date,
+			publish_time_pt: post.publish_time_pt,
+			tags: post.tags,
+		})
+	}
+}
+
+/**
+ * Track Substack subscribe click
+ */
+export function trackSubstackClick(): void {
+	if (typeof window !== "undefined" && posthog.__loaded) {
+		posthog.capture("blog_substack_click")
+	}
+}

+ 205 - 0
apps/web-roo-code/src/lib/blog/content.ts

@@ -0,0 +1,205 @@
+/**
+ * Blog content loading from Markdown files
+ * MKT-67: Blog Content Layer
+ */
+
+import fs from "fs"
+import path from "path"
+import matter from "gray-matter"
+import { BlogFrontmatterSchema } from "./validation"
+import type { BlogPost } from "./types"
+import { getNowPt, isPublished, parsePublishTimePt } from "./time"
+import { filterFeaturedPosts } from "./curated"
+
+const BLOG_DIR = path.join(process.cwd(), "src/content/blog")
+
+/** Posts per page for pagination */
+export const POSTS_PER_PAGE = 12
+
+/** Pagination result type */
+export interface PaginatedBlogPosts {
+	posts: BlogPost[]
+	currentPage: number
+	totalPages: number
+	totalPosts: number
+	hasNextPage: boolean
+	hasPreviousPage: boolean
+}
+
+/**
+ * Get all blog posts from the content directory
+ * @param options.includeDrafts - If true, include draft and future posts
+ * @returns Array of blog posts sorted by publish date (newest first)
+ */
+export function getAllBlogPosts(options?: { includeDrafts?: boolean }): BlogPost[] {
+	const nowPt = getNowPt()
+
+	// Ensure blog directory exists
+	if (!fs.existsSync(BLOG_DIR)) {
+		return []
+	}
+
+	const files = fs.readdirSync(BLOG_DIR).filter((f) => f.endsWith(".md"))
+
+	const posts: BlogPost[] = []
+	const slugs = new Map<string, string>() // slug -> filepath for duplicate detection
+
+	for (const file of files) {
+		const filepath = path.join(BLOG_DIR, file)
+		const raw = fs.readFileSync(filepath, "utf8")
+		const { data, content } = matter(raw)
+
+		// Validate frontmatter
+		const result = BlogFrontmatterSchema.safeParse(data)
+		if (!result.success) {
+			const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")
+			throw new Error(`Invalid frontmatter in ${file}: ${errors}`)
+		}
+
+		const frontmatter = result.data
+
+		// Check for duplicate slugs
+		if (slugs.has(frontmatter.slug)) {
+			throw new Error(`Duplicate slug "${frontmatter.slug}" found in ${file} and ${slugs.get(frontmatter.slug)}`)
+		}
+		slugs.set(frontmatter.slug, file)
+
+		const post: BlogPost = {
+			...frontmatter,
+			content,
+			filepath: file,
+		}
+
+		// Filter based on options
+		if (options?.includeDrafts) {
+			posts.push(post)
+		} else if (isPublished(post, nowPt)) {
+			posts.push(post)
+		}
+	}
+
+	// Sort by publish_date desc, then publish_time_pt desc
+	return posts.sort((a, b) => {
+		if (a.publish_date !== b.publish_date) {
+			return b.publish_date.localeCompare(a.publish_date)
+		}
+		const aMinutes = parsePublishTimePt(a.publish_time_pt)
+		const bMinutes = parsePublishTimePt(b.publish_time_pt)
+		return bMinutes - aMinutes
+	})
+}
+
+/**
+ * Get paginated blog posts
+ * @param page - Page number (1-indexed)
+ * @param options.includeDrafts - If true, include draft and future posts
+ * @returns Paginated result with posts and pagination metadata
+ */
+export function getPaginatedBlogPosts(page: number = 1, options?: { includeDrafts?: boolean }): PaginatedBlogPosts {
+	const allPosts = getAllBlogPosts(options)
+	const totalPosts = allPosts.length
+	const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE)
+
+	// Clamp page to valid range
+	const currentPage = Math.max(1, Math.min(page, totalPages || 1))
+
+	const startIndex = (currentPage - 1) * POSTS_PER_PAGE
+	const endIndex = startIndex + POSTS_PER_PAGE
+	const posts = allPosts.slice(startIndex, endIndex)
+
+	return {
+		posts,
+		currentPage,
+		totalPages,
+		totalPosts,
+		hasNextPage: currentPage < totalPages,
+		hasPreviousPage: currentPage > 1,
+	}
+}
+
+/**
+ * Get a single blog post by slug
+ * Returns null if not found or not published
+ * @param slug - The post slug
+ * @returns The blog post or null
+ */
+export function getBlogPostBySlug(slug: string): BlogPost | null {
+	const nowPt = getNowPt()
+
+	// Ensure blog directory exists
+	if (!fs.existsSync(BLOG_DIR)) {
+		return null
+	}
+
+	const files = fs.readdirSync(BLOG_DIR).filter((f) => f.endsWith(".md"))
+
+	for (const file of files) {
+		const filepath = path.join(BLOG_DIR, file)
+		const raw = fs.readFileSync(filepath, "utf8")
+		const { data, content } = matter(raw)
+
+		// Validate frontmatter
+		const result = BlogFrontmatterSchema.safeParse(data)
+		if (!result.success) {
+			continue // Skip invalid posts when looking up by slug
+		}
+
+		const frontmatter = result.data
+
+		if (frontmatter.slug === slug) {
+			const post: BlogPost = {
+				...frontmatter,
+				content,
+				filepath: file,
+			}
+
+			// Only return if published
+			if (isPublished(post, nowPt)) {
+				return post
+			}
+
+			// Post exists but is not published (draft or scheduled)
+			return null
+		}
+	}
+
+	return null
+}
+
+/**
+ * Get adjacent posts (previous and next) for navigation
+ * Posts are ordered newest-first, so:
+ * - "previous" = newer post (earlier in the array)
+ * - "next" = older post (later in the array)
+ * @param slug - The current post's slug
+ * @returns Object with previous and next posts (or null if they don't exist)
+ */
+export function getAdjacentPosts(slug: string): { previous: BlogPost | null; next: BlogPost | null } {
+	const posts = getAllBlogPosts()
+	const currentIndex = posts.findIndex((p) => p.slug === slug)
+
+	if (currentIndex === -1) {
+		return { previous: null, next: null }
+	}
+
+	// Posts are sorted newest-first
+	// "previous" = newer post (index - 1)
+	// "next" = older post (index + 1)
+	const previous = currentIndex > 0 ? (posts[currentIndex - 1] ?? null) : null
+	const next = currentIndex < posts.length - 1 ? (posts[currentIndex + 1] ?? null) : null
+
+	return { previous, next }
+}
+
+/**
+ * Get featured blog posts
+ *
+ * Returns published posts with `featured: true` in frontmatter,
+ * sorted by publish_date (newest first).
+ *
+ * @returns Array of featured blog posts
+ */
+export function getCuratedBlogPosts(): BlogPost[] {
+	const allPosts = getAllBlogPosts()
+	return filterFeaturedPosts(allPosts)
+}

+ 23 - 0
apps/web-roo-code/src/lib/blog/curated.ts

@@ -0,0 +1,23 @@
+/**
+ * Curated/featured blog posts utilities
+ *
+ * Featured posts are determined by the `featured: true` frontmatter field.
+ * This approach is scalable: edit the markdown file to add/remove from featured.
+ */
+
+import type { BlogPost } from "./types"
+
+/**
+ * Check if a post is featured based on frontmatter
+ */
+export function isCuratedPost(post: BlogPost): boolean {
+	return post.featured === true
+}
+
+/**
+ * Filter posts to only featured ones
+ * Returns posts sorted by publish_date (newest first)
+ */
+export function filterFeaturedPosts(posts: BlogPost[]): BlogPost[] {
+	return posts.filter((post) => post.featured === true)
+}

+ 37 - 0
apps/web-roo-code/src/lib/blog/index.ts

@@ -0,0 +1,37 @@
+/**
+ * Blog content layer exports
+ * MKT-67: Blog Content Layer
+ */
+
+// Types
+export type { BlogPost, BlogPostFrontmatter, BlogSource, NowPt } from "./types"
+export type { PaginatedBlogPosts } from "./content"
+
+// Content loading
+export {
+	getAllBlogPosts,
+	getBlogPostBySlug,
+	getAdjacentPosts,
+	getPaginatedBlogPosts,
+	getCuratedBlogPosts,
+	POSTS_PER_PAGE,
+} from "./content"
+
+// Featured posts
+export { isCuratedPost, filterFeaturedPosts } from "./curated"
+
+// Time utilities
+export {
+	getNowPt,
+	parsePublishTimePt,
+	isPublished,
+	formatPostDatePt,
+	calculateReadingTime,
+	formatReadingTime,
+} from "./time"
+
+// Validation
+export { BlogFrontmatterSchema, type ValidatedFrontmatter } from "./validation"
+
+// Analytics
+export { trackBlogIndexView, trackBlogPostView, trackSubstackClick } from "./analytics"

+ 158 - 0
apps/web-roo-code/src/lib/blog/time.ts

@@ -0,0 +1,158 @@
+/**
+ * Pacific Time utilities for blog publishing
+ * MKT-67: Blog Content Layer
+ */
+
+import type { BlogPost, NowPt } from "./types"
+
+/**
+ * Get the current time in Pacific Time
+ * Returns date as YYYY-MM-DD and minutes since midnight
+ */
+export function getNowPt(): NowPt {
+	const formatter = new Intl.DateTimeFormat("en-US", {
+		timeZone: "America/Los_Angeles",
+		year: "numeric",
+		month: "2-digit",
+		day: "2-digit",
+		hour: "2-digit",
+		minute: "2-digit",
+		hour12: false,
+	})
+
+	const parts = formatter.formatToParts(new Date())
+	const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""
+
+	const date = `${get("year")}-${get("month")}-${get("day")}`
+	const minutes = parseInt(get("hour"), 10) * 60 + parseInt(get("minute"), 10)
+
+	return { date, minutes }
+}
+
+/**
+ * Parse publish_time_pt string to minutes since midnight
+ * @param time - Time string in h:mmam/pm format (e.g., "9:00am")
+ * @returns Minutes since midnight
+ * @throws Error if format is invalid
+ */
+export function parsePublishTimePt(time: string): number {
+	const match = time.match(/^(1[0-2]|[1-9]):([0-5][0-9])(am|pm)$/i)
+	if (!match) {
+		throw new Error(`Invalid time format: ${time}. Expected h:mmam/pm (e.g., 9:00am)`)
+	}
+
+	const hoursStr = match[1]
+	const minsStr = match[2]
+	const amPm = match[3]
+
+	if (!hoursStr || !minsStr || !amPm) {
+		throw new Error(`Invalid time format: ${time}. Expected h:mmam/pm (e.g., 9:00am)`)
+	}
+
+	let hours = parseInt(hoursStr, 10)
+	const mins = parseInt(minsStr, 10)
+	const isPm = amPm.toLowerCase() === "pm"
+
+	// Convert 12-hour to 24-hour
+	if (hours === 12) {
+		hours = isPm ? 12 : 0
+	} else if (isPm) {
+		hours += 12
+	}
+
+	return hours * 60 + mins
+}
+
+/**
+ * Check if a blog post is published based on PT time
+ * A post is public when:
+ * - status is "published"
+ * - AND (now_pt_date > publish_date OR (now_pt_date == publish_date AND now_pt_minutes >= publish_time_pt_minutes))
+ */
+export function isPublished(post: BlogPost, nowPt: NowPt): boolean {
+	if (post.status !== "published") {
+		return false
+	}
+
+	const postMinutes = parsePublishTimePt(post.publish_time_pt)
+
+	// Public when: now_pt_date > publish_date
+	if (nowPt.date > post.publish_date) {
+		return true
+	}
+
+	// OR (now_pt_date == publish_date AND now_pt_minutes >= publish_time_pt_minutes)
+	if (nowPt.date === post.publish_date && nowPt.minutes >= postMinutes) {
+		return true
+	}
+
+	return false
+}
+
+/**
+ * Format publish date for display
+ * Returns the date as-is in YYYY-MM-DD format
+ */
+export function formatPostDatePt(publishDate: string): string {
+	return publishDate
+}
+
+/**
+ * Strip all angle brackets from text to remove any HTML tags or fragments.
+ * This is used only for word-count purposes in reading-time calculation,
+ * so a single-pass removal of every `<` and `>` is sufficient and
+ * avoids the incomplete multi-character sanitization pattern that
+ * iterative tag-stripping is vulnerable to.
+ */
+function stripHtmlTags(text: string): string {
+	return text.replace(/[<>]/g, "")
+}
+
+/**
+ * Calculate reading time for a piece of content
+ * Uses average reading speed of 200 words per minute
+ * @param content - The markdown content to calculate reading time for
+ * @returns Reading time in minutes (minimum 1)
+ */
+export function calculateReadingTime(content: string): number {
+	// Strip markdown syntax for more accurate word count
+	const plainText = stripHtmlTags(
+		content
+			// Remove code blocks
+			.replace(/```[\s\S]*?```/g, "")
+			// Remove inline code
+			.replace(/`[^`]+`/g, "")
+			// Remove images
+			.replace(/!\[.*?\]\(.*?\)/g, "")
+			// Remove links but keep text
+			.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+			// Remove headers markers
+			.replace(/^#{1,6}\s+/gm, "")
+			// Remove emphasis
+			.replace(/[*_]{1,2}([^*_]+)[*_]{1,2}/g, "$1")
+			// Remove horizontal rules
+			.replace(/^[-*_]{3,}\s*$/gm, ""),
+	)
+
+	// Count words (split on whitespace)
+	const words = plainText
+		.trim()
+		.split(/\s+/)
+		.filter((word) => word.length > 0)
+	const wordCount = words.length
+
+	// Calculate reading time (200 words per minute average)
+	const readingTime = Math.ceil(wordCount / 200)
+
+	// Return minimum of 1 minute
+	return Math.max(1, readingTime)
+}
+
+/**
+ * Format reading time for display
+ * @param minutes - Reading time in minutes
+ * @returns Formatted string (e.g., "5 min read")
+ */
+export function formatReadingTime(minutes: number): string {
+	return `${minutes} min read`
+}

+ 32 - 0
apps/web-roo-code/src/lib/blog/types.ts

@@ -0,0 +1,32 @@
+/**
+ * Blog content types
+ * MKT-67: Blog Content Layer
+ */
+
+/**
+ * Content source for blog posts
+ * Posts derived from podcast episodes have a source; standalone articles may not
+ */
+export type BlogSource = "Office Hours" | "After Hours" | "Roo Cast"
+
+export interface BlogPostFrontmatter {
+	title: string
+	slug: string
+	description: string
+	tags: string[]
+	status: "draft" | "published"
+	publish_date: string // YYYY-MM-DD
+	publish_time_pt: string // h:mmam/pm (e.g., "9:00am")
+	source?: BlogSource // Optional: indicates podcast source
+	featured?: boolean // Optional: marks post as featured
+}
+
+export interface BlogPost extends BlogPostFrontmatter {
+	content: string // Markdown body
+	filepath: string // For error messages
+}
+
+export interface NowPt {
+	date: string // YYYY-MM-DD
+	minutes: number // Minutes since midnight PT
+}

+ 29 - 0
apps/web-roo-code/src/lib/blog/validation.ts

@@ -0,0 +1,29 @@
+/**
+ * Blog frontmatter validation using Zod
+ * MKT-67: Blog Content Layer
+ */
+
+import { z } from "zod"
+
+// Slug must be lowercase alphanumeric with hyphens
+const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
+
+// Time format: h:mmam/pm (e.g., "9:00am", "12:30pm")
+const TIME_REGEX = /^(1[0-2]|[1-9]):([0-5][0-9])(am|pm)$/i
+
+export const BlogFrontmatterSchema = z.object({
+	title: z.string().min(1, "Title is required"),
+	slug: z.string().regex(SLUG_REGEX, "Slug must match ^[a-z0-9]+(?:-[a-z0-9]+)*$"),
+	description: z.string().min(1, "Description is required"),
+	tags: z
+		.array(z.string())
+		.max(15, "Maximum 15 tags allowed")
+		.transform((tags) => tags.map((t) => t.toLowerCase().trim())),
+	status: z.enum(["draft", "published"]),
+	publish_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD format"),
+	publish_time_pt: z.string().regex(TIME_REGEX, "Must be h:mmam/pm format (e.g., 9:00am)"),
+	source: z.enum(["Office Hours", "After Hours", "Roo Cast"]).optional(),
+	featured: z.boolean().optional(),
+})
+
+export type ValidatedFrontmatter = z.infer<typeof BlogFrontmatterSchema>

+ 2 - 1
apps/web-roo-code/src/lib/constants.ts

@@ -21,7 +21,7 @@ export const EXTERNAL_LINKS = {
 	MARKETPLACE: "https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline",
 	SECURITY: "https://trust.roocode.com",
 	EVALS: "https://roocode.com/evals",
-	BLOG: "https://blog.roocode.com",
+	BLOG_SUBSTACK: "https://blog.roocode.com",
 	OFFICE_HOURS_PODCAST: "https://www.youtube.com/@RooCodeYT/podcasts",
 	FAQ: "https://roocode.com/#faq",
 	TESTIMONIALS: "https://roocode.com/#testimonials",
@@ -35,4 +35,5 @@ export const EXTERNAL_LINKS = {
 
 export const INTERNAL_LINKS = {
 	PRIVACY_POLICY_WEBSITE: "/privacy",
+	BLOG: "/blog",
 }

+ 14 - 0
apps/web-roo-code/vitest.config.ts

@@ -0,0 +1,14 @@
+import { defineConfig } from "vitest/config"
+import path from "path"
+
+export default defineConfig({
+	test: {
+		include: ["src/**/*.test.{ts,tsx}"],
+		environment: "node",
+	},
+	resolve: {
+		alias: {
+			"@": path.resolve(__dirname, "./src"),
+		},
+	},
+})

+ 222 - 10
pnpm-lock.yaml

@@ -376,6 +376,9 @@ importers:
       framer-motion:
         specifier: ^12.29.2
         version: 12.29.2(@emotion/[email protected])([email protected]([email protected]))([email protected])
+      gray-matter:
+        specifier: ^4.0.3
+        version: 4.0.3
       lucide-react:
         specifier: ^0.563.0
         version: 0.563.0([email protected])
@@ -455,6 +458,9 @@ importers:
       tailwindcss:
         specifier: ^3.4.17
         version: 3.4.17
+      vitest:
+        specifier: ^4.0.18
+        version: 4.0.18(@opentelemetry/[email protected])(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
 
   packages/build:
     dependencies:
@@ -2517,6 +2523,9 @@ packages:
   '@jridgewell/[email protected]':
     resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
 
+  '@jridgewell/[email protected]':
+    resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
   '@jridgewell/[email protected]':
     resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
 
@@ -4720,6 +4729,9 @@ packages:
   '@vitest/[email protected]':
     resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
 
+  '@vitest/[email protected]':
+    resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
+
   '@vitest/[email protected]':
     resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
     peerDependencies:
@@ -4731,18 +4743,41 @@ packages:
       vite:
         optional: true
 
+  '@vitest/[email protected]':
+    resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
+    peerDependencies:
+      msw: ^2.4.9
+      vite: ^6.0.0 || ^7.0.0-0
+    peerDependenciesMeta:
+      msw:
+        optional: true
+      vite:
+        optional: true
+
   '@vitest/[email protected]':
     resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
 
+  '@vitest/[email protected]':
+    resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
+
   '@vitest/[email protected]':
     resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
 
+  '@vitest/[email protected]':
+    resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
+
   '@vitest/[email protected]':
     resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
 
+  '@vitest/[email protected]':
+    resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
+
   '@vitest/[email protected]':
     resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
 
+  '@vitest/[email protected]':
+    resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
+
   '@vitest/[email protected]':
     resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==}
     peerDependencies:
@@ -4751,6 +4786,9 @@ packages:
   '@vitest/[email protected]':
     resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
 
+  '@vitest/[email protected]':
+    resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
+
   '@vscode/[email protected]':
     resolution: {integrity: sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==}
 
@@ -5258,6 +5296,10 @@ packages:
     resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
     engines: {node: '>=12'}
 
+  [email protected]:
+    resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+    engines: {node: '>=18'}
+
   [email protected]:
     resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
 
@@ -6505,6 +6547,10 @@ packages:
     resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==}
     engines: {node: '>=12.0.0'}
 
+  [email protected]:
+    resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+    engines: {node: '>=12.0.0'}
+
   [email protected]:
     resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -6608,6 +6654,15 @@ packages:
       picomatch:
         optional: true
 
+  [email protected]:
+    resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+    engines: {node: '>=12.0.0'}
+    peerDependencies:
+      picomatch: ^3 || ^4
+    peerDependenciesMeta:
+      picomatch:
+        optional: true
+
   [email protected]:
     resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
     engines: {node: ^12.20 || >= 14.13}
@@ -8023,6 +8078,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
 
+  [email protected]:
+    resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
   [email protected]:
     resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
     engines: {node: '>=10'}
@@ -8547,6 +8605,9 @@ packages:
     resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
     engines: {node: '>= 0.4'}
 
+  [email protected]:
+    resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+
   [email protected]:
     resolution: {integrity: sha512-q5LmPtk6GLFouS+3aURIVl+qcAOPC4+Msmx7uBb3pd+fxI55WnGjmLZ0yijI/CYy79x0QPGx3BwC3u5zv9fBvQ==}
 
@@ -8804,6 +8865,10 @@ packages:
     resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
     engines: {node: '>=12'}
 
+  [email protected]:
+    resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+    engines: {node: '>=12'}
+
   [email protected]:
     resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
     engines: {node: '>=0.10'}
@@ -9778,6 +9843,9 @@ packages:
     resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
     engines: {node: '>= 0.8'}
 
+  [email protected]:
+    resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
   [email protected]:
     resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
 
@@ -10081,14 +10149,18 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
 
-  tiny[email protected]:
-    resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==}
-    engines: {node: '>=12.0.0'}
+  tiny[email protected]:
+    resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
+    engines: {node: '>=18'}
 
   [email protected]:
     resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
     engines: {node: '>=12.0.0'}
 
+  [email protected]:
+    resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+    engines: {node: '>=12.0.0'}
+
   [email protected]:
     resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
     engines: {node: ^18.0.0 || >=20.0.0}
@@ -10097,6 +10169,10 @@ packages:
     resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
     engines: {node: '>=14.0.0'}
 
+  [email protected]:
+    resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
+    engines: {node: '>=14.0.0'}
+
   [email protected]:
     resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==}
     engines: {node: '>=14.0.0'}
@@ -10651,6 +10727,40 @@ packages:
       jsdom:
         optional: true
 
+  [email protected]:
+    resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
+    engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+    hasBin: true
+    peerDependencies:
+      '@edge-runtime/vm': '*'
+      '@opentelemetry/api': ^1.9.0
+      '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+      '@vitest/browser-playwright': 4.0.18
+      '@vitest/browser-preview': 4.0.18
+      '@vitest/browser-webdriverio': 4.0.18
+      '@vitest/ui': 4.0.18
+      happy-dom: '*'
+      jsdom: '*'
+    peerDependenciesMeta:
+      '@edge-runtime/vm':
+        optional: true
+      '@opentelemetry/api':
+        optional: true
+      '@types/node':
+        optional: true
+      '@vitest/browser-playwright':
+        optional: true
+      '@vitest/browser-preview':
+        optional: true
+      '@vitest/browser-webdriverio':
+        optional: true
+      '@vitest/ui':
+        optional: true
+      happy-dom:
+        optional: true
+      jsdom:
+        optional: true
+
   [email protected]:
     resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
     engines: {node: '>=0.10.0'}
@@ -12538,6 +12648,8 @@ snapshots:
 
   '@jridgewell/[email protected]': {}
 
+  '@jridgewell/[email protected]': {}
+
   '@jridgewell/[email protected]':
     dependencies:
       '@jridgewell/resolve-uri': 3.1.2
@@ -14874,6 +14986,15 @@ snapshots:
       chai: 5.2.0
       tinyrainbow: 2.0.0
 
+  '@vitest/[email protected]':
+    dependencies:
+      '@standard-schema/spec': 1.1.0
+      '@types/chai': 5.2.2
+      '@vitest/spy': 4.0.18
+      '@vitest/utils': 4.0.18
+      chai: 6.2.2
+      tinyrainbow: 3.0.3
+
   '@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
     dependencies:
       '@vitest/spy': 3.2.4
@@ -14898,26 +15019,51 @@ snapshots:
     optionalDependencies:
       vite: 6.3.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
 
+  '@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
+    dependencies:
+      '@vitest/spy': 4.0.18
+      estree-walker: 3.0.3
+      magic-string: 0.30.21
+    optionalDependencies:
+      vite: 6.3.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
+
   '@vitest/[email protected]':
     dependencies:
       tinyrainbow: 2.0.0
 
+  '@vitest/[email protected]':
+    dependencies:
+      tinyrainbow: 3.0.3
+
   '@vitest/[email protected]':
     dependencies:
       '@vitest/utils': 3.2.4
       pathe: 2.0.3
       strip-literal: 3.0.0
 
+  '@vitest/[email protected]':
+    dependencies:
+      '@vitest/utils': 4.0.18
+      pathe: 2.0.3
+
   '@vitest/[email protected]':
     dependencies:
       '@vitest/pretty-format': 3.2.4
       magic-string: 0.30.17
       pathe: 2.0.3
 
+  '@vitest/[email protected]':
+    dependencies:
+      '@vitest/pretty-format': 4.0.18
+      magic-string: 0.30.21
+      pathe: 2.0.3
+
   '@vitest/[email protected]':
     dependencies:
       tinyspy: 4.0.3
 
+  '@vitest/[email protected]': {}
+
   '@vitest/[email protected]([email protected])':
     dependencies:
       '@vitest/utils': 3.2.4
@@ -14935,6 +15081,11 @@ snapshots:
       loupe: 3.1.4
       tinyrainbow: 2.0.0
 
+  '@vitest/[email protected]':
+    dependencies:
+      '@vitest/pretty-format': 4.0.18
+      tinyrainbow: 3.0.3
+
   '@vscode/[email protected]': {}
 
   '@vscode/[email protected]':
@@ -15522,6 +15673,8 @@ snapshots:
       loupe: 3.1.3
       pathval: 2.0.0
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       traverse: 0.3.9
@@ -16857,6 +17010,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       '@jest/expect-utils': 29.7.0
@@ -16986,6 +17141,10 @@ snapshots:
     optionalDependencies:
       picomatch: 4.0.2
 
+  [email protected]([email protected]):
+    optionalDependencies:
+      picomatch: 4.0.3
+
   [email protected]:
     dependencies:
       node-domexception: 1.0.0
@@ -18504,6 +18663,10 @@ snapshots:
     dependencies:
       '@jridgewell/sourcemap-codec': 1.5.0
 
+  [email protected]:
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.5.5
+
   [email protected]:
     dependencies:
       semver: 7.7.3
@@ -19297,6 +19460,8 @@ snapshots:
       define-properties: 1.2.1
       es-object-atoms: 1.1.1
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       whatwg-fetch: 3.6.20
@@ -19584,6 +19749,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -20805,6 +20972,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -21149,20 +21318,24 @@ snapshots:
 
   [email protected]: {}
 
-  [email protected]:
-    dependencies:
-      fdir: 6.4.6([email protected])
-      picomatch: 4.0.2
+  [email protected]: {}
 
   [email protected]:
     dependencies:
       fdir: 6.4.6([email protected])
       picomatch: 4.0.2
 
+  [email protected]:
+    dependencies:
+      fdir: 6.5.0([email protected])
+      picomatch: 4.0.3
+
   [email protected]: {}
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -21707,7 +21880,7 @@ snapshots:
       picomatch: 4.0.2
       postcss: 8.5.6
       rollup: 4.40.2
-      tinyglobby: 0.2.13
+      tinyglobby: 0.2.14
     optionalDependencies:
       '@types/node': 20.17.50
       fsevents: 2.3.3
@@ -21723,7 +21896,7 @@ snapshots:
       picomatch: 4.0.2
       postcss: 8.5.6
       rollup: 4.40.2
-      tinyglobby: 0.2.13
+      tinyglobby: 0.2.14
     optionalDependencies:
       '@types/node': 20.17.57
       fsevents: 2.3.3
@@ -21739,7 +21912,7 @@ snapshots:
       picomatch: 4.0.2
       postcss: 8.5.6
       rollup: 4.40.2
-      tinyglobby: 0.2.13
+      tinyglobby: 0.2.14
     optionalDependencies:
       '@types/node': 24.2.1
       fsevents: 2.3.3
@@ -21928,6 +22101,45 @@ snapshots:
       - tsx
       - yaml
 
+  [email protected](@opentelemetry/[email protected])(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]):
+    dependencies:
+      '@vitest/expect': 4.0.18
+      '@vitest/mocker': 4.0.18([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
+      '@vitest/pretty-format': 4.0.18
+      '@vitest/runner': 4.0.18
+      '@vitest/snapshot': 4.0.18
+      '@vitest/spy': 4.0.18
+      '@vitest/utils': 4.0.18
+      es-module-lexer: 1.7.0
+      expect-type: 1.3.0
+      magic-string: 0.30.21
+      obug: 2.1.1
+      pathe: 2.0.3
+      picomatch: 4.0.3
+      std-env: 3.10.0
+      tinybench: 2.9.0
+      tinyexec: 1.0.2
+      tinyglobby: 0.2.15
+      tinyrainbow: 3.0.3
+      vite: 6.3.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
+      why-is-node-running: 2.3.0
+    optionalDependencies:
+      '@opentelemetry/api': 1.9.0
+      '@types/node': 20.17.57
+      jsdom: 26.1.0
+    transitivePeerDependencies:
+      - jiti
+      - less
+      - lightningcss
+      - msw
+      - sass
+      - sass-embedded
+      - stylus
+      - sugarss
+      - terser
+      - tsx
+      - yaml
+
   [email protected]: {}
 
   [email protected]: {}

+ 143 - 39
webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx

@@ -3,24 +3,28 @@ import { render, screen } from "@/utils/test-utils"
 import type { HistoryItem } from "@roo-code/types"
 
 import HistoryPreview from "../HistoryPreview"
+import type { TaskGroup } from "../types"
 
 vi.mock("../useTaskSearch")
+vi.mock("../useGroupedTasks")
 
-vi.mock("../TaskItem", () => {
+vi.mock("../TaskGroupItem", () => {
 	return {
-		default: vi.fn(({ item, variant }) => (
-			<div data-testid={`task-item-${item.id}`} data-variant={variant}>
-				{item.task}
+		default: vi.fn(({ group, variant }) => (
+			<div data-testid={`task-group-${group.parent.id}`} data-variant={variant}>
+				{group.parent.task}
 			</div>
 		)),
 	}
 })
 
 import { useTaskSearch } from "../useTaskSearch"
-import TaskItem from "../TaskItem"
+import { useGroupedTasks } from "../useGroupedTasks"
+import TaskGroupItem from "../TaskGroupItem"
 
 const mockUseTaskSearch = useTaskSearch as any
-const mockTaskItem = TaskItem as any
+const mockUseGroupedTasks = useGroupedTasks as any
+const mockTaskGroupItem = TaskGroupItem as any
 
 const mockTasks: HistoryItem[] = [
 	{
@@ -79,6 +83,15 @@ const mockTasks: HistoryItem[] = [
 	},
 ]
 
+// Helper to create mock groups from tasks
+function createMockGroups(tasks: HistoryItem[]): TaskGroup[] {
+	return tasks.map((task) => ({
+		parent: { ...task, isSubtask: false },
+		subtasks: [],
+		isExpanded: false,
+	}))
+}
+
 describe("HistoryPreview", () => {
 	beforeEach(() => {
 		vi.clearAllMocks()
@@ -97,14 +110,21 @@ describe("HistoryPreview", () => {
 			setShowAllWorkspaces: vi.fn(),
 		})
 
+		mockUseGroupedTasks.mockReturnValue({
+			groups: [],
+			flatTasks: null,
+			toggleExpand: vi.fn(),
+			isSearchMode: false,
+		})
+
 		const { container } = render(<HistoryPreview />)
 
-		// Should render the container but no task items
+		// Should render the container but no task groups
 		expect(container.firstChild).toHaveClass("flex", "flex-col", "gap-1")
-		expect(screen.queryByTestId(/task-item-/)).not.toBeInTheDocument()
+		expect(screen.queryByTestId(/task-group-/)).not.toBeInTheDocument()
 	})
 
-	it("renders up to 4 tasks when tasks are available", () => {
+	it("renders up to 4 groups when tasks are available", () => {
 		mockUseTaskSearch.mockReturnValue({
 			tasks: mockTasks,
 			searchQuery: "",
@@ -117,18 +137,26 @@ describe("HistoryPreview", () => {
 			setShowAllWorkspaces: vi.fn(),
 		})
 
+		const mockGroups = createMockGroups(mockTasks)
+		mockUseGroupedTasks.mockReturnValue({
+			groups: mockGroups,
+			flatTasks: null,
+			toggleExpand: vi.fn(),
+			isSearchMode: false,
+		})
+
 		render(<HistoryPreview />)
 
-		// Should render only the first 3 tasks
-		expect(screen.getByTestId("task-item-task-1")).toBeInTheDocument()
-		expect(screen.getByTestId("task-item-task-2")).toBeInTheDocument()
-		expect(screen.getByTestId("task-item-task-3")).toBeInTheDocument()
-		expect(screen.getByTestId("task-item-task-4")).toBeInTheDocument()
-		expect(screen.queryByTestId("task-item-task-5")).not.toBeInTheDocument()
-		expect(screen.queryByTestId("task-item-task-6")).not.toBeInTheDocument()
+		// Should render only the first 4 groups
+		expect(screen.getByTestId("task-group-task-1")).toBeInTheDocument()
+		expect(screen.getByTestId("task-group-task-2")).toBeInTheDocument()
+		expect(screen.getByTestId("task-group-task-3")).toBeInTheDocument()
+		expect(screen.getByTestId("task-group-task-4")).toBeInTheDocument()
+		expect(screen.queryByTestId("task-group-task-5")).not.toBeInTheDocument()
+		expect(screen.queryByTestId("task-group-task-6")).not.toBeInTheDocument()
 	})
 
-	it("renders all tasks when there are 3 or fewer", () => {
+	it("renders all groups when there are 4 or fewer", () => {
 		const threeTasks = mockTasks.slice(0, 3)
 		mockUseTaskSearch.mockReturnValue({
 			tasks: threeTasks,
@@ -142,17 +170,25 @@ describe("HistoryPreview", () => {
 			setShowAllWorkspaces: vi.fn(),
 		})
 
+		const mockGroups = createMockGroups(threeTasks)
+		mockUseGroupedTasks.mockReturnValue({
+			groups: mockGroups,
+			flatTasks: null,
+			toggleExpand: vi.fn(),
+			isSearchMode: false,
+		})
+
 		render(<HistoryPreview />)
 
-		expect(screen.getByTestId("task-item-task-1")).toBeInTheDocument()
-		expect(screen.getByTestId("task-item-task-2")).toBeInTheDocument()
-		expect(screen.getByTestId("task-item-task-3")).toBeInTheDocument()
-		expect(screen.queryByTestId("task-item-task-4")).not.toBeInTheDocument()
-		expect(screen.queryByTestId("task-item-task-5")).not.toBeInTheDocument()
-		expect(screen.queryByTestId("task-item-task-6")).not.toBeInTheDocument()
+		expect(screen.getByTestId("task-group-task-1")).toBeInTheDocument()
+		expect(screen.getByTestId("task-group-task-2")).toBeInTheDocument()
+		expect(screen.getByTestId("task-group-task-3")).toBeInTheDocument()
+		expect(screen.queryByTestId("task-group-task-4")).not.toBeInTheDocument()
+		expect(screen.queryByTestId("task-group-task-5")).not.toBeInTheDocument()
+		expect(screen.queryByTestId("task-group-task-6")).not.toBeInTheDocument()
 	})
 
-	it("renders only 1 task when there is only 1 task", () => {
+	it("renders only 1 group when there is only 1 task", () => {
 		const oneTask = mockTasks.slice(0, 1)
 		mockUseTaskSearch.mockReturnValue({
 			tasks: oneTask,
@@ -166,15 +202,24 @@ describe("HistoryPreview", () => {
 			setShowAllWorkspaces: vi.fn(),
 		})
 
+		const mockGroups = createMockGroups(oneTask)
+		mockUseGroupedTasks.mockReturnValue({
+			groups: mockGroups,
+			flatTasks: null,
+			toggleExpand: vi.fn(),
+			isSearchMode: false,
+		})
+
 		render(<HistoryPreview />)
 
-		expect(screen.getByTestId("task-item-task-1")).toBeInTheDocument()
-		expect(screen.queryByTestId("task-item-task-2")).not.toBeInTheDocument()
+		expect(screen.getByTestId("task-group-task-1")).toBeInTheDocument()
+		expect(screen.queryByTestId("task-group-task-2")).not.toBeInTheDocument()
 	})
 
-	it("passes correct props to TaskItem components", () => {
+	it("passes correct props to TaskGroupItem components", () => {
+		const threeTasks = mockTasks.slice(0, 3)
 		mockUseTaskSearch.mockReturnValue({
-			tasks: mockTasks.slice(0, 3),
+			tasks: threeTasks,
 			searchQuery: "",
 			setSearchQuery: vi.fn(),
 			sortOption: "newest",
@@ -185,35 +230,43 @@ describe("HistoryPreview", () => {
 			setShowAllWorkspaces: vi.fn(),
 		})
 
+		const mockGroups = createMockGroups(threeTasks)
+		mockUseGroupedTasks.mockReturnValue({
+			groups: mockGroups,
+			flatTasks: null,
+			toggleExpand: vi.fn(),
+			isSearchMode: false,
+		})
+
 		render(<HistoryPreview />)
 
-		// Verify TaskItem was called with correct props for first 3 tasks
-		expect(mockTaskItem).toHaveBeenCalledWith(
+		// Verify TaskGroupItem was called with correct props for first 3 groups
+		expect(mockTaskGroupItem).toHaveBeenCalledWith(
 			expect.objectContaining({
-				item: mockTasks[0],
+				group: mockGroups[0],
 				variant: "compact",
 			}),
 			expect.anything(),
 		)
-		expect(mockTaskItem).toHaveBeenCalledWith(
+		expect(mockTaskGroupItem).toHaveBeenCalledWith(
 			expect.objectContaining({
-				item: mockTasks[1],
+				group: mockGroups[1],
 				variant: "compact",
 			}),
 			expect.anything(),
 		)
-		expect(mockTaskItem).toHaveBeenCalledWith(
+		expect(mockTaskGroupItem).toHaveBeenCalledWith(
 			expect.objectContaining({
-				item: mockTasks[2],
+				group: mockGroups[2],
 				variant: "compact",
 			}),
 			expect.anything(),
 		)
 	})
 
-	it("renders with correct container classes", () => {
+	it("displays the header and view all button", () => {
 		mockUseTaskSearch.mockReturnValue({
-			tasks: mockTasks.slice(0, 1),
+			tasks: mockTasks,
 			searchQuery: "",
 			setSearchQuery: vi.fn(),
 			sortOption: "newest",
@@ -224,8 +277,59 @@ describe("HistoryPreview", () => {
 			setShowAllWorkspaces: vi.fn(),
 		})
 
-		const { container } = render(<HistoryPreview />)
+		const mockGroups = createMockGroups(mockTasks)
+		mockUseGroupedTasks.mockReturnValue({
+			groups: mockGroups,
+			flatTasks: null,
+			toggleExpand: vi.fn(),
+			isSearchMode: false,
+		})
 
-		expect(container.firstChild).toHaveClass("flex", "flex-col", "gap-1")
+		render(<HistoryPreview />)
+
+		// Should show header and view all button
+		expect(screen.getByText("history:recentTasks")).toBeInTheDocument()
+		expect(screen.getByText("history:viewAllHistory")).toBeInTheDocument()
+	})
+
+	it("calls toggleExpand when onToggleExpand is called", () => {
+		const oneTask = mockTasks.slice(0, 1)
+		mockUseTaskSearch.mockReturnValue({
+			tasks: oneTask,
+			searchQuery: "",
+			setSearchQuery: vi.fn(),
+			sortOption: "newest",
+			setSortOption: vi.fn(),
+			lastNonRelevantSort: null,
+			setLastNonRelevantSort: vi.fn(),
+			showAllWorkspaces: false,
+			setShowAllWorkspaces: vi.fn(),
+		})
+
+		const mockToggleExpand = vi.fn()
+		const mockGroups = createMockGroups(oneTask)
+		mockUseGroupedTasks.mockReturnValue({
+			groups: mockGroups,
+			flatTasks: null,
+			toggleExpand: mockToggleExpand,
+			isSearchMode: false,
+		})
+
+		render(<HistoryPreview />)
+
+		// Verify TaskGroupItem received onToggleExpand prop
+		expect(mockTaskGroupItem).toHaveBeenCalledWith(
+			expect.objectContaining({
+				onToggleExpand: expect.any(Function),
+			}),
+			expect.anything(),
+		)
+
+		// Call the onToggleExpand function passed to TaskGroupItem
+		const callArgs = mockTaskGroupItem.mock.calls[0][0]
+		callArgs.onToggleExpand()
+
+		// Verify toggleExpand was called with the parent id
+		expect(mockToggleExpand).toHaveBeenCalledWith("task-1")
 	})
 })