+ any>(
+ fn: T,
+ delay: number,
+): { (...args: Parameters): void; cancel: () => void } {
+ let timer: ReturnType | null = null
+
+ const debounced = (...args: Parameters) => {
+ if (timer) clearTimeout(timer)
+ timer = setTimeout(() => {
+ fn(...args)
+ timer = null
+ }, delay)
+ }
+
+ debounced.cancel = () => {
+ if (timer) {
+ clearTimeout(timer)
+ timer = null
+ }
+ }
+
+ return debounced
+}
+\`\`\`
+
+Usage example:
+
+\`\`\`typescript
+const debouncedSearch = debounce((query: string) => {
+ console.log('Searching for:', query)
+ fetch(\`/api/search?q=\${encodeURIComponent(query)}\`)
+}, 300)
+
+// In an input handler:
+input.addEventListener('input', (e) => {
+ debouncedSearch((e.target as HTMLInputElement).value)
+})
+
+// Cleanup:
+debouncedSearch.cancel()
+\`\`\`
+
+Key design decisions:
+- **Generic types** preserve the original function's parameter types
+- **Cancel method** prevents stale calls on component unmount
+- **ReturnType** works in both Node and browser
+- The return type explicitly shows the augmented function signature`,
+ expected: { codeBlocks: 2 },
+ },
+
+ // ─── Images ─────────────────────────────────────────────────
+ {
+ id: 'seed-images',
+ types: ['images'],
+ userQuery: 'Show me some beautiful landscape photography',
+ assistantResponse: `Here are stunning landscape photographs:
+
+
+
+The granite towers catching first light create one of Earth's most dramatic mountain scenes.
+
+
+
+The iconic arrowhead-shaped mountain with northern lights dancing above.
+
+
+
+Crimson maples frame the famous wooden stage during peak koyo season.
+
+These represent three different approaches to landscape photography: dramatic alpine, astrophotography, and cultural landscape. Each uses natural light and careful composition to tell a story about place.`,
+ expected: { images: 3 },
+ },
+
+ // ─── Mixed: Films + Songs ───────────────────────────────────
+ {
+ id: 'seed-mixed-films-songs',
+ types: ['films', 'songs'],
+ userQuery: 'What are the best movie soundtracks?',
+ assistantResponse: `Great question — here are films with legendary soundtracks:
+
+[[film_ext:Drive|2011|Nicolas Winding Refn]] — The synthwave soundtrack defined a genre. Cliff Martinez's score plus curated tracks create an unforgettable atmosphere.
+
+Key tracks:
+[[song_ext:Nightcall|Kavinsky|2010]] — The opening drive through LA at night. Pure 80s-inspired synth perfection.
+[[song_ext:A Real Hero|College & Electric Youth|2010]] — The emotional core of the film. Dreamy, melancholic electronica.
+
+[[film_ext:Blade Runner 2049|2017|Denis Villeneuve]] — Hans Zimmer and Benjamin Wallfisch created a soundscape that honors Vangelis while pushing into new territory.
+
+[[film_ext:Interstellar|2014|Christopher Nolan]] — Zimmer's organ-driven score is overwhelming in IMAX. "No Time for Caution" during the docking scene is peak cinema.
+
+[[song_ext:Time|Hans Zimmer|2010]] — From Inception. The building layers of strings and brass have become the template for modern trailer music.
+
+[[song_ext:Where Is My Mind?|Pixies|1988]] — The Fight Club ending. Sometimes the perfect needle drop is worth more than any original score.
+
+The best soundtracks don't just accompany the film — they become inseparable from the experience.`,
+ expected: { films: 3, songs: 2 },
+ },
+
+ // ─── Mixed: Books + Podcasts ────────────────────────────────
+ {
+ id: 'seed-mixed-books-podcasts',
+ types: ['books', 'podcasts'],
+ userQuery: 'I want to learn about Austrian economics, what should I read and listen to?',
+ assistantResponse: `Here's a complete Austrian economics learning path:
+
+## Essential Reading
+
+[[book_ext:Economics in One Lesson|Henry Hazlitt|1946]] — The best starting point. Hazlitt distills Bastiat's "seen and unseen" into a masterclass on economic thinking. Short, clear, devastating to interventionist arguments.
+
+[[book_ext:Human Action|Ludwig von Mises|1949]] — The magnum opus of Austrian economics. Dense but comprehensive — covers praxeology, money, banking, and the business cycle. Read after Hazlitt.
+
+[[book_ext:The Road to Serfdom|Friedrich Hayek|1944]] — Hayek's warning about central planning. Written during WWII, prophetic about the dangers of collectivism. Surprisingly readable.
+
+[[book_ext:Man, Economy, and State|Murray Rothbard|1962]] — Rothbard's systematic treatise building from first principles. More accessible than Mises, more rigorous than Hazlitt.
+
+## Podcasts for Ongoing Learning
+
+[[podcast_ext:The Tom Woods Show|Tom Woods|2013]] — Daily episodes on economics, liberty, and history. Woods makes Austrian concepts accessible with humor and clarity.
+
+[[podcast_ext:Human Action Podcast|Jeff Deist|2018]] — Chapter-by-chapter walkthrough of Mises' Human Action. Perfect companion to the book.
+
+[[podcast_ext:EconTalk|Russ Roberts|2006]] — Not strictly Austrian, but Roberts' classical liberal perspective and interview style make this essential economics listening.
+
+**Path:** Hazlitt → Tom Woods podcast → Road to Serfdom → Human Action book + podcast → Rothbard for depth.`,
+ expected: { books: 4, podcasts: 3 },
+ },
+
+ // ─── Magazine / News ────────────────────────────────────────
+ {
+ id: 'seed-magazine',
+ types: ['magazine'],
+ userQuery: "What's happening in the Bitcoin ecosystem this week?",
+ assistantResponse: `## Bitcoin Weekly Roundup
+
+### Lightning Network Milestone
+The Lightning Network reached 6,000 BTC in public capacity this week, a new all-time high. Channel count grew 12% month-over-month, with major growth in developing markets.
+
+**amboss.space** — Track Lightning network statistics and node rankings.
+
+---
+
+### Ordinals Debate Heats Up
+Bitcoin Core developers are split on whether to filter Ordinals inscriptions at the mempool level. Luke Dashjr's patch was rejected, but the debate continues about block space allocation.
+
+**delvingbitcoin.org** — Developer discussion forum where the technical arguments play out.
+
+---
+
+### Regulatory Update: EU MiCA
+The EU's Markets in Crypto-Assets regulation takes effect next month. Self-custody remains legal, but exchanges face new reporting requirements. Most Bitcoin-only companies are prepared.
+
+---
+
+### Mining Difficulty Adjustment
+Difficulty increased 4.7% — the third consecutive positive adjustment. Hash rate continues climbing post-halving as miners optimize operations.
+
+**mempool.space** — Real-time mempool and mining statistics.
+
+---
+
+*Sources: Bitcoin Magazine, Nostr, Lightning Labs blog*`,
+ expected: { magazineSections: 4 },
+ },
+
+ // ─── Recipes ────────────────────────────────────────────────
+ {
+ id: 'seed-recipes',
+ types: ['recipes'],
+ userQuery: 'How do I make a proper Italian carbonara?',
+ assistantResponse: `Here's an authentic Roman carbonara — no cream, no shortcuts:
+
+
+- 400g spaghetti or rigatoni
+- 200g guanciale (cured pork cheek), cut into strips
+- 4 large egg yolks + 2 whole eggs
+- 100g Pecorino Romano, finely grated
+- Freshly ground black pepper
+1. Bring a large pot of well-salted water to boil. Cook pasta 1 minute short of al dente.
+2. Cut guanciale into 1cm strips. Cook in a cold dry pan over medium heat until fat renders and edges crisp (8-10 min). Remove from heat.
+3. Whisk egg yolks, whole eggs, and most of the Pecorino together. Season generously with black pepper.
+4. When pasta is ready, reserve 200ml pasta water. Drain and add pasta to the guanciale pan (off heat).
+5. Wait 30 seconds for the pan to cool slightly, then pour the egg mixture over the pasta. Toss vigorously — the residual heat cooks the eggs into a creamy sauce. Add pasta water a splash at a time if needed.
+6. Serve immediately with remaining Pecorino and more black pepper.
+
+
+**Keys to success:**
+- Guanciale, not pancetta or bacon — the flavor is completely different
+- Pan OFF heat before adding eggs — scrambled eggs ≠ carbonara
+- Pecorino Romano, not Parmesan — sharper, saltier, authentic
+- Save that pasta water — it's liquid gold for emulsifying the sauce`,
+ expected: { recipes: 1 },
+ },
+
+ // ─── Events ─────────────────────────────────────────────────
+ {
+ id: 'seed-events',
+ types: ['events'],
+ userQuery: 'What Bitcoin conferences are coming up?',
+ assistantResponse: `Here are the major upcoming Bitcoin events:
+
+
+The largest annual Bitcoin conference. 35,000+ attendees, major announcements, and networking. Past speakers include Saylor, Dorsey, and Bukele.
+
+
+
+The OG Bitcoin-only conference. Technical focus, cypherpunk ethos, and intimate atmosphere. 1,000 attendees who actually build things.
+
+
+
+Focused on real-world Bitcoin adoption. Lightning Network workshops, merchant onboarding, and stories from the ground in El Salvador.
+
+
+
+Developer-focused unconference. Hands-on workshops for building on Bitcoin and Lightning. Small, technical, high signal.
+
+
+**Tip:** Baltic Honeybadger sells out fast. TABConf is the best value for developers. Bitcoin 2026 is the big tent event.`,
+ expected: { events: 4 },
+ },
+
+ // ─── TV + Places combined ───────────────────────────────────
+ {
+ id: 'seed-mixed-tv-places',
+ types: ['tvSeries', 'places'],
+ userQuery: 'Best food shows and where to eat what they feature',
+ assistantResponse: `Food TV worth watching, with real-world destinations:
+
+[[tv_ext:The Bear|Christopher Storer|2022–present]] — Set in a Chicago Italian beef shop. The kitchen chaos is real — watch it for the cooking AND the anxiety.
+
+Eat the real thing at:
+[[place_ext:Mr. Beef|Italian Beef|Chicago IL|4.2|1|666 N Orleans St]] — The actual shop that inspired The Bear. Cash only. Get the Italian beef dipped with hot giardiniera.
+
+[[tv_ext:Chef's Table|David Gelb|2015–present]] — Netflix's cinematic exploration of the world's best chefs. Each episode is a visual feast.
+
+Featured restaurant still taking reservations:
+[[place_ext:Gaggan Anand|Indian|Bangkok|4.9|4|68/1 Soi Langsuan]] — The molecular Indian cuisine from Chef's Table Season 2. 25-course tasting menu.
+
+[[tv_ext:Anthony Bourdain: Parts Unknown|Zero Point Zero|2013–2018]] — Bourdain's masterpiece. Food, culture, and humanity. The Japan and Iran episodes are peak television.
+
+Bourdain's favorite:
+[[place_ext:Bún Bò Huế Bà Phượng|Vietnamese|Huế Vietnam|4.5|1|Huế City]] — The noodle soup spot from the Vietnam episode. Simple, perfect, $2 a bowl.`,
+ expected: { tvSeries: 3, places: 3 },
+ },
+
+ // ─── Tag stripping test — all tags in one response ──────────
+ {
+ id: 'seed-strip-tags',
+ types: ['films', 'songs', 'books', 'tvSeries', 'places', 'podcasts'],
+ userQuery: 'Give me your top pick in every category',
+ assistantResponse: `Here's one masterpiece per category:
+
+**Film:** [[film_ext:There Will Be Blood|2007|Paul Thomas Anderson]] — Daniel Day-Lewis as an oil prospector consumed by greed. The milkshake scene. The bowling alley. Perfect.
+
+**Song:** [[song_ext:Bohemian Rhapsody|Queen|1975]] — Six minutes that redefined what a pop single could be. Opera section? Guitar solo? Headbanging? Yes to all.
+
+**Book:** [[book_ext:Blood Meridian|Cormac McCarthy|1985]] — The darkest, most beautiful novel in American literature. The Judge is literature's greatest villain.
+
+**TV Show:** [[tv_ext:The Wire|David Simon|2002–2008]] — Every institution fails. Every character is compromised. Baltimore becomes a lens for all of America.
+
+**Restaurant:** [[place_ext:Jiro Sushi|Sushi|Tokyo|4.9|4|Ginza]] — 20 pieces of sushi. No menu. The greatest craftsman alive serves fish that transcends food.
+
+**Podcast:** [[podcast_ext:Hardcore History|Dan Carlin|2006]] — Multi-hour epics on history's most dramatic moments. "Blueprint for Armageddon" (WWI) is the greatest podcast ever made.
+
+One of each is all you need to start.`,
+ expected: { films: 1, songs: 1, books: 1, tvSeries: 1, places: 1, podcasts: 1 },
+ },
+]
+
+/**
+ * Convert seed prompts to the .dev/chats.json conversation format.
+ */
+export function seedPromptsToConversations(): Record {
+ const conversations: Record = {}
+ const baseTime = 1772488800000 // Stable base timestamp
+
+ for (let i = 0; i < seedPrompts.length; i++) {
+ const seed = seedPrompts[i]
+ const ts = baseTime + i * 60000
+ conversations[seed.id] = {
+ id: seed.id,
+ title: seed.userQuery.slice(0, 60),
+ messages: [
+ { id: `${seed.id}-q`, role: 'user', content: seed.userQuery, timestamp: ts },
+ { id: `${seed.id}-a`, role: 'assistant', content: seed.assistantResponse, timestamp: ts + 3000 },
+ ],
+ createdAt: ts,
+ updatedAt: ts + 3000,
+ }
+ }
+
+ return conversations
+}
diff --git a/packages/app/src/__tests__/seedExtraction.test.ts b/packages/app/src/__tests__/seedExtraction.test.ts
new file mode 100644
index 00000000..24baa863
--- /dev/null
+++ b/packages/app/src/__tests__/seedExtraction.test.ts
@@ -0,0 +1,239 @@
+/**
+ * Seed Extraction Tests
+ *
+ * Validates that every seed prompt in the prompt index extracts the expected
+ * content types and counts. These are the gold-standard test cases — if any
+ * fail, the content surfacing pipeline has regressed.
+ *
+ * Run overnight to harden extraction patterns against real-world AI responses.
+ */
+import { describe, it, expect } from 'vitest'
+import { seedPrompts } from './fixtures/seedPrompts'
+import {
+ extractAllFilms,
+ extractAllSongs,
+ extractAllPodcasts,
+ extractAllBooks,
+ extractAllTVSeries,
+ extractAllPlaces,
+ extractAllImages,
+ extractCodeBlocks,
+ extractRecipes,
+ extractEvents,
+ extractMagazineSections,
+ stripContentTags,
+ stripRecipeTags,
+ stripEventTags,
+} from '@/composables/contentExtraction'
+
+// ─── Extraction count validation ─────────────────────────────
+
+describe('Seed prompt extraction', () => {
+ for (const seed of seedPrompts) {
+ describe(`[${seed.id}] "${seed.userQuery}"`, () => {
+ const text = seed.assistantResponse
+ const query = seed.userQuery
+
+ if (seed.expected.films !== undefined) {
+ it(`extracts ${seed.expected.films} films`, () => {
+ const films = extractAllFilms(text)
+ expect(films.length).toBe(seed.expected.films)
+ for (const f of films) {
+ expect(f.title).toBeTruthy()
+ }
+ })
+ }
+
+ if (seed.expected.songs !== undefined) {
+ it(`extracts ${seed.expected.songs} songs`, () => {
+ const songs = extractAllSongs(text, query)
+ expect(songs.length).toBe(seed.expected.songs)
+ for (const s of songs) {
+ expect(s.title).toBeTruthy()
+ expect(s.artist).toBeTruthy()
+ }
+ })
+ }
+
+ if (seed.expected.books !== undefined) {
+ it(`extracts ${seed.expected.books} books`, () => {
+ const books = extractAllBooks(text, query)
+ expect(books.length).toBe(seed.expected.books)
+ for (const b of books) {
+ expect(b.title).toBeTruthy()
+ }
+ })
+ }
+
+ if (seed.expected.tvSeries !== undefined) {
+ it(`extracts ${seed.expected.tvSeries} TV series`, () => {
+ const tv = extractAllTVSeries(text, query)
+ expect(tv.length).toBe(seed.expected.tvSeries)
+ for (const t of tv) {
+ expect(t.title).toBeTruthy()
+ }
+ })
+ }
+
+ if (seed.expected.places !== undefined) {
+ it(`extracts ${seed.expected.places} places`, () => {
+ const places = extractAllPlaces(text, query)
+ expect(places.length).toBe(seed.expected.places)
+ for (const p of places) {
+ expect(p.name).toBeTruthy()
+ }
+ })
+ }
+
+ if (seed.expected.podcasts !== undefined) {
+ it(`extracts ${seed.expected.podcasts} podcasts`, () => {
+ const podcasts = extractAllPodcasts(text)
+ expect(podcasts.length).toBe(seed.expected.podcasts)
+ for (const p of podcasts) {
+ expect(p.title).toBeTruthy()
+ }
+ })
+ }
+
+ if (seed.expected.images !== undefined) {
+ it(`extracts ${seed.expected.images} images`, () => {
+ const images = extractAllImages(text, query)
+ expect(images.length).toBe(seed.expected.images)
+ })
+ }
+
+ if (seed.expected.codeBlocks !== undefined) {
+ it(`extracts ${seed.expected.codeBlocks} code blocks`, () => {
+ const code = extractCodeBlocks(text)
+ expect(code.length).toBe(seed.expected.codeBlocks)
+ for (const c of code) {
+ expect(c.code.trim()).toBeTruthy()
+ }
+ })
+ }
+
+ if (seed.expected.recipes !== undefined) {
+ it(`extracts ${seed.expected.recipes} recipes`, () => {
+ const recipes = extractRecipes(text)
+ expect(recipes.length).toBe(seed.expected.recipes)
+ for (const r of recipes) {
+ expect(r.title).toBeTruthy()
+ expect(r.ingredients.length).toBeGreaterThan(0)
+ expect(r.steps.length).toBeGreaterThan(0)
+ }
+ })
+ }
+
+ if (seed.expected.events !== undefined) {
+ it(`extracts ${seed.expected.events} events`, () => {
+ const events = extractEvents(text)
+ expect(events.length).toBe(seed.expected.events)
+ for (const e of events) {
+ expect(e.title).toBeTruthy()
+ }
+ })
+ }
+
+ if (seed.expected.magazineSections !== undefined) {
+ it(`extracts ${seed.expected.magazineSections} magazine sections`, () => {
+ const sections = extractMagazineSections(text)
+ expect(sections.length).toBeGreaterThanOrEqual(seed.expected.magazineSections!)
+ })
+ }
+ })
+ }
+})
+
+// ─── Tag stripping — no tags leak into displayed content ──────
+
+describe('Tag stripping completeness', () => {
+ for (const seed of seedPrompts) {
+ it(`[${seed.id}] stripContentTags removes all bracket tags`, () => {
+ const cleaned = stripContentTags(seed.assistantResponse)
+ // No [[...]] bracket tags should remain
+ const bracketMatches = cleaned.match(/\[\[[^\]]+\]\]/g)
+ expect(bracketMatches).toBeNull()
+ })
+
+ it(`[${seed.id}] strip functions remove all XML tags`, () => {
+ let cleaned = stripRecipeTags(stripEventTags(seed.assistantResponse))
+ cleaned = stripContentTags(cleaned)
+ // No <..._ext> XML tags should remain
+ const xmlMatches = cleaned.match(/<\/?(?:recipe|event)_ext[^>]*>/g)
+ expect(xmlMatches).toBeNull()
+ })
+ }
+})
+
+// ─── Data integrity — extracted content has required fields ───
+
+describe('Extraction data integrity', () => {
+ const filmSeed = seedPrompts.find(s => s.id === 'seed-films')!
+ it('films have title, year, and director', () => {
+ const films = extractAllFilms(filmSeed.assistantResponse)
+ for (const f of films) {
+ expect(f.title).toBeTruthy()
+ expect(f.year).toBeGreaterThan(1900)
+ expect(f.director).toBeTruthy()
+ }
+ })
+
+ const songSeed = seedPrompts.find(s => s.id === 'seed-songs')!
+ it('songs have title, artist, and year', () => {
+ const songs = extractAllSongs(songSeed.assistantResponse, songSeed.userQuery)
+ for (const s of songs) {
+ expect(s.title).toBeTruthy()
+ expect(s.artist).toBeTruthy()
+ }
+ })
+
+ const bookSeed = seedPrompts.find(s => s.id === 'seed-books')!
+ it('books have title and author', () => {
+ const books = extractAllBooks(bookSeed.assistantResponse, bookSeed.userQuery)
+ for (const b of books) {
+ expect(b.title).toBeTruthy()
+ expect(b.author).toBeTruthy()
+ }
+ })
+
+ const tvSeed = seedPrompts.find(s => s.id === 'seed-tv')!
+ it('TV series have title and creator', () => {
+ const tv = extractAllTVSeries(tvSeed.assistantResponse, tvSeed.userQuery)
+ for (const t of tv) {
+ expect(t.title).toBeTruthy()
+ expect(t.creator).toBeTruthy()
+ }
+ })
+
+ const placeSeed = seedPrompts.find(s => s.id === 'seed-places')!
+ it('places have name, cuisine, and city', () => {
+ const places = extractAllPlaces(placeSeed.assistantResponse, placeSeed.userQuery)
+ for (const p of places) {
+ expect(p.name).toBeTruthy()
+ expect(p.cuisine).toBeTruthy()
+ expect(p.city).toBeTruthy()
+ }
+ })
+
+ const recipeSeed = seedPrompts.find(s => s.id === 'seed-recipes')!
+ it('recipes have complete data', () => {
+ const recipes = extractRecipes(recipeSeed.assistantResponse)
+ expect(recipes.length).toBe(1)
+ const r = recipes[0]
+ expect(r.title).toBe('Spaghetti alla Carbonara')
+ expect(r.servings).toBe('4')
+ expect(r.time).toBe('25 min')
+ expect(r.ingredients.length).toBeGreaterThanOrEqual(4)
+ expect(r.steps.length).toBeGreaterThanOrEqual(5)
+ })
+
+ const eventSeed = seedPrompts.find(s => s.id === 'seed-events')!
+ it('events have title, date, and location', () => {
+ const events = extractEvents(eventSeed.assistantResponse)
+ for (const e of events) {
+ expect(e.title).toBeTruthy()
+ expect(e.date).toBeTruthy()
+ expect(e.location).toBeTruthy()
+ }
+ })
+})
diff --git a/packages/app/src/components/chat/ChatHeader.vue b/packages/app/src/components/chat/ChatHeader.vue
index 120ce5d0..fae9ca16 100644
--- a/packages/app/src/components/chat/ChatHeader.vue
+++ b/packages/app/src/components/chat/ChatHeader.vue
@@ -14,20 +14,6 @@
✦