From be01c1d1a96cf8cc0c57789e55fe0d15e612a370 Mon Sep 17 00:00:00 2001 From: Dani Akash Date: Fri, 16 Jan 2026 01:21:09 +0530 Subject: [PATCH] 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 --- apps/agent/entrypoints/sidepanel/App.tsx | 7 +- .../sidepanel/history/ChatHistory.tsx | 185 ++++++++++++++++++ .../entrypoints/sidepanel/index/Chat.tsx | 30 +-- .../sidepanel/index/ChatHeader.tsx | 33 +++- .../sidepanel/index/useChatSession.ts | 48 ++++- .../sidepanel/layout/ChatLayout.tsx | 49 +++++ .../sidepanel/layout/ChatSessionContext.tsx | 27 +++ .../lib/conversations/conversationStorage.ts | 78 ++++++++ 8 files changed, 426 insertions(+), 31 deletions(-) create mode 100644 apps/agent/entrypoints/sidepanel/history/ChatHistory.tsx create mode 100644 apps/agent/entrypoints/sidepanel/layout/ChatLayout.tsx create mode 100644 apps/agent/entrypoints/sidepanel/layout/ChatSessionContext.tsx create mode 100644 apps/agent/lib/conversations/conversationStorage.ts diff --git a/apps/agent/entrypoints/sidepanel/App.tsx b/apps/agent/entrypoints/sidepanel/App.tsx index 20958e77..fbac7045 100644 --- a/apps/agent/entrypoints/sidepanel/App.tsx +++ b/apps/agent/entrypoints/sidepanel/App.tsx @@ -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 ( - } /> + }> + } /> + } /> + ) diff --git a/apps/agent/entrypoints/sidepanel/history/ChatHistory.tsx b/apps/agent/entrypoints/sidepanel/history/ChatHistory.tsx new file mode 100644 index 00000000..91b90dfe --- /dev/null +++ b/apps/agent/entrypoints/sidepanel/history/ChatHistory.tsx @@ -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 = { + 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 ( + +
+ +
+
+

{label}

+

{relativeTimeAgo}

+
+ + + ) +} + +const ConversationGroup: FC<{ + label: string + conversations: Conversation[] + onDelete: (id: string) => void + activeConversationId: string +}> = ({ label, conversations, onDelete, activeConversationId }) => { + if (conversations.length === 0) return null + + return ( +
+

+ {label} +

+
+ {conversations.map((conversation) => ( + + ))} +
+
+ ) +} + +export const ChatHistory: FC = () => { + const { conversations, removeConversation } = useConversations() + const { conversationId: activeConversationId } = useChatSessionContext() + + const groupedConversations = useMemo(() => { + 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 ( +
+
+ {!hasConversations ? ( +
+ +

+ No conversations yet +

+ + Start a new chat + +
+ ) : ( + <> + + + + + + )} +
+
+ ) +} diff --git a/apps/agent/entrypoints/sidepanel/index/Chat.tsx b/apps/agent/entrypoints/sidepanel/index/Chat.tsx index 3ecc54b7..87551d3a 100644 --- a/apps/agent/entrypoints/sidepanel/index/Chat.tsx +++ b/apps/agent/entrypoints/sidepanel/index/Chat.tsx @@ -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 ( -
-
-
- ) - } - return ( -
- 0} - /> - + <>
{messages.length === 0 ? ( { onToggleTab={toggleTabSelection} onRemoveTab={removeTab} /> -
+ ) } diff --git a/apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx b/apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx index 95d80e97..93a73639 100644 --- a/apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx +++ b/apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx @@ -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 = ({ onNewConversation, hasMessages, }) => { + const location = useLocation() + const navigate = useNavigate() + const isHistoryPage = location.pathname === '/history' + + const handleNewConversationFromHistory = () => { + onNewConversation() + navigate('/') + } + return (
@@ -52,7 +62,7 @@ export const ChatHeader: FC = ({
- {hasMessages && ( + {!isHistoryPage && hasMessages && ( )} + {isHistoryPage ? ( + + ) : ( + + + + )} + { 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>({}) const [disliked, setDisliked] = useState>({}) - 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, + ) + 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, } } diff --git a/apps/agent/entrypoints/sidepanel/layout/ChatLayout.tsx b/apps/agent/entrypoints/sidepanel/layout/ChatLayout.tsx new file mode 100644 index 00000000..eadc1591 --- /dev/null +++ b/apps/agent/entrypoints/sidepanel/layout/ChatLayout.tsx @@ -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 ( +
+
+
+ ) + } + + return ( +
+ 0} + /> +
+ +
+
+ ) +} + +export const ChatLayout: FC = () => { + return ( + + + + ) +} diff --git a/apps/agent/entrypoints/sidepanel/layout/ChatSessionContext.tsx b/apps/agent/entrypoints/sidepanel/layout/ChatSessionContext.tsx new file mode 100644 index 00000000..f9ccb114 --- /dev/null +++ b/apps/agent/entrypoints/sidepanel/layout/ChatSessionContext.tsx @@ -0,0 +1,27 @@ +import { createContext, type FC, type ReactNode, useContext } from 'react' +import { useChatSession } from '../index/useChatSession' + +type ChatSessionContextValue = ReturnType + +const ChatSessionContext = createContext(null) + +export const ChatSessionProvider: FC<{ children: ReactNode }> = ({ + children, +}) => { + const session = useChatSession() + return ( + + {children} + + ) +} + +export const useChatSessionContext = () => { + const context = useContext(ChatSessionContext) + if (!context) { + throw new Error( + 'useChatSessionContext must be used within a ChatSessionProvider', + ) + } + return context +} diff --git a/apps/agent/lib/conversations/conversationStorage.ts b/apps/agent/lib/conversations/conversationStorage.ts new file mode 100644 index 00000000..adf7a043 --- /dev/null +++ b/apps/agent/lib/conversations/conversationStorage.ts @@ -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( + 'local:conversations', + { + fallback: [], + }, +) + +export function useConversations() { + const [conversations, setConversations] = useState([]) + + 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, + } +}