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