657 lines
20 KiB
TypeScript
657 lines
20 KiB
TypeScript
import {
|
|
ActionBarPrimitive,
|
|
AuiIf,
|
|
BranchPickerPrimitive,
|
|
ErrorPrimitive,
|
|
MessagePrimitive,
|
|
ThreadPrimitive,
|
|
type ToolCallMessagePartProps,
|
|
useAuiState
|
|
} from '@assistant-ui/react'
|
|
import { useStore } from '@nanostores/react'
|
|
import {
|
|
CheckIcon,
|
|
ChevronLeftIcon,
|
|
ChevronRightIcon,
|
|
CopyIcon,
|
|
GitBranchIcon,
|
|
Loader2Icon,
|
|
MoreHorizontalIcon,
|
|
RefreshCwIcon,
|
|
Volume2Icon,
|
|
VolumeXIcon
|
|
} from 'lucide-react'
|
|
import {
|
|
type FC,
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState
|
|
} from 'react'
|
|
|
|
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
|
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
|
import { DirectiveText } from '@/components/assistant-ui/directive-text'
|
|
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
|
|
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
|
|
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
|
|
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
|
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
|
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuTrigger
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { Loader } from '@/components/ui/loader'
|
|
import { triggerHaptic } from '@/lib/haptics'
|
|
import { cn } from '@/lib/utils'
|
|
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
|
import { notifyError } from '@/store/notifications'
|
|
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
|
import { $voicePlayback } from '@/store/voice-playback'
|
|
|
|
const THINKING_FACES = [
|
|
'(。•́︿•̀。)',
|
|
'(◔_◔)',
|
|
'(¬‿¬)',
|
|
'( •_•)>⌐■-■',
|
|
'(⌐■_■)',
|
|
'(´・_・`)',
|
|
'◉_◉',
|
|
'(°ロ°)',
|
|
'( ˘⌣˘)♡',
|
|
'ヽ(>∀<☆)☆',
|
|
'٩(๑❛ᴗ❛๑)۶',
|
|
'(⊙_⊙)',
|
|
'(¬_¬)',
|
|
'( ͡° ͜ʖ ͡°)',
|
|
'ಠ_ಠ'
|
|
]
|
|
|
|
const THINKING_VERBS = [
|
|
'pondering',
|
|
'contemplating',
|
|
'musing',
|
|
'cogitating',
|
|
'ruminating',
|
|
'deliberating',
|
|
'mulling',
|
|
'reflecting',
|
|
'processing',
|
|
'reasoning',
|
|
'analyzing',
|
|
'computing',
|
|
'synthesizing',
|
|
'formulating',
|
|
'brainstorming'
|
|
]
|
|
|
|
type ThreadLoadingState = 'response' | 'session' | 'working'
|
|
|
|
interface MessageActionProps {
|
|
messageId: string
|
|
messageText: string
|
|
onBranchInNewChat?: (messageId: string) => void
|
|
}
|
|
|
|
const BOTTOM_DISTANCE_PX = 24
|
|
let readAloudAudio: HTMLAudioElement | null = null
|
|
|
|
function isNearBottom(el: HTMLElement): boolean {
|
|
return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX
|
|
}
|
|
|
|
function partText(part: unknown): string {
|
|
if (typeof part === 'string') {
|
|
return part
|
|
}
|
|
|
|
if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
|
|
return part.text
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
function messageContentText(content: unknown): string {
|
|
if (typeof content === 'string') {
|
|
return content.trim()
|
|
}
|
|
|
|
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
|
|
}
|
|
|
|
export const Thread: FC<{
|
|
intro?: IntroProps
|
|
loading?: ThreadLoadingState
|
|
onBranchInNewChat?: (messageId: string) => void
|
|
sessionKey?: string | null
|
|
}> = ({ intro, loading, onBranchInNewChat, sessionKey }) => {
|
|
const viewportRef = useRef<HTMLDivElement | null>(null)
|
|
const contentRef = useRef<HTMLDivElement | null>(null)
|
|
const messageCount = useAuiState(s => s.thread.messages.length)
|
|
const isRunning = useAuiState(s => s.thread.isRunning)
|
|
const lastMessageId = useAuiState(s => s.thread.messages.at(-1)?.id ?? '')
|
|
const shouldStickToBottomRef = useRef(true)
|
|
const scrollFrameRef = useRef<number | null>(null)
|
|
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
|
|
|
|
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
|
const nearBottom = isNearBottom(event.currentTarget)
|
|
shouldStickToBottomRef.current = nearBottom
|
|
setThreadScrolledUp(!nearBottom)
|
|
}, [])
|
|
|
|
const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
|
if (event.deltaY < 0) {
|
|
shouldStickToBottomRef.current = false
|
|
setThreadScrolledUp(true)
|
|
}
|
|
}, [])
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
const viewport = viewportRef.current
|
|
|
|
if (!viewport) {
|
|
return
|
|
}
|
|
|
|
viewport.scrollTop = viewport.scrollHeight
|
|
shouldStickToBottomRef.current = true
|
|
setThreadScrolledUp(false)
|
|
}, [])
|
|
|
|
const scheduleScrollToBottom = useCallback(() => {
|
|
if (scrollFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(scrollFrameRef.current)
|
|
}
|
|
|
|
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
|
scrollFrameRef.current = null
|
|
scrollToBottom()
|
|
})
|
|
}, [scrollToBottom])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (scrollFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(scrollFrameRef.current)
|
|
}
|
|
|
|
setThreadScrolledUp(false)
|
|
}
|
|
}, [])
|
|
|
|
useLayoutEffect(() => {
|
|
const viewport = viewportRef.current
|
|
|
|
if (!viewport) {
|
|
return
|
|
}
|
|
|
|
const nextSessionKey = sessionKey ?? null
|
|
const sessionChanged = sessionKeyRef.current !== nextSessionKey
|
|
sessionKeyRef.current = nextSessionKey
|
|
const force = loading === 'session' || sessionChanged
|
|
|
|
if (!force && !shouldStickToBottomRef.current) {
|
|
return
|
|
}
|
|
|
|
scheduleScrollToBottom()
|
|
}, [isRunning, lastMessageId, loading, messageCount, scheduleScrollToBottom, sessionKey])
|
|
|
|
useLayoutEffect(() => {
|
|
const content = contentRef.current
|
|
const viewport = viewportRef.current
|
|
|
|
if (!content || !viewport) {
|
|
return
|
|
}
|
|
|
|
let previousHeight = content.getBoundingClientRect().height
|
|
|
|
const observer = new ResizeObserver(entries => {
|
|
const height = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height
|
|
|
|
if (height === previousHeight) {
|
|
return
|
|
}
|
|
|
|
previousHeight = height
|
|
|
|
if (!shouldStickToBottomRef.current && !isNearBottom(viewport)) {
|
|
return
|
|
}
|
|
|
|
scheduleScrollToBottom()
|
|
})
|
|
|
|
observer.observe(content)
|
|
|
|
return () => observer.disconnect()
|
|
}, [scheduleScrollToBottom])
|
|
|
|
return (
|
|
<GeneratedImageProvider>
|
|
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
|
|
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
|
|
|
|
<ThreadPrimitive.Viewport
|
|
autoScroll={false}
|
|
className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)]"
|
|
data-slot="aui_thread-viewport"
|
|
onScroll={handleScroll}
|
|
onWheel={handleWheel}
|
|
ref={viewportRef}
|
|
scrollToBottomOnInitialize
|
|
scrollToBottomOnRunStart
|
|
scrollToBottomOnThreadSwitch
|
|
>
|
|
<div className="flex w-full flex-col gap-3" ref={contentRef}>
|
|
<ThreadPrimitive.Messages>{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}</ThreadPrimitive.Messages>
|
|
{loading === 'response' && <ResponseLoadingIndicator />}
|
|
{loading === 'working' && <WorkingIndicator />}
|
|
</div>
|
|
<ThreadPrimitive.ViewportFooter className="h-[220px] shrink-0" />
|
|
</ThreadPrimitive.Viewport>
|
|
{loading === 'session' && <CenteredThreadSpinner />}
|
|
</ThreadPrimitive.Root>
|
|
</GeneratedImageProvider>
|
|
)
|
|
}
|
|
|
|
const CenteredThreadSpinner: FC = () => (
|
|
<div
|
|
aria-label="Loading session"
|
|
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
|
|
role="status"
|
|
>
|
|
<Loader
|
|
aria-hidden="true"
|
|
className="size-12 text-primary/70"
|
|
pathSteps={220}
|
|
role="presentation"
|
|
strokeScale={0.72}
|
|
type="rose-curve"
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
const ThreadMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
|
const role = useAuiState(s => s.message.role)
|
|
const isEditing = useAuiState(s => s.message.composer.isEditing)
|
|
|
|
// The runtime synthesizes an empty assistant placeholder while isRunning is true
|
|
// (last message is user). Rendering the full `MessagePrimitive.Root` for it adds
|
|
// ~36px of invisible chrome (gap-2 + min-h-7 footer) which can push the
|
|
// loading affordance too far below the user message. Skip it —
|
|
// `ResponseLoadingIndicator` in the viewport handles the loading affordance directly.
|
|
const isPlaceholder = useAuiState(
|
|
s => s.message.role === 'assistant' && s.message.status?.type === 'running' && s.message.content.length === 0
|
|
)
|
|
|
|
if (isEditing) {
|
|
return <EditComposer />
|
|
}
|
|
|
|
if (role === 'user') {
|
|
return <UserMessage />
|
|
}
|
|
|
|
if (isPlaceholder) {
|
|
return null
|
|
}
|
|
|
|
return <AssistantMessage onBranchInNewChat={onBranchInNewChat} />
|
|
}
|
|
|
|
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
|
const messageId = useAuiState(s => s.message.id)
|
|
const content = useAuiState(s => s.message.content)
|
|
const messageText = messageContentText(content)
|
|
|
|
return (
|
|
<MessagePrimitive.Root
|
|
className="group flex w-full flex-col gap-2 self-start"
|
|
data-role="assistant"
|
|
data-slot="aui_assistant-message-root"
|
|
>
|
|
<div className="wrap-anywhere text-pretty text-foreground" data-slot="aui_assistant-message-content">
|
|
<MessagePrimitive.Parts
|
|
components={{
|
|
Text: MarkdownText,
|
|
Reasoning: ReasoningPart,
|
|
tools: { Fallback: ChainToolFallback }
|
|
}}
|
|
/>
|
|
<MessagePrimitive.Error>
|
|
<ErrorPrimitive.Root
|
|
className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
|
role="alert"
|
|
>
|
|
<ErrorPrimitive.Message />
|
|
</ErrorPrimitive.Root>
|
|
</MessagePrimitive.Error>
|
|
</div>
|
|
<div className="min-h-6">
|
|
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
|
|
</div>
|
|
</MessagePrimitive.Root>
|
|
)
|
|
}
|
|
|
|
const STATUS_ROW_CLASS = 'flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70'
|
|
|
|
const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label }) => (
|
|
<div aria-label={label} aria-live="polite" className={STATUS_ROW_CLASS} role="status">
|
|
{children}
|
|
</div>
|
|
)
|
|
|
|
const ResponseLoadingIndicator: FC = () => {
|
|
const [tick, setTick] = useState(0)
|
|
const elapsed = useElapsedSeconds()
|
|
|
|
useEffect(() => {
|
|
const id = window.setInterval(() => setTick(t => t + 1), 900)
|
|
|
|
return () => window.clearInterval(id)
|
|
}, [])
|
|
|
|
const face = THINKING_FACES[tick % THINKING_FACES.length]
|
|
const verb = THINKING_VERBS[tick % THINKING_VERBS.length]
|
|
|
|
return (
|
|
<StatusRow label="Hermes is loading a response">
|
|
<span className="shimmer shimmer-repeat-delay-0 min-w-0 truncate text-muted-foreground/55">
|
|
{face} {verb}…
|
|
</span>
|
|
<ActivityTimerText seconds={elapsed} />
|
|
</StatusRow>
|
|
)
|
|
}
|
|
|
|
const WorkingIndicator: FC = () => {
|
|
const elapsed = useElapsedSeconds()
|
|
|
|
return (
|
|
<StatusRow label="Hermes is still working">
|
|
<Loader className="size-4 text-muted-foreground/60" label="Still working" strokeScale={0.65} type="spiral-search" />
|
|
<span className="shimmer min-w-0 truncate text-muted-foreground/60">Still working…</span>
|
|
<ActivityTimerText seconds={elapsed} />
|
|
</StatusRow>
|
|
)
|
|
}
|
|
|
|
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
|
const generatedImage = useGeneratedImageContext()
|
|
const running = result === undefined
|
|
|
|
useEffect(() => {
|
|
generatedImage?.setPending(running)
|
|
}, [generatedImage, running])
|
|
|
|
if (!running) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div className="mt-2">
|
|
<ImageGenerationPlaceholder />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
|
if (props.toolName === 'image_generate') {
|
|
return <ImageGenerateTool {...props} />
|
|
}
|
|
|
|
return <ToolFallback {...props} />
|
|
}
|
|
|
|
const ThinkingDisclosure: FC<{
|
|
children: ReactNode
|
|
pending?: boolean
|
|
}> = ({ children, pending = false }) => {
|
|
const [open, setOpen] = useState(false)
|
|
const elapsed = useElapsedSeconds(pending)
|
|
|
|
return (
|
|
<div className="mb-3 text-sm text-muted-foreground">
|
|
<button
|
|
aria-expanded={open}
|
|
className="inline-flex max-w-full items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
onClick={() => setOpen(value => !value)}
|
|
type="button"
|
|
>
|
|
<ChevronRightIcon
|
|
className={cn('size-3 shrink-0 text-muted-foreground/80 transition-transform', open && 'rotate-90')}
|
|
/>
|
|
<span
|
|
className={cn('shrink-0 text-xs font-medium text-foreground/70', pending && 'shimmer text-foreground/55')}
|
|
>
|
|
Thinking
|
|
</span>
|
|
{pending && <ActivityTimerText seconds={elapsed} />}
|
|
</button>
|
|
{open && <div className="ml-4 mt-1 max-w-full wrap-anywhere border-l border-border pl-3">{children}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => (
|
|
<div className="mb-1 mt-1">
|
|
<ThinkingDisclosure pending={status?.type === 'running'}>
|
|
<div
|
|
className={cn(
|
|
'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85',
|
|
status?.type === 'running' && 'shimmer text-muted-foreground/55'
|
|
)}
|
|
>
|
|
{text}
|
|
</div>
|
|
</ThinkingDisclosure>
|
|
</div>
|
|
)
|
|
|
|
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
|
|
|
const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
month: 'short'
|
|
})
|
|
|
|
function startOfDay(d: Date): number {
|
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
|
}
|
|
|
|
function formatMessageTimestamp(value: Date | string | number | undefined): string {
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
const date = value instanceof Date ? value : new Date(value)
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return ''
|
|
}
|
|
|
|
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
|
|
|
|
if (dayDelta === 0) {
|
|
return `Today, ${TIME_FMT.format(date)}`
|
|
}
|
|
|
|
if (dayDelta === 1) {
|
|
return `Yesterday, ${TIME_FMT.format(date)}`
|
|
}
|
|
|
|
return SHORT_FMT.format(date)
|
|
}
|
|
|
|
const ACTION_BAR_CLASS = cn(
|
|
'absolute inset-0 flex gap-1 text-muted-foreground opacity-0 transition-opacity duration-100',
|
|
'pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100',
|
|
'focus-within:pointer-events-auto focus-within:opacity-100'
|
|
)
|
|
|
|
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
|
|
return (
|
|
<div className="relative h-6 w-20 shrink-0">
|
|
<ActionBarPrimitive.Root
|
|
className={cn(ACTION_BAR_CLASS, menuOpen && 'pointer-events-auto opacity-100')}
|
|
hideWhenRunning
|
|
>
|
|
<CopyMessageButton text={messageText} />
|
|
<ActionBarPrimitive.Reload asChild>
|
|
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh">
|
|
<RefreshCwIcon />
|
|
</TooltipIconButton>
|
|
</ActionBarPrimitive.Reload>
|
|
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<TooltipIconButton tooltip="More actions">
|
|
<MoreHorizontalIcon />
|
|
</TooltipIconButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
|
|
<MessageTimestamp />
|
|
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
|
|
<GitBranchIcon />
|
|
Branch in new chat
|
|
</DropdownMenuItem>
|
|
<ReadAloudItem messageId={messageId} text={messageText} />
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</ActionBarPrimitive.Root>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const CopyMessageButton: FC<{ text: string }> = ({ text }) => {
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
const copy = useCallback(async () => {
|
|
if (!text) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
triggerHaptic('selection')
|
|
setCopied(true)
|
|
window.setTimeout(() => setCopied(false), 2000)
|
|
} catch (error) {
|
|
notifyError(error, 'Copy failed')
|
|
}
|
|
}, [text])
|
|
|
|
return (
|
|
<TooltipIconButton disabled={!text} onClick={() => void copy()} tooltip={copied ? 'Copied' : 'Copy'}>
|
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
</TooltipIconButton>
|
|
)
|
|
}
|
|
|
|
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
|
|
const voicePlayback = useStore($voicePlayback)
|
|
|
|
const readAloudStatus =
|
|
voicePlayback.source === 'read-aloud' && voicePlayback.messageId === messageId ? voicePlayback.status : 'idle'
|
|
|
|
const isPreparing = readAloudStatus === 'preparing'
|
|
const isSpeaking = readAloudStatus === 'speaking'
|
|
const anyPlaybackActive = voicePlayback.status !== 'idle'
|
|
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
|
|
|
|
const read = useCallback(async () => {
|
|
if (!text || $voicePlayback.get().status !== 'idle') {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await playSpeechText(text, { messageId, source: 'read-aloud' })
|
|
} catch (error) {
|
|
notifyError(error, 'Read aloud failed')
|
|
}
|
|
}, [messageId, text])
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
disabled={isPreparing || (!isSpeaking && (anyPlaybackActive || !text))}
|
|
onSelect={e => {
|
|
e.preventDefault()
|
|
void (isSpeaking ? stopVoicePlayback() : read())
|
|
}}
|
|
>
|
|
<Icon className={isPreparing ? 'animate-spin' : undefined} />
|
|
{isPreparing ? 'Preparing audio...' : isSpeaking ? 'Stop reading' : 'Read aloud'}
|
|
</DropdownMenuItem>
|
|
)
|
|
}
|
|
|
|
const MessageTimestamp: FC = () => {
|
|
const createdAt = useAuiState(s => s.message.createdAt)
|
|
const label = formatMessageTimestamp(createdAt)
|
|
|
|
if (!label) {
|
|
return null
|
|
}
|
|
|
|
return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
|
|
}
|
|
|
|
const AssistantFooter: FC<MessageActionProps> = props => (
|
|
<div className="flex min-h-6 flex-col items-start gap-1">
|
|
<BranchPickerPrimitive.Root
|
|
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
|
hideWhenSingleBranch
|
|
>
|
|
<BranchPickerPrimitive.Previous className={branchButtonClass}>
|
|
<ChevronLeftIcon className="size-3.5" />
|
|
</BranchPickerPrimitive.Previous>
|
|
<span className="tabular-nums">
|
|
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
|
</span>
|
|
<BranchPickerPrimitive.Next className={branchButtonClass}>
|
|
<ChevronRightIcon className="size-3.5" />
|
|
</BranchPickerPrimitive.Next>
|
|
</BranchPickerPrimitive.Root>
|
|
<AssistantActionBar {...props} />
|
|
</div>
|
|
)
|
|
|
|
const branchButtonClass =
|
|
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
|
|
|
|
const UserMessage: FC = () => {
|
|
return (
|
|
<MessagePrimitive.Root
|
|
className="group flex max-w-[min(72%,34rem)] flex-col gap-2 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2"
|
|
data-role="user"
|
|
data-slot="aui_user-message-root"
|
|
>
|
|
<div className="wrap-anywhere whitespace-pre-line leading-[1.48] text-foreground/95">
|
|
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
|
</div>
|
|
</MessagePrimitive.Root>
|
|
)
|
|
}
|
|
|
|
const EditComposer: FC = () => {
|
|
// Editing requires a real onEdit implementation against Hermes history.
|
|
// Hide the edit composer until that contract is implemented.
|
|
return null
|
|
}
|