import { navigateWithAgentChatViewTransition, useChatThreads, type ChatThreadSummary, } from "@agent-native/core/client/agent-chat"; import { appPath } from "@agent-native/core/client/api-path"; import { LanguagePicker, useT } from "@agent-native/core/client/i18n"; import { openCommandMenu } from "@agent-native/core/client/navigation"; import { OrgSwitcher } from "@agent-native/core/client/org"; import { FeedbackButton } from "@agent-native/core/client/ui"; import { SidebarFooterActions } from "@agent-native/toolkit/app-shell"; import { ChatHistoryRail, type ChatHistoryItem, } from "@agent-native/toolkit/chat-history"; import { IconBooks, IconHeart, IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconMessageCircle, IconSearch, IconSettings, } from "@tabler/icons-react"; import { useEffect, useMemo } from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { APP_TITLE } from "@/lib/app-config"; import { cn } from "@/lib/utils"; const navItems = [ { icon: IconBooks, labelKey: "navigation.quotes", href: "/", view: "quotes", }, { icon: IconHeart, labelKey: "navigation.favorites", href: "/favorites", view: "favorites", }, { icon: IconMessageCircle, labelKey: "navigation.chat", href: "/chat", view: "chat", }, ]; const bottomNavItems = [ { icon: IconSettings, labelKey: "navigation.settings", href: "/settings", view: "settings", }, ]; const CHAT_STORAGE_KEY = "chat"; const CHAT_ACTIVE_THREAD_KEY = `agent-chat-active-thread:${CHAT_STORAGE_KEY}`; interface SidebarProps { collapsed?: boolean; collapsible?: boolean; onCollapsedChange?: (collapsed: boolean) => void; } function formatThreadAge(updatedAt: number) { const diffMs = Math.max(0, Date.now() - updatedAt); const minutes = Math.floor(diffMs / 60_000); if (minutes < 1) return "now"; if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); if (days < 7) return `${days}d`; return new Date(updatedAt).toLocaleDateString([], { month: "short", day: "numeric", }); } function threadTitle(thread: ChatThreadSummary) { return thread.title || thread.preview || "Untitled chat"; } function threadUpdatedAt(thread: ChatThreadSummary) { return Number.isFinite(thread.updatedAt) ? thread.updatedAt : Number.isFinite(thread.createdAt) ? thread.createdAt : 0; } function compareThreads(a: ChatThreadSummary, b: ChatThreadSummary) { const aPinned = a.pinnedAt ?? 0; const bPinned = b.pinnedAt ?? 0; if (aPinned || bPinned) return bPinned - aPinned; return threadUpdatedAt(b) - threadUpdatedAt(a); } function persistedActiveThreadId() { try { return localStorage.getItem(CHAT_ACTIVE_THREAD_KEY); } catch { return null; } } function persistActiveThreadId(threadId: string) { try { localStorage.setItem(CHAT_ACTIVE_THREAD_KEY, threadId); } catch {} } function threadIdFromPath(pathname: string) { const match = pathname.match(/^\/chat\/([^/]+)/); if (!match) return null; try { const value = decodeURIComponent(match[1]).trim(); return value || null; } catch { return null; } } function chatThreadPath(threadId: string) { return `/chat/${encodeURIComponent(threadId)}`; } function ChatThreadsSection({ open }: { open: boolean }) { const navigate = useNavigate(); const location = useLocation(); const t = useT(); const { threads, activeThreadId, createThread, switchThread, pinThread, archiveThread, renameThread, refreshThreads, } = useChatThreads(undefined, CHAT_STORAGE_KEY, undefined, { autoCreate: false, restoreActiveThread: false, }); const visibleThreads = useMemo( () => threads .filter((thread) => thread.messageCount > 0 && !thread.archivedAt) .sort(compareThreads) .slice(0, 15), [threads], ); const displayedActiveThreadId = threadIdFromPath(location.pathname) ?? (location.pathname === "/" ? null : activeThreadId); const chatItems = useMemo( () => visibleThreads.map((thread) => ({ id: thread.id, title: threadTitle(thread), titleText: threadTitle(thread), timestamp: thread.id === displayedActiveThreadId ? undefined : formatThreadAge(threadUpdatedAt(thread)), pinned: Boolean(thread.pinnedAt), })), [displayedActiveThreadId, visibleThreads], ); useEffect(() => { const refresh = () => refreshThreads(); const handleRunning = (event: Event) => { const detail = (event as CustomEvent).detail as | { isRunning?: unknown } | undefined; if (typeof detail?.isRunning === "boolean") refreshThreads(); }; window.addEventListener("agent-chat:threads-updated", refresh); window.addEventListener("agentNative.chatRunning", handleRunning); window.addEventListener("focus", refresh); return () => { window.removeEventListener("agent-chat:threads-updated", refresh); window.removeEventListener("agentNative.chatRunning", handleRunning); window.removeEventListener("focus", refresh); }; }, [refreshThreads]); function openThread(threadId: string, options?: { isNew?: boolean }) { switchThread(threadId); persistActiveThreadId(threadId); navigateWithAgentChatViewTransition( navigate, options?.isNew ? "/" : chatThreadPath(threadId), ); window.requestAnimationFrame(() => { window.dispatchEvent( new CustomEvent("agent-chat:open-thread", { detail: { threadId, newThread: options?.isNew === true }, }), ); }); } async function handleNewChat() { const threadId = await createThread(); if (threadId) openThread(threadId, { isNew: true }); } async function handleArchiveThread(threadId: string) { const wasActive = threadId === activeThreadId || threadId === persistedActiveThreadId(); const archived = await archiveThread(threadId); if (!archived) { toast.error(t("chat.archiveFailed")); return; } if (wasActive) { await handleNewChat(); } } function handleRenameThread(threadId: string, title: string) { void renameThread(threadId, title).then((renamed) => { if (!renamed) toast.error(t("chat.renameFailed")); }); } return (
openThread(threadId)} onNewChat={() => void handleNewChat()} railLabels={{ newChat: t("chat.newChat"), showMore: t("chat.chats"), showLess: t("chat.chats"), }} renameMaxLength={160} onTogglePin={(threadId) => { const thread = visibleThreads.find((item) => item.id === threadId); if (thread) void pinThread(threadId, !thread.pinnedAt); }} onRename={handleRenameThread} onDelete={(threadId) => void handleArchiveThread(threadId)} labels={{ options: (item) => t("chat.optionsFor", { title: item.titleText ?? "" }), renameInput: (item) => t("chat.renameThread", { title: item.titleText ?? "" }), rename: t("chat.renameChat"), pin: t("chat.pinChat"), unpin: t("chat.unpinChat"), delete: t("chat.archiveChat"), }} className="min-w-0" />
); } export function Sidebar({ collapsed = false, collapsible = true, onCollapsedChange, }: SidebarProps) { const location = useLocation(); const navigate = useNavigate(); const t = useT(); const isChatRoute = location.pathname === "/chat" || location.pathname.startsWith("/chat/"); const ToggleIcon = collapsed ? IconLayoutSidebarLeftExpand : IconLayoutSidebarLeftCollapse; const navClass = ({ isActive }: { isActive: boolean }) => cn( "flex items-center text-sm transition-colors", collapsed ? "relative h-10 w-full justify-center rounded-none px-0" : "h-9 rounded-md gap-3 px-3", isActive ? collapsed ? "bg-sidebar-accent text-sidebar-accent-foreground" : "bg-sidebar-accent text-sidebar-accent-foreground" : collapsed ? "text-sidebar-foreground/70 hover:bg-sidebar-accent/55 hover:text-sidebar-accent-foreground" : "text-sidebar-foreground hover:bg-sidebar-accent/65 hover:text-sidebar-accent-foreground", ); const collapseButton = collapsible ? ( {collapsed ? t("navigation.expandSidebar") : t("navigation.collapseSidebar")} ) : null; const searchButton = ( {t("root.commandSearch")} ); const translateButton = ( ); const feedbackButton = ( ); return ( ); }