Feat: UI fixes in graph

This commit is contained in:
Harshith Mullapudi 2025-07-11 09:10:23 +05:30
parent 26040ffb74
commit 50c4e2bcce
25 changed files with 276 additions and 229 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,13 +1,6 @@
import { useFetcher, useNavigate } from "@remix-run/react";
import { useEffect, useState, useCallback, useRef } from "react";
import {
List,
AutoSizer,
InfiniteLoader,
type ListRowRenderer,
} from "react-virtualized";
import { format } from "date-fns";
import { MessageSquare, Clock, Plus } from "lucide-react";
import { AutoSizer, List, type ListRowRenderer } from "react-virtualized";
import { cn } from "~/lib/utils";
import { Button } from "../ui";
@ -40,10 +33,8 @@ type ConversationListResponse = {
export const ConversationList = ({
currentConversationId,
showNewConversationCTA,
}: {
currentConversationId?: string;
showNewConversationCTA?: boolean;
}) => {
const fetcher = useFetcher<ConversationListResponse>();
const navigate = useNavigate();
@ -51,8 +42,9 @@ export const ConversationList = ({
const [currentPage, setCurrentPage] = useState(1);
const [hasNextPage, setHasNextPage] = useState(true);
const [isLoading, setIsLoading] = useState(false);
// const [searchTerm, setSearchTerm] = useState("");
// const searchTimeoutRef = useRef<NodeJS.Timeout>();
// Prevent duplicate conversations when paginating
const loadedConversationIds = useRef<Set<string>>(new Set());
const loadMoreConversations = useCallback(
(page: number) => {
@ -61,89 +53,75 @@ export const ConversationList = ({
setIsLoading(true);
const searchParams = new URLSearchParams({
page: page.toString(),
limit: "25",
limit: "5", // Increased for better density
});
fetcher.load(`/api/v1/conversations?${searchParams}`, {
flushSync: true,
});
},
[isLoading, fetcher, currentPage],
[isLoading, fetcher],
);
// Initial load
useEffect(() => {
loadMoreConversations(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Handle fetcher response
useEffect(() => {
if (fetcher.data && fetcher.state === "idle") {
setIsLoading(false);
const response = fetcher.data;
if (currentPage === 1) {
setConversations(response.conversations);
} else {
setConversations((prev) => [...prev, ...response.conversations]);
}
// Prevent duplicate conversations
const newConversations = response.conversations.filter(
(c) => !loadedConversationIds.current.has(c.id),
);
newConversations.forEach((c) => loadedConversationIds.current.add(c.id));
setConversations((prev) => [...prev, ...newConversations]);
setHasNextPage(response.pagination.hasNext);
setCurrentPage(response.pagination.page);
}
}, [fetcher.data, fetcher.state, currentPage]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data, fetcher.state]);
// const handleSearch = useCallback(
// (term: string) => {
// setSearchTerm(term);
// setCurrentPage(1);
// setConversations([]);
// setHasNextPage(true);
// if (searchTimeoutRef.current) {
// clearTimeout(searchTimeoutRef.current);
// }
// searchTimeoutRef.current = setTimeout(() => {
// loadMoreConversations(1);
// }, 300);
// },
// [loadMoreConversations],
// );
const isRowLoaded = useCallback(
({ index }: { index: number }) => {
return !!conversations[index];
},
[conversations],
);
const loadMoreRows = useCallback(() => {
if (!hasNextPage || isLoading) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
if (conversations.length === 25) {
const nextPage = currentPage + 1;
loadMoreConversations(nextPage);
const checkLoaded = () => {
if (!isLoading) {
resolve();
} else {
setTimeout(checkLoaded, 100);
}
};
checkLoaded();
}
});
}, [
hasNextPage,
isLoading,
currentPage,
loadMoreConversations,
conversations,
]);
// The row count is conversations.length + 1 if hasNextPage, else just conversations.length
const rowCount = hasNextPage
? conversations.length + 1
: conversations.length;
const rowRenderer: ListRowRenderer = useCallback(
({ index, key, style }) => {
// If this is the last row and hasNextPage, show the Load More button
if (hasNextPage && index === conversations.length) {
return (
<div
key={key}
style={style}
className="-ml-1 flex items-center justify-start p-2 py-0 text-sm"
>
<Button
variant="link"
onClick={() => loadMoreConversations(currentPage + 1)}
disabled={isLoading}
className="w-fit underline underline-offset-4"
>
{isLoading ? (
<>
<div className="border-primary mr-2 h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
Loading...
</>
) : (
"Load More"
)}
</Button>
</div>
);
}
const conversation = conversations[index];
if (!conversation) {
@ -158,16 +136,21 @@ export const ConversationList = ({
return (
<div key={key} style={style}>
<div className="p-1">
<div className="px-1 pr-2">
<Button
variant="ghost"
className={cn(
"border-border h-auto w-full justify-start p-2 text-left",
currentConversationId === conversation.id && "bg-grayAlpha-100",
"border-border h-auto w-full justify-start rounded p-2 py-1 text-left",
currentConversationId === conversation.id &&
"bg-accent text-accent-foreground font-semibold",
)}
onClick={() => {
window.location.href = `/home/conversation/${conversation.id}`;
navigate(`/home/conversation/${conversation.id}`);
}}
tabIndex={0}
aria-current={
currentConversationId === conversation.id ? "page" : undefined
}
>
<div className="flex w-full items-start space-x-3">
<div className="min-w-0 flex-1">
@ -175,11 +158,6 @@ export const ConversationList = ({
<p className={cn("truncate font-normal")}>
{conversation.title || "Untitled Conversation"}
</p>
<div className="text-muted-foreground flex items-center space-x-1 text-xs">
<span>
{format(new Date(conversation.updatedAt), "MMM d")}
</span>
</div>
</div>
</div>
</div>
@ -188,62 +166,32 @@ export const ConversationList = ({
</div>
);
},
[conversations],
[
conversations,
currentConversationId,
hasNextPage,
isLoading,
currentPage,
loadMoreConversations,
navigate,
],
);
const rowCount = hasNextPage
? conversations.length + 1
: conversations.length;
return (
<div className="flex h-full flex-col">
{showNewConversationCTA && (
<div className="flex items-center justify-start p-1 pb-0">
<Button
variant="ghost"
className="w-full justify-start gap-2 py-4"
onClick={() => {
navigate("/home/conversation");
}}
>
<Plus size={14} /> New conversation
</Button>
</div>
)}
{/* <div className="border-b">
<Input
type="text"
placeholder="Search conversations..."
className="focus:ring-primary w-full rounded-none px-3 py-2 focus:ring-2 focus:outline-none"
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
/>
</div> */}
<div className="flex-1 overflow-hidden">
<InfiniteLoader
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={rowCount}
threshold={5}
>
{({ onRowsRendered, registerChild }) => (
<AutoSizer>
{({ height, width }) => (
<List
ref={registerChild}
height={height}
width={width}
rowCount={rowCount}
rowHeight={40}
onRowsRendered={onRowsRendered}
rowRenderer={rowRenderer}
overscanRowCount={5}
/>
)}
</AutoSizer>
<div className="flex h-full flex-col pt-1 pl-1">
<div className="grow overflow-hidden">
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
rowCount={rowCount}
rowHeight={36} // Slightly taller for better click area
rowRenderer={rowRenderer}
overscanRowCount={5}
/>
)}
</InfiniteLoader>
</AutoSizer>
</div>
{isLoading && conversations.length === 0 && (

View File

@ -8,7 +8,7 @@ import { EditorContent, Placeholder, EditorRoot } from "novel";
import { useCallback, useState } from "react";
import { cn } from "~/lib/utils";
import { Button } from "../ui";
import { Loader } from "lucide-react";
import { Loader, LoaderCircle } from "lucide-react";
import { Form, useSubmit } from "@remix-run/react";
interface ConversationTextareaProps {
@ -159,7 +159,7 @@ export function ConversationTextarea({
>
{isLoading ? (
<>
<Loader size={18} className="mr-1 animate-spin" />
<LoaderCircle size={18} className="mr-1 animate-spin" />
Stop
</>
) : (

View File

@ -46,17 +46,7 @@ export const ConversationNew = ({
);
return (
<ResizablePanelGroup direction="horizontal" className="bg-background-2">
<ResizablePanel
maxSize={50}
defaultSize={16}
minSize={16}
collapsible
collapsedSize={16}
className="border-border h-[calc(100vh_-_60px)] min-w-[200px] border-r-1"
>
<ConversationList />
</ResizablePanel>
<ResizablePanelGroup direction="horizontal" className="rounded-md">
<ResizableHandle className="w-1" />
<ResizablePanel
@ -70,7 +60,7 @@ export const ConversationNew = ({
onSubmit={(e) => submitForm(e)}
className="pt-2"
>
<div className={cn("flex h-[calc(100vh_-_60px)] flex-col")}>
<div className={cn("flex h-[calc(100vh_-_56px)] flex-col")}>
<div className="flex h-full w-full flex-col items-start justify-start overflow-y-auto p-4">
<div className="flex w-full flex-col items-center">
<div className="w-full max-w-[90ch]">

View File

@ -134,8 +134,6 @@ export const extensionsForConversation = [
tiptapLink,
horizontalRule,
heading,
AIHighlight,
HighlightExtension,
Table.configure({
resizable: true,
}),

View File

@ -310,7 +310,7 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
...settings,
barnesHutOptimize: true,
strongGravityMode: false,
gravity: 0.05,
gravity: 0.1,
scalingRatio: 10,
slowDown: 5,
},

View File

@ -13,6 +13,7 @@ import { NavMain } from "./nav-main";
import { useUser } from "~/hooks/useUser";
import { NavUser } from "./nav-user";
import Logo from "../logo/logo";
import { ConversationList } from "../conversation";
const data = {
navMain: [
@ -44,24 +45,29 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar
collapsible="none"
variant="inset"
{...props}
className="bg-background h-[100vh] w-[calc(var(--sidebar-width-icon)+1px)]! py-2"
className="bg-background h-[100vh] py-2"
>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div className="mt-1 flex w-full items-center justify-center">
<div className="mt-1 flex w-full items-center justify-start gap-2">
<Logo width={20} height={20} />
C.O.R.E.
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<div className="mt-4 flex h-full flex-col">
<h2 className="text-muted-foreground px-4 text-sm"> History </h2>
<ConversationList />
</div>
</SidebarContent>
<SidebarFooter className="p-0">
<SidebarFooter className="px-2">
<NavUser user={user} />
</SidebarFooter>
</Sidebar>

View File

@ -7,6 +7,7 @@ import {
SidebarMenuItem,
} from "../ui/sidebar";
import { useLocation, useNavigate } from "@remix-run/react";
import { Button } from "../ui";
export const NavMain = ({
items,
@ -23,20 +24,22 @@ export const NavMain = ({
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenu className="gap-0.5">
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
<Button
isActive={location.pathname.includes(item.url)}
className={cn(
"bg-grayAlpha-100 w-fit gap-1 !rounded-md",
location.pathname.includes(item.url) &&
"!bg-grayAlpha-100 hover:bg-grayAlpha-100!",
"!bg-accent !text-accent-foreground",
)}
onClick={() => navigate(item.url)}
variant="ghost"
>
{item.icon && <item.icon />}
</SidebarMenuButton>
{item.icon && <item.icon size={16} />}
{item.title}
</Button>
</SidebarMenuItem>
))}
</SidebarMenu>

View File

@ -26,26 +26,10 @@ export function NavUser({ user }: { user: User }) {
return (
<SidebarMenu>
<SidebarMenuItem className="mb-2 flex justify-center">
<Button
variant="ghost"
isActive={location.pathname.includes("settings")}
className={cn(
location.pathname.includes("settings") &&
"!bg-grayAlpha-100 hover:bg-grayAlpha-100!",
)}
onClick={() => navigate("/settings")}
>
<Settings size={18} />
</Button>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuItem className="flex justify-between">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground mb-2 gap-2 px-3"
>
<Button variant="link" className="mb-2 ml-2 gap-2 px-0">
<AvatarText
text={user.name ?? "User"}
className="h-6 w-6 rounded"
@ -55,7 +39,7 @@ export function NavUser({ user }: { user: User }) {
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "top"}
align="end"
align="start"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
@ -71,7 +55,14 @@ export function NavUser({ user }: { user: User }) {
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex gap-2"
onClick={() => (window.location.href = "/logout")}
onClick={() => navigate("/settings")}
>
<Settings size={16} />
Settings
</DropdownMenuItem>
<DropdownMenuItem
className="flex gap-2"
onClick={() => navigate("/logout")}
>
<LogOut size={16} />
Log out

View File

@ -1,4 +1,7 @@
import { useLocation } from "@remix-run/react";
import { useLocation, useNavigate } from "@remix-run/react";
import { Button } from "./button";
import { Plus } from "lucide-react";
import { SidebarTrigger } from "./sidebar";
const PAGE_TITLES: Record<string, string> = {
"/home/dashboard": "Memory graph",
@ -18,14 +21,37 @@ function getHeaderTitle(pathname: string): string {
return "Documents";
}
function isConversationDetail(pathname: string): boolean {
// Matches /home/conversation/<something> but not /home/conversation exactly
return /^\/home\/conversation\/[^/]+$/.test(pathname);
}
export function SiteHeader() {
const location = useLocation();
const navigate = useNavigate();
const title = getHeaderTitle(location.pathname);
const showNewConversationButton = isConversationDetail(location.pathname);
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2">
<h1 className="text-base">{title}</h1>
<header className="border-border flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
<div className="flex items-center gap-1">
<SidebarTrigger className="-ml-1" />
<h1 className="text-base">{title}</h1>
</div>
<div>
{showNewConversationButton && (
<Button
onClick={() => navigate("/home/conversation")}
variant="secondary"
className="gap-2"
>
<Plus size={14} />
New conversation
</Button>
)}
</div>
</div>
</header>
);

View File

@ -227,7 +227,7 @@ function Sidebar({
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) !px-0 transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@ -514,7 +514,11 @@ function SidebarMenuButton({
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
className={cn(
sidebarMenuButtonVariants({ variant, size }),
className,
"rounded-md",
)}
{...props}
/>
);

View File

@ -129,29 +129,16 @@ export default function SingleConversation() {
}
return (
<ResizablePanelGroup direction="horizontal" className="bg-background-2">
<ResizablePanel
maxSize={50}
defaultSize={16}
minSize={16}
collapsible
collapsedSize={16}
className="border-border h-[calc(100vh_-_60px)] min-w-[200px] border-r-1"
>
<ConversationList
currentConversationId={conversationId}
showNewConversationCTA
/>
</ResizablePanel>
<ResizablePanelGroup direction="horizontal" className="!rounded-md">
<ResizableHandle className="w-1" />
<ResizablePanel
collapsible
collapsedSize={0}
className="flex h-[calc(100vh_-_24px)] w-full flex-col"
className="flex w-full flex-col"
>
<div className="relative flex h-[calc(100vh_-_70px)] w-full flex-col items-center justify-center overflow-auto">
<div className="flex h-[calc(100vh_-_60px)] w-full flex-col justify-end overflow-hidden">
<div className="flex h-[calc(100vh_-_56px)] w-full flex-col justify-end overflow-hidden">
<ScrollAreaWithAutoScroll>
{getConversations()}
{conversationResponse && (

View File

@ -13,6 +13,7 @@ import { useTypedLoaderData } from "remix-typedjson";
import { SearchBodyRequest } from "./search";
import { SearchService } from "~/services/search.server";
import { GraphVisualizationClient } from "~/components/graph/graph-client";
import { LoaderCircle } from "lucide-react";
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
@ -83,11 +84,11 @@ export default function Dashboard() {
}, [userId]);
return (
<div className="home flex h-[calc(100vh_-_60px)] flex-col overflow-y-auto p-3 text-base">
<div className="home flex h-[calc(100vh_-_56px)] flex-col overflow-y-auto p-3 text-base">
<div className="flex grow items-center justify-center rounded">
{loading ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-b-2 border-gray-400" />
<LoaderCircle size={18} className="mr-1 animate-spin" />
<span className="text-muted-foreground">Loading graph...</span>
</div>
) : (

View File

@ -33,19 +33,17 @@ export default function Home() {
{
"--sidebar-width": "calc(var(--spacing) * 54)",
"--header-height": "calc(var(--spacing) * 12)",
background: "var(--background-2)",
background: "var(--background)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset className="bg-background h-[100vh] py-2 pr-2">
<div className="bg-background-2 h-full rounded-md">
<SiteHeader />
<div className="flex h-[calc(100vh_-_60px)] flex-col">
<div className="@container/main flex h-full flex-col gap-2">
<div className="flex h-full flex-col">
<Outlet />
</div>
<SidebarInset className="bg-background-2 h-full rounded pr-0">
<SiteHeader />
<div className="flex h-[calc(100vh_-_56px)] flex-col rounded">
<div className="@container/main flex h-full flex-col gap-2">
<div className="flex h-full flex-col">
<Outlet />
</div>
</div>
</div>

View File

@ -322,7 +322,7 @@
@layer base {
* {
@apply border-border outline-ring/50;
--header-height: 44px;
--header-height: 40px;
}
body {
@apply bg-background-2 text-foreground text-base;
@ -467,3 +467,74 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255,255,255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
}
@layer base {
.tiptap {
:first-child {
margin-top: 0;
}
pre {
@apply bg-grayAlpha-100 text-foreground p-4 rounded-md w-fit;
margin: 1.5rem 0;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
/* Code styling */
.hljs-comment,
.hljs-quote {
@apply text-muted-foreground;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: var(--custom-color-4); /* #886dbc */
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: var(--custom-color-2); /* #7b8a34 */
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: var(--custom-color-3); /* #1c91a8 */
}
.hljs-title,
.hljs-section {
color: var(--custom-color-1); /* #b56455 */
}
.hljs-keyword,
.hljs-selector-tag {
color: var(--custom-color-5); /* #ad6e30 */
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 79 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 79 KiB

BIN
apps/webapp/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB