Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 38 additions & 12 deletions components/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Button } from './ui/button'
import { IconBlinkingLogo } from './ui/icons'
import { ActionButtons } from './action-buttons'
import { FileUploadButton } from './file-upload-button'
import { MessageNavigationDots } from './message-navigation-dots'
import { ModelSelectorClient } from './model-selector-client'
import { SearchModeSelector } from './search-mode-selector'
import { UploadedFileList } from './uploaded-file-list'
Expand Down Expand Up @@ -49,6 +50,8 @@ interface ChatPanelProps {
/** Whether the deployment is cloud mode */
isCloudDeployment?: boolean
modelSelectorData?: ModelSelectorData
/** Chat sections for message navigation dots */
sections?: { id: string; userMessage: UIMessage }[]
}

export function ChatPanel({
Expand All @@ -69,7 +72,8 @@ export function ChatPanel({
onNewChat,
isGuest = false,
isCloudDeployment = false,
modelSelectorData
modelSelectorData,
sections = []
}: ChatPanelProps) {
const router = useRouter()
const inputRef = useRef<HTMLTextAreaElement>(null)
Expand Down Expand Up @@ -182,18 +186,40 @@ export function ChatPanel({
}}
className={cn('max-w-full md:max-w-3xl w-full mx-auto relative')}
>
{/* Scroll to bottom button - only shown when showScrollToBottomButton is true */}
{showScrollToBottomButton && messages.length > 0 && (
<Button
type="button"
variant="outline"
size="icon"
className="absolute -top-10 right-4 z-20 size-8 rounded-full shadow-md"
onClick={handleScrollToBottom}
title="Scroll to bottom"
{/* Scroll to bottom button */}
{messages.length > 0 && (
<div
className={cn(
'transition-opacity duration-100',
showScrollToBottomButton
? 'opacity-100'
: 'pointer-events-none opacity-0'
)}
>
<ChevronDown size={16} />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="absolute -top-10 right-0 z-20 size-8 rounded-full shadow-md"
onClick={handleScrollToBottom}
title="Scroll to bottom"
>
<ChevronDown size={16} />
</Button>
</div>
)}
{/* Message navigation dots */}
{sections.length > 0 && (
<div
className={cn(
'transition-opacity duration-100',
!showScrollToBottomButton && status === 'ready'
? 'opacity-100'
: 'pointer-events-none opacity-0'
)}
>
<MessageNavigationDots sections={sections} />
</div>
)}

<div
Expand Down
1 change: 1 addition & 0 deletions components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ export function Chat({
isGuest={isGuest}
isCloudDeployment={isCloudDeployment}
modelSelectorData={modelSelectorData}
sections={sections}
/>
<DragOverlay visible={dragHandlers.isDragging} />
<ErrorModal
Expand Down
69 changes: 69 additions & 0 deletions components/message-navigation-dots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use client'

import type { UIMessage } from '@/lib/types/ai'

import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from './ui/tooltip'

interface Section {
id: string
userMessage: UIMessage
}

interface MessageNavigationDotsProps {
sections: Section[]
}

function getUserMessagePreview(message: UIMessage): string {
for (const part of message.parts ?? []) {
if (part.type === 'text' && part.text) {
const text = part.text.trim()
return text.length > 20 ? `${text.slice(0, 20)}…` : text
}
}
return ''
}

export function MessageNavigationDots({
sections
}: MessageNavigationDotsProps) {
const visibleSections = sections.slice(-4)

const handleClick = (sectionId: string) => {
const el = document.getElementById(`section-${sectionId}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}

return (
<TooltipProvider delayDuration={200}>
<div className="absolute bottom-full right-0 z-20 mb-2 flex w-8 flex-col items-center gap-0 pb-2">
{visibleSections.map(section => {
const preview = getUserMessagePreview(section.userMessage)
return (
<Tooltip key={section.id}>
<TooltipTrigger asChild>
<button
type="button"
className="group flex size-3 items-center justify-center"
onClick={() => handleClick(section.id)}
aria-label={preview || 'Go to message'}
>
<span className="size-1.5 rounded-full bg-foreground/30 transition-colors group-hover:bg-foreground/60" />
</button>
</TooltipTrigger>
{preview && (
<TooltipContent side="left" className="max-w-48 text-xs">
{preview}
</TooltipContent>
)}
</Tooltip>
)
})}
</div>
</TooltipProvider>
)
}
Loading