diff --git a/ui/src/components/ChatBookmarkList.tsx b/ui/src/components/ChatBookmarkList.tsx new file mode 100644 index 00000000..9c18b459 --- /dev/null +++ b/ui/src/components/ChatBookmarkList.tsx @@ -0,0 +1,64 @@ +import { Bookmark } from "lucide-react"; +import { useChatBookmarks } from "../hooks/useChatBookmarks"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface ChatBookmarkListProps { + companyId: string; + onNavigate: (conversationId: string, messageId: string) => void; +} + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDays = Math.floor(diffHr / 24); + if (diffDays < 30) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +export function ChatBookmarkList({ companyId, onNavigate }: ChatBookmarkListProps) { + const { data, isLoading } = useChatBookmarks(companyId); + const bookmarks = data?.items ?? []; + + return ( + +
+ {isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + )) + ) : bookmarks.length === 0 ? ( +
+ +

No bookmarks yet

+
+ ) : ( + bookmarks.map((bookmark) => ( + + )) + )} +
+
+ ); +} diff --git a/ui/src/components/ChatBranchSelector.tsx b/ui/src/components/ChatBranchSelector.tsx new file mode 100644 index 00000000..e34212b4 --- /dev/null +++ b/ui/src/components/ChatBranchSelector.tsx @@ -0,0 +1,62 @@ +import { GitBranch } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { ChatConversation } from "@paperclipai/shared"; + +interface ChatBranchSelectorProps { + conversationId: string; + branches: ChatConversation[]; + activeBranchId: string | null; + onSelectBranch: (id: string) => void; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +export function ChatBranchSelector({ + conversationId, + branches, + activeBranchId, + onSelectBranch, +}: ChatBranchSelectorProps) { + if (branches.length === 0) return null; + + return ( +
+ + Branch: + + {/* Original conversation */} + + + {branches.map((branch, index) => ( + + ))} +
+ ); +} diff --git a/ui/src/components/ChatMessageBookmark.tsx b/ui/src/components/ChatMessageBookmark.tsx new file mode 100644 index 00000000..53d367e3 --- /dev/null +++ b/ui/src/components/ChatMessageBookmark.tsx @@ -0,0 +1,35 @@ +import { Bookmark } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface ChatMessageBookmarkProps { + messageId: string; + conversationId: string; + isBookmarked: boolean; + onToggle: () => void; +} + +export function ChatMessageBookmark({ isBookmarked, onToggle }: ChatMessageBookmarkProps) { + return ( + + + + + {isBookmarked ? "Remove bookmark" : "Bookmark message"} + + ); +} diff --git a/ui/src/components/ChatSearchDialog.tsx b/ui/src/components/ChatSearchDialog.tsx new file mode 100644 index 00000000..af3d9e48 --- /dev/null +++ b/ui/src/components/ChatSearchDialog.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { Search } from "lucide-react"; +import { useChatSearch } from "../hooks/useChatSearch"; +import { + CommandDialog, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Command } from "cmdk"; + +interface ChatSearchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + companyId: string | null; + onNavigate: (conversationId: string, messageId: string) => void; +} + +function stripMarkdown(text: string): string { + return text + .replace(/```[\s\S]*?```/g, "") + .replace(/`[^`]+`/g, "") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/#{1,6}\s/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/>\s/g, "") + .trim(); +} + +/** Split text into segments, marking portions that match the query terms */ +function splitWithHighlight(text: string, query: string): Array<{ text: string; highlight: boolean }> { + if (!query.trim()) return [{ text, highlight: false }]; + const terms = query.trim().split(/\s+/).filter(Boolean); + const pattern = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); + const re = new RegExp(`(${pattern})`, "gi"); + const parts = text.split(re); + return parts.map((part) => ({ + text: part, + highlight: re.test(part), + })); +} + +function HighlightedText({ text, query }: { text: string; query: string }) { + const segments = splitWithHighlight(text, query); + return ( + <> + {segments.map((seg, i) => + seg.highlight ? ( + + {seg.text} + + ) : ( + {seg.text} + ), + )} + + ); +} + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDays = Math.floor(diffHr / 24); + if (diffDays < 30) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +export function ChatSearchDialog({ open, onOpenChange, companyId, onNavigate }: ChatSearchDialogProps) { + const [query, setQuery] = useState(""); + const { data } = useChatSearch(companyId, query); + const results = data?.items ?? []; + + function handleSelect(conversationId: string, messageId: string) { + onNavigate(conversationId, messageId); + onOpenChange(false); + setQuery(""); + } + + return ( + { + onOpenChange(v); + if (!v) setQuery(""); + }} + title="Search messages" + description="Search all messages across conversations" + > + + + + {query.trim().length >= 2 && results.length === 0 && ( + No results found. + )} + {results.map((result) => { + const snippet = stripMarkdown(result.content).slice(0, 120); + return ( + handleSelect(result.conversationId, result.messageId)} + className="flex flex-col items-start gap-0.5 py-2" + > +
+ + + {result.conversationTitle ?? "Untitled conversation"} + + + {result.role} + + + {formatRelativeTime(result.createdAt)} + +
+

+ +

+
+ ); + })} +
+
+
+ ); +}