Skip to content
Merged
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
11 changes: 4 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
# Maple Project Guidelines

## Build & Development Commands
- `bun run dev` - Start development server
- `bun run build` - Build for production
- `bun run preview` - Preview production build
- `bun run lint` - Run ESLint
- `bun run format` - Format code with Prettier
- `npx playwright test` - Run all tests
- `npx playwright test [file-path]` - Run a specific test
- `bun run lint` - Run ESLint
- `bun run build` - Build for production
- Always run the above commands when you get done with code changes and fix any errors.
Comment thread
AnthonyRonning marked this conversation as resolved.

## Code Style Guidelines
- **Imports**: Use path aliases (e.g., `@/*` maps to `./src/*`)
Expand All @@ -22,4 +19,4 @@
- TypeScript + React (Vite)
- Tailwind CSS for styling
- Playwright for testing
- Bun package manager (v1.2.2+)
- Bun package manager (v1.2.2+)
13 changes: 3 additions & 10 deletions frontend/src/components/ChatBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useEffect, useRef, useState } from "react";
import { useLocalState } from "@/state/useLocalState";
import { cn } from "@/utils/utils";
import { cn, useIsMobile } from "@/utils/utils";
import { useQuery } from "@tanstack/react-query";
import { getBillingService } from "@/billing/billingService";
import { BillingStatus } from "@/billing/billingApi";
Expand Down Expand Up @@ -109,7 +109,6 @@ export default function Component({
} = useLocalState();
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [isMobile, setIsMobile] = useState(false);
const lastDraftRef = useRef<string>("");
const previousChatIdRef = useRef<string | undefined>(undefined);
const currentInputRef = useRef<string>("");
Expand All @@ -130,14 +129,8 @@ export default function Component({
}
});

