diff --git a/.changeset/assistant-lifecycle.md b/.changeset/assistant-lifecycle.md new file mode 100644 index 00000000..6df19a36 --- /dev/null +++ b/.changeset/assistant-lifecycle.md @@ -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. diff --git a/packages/cli/tests-e2e/assistant-examples.spec.ts b/packages/cli/tests-e2e/assistant-examples.spec.ts new file mode 100644 index 00000000..b5f26055 --- /dev/null +++ b/packages/cli/tests-e2e/assistant-examples.spec.ts @@ -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() + } + } + }) +} diff --git a/packages/create/src/frameworks/react/examples/events/assets/src/components/RemyAssistant.tsx b/packages/create/src/frameworks/react/examples/events/assets/src/components/RemyAssistant.tsx index e22c04d9..f76fb04f 100644 --- a/packages/create/src/frameworks/react/examples/events/assets/src/components/RemyAssistant.tsx +++ b/packages/create/src/frameworks/react/examples/events/assets/src/components/RemyAssistant.tsx @@ -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' @@ -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) } diff --git a/packages/create/src/frameworks/react/examples/events/assets/src/components/RemyButton.tsx b/packages/create/src/frameworks/react/examples/events/assets/src/components/RemyButton.tsx index 52fd2b11..c1a85472 100644 --- a/packages/create/src/frameworks/react/examples/events/assets/src/components/RemyButton.tsx +++ b/packages/create/src/frameworks/react/examples/events/assets/src/components/RemyButton.tsx @@ -4,7 +4,7 @@ import { showRemyAssistant } from "./RemyAssistant"; export default function RemyButton() { return (