'use client' /** * `` — the composer's CONTEXT MEMORY strip * (Figma node 271:38656). * * A one-line summary row pinned to the top of the composer card ("5 recently * viewed items in context") with a `⋯` trigger that opens a dropdown listing * every remembered entity, each removable via its `×`. * * This is the AMBIENT half of the composer's context UI — the entities the host * accumulated from the user's navigation history and sends along with every * message — as opposed to ``, which shows the items the * user ASSIGNED to the message they're typing. Both sit above the input; memory * on top (passive, always the same items), assigned chips closest to the * textarea (active selection for this message). * * The lib owns only the chrome: items and removal are host-fed * (`EmbeddableChat`'s `contextMemory` prop), and lead glyphs come from the * picker's entity-type icon map via `resolveIcon`. Renders `null` when the host * has nothing remembered, so it has no permanent footprint. */ import type * as React from 'react' import { useEffect, useRef, useState } from 'react' import { Ellipsis01Icon } from '../icons-v2-generated/interface/ellipsis-01-icon' import { XmarkCircleIcon } from '../icons-v2-generated/signs-and-symbols/xmark-circle-icon' import { cn } from '../../utils/cn' import { CONTEXT_ICON_CLASS, CONTEXT_LABEL_CLASS, CONTEXT_ROW_CLASS } from './context-items-list' import type { ChatContextItem } from './types/context-item.types' const itemKey = (item: { type: string; id: string }) => `${item.type}:${item.id}` export interface ChatContextMemoryBarProps { /** Remembered entities, most-recent-first. Empty → the bar renders nothing. */ items: ChatContextItem[] /** Drop one entity from the host's memory (dropdown row `×`). When omitted the * list is read-only. */ onRemove?: (item: ChatContextItem) => void /** Lead-glyph resolver — the composer passes its entity-type icon map, so the * rows match the picker and the assigned chips. */ resolveIcon?: (item: ChatContextItem) => React.ReactNode /** External disable (e.g. while a message is streaming). */ disabled?: boolean className?: string } export function ChatContextMemoryBar({ items, onRemove, resolveIcon, disabled = false, className, }: ChatContextMemoryBarProps) { const [open, setOpen] = useState(false) const rootRef = useRef(null) // Removing the last remembered entity unmounts the bar; close first so a // stale popover can't outlive its trigger. useEffect(() => { if (items.length === 0) setOpen(false) }, [items.length]) // Outside-press close. Registered only while open. The root wraps BOTH the // trigger and the popover, so a press on the `⋯` (inside root) is ignored here // and handled by the button's own toggle. // // CAPTURE phase (the `true` 3rd arg): the chat lives inside a resizable drawer // whose panel captures pointer events and whose composer sits on a // `contentEditable`; a bubble-phase `document` listener can be pre-empted by // an ancestor's `stopPropagation`, which is why a press outside the popover // wasn't closing it. Capture runs top-down before any of that, so the handler // always fires. `touchstart` covers tap-to-dismiss on touch devices. useEffect(() => { if (!open) return const onOutside = (e: Event) => { if (rootRef.current?.contains(e.target as Node)) return setOpen(false) } document.addEventListener('mousedown', onOutside, true) document.addEventListener('touchstart', onOutside, true) return () => { document.removeEventListener('mousedown', onOutside, true) document.removeEventListener('touchstart', onOutside, true) } }, [open]) if (items.length === 0) return null return ( // `relative` anchors the popover to this strip; `bottom-full` floats it // above, matching the context picker's placement over the input.
{ if (e.key === 'Escape' && open) { e.preventDefault() setOpen(false) } }} >

{items.length} recently viewed {items.length === 1 ? 'item' : 'items'} in context

{open && (
{items.map(item => ( // Not a ` )}
))}
)} ) }