Feat: UI fixes in graph
@ -1,13 +1,6 @@
|
|||||||
import { useFetcher, useNavigate } from "@remix-run/react";
|
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import {
|
import { AutoSizer, List, type ListRowRenderer } from "react-virtualized";
|
||||||
List,
|
|
||||||
AutoSizer,
|
|
||||||
InfiniteLoader,
|
|
||||||
type ListRowRenderer,
|
|
||||||
} from "react-virtualized";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { MessageSquare, Clock, Plus } from "lucide-react";
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Button } from "../ui";
|
import { Button } from "../ui";
|
||||||
|
|
||||||
@ -40,10 +33,8 @@ type ConversationListResponse = {
|
|||||||
|
|
||||||
export const ConversationList = ({
|
export const ConversationList = ({
|
||||||
currentConversationId,
|
currentConversationId,
|
||||||
showNewConversationCTA,
|
|
||||||
}: {
|
}: {
|
||||||
currentConversationId?: string;
|
currentConversationId?: string;
|
||||||
showNewConversationCTA?: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const fetcher = useFetcher<ConversationListResponse>();
|
const fetcher = useFetcher<ConversationListResponse>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -51,8 +42,9 @@ export const ConversationList = ({
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [hasNextPage, setHasNextPage] = useState(true);
|
const [hasNextPage, setHasNextPage] = useState(true);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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(
|
const loadMoreConversations = useCallback(
|
||||||
(page: number) => {
|
(page: number) => {
|
||||||
@ -61,89 +53,75 @@ export const ConversationList = ({
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
limit: "25",
|
limit: "5", // Increased for better density
|
||||||
});
|
});
|
||||||
|
|
||||||
fetcher.load(`/api/v1/conversations?${searchParams}`, {
|
fetcher.load(`/api/v1/conversations?${searchParams}`, {
|
||||||
flushSync: true,
|
flushSync: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isLoading, fetcher, currentPage],
|
[isLoading, fetcher],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMoreConversations(1);
|
loadMoreConversations(1);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle fetcher response
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fetcher.data && fetcher.state === "idle") {
|
if (fetcher.data && fetcher.state === "idle") {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
const response = fetcher.data;
|
const response = fetcher.data;
|
||||||
|
|
||||||
if (currentPage === 1) {
|
// Prevent duplicate conversations
|
||||||
setConversations(response.conversations);
|
const newConversations = response.conversations.filter(
|
||||||
} else {
|
(c) => !loadedConversationIds.current.has(c.id),
|
||||||
setConversations((prev) => [...prev, ...response.conversations]);
|
);
|
||||||
}
|
newConversations.forEach((c) => loadedConversationIds.current.add(c.id));
|
||||||
|
|
||||||
|
setConversations((prev) => [...prev, ...newConversations]);
|
||||||
setHasNextPage(response.pagination.hasNext);
|
setHasNextPage(response.pagination.hasNext);
|
||||||
setCurrentPage(response.pagination.page);
|
setCurrentPage(response.pagination.page);
|
||||||
}
|
}
|
||||||
}, [fetcher.data, fetcher.state, currentPage]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [fetcher.data, fetcher.state]);
|
||||||
|
|
||||||
// const handleSearch = useCallback(
|
// The row count is conversations.length + 1 if hasNextPage, else just conversations.length
|
||||||
// (term: string) => {
|
const rowCount = hasNextPage
|
||||||
// setSearchTerm(term);
|
? conversations.length + 1
|
||||||
// setCurrentPage(1);
|
: conversations.length;
|
||||||
// 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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rowRenderer: ListRowRenderer = useCallback(
|
const rowRenderer: ListRowRenderer = useCallback(
|
||||||
({ index, key, style }) => {
|
({ 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];
|
const conversation = conversations[index];
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
@ -158,16 +136,21 @@ export const ConversationList = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} style={style}>
|
<div key={key} style={style}>
|
||||||
<div className="p-1">
|
<div className="px-1 pr-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-border h-auto w-full justify-start p-2 text-left",
|
"border-border h-auto w-full justify-start rounded p-2 py-1 text-left",
|
||||||
currentConversationId === conversation.id && "bg-grayAlpha-100",
|
currentConversationId === conversation.id &&
|
||||||
|
"bg-accent text-accent-foreground font-semibold",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
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="flex w-full items-start space-x-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@ -175,11 +158,6 @@ export const ConversationList = ({
|
|||||||
<p className={cn("truncate font-normal")}>
|
<p className={cn("truncate font-normal")}>
|
||||||
{conversation.title || "Untitled Conversation"}
|
{conversation.title || "Untitled Conversation"}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -188,62 +166,32 @@ export const ConversationList = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[conversations],
|
[
|
||||||
|
conversations,
|
||||||
|
currentConversationId,
|
||||||
|
hasNextPage,
|
||||||
|
isLoading,
|
||||||
|
currentPage,
|
||||||
|
loadMoreConversations,
|
||||||
|
navigate,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rowCount = hasNextPage
|
|
||||||
? conversations.length + 1
|
|
||||||
: conversations.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col pt-1 pl-1">
|
||||||
{showNewConversationCTA && (
|
<div className="grow overflow-hidden">
|
||||||
<div className="flex items-center justify-start p-1 pb-0">
|
<AutoSizer>
|
||||||
<Button
|
{({ height, width }) => (
|
||||||
variant="ghost"
|
<List
|
||||||
className="w-full justify-start gap-2 py-4"
|
height={height}
|
||||||
onClick={() => {
|
width={width}
|
||||||
navigate("/home/conversation");
|
rowCount={rowCount}
|
||||||
}}
|
rowHeight={36} // Slightly taller for better click area
|
||||||
>
|
rowRenderer={rowRenderer}
|
||||||
<Plus size={14} /> New conversation
|
overscanRowCount={5}
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</InfiniteLoader>
|
</AutoSizer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && conversations.length === 0 && (
|
{isLoading && conversations.length === 0 && (
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { EditorContent, Placeholder, EditorRoot } from "novel";
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Button } from "../ui";
|
import { Button } from "../ui";
|
||||||
import { Loader } from "lucide-react";
|
import { Loader, LoaderCircle } from "lucide-react";
|
||||||
import { Form, useSubmit } from "@remix-run/react";
|
import { Form, useSubmit } from "@remix-run/react";
|
||||||
|
|
||||||
interface ConversationTextareaProps {
|
interface ConversationTextareaProps {
|
||||||
@ -159,7 +159,7 @@ export function ConversationTextarea({
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader size={18} className="mr-1 animate-spin" />
|
<LoaderCircle size={18} className="mr-1 animate-spin" />
|
||||||
Stop
|
Stop
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -46,17 +46,7 @@ export const ConversationNew = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup direction="horizontal" className="bg-background-2">
|
<ResizablePanelGroup direction="horizontal" className="rounded-md">
|
||||||
<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>
|
|
||||||
<ResizableHandle className="w-1" />
|
<ResizableHandle className="w-1" />
|
||||||
|
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
@ -70,7 +60,7 @@ export const ConversationNew = ({
|
|||||||
onSubmit={(e) => submitForm(e)}
|
onSubmit={(e) => submitForm(e)}
|
||||||
className="pt-2"
|
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 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="flex w-full flex-col items-center">
|
||||||
<div className="w-full max-w-[90ch]">
|
<div className="w-full max-w-[90ch]">
|
||||||
|
|||||||
@ -134,8 +134,6 @@ export const extensionsForConversation = [
|
|||||||
tiptapLink,
|
tiptapLink,
|
||||||
horizontalRule,
|
horizontalRule,
|
||||||
heading,
|
heading,
|
||||||
AIHighlight,
|
|
||||||
HighlightExtension,
|
|
||||||
Table.configure({
|
Table.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -310,7 +310,7 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
|
|||||||
...settings,
|
...settings,
|
||||||
barnesHutOptimize: true,
|
barnesHutOptimize: true,
|
||||||
strongGravityMode: false,
|
strongGravityMode: false,
|
||||||
gravity: 0.05,
|
gravity: 0.1,
|
||||||
scalingRatio: 10,
|
scalingRatio: 10,
|
||||||
slowDown: 5,
|
slowDown: 5,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { NavMain } from "./nav-main";
|
|||||||
import { useUser } from "~/hooks/useUser";
|
import { useUser } from "~/hooks/useUser";
|
||||||
import { NavUser } from "./nav-user";
|
import { NavUser } from "./nav-user";
|
||||||
import Logo from "../logo/logo";
|
import Logo from "../logo/logo";
|
||||||
|
import { ConversationList } from "../conversation";
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
navMain: [
|
navMain: [
|
||||||
@ -44,24 +45,29 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
collapsible="none"
|
variant="inset"
|
||||||
{...props}
|
{...props}
|
||||||
className="bg-background h-[100vh] w-[calc(var(--sidebar-width-icon)+1px)]! py-2"
|
className="bg-background h-[100vh] py-2"
|
||||||
>
|
>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<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} />
|
<Logo width={20} height={20} />
|
||||||
|
C.O.R.E.
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<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>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter className="p-0">
|
<SidebarFooter className="px-2">
|
||||||
<NavUser user={user} />
|
<NavUser user={user} />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "../ui/sidebar";
|
} from "../ui/sidebar";
|
||||||
import { useLocation, useNavigate } from "@remix-run/react";
|
import { useLocation, useNavigate } from "@remix-run/react";
|
||||||
|
import { Button } from "../ui";
|
||||||
|
|
||||||
export const NavMain = ({
|
export const NavMain = ({
|
||||||
items,
|
items,
|
||||||
@ -23,20 +24,22 @@ export const NavMain = ({
|
|||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent className="flex flex-col gap-2">
|
<SidebarGroupContent className="flex flex-col gap-2">
|
||||||
<SidebarMenu>
|
<SidebarMenu className="gap-0.5">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton
|
<Button
|
||||||
tooltip={item.title}
|
|
||||||
isActive={location.pathname.includes(item.url)}
|
isActive={location.pathname.includes(item.url)}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
"bg-grayAlpha-100 w-fit gap-1 !rounded-md",
|
||||||
location.pathname.includes(item.url) &&
|
location.pathname.includes(item.url) &&
|
||||||
"!bg-grayAlpha-100 hover:bg-grayAlpha-100!",
|
"!bg-accent !text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
onClick={() => navigate(item.url)}
|
onClick={() => navigate(item.url)}
|
||||||
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon size={16} />}
|
||||||
</SidebarMenuButton>
|
{item.title}
|
||||||
|
</Button>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
|||||||
@ -26,26 +26,10 @@ export function NavUser({ user }: { user: User }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem className="mb-2 flex justify-center">
|
<SidebarMenuItem className="flex justify-between">
|
||||||
<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>
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="link" className="mb-2 ml-2 gap-2 px-0">
|
||||||
variant="link"
|
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground mb-2 gap-2 px-3"
|
|
||||||
>
|
|
||||||
<AvatarText
|
<AvatarText
|
||||||
text={user.name ?? "User"}
|
text={user.name ?? "User"}
|
||||||
className="h-6 w-6 rounded"
|
className="h-6 w-6 rounded"
|
||||||
@ -55,7 +39,7 @@ export function NavUser({ user }: { user: User }) {
|
|||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||||
side={isMobile ? "bottom" : "top"}
|
side={isMobile ? "bottom" : "top"}
|
||||||
align="end"
|
align="start"
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
@ -71,7 +55,14 @@ export function NavUser({ user }: { user: User }) {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex gap-2"
|
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} />
|
<LogOut size={16} />
|
||||||
Log out
|
Log out
|
||||||
|
|||||||
@ -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> = {
|
const PAGE_TITLES: Record<string, string> = {
|
||||||
"/home/dashboard": "Memory graph",
|
"/home/dashboard": "Memory graph",
|
||||||
@ -18,14 +21,37 @@ function getHeaderTitle(pathname: string): string {
|
|||||||
return "Documents";
|
return "Documents";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConversationDetail(pathname: string): boolean {
|
||||||
|
// Matches /home/conversation/<something> but not /home/conversation exactly
|
||||||
|
return /^\/home\/conversation\/[^/]+$/.test(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const title = getHeaderTitle(location.pathname);
|
const title = getHeaderTitle(location.pathname);
|
||||||
|
|
||||||
|
const showNewConversationButton = isConversationDetail(location.pathname);
|
||||||
|
|
||||||
return (
|
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)">
|
<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 gap-1 px-4 lg:gap-2">
|
<div className="flex w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
|
||||||
<h1 className="text-base">{title}</h1>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -227,7 +227,7 @@ function Sidebar({
|
|||||||
<div
|
<div
|
||||||
data-slot="sidebar-container"
|
data-slot="sidebar-container"
|
||||||
className={cn(
|
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"
|
side === "left"
|
||||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
: "right-0 group-data-[collapsible=offcanvas]:right-[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-sidebar="menu-button"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
className={cn(
|
||||||
|
sidebarMenuButtonVariants({ variant, size }),
|
||||||
|
className,
|
||||||
|
"rounded-md",
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -129,29 +129,16 @@ export default function SingleConversation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup direction="horizontal" className="bg-background-2">
|
<ResizablePanelGroup direction="horizontal" className="!rounded-md">
|
||||||
<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>
|
|
||||||
<ResizableHandle className="w-1" />
|
<ResizableHandle className="w-1" />
|
||||||
|
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
collapsible
|
collapsible
|
||||||
collapsedSize={0}
|
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="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>
|
<ScrollAreaWithAutoScroll>
|
||||||
{getConversations()}
|
{getConversations()}
|
||||||
{conversationResponse && (
|
{conversationResponse && (
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { useTypedLoaderData } from "remix-typedjson";
|
|||||||
import { SearchBodyRequest } from "./search";
|
import { SearchBodyRequest } from "./search";
|
||||||
import { SearchService } from "~/services/search.server";
|
import { SearchService } from "~/services/search.server";
|
||||||
import { GraphVisualizationClient } from "~/components/graph/graph-client";
|
import { GraphVisualizationClient } from "~/components/graph/graph-client";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
@ -83,11 +84,11 @@ export default function Dashboard() {
|
|||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex grow items-center justify-center rounded">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
<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>
|
<span className="text-muted-foreground">Loading graph...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -33,19 +33,17 @@ export default function Home() {
|
|||||||
{
|
{
|
||||||
"--sidebar-width": "calc(var(--spacing) * 54)",
|
"--sidebar-width": "calc(var(--spacing) * 54)",
|
||||||
"--header-height": "calc(var(--spacing) * 12)",
|
"--header-height": "calc(var(--spacing) * 12)",
|
||||||
background: "var(--background-2)",
|
background: "var(--background)",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppSidebar variant="inset" />
|
<AppSidebar variant="inset" />
|
||||||
<SidebarInset className="bg-background h-[100vh] py-2 pr-2">
|
<SidebarInset className="bg-background-2 h-full rounded pr-0">
|
||||||
<div className="bg-background-2 h-full rounded-md">
|
<SiteHeader />
|
||||||
<SiteHeader />
|
<div className="flex h-[calc(100vh_-_56px)] flex-col rounded">
|
||||||
<div className="flex h-[calc(100vh_-_60px)] flex-col">
|
<div className="@container/main flex h-full flex-col gap-2">
|
||||||
<div className="@container/main flex h-full flex-col gap-2">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex h-full flex-col">
|
<Outlet />
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -322,7 +322,7 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
--header-height: 44px;
|
--header-height: 40px;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background-2 text-foreground text-base;
|
@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");
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 79 KiB |
BIN
apps/webapp/public/logo.png
Normal file
|
After Width: | Height: | Size: 79 KiB |