packages/ai/components/AiModule.tsx

1import type { JSX } from 'solid-js'
2import { createEffect, createSignal, onCleanup, onMount } from 'solid-js'
3import { createStore, produce, reconcile } from 'solid-js/store'
4import { AiBrandMark } from './items/AiBrandMark'
5import { AiButtonIcon } from './items/AiButtonIcon'
6import { AiChat } from './modules/AiChat'
7import { AiNotice } from './modules/AiNotice'
8import { AiPrompts } from './modules/AiPrompts'
9import type { CurrentAiSectionData } from './stores'
10import { currentAiSection, getOpenAiRequestId } from './stores'
11import type {
12	AiEvent,
13	ChatMessage,
14	ChatMessageType,
15	ChatRequestPayload,
16	ProfileExtractResponsePayload,
17	UserProfile,
18} from './types'
19import { eventFromPath, parseAiEvent, sessionConsent } from './workflows'
20
21type SectionPrompts = { label?: string; text?: string }[]
22type AiModuleProps = {
23	enabled?: boolean
24	pathname?: string
25	defaultAssistantMessage?: string
26	/** When set (e.g. event page CMS color), shell tints toward this hue. */
27	accentColor?: string
28	/** Temporarily hide the module when overlapping page footer. */
29	hideForFooter?: boolean
30	/** Force a specific chat type (e.g. 'sales-agent'). Defaults to 'agenda' (in-view) or 'chat'. */
31	type?: ChatMessageType
32	/** Legacy prop retained for callers; the server supplies the conversational consent note. */
33	consentStatement?: string
34}
35const FALLBACK_ASSISTANT_MESSAGE = 'What can I help you with today?'
36
37function getInitialAssistantMessage(defaultMessage?: string) {
38	const message = defaultMessage?.trim()
39	return message && message.length > 0 ? message : FALLBACK_ASSISTANT_MESSAGE
40}
41
42function getSectionPrompts(
43	section: CurrentAiSectionData | null,
44): SectionPrompts | null {
45	if (!section?.aiLink || typeof section.aiLink !== 'object') return null
46	const link = section.aiLink as { prompts?: SectionPrompts }
47	// Editors can save a prompt row with nothing in it; without text there is
48	// nothing to send, so it is not a prompt — otherwise the notice opens with
49	// placeholder copy and a dead click.
50	const prompts = Array.isArray(link.prompts)
51		? link.prompts.filter((p) => Boolean(p?.text?.trim()))
52		: []
53	return prompts.length > 0 ? prompts : null
54}
55
56/** Single prompt: its label for the notice (falling back to the prompt text). Multiple prompts: null (use prompts panel). */
57function getSinglePromptLabel(
58	section: CurrentAiSectionData | null,
59): string | null {
60	const prompts = getSectionPrompts(section)
61	if (!prompts || prompts.length !== 1) return null
62	const p = prompts[0]
63	const label = p?.label?.trim()
64	return label || p?.text?.trim() || null
65}
66
67/** Single prompt: return prompt text to submit to chat (no label fallback). Multiple prompts: return null. */
68function getSinglePromptSubmitValue(
69	section: CurrentAiSectionData | null,
70): string | null {
71	const prompts = getSectionPrompts(section)
72	if (!prompts || prompts.length !== 1) return null
73	const p = prompts[0]
74	const text = p?.text?.trim()
75	return text ? text : null
76}
77
78function getCurrentLocationPath() {
79	if (typeof window === 'undefined') return 'unknown'
80	return `${window.location.pathname}${window.location.search}`
81}
82
83function getUserProfileSummary() {
84	if (typeof window === 'undefined') return [] as string[]
85
86	// TODO: Replace browser-derived profile with account data once auth/user context is available here.
87	const profileParts: string[] = []
88	const language = window.navigator?.language?.trim()
89	const platform = window.navigator?.platform?.trim()
90	const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone?.trim()
91
92	if (language) profileParts.push(`language:${language}`)
93	if (platform) profileParts.push(`platform:${platform}`)
94	if (timezone) profileParts.push(`timezone:${timezone}`)
95
96	return profileParts
97}
98
99function getUserInterests() {
100	// TODO: Populate interests from account data when available.
101	return [] as string[]
102}
103
104const CHAT_ID_STORAGE_KEY = 'ai_chat_id'
105const CHAT_HISTORY_STORAGE_KEY = 'ai_chat_history'
106const PROFILE_STORAGE_KEY = 'ai_user_profile'
107const CONSENT_STORAGE_KEY = 'ai_session_consent'
108const MAX_HISTORY_MESSAGES = 20
109
110/** Visitor profile collected through chat; persisted so the AI always knows what we already have. */
111function loadStoredProfile(): UserProfile {
112	if (typeof window === 'undefined') return {}
113	try {
114		const raw = window.sessionStorage.getItem(PROFILE_STORAGE_KEY)
115		if (!raw) return {}
116		const parsed = JSON.parse(raw) as unknown
117		return parsed && typeof parsed === 'object' ? (parsed as UserProfile) : {}
118	} catch {
119		return {}
120	}
121}
122
123function saveStoredProfile(profile: UserProfile) {
124	if (typeof window === 'undefined') return
125	try {
126		window.sessionStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(profile))
127	} catch {
128		// ignore storage failures (quota/private mode)
129	}
130}
131
132function generateChatId() {
133	if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
134		return crypto.randomUUID()
135	}
136	return `${Date.now()}-${Math.random().toString(16).slice(2)}`
137}
138
139/** Fallback when sessionStorage is unavailable (private mode): keep one id per page load so requests still correlate. */
140let fallbackChatId: string | undefined
141
142function getOrCreateChatId() {
143	if (typeof window === 'undefined') return generateChatId()
144
145	try {
146		const current = window.sessionStorage.getItem(CHAT_ID_STORAGE_KEY)?.trim()
147		if (current) return current
148		const chatId = generateChatId()
149		window.sessionStorage.setItem(CHAT_ID_STORAGE_KEY, chatId)
150		return chatId
151	} catch {
152		if (!fallbackChatId) fallbackChatId = generateChatId()
153		return fallbackChatId
154	}
155}
156
157function sanitizeChatHistory(value: unknown): ChatMessage[] {
158	if (!Array.isArray(value)) return []
159
160	return value
161		.filter(
162			(
163				item,
164			): item is {
165				role: 'assistant' | 'user'
166				text: string
167			} =>
168				!!item &&
169				typeof item === 'object' &&
170				((item as { role?: unknown }).role === 'assistant' ||
171					(item as { role?: unknown }).role === 'user') &&
172				typeof (item as { text?: unknown }).text === 'string',
173		)
174		.map((item) => ({
175			role: item.role,
176			text: item.text.trim(),
177		}))
178		.filter((item) => item.text.length > 0)
179		.slice(-MAX_HISTORY_MESSAGES)
180}
181
182export const AiModule = (props: AiModuleProps) => {
183	const accent = () => props.accentColor?.trim()
184	const [isExpanded, setIsExpanded] = createSignal(false)
185	// The greeting is "typed" the first time the panel opens so the module
186	// reads as responding to the visitor rather than a static banner.
187	const [isTypingGreeting, setIsTypingGreeting] = createSignal(false)
188	let greetingTyped = false
189	let greetingTimer: ReturnType<typeof setTimeout> | undefined
190	const [activePanel, setActivePanel] = createSignal<
191		'notice' | 'chat' | 'prompts'
192	>('chat')
193	const [isSending, setIsSending] = createSignal(false)
194	/** When true, we don't auto-close when section leaves view; user must close manually. */
195	const [userHasControl, setUserHasControl] = createSignal(false)
196	/** When true, section left view while module was hovered; close when mouse leaves (if no click). */
197	const [pendingCloseOnLeave, setPendingCloseOnLeave] = createSignal(false)
198	const [isModuleHovered, setIsModuleHovered] = createSignal(false)
199	const [sectionNoticeText, setSectionNoticeText] = createSignal<string>('')
200	// Store (not signal) so streamed chunks update one text node instead of
201	// replacing the array and re-rendering message items.
202	const [messages, setMessages] = createStore<ChatMessage[]>([
203		{
204			role: 'assistant',
205			text: getInitialAssistantMessage(props.defaultAssistantMessage),
206		},
207	])
208	const [userProfile, setUserProfile] = createSignal<UserProfile>({})
209	const [consentAccepted, setConsentAccepted] = createSignal(false)
210	const [selectedEvent, setSelectedEvent] = createSignal<AiEvent>()
211	const [requestError, setRequestError] = createSignal('')
212	let requestSequence = 0
213	let toggleRef: HTMLButtonElement | undefined
214	createEffect(() => {
215		const path = props.pathname
216		if (!path) return
217		const pageEvent = eventFromPath(path)
218		if (pageEvent) {
219			setSelectedEvent(pageEvent)
220		}
221	})
222
223	let sectionCloseFrameId: number | undefined
224	let activeRequestController: AbortController | undefined
225
226	onMount(() => {
227		setUserProfile(loadStoredProfile())
228		try {
229			setConsentAccepted(
230				window.sessionStorage.getItem(
231					`${CONSENT_STORAGE_KEY}:${getOrCreateChatId()}`,
232				) === 'true',
233			)
234		} catch {
235			/* Storage is optional. */
236		}
237		setSelectedEvent(eventFromPath(getCurrentLocationPath()))
238	})
239
240	/** Save this message's facts before the concierge chooses its next question. */
241	const updateProfileFromMessage = async (
242		message: string,
243		previousQuestion: string,
244		signal: AbortSignal,
245	) => {
246		try {
247			const response = await fetch('/api/ai/profile', {
248				method: 'POST',
249				headers: { 'Content-Type': 'application/json' },
250				signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
251				body: JSON.stringify({
252					chat_id: getOrCreateChatId(),
253					message,
254					reply: previousQuestion,
255					profile: userProfile(),
256					location: getCurrentLocationPath(),
257				}),
258			})
259			const data: ProfileExtractResponsePayload | null = response.ok
260				? await response.json()
261				: null
262			if (signal.aborted || !data?.profile || typeof data.profile !== 'object')
263				return
264			setUserProfile(data.profile)
265			saveStoredProfile(data.profile)
266		} catch {
267			// Retain the saved profile and transcript if extraction is unavailable.
268		}
269	}
270
271	const cancelPendingSectionClose = () => {
272		if (sectionCloseFrameId !== undefined) {
273			cancelAnimationFrame(sectionCloseFrameId)
274			sectionCloseFrameId = undefined
275		}
276	}
277
278	const closeModule = () => {
279		if (isExpanded() && userHasControl()) toggleRef?.focus()
280		cancelPendingSectionClose()
281		setUserHasControl(false)
282		setPendingCloseOnLeave(false)
283		setIsExpanded(false)
284	}
285
286	// Click-away and Escape close the module the visitor opened, like any
287	// popover. Only when the visitor has control: the section notice and
288	// prompt panels are opened by the page itself (scrolling into an AI
289	// section, picking a session in the agenda modal) and close on their own
290	// when the section leaves view — a page click must not shut them, or they
291	// never get to show. Pointer down (capture) so page handlers can't swallow it.
292	let moduleRootRef: HTMLDivElement | undefined
293	onMount(() => {
294		const onPointerDown = (event: PointerEvent) => {
295			if (!isExpanded() || !userHasControl()) return
296			const target = event.target
297			if (target instanceof Node && moduleRootRef?.contains(target)) return
298			closeModule()
299		}
300		const onEscape = (event: KeyboardEvent) => {
301			if (event.key === 'Escape' && isExpanded()) closeModule()
302		}
303		document.addEventListener('pointerdown', onPointerDown, true)
304		window.addEventListener('keydown', onEscape)
305		onCleanup(() => {
306			document.removeEventListener('pointerdown', onPointerDown, true)
307			window.removeEventListener('keydown', onEscape)
308		})
309	})
310
311	const onToggleButtonClick = () => {
312		if (!props.enabled) return
313
314		if (isExpanded()) {
315			closeModule()
316			return
317		}
318
319		setUserHasControl(true)
320		setPendingCloseOnLeave(false)
321		setActivePanel('chat')
322		setIsExpanded(true)
323	}
324
325	const onModuleMouseEnter = () => {
326		setIsModuleHovered(true)
327	}
328	const onModuleMouseLeave = () => {
329		setIsModuleHovered(false)
330		if (pendingCloseOnLeave() && currentAiSection() == null) {
331			setPendingCloseOnLeave(false)
332			closeModule()
333			setTimeout(() => {
334				setSectionNoticeText('')
335				setSectionPrompts(null)
336			}, 350)
337		}
338	}
339
340	const [sectionPrompts, setSectionPrompts] =
341		createSignal<SectionPrompts | null>(null)
342
343	const COLLAPSE_DURATION_MS = 350
344
345	createEffect(() => {
346		const initialMessage = getInitialAssistantMessage(
347			props.defaultAssistantMessage,
348		)
349		if (
350			!isTypingGreeting() &&
351			messages.length === 1 &&
352			messages[0]?.role === 'assistant' &&
353			messages[0]?.text !== initialMessage
354		) {
355			setMessages(0, 'text', initialMessage)
356		}
357	})
358
359	const GREETING_CHAR_MS = 14
360	createEffect(() => {
361		if (!isExpanded() || greetingTyped) return
362		if (messages.length !== 1 || messages[0]?.role !== 'assistant') {
363			// A restored conversation: nothing to type.
364			greetingTyped = true
365			return
366		}
367		greetingTyped = true
368		// Effects only run on the client; matchMedia is optional for old engines.
369		if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
370			return
371		}
372		const full = messages[0].text
373		let shown = 0
374		setIsTypingGreeting(true)
375		setMessages(0, 'text', '')
376		const tick = () => {
377			// The visitor already sent something: finish instantly.
378			if (messages.length !== 1) {
379				setMessages(0, 'text', full)
380				setIsTypingGreeting(false)
381				return
382			}
383			shown += 1
384			setMessages(0, 'text', full.slice(0, shown))
385			if (shown < full.length) {
386				greetingTimer = setTimeout(tick, GREETING_CHAR_MS)
387			} else {
388				setIsTypingGreeting(false)
389			}
390		}
391		greetingTimer = setTimeout(tick, 220)
392	})
393
394	onMount(() => {
395		if (typeof window === 'undefined') return
396
397		try {
398			const raw = window.sessionStorage.getItem(CHAT_HISTORY_STORAGE_KEY)
399			if (!raw) return
400			const parsed = JSON.parse(raw) as unknown
401			const history = sanitizeChatHistory(parsed)
402			if (history.length > 0) setMessages(reconcile(history))
403		} catch {
404			// ignore invalid local storage payloads
405		}
406	})
407
408	// Persist only while idle: during streaming this would otherwise serialize
409	// the whole history to localStorage on every chunk.
410	createEffect(() => {
411		if (typeof window === 'undefined') return
412		if (isSending()) return
413
414		const history = sanitizeChatHistory(messages)
415		try {
416			window.sessionStorage.setItem(
417				CHAT_HISTORY_STORAGE_KEY,
418				JSON.stringify(history),
419			)
420		} catch {
421			// ignore storage failures (quota/private mode)
422		}
423	})
424
425	onCleanup(() => {
426		activeRequestController?.abort()
427		if (greetingTimer) clearTimeout(greetingTimer)
428	})
429
430	createEffect(() => {
431		if (!props.enabled) {
432			closeModule()
433			setSectionNoticeText('')
434			setSectionPrompts(null)
435			return
436		}
437
438		const section = currentAiSection()
439		const singlePromptLabel = getSinglePromptLabel(section ?? null)
440		const prompts = getSectionPrompts(section ?? null)
441
442		if (section == null) {
443			cancelPendingSectionClose()
444			if (!userHasControl()) {
445				if (isModuleHovered()) {
446					setPendingCloseOnLeave(true)
447					return
448				}
449				sectionCloseFrameId = requestAnimationFrame(() => {
450					sectionCloseFrameId = undefined
451					if (currentAiSection() != null) return
452					closeModule()
453					setTimeout(() => {
454						setSectionNoticeText('')
455						setSectionPrompts(null)
456					}, COLLAPSE_DURATION_MS)
457				})
458			}
459			return
460		}
461
462		cancelPendingSectionClose()
463		setPendingCloseOnLeave(false)
464
465		if (userHasControl()) {
466			if (singlePromptLabel != null) {
467				setSectionNoticeText(singlePromptLabel)
468				setSectionPrompts(null)
469			} else if (prompts != null && prompts.length > 1) {
470				setSectionNoticeText('')
471				setSectionPrompts(prompts)
472			} else {
473				setSectionNoticeText('')
474				setSectionPrompts(null)
475			}
476			return
477		}
478
479		if (singlePromptLabel != null) {
480			setSectionNoticeText(singlePromptLabel)
481			setSectionPrompts(null)
482			setActivePanel('notice')
483			setIsExpanded(true)
484		} else if (prompts != null && prompts.length > 1) {
485			setSectionNoticeText('')
486			setSectionPrompts(prompts)
487			setActivePanel('prompts')
488			setIsExpanded(true)
489		} else {
490			setSectionNoticeText('')
491			setSectionPrompts(null)
492		}
493	})
494
495	const isNoticeVisible = () => isExpanded() && activePanel() === 'notice'
496	const isChatVisible = () => isExpanded() && activePanel() === 'chat'
497	const isPromptsVisible = () => isExpanded() && activePanel() === 'prompts'
498
499	let lastHandledOpenRequest = 0
500	createEffect(() => {
501		const id = getOpenAiRequestId()()
502		if (id === lastHandledOpenRequest) return
503		if (id === 0) return
504		if (!props.enabled) return
505		lastHandledOpenRequest = id
506		setUserHasControl(true)
507		setPendingCloseOnLeave(false)
508		setActivePanel('chat')
509		setIsExpanded(true)
510	})
511
512	const submitUserMessage = async (message: string) => {
513		if (isSending() || !message.trim()) return
514		const consent = sessionConsent(
515			sanitizeChatHistory(messages),
516			message,
517			consentAccepted(),
518		)
519		setConsentAccepted(consent)
520		try {
521			window.sessionStorage.setItem(
522				`${CONSENT_STORAGE_KEY}:${getOrCreateChatId()}`,
523				String(consent),
524			)
525		} catch {
526			/* Keep the in-memory decision. */
527		}
528		const sequence = ++requestSequence
529		setRequestError('')
530		setUserHasControl(true)
531		setPendingCloseOnLeave(false)
532		setMessages(produce((list) => list.push({ role: 'user', text: message })))
533		setActivePanel('chat')
534		setIsExpanded(true)
535		setIsSending(true)
536		const controller = new AbortController()
537		activeRequestController = controller
538		let watchdog: ReturnType<typeof setTimeout> | undefined
539		let timedOut = false
540		const armWatchdog = () => {
541			clearTimeout(watchdog)
542			watchdog = setTimeout(() => {
543				timedOut = true
544				controller.abort()
545			}, 60_000)
546		}
547		armWatchdog()
548		let receivedText = ''
549		try {
550			const previousQuestion =
551				[...messages].reverse().find((turn) => turn.role === 'assistant')?.text ||
552				''
553			await updateProfileFromMessage(message, previousQuestion, controller.signal)
554			if (sequence !== requestSequence || controller.signal.aborted) return
555			const response = await fetch('/api/ai', {
556				method: 'POST',
557				headers: { 'Content-Type': 'application/json' },
558				signal: controller.signal,
559				body: JSON.stringify({
560					message,
561					type: props.type || (currentAiSection() ? 'agenda' : 'chat'),
562					profile: getUserProfileSummary(),
563					interests: getUserInterests(),
564					location: getCurrentLocationPath(),
565					chat_id: getOrCreateChatId(),
566					history: sanitizeChatHistory(messages),
567					userProfile: userProfile(),
568					selectedEvent: selectedEvent(),
569					consent,
570				} satisfies ChatRequestPayload),
571			})
572			if (!response.ok) {
573				const data = await response.json().catch(() => ({}))
574				throw new Error(
575					data.error || 'Could not reach the assistant. Please try again.',
576				)
577			}
578			if (sequence !== requestSequence) return
579			const event = parseAiEvent(response.headers.get('X-AI-Event'))
580			if (event && event !== selectedEvent()) {
581				setSelectedEvent(event)
582			}
583			if (!response.body) throw new Error('No response was received.')
584			const reader = response.body.getReader()
585			const decoder = new TextDecoder()
586			const append = (chunk: string) => {
587				if (!chunk || sequence !== requestSequence) return
588				if (!receivedText)
589					setMessages(
590						produce((list) => list.push({ role: 'assistant', text: chunk })),
591					)
592				else setMessages(messages.length - 1, 'text', (text) => text + chunk)
593				receivedText += chunk
594			}
595			while (true) {
596				const { done, value } = await reader.read()
597				armWatchdog()
598				if (done) break
599				append(decoder.decode(value, { stream: true }))
600			}
601			append(decoder.decode())
602			if (!receivedText.trim())
603				throw new Error('No answer was received. Please try again.')
604		} catch (error) {
605			if (sequence !== requestSequence) return
606			setRequestError(
607				timedOut
608					? 'The connection stalled. Your response may be incomplete.'
609					: controller.signal.aborted
610						? 'Response stopped. You can retry or ask another question.'
611						: receivedText
612							? 'Response interrupted. The answer above is incomplete.'
613							: error instanceof Error
614								? error.message
615								: 'Could not get a response.',
616			)
617		} finally {
618			clearTimeout(watchdog)
619			if (sequence === requestSequence) {
620				setIsSending(false)
621				activeRequestController = undefined
622			}
623		}
624	}
625
626	return (
627		<div
628			ref={moduleRootRef}
629			class="ai-module relative w-[30rem] max-w-[calc(100vw-1rem)]"
630			style={
631				accent()
632					? ({
633							'--ai-accent': accent(),
634						} as JSX.CSSProperties)
635					: undefined
636			}
637			data-ai-accent={accent() ? '' : undefined}
638		>
639			{/* Anchored to the bottom-right corner of the shell: the toggle button
640			    sits in the corner and the panel grows leftwards and upwards from
641			    it — towards the centre of the screen — instead of off-screen. */}
642			{/* Default theme is the nav-like dark shell; set data-theme="light" on this div to try the light variant (ai-module.css) */}
643			<div
644				inert={!props.enabled || Boolean(props.hideForFooter)}
645				class={`absolute right-0 bottom-0 transition-transform duration-300 ease-out ${
646					props.enabled && !props.hideForFooter
647						? 'translate-y-0'
648						: 'translate-y-[200%]'
649				}`}
650			>
651				{/* biome-ignore lint/a11y/noStaticElementInteractions: hover handlers only track pointer presence for section-notice behavior; real controls are the buttons/inputs inside */}
652				<div
653					class={`pointer-events-auto relative flex flex-row-reverse items-end justify-start overflow-hidden rounded-2xl border border-[var(--ai-module-border)] p-2 backdrop-blur-xl ${
654						isExpanded()
655							? 'bg-[var(--ai-module-bg-expanded)]'
656							: 'bg-[var(--ai-module-bg-collapsed)]'
657					}`}
658					onMouseEnter={onModuleMouseEnter}
659					onMouseLeave={onModuleMouseLeave}
660				>
661					{isChatVisible() && (
662						<div
663							class="pointer-events-none absolute top-16 right-2 flex w-50 justify-center"
664							aria-hidden="true"
665						>
666							<AiBrandMark />
667						</div>
668					)}
669					<button
670						type="button"
671						class="relative flex aspect-square size-50 items-center justify-center"
672						ref={toggleRef}
673						aria-label="Toggle AI module"
674						aria-expanded={isExpanded()}
675						onClick={onToggleButtonClick}
676					>
677						<AiButtonIcon isNoticeActive={isNoticeVisible()} />
678					</button>
679
680					<div
681						class={`grid overflow-hidden transition-[grid-template-columns] duration-400 ease-out ${
682							isExpanded() ? 'grid-cols-[1fr] delay-75' : 'grid-cols-[0fr] delay-0'
683						}`}
684					>
685						<div class="min-w-0">
686							<div>
687								{/* Collapsed panels keep their rows at 0fr for the height
688								    animation but must not contribute WIDTH either: the hidden
689								    chat would otherwise stretch the notice pill to its width. */}
690								<div
691									inert={!isNoticeVisible()}
692									class={`grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out ${
693										isNoticeVisible() ? 'grid-rows-[1fr]' : 'max-w-0 grid-rows-[0fr]'
694									}`}
695								>
696									<div class="min-h-0">
697										<AiNotice
698											text={sectionNoticeText() || undefined}
699											isVisible={isNoticeVisible()}
700											onClick={
701												sectionNoticeText()
702													? () => {
703															setUserHasControl(true)
704															setPendingCloseOnLeave(false)
705															setActivePanel('chat')
706															setIsExpanded(true)
707															const text = getSinglePromptSubmitValue(
708																currentAiSection() ?? null,
709															)
710															if (text) submitUserMessage(text)
711														}
712													: undefined
713											}
714										/>
715									</div>
716								</div>
717
718								<div
719									inert={!isChatVisible()}
720									class={`grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out ${
721										isChatVisible() ? 'grid-rows-[1fr]' : 'max-w-0 grid-rows-[0fr]'
722									}`}
723								>
724									<div class="min-h-0">
725										<AiChat
726											messages={messages}
727											isSending={isSending()}
728											isVisible={isChatVisible()}
729											onSubmitMessage={submitUserMessage}
730											error={requestError()}
731										/>
732									</div>
733								</div>
734
735								<div
736									inert={!isPromptsVisible()}
737									class={`grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out ${
738										isPromptsVisible() ? 'grid-rows-[1fr]' : 'max-w-0 grid-rows-[0fr]'
739									}`}
740								>
741									<div class="min-h-0">
742										<AiPrompts
743											prompts={sectionPrompts()}
744											isVisible={isPromptsVisible()}
745											onSubmitMessage={(message) => {
746												if (isSending()) return
747												setUserHasControl(true)
748												setPendingCloseOnLeave(false)
749												setActivePanel('chat')
750												setIsExpanded(true)
751												if (message) submitUserMessage(message)
752											}}
753										/>
754									</div>
755								</div>
756							</div>
757						</div>
758					</div>
759				</div>
760			</div>
761		</div>
762	)
763}
764