Skip to content
Open
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
36 changes: 34 additions & 2 deletions components/chat/ChatContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"use client";

import React from "react";
import { Menu } from "lucide-react";
import React, { useCallback, useState } from "react";
import { useChat } from "@/context/ChatProvider";
import { useAuth } from "@/context/AuthProvider";
import ChatHeader from "./ChatHeader";
import MainChatArea from "./MainChatArea";
import Sidebar from "./Sidebar";
import ChatSearchOverlay from "./ChatSearchOverlay";

/**
* Main layout container and orchestration component
Expand All @@ -27,6 +27,7 @@ const ChatContainer: React.FC<ChatContainerProps> = ({
isQrModalOpen,
}) => {
const { isAuthenticated } = useAuth();
const [isChatSearchOpen, setIsChatSearchOpen] = useState(false);
const {
// UI State
isSidebarOpen,
Expand All @@ -52,6 +53,26 @@ const ChatContainer: React.FC<ChatContainerProps> = ({
isSyncing,
} = useChat();

const openChatSearch = useCallback(() => {
setIsChatSearchOpen(true);
}, []);

const closeChatSearch = useCallback(() => {
setIsChatSearchOpen(false);
}, []);

const handleSelectConversationFromSearch = useCallback(
(conversationId: string) => {
loadConversation(conversationId);
setIsChatSearchOpen(false);

if (isMobile) {
setIsSidebarOpen(false);
}
},
[isMobile, loadConversation, setIsSidebarOpen]
);

return (
<div
className={`flex h-dvh w-full bg-background text-foreground overflow-hidden`}
Expand All @@ -78,6 +99,7 @@ const ChatContainer: React.FC<ChatContainerProps> = ({
conversations={conversations}
activeConversationId={activeConversationId}
createNewConversation={startNewConversation}
openSearchOverlay={openChatSearch}
loadConversation={loadConversation}
deleteConversation={deleteConversation}
setIsSettingsOpen={setIsSettingsOpen}
Expand All @@ -100,6 +122,16 @@ const ChatContainer: React.FC<ChatContainerProps> = ({
{/* Main Chat Content */}
<MainChatArea />
</div>

{isAuthenticated && (
<ChatSearchOverlay
open={isChatSearchOpen}
onClose={closeChatSearch}
conversations={conversations}
activeConversationId={activeConversationId}
onSelectConversation={handleSelectConversationFromSearch}
/>
)}
</div>
);
};
Expand Down
254 changes: 254 additions & 0 deletions components/chat/ChatSearchOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
"use client";

import React, {
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { MessageSquareText, Search } from "lucide-react";
import CloseButton from "@/components/ui/CloseButton";
import { ModalShell } from "@/components/ui/ModalShell";
import { cn } from "@/lib/utils";
import { Conversation } from "@/types/chat";
import { getTextFromContent } from "@/utils/messageUtils";

interface ChatSearchOverlayProps {
open: boolean;
onClose: () => void;
conversations: Conversation[];
activeConversationId: string | null;
onSelectConversation: (conversationId: string) => void;
}

interface SearchResult {
conversation: Conversation;
snippet: string;
matchesTitle: boolean;
}

const normalizeText = (value: string): string =>
value.replace(/\s+/g, " ").trim();

const truncateText = (value: string, maxLength = 140): string => {
if (value.length <= maxLength) {
return value;
}

return `${value.slice(0, maxLength).trimEnd()}...`;
};

const createSnippet = (value: string, query: string): string => {
const normalizedValue = normalizeText(value);
if (!normalizedValue) {
return "No searchable messages in this chat yet.";
}

const normalizedQuery = normalizeText(query).toLowerCase();
if (!normalizedQuery) {
return truncateText(normalizedValue);
}

const lowerValue = normalizedValue.toLowerCase();
const matchIndex = lowerValue.indexOf(normalizedQuery);
if (matchIndex === -1) {
return truncateText(normalizedValue);
}

const start = Math.max(0, matchIndex - 48);
const end = Math.min(
normalizedValue.length,
matchIndex + normalizedQuery.length + 92
);

return `${start > 0 ? "..." : ""}${normalizedValue.slice(start, end)}${
end < normalizedValue.length ? "..." : ""
}`;
};

const getSearchableMessages = (conversation: Conversation): string[] =>
conversation.messages
.map((message) => normalizeText(getTextFromContent(message.content)))
.filter(Boolean);

const getDefaultSnippet = (searchableMessages: string[]): string => {
const latestMessage = searchableMessages[searchableMessages.length - 1];

if (latestMessage) {
return truncateText(latestMessage);
}

return "No searchable messages in this chat yet.";
};

export default function ChatSearchOverlay({
open,
onClose,
conversations,
activeConversationId,
onSelectConversation,
}: ChatSearchOverlayProps) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (!open) {
return;
}

setQuery("");

const timerId = window.setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);

const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};

window.addEventListener("keydown", handleEscape);

return () => {
window.clearTimeout(timerId);
window.removeEventListener("keydown", handleEscape);
};
}, [open, onClose]);

