feat: conversation history (#235)

* feat: create conversations storage hook

* feat: save conversation hook

* feat: created chat layout

* feat: created chat history button

* feat: setup chat history view links

* chore: updated placeholder

* fix: width of the chat history screen

* feat: provide navigation from history page back to conversation page

* fix: issue with restoring conversation id

* chore: do not update history when content doesn't change

* feat: mark active conversation id

* fix: syncing the conversation id ref
This commit is contained in:
Dani Akash
2026-01-16 01:21:09 +05:30
committed by GitHub
parent 9f87d817ff
commit be01c1d1a9
8 changed files with 426 additions and 31 deletions

View File

@@ -1,12 +1,17 @@
import type { FC } from 'react'
import { HashRouter, Route, Routes } from 'react-router'
import { ChatHistory } from './history/ChatHistory'
import { Chat } from './index/Chat'
import { ChatLayout } from './layout/ChatLayout'
export const App: FC = () => {
return (
<HashRouter>
<Routes>
<Route index element={<Chat />} />
<Route element={<ChatLayout />}>
<Route index element={<Chat />} />
<Route path="history" element={<ChatHistory />} />
</Route>
</Routes>
</HashRouter>
)

View File

@@ -0,0 +1,185 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { MessageSquare, Trash2 } from 'lucide-react'
import { type FC, useMemo } from 'react'
import { Link } from 'react-router'
import {
type Conversation,
useConversations,
} from '@/lib/conversations/conversationStorage'
import { useChatSessionContext } from '../layout/ChatSessionContext'
dayjs.extend(relativeTime)
type TimeGroup = 'today' | 'thisWeek' | 'thisMonth' | 'older'
interface GroupedConversations {
today: Conversation[]
thisWeek: Conversation[]
thisMonth: Conversation[]
older: Conversation[]
}
const TIME_GROUP_LABELS: Record<TimeGroup, string> = {
today: 'Today',
thisWeek: 'This Week',
thisMonth: 'This Month',
older: 'Older',
}
const getTimeGroup = (timestamp: number): TimeGroup => {
const date = dayjs(timestamp)
const now = dayjs()
if (date.isSame(now, 'day')) return 'today'
if (date.isSame(now, 'week')) return 'thisWeek'
if (date.isSame(now, 'month')) return 'thisMonth'
return 'older'
}
const getLastUserMessage = (conversation: Conversation): string => {
const userMessages = conversation.messages.filter((m) => m.role === 'user')
const lastUserMessage = userMessages[userMessages.length - 1]
if (!lastUserMessage) return 'New conversation'
const textParts = lastUserMessage.parts.filter((p) => p.type === 'text')
const text = textParts.map((p) => p.text).join(' ')
return text || 'New conversation'
}
const ConversationItem: FC<{
conversation: Conversation
onDelete: (id: string) => void
isActive: boolean
}> = ({ conversation, onDelete, isActive }) => {
const label = getLastUserMessage(conversation)
const relativeTimeAgo = dayjs(conversation.lastMessagedAt).fromNow()
return (
<Link
to={`/?conversationId=${conversation.id}`}
className={`group flex w-full items-start gap-3 rounded-lg px-3 py-2.5 transition-colors hover:bg-muted/50 ${
isActive ? 'bg-muted/70' : ''
}`}
>
<div
className={`mt-0.5 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground'}`}
>
<MessageSquare className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<p className="truncate font-medium text-foreground text-sm">{label}</p>
<p className="text-muted-foreground text-xs">{relativeTimeAgo}</p>
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDelete(conversation.id)
}}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
title="Delete conversation"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</Link>
)
}
const ConversationGroup: FC<{
label: string
conversations: Conversation[]
onDelete: (id: string) => void
activeConversationId: string
}> = ({ label, conversations, onDelete, activeConversationId }) => {
if (conversations.length === 0) return null
return (
<div className="mb-4">
<h3 className="mb-2 px-3 font-semibold text-muted-foreground text-xs uppercase tracking-wider">
{label}
</h3>
<div className="space-y-1">
{conversations.map((conversation) => (
<ConversationItem
key={conversation.id}
conversation={conversation}
onDelete={onDelete}
isActive={conversation.id === activeConversationId}
/>
))}
</div>
</div>
)
}
export const ChatHistory: FC = () => {
const { conversations, removeConversation } = useConversations()
const { conversationId: activeConversationId } = useChatSessionContext()
const groupedConversations = useMemo<GroupedConversations>(() => {
const groups: GroupedConversations = {
today: [],
thisWeek: [],
thisMonth: [],
older: [],
}
for (const conversation of conversations) {
const group = getTimeGroup(conversation.lastMessagedAt)
groups[group].push(conversation)
}
return groups
}, [conversations])
const hasConversations = conversations.length > 0
return (
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
<div className="w-full p-3">
{!hasConversations ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/50" />
<p className="text-muted-foreground text-sm">
No conversations yet
</p>
<Link to="/" className="mt-2 text-primary text-sm hover:underline">
Start a new chat
</Link>
</div>
) : (
<>
<ConversationGroup
label={TIME_GROUP_LABELS.today}
conversations={groupedConversations.today}
onDelete={removeConversation}
activeConversationId={activeConversationId}
/>
<ConversationGroup
label={TIME_GROUP_LABELS.thisWeek}
conversations={groupedConversations.thisWeek}
onDelete={removeConversation}
activeConversationId={activeConversationId}
/>
<ConversationGroup
label={TIME_GROUP_LABELS.thisMonth}
conversations={groupedConversations.thisMonth}
onDelete={removeConversation}
activeConversationId={activeConversationId}
/>
<ConversationGroup
label={TIME_GROUP_LABELS.older}
conversations={groupedConversations.older}
onDelete={removeConversation}
activeConversationId={activeConversationId}
/>
</>
)}
</div>
</main>
)
}