useEffect(() => {
const checkMobile = () => {
setIsMobile(window.matchMedia("(max-width: 768px)").matches);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
// Use the centralized hook for mobile detection directly
const isMobile = useIsMobile();

const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
Expand Down
27 changes: 24 additions & 3 deletions frontend/src/components/ChatHistoryList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useLocalState } from "@/state/useLocalState";
import { Link } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
Expand All @@ -14,9 +14,10 @@ import { RenameChatDialog } from "@/components/RenameChatDialog";

interface ChatHistoryListProps {
currentChatId?: string;
searchQuery?: string;
}

export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) {
export function ChatHistoryList({ currentChatId, searchQuery = "" }: ChatHistoryListProps) {
const { fetchOrCreateHistoryList, deleteChat, renameChat } = useLocalState();
const navigate = useNavigate();
const queryClient = useQueryClient();
Expand All @@ -32,6 +33,15 @@ export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) {
queryFn: () => fetchOrCreateHistoryList()
});

// Filter chats based on search query
const filteredChats = useMemo(() => {
if (!chats) return [];
if (!searchQuery.trim()) return chats;

const normalizedQuery = searchQuery.trim().toLowerCase();
return chats.filter((chat) => chat.title.toLowerCase().includes(normalizedQuery));
}, [chats, searchQuery]);

const handleDeleteChat = async (chatId: string) => {
try {
await deleteChat(chatId);
Expand Down Expand Up @@ -69,9 +79,20 @@ export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) {
return <div>Loading chat history...</div>;
}

// Only show no results message if we have a trimmed search query
const trimmedQuery = searchQuery.trim();
if (trimmedQuery && filteredChats.length === 0) {
return (
<div className="text-muted-foreground text-center py-4">
<p>No chats found matching "{trimmedQuery}"</p>
<p className="text-sm mt-1">Try a different search term</p>
</div>
);
}

return (
<>
{chats.map((chat) => (
{filteredChats.map((chat) => (
<div key={chat.id} className="relative">
<Link to="/chat/$chatId" params={{ chatId: chat.id }}>
<div
Expand Down
83 changes: 75 additions & 8 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { SquarePenIcon, PanelRightClose, PanelRightOpen } from "lucide-react";
import { Search, SquarePenIcon, PanelRightClose, PanelRightOpen, XCircle } from "lucide-react";
import { Button } from "./ui/button";
import { useLocation, useRouter } from "@tanstack/react-router";
import { ChatHistoryList } from "./ChatHistoryList";
import { AccountMenu } from "./AccountMenu";
import { useRef, useEffect } from "react";
import { cn, useClickOutside } from "@/utils/utils";
import { useRef, useEffect, KeyboardEvent } from "react";
import { cn, useClickOutside, useIsMobile } from "@/utils/utils";
import { Input } from "./ui/input";
import { useLocalState } from "@/state/useLocalState";

export function Sidebar({
chatId,
Expand All @@ -17,6 +19,8 @@ export function Sidebar({
}) {
const router = useRouter();
const location = useLocation();
const { searchQuery, setSearchQuery, isSearchVisible, setIsSearchVisible } = useLocalState();
const searchInputRef = useRef<HTMLInputElement>(null);

async function addChat() {
// If sidebar is open, close it
Expand All @@ -37,6 +41,28 @@ export function Sidebar({
}
}

const toggleSearch = () => {
setIsSearchVisible(!isSearchVisible);
if (!isSearchVisible) {
// Focus the search input when it becomes visible
setTimeout(() => searchInputRef.current?.focus(), 0);
} else {
// Clear search when hiding
setSearchQuery("");
}
};

const clearSearch = () => {
setSearchQuery("");
searchInputRef.current?.focus();
};

const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
clearSearch();
}
Comment thread
AnthonyRonning marked this conversation as resolved.
};

const sidebarRef = useRef<HTMLDivElement>(null);

// Modified click outside handler to ignore clicks in dropdowns and dialogs
Expand All @@ -54,18 +80,25 @@ export function Sidebar({
}
});

// Close the sidebar if we navigate to a different route
// Use the centralized hook for mobile detection
const isMobile = useIsMobile();

// This effect closes the sidebar on mobile when navigating,
// but preserves search state between navigations
useEffect(() => {
const unsubscribe = router.subscribe("onResolved", () => {
if (isOpen) {
// On mobile: close the sidebar when navigating to any page
// On desktop: keep the sidebar open
if (isOpen && isMobile) {
// Always close sidebar on mobile when navigating to preserve screen real estate
onToggle();
}
});

return () => {
unsubscribe();
};
}, [router, isOpen, onToggle]);
}, [router, isOpen, onToggle, isMobile]);

return (
<div
Expand All @@ -85,9 +118,43 @@ export function Sidebar({
<PanelRightOpen className="h-4 w-4" />
</Button>
</div>
<h2 className="font-semibold -mb-2">History</h2>
<div className="flex justify-between items-center">
<h2 className="font-semibold">History</h2>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={toggleSearch}
aria-label={isSearchVisible ? "Hide search" : "Search chat history"}
>
<Search className="h-4 w-4" />
</Button>
</div>
{isSearchVisible && (
<div className="relative transition-all duration-200 ease-in-out">
<Input
ref={searchInputRef}
type="text"
placeholder="Search chat titles..."
className="pl-2 pr-8 h-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
aria-label="Search chat titles"
/>
{searchQuery && (
<button
onClick={clearSearch}
className="absolute right-2.5 top-2.5 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
)}
<nav className="flex flex-col gap-2 px-4 -mx-4 h-full overflow-y-auto">
<ChatHistoryList currentChatId={chatId} />
<ChatHistoryList currentChatId={chatId} searchQuery={searchQuery} />
</nav>
<AccountMenu />
</div>
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/state/LocalStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode })
model:
import.meta.env.VITE_DEV_MODEL_OVERRIDE || "ibnzterrell/Meta-Llama-3.3-70B-Instruct-AWQ-INT4",
billingStatus: null as BillingStatus | null,
searchQuery: "",
isSearchVisible: false,
draftMessages: new Map<string, string>()
});

Expand Down Expand Up @@ -71,6 +73,14 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode })
setLocalState((prev) => ({ ...prev, billingStatus: status }));
}

function setSearchQuery(query: string) {
setLocalState((prev) => ({ ...prev, searchQuery: query }));
}

function setIsSearchVisible(visible: boolean) {
setLocalState((prev) => ({ ...prev, isSearchVisible: visible }));
}

async function addChat(title: string = "New Chat") {
const newChat = { id: window.crypto.randomUUID(), title, messages: [] };
await persistChat(newChat);
Expand Down Expand Up @@ -212,6 +222,10 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode })
model: localState.model,
userPrompt: localState.userPrompt,
billingStatus: localState.billingStatus,
searchQuery: localState.searchQuery,
setSearchQuery,
isSearchVisible: localState.isSearchVisible,
setIsSearchVisible,
setBillingStatus,
setUserPrompt,
addChat,
Expand Down
28 changes: 20 additions & 8 deletions frontend/src/state/LocalStateContextDef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export type LocalState = {
model: string;
userPrompt: string;
billingStatus: BillingStatus | null;
/** Current search query for filtering chat history */
searchQuery: string;
/** Updates the current search query */
setSearchQuery: (query: string) => void;
/** Whether the search input is currently visible */
isSearchVisible: boolean;
/** Controls the visibility of the search input */
setIsSearchVisible: (visible: boolean) => void;
setBillingStatus: (status: BillingStatus) => void;
setUserPrompt: (prompt: string) => void;
addChat: (title?: string) => Promise<string>;
Expand All @@ -44,16 +52,20 @@ export const LocalStateContext = createContext<LocalState>({
model: "",
userPrompt: "",
billingStatus: null,
setBillingStatus: () => {},
setUserPrompt: () => {},
searchQuery: "",
setSearchQuery: () => void 0,
isSearchVisible: false,
setIsSearchVisible: () => void 0,
setBillingStatus: () => void 0,
setUserPrompt: () => void 0,
addChat: async () => "",
getChatById: async () => undefined,
persistChat: async () => {},
persistChat: async () => void 0,
fetchOrCreateHistoryList: async () => [],
clearHistory: async () => {},
deleteChat: async () => {},
renameChat: async () => {},
clearHistory: async () => void 0,
deleteChat: async () => void 0,
renameChat: async () => void 0,
draftMessages: new Map(),
setDraftMessage: () => {},
clearDraftMessage: () => {}
setDraftMessage: () => void 0,
clearDraftMessage: () => void 0
});
49 changes: 48 additions & 1 deletion frontend/src/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,58 @@
import { type ClassValue, clsx } from "clsx";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";

// Tailwind breakpoint for md: (consistent with Tailwind's default)
export const MD_BREAKPOINT = 768;

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

/**
* Hook to detect if the viewport is mobile size
* Uses Tailwind's md breakpoint (768px) for consistency
*/
export function useIsMobile() {
// Initialize with correct value to prevent flash of incorrect content
// Also handle server-side rendering with typeof window check
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined"
? window.matchMedia(`(max-width: ${MD_BREAKPOINT - 1}px)`).matches
: false
);

useEffect(() => {
// Create media query list
const mediaQuery = window.matchMedia(`(max-width: ${MD_BREAKPOINT - 1}px)`);

// Function to handle media query changes
const handleMediaChange = (e: MediaQueryListEvent) => {
setIsMobile(e.matches);
};

// Use addListener for broader browser support
if ('addEventListener' in mediaQuery) {
mediaQuery.addEventListener("change", handleMediaChange);
} else {
// For older browsers - using type assertion for deprecated method
(mediaQuery as any).addListener(handleMediaChange);
}

// Cleanup
return () => {
if ('removeEventListener' in mediaQuery) {
mediaQuery.removeEventListener("change", handleMediaChange);
} else {
// For older browsers - using type assertion for deprecated method
(mediaQuery as any).removeListener(handleMediaChange);
}
};
}, []);

return isMobile;
}

export function useClickOutside(
ref: React.RefObject<HTMLElement>,
callback: (event: MouseEvent | TouchEvent) => void
Expand Down