{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"chat-transcript","type":"registry:ui","title":"ChatTranscript","description":"The conversation. Agent output renders sanitised, because a reply is model output and an uploaded file can talk a model into emitting markup.","package":"teachers-aid-ui","dependencies":["@particle-academy\/react-fancy"],"registryDependencies":["types"],"files":[{"path":"components\/fancy\/chat-transcript\/ChatTranscript.tsx","content":"import { Badge, ContentRenderer, Text } from '@particle-academy\/react-fancy';\r\nimport { useEffect, useRef } from 'react';\r\nimport type { ChatEntry } from '..\/types';\r\n\r\n\/**\r\n * Prose styling for rendered agent output.\r\n *\r\n * Written as arbitrary variants rather than a stylesheet so the package ships\r\n * no CSS of its own \u2014 a host that already scans this package with Tailwind gets\r\n * the styling for free, with nothing to import and nothing to override.\r\n *\/\r\nconst PROSE = [\r\n    '[&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0',\r\n    '[&_strong]:font-semibold',\r\n    '[&_em]:italic',\r\n    '[&_ul]:my-1 [&_ul]:list-disc [&_ul]:pl-5',\r\n    '[&_ol]:my-1 [&_ol]:list-decimal [&_ol]:pl-5',\r\n    '[&_li]:my-0.5',\r\n    '[&_h1]:text-base [&_h2]:text-base [&_h3]:text-sm',\r\n    '[&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold',\r\n    '[&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0',\r\n    '[&_code]:rounded [&_code]:bg-black\/5 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[0.85em]',\r\n    '[&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-black\/5 [&_pre]:p-2',\r\n    '[&_pre_code]:bg-transparent [&_pre_code]:p-0',\r\n    '[&_a]:underline [&_a]:underline-offset-2',\r\n    '[&_blockquote]:border-l-2 [&_blockquote]:border-secondary-300 [&_blockquote]:pl-3 [&_blockquote]:italic',\r\n    '[&_table]:my-2 [&_table]:w-full [&_table]:text-left',\r\n    '[&_th]:border-b [&_th]:border-secondary-300 [&_th]:pr-3 [&_th]:font-medium',\r\n    '[&_td]:border-b [&_td]:border-secondary-200 [&_td]:pr-3 [&_td]:align-top',\r\n    '[&_hr]:my-2 [&_hr]:border-secondary-300',\r\n].join(' ');\r\n\r\nexport interface ChatTranscriptProps {\r\n    history: ChatEntry[];\r\n    agentName: string;\r\n    \/** Renders the working indicator while a turn is in flight. *\/\r\n    thinking?: boolean;\r\n    \/** Replaces the default empty state. *\/\r\n    emptyState?: React.ReactNode;\r\n}\r\n\r\n\/**\r\n * The conversation.\r\n *\r\n * Renders `display` in preference to `content`: the latter is what the model\r\n * was sent, with extracted file text folded in, and showing it would paste a\r\n * whole handbook back at the teacher.\r\n *\/\r\nexport function ChatTranscript({ history, agentName, thinking, emptyState }: ChatTranscriptProps) {\r\n    const endRef = useRef<HTMLDivElement>(null);\r\n\r\n    useEffect(() => {\r\n        endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });\r\n    }, [history.length, thinking]);\r\n\r\n    if (history.length === 0 && !thinking) {\r\n        return (\r\n            <div className=\"flex flex-1 items-center justify-center px-6 py-12 text-center\">\r\n                {emptyState ?? (\r\n                    <div className=\"max-w-md\">\r\n                        <Text className=\"!text-sm !text-secondary-600\">\r\n                            Describe the course you want, or attach the material to build it from \u2014\r\n                            a handbook, a slide deck, a question bank.\r\n                        <\/Text>\r\n                        <Text className=\"!text-xs !text-secondary-500 !mt-3\">\r\n                            {agentName} drafts changes for you to review. It cannot save anything itself.\r\n                        <\/Text>\r\n                    <\/div>\r\n                )}\r\n            <\/div>\r\n        );\r\n    }\r\n\r\n    return (\r\n        <div className=\"flex-1 overflow-y-auto px-4 py-4 grid gap-4 content-start\">\r\n            {history.map((entry, i) => (\r\n                <Bubble key={i} entry={entry} agentName={agentName} \/>\r\n            ))}\r\n\r\n            {thinking && (\r\n                <div className=\"flex gap-3\">\r\n                    <Who label={agentName} \/>\r\n                    <div className=\"rounded-lg bg-secondary-100 px-3 py-2\">\r\n                        <span className=\"inline-flex gap-1\" aria-label={`${agentName} is working`}>\r\n                            <Dot delay=\"0ms\" \/>\r\n                            <Dot delay=\"150ms\" \/>\r\n                            <Dot delay=\"300ms\" \/>\r\n                        <\/span>\r\n                    <\/div>\r\n                <\/div>\r\n            )}\r\n\r\n            <div ref={endRef} \/>\r\n        <\/div>\r\n    );\r\n}\r\n\r\nfunction Bubble({ entry, agentName }: { entry: ChatEntry; agentName: string }) {\r\n    const mine = entry.role === 'user';\r\n    const body = entry.display || entry.content || '';\r\n\r\n    return (\r\n        <div className={`flex gap-3 ${mine ? 'flex-row-reverse' : ''}`}>\r\n            <Who label={mine ? 'You' : agentName} \/>\r\n            <div className={`max-w-[46rem] ${mine ? 'text-right' : ''}`}>\r\n                <div\r\n                    className={`inline-block rounded-lg px-3 py-2 text-left break-words text-sm ${\r\n                        mine\r\n                            ? 'bg-brand text-white whitespace-pre-wrap'\r\n                            : 'bg-secondary-100 text-secondary-900'\r\n                    }`}\r\n                >\r\n                    {mine ? (\r\n                        \/\/ The teacher's own words, verbatim. Rendering these as\r\n                        \/\/ markdown would silently reformat what they typed.\r\n                        body\r\n                    ) : (\r\n                        \/\/ Model output is markdown, and it is not trusted input \u2014\r\n                        \/\/ it can carry whatever an uploaded file talked it into\r\n                        \/\/ writing. ContentRenderer sanitizes by default; never\r\n                        \/\/ pass `unsafe` here.\r\n                        <ContentRenderer value={body} format=\"markdown\" className={PROSE} \/>\r\n                    )}\r\n                <\/div>\r\n                {entry.files && entry.files.length > 0 && (\r\n                    <div className={`mt-1.5 flex flex-wrap gap-1.5 ${mine ? 'justify-end' : ''}`}>\r\n                        {entry.files.map((name) => (\r\n                            <Badge key={name} color=\"gray\" variant=\"soft\" size=\"sm\">\r\n                                {name}\r\n                            <\/Badge>\r\n                        ))}\r\n                    <\/div>\r\n                )}\r\n            <\/div>\r\n        <\/div>\r\n    );\r\n}\r\n\r\nfunction Who({ label }: { label: string }) {\r\n    return (\r\n        <div className=\"shrink-0 pt-1\">\r\n            <span className=\"text-[10px] font-semibold uppercase tracking-wide text-secondary-500\">\r\n                {label}\r\n            <\/span>\r\n        <\/div>\r\n    );\r\n}\r\n\r\nfunction Dot({ delay }: { delay: string }) {\r\n    return (\r\n        <span\r\n            className=\"h-1.5 w-1.5 rounded-full bg-secondary-400 animate-bounce\"\r\n            style={{ animationDelay: delay }}\r\n        \/>\r\n    );\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/chat-transcript\/ChatTranscript.tsx"}]}