View File

@@ -3,12 +3,11 @@ import { createBrowserOSAction } from '@/lib/chat-actions/types'
import { SIDEPANEL_AI_TRIGGERED_EVENT } from '@/lib/constants/analyticsEvents'
import { useJtbdPopup } from '@/lib/jtbd-popup/useJtbdPopup'
import { track } from '@/lib/metrics/track'
import { useChatSessionContext } from '../layout/ChatSessionContext'
import { ChatEmptyState } from './ChatEmptyState'
import { ChatError } from './ChatError'
import { ChatFooter } from './ChatFooter'
import { ChatHeader } from './ChatHeader'
import { ChatMessages } from './ChatMessages'
import { useChatSession } from './useChatSession'
/**
* @public
@@ -21,19 +20,14 @@ export const Chat = () => {
sendMessage,
status,
stop,
providers,
selectedProvider,
isLoading,
agentUrlError,
chatError,
handleSelectProvider,
getActionForMessage,
resetConversation,
liked,
onClickLike,
disliked,
onClickDislike,
} = useChatSession()
} = useChatSessionContext()
const {
popupVisible,
@@ -137,24 +131,8 @@ export const Chat = () => {
executeMessage(suggestion)
}
if (isLoading || !selectedProvider) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
)
}
return (
<div className="mx-auto flex h-screen w-screen flex-col bg-background text-foreground">
<ChatHeader
selectedProvider={selectedProvider}
onSelectProvider={handleSelectProvider}
providers={providers}
onNewConversation={resetConversation}
hasMessages={messages.length > 0}
/>
<>
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
{messages.length === 0 ? (
<ChatEmptyState
@@ -193,6 +171,6 @@ export const Chat = () => {
onToggleTab={toggleTabSelection}
onRemoveTab={removeTab}
/>
</div>
</>
)
}

View File

@@ -1,5 +1,6 @@
import { Github, Plus, SettingsIcon } from 'lucide-react'
import { Github, History, Plus, SettingsIcon } from 'lucide-react'
import type { FC } from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { ThemeToggle } from '@/components/elements/theme-toggle'
@@ -22,6 +23,15 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
onNewConversation,
hasMessages,
}) => {
const location = useLocation()
const navigate = useNavigate()
const isHistoryPage = location.pathname === '/history'
const handleNewConversationFromHistory = () => {
onNewConversation()
navigate('/')
}
return (
<header className="flex items-center justify-between border-border/40 border-b bg-background/80 px-3 py-2.5 backdrop-blur-md">
<div className="flex items-center gap-2">
@@ -52,7 +62,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
</div>
<div className="flex items-center gap-1">
{hasMessages && (
{!isHistoryPage && hasMessages && (
<button
type="button"
onClick={onNewConversation}
@@ -63,6 +73,25 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
</button>
)}
{isHistoryPage ? (
<button
type="button"
onClick={handleNewConversationFromHistory}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="New conversation"
>
<Plus className="h-4 w-4" />
</button>
) : (
<Link
to="/history"
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Chat history"
>
<History className="h-4 w-4" />
</Link>
)}
<a
href={productRepositoryUrl}
target="_blank"

View File

@@ -2,6 +2,7 @@ import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import { compact } from 'es-toolkit/array'
import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import useDeepCompareEffect from 'use-deep-compare-effect'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
@@ -13,6 +14,10 @@ import {
MESSAGE_SENT_EVENT,
PROVIDER_SELECTED_EVENT,
} from '@/lib/constants/analyticsEvents'
import {
conversationStorage,
useConversations,
} from '@/lib/conversations/conversationStorage'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { track } from '@/lib/metrics/track'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
@@ -69,6 +74,10 @@ export const useChatSession = () => {
error: agentUrlError,
} = useAgentServerUrl()
const { saveConversation } = useConversations()
const [searchParams, setSearchParams] = useSearchParams()
const conversationIdParam = searchParams.get('conversationId')
const agentUrlRef = useRef(agentServerUrl)
useEffect(() => {
@@ -87,7 +96,12 @@ export const useChatSession = () => {
)
const [liked, setLiked] = useState<Record<string, boolean>>({})
const [disliked, setDisliked] = useState<Record<string, boolean>>({})
const conversationIdRef = useRef(crypto.randomUUID())
const [conversationId, setConversationId] = useState(crypto.randomUUID())
const conversationIdRef = useRef(conversationId)
useEffect(() => {
conversationIdRef.current = conversationId
}, [conversationId])
const onClickLike = (messageId: string) => {
const { responseText, queryText } = getResponseAndQueryFromMessageId(
@@ -251,6 +265,35 @@ export const useChatSession = () => {
conversationId: conversationIdRef.current,
})
useEffect(() => {
if (!conversationIdParam) return
const restoreConversation = async () => {
const conversations = await conversationStorage.getValue()
const conversation = conversations?.find(
(c) => c.id === conversationIdParam,
)
if (conversation) {
setConversationId(
conversation.id as ReturnType<typeof crypto.randomUUID>,
)
setMessages(conversation.messages)
}
setSearchParams({}, { replace: true })
}
restoreConversation()
}, [conversationIdParam, setMessages, setSearchParams])
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run when messages change
useEffect(() => {
if (messages.length > 0) {
saveConversation(conversationIdRef.current, messages)
}
}, [messages])
const sendMessage = (params: { text: string; action?: ChatAction }) => {
track(MESSAGE_SENT_EVENT, {
mode,
@@ -300,7 +343,7 @@ export const useChatSession = () => {
track(CONVERSATION_RESET_EVENT, { message_count: messages.length })
stop()
const oldConversationId = conversationIdRef.current
conversationIdRef.current = crypto.randomUUID()
setConversationId(crypto.randomUUID())
setMessages([])
setTextToAction(new Map())
setLiked({})
@@ -332,5 +375,6 @@ export const useChatSession = () => {
onClickLike,
disliked,
onClickDislike,
conversationId,
}
}

View File

@@ -0,0 +1,49 @@
import type { FC } from 'react'
import { Outlet } from 'react-router'
import { ChatHeader } from '../index/ChatHeader'
import {
ChatSessionProvider,
useChatSessionContext,
} from './ChatSessionContext'
const ChatLayoutContent: FC = () => {
const {
providers,
selectedProvider,
handleSelectProvider,
resetConversation,
messages,
isLoading,
} = useChatSessionContext()
if (isLoading || !selectedProvider) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
)
}
return (
<div className="mx-auto flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<ChatHeader
selectedProvider={selectedProvider}
onSelectProvider={handleSelectProvider}
providers={providers}
onNewConversation={resetConversation}
hasMessages={messages.length > 0}
/>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Outlet />
</div>
</div>
)
}
export const ChatLayout: FC = () => {
return (
<ChatSessionProvider>
<ChatLayoutContent />
</ChatSessionProvider>
)
}

View File

@@ -0,0 +1,27 @@
import { createContext, type FC, type ReactNode, useContext } from 'react'
import { useChatSession } from '../index/useChatSession'
type ChatSessionContextValue = ReturnType<typeof useChatSession>
const ChatSessionContext = createContext<ChatSessionContextValue | null>(null)
export const ChatSessionProvider: FC<{ children: ReactNode }> = ({
children,
}) => {
const session = useChatSession()
return (
<ChatSessionContext.Provider value={session}>
{children}
</ChatSessionContext.Provider>
)
}
export const useChatSessionContext = () => {
const context = useContext(ChatSessionContext)
if (!context) {
throw new Error(
'useChatSessionContext must be used within a ChatSessionProvider',
)
}
return context
}

View File

@@ -0,0 +1,78 @@
import { storage } from '@wxt-dev/storage'
import type { UIMessage } from 'ai'
import { useEffect, useState } from 'react'
const MAX_CONVERSATIONS = 50
export interface Conversation {
id: string
messages: UIMessage[]
lastMessagedAt: number
}
export const conversationStorage = storage.defineItem<Conversation[]>(
'local:conversations',
{
fallback: [],
},
)
export function useConversations() {
const [conversations, setConversations] = useState<Conversation[]>([])
useEffect(() => {
conversationStorage.getValue().then(setConversations)
const unwatch = conversationStorage.watch((newValue) => {
setConversations(newValue ?? [])
})
return unwatch
}, [])
const removeConversation = async (id: string) => {
const current = (await conversationStorage.getValue()) ?? []
await conversationStorage.setValue(current.filter((c) => c.id !== id))
}
const saveConversation = async (id: string, messages: UIMessage[]) => {
const current = (await conversationStorage.getValue()) ?? []
const existingIndex = current.findIndex((c) => c.id === id)
if (existingIndex >= 0) {
const existing = current[existingIndex]
const hasContentChanged =
existing.messages.length !== messages.length ||
JSON.stringify(existing.messages) !== JSON.stringify(messages)
if (!hasContentChanged) return
current[existingIndex] = {
...existing,
messages,
lastMessagedAt: Date.now(),
}
await conversationStorage.setValue(current)
} else {
const newConversation: Conversation = {
id,
messages,
lastMessagedAt: Date.now(),
}
let updated = [newConversation, ...current]
if (updated.length > MAX_CONVERSATIONS) {
updated = updated.slice(0, MAX_CONVERSATIONS)
}
await conversationStorage.setValue(updated)
}
}
const getConversation = (id: string) => {
return conversations.find((c) => c.id === id)
}
return {
conversations,
removeConversation,
saveConversation,
getConversation,
}
}