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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/assistant-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/create": patch
---

Fix Events and Resume assistant rendering and subscription cleanup, and align their generated types and content collection configuration with current dependencies.
81 changes: 81 additions & 0 deletions packages/cli/tests-e2e/assistant-examples.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test'
import { attachRuntimeGuards, createAppFixture, optimizePageForFastE2E } from './helpers'

for (const scenario of [
{
id: 'events',
packageManager: 'npm',
button: 'Open Remy Assistant',
placeholder: 'Ask about speakers, sessions, techniques...',
endpoint: '/api/remy-chat',
threadId: 'conference-assistant',
},
{
id: 'resume',
packageManager: 'pnpm',
button: 'Open Resume Assistant',
placeholder: 'Ask about skills, experience, or qualifications...',
endpoint: '/api/resume-chat',
threadId: 'resume-assistant',
},
] as const) {
test(`@matrix ${scenario.id} assistant streams and survives navigation`, async ({ page }) => {
const fixture = await createAppFixture({
appName: `${scenario.id}-assistant-lifecycle`,
framework: 'react',
packageManager: scenario.packageManager,
addOns: [scenario.id],
runQualityGatesChecks: true,
})
const guards = attachRuntimeGuards(page, fixture.url)
try {
const invalid = await page.request.post(`${fixture.url}${scenario.endpoint}`, {
data: { messages: [] },
})
expect(invalid.status()).toBe(400)
await optimizePageForFastE2E(page)
await page.route(`**${scenario.endpoint}`, async (route) => {
const { threadId } = route.request().postDataJSON()
expect(threadId).toBe(scenario.threadId)
const events = [
{ type: 'RUN_STARTED', threadId, runId: 'example-run' },
{ type: 'TEXT_MESSAGE_START', messageId: 'example-reply', role: 'assistant' },
{ type: 'TEXT_MESSAGE_CONTENT', messageId: 'example-reply', delta: 'Local example reply' },
{ type: 'TEXT_MESSAGE_END', messageId: 'example-reply' },
{ type: 'RUN_FINISHED', threadId, runId: 'example-run' },
]
await route.fulfill({
contentType: 'text/event-stream',
body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''),
})
})
await page.addInitScript(() => localStorage.setItem('theme', 'light'))
await page.goto(fixture.url)
await expect(
page.getByRole('button', {
name: 'Theme mode: light. Click to switch mode.',
exact: true,
}),
).toBeVisible()
await page.getByRole('button', { name: scenario.button, exact: true }).click()
const input = page.getByPlaceholder(scenario.placeholder)
await input.fill('Hello')
await input.press('Enter')
await expect(page.getByText('Local example reply', { exact: true })).toBeVisible()
await page.getByRole('link', { name: 'About', exact: true }).click()
await expect(page).toHaveURL(/\/about$/)
await expect(page.getByText('Something went wrong!', { exact: true })).toHaveCount(0)
await page.goBack()
await page.getByRole('button', { name: scenario.button, exact: true }).click()
await expect(page.getByPlaceholder(scenario.placeholder)).toBeVisible()
} finally {
try {
guards.assertClean()
} finally {
guards.dispose()
await fixture.stop()
await fixture.cleanup()
}
}
})
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { Send, X, ChefHat, Croissant } from 'lucide-react'
import { Streamdown } from 'streamdown'
import { Store } from '@tanstack/store'
Expand Down Expand Up @@ -80,25 +80,26 @@ interface RemyAssistantProps {
// Export store for header control
export const showRemyAssistant = new Store(false)

function subscribeToAssistant(onChange: () => void) {
const subscription = showRemyAssistant.subscribe(onChange)
return () => subscription.unsubscribe()
}

export default function RemyAssistant({
speakerSlug,
talkSlug,
contextTitle,
}: RemyAssistantProps) {
const [isOpen, setIsOpen] = useState(false)
const isOpen = useSyncExternalStore(
subscribeToAssistant,
() => showRemyAssistant.state,
() => false,
)
const { messages, sendMessage, isLoading } = useConferenceChat(speakerSlug, talkSlug)
const [input, setInput] = useState('')

// Sync with store for header control
useEffect(() => {
return showRemyAssistant.subscribe(() => {
setIsOpen(showRemyAssistant.state)
})
}, [])

const handleToggle = () => {
const newState = !isOpen
setIsOpen(newState)
showRemyAssistant.setState(() => newState)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { showRemyAssistant } from "./RemyAssistant";
export default function RemyButton() {
return (
<button
onClick={() => showRemyAssistant.setState(true)}
onClick={() => showRemyAssistant.setState(() => true)}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-lg bg-linear-to-r from-orange-500 to-red-600 text-white hover:opacity-90 transition-opacity"
aria-label="Open Remy Assistant"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ interface SpeakerCardProps {
export default function SpeakerCard({ speaker, featured = false }: SpeakerCardProps) {
return (
<Link
to={`/speakers/${speaker.slug}`}
to="/speakers/$slug"
params={{ slug: speaker.slug }}
className="group relative block"
>
<Card
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ interface TalkCardProps {
export default function TalkCard({ talk, featured = false }: TalkCardProps) {
return (
<Link
to={`/talks/${talk.slug}`}
to="/talks/$slug"
params={{ slug: talk.slug }}
className="group relative block"
>
<Card
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,10 @@ import {
useChat,
createChatClientOptions,
} from '@tanstack/ai-react'
import type { InferChatMessages } from '@tanstack/ai-react'

// Default chat options for type inference
const defaultChatOptions = createChatClientOptions({
connection: fetchServerSentEvents('/api/remy-chat'),
})

export type ConferenceChatMessages = InferChatMessages<typeof defaultChatOptions>

export const useConferenceChat = (speakerSlug?: string, talkSlug?: string) => {
const chatOptions = createChatClientOptions({
threadId: 'conference-assistant',
connection: fetchServerSentEvents('/api/remy-chat', {
body: {
speakerSlug,
Expand All @@ -24,3 +17,5 @@ export const useConferenceChat = (speakerSlug?: string, talkSlug?: string) => {

return useChat(chatOptions)
}

export type ConferenceChatMessages = ReturnType<typeof useConferenceChat>['messages']
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@ export const Route = createFileRoute('/api/remy-chat')({

try {
const body = await request.json()
const { messages, speakerSlug, talkSlug } = body
const data = body.data || {}
const { messages, threadId, speakerSlug, talkSlug } = body
if (typeof threadId !== 'string' || !threadId.trim()) {
return Response.json(
{ error: 'A threadId is required' },
{ status: 400 },
)
}

const SYSTEM_PROMPT = `You are Remy, a charming and knowledgeable culinary assistant for the Haute Pâtisserie 2026 conference in Paris. You have a warm, enthusiastic personality and deep appreciation for the art of pastry and baking.
Expand Down Expand Up @@ -59,7 +64,7 @@ ${talkSlug ? `CONTEXT: The user is viewing the session with slug "${talkSlug}".`
Remember: You are the friendly face of Haute Pâtisserie 2026. Make every attendee feel welcome and excited about the culinary journey ahead!`

// Determine the best available provider
let provider: string = 'ollama'
let provider: 'anthropic' | 'openai' | 'gemini' | 'ollama' = 'ollama'
let model: string = 'mistral:7b'
if (process.env.ANTHROPIC_API_KEY) {
provider = 'anthropic'
Expand Down Expand Up @@ -95,6 +100,7 @@ Remember: You are the friendly face of Haute Pâtisserie 2026. Make every attend
systemPrompts: [SYSTEM_PROMPT],
agentLoopStrategy: maxIterations(5),
messages,
threadId,
abortController,
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ function SchedulePage() {
return (
<Link
key={session.talkSlug}
to={`/talks/${talk.slug}`}
to="/talks/$slug"
params={{ slug: talk.slug }}
className="group block"
>
<div className="relative flex gap-6 md:gap-10">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ function TalkDetailPage() {
{/* Speaker link */}
{speaker ? (
<Link
to={`/speakers/${speaker.slug}`}
to="/speakers/$slug"
params={{ slug: speaker.slug }}
className="flex items-center gap-3 group"
>
<div className="w-12 h-12 rounded-full overflow-hidden border border-border/50">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { Send, X, Briefcase, UserCheck } from "lucide-react";
import { Streamdown } from "streamdown";
import { Store } from "@tanstack/store";
Expand Down Expand Up @@ -74,21 +74,22 @@ function Messages({ messages }: { messages: ResumeChatMessages }) {
// Export store for header control
export const showResumeAssistant = new Store(false);

function subscribeToAssistant(onChange: () => void) {
const subscription = showResumeAssistant.subscribe(onChange)
return () => subscription.unsubscribe()
}

export default function ResumeAssistant() {
const [isOpen, setIsOpen] = useState(false);
const isOpen = useSyncExternalStore(
subscribeToAssistant,
() => showResumeAssistant.state,
() => false,
);
const { messages, sendMessage, isLoading } = useResumeChat();
const [input, setInput] = useState("");

// Sync with store for header control
useEffect(() => {
return showResumeAssistant.subscribe(() => {
setIsOpen(showResumeAssistant.state);
});
}, []);

const handleToggle = () => {
const newState = !isOpen;
setIsOpen(newState);
showResumeAssistant.setState(() => newState);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default function RemyButton() {
return (
<div className="px-2 mb-2 w-full">
<button
onClick={() => showResumeAssistant.setState(true)}
onClick={() => showResumeAssistant.setState(() => true)}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-lg bg-linear-to-r from-blue-500 to-purple-800 text-white hover:opacity-90 transition-opacity"
aria-label="Open Resume Assistant"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,14 @@ import {
useChat,
createChatClientOptions,
} from '@tanstack/ai-react'
import type { InferChatMessages } from '@tanstack/ai-react'

// Default chat options for type inference
const defaultChatOptions = createChatClientOptions({
connection: fetchServerSentEvents('/api/resume-chat'),
})

export type ResumeChatMessages = InferChatMessages<typeof defaultChatOptions>

export const useResumeChat = () => {
const chatOptions = createChatClientOptions({
threadId: 'resume-assistant',
connection: fetchServerSentEvents('/api/resume-chat'),
})

return useChat(chatOptions)
}

export type ResumeChatMessages = ReturnType<typeof useResumeChat>['messages']
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ export const Route = createFileRoute('/api/resume-chat')({

try {
const body = await request.json()
const { messages } = body
const { messages, threadId } = body
if (typeof threadId !== 'string' || !threadId.trim()) {
return Response.json(
{ error: 'A threadId is required' },
{ status: 400 },
)
}
const data = body.data || {}

const SYSTEM_PROMPT = `You are a helpful resume assistant helping recruiters and hiring managers evaluate if this candidate is a good fit for their job requirements.
Expand Down Expand Up @@ -84,6 +90,7 @@ CONTEXT: You are helping evaluate this candidate's qualifications for potential
systemPrompts: [SYSTEM_PROMPT],
agentLoopStrategy: maxIterations(5),
messages,
threadId,
abortController,
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"module": "ESNext",
"paths": {
"#/*": ["./src/*"],
"@/*": ["./src/*"]
"@/*": ["./src/*"]<% if (addOnEnabled.events || addOnEnabled.resume) { %>,
"content-collections": ["./.content-collections/generated"]<% } %>
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["<%= bundler === 'vite' ? 'vite/client' : '@rsbuild/core/types' %>"],
Expand Down
Loading