const results = useMemo<SearchResult[]>(() => {
const normalizedQuery = normalizeText(deferredQuery).toLowerCase();

return conversations
.map((conversation) => {
const normalizedTitle = normalizeText(conversation.title);
const searchableMessages = getSearchableMessages(conversation);

if (!normalizedQuery) {
return {
conversation,
snippet: getDefaultSnippet(searchableMessages),
matchesTitle: false,
};
}

const matchesTitle = normalizedTitle.toLowerCase().includes(
normalizedQuery
);
const matchingMessage = searchableMessages.find((message) =>
message.toLowerCase().includes(normalizedQuery)
);

if (!matchesTitle && !matchingMessage) {
return null;
}

return {
conversation,
snippet: createSnippet(matchingMessage ?? normalizedTitle, deferredQuery),
matchesTitle,
};
})
.filter((result): result is SearchResult => result !== null);
}, [conversations, deferredQuery]);

const resultCountLabel =
deferredQuery.trim().length > 0
? `${results.length} match${results.length === 1 ? "" : "es"}`
: `${conversations.length} chat${conversations.length === 1 ? "" : "s"}`;

return (
<ModalShell
open={open}
onClose={onClose}
closeOnOverlayClick
overlayClassName="bg-black/70 backdrop-blur-sm z-[80] p-4"
contentClassName="w-full max-w-2xl overflow-hidden rounded-2xl border border-sidebar-border bg-background shadow-2xl"
contentAriaLabel="Search chats"
>
<div className="flex items-center gap-3 border-b border-sidebar-border px-4 py-4">
<div className="rounded-full border border-sidebar-border bg-sidebar-accent/30 p-2 text-muted-foreground">
<Search className="h-4 w-4" />
</div>
<div className="flex-1">
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && results[0]) {
event.preventDefault();
onSelectConversation(results[0].conversation.id);
}
}}
placeholder="Search by title or message text"
className="w-full bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
aria-label="Search chats by title or message text"
/>
<p className="mt-1 text-xs text-muted-foreground">{resultCountLabel}</p>
</div>
<CloseButton
onClick={onClose}
className="rounded-full border border-sidebar-border bg-sidebar-accent/20 p-2 text-muted-foreground transition-colors hover:bg-sidebar-accent/40 hover:text-foreground"
iconClassName="h-4 w-4"
ariaLabel="Close chat search"
/>
</div>

<div className="max-h-[min(70vh,640px)] overflow-y-auto p-2">
{results.length === 0 ? (
<div className="flex min-h-56 flex-col items-center justify-center px-6 text-center">
<div className="rounded-full border border-sidebar-border bg-sidebar-accent/20 p-3 text-muted-foreground">
<MessageSquareText className="h-5 w-5" />
</div>
<p className="mt-4 text-sm font-medium text-foreground">
{conversations.length === 0 && deferredQuery.trim().length === 0
? "No saved chats yet."
: "No chats matched your search."}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{conversations.length === 0 && deferredQuery.trim().length === 0
? "Start a conversation and it will show up here once it is saved."
: "Try a chat title, prompt text, or part of a reply."}
</p>
</div>
) : (
results.map(({ conversation, snippet, matchesTitle }) => {
const isActive = activeConversationId === conversation.id;

return (
<button
key={conversation.id}
type="button"
onClick={() => onSelectConversation(conversation.id)}
className={cn(
"w-full rounded-xl border px-4 py-3 text-left transition-colors",
isActive
? "border-sidebar-border bg-sidebar-accent/70"
: "border-transparent hover:border-sidebar-border hover:bg-sidebar-accent/30"
)}
>
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-medium text-foreground">
{conversation.title || "Untitled chat"}
</p>
<div className="shrink-0 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
{isActive
? "Open now"
: matchesTitle
? "Title match"
: "Message match"}
</div>
</div>
<p className="mt-1 text-sm text-muted-foreground">{snippet}</p>
</button>
);
})
)}
</div>
</ModalShell>
);
}
18 changes: 17 additions & 1 deletion components/chat/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import {
ChevronDown,
PlusCircle,
Settings,
Trash2,
X,
Key,
SquarePen,
RefreshCw,
Search,
} from "lucide-react";
import { Conversation } from "@/types/chat";

Expand All @@ -20,6 +20,7 @@ interface SidebarProps {
conversations: Conversation[];
activeConversationId: string | null;
createNewConversation: () => void;
openSearchOverlay: () => void;
loadConversation: (id: string) => void;
deleteConversation: (id: string, e: React.MouseEvent) => Promise<void>;
setIsSettingsOpen: (isOpen: boolean) => void;
Expand All @@ -41,6 +42,7 @@ export default function Sidebar({
conversations,
activeConversationId,
createNewConversation,
openSearchOverlay,
loadConversation,
deleteConversation,
setIsSettingsOpen,
Expand Down Expand Up @@ -105,6 +107,20 @@ export default function Sidebar({
)}
</div>

<div className="px-4 pb-2">
<button
onClick={() => {
openSearchOverlay();
if (isMobile) setIsSidebarOpen(false);
}}
className="w-full flex items-center gap-2 text-sidebar-foreground/80 hover:text-sidebar-foreground bg-sidebar-accent/20 hover:bg-sidebar-accent/40 border border-sidebar-border rounded-md py-2 px-3 h-[36px] text-sm transition-colors cursor-pointer"
aria-label="Search chats"
>
<Search className="h-4 w-4" />
<span>Search chats</span>
</button>
</div>

{/* Conversations List */}
<div className="flex-1 overflow-y-auto px-4 py-2 space-y-2">
<div className="flex items-center justify-between px-2 pb-2">
Expand